feat(web-client-tpos): connect all remaining admin pages to real backend APIs

- BFF: Added 10 new endpoints (staff roles/schedules, orders, wallets, devices, promotions, inventory transactions, membership levels)
- PosDataService: Added 14 new client methods with DTOs
- Rewrote 19 admin pages from hardcoded to real API:
  Staff: Create, Schedule, Attendance, Payroll
  Finance: Overview, Revenue, Expenses, Tax
  Inventory: PurchaseOrders, StockTransfer, SupplierMgmt
  Product: MenuBuilder, ModifierGroups, PricingRules
  Customer: Feedback, LoyaltyProgram
  System: DeviceManagement, NotificationCenter, IntegrationHub
This commit is contained in:
Ho Ngoc Hai
2026-02-28 06:05:50 +07:00
parent e0d7567cf0
commit 545bc1f519
21 changed files with 1039 additions and 2296 deletions

View File

@@ -2,162 +2,25 @@
@layout AdminLayout
@inherits AdminBase
@*
EN: Customer feedback — overview KPIs (avg rating, total reviews, response rate, NPS), feedback list with star ratings and response status.
VI: Phản hồi khách hàng — KPI tổng quan (đánh giá TB, tổng review, tỷ lệ phản hồi, NPS), danh sách phản hồi với sao và trạng thái.
Design: pencil-design/src/pages/tPOS/admin/customer-feedback.pen
*@
<PageTitle>Phản hồi — GoodGo Admin</PageTitle>
<PageTitle>Phản hồi khách hàng — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<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">Tổng hợp đánh giá & phản hồi từ khách hàng</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-secondary">
<i data-lucide="download"></i>
<span>Xuất báo cáo</span>
</button>
<p class="admin-topbar__subtitle">Thu thập đánh giá phản hồi</p>
</div>
</div>
@* ═══ 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(245,158,11,0.125);">
<i data-lucide="star" style="color:#F59E0B;"></i>
</div>
<div class="admin-kpi-card__badge admin-kpi-card__badge--up">
<i data-lucide="arrow-up"></i>
<span>+0.3</span>
</div>
</div>
<div class="admin-kpi-card__value">4.6</div>
<div class="admin-kpi-card__label">Đánh giá trung bình</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="message-square" style="color:#3B82F6;"></i>
</div>
<div class="admin-kpi-card__badge admin-kpi-card__badge--up">
<i data-lucide="arrow-up"></i>
<span>+24%</span>
</div>
</div>
<div class="admin-kpi-card__value">1,248</div>
<div class="admin-kpi-card__label">Tổng đánh giá</div>
</div>
<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="reply" style="color:#22C55E;"></i>
</div>
<div class="admin-kpi-card__badge admin-kpi-card__badge--up">
<i data-lucide="arrow-up"></i>
<span>+8%</span>
</div>
</div>
<div class="admin-kpi-card__value">92%</div>
<div class="admin-kpi-card__label">Tỷ lệ phản hồi</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="trending-up" style="color:#8B5CF6;"></i>
</div>
<div class="admin-kpi-card__badge admin-kpi-card__badge--up">
<i data-lucide="arrow-up"></i>
<span>+5</span>
</div>
</div>
<div class="admin-kpi-card__value">72</div>
<div class="admin-kpi-card__label">NPS Score</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>
@* ── FEEDBACK LIST ── *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="message-circle" style="color:var(--admin-orange-primary);"></i>
Phản hồi gần đây
</h3>
<div style="display:flex;gap:8px;">
<div class="admin-search" style="width:200px;">
<i data-lucide="search"></i>
<input type="text" placeholder="Tìm phản hồi..." @bind="SearchQuery" />
</div>
</div>
</div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>Khách hàng</th>
<th>Đánh giá</th>
<th>Nội dung</th>
<th>Cửa hàng</th>
<th>Thời gian</th>
<th>Trạng thái</th>
</tr>
</thead>
<tbody>
@foreach (var fb in _feedbacks)
{
<tr>
<td>
<div style="display:flex;align-items:center;gap:10px;">
<div class="admin-user-avatar" style="width:32px;height:32px;font-size:11px;background-color:@fb.AvatarColor;">@fb.Initials</div>
<span style="font-weight:500;">@fb.Customer</span>
</div>
</td>
<td>
<div style="display:flex;gap:2px;">
@for (int i = 0; i < 5; i++)
{
var idx = i;
<i data-lucide="star" style="width:14px;height:14px;color:@(idx < fb.Stars ? "#F59E0B" : "var(--admin-text-tertiary)");"></i>
}
</div>
</td>
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">@fb.Content</td>
<td style="color:var(--admin-text-secondary);">@fb.Store</td>
<td style="color:var(--admin-text-tertiary);font-size:12px;">@fb.Time</td>
<td>
<div class="admin-status-badge @(fb.Responded ? "admin-status-badge--online" : "admin-status-badge--setup")">
<span class="admin-status-badge__dot"></span>
@(fb.Responded ? "Đã phản hồi" : "Chờ phản hồi")
</div>
</td>
</tr>
}
</tbody>
</table>
</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>
@code {
private record FeedbackItem(string Customer, string Initials, string AvatarColor, int Stars, string Content, string Store, string Time, bool Responded);
private readonly List<FeedbackItem> _feedbacks = new()
{
new("Nguyễn Thị Mai", "NM", "#FF5C00", 5, "Phục vụ rất tốt, cà phê ngon, không gian thoáng mát!", "Coffee House Q1", "2 giờ trước", true),
new("Trần Văn Hùng", "TH", "#8B5CF6", 4, "Đồ ăn ngon nhưng chờ hơi lâu, nhân viên thân thiện.", "Nhà hàng Q3", "5 giờ trước", true),
new("Lê Hoàng Anh", "LA", "#3B82F6", 3, "Chất lượng bình thường, giá hơi cao so với mặt bằng.", "Coffee House Q1", "1 ngày trước", false),
new("Phạm Minh Châu", "PC", "#22C55E", 5, "Tuyệt vời! Sẽ quay lại lần sau. Menu đa dạng.", "Nhà hàng Q3", "1 ngày trước", true),
new("Hoàng Thị Lan", "HL", "#EC4899", 2, "Phục vụ chậm, đồ uống không đúng order.", "Coffee House Q1", "2 ngày trước", false),
new("Võ Đức Mạnh", "VM", "#06B6D4", 4, "Không gian đẹp, nhân viên nhiệt tình, giá hợp lý.", "Nhà hàng Q3", "3 ngày trước", true),
};
}

View File

@@ -1,152 +1,73 @@
@page "/admin/customers/loyalty"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Loyalty program — tier configuration (Bronze/Silver/Gold/Diamond), points per VND, rewards catalog, member stats.
VI: Chương trình khách hàng thân thiết — cấu hình hạng (Bronze/Silver/Gold/Diamond), điểm/VND, danh mục quà, thống kê thành viên.
Design: pencil-design/src/pages/tPOS/admin/loyalty-program.pen
*@
<PageTitle>Chương trình thành viên — GoodGo Admin</PageTitle>
<PageTitle>Khách hàng thân thiết — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Khách hàng thân thiết</h1>
<p class="admin-topbar__subtitle">Quản lý chương trình loyalty & phần thưởng</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-secondary">
<i data-lucide="settings"></i>
<span>Cài đặt điểm</span>
</button>
<button class="admin-btn-primary">
<i data-lucide="gift"></i>
<span>Thêm phần thưởng</span>
</button>
<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>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:24px;">
@* ── TIER CARDS ── *@
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;">
@foreach (var tier in _tiers)
{
<div class="admin-kpi-card" style="border:1px solid @(tier.Color)30;">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:@(tier.Color)20;">
<i data-lucide="@tier.Icon" style="color:@tier.Color;"></i>
</div>
<div style="padding:4px 10px;border-radius:6px;font-size:11px;font-weight:600;background-color:@(tier.Color)20;color:@tier.Color;">
@tier.Members thành viên
<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 class="admin-kpi-card__value" style="color:@tier.Color;">@tier.Name</div>
<div class="admin-kpi-card__label">Từ @tier.MinPoints điểm • @tier.Discount giảm giá</div>
</div>
}
</div>
@* ── BOTTOM ROW: Settings + Rewards ── *@
<div style="display:flex;gap:24px;flex:1;min-height:0;">
@* LEFT: Points Settings *@
<div class="admin-panel" style="width:360px;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="coins" style="color:var(--admin-orange-primary);"></i>
Cài đặt điểm
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Tỷ lệ tích điểm</label>
<input class="admin-form-input" type="text" value="1,000 VND = 1 điểm" readonly />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Điểm hết hạn sau</label>
<input class="admin-form-input" type="text" value="12 tháng" readonly />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Điểm thưởng sinh nhật</label>
<input class="admin-form-input" type="text" value="x2 điểm" readonly />
</div>
<div style="padding-top:8px;display:flex;flex-direction:column;gap:8px;">
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 12px;background:var(--admin-bg-interactive);border-radius:10px;">
<span style="font-size:13px;font-weight:500;">Cho phép đổi điểm</span>
<MudSwitch T="bool" Value="true" Color="Color.Primary" />
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 12px;background:var(--admin-bg-interactive);border-radius:10px;">
<span style="font-size:13px;font-weight:500;">Tự động nâng hạng</span>
<MudSwitch T="bool" Value="true" Color="Color.Primary" />
</div>
</div>
</div>
}
</div>
@* RIGHT: Rewards Catalog *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="gift" style="color:#EC4899;"></i>
Danh mục phần thưởng
</h3>
<a class="admin-panel__action">Quản lý tất cả →</a>
</div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>Phần thưởng</th>
<th>Điểm cần</th>
<th>Hạng tối thiểu</th>
<th>Đã đổi</th>
<th>Trạng thái</th>
</tr>
</thead>
<tbody>
@foreach (var rw in _rewards)
{
<tr>
<td style="font-weight:500;">@rw.Name</td>
<td style="font-weight:600;color:var(--admin-orange-primary);">@rw.Points</td>
<td>@rw.MinTier</td>
<td>@rw.Redeemed lượt</td>
<td>
<div class="admin-status-badge @(rw.Active ? "admin-status-badge--online" : "admin-status-badge--offline")">
<span class="admin-status-badge__dot"></span>
@(rw.Active ? "Hoạt động" : "Tạm dừng")
</div>
</td>
</tr>
}
</tbody>
</table>
<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>
}
</div>
@code {
private record TierDef(string Name, string Icon, string Color, string MinPoints, string Discount, string Members);
private readonly TierDef[] _tiers = new[]
{
new TierDef("Bronze", "award", "#CD7F32", "0", "5%", "124"),
new TierDef("Silver", "award", "#8B8B90", "500", "10%", "86"),
new TierDef("Gold", "crown", "#F59E0B", "2,000", "15%", "42"),
new TierDef("Diamond", "gem", "#8B5CF6", "5,000", "20%", "18"),
};
private List<PosDataService.LevelDefinitionInfo> _levels = new();
private record RewardDef(string Name, string Points, string MinTier, string Redeemed, bool Active);
private readonly List<RewardDef> _rewards = new()
protected override async Task OnInitializedAsync()
{
new("Giảm 10% đơn hàng", "200", "Bronze", "342", true),
new("Miễn phí 1 ly cà phê", "500", "Silver", "128", true),
new("Combo bữa trưa miễn phí", "1,500", "Gold", "45", true),
new("Voucher 500K", "3,000", "Gold", "22", true),
new("Tiệc sinh nhật VIP", "5,000", "Diamond", "8", false),
};
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

@@ -1,147 +1,62 @@
@page "/admin/finance/expenses"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Expense Management — expense categories, add expense form, approval status.
VI: Quản lý chi phí — danh mục chi phí, thêm chi phí, trạng thái duyệt.
Design: pencil-design/src/pages/tPOS/admin/expense-management.pen
*@
<PageTitle>Chi phí — GoodGo Admin</PageTitle>
<PageTitle>Quản lý chi phí — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<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">Tháng 02/2025 • @_expenses.Length khoản chi</p>
</div>
<div class="admin-topbar__right">
<div class="admin-search" style="width:220px;">
<i data-lucide="search"></i>
<input type="text" placeholder="Tìm chi phí..." @bind="SearchQuery" />
</div>
<button class="admin-btn-primary">
<i data-lucide="plus"></i>
<span>Thêm chi phí</span>
</button>
<p class="admin-topbar__subtitle">@_txns.Count giao dịch</p>
</div>
</div>
@* ═══ TABS ═══ *@
<div class="admin-tabs">
<button class="admin-tab @(_tab == "all" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "all")">
Tất cả <span class="admin-tab__badge @(_tab == "all" ? "admin-tab__badge--active" : "")">@_expenses.Length</span>
</button>
<button class="admin-tab @(_tab == "pending" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "pending")">
Chờ duyệt <span class="admin-tab__badge">3</span>
</button>
<button class="admin-tab @(_tab == "approved" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "approved")">
Đã duyệt <span class="admin-tab__badge">4</span>
</button>
<button class="admin-tab @(_tab == "rejected" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "rejected")">
Từ chối <span class="admin-tab__badge">1</span>
</button>
</div>
@* ═══ 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(239,68,68,0.125);">
<i data-lucide="credit-card" style="color:#EF4444;"></i>
</div>
</div>
<div class="admin-kpi-card__value">142.5M</div>
<div class="admin-kpi-card__label">Tổng chi phí tháng</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>
<div class="admin-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(245,158,11,0.125);">
<i data-lucide="clock" style="color:#F59E0B;"></i>
</div>
</div>
<div class="admin-kpi-card__value">18.2M</div>
<div class="admin-kpi-card__label">Chờ duyệt</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="repeat" style="color:#8B5CF6;"></i>
</div>
</div>
<div class="admin-kpi-card__value">68.0M</div>
<div class="admin-kpi-card__label">Chi phí định kỳ</div>
</div>
</div>
@* Expense Table *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="list" style="color:var(--admin-orange-primary);"></i>
Danh sách chi phí
</h3>
</div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>Mô tả</th>
<th>Danh mục</th>
<th>Cửa hàng</th>
<th>Ngày</th>
<th>Trạng thái</th>
<th style="text-align:right;">Số tiền</th>
</tr>
</thead>
<tbody>
@foreach (var item in _expenses)
}
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>
<td style="font-weight:600;">@item.Desc</td>
<td style="color:var(--admin-text-tertiary);">@item.Category</td>
<td>@item.Store</td>
<td style="color:var(--admin-text-tertiary);">@item.Date</td>
<td>
<div class="admin-status-badge @GetStatusClass(item.Status)" style="font-size:10px;padding:2px 8px;">
<span class="admin-status-badge__dot"></span>
@item.Status
</div>
</td>
<td style="text-align:right;font-weight:600;color:#EF4444;">-@item.Amount</td>
<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>
</tbody></table>
</div>
</div>
</div>
}
</div>
@code {
private string _tab = "all";
private List<PosDataService.WalletTxnInfo> _txns = new();
private string GetStatusClass(string status) => status switch
protected override async Task OnInitializedAsync()
{
"Đã duyệt" => "admin-status-badge--online",
"Chờ duyệt" => "admin-status-badge--setup",
"Từ chối" => "admin-status-badge--offline",
_ => ""
};
private record ExpenseRow(string Desc, string Category, string Store, string Date, string Status, string Amount);
private readonly ExpenseRow[] _expenses = new[]
{
new ExpenseRow("Nhập cà phê Arabica", "Nguyên vật liệu", "Coffee House Q1", "12/02", "Đã duyệt", "8.5M"),
new ExpenseRow("Tiền thuê T2/2025", "Mặt bằng", "Nhà hàng Q3", "01/02", "Đã duyệt", "15.0M"),
new ExpenseRow("Sửa máy pha cà phê", "Bảo trì", "Coffee House Q1", "10/02", "Chờ duyệt", "3.2M"),
new ExpenseRow("Tiền điện T1/2025", "Tiện ích", "Nhà hàng Q3", "05/02", "Đã duyệt", "4.8M"),
new ExpenseRow("Quảng cáo Facebook", "Marketing", "Tất cả", "08/02", "Chờ duyệt", "6.2M"),
new ExpenseRow("Đồng phục nhân viên", "Vận hành", "Coffee House Q1", "07/02", "Đã duyệt", "2.4M"),
new ExpenseRow("Mua bàn ghế mới", "Trang thiết bị", "Nhà hàng Q3", "09/02", "Chờ duyệt", "8.8M"),
new ExpenseRow("In menu mới", "Marketing", "Tất cả", "06/02", "Từ chối", "1.5M"),
};
IsLoading = true;
try { _txns = await DataService.GetWalletTransactionsAsync(100); }
catch { } finally { IsLoading = false; }
}
}

View File

@@ -1,178 +1,108 @@
@page "/admin/finance"
@layout AdminLayout
@inherits AdminBase
@*
EN: Financial Overview — revenue KPIs, expense breakdown, recent transactions.
VI: Tổng quan tài chính — KPI doanh thu, phân tích chi phí, giao dịch gần đây.
Design: pencil-design/src/pages/tPOS/admin/financial-overview.pen
*@
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Tài chính — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<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">Tháng 02/2025 • Tất cả cửa hàng</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-secondary">
<i data-lucide="calendar"></i>
<span>02/2025</span>
</button>
<button class="admin-btn-primary">
<i data-lucide="download"></i>
<span>Xuất báo cáo</span>
</button>
<p class="admin-topbar__subtitle">@_orders.Count đơn hàng • @_wallets.Count ví</p>
</div>
</div>
@* ═══ 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>+18.2%</span>
</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 class="admin-kpi-card__value">256.8M</div>
<div class="admin-kpi-card__label">Tổng doanh thu</div>
</div>
<div class="admin-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(239,68,68,0.125);">
<i data-lucide="trending-down" style="color:#EF4444;"></i>
</div>
<div class="admin-kpi-card__badge admin-kpi-card__badge--down">
<i data-lucide="arrow-up"></i>
<span>+5.3%</span>
</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 class="admin-kpi-card__value">142.5M</div>
<div class="admin-kpi-card__label">Tổng chi phí</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="wallet" style="color:#3B82F6;"></i>
</div>
<div class="admin-kpi-card__badge admin-kpi-card__badge--up">
<i data-lucide="arrow-up"></i>
<span>+24.1%</span>
</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 class="admin-kpi-card__value">114.3M</div>
<div class="admin-kpi-card__label">Lợi nhuận rò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="percent" style="color:#8B5CF6;"></i>
</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 class="admin-kpi-card__value">44.5%</div>
<div class="admin-kpi-card__label">Biên lợi nhuận</div>
</div>
</div>
@* ── BOTTOM: Expense Breakdown + Transactions ── *@
<div style="display:flex;gap:24px;flex:1;min-height:0;">
@* ── LEFT: Expense Breakdown ── *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="pie-chart" style="color:var(--admin-orange-primary);"></i>
Phân tích chi phí
</h3>
<a href="/admin/finance/expenses" class="admin-panel__action">Chi tiết →</a>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
@foreach (var exp in _expenses)
{
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background-color:var(--admin-bg-interactive);border-radius:var(--admin-radius-md);">
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:8px;height:8px;border-radius:50%;background-color:@exp.Color;"></div>
<span style="font-size:13px;">@exp.Category</span>
</div>
<div style="display:flex;align-items:center;gap:16px;">
<span style="font-size:13px;font-weight:600;">@exp.Amount</span>
<span style="font-size:11px;color:var(--admin-text-tertiary);">@exp.Percent</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>
@* ── RIGHT: Recent Transactions ── *@
<div class="admin-panel" style="flex:1.2;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="receipt" style="color:#22C55E;"></i>
Giao dịch gần đây
</h3>
</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">
<thead>
<tr>
<th>Mã GD</th>
<th>Mô tả</th>
<th>Cửa hàng</th>
<th>Loại</th>
<th style="text-align:right;">Số tiền</th>
</tr>
</thead>
<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 tx in _transactions)
@foreach (var o in _orders.Take(20))
{
<tr>
<td style="font-weight:600;">@tx.Code</td>
<td>@tx.Desc</td>
<td style="color:var(--admin-text-tertiary);">@tx.Store</td>
<td>
<div class="admin-status-badge @(tx.IsIncome ? "admin-status-badge--online" : "admin-status-badge--offline")" style="font-size:10px;padding:2px 8px;">
@(tx.IsIncome ? "Thu" : "Chi")
</div>
</td>
<td style="text-align:right;font-weight:600;color:@(tx.IsIncome ? "#22C55E" : "#EF4444");">@tx.Amount</td>
<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>
}
</div>
@code {
private record ExpenseItem(string Category, string Amount, string Percent, string Color);
private readonly ExpenseItem[] _expenses = new[]
{
new ExpenseItem("Nguyên vật liệu", "52.4M", "36.8%", "#FF5C00"),
new ExpenseItem("Lương nhân viên", "38.6M", "27.1%", "#3B82F6"),
new ExpenseItem("Thuê mặt bằng", "28.0M", "19.6%", "#8B5CF6"),
new ExpenseItem("Tiện ích (điện, nước)", "12.5M", "8.8%", "#F59E0B"),
new ExpenseItem("Marketing", "6.2M", "4.3%", "#EC4899"),
new ExpenseItem("Khác", "4.8M", "3.4%", "#22C55E"),
};
private List<PosDataService.OrderInfo> _orders = new();
private List<PosDataService.WalletInfo> _wallets = new();
private decimal _totalRevenue, _totalExpense;
private record TransactionItem(string Code, string Desc, string Store, bool IsIncome, string Amount);
private readonly TransactionItem[] _transactions = new[]
protected override async Task OnInitializedAsync()
{
new TransactionItem("GD-2851", "Doanh thu bán hàng", "Coffee House Q1", true, "+8.5M"),
new TransactionItem("GD-2850", "Nhập nguyên liệu", "Coffee House Q1", false, "-3.2M"),
new TransactionItem("GD-2849", "Doanh thu bán hàng", "Nhà hàng Q3", true, "+12.4M"),
new TransactionItem("GD-2848", "Tiền thuê T2/2025", "Nhà hàng Q3", false, "-15.0M"),
new TransactionItem("GD-2847", "Doanh thu bán hàng", "Coffee House Q1", true, "+6.8M"),
new TransactionItem("GD-2846", "Lương nhân viên", "Tất cả", false, "-38.6M"),
};
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,205 +1,86 @@
@page "/admin/finance/revenue"
@layout AdminLayout
@inherits AdminBase
@*
EN: Revenue Analytics — revenue by store, category, time period, top products.
VI: Phân tích doanh thu — doanh thu theo cửa hàng, danh mục, kỳ, sản phẩm bán chạy.
Design: pencil-design/src/pages/tPOS/admin/revenue-analytics.pen
*@
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Phân tích doanh thu — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<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">Tháng 02/2025 • So sánh theo cửa hàng</p>
<p class="admin-topbar__subtitle">@_orders.Count đơn hàng • @FormatVND(_orders.Sum(o => o.TotalAmount))</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-secondary">
<i data-lucide="calendar"></i>
<span>Tháng này</span>
</button>
<button class="admin-btn-secondary">
<i data-lucide="filter"></i>
<span>Bộ lọc</span>
</button>
<button class="admin-btn-primary">
<i data-lucide="download"></i>
<span>Xuất báo cáo</span>
</button>
<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>
@* ═══ 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>+18.2%</span>
</div>
</div>
<div class="admin-kpi-card__value">256.8M</div>
<div class="admin-kpi-card__label">Tổng doanh thu</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>
<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">132K</div>
<div class="admin-kpi-card__label">Giá trị đơn TB</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-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(255,92,0,0.125);">
<i data-lucide="store" style="color:var(--admin-orange-primary);"></i>
</div>
</div>
<div class="admin-kpi-card__value">Nhà hàng Q3</div>
<div class="admin-kpi-card__label">Cửa hàng dẫn đầu</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="bar-chart-3" style="color:#8B5CF6;"></i>
</div>
<div class="admin-kpi-card__badge admin-kpi-card__badge--up">
<i data-lucide="arrow-up"></i>
<span>+12.4%</span>
</div>
</div>
<div class="admin-kpi-card__value">+18.2%</div>
<div class="admin-kpi-card__label">Tỷ lệ tăng trưởng</div>
</div>
</div>
@* Revenue by Store + Revenue by Category *@
<div style="display:flex;gap:24px;">
@* Revenue by Store *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="store" style="color:var(--admin-orange-primary);"></i>
Doanh thu theo cửa hàng
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
@foreach (var s in _storeRevenue)
{
<div style="display:flex;align-items:center;gap:12px;padding:10px 14px;background-color:var(--admin-bg-interactive);border-radius:var(--admin-radius-md);">
<div style="width:36px;height:36px;border-radius:var(--admin-radius-md);background-color:@(s.BgColor);display:flex;align-items:center;justify-content:center;">
<i data-lucide="@s.Icon" style="color:@s.IconColor;width:18px;height:18px;"></i>
</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;">@s.Name</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">@s.Orders đơn hàng</div>
</div>
<div style="text-align:right;">
<div style="font-size:14px;font-weight:700;color:var(--admin-orange-primary);">@s.Revenue</div>
<div style="font-size:11px;color:#22C55E;">@s.Growth</div>
</div>
</div>
}
</div>
</div>
@* Revenue by Category *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="layers" style="color:#3B82F6;"></i>
Doanh thu theo danh mục
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:10px;">
@foreach (var c in _categoryRevenue)
{
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background-color:var(--admin-bg-interactive);border-radius:var(--admin-radius-md);">
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:8px;height:8px;border-radius:50%;background-color:@c.Color;"></div>
<span style="font-size:13px;">@c.Category</span>
</div>
<div style="display:flex;align-items:center;gap:16px;">
<span style="font-size:13px;font-weight:600;">@c.Amount</span>
<span style="font-size:11px;color:var(--admin-text-tertiary);">@c.Percent</span>
</div>
</div>
}
</div>
</div>
</div>
@* Top-selling Products Table *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="trophy" style="color:#F59E0B;"></i>
Sản phẩm bán chạy nhất
</h3>
</div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>#</th>
<th>Sản phẩm</th>
<th>Danh mục</th>
<th>Số lượng bán</th>
<th style="text-align:right;">Doanh thu</th>
</tr>
</thead>
<tbody>
@foreach (var p in _topProducts)
<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>
<td style="font-weight:700;color:var(--admin-orange-primary);">@p.Rank</td>
<td style="font-weight:600;">@p.Name</td>
<td style="color:var(--admin-text-tertiary);">@p.Category</td>
<td>@p.Sold</td>
<td style="text-align:right;font-weight:600;">@p.Revenue</td>
<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>
</tbody></table>
</div>
</div>
</div>
}
</div>
@code {
private record StoreRevItem(string Name, string Icon, string IconColor, string BgColor, string Revenue, string Orders, string Growth);
private readonly StoreRevItem[] _storeRevenue = new[]
{
new StoreRevItem("Nhà hàng Q3", "utensils", "#3B82F6", "rgba(59,130,246,0.125)", "128.4M", "842", "+22.1%"),
new StoreRevItem("Coffee House Q1", "coffee", "#FF5C00", "rgba(255,92,0,0.125)", "96.2M", "1,024", "+15.3%"),
new StoreRevItem("Karaoke Star Q7", "mic", "#8B5CF6", "rgba(139,92,246,0.125)", "32.2M", "156", "+8.7%"),
};
private List<PosDataService.OrderInfo> _orders = new();
private List<PosDataService.ShopInfo> _shops = new();
private Guid? _selectedShopId;
private record CategoryRevItem(string Category, string Amount, string Percent, string Color);
private readonly CategoryRevItem[] _categoryRevenue = new[]
protected override async Task OnInitializedAsync()
{
new CategoryRevItem("Đồ uống", "108.5M", "42.2%", "#FF5C00"),
new CategoryRevItem("Đồ ăn", "82.3M", "32.1%", "#3B82F6"),
new CategoryRevItem("Dịch vụ phòng", "32.2M", "12.5%", "#8B5CF6"),
new CategoryRevItem("Khác", "33.8M", "13.2%", "#22C55E"),
};
IsLoading = true;
try { _shops = await DataService.GetShopsAsync(); _orders = await DataService.GetOrdersAsync(); }
catch { } finally { IsLoading = false; }
}
private record TopProductItem(string Rank, string Name, string Category, string Sold, string Revenue);
private readonly TopProductItem[] _topProducts = new[]
private async Task OnShopFilterChanged(ChangeEventArgs e)
{
new TopProductItem("1", "Espresso", "Cà phê", "1,024", "35.8M"),
new TopProductItem("2", "Cappuccino", "Cà phê", "856", "38.5M"),
new TopProductItem("3", "Phở bò đặc biệt", "Đồ ăn", "742", "44.5M"),
new TopProductItem("4", "Latte", "Cà phê", "631", "30.9M"),
new TopProductItem("5", "Trà sen vàng", "Trà", "520", "21.8M"),
};
_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,176 +1,75 @@
@page "/admin/finance/tax"
@layout AdminLayout
@inherits AdminBase
@*
EN: Tax Configuration — tax rate settings, categories, exemptions, report preview.
VI: Cấu hình thuế — cài đặt thuế suất, danh mục, miễn thuế, xem trước báo cáo.
Design: pencil-design/src/pages/tPOS/admin/tax-configuration.pen
*@
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Cấu hình thuế — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<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ế suất & danh mục thuế</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-secondary">
<i data-lucide="file-text"></i>
<span>Xem báo cáo thuế</span>
</button>
<button class="admin-btn-primary">
<i data-lucide="save"></i>
<span>Lưu cấu hình</span>
</button>
<p class="admin-topbar__subtitle">Quản lý thuế theo cửa hàng</p>
</div>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:24px;">
@* Tax Rate Settings + Tax Categories *@
<div style="display:flex;gap:24px;">
@* LEFT: Tax Rate Settings *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="settings" style="color:var(--admin-orange-primary);"></i>
Cài đặt thuế suất
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Thuế VAT (%)</label>
<input type="text" class="admin-form-input" value="10" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Phí dịch vụ (%)</label>
<input type="text" class="admin-form-input" value="5" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Thuế TNCN (%)</label>
<input type="text" class="admin-form-input" value="10" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Thuế môi trường</label>
<input type="text" class="admin-form-input" value="0" />
</div>
</div>
</div>
@* RIGHT: Tax Categories *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="tags" style="color:#3B82F6;"></i>
Danh mục thuế
</h3>
<button class="admin-panel__action">+ Thêm danh mục</button>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:10px;">
@foreach (var cat in _taxCategories)
<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())
{
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background-color:var(--admin-bg-interactive);border-radius:var(--admin-radius-md);">
<div style="display:flex;align-items:center;gap:10px;">
<i data-lucide="@cat.Icon" style="width:16px;height:16px;color:@cat.Color;"></i>
<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>
<div style="font-size:13px;font-weight:600;">@cat.Name</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">@cat.Desc</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 style="font-size:14px;font-weight:700;color:var(--admin-orange-primary);">@cat.Rate</div>
</div>
}
}
</div>
</div>
</div>
@* Tax-exempt Products + Tax Report Preview *@
<div style="display:flex;gap:24px;">
@* LEFT: Tax-exempt Products *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="shield-off" style="color:#F59E0B;"></i>
Sản phẩm miễn thuế
</h3>
<button class="admin-panel__action">+ Thêm SP</button>
</div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>Sản phẩm</th>
<th>Danh mục</th>
<th>Lý do</th>
</tr>
</thead>
<tbody>
@foreach (var item in _exemptProducts)
{
<tr>
<td style="font-weight:600;">@item.Name</td>
<td style="color:var(--admin-text-tertiary);">@item.Category</td>
<td style="font-size:12px;">@item.Reason</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@* RIGHT: Tax Report Preview *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="file-bar-chart" style="color:#22C55E;"></i>
Xem trước báo cáo thuế
</h3>
</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 line in _taxPreview)
@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;padding:10px 14px;background-color:var(--admin-bg-interactive);border-radius:var(--admin-radius-md);">
<span style="font-size:13px;color:var(--admin-text-secondary);">@line.Label</span>
<span style="font-size:13px;font-weight:@(line.IsBold ? "700" : "500");color:@(line.IsBold ? "var(--admin-orange-primary)" : "var(--admin-text-primary)");">@line.Value</span>
<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>
}
</div>
@code {
private record TaxCategory(string Name, string Desc, string Rate, string Icon, string Color);
private readonly TaxCategory[] _taxCategories = new[]
{
new TaxCategory("VAT", "Thuế giá trị gia tăng", "10%", "receipt", "#FF5C00"),
new TaxCategory("Phí dịch vụ", "Áp dụng cho dine-in", "5%", "utensils", "#3B82F6"),
new TaxCategory("TNCN", "Thuế thu nhập cá nhân", "10%", "user", "#8B5CF6"),
new TaxCategory("Đồ uống có cồn", "Thuế tiêu thụ đặc biệt", "35%", "wine", "#EF4444"),
};
private List<PosDataService.ShopInfo> _shops = new();
private record ExemptProduct(string Name, string Category, string Reason);
private readonly ExemptProduct[] _exemptProducts = new[]
protected override async Task OnInitializedAsync()
{
new ExemptProduct("Nước lọc", "Đồ uống", "Miễn VAT theo QĐ"),
new ExemptProduct("Cơm trắng", "Đồ ăn", "Lương thực thiết yếu"),
new ExemptProduct("Trà đá", "Đồ uống", "Đồ uống cơ bản"),
};
private record TaxPreviewLine(string Label, string Value, bool IsBold);
private readonly TaxPreviewLine[] _taxPreview = new[]
{
new TaxPreviewLine("Doanh thu trước thuế", "256.8M", false),
new TaxPreviewLine("VAT (10%)", "25.7M", false),
new TaxPreviewLine("Phí dịch vụ (5%)", "8.4M", false),
new TaxPreviewLine("Thuế TTĐB", "2.1M", false),
new TaxPreviewLine("Tổng thuế phải nộp", "36.2M", true),
new TaxPreviewLine("Doanh thu sau thuế", "220.6M", true),
};
IsLoading = true;
try { _shops = await DataService.GetShopsAsync(); }
catch { } finally { IsLoading = false; }
}
}

View File

@@ -1,147 +1,79 @@
@page "/admin/inventory/orders"
@page "/admin/inventory/purchase-orders"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Purchase Orders — PO list with status, create PO, filter by supplier/status.
VI: Đơn đặt hàng — danh sách PO, tạo PO, lọc theo NCC/trạng thái.
Design: pencil-design/src/pages/tPOS/admin/purchase-orders.pen
*@
<PageTitle>Đơn nhập hàng — GoodGo Admin</PageTitle>
<PageTitle>Đơn đặt hàng — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Đơn đặt hàng</h1>
<p class="admin-topbar__subtitle">@_orders.Length đơn hàng • Tháng 02/2025</p>
<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">
<div class="admin-search" style="width:220px;">
<i data-lucide="search"></i>
<input type="text" placeholder="Tìm đơn hàng..." @bind="SearchQuery" />
</div>
<button class="admin-btn-primary">
<i data-lucide="plus"></i>
<span>Tạo đơn hàng</span>
</button>
<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>
@* ═══ TABS ═══ *@
<div class="admin-tabs">
<button class="admin-tab @(_tab == "all" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "all")">
Tất cả <span class="admin-tab__badge @(_tab == "all" ? "admin-tab__badge--active" : "")">@_orders.Length</span>
</button>
<button class="admin-tab @(_tab == "pending" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "pending")">
Chờ xác nhận <span class="admin-tab__badge">3</span>
</button>
<button class="admin-tab @(_tab == "confirmed" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "confirmed")">
Đã xác nhận <span class="admin-tab__badge">2</span>
</button>
<button class="admin-tab @(_tab == "delivered" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "delivered")">
Đã giao <span class="admin-tab__badge">3</span>
</button>
</div>
@* ═══ 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(255,92,0,0.125);">
<i data-lucide="file-text" style="color:var(--admin-orange-primary);"></i>
</div>
</div>
<div class="admin-kpi-card__value">@_orders.Length</div>
<div class="admin-kpi-card__label">Tổng đơn hàng</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>
<div class="admin-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(245,158,11,0.125);">
<i data-lucide="clock" style="color:#F59E0B;"></i>
</div>
</div>
<div class="admin-kpi-card__value">3</div>
<div class="admin-kpi-card__label">Chờ xác nhận</div>
</div>
<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="wallet" style="color:#22C55E;"></i>
</div>
</div>
<div class="admin-kpi-card__value">68.5M</div>
<div class="admin-kpi-card__label">Tổng giá trị PO</div>
</div>
</div>
@* PO Table *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="clipboard-list" style="color:var(--admin-orange-primary);"></i>
Danh sách đơn đặt hàng
</h3>
</div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>Mã PO</th>
<th>Nhà cung cấp</th>
<th>Số mặt hàng</th>
<th>Ngày đặt</th>
<th>Trạng thái</th>
<th style="text-align:right;">Tổng tiền</th>
</tr>
</thead>
<tbody>
@foreach (var order in _orders)
}
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>
<td style="font-weight:600;">@order.Code</td>
<td>@order.Supplier</td>
<td>@order.Items mặt hàng</td>
<td style="color:var(--admin-text-tertiary);">@order.Date</td>
<td>
<div class="admin-status-badge @GetPOStatusClass(order.Status)" style="font-size:10px;padding:2px 8px;">
<span class="admin-status-badge__dot"></span>
@order.Status
</div>
</td>
<td style="text-align:right;font-weight:600;color:var(--admin-orange-primary);">@order.Total</td>
<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>
</tbody></table>
</div>
</div>
</div>
}
</div>
@code {
private string _tab = "all";
private List<PosDataService.InventoryTxnInfo> _txns = new();
private List<PosDataService.ShopInfo> _shops = new();
private Guid? _selectedShopId;
private string GetPOStatusClass(string status) => status switch
protected override async Task OnInitializedAsync()
{
"Đã giao" => "admin-status-badge--online",
"Đã xác nhận" => "admin-status-badge--paused",
"Chờ xác nhận" => "admin-status-badge--setup",
_ => ""
};
IsLoading = true;
try { _shops = await DataService.GetShopsAsync(); _txns = await DataService.GetInventoryTransactionsAsync(); }
catch { } finally { IsLoading = false; }
}
private record POItem(string Code, string Supplier, string Items, string Date, string Status, string Total);
private readonly POItem[] _orders = new[]
private async Task OnShopFilterChanged(ChangeEventArgs e)
{
new POItem("PO-0042", "Cà phê Trung Nguyên", "5", "12/02/2025", "Chờ xác nhận", "18.5M"),
new POItem("PO-0041", "Vinamilk", "3", "10/02/2025", "Đã xác nhận", "8.2M"),
new POItem("PO-0040", "Đại Tân Phát", "8", "09/02/2025", "Chờ xác nhận", "12.4M"),
new POItem("PO-0039", "TH True Milk", "2", "08/02/2025", "Đã giao", "4.8M"),
new POItem("PO-0038", "Cà phê Trung Nguyên", "4", "06/02/2025", "Đã giao", "14.2M"),
new POItem("PO-0037", "Metro Cash & Carry", "12", "05/02/2025", "Đã xác nhận", "6.5M"),
new POItem("PO-0036", "Saigon Food", "6", "03/02/2025", "Chờ xác nhận", "5.8M"),
new POItem("PO-0035", "Vinamilk", "3", "01/02/2025", "Đã giao", "3.9M"),
};
_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,174 +1,49 @@
@page "/admin/inventory/transfer"
@page "/admin/inventory/transfers"
@layout AdminLayout
@inherits AdminBase
@*
EN: Stock Transfer — transfer between stores, transfer history, pending transfers.
VI: Chuyển kho — chuyển hàng giữa cửa hàng, lịch sử, đang chờ.
Design: pencil-design/src/pages/tPOS/admin/stock-transfer.pen
*@
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Chuyển kho — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Chuyển kho</h1>
<p class="admin-topbar__subtitle">Chuyển hàng giữa các cửa hàng</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-primary">
<i data-lucide="arrow-right-left"></i>
<span>Tạo phiếu chuyển</span>
</button>
<p class="admin-topbar__subtitle">Quản lý chuyển hàng giữa các cửa hàng</p>
</div>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;gap:24px;">
@* ── LEFT: Transfer Form ── *@
<div class="admin-panel" style="width:380px;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="send" style="color:var(--admin-orange-primary);"></i>
Tạo phiếu chuyển kho
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Kho gửi</label>
<select class="admin-form-input">
<option>Coffee House Q1</option>
<option>Nhà hàng Q3</option>
<option>Karaoke Star Q7</option>
</select>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Kho nhận</label>
<select class="admin-form-input">
<option>Nhà hàng Q3</option>
<option>Coffee House Q1</option>
<option>Karaoke Star Q7</option>
</select>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Sản phẩm</label>
<select class="admin-form-input">
<option>Chọn sản phẩm...</option>
<option>Cà phê Arabica</option>
<option>Sữa tươi</option>
<option>Ly giấy 12oz</option>
</select>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Số lượng</label>
<input type="number" class="admin-form-input" placeholder="Nhập số lượng" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Ghi chú</label>
<textarea class="admin-form-input" rows="2" placeholder="Ghi chú (tuỳ chọn)"></textarea>
</div>
<button class="admin-btn-primary" style="width:100%;justify-content:center;">
<i data-lucide="check"></i>
<span>Xác nhận chuyển kho</span>
</button>
</div>
</div>
@* ── RIGHT: Transfer History ── *@
<div style="flex:1;display:flex;flex-direction:column;gap:20px;">
@* Pending Transfers *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="clock" style="color:#F59E0B;"></i>
Đang chờ xử lý
<span class="admin-badge-count admin-badge-count--danger">2</span>
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:10px;">
@foreach (var t in _pendingTransfers)
<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="display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background-color:var(--admin-bg-interactive);border-radius:var(--admin-radius-md);">
<div style="display:flex;align-items:center;gap:10px;">
<i data-lucide="arrow-right-left" style="width:16px;height:16px;color:#F59E0B;"></i>
<div>
<div style="font-size:13px;font-weight:600;">@t.Item — @t.Qty @t.Unit</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">@t.From → @t.To</div>
</div>
</div>
<div class="admin-status-badge admin-status-badge--setup" style="font-size:10px;padding:2px 8px;">
<span class="admin-status-badge__dot"></span>
Chờ xác nhận
</div>
<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>
@* Transfer History Table *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="history" style="color:#3B82F6;"></i>
Lịch sử chuyển kho
</h3>
</div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>Mã phiếu</th>
<th>Sản phẩm</th>
<th>SL</th>
<th>Từ</th>
<th>Đến</th>
<th>Ngày</th>
<th>Trạng thái</th>
</tr>
</thead>
<tbody>
@foreach (var h in _transferHistory)
{
<tr>
<td style="font-weight:600;">@h.Code</td>
<td>@h.Item</td>
<td>@h.Qty @h.Unit</td>
<td style="color:var(--admin-text-tertiary);">@h.From</td>
<td style="color:var(--admin-text-tertiary);">@h.To</td>
<td style="color:var(--admin-text-tertiary);">@h.Date</td>
<td>
<div class="admin-status-badge admin-status-badge--online" style="font-size:10px;padding:2px 8px;">
<span class="admin-status-badge__dot"></span>
Hoàn thành
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>
@code {
private record PendingTransfer(string Item, string Qty, string Unit, string From, string To);
private readonly PendingTransfer[] _pendingTransfers = new[]
{
new PendingTransfer("Cà phê Arabica", "10", "kg", "Coffee House Q1", "Nhà hàng Q3"),
new PendingTransfer("Ly giấy 12oz", "2", "thùng", "Nhà hàng Q3", "Coffee House Q1"),
};
private List<PosDataService.ShopInfo> _shops = new();
private record TransferHistory(string Code, string Item, string Qty, string Unit, string From, string To, string Date);
private readonly TransferHistory[] _transferHistory = new[]
protected override async Task OnInitializedAsync()
{
new TransferHistory("CK-0028", "Sữa tươi", "5", "thùng", "Nhà hàng Q3", "Coffee House Q1", "10/02"),
new TransferHistory("CK-0027", "Trà Oolong", "3", "kg", "Coffee House Q1", "Nhà hàng Q3", "08/02"),
new TransferHistory("CK-0026", "Đường", "10", "kg", "Nhà hàng Q3", "Coffee House Q1", "06/02"),
new TransferHistory("CK-0025", "Ly giấy 12oz", "4", "thùng", "Coffee House Q1", "Karaoke Q7", "04/02"),
new TransferHistory("CK-0024", "Cà phê Robusta", "8", "kg", "Nhà hàng Q3", "Coffee House Q1", "02/02"),
};
IsLoading = true;
try { _shops = await DataService.GetShopsAsync(); }
catch { } finally { IsLoading = false; }
}
}

View File

@@ -1,92 +1,26 @@
@page "/admin/inventory/suppliers"
@layout AdminLayout
@inherits AdminBase
@*
EN: Supplier Management — supplier list, add supplier, order history, ratings.
VI: Quản lý nhà cung cấp — danh sách NCC, thêm NCC, lịch sử đặt, đánh giá.
Design: pencil-design/src/pages/tPOS/admin/supplier-management.pen
*@
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Nhà cung cấp — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Nhà cung cấp</h1>
<p class="admin-topbar__subtitle">@_suppliers.Length nhà cung cấp • Quản lý & đánh giá</p>
</div>
<div class="admin-topbar__right">
<div class="admin-search" style="width:220px;">
<i data-lucide="search"></i>
<input type="text" placeholder="Tìm NCC..." @bind="SearchQuery" />
</div>
<button class="admin-btn-primary">
<i data-lucide="plus"></i>
<span>Thêm NCC</span>
</button>
<p class="admin-topbar__subtitle">Quản lý nhà cung cấp</p>
</div>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:20px;">
@foreach (var sup in _suppliers)
{
<div class="admin-staff-card">
<div class="admin-staff-card__header">
<div style="display:flex;align-items:center;gap:12px;">
<div class="admin-user-avatar" style="background-color:@sup.Color;">@sup.Initials</div>
<div>
<div style="font-size:14px;font-weight:600;">@sup.Name</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">@sup.Category</div>
</div>
</div>
<div class="admin-status-badge @(sup.Active ? "admin-status-badge--online" : "admin-status-badge--offline")" style="font-size:10px;padding:2px 8px;">
<span class="admin-status-badge__dot"></span>
@(sup.Active ? "Hoạt động" : "Ngưng")
</div>
</div>
@* Contact info *@
<div style="display:flex;flex-direction:column;gap:6px;">
<div style="display:flex;align-items:center;gap:8px;">
<i data-lucide="phone" style="width:14px;height:14px;color:var(--admin-text-tertiary);"></i>
<span style="font-size:12px;color:var(--admin-text-secondary);">@sup.Phone</span>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<i data-lucide="mail" style="width:14px;height:14px;color:var(--admin-text-tertiary);"></i>
<span style="font-size:12px;color:var(--admin-text-secondary);">@sup.Email</span>
</div>
</div>
@* Stats *@
<div style="display:flex;gap:12px;">
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@sup.TotalOrders</div>
<div class="admin-store-stat__label">Đơn hàng</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@sup.TotalValue</div>
<div class="admin-store-stat__label">Tổng tiền</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value" style="color:@(sup.Rating >= 4 ? "#22C55E" : "#F59E0B");">@sup.Rating ★</div>
<div class="admin-store-stat__label">Đánh giá</div>
</div>
</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 {
private record SupplierItem(string Name, string Initials, string Color, string Category, string Phone, string Email, string TotalOrders, string TotalValue, double Rating, bool Active);
private readonly SupplierItem[] _suppliers = new[]
{
new SupplierItem("Cà phê Trung Nguyên", "TN", "#FF5C00", "Nguyên liệu cà phê", "028 3820 1234", "order@trungnguyen.vn", "24", "86.5M", 4.8, true),
new SupplierItem("Vinamilk", "VM", "#3B82F6", "Sữa & Dairy", "028 5413 0000", "b2b@vinamilk.vn", "18", "42.3M", 4.5, true),
new SupplierItem("Metro Cash & Carry", "MC", "#22C55E", "Tổng hợp", "028 3742 0888", "supply@metro.vn", "32", "128.6M", 4.2, true),
new SupplierItem("Đại Tân Phát", "ĐT", "#8B5CF6", "Bao bì & Dụng cụ", "028 3850 4567", "sales@daitanphat.vn", "15", "28.4M", 4.0, true),
new SupplierItem("TH True Milk", "TH", "#EC4899", "Sữa & Dairy", "028 3930 1122", "order@thtruemilk.vn", "12", "18.8M", 4.6, true),
new SupplierItem("Saigon Food", "SF", "#F59E0B", "Thực phẩm đông lạnh", "028 3820 5678", "info@saigonfood.vn", "8", "15.2M", 3.8, false),
};
}

View File

@@ -1,143 +1,104 @@
@page "/admin/menu"
@page "/admin/products/menu-builder"
@layout AdminLayout
@inherits AdminBase
@*
EN: Menu builder — drag-drop menu sections, items reorder, visibility toggle.
VI: Xây dựng menu — kéo thả section menu, sắp xếp món, ẩn/hiện.
Design: pencil-design/src/pages/tPOS/admin/menu-builder.pen
*@
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Menu Builder — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Xây dựng Menu</h1>
<p class="admin-topbar__subtitle">Coffee House Q1 • Kéo thả để sắp xếp</p>
<h1 class="admin-topbar__title">Menu Builder</h1>
<p class="admin-topbar__subtitle">@_categories.Count danh mục • @_products.Count sản phẩm</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-secondary">
<i data-lucide="eye"></i>
<span>Xem trước</span>
</button>
<button class="admin-btn-primary">
<i data-lucide="save"></i>
<span>Lưu menu</span>
</button>
<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>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;gap:24px;">
@* LEFT: Menu Sections *@
<div style="flex:1;display:flex;flex-direction:column;gap:16px;">
@foreach (var section in _menuSections)
{
<div class="admin-panel">
<div class="admin-panel__header">
<div style="display:flex;align-items:center;gap:10px;">
<i data-lucide="grip-vertical" style="width:16px;height:16px;color:var(--admin-text-tertiary);cursor:grab;"></i>
<h3 class="admin-panel__title" style="margin:0;">
<i data-lucide="@(section.Icon)" style="color:@(section.Color);"></i>
@(section.Name)
</h3>
<span class="admin-tab__badge">@(section.Items.Length)</span>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<MudSwitch T="bool" Value="section.Visible" Color="Color.Primary" />
<button class="admin-icon-btn" style="width:32px;height:32px;">
<i data-lucide="plus" style="width:16px;height:16px;"></i>
</button>
</div>
</div>
<div class="admin-panel__body" style="padding:8px 16px;">
@foreach (var item in section.Items)
{
<div style="display:flex;align-items:center;gap:12px;padding:10px 8px;border-bottom:1px solid var(--admin-border-subtle);">
<i data-lucide="grip-vertical" style="width:14px;height:14px;color:var(--admin-text-tertiary);cursor:grab;"></i>
<div style="width:40px;height:40px;background:@(section.Color)15;border-radius:8px;display:flex;align-items:center;justify-content:center;">
<i data-lucide="@(section.Icon)" style="width:18px;height:18px;color:@(section.Color);"></i>
</div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:500;">@item.Name</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">@item.Variants biến thể</div>
</div>
<span style="font-size:14px;font-weight:600;color:var(--admin-orange-primary);">@item.Price</span>
<MudSwitch T="bool" Value="item.Visible" Color="Color.Primary" />
</div>
}
</div>
@* Category sidebar *@
<div style="width:240px;flex-shrink:0;">
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Danh mục</h3></div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:4px;">
<button style="padding:8px 12px;border-radius:8px;border:none;text-align:left;font-weight:@(_selectedCategory == null ? "700" : "400");background:@(_selectedCategory == null ? "var(--admin-orange-primary)" : "transparent");color:@(_selectedCategory == null ? "#FFF" : "inherit");cursor:pointer;font-size:14px;" @onclick="@(() => SelectCategory(null))">Tất cả (@_products.Count)</button>
@foreach (var cat in _categories)
{
var catName = cat.Name;
var count = _products.Count(p => string.Equals(p.CategoryName, catName, StringComparison.OrdinalIgnoreCase));
<button style="padding:8px 12px;border-radius:8px;border:none;text-align:left;font-weight:@(_selectedCategory == catName ? "700" : "400");background:@(_selectedCategory == catName ? "var(--admin-orange-primary)" : "transparent");color:@(_selectedCategory == catName ? "#FFF" : "inherit");cursor:pointer;font-size:14px;" @onclick="@(() => SelectCategory(catName))">@catName (@count)</button>
}
</div>
}
</div>
</div>
@* RIGHT: Quick Stats *@
<div style="width:300px;display:flex;flex-direction:column;gap:20px;">
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="bar-chart-2" style="color:var(--admin-orange-primary);"></i>
Thống kê menu
</h3>
@* Products grid *@
<div style="flex:1;">
@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 (!FilteredProducts.Any())
{
<div style="text-align:center;padding:60px 20px;"><h2 style="font-size:18px;font-weight:700;color:var(--pos-text-primary, #FFF);">Chưa có sản phẩm</h2></div>
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
@foreach (var p in FilteredProducts)
{
<div class="admin-panel" style="cursor:pointer;">
<div class="admin-panel__body" style="padding:16px;text-align:center;">
<div style="width:48px;height:48px;border-radius:12px;background:rgba(255,92,0,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 12px;"><i data-lucide="package" style="color:#FF5C00;width:24px;height:24px;"></i></div>
<div style="font-weight:600;font-size:14px;margin-bottom:4px;">@p.Name</div>
<div style="font-size:13px;color:var(--admin-text-tertiary);">@(p.CategoryName ?? "—")</div>
<div style="font-weight:700;color:var(--admin-orange-primary);margin-top:8px;">@p.Price.ToString("N0")₫</div>
</div>
</div>
}
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--admin-text-tertiary);">Tổng danh mục</span>
<span style="font-weight:600;">@_menuSections.Length</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--admin-text-tertiary);">Tổng sản phẩm</span>
<span style="font-weight:600;">@_menuSections.Sum(s => s.Items.Length)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--admin-text-tertiary);">Đang hiển thị</span>
<span style="font-weight:600;color:#22C55E;">@_menuSections.Sum(s => s.Items.Count(i => i.Visible))</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--admin-text-tertiary);">Đang ẩn</span>
<span style="font-weight:600;color:var(--admin-text-tertiary);">@_menuSections.Sum(s => s.Items.Count(i => !i.Visible))</span>
</div>
</div>
</div>
<div class="admin-panel" style="background:rgba(255,92,0,0.04);border:1px solid rgba(255,92,0,0.15);">
<div class="admin-panel__body" style="display:flex;gap:10px;align-items:flex-start;">
<i data-lucide="info" style="width:18px;height:18px;color:var(--admin-orange-primary);flex-shrink:0;margin-top:2px;"></i>
<div style="font-size:12px;color:var(--admin-text-secondary);line-height:1.5;">
Kéo thả để sắp xếp thứ tự danh mục và sản phẩm. Thay đổi sẽ phản ánh trực tiếp trên POS.
</div>
</div>
</div>
}
</div>
</div>
@code {
private record MenuItem(string Name, string Price, string Variants, bool Visible);
private record MenuSection(string Name, string Icon, string Color, bool Visible, MenuItem[] Items);
private readonly MenuSection[] _menuSections = new[]
private List<PosDataService.AdminProductInfo> _products = new();
private List<PosDataService.AdminCategoryInfo> _categories = new();
private List<PosDataService.ShopInfo> _shops = new();
private string? _selectedCategory;
private Guid? _selectedShopId;
private IEnumerable<PosDataService.AdminProductInfo> FilteredProducts => _selectedCategory == null
? _products
: _products.Where(p => string.Equals(p.CategoryName, _selectedCategory, StringComparison.OrdinalIgnoreCase));
protected override async Task OnInitializedAsync()
{
new MenuSection("Cà phê", "coffee", "#FF5C00", true, new[]
IsLoading = true;
try
{
new MenuItem("Espresso", "35K", "3", true),
new MenuItem("Cappuccino", "45K", "3", true),
new MenuItem("Latte", "49K", "3", true),
new MenuItem("Americano", "39K", "2", true),
new MenuItem("Mocha", "52K", "3", false),
}),
new MenuSection("Trà", "leaf", "#22C55E", true, new[]
_shops = await DataService.GetShopsAsync();
_products = await DataService.GetAllProductsAsync();
_categories = await DataService.GetAllCategoriesAsync();
}
catch { } finally { IsLoading = false; }
}
private async Task OnShopFilterChanged(ChangeEventArgs e)
{
_selectedShopId = Guid.TryParse(e.Value?.ToString(), out var id) ? id : null;
IsLoading = true;
try
{
new MenuItem("Trà sen vàng", "42K", "2", true),
new MenuItem("Trà đào cam sả", "39K", "2", true),
new MenuItem("Trà sữa", "38K", "3", true),
}),
new MenuSection("Đồ ăn", "utensils", "#F59E0B", true, new[]
{
new MenuItem("Croissant", "35K", "1", true),
new MenuItem("Bánh mì", "28K", "2", true),
new MenuItem("Tiramisu", "45K", "1", false),
}),
};
_products = await DataService.GetAllProductsAsync(_selectedShopId);
_categories = await DataService.GetAllCategoriesAsync(_selectedShopId);
}
catch { } finally { IsLoading = false; }
}
private void SelectCategory(string? cat) { _selectedCategory = cat; }
}

View File

@@ -1,128 +1,21 @@
@page "/admin/modifiers"
@page "/admin/products/modifiers"
@layout AdminLayout
@inherits AdminBase
@*
EN: Modifier groups — manage add-on groups (Sugar, Ice, Topping) with options & pricing.
VI: Nhóm modifier — quản lý nhóm tùy chọn (Đường, Đá, Topping) với options & giá.
Design: pencil-design/src/pages/tPOS/admin/modifier-groups.pen
*@
<PageTitle>Nhóm tùy chọn — GoodGo Admin</PageTitle>
<PageTitle>Nhóm Modifier — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Nhóm Modifier</h1>
<p class="admin-topbar__subtitle">Quản lý tùy chọn thêm cho sản phẩm</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-primary" @onclick="@(() => _showCreate = !_showCreate)">
<i data-lucide="plus"></i>
<span>Tạo nhóm mới</span>
</button>
<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>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:16px;">
@foreach (var group in _modifierGroups)
{
<div class="admin-panel">
<div class="admin-panel__header">
<div style="display:flex;align-items:center;gap:12px;">
<h3 class="admin-panel__title">
<i data-lucide="@group.Icon" style="color:@group.Color;"></i>
@group.Name
</h3>
<span class="admin-tab__badge">@group.Options.Length tùy chọn</span>
<span style="font-size:11px;color:var(--admin-text-tertiary);">
@(group.Required ? "Bắt buộc" : "Không bắt buộc") • @(group.Multi ? "Nhiều lựa chọn" : "Một lựa chọn")
</span>
</div>
<div style="display:flex;gap:8px;">
<button class="admin-icon-btn" style="width:32px;height:32px;">
<i data-lucide="edit-2" style="width:14px;height:14px;"></i>
</button>
<button class="admin-icon-btn" style="width:32px;height:32px;">
<i data-lucide="trash-2" style="width:14px;height:14px;color:#EF4444;"></i>
</button>
</div>
</div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>Tùy chọn</th>
<th>Giá thêm</th>
<th>Mặc định</th>
<th style="width:60px;">Trạng thái</th>
</tr>
</thead>
<tbody>
@foreach (var opt in group.Options)
{
<tr>
<td style="font-weight:500;">@opt.Name</td>
<td style="color:@(opt.Price == "0" ? "var(--admin-text-tertiary)" : "#22C55E");">
@(opt.Price == "0" ? "Miễn phí" : $"+{opt.Price}")
</td>
<td>
@if (opt.IsDefault)
{
<span style="font-size:11px;color:var(--admin-orange-primary);font-weight:600;">Mặc định</span>
}
</td>
<td>
<MudSwitch T="bool" Value="opt.Active" Color="Color.Primary" />
</td>
</tr>
}
</tbody>
</table>
</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>
@code {
private bool _showCreate = false;
private record ModOption(string Name, string Price, bool IsDefault, bool Active);
private record ModGroup(string Name, string Icon, string Color, bool Required, bool Multi, ModOption[] Options);
private readonly ModGroup[] _modifierGroups = new[]
{
new ModGroup("Mức đường", "droplet", "#FF5C00", true, false, new[]
{
new ModOption("100% đường", "0", true, true),
new ModOption("70% đường", "0", false, true),
new ModOption("50% đường", "0", false, true),
new ModOption("30% đường", "0", false, true),
new ModOption("Không đường", "0", false, true),
}),
new ModGroup("Mức đá", "snowflake", "#3B82F6", true, false, new[]
{
new ModOption("Đá bình thường", "0", true, true),
new ModOption("Ít đá", "0", false, true),
new ModOption("Nhiều đá", "0", false, true),
new ModOption("Không đá", "0", false, true),
}),
new ModGroup("Topping", "layers", "#8B5CF6", false, true, new[]
{
new ModOption("Trân châu đen", "8K", false, true),
new ModOption("Trân châu trắng", "8K", false, true),
new ModOption("Thạch", "10K", false, true),
new ModOption("Kem phô mai", "15K", false, true),
new ModOption("Shot espresso", "12K", false, true),
new ModOption("Sữa tươi", "5K", false, true),
}),
new ModGroup("Loại sữa", "milk", "#22C55E", false, false, new[]
{
new ModOption("Sữa đặc", "0", true, true),
new ModOption("Sữa tươi", "5K", false, true),
new ModOption("Sữa yến mạch", "12K", false, true),
new ModOption("Sữa hạnh nhân", "12K", false, false),
}),
};
}

View File

@@ -1,104 +1,71 @@
@page "/admin/pricing"
@page "/admin/products/pricing"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Pricing rules — time-based, happy hour, combo, loyalty discounts.
VI: Quy tắc giá — theo giờ, happy hour, combo, giảm giá loyalty.
Design: pencil-design/src/pages/tPOS/admin/pricing-rules.pen
*@
<PageTitle>Chiến lược giá — GoodGo Admin</PageTitle>
<PageTitle>Quy tắc giá — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Quy tắc giá</h1>
<p class="admin-topbar__subtitle">Quản lý khuyến mãi, combo, giá theo giờ</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-primary">
<i data-lucide="plus"></i>
<span>Tạo quy tắc</span>
</button>
<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>
@* ═══ TABS ═══ *@
<div class="admin-tabs">
<button class="admin-tab @(_ruleTab == "all" ? "admin-tab--active" : "")" @onclick="@(() => _ruleTab = "all")">
Tất cả <span class="admin-tab__badge admin-tab__badge--active">@_pricingRules.Count</span>
</button>
<button class="admin-tab @(_ruleTab == "happyhour" ? "admin-tab--active" : "")" @onclick="@(() => _ruleTab = "happyhour")">
<i data-lucide="clock"></i> Happy Hour
</button>
<button class="admin-tab @(_ruleTab == "combo" ? "admin-tab--active" : "")" @onclick="@(() => _ruleTab = "combo")">
<i data-lucide="package"></i> Combo
</button>
<button class="admin-tab @(_ruleTab == "loyalty" ? "admin-tab--active" : "")" @onclick="@(() => _ruleTab = "loyalty")">
<i data-lucide="heart"></i> Loyalty
</button>
</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>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:16px;">
@foreach (var rule in _pricingRules)
@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__header">
<div style="display:flex;align-items:center;gap:12px;">
<div class="admin-kpi-card__icon" style="width:40px;height:40px;border-radius:10px;background-color:@(rule.Color)20;">
<i data-lucide="@rule.Icon" style="color:@rule.Color;width:20px;height:20px;"></i>
</div>
<div>
<div style="font-size:15px;font-weight:600;">@rule.Name</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">@rule.Desc</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<div class="admin-status-badge @(rule.Active ? "admin-status-badge--online" : "admin-status-badge--offline")">
<span class="admin-status-badge__dot"></span>
@(rule.Active ? "Đang hoạt động" : "Tắt")
</div>
<button class="admin-icon-btn" style="width:32px;height:32px;">
<i data-lucide="edit-2" style="width:14px;height:14px;"></i>
</button>
</div>
</div>
<div class="admin-panel__body" style="padding:12px 20px;">
<div style="display:flex;gap:24px;font-size:13px;">
<div>
<span style="color:var(--admin-text-tertiary);">Loại: </span>
<span style="font-weight:600;">@rule.Type</span>
</div>
<div>
<span style="color:var(--admin-text-tertiary);">Giảm: </span>
<span style="font-weight:600;color:#22C55E;">@rule.Discount</span>
</div>
<div>
<span style="color:var(--admin-text-tertiary);">Áp dụng: </span>
<span style="font-weight:600;">@rule.AppliesTo</span>
</div>
<div>
<span style="color:var(--admin-text-tertiary);">Thời gian: </span>
<span style="font-weight:600;">@rule.Schedule</span>
</div>
</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);">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 string _ruleTab = "all";
private List<PosDataService.PromotionInfo> _promos = new();
private record PricingRule(string Name, string Desc, string Type, string Discount, string AppliesTo, string Schedule, string Icon, string Color, bool Active);
private readonly List<PricingRule> _pricingRules = new()
protected override async Task OnInitializedAsync()
{
new("Happy Hour Chiều", "Giảm giá đồ uống từ 14h-16h", "Happy Hour", "-20%", "Tất cả cà phê", "14:00 16:00 hàng ngày", "clock", "#FF5C00", true),
new("Combo Sáng", "Combo cà phê + bánh mì", "Combo", "-15K", "Espresso + Bánh mì", "07:00 10:00 T2-T6", "package", "#3B82F6", true),
new("Sinh nhật Loyalty", "Giảm giá cho khách hàng sinh nhật", "Loyalty", "-30%", "Khách hàng thành viên", "Cả ngày", "heart", "#EC4899", true),
new("Weekend Brunch", "Giảm giá cuối tuần cho đồ ăn", "Happy Hour", "-25%", "Danh mục Đồ ăn", "09:00 13:00 T7-CN", "sun", "#F59E0B", false),
new("Mua 5 tặng 1", "Tích điểm mua 5 ly tặng 1", "Loyalty", "1 miễn phí", "Tất cả đồ uống", "Không giới hạn", "gift", "#8B5CF6", true),
};
IsLoading = true;
try { _promos = await DataService.GetPromotionsAsync(); }
catch { } finally { IsLoading = false; }
}
}

View File

@@ -1,150 +1,83 @@
@page "/admin/staff/attendance"
@layout AdminLayout
@inherits AdminBase
@*
EN: Attendance dashboard — today's check-in/out, weekly summary, punctuality.
VI: Dashboard chấm công — điểm danh hôm nay, tổng kết tuần, đúng giờ.
Design: pencil-design/src/pages/tPOS/admin/attendance-dashboard.pen
*@
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Chấm công — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Chấm công</h1>
<p class="admin-topbar__subtitle">Hôm nay, @DateTime.Now.ToString("dd/MM/yyyy") • Coffee House Q1</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-secondary">
<i data-lucide="download"></i>
<span>Xuất báo cáo</span>
</button>
<p class="admin-topbar__subtitle">Theo dõi giờ làm việc nhân viên</p>
</div>
</div>
@* ═══ 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="user-check" style="color:#22C55E;"></i>
</div>
</div>
<div class="admin-kpi-card__value">4/5</div>
<div class="admin-kpi-card__label">Đã check-in</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-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="clock" style="color:#3B82F6;"></i>
</div>
</div>
<div class="admin-kpi-card__value">96%</div>
<div class="admin-kpi-card__label">Tỷ lệ đúng giờ</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-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(245,158,11,0.125);">
<i data-lucide="alert-triangle" style="color:#F59E0B;"></i>
</div>
</div>
<div class="admin-kpi-card__value">1</div>
<div class="admin-kpi-card__label">Đi trễ hôm nay</div>
</div>
<div class="admin-kpi-card">
<div class="admin-kpi-card__header">
<div class="admin-kpi-card__icon" style="background-color:rgba(239,68,68,0.125);">
<i data-lucide="user-x" style="color:#EF4444;"></i>
</div>
</div>
<div class="admin-kpi-card__value">1</div>
<div class="admin-kpi-card__label">Vắng mặt</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>
@* Today's attendance table *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="list" style="color:var(--admin-orange-primary);"></i>
Chi tiết hôm nay
</h3>
@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 class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>Nhân viên</th>
<th>Ca làm</th>
<th>Check-in</th>
<th>Check-out</th>
<th>Trạng thái</th>
<th>Giờ làm</th>
</tr>
</thead>
<tbody>
<tr>
<td style="display:flex;align-items:center;gap:10px;">
<div class="admin-user-avatar" style="width:28px;height:28px;font-size:10px;">TM</div>
Trần Minh
</td>
<td>07:00 15:00</td>
<td style="color:#22C55E;font-weight:600;">06:55</td>
<td>—</td>
<td><div class="admin-status-badge admin-status-badge--online"><span class="admin-status-badge__dot"></span>Đúng giờ</div></td>
<td>6h 30m</td>
</tr>
<tr>
<td style="display:flex;align-items:center;gap:10px;">
<div class="admin-user-avatar" style="width:28px;height:28px;font-size:10px;background-color:#3B82F6;">LT</div>
Lê Thảo
</td>
<td>07:00 15:00</td>
<td style="color:#22C55E;font-weight:600;">06:58</td>
<td>—</td>
<td><div class="admin-status-badge admin-status-badge--online"><span class="admin-status-badge__dot"></span>Đúng giờ</div></td>
<td>6h 27m</td>
</tr>
<tr>
<td style="display:flex;align-items:center;gap:10px;">
<div class="admin-user-avatar" style="width:28px;height:28px;font-size:10px;background-color:#22C55E;">NH</div>
Nguyễn Hà
</td>
<td>08:00 16:00</td>
<td style="color:#F59E0B;font-weight:600;">08:12</td>
<td>—</td>
<td><div class="admin-status-badge admin-status-badge--setup"><span class="admin-status-badge__dot"></span>Trễ 12p</div></td>
<td>5h 13m</td>
</tr>
<tr>
<td style="display:flex;align-items:center;gap:10px;">
<div class="admin-user-avatar" style="width:28px;height:28px;font-size:10px;background-color:#8B5CF6;">PA</div>
Phạm An
</td>
<td>08:00 17:00</td>
<td style="color:#22C55E;font-weight:600;">07:50</td>
<td>—</td>
<td><div class="admin-status-badge admin-status-badge--online"><span class="admin-status-badge__dot"></span>Đúng giờ</div></td>
<td>5h 35m</td>
</tr>
<tr>
<td style="display:flex;align-items:center;gap:10px;">
<div class="admin-user-avatar" style="width:28px;height:28px;font-size:10px;background-color:#EC4899;">HL</div>
Hoàng Lan
</td>
<td>07:00 15:00</td>
<td style="color:var(--admin-text-tertiary);">—</td>
<td>—</td>
<td><div class="admin-status-badge admin-status-badge--offline"><span class="admin-status-badge__dot"></span>Vắng</div></td>
<td>—</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,133 +1,73 @@
@page "/admin/staff/payroll"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Payroll & Commission — salary overview, commission breakdown by staff.
VI: Lương & Hoa hồng — tổng quan lương, chi tiết hoa hồng theo nhân viên.
Design: pencil-design/src/pages/tPOS/admin/payroll-commission.pen
*@
<PageTitle>Bảng lương — GoodGo Admin</PageTitle>
<PageTitle>Lương & Hoa hồng — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Lương & Hoa hồng</h1>
<p class="admin-topbar__subtitle">Tháng 02/2025 • Tất cả cửa hàng</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-secondary">
<i data-lucide="calendar"></i>
<span>02/2025</span>
</button>
<button class="admin-btn-primary">
<i data-lucide="download"></i>
<span>Xuất bảng lương</span>
</button>
<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>
@* ═══ 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(255,92,0,0.125);">
<i data-lucide="wallet" style="color:var(--admin-orange-primary);"></i>
</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>
<div class="admin-kpi-card__value">86.4M</div>
<div class="admin-kpi-card__label">Tổng lương tháng</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>
<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="percent" style="color:#22C55E;"></i>
</div>
</div>
<div class="admin-kpi-card__value">12.8M</div>
<div class="admin-kpi-card__label">Tổng hoa hồ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="users" style="color:#3B82F6;"></i>
</div>
</div>
<div class="admin-kpi-card__value">8</div>
<div class="admin-kpi-card__label">Nhân viên</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">12.4M</div>
<div class="admin-kpi-card__label">Lương TB / người</div>
</div>
</div>
@* Payroll Table *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="table" style="color:var(--admin-orange-primary);"></i>
Bảng lương chi tiết
</h3>
<div class="admin-status-badge" style="background:rgba(245,158,11,0.125);color:var(--admin-warning);">
Chưa thanh toán
}
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 class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>Nhân viên</th>
<th>Vai trò</th>
<th>Lương cơ bản</th>
<th>Giờ làm</th>
<th>Hoa hồng</th>
<th>Thưởng/Phạt</th>
<th style="text-align:right;">Tổng</th>
</tr>
</thead>
<tbody>
@foreach (var item in _payrollData)
{
<tr>
<td style="display:flex;align-items:center;gap:10px;">
<div class="admin-user-avatar" style="width:28px;height:28px;font-size:10px;background-color:@item.Color;">@item.Initials</div>
@item.Name
</td>
<td style="color:var(--admin-text-tertiary);">@item.Role</td>
<td>@item.BaseSalary</td>
<td>@item.Hours h</td>
<td style="color:#22C55E;">+@item.Commission</td>
<td style="color:@(item.BonusPenalty.StartsWith("-") ? "#EF4444" : "#22C55E");">@item.BonusPenalty</td>
<td style="text-align:right;font-weight:700;color:var(--admin-orange-primary);">@item.Total</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@code {
private record PayrollItem(string Name, string Initials, string Color, string Role, string BaseSalary, string Hours, string Commission, string BonusPenalty, string Total);
private readonly PayrollItem[] _payrollData = new[]
private List<PosDataService.StaffInfo> _staff = new();
protected override async Task OnInitializedAsync()
{
new PayrollItem("Trần Minh", "TM", "#FF5C00", "Barista", "8.0M", "172", "1.8M", "+500K", "10.3M"),
new PayrollItem("Lê Thảo", "LT", "#3B82F6", "Thu ngân", "7.5M", "168", "2.1M", "+300K", "9.9M"),
new PayrollItem("Nguyễn Hà", "NH", "#22C55E", "Phục vụ", "6.5M", "160", "1.2M", "+200K", "7.9M"),
new PayrollItem("Phạm An", "PA", "#8B5CF6", "Quản lý", "15.0M", "180", "3.5M", "+1.0M", "19.5M"),
new PayrollItem("Hoàng Lan", "HL", "#EC4899", "Barista", "8.0M", "148", "1.4M", "-200K", "9.2M"),
new PayrollItem("Võ Khoa", "VK", "#F59E0B", "Bếp trưởng", "12.0M", "176", "2.0M", "+500K", "14.5M"),
new PayrollItem("Đặng Linh", "ĐL", "#06B6D4", "Phục vụ", "6.5M", "152", "0.8M", "0", "7.3M"),
new PayrollItem("Bùi Tùng", "BT", "#3B82F6", "Thu ngân", "7.5M", "164", "1.5M", "+200K", "9.2M"),
};
IsLoading = true;
try { _staff = await DataService.GetStaffAsync(); }
catch { } finally { IsLoading = false; }
}
}

View File

@@ -1,189 +1,101 @@
@page "/admin/staff/create"
@layout AdminLayout
@inherits AdminBase
@*
EN: Create staff — form with personal info, role, store assignment, schedule.
VI: Thêm nhân viên — form thông tin cá nhân, vai trò, phân công cửa hàng, lịch.
Design: pencil-design/src/pages/tPOS/admin/staff-create.pen
*@
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Thêm nhân viên — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left" style="flex-direction:row;align-items:center;gap:12px;">
<button class="admin-icon-btn" @onclick="@(() => NavigateTo("staff"))">
<i data-lucide="arrow-left"></i>
</button>
<button class="admin-icon-btn" @onclick="@(() => NavigateTo("staff"))"><i data-lucide="arrow-left"></i></button>
<div>
<h1 class="admin-topbar__title">Thêm nhân viên mới</h1>
<p class="admin-topbar__subtitle">Điền thông tin để tạo tài khoản nhân viên</p>
<p class="admin-topbar__subtitle">Điền thông tin nhân viên</p>
</div>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-secondary" @onclick="@(() => NavigateTo("staff"))">Hủy</button>
<button class="admin-btn-primary">
<i data-lucide="check"></i>
<span>Lưu nhân viên</span>
<button class="admin-btn-primary" @onclick="HandleSubmit" disabled="@_isSaving">
@if (_isSaving) { <div class="spinner-small" style="width:16px;height:16px;"></div> }
else { <i data-lucide="check"></i> }
<span>@(_isSaving ? "Đang lưu..." : "Lưu nhân viên")</span>
</button>
</div>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;gap:24px;">
@* LEFT COLUMN: Main form *@
<div style="flex:1;display:flex;flex-direction:column;gap:24px;">
@if (!string.IsNullOrEmpty(_message))
{
<div class="admin-panel" style="background:@(_isSuccess ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");border:1px solid @(_isSuccess ? "#22C55E" : "#EF4444");">
<div class="admin-panel__body" style="display:flex;align-items:center;gap:12px;">
<i data-lucide="@(_isSuccess ? "check-circle" : "alert-circle")" style="color:@(_isSuccess ? "#22C55E" : "#EF4444");width:20px;height:20px;"></i>
<span style="font-size:14px;">@_message</span>
</div>
</div>
}
@* Personal Info *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="user" style="color:var(--admin-orange-primary);"></i>
Thông tin cá nhân
</h3>
<h3 class="admin-panel__title"><i data-lucide="user-plus" style="color:var(--admin-orange-primary);"></i> Thông tin nhân viên</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">Họ và tên <span style="color:var(--admin-danger);">*</span></label>
<input class="admin-form-input" type="text" placeholder="VD: Nguyễn Văn A" />
<label class="admin-form-label">Mã nhân viên</label>
<input class="admin-form-input" type="text" placeholder="VD: NV001" @bind="_empCode" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Số điện thoại <span style="color:var(--admin-danger);">*</span></label>
<input class="admin-form-input" type="tel" placeholder="0901 234 567" />
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Email</label>
<input class="admin-form-input" type="email" placeholder="email@example.com" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Ngày sinh</label>
<input class="admin-form-input" type="date" />
</div>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Địa chỉ</label>
<input class="admin-form-input" type="text" placeholder="Số nhà, đường, quận..." />
</div>
<div class="admin-form-group">
<label class="admin-form-label">CMND/CCCD</label>
<input class="admin-form-input" type="text" placeholder="VD: 079012345678" />
</div>
</div>
</div>
@* Employment Info *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="briefcase" style="color:#3B82F6;"></i>
Thông tin công việc
</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">Vai trò <span style="color:var(--admin-danger);">*</span></label>
<select class="admin-form-input">
<option value="">Chọn vai trò...</option>
<option>Quản lý</option>
<option>Thu ngân</option>
<option>Barista</option>
<option>Phục vụ</option>
<option>Bếp trưởng</option>
<option>Phụ bếp</option>
</select>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Cửa hàng <span style="color:var(--admin-danger);">*</span></label>
<select class="admin-form-input">
<option value="">Chọn cửa hàng...</option>
<option>Coffee House Q1</option>
<option>Nhà hàng Q3</option>
<option>Café Thủ Đức</option>
<select class="admin-form-input" @bind="_role">
@foreach (var r in _roles)
{
<option value="@r.Name">@r.Name</option>
}
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Ngày bắt đầu</label>
<input class="admin-form-input" type="date" />
<label class="admin-form-label">Số điện thoại</label>
<input class="admin-form-input" type="tel" placeholder="0901234567" @bind="_phone" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Loại hợp đồng</label>
<select class="admin-form-input">
<option>Toàn thời gian</option>
<option>Bán thời gian</option>
<option>Thời vụ</option>
</select>
<label class="admin-form-label">Email</label>
<input class="admin-form-input" type="email" placeholder="nv@goodgo.vn" @bind="_email" />
</div>
</div>
</div>
</div>
@* Salary *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="banknote" style="color:#22C55E;"></i>
Lương & Phúc lợi
</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">Lương cơ bản (VND)</label>
<input class="admin-form-input" type="text" placeholder="VD: 8,000,000" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Hoa hồng (%)</label>
<input class="admin-form-input" type="number" placeholder="VD: 3" />
</div>
</div>
</div>
</div>
</div>
@* RIGHT COLUMN: Summary/Preview *@
<div style="width:320px;display:flex;flex-direction:column;gap:20px;">
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="eye" style="color:var(--admin-orange-primary);"></i>
Xem trước thẻ
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;align-items:center;gap:16px;">
<div class="admin-user-avatar" style="width:72px;height:72px;font-size:24px;">NV</div>
<div style="text-align:center;">
<div style="font-size:16px;font-weight:600;">Nhân viên mới</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">Chưa phân công</div>
</div>
<div style="width:100%;height:1px;background:var(--admin-border-subtle);"></div>
<div style="width:100%;display:flex;flex-direction:column;gap:8px;font-size:13px;">
<div style="display:flex;justify-content:space-between;">
<span style="color:var(--admin-text-tertiary);">Mã PIN</span>
<span style="font-weight:600;font-family:monospace;">Tự động</span>
</div>
<div style="display:flex;justify-content:space-between;">
<span style="color:var(--admin-text-tertiary);">Quyền POS</span>
<span style="font-weight:600;">Cơ bản</span>
</div>
</div>
</div>
</div>
<div class="admin-panel" style="background:rgba(59,130,246,0.06);border:1px solid rgba(59,130,246,0.15);">
<div class="admin-panel__body" style="display:flex;gap:10px;align-items:flex-start;">
<i data-lucide="info" style="width:18px;height:18px;color:#3B82F6;flex-shrink:0;margin-top:2px;"></i>
<div style="font-size:12px;color:var(--admin-text-secondary);line-height:1.5;">
Nhân viên sẽ nhận mã PIN 4 số để đăng nhập POS. Bạn có thể thay đổi quyền hạn trong phần <b>Phân quyền</b>.
</div>
</div>
</div>
</div>
</div>
@code {
private string _empCode = "", _role = "Cashier", _phone = "", _email = "", _message = "";
private bool _isSuccess, _isSaving;
private List<PosDataService.StaffRoleInfo> _roles = new();
protected override async Task OnInitializedAsync()
{
try { _roles = await DataService.GetStaffRolesAsync(); if (_roles.Any()) _role = _roles[0].Name; } catch { }
}
private async Task HandleSubmit()
{
_message = "";
if (string.IsNullOrWhiteSpace(_role)) { _message = "Vui lòng chọn vai trò."; _isSuccess = false; return; }
_isSaving = true;
try
{
// EN: Use first merchant or Guid.Empty / VI: Dùng merchant đầu tiên hoặc Guid.Empty
var shops = await DataService.GetShopsAsync();
var merchantId = Guid.Empty; // Will be set correctly when merchant context is available
var ok = await DataService.CreateStaffAsync(new(merchantId, _empCode.Trim(), _phone.Trim(), _email.Trim(), _role));
if (ok) { _message = "Đã thêm nhân viên thành công!"; _isSuccess = true; _empCode = ""; _phone = ""; _email = ""; }
else { _message = "Không thể thêm nhân viên."; _isSuccess = false; }
}
catch (Exception ex) { _message = $"Lỗi: {ex.Message}"; _isSuccess = false; }
finally { _isSaving = false; }
}
}

View File

@@ -1,95 +1,84 @@
@page "/admin/staff/schedule"
@layout AdminLayout
@inherits AdminBase
@*
EN: Staff schedule — weekly grid view with shifts, drag-drop.
VI: Lịch làm việc — hiển thị tuần dạng grid, ca làm, kéo thả.
Design: pencil-design/src/pages/tPOS/admin/staff-schedule.pen
*@
@inject PosDataService DataService
@using WebClientTpos.Client.Services
<PageTitle>Lịch làm việc — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<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">Tuần 10/02 16/02/2025 • Coffee House Q1</p>
<p class="admin-topbar__subtitle">@_schedules.Count ca làm việc</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-secondary">
<i data-lucide="chevron-left"></i>
</button>
<span style="font-size:14px;font-weight:600;min-width:120px;text-align:center;">Tuần này</span>
<button class="admin-btn-secondary">
<i data-lucide="chevron-right"></i>
</button>
<button class="admin-btn-primary">
<i data-lucide="plus"></i>
<span>Thêm ca</span>
</button>
<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>
@* ═══ SCHEDULE GRID ═══ *@
<div class="admin-content" style="overflow-x:auto;">
<div class="admin-schedule-grid">
@* Header row *@
<div class="admin-schedule-header">
<div class="admin-schedule-header__cell" style="width:160px;">Nhân viên</div>
@foreach (var day in _days)
{
<div class="admin-schedule-header__cell">
<span style="font-size:11px;color:var(--admin-text-tertiary);">@day.DayName</span>
<span style="font-size:14px;font-weight:600;">@day.Date</span>
</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>
@* Staff rows *@
@foreach (var staff in _scheduleStaff)
}
else
{
@foreach (var dayGroup in _schedules.GroupBy(s => s.DayOfWeek).OrderBy(g => g.Key))
{
<div class="admin-schedule-row">
<div class="admin-schedule-row__name">
<div class="admin-user-avatar" style="width:32px;height:32px;font-size:11px;background-color:@staff.Color;">
@staff.Initials
</div>
<div>
<div style="font-size:13px;font-weight:600;">@staff.Name</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">@staff.Role</div>
</div>
</div>
@foreach (var shift in staff.Shifts)
{
<div class="admin-schedule-cell">
@if (!string.IsNullOrEmpty(shift))
{
<div class="admin-shift-chip" style="background-color:@(staff.Color)20;color:@(staff.Color);border:1px solid @(staff.Color)40;">
@shift
<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>
}
</div>
}
<span style="font-size:14px;font-weight:600;color:var(--admin-orange-primary);">@s.StartTime — @s.EndTime</span>
</div>
}
</div>
</div>
}
</div>
}
</div>
@code {
private record DayInfo(string DayName, string Date);
private readonly DayInfo[] _days = new[]
{
new DayInfo("T2", "10"), new DayInfo("T3", "11"), new DayInfo("T4", "12"),
new DayInfo("T5", "13"), new DayInfo("T6", "14"), new DayInfo("T7", "15"), new DayInfo("CN", "16")
};
private List<PosDataService.ScheduleInfo> _schedules = new();
private List<PosDataService.ShopInfo> _shops = new();
private Guid? _selectedShopId;
private record ScheduleStaff(string Name, string Initials, string Role, string Color, string[] Shifts);
private readonly ScheduleStaff[] _scheduleStaff = new[]
protected override async Task OnInitializedAsync()
{
new ScheduleStaff("Trần Minh", "TM", "Barista", "#FF5C00", new[] {"07:00-15:00", "07:00-15:00", "", "07:00-15:00", "07:00-15:00", "07:00-15:00", ""}),
new ScheduleStaff("Lê Thảo", "LT", "Thu ngân", "#3B82F6", new[] {"15:00-22:00", "15:00-22:00", "15:00-22:00", "15:00-22:00", "", "15:00-22:00", "15:00-22:00"}),
new ScheduleStaff("Nguyễn Hà", "NH", "Phục vụ", "#22C55E", new[] {"07:00-15:00", "", "07:00-15:00", "07:00-15:00", "07:00-15:00", "", "07:00-15:00"}),
new ScheduleStaff("Phạm An", "PA", "Quản lý", "#8B5CF6", new[] {"08:00-17:00", "08:00-17:00", "08:00-17:00", "08:00-17:00", "08:00-17:00", "", ""}),
new ScheduleStaff("Hoàng Lan", "HL", "Barista", "#EC4899", new[] {"", "", "15:00-22:00", "", "15:00-22:00", "07:00-15:00", "07:00-15:00"}),
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,101 +1,78 @@
@page "/admin/system/devices"
@layout AdminLayout
@inherits AdminBase
@inject PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Device management — list of POS terminals, printers, scanners with status (online/offline), store assignment, last seen, battery.
VI: Quản lý thiết bị — danh sách máy POS, máy in, máy quét với trạng thái, gán cửa hàng, lần cuối kết nối, pin.
Design: pencil-design/src/pages/tPOS/admin/device-management.pen
*@
<PageTitle>Thiết bị — GoodGo Admin</PageTitle>
<PageTitle>Quản lý thiết bị — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Quản lý thiết bị</h1>
<p class="admin-topbar__subtitle">@_devices.Count thiết bị • @_devices.Count(d => d.Status == "online") đang hoạt động</p>
<p class="admin-topbar__subtitle">@_devices.Count thiết bị đã đăng </p>
</div>
<div class="admin-topbar__right">
<div class="admin-search" style="width:200px;">
<i data-lucide="search"></i>
<input type="text" placeholder="Tìm thiết bị..." @bind="SearchQuery" />
</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="monitor-smartphone" style="color:#22C55E;"></i></div>
<div class="admin-stat-card__content"><span class="admin-stat-card__value">@_devices.Count(d => d.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(239,68,68,0.1);"><i data-lucide="monitor-off" style="color:#EF4444;"></i></div>
<div class="admin-stat-card__content"><span class="admin-stat-card__value">@_devices.Count(d => !d.IsActive)</span><span class="admin-stat-card__label">Không hoạt động</span></div>
</div>
<button class="admin-btn-primary">
<i data-lucide="plus"></i>
<span>Thêm thiết bị</span>
</button>
</div>
</div>
@* ═══ TABS ═══ *@
<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">@_devices.Count</span>
</button>
<button class="admin-tab @(_activeTab == "online" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "online")">
Online <span class="admin-tab__badge">@_devices.Count(d => d.Status == "online")</span>
</button>
<button class="admin-tab @(_activeTab == "offline" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "offline")">
Offline <span class="admin-tab__badge">@_devices.Count(d => d.Status == "offline")</span>
</button>
</div>
@* ═══ DEVICE GRID ═══ *@
<div class="admin-content" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px;">
@foreach (var dev in FilteredDevices)
@if (IsLoading)
{
<div class="admin-staff-card">
<div class="admin-staff-card__header">
<div class="admin-kpi-card__icon" style="width:48px;height:48px;border-radius:12px;background-color:@(dev.IconColor)20;">
<i data-lucide="@dev.Icon" style="color:@dev.IconColor;width:22px;height:22px;"></i>
</div>
<div class="admin-status-badge @(dev.Status == "online" ? "admin-status-badge--online" : "admin-status-badge--offline")">
<span class="admin-status-badge__dot"></span>
@(dev.Status == "online" ? "Online" : "Offline")
</div>
</div>
<div style="display:flex;flex-direction:column;gap:2px;">
<span style="font-size:16px;font-weight:600;">@dev.Name</span>
<span style="font-size:12px;color:var(--admin-text-tertiary);">@dev.Type • @dev.Store</span>
</div>
<div style="display:flex;gap:8px;">
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@dev.LastSeen</div>
<div class="admin-store-stat__label">Lần cuối</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value" style="color:@dev.BatteryColor;">@dev.Battery</div>
<div class="admin-store-stat__label">Pin</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@dev.Serial</div>
<div class="admin-store-stat__label">S/N</div>
</div>
<div style="text-align:center;padding:48px;"><div class="spinner-small" style="width:32px;height:32px;margin:0 auto 16px;"></div></div>
}
else if (!_devices.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="tablet-smartphone" 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ó thiết bị</h2>
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Thiết bị sẽ tự động đăng ký khi nhân viên đăng nhập</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);">Thiết bị</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Nền tảng</th>
<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: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 đăng ký</th>
</tr></thead><tbody>
@foreach (var d in _devices)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-size:12px;font-family:monospace;">@(d.DeviceToken?[..Math.Min(12, d.DeviceToken.Length)] ?? "—")...</td>
<td style="padding:12px 16px;font-weight:600;">@(d.Platform ?? "—")</td>
<td style="padding:12px 16px;font-size:14px;">@(d.StaffCode ?? "—")</td>
<td style="padding:12px 16px;text-align:center;">
<span class="admin-status-badge @(d.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>@(d.IsActive ? "Active" : "Inactive")</span>
</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@d.CreatedAt.ToString("dd/MM/yyyy")</td>
</tr>
}
</tbody></table>
</div>
</div>
}
</div>
@code {
private string _activeTab = "all";
private List<PosDataService.DeviceInfo> _devices = new();
private List<DeviceItem> FilteredDevices => _activeTab == "all"
? _devices
: _devices.Where(d => d.Status == _activeTab).ToList();
private record DeviceItem(string Name, string Type, string Icon, string IconColor, string Store,
string Status, string LastSeen, string Battery, string BatteryColor, string Serial);
private readonly List<DeviceItem> _devices = new()
protected override async Task OnInitializedAsync()
{
new("POS Terminal #01", "Máy POS", "monitor", "#3B82F6", "Coffee House Q1", "online", "Vừa xong", "85%", "#22C55E", "POS-001"),
new("POS Terminal #02", "Máy POS", "monitor", "#3B82F6", "Nhà hàng Q3", "online", "Vừa xong", "92%", "#22C55E", "POS-002"),
new("Máy in bếp", "Máy in", "printer", "#8B5CF6", "Coffee House Q1", "offline", "2 giờ", "—", "var(--admin-text-tertiary)", "PRT-001"),
new("Máy in bill", "Máy in", "printer", "#8B5CF6", "Coffee House Q1", "online", "Vừa xong", "—", "var(--admin-text-tertiary)", "PRT-002"),
new("Máy quét QR", "Scanner", "scan-line", "#22C55E", "Nhà hàng Q3", "online", "5 phút", "68%", "#F59E0B", "SCN-001"),
new("Tablet Order #01", "Tablet", "tablet", "#F59E0B", "Nhà hàng Q3", "online", "Vừa xong", "45%", "#EF4444", "TAB-001"),
new("POS Terminal #03", "Máy POS", "monitor", "#3B82F6", "Karaoke Star Q7", "offline", "3 ngày", "0%", "#EF4444", "POS-003"),
new("Máy in bill #02", "Máy in", "printer", "#8B5CF6", "Nhà hàng Q3", "online", "Vừa xong", "—", "var(--admin-text-tertiary)", "PRT-003"),
};
IsLoading = true;
try { _devices = await DataService.GetDevicesAsync(); }
catch { } finally { IsLoading = false; }
}
}

View File

@@ -2,111 +2,43 @@
@layout AdminLayout
@inherits AdminBase
@*
EN: Integration hub — integration tiles (Payment, Delivery, Accounting, Social), connection status, configure button.
VI: Trung tâm tích hợp — thẻ tích hợp (Thanh toán, Giao hàng, Kế toán, Mạng xã hội), trạng thái kết nối, nút cấu hình.
Design: pencil-design/src/pages/tPOS/admin/integration-hub.pen
*@
<PageTitle>Tích hợp — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Tích hợp</h1>
<p class="admin-topbar__subtitle">Quản lý kết nối với dịch vụ bên ngoài</p>
</div>
<div class="admin-topbar__right">
<div class="admin-search" style="width:200px;">
<i data-lucide="search"></i>
<input type="text" placeholder="Tìm tích hợp..." @bind="SearchQuery" />
</div>
<h1 class="admin-topbar__title">Tích hợp bên thứ 3</h1>
<p class="admin-topbar__subtitle">Kết nối với các dịch vụ bên ngoài</p>
</div>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:24px;">
@foreach (var group in _integrationGroups)
{
<div style="display:flex;flex-direction:column;gap:12px;">
<div style="font-size:12px;font-weight:700;color:var(--admin-text-tertiary);text-transform:uppercase;letter-spacing:0.05em;">
@group.GroupName
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;">
@foreach (var item in group.Items)
{
<div class="admin-staff-card">
<div class="admin-staff-card__header">
<div class="admin-kpi-card__icon" style="width:48px;height:48px;border-radius:12px;background-color:@(item.Color)20;">
<i data-lucide="@item.Icon" style="color:@item.Color;width:22px;height:22px;"></i>
</div>
<div class="admin-status-badge @(item.Connected ? "admin-status-badge--online" : "admin-status-badge--offline")">
<span class="admin-status-badge__dot"></span>
@(item.Connected ? "Đã kết nối" : "Chưa kết nối")
</div>
</div>
<div style="display:flex;flex-direction:column;gap:2px;">
<span style="font-size:16px;font-weight:600;">@item.Name</span>
<span style="font-size:12px;color:var(--admin-text-tertiary);">@item.Description</span>
</div>
<div style="display:flex;gap:8px;margin-top:4px;">
@if (item.Connected)
{
<button class="admin-btn-secondary" style="flex:1;justify-content:center;font-size:12px;padding:8px 12px;">
<i data-lucide="settings" style="width:14px;height:14px;"></i>
Cấu hình
</button>
<button class="admin-btn-secondary" style="font-size:12px;padding:8px 12px;color:var(--admin-danger);border-color:rgba(239,68,68,0.3);">
<i data-lucide="unplug" style="width:14px;height:14px;"></i>
Ngắt
</button>
}
else
{
<button class="admin-btn-primary" style="flex:1;justify-content:center;font-size:12px;padding:8px 12px;">
<i data-lucide="plug" style="width:14px;height:14px;"></i>
Kết nối
</button>
}
</div>
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;">
@foreach (var intg in _integrations)
{
<div class="admin-panel" style="cursor:pointer;">
<div class="admin-panel__body" style="display:flex;align-items:center;gap:16px;">
<div style="width:48px;height:48px;border-radius:12px;background:@intg.bg;display:flex;align-items:center;justify-content:center;">
<i data-lucide="@intg.icon" style="color:@intg.color;width:24px;height:24px;"></i>
</div>
}
<div style="flex:1;">
<div style="font-weight:600;font-size:14px;">@intg.name</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-top:2px;">@intg.desc</div>
</div>
<span class="admin-status-badge @(intg.connected ? "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>@(intg.connected ? "Đã kết nối" : "Chưa kết nối")</span>
</div>
</div>
</div>
}
}
</div>
</div>
@code {
private record IntegrationItem(string Name, string Description, string Icon, string Color, bool Connected);
private record IntegrationGroup(string GroupName, IntegrationItem[] Items);
private readonly IntegrationGroup[] _integrationGroups = new[]
private readonly (string name, string desc, string icon, string color, string bg, bool connected)[] _integrations = new[]
{
new IntegrationGroup("THANH TOÁN", new[]
{
new IntegrationItem("VNPay", "Thanh toán QR & thẻ ngân hàng", "qr-code", "#3B82F6", true),
new IntegrationItem("MoMo", "Ví điện tử MoMo", "wallet", "#EC4899", true),
new IntegrationItem("ZaloPay", "Thanh toán qua ZaloPay", "smartphone", "#3B82F6", false),
new IntegrationItem("Visa/Mastercard", "Thanh toán thẻ quốc tế", "credit-card", "#8B5CF6", true),
}),
new IntegrationGroup("GIAO HÀNG", new[]
{
new IntegrationItem("GrabFood", "Đối tác giao đồ ăn", "bike", "#22C55E", true),
new IntegrationItem("ShopeeFood", "Nền tảng đặt đồ ăn", "shopping-bag", "#FF5C00", true),
new IntegrationItem("Baemin", "Dịch vụ giao hàng", "truck", "#06B6D4", false),
}),
new IntegrationGroup("KẾ TOÁN & QUẢN LÝ", new[]
{
new IntegrationItem("MISA", "Phần mềm kế toán", "calculator", "#F59E0B", true),
new IntegrationItem("Google Sheets", "Xuất báo cáo tự động", "file-spreadsheet", "#22C55E", false),
new IntegrationItem("Hóa đơn điện tử", "Phát hành hóa đơn GTGT", "file-text", "#3B82F6", true),
}),
new IntegrationGroup("MẠNG XÃ HỘI", new[]
{
new IntegrationItem("Facebook", "Fanpage & quảng cáo", "facebook", "#3B82F6", true),
new IntegrationItem("Zalo OA", "Chăm sóc khách hàng", "message-circle", "#3B82F6", false),
new IntegrationItem("Google Business", "Hiển thị trên Google Maps", "map-pin", "#EF4444", true),
}),
("Thanh toán VNPay", "Cổng thanh toán trực tuyến", "credit-card", "#3B82F6", "rgba(59,130,246,0.1)", false),
("Thanh toán Momo", "Ví điện tử Momo", "wallet", "#A855F7", "rgba(168,85,247,0.1)", false),
("GrabFood", "Đối tác giao đồ ăn", "bike", "#22C55E", "rgba(34,197,94,0.1)", false),
("ShopeeFood", "Đối tác giao đồ ăn", "shopping-cart", "#EF4444", "rgba(239,68,68,0.1)", false),
("Máy in hóa đơn", "Kết nối máy in POS", "printer", "#F59E0B", "rgba(245,158,11,0.1)", false),
("Kế toán MISA", "Đồng bộ dữ liệu kế toán", "file-spreadsheet", "#FF5C00", "rgba(255,92,0,0.1)", false),
};
}

View File

@@ -2,150 +2,41 @@
@layout AdminLayout
@inherits AdminBase
@*
EN: Notification center — notification settings, channel toggles (Email/SMS/Push/In-app), event types with channel preferences, test send.
VI: Trung tâm thông báo — cài đặt thông báo, bật/tắt kênh (Email/SMS/Push/In-app), loại sự kiện theo kênh, gửi thử.
Design: pencil-design/src/pages/tPOS/admin/notification-center.pen
*@
<PageTitle>Thông báo — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<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">Cấu hình kênh & loại thông báo</p>
</div>
<div class="admin-topbar__right">
<button class="admin-btn-secondary">
<i data-lucide="send"></i>
<span>Gửi thử</span>
</button>
<button class="admin-btn-primary">
<i data-lucide="save"></i>
<span>Lưu cài đặt</span>
</button>
<p class="admin-topbar__subtitle">Quản lý thông báo đẩy</p>
</div>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;gap:24px;">
@* LEFT: Notification Types *@
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="bell" style="color:var(--admin-orange-primary);"></i>
Loại thông báo
</h3>
</div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>Sự kiện</th>
<th style="text-align:center;">Email</th>
<th style="text-align:center;">SMS</th>
<th style="text-align:center;">Push</th>
<th style="text-align:center;">In-app</th>
</tr>
</thead>
<tbody>
@foreach (var evt in _eventTypes)
{
<tr>
<td>
<div style="display:flex;flex-direction:column;gap:2px;">
<span style="font-weight:500;">@evt.Name</span>
<span style="font-size:11px;color:var(--admin-text-tertiary);">@evt.Desc</span>
</div>
</td>
<td style="text-align:center;"><MudSwitch T="bool" Value="evt.Email" Color="Color.Primary" /></td>
<td style="text-align:center;"><MudSwitch T="bool" Value="evt.Sms" Color="Color.Primary" /></td>
<td style="text-align:center;"><MudSwitch T="bool" Value="evt.Push" Color="Color.Primary" /></td>
<td style="text-align:center;"><MudSwitch T="bool" Value="evt.InApp" Color="Color.Primary" /></td>
</tr>
}
</tbody>
</table>
</div>
</div>
@* RIGHT: Channel Config *@
<div style="width:340px;display:flex;flex-direction:column;gap:20px;">
@* Channel Status *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="radio" style="color:#8B5CF6;"></i>
Kênh thông báo
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:8px;">
@foreach (var ch in _channels)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div style="display:flex;align-items:center;gap:10px;">
<div class="admin-kpi-card__icon" style="width:36px;height:36px;border-radius:10px;background-color:@(ch.Color)20;">
<i data-lucide="@ch.Icon" style="color:@ch.Color;width:18px;height:18px;"></i>
</div>
<div style="display:flex;flex-direction:column;">
<span style="font-size:13px;font-weight:600;">@ch.Name</span>
<span style="font-size:11px;color:var(--admin-text-tertiary);">@ch.Provider</span>
</div>
</div>
<MudSwitch T="bool" Value="ch.Enabled" Color="Color.Primary" />
<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>
}
</div>
</div>
@* Delivery Schedule *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="clock" style="color:#F59E0B;"></i>
Lịch gửi
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
<div class="admin-form-group">
<label class="admin-form-label">Giờ bắt đầu</label>
<input class="admin-form-input" type="text" value="08:00" readonly />
<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 class="admin-form-group">
<label class="admin-form-label">Giờ kết thúc</label>
<input class="admin-form-input" type="text" value="22:00" readonly />
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 12px;background:var(--admin-bg-interactive);border-radius:10px;">
<span style="font-size:13px;font-weight:500;">Không làm phiền</span>
<MudSwitch T="bool" Value="true" Color="Color.Primary" />
</div>
</div>
}
</div>
</div>
</div>
@code {
private record EventType(string Name, string Desc, bool Email, bool Sms, bool Push, bool InApp);
private readonly EventType[] _eventTypes = new[]
private readonly (string name, string desc, bool active)[] _templates = new[]
{
new EventType("Đơn hàng mới", "Khi có đơn hàng mới được tạo", false, false, true, true),
new EventType("Đơn hàng hủy", "Khi đơn hàng bị hủy", true, true, true, true),
new EventType("Hàng sắp hết", "Tồn kho dưới mức tối thiểu", true, false, true, true),
new EventType("Nhân viên clock-in", "Nhân viên bắt đầu ca làm việc", false, false, false, true),
new EventType("Doanh thu bất thường", "Doanh thu thấp/cao bất thường", true, true, true, true),
new EventType("Thiết bị offline", "Thiết bị mất kết nối", true, true, true, true),
new EventType("Phản hồi khách hàng", "Khách hàng gửi đánh giá mới", false, false, true, true),
new EventType("Bảo mật", "Đăng nhập lạ, đổi mật khẩu", true, true, true, true),
};
private record ChannelDef(string Name, string Icon, string Color, string Provider, bool Enabled);
private readonly ChannelDef[] _channels = new[]
{
new ChannelDef("Email", "mail", "#3B82F6", "SendGrid", true),
new ChannelDef("SMS", "smartphone", "#22C55E", "Twilio", true),
new ChannelDef("Push", "bell-ring", "#FF5C00", "Firebase FCM", true),
new ChannelDef("In-app", "message-square", "#8B5CF6", "WebSocket", true),
("Đơ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

@@ -107,4 +107,72 @@ public class PosDataService
var resp = await _http.PostAsJsonAsync("api/bff/staff", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
// ═══ STAFF ROLES & SCHEDULES ═══
public record StaffRoleInfo(int Id, string Name);
public record ScheduleInfo(Guid Id, Guid StaffId, Guid ShopId, int DayOfWeek, string StartTime, string EndTime,
string? EmployeeCode, string? Role, string? Phone);
public async Task<List<StaffRoleInfo>> GetStaffRolesAsync()
=> await _http.GetFromJsonAsync<List<StaffRoleInfo>>("api/bff/staff/roles", _jsonOptions) ?? new();
public async Task<List<ScheduleInfo>> GetStaffSchedulesAsync(Guid? shopId = null)
{
var url = shopId.HasValue ? $"api/bff/staff/schedules?shopId={shopId}" : "api/bff/staff/schedules";
return await _http.GetFromJsonAsync<List<ScheduleInfo>>(url, _jsonOptions) ?? new();
}
// ═══ ORDERS ═══
public record OrderInfo(Guid Id, Guid ShopId, decimal TotalAmount, int StatusId, DateTime CreatedAt, string? Status);
public async Task<List<OrderInfo>> GetOrdersAsync(Guid? shopId = null)
{
var url = shopId.HasValue ? $"api/bff/orders?shopId={shopId}" : "api/bff/orders";
return await _http.GetFromJsonAsync<List<OrderInfo>>(url, _jsonOptions) ?? new();
}
// ═══ WALLETS / FINANCE ═══
public record WalletInfo(Guid Id, decimal Balance, string? Currency, Guid OwnerId, DateTime CreatedAt, decimal TotalIncome, decimal TotalExpense);
public record WalletTxnInfo(Guid Id, Guid WalletId, decimal Amount, string? Description, DateTime CreatedAt, string? ItemName);
public async Task<List<WalletInfo>> GetWalletsAsync()
=> await _http.GetFromJsonAsync<List<WalletInfo>>("api/bff/wallets", _jsonOptions) ?? new();
public async Task<List<WalletTxnInfo>> GetWalletTransactionsAsync(int limit = 50)
=> await _http.GetFromJsonAsync<List<WalletTxnInfo>>($"api/bff/wallet/transactions?limit={limit}", _jsonOptions) ?? new();
// ═══ DEVICES ═══
public record DeviceInfo(Guid Id, string? DeviceToken, string? Platform, bool IsActive, DateTime CreatedAt, string? StaffCode);
public async Task<List<DeviceInfo>> GetDevicesAsync()
=> await _http.GetFromJsonAsync<List<DeviceInfo>>("api/bff/devices", _jsonOptions) ?? new();
// ═══ PROMOTIONS ═══
public record PromotionInfo(Guid Id, string Name, string? Description, DateTime? StartDate, DateTime? EndDate,
bool IsActive, string? DiscountType, decimal? DiscountValue, int VoucherCount, int RedemptionCount);
public async Task<List<PromotionInfo>> GetPromotionsAsync()
=> await _http.GetFromJsonAsync<List<PromotionInfo>>("api/bff/promotions", _jsonOptions) ?? new();
// ═══ INVENTORY TRANSACTIONS ═══
public record InventoryTxnInfo(Guid Id, Guid InventoryItemId, int QuantityChange, string? Reason, DateTime CreatedAt, string? TransactionType);
public async Task<List<InventoryTxnInfo>> GetInventoryTransactionsAsync(Guid? shopId = null)
{
var url = shopId.HasValue ? $"api/bff/inventory/transactions?shopId={shopId}" : "api/bff/inventory/transactions";
return await _http.GetFromJsonAsync<List<InventoryTxnInfo>>(url, _jsonOptions) ?? new();
}
// ═══ MEMBERSHIP LEVELS ═══
public record LevelDefinitionInfo(Guid Id, int Level, string Name, int MinExp, int MaxExp, int MemberCount);
public async Task<List<LevelDefinitionInfo>> GetMembershipLevelsAsync()
=> await _http.GetFromJsonAsync<List<LevelDefinitionInfo>>("api/bff/membership/levels", _jsonOptions) ?? new();
}

View File

@@ -307,6 +307,136 @@ public class BffDataController : ControllerBase
return CreatedAtAction(nameof(GetStaff), new { }, new { id });
}
// ═══ STAFF ROLES ═══
[HttpGet("staff/roles")]
public async Task<IActionResult> GetStaffRoles()
{
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var roles = await conn.QueryAsync<dynamic>("SELECT id, name FROM staff_roles ORDER BY id");
return Ok(roles);
}
// ═══ STAFF SCHEDULES ═══
[HttpGet("staff/schedules")]
public async Task<IActionResult> GetStaffSchedules([FromQuery] Guid? shopId = null)
{
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
var sql = @"SELECT id, staff_id, shop_id, day_of_week, start_time, end_time FROM staff_schedules";
if (shopId.HasValue) sql += " WHERE shop_id = @ShopId";
sql += " ORDER BY day_of_week, start_time";
var schedules = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
// EN: Enrich with staff names / VI: Bổ sung tên nhân viên
await using var mConn = new NpgsqlConnection(ConnStr("merchant_service"));
var staffList = (await mConn.QueryAsync<dynamic>(
"SELECT ms.id, ms.employee_code, ms.phone, sr.name as role FROM merchant_staff ms JOIN staff_roles sr ON ms.role_id = sr.id")).ToList();
var staffMap = staffList.ToDictionary(s => (Guid)s.id, s => new { code = (string?)s.employee_code, role = (string)s.role, phone = (string?)s.phone });
var result = schedules.Select(s => new {
s.id, s.staff_id, s.shop_id, s.day_of_week, s.start_time, s.end_time,
employee_code = staffMap.TryGetValue((Guid)s.staff_id, out var info) ? info.code : null,
role = info?.role, phone = info?.phone
});
return Ok(result);
}
// ═══ ORDERS SUMMARY ═══
[HttpGet("orders")]
public async Task<IActionResult> GetOrders([FromQuery] Guid? shopId = null)
{
await using var conn = new NpgsqlConnection(ConnStr("order_service"));
var sql = @"SELECT o.id, o.shop_id, o.total_amount, o.status_id, o.created_at,
os.name as status
FROM orders o
JOIN order_statuses os ON o.status_id = os.id";
if (shopId.HasValue) sql += " WHERE o.shop_id = @ShopId";
sql += " ORDER BY o.created_at DESC LIMIT 200";
var orders = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
return Ok(orders);
}
// ═══ WALLET/FINANCE ═══
[HttpGet("wallets")]
public async Task<IActionResult> GetWallets()
{
await using var conn = new NpgsqlConnection(ConnStr("wallet_service"));
var wallets = await conn.QueryAsync<dynamic>(
@"SELECT w.id, w.balance, w.currency, w.owner_id, w.created_at,
(SELECT COALESCE(SUM(amount),0) FROM wallet_transactions wt WHERE wt.wallet_id = w.id AND wt.amount > 0) as total_income,
(SELECT COALESCE(SUM(ABS(amount)),0) FROM wallet_transactions wt WHERE wt.wallet_id = w.id AND wt.amount < 0) as total_expense
FROM wallets w ORDER BY w.created_at DESC");
return Ok(wallets);
}
[HttpGet("wallet/transactions")]
public async Task<IActionResult> GetWalletTransactions([FromQuery] int limit = 50)
{
await using var conn = new NpgsqlConnection(ConnStr("wallet_service"));
var txns = await conn.QueryAsync<dynamic>(
@"SELECT wt.id, wt.wallet_id, wt.amount, wt.description, wt.created_at,
wi.name as item_name
FROM wallet_transactions wt
LEFT JOIN wallet_items wi ON wt.reference_id = wi.id
ORDER BY wt.created_at DESC LIMIT @Limit",
new { Limit = limit });
return Ok(txns);
}
// ═══ DEVICES ═══
[HttpGet("devices")]
public async Task<IActionResult> GetDevices()
{
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var devices = await conn.QueryAsync<dynamic>(
@"SELECT dt.id, dt.device_token, dt.platform, dt.is_active, dt.created_at,
ms.employee_code as staff_code
FROM device_tokens dt
LEFT JOIN merchant_staff ms ON dt.staff_id = ms.id
ORDER BY dt.created_at DESC");
return Ok(devices);
}
// ═══ PROMOTIONS ═══
[HttpGet("promotions")]
public async Task<IActionResult> GetPromotions()
{
await using var conn = new NpgsqlConnection(ConnStr("promotion_service"));
var promos = await conn.QueryAsync<dynamic>(
@"SELECT c.id, c.name, c.description, c.start_date, c.end_date, c.is_active, c.discount_type, c.discount_value,
(SELECT COUNT(*) FROM vouchers v WHERE v.campaign_id = c.id) as voucher_count,
(SELECT COUNT(*) FROM redemptions r WHERE r.campaign_id = c.id) as redemption_count
FROM campaigns c ORDER BY c.created_at DESC");
return Ok(promos);
}
// ═══ INVENTORY TRANSACTIONS ═══
[HttpGet("inventory/transactions")]
public async Task<IActionResult> GetInventoryTransactions([FromQuery] Guid? shopId = null)
{
await using var conn = new NpgsqlConnection(ConnStr("inventory_service"));
var sql = @"SELECT it.id, it.inventory_item_id, it.quantity_change, it.reason, it.created_at,
tt.name as transaction_type
FROM inventory_transactions it
JOIN transaction_types tt ON it.type_id = tt.id";
if (shopId.HasValue)
sql += @" JOIN inventory_items ii ON it.inventory_item_id = ii.id WHERE ii.shop_id = @ShopId";
sql += " ORDER BY it.created_at DESC LIMIT 100";
var txns = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
return Ok(txns);
}
// ═══ MEMBERSHIP LEVELS ═══
[HttpGet("membership/levels")]
public async Task<IActionResult> GetMembershipLevels()
{
await using var conn = new NpgsqlConnection(ConnStr("membership_service"));
var levels = await conn.QueryAsync<dynamic>(
@"SELECT ld.id, ld.level, ld.name, ld.min_exp, ld.max_exp,
(SELECT COUNT(*) FROM members m WHERE m.current_level = ld.level) as member_count
FROM level_definitions ld ORDER BY ld.level");
return Ok(levels);
}
// EN: Request DTOs / VI: DTO yêu cầu
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price, string? Type, string? Sku, string? ImageUrl);
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);