feat(web-client-tpos): replace hardcoded POS data with API-driven endpoints
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, 6am–11pm)
|
||||
// VI: Doanh thu theo giờ (hôm nay, 6h–23h)
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user