feat: Implement date range filtering, CSV export, and enhanced revenue report display in shop reports.

This commit is contained in:
Ho Ngoc Hai
2026-03-25 15:20:56 +07:00
parent 36a0a9c256
commit af1b1fb101
5 changed files with 214 additions and 38 deletions

View File

@@ -155,7 +155,7 @@
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;min-width:0;">
<span style="font-size:10px;font-weight:600;color:var(--admin-orange-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;">@FormatVND(r.Revenue)</span>
<div style="width:100%;height:@(Math.Max(pct, 4))%;min-height:4px;background:var(--admin-orange-primary);border-radius:6px 6px 0 0;transition:height 0.3s;"></div>
<span style="font-size:10px;color:var(--admin-text-tertiary);">@r.Period.ToString("dd/MM")</span>
<span style="font-size:10px;color:var(--admin-text-tertiary);">@r.PeriodStart.ToString("dd/MM")</span>
</div>
}
</div>

View File

@@ -1,14 +1,53 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService
@inject IJSRuntime JS
@* ─── Date Range Filter ─── *@
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:12px;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:6px;">
<label style="font-size:12px;font-weight:600;color:var(--admin-text-tertiary);">Từ</label>
<input type="date" value="@_startDate.ToString("yyyy-MM-dd")" @onchange="OnStartDateChanged"
style="padding:6px 10px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:13px;" />
</div>
<div style="display:flex;align-items:center;gap:6px;">
<label style="font-size:12px;font-weight:600;color:var(--admin-text-tertiary);">Đến</label>
<input type="date" value="@_endDate.ToString("yyyy-MM-dd")" @onchange="OnEndDateChanged"
style="padding:6px 10px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:13px;" />
</div>
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;">
@foreach (var (label, days) in new[] { ("7 ngày", 7), ("30 ngày", 30), ("90 ngày", 90), ("Tất cả", 0) })
{
<button @onclick="@(() => SetDateRange(days))"
style="padding:5px 12px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;border:none;
background:@(_activeDateRange == days ? "var(--admin-orange-primary)" : "transparent");
color:@(_activeDateRange == days ? "#FFF" : "var(--admin-text-tertiary)");">
@label
</button>
}
</div>
</div>
<button @onclick="ExportCsv" style="padding:6px 14px;border-radius:8px;border:1px solid rgba(34,197,94,0.3);background:rgba(34,197,94,0.1);color:#22C55E;font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:6px;">
<i data-lucide="download" style="width:14px;height:14px;"></i> Xuất CSV
</button>
</div>
@* ─── 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;
}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-bottom:20px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="trending-up" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@ShopHelpers.FormatVND(_reportOrders.Sum(o => o.TotalAmount))</span><span class="admin-stat-card__label">Tổng doanh thu</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="shopping-bag" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_reportOrders.Count</span><span class="admin-stat-card__label">Tổng đơn hàng</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);"><i data-lucide="banknote" style="color:#8B5CF6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@ShopHelpers.FormatVND(_reportOrders.Any() ? _reportOrders.Average(o => o.TotalAmount) : 0)</span><span class="admin-stat-card__label">Giá trị TB / đơn</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="trending-up" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@ShopHelpers.FormatVND(totalRevenue)</span><span class="admin-stat-card__label">Tổng doanh thu</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="shopping-bag" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@totalOrders</span><span class="admin-stat-card__label">Tổng đơn hàng</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);"><i data-lucide="banknote" style="color:#8B5CF6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@ShopHelpers.FormatVND(avgOrderValue)</span><span class="admin-stat-card__label">Giá trị TB / đơn</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(236,72,153,0.1);"><i data-lucide="package" style="color:#EC4899;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_reportProducts.Count</span><span class="admin-stat-card__label">Sản phẩm</span></div></div>
</div>
@* Revenue Report *@
@* ─── Revenue Report ─── *@
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Doanh thu theo kỳ</h3>
@@ -25,7 +64,14 @@
</div>
</div>
<div class="admin-panel__body" style="padding:0;">
@if (_revenueReport.Any())
@if (_loadingRevenue)
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);font-size:14px;">
<i data-lucide="loader-2" style="width:20px;height:20px;animation:spin 1s linear infinite;"></i>
Đang tải...
</div>
}
else if (_revenueReport.Any())
{
<table style="width:100%;border-collapse:collapse;">
<thead><tr style="font-size:12px;color:var(--admin-text-tertiary);text-align:left;border-bottom:1px solid var(--admin-border-subtle);">
@@ -34,22 +80,33 @@
<tbody>
@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")
};
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:10px 16px;color:var(--admin-text-primary);font-weight:500;">@r.Period.ToString("dd/MM/yyyy")</td>
<td style="padding:10px 16px;color:var(--admin-text-primary);font-weight:500;">@periodLabel</td>
<td style="padding:10px 16px;text-align:right;color:var(--admin-text-secondary);">@r.OrderCount</td>
<td style="padding:10px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@ShopHelpers.FormatVND(r.Revenue)</td>
</tr>
}
<tr style="border-top:2px solid var(--admin-orange-primary);font-weight:700;">
<td style="padding:10px 16px;color:var(--admin-text-primary);">Tổng cộng</td>
<td style="padding:10px 16px;text-align:right;color:var(--admin-text-primary);">@_revenueReport.Sum(r => r.OrderCount)</td>
<td style="padding:10px 16px;text-align:right;color:var(--admin-orange-primary);">@ShopHelpers.FormatVND(_revenueReport.Sum(r => r.Revenue))</td>
</tr>
</tbody>
</table>
}
else
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);font-size:14px;">Nhấn Ngày / Tuần / Tháng để tải dữ liệu doanh thu.</div>
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);font-size:14px;">Không có dữ liệu cho khoảng thời gian đã chọn.</div>
}
</div>
</div>
@* ─── Top products from real order_items data ─── *@
@* ─── Top Products ─── *@
@if (_topProducts.Any())
{
<div class="admin-panel" style="margin-bottom:16px;">
@@ -67,7 +124,7 @@
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:700;color:var(--admin-orange-primary);">@(tpRank++)</td>
<td style="padding:12px 16px;font-weight:600;">@(tp.ProductName ?? "—")</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:#3B82F6;">@tp.TotalSold</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:#3B82F6;">@tp.TotalQuantity</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@ShopHelpers.FormatVND(tp.TotalRevenue)</td>
</tr>
}
@@ -75,10 +132,15 @@
</div>
</div>
}
@if (_reportOrders.Any())
@* ─── Recent Orders ─── *@
@if (filteredOrders.Any())
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 style="font-size:14px;font-weight:700;margin:0;">Đơn hàng gần nhất</h3></div>
<div class="admin-panel__header">
<h3 style="font-size:14px;font-weight:700;margin:0;">Đơn hàng gần nhất</h3>
<span style="font-size:12px;color:var(--admin-text-tertiary);">@filteredOrders.Count đơn</span>
</div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mã đơn</th>
@@ -86,7 +148,7 @@
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Trạng thái</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày tạo</th>
</tr></thead><tbody>
@foreach (var o in _reportOrders.Take(20))
@foreach (var o in filteredOrders.Take(20))
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-family:monospace;font-size:12px;font-weight:600;">@o.Id.ToString()[..8]</td>
@@ -101,35 +163,122 @@
}
else
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(34,197,94,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="bar-chart-2" style="width:36px;height:36px;color:#22C55E;"></i>
<div style="text-align:center;padding:40px 20px;">
<div style="width:64px;height:64px;border-radius:20px;background:rgba(34,197,94,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
<i data-lucide="bar-chart-2" style="width:28px;height:28px;color:#22C55E;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có dữ liệu báo cáo</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">Dữ liệu sẽ hiển thị khi có đơn hàng và hoạt động kinh doanh</p>
<h3 style="font-size:16px;font-weight:700;margin:0 0 6px;color:var(--pos-text-primary, #FFF);">Chưa có đơn hàng trong khoảng thời gian này</h3>
<p style="font-size:13px;color:var(--admin-text-tertiary);margin:0;">Thử mở rộng khoảng thời gian hoặc tạo đơn hàng mới qua POS</p>
</div>
}
@code {
[Parameter] public Guid ShopId { get; set; }
private List<PosDataService.OrderInfo> _reportOrders = new();
private List<PosDataService.OrderInfo> _allOrders = new();
private List<PosDataService.AdminProductInfo> _reportProducts = new();
private List<PosDataService.TopProductInfo> _topProducts = new();
private List<PosDataService.RevenueReportItem> _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<PosDataService.OrderInfo> 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}')");
}
}

View File

@@ -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<List<RevenueReportItem>> 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<List<TopProductInfo>> GetTopProductsAsync(Guid? shopId = null, int limit = 10)
{
@@ -1199,12 +1203,14 @@ public class PosDataService
public async Task<List<StorageFileInfo>> 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<StorageApiResponse<UserFilesResult>>($"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<StorageApiResponse<UserFilesResult>>($"api/bff/files{qs}", _jsonOptions);
return wrapper?.Data?.Files ?? new();
}
catch { return new(); }
}
public async Task<bool> DeleteStorageFileAsync(Guid fileId)
@@ -1226,11 +1232,13 @@ public class PosDataService
public async Task<List<StorageFolderInfo>> 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<StorageApiResponse<IEnumerable<StorageFolderInfo>>>($"api/bff/folders{qs}", _jsonOptions);
return wrapper?.Data?.ToList() ?? new();
try
{
var qs = parentId.HasValue ? $"?parentId={parentId}" : "";
var wrapper = await _http.GetFromJsonAsync<StorageApiResponse<IEnumerable<StorageFolderInfo>>>($"api/bff/folders{qs}", _jsonOptions);
return wrapper?.Data?.ToList() ?? new();
}
catch { return new(); }
}
public async Task<(bool Ok, string? Error)> CreateFolderAsync(CreateFolderRequest req)

View File

@@ -68,14 +68,19 @@ public class StorageController : ControllerBase
/// <summary>
/// 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).
/// </summary>
[HttpGet("files")]
public Task<IActionResult> GetFiles([FromQuery] int skip = 0, [FromQuery] int take = 50, [FromQuery] string? search = null)
public async Task<IActionResult> 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<object>(), totalCount = 0 } });
return await response.ToActionResultAsync();
}
/// <summary>
@@ -96,13 +101,18 @@ public class StorageController : ControllerBase
/// <summary>
/// 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).
/// </summary>
[HttpGet("folders")]
public Task<IActionResult> GetFolders([FromQuery] Guid? parentId = null)
public async Task<IActionResult> 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<object>() });
return await response.ToActionResultAsync();
}
/// <summary>

View File

@@ -77,6 +77,15 @@ public static class HttpProxyExtensions
public static async Task<IActionResult> ProxyAsync(this Task<HttpResponseMessage> responseTask)
{
var response = await responseTask;
return await response.ToActionResultAsync();
}
/// <summary>
/// EN: Convert an already-awaited HttpResponseMessage to IActionResult.
/// VI: Chuyển đổi HttpResponseMessage đã await thành IActionResult.
/// </summary>
public static async Task<IActionResult> ToActionResultAsync(this HttpResponseMessage response)
{
var content = await response.Content.ReadAsStringAsync();
return new ContentResult
{