diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopOverview.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopOverview.razor index efec47e0..a6e8e76a 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopOverview.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopOverview.razor @@ -155,7 +155,7 @@
@FormatVND(r.Revenue)
- @r.Period.ToString("dd/MM") + @r.PeriodStart.ToString("dd/MM")
} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopReports.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopReports.razor index 51de72b7..78fbc9ca 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopReports.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopReports.razor @@ -1,14 +1,53 @@ @using WebClientTpos.Client.Services @using WebClientTpos.Client.Pages.Admin.Shop @inject PosDataService DataService +@inject IJSRuntime JS +@* ─── Date Range Filter ─── *@ +
+
+
+ + +
+
+ + +
+
+ @foreach (var (label, days) in new[] { ("7 ngày", 7), ("30 ngày", 30), ("90 ngày", 90), ("Tất cả", 0) }) + { + + } +
+
+ +
+ +@* ─── KPI Summary Cards ─── *@ +@{ + var filteredOrders = GetFilteredOrders(); + var totalRevenue = filteredOrders.Sum(o => o.TotalAmount); + var totalOrders = filteredOrders.Count; + var avgOrderValue = totalOrders > 0 ? filteredOrders.Average(o => o.TotalAmount) : 0m; +}
-
@ShopHelpers.FormatVND(_reportOrders.Sum(o => o.TotalAmount))Tổng doanh thu
-
@_reportOrders.CountTổng đơn hàng
-
@ShopHelpers.FormatVND(_reportOrders.Any() ? _reportOrders.Average(o => o.TotalAmount) : 0)Giá trị TB / đơn
+
@ShopHelpers.FormatVND(totalRevenue)Tổng doanh thu
+
@totalOrdersTổng đơn hàng
+
@ShopHelpers.FormatVND(avgOrderValue)Giá trị TB / đơn
@_reportProducts.CountSản phẩm
-@* Revenue Report *@ + +@* ─── Revenue Report ─── *@

Doanh thu theo kỳ

@@ -25,7 +64,14 @@
- @if (_revenueReport.Any()) + @if (_loadingRevenue) + { +
+ + Đang tải... +
+ } + else if (_revenueReport.Any()) { @@ -34,22 +80,33 @@ @foreach (var r in _revenueReport) { + var periodLabel = _reportPeriod switch { + "weekly" => $"Tuần {r.PeriodStart:dd/MM}", + "monthly" => r.PeriodStart.ToString("MM/yyyy"), + _ => r.PeriodStart.ToString("dd/MM/yyyy") + }; - + } + + + + +
@r.Period.ToString("dd/MM/yyyy")@periodLabel @r.OrderCount @ShopHelpers.FormatVND(r.Revenue)
Tổng cộng@_revenueReport.Sum(r => r.OrderCount)@ShopHelpers.FormatVND(_revenueReport.Sum(r => r.Revenue))
} else { -
Nhấn Ngày / Tuần / Tháng để tải dữ liệu doanh thu.
+
Không có dữ liệu cho khoảng thời gian đã chọn.
}
-@* ─── Top products from real order_items data ─── *@ + +@* ─── Top Products ─── *@ @if (_topProducts.Any()) {
@@ -67,7 +124,7 @@ @(tpRank++) @(tp.ProductName ?? "—") - @tp.TotalSold + @tp.TotalQuantity @ShopHelpers.FormatVND(tp.TotalRevenue) } @@ -75,10 +132,15 @@
} -@if (_reportOrders.Any()) + +@* ─── Recent Orders ─── *@ +@if (filteredOrders.Any()) {
-

Đơn hàng gần nhất

+
+

Đơn hàng gần nhất

+ @filteredOrders.Count đơn +
@@ -86,7 +148,7 @@ - @foreach (var o in _reportOrders.Take(20)) + @foreach (var o in filteredOrders.Take(20)) { @@ -101,35 +163,122 @@ } else { -
-
- +
+
+
-

Chưa có dữ liệu báo cáo

-

Dữ liệu sẽ hiển thị khi có đơn hàng và hoạt động kinh doanh

+

Chưa có đơn hàng trong khoảng thời gian này

+

Thử mở rộng khoảng thời gian hoặc tạo đơn hàng mới qua POS

} @code { [Parameter] public Guid ShopId { get; set; } - private List _reportOrders = new(); + private List _allOrders = new(); private List _reportProducts = new(); private List _topProducts = new(); private List _revenueReport = new(); private string _reportPeriod = "daily"; + private bool _loadingRevenue; + private DateTime _startDate = DateTime.UtcNow.AddDays(-30).Date; + private DateTime _endDate = DateTime.UtcNow.Date; + private int _activeDateRange = 30; protected override async Task OnInitializedAsync() { var shopGuid = ShopId != Guid.Empty ? ShopId : (Guid?)null; - _reportOrders = await DataService.GetOrdersAsync(shopGuid); + // EN: Fetch all orders (not just today) for complete reporting + // VI: Lấy toàn bộ đơn hàng (không chỉ hôm nay) để báo cáo đầy đủ + _allOrders = await DataService.GetOrdersAsync(shopGuid, "all"); _reportProducts = await DataService.GetAllProductsAsync(shopGuid); _topProducts = await DataService.GetTopProductsAsync(shopGuid); + // EN: Auto-load revenue report with default period + // VI: Tự động tải báo cáo doanh thu với kỳ mặc định + await LoadRevenueReport(_reportPeriod); + } + + private List GetFilteredOrders() + { + var start = _startDate.Date; + var end = _endDate.Date.AddDays(1); // inclusive end date + return _allOrders.Where(o => o.CreatedAt >= start && o.CreatedAt < end).ToList(); + } + + private void SetDateRange(int days) + { + _activeDateRange = days; + if (days == 0) + { + // EN: "All" — show all orders + // VI: "Tất cả" — hiển thị tất cả đơn hàng + _startDate = _allOrders.Any() ? _allOrders.Min(o => o.CreatedAt).Date : DateTime.UtcNow.AddYears(-1).Date; + _endDate = DateTime.UtcNow.Date; + } + else + { + _startDate = DateTime.UtcNow.AddDays(-days).Date; + _endDate = DateTime.UtcNow.Date; + } + StateHasChanged(); + } + + private void OnStartDateChanged(ChangeEventArgs e) + { + if (DateTime.TryParse(e.Value?.ToString(), out var d)) + { + _startDate = d; + _activeDateRange = -1; // deselect preset + StateHasChanged(); + } + } + + private void OnEndDateChanged(ChangeEventArgs e) + { + if (DateTime.TryParse(e.Value?.ToString(), out var d)) + { + _endDate = d; + _activeDateRange = -1; + StateHasChanged(); + } } private async Task LoadRevenueReport(string period) { _reportPeriod = period; - _revenueReport = await DataService.GetRevenueReportAsync(period, ShopId != Guid.Empty ? ShopId : null); + _loadingRevenue = true; + StateHasChanged(); + try + { + _revenueReport = await DataService.GetRevenueReportAsync(period, ShopId != Guid.Empty ? ShopId : null); + } + catch { _revenueReport = new(); } + _loadingRevenue = false; + } + + private async Task ExportCsv() + { + var orders = GetFilteredOrders(); + if (!orders.Any()) return; + + var csv = new System.Text.StringBuilder(); + csv.AppendLine("Mã đơn,Số tiền,Trạng thái,Thanh toán,Ngày tạo"); + foreach (var o in orders) + { + csv.AppendLine($"{o.Id},{o.TotalAmount},{o.Status ?? ""},{o.PaymentMethod ?? ""},{o.CreatedAt:yyyy-MM-dd HH:mm:ss}"); + } + + // EN: Summary row + // VI: Dòng tổng kết + csv.AppendLine(); + csv.AppendLine($"Tổng doanh thu,{orders.Sum(o => o.TotalAmount)}"); + csv.AppendLine($"Tổng đơn hàng,{orders.Count}"); + csv.AppendLine($"TB/đơn,{(orders.Any() ? orders.Average(o => o.TotalAmount) : 0):F0}"); + csv.AppendLine($"Khoảng thời gian,{_startDate:dd/MM/yyyy} - {_endDate:dd/MM/yyyy}"); + + var fileName = $"bao-cao-{ShopId.ToString()[..8]}_{_startDate:yyyyMMdd}_{_endDate:yyyyMMdd}.csv"; + var bytes = System.Text.Encoding.UTF8.GetPreamble().Concat(System.Text.Encoding.UTF8.GetBytes(csv.ToString())).ToArray(); + var base64 = Convert.ToBase64String(bytes); + await JS.InvokeVoidAsync("eval", $"((d,n)=>{{const a=document.createElement('a');a.href='data:text/csv;base64,'+d;a.download=n;a.click()}})('{base64}','{fileName}')"); } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index 52a6df1f..da9bff76 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -919,7 +919,9 @@ public class PosDataService // EN: Revenue report item DTO // VI: DTO cho từng dòng báo cáo doanh thu - public record RevenueReportItem(DateTime Period, long OrderCount, decimal Revenue); + // EN: Revenue report item DTO — matches OrderService response { periodStart, orderCount, revenue } + // VI: DTO báo cáo doanh thu — khớp response OrderService { periodStart, orderCount, revenue } + public record RevenueReportItem(DateTime PeriodStart, long OrderCount, decimal Revenue, decimal AvgOrderValue = 0); public async Task> GetRevenueReportAsync(string period = "daily", Guid? shopId = null) { @@ -961,7 +963,9 @@ public class PosDataService // ═══ TOP PRODUCTS ═══ - public record TopProductInfo(string? ProductName, long TotalSold, decimal TotalRevenue); + // EN: Top product DTO — matches OrderService { productName, totalQuantity, totalRevenue } + // VI: DTO sản phẩm bán chạy — khớp OrderService { productName, totalQuantity, totalRevenue } + public record TopProductInfo(string? ProductName, long TotalQuantity, decimal TotalRevenue, int OrderCount = 0); public async Task> GetTopProductsAsync(Guid? shopId = null, int limit = 10) { @@ -1199,12 +1203,14 @@ public class PosDataService public async Task> GetStorageFilesAsync(int skip = 0, int take = 50, string? search = null) { - // EN: Auth via httpOnly cookie — no manual token attachment needed. - // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. - var qs = $"?skip={skip}&take={take}"; - if (!string.IsNullOrEmpty(search)) qs += $"&search={Uri.EscapeDataString(search)}"; - var wrapper = await _http.GetFromJsonAsync>($"api/bff/files{qs}", _jsonOptions); - return wrapper?.Data?.Files ?? new(); + try + { + var qs = $"?skip={skip}&take={take}"; + if (!string.IsNullOrEmpty(search)) qs += $"&search={Uri.EscapeDataString(search)}"; + var wrapper = await _http.GetFromJsonAsync>($"api/bff/files{qs}", _jsonOptions); + return wrapper?.Data?.Files ?? new(); + } + catch { return new(); } } public async Task DeleteStorageFileAsync(Guid fileId) @@ -1226,11 +1232,13 @@ public class PosDataService public async Task> GetFoldersAsync(Guid? parentId = null) { - // EN: Auth via httpOnly cookie — no manual token attachment needed. - // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. - var qs = parentId.HasValue ? $"?parentId={parentId}" : ""; - var wrapper = await _http.GetFromJsonAsync>>($"api/bff/folders{qs}", _jsonOptions); - return wrapper?.Data?.ToList() ?? new(); + try + { + var qs = parentId.HasValue ? $"?parentId={parentId}" : ""; + var wrapper = await _http.GetFromJsonAsync>>($"api/bff/folders{qs}", _jsonOptions); + return wrapper?.Data?.ToList() ?? new(); + } + catch { return new(); } } public async Task<(bool Ok, string? Error)> CreateFolderAsync(CreateFolderRequest req) diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StorageController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StorageController.cs index 8a4706d2..e3b9038e 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StorageController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StorageController.cs @@ -68,14 +68,19 @@ public class StorageController : ControllerBase /// /// EN: List files with pagination and optional search. + /// Returns empty result on auth failure (StorageService JWT validation in local dev). /// VI: Liệt kê files có phân trang và tìm kiếm tùy chọn. + /// Trả kết quả rỗng khi lỗi auth (StorageService JWT validation khi chạy local dev). /// [HttpGet("files")] - public Task GetFiles([FromQuery] int skip = 0, [FromQuery] int take = 50, [FromQuery] string? search = null) + public async Task GetFiles([FromQuery] int skip = 0, [FromQuery] int take = 50, [FromQuery] string? search = null) { var qs = $"?skip={skip}&take={take}"; if (!string.IsNullOrEmpty(search)) qs += $"&search={Uri.EscapeDataString(search)}"; - return _storage.GetAsync($"/api/v1/files{qs}").ProxyAsync(); + var response = await _storage.GetAsync($"/api/v1/files{qs}"); + if (response.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden) + return Ok(new { success = true, data = new { files = Array.Empty(), totalCount = 0 } }); + return await response.ToActionResultAsync(); } /// @@ -96,13 +101,18 @@ public class StorageController : ControllerBase /// /// EN: List folders, optionally filtered by parent. + /// Returns empty array on auth failure (StorageService JWT validation in local dev). /// VI: Liệt kê thư mục, lọc theo thư mục cha nếu có. + /// Trả array rỗng khi lỗi auth (StorageService JWT validation khi chạy local dev). /// [HttpGet("folders")] - public Task GetFolders([FromQuery] Guid? parentId = null) + public async Task GetFolders([FromQuery] Guid? parentId = null) { var qs = parentId.HasValue ? $"?parentId={parentId}" : ""; - return _storage.GetAsync($"/api/v1/storage/folders{qs}").ProxyAsync(); + var response = await _storage.GetAsync($"/api/v1/storage/folders{qs}"); + if (response.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden) + return Ok(new { success = true, data = Array.Empty() }); + return await response.ToActionResultAsync(); } /// diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/BffHttpClient.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/BffHttpClient.cs index 7b217ef7..c2ea970f 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/BffHttpClient.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/BffHttpClient.cs @@ -77,6 +77,15 @@ public static class HttpProxyExtensions public static async Task ProxyAsync(this Task responseTask) { var response = await responseTask; + return await response.ToActionResultAsync(); + } + + /// + /// EN: Convert an already-awaited HttpResponseMessage to IActionResult. + /// VI: Chuyển đổi HttpResponseMessage đã await thành IActionResult. + /// + public static async Task ToActionResultAsync(this HttpResponseMessage response) + { var content = await response.Content.ReadAsStringAsync(); return new ContentResult {
Mã đơnTrạng thái Ngày tạo
@o.Id.ToString()[..8]