fix(web-client): audit fixes — real shop stats, dynamic CTA links, shared helpers
- C1: StoreList shows real per-shop stats (revenue/orders/staff/products) via new BFF endpoint - C2: RenderEmpty CTA links now route to correct section (finance→/pos, inventory→/menu, etc.) - C3+M5: ShopOverview right column shows real recent orders instead of always-empty - C4: AdminSettings expanded from 5 hardcoded services to 11 with icons - M1-M3: Consolidated duplicate helpers (GetShopIcon, GetStatusBadgeClass, GetStatusLabel) into ShopVerticalHelper - M2: Added proper error state UI for StoreList data loading failures - Added GET /api/bff/shops/stats BFF endpoint for aggregated per-shop statistics
This commit is contained in:
@@ -80,11 +80,12 @@
|
||||
@foreach (var svc in _services)
|
||||
{
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
|
||||
<i data-lucide="@svc.Icon" style="width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
|
||||
<span style="font-size:13px;font-weight:500;flex:1;">@svc.Name</span>
|
||||
<div class="admin-status-badge admin-status-badge--online">
|
||||
<span class="admin-status-badge__dot"></span>
|
||||
Online
|
||||
</div>
|
||||
<span style="font-size:13px;font-weight:500;">@svc</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -202,7 +203,22 @@
|
||||
private string _tab = "general";
|
||||
private int _shopCount = 0;
|
||||
|
||||
private readonly string[] _services = { "API Gateway", "IAM Service", "Merchant Service", "Catalog Service", "Order Service" };
|
||||
// EN: Service list matching actual deployed Docker containers
|
||||
// VI: Danh sách dịch vụ khớp với Docker containers thực tế
|
||||
private readonly (string Name, string Icon)[] _services = new[]
|
||||
{
|
||||
("IAM Service", "shield"),
|
||||
("Merchant Service", "store"),
|
||||
("Catalog Service", "package"),
|
||||
("Order Service", "shopping-bag"),
|
||||
("Inventory Service", "warehouse"),
|
||||
("Wallet Service", "wallet"),
|
||||
("Membership Service", "users"),
|
||||
("Promotion Service", "tag"),
|
||||
("Booking Service", "calendar"),
|
||||
("F&B Engine", "utensils"),
|
||||
("Storage Service", "hard-drive"),
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -211,7 +227,11 @@
|
||||
var shops = await DataService.GetShopsAsync();
|
||||
_shopCount = shops.Count;
|
||||
}
|
||||
catch { _shopCount = 0; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_shopCount = 0;
|
||||
Console.Error.WriteLine($"[AdminSettings] Error loading shop count: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private record NotifSetting(string Label, string Desc, bool Enabled);
|
||||
|
||||
@@ -161,10 +161,33 @@
|
||||
Đơn gần nhất
|
||||
</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="text-align:center;padding:20px;color:var(--admin-text-tertiary);font-size:14px;">
|
||||
<i data-lucide="inbox" style="width:32px;height:32px;margin-bottom:8px;opacity:0.5;"></i>
|
||||
<p style="margin:0;">Chưa có đơn hàng nào</p>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="padding:0;">
|
||||
@if (_orders.Any())
|
||||
{
|
||||
<div style="display:flex;flex-direction:column;">
|
||||
@foreach (var o in _orders.Take(5))
|
||||
{
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid var(--admin-border-subtle);">
|
||||
<div style="display:flex;flex-direction:column;gap:2px;">
|
||||
<span style="font-size:12px;font-family:monospace;color:var(--admin-text-tertiary);">@o.Id.ToString()[..8]</span>
|
||||
<span style="font-size:11px;color:var(--admin-text-tertiary);">@o.CreatedAt.ToString("dd/MM HH:mm")</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span class="admin-status-badge admin-status-badge--online" style="font-size:10px;padding:2px 8px;"><span class="admin-status-badge__dot" style="width:4px;height:4px;"></span>@(o.Status ?? "—")</span>
|
||||
<span style="font-weight:600;font-size:13px;">@FormatVND(o.TotalAmount)</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="text-align:center;padding:20px;color:var(--admin-text-tertiary);font-size:14px;">
|
||||
<i data-lucide="inbox" style="width:32px;height:32px;margin-bottom:8px;opacity:0.5;"></i>
|
||||
<p style="margin:0;">Chưa có đơn hàng nào</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel" style="flex:1;">
|
||||
@@ -235,21 +258,8 @@
|
||||
|
||||
private static string FormatVND(decimal val) => val.ToString("N0") + " ₫";
|
||||
|
||||
private static string GetStatusBadgeClass(string? status) => status?.ToLowerInvariant() switch
|
||||
{
|
||||
"published" or "active" => "online",
|
||||
"draft" or "setup" => "setup",
|
||||
"inactive" or "paused" => "paused",
|
||||
_ => "setup"
|
||||
};
|
||||
private static string GetStatusBadgeClass(string? status) => ShopVerticalHelper.GetStatusBadgeClass(status);
|
||||
|
||||
private static string GetStatusLabel(string? status) => status?.ToLowerInvariant() switch
|
||||
{
|
||||
"published" or "active" => "Đang mở",
|
||||
"draft" or "setup" => "Thiết lập",
|
||||
"inactive" or "paused" => "Tạm dừng",
|
||||
"closed" => "Đã đóng",
|
||||
_ => status ?? "—"
|
||||
};
|
||||
private static string GetStatusLabel(string? status) => ShopVerticalHelper.GetStatusLabel(status);
|
||||
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
case "products":
|
||||
@if (!_products.Any())
|
||||
{
|
||||
@RenderEmpty("coffee", "#F59E0B", "Chưa có sản phẩm", "Thêm sản phẩm để bắt đầu bán hàng", "plus-circle", "Thêm sản phẩm")
|
||||
@RenderEmpty("coffee", "#F59E0B", "Chưa có sản phẩm", "Thêm sản phẩm để bắt đầu bán hàng", "plus-circle", "Thêm sản phẩm", $"/admin/shop/{ShopId}/menu")
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -78,7 +78,7 @@
|
||||
case "inventory":
|
||||
@if (!_inventory.Any())
|
||||
{
|
||||
@RenderEmpty("warehouse", "#3B82F6", "Chưa có tồn kho", "Tồn kho sẽ hiển thị khi có sản phẩm", "package", "Thêm sản phẩm trước")
|
||||
@RenderEmpty("warehouse", "#3B82F6", "Chưa có tồn kho", "Tồn kho sẽ hiển thị khi có sản phẩm", "package", "Thêm sản phẩm trước", $"/admin/shop/{ShopId}/menu")
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -117,7 +117,7 @@
|
||||
</div>
|
||||
@if (!_orders.Any())
|
||||
{
|
||||
@RenderEmpty("bar-chart-3", "#22C55E", "Chưa có dữ liệu tài chính", "Dữ liệu sẽ tự động cập nhật khi có đơn hàng", "monitor", "Mở POS bán hàng")
|
||||
@RenderEmpty("bar-chart-3", "#22C55E", "Chưa có dữ liệu tài chính", "Dữ liệu sẽ tự động cập nhật khi có đơn hàng", "monitor", "Mở POS bán hàng", "/pos")
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -149,7 +149,7 @@
|
||||
case "staff":
|
||||
@if (!_staff.Any())
|
||||
{
|
||||
@RenderEmpty("users", "#8B5CF6", "Chưa có nhân viên", "Thêm nhân viên để quản lý cửa hàng", "user-plus", "Thêm nhân viên")
|
||||
@RenderEmpty("users", "#8B5CF6", "Chưa có nhân viên", "Thêm nhân viên để quản lý cửa hàng", "user-plus", "Thêm nhân viên", $"/admin/shop/{ShopId}/staff")
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -184,7 +184,7 @@
|
||||
case "customers":
|
||||
@if (!_members.Any())
|
||||
{
|
||||
@RenderEmpty("heart", "#EF4444", "Chưa có khách hàng", "Khách hàng sẽ hiển thị khi có giao dịch", "monitor", "Mở POS bán hàng")
|
||||
@RenderEmpty("heart", "#EF4444", "Chưa có khách hàng", "Khách hàng sẽ hiển thị khi có giao dịch", "monitor", "Mở POS bán hàng", "/pos")
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -339,8 +339,8 @@
|
||||
|
||||
private static string FormatVND(decimal val) => val.ToString("N0") + " ₫";
|
||||
|
||||
// EN: Reusable empty state renderer with optional CTA / VI: Renderer trạng thái trống tái sử dụng với CTA tùy chọn
|
||||
private RenderFragment RenderEmpty(string icon, string color, string title, string desc, string? ctaIcon = null, string? ctaLabel = null) => __builder =>
|
||||
// EN: Reusable empty state renderer with dynamic CTA href / VI: Renderer trạng thái trống với CTA href động
|
||||
private RenderFragment RenderEmpty(string icon, string color, string title, string desc, string? ctaIcon = null, string? ctaLabel = null, string? ctaHref = null) => __builder =>
|
||||
{
|
||||
<div style="text-align:center;padding:60px 20px;">
|
||||
<div style="width:80px;height:80px;border-radius:24px;background:@($"rgba({HexToRgb(color)},0.1)");display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
|
||||
@@ -350,7 +350,7 @@
|
||||
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">@desc</p>
|
||||
@if (ctaIcon != null && ctaLabel != null)
|
||||
{
|
||||
<a href="/admin/shop/@ShopId/menu" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;text-decoration:none;">
|
||||
<a href="@(ctaHref ?? $"/admin/shop/{ShopId}/menu")" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;text-decoration:none;">
|
||||
<i data-lucide="@ctaIcon" style="width:16px;height:16px;"></i>
|
||||
@ctaLabel
|
||||
</a>
|
||||
|
||||
@@ -37,22 +37,38 @@
|
||||
</button>
|
||||
<button class="admin-tab @(_activeTab == "active" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "active")">
|
||||
Hoạt động
|
||||
<span class="admin-tab__badge">@_shops.Count(s => IsActive(s.Status))</span>
|
||||
<span class="admin-tab__badge">@_shops.Count(s => ShopVerticalHelper.IsActive(s.Status))</span>
|
||||
</button>
|
||||
<button class="admin-tab @(_activeTab == "setup" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "setup")">
|
||||
Thiết lập
|
||||
<span class="admin-tab__badge">@_shops.Count(s => IsSetup(s.Status))</span>
|
||||
<span class="admin-tab__badge">@_shops.Count(s => ShopVerticalHelper.IsSetup(s.Status))</span>
|
||||
</button>
|
||||
<button class="admin-tab @(_activeTab == "paused" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "paused")">
|
||||
Tạm dừng
|
||||
<span class="admin-tab__badge">@_shops.Count(s => IsPaused(s.Status))</span>
|
||||
<span class="admin-tab__badge">@_shops.Count(s => ShopVerticalHelper.IsPaused(s.Status))</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* ═══ STORE LIST ═══ *@
|
||||
<div class="admin-content" style="display:flex;flex-direction:column;gap:16px;padding:28px 32px;">
|
||||
|
||||
@if (IsLoading)
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="admin-panel" style="border:1px solid rgba(239,68,68,0.3);">
|
||||
<div class="admin-panel__body" style="text-align:center;padding:40px 20px;">
|
||||
<div style="width:64px;height:64px;border-radius:20px;background:rgba(239,68,68,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
|
||||
<i data-lucide="alert-circle" style="width:28px;height:28px;color:#EF4444;"></i>
|
||||
</div>
|
||||
<h3 style="font-size:16px;font-weight:700;margin:0 0 8px;color:#EF4444;">Lỗi tải dữ liệu</h3>
|
||||
<p style="font-size:13px;color:var(--admin-text-tertiary);margin:0 0 16px;">@_errorMessage</p>
|
||||
<button class="admin-btn-primary" @onclick="LoadData" style="display:inline-flex;align-items:center;gap:8px;">
|
||||
<i data-lucide="refresh-cw" style="width:14px;height:14px;"></i>
|
||||
Thử lại
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (IsLoading)
|
||||
{
|
||||
<div style="text-align:center;padding:48px 20px;">
|
||||
<div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div>
|
||||
@@ -96,43 +112,44 @@
|
||||
{
|
||||
@foreach (var shop in FilteredShops)
|
||||
{
|
||||
var isPaused = IsPaused(shop.Status);
|
||||
var isPaused = ShopVerticalHelper.IsPaused(shop.Status);
|
||||
var stats = GetStatsForShop(shop.Id);
|
||||
<div class="admin-store-list-card @(isPaused ? "admin-store-list-card--paused" : "")" @onclick="@(() => NavigateTo($"shop/{shop.Id}/overview"))">
|
||||
<div class="admin-store-list-card__left">
|
||||
<div class="admin-store-card__avatar" style="width:52px;height:52px;background-color:@(GetCategoryBgColor(shop.Category));border-radius:14px;">
|
||||
<i data-lucide="@GetShopIcon(shop.Category)" style="color:@(GetCategoryColor(shop.Category));width:24px;height:24px;"></i>
|
||||
<div class="admin-store-card__avatar" style="width:52px;height:52px;background-color:@(ShopVerticalHelper.GetBgColor(shop.Category));border-radius:14px;">
|
||||
<i data-lucide="@ShopVerticalHelper.GetIcon(shop.Category)" style="color:@(ShopVerticalHelper.GetColor(shop.Category));width:24px;height:24px;"></i>
|
||||
</div>
|
||||
<div class="admin-store-list-card__info">
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<span style="font-size:16px;font-weight:600;">@shop.Name</span>
|
||||
<div class="admin-status-badge admin-status-badge--@GetStatusBadgeClass(shop.Status)">
|
||||
<div class="admin-status-badge admin-status-badge--@ShopVerticalHelper.GetStatusBadgeClass(shop.Status)">
|
||||
<span class="admin-status-badge__dot"></span>
|
||||
@GetStatusLabel(shop.Status)
|
||||
@ShopVerticalHelper.GetStatusLabel(shop.Status)
|
||||
</div>
|
||||
</div>
|
||||
<span style="font-size:12px;color:var(--admin-text-tertiary);">@(shop.Category ?? "—") • @(shop.Slug)</span>
|
||||
<span style="font-size:12px;color:var(--admin-text-tertiary);">@ShopVerticalHelper.GetLabel(shop.Category) • @(shop.Slug)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-store-list-card__stats">
|
||||
<div class="admin-store-stat">
|
||||
<div class="admin-store-stat__value" style="@(isPaused ? "opacity:0.5;" : "")">--</div>
|
||||
<div class="admin-store-stat__value" style="@(isPaused ? "opacity:0.5;" : "")">@FormatRevenue(stats.Revenue)</div>
|
||||
<div class="admin-store-stat__label">Doanh thu</div>
|
||||
</div>
|
||||
<div class="admin-store-stat">
|
||||
<div class="admin-store-stat__value" style="@(isPaused ? "opacity:0.5;" : "")">--</div>
|
||||
<div class="admin-store-stat__value" style="@(isPaused ? "opacity:0.5;" : "")">@stats.OrderCount</div>
|
||||
<div class="admin-store-stat__label">Đơn hàng</div>
|
||||
</div>
|
||||
<div class="admin-store-stat">
|
||||
<div class="admin-store-stat__value" style="@(isPaused ? "opacity:0.5;" : "")">--</div>
|
||||
<div class="admin-store-stat__value" style="@(isPaused ? "opacity:0.5;" : "")">@stats.StaffCount</div>
|
||||
<div class="admin-store-stat__label">Nhân viên</div>
|
||||
</div>
|
||||
<div class="admin-store-stat">
|
||||
<div class="admin-store-stat__value" style="@(isPaused ? "opacity:0.5;" : "")">--</div>
|
||||
<div class="admin-store-stat__value" style="@(isPaused ? "opacity:0.5;" : "")">@stats.ProductCount</div>
|
||||
<div class="admin-store-stat__label">Sản phẩm</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-store-list-card__actions">
|
||||
@if (IsSetup(shop.Status))
|
||||
@if (ShopVerticalHelper.IsSetup(shop.Status))
|
||||
{
|
||||
<button class="admin-btn-primary" style="font-size:12px;padding:6px 12px;" @onclick:stopPropagation @onclick="@(() => NavigateTo($"stores/{shop.Id}/settings"))">
|
||||
<i data-lucide="settings" style="width:14px;height:14px;"></i>
|
||||
@@ -163,28 +180,38 @@
|
||||
|
||||
@code {
|
||||
private List<PosDataService.ShopInfo> _shops = new();
|
||||
private List<PosDataService.ShopStatsInfo> _shopStats = new();
|
||||
private string _activeTab = "all";
|
||||
private string? _errorMessage;
|
||||
|
||||
private IEnumerable<PosDataService.ShopInfo> FilteredShops => _shops
|
||||
.Where(s => _activeTab == "all"
|
||||
|| (_activeTab == "active" && IsActive(s.Status))
|
||||
|| (_activeTab == "setup" && IsSetup(s.Status))
|
||||
|| (_activeTab == "paused" && IsPaused(s.Status)))
|
||||
|| (_activeTab == "active" && ShopVerticalHelper.IsActive(s.Status))
|
||||
|| (_activeTab == "setup" && ShopVerticalHelper.IsSetup(s.Status))
|
||||
|| (_activeTab == "paused" && ShopVerticalHelper.IsPaused(s.Status)))
|
||||
.Where(s => string.IsNullOrWhiteSpace(SearchQuery)
|
||||
|| (s.Name?.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ?? false)
|
||||
|| (s.Slug?.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ?? false)
|
||||
|| (s.Category?.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override async Task OnInitializedAsync() => await LoadData();
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
IsLoading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_shops = await DataService.GetShopsAsync();
|
||||
// EN: Load shops and stats in parallel / VI: Tải shops và stats song song
|
||||
var shopsTask = DataService.GetShopsAsync();
|
||||
var statsTask = DataService.GetShopStatsAsync();
|
||||
_shops = await shopsTask;
|
||||
_shopStats = await statsTask;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
_shops = new();
|
||||
_errorMessage = $"Không thể tải danh sách cửa hàng: {ex.Message}";
|
||||
Console.Error.WriteLine($"[StoreList] Error: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -192,57 +219,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsActive(string? status) => status?.Equals("Published", StringComparison.OrdinalIgnoreCase) == true
|
||||
|| status?.Equals("active", StringComparison.OrdinalIgnoreCase) == true;
|
||||
private static bool IsSetup(string? status) => status?.Equals("Draft", StringComparison.OrdinalIgnoreCase) == true
|
||||
|| status?.Equals("setup", StringComparison.OrdinalIgnoreCase) == true;
|
||||
private static bool IsPaused(string? status) => status?.Equals("Inactive", StringComparison.OrdinalIgnoreCase) == true
|
||||
|| status?.Equals("paused", StringComparison.OrdinalIgnoreCase) == true;
|
||||
// EN: Get stats for a specific shop / VI: Lấy stats cho shop cụ thể
|
||||
private PosDataService.ShopStatsInfo GetStatsForShop(Guid shopId) =>
|
||||
_shopStats.FirstOrDefault(s => s.ShopId == shopId)
|
||||
?? new PosDataService.ShopStatsInfo(shopId, 0, 0, 0, 0);
|
||||
|
||||
private static string GetStatusBadgeClass(string? status) => status?.ToLowerInvariant() switch
|
||||
private static string FormatRevenue(decimal val)
|
||||
{
|
||||
"published" or "active" => "online",
|
||||
"draft" or "setup" => "setup",
|
||||
"inactive" or "paused" => "paused",
|
||||
_ => "setup"
|
||||
};
|
||||
|
||||
private static string GetStatusLabel(string? status) => status?.ToLowerInvariant() switch
|
||||
{
|
||||
"published" or "active" => "Hoạt động",
|
||||
"draft" or "setup" => "Thiết lập",
|
||||
"inactive" or "paused" => "Tạm dừng",
|
||||
"closed" => "Đã đóng",
|
||||
_ => status ?? "—"
|
||||
};
|
||||
|
||||
private static string GetShopIcon(string? category) => category?.ToLowerInvariant() switch
|
||||
{
|
||||
"foodbeverage" or "café" or "cafe" or "coffee" => "coffee",
|
||||
"restaurant" or "nhà hàng" => "utensils",
|
||||
"entertainment" or "karaoke" => "mic",
|
||||
"beauty" or "spa" => "sparkles",
|
||||
"retail" => "shopping-bag",
|
||||
_ => "store"
|
||||
};
|
||||
|
||||
private static string GetCategoryColor(string? category) => category?.ToLowerInvariant() switch
|
||||
{
|
||||
"foodbeverage" or "café" or "cafe" => "var(--admin-orange-primary)",
|
||||
"restaurant" => "#3B82F6",
|
||||
"entertainment" or "karaoke" => "#8B5CF6",
|
||||
"beauty" or "spa" => "#EC4899",
|
||||
"retail" => "#22C55E",
|
||||
_ => "var(--admin-orange-primary)"
|
||||
};
|
||||
|
||||
private static string GetCategoryBgColor(string? category) => category?.ToLowerInvariant() switch
|
||||
{
|
||||
"foodbeverage" or "café" or "cafe" => "rgba(255,92,0,0.125)",
|
||||
"restaurant" => "rgba(59,130,246,0.125)",
|
||||
"entertainment" or "karaoke" => "rgba(139,92,246,0.125)",
|
||||
"beauty" or "spa" => "rgba(236,72,153,0.125)",
|
||||
"retail" => "rgba(34,197,94,0.125)",
|
||||
_ => "rgba(255,92,0,0.125)"
|
||||
};
|
||||
if (val >= 1_000_000) return $"{val / 1_000_000:0.#}M";
|
||||
if (val >= 1_000) return $"{val / 1_000:0.#}K";
|
||||
return val == 0 ? "0₫" : $"{val:N0}₫";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,4 +175,11 @@ public class PosDataService
|
||||
|
||||
public async Task<List<LevelDefinitionInfo>> GetMembershipLevelsAsync()
|
||||
=> await _http.GetFromJsonAsync<List<LevelDefinitionInfo>>("api/bff/membership/levels", _jsonOptions) ?? new();
|
||||
|
||||
// ═══ SHOP STATS (aggregated per-shop) ═══
|
||||
|
||||
public record ShopStatsInfo(Guid ShopId, int ProductCount, int OrderCount, int StaffCount, decimal Revenue);
|
||||
|
||||
public async Task<List<ShopStatsInfo>> GetShopStatsAsync()
|
||||
=> await _http.GetFromJsonAsync<List<ShopStatsInfo>>("api/bff/shops/stats", _jsonOptions) ?? new();
|
||||
}
|
||||
|
||||
@@ -54,4 +54,71 @@ public static class ShopVerticalHelper
|
||||
"retail" => "Bán lẻ",
|
||||
_ => "Cửa hàng"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get accent color CSS value for a vertical.
|
||||
/// VI: Lấy màu nhấn CSS cho ngành hàng.
|
||||
/// </summary>
|
||||
public static string GetColor(string? category) => NormalizeVertical(category) switch
|
||||
{
|
||||
"cafe" => "var(--admin-orange-primary)",
|
||||
"restaurant" => "#3B82F6",
|
||||
"karaoke" => "#8B5CF6",
|
||||
"spa" => "#EC4899",
|
||||
"retail" => "#22C55E",
|
||||
_ => "var(--admin-orange-primary)"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get background color (RGBA) for a vertical.
|
||||
/// VI: Lấy màu nền (RGBA) cho ngành hàng.
|
||||
/// </summary>
|
||||
public static string GetBgColor(string? category) => NormalizeVertical(category) switch
|
||||
{
|
||||
"cafe" => "rgba(255,92,0,0.125)",
|
||||
"restaurant" => "rgba(59,130,246,0.125)",
|
||||
"karaoke" => "rgba(139,92,246,0.125)",
|
||||
"spa" => "rgba(236,72,153,0.125)",
|
||||
"retail" => "rgba(34,197,94,0.125)",
|
||||
_ => "rgba(255,92,0,0.125)"
|
||||
};
|
||||
|
||||
// ─── Shop Status Helpers (shared across StoreList, ShopOverview, etc.) ───
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get CSS badge class for shop status.
|
||||
/// VI: Lấy CSS badge class cho trạng thái shop.
|
||||
/// </summary>
|
||||
public static string GetStatusBadgeClass(string? status) => status?.ToLowerInvariant() switch
|
||||
{
|
||||
"published" or "active" => "online",
|
||||
"draft" or "setup" => "setup",
|
||||
"inactive" or "paused" => "paused",
|
||||
_ => "setup"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get display label for shop status.
|
||||
/// VI: Lấy nhãn hiển thị cho trạng thái shop.
|
||||
/// </summary>
|
||||
public static string GetStatusLabel(string? status) => status?.ToLowerInvariant() switch
|
||||
{
|
||||
"published" or "active" => "Đang mở",
|
||||
"draft" or "setup" => "Thiết lập",
|
||||
"inactive" or "paused" => "Tạm dừng",
|
||||
"closed" => "Đã đóng",
|
||||
_ => status ?? "—"
|
||||
};
|
||||
|
||||
public static bool IsActive(string? status) =>
|
||||
status?.Equals("Published", StringComparison.OrdinalIgnoreCase) == true
|
||||
|| status?.Equals("active", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
public static bool IsSetup(string? status) =>
|
||||
status?.Equals("Draft", StringComparison.OrdinalIgnoreCase) == true
|
||||
|| status?.Equals("setup", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
public static bool IsPaused(string? status) =>
|
||||
status?.Equals("Inactive", StringComparison.OrdinalIgnoreCase) == true
|
||||
|| status?.Equals("paused", StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
@@ -437,6 +437,84 @@ public class BffDataController : ControllerBase
|
||||
return Ok(levels);
|
||||
}
|
||||
|
||||
// ═══ SHOP STATS (aggregated per-shop counts) ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get aggregated stats per shop — product count, order count, staff count, revenue.
|
||||
/// VI: Lấy thống kê tổng hợp theo shop — số sản phẩm, đơn hàng, nhân viên, doanh thu.
|
||||
/// </summary>
|
||||
[HttpGet("shops/stats")]
|
||||
public async Task<IActionResult> GetShopStats()
|
||||
{
|
||||
// EN: Collect stats from multiple service databases
|
||||
// VI: Thu thập stats từ nhiều database dịch vụ
|
||||
|
||||
// Products per shop
|
||||
Dictionary<Guid, int> productCounts = new();
|
||||
try
|
||||
{
|
||||
await using var catConn = new NpgsqlConnection(ConnStr("catalog_service"));
|
||||
var prodStats = await catConn.QueryAsync<dynamic>(
|
||||
"SELECT shop_id, COUNT(*) as cnt FROM products WHERE is_active = true GROUP BY shop_id");
|
||||
foreach (var ps in prodStats)
|
||||
productCounts[(Guid)ps.shop_id] = (int)(long)ps.cnt;
|
||||
}
|
||||
catch { /* catalog_service may not have data yet */ }
|
||||
|
||||
// Orders per shop + revenue
|
||||
Dictionary<Guid, int> orderCounts = new();
|
||||
Dictionary<Guid, decimal> revenues = new();
|
||||
try
|
||||
{
|
||||
await using var orderConn = new NpgsqlConnection(ConnStr("order_service"));
|
||||
var orderStats = await orderConn.QueryAsync<dynamic>(
|
||||
"SELECT shop_id, COUNT(*) as cnt, COALESCE(SUM(total_amount), 0) as revenue FROM orders GROUP BY shop_id");
|
||||
foreach (var os in orderStats)
|
||||
{
|
||||
orderCounts[(Guid)os.shop_id] = (int)(long)os.cnt;
|
||||
revenues[(Guid)os.shop_id] = (decimal)os.revenue;
|
||||
}
|
||||
}
|
||||
catch { /* order_service may not have data yet */ }
|
||||
|
||||
// Staff per shop (via shop_members join)
|
||||
Dictionary<Guid, int> staffCounts = new();
|
||||
try
|
||||
{
|
||||
await using var mConn = new NpgsqlConnection(ConnStr("merchant_service"));
|
||||
var staffStats = await mConn.QueryAsync<dynamic>(
|
||||
@"SELECT sm.shop_id, COUNT(DISTINCT sm.staff_id) as cnt
|
||||
FROM shop_members sm
|
||||
JOIN merchant_staff ms ON sm.staff_id = ms.id
|
||||
JOIN staff_statuses ss ON ms.status_id = ss.id
|
||||
WHERE ss.name = 'Active'
|
||||
GROUP BY sm.shop_id");
|
||||
foreach (var ss in staffStats)
|
||||
staffCounts[(Guid)ss.shop_id] = (int)(long)ss.cnt;
|
||||
}
|
||||
catch { /* merchant_service may not have data yet */ }
|
||||
|
||||
// EN: Get all shops and merge stats / VI: Lấy shops rồi merge stats
|
||||
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
|
||||
var shops = await conn.QueryAsync<dynamic>(
|
||||
"SELECT id FROM shops WHERE is_deleted = false");
|
||||
|
||||
var result = shops.Select(s =>
|
||||
{
|
||||
var shopId = (Guid)s.id;
|
||||
return new
|
||||
{
|
||||
shop_id = shopId,
|
||||
product_count = productCounts.GetValueOrDefault(shopId, 0),
|
||||
order_count = orderCounts.GetValueOrDefault(shopId, 0),
|
||||
staff_count = staffCounts.GetValueOrDefault(shopId, 0),
|
||||
revenue = revenues.GetValueOrDefault(shopId, 0m)
|
||||
};
|
||||
});
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user