feat(web-client-tpos): add shop-level pages and per-vertical sidebar switching

This commit is contained in:
Ho Ngoc Hai
2026-02-28 05:24:07 +07:00
parent fa0efbd669
commit 51cc8b249c
4 changed files with 468 additions and 2 deletions

View File

@@ -153,7 +153,9 @@
@* ═══ MAIN AREA ═══ *@
<main class="admin-main">
@Body
<CascadingValue Value="this">
@Body
</CascadingValue>
</main>
</div>

View File

@@ -0,0 +1,251 @@
@page "/admin/shop/{ShopId}/overview"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Shop overview — KPIs and info when inside a shop context.
Triggers AdminLayout._isShopContext → per-vertical sidebar.
VI: Tổng quan cửa hàng — KPI khi đang trong context cửa hàng.
Kích hoạt AdminLayout._isShopContext → sidebar theo ngành hàng.
*@
<PageTitle>@(_shop?.Name ?? "Cửa hàng") — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Tổng quan</h1>
<p class="admin-topbar__subtitle">@(_shop?.Name ?? "Đang tải...") • @(_shop?.Category ?? "—")</p>
</div>
<div class="admin-topbar__right">
@if (_shop != null)
{
<div class="admin-status-badge admin-status-badge--@GetStatusBadgeClass(_shop.Status)">
<span class="admin-status-badge__dot"></span>
@GetStatusLabel(_shop.Status)
</div>
}
<button class="admin-btn-primary">
<i data-lucide="monitor"></i>
<span>Mở POS</span>
</button>
</div>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:24px;">
@if (IsLoading)
{
<div style="text-align:center;padding:48px 20px;">
<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 thông tin cửa hàng...</p>
</div>
}
else if (_shop == null)
{
<div style="text-align:center;padding:48px 20px;">
<i data-lucide="alert-circle" style="width:48px;height:48px;color:#EF4444;margin-bottom:16px;"></i>
<h3 style="font-size:18px;font-weight:700;color:var(--pos-text-primary, #FFFFFF);margin:0 0 8px;">Không tìm thấy cửa hàng</h3>
<p style="font-size:14px;color:var(--pos-text-tertiary, #ADADB0);margin:0 0 20px;">Cửa hàng không tồn tại hoặc đã bị xóa.</p>
<a href="/admin/stores" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;">
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
Quay lại danh sách
</a>
</div>
}
else
{
@* ── Store Info Card ── *@
<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>
</div>
<div style="flex:1;">
<div style="font-size:18px;font-weight:700;">@_shop.Name</div>
<div style="font-size:13px;color:var(--admin-text-tertiary);">@_shop.Slug • @ShopSidebarConfig.GetVerticalLabel(_shop.Category)</div>
@if (!string.IsNullOrEmpty(_shop.Description))
{
<div style="font-size:13px;color:var(--admin-text-tertiary);margin-top:4px;">@_shop.Description</div>
}
</div>
<div style="display:flex;flex-direction:column;gap:4px;align-items:flex-end;">
@if (!string.IsNullOrEmpty(_shop.Phone))
{
<div style="font-size:13px;color:var(--admin-text-tertiary);display:flex;align-items:center;gap:6px;">
<i data-lucide="phone" style="width:14px;height:14px;"></i> @_shop.Phone
</div>
}
@if (!string.IsNullOrEmpty(_shop.Email))
{
<div style="font-size:13px;color:var(--admin-text-tertiary);display:flex;align-items:center;gap:6px;">
<i data-lucide="mail" style="width:14px;height:14px;"></i> @_shop.Email
</div>
}
</div>
</div>
</div>
@* ── KPI ROW ── *@
<div class="admin-kpi-row">
<div class="admin-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(34,197,94,0.125);">
<i data-lucide="trending-up" style="color:#22C55E;"></i>
</div>
</div>
<div class="admin-kpi-card__value">--</div>
<div class="admin-kpi-card__label">Doanh thu tháng</div>
</div>
<div class="admin-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(59,130,246,0.125);">
<i data-lucide="shopping-bag" style="color:#3B82F6;"></i>
</div>
</div>
<div class="admin-kpi-card__value">--</div>
<div class="admin-kpi-card__label">Đơn hàng tháng</div>
</div>
<div class="admin-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(139,92,246,0.125);">
<i data-lucide="banknote" style="color:#8B5CF6;"></i>
</div>
</div>
<div class="admin-kpi-card__value">--</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>
</div>
</div>
<div class="admin-kpi-card__value">--</div>
<div class="admin-kpi-card__label">Đánh giá TB</div>
</div>
</div>
@* ── BOTTOM: Chart + Right Column ── *@
<div style="display:flex;gap:24px;flex:1;min-height:0;">
@* LEFT: Revenue Chart *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="bar-chart-2" style="color:var(--admin-orange-primary);"></i>
Doanh thu 7 ngày gần nhất
</h3>
<div style="display:flex;gap:8px;">
<button class="admin-tab admin-tab--active" style="padding:6px 12px;font-size:12px;">7 ngày</button>
<button class="admin-tab" style="padding:6px 12px;font-size:12px;">30 ngày</button>
</div>
</div>
<div class="admin-panel__body" style="display:flex;align-items:center;justify-content:center;min-height:200px;">
<div style="text-align:center;color:var(--admin-text-tertiary);">
<i data-lucide="bar-chart-2" style="width:40px;height:40px;margin-bottom:12px;opacity:0.5;"></i>
<p style="font-size:14px;margin:0;">Chưa có dữ liệu doanh thu</p>
<p style="font-size:12px;margin:4px 0 0;">Dữ liệu sẽ hiển thị khi có đơn hàng</p>
</div>
</div>
</div>
@* RIGHT COLUMN *@
<div style="width:380px;display:flex;flex-direction:column;gap:20px;">
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="shopping-bag" style="color:#3B82F6;"></i>
Đơn gần nhất
</h3>
</div>
<div class="admin-panel__body" style="text-align:center;padding:20px;color:var(--admin-text-tertiary);font-size:14px;">
<i data-lucide="inbox" style="width:32px;height:32px;margin-bottom:8px;opacity:0.5;"></i>
<p style="margin:0;">Chưa có đơn hàng nào</p>
</div>
</div>
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="info" style="color:#22C55E;"></i>
Thông tin
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="color:var(--admin-text-tertiary);font-size:14px;">Trạng thái</span>
<span style="font-size:13px;font-weight:600;">@GetStatusLabel(_shop.Status)</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="color:var(--admin-text-tertiary);font-size:14px;">Ngành hàng</span>
<span style="font-size:13px;font-weight:600;">@ShopSidebarConfig.GetVerticalLabel(_shop.Category)</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="color:var(--admin-text-tertiary);font-size:14px;">Slug</span>
<span style="font-size:13px;font-weight:600;">@(_shop.Slug)</span>
</div>
</div>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public string ShopId { get; set; } = "";
private PosDataService.ShopInfo? _shop;
// EN: Cascade layout reference to set shop context for sidebar switching.
// VI: Cascade layout để set shop context cho sidebar chuyển đổi.
[CascadingParameter] public AdminLayout? Layout { get; set; }
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try
{
if (Guid.TryParse(ShopId, out var id))
{
_shop = await DataService.GetShopByIdAsync(id);
if (_shop != null)
{
Layout?.SetShopContext(ShopId, _shop.Name ?? "Cửa hàng", _shop.Category);
}
}
}
catch { _shop = null; }
finally { IsLoading = false; }
}
private static string GetStatusBadgeClass(string? status) => status?.ToLowerInvariant() switch
{
"published" or "active" => "online",
"draft" or "setup" => "setup",
"inactive" or "paused" => "paused",
_ => "setup"
};
private static string GetStatusLabel(string? status) => status?.ToLowerInvariant() switch
{
"published" or "active" => "Đang mở",
"draft" or "setup" => "Thiết lập",
"inactive" or "paused" => "Tạm dừng",
"closed" => "Đã đóng",
_ => 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

@@ -0,0 +1,213 @@
@page "/admin/shop/{ShopId}/{Section}"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Catch-all for shop sub-pages (menu, inventory, staff, customers, etc).
Each section either shows real content or a "coming soon" placeholder.
VI: Catch-all cho các trang con cửa hàng (menu, kho, nhân sự, khách hàng...).
Mỗi section hiển thị nội dung thật hoặc placeholder "sắp ra mắt".
*@
<PageTitle>@_sectionTitle — @(_shopName ?? "Cửa hàng") — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">@_sectionTitle</h1>
<p class="admin-topbar__subtitle">@(_shopName ?? "Cửa hàng") • @_verticalLabel</p>
</div>
<div class="admin-topbar__right">
@if (_sectionActions.Count > 0)
{
@foreach (var act in _sectionActions)
{
<button class="admin-btn-primary">
<i data-lucide="@act.Icon"></i>
<span>@act.Label</span>
</button>
}
}
</div>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:24px;">
@if (IsLoading)
{
<div style="text-align:center;padding:48px 20px;">
<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...</p>
</div>
}
else
{
@* ── Section-specific content placeholder ── *@
<div class="admin-panel">
<div class="admin-panel__body" 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="@_sectionIcon" style="width:36px;height:36px;color:var(--admin-orange-primary);"></i>
</div>
<h2 style="font-size:22px;font-weight:700;margin:0 0 8px;color:var(--pos-text-primary, #FFFFFF);">@_sectionTitle</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 24px;max-width:400px;margin-left:auto;margin-right:auto;">
@_sectionDescription
</p>
@if (_hasQuickStats)
{
<div style="display:flex;gap:16px;justify-content:center;margin-bottom:24px;">
@foreach (var stat in _quickStats)
{
<div style="background:var(--admin-surface);border:1px solid var(--admin-border, #1F1F23);border-radius:12px;padding:16px 24px;min-width:120px;">
<div style="font-size:24px;font-weight:700;color:var(--admin-orange-primary);">@stat.Value</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-top:4px;">@stat.Label</div>
</div>
}
</div>
}
<p style="font-size:13px;color:var(--admin-text-quaternary, #6B6B6F);margin:0;">
Tính năng này sẽ được kích hoạt khi có dữ liệu từ hệ thống
</p>
</div>
</div>
}
</div>
@code {
[Parameter] public string ShopId { get; set; } = "";
[Parameter] public string Section { get; set; } = "";
[CascadingParameter] public AdminLayout? Layout { get; set; }
private string _shopName = "";
private string _verticalLabel = "";
private string _sectionTitle = "";
private string _sectionIcon = "layout-dashboard";
private string _sectionDescription = "";
private bool _hasQuickStats = false;
private List<(string Value, string Label)> _quickStats = new();
private List<(string Icon, string Label)> _sectionActions = new();
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try
{
if (Guid.TryParse(ShopId, out var id))
{
var shop = await DataService.GetShopByIdAsync(id);
if (shop != null)
{
_shopName = shop.Name ?? "Cửa hàng";
_verticalLabel = ShopSidebarConfig.GetVerticalLabel(shop.Category);
Layout?.SetShopContext(ShopId, _shopName, shop.Category);
}
}
ConfigureSection();
}
catch { }
finally { IsLoading = false; }
}
protected override void OnParametersSet()
{
ConfigureSection();
}
/// <summary>
/// EN: Configure section-specific title, icon, description, and quick stats.
/// VI: Cấu hình tiêu đề, icon, mô tả, thống kê nhanh theo section.
/// </summary>
private void ConfigureSection()
{
var sec = Section?.ToLowerInvariant() ?? "";
// EN: Reset defaults
// VI: Đặt lại giá trị mặc định
_quickStats = new();
_sectionActions = new();
switch (sec)
{
case "pos":
_sectionTitle = "POS Bán hàng";
_sectionIcon = "monitor";
_sectionDescription = "Mở giao diện bán hàng tại điểm để phục vụ khách hàng nhanh chóng.";
_sectionActions = new() { ("monitor", "Mở POS") };
break;
case "menu":
_sectionTitle = "Quản lý Menu";
_sectionIcon = "coffee";
_sectionDescription = "Quản lý danh mục, món/sản phẩm, giá, tùy chọn thêm cho cửa hàng.";
_quickStats = new() { ("0", "Danh mục"), ("0", "Sản phẩm"), ("0", "Topping") };
_sectionActions = new() { ("plus", "Thêm sản phẩm") };
break;
case "tables":
_sectionTitle = "Quản lý Bàn";
_sectionIcon = "grid-3x3";
_sectionDescription = "Thiết lập sơ đồ bàn, khu vực phục vụ cho nhà hàng.";
_quickStats = new() { ("0", "Bàn"), ("0", "Khu vực") };
_sectionActions = new() { ("plus", "Thêm bàn") };
break;
case "kitchen":
_sectionTitle = "Bếp (Kitchen Display)";
_sectionIcon = "flame";
_sectionDescription = "Màn hình hiển thị đơn cho bếp, quản lý tiến độ chế biến.";
break;
case "rooms":
_sectionTitle = "Quản lý Phòng";
_sectionIcon = "door-open";
_sectionDescription = "Thiết lập loại phòng, giá theo giờ, trạng thái phòng karaoke.";
_quickStats = new() { ("0", "Phòng"), ("0", "Loại phòng") };
_sectionActions = new() { ("plus", "Thêm phòng") };
break;
case "appointments":
_sectionTitle = "Lịch hẹn";
_sectionIcon = "calendar";
_sectionDescription = "Quản lý lịch hẹn khách hàng, phân công nhân viên phục vụ.";
_quickStats = new() { ("0", "Hôm nay"), ("0", "Tuần này") };
_sectionActions = new() { ("plus", "Tạo lịch hẹn") };
break;
case "services":
_sectionTitle = "Dịch vụ";
_sectionIcon = "sparkles";
_sectionDescription = "Quản lý danh mục dịch vụ, giá, thời gian thực hiện.";
_quickStats = new() { ("0", "Dịch vụ"), ("0", "Gói combo") };
_sectionActions = new() { ("plus", "Thêm dịch vụ") };
break;
case "inventory":
_sectionTitle = "Tồn kho";
_sectionIcon = "warehouse";
_sectionDescription = "Theo dõi nguyên liệu, hàng tồn kho, cảnh báo hết hàng.";
_quickStats = new() { ("0", "Nguyên liệu"), ("0", "Cần nhập") };
break;
case "staff":
_sectionTitle = "Nhân sự";
_sectionIcon = "users";
_sectionDescription = "Quản lý nhân viên cửa hàng, ca làm việc, phân công.";
_quickStats = new() { ("0", "Nhân viên"), ("0", "Ca hôm nay") };
_sectionActions = new() { ("plus", "Thêm nhân viên") };
break;
case "customers":
_sectionTitle = "Khách hàng";
_sectionIcon = "heart";
_sectionDescription = "Danh sách khách hàng, lịch sử mua hàng, tích điểm.";
_quickStats = new() { ("0", "Khách hàng"), ("0", "Thành viên") };
break;
case "reports":
_sectionTitle = "Báo cáo";
_sectionIcon = "bar-chart-2";
_sectionDescription = "Doanh thu, đơn hàng, sản phẩm bán chạy, hiệu suất nhân viên.";
_sectionActions = new() { ("download", "Xuất báo cáo") };
break;
default:
_sectionTitle = Section ?? "Trang";
_sectionIcon = "layout-dashboard";
_sectionDescription = "Trang này đang được phát triển.";
break;
}
_hasQuickStats = _quickStats.Any();
}
}

View File

@@ -97,7 +97,7 @@
@foreach (var shop in FilteredShops)
{
var isPaused = IsPaused(shop.Status);
<div class="admin-store-list-card @(isPaused ? "admin-store-list-card--paused" : "")" @onclick="@(() => NavigateTo($"stores/{shop.Id}"))">
<div class="admin-store-list-card @(isPaused ? "admin-store-list-card--paused" : "")" @onclick="@(() => NavigateTo($"shop/{shop.Id}/overview"))">
<div class="admin-store-list-card__left">
<div class="admin-store-card__avatar" style="width:52px;height:52px;background-color:@(GetCategoryBgColor(shop.Category));border-radius:14px;">
<i data-lucide="@GetShopIcon(shop.Category)" style="color:@(GetCategoryColor(shop.Category));width:24px;height:24px;"></i>