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:
Ho Ngoc Hai
2026-02-28 06:56:27 +07:00
parent d703109096
commit abd709d31c
7 changed files with 270 additions and 103 deletions

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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}₫";
}
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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);