feat(web-client-tpos): replace hardcoded POS data with API-driven endpoints

This commit is contained in:
Ho Ngoc Hai
2026-03-03 11:27:34 +07:00
parent fe6e14ce85
commit d969f3dda8
3 changed files with 405 additions and 122 deletions

View File

@@ -1,13 +1,14 @@
@*
EN: Café POS Desktop — Unified single-page with tabs: Sale, History, Dashboard.
Payment flow is inline (cart panel transforms to payment panel).
All data fetched from API — no hardcoded demo data.
VI: POS Café Desktop — Trang đơn với tabs: Bán hàng, Lịch sử, Dashboard.
Thanh toán inline (panel giỏ hàng chuyển thành panel thanh toán).
Toàn bộ dữ liệu lấy từ API — không có dữ liệu demo cứng.
*@
@page "/pos/{ShopId:guid}/cafe"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@* ═══════════════ MAIN CONTENT AREA ═══════════════ *@
<div class="pos-content-area">
@@ -250,31 +251,57 @@
}
</div>
<div class="pos-history__list">
@foreach (var order in FilteredOrders)
@if (_historyLoading)
{
<div class="pos-history__card" @onclick="() => _selectedOrder = order">
<div class="pos-history__card-header">
<span class="pos-history__order-id">@order.Id</span>
<span class="pos-history__status @(order.Status == "Hoàn thành" ? "pos-history__status--completed" : "pos-history__status--refunded")">
@order.Status
</span>
</div>
<div class="pos-history__card-body">
<span class="pos-history__items-preview">@order.Items</span>
<div class="pos-history__card-meta">
<div class="pos-history__total">@FormatPrice(order.Total)</div>
<div class="pos-history__time">@order.Time</div>
<div class="pos-history__method">@order.Method</div>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:center;padding:60px 0;color:var(--pos-text-tertiary);">
<div style="font-size:14px;">Đang tải đơn hàng...</div>
</div>
}
@if (!FilteredOrders.Any())
else
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 0;color:var(--pos-text-tertiary);">
<i data-lucide="search" style="width:40px;height:40px;margin-bottom:12px;opacity:0.4;"></i>
<div style="font-size:14px;">Không tìm thấy đơn hàng</div>
</div>
@* EN: Session orders (created during this POS session) shown first *@
@foreach (var order in FilteredSessionOrders)
{
<div class="pos-history__card" @onclick="() => _selectedOrder = order">
<div class="pos-history__card-header">
<span class="pos-history__order-id">@order.Id</span>
<span class="pos-history__status pos-history__status--completed">@order.Status</span>
</div>
<div class="pos-history__card-body">
<span class="pos-history__items-preview">@order.Items</span>
<div class="pos-history__card-meta">
<div class="pos-history__total">@FormatPrice(order.Total)</div>
<div class="pos-history__time">@order.Time</div>
<div class="pos-history__method">@order.Method</div>
</div>
</div>
</div>
}
@* EN: API orders from DB *@
@foreach (var order in FilteredApiOrders)
{
<div class="pos-history__card">
<div class="pos-history__card-header">
<span class="pos-history__order-id">@order.Id.ToString()[..8].ToUpper()</span>
<span class="pos-history__status @(order.Status == "Completed" || order.Status == "Delivered" ? "pos-history__status--completed" : "pos-history__status--refunded")">
@MapApiStatus(order.Status)
</span>
</div>
<div class="pos-history__card-body">
<div class="pos-history__card-meta">
<div class="pos-history__total">@FormatPrice(order.TotalAmount)</div>
<div class="pos-history__time">@order.CreatedAt.ToString("HH:mm dd/MM")</div>
</div>
</div>
</div>
}
@if (!FilteredSessionOrders.Any() && !FilteredApiOrders.Any())
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 0;color:var(--pos-text-tertiary);">
<i data-lucide="search" style="width:40px;height:40px;margin-bottom:12px;opacity:0.4;"></i>
<div style="font-size:14px;">Chưa có đơn hàng nào</div>
</div>
}
}
</div>
</div>
@@ -290,61 +317,98 @@
</div>
</div>
<div class="pos-dashboard__stats">
@foreach (var stat in _dashStats)
{
@if (_dashLoading)
{
<div style="display:flex;align-items:center;justify-content:center;padding:60px 0;color:var(--pos-text-tertiary);">
<div style="font-size:14px;">Đang tải thống kê...</div>
</div>
}
else
{
<div class="pos-dashboard__stats">
<div class="pos-dashboard__stat-card">
<div class="pos-dashboard__stat-label">@stat.Label</div>
<div class="pos-dashboard__stat-value" style="color:@stat.Color;">@stat.Value</div>
<div class="pos-dashboard__stat-sub">@stat.Sub</div>
<div class="pos-dashboard__stat-label">Doanh thu</div>
<div class="pos-dashboard__stat-value" style="color:var(--pos-orange-primary);">@FormatPrice(_dashboard.Revenue)</div>
<div class="pos-dashboard__stat-sub">TB @FormatPrice(_dashboard.AvgOrderValue)/đơn</div>
</div>
<div class="pos-dashboard__stat-card">
<div class="pos-dashboard__stat-label">Đơn hàng</div>
<div class="pos-dashboard__stat-value" style="color:var(--pos-success);">@_dashboard.OrderCount</div>
<div class="pos-dashboard__stat-sub">Hôm nay</div>
</div>
<div class="pos-dashboard__stat-card">
<div class="pos-dashboard__stat-label">Món bán ra</div>
<div class="pos-dashboard__stat-value" style="color:var(--pos-warning);">@_dashboard.ItemsSold</div>
<div class="pos-dashboard__stat-sub">@(_dashboard.OrderCount > 0 ? $"{(double)_dashboard.ItemsSold / _dashboard.OrderCount:F1} món/đơn" : "—")</div>
</div>
}
</div>
<div class="pos-dashboard__grid">
@* Popular items *@
<div class="pos-dashboard__section">
<div class="pos-dashboard__section-title">Món bán chạy</div>
@foreach (var item in _dashPopular)
{
<div class="pos-dashboard__popular-item">
<div>
<div style="font-size:13px;font-weight:500;">@item.Name</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@item.Qty đã bán</div>
</div>
<span style="font-size:13px;font-weight:600;color:var(--pos-orange-primary);">@FormatPrice(item.Revenue)</span>
</div>
}
</div>
@* Payment breakdown + Hourly chart *@
<div class="pos-dashboard__section">
<div class="pos-dashboard__section-title">Hình thức thanh toán</div>
@foreach (var p in _dashPayments)
{
<div class="pos-dashboard__payment-row">
<div class="pos-dashboard__payment-header">
<span>@p.Method</span>
<span style="font-weight:600;">@FormatPrice(p.Amount)</span>
</div>
<div class="pos-dashboard__payment-bar">
<div class="pos-dashboard__payment-fill" style="width:@(p.Pct)%;background:@p.Color;"></div>
</div>
</div>
}
<div class="pos-dashboard__section-title" style="margin-top:20px;">Doanh thu theo giờ</div>
<div class="pos-dashboard__hourly-chart">
@foreach (var h in _dashHourly)
<div class="pos-dashboard__grid">
@* Popular items *@
<div class="pos-dashboard__section">
<div class="pos-dashboard__section-title">Món bán chạy</div>
@if (_dashboard.PopularItems.Any())
{
<div class="pos-dashboard__hourly-bar">
<div class="pos-dashboard__hourly-fill" style="height:@(h.Pct)%;opacity:@(h.Pct > 0 ? 1 : 0.3);"></div>
<span class="pos-dashboard__hourly-label">@h.Hour</span>
@foreach (var item in _dashboard.PopularItems)
{
<div class="pos-dashboard__popular-item">
<div>
<div style="font-size:13px;font-weight:500;">@item.Name</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@item.Qty đã bán</div>
</div>
<span style="font-size:13px;font-weight:600;color:var(--pos-orange-primary);">@FormatPrice(item.Revenue)</span>
</div>
}
}
else
{
<div style="font-size:13px;color:var(--pos-text-tertiary);padding:16px 0;">Chưa có dữ liệu</div>
}
</div>
@* Payment breakdown + Hourly chart *@
<div class="pos-dashboard__section">
<div class="pos-dashboard__section-title">Hình thức thanh toán</div>
@if (_dashboard.PaymentBreakdown.Any())
{
@foreach (var p in _dashboard.PaymentBreakdown)
{
<div class="pos-dashboard__payment-row">
<div class="pos-dashboard__payment-header">
<span>@p.Method</span>
<span style="font-weight:600;">@FormatPrice(p.Amount)</span>
</div>
<div class="pos-dashboard__payment-bar">
<div class="pos-dashboard__payment-fill" style="width:@(p.Pct)%;background:var(--pos-orange-primary);"></div>
</div>
</div>
}
}
else
{
<div style="font-size:13px;color:var(--pos-text-tertiary);padding:16px 0;">Chưa có dữ liệu</div>
}
<div class="pos-dashboard__section-title" style="margin-top:20px;">Doanh thu theo giờ</div>
@if (_dashboard.HourlyRevenue.Any())
{
<div class="pos-dashboard__hourly-chart">
@foreach (var h in _dashboard.HourlyRevenue)
{
<div class="pos-dashboard__hourly-bar">
<div class="pos-dashboard__hourly-fill" style="height:@(h.Pct)%;opacity:@(h.Pct > 0 ? 1 : 0.3);"></div>
<span class="pos-dashboard__hourly-label">@h.Hour</span>
</div>
}
</div>
}
else
{
<div style="font-size:13px;color:var(--pos-text-tertiary);padding:16px 0;">Chưa có dữ liệu</div>
}
</div>
</div>
</div>
}
</div>
break;
}
@@ -374,10 +438,16 @@
private enum PosTab { Sale, History, Dashboard }
private PosTab _activeTab = PosTab.Sale;
private void SwitchTab(PosTab tab)
private async Task SwitchTab(PosTab tab)
{
if (_paymentStep != PayStep.None && _paymentStep != PayStep.Success) return; // Block tab switch during payment
_activeTab = tab;
// EN: Lazy-load data when switching tabs / VI: Load dữ liệu khi chuyển tab
if (tab == PosTab.History && !_historyLoaded)
await LoadHistoryAsync();
else if (tab == PosTab.Dashboard && !_dashLoaded)
await LoadDashboardAsync();
}
// ═══════════════ SALE TAB — Product & Cart ═══════════════
@@ -500,11 +570,12 @@
private void ConfirmPayment()
{
_lastOrderTotal = CartTotal;
_lastTransactionId = $"TXN-{DateTime.Now:yyyyMMdd}-{Random.Shared.Next(100, 999)}";
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
// EN: Save to history / VI: Lưu vào lịch sử
// EN: Save to session history (in-memory for this POS session)
// VI: Lưu vào lịch sử phiên (in-memory cho phiên POS hiện tại)
var methodLabel = _selectedMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" };
_orderHistory.Insert(0, new OrderRecord(
_sessionOrders.Insert(0, new SessionOrder(
_lastTransactionId,
string.Join(", ", _cartItems.Select(i => $"{i.Name} x{i.Qty}")),
_lastOrderTotal,
@@ -513,6 +584,10 @@
"Hoàn thành"
));
// EN: Invalidate dashboard cache so next view refreshes
// VI: Xóa cache dashboard để refresh khi xem lại
_dashLoaded = false;
_paymentStep = PayStep.Success;
}
@@ -525,10 +600,20 @@
_customAmountInput = "";
}
// ═══════════════ HISTORY TAB ═══════════════
// ═══════════════ HISTORY TAB — API-driven ═══════════════
private string _historySearch = "";
private string _historyFilter = "today";
private OrderRecord? _selectedOrder;
private SessionOrder? _selectedOrder;
private bool _historyLoading;
private bool _historyLoaded;
// EN: Session orders created during this POS session (in-memory, real-time)
// VI: Đơn hàng tạo trong phiên POS này (in-memory, real-time)
private readonly List<SessionOrder> _sessionOrders = new();
// EN: API orders fetched from DB
// VI: Đơn hàng từ API (DB)
private List<PosDataService.OrderInfo> _apiOrders = new();
private readonly List<HistoryFilter> _historyFilters = new()
{
@@ -537,58 +622,73 @@
new("month", "30 ngày"),
};
// EN: Demo history data + real-time additions / VI: Dữ liệu lịch sử mẫu + thêm real-time
private readonly List<OrderRecord> _orderHistory = new()
private async Task LoadHistoryAsync()
{
new("TXN-20260303-001", "Cà phê sữa đá x2, Bạc xỉu x1", 103_000, "10:15", "Tiền mặt", "Hoàn thành"),
new("TXN-20260303-002", "Cappuccino x1, Croissant bơ x2", 125_000, "10:02", "QR Code", "Hoàn thành"),
new("TXN-20260303-003", "Trà đào cam sả x3, Sinh tố bơ x1", 190_000, "09:48", "Thẻ", "Hoàn thành"),
new("TXN-20260303-004", "Matcha Latte x1", 59_000, "09:30", "Tiền mặt", "Hoàn thành"),
new("TXN-20260303-005", "Espresso x2, Latte x1", 145_000, "09:15", "Chuyển khoản", "Hoàn thành"),
new("TXN-20260303-006", "Cà phê đen đá x4", 116_000, "08:55", "Tiền mặt", "Hoàn thành"),
new("TXN-20260303-007", "Trà sen vàng x2, Bánh mì bơ x1", 133_000, "08:40", "QR Code", "Hoàn thành"),
new("TXN-20260303-008", "Sinh tố bơ x1", 55_000, "08:25", "Tiền mặt", "Hoàn trả"),
};
_historyLoading = true;
StateHasChanged();
try
{
_apiOrders = await DataService.GetOrdersAsync(ShopId);
}
catch { _apiOrders = new(); }
_historyLoaded = true;
_historyLoading = false;
StateHasChanged();
}
private IEnumerable<OrderRecord> FilteredOrders =>
private IEnumerable<SessionOrder> FilteredSessionOrders =>
string.IsNullOrWhiteSpace(_historySearch)
? _orderHistory
: _orderHistory.Where(o =>
? _sessionOrders
: _sessionOrders.Where(o =>
o.Id.Contains(_historySearch, StringComparison.OrdinalIgnoreCase) ||
o.Items.Contains(_historySearch, StringComparison.OrdinalIgnoreCase));
// ═══════════════ DASHBOARD TAB ═══════════════
private readonly List<DashStat> _dashStats = new()
private IEnumerable<PosDataService.OrderInfo> FilteredApiOrders =>
string.IsNullOrWhiteSpace(_historySearch)
? _apiOrders
: _apiOrders.Where(o =>
o.Id.ToString().Contains(_historySearch, StringComparison.OrdinalIgnoreCase) ||
(o.Status?.Contains(_historySearch, StringComparison.OrdinalIgnoreCase) ?? false));
private static string MapApiStatus(string? status) => status switch
{
new("Doanh thu", "8,450,000₫", "var(--pos-orange-primary)", "+12% so với hôm qua"),
new("Đơn hàng", "156", "var(--pos-success)", "TB 54,167₫/đơn"),
new("Khách hàng", "132", "var(--pos-info)", "18% khách quay lại"),
new("Món bán ra", "347", "var(--pos-warning)", "2.2 món/đơn"),
"Completed" or "Delivered" => "Hoàn thành",
"Cancelled" => "Đã hủy",
"Refunded" => "Hoàn trả",
"Processing" => "Đang xử lý",
"Pending" => "Chờ xác nhận",
_ => status ?? "—"
};
private readonly List<DashPopular> _dashPopular = new()
{
new("Cà phê sữa đá", 52, 1_820_000),
new("Bạc xỉu", 38, 1_482_000),
new("Trà đào cam sả", 29, 1_305_000),
new("Cappuccino", 24, 1_320_000),
new("Sinh tố bơ", 18, 990_000),
};
// ═══════════════ DASHBOARD TAB — API-driven ═══════════════
private bool _dashLoading;
private bool _dashLoaded;
private PosDataService.PosDashboardInfo _dashboard = new(0, 0, 0, 0, new(), new(), new(), new());
private readonly List<DashPayment> _dashPayments = new()
private async Task LoadDashboardAsync()
{
new("Tiền mặt", 4_650_000, 55, "var(--pos-success)"),
new("Chuyển khoản", 2_535_000, 30, "var(--pos-info)"),
new("Thẻ", 845_000, 10, "var(--pos-warning)"),
new("Ví điện tử", 420_000, 5, "var(--pos-orange-primary)"),
};
private readonly List<DashHour> _dashHourly = new()
{
new("7h", 30), new("8h", 65), new("9h", 85), new("10h", 55),
new("11h", 90), new("12h", 100), new("13h", 70), new("14h", 45),
new("15h", 60), new("16h", 75), new("17h", 50), new("18h", 20),
};
_dashLoading = true;
StateHasChanged();
try
{
var data = await DataService.GetPosDashboardAsync(ShopId);
// EN: Null-coalesce all lists to prevent NRE if API returns null
// VI: Null-coalesce tất cả list để tránh NRE nếu API trả null
_dashboard = new PosDataService.PosDashboardInfo(
data.Revenue, data.OrderCount, data.ItemsSold, data.AvgOrderValue,
data.PopularItems ?? new(),
data.PaymentBreakdown ?? new(),
data.HourlyRevenue ?? new(),
data.RecentOrders ?? new());
}
catch
{
_dashboard = new(0, 0, 0, 0, new(), new(), new(), new());
}
_dashLoaded = true;
_dashLoading = false;
StateHasChanged();
}
// ═══════════════ RECORDS ═══════════════
private record Product(string Name, decimal Price, string Category);
@@ -598,10 +698,6 @@
public decimal Price { get; set; } = price;
public int Qty { get; set; } = 1;
}
private record OrderRecord(string Id, string Items, decimal Total, string Time, string Method, string Status);
private record SessionOrder(string Id, string Items, decimal Total, string Time, string Method, string Status);
private record HistoryFilter(string Key, string Label);
private record DashStat(string Label, string Value, string Color, string Sub);
private record DashPopular(string Name, int Qty, decimal Revenue);
private record DashPayment(string Method, decimal Amount, int Pct, string Color);
private record DashHour(string Hour, int Pct);
}

View File

@@ -219,4 +219,27 @@ public class PosDataService
public async Task<List<ResourceInfo>> GetResourcesAsync(Guid shopId)
{ AttachToken(); return await _http.GetFromJsonAsync<List<ResourceInfo>>($"api/bff/shops/{shopId}/resources", _jsonOptions) ?? new(); }
// ═══ POS DASHBOARD (real-time daily stats) ═══
// EN: POS dashboard response DTOs
// VI: DTOs cho response dashboard POS
public record PosDashboardInfo(
decimal Revenue, int OrderCount, int ItemsSold, decimal AvgOrderValue,
List<PopularItemInfo> PopularItems,
List<PaymentBreakdownInfo> PaymentBreakdown,
List<HourlyRevenueInfo> HourlyRevenue,
List<RecentOrderInfo> RecentOrders);
public record PopularItemInfo(string Name, int Qty, decimal Revenue);
public record PaymentBreakdownInfo(string Method, decimal Amount, int Pct);
public record HourlyRevenueInfo(string Hour, decimal Revenue, int Pct);
public record RecentOrderInfo(string Id, decimal Total, string Time, string Status, string Method);
public async Task<PosDashboardInfo> GetPosDashboardAsync(Guid shopId)
{
AttachToken();
return await _http.GetFromJsonAsync<PosDashboardInfo>(
$"api/bff/pos/dashboard?shopId={shopId}", _jsonOptions)
?? new(0, 0, 0, 0, new(), new(), new(), new());
}
}

View File

@@ -734,6 +734,170 @@ public class BffDataController : ControllerBase
return Ok(result);
}
// ═══ POS DASHBOARD (real-time daily stats for POS screen) ═══
/// <summary>
/// EN: Get POS dashboard data — daily revenue, order count, popular items, payment breakdown, hourly chart.
/// VI: Lấy dữ liệu dashboard POS — doanh thu ngày, số đơn, món bán chạy, thanh toán, biểu đồ theo giờ.
/// </summary>
[HttpGet("pos/dashboard")]
public async Task<IActionResult> GetPosDashboard([FromQuery] Guid? shopId = null)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(EmptyDashboard());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(EmptyDashboard());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(EmptyDashboard());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
// EN: Today's stats / VI: Thống kê hôm nay
decimal revenue = 0; int orderCount = 0; int itemsSold = 0;
List<object> popularItems = new();
List<object> paymentBreakdown = new();
List<object> hourlyRevenue = new();
List<object> recentOrders = new();
try
{
await using var conn = new NpgsqlConnection(ConnStr("order_service"));
// EN: Summary: total revenue + order count for today
// VI: Tổng kết: doanh thu + số đơn hôm nay
var summary = await conn.QueryFirstOrDefaultAsync<dynamic>(
@"SELECT COUNT(*) as cnt, COALESCE(SUM(total_amount), 0) as total
FROM orders WHERE shop_id = ANY(@ShopIds) AND DATE(created_at) = CURRENT_DATE",
new { ShopIds = targetShopIds.ToArray() });
if (summary != null)
{
orderCount = (int)(long)summary.cnt;
revenue = (decimal)summary.total;
}
// EN: Recent orders (last 50 today)
// VI: Đơn hàng gần đây (50 đơn cuối hôm nay)
var orders = await conn.QueryAsync<dynamic>(
@"SELECT o.id, o.total_amount, o.created_at, os.name as status,
COALESCE(o.payment_method, 'cash') as payment_method
FROM orders o
JOIN order_statuses os ON o.status_id = os.id
WHERE o.shop_id = ANY(@ShopIds) AND DATE(o.created_at) = CURRENT_DATE
ORDER BY o.created_at DESC LIMIT 50",
new { ShopIds = targetShopIds.ToArray() });
recentOrders = orders.Select(o => (object)new
{
id = ((Guid)o.id).ToString()[..8].ToUpper(),
total = (decimal)o.total_amount,
time = ((DateTime)o.created_at).ToString("HH:mm"),
status = (string)o.status,
method = MapPaymentMethod((string)o.payment_method)
}).ToList();
// EN: Payment breakdown (today)
// VI: Phân loại thanh toán (hôm nay)
try
{
var payments = await conn.QueryAsync<dynamic>(
@"SELECT COALESCE(payment_method, 'cash') as method,
SUM(total_amount) as total, COUNT(*) as cnt
FROM orders
WHERE shop_id = ANY(@ShopIds) AND DATE(created_at) = CURRENT_DATE
GROUP BY COALESCE(payment_method, 'cash')
ORDER BY total DESC",
new { ShopIds = targetShopIds.ToArray() });
var totalRev = payments.Sum(p => (decimal)p.total);
paymentBreakdown = payments.Select(p => (object)new
{
method = MapPaymentMethod((string)p.method),
amount = (decimal)p.total,
pct = totalRev > 0 ? (int)Math.Round((decimal)p.total / totalRev * 100) : 0
}).ToList();
}
catch { /* payment_method column may not exist */ }
// EN: Hourly revenue (today, 6am11pm)
// VI: Doanh thu theo giờ (hôm nay, 6h23h)
try
{
var hourly = await conn.QueryAsync<dynamic>(
@"SELECT EXTRACT(HOUR FROM created_at)::int as hr,
SUM(total_amount) as total
FROM orders
WHERE shop_id = ANY(@ShopIds) AND DATE(created_at) = CURRENT_DATE
GROUP BY 1 ORDER BY 1",
new { ShopIds = targetShopIds.ToArray() });
var maxHr = hourly.Any() ? hourly.Max(h => (decimal)h.total) : 1;
for (int h = 6; h <= 22; h++)
{
var match = hourly.FirstOrDefault(x => (int)x.hr == h);
var val = match != null ? (decimal)match.total : 0;
hourlyRevenue.Add(new { hour = $"{h}h", revenue = val, pct = maxHr > 0 ? (int)(val / maxHr * 100) : 0 });
}
}
catch { /* OK */ }
// EN: Popular items (today — from order_items if exists)
// VI: Món bán chạy (hôm nay — từ order_items nếu có)
try
{
var popular = await conn.QueryAsync<dynamic>(
@"SELECT oi.product_name as name, SUM(oi.quantity) as qty,
SUM(oi.quantity * oi.unit_price) as revenue
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE o.shop_id = ANY(@ShopIds) AND DATE(o.created_at) = CURRENT_DATE
GROUP BY oi.product_name
ORDER BY qty DESC LIMIT 10",
new { ShopIds = targetShopIds.ToArray() });
itemsSold = (int)popular.Sum(p => (long)p.qty);
popularItems = popular.Select(p => (object)new
{
name = (string)p.name,
qty = (int)(long)p.qty,
revenue = (decimal)p.revenue
}).ToList();
}
catch { /* order_items table may not exist yet */ }
}
catch { /* order_service DB not available */ }
return Ok(new
{
revenue,
orderCount,
itemsSold,
avgOrderValue = orderCount > 0 ? revenue / orderCount : 0,
popularItems,
paymentBreakdown,
hourlyRevenue,
recentOrders
});
}
private static object EmptyDashboard() => new
{
revenue = 0m, orderCount = 0, itemsSold = 0, avgOrderValue = 0m,
popularItems = Array.Empty<object>(),
paymentBreakdown = Array.Empty<object>(),
hourlyRevenue = Array.Empty<object>(),
recentOrders = Array.Empty<object>()
};
private static string MapPaymentMethod(string method) => method switch
{
"cash" => "Tiền mặt",
"card" => "Thẻ",
"qr" => "QR Code",
"transfer" => "Chuyển khoản",
"ewallet" => "Ví điện tử",
_ => method
};
// 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);