feat(web-client-tpos): Phase C-E — shop settings, top products report, enhanced placeholders

BFF Endpoints (3 new):
- GET/PUT shops/{id}/settings — shop features config, hours, days
- GET reports/top-products — bestselling products by quantity + revenue

PosDataService (3 new methods):
- GetShopSettings, UpdateShopSettings, GetTopProducts

ShopPage UI (149 lines):
- Settings tab: opening hours, business days, features config form
- Reports tab: top products table alongside revenue report
- Enhanced default placeholder for sections without DB tables
This commit is contained in:
Ho Ngoc Hai
2026-03-03 21:36:52 +07:00
parent 96301831f1
commit 33047afa75
3 changed files with 244 additions and 38 deletions

View File

@@ -926,25 +926,26 @@
}
</div>
</div>
@if (_reportProducts.Any())
@* ─── Top products from real order_items data ─── *@
@if (_topProducts.Any())
{
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header"><h3 style="font-size:14px;font-weight:700;margin:0;">Top sản phẩm</h3></div>
<div class="admin-panel__header"><h3 style="font-size:14px;font-weight:700;margin:0;">Top sản phẩm bán chạy</h3></div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">#</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Sản phẩm</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Giá</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Loại</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên SP</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Đã bán</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Doanh thu</th>
</tr></thead><tbody>
@{ var rank = 1; }
@foreach (var p in _reportProducts.OrderByDescending(x => x.Price).Take(10))
@{ var tpRank = 1; }
@foreach (var tp in _topProducts)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:700;color:var(--admin-orange-primary);">@(rank++)</td>
<td style="padding:12px 16px;font-weight:600;">@p.Name</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@FormatVND(p.Price)</td>
<td style="padding:12px 16px;text-align:center;font-size:12px;color:var(--admin-text-tertiary);">@(p.Type ?? "—")</td>
<td style="padding:12px 16px;font-weight:700;color:var(--admin-orange-primary);">@(tpRank++)</td>
<td style="padding:12px 16px;font-weight:600;">@(tp.ProductName ?? "—")</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:#3B82F6;">@tp.TotalSold</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@FormatVND(tp.TotalRevenue)</td>
</tr>
}
</tbody></table>
@@ -1269,6 +1270,7 @@
// ═══ SETTINGS ═══
case "settings":
@* ─── Shop info (read-only) ─── *@
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Thông tin cửa hàng</h3></div>
<div class="admin-panel__body">
@@ -1278,39 +1280,55 @@
</div>
</div>
</div>
@* ─── Opening hours + business days ─── *@
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Giờ hoạt động</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giờ mở cửa</label><div style="padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;">08:00</div></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giờ đóng cửa</label><div style="padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;">22:00</div></div>
</div>
</div>
</div>
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Thuế & Phí</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">VAT (%)</label><div style="padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;">10%</div></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Phí dịch vụ (%)</label><div style="padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;">5%</div></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Phí ship mặc định</label><div style="padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;">15,000₫</div></div>
</div>
</div>
</div>
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">🧾 Cài đặt hóa đơn / Bill</h3></div>
<div class="admin-panel__header"><h3 class="admin-panel__title">Giờ & ngày hoạt động</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Header hóa đơn</label><input type="text" value="Cảm ơn quý khách!" style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Footer hóa đơn</label><input type="text" value="Hẹn gặp lại!" style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;color:var(--admin-text-primary);" /></div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giờ mở cửa</label>
<input type="time" value="@_settingsOpenTime" @oninput="@(e => _settingsOpenTime = e.Value?.ToString() ?? "")" style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;color:var(--admin-text-primary);" />
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giờ đóng cửa</label>
<input type="time" value="@_settingsCloseTime" @oninput="@(e => _settingsCloseTime = e.Value?.ToString() ?? "")" style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;color:var(--admin-text-primary);" />
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Khổ giấy</label><div style="padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:14px;">80mm</div></div>
<div style="display:flex;align-items:center;gap:8px;padding-top:18px;"><input type="checkbox" checked style="accent-color:var(--admin-orange-primary);" /><label style="font-size:13px;">Hiển logo</label></div>
<div style="display:flex;align-items:center;gap:8px;padding-top:18px;"><input type="checkbox" style="accent-color:var(--admin-orange-primary);" /><label style="font-size:13px;">In QR Code</label></div>
<div style="margin-bottom:16px;">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:8px;">Ngày kinh doanh</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
@foreach (var (day, code) in new[] { ("T2","Mon"),("T3","Tue"),("T4","Wed"),("T5","Thu"),("T6","Fri"),("T7","Sat"),("CN","Sun") })
{
var isOn = _settingsOpenDays.Contains(code);
<button type="button" @onclick="@(() => ToggleDay(code))"
style="width:40px;height:40px;border-radius:10px;border:1px solid @(isOn ? "var(--admin-orange-primary)" : "var(--admin-border-subtle)");background:@(isOn ? "rgba(255,92,0,0.15)" : "var(--admin-bg-elevated)");color:@(isOn ? "var(--admin-orange-primary)" : "var(--admin-text-tertiary)");font-size:12px;font-weight:@(isOn ? "700" : "500");cursor:pointer;">
@day
</button>
}
</div>
</div>
</div>
</div>
@* ─── Features config JSON ─── *@
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Cấu hình tính năng (JSON)</h3></div>
<div class="admin-panel__body">
<textarea @bind="_settingsFeaturesConfig" rows="6"
placeholder="{}"
style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:13px;font-family:monospace;color:var(--admin-text-primary);resize:vertical;"></textarea>
<p style="font-size:12px;color:var(--admin-text-tertiary);margin:8px 0 0;">Nhập JSON để bật/tắt tính năng cho cửa hàng này.</p>
</div>
</div>
@* ─── Save button ─── *@
<div style="margin-top:16px;display:flex;align-items:center;gap:12px;">
<button class="admin-btn-primary" @onclick="SaveShopSettings" style="display:inline-flex;align-items:center;gap:8px;">
<i data-lucide="save" style="width:16px;height:16px;"></i>Lưu thiết lập
</button>
@if (_settingsMessage != null)
{
<span style="font-size:13px;color:@(_settingsSuccess ? "#22C55E" : "#EF4444");">@_settingsMessage</span>
}
</div>
break;
// ═══ C2: RECIPES / NGUYÊN LIỆU (Café) ═══
@@ -1713,7 +1731,12 @@
<i data-lucide="@_sectionIcon" style="width:36px;height:36px;color:var(--admin-orange-primary);"></i>
</div>
<h2 style="font-size:22px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFFFFF);">@_sectionTitle</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">@_sectionDescription</p>
<p style="font-size:13px;color:#F59E0B;font-weight:600;margin:0 0 8px;">Tính năng đang phát triển</p>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;max-width:420px;margin-left:auto;margin-right:auto;">@_sectionDescription</p>
<div style="display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border-radius:10px;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.25);">
<i data-lucide="clock" style="width:14px;height:14px;color:#F59E0B;"></i>
<span style="font-size:12px;color:#F59E0B;font-weight:600;">Sẽ ra mắt trong phiên bản tiếp theo</span>
</div>
</div>
</div>
break;
@@ -1833,6 +1856,16 @@
// Revenue report state
private string _reportPeriod = "daily";
private List<PosDataService.RevenueReportItem> _revenueReport = new();
// Settings state
private PosDataService.ShopSettingsInfo? _shopSettings;
private string _settingsOpenTime = "";
private string _settingsCloseTime = "";
private string _settingsOpenDays = "";
private string _settingsFeaturesConfig = "{}";
private string? _settingsMessage;
private bool _settingsSuccess;
// Top products state
private List<PosDataService.TopProductInfo> _topProducts = new();
protected override async Task OnInitializedAsync() => await LoadData();
@@ -1919,6 +1952,11 @@
case "reports":
_reportOrders = await DataService.GetOrdersAsync(_shopGuid);
_reportProducts = await DataService.GetAllProductsAsync(_shopGuid);
_topProducts = await DataService.GetTopProductsAsync(_shopGuid);
break;
case "settings":
if (_shopGuid.HasValue)
await LoadShopSettings();
break;
case "promotions":
_campaigns = await DataService.GetCampaignsAsync();
@@ -1936,6 +1974,47 @@
finally { IsLoading = false; }
}
private async Task LoadShopSettings()
{
if (!_shopGuid.HasValue) return;
try
{
_shopSettings = await DataService.GetShopSettingsAsync(_shopGuid.Value);
if (_shopSettings != null)
{
_settingsOpenTime = _shopSettings.OpenTime ?? "";
_settingsCloseTime = _shopSettings.CloseTime ?? "";
_settingsOpenDays = _shopSettings.OpenDays ?? "";
_settingsFeaturesConfig= string.IsNullOrWhiteSpace(_shopSettings.FeaturesConfig) ? "{}" : _shopSettings.FeaturesConfig;
}
}
catch { /* non-fatal */ }
}
private async Task SaveShopSettings()
{
if (!_shopGuid.HasValue) return;
_settingsMessage = null;
var req = new PosDataService.UpdateShopSettingsRequest(
FeaturesConfig: _settingsFeaturesConfig,
OpenTime: string.IsNullOrWhiteSpace(_settingsOpenTime) ? null : _settingsOpenTime,
CloseTime: string.IsNullOrWhiteSpace(_settingsCloseTime) ? null : _settingsCloseTime,
OpenDays: _settingsOpenDays);
var ok = await DataService.UpdateShopSettingsAsync(_shopGuid.Value, req);
_settingsSuccess = ok;
_settingsMessage = ok ? "Đã lưu thiết lập thành công!" : "Lỗi khi lưu thiết lập.";
StateHasChanged();
}
private void ToggleDay(string code)
{
var days = _settingsOpenDays.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList();
if (days.Contains(code)) days.Remove(code);
else days.Add(code);
_settingsOpenDays = string.Join(",", days);
StateHasChanged();
}
private void ConfigureSection()
{
switch (_section)

View File

@@ -437,4 +437,35 @@ public class PosDataService
: $"api/bff/reports/revenue?period={period}";
return await _http.GetFromJsonAsync<List<RevenueReportItem>>(url, _jsonOptions) ?? new();
}
// ═══ SHOP SETTINGS ═══
public record ShopSettingsInfo(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays);
public record UpdateShopSettingsRequest(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays);
public async Task<ShopSettingsInfo?> GetShopSettingsAsync(Guid shopId)
{
AttachToken();
return await _http.GetFromJsonAsync<ShopSettingsInfo>($"api/bff/shops/{shopId}/settings", _jsonOptions);
}
public async Task<bool> UpdateShopSettingsAsync(Guid shopId, UpdateShopSettingsRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}/settings", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
// ═══ TOP PRODUCTS ═══
public record TopProductInfo(string? ProductName, long TotalSold, decimal TotalRevenue);
public async Task<List<TopProductInfo>> GetTopProductsAsync(Guid? shopId = null, int limit = 10)
{
AttachToken();
var url = shopId.HasValue
? $"api/bff/reports/top-products?shopId={shopId}&limit={limit}"
: $"api/bff/reports/top-products?limit={limit}";
return await _http.GetFromJsonAsync<List<TopProductInfo>>(url, _jsonOptions) ?? new();
}
}

View File

@@ -1406,6 +1406,100 @@ public class BffDataController : ControllerBase
catch { return Ok(Array.Empty<object>()); }
}
// ═══ C1: SHOP SETTINGS ═══
[HttpGet("shops/{shopId:guid}/settings")]
public async Task<IActionResult> GetShopSettings(Guid shopId)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null) return NotFound();
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var settings = await conn.QueryFirstOrDefaultAsync<dynamic>(
@"SELECT features_config::text as features_config,
open_time::text as open_time,
close_time::text as close_time,
open_days
FROM shops
WHERE id = @ShopId AND merchant_id = @MerchantId AND is_deleted = false",
new { ShopId = shopId, MerchantId = merchantId });
if (settings == null) return NotFound();
return Ok(settings);
}
[HttpPut("shops/{shopId:guid}/settings")]
public async Task<IActionResult> UpdateShopSettings(Guid shopId, [FromBody] UpdateShopSettingsRequest req)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null) return Unauthorized();
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Contains(shopId)) return Forbid();
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
try
{
await conn.ExecuteAsync(
@"UPDATE shops
SET features_config = @FeaturesConfig::jsonb,
open_time = @OpenTime::time,
close_time = @CloseTime::time,
open_days = @OpenDays,
updated_at = NOW()
WHERE id = @ShopId AND id = ANY(@ShopIds)",
new
{
ShopId = shopId,
ShopIds = myShopIds.ToArray(),
FeaturesConfig = string.IsNullOrWhiteSpace(req.FeaturesConfig) ? "{}" : req.FeaturesConfig,
OpenTime = string.IsNullOrWhiteSpace(req.OpenTime) ? (object)DBNull.Value : req.OpenTime,
CloseTime= string.IsNullOrWhiteSpace(req.CloseTime) ? (object)DBNull.Value : req.CloseTime,
OpenDays = req.OpenDays
});
return Ok(new { success = true });
}
catch (Exception ex) { return BadRequest(new { error = ex.Message }); }
}
// ═══ C2: TOP PRODUCTS ═══
[HttpGet("reports/top-products")]
public async Task<IActionResult> GetTopProducts(
[FromQuery] Guid? shopId = null,
[FromQuery] int limit = 10)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null) return Ok(Array.Empty<object>());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any()) return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = new NpgsqlConnection(ConnStr("order_service"));
try
{
var rows = await conn.QueryAsync<dynamic>(
@"SELECT oi.product_name,
SUM(oi.quantity)::bigint AS total_sold,
SUM(oi.quantity * oi.unit_price)::numeric AS total_revenue
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE o.shop_id = ANY(@ShopIds)
AND o.status_id IN (3, 5)
GROUP BY oi.product_name
ORDER BY total_sold DESC
LIMIT @Limit",
new { ShopIds = targetShopIds.ToArray(), Limit = limit });
return Ok(rows);
}
catch { return Ok(Array.Empty<object>()); }
}
// EN: Request DTOs / VI: DTO yêu cầu
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price, string? Type, string? Sku, string? ImageUrl);
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);
@@ -1417,4 +1511,6 @@ public class BffDataController : ControllerBase
public record CreateCampaignRequest(string Name, string? Description, decimal FaceValue, int TotalVouchers, DateTime StartDate, DateTime EndDate);
public record CreateMemberRequest(string? Gender, string? CountryCode);
public record UpdateMemberRequest(string? Gender, string? Preferences);
public record UpdateShopSettingsRequest(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays);
public record TopProductItem(string ProductName, long TotalSold, decimal TotalRevenue);
}