refactor(web-client): audit shop sidebar — fix bugs, DRY code, enhance UX

P1 Bug Fixes:
- Fix Finance stat spacing (0₫ → 0 ₫)
- Replace 3 empty catch blocks with error state UI + logging
- Make POS button functional (navigate to /pos)
- Add @implements IDisposable to AdminLayout

P2 Code Quality:
- Create ShopVerticalHelper.cs (DRY icon/label/normalize)
- Remove dead overview case from ShopPage.razor
- Delete 15 orphaned admin pages (moved to shop-scope)
- ShopSidebarConfig delegates to ShopVerticalHelper

P3 UX Enhancement:
- Add CTA buttons to all empty states
- Real KPIs in ShopOverview (orders, products, revenue)
- User role from auth token (Admin/Khách)
- Responsive CSS for stat cards, tables, mobile sidebar
This commit is contained in:
Ho Ngoc Hai
2026-02-28 06:36:41 +07:00
parent f7e431fd01
commit d703109096
22 changed files with 172 additions and 1050 deletions

View File

@@ -6,6 +6,7 @@
Shop Level: Detected via URL /admin/shop/{shopId}/**, shows vertical-specific menu
*@
@inherits LayoutComponentBase
@implements IDisposable
@inject NavigationManager NavigationManager
@inject IJSRuntime JS
@inject WebClientTpos.Client.Services.AuthStateService AuthState
@@ -117,7 +118,7 @@
<div class="admin-user-avatar">@_userInitials</div>
<div class="admin-user-info">
<span class="admin-user-name">@_userName</span>
<span class="admin-user-role">Owner</span>
<span class="admin-user-role">@_userRole</span>
</div>
<button @onclick="Logout" title="Đăng xuất">
<i data-lucide="log-out"></i>
@@ -222,6 +223,7 @@
private string _userInitials => _userName.Length >= 2
? _userName[..2].ToUpper()
: _userName.ToUpper();
private string _userRole => AuthState.IsAuthenticated ? "Admin" : "Khách";
private async Task Logout()
{

View File

@@ -1,26 +0,0 @@
@page "/admin/customers/feedback"
@layout AdminLayout
@inherits AdminBase
<PageTitle>Phản hồi — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Phản hồi khách hàng</h1>
<p class="admin-topbar__subtitle">Thu thập đánh giá và phản hồi</p>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(245,158,11,0.1);"><i data-lucide="star" style="color:#F59E0B;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">—</span><span class="admin-stat-card__label">Đánh giá TB</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="message-square" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">0</span><span class="admin-stat-card__label">Tổng phản hồi</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="thumbs-up" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">0</span><span class="admin-stat-card__label">Tích cực</span></div></div>
</div>
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(245,158,11,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;"><i data-lucide="message-circle" style="width:36px;height:36px;color:#F59E0B;"></i></div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có phản hồi</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Phản hồi sẽ hiển thị khi khách hàng đánh giá dịch vụ</p>
</div>
</div>

View File

@@ -1,73 +0,0 @@
@page "/admin/customers/loyalty"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Chương trình thành viên — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Chương trình thành viên</h1>
<p class="admin-topbar__subtitle">Quản lý cấp bậc & ưu đãi thành viên</p>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
@if (IsLoading)
{
<div style="text-align:center;padding:48px;"><div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div></div>
}
else if (!_levels.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(255,92,0,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;"><i data-lucide="crown" style="width:36px;height:36px;color:#FF5C00;"></i></div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa cấu hình cấp bậc</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Thiết lập cấp bậc thành viên để xây dựng chương trình loyalty</p>
</div>
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;">
@foreach (var level in _levels)
{
<div class="admin-panel">
<div class="admin-panel__body" style="text-align:center;padding:24px;">
<div style="width:56px;height:56px;border-radius:16px;background:@GetLevelBg(level.Level);display:flex;align-items:center;justify-content:center;margin:0 auto 12px;">
<i data-lucide="@GetLevelIcon(level.Level)" style="color:@GetLevelColor(level.Level);width:28px;height:28px;"></i>
</div>
<h3 style="font-size:18px;font-weight:700;margin:0 0 4px;">@level.Name</h3>
<p style="font-size:13px;color:var(--admin-text-tertiary);margin:0 0 16px;">Level @level.Level</p>
<div style="display:flex;justify-content:center;gap:24px;">
<div><span style="font-weight:700;font-size:16px;color:var(--admin-orange-primary);">@level.MinExp.ToString("N0")</span><br/><span style="font-size:11px;color:var(--admin-text-tertiary);">Min EXP</span></div>
<div><span style="font-weight:700;font-size:16px;">@level.MemberCount</span><br/><span style="font-size:11px;color:var(--admin-text-tertiary);">Thành viên</span></div>
</div>
</div>
</div>
}
</div>
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Tổng quan</h3></div>
<div class="admin-panel__body" style="display:flex;gap:24px;">
<div><span style="font-weight:700;font-size:24px;">@_levels.Sum(l => l.MemberCount)</span><br/><span style="font-size:13px;color:var(--admin-text-tertiary);">Tổng thành viên</span></div>
<div><span style="font-weight:700;font-size:24px;">@_levels.Count</span><br/><span style="font-size:13px;color:var(--admin-text-tertiary);">Cấp bậc</span></div>
</div>
</div>
}
</div>
@code {
private List<PosDataService.LevelDefinitionInfo> _levels = new();
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try { _levels = await DataService.GetMembershipLevelsAsync(); }
catch { } finally { IsLoading = false; }
}
private static string GetLevelColor(int l) => l switch { 1 => "#94A3B8", 2 => "#22C55E", 3 => "#3B82F6", 4 => "#F59E0B", _ => "#FF5C00" };
private static string GetLevelBg(int l) => l switch { 1 => "rgba(148,163,184,0.1)", 2 => "rgba(34,197,94,0.1)", 3 => "rgba(59,130,246,0.1)", 4 => "rgba(245,158,11,0.1)", _ => "rgba(255,92,0,0.1)" };
private static string GetLevelIcon(int l) => l switch { 1 => "user", 2 => "star", 3 => "award", 4 => "gem", _ => "crown" };
}

View File

@@ -118,7 +118,7 @@
<div class="admin-store-card__top">
<div class="admin-store-card__info">
<div class="admin-store-card__avatar" style="background-color:rgba(255,92,0,0.125);">
<i data-lucide="@GetShopIcon(shop.Category)" style="color:var(--admin-orange-primary);"></i>
<i data-lucide="@ShopVerticalHelper.GetIcon(shop.Category)" style="color:var(--admin-orange-primary);"></i>
</div>
<div>
<div class="admin-store-card__name">@shop.Name</div>
@@ -208,9 +208,10 @@
{
_shops = await DataService.GetShopsAsync();
}
catch
catch (Exception ex)
{
_shops = new();
Console.Error.WriteLine($"[Dashboard] Error loading shops: {ex}");
}
finally
{
@@ -218,13 +219,4 @@
}
}
private static string GetShopIcon(string? category) => category?.ToLowerInvariant() switch
{
"cafe" or "café" or "coffee" => "coffee",
"restaurant" or "nhà hàng" => "utensils",
"karaoke" => "mic",
"spa" => "sparkles",
"retail" => "shopping-bag",
_ => "store"
};
}

View File

@@ -1,62 +0,0 @@
@page "/admin/finance/expenses"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Chi phí — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Quản lý chi phí</h1>
<p class="admin-topbar__subtitle">@_txns.Count giao dịch</p>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
@if (IsLoading)
{
<div style="text-align:center;padding:48px;"><div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div></div>
}
else if (!_txns.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(239,68,68,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;"><i data-lucide="receipt" style="width:36px;height:36px;color:#EF4444;"></i></div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có giao dịch chi phí</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Giao dịch sẽ hiển thị khi có hoạt động ví</p>
</div>
}
else
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Giao dịch gần đây</h3></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ô tả</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số tiền</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày</th>
</tr></thead><tbody>
@foreach (var t in _txns)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-size:14px;">@(t.Description ?? t.ItemName ?? "—")</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:@(t.Amount < 0 ? "#EF4444" : "#22C55E");">@(t.Amount < 0 ? "-" : "+")@Math.Abs(t.Amount).ToString("N0")₫</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@t.CreatedAt.ToString("dd/MM HH:mm")</td>
</tr>
}
</tbody></table>
</div>
</div>
}
</div>
@code {
private List<PosDataService.WalletTxnInfo> _txns = new();
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try { _txns = await DataService.GetWalletTransactionsAsync(100); }
catch { } finally { IsLoading = false; }
}
}

View File

@@ -1,108 +0,0 @@
@page "/admin/finance"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Tài chính — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Tổng quan tài chính</h1>
<p class="admin-topbar__subtitle">@_orders.Count đơn hàng • @_wallets.Count ví</p>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;">
<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">@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(239,68,68,0.1);"><i data-lucide="trending-down" style="color:#EF4444;"></i></div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@FormatVND(_totalExpense)</span>
<span class="admin-stat-card__label">Tổng chi phí</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="receipt" style="color:#3B82F6;"></i></div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@_orders.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(255,92,0,0.1);"><i data-lucide="wallet" style="color:#FF5C00;"></i></div>
<div class="admin-stat-card__content">
<span class="admin-stat-card__value">@FormatVND(_wallets.Sum(w => w.Balance))</span>
<span class="admin-stat-card__label">Số dư ví</span>
</div>
</div>
</div>
@if (IsLoading)
{
<div style="text-align:center;padding:48px;"><div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div></div>
}
else if (!_orders.Any())
{
<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-3" style="width:36px;height:36px;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 tài chính</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Dữ liệu sẽ tự động cập nhật khi có đơn hàng</p>
</div>
}
else
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Đơn hàng gần đây</h3></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);">ID</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số tiền</th>
<th style="padding:12px 16px;text-align:center;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</th>
</tr></thead>
<tbody>
@foreach (var o in _orders.Take(20))
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-size:12px;font-family:monospace;color:var(--admin-text-tertiary);">@o.Id.ToString()[..8]</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;">@FormatVND(o.TotalAmount)</td>
<td style="padding:12px 16px;text-align:center;"><span class="admin-status-badge admin-status-badge--online" style="font-size:11px;padding:2px 10px;"><span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>@(o.Status ?? "—")</span></td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@o.CreatedAt.ToString("dd/MM HH:mm")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@code {
private List<PosDataService.OrderInfo> _orders = new();
private List<PosDataService.WalletInfo> _wallets = new();
private decimal _totalRevenue, _totalExpense;
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try
{
_orders = await DataService.GetOrdersAsync();
_wallets = await DataService.GetWalletsAsync();
_totalRevenue = _wallets.Sum(w => w.TotalIncome);
_totalExpense = _wallets.Sum(w => w.TotalExpense);
}
catch { } finally { IsLoading = false; }
}
private static string FormatVND(decimal val) => val.ToString("N0") + "₫";
}

View File

@@ -1,86 +0,0 @@
@page "/admin/finance/revenue"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Phân tích doanh thu — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Phân tích doanh thu</h1>
<p class="admin-topbar__subtitle">@_orders.Count đơn hàng • @FormatVND(_orders.Sum(o => o.TotalAmount))</p>
</div>
<div class="admin-topbar__right">
<select class="admin-select" style="min-width:160px;" @onchange="OnShopFilterChanged">
<option value="">Tất cả cửa hàng</option>
@foreach (var s in _shops) { <option value="@s.Id">@s.Name</option> }
</select>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
@if (IsLoading)
{
<div style="text-align:center;padding:48px;"><div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div></div>
}
else if (!_orders.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(59,130,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;"><i data-lucide="bar-chart" style="width:36px;height:36px;color:#3B82F6;"></i></div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có đơn hàng</h2>
</div>
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="dollar-sign" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@FormatVND(_orders.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">@_orders.Count</span><span class="admin-stat-card__label">Số đơn hàng</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(255,92,0,0.1);"><i data-lucide="calculator" style="color:#FF5C00;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@FormatVND(_orders.Any() ? _orders.Average(o => o.TotalAmount) : 0)</span><span class="admin-stat-card__label">Trung bình/đơn</span></div></div>
</div>
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Chi tiết đơn hàng</h3></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);">ID</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số tiền</th>
<th style="padding:12px 16px;text-align:center;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</th>
</tr></thead><tbody>
@foreach (var o in _orders.Take(50))
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-size:12px;font-family:monospace;">@o.Id.ToString()[..8]</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;">@FormatVND(o.TotalAmount)</td>
<td style="padding:12px 16px;text-align:center;"><span class="admin-status-badge admin-status-badge--online" style="font-size:11px;padding:2px 10px;">@(o.Status ?? "—")</span></td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@o.CreatedAt.ToString("dd/MM HH:mm")</td>
</tr>
}
</tbody></table>
</div>
</div>
}
</div>
@code {
private List<PosDataService.OrderInfo> _orders = new();
private List<PosDataService.ShopInfo> _shops = new();
private Guid? _selectedShopId;
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try { _shops = await DataService.GetShopsAsync(); _orders = await DataService.GetOrdersAsync(); }
catch { } finally { IsLoading = false; }
}
private async Task OnShopFilterChanged(ChangeEventArgs e)
{
_selectedShopId = Guid.TryParse(e.Value?.ToString(), out var id) ? id : null;
IsLoading = true;
try { _orders = await DataService.GetOrdersAsync(_selectedShopId); }
catch { } finally { IsLoading = false; }
}
private static string FormatVND(decimal val) => val.ToString("N0") + "₫";
}

View File

@@ -1,75 +0,0 @@
@page "/admin/finance/tax"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Cấu hình thuế — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Cấu hình thuế</h1>
<p class="admin-topbar__subtitle">Quản lý thuế theo cửa hàng</p>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
@if (IsLoading)
{
<div style="text-align:center;padding:48px;"><div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div></div>
}
else
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Cửa hàng</h3></div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
@if (!_shops.Any())
{
<p style="color:var(--admin-text-tertiary);font-size:14px;text-align:center;padding:20px;">Chưa có cửa hàng</p>
}
else
{
@foreach (var shop in _shops)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-radius:8px;background:var(--admin-bg-interactive);">
<div>
<span style="font-weight:600;font-size:14px;">@shop.Name</span>
<span style="font-size:12px;color:var(--admin-text-tertiary);margin-left:8px;">@(shop.Category ?? "")</span>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:14px;color:var(--admin-text-tertiary);">VAT: 10%</span>
<i data-lucide="chevron-right" style="width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
</div>
</div>
}
}
</div>
</div>
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Loại thuế</h3></div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
@foreach (var tax in new[] { ("VAT", "10%", "Thuế giá trị gia tăng"), ("TNCN", "Tự động", "Thuế thu nhập cá nhân"), ("Phí dịch vụ", "5%", "Service charge") })
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-radius:8px;background:var(--admin-bg-interactive);">
<div>
<span style="font-weight:600;font-size:14px;">@tax.Item1</span>
<span style="font-size:12px;color:var(--admin-text-tertiary);margin-left:8px;">@tax.Item3</span>
</div>
<span style="font-weight:600;color:var(--admin-orange-primary);">@tax.Item2</span>
</div>
}
</div>
</div>
}
</div>
@code {
private List<PosDataService.ShopInfo> _shops = new();
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try { _shops = await DataService.GetShopsAsync(); }
catch { } finally { IsLoading = false; }
}
}

View File

@@ -1,79 +0,0 @@
@page "/admin/inventory/purchase-orders"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Đơn nhập hàng — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Đơn nhập hàng</h1>
<p class="admin-topbar__subtitle">@_txns.Count giao dịch nhập kho</p>
</div>
<div class="admin-topbar__right">
<select class="admin-select" style="min-width:160px;" @onchange="OnShopFilterChanged">
<option value="">Tất cả cửa hàng</option>
@foreach (var s in _shops) { <option value="@s.Id">@s.Name</option> }
</select>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
@if (IsLoading)
{
<div style="text-align:center;padding:48px;"><div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div></div>
}
else if (!_txns.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(59,130,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;"><i data-lucide="truck" style="width:36px;height:36px;color:#3B82F6;"></i></div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có đơn nhập hàng</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Tạo đơn nhập hàng để quản lý tồn kho</p>
</div>
}
else
{
<div class="admin-panel">
<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);">Loại</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số lượng</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ghi chú</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày</th>
</tr></thead><tbody>
@foreach (var t in _txns)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@(t.TransactionType ?? "—")</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:@(t.QuantityChange > 0 ? "#22C55E" : "#EF4444");">@(t.QuantityChange > 0 ? "+" : "")@t.QuantityChange</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@(t.Reason ?? "—")</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@t.CreatedAt.ToString("dd/MM HH:mm")</td>
</tr>
}
</tbody></table>
</div>
</div>
}
</div>
@code {
private List<PosDataService.InventoryTxnInfo> _txns = new();
private List<PosDataService.ShopInfo> _shops = new();
private Guid? _selectedShopId;
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try { _shops = await DataService.GetShopsAsync(); _txns = await DataService.GetInventoryTransactionsAsync(); }
catch { } finally { IsLoading = false; }
}
private async Task OnShopFilterChanged(ChangeEventArgs e)
{
_selectedShopId = Guid.TryParse(e.Value?.ToString(), out var id) ? id : null;
IsLoading = true;
try { _txns = await DataService.GetInventoryTransactionsAsync(_selectedShopId); }
catch { } finally { IsLoading = false; }
}
}

View File

@@ -1,49 +0,0 @@
@page "/admin/inventory/transfers"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Chuyển kho — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Chuyển kho</h1>
<p class="admin-topbar__subtitle">Quản lý chuyển hàng giữa các cửa hàng</p>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
@if (IsLoading)
{
<div style="text-align:center;padding:48px;"><div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div></div>
}
else
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(139,92,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;"><i data-lucide="arrow-right-left" style="width:36px;height:36px;color:#8B5CF6;"></i></div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chuyển kho</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 20px;">Tạo yêu cầu chuyển hàng giữa các cửa hàng</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;max-width:400px;margin:0 auto;">
@foreach (var shop in _shops)
{
<div style="padding:16px;border-radius:12px;background:var(--admin-bg-interactive);text-align:center;">
<div style="font-weight:600;font-size:14px;margin-bottom:4px;">@shop.Name</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">@(shop.Category ?? "—")</div>
</div>
}
</div>
</div>
}
</div>
@code {
private List<PosDataService.ShopInfo> _shops = new();
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try { _shops = await DataService.GetShopsAsync(); }
catch { } finally { IsLoading = false; }
}
}

View File

@@ -1,26 +0,0 @@
@page "/admin/inventory/suppliers"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Nhà cung cấp — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Nhà cung cấp</h1>
<p class="admin-topbar__subtitle">Quản lý nhà cung cấp</p>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(59,130,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;"><i data-lucide="building-2" style="width:36px;height:36px;color:#3B82F6;"></i></div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Quản lý nhà cung cấp</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 16px;">Tính năng đang phát triển — sẽ sớm ra mắt</p>
<span class="admin-status-badge admin-status-badge--warning" style="font-size:12px;padding:4px 12px;"><span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>Coming Soon</span>
</div>
</div>
@code {
}

View File

@@ -1,21 +0,0 @@
@page "/admin/products/modifiers"
@layout AdminLayout
@inherits AdminBase
<PageTitle>Nhóm tùy chọn — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Nhóm tùy chỉnh</h1>
<p class="admin-topbar__subtitle">Quản lý topping, size, đường/đá</p>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(139,92,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;"><i data-lucide="sliders-horizontal" style="width:36px;height:36px;color:#8B5CF6;"></i></div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Nhóm tùy chỉnh</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 16px;">Tính năng đang phát triển — sẽ sớm ra mắt</p>
<span class="admin-status-badge admin-status-badge--warning" style="font-size:12px;padding:4px 12px;"><span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>Coming Soon</span>
</div>
</div>

View File

@@ -1,71 +0,0 @@
@page "/admin/products/pricing"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Chiến lược giá — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Chiến lược giá & Khuyến mãi</h1>
<p class="admin-topbar__subtitle">@_promos.Count chương trình</p>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="tag" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_promos.Count(p => p.IsActive)</span><span class="admin-stat-card__label">Đang hoạt động</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="ticket" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_promos.Sum(p => p.VoucherCount)</span><span class="admin-stat-card__label">Tổng voucher</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(255,92,0,0.1);"><i data-lucide="check-circle" style="color:#FF5C00;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_promos.Sum(p => p.RedemptionCount)</span><span class="admin-stat-card__label">Đã sử dụng</span></div></div>
</div>
@if (IsLoading)
{
<div style="text-align:center;padding:48px;"><div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div></div>
}
else if (!_promos.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(255,92,0,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;"><i data-lucide="percent" style="width:36px;height:36px;color:#FF5C00;"></i></div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có chương trình khuyến mãi</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Tạo chiến dịch mới để thu hút khách hàng</p>
</div>
}
else
{
<div class="admin-panel">
<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);">Tên</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Giảm giá</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Voucher</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Đã dùng</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Trạng thái</th>
</tr></thead><tbody>
@foreach (var p in _promos)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;"><div style="font-weight:600;font-size:14px;">@p.Name</div><div style="font-size:12px;color:var(--admin-text-tertiary);">@(p.Description ?? "")</div></td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@(p.DiscountType == "Percentage" ? $"{p.DiscountValue}%" : $"{p.DiscountValue?.ToString("N0")}₫")</td>
<td style="padding:12px 16px;text-align:right;">@p.VoucherCount</td>
<td style="padding:12px 16px;text-align:right;">@p.RedemptionCount</td>
<td style="padding:12px 16px;text-align:center;"><span class="admin-status-badge @(p.IsActive ? "admin-status-badge--online" : "admin-status-badge--offline")" style="font-size:11px;padding:2px 10px;"><span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>@(p.IsActive ? "Active" : "Inactive")</span></td>
</tr>
}
</tbody></table>
</div>
</div>
}
</div>
@code {
private List<PosDataService.PromotionInfo> _promos = new();
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try { _promos = await DataService.GetPromotionsAsync(); }
catch { } finally { IsLoading = false; }
}
}

View File

@@ -27,10 +27,10 @@
@GetStatusLabel(_shop.Status)
</div>
}
<button class="admin-btn-primary">
<a href="/pos" class="admin-btn-primary" style="text-decoration:none;">
<i data-lucide="monitor"></i>
<span>Mở POS</span>
</button>
</a>
</div>
</div>
@@ -62,7 +62,7 @@
<div class="admin-panel">
<div class="admin-panel__body" style="display:flex;gap:20px;align-items:center;">
<div class="admin-store-card__avatar" style="width:64px;height:64px;background-color:rgba(255,92,0,0.125);border-radius:16px;">
<i data-lucide="@GetShopIcon(_shop.Category)" style="color:var(--admin-orange-primary);width:28px;height:28px;"></i>
<i data-lucide="@ShopVerticalHelper.GetIcon(_shop.Category)" style="color:var(--admin-orange-primary);width:28px;height:28px;"></i>
</div>
<div style="flex:1;">
<div style="font-size:18px;font-weight:700;">@_shop.Name</div>
@@ -97,7 +97,7 @@
<i data-lucide="trending-up" style="color:#22C55E;"></i>
</div>
</div>
<div class="admin-kpi-card__value">--</div>
<div class="admin-kpi-card__value">@FormatVND(_orders.Sum(o => o.TotalAmount))</div>
<div class="admin-kpi-card__label">Doanh thu tháng</div>
</div>
<div class="admin-kpi-card">
@@ -106,7 +106,7 @@
<i data-lucide="shopping-bag" style="color:#3B82F6;"></i>
</div>
</div>
<div class="admin-kpi-card__value">--</div>
<div class="admin-kpi-card__value">@_orders.Count</div>
<div class="admin-kpi-card__label">Đơn hàng tháng</div>
</div>
<div class="admin-kpi-card">
@@ -115,17 +115,17 @@
<i data-lucide="banknote" style="color:#8B5CF6;"></i>
</div>
</div>
<div class="admin-kpi-card__value">--</div>
<div class="admin-kpi-card__value">@FormatVND(_orders.Any() ? _orders.Average(o => o.TotalAmount) : 0)</div>
<div class="admin-kpi-card__label">Giá trị TB / đơn</div>
</div>
<div class="admin-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(236,72,153,0.125);">
<i data-lucide="star" style="color:#EC4899;"></i>
<i data-lucide="package" style="color:#EC4899;"></i>
</div>
</div>
<div class="admin-kpi-card__value">--</div>
<div class="admin-kpi-card__label">Đánh giá TB</div>
<div class="admin-kpi-card__value">@_products.Count</div>
<div class="admin-kpi-card__label">Sản phẩm</div>
</div>
</div>
@@ -199,6 +199,8 @@
[Parameter] public string ShopId { get; set; } = "";
private PosDataService.ShopInfo? _shop;
private List<PosDataService.OrderInfo> _orders = new();
private List<PosDataService.AdminProductInfo> _products = new();
// EN: Cascade layout reference to set shop context for sidebar switching.
// VI: Cascade layout để set shop context cho sidebar chuyển đổi.
@@ -216,12 +218,23 @@
{
Layout?.SetShopContext(ShopId, _shop.Name ?? "Cửa hàng", _shop.Category);
}
// EN: Load KPI data in parallel / VI: Tải dữ liệu KPI song song
var ordersTask = DataService.GetOrdersAsync(id);
var productsTask = DataService.GetAllProductsAsync(id);
_orders = await ordersTask;
_products = await productsTask;
}
}
catch { _shop = null; }
catch (Exception ex)
{
_shop = null;
Console.Error.WriteLine($"[ShopOverview] Error loading shop {ShopId}: {ex}");
}
finally { IsLoading = false; }
}
private static string FormatVND(decimal val) => val.ToString("N0") + " ₫";
private static string GetStatusBadgeClass(string? status) => status?.ToLowerInvariant() switch
{
"published" or "active" => "online",
@@ -239,13 +252,4 @@
_ => 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"
};
}

View File

@@ -22,7 +22,23 @@
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:24px;">
@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;max-width:400px;margin-left:auto;margin-right:auto;">@_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>
@@ -33,22 +49,12 @@
{
@switch (_section)
{
// ═══ OVERVIEW ═══
case "overview":
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="package" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_products.Count</span><span class="admin-stat-card__label">Sản phẩm</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="warehouse" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_inventory.Count</span><span class="admin-stat-card__label">Tồn kho</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(255,92,0,0.1);"><i data-lucide="receipt" style="color:#FF5C00;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_orders.Count</span><span class="admin-stat-card__label">Đơ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="users" style="color:#8B5CF6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staff.Count</span><span class="admin-stat-card__label">Nhân viên</span></div></div>
</div>
break;
// ═══ MENU / PRODUCTS ═══
case "menu":
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")
@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")
}
else
{
@@ -72,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")
@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")
}
else
{
@@ -111,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")
@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")
}
else
{
@@ -143,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")
@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")
}
else
{
@@ -178,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")
@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")
}
else
{
@@ -240,6 +246,7 @@
private string _sectionTitle = "";
private string _sectionIcon = "layout-dashboard";
private string _sectionDescription = "";
private string? _errorMessage;
private Guid? _shopGuid;
// ═══ DATA ═══
@@ -261,6 +268,7 @@
private async Task LoadData()
{
IsLoading = true;
_errorMessage = null;
_section = Section?.ToLowerInvariant() ?? "";
ConfigureSection();
@@ -281,12 +289,6 @@
// EN: Load only data needed for current section / VI: Chỉ tải data cần cho section hiện tại
switch (_section)
{
case "overview":
_products = await DataService.GetAllProductsAsync(_shopGuid);
_inventory = await DataService.GetInventoryAsync(_shopGuid);
_orders = await DataService.GetOrdersAsync(_shopGuid);
_staff = await DataService.GetStaffAsync();
break;
case "menu":
case "products":
_products = await DataService.GetAllProductsAsync(_shopGuid);
@@ -305,7 +307,11 @@
break;
}
}
catch { }
catch (Exception ex)
{
_errorMessage = $"Không thể tải dữ liệu: {ex.Message}";
Console.Error.WriteLine($"[ShopPage] Error loading {_section}: {ex}");
}
finally { IsLoading = false; }
}
@@ -331,17 +337,24 @@
}
}
private static string FormatVND(decimal val) => val.ToString("N0") + "₫";
private static string FormatVND(decimal val) => val.ToString("N0") + " ₫";
// EN: Reusable empty state renderer / VI: Renderer trạng thái trống tái sử dụng
private RenderFragment RenderEmpty(string icon, string color, string title, string desc) => __builder =>
// 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 =>
{
<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;">
<i data-lucide="@icon" style="width:36px;height:36px;color:@color;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">@title</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">@desc</p>
<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;">
<i data-lucide="@ctaIcon" style="width:16px;height:16px;"></i>
@ctaLabel
</a>
}
</div>
};

View File

@@ -1,83 +0,0 @@
@page "/admin/staff/attendance"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Chấm công — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Chấm công</h1>
<p class="admin-topbar__subtitle">Theo dõi giờ làm việc nhân viên</p>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="user-check" style="color:#22C55E;"></i></div>
<div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staff.Count(s => s.Status == "Active")</span><span class="admin-stat-card__label">Đang hoạt động</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="users" style="color:#3B82F6;"></i></div>
<div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staff.Count</span><span class="admin-stat-card__label">Tổng nhân viên</span></div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-card__icon" style="background:rgba(245,158,11,0.1);"><i data-lucide="clock" style="color:#F59E0B;"></i></div>
<div class="admin-stat-card__content"><span class="admin-stat-card__value">@_schedules.Count</span><span class="admin-stat-card__label">Tổng ca hôm nay</span></div>
</div>
</div>
@if (IsLoading)
{
<div style="text-align:center;padding:48px;"><div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div></div>
}
else
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Nhân viên hôm nay</h3></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);">Nhân viên</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Vai trò</th>
<th style="padding:12px 16px;text-align:center;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);">Cửa hàng</th>
</tr></thead>
<tbody>
@foreach (var s in _staff)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@(s.EmployeeCode ?? s.Id.ToString()[..6])</td>
<td style="padding:12px 16px;">@(s.Role ?? "—")</td>
<td style="padding:12px 16px;text-align:center;">
<span class="admin-status-badge @(s.Status == "Active" ? "admin-status-badge--online" : "admin-status-badge--offline")" style="font-size:11px;padding:2px 10px;">
<span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>@(s.Status ?? "—")
</span>
</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@(s.ShopName ?? "—")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@code {
private List<PosDataService.StaffInfo> _staff = new();
private List<PosDataService.ScheduleInfo> _schedules = new();
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try
{
_staff = await DataService.GetStaffAsync();
_schedules = await DataService.GetStaffSchedulesAsync();
}
catch { } finally { IsLoading = false; }
}
}

View File

@@ -1,73 +0,0 @@
@page "/admin/staff/payroll"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Bảng lương — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Bảng lương</h1>
<p class="admin-topbar__subtitle">@_staff.Count nhân viên • @DateTime.Now.ToString("MM/yyyy")</p>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
@if (IsLoading)
{
<div style="text-align:center;padding:48px;"><div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div></div>
}
else if (!_staff.Any())
{
<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="banknote" style="width:36px;height:36px;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 lương</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Thêm nhân viên để quản lý bảng lương</p>
</div>
}
else
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Danh sách nhân viên</h3></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ã NV</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Vai trò</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày vào</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Trạng thái</th>
</tr></thead>
<tbody>
@foreach (var s in _staff)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@(s.EmployeeCode ?? "—")</td>
<td style="padding:12px 16px;">@(s.Role ?? "—")</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@(s.JoinedAt?.ToString("dd/MM/yyyy") ?? "—")</td>
<td style="padding:12px 16px;text-align:center;">
<span class="admin-status-badge @(s.Status == "Active" ? "admin-status-badge--online" : "admin-status-badge--offline")" style="font-size:11px;padding:2px 10px;">
<span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>@(s.Status ?? "—")
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@code {
private List<PosDataService.StaffInfo> _staff = new();
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try { _staff = await DataService.GetStaffAsync(); }
catch { } finally { IsLoading = false; }
}
}

View File

@@ -1,84 +0,0 @@
@page "/admin/staff/schedule"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Lịch làm việc — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Lịch làm việc</h1>
<p class="admin-topbar__subtitle">@_schedules.Count ca làm việc</p>
</div>
<div class="admin-topbar__right">
<select class="admin-select" style="min-width:160px;" @onchange="OnShopFilterChanged">
<option value="">Tất cả cửa hàng</option>
@foreach (var shop in _shops) { <option value="@shop.Id">@shop.Name</option> }
</select>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
@if (IsLoading)
{
<div style="text-align:center;padding:48px;"><div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div>
<p style="color:var(--admin-text-tertiary);font-size:14px;">Đang tải lịch...</p></div>
}
else if (!_schedules.Any())
{
<div style="text-align:center;padding:60px 20px;">
<div style="width:80px;height:80px;border-radius:24px;background:rgba(59,130,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="calendar" style="width:36px;height:36px;color:#3B82F6;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFF);">Chưa có lịch làm việc</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Thêm lịch cho nhân viên để quản lý ca làm việc</p>
</div>
}
else
{
@foreach (var dayGroup in _schedules.GroupBy(s => s.DayOfWeek).OrderBy(g => g.Key))
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">@DayName(dayGroup.Key)</h3></div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:8px;">
@foreach (var s in dayGroup)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-radius:8px;background:var(--admin-bg-interactive);">
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-weight:600;font-size:14px;">@(s.EmployeeCode ?? s.StaffId.ToString()[..6])</span>
<span style="font-size:12px;color:var(--admin-text-tertiary);">@(s.Role ?? "")</span>
</div>
<span style="font-size:14px;font-weight:600;color:var(--admin-orange-primary);">@s.StartTime — @s.EndTime</span>
</div>
}
</div>
</div>
}
}
</div>
@code {
private List<PosDataService.ScheduleInfo> _schedules = new();
private List<PosDataService.ShopInfo> _shops = new();
private Guid? _selectedShopId;
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try { _shops = await DataService.GetShopsAsync(); _schedules = await DataService.GetStaffSchedulesAsync(); }
catch { } finally { IsLoading = false; }
}
private async Task OnShopFilterChanged(ChangeEventArgs e)
{
_selectedShopId = Guid.TryParse(e.Value?.ToString(), out var id) ? id : null;
IsLoading = true;
try { _schedules = await DataService.GetStaffSchedulesAsync(_selectedShopId); }
catch { } finally { IsLoading = false; }
}
private static string DayName(int day) => day switch {
0 => "Chủ nhật", 1 => "Thứ 2", 2 => "Thứ 3", 3 => "Thứ 4", 4 => "Thứ 5", 5 => "Thứ 6", 6 => "Thứ 7", _ => $"Ngày {day}"
};
}

View File

@@ -1,42 +0,0 @@
@page "/admin/system/notifications"
@layout AdminLayout
@inherits AdminBase
<PageTitle>Thông báo — GoodGo Admin</PageTitle>
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Trung tâm thông báo</h1>
<p class="admin-topbar__subtitle">Quản lý thông báo đẩy</p>
</div>
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Mẫu thông báo</h3></div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
@foreach (var tmpl in _templates)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-radius:8px;background:var(--admin-bg-interactive);">
<div>
<div style="font-weight:600;font-size:14px;">@tmpl.name</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-top:2px;">@tmpl.desc</div>
</div>
<span class="admin-status-badge @(tmpl.active ? "admin-status-badge--online" : "admin-status-badge--offline")" style="font-size:11px;padding:2px 10px;"><span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>@(tmpl.active ? "Bật" : "Tắt")</span>
</div>
}
</div>
</div>
</div>
@code {
private readonly (string name, string desc, bool active)[] _templates = new[]
{
("Đơn hàng mới", "Thông báo khi có đơn hàng mới", true),
("Hết hàng", "Cảnh báo khi sản phẩm hết hàng", true),
("Khuyến mãi", "Thông báo chương trình khuyến mãi", false),
("Đặt lịch", "Nhắc nhở lịch hẹn", true),
("Chấm công", "Thông báo chấm công", false),
("Hệ thống", "Thông báo bảo trì hệ thống", true),
};
}

View File

@@ -24,7 +24,7 @@ public static class ShopSidebarConfig
/// </summary>
public static List<MenuItem> GetMenuItems(string? category)
{
var vertical = NormalizeVertical(category);
var vertical = ShopVerticalHelper.NormalizeVertical(category);
return vertical switch
{
@@ -91,41 +91,14 @@ public static class ShopSidebarConfig
}
/// <summary>
/// EN: Normalize category string to internal vertical key.
/// VI: Chuẩn hóa chuỗi category thành key nội bộ.
/// EN: Get vertical display name (delegates to ShopVerticalHelper).
/// VI: Lấy tên hiển thị của ngành hàng (ủy quyền cho ShopVerticalHelper).
/// </summary>
private static string NormalizeVertical(string? category) => (category ?? "").ToLowerInvariant() switch
{
"cafe" or "café" or "coffee" or "foodbeverage" => "cafe",
"restaurant" or "nhà hàng" or "bar" => "restaurant",
"karaoke" or "entertainment" => "karaoke",
"spa" or "beauty" or "salon" => "spa",
_ => "default"
};
public static string GetVerticalLabel(string? category) => ShopVerticalHelper.GetLabel(category);
/// <summary>
/// EN: Get vertical display name.
/// VI: Lấy tên hiển thị của ngành hàng.
/// EN: Get vertical icon (delegates to ShopVerticalHelper).
/// VI: Lấy icon ngành hàng (ủy quyền cho ShopVerticalHelper).
/// </summary>
public static string GetVerticalLabel(string? category) => NormalizeVertical(category) switch
{
"cafe" => "Café",
"restaurant" => "Nhà hàng / Bar",
"karaoke" => "Karaoke",
"spa" => "Spa / Thẩm mỹ",
_ => "Cửa hàng"
};
/// <summary>
/// EN: Get vertical icon.
/// VI: Lấy icon ngành hàng.
/// </summary>
public static string GetVerticalIcon(string? category) => NormalizeVertical(category) switch
{
"cafe" => "coffee",
"restaurant" => "utensils",
"karaoke" => "mic",
"spa" => "sparkles",
_ => "store"
};
public static string GetVerticalIcon(string? category) => ShopVerticalHelper.GetIcon(category);
}

View File

@@ -0,0 +1,57 @@
// EN: Centralized helper for shop vertical/category utilities.
// VI: Helper tập trung cho các tiện ích ngành hàng cửa hàng.
//
// Consolidates duplicate GetShopIcon, GetVerticalLabel, NormalizeVertical
// logic that was scattered across Dashboard.razor, ShopOverview.razor,
// and ShopSidebarConfig.cs.
namespace WebClientTpos.Client.Services;
/// <summary>
/// EN: Centralized utilities for shop vertical display — icon, label, normalize.
/// VI: Tiện ích tập trung cho hiển thị ngành hàng — icon, label, chuẩn hóa.
/// </summary>
public static class ShopVerticalHelper
{
/// <summary>
/// EN: Normalize category string to internal vertical key.
/// VI: Chuẩn hóa chuỗi category thành key nội bộ.
/// </summary>
public static string NormalizeVertical(string? category) => (category ?? "").ToLowerInvariant() switch
{
"cafe" or "café" or "coffee" or "foodbeverage" => "cafe",
"restaurant" or "nhà hàng" or "bar" => "restaurant",
"karaoke" or "entertainment" => "karaoke",
"spa" or "beauty" or "salon" => "spa",
"retail" => "retail",
_ => "default"
};
/// <summary>
/// EN: Get Lucide icon name for a shop vertical/category.
/// VI: Lấy tên icon Lucide cho ngành hàng.
/// </summary>
public static string GetIcon(string? category) => NormalizeVertical(category) switch
{
"cafe" => "coffee",
"restaurant" => "utensils",
"karaoke" => "mic",
"spa" => "sparkles",
"retail" => "shopping-bag",
_ => "store"
};
/// <summary>
/// EN: Get display label for a vertical.
/// VI: Lấy tên hiển thị cho ngành hàng.
/// </summary>
public static string GetLabel(string? category) => NormalizeVertical(category) switch
{
"cafe" => "Café",
"restaurant" => "Nhà hàng / Bar",
"karaoke" => "Karaoke",
"spa" => "Spa / Thẩm mỹ",
"retail" => "Bán lẻ",
_ => "Cửa hàng"
};
}

View File

@@ -1003,6 +1003,20 @@ a.admin-store-card:hover {
left: 0;
}
/* EN: Mobile sidebar overlay / VI: Overlay sidebar trên mobile */
.admin-sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 199;
backdrop-filter: blur(2px);
}
/* EN: Mobile hamburger button / VI: Nút hamburger trên mobile */
.admin-topbar__menu-btn {
display: flex !important;
}
.admin-topbar {
padding: 12px 20px;
}
@@ -1018,6 +1032,18 @@ a.admin-store-card:hover {
.admin-kpi-card {
min-width: calc(50% - 10px);
}
/* EN: Stat card grid responsive / VI: Lưới stat card responsive */
.admin-stat-card {
min-width: calc(50% - 8px);
}
/* EN: Tables scroll horizontally / VI: Bảng scroll ngang */
.admin-table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
@media (max-width: 640px) {
@@ -1025,6 +1051,10 @@ a.admin-store-card:hover {
min-width: 100%;
}
.admin-stat-card {
min-width: 100%;
}
.admin-content {
padding: 16px;
}
@@ -1035,6 +1065,10 @@ a.admin-store-card:hover {
gap: 12px;
}
.admin-topbar__title {
font-size: 18px;
}
.admin-search {
width: 100%;
}
@@ -1046,6 +1080,11 @@ a.admin-store-card:hover {
.admin-store-stat {
min-width: calc(50% - 8px);
}
.admin-btn-primary {
width: 100%;
justify-content: center;
}
}
/* ═════════════════════════════════════════════════════════════════════════