feat(web-client-tpos): replace hardcoded store pages with real API data

- Rewrite StoreList.razor with real data from PosDataService
- Rewrite StoreDetail.razor with real shop data from BFF
- Rewrite StoreSettings.razor with editable form bound to real data
- Add GetShopByIdAsync to PosDataService and BFF endpoint
- Add UpdateShopAsync to MerchantApiService
- Add ShopUpdateDto to MerchantDtos
- Fix BFF DB connection: configurable via env vars (BFF_DB_HOST)
- Add BFF_DB env vars to docker-compose.yml
This commit is contained in:
Ho Ngoc Hai
2026-02-28 04:23:11 +07:00
parent 1e211dec27
commit 12be9737d9
8 changed files with 756 additions and 557 deletions

View File

@@ -1,14 +1,15 @@
@page "/admin/stores/{Id}"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Store detail — overview with KPIs, revenue chart, recent orders, and staff.
VI: Chi tiết cửa hàng — tổng quan KPI, biểu đồ doanh thu, đơn gần đây, nhân viên.
Design: pencil-design/src/pages/tPOS/admin/store-detail.pen
EN: Store detail — overview with KPIs (real data from API).
VI: Chi tiết cửa hàng — tổng quan KPI (dữ liệu thực từ API).
*@
<PageTitle>@_storeName — GoodGo Admin</PageTitle>
<PageTitle>@(_shop?.Name ?? "Cửa hàng") — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
@@ -17,15 +18,18 @@
<i data-lucide="arrow-left"></i>
</button>
<div>
<h1 class="admin-topbar__title">@_storeName</h1>
<p class="admin-topbar__subtitle">@_storeType • @_storeAddress</p>
<h1 class="admin-topbar__title">@(_shop?.Name ?? "Đang tải...")</h1>
<p class="admin-topbar__subtitle">@(_shop?.Category ?? "—") • @(_shop?.Slug ?? "—")</p>
</div>
</div>
<div class="admin-topbar__right">
<div class="admin-status-badge admin-status-badge--online">
<span class="admin-status-badge__dot"></span>
Đang mở
</div>
@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-secondary" @onclick="@(() => NavigateTo($"stores/{Id}/settings"))">
<i data-lucide="settings"></i>
<span>Cài đặt</span>
@@ -40,181 +44,233 @@
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:24px;">
@* ── 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 class="admin-kpi-card__badge admin-kpi-card__badge--up">
<i data-lucide="arrow-up"></i>
<span>+15.3%</span>
</div>
</div>
<div class="admin-kpi-card__value">45.2M</div>
<div class="admin-kpi-card__label">Doanh thu tháng</div>
@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>
<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 class="admin-kpi-card__badge admin-kpi-card__badge--up">
<i data-lucide="arrow-up"></i>
<span>+8.7%</span>
</div>
</div>
<div class="admin-kpi-card__value">342</div>
<div class="admin-kpi-card__label">Đơn hàng tháng</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>
<button class="admin-btn-primary" @onclick="@(() => NavigateTo("stores"))">
<i data-lucide="arrow-left"></i>
<span>Quay lại danh sách</span>
</button>
</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>
}
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>
<div class="admin-kpi-card__value">132K</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">4.7</div>
<div class="admin-kpi-card__label">Đánh giá TB</div>
</div>
</div>
@* ── BOTTOM: Revenue Chart + Right Column ── *@
<div style="display:flex;gap:24px;flex:1;min-height:0;">
@* LEFT: Revenue Chart Placeholder *@
<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>
<button class="admin-tab" style="padding:6px 12px;font-size:12px;">Tùy chọn</button>
</div>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
@* Chart placeholder — bar chart *@
<div style="display:flex;align-items:flex-end;gap:12px;height:200px;padding:16px 0;">
@foreach (var bar in _chartData)
<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 • @(_shop.Category ?? "—")</div>
@if (!string.IsNullOrEmpty(_shop.Description))
{
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;">
<div style="width:100%;background:linear-gradient(180deg,var(--admin-orange-primary),rgba(255,92,0,0.4));border-radius:6px 6px 0 0;height:@(bar.Percent)%;min-height:8px;transition:height 0.3s;"></div>
<span style="font-size:10px;color:var(--admin-text-tertiary);">@bar.Label</span>
<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>
@* RIGHT COLUMN *@
<div style="width:380px;display:flex;flex-direction:column;gap:20px;">
@* ── KPI ROW (placeholder — chưa có dữ liệu order/revenue) ── *@
<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>
@* Recent Orders *@
<div class="admin-panel">
<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 Placeholder *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="shopping-bag" style="color:#3B82F6;"></i>
Đơn gần nhất
<i data-lucide="bar-chart-2" style="color:var(--admin-orange-primary);"></i>
Doanh thu 7 ngày gần nhất
</h3>
<a href="/admin/reports" class="admin-panel__action">Tất cả →</a>
<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">
<div class="admin-activity-list">
<div class="admin-activity-item">
<span class="admin-activity-dot" style="background-color:#22C55E;"></span>
<div class="admin-activity-item__text">
<div style="display:flex;justify-content:space-between;">
<span class="admin-activity-item__title">#2847</span>
<span style="font-size:13px;font-weight:600;color:var(--admin-orange-primary);">185K</span>
</div>
<div class="admin-activity-item__time">Hoàn thành • 2 phút trước</div>
</div>
</div>
<div class="admin-activity-item">
<span class="admin-activity-dot" style="background-color:#3B82F6;"></span>
<div class="admin-activity-item__text">
<div style="display:flex;justify-content:space-between;">
<span class="admin-activity-item__title">#2846</span>
<span style="font-size:13px;font-weight:600;color:var(--admin-orange-primary);">92K</span>
</div>
<div class="admin-activity-item__time">Đang pha chế • 5 phút trước</div>
</div>
</div>
<div class="admin-activity-item">
<span class="admin-activity-dot" style="background-color:#22C55E;"></span>
<div class="admin-activity-item__text">
<div style="display:flex;justify-content:space-between;">
<span class="admin-activity-item__title">#2845</span>
<span style="font-size:13px;font-weight:600;color:var(--admin-orange-primary);">240K</span>
</div>
<div class="admin-activity-item__time">Hoàn thành • 12 phút trước</div>
</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>
@* Staff On Shift *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="users" style="color:#22C55E;"></i>
Nhân viên đang ca (@_staffOnShift.Count)
</h3>
@* RIGHT COLUMN *@
<div style="width:380px;display:flex;flex-direction:column;gap:20px;">
@* Recent Orders Placeholder *@
<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__body">
<div class="admin-activity-list">
@foreach (var staff in _staffOnShift)
@* Shop Quick Info *@
<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;">@(_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>
@if (!string.IsNullOrEmpty(_shop.Phone))
{
<div class="admin-activity-item">
<div class="admin-user-avatar" style="width:32px;height:32px;font-size:11px;">@staff.Initials</div>
<div class="admin-activity-item__text">
<span class="admin-activity-item__title">@staff.Name</span>
<span class="admin-activity-item__time">@staff.Role • từ @staff.StartTime</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="color:var(--admin-text-tertiary);font-size:14px;">Điện thoại</span>
<span style="font-size:13px;font-weight:600;">@_shop.Phone</span>
</div>
}
@if (!string.IsNullOrEmpty(_shop.Email))
{
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="color:var(--admin-text-tertiary);font-size:14px;">Email</span>
<span style="font-size:13px;font-weight:600;">@_shop.Email</span>
</div>
}
</div>
</div>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public string Id { get; set; } = "1";
[Parameter] public string Id { get; set; } = "";
private string _storeName = "Coffee House Q1";
private string _storeType = "Café";
private string _storeAddress = "123 Nguyễn Huệ, Q1, TP.HCM";
private PosDataService.ShopInfo? _shop;
private record ChartBar(string Label, int Percent);
private readonly List<ChartBar> _chartData = new()
protected override async Task OnInitializedAsync()
{
new("T2", 65), new("T3", 78), new("T4", 55), new("T5", 82),
new("T6", 90), new("T7", 95), new("CN", 72)
IsLoading = true;
try
{
if (Guid.TryParse(Id, out var shopId))
{
_shop = await DataService.GetShopByIdAsync(shopId);
}
}
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 record StaffInfo(string Name, string Initials, string Role, string StartTime);
private readonly List<StaffInfo> _staffOnShift = new()
private static string GetStatusLabel(string? status) => status?.ToLowerInvariant() switch
{
new("Trần Minh", "TM", "Barista", "07:00"),
new("Lê Thảo", "LT", "Thu ngân", "07:30"),
new("Nguyễn Hà", "NH", "Phục vụ", "08:00"),
"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

@@ -1,11 +1,12 @@
@page "/admin/stores"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Store list — all stores with tabs, search, filter.
VI: Danh sách cửa hàng — tất cả cửa hàng với tabs, tìm kiếm, lọc.
Design: pencil-design/src/pages/tPOS/admin/store-list.pen
EN: Store list — all stores with tabs, search, filter (real data from API).
VI: Danh sách cửa hàng — tất cả cửa hàng với tabs, tìm kiếm, lọc (dữ liệu thực từ API).
*@
<PageTitle>Quản lý cửa hàng — GoodGo Admin</PageTitle>
@@ -17,13 +18,9 @@
<p class="admin-topbar__subtitle">Tạo và quản lý tất cả cửa hàng trong hệ thống</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-secondary">
<i data-lucide="filter"></i>
<span>Bộ lọc</span>
</button>
<div class="admin-search" style="width:260px;">
<i data-lucide="search"></i>
<input type="text" placeholder="Tìm cửa hàng..." @bind="SearchQuery" />
<input type="text" placeholder="Tìm cửa hàng..." @bind="SearchQuery" @bind:event="oninput" />
</div>
<button class="admin-btn-primary" @onclick="@(() => NavigateTo("stores/create"))">
<i data-lucide="plus"></i>
@@ -36,247 +33,216 @@
<div class="admin-tabs">
<button class="admin-tab @(_activeTab == "all" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "all")">
Tất cả
<span class="admin-tab__badge admin-tab__badge--active">5</span>
<span class="admin-tab__badge admin-tab__badge--active">@_shops.Count</span>
</button>
<button class="admin-tab @(_activeTab == "active" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "active")">
Hoạt động
<span class="admin-tab__badge">3</span>
<span class="admin-tab__badge">@_shops.Count(s => IsActive(s.Status))</span>
</button>
<button class="admin-tab @(_activeTab == "setup" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "setup")">
Thiết lập
<span class="admin-tab__badge">1</span>
<span class="admin-tab__badge">@_shops.Count(s => IsSetup(s.Status))</span>
</button>
<button class="admin-tab @(_activeTab == "paused" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "paused")">
Tạm dừng
<span class="admin-tab__badge">1</span>
<span class="admin-tab__badge">@_shops.Count(s => IsPaused(s.Status))</span>
</button>
</div>
@* ═══ STORE LIST ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:16px;padding:28px 32px;">
@* Store 1: Coffee House Q1 — Active *@
<div class="admin-store-list-card" @onclick="@(() => NavigateTo("stores/1"))">
<div class="admin-store-list-card__left">
<div class="admin-store-card__avatar" style="width:52px;height:52px;background-color:rgba(255,92,0,0.125);border-radius:14px;">
<i data-lucide="coffee" style="color:var(--admin-orange-primary);width:24px;height:24px;"></i>
</div>
<div class="admin-store-list-card__info">
<div style="display:flex;align-items:center;gap:10px;">
<span style="font-size:16px;font-weight:600;">Coffee House Q1</span>
<div class="admin-status-badge admin-status-badge--online">
<span class="admin-status-badge__dot"></span>
Hoạt động
@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 danh sách cửa hàng...</p>
</div>
}
else if (!FilteredShops.Any())
{
<div style="text-align:center;padding:48px 20px;">
<i data-lucide="store" style="width:48px;height:48px;color:var(--admin-orange-primary);margin-bottom:16px;"></i>
<h3 style="font-size:18px;font-weight:700;color:var(--pos-text-primary, #FFFFFF);margin:0 0 8px;">
@if (_shops.Count == 0)
{
@:Chưa có cửa hàng nào
}
else
{
@: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;">
@if (_shops.Count == 0)
{
@:Bắt đầu bằng việc tạo cửa hàng đầu tiên.
}
else
{
@:Thử thay đổi bộ lọc hoặc từ khóa tìm kiếm.
}
</p>
@if (_shops.Count == 0)
{
<a href="/admin/stores/create" class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;">
<i data-lucide="plus" style="width:16px;height:16px;"></i>
Tạo cửa hàng ngay
</a>
}
</div>
}
else
{
@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__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>
</div>
<div class="admin-store-list-card__info">
<div style="display:flex;align-items:center;gap:10px;">
<span style="font-size:16px;font-weight:600;">@shop.Name</span>
<div class="admin-status-badge admin-status-badge--@GetStatusBadgeClass(shop.Status)">
<span class="admin-status-badge__dot"></span>
@GetStatusLabel(shop.Status)
</div>
</div>
<span style="font-size:12px;color:var(--admin-text-tertiary);">@(shop.Category ?? "—") • @(shop.Slug)</span>
</div>
</div>
<span style="font-size:12px;color:var(--admin-text-tertiary);">Café • 123 Nguyễn Huệ, Q1, TP.HCM</span>
</div>
</div>
<div class="admin-store-list-card__stats">
<div class="admin-store-stat">
<div class="admin-store-stat__value">45.2M</div>
<div class="admin-store-stat__label">Doanh thu</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value">342</div>
<div class="admin-store-stat__label">Đơn hàng</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value">5</div>
<div class="admin-store-stat__label">Nhân viên</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value">48</div>
<div class="admin-store-stat__label">Sản phẩm</div>
</div>
</div>
<div class="admin-store-list-card__actions">
<button class="admin-icon-btn" title="Cài đặt" @onclick:stopPropagation @onclick="@(() => NavigateTo("stores/1/settings"))">
<i data-lucide="settings"></i>
</button>
<button class="admin-icon-btn" title="Xem chi tiết">
<i data-lucide="chevron-right"></i>
</button>
</div>
</div>
@* Store 2: Nhà hàng Q3 — Active *@
<div class="admin-store-list-card" @onclick="@(() => NavigateTo("stores/2"))">
<div class="admin-store-list-card__left">
<div class="admin-store-card__avatar" style="width:52px;height:52px;background-color:rgba(59,130,246,0.125);border-radius:14px;">
<i data-lucide="utensils" style="color:#3B82F6;width:24px;height:24px;"></i>
</div>
<div class="admin-store-list-card__info">
<div style="display:flex;align-items:center;gap:10px;">
<span style="font-size:16px;font-weight:600;">Nhà hàng Q3</span>
<div class="admin-status-badge admin-status-badge--online">
<span class="admin-status-badge__dot"></span>
Hoạt động
<div class="admin-store-list-card__stats">
<div class="admin-store-stat">
<div class="admin-store-stat__value" style="@(isPaused ? "opacity:0.5;" : "")">--</div>
<div class="admin-store-stat__label">Doanh thu</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value" style="@(isPaused ? "opacity:0.5;" : "")">--</div>
<div class="admin-store-stat__label">Đơn hàng</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value" style="@(isPaused ? "opacity:0.5;" : "")">--</div>
<div class="admin-store-stat__label">Nhân viên</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value" style="@(isPaused ? "opacity:0.5;" : "")">--</div>
<div class="admin-store-stat__label">Sản phẩm</div>
</div>
</div>
<span style="font-size:12px;color:var(--admin-text-tertiary);">Restaurant • 456 Lê Văn Sỹ, Q3, TP.HCM</span>
</div>
</div>
<div class="admin-store-list-card__stats">
<div class="admin-store-stat">
<div class="admin-store-stat__value">62.8M</div>
<div class="admin-store-stat__label">Doanh thu</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value">185</div>
<div class="admin-store-stat__label">Đơn hàng</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value">8</div>
<div class="admin-store-stat__label">Nhân viên</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value">72</div>
<div class="admin-store-stat__label">Sản phẩm</div>
</div>
</div>
<div class="admin-store-list-card__actions">
<button class="admin-icon-btn" title="Cài đặt" @onclick:stopPropagation @onclick="@(() => NavigateTo("stores/2/settings"))">
<i data-lucide="settings"></i>
</button>
<button class="admin-icon-btn" title="Xem chi tiết">
<i data-lucide="chevron-right"></i>
</button>
</div>
</div>
@* Store 3: Café Thủ Đức — Active *@
<div class="admin-store-list-card" @onclick="@(() => NavigateTo("stores/3"))">
<div class="admin-store-list-card__left">
<div class="admin-store-card__avatar" style="width:52px;height:52px;background-color:rgba(236,72,153,0.125);border-radius:14px;">
<i data-lucide="coffee" style="color:#EC4899;width:24px;height:24px;"></i>
</div>
<div class="admin-store-list-card__info">
<div style="display:flex;align-items:center;gap:10px;">
<span style="font-size:16px;font-weight:600;">Café Thủ Đức</span>
<div class="admin-status-badge admin-status-badge--online">
<span class="admin-status-badge__dot"></span>
Hoạt động
</div>
<div class="admin-store-list-card__actions">
@if (IsSetup(shop.Status))
{
<button class="admin-btn-primary" style="font-size:12px;padding:6px 12px;" @onclick:stopPropagation @onclick="@(() => NavigateTo($"stores/{shop.Id}/settings"))">
<i data-lucide="settings" style="width:14px;height:14px;"></i>
Hoàn tất thiết lập
</button>
}
else if (isPaused)
{
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;" @onclick:stopPropagation>
<i data-lucide="play" style="width:14px;height:14px;"></i>
Kích hoạt lại
</button>
}
else
{
<button class="admin-icon-btn" title="Cài đặt" @onclick:stopPropagation @onclick="@(() => NavigateTo($"stores/{shop.Id}/settings"))">
<i data-lucide="settings"></i>
</button>
<button class="admin-icon-btn" title="Xem chi tiết">
<i data-lucide="chevron-right"></i>
</button>
}
</div>
<span style="font-size:12px;color:var(--admin-text-tertiary);">Café • 12 Võ Văn Ngân, Thủ Đức</span>
</div>
</div>
<div class="admin-store-list-card__stats">
<div class="admin-store-stat">
<div class="admin-store-stat__value">20.5M</div>
<div class="admin-store-stat__label">Doanh thu</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value">156</div>
<div class="admin-store-stat__label">Đơn hàng</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value">3</div>
<div class="admin-store-stat__label">Nhân viên</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value">35</div>
<div class="admin-store-stat__label">Sản phẩm</div>
</div>
</div>
<div class="admin-store-list-card__actions">
<button class="admin-icon-btn" title="Cài đặt" @onclick:stopPropagation @onclick="@(() => NavigateTo("stores/3/settings"))">
<i data-lucide="settings"></i>
</button>
<button class="admin-icon-btn" title="Xem chi tiết">
<i data-lucide="chevron-right"></i>
</button>
</div>
</div>
@* Store 4: Karaoke Star Q7 — Setup *@
<div class="admin-store-list-card" @onclick="@(() => NavigateTo("stores/4"))">
<div class="admin-store-list-card__left">
<div class="admin-store-card__avatar" style="width:52px;height:52px;background-color:rgba(139,92,246,0.125);border-radius:14px;">
<i data-lucide="mic" style="color:#8B5CF6;width:24px;height:24px;"></i>
</div>
<div class="admin-store-list-card__info">
<div style="display:flex;align-items:center;gap:10px;">
<span style="font-size:16px;font-weight:600;">Karaoke Star Q7</span>
<div class="admin-status-badge admin-status-badge--setup">
<span class="admin-status-badge__dot"></span>
Thiết lập
</div>
</div>
<span style="font-size:12px;color:var(--admin-text-tertiary);">Karaoke • 789 Nguyễn Thị Thập, Q7</span>
</div>
</div>
<div class="admin-store-list-card__stats">
<div class="admin-store-stat">
<div class="admin-store-stat__value">--</div>
<div class="admin-store-stat__label">Doanh thu</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value">--</div>
<div class="admin-store-stat__label">Đơn hàng</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value">0</div>
<div class="admin-store-stat__label">Nhân viên</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value">0</div>
<div class="admin-store-stat__label">Sản phẩm</div>
</div>
</div>
<div class="admin-store-list-card__actions">
<button class="admin-btn-primary" style="font-size:12px;padding:6px 12px;" @onclick:stopPropagation @onclick="@(() => NavigateTo("stores/4/settings"))">
<i data-lucide="settings" style="width:14px;height:14px;"></i>
Hoàn tất thiết lập
</button>
</div>
</div>
@* Store 5: Spa Garden Q2 — Paused *@
<div class="admin-store-list-card admin-store-list-card--paused" @onclick="@(() => NavigateTo("stores/5"))">
<div class="admin-store-list-card__left">
<div class="admin-store-card__avatar" style="width:52px;height:52px;background-color:rgba(245,158,11,0.125);border-radius:14px;">
<i data-lucide="sparkles" style="color:#F59E0B;width:24px;height:24px;"></i>
</div>
<div class="admin-store-list-card__info">
<div style="display:flex;align-items:center;gap:10px;">
<span style="font-size:16px;font-weight:600;">Spa Garden Q2</span>
<div class="admin-status-badge admin-status-badge--paused">
<span class="admin-status-badge__dot"></span>
Tạm dừng
</div>
</div>
<span style="font-size:12px;color:var(--admin-text-tertiary);">Spa • 321 Nguyễn Đình Chiểu, Q2</span>
</div>
</div>
<div class="admin-store-list-card__stats">
<div class="admin-store-stat">
<div class="admin-store-stat__value" style="opacity:0.5;">12.1M</div>
<div class="admin-store-stat__label">Doanh thu</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value" style="opacity:0.5;">89</div>
<div class="admin-store-stat__label">Đơn hàng</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value" style="opacity:0.5;">2</div>
<div class="admin-store-stat__label">Nhân viên</div>
</div>
<div class="admin-store-stat">
<div class="admin-store-stat__value" style="opacity:0.5;">24</div>
<div class="admin-store-stat__label">Sản phẩm</div>
</div>
</div>
<div class="admin-store-list-card__actions">
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;" @onclick:stopPropagation>
<i data-lucide="play" style="width:14px;height:14px;"></i>
Kích hoạt lại
</button>
</div>
</div>
}
}
</div>
@code {
private List<PosDataService.ShopInfo> _shops = new();
private string _activeTab = "all";
private IEnumerable<PosDataService.ShopInfo> FilteredShops => _shops
.Where(s => _activeTab == "all"
|| (_activeTab == "active" && IsActive(s.Status))
|| (_activeTab == "setup" && IsSetup(s.Status))
|| (_activeTab == "paused" && IsPaused(s.Status)))
.Where(s => string.IsNullOrWhiteSpace(SearchQuery)
|| (s.Name?.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ?? false)
|| (s.Slug?.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ?? false)
|| (s.Category?.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ?? false));
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try
{
_shops = await DataService.GetShopsAsync();
}
catch
{
_shops = new();
}
finally
{
IsLoading = false;
}
}
private static bool IsActive(string? status) => status?.Equals("Published", StringComparison.OrdinalIgnoreCase) == true
|| status?.Equals("active", StringComparison.OrdinalIgnoreCase) == true;
private static bool IsSetup(string? status) => status?.Equals("Draft", StringComparison.OrdinalIgnoreCase) == true
|| status?.Equals("setup", StringComparison.OrdinalIgnoreCase) == true;
private static bool IsPaused(string? status) => status?.Equals("Inactive", StringComparison.OrdinalIgnoreCase) == true
|| status?.Equals("paused", StringComparison.OrdinalIgnoreCase) == true;
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" => "Hoạt động",
"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"
};
private static string GetCategoryColor(string? category) => category?.ToLowerInvariant() switch
{
"foodbeverage" or "café" or "cafe" => "var(--admin-orange-primary)",
"restaurant" => "#3B82F6",
"entertainment" or "karaoke" => "#8B5CF6",
"beauty" or "spa" => "#EC4899",
"retail" => "#22C55E",
_ => "var(--admin-orange-primary)"
};
private static string GetCategoryBgColor(string? category) => category?.ToLowerInvariant() switch
{
"foodbeverage" or "café" or "cafe" => "rgba(255,92,0,0.125)",
"restaurant" => "rgba(59,130,246,0.125)",
"entertainment" or "karaoke" => "rgba(139,92,246,0.125)",
"beauty" or "spa" => "rgba(236,72,153,0.125)",
"retail" => "rgba(34,197,94,0.125)",
_ => "rgba(255,92,0,0.125)"
};
}

View File

@@ -1,11 +1,13 @@
@page "/admin/stores/{Id}/settings"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@using WebClientTpos.Shared.DTOs
@*
EN: Store settings — tabs for general, payment, receipts, integrations.
VI: Cài đặt cửa hàng — tabs cho chung, thanh toán, hóa đơn, tích hợp.
Design: pencil-design/src/pages/tPOS/admin/store-settings.pen
EN: Store settings — tabs for general, payment, receipts, integrations (real data from API).
VI: Cài đặt cửa hàng — tabs cho chung, thanh toán, hóa đơn, tích hợp (dữ liệu thực từ API).
*@
<PageTitle>Cài đặt cửa hàng — GoodGo Admin</PageTitle>
@@ -18,13 +20,32 @@
</button>
<div>
<h1 class="admin-topbar__title">Cài đặt cửa hàng</h1>
<p class="admin-topbar__subtitle">Coffee House Q1 — Quản lý cài đặt</p>
<p class="admin-topbar__subtitle">@(_shop?.Name ?? "Đang tải...") — Quản lý cài đặt</p>
</div>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-primary" @onclick="SaveSettings">
<i data-lucide="check"></i>
<span>Lưu thay đổi</span>
@if (!string.IsNullOrEmpty(_successMessage))
{
<span style="font-size:13px;color:#22C55E;display:flex;align-items:center;gap:4px;">
<i data-lucide="check-circle" style="width:14px;height:14px;"></i>
@_successMessage
</span>
}
@if (!string.IsNullOrEmpty(_errorMessage))
{
<span style="font-size:13px;color:#EF4444;">@_errorMessage</span>
}
<button class="admin-btn-primary" @onclick="SaveSettings" disabled="@_isSaving">
@if (_isSaving)
{
<span class="spinner-small" style="margin-right:6px;"></span>
<span>Đang lưu...</span>
}
else
{
<i data-lucide="check"></i>
<span>Lưu thay đổi</span>
}
</button>
</div>
</div>
@@ -52,198 +73,284 @@
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:24px;">
@if (_tab == "general")
@if (IsLoading)
{
@* General Settings *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Thông tin cửa hàng</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Tên cửa hàng</label>
<input class="admin-form-input" type="text" value="Coffee House Q1" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Loại hình</label>
<select class="admin-form-input">
<option selected>Café</option>
<option>Nhà hàng</option>
<option>Karaoke</option>
<option>Spa</option>
</select>
</div>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Địa chỉ</label>
<input class="admin-form-input" type="text" value="123 Nguyễn Huệ, Q1, TP.HCM" />
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Điện thoại</label>
<input class="admin-form-input" type="tel" value="0901 234 567" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Email</label>
<input class="admin-form-input" type="email" value="coffeehouse.q1@goodgo.vn" />
</div>
</div>
</div>
</div>
@* Operating Hours *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Giờ hoạt động</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Giờ mở cửa</label>
<input class="admin-form-input" type="time" value="07:00" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Giờ đóng cửa</label>
<input class="admin-form-input" type="time" value="22:00" />
</div>
</div>
</div>
</div>
@* Danger Zone *@
<div class="admin-panel" style="border:1px solid rgba(239,68,68,0.3);">
<div class="admin-panel__header">
<h3 class="admin-panel__title" style="color:#EF4444;">
<i data-lucide="alert-triangle" style="color:#EF4444;"></i>
Vùng nguy hiểm
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-weight:600;">Tạm dừng cửa hàng</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">Cửa hàng sẽ ngừng hoạt động tạm thời</div>
</div>
<button class="admin-btn-secondary" style="color:#F59E0B;border-color:rgba(245,158,11,0.3);">
<i data-lucide="pause"></i>
Tạm dừng
</button>
</div>
<div style="height:1px;background:var(--admin-border-subtle);"></div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-weight:600;color:#EF4444;">Xóa cửa hàng</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">Không thể hoàn tác, tất cả dữ liệu sẽ bị xóa</div>
</div>
<button class="admin-btn-secondary" style="color:#EF4444;border-color:rgba(239,68,68,0.3);">
<i data-lucide="trash-2"></i>
Xóa
</button>
</div>
</div>
<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 cài đặt cửa hàng...</p>
</div>
}
@if (_tab == "payment")
else if (_shop == null)
{
@* Payment Methods *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Phương thức thanh toán</h3>
<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>
<button class="admin-btn-primary" @onclick="@(() => NavigateTo("stores"))">Quay lại</button>
</div>
}
else
{
@if (_tab == "general")
{
@* General Settings *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Thông tin cửa hàng</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Tên cửa hàng</label>
<input class="admin-form-input" type="text" @bind="_editName" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Slug (URL)</label>
<input class="admin-form-input" type="text" value="@_shop.Slug" disabled />
<small style="color:var(--admin-text-tertiary);font-size:12px;">Không thể thay đổi slug sau khi tạo</small>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Ngành hàng</label>
<input class="admin-form-input" type="text" value="@(_shop.Category ?? "—")" disabled />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Trạng thái</label>
<input class="admin-form-input" type="text" value="@GetStatusLabel(_shop.Status)" disabled />
</div>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Mô tả</label>
<textarea class="admin-form-input" rows="3" @bind="_editDescription"></textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Điện thoại</label>
<input class="admin-form-input" type="tel" @bind="_editPhone" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Email</label>
<input class="admin-form-input" type="email" @bind="_editEmail" />
</div>
</div>
</div>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
@foreach (var pm in _paymentMethods)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;background:var(--admin-bg-interactive);border-radius:12px;">
@* Danger Zone *@
<div class="admin-panel" style="border:1px solid rgba(239,68,68,0.3);">
<div class="admin-panel__header">
<h3 class="admin-panel__title" style="color:#EF4444;">
<i data-lucide="alert-triangle" style="color:#EF4444;"></i>
Vùng nguy hiểm
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-weight:600;">Tạm dừng cửa hàng</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">Cửa hàng sẽ ngừng hoạt động tạm thời</div>
</div>
<button class="admin-btn-secondary" style="color:#F59E0B;border-color:rgba(245,158,11,0.3);">
<i data-lucide="pause"></i>
Tạm dừng
</button>
</div>
<div style="height:1px;background:var(--admin-border-subtle);"></div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-weight:600;color:#EF4444;">Đóng cửa hàng</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">Không thể hoàn tác, cửa hàng sẽ bị đóng vĩnh viễn</div>
</div>
<button class="admin-btn-secondary" style="color:#EF4444;border-color:rgba(239,68,68,0.3);">
<i data-lucide="trash-2"></i>
Đóng
</button>
</div>
</div>
</div>
}
@if (_tab == "payment")
{
@* Payment Methods *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Phương thức thanh toán</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
@foreach (var pm in _paymentMethods)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;background:var(--admin-bg-interactive);border-radius:12px;">
<div style="display:flex;align-items:center;gap:12px;">
<i data-lucide="@pm.Icon" style="color:@pm.Color;"></i>
<div>
<div style="font-weight:600;font-size:14px;">@pm.Name</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">@pm.Desc</div>
</div>
</div>
<MudSwitch T="bool" Value="pm.Enabled" Color="Color.Primary" />
</div>
}
</div>
</div>
}
@if (_tab == "receipt")
{
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Mẫu hóa đơn</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:20px;">
<div class="admin-form-group">
<label class="admin-form-label">Header hóa đơn</label>
<input class="admin-form-input" type="text" value="@(_shop.Name)" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Footer</label>
<textarea class="admin-form-input" rows="2">Cảm ơn quý khách! Hẹn gặp lại!</textarea>
</div>
<div style="display:flex;gap:16px;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<MudCheckBox T="bool" Value="true" Color="Color.Primary" />
<span style="font-size:13px;">Hiện logo</span>
</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<MudCheckBox T="bool" Value="true" Color="Color.Primary" />
<span style="font-size:13px;">Hiện QR code</span>
</label>
</div>
</div>
</div>
}
@if (_tab == "integrations")
{
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Tích hợp bên thứ ba</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;padding:14px;background:var(--admin-bg-interactive);border-radius:12px;">
<div style="display:flex;align-items:center;gap:12px;">
<i data-lucide="@pm.Icon" style="color:@pm.Color;"></i>
<i data-lucide="truck" style="color:var(--admin-text-tertiary);"></i>
<div>
<div style="font-weight:600;font-size:14px;">@pm.Name</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">@pm.Desc</div>
<div style="font-weight:600;">GrabFood</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">Đồng bộ đơn hàng delivery</div>
</div>
</div>
<MudSwitch T="bool" Value="pm.Enabled" Color="Color.Primary" />
<button class="admin-btn-secondary" style="font-size:12px;padding:4px 12px;">Kết nối</button>
</div>
}
</div>
</div>
}
@if (_tab == "receipt")
{
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Mẫu hóa đơn</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:20px;">
<div class="admin-form-group">
<label class="admin-form-label">Header hóa đơn</label>
<input class="admin-form-input" type="text" value="Coffee House Q1" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Footer</label>
<textarea class="admin-form-input" rows="2">Cảm ơn quý khách! Hẹn gặp lại!</textarea>
</div>
<div style="display:flex;gap:16px;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<MudCheckBox T="bool" Value="true" Color="Color.Primary" />
<span style="font-size:13px;">Hiện logo</span>
</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<MudCheckBox T="bool" Value="true" Color="Color.Primary" />
<span style="font-size:13px;">Hiện QR code</span>
</label>
</div>
</div>
</div>
}
@if (_tab == "integrations")
{
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Tích hợp bên thứ ba</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;padding:14px;background:var(--admin-bg-interactive);border-radius:12px;">
<div style="display:flex;align-items:center;gap:12px;">
<i data-lucide="truck" style="color:#22C55E;"></i>
<div>
<div style="font-weight:600;">GrabFood</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">Đồng bộ đơn hàng delivery</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:14px;background:var(--admin-bg-interactive);border-radius:12px;">
<div style="display:flex;align-items:center;gap:12px;">
<i data-lucide="truck" style="color:var(--admin-text-tertiary);"></i>
<div>
<div style="font-weight:600;">ShopeeFood</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">Đồng bộ đơn hàng delivery</div>
</div>
</div>
<button class="admin-btn-secondary" style="font-size:12px;padding:4px 12px;">Kết nối</button>
</div>
<span style="font-size:12px;color:#22C55E;font-weight:600;">Đã kết nối</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:14px;background:var(--admin-bg-interactive);border-radius:12px;">
<div style="display:flex;align-items:center;gap:12px;">
<i data-lucide="truck" style="color:var(--admin-text-tertiary);"></i>
<div>
<div style="font-weight:600;">ShopeeFood</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">Đồng bộ đơn hàng delivery</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:14px;background:var(--admin-bg-interactive);border-radius:12px;">
<div style="display:flex;align-items:center;gap:12px;">
<i data-lucide="file-text" style="color:var(--admin-text-tertiary);"></i>
<div>
<div style="font-weight:600;">Kế toán (Misa)</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">Xuất hóa đơn điện tử</div>
</div>
</div>
<button class="admin-btn-secondary" style="font-size:12px;padding:4px 12px;">Kết nối</button>
</div>
<button class="admin-btn-secondary" style="font-size:12px;padding:4px 12px;">Kết nối</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:14px;background:var(--admin-bg-interactive);border-radius:12px;">
<div style="display:flex;align-items:center;gap:12px;">
<i data-lucide="file-text" style="color:var(--admin-text-tertiary);"></i>
<div>
<div style="font-weight:600;">Kế toán (Misa)</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">Xuất hóa đơn điện tử</div>
</div>
</div>
<button class="admin-btn-secondary" style="font-size:12px;padding:4px 12px;">Kết nối</button>
</div>
</div>
</div>
}
}
</div>
@code {
[Parameter] public string Id { get; set; } = "1";
[Inject] private MerchantApiService MerchantApi { get; set; } = default!;
[Parameter] public string Id { get; set; } = "";
private PosDataService.ShopInfo? _shop;
private string _tab = "general";
private bool _isSaving = false;
private string? _successMessage;
private string? _errorMessage;
// EN: Editable fields pre-filled from shop data
// VI: Các trường có thể sửa, điền sẵn từ dữ liệu shop
private string _editName = "";
private string _editDescription = "";
private string _editPhone = "";
private string _editEmail = "";
protected override async Task OnInitializedAsync()
{
IsLoading = true;
try
{
if (Guid.TryParse(Id, out var shopId))
{
_shop = await DataService.GetShopByIdAsync(shopId);
if (_shop != null)
{
_editName = _shop.Name;
_editDescription = _shop.Description ?? "";
_editPhone = _shop.Phone ?? "";
_editEmail = _shop.Email ?? "";
}
}
}
catch
{
_shop = null;
}
finally
{
IsLoading = false;
}
}
private async Task SaveSettings()
{
if (_isSaving || _shop == null) return;
_isSaving = true;
_successMessage = null;
_errorMessage = null;
StateHasChanged();
var dto = new ShopUpdateDto
{
Name = _editName,
Description = _editDescription,
Phone = _editPhone,
Email = _editEmail
};
var (success, error) = await MerchantApi.UpdateShopAsync(_shop.Id, dto);
if (success)
{
_successMessage = "Đã lưu thành công!";
// EN: Reload shop data to reflect changes
// VI: Tải lại dữ liệu shop để phản ánh thay đổi
_shop = await DataService.GetShopByIdAsync(_shop.Id);
}
else
{
_errorMessage = error ?? "Không thể lưu thay đổi";
}
_isSaving = false;
}
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 record PaymentMethod(string Name, string Icon, string Color, string Desc, bool Enabled);
private readonly PaymentMethod[] _paymentMethods = new[]
@@ -254,6 +361,4 @@
new PaymentMethod("Chuyển khoản", "send", "#F59E0B", "Chuyển khoản ngân hàng", false),
new PaymentMethod("Thẻ quà tặng", "gift", "#EC4899", "Gift card & voucher", false),
};
private void SaveSettings() { /* Save logic */ }
}

View File

@@ -76,6 +76,29 @@ public class MerchantApiService
}
}
/// <summary>
/// EN: Update an existing shop.
/// VI: Cập nhật shop hiện tại.
/// </summary>
public async Task<(bool Success, string? Error)> UpdateShopAsync(Guid shopId, ShopUpdateDto dto)
{
try
{
await AttachTokenAsync();
var response = await _http.PutAsJsonAsync($"/api/merchants/api/v1/shops/{shopId}", dto);
if (response.IsSuccessStatusCode)
return (true, null);
var error = await ParseErrorAsync(response);
return (false, error);
}
catch (Exception ex)
{
return (false, $"Lỗi kết nối: {ex.Message}");
}
}
/// <summary>
/// EN: Attach Bearer token to HttpClient for authorized requests.
/// VI: Gắn Bearer token vào HttpClient cho các request cần xác thực.

View File

@@ -25,6 +25,9 @@ public class PosDataService
public async Task<List<ShopInfo>> GetShopsAsync()
=> await _http.GetFromJsonAsync<List<ShopInfo>>("api/bff/shops", _jsonOptions) ?? new();
public async Task<ShopInfo?> GetShopByIdAsync(Guid shopId)
=> await _http.GetFromJsonAsync<ShopInfo>($"api/bff/shops/{shopId}", _jsonOptions);
public async Task<List<ProductInfo>> GetProductsAsync(Guid shopId)
=> await _http.GetFromJsonAsync<List<ProductInfo>>($"api/bff/shops/{shopId}/products", _jsonOptions) ?? new();

View File

@@ -8,8 +8,15 @@ namespace WebClientTpos.Server.Controllers;
[Route("api/bff")]
public class BffDataController : ControllerBase
{
// EN: DB host configurable via env var (Docker: "postgres", dev: "localhost")
// VI: DB host cấu hình qua env var (Docker: "postgres", dev: "localhost")
private static readonly string _dbHost = Environment.GetEnvironmentVariable("BFF_DB_HOST") ?? "localhost";
private static readonly string _dbPort = Environment.GetEnvironmentVariable("BFF_DB_PORT") ?? "5432";
private static readonly string _dbUser = Environment.GetEnvironmentVariable("BFF_DB_USER") ?? "goodgo";
private static readonly string _dbPass = Environment.GetEnvironmentVariable("BFF_DB_PASS") ?? "goodgo_dev_2024";
private static string ConnStr(string db) =>
$"Host=localhost;Port=5432;Database={db};Username=goodgo;Password=goodgo_dev_2024";
$"Host={_dbHost};Port={_dbPort};Database={db};Username={_dbUser};Password={_dbPass}";
[HttpGet("shops")]
public async Task<IActionResult> GetShops()
@@ -27,6 +34,26 @@ public class BffDataController : ControllerBase
return Ok(shops);
}
[HttpGet("shops/{shopId:guid}")]
public async Task<IActionResult> GetShopById(Guid shopId)
{
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var shop = await conn.QueryFirstOrDefaultAsync<dynamic>(
@"SELECT s.id, s.name, s.slug, s.description, s.phone, s.email,
s.open_time, s.close_time,
bc.name as category, st.name as status
FROM shops s
JOIN business_categories bc ON s.category_id = bc.id
JOIN shop_statuses st ON s.status_id = st.id
WHERE s.id = @ShopId AND s.is_deleted = false",
new { ShopId = shopId });
if (shop == null)
return NotFound(new { message = "Shop not found" });
return Ok(shop);
}
[HttpGet("shops/{shopId}/products")]
public async Task<IActionResult> GetProducts(Guid shopId)
{

View File

@@ -63,3 +63,16 @@ public class ShopCreateResult
public string Slug { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
}
/// <summary>
/// EN: DTO for updating an existing shop.
/// VI: DTO cho cập nhật shop.
/// </summary>
public class ShopUpdateDto
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Website { get; set; }
}

View File

@@ -1288,6 +1288,12 @@ services:
- ReverseProxy__Clusters__merchant-cluster__Destinations__destination1__Address=http://merchant-service-net:8080
- ReverseProxy__Clusters__catalog-cluster__Destinations__destination1__Address=http://catalog-service-net:8080
- ReverseProxy__Clusters__order-cluster__Destinations__destination1__Address=http://order-service-net:8080
# EN: BFF Data Controller — Direct DB queries for dashboard data
# VI: BFF Data Controller — Truy vấn DB trực tiếp cho dữ liệu dashboard
- BFF_DB_HOST=postgres
- BFF_DB_PORT=5432
- BFF_DB_USER=goodgo
- BFF_DB_PASS=goodgo-local-2024
ports:
- "3001:8080"
depends_on: