feat(allPos): upgrad frontend

This commit is contained in:
Ho Ngoc Hai
2026-03-05 15:33:23 +07:00
parent 1fb1a5c52c
commit e4bedf2cd3
55 changed files with 8422 additions and 1794 deletions

View File

@@ -0,0 +1,264 @@
@*
EN: Beauty Salon POS Desktop — 2-panel layout: service categories + grid (left), current appointment/bill (right).
VI: POS Tham My Desktop — Bo cuc 2 panel: danh muc dich vu + luoi (trai), lich hen/hoa don hien tai (phai).
*@
@page "/pos/{ShopId:guid}/beauty"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@* === SERVICE PANEL (LEFT) / PANEL DICH VU (TRAI) === *@
<div class="pos-product-panel" style="padding:16px;overflow-y:auto;">
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--pos-text-tertiary);">
Dang tai...
</div>
}
else if (_loadError)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--pos-text-tertiary);">
Khong the tai du lieu
</div>
}
else
{
@* EN: Category tabs / VI: Tab danh muc *@
<div class="pos-category-tabs">
@foreach (var cat in _categories)
{
<button class="pos-category-tab @(cat == _selectedCategory ? "pos-category-tab--active" : "")"
style="@(cat == _selectedCategory ? "background:#A855F7;color:#fff;" : "")"
@onclick="() => _selectedCategory = cat">
@cat
</button>
}
</div>
@* === SERVICE GRID / LUOI DICH VU === *@
@if (!_services.Any())
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 0;color:var(--pos-text-tertiary);">
<i data-lucide="scissors" style="width:48px;height:48px;margin-bottom:12px;opacity:0.3;"></i>
<div style="font-size:15px;font-weight:600;margin-bottom:4px;">Chua co dich vu nao</div>
<div style="font-size:13px;">Vui long them dich vu trong phan Quan ly cua hang</div>
</div>
}
else
{
<div class="pos-product-grid">
@foreach (var svc in FilteredServices)
{
<div class="pos-product-card" @onclick="() => AddToAppointment(svc)">
<div class="pos-product-card__image" style="display:flex;align-items:center;justify-content:center;">
<i data-lucide="@GetCategoryIcon(svc.Category)" style="width:32px;height:32px;color:#A855F7;"></i>
</div>
<span class="pos-product-card__name">@svc.Name</span>
<span class="pos-product-card__price">@FormatPrice(svc.Price)</span>
<span style="font-size:11px;color:var(--pos-text-tertiary);">
<i data-lucide="clock" style="width:10px;height:10px;display:inline;"></i> @svc.Duration phut
</span>
</div>
}
</div>
}
}
</div>
@* === APPOINTMENT PANEL (RIGHT) / PANEL LICH HEN (PHAI) === *@
<div class="pos-cart-panel">
<div class="pos-cart-header">
<span class="pos-cart-header__title">
<i data-lucide="scissors" style="width:18px;height:18px;display:inline;color:#A855F7;"></i>
Dich Vu Tham My
</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">@_appointmentItems.Count dich vu</span>
</div>
@* EN: Customer info / VI: Thong tin khach *@
@if (_customerName is not null)
{
<div style="padding:8px 12px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--pos-border-subtle);">
<div style="width:36px;height:36px;border-radius:50%;background:#A855F7;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;color:#fff;">
@_customerName[..1]
</div>
<div>
<div style="font-size:13px;font-weight:600;">@_customerName</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@_customerPhone</div>
</div>
</div>
}
else
{
<div style="padding:8px 12px;border-bottom:1px solid var(--pos-border-subtle);">
<button style="width:100%;padding:10px;border-radius:var(--pos-radius);background:var(--pos-bg-interactive);border:1px dashed var(--pos-border-default);color:var(--pos-text-tertiary);cursor:pointer;font-size:13px;"
@onclick="@(() => NavigateTo("beauty/consent-form"))">
<i data-lucide="user-plus" style="width:14px;height:14px;display:inline;"></i> Chon khach hang
</button>
</div>
}
<div class="pos-cart-items">
@foreach (var item in _appointmentItems)
{
<div class="pos-cart-item">
<div class="pos-cart-item__info">
<span class="pos-cart-item__name">@item.Name</span>
<div style="display:flex;align-items:center;gap:8px;">
<span class="pos-cart-item__price">@FormatPrice(item.Price)</span>
<span style="font-size:11px;color:var(--pos-text-tertiary);">@item.Duration phut</span>
</div>
</div>
<div class="pos-cart-item__qty">
<button @onclick="() => RemoveItem(item)">
<i data-lucide="x" style="width:14px;height:14px;"></i>
</button>
</div>
</div>
}
</div>
<div class="pos-cart-footer">
@* EN: Duration total / VI: Tong thoi gian *@
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Tong thoi gian</span>
<span>@_appointmentItems.Sum(i => i.Duration) phut</span>
</div>
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tong cong</span>
<span class="pos-cart-total__value">@FormatPrice(AppointmentTotal)</span>
</div>
@* EN: Consent form requirement / VI: Yeu cau phieu dong y *@
<div style="display:flex;align-items:center;gap:8px;padding:8px 0;font-size:13px;">
<input type="checkbox" id="consentCheck" @bind="_consentChecked"
style="width:18px;height:18px;accent-color:#A855F7;cursor:pointer;" />
<label for="consentCheck" style="color:var(--pos-text-secondary);cursor:pointer;">
<i data-lucide="clipboard-check" style="width:14px;height:14px;display:inline;color:#A855F7;"></i>
Phieu dong y da ky
</label>
<button style="margin-left:auto;background:none;border:none;color:#A855F7;cursor:pointer;font-size:12px;text-decoration:underline;"
@onclick="@(() => NavigateTo("beauty/consent-form"))">
Tao phieu
</button>
</div>
<div style="display:flex;gap:8px;">
<button style="flex:1;padding:12px;border-radius:var(--pos-radius);background:var(--pos-bg-interactive);border:1px solid var(--pos-border-default);color:var(--pos-text-primary);cursor:pointer;font-size:13px;font-weight:600;"
@onclick="@(() => NavigateTo("beauty/treatment-plan"))">
<i data-lucide="clipboard-list" style="width:16px;height:16px;display:inline;"></i> Phac do
</button>
<button style="flex:1;padding:12px;border-radius:var(--pos-radius);background:var(--pos-bg-interactive);border:1px solid var(--pos-border-default);color:var(--pos-text-primary);cursor:pointer;font-size:13px;font-weight:600;"
@onclick="@(() => NavigateTo("beauty/doctor-schedule"))">
<i data-lucide="calendar" style="width:16px;height:16px;display:inline;"></i> Dat lich
</button>
<button class="pos-btn-checkout" style="flex:1;background:#A855F7;" @onclick="Checkout"
disabled="@(_isCheckingOut || !_consentChecked)">
<i data-lucide="credit-card" style="width:18px;height:18px;"></i> @(_isCheckingOut ? "Dang xu ly..." : "Thanh toan")
</button>
</div>
</div>
</div>
@code {
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
// EN: Categories / VI: Danh muc
private string[] _categories = { "Tat ca" };
private string _selectedCategory = "Tat ca";
// EN: Customer info / VI: Thong tin khach hang
private string? _customerName;
private string _customerPhone = "";
private bool _isCheckingOut;
private bool _consentChecked;
// EN: Service list from API / VI: Danh sach dich vu tu API
private List<BeautyService> _services = new();
// EN: Appointment items / VI: Muc lich hen
private readonly List<AppointmentItem> _appointmentItems = new();
private IEnumerable<BeautyService> FilteredServices =>
_selectedCategory == "Tat ca" ? _services : _services.Where(s => s.Category == _selectedCategory);
private decimal AppointmentTotal => _appointmentItems.Sum(i => i.Price);
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
var apiProducts = await DataService.GetProductsAsync(ShopId);
_services = apiProducts.Select(p => new BeautyService(
p.Id,
p.Name,
p.Price,
p.DurationMinutes ?? 60,
p.Category ?? "Khac"
)).ToList();
var cats = _services.Select(s => s.Category).Distinct().ToList();
_categories = new[] { "Tat ca" }.Concat(cats).ToArray();
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
}
private void AddToAppointment(BeautyService svc)
{
_appointmentItems.Add(new AppointmentItem(svc.Id, svc.Name, svc.Price, svc.Duration));
}
private void RemoveItem(AppointmentItem item) => _appointmentItems.Remove(item);
private async Task Checkout()
{
if (!_appointmentItems.Any() || !_consentChecked) return;
_isCheckingOut = true;
try
{
var orderItems = _appointmentItems.Select(i =>
new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest(
i.ProductId, i.Name, 1, i.Price, "Service"
)).ToList();
var request = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest(
ShopId, null, orderItems);
var result = await DataService.CreatePosOrderAsync(request);
if (result is not null)
{
NavigateTo("beauty/follow-up");
}
}
catch
{
// EN: Order creation failed / VI: Tao don hang that bai
}
finally
{
_isCheckingOut = false;
}
}
private static string GetCategoryIcon(string category) => category switch
{
"Botox" => "syringe", "Filler" => "droplets", "Laser" => "zap",
"Skincare" => "sparkles", "Phau thuat" => "stethoscope", _ => "scissors"
};
// EN: Models / VI: Mo hinh du lieu
private record BeautyService(Guid Id, string Name, decimal Price, int Duration, string Category);
private record AppointmentItem(Guid ProductId, string Name, decimal Price, int Duration);
}

View File

@@ -0,0 +1,228 @@
@*
EN: Beauty Salon POS Mobile — Single column: categories, service grid, floating appointment button, bottom sheet.
VI: POS Tham My Mobile — Mot cot: danh muc, luoi dich vu, nut lich hen noi, sheet duoi.
*@
@page "/pos/{ShopId:guid}/beauty/mobile"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="display:flex;flex-direction:column;width:100%;height:100%;overflow:hidden;">
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--pos-text-tertiary);">
Dang tai...
</div>
}
else if (_loadError)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--pos-text-tertiary);">
Khong the tai du lieu
</div>
}
else
{
@* EN: Category tabs / VI: Tab danh muc *@
<div class="pos-category-tabs" style="padding:8px 12px;">
@foreach (var cat in _categories)
{
<button class="pos-category-tab @(cat == _selectedCategory ? "pos-category-tab--active" : "")"
style="padding:10px 16px;font-size:14px;@(cat == _selectedCategory ? "background:#A855F7;color:#fff;" : "")"
@onclick="() => _selectedCategory = cat">
@cat
</button>
}
</div>
@* EN: Service grid / VI: Luoi dich vu *@
<div class="pos-product-grid" style="grid-template-columns:repeat(2, 1fr);gap:10px;padding:12px;">
@foreach (var svc in FilteredServices)
{
<div class="pos-product-card" style="padding:10px;" @onclick="() => AddToAppointment(svc)">
<div class="pos-product-card__image" style="aspect-ratio:1.2;display:flex;align-items:center;justify-content:center;">
<i data-lucide="@GetCategoryIcon(svc.Category)" style="width:28px;height:28px;color:#A855F7;"></i>
</div>
<span class="pos-product-card__name" style="font-size:12px;">@svc.Name</span>
<span class="pos-product-card__price" style="font-size:13px;">@FormatPrice(svc.Price)</span>
<span style="font-size:10px;color:var(--pos-text-tertiary);">@svc.Duration phut</span>
</div>
}
</div>
}
@* EN: Floating appointment button / VI: Nut lich hen noi *@
@if (_appointmentItems.Any())
{
<button style="position:fixed;bottom:20px;right:20px;width:64px;height:64px;border-radius:50%;background:#A855F7;border:none;color:#fff;font-size:20px;cursor:pointer;box-shadow:0 4px 20px rgba(168,85,247,0.4);display:flex;align-items:center;justify-content:center;z-index:100;"
@onclick="() => _showSheet = !_showSheet">
<i data-lucide="scissors" style="width:24px;height:24px;"></i>
<span style="position:absolute;top:-4px;right:-4px;background:var(--pos-danger);color:#fff;font-size:11px;font-weight:700;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;">
@_appointmentItems.Count
</span>
</button>
}
@* EN: Bottom sheet appointment / VI: Lich hen dang sheet duoi *@
@if (_showSheet)
{
<div class="pos-dialog-overlay" @onclick="() => _showSheet = false">
<div style="position:fixed;bottom:0;left:0;right:0;max-height:70vh;background:var(--pos-bg-elevated);border-radius:20px 20px 0 0;display:flex;flex-direction:column;overflow:hidden;"
@onclick:stopPropagation="true">
@* EN: Handle bar / VI: Thanh keo *@
<div style="padding:12px;display:flex;justify-content:center;">
<div style="width:40px;height:4px;border-radius:2px;background:var(--pos-border-default);"></div>
</div>
<div class="pos-cart-header">
<span class="pos-cart-header__title">
<i data-lucide="scissors" style="width:16px;height:16px;display:inline;color:#A855F7;"></i>
Dich Vu Tham My
</span>
<button style="background:none;border:none;color:var(--pos-danger);font-size:13px;cursor:pointer;"
@onclick="() => { _appointmentItems.Clear(); _showSheet = false; }">Xoa</button>
</div>
<div class="pos-cart-items" style="max-height:40vh;">
@foreach (var item in _appointmentItems)
{
<div class="pos-cart-item">
<div class="pos-cart-item__info">
<span class="pos-cart-item__name">@item.Name</span>
<div style="display:flex;align-items:center;gap:6px;">
<span class="pos-cart-item__price">@FormatPrice(item.Price)</span>
<span style="font-size:11px;color:var(--pos-text-tertiary);">@item.Duration phut</span>
</div>
</div>
<button style="background:none;border:none;color:var(--pos-danger);cursor:pointer;font-size:16px;"
@onclick="() => _appointmentItems.Remove(item)">x</button>
</div>
}
</div>
<div class="pos-cart-footer">
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Tong thoi gian</span>
<span>@_appointmentItems.Sum(i => i.Duration) phut</span>
</div>
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tong cong</span>
<span class="pos-cart-total__value">@FormatPrice(AppointmentTotal)</span>
</div>
@* EN: Consent check / VI: Kiem tra dong y *@
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;font-size:13px;">
<input type="checkbox" @bind="_consentChecked"
style="width:18px;height:18px;accent-color:#A855F7;" />
<span style="color:var(--pos-text-secondary);">
<i data-lucide="clipboard-check" style="width:12px;height:12px;display:inline;color:#A855F7;"></i>
Phieu dong y da ky
</span>
</div>
<button class="pos-btn-checkout" style="background:#A855F7;" @onclick="Checkout"
disabled="@(_isCheckingOut || !_consentChecked)">
@(_isCheckingOut ? "Dang xu ly..." : "Thanh toan")
</button>
</div>
</div>
</div>
}
</div>
@code {
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
// EN: Categories / VI: Danh muc
private string[] _categories = { "Tat ca" };
private string _selectedCategory = "Tat ca";
private bool _showSheet;
private bool _consentChecked;
private bool _isCheckingOut;
// EN: Service list from API / VI: Danh sach dich vu tu API
private List<BeautyService> _services = new();
// EN: Appointment items / VI: Muc lich hen
private readonly List<AppointmentItem> _appointmentItems = new();
private IEnumerable<BeautyService> FilteredServices =>
_selectedCategory == "Tat ca" ? _services : _services.Where(s => s.Category == _selectedCategory);
private decimal AppointmentTotal => _appointmentItems.Sum(i => i.Price);
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
var apiProducts = await DataService.GetProductsAsync(ShopId);
_services = apiProducts.Select(p => new BeautyService(
p.Id,
p.Name,
p.Price,
p.DurationMinutes ?? 60,
p.Category ?? "Khac"
)).ToList();
var cats = _services.Select(s => s.Category).Distinct().ToList();
_categories = new[] { "Tat ca" }.Concat(cats).ToArray();
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
}
private void AddToAppointment(BeautyService svc)
{
_appointmentItems.Add(new AppointmentItem(svc.Id, svc.Name, svc.Price, svc.Duration));
}
private async Task Checkout()
{
if (!_appointmentItems.Any() || !_consentChecked) return;
_isCheckingOut = true;
try
{
var orderItems = _appointmentItems.Select(i =>
new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest(
i.ProductId, i.Name, 1, i.Price, "Service"
)).ToList();
var request = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest(
ShopId, null, orderItems);
var result = await DataService.CreatePosOrderAsync(request);
if (result is not null)
{
NavigateTo("beauty/follow-up");
}
}
catch
{
// EN: Order creation failed / VI: Tao don hang that bai
}
finally
{
_isCheckingOut = false;
}
}
private static string GetCategoryIcon(string category) => category switch
{
"Botox" => "syringe", "Filler" => "droplets", "Laser" => "zap",
"Skincare" => "sparkles", "Phau thuat" => "stethoscope", _ => "scissors"
};
// EN: Models / VI: Mo hinh du lieu
private record BeautyService(Guid Id, string Name, decimal Price, int Duration, string Category);
private record AppointmentItem(Guid ProductId, string Name, decimal Price, int Duration);
}

View File

@@ -0,0 +1,217 @@
@*
EN: Beauty Salon POS Tablet — 2-column layout: service grid + appointment sidebar (340px), touch-friendly.
VI: POS Tham My Tablet — Bo cuc 2 cot: luoi dich vu + sidebar lich hen (340px), than thien cam ung.
*@
@page "/pos/{ShopId:guid}/beauty/tablet"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@* === SERVICE PANEL / PANEL DICH VU === *@
<div class="pos-product-panel">
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--pos-text-tertiary);">
Dang tai...
</div>
}
else if (_loadError)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--pos-text-tertiary);">
Khong the tai du lieu
</div>
}
else
{
<div class="pos-category-tabs">
@foreach (var cat in _categories)
{
<button class="pos-category-tab @(cat == _selectedCategory ? "pos-category-tab--active" : "")"
style="padding:12px 20px;font-size:15px;@(cat == _selectedCategory ? "background:#A855F7;color:#fff;" : "")"
@onclick="() => _selectedCategory = cat">
@cat
</button>
}
</div>
<div class="pos-product-grid" style="grid-template-columns:repeat(auto-fill, minmax(160px, 1fr));gap:16px;padding:20px;">
@foreach (var svc in FilteredServices)
{
<div class="pos-product-card" style="padding:16px;" @onclick="() => AddToAppointment(svc)">
<div class="pos-product-card__image" style="display:flex;align-items:center;justify-content:center;">
<i data-lucide="@GetCategoryIcon(svc.Category)" style="width:36px;height:36px;color:#A855F7;"></i>
</div>
<span class="pos-product-card__name" style="font-size:15px;">@svc.Name</span>
<span class="pos-product-card__price" style="font-size:16px;">@FormatPrice(svc.Price)</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">
<i data-lucide="clock" style="width:11px;height:11px;display:inline;"></i> @svc.Duration phut
</span>
</div>
}
</div>
}
</div>
@* === APPOINTMENT SIDEBAR / SIDEBAR LICH HEN === *@
<div class="pos-cart-panel" style="width:340px;min-width:340px;">
<div class="pos-cart-header">
<span class="pos-cart-header__title" style="font-size:17px;">
<i data-lucide="scissors" style="width:18px;height:18px;display:inline;color:#A855F7;"></i>
Dich Vu Tham My
</span>
<button style="background:none;border:none;color:var(--pos-danger);font-size:13px;cursor:pointer;"
@onclick="() => _appointmentItems.Clear()">Xoa tat ca</button>
</div>
@* EN: Customer selection / VI: Chon khach hang *@
<div style="padding:10px 12px;border-bottom:1px solid var(--pos-border-subtle);">
<button style="width:100%;padding:12px;border-radius:var(--pos-radius);background:var(--pos-bg-interactive);border:1px dashed var(--pos-border-default);color:var(--pos-text-tertiary);cursor:pointer;font-size:14px;"
@onclick="@(() => NavigateTo("beauty/consent-form"))">
<i data-lucide="user-plus" style="width:16px;height:16px;display:inline;"></i> Chon khach hang
</button>
</div>
<div class="pos-cart-items">
@foreach (var item in _appointmentItems)
{
<div class="pos-cart-item" style="padding:14px 12px;">
<div class="pos-cart-item__info">
<span class="pos-cart-item__name" style="font-size:15px;">@item.Name</span>
<div style="display:flex;align-items:center;gap:8px;">
<span class="pos-cart-item__price">@FormatPrice(item.Price)</span>
<span style="font-size:11px;color:var(--pos-text-tertiary);">@item.Duration phut</span>
</div>
</div>
<button style="width:36px;height:36px;border-radius:10px;border:1px solid var(--pos-border-default);background:transparent;color:var(--pos-danger);font-size:18px;cursor:pointer;"
@onclick="() => _appointmentItems.Remove(item)">x</button>
</div>
}
</div>
<div class="pos-cart-footer">
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Tong thoi gian</span>
<span>@_appointmentItems.Sum(i => i.Duration) phut</span>
</div>
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tong cong</span>
<span class="pos-cart-total__value">@FormatPrice(AppointmentTotal)</span>
</div>
@* EN: Consent check / VI: Kiem tra dong y *@
<div style="display:flex;align-items:center;gap:8px;padding:8px 0;font-size:13px;">
<input type="checkbox" @bind="_consentChecked"
style="width:18px;height:18px;accent-color:#A855F7;" />
<span style="color:var(--pos-text-secondary);">
<i data-lucide="clipboard-check" style="width:14px;height:14px;display:inline;color:#A855F7;"></i>
Phieu dong y da ky
</span>
<button style="margin-left:auto;background:none;border:none;color:#A855F7;cursor:pointer;font-size:12px;text-decoration:underline;"
@onclick="@(() => NavigateTo("beauty/consent-form"))">
Tao phieu
</button>
</div>
<button class="pos-btn-checkout" style="height:56px;font-size:17px;background:#A855F7;" @onclick="Checkout"
disabled="@(_isCheckingOut || !_consentChecked)">
@(_isCheckingOut ? "Dang xu ly..." : $"Thanh toan — {FormatPrice(AppointmentTotal)}")
</button>
</div>
</div>
@code {
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
// EN: Categories / VI: Danh muc
private string[] _categories = { "Tat ca" };
private string _selectedCategory = "Tat ca";
private bool _consentChecked;
private bool _isCheckingOut;
// EN: Service list from API / VI: Danh sach dich vu tu API
private List<BeautyService> _services = new();
// EN: Appointment items / VI: Muc lich hen
private readonly List<AppointmentItem> _appointmentItems = new();
private IEnumerable<BeautyService> FilteredServices =>
_selectedCategory == "Tat ca" ? _services : _services.Where(s => s.Category == _selectedCategory);
private decimal AppointmentTotal => _appointmentItems.Sum(i => i.Price);
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
var apiProducts = await DataService.GetProductsAsync(ShopId);
_services = apiProducts.Select(p => new BeautyService(
p.Id,
p.Name,
p.Price,
p.DurationMinutes ?? 60,
p.Category ?? "Khac"
)).ToList();
var cats = _services.Select(s => s.Category).Distinct().ToList();
_categories = new[] { "Tat ca" }.Concat(cats).ToArray();
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
}
private void AddToAppointment(BeautyService svc)
{
_appointmentItems.Add(new AppointmentItem(svc.Id, svc.Name, svc.Price, svc.Duration));
}
private async Task Checkout()
{
if (!_appointmentItems.Any() || !_consentChecked) return;
_isCheckingOut = true;
try
{
var orderItems = _appointmentItems.Select(i =>
new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest(
i.ProductId, i.Name, 1, i.Price, "Service"
)).ToList();
var request = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest(
ShopId, null, orderItems);
var result = await DataService.CreatePosOrderAsync(request);
if (result is not null)
{
NavigateTo("beauty/follow-up");
}
}
catch
{
// EN: Order creation failed / VI: Tao don hang that bai
}
finally
{
_isCheckingOut = false;
}
}
private static string GetCategoryIcon(string category) => category switch
{
"Botox" => "syringe", "Filler" => "droplets", "Laser" => "zap",
"Skincare" => "sparkles", "Phau thuat" => "stethoscope", _ => "scissors"
};
// EN: Models / VI: Mo hinh du lieu
private record BeautyService(Guid Id, string Name, decimal Price, int Duration, string Category);
private record AppointmentItem(Guid ProductId, string Name, decimal Price, int Duration);
}

View File

@@ -0,0 +1,201 @@
@*
EN: Consent Form — Digital consent form before medical/cosmetic procedures at beauty salon.
VI: Phieu Dong Y — Phieu dong y dien tu truoc khi thuc hien thu tuc y te/tham my.
*@
@page "/pos/{ShopId:guid}/beauty/consent-form"
@layout PosLayout
@inherits PosBase
@* TODO: Integrate with backend API for persisting consent forms *@
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* === HEADER / TIEU DE === *@
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);flex-shrink:0;">
<div style="display:flex;align-items:center;gap:12px;">
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
@onclick="@(() => NavigateTo("beauty"))">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;color:#A855F7;">
<i data-lucide="clipboard-check" style="width:20px;height:20px;display:inline;"></i>
Phieu Dong Y Dieu Tri
</span>
</div>
</div>
<div style="flex:1;overflow-y:auto;padding:20px;max-width:800px;margin:0 auto;width:100%;">
@* === SERVICE & CUSTOMER INFO / THONG TIN DICH VU & KHACH HANG === *@
<div style="background:var(--pos-bg-elevated);border-radius:12px;padding:20px;border:1px solid var(--pos-border-subtle);margin-bottom:20px;">
<div style="font-size:14px;font-weight:700;margin-bottom:16px;color:#A855F7;">
<i data-lucide="info" style="width:16px;height:16px;display:inline;"></i> Thong tin dieu tri
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div>
<label style="font-size:12px;color:var(--pos-text-tertiary);display:block;margin-bottom:4px;">Dich vu</label>
<input type="text" @bind="_serviceName" placeholder="Ten dich vu"
style="width:100%;padding:10px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:14px;" />
</div>
<div>
<label style="font-size:12px;color:var(--pos-text-tertiary);display:block;margin-bottom:4px;">Khach hang</label>
<input type="text" @bind="_customerName" placeholder="Ho ten khach hang"
style="width:100%;padding:10px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:14px;" />
</div>
<div>
<label style="font-size:12px;color:var(--pos-text-tertiary);display:block;margin-bottom:4px;">Bac si thuc hien</label>
<input type="text" @bind="_doctorName" placeholder="Ten bac si"
style="width:100%;padding:10px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:14px;" />
</div>
</div>
</div>
@* === CONSENT SECTIONS / PHAN DONG Y === *@
<div style="background:var(--pos-bg-elevated);border-radius:12px;padding:20px;border:1px solid var(--pos-border-subtle);margin-bottom:20px;">
<div style="font-size:14px;font-weight:700;margin-bottom:16px;color:#A855F7;">
<i data-lucide="shield-check" style="width:16px;height:16px;display:inline;"></i> Cam ket dong y
</div>
<div style="display:flex;flex-direction:column;gap:16px;">
@* Consent 1: Counseled about procedure *@
<label style="display:flex;align-items:flex-start;gap:12px;cursor:pointer;padding:12px;border-radius:8px;border:1px solid var(--pos-border-subtle);background:@(_consent1 ? "rgba(168,85,247,0.05)" : "transparent");">
<input type="checkbox" @bind="_consent1"
style="width:20px;height:20px;accent-color:#A855F7;flex-shrink:0;margin-top:2px;" />
<div>
<div style="font-size:14px;font-weight:600;color:var(--pos-text-primary);">
Toi da duoc tu van ve quy trinh dieu tri
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:2px;">
Bac si da giai thich day du ve quy trinh, buoc thuc hien va ket qua ky vong.
</div>
</div>
</label>
@* Consent 2: Understand risks *@
<label style="display:flex;align-items:flex-start;gap:12px;cursor:pointer;padding:12px;border-radius:8px;border:1px solid var(--pos-border-subtle);background:@(_consent2 ? "rgba(168,85,247,0.05)" : "transparent");">
<input type="checkbox" @bind="_consent2"
style="width:20px;height:20px;accent-color:#A855F7;flex-shrink:0;margin-top:2px;" />
<div>
<div style="font-size:14px;font-weight:600;color:var(--pos-text-primary);">
Toi hieu ro cac rui ro va tac dung phu
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:2px;">
Toi da duoc thong bao ve cac rui ro co the xay ra, tac dung phu va bien chung lien quan.
</div>
</div>
</label>
@* Consent 3: Agree to treatment *@
<label style="display:flex;align-items:flex-start;gap:12px;cursor:pointer;padding:12px;border-radius:8px;border:1px solid var(--pos-border-subtle);background:@(_consent3 ? "rgba(168,85,247,0.05)" : "transparent");">
<input type="checkbox" @bind="_consent3"
style="width:20px;height:20px;accent-color:#A855F7;flex-shrink:0;margin-top:2px;" />
<div>
<div style="font-size:14px;font-weight:600;color:var(--pos-text-primary);">
Toi dong y thuc hien dieu tri
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:2px;">
Toi tu nguyen dong y thuc hien dich vu/thu thuat da duoc tu van.
</div>
</div>
</label>
@* Consent 4: Health info accurate *@
<label style="display:flex;align-items:flex-start;gap:12px;cursor:pointer;padding:12px;border-radius:8px;border:1px solid var(--pos-border-subtle);background:@(_consent4 ? "rgba(168,85,247,0.05)" : "transparent");">
<input type="checkbox" @bind="_consent4"
style="width:20px;height:20px;accent-color:#A855F7;flex-shrink:0;margin-top:2px;" />
<div>
<div style="font-size:14px;font-weight:600;color:var(--pos-text-primary);">
Toi xac nhan thong tin suc khoe da cung cap la chinh xac
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:2px;">
Toi xac nhan tat ca thong tin y te, tien su benh, di ung da cung cap la trung thuc va day du.
</div>
</div>
</label>
</div>
</div>
@* === MEDICAL HISTORY / TIEN SU Y TE === *@
<div style="background:var(--pos-bg-elevated);border-radius:12px;padding:20px;border:1px solid var(--pos-border-subtle);margin-bottom:20px;">
<div style="font-size:14px;font-weight:700;margin-bottom:16px;color:#A855F7;">
<i data-lucide="stethoscope" style="width:16px;height:16px;display:inline;"></i> Tien su y te
</div>
<div style="display:flex;flex-direction:column;gap:12px;">
<div>
<label style="font-size:12px;color:var(--pos-text-tertiary);display:block;margin-bottom:4px;">Di ung (thuoc, thuc pham, hoa chat...)</label>
<textarea @bind="_allergies" placeholder="Liet ke cac di ung neu co, hoac ghi 'Khong co'"
style="width:100%;padding:10px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:14px;min-height:80px;resize:vertical;"></textarea>
</div>
<div>
<label style="font-size:12px;color:var(--pos-text-tertiary);display:block;margin-bottom:4px;">Thuoc dang su dung</label>
<textarea @bind="_currentMedications" placeholder="Liet ke cac thuoc dang dung, hoac ghi 'Khong co'"
style="width:100%;padding:10px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:14px;min-height:80px;resize:vertical;"></textarea>
</div>
</div>
</div>
@* === SIGNATURE / CHU KY === *@
<div style="background:var(--pos-bg-elevated);border-radius:12px;padding:20px;border:1px solid var(--pos-border-subtle);margin-bottom:20px;">
<div style="font-size:14px;font-weight:700;margin-bottom:16px;color:#A855F7;">
<i data-lucide="pen-tool" style="width:16px;height:16px;display:inline;"></i> Chu ky dien tu
</div>
<div>
<label style="font-size:12px;color:var(--pos-text-tertiary);display:block;margin-bottom:4px;">Nhap ho ten day du de xac nhan</label>
<input type="text" @bind="_signature" placeholder="Nhap ho ten cua ban de ky"
style="width:100%;padding:12px;border-radius:8px;border:2px solid @(IsSignatureValid ? "#A855F7" : "var(--pos-border-default)");background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:16px;font-style:italic;" />
</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:6px;">
Ngay ky: @DateTime.Now.ToString("dd/MM/yyyy HH:mm")
</div>
</div>
@* === CONFIRM BUTTON / NUT XAC NHAN === *@
<div style="display:flex;justify-content:center;padding-bottom:20px;">
<button style="padding:14px 48px;border-radius:12px;border:none;font-size:16px;font-weight:700;cursor:pointer;
background:@(CanConfirm ? "#A855F7" : "var(--pos-bg-interactive)");
color:@(CanConfirm ? "#fff" : "var(--pos-text-tertiary)");
box-shadow:@(CanConfirm ? "0 4px 20px rgba(168,85,247,0.3)" : "none");"
disabled="@(!CanConfirm)"
@onclick="ConfirmConsent">
<i data-lucide="check-circle" style="width:20px;height:20px;display:inline;"></i>
Xac nhan dong y
</button>
</div>
</div>
</div>
@code {
// TODO: Replace local state with API calls when backend consent form endpoints are available
// EN: Service/customer/doctor info / VI: Thong tin dich vu/khach hang/bac si
private string _serviceName = "";
private string _customerName = "";
private string _doctorName = "";
// EN: Consent checkboxes / VI: Cac muc dong y
private bool _consent1;
private bool _consent2;
private bool _consent3;
private bool _consent4;
// EN: Medical history / VI: Tien su y te
private string _allergies = "";
private string _currentMedications = "";
// EN: Signature / VI: Chu ky
private string _signature = "";
private bool AllConsentsChecked => _consent1 && _consent2 && _consent3 && _consent4;
private bool IsSignatureValid => !string.IsNullOrWhiteSpace(_signature);
private bool CanConfirm => AllConsentsChecked && IsSignatureValid;
private void ConfirmConsent()
{
if (!CanConfirm) return;
// TODO: Save consent form data to backend API
// var consentData = new { ServiceName = _serviceName, CustomerName = _customerName, ... };
NavigateTo("beauty");
}
}

View File

@@ -0,0 +1,262 @@
@*
EN: Doctor Schedule — Calendar day view with horizontal timeline, doctor/specialist rows, appointment blocks, current time.
VI: Lich Bac Si — Xem theo ngay voi timeline ngang, hang bac si/chuyen gia, khoi lich hen, thoi gian hien tai.
*@
@page "/pos/{ShopId:guid}/beauty/doctor-schedule"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* === HEADER / TIEU DE === *@
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);flex-shrink:0;">
<div style="display:flex;align-items:center;gap:12px;">
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
@onclick="@(() => NavigateTo("beauty"))">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;color:#A855F7;">
<i data-lucide="stethoscope" style="width:20px;height:20px;display:inline;"></i>
Lich Bac Si
</span>
<span style="font-size:13px;color:var(--pos-text-tertiary);">Hom nay, @DateTime.Now.ToString("dd/MM/yyyy")</span>
</div>
@* EN: Legend / VI: Chu thich *@
<div style="display:flex;gap:12px;font-size:11px;">
<span style="display:flex;align-items:center;gap:4px;">
<span style="width:10px;height:10px;border-radius:3px;background:rgba(168,85,247,.3);"></span> Botox
</span>
<span style="display:flex;align-items:center;gap:4px;">
<span style="width:10px;height:10px;border-radius:3px;background:rgba(236,72,153,.3);"></span> Filler
</span>
<span style="display:flex;align-items:center;gap:4px;">
<span style="width:10px;height:10px;border-radius:3px;background:rgba(245,158,11,.3);"></span> Laser
</span>
<span style="display:flex;align-items:center;gap:4px;">
<span style="width:10px;height:10px;border-radius:3px;background:rgba(34,197,94,.3);"></span> Skincare
</span>
</div>
</div>
@* === SCHEDULE GRID / LUOI LICH === *@
@if (_isLoading)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Dang tai lich...
</div>
}
else if (_loadError)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Khong the tai du lieu lich
</div>
}
else
{
<div style="flex:1;overflow:auto;position:relative;">
@* EN: Time header row / VI: Hang tieu de gio *@
<div style="display:flex;position:sticky;top:0;z-index:10;background:var(--pos-bg-elevated);border-bottom:1px solid var(--pos-border-subtle);">
<div style="width:140px;min-width:140px;padding:10px 12px;font-size:12px;font-weight:600;color:var(--pos-text-tertiary);
border-right:1px solid var(--pos-border-subtle);">
Bac si
</div>
<div style="display:flex;flex:1;">
@foreach (var hour in _hours)
{
<div style="min-width:100px;padding:10px 8px;text-align:center;font-size:12px;font-weight:500;
color:var(--pos-text-tertiary);border-right:1px solid var(--pos-border-subtle);">
@hour:00
</div>
}
</div>
</div>
@* EN: Doctor rows / VI: Hang bac si *@
@foreach (var doctor in _scheduleData)
{
<div style="display:flex;border-bottom:1px solid var(--pos-border-subtle);min-height:64px;">
@* EN: Doctor name column / VI: Cot ten bac si *@
<div style="width:140px;min-width:140px;padding:12px;display:flex;align-items:center;gap:8px;
border-right:1px solid var(--pos-border-subtle);flex-shrink:0;">
<div style="width:32px;height:32px;border-radius:50%;background:rgba(168,85,247,0.15);
display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#A855F7;flex-shrink:0;">
@doctor.Name[..1]
</div>
<div>
<div style="font-size:12px;font-weight:600;">@doctor.Name</div>
<div style="font-size:10px;color:var(--pos-text-tertiary);">@doctor.Role</div>
</div>
</div>
@* EN: Timeline with blocks / VI: Timeline voi khoi lich *@
<div style="flex:1;position:relative;display:flex;">
@foreach (var hour in _hours)
{
<div style="min-width:100px;border-right:1px solid var(--pos-border-subtle);"></div>
}
@* EN: Appointment blocks / VI: Khoi lich hen *@
@foreach (var appt in doctor.Appointments)
{
var leftPos = (appt.StartHour - 9) * 100 + (appt.StartMin / 30.0 * 50);
var widthVal = appt.DurationMin / 30.0 * 50;
<div style="position:absolute;left:@(leftPos)px;top:8px;bottom:8px;width:@(widthVal)px;
background:@GetServiceBg(appt.Type);border-left:3px solid @GetServiceColor(appt.Type);
border-radius:6px;padding:4px 8px;overflow:hidden;cursor:pointer;"
@onclick="@(() => NavigateTo("beauty/treatment-plan"))">
<div style="font-size:11px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
@appt.CustomerName
</div>
<div style="font-size:10px;color:var(--pos-text-tertiary);white-space:nowrap;">
@appt.Service
</div>
</div>
}
</div>
</div>
}
@* EN: Current time indicator / VI: Chi bao thoi gian hien tai *@
<div style="position:absolute;top:0;bottom:0;left:@(140 + (DateTime.Now.Hour - 9) * 100 + DateTime.Now.Minute / 30.0 * 50)px;
width:2px;background:#A855F7;z-index:5;">
<div style="position:absolute;top:-4px;left:-4px;width:10px;height:10px;border-radius:50%;background:#A855F7;"></div>
</div>
</div>
}
@* === SUMMARY / TOM TAT === *@
<div style="padding:10px 16px;border-top:1px solid var(--pos-border-subtle);display:flex;gap:20px;font-size:12px;color:var(--pos-text-secondary);flex-shrink:0;">
<span>Tong: <b>@_scheduleData.Sum(s => s.Appointments.Count)</b> lich hen</span>
<span style="color:#A855F7;">Dang thuc hien: <b>@_inProgressCount</b></span>
<span style="color:var(--pos-success);">Hoan thanh: <b>@_completedCount</b></span>
<span style="color:var(--pos-text-tertiary);">Sap toi: <b>@_upcomingCount</b></span>
</div>
</div>
@code {
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
// EN: Hours range / VI: Pham vi gio
private readonly int[] _hours = { 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 };
private List<DoctorScheduleRow> _scheduleData = new();
// EN: Summary counters / VI: Bo dem tom tat
private int _inProgressCount;
private int _completedCount;
private int _upcomingCount;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
// EN: Load staff and appointments in parallel / VI: Tai nhan vien va lich hen song song
var staffTask = DataService.GetStaffForShopAsync(ShopId);
var appointmentsTask = DataService.GetAppointmentsAsync(ShopId);
var staffData = await staffTask;
var appointments = await appointmentsTask;
// EN: Filter today's appointments / VI: Loc lich hen hom nay
var todayAppointments = appointments
.Where(a => a.StartTime.Date == DateTime.Today)
.ToList();
// EN: Count by status / VI: Dem theo trang thai
var now = DateTime.Now;
_completedCount = todayAppointments.Count(a => a.Status?.ToLower() == "completed");
_inProgressCount = todayAppointments.Count(a => a.StartTime <= now && a.EndTime >= now && a.Status?.ToLower() != "completed" && a.Status?.ToLower() != "cancelled");
_upcomingCount = todayAppointments.Count(a => a.StartTime > now && a.Status?.ToLower() != "cancelled");
// EN: Group appointments by staff / VI: Nhom lich hen theo nhan vien
var staffMap = staffData.ToDictionary(s => s.Id, s =>
{
var name = $"{s.FirstName ?? ""} {s.LastName ?? ""}".Trim();
if (string.IsNullOrEmpty(name)) name = s.EmployeeCode ?? s.Id.ToString()[..8];
return (Name: name, Role: s.Role ?? "Bac si");
});
// EN: Build schedule data — group by staff / VI: Xay dung du lieu lich — nhom theo nhan vien
var grouped = todayAppointments
.Where(a => a.StaffId.HasValue)
.GroupBy(a => a.StaffId!.Value)
.ToList();
_scheduleData = new();
foreach (var group in grouped)
{
var doctorName = staffMap.TryGetValue(group.Key, out var info) ? info.Name : group.Key.ToString()[..8];
var doctorRole = staffMap.TryGetValue(group.Key, out var info2) ? info2.Role : "Bac si";
var blocks = group.Select(a => new AppointmentBlock(
a.ResourceName ?? "Khach",
a.ResourceName ?? "Dich vu",
GuessServiceType(a.ResourceName),
a.StartTime.Hour,
a.StartTime.Minute,
(int)(a.EndTime - a.StartTime).TotalMinutes
)).ToList();
_scheduleData.Add(new DoctorScheduleRow(doctorName, doctorRole, blocks));
}
// EN: Add staff without appointments / VI: Them nhan vien chua co lich hen
var assignedStaffIds = grouped.Select(g => g.Key).ToHashSet();
foreach (var s in staffData.Where(s => !assignedStaffIds.Contains(s.Id)))
{
var name = $"{s.FirstName ?? ""} {s.LastName ?? ""}".Trim();
if (string.IsNullOrEmpty(name)) name = s.EmployeeCode ?? s.Id.ToString()[..8];
_scheduleData.Add(new DoctorScheduleRow(name, s.Role ?? "Bac si", new()));
}
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
}
// EN: Guess service type from name for color coding / VI: Doan loai dich vu tu ten de to mau
private static string GuessServiceType(string? name)
{
if (name is null) return "Other";
var lower = name.ToLower();
if (lower.Contains("botox") || lower.Contains("tiem")) return "Botox";
if (lower.Contains("filler") || lower.Contains("chat lan")) return "Filler";
if (lower.Contains("laser") || lower.Contains("tri nam") || lower.Contains("triet long")) return "Laser";
if (lower.Contains("skincare") || lower.Contains("da") || lower.Contains("duong")) return "Skincare";
return "Other";
}
// EN: Beauty service type colors / VI: Mau loai dich vu tham my
private static string GetServiceBg(string type) => type switch
{
"Botox" => "rgba(168,85,247,.15)", // #A855F7 purple
"Filler" => "rgba(236,72,153,.15)", // #EC4899 pink
"Laser" => "rgba(245,158,11,.15)", // #F59E0B amber
"Skincare" => "rgba(34,197,94,.15)", // #22C55E green
_ => "rgba(148,163,184,.15)"
};
private static string GetServiceColor(string type) => type switch
{
"Botox" => "#A855F7",
"Filler" => "#EC4899",
"Laser" => "#F59E0B",
"Skincare" => "#22C55E",
_ => "#94A3B8"
};
private record AppointmentBlock(string CustomerName, string Service, string Type, int StartHour, int StartMin, int DurationMin);
private record DoctorScheduleRow(string Name, string Role, List<AppointmentBlock> Appointments);
}

View File

@@ -0,0 +1,287 @@
@*
EN: Follow-Up Management — Manage follow-up appointments after beauty treatments.
VI: Quan Ly Tai Kham — Quan ly cac lich hen tai kham sau dieu tri tham my.
*@
@page "/pos/{ShopId:guid}/beauty/follow-up"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* === HEADER / TIEU DE === *@
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);flex-shrink:0;">
<div style="display:flex;align-items:center;gap:12px;">
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
@onclick="@(() => NavigateTo("beauty"))">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;color:#A855F7;">
<i data-lucide="calendar-heart" style="width:20px;height:20px;display:inline;"></i>
Tai Kham
</span>
</div>
@* EN: Summary stats / VI: Thong ke tom tat *@
<div style="display:flex;gap:16px;font-size:12px;">
<span style="display:flex;align-items:center;gap:4px;color:var(--pos-text-secondary);">
Tong: <b>@_followUps.Count</b>
</span>
<span style="display:flex;align-items:center;gap:4px;color:var(--pos-danger);">
Qua han: <b>@_followUps.Count(f => f.Status == "overdue")</b>
</span>
<span style="display:flex;align-items:center;gap:4px;color:var(--pos-success);">
Hoan thanh hom nay: <b>@_followUps.Count(f => f.Status == "completed" && f.FollowUpDate.Date == DateTime.Today)</b>
</span>
</div>
</div>
@* === FILTER TABS / TAB LOC === *@
<div style="display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);flex-shrink:0;">
@foreach (var tab in _tabs)
{
<button style="padding:8px 20px;border-radius:20px;border:1px solid @(_selectedTab == tab.Key ? "#A855F7" : "var(--pos-border-default)");
background:@(_selectedTab == tab.Key ? "#A855F7" : "var(--pos-bg-interactive)");
color:@(_selectedTab == tab.Key ? "#fff" : "var(--pos-text-primary)");
cursor:pointer;font-size:13px;font-weight:@(_selectedTab == tab.Key ? "600" : "400");"
@onclick="() => _selectedTab = tab.Key">
@tab.Label
@if (tab.Count > 0)
{
<span style="margin-left:4px;font-size:11px;opacity:0.8;">(@tab.Count)</span>
}
</button>
}
</div>
@* === FOLLOW-UP LIST / DANH SACH TAI KHAM === *@
@if (_isLoading)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Dang tai...
</div>
}
else if (_loadError)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Khong the tai du lieu
</div>
}
else
{
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px;">
@if (!FilteredFollowUps.Any())
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 0;color:var(--pos-text-tertiary);">
<i data-lucide="calendar-heart" style="width:48px;height:48px;margin-bottom:12px;opacity:0.3;"></i>
<div style="font-size:15px;font-weight:600;margin-bottom:4px;">Khong co lich tai kham</div>
<div style="font-size:13px;">Khong co lich tai kham nao trong muc nay</div>
</div>
}
@foreach (var followUp in FilteredFollowUps)
{
<div style="background:var(--pos-bg-elevated);border-radius:12px;padding:16px;border:1px solid var(--pos-border-subtle);
border-left:4px solid @GetStatusColor(followUp.Status);">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px;">
<div>
<div style="font-size:15px;font-weight:700;color:var(--pos-text-primary);">@followUp.CustomerName</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);">
<i data-lucide="phone" style="width:12px;height:12px;display:inline;"></i> @followUp.CustomerPhone
</div>
</div>
<span style="font-size:11px;padding:4px 12px;border-radius:20px;background:@GetStatusBg(followUp.Status);color:@GetStatusColor(followUp.Status);font-weight:600;">
@GetStatusLabel(followUp.Status)
</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px;font-size:13px;">
<div>
<span style="color:var(--pos-text-tertiary);">Dieu tri goc:</span>
<div style="font-weight:600;">@followUp.TreatmentName</div>
</div>
<div>
<span style="color:var(--pos-text-tertiary);">Ngay dieu tri:</span>
<div style="font-weight:600;">@followUp.TreatmentDate.ToString("dd/MM/yyyy")</div>
</div>
<div>
<span style="color:var(--pos-text-tertiary);">Ngay tai kham:</span>
<div style="font-weight:600;color:@(followUp.FollowUpDate.Date < DateTime.Today && followUp.Status != "completed" ? "var(--pos-danger)" : "var(--pos-text-primary)");">
@followUp.FollowUpDate.ToString("dd/MM/yyyy")
@if (followUp.FollowUpDate.Date < DateTime.Today && followUp.Status != "completed")
{
<span style="font-size:11px;"> (qua han)</span>
}
</div>
</div>
<div>
<span style="color:var(--pos-text-tertiary);">Bac si:</span>
<div style="font-weight:600;">@followUp.DoctorName</div>
</div>
</div>
@* EN: Action buttons / VI: Nut hanh dong *@
<div style="display:flex;gap:8px;">
<button style="flex:1;padding:8px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);cursor:pointer;font-size:12px;font-weight:600;"
@onclick="() => CallCustomer(followUp)">
<i data-lucide="phone" style="width:14px;height:14px;display:inline;"></i> Goi
</button>
<button style="flex:1;padding:8px;border-radius:8px;border:1px solid #A855F7;background:rgba(168,85,247,0.1);color:#A855F7;cursor:pointer;font-size:12px;font-weight:600;"
@onclick="() => Reschedule(followUp)">
<i data-lucide="calendar" style="width:14px;height:14px;display:inline;"></i> Doi lich
</button>
<button style="flex:1;padding:8px;border-radius:8px;border:none;background:var(--pos-success);color:#fff;cursor:pointer;font-size:12px;font-weight:600;"
@onclick="() => MarkComplete(followUp)">
<i data-lucide="check" style="width:14px;height:14px;display:inline;"></i> Hoan thanh
</button>
</div>
</div>
}
</div>
}
</div>
@code {
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
private string _selectedTab = "today";
private List<FollowUpItem> _followUps = new();
private record TabItem(string Key, string Label, int Count);
private TabItem[] _tabs = Array.Empty<TabItem>();
private IEnumerable<FollowUpItem> FilteredFollowUps => _selectedTab switch
{
"today" => _followUps.Where(f => f.FollowUpDate.Date == DateTime.Today),
"week" => _followUps.Where(f => f.FollowUpDate.Date >= DateTime.Today && f.FollowUpDate.Date <= DateTime.Today.AddDays(7)),
"overdue" => _followUps.Where(f => f.FollowUpDate.Date < DateTime.Today && f.Status != "completed"),
_ => _followUps
};
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
var appointments = await DataService.GetAppointmentsAsync(ShopId);
// EN: Map appointments to follow-up context / VI: Map lich hen sang ngu canh tai kham
_followUps = appointments
.Where(a => a.Status?.ToLower() != "cancelled")
.Select(a =>
{
var followUpDate = a.EndTime.AddDays(14); // EN: Default 2-week follow-up / VI: Mac dinh tai kham sau 2 tuan
var status = DetermineFollowUpStatus(a, followUpDate);
return new FollowUpItem
{
Id = a.Id,
CustomerName = a.ResourceName ?? "Khach hang",
CustomerPhone = "0901-xxx-xxx",
TreatmentName = a.ResourceName ?? "Dich vu tham my",
TreatmentDate = a.StartTime,
FollowUpDate = followUpDate,
DoctorName = "Bac si",
Status = status
};
})
.OrderBy(f => f.FollowUpDate)
.ToList();
UpdateTabs();
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
}
private static string DetermineFollowUpStatus(WebClientTpos.Client.Services.PosDataService.AppointmentInfo a, DateTime followUpDate)
{
if (a.Status?.ToLower() == "completed" && followUpDate.Date <= DateTime.Today)
return "completed";
if (followUpDate.Date < DateTime.Today)
return "overdue";
if (followUpDate.Date == DateTime.Today)
return "scheduled";
return "scheduled";
}
private void UpdateTabs()
{
_tabs = new[]
{
new TabItem("today", "Hom nay", _followUps.Count(f => f.FollowUpDate.Date == DateTime.Today)),
new TabItem("week", "Tuan nay", _followUps.Count(f => f.FollowUpDate.Date >= DateTime.Today && f.FollowUpDate.Date <= DateTime.Today.AddDays(7))),
new TabItem("overdue", "Qua han", _followUps.Count(f => f.FollowUpDate.Date < DateTime.Today && f.Status != "completed")),
};
}
private void CallCustomer(FollowUpItem followUp)
{
followUp.Status = "contacted";
UpdateTabs();
}
private void Reschedule(FollowUpItem followUp)
{
followUp.FollowUpDate = followUp.FollowUpDate.AddDays(7);
followUp.Status = "scheduled";
UpdateTabs();
}
private void MarkComplete(FollowUpItem followUp)
{
followUp.Status = "completed";
UpdateTabs();
}
private static string GetStatusColor(string status) => status switch
{
"completed" => "#22C55E",
"contacted" => "#3B82F6",
"scheduled" => "#A855F7",
"overdue" => "#EF4444",
"missed" => "#EF4444",
_ => "#94A3B8"
};
private static string GetStatusBg(string status) => status switch
{
"completed" => "rgba(34,197,94,0.1)",
"contacted" => "rgba(59,130,246,0.1)",
"scheduled" => "rgba(168,85,247,0.1)",
"overdue" => "rgba(239,68,68,0.1)",
"missed" => "rgba(239,68,68,0.1)",
_ => "rgba(148,163,184,0.1)"
};
private static string GetStatusLabel(string status) => status switch
{
"completed" => "Hoan thanh",
"contacted" => "Da lien he",
"scheduled" => "Da hen",
"overdue" => "Qua han",
"missed" => "Bo lo",
_ => "Chua xac dinh"
};
private class FollowUpItem
{
public Guid Id { get; set; }
public string CustomerName { get; set; } = "";
public string CustomerPhone { get; set; } = "";
public string TreatmentName { get; set; } = "";
public DateTime TreatmentDate { get; set; }
public DateTime FollowUpDate { get; set; }
public string DoctorName { get; set; } = "";
public string Status { get; set; } = "scheduled";
}
}

View File

@@ -0,0 +1,229 @@
@*
EN: Treatment Plan — Create and manage treatment plans (phac do dieu tri) for beauty salon customers.
VI: Phac Do Dieu Tri — Tao va quan ly phac do dieu tri cho khach hang tham my vien.
*@
@page "/pos/{ShopId:guid}/beauty/treatment-plan"
@layout PosLayout
@inherits PosBase
@* TODO: Integrate with backend API for treatment plan CRUD operations *@
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* === HEADER / TIEU DE === *@
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);flex-shrink:0;">
<div style="display:flex;align-items:center;gap:12px;">
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
@onclick="@(() => NavigateTo("beauty"))">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;color:#A855F7;">
<i data-lucide="clipboard-list" style="width:20px;height:20px;display:inline;"></i>
Phac Do Dieu Tri
</span>
</div>
<button style="background:#A855F7;color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;"
@onclick="AddSession">
<i data-lucide="plus" style="width:14px;height:14px;display:inline;"></i> Them buoi dieu tri
</button>
</div>
<div style="flex:1;display:flex;overflow:hidden;">
@* === LEFT PANEL: Customer + Treatment Plan / PANEL TRAI: Khach hang + Phac do === *@
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:16px;">
@* EN: Customer info section / VI: Phan thong tin khach hang *@
<div style="background:var(--pos-bg-elevated);border-radius:12px;padding:16px;border:1px solid var(--pos-border-subtle);">
<div style="font-size:14px;font-weight:700;margin-bottom:12px;color:#A855F7;">
<i data-lucide="user" style="width:16px;height:16px;display:inline;"></i> Thong tin khach hang
</div>
<div style="display:flex;gap:12px;">
<div style="flex:1;">
<label style="font-size:12px;color:var(--pos-text-tertiary);display:block;margin-bottom:4px;">Ho ten</label>
<input type="text" @bind="_customerName" placeholder="Nhap ho ten khach hang"
style="width:100%;padding:10px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:14px;" />
</div>
<div style="flex:1;">
<label style="font-size:12px;color:var(--pos-text-tertiary);display:block;margin-bottom:4px;">So dien thoai</label>
<input type="tel" @bind="_customerPhone" placeholder="Nhap so dien thoai"
style="width:100%;padding:10px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:14px;" />
</div>
</div>
</div>
@* EN: Treatment sessions / VI: Cac buoi dieu tri *@
<div style="font-size:14px;font-weight:700;color:var(--pos-text-primary);">
Ke hoach dieu tri (@_sessions.Count buoi)
</div>
@if (!_sessions.Any())
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 0;color:var(--pos-text-tertiary);">
<i data-lucide="clipboard-list" style="width:40px;height:40px;margin-bottom:8px;opacity:0.3;"></i>
<div style="font-size:14px;">Chua co buoi dieu tri nao</div>
<div style="font-size:12px;">Bam "Them buoi dieu tri" de bat dau</div>
</div>
}
@foreach (var (session, index) in _sessions.Select((s, i) => (s, i)))
{
<div style="background:var(--pos-bg-elevated);border-radius:12px;padding:16px;border:1px solid var(--pos-border-subtle);
border-left:4px solid @GetStatusColor(session.Status);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<span style="font-size:14px;font-weight:700;">
Buoi @(index + 1): @session.ServiceName
</span>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:11px;padding:4px 10px;border-radius:20px;background:@GetStatusBg(session.Status);color:@GetStatusColor(session.Status);font-weight:600;">
@GetStatusLabel(session.Status)
</span>
<button style="background:none;border:none;color:var(--pos-danger);cursor:pointer;font-size:14px;"
@onclick="() => _sessions.Remove(session)">
<i data-lucide="trash-2" style="width:14px;height:14px;display:inline;"></i>
</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label style="font-size:11px;color:var(--pos-text-tertiary);display:block;margin-bottom:2px;">Ngay dieu tri</label>
<input type="date" @bind="session.Date"
style="width:100%;padding:8px;border-radius:6px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:13px;" />
</div>
<div>
<label style="font-size:11px;color:var(--pos-text-tertiary);display:block;margin-bottom:2px;">Bac si / Chuyen gia</label>
<input type="text" @bind="session.DoctorName" placeholder="Ten bac si"
style="width:100%;padding:8px;border-radius:6px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:13px;" />
</div>
<div style="grid-column:span 2;">
<label style="font-size:11px;color:var(--pos-text-tertiary);display:block;margin-bottom:2px;">Ghi chu</label>
<textarea @bind="session.Notes" placeholder="Ghi chu cho buoi dieu tri nay..."
style="width:100%;padding:8px;border-radius:6px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:13px;min-height:60px;resize:vertical;"></textarea>
</div>
<div style="grid-column:span 2;display:flex;gap:8px;">
<button style="padding:6px 12px;border-radius:6px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);cursor:pointer;font-size:12px;"
@onclick="() => SetSessionStatus(session, StatusPlanned)">Ke hoach</button>
<button style="padding:6px 12px;border-radius:6px;border:1px solid #A855F7;background:rgba(168,85,247,0.1);color:#A855F7;cursor:pointer;font-size:12px;"
@onclick="() => SetSessionStatus(session, StatusInProgress)">Dang thuc hien</button>
<button style="padding:6px 12px;border-radius:6px;border:1px solid var(--pos-success);background:rgba(34,197,94,0.1);color:var(--pos-success);cursor:pointer;font-size:12px;"
@onclick="() => SetSessionStatus(session, StatusCompleted)">Hoan thanh</button>
</div>
</div>
</div>
}
</div>
@* === RIGHT PANEL: Treatment History / PANEL PHAI: Lich su dieu tri === *@
<div style="width:360px;min-width:360px;border-left:1px solid var(--pos-border-subtle);overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px;">
<div style="font-size:14px;font-weight:700;color:#A855F7;">
<i data-lucide="history" style="width:16px;height:16px;display:inline;"></i> Lich su dieu tri
</div>
@if (!_sessions.Where(s => s.Status == "completed").Any())
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 0;color:var(--pos-text-tertiary);">
<i data-lucide="clock" style="width:32px;height:32px;margin-bottom:8px;opacity:0.3;"></i>
<div style="font-size:13px;">Chua co lich su dieu tri</div>
</div>
}
@foreach (var (session, index) in _sessions.Where(s => s.Status == "completed").Select((s, i) => (s, i)))
{
<div style="background:var(--pos-bg-elevated);border-radius:10px;padding:12px;border:1px solid var(--pos-border-subtle);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-size:13px;font-weight:600;">@session.ServiceName</span>
<span style="font-size:11px;color:var(--pos-success);font-weight:600;">Hoan thanh</span>
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">
<div><i data-lucide="calendar" style="width:11px;height:11px;display:inline;"></i> @session.Date.ToString("dd/MM/yyyy")</div>
<div><i data-lucide="stethoscope" style="width:11px;height:11px;display:inline;"></i> @(string.IsNullOrEmpty(session.DoctorName) ? "Chua chi dinh" : session.DoctorName)</div>
@if (!string.IsNullOrEmpty(session.Notes))
{
<div style="margin-top:4px;font-style:italic;">@session.Notes</div>
}
</div>
</div>
}
@* EN: Summary stats / VI: Thong ke tom tat *@
<div style="margin-top:auto;background:var(--pos-bg-elevated);border-radius:10px;padding:12px;border:1px solid var(--pos-border-subtle);">
<div style="font-size:12px;font-weight:600;margin-bottom:8px;">Tong quan</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--pos-text-secondary);margin-bottom:4px;">
<span>Tong buoi</span>
<span style="font-weight:600;">@_sessions.Count</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--pos-success);margin-bottom:4px;">
<span>Hoan thanh</span>
<span style="font-weight:600;">@_sessions.Count(s => s.Status == "completed")</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:#A855F7;margin-bottom:4px;">
<span>Dang thuc hien</span>
<span style="font-weight:600;">@_sessions.Count(s => s.Status == "in-progress")</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--pos-text-tertiary);">
<span>Ke hoach</span>
<span style="font-weight:600;">@_sessions.Count(s => s.Status == "planned")</span>
</div>
</div>
</div>
</div>
</div>
@code {
// TODO: Replace local state with API calls when backend treatment plan endpoints are available
private const string StatusPlanned = "planned";
private const string StatusInProgress = "in-progress";
private const string StatusCompleted = "completed";
private void SetSessionStatus(TreatmentSession session, string status) { session.Status = status; }
private string _customerName = "";
private string _customerPhone = "";
private readonly List<TreatmentSession> _sessions = new();
private void AddSession()
{
_sessions.Add(new TreatmentSession
{
ServiceName = "Dich vu moi",
Date = DateTime.Today.AddDays(_sessions.Count * 14),
DoctorName = "",
Notes = "",
Status = "planned"
});
}
private static string GetStatusColor(string status) => status switch
{
"completed" => "#22C55E",
"in-progress" => "#A855F7",
"planned" => "#94A3B8",
_ => "#94A3B8"
};
private static string GetStatusBg(string status) => status switch
{
"completed" => "rgba(34,197,94,0.1)",
"in-progress" => "rgba(168,85,247,0.1)",
"planned" => "rgba(148,163,184,0.1)",
_ => "rgba(148,163,184,0.1)"
};
private static string GetStatusLabel(string status) => status switch
{
"completed" => "Hoan thanh",
"in-progress" => "Dang thuc hien",
"planned" => "Ke hoach",
_ => "Ke hoach"
};
private class TreatmentSession
{
public string ServiceName { get; set; } = "";
public DateTime Date { get; set; } = DateTime.Today;
public string DoctorName { get; set; } = "";
public string Notes { get; set; } = "";
public string Status { get; set; } = "planned";
}
}

View File

@@ -107,9 +107,20 @@
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(FinalTotal)</span>
</div>
<button class="pos-btn-checkout" @onclick="StartPayment" disabled="@(!_cartItems.Any())">
<i data-lucide="credit-card" style="width:18px;height:18px;"></i>
Thanh toán
@if (!string.IsNullOrEmpty(_orderError))
{
<div style="font-size:11px;margin-bottom:6px;color:#EF4444;">@_orderError</div>
}
<button class="pos-btn-checkout" @onclick="StartPayment" disabled="@(!_cartItems.Any() || _orderCreating)">
@if (_orderCreating)
{
<span>Đang tạo đơn...</span>
}
else
{
<i data-lucide="credit-card" style="width:18px;height:18px;"></i>
<span>Thanh toán</span>
}
</button>
</div>
}
@@ -812,14 +823,64 @@
private List<(string Name, int Qty, decimal Price)> _lastReceiptItems = new();
private decimal ChangeAmount => _receivedAmount - FinalTotal;
private void StartPayment()
// EN: Created order ID — set when order is submitted to API before payment
// VI: ID đơn hàng đã tạo — gán khi đơn được gửi lên API trước thanh toán
private Guid? _createdOrderId;
private string? _orderError;
private bool _orderCreating;
private async Task StartPayment()
{
if (!_cartItems.Any()) return;
_paymentStep = PayStep.MethodSelect;
if (_orderCreating) return;
_orderCreating = true;
_orderError = null;
StateHasChanged();
try
{
// EN: Create order in DB before proceeding to payment method selection
// VI: Tạo đơn hàng trong DB trước khi chọn phương thức thanh toán
var orderReq = new PosDataService.CreatePosOrderRequest(
ShopId,
null, // EN: Payment method not yet selected / VI: Chưa chọn phương thức thanh toán
_cartItems.Select(i => new PosDataService.PosOrderItemRequest(
i.ProductId, i.Name, i.Qty, i.Price)).ToList(),
_discountAmount > 0 ? _discountAmount : null,
_appliedVoucher != null ? "voucher" : null,
_appliedVoucher?.VoucherCode);
var result = await DataService.CreatePosOrderAsync(orderReq);
if (result != null)
{
_createdOrderId = result.OrderId;
_paymentStep = PayStep.MethodSelect;
}
else
{
_orderError = "Không thể tạo đơn hàng. Vui lòng thử lại.";
}
}
catch (Exception ex)
{
_orderError = $"Lỗi tạo đơn: {ex.Message}";
}
finally
{
_orderCreating = false;
StateHasChanged();
}
}
private void CancelPayment()
private async Task CancelPayment()
{
// EN: Cancel the created order if payment is abandoned
// VI: Hủy đơn hàng đã tạo nếu bỏ thanh toán
if (_createdOrderId.HasValue)
{
try { await DataService.CancelOrderAsync(_createdOrderId.Value); } catch { }
_createdOrderId = null;
}
_paymentStep = PayStep.None;
_selectedMethod = "";
_receivedAmount = 0;
@@ -881,21 +942,25 @@
_lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList();
var methodLabel = _selectedMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" };
// EN: Call API to create real order in DB
// VI: Gọi API tạo đơn hàng thật trong DB
// EN: Call API to mark order as paid (order was already created in StartPayment)
// VI: Gọi API đánh dấu đơn đã thanh toán (đơn đã được tạo ở StartPayment)
try
{
var orderReq = new PosDataService.CreatePosOrderRequest(
ShopId,
_selectedMethod,
_cartItems.Select(i => new PosDataService.PosOrderItemRequest(
i.ProductId, i.Name, i.Qty, i.Price)).ToList(),
_discountAmount > 0 ? _discountAmount : null,
_appliedVoucher != null ? "voucher" : null,
_appliedVoucher?.VoucherCode);
if (_createdOrderId.HasValue)
{
await DataService.PayOrderAsync(_createdOrderId.Value, ShopId);
_lastTransactionId = _createdOrderId.Value.ToString()[..8].ToUpper();
}
else
{
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
}
var result = await DataService.CreatePosOrderAsync(orderReq);
_lastTransactionId = result?.TransactionId ?? $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
// EN: Redeem voucher if applied / VI: Sử dụng voucher nếu đã áp dụng
if (_appliedVoucher?.VoucherId != null && _discountAmount > 0)
{
try { await DataService.RedeemVoucherAsync(_appliedVoucher.VoucherId.Value, _discountAmount); } catch { }
}
}
catch
{
@@ -932,6 +997,8 @@
_selectedMethod = "";
_receivedAmount = 0;
_customAmountInput = "";
_createdOrderId = null;
_orderError = null;
ClearVoucher();
await SaveCartToLocalStorage();
}

View File

@@ -1,11 +1,15 @@
@*
EN: Café POS Mobile — Single column: categories, product grid, floating cart button.
All data fetched from API — no hardcoded demo data.
VI: POS Café Mobile — Một cột: danh mục, lưới sản phẩm, nút giỏ hàng nổi.
Toàn bộ dữ liệu lấy từ API — không có dữ liệu demo cứng.
*@
@page "/pos/{ShopId:guid}/cafe/mobile"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@inject IJSRuntime JS
<div style="display:flex;flex-direction:column;width:100%;height:100%;overflow:hidden;">
@if (_isLoading)
@@ -71,36 +75,224 @@
<div style="width:40px;height:4px;border-radius:2px;background:var(--pos-border-default);"></div>
</div>
<div class="pos-cart-header">
<span class="pos-cart-header__title">Giỏ hàng</span>
<button style="background:none;border:none;color:var(--pos-danger);font-size:13px;cursor:pointer;"
@onclick="() => { _cartItems.Clear(); _showCart = false; }">Xóa</button>
</div>
@if (_paymentStep == PayStep.None)
{
@* ─── NORMAL CART MODE ─── *@
<div class="pos-cart-header">
<span class="pos-cart-header__title">Giỏ hàng</span>
<button style="background:none;border:none;color:var(--pos-danger);font-size:13px;cursor:pointer;"
@onclick="ClearCart">Xóa</button>
</div>
<div class="pos-cart-items" style="max-height:40vh;">
@foreach (var item in _cartItems)
{
<div class="pos-cart-item">
<div class="pos-cart-item__info">
<span class="pos-cart-item__name">@item.Name</span>
<span class="pos-cart-item__price">@FormatPrice(item.Price)</span>
<div class="pos-cart-items" style="max-height:40vh;">
@foreach (var item in _cartItems)
{
<div class="pos-cart-item">
<div class="pos-cart-item__info">
<span class="pos-cart-item__name">@item.Name</span>
<span class="pos-cart-item__price">@FormatPrice(item.Price)</span>
</div>
<div class="pos-cart-item__qty">
<button @onclick="() => ChangeQty(item, -1)"></button>
<span style="font-size:14px;font-weight:600;">@item.Qty</span>
<button @onclick="() => ChangeQty(item, 1)">+</button>
</div>
</div>
<div class="pos-cart-item__qty">
<button @onclick="() => ChangeQty(item, -1)"></button>
<span style="font-size:14px;font-weight:600;">@item.Qty</span>
<button @onclick="() => ChangeQty(item, 1)">+</button>
}
</div>
<div class="pos-cart-footer">
<!-- Voucher input -->
<div style="display:flex;gap:6px;margin-bottom:8px;">
<input type="text" @bind="_voucherCode" placeholder="Mã voucher..." style="flex:1;padding:8px 10px;border:1px solid var(--pos-border);border-radius:8px;font-size:12px;background:var(--pos-bg-elevated);color:var(--pos-text-primary);" />
<button @onclick="ValidateVoucher" style="padding:6px 12px;border-radius:8px;border:none;background:rgba(139,92,246,0.1);color:#8B5CF6;font-size:11px;font-weight:600;cursor:pointer;">Áp dụng</button>
@if (_appliedVoucher != null)
{
<button @onclick="ClearVoucher" style="padding:6px 8px;border-radius:8px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;font-size:11px;cursor:pointer;" title="Xóa voucher"><i data-lucide="x" style="width:12px;height:12px;"></i></button>
}
</div>
@if (_voucherMessage != null)
{
<div style="font-size:11px;margin-bottom:6px;color:@(_appliedVoucher != null ? "#16A34A" : "#EF4444");">@_voucherMessage</div>
}
@if (_appliedVoucher != null)
{
<div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:12px;">
<span style="color:var(--pos-text-tertiary);">Giảm giá (@_appliedVoucher.CampaignName)</span>
<span style="color:#16A34A;font-weight:600;">-@FormatPrice(_discountAmount)</span>
</div>
}
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(FinalTotal)</span>
</div>
@if (!string.IsNullOrEmpty(_orderError))
{
<div style="font-size:11px;margin-bottom:6px;color:#EF4444;">@_orderError</div>
}
<button class="pos-btn-checkout" @onclick="StartPayment" disabled="@(!_cartItems.Any() || _orderCreating)">
@if (_orderCreating)
{
<span>Đang tạo đơn...</span>
}
else
{
<span>Thanh toán</span>
}
</button>
</div>
}
else if (_paymentStep == PayStep.MethodSelect)
{
@* ─── PAYMENT: METHOD SELECT ─── *@
<div class="pos-payment-panel" style="flex:1;display:flex;flex-direction:column;">
<div class="pos-payment-header">
<button class="pos-payment-header__back" @onclick="CancelPayment">
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
</button>
<span style="font-weight:600;">Thanh toán</span>
<span style="margin-left:auto;font-size:18px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(FinalTotal)</span>
</div>
<div class="pos-payment-methods" style="padding:16px;">
<button class="pos-payment-method-btn" @onclick='() => SelectPaymentMethod("cash")'>
<span class="pos-payment-method-btn__icon"><i data-lucide="wallet" style="width:28px;height:28px;"></i></span>
<span class="pos-payment-method-btn__label">Tiền mặt</span>
</button>
@if (_payCardEnabled)
{
<button class="pos-payment-method-btn" @onclick='() => SelectPaymentMethod("card")'>
<span class="pos-payment-method-btn__icon"><i data-lucide="credit-card" style="width:28px;height:28px;"></i></span>
<span class="pos-payment-method-btn__label">Thẻ</span>
</button>
}
@if (_payQrEnabled)
{
<button class="pos-payment-method-btn" @onclick='() => SelectPaymentMethod("qr")'>
<span class="pos-payment-method-btn__icon"><i data-lucide="smartphone" style="width:28px;height:28px;"></i></span>
<span class="pos-payment-method-btn__label">Mã QR</span>
</button>
}
@if (_payTransferEnabled)
{
<button class="pos-payment-method-btn" @onclick='() => SelectPaymentMethod("transfer")'>
<span class="pos-payment-method-btn__icon"><i data-lucide="building-2" style="width:28px;height:28px;"></i></span>
<span class="pos-payment-method-btn__label">Chuyển khoản</span>
</button>
}
</div>
</div>
}
else if (_paymentStep == PayStep.AmountInput)
{
@* ─── PAYMENT: CASH AMOUNT INPUT ─── *@
<div class="pos-payment-panel" style="flex:1;display:flex;flex-direction:column;">
<div class="pos-payment-header">
<button class="pos-payment-header__back" @onclick="() => _paymentStep = PayStep.MethodSelect">
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
</button>
<span style="font-weight:600;display:flex;align-items:center;gap:6px;"><i data-lucide="wallet" style="width:16px;height:16px;"></i> Tiền mặt</span>
<span style="margin-left:auto;font-size:16px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(FinalTotal)</span>
</div>
<div class="pos-payment-amount-section" style="flex:1;overflow-y:auto;padding:16px;">
<div style="font-size:13px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:10px;">Số tiền nhanh</div>
<div class="pos-payment-quick-amounts">
@foreach (var qa in GetQuickAmounts())
{
<button class="pos-payment-quick-btn @(_receivedAmount == qa.Value ? "pos-payment-quick-btn--selected" : "")"
@onclick="() => _receivedAmount = qa.Value">
@qa.Label
</button>
}
</div>
<div style="font-size:13px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:8px;">Nhập số tiền</div>
<input type="number" class="pos-payment-input" placeholder="Nhập số tiền..."
@bind="_customAmountInput" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(_customAmountInput))
{
<button style="width:100%;padding:10px;border-radius:var(--pos-radius);border:none;background:var(--pos-bg-interactive);color:var(--pos-text-primary);cursor:pointer;font-size:13px;font-weight:600;margin-bottom:16px;"
@onclick="ApplyCustomAmount">Áp dụng</button>
}
<div class="pos-payment-change">
<div class="pos-payment-change__row">
<span style="color:var(--pos-text-tertiary);">Khách đưa</span>
<span style="font-weight:600;">@FormatPrice(_receivedAmount)</span>
</div>
<div class="pos-payment-change__row" style="padding-top:8px;border-top:1px dashed var(--pos-border-subtle);">
<span style="color:var(--pos-text-tertiary);">Tiền thối</span>
<span style="font-size:18px;font-weight:700;color:@(ChangeAmount >= 0 ? "var(--pos-success)" : "var(--pos-danger)");">
@FormatPrice(ChangeAmount)
</span>
</div>
</div>
</div>
}
</div>
<div class="pos-cart-footer">
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(CartTotal)</span>
<div style="padding:16px;border-top:1px solid var(--pos-border-subtle);">
<button class="pos-btn-checkout" @onclick="ConfirmPayment" disabled="@(_receivedAmount < FinalTotal)">
Xác nhận thanh toán
</button>
</div>
</div>
<button class="pos-btn-checkout" @onclick="Checkout">Thanh toán</button>
</div>
}
else if (_paymentStep == PayStep.Processing)
{
@* ─── PAYMENT: QR/CARD/TRANSFER — Confirm ─── *@
<div class="pos-payment-panel" style="flex:1;display:flex;flex-direction:column;">
<div class="pos-payment-header">
<button class="pos-payment-header__back" @onclick="() => _paymentStep = PayStep.MethodSelect">
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
</button>
<span style="font-weight:600;">@GetMethodLabel()</span>
</div>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:24px;">
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(FinalTotal)</div>
@if (_selectedMethod == "qr")
{
<div style="width:160px;height:160px;background:white;border-radius:12px;display:flex;align-items:center;justify-content:center;">
<div style="color:#333;font-size:14px;font-weight:600;text-align:center;">QR Code<br/>VietQR</div>
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">Quét mã bằng app Ngân hàng / MoMo / ZaloPay</div>
}
else if (_selectedMethod == "card")
{
<div><i data-lucide="credit-card" style="width:48px;height:48px;color:var(--pos-text-secondary);"></i></div>
<div style="font-size:14px;color:var(--pos-text-secondary);">Chạm, quẹt hoặc cắm thẻ</div>
}
else
{
<div><i data-lucide="building-2" style="width:48px;height:48px;color:var(--pos-text-secondary);"></i></div>
<div style="font-size:14px;color:var(--pos-text-secondary);">Xác nhận đã nhận chuyển khoản</div>
}
</div>
<div style="padding:16px;border-top:1px solid var(--pos-border-subtle);">
<button class="pos-btn-checkout" @onclick="ConfirmPayment">
Xác nhận đã thanh toán
</button>
</div>
</div>
}
else if (_paymentStep == PayStep.Success)
{
@* ─── PAYMENT: SUCCESS ─── *@
<div class="pos-payment-success" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:24px;">
<div class="pos-payment-success__circle">
<div class="pos-payment-success__inner">
<i data-lucide="check" style="width:28px;height:28px;color:#fff;stroke-width:3;"></i>
</div>
</div>
<div style="font-size:18px;font-weight:700;color:var(--pos-success);">Thanh toán thành công!</div>
<div style="font-size:14px;color:var(--pos-text-tertiary);">@FormatPrice(_lastOrderTotal)</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">Mã: @_lastTransactionId</div>
<div style="display:flex;gap:10px;margin-top:8px;">
<button style="padding:10px 20px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);background:transparent;color:var(--pos-text-primary);cursor:pointer;font-size:13px;font-weight:600;"
@onclick="PrintReceipt">
<i data-lucide="printer" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i>In hóa đơn
</button>
<button style="padding:10px 20px;border-radius:var(--pos-radius);border:none;background:var(--pos-orange-primary);color:#fff;cursor:pointer;font-size:13px;font-weight:600;"
@onclick="ResetAfterPayment">
<i data-lucide="plus" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i>Đơn mới
</button>
</div>
</div>
}
</div>
</div>
}
@@ -109,23 +301,24 @@
@code {
// EN: Loading state / VI: Trạng thái tải
// ═══════════════ SALE — Product & Cart ═══════════════
private bool _isLoading = true;
private bool _loadError;
// EN: Categories / VI: Danh mục
private string[] _categories = { "Tất cả" };
private string _selectedCategory = "Tất cả";
private bool _showCart;
// EN: Product list / VI: Danh sách sản phẩm
private List<Product> _products = new();
// EN: Cart items / VI: Mục giỏ hàng
private readonly List<CartItem> _cartItems = new();
private IEnumerable<Product> FilteredProducts =>
_selectedCategory == "Tất cả" ? _products : _products.Where(p => p.Category == _selectedCategory);
private decimal CartTotal => _cartItems.Sum(i => i.Price * i.Qty);
private decimal FinalTotal => Math.Max(0, CartTotal - _discountAmount);
// Voucher state
private string _voucherCode = "";
private string? _voucherMessage;
private PosDataService.VoucherValidationInfo? _appliedVoucher;
private decimal _discountAmount;
protected override async Task OnInitializedAsync()
{
@@ -144,7 +337,7 @@
p.Id,
p.Name,
p.Price,
p.Category ?? "Khác"
p.CategoryName ?? "Khác"
)).ToList();
var catNames = apiCategories.Select(c => c.Name).ToList();
@@ -156,31 +349,332 @@
_categories = new[] { "Tất cả" }.Concat(productCats).ToArray();
}
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
catch { _loadError = true; }
finally { _isLoading = false; }
await LoadPaymentSettings();
await RestoreCartFromLocalStorage();
}
private void AddToCart(Product product)
private async Task AddToCart(Product product)
{
if (_paymentStep != PayStep.None) return;
var existing = _cartItems.FirstOrDefault(i => i.ProductId == product.Id);
if (existing != null) existing.Qty++;
else _cartItems.Add(new CartItem(product.Id, product.Name, product.Price));
await SaveCartToLocalStorage();
}
private void ChangeQty(CartItem item, int delta)
private async Task ChangeQty(CartItem item, int delta)
{
item.Qty += delta;
if (item.Qty <= 0) _cartItems.Remove(item);
await SaveCartToLocalStorage();
}
private void Checkout() => NavigateTo("cafe/order-customize");
private async Task ClearCart()
{
_cartItems.Clear();
_showCart = false;
await SaveCartToLocalStorage();
}
// ═══════════════ VOUCHER ═══════════════
private async Task ValidateVoucher()
{
_voucherMessage = null; _appliedVoucher = null; _discountAmount = 0;
if (string.IsNullOrWhiteSpace(_voucherCode)) { _voucherMessage = "Vui lòng nhập mã voucher."; return; }
var info = await DataService.ValidateVoucherAsync(_voucherCode.Trim());
if (info == null) { _voucherMessage = "Không thể kiểm tra voucher."; return; }
if (!info.IsValid) { _voucherMessage = info.ErrorMessage ?? "Mã voucher không hợp lệ."; return; }
_appliedVoucher = info;
var isPercentage = (info.CampaignName ?? "").Contains("[percentage]", StringComparison.OrdinalIgnoreCase);
if (isPercentage)
{
var pct = Math.Min(info.RemainingValue ?? 0, 100);
_discountAmount = Math.Round(CartTotal * pct / 100, 0);
_voucherMessage = $"Voucher {info.CampaignName?.Replace("[percentage]","").Trim()}: giảm {pct}% = {FormatPrice(_discountAmount)}";
}
else
{
_discountAmount = Math.Min(info.RemainingValue ?? 0, CartTotal);
_voucherMessage = $"Voucher {info.CampaignName}: giảm {FormatPrice(_discountAmount)}";
}
}
private void ClearVoucher() { _appliedVoucher = null; _discountAmount = 0; _voucherCode = ""; _voucherMessage = null; }
// ═══════════════ INLINE PAYMENT ═══════════════
private enum PayStep { None, MethodSelect, AmountInput, Processing, Success }
private PayStep _paymentStep = PayStep.None;
private string _selectedMethod = "";
private decimal _receivedAmount;
private string _customAmountInput = "";
private decimal _lastOrderTotal;
private string _lastTransactionId = "";
private string _lastPaymentMethod = "";
private List<(string Name, int Qty, decimal Price)> _lastReceiptItems = new();
private decimal ChangeAmount => _receivedAmount - FinalTotal;
// EN: Created order ID — set when order is submitted to API before payment
// VI: ID đơn hàng đã tạo — gán khi đơn được gửi lên API trước thanh toán
private Guid? _createdOrderId;
private string? _orderError;
private bool _orderCreating;
private async Task StartPayment()
{
if (!_cartItems.Any()) return;
if (_orderCreating) return;
_orderCreating = true;
_orderError = null;
StateHasChanged();
try
{
var orderReq = new PosDataService.CreatePosOrderRequest(
ShopId,
null,
_cartItems.Select(i => new PosDataService.PosOrderItemRequest(
i.ProductId, i.Name, i.Qty, i.Price)).ToList(),
_discountAmount > 0 ? _discountAmount : null,
_appliedVoucher != null ? "voucher" : null,
_appliedVoucher?.VoucherCode);
var result = await DataService.CreatePosOrderAsync(orderReq);
if (result != null)
{
_createdOrderId = result.OrderId;
_paymentStep = PayStep.MethodSelect;
}
else
{
_orderError = "Không thể tạo đơn hàng. Vui lòng thử lại.";
}
}
catch (Exception ex)
{
_orderError = $"Lỗi tạo đơn: {ex.Message}";
}
finally
{
_orderCreating = false;
StateHasChanged();
}
}
private async Task CancelPayment()
{
if (_createdOrderId.HasValue)
{
try { await DataService.CancelOrderAsync(_createdOrderId.Value); } catch { }
_createdOrderId = null;
}
_paymentStep = PayStep.None;
_selectedMethod = "";
_receivedAmount = 0;
_customAmountInput = "";
}
private void SelectPaymentMethod(string method)
{
_selectedMethod = method;
_receivedAmount = 0;
_customAmountInput = "";
if (method == "cash")
_paymentStep = PayStep.AmountInput;
else
_paymentStep = PayStep.Processing;
}
private string GetMethodLabel() => _selectedMethod switch
{
"cash" => "Tiền mặt",
"card" => "Thẻ",
"qr" => "Mã QR",
"transfer" => "Chuyển khoản",
_ => "Thanh toán"
};
private List<(string Label, decimal Value)> GetQuickAmounts()
{
var total = FinalTotal;
var amounts = new List<(string, decimal)>();
var roundUp = Math.Ceiling(total / 50_000) * 50_000;
if (roundUp == total) roundUp += 50_000;
amounts.Add(("Đúng tiền", total));
amounts.Add((FormatPrice(roundUp), roundUp));
amounts.Add((FormatPrice(roundUp + 50_000), roundUp + 50_000));
amounts.Add((FormatPrice(500_000), 500_000));
amounts.Add((FormatPrice(200_000), 200_000));
amounts.Add((FormatPrice(1_000_000), 1_000_000));
return amounts;
}
private void ApplyCustomAmount()
{
if (decimal.TryParse(_customAmountInput, out var val))
_receivedAmount = val;
}
private bool _paymentProcessing;
private async Task ConfirmPayment()
{
if (_paymentProcessing) return;
_paymentProcessing = true;
StateHasChanged();
_lastOrderTotal = FinalTotal;
_lastPaymentMethod = _selectedMethod;
_lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList();
try
{
if (_createdOrderId.HasValue)
{
await DataService.PayOrderAsync(_createdOrderId.Value, ShopId);
_lastTransactionId = _createdOrderId.Value.ToString()[..8].ToUpper();
}
else
{
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
}
if (_appliedVoucher?.VoucherId != null && _discountAmount > 0)
{
try { await DataService.RedeemVoucherAsync(_appliedVoucher.VoucherId.Value, _discountAmount); } catch { }
}
}
catch
{
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
}
_paymentProcessing = false;
_paymentStep = PayStep.Success;
StateHasChanged();
}
private async Task ResetAfterPayment()
{
_cartItems.Clear();
_paymentStep = PayStep.None;
_selectedMethod = "";
_receivedAmount = 0;
_customAmountInput = "";
_createdOrderId = null;
_orderError = null;
_showCart = false;
ClearVoucher();
await SaveCartToLocalStorage();
}
private async Task PrintReceipt()
{
var payLabel = _lastPaymentMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" };
var now = DateTime.Now;
var sb = new System.Text.StringBuilder();
foreach (var item in _lastReceiptItems)
{
sb.AppendLine($"<tr><td style='text-align:left;padding:3px 0;'>{System.Net.WebUtility.HtmlEncode(item.Name)}</td>");
sb.AppendLine($"<td style='text-align:center;padding:3px 4px;'>{item.Qty}</td>");
sb.AppendLine($"<td style='text-align:right;padding:3px 0;'>{item.Price:N0}</td>");
sb.AppendLine($"<td style='text-align:right;padding:3px 0;font-weight:600;'>{item.Qty * item.Price:N0}</td></tr>");
}
var receiptHtml = "<!DOCTYPE html><html><head><meta charset='utf-8'>" +
$"<title>Hóa đơn - {_lastTransactionId}</title>" +
"<style>" +
"@page { margin: 4mm; size: 80mm auto; }" +
"body { font-family: 'Courier New', monospace; font-size: 12px; width: 72mm; margin: 0 auto; color: #000; }" +
".c { text-align: center; } .b { font-weight: bold; }" +
".d { border-top: 1px dashed #000; margin: 6px 0; }" +
"table { width: 100%; border-collapse: collapse; }" +
"th { text-align: left; font-size: 11px; border-bottom: 1px solid #000; padding: 2px 0; }" +
".f { font-size: 10px; text-align: center; margin-top: 8px; color: #555; }" +
"</style></head><body>" +
"<div class='c b' style='font-size:16px;'>GoodGo POS</div>" +
"<div class='c' style='font-size:10px;margin-bottom:4px;'>Hệ thống quản lý bán hàng thông minh</div>" +
"<div class='d'></div>" +
$"<div><b>Mã đơn:</b> {_lastTransactionId}</div>" +
$"<div><b>Ngày:</b> {now:dd/MM/yyyy} — {now:HH:mm:ss}</div>" +
$"<div><b>Thanh toán:</b> {payLabel}</div>" +
"<div class='d'></div>" +
"<table><tr><th>Sản phẩm</th><th style='text-align:center;'>SL</th><th style='text-align:right;'>Đ.Giá</th><th style='text-align:right;'>T.Tiền</th></tr>" +
sb.ToString() +
"</table><div class='d'></div>" +
$"<div style='display:flex;justify-content:space-between;font-size:14px;font-weight:bold;'><span>TỔNG CỘNG</span><span>{_lastOrderTotal:N0}₫</span></div>" +
"<div class='d'></div>" +
"<div class='f'>Cảm ơn quý khách! Hẹn gặp lại</div>" +
"<div class='f'>Powered by GoodGo Platform</div>" +
"<script>window.onload=function(){window.print();window.onafterprint=function(){window.close();}}</script>" +
"</body></html>";
await JS.InvokeVoidAsync("printPosReceipt", receiptHtml);
}
// ═══════════════ PAYMENT SETTINGS ═══════════════
private bool _payCardEnabled = true;
private bool _payQrEnabled = true;
private bool _payTransferEnabled = true;
private async Task LoadPaymentSettings()
{
try
{
var json = await JS.InvokeAsync<string?>("localStorage.getItem", "pos_payment_settings");
if (!string.IsNullOrEmpty(json))
{
var doc = System.Text.Json.JsonDocument.Parse(json);
var r = doc.RootElement;
if (r.TryGetProperty("cardEnabled", out var ce)) _payCardEnabled = ce.GetBoolean();
if (r.TryGetProperty("qrEnabled", out var qe)) _payQrEnabled = qe.GetBoolean();
if (r.TryGetProperty("transferEnabled", out var te)) _payTransferEnabled = te.GetBoolean();
}
}
catch { /* first load — no settings yet */ }
}
// ═══════════════ LOCALSTORAGE PERSISTENCE ═══════════════
private string CartStorageKey => $"pos_cafe_cart_{ShopId}";
private async Task SaveCartToLocalStorage()
{
try
{
var cartData = _cartItems.Select(i => new { i.ProductId, i.Name, i.Price, i.Qty }).ToList();
await JS.InvokeVoidAsync("localStorage.setItem", CartStorageKey,
System.Text.Json.JsonSerializer.Serialize(cartData));
}
catch { }
}
private async Task RestoreCartFromLocalStorage()
{
try
{
var cartJson = await JS.InvokeAsync<string?>("localStorage.getItem", CartStorageKey);
if (!string.IsNullOrEmpty(cartJson))
{
var items = System.Text.Json.JsonSerializer.Deserialize<List<StoredCartItem>>(cartJson, _lsJsonOptions);
if (items != null)
foreach (var i in items)
_cartItems.Add(new CartItem(i.ProductId, i.Name, i.Price) { Qty = i.Qty });
}
}
catch { }
}
private static readonly System.Text.Json.JsonSerializerOptions _lsJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private record StoredCartItem(Guid ProductId, string Name, decimal Price, int Qty);
// ═══════════════ RECORDS ═══════════════
private record Product(Guid Id, string Name, decimal Price, string Category);
private class CartItem(Guid productId, string name, decimal price)
{

View File

@@ -1,14 +1,18 @@
@*
EN: Café POS Tablet — 2-column layout: product grid + cart sidebar, touch-friendly.
All data fetched from API — no hardcoded demo data.
VI: POS Café Tablet — Bố cục 2 cột: lưới sản phẩm + giỏ hàng bên, thân thiện cảm ứng.
Toàn bộ dữ liệu lấy từ API — không có dữ liệu demo cứng.
*@
@page "/pos/{ShopId:guid}/cafe/tablet"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@inject IJSRuntime JS
@* ═══ PRODUCT PANEL ═══ *@
<div class="pos-product-panel">
<div class="pos-product-panel @(_paymentStep != PayStep.None ? "pos-product-panel--dimmed" : "")">
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--pos-text-tertiary);">
@@ -49,62 +53,250 @@
}
</div>
@* ═══ CART SIDEBAR ═══ *@
@* ═══ CART / PAYMENT SIDEBAR ═══ *@
<div class="pos-cart-panel" style="width:340px;min-width:340px;">
<div class="pos-cart-header">
<span class="pos-cart-header__title" style="font-size:17px;">Đơn hàng</span>
<button style="background:none;border:none;color:var(--pos-danger);font-size:13px;cursor:pointer;"
@onclick="() => _cartItems.Clear()">Xóa tất cả</button>
</div>
@if (_paymentStep == PayStep.None)
{
@* ─── NORMAL CART MODE ─── *@
<div class="pos-cart-header">
<span class="pos-cart-header__title" style="font-size:17px;">Đơn hàng</span>
<button style="background:none;border:none;color:var(--pos-danger);font-size:13px;cursor:pointer;"
@onclick="ClearCart">Xóa tất cả</button>
</div>
<div class="pos-cart-items">
@foreach (var item in _cartItems)
{
<div class="pos-cart-item" style="padding:14px 12px;">
<div class="pos-cart-item__info">
<span class="pos-cart-item__name" style="font-size:15px;">@item.Name</span>
<span class="pos-cart-item__price">@FormatPrice(item.Price)</span>
<div class="pos-cart-items">
@foreach (var item in _cartItems)
{
<div class="pos-cart-item" style="padding:14px 12px;">
<div class="pos-cart-item__info">
<span class="pos-cart-item__name" style="font-size:15px;">@item.Name</span>
<span class="pos-cart-item__price">@FormatPrice(item.Price)</span>
</div>
<div class="pos-cart-item__qty">
<button style="width:36px;height:36px;border-radius:10px;border:1px solid var(--pos-border-default);background:transparent;color:var(--pos-text-primary);font-size:18px;cursor:pointer;"
@onclick="() => ChangeQty(item, -1)"></button>
<span style="font-size:16px;font-weight:600;min-width:24px;text-align:center;">@item.Qty</span>
<button style="width:36px;height:36px;border-radius:10px;border:1px solid var(--pos-border-default);background:transparent;color:var(--pos-text-primary);font-size:18px;cursor:pointer;"
@onclick="() => ChangeQty(item, 1)">+</button>
</div>
</div>
<div class="pos-cart-item__qty">
<button style="width:36px;height:36px;border-radius:10px;border:1px solid var(--pos-border-default);background:transparent;color:var(--pos-text-primary);font-size:18px;cursor:pointer;"
@onclick="() => ChangeQty(item, -1)"></button>
<span style="font-size:16px;font-weight:600;min-width:24px;text-align:center;">@item.Qty</span>
<button style="width:36px;height:36px;border-radius:10px;border:1px solid var(--pos-border-default);background:transparent;color:var(--pos-text-primary);font-size:18px;cursor:pointer;"
@onclick="() => ChangeQty(item, 1)">+</button>
}
</div>
<div class="pos-cart-footer">
<!-- Voucher input -->
<div style="display:flex;gap:6px;margin-bottom:8px;">
<input type="text" @bind="_voucherCode" placeholder="Mã voucher..." style="flex:1;padding:8px 10px;border:1px solid var(--pos-border);border-radius:8px;font-size:12px;background:var(--pos-bg-elevated);color:var(--pos-text-primary);" />
<button @onclick="ValidateVoucher" style="padding:6px 12px;border-radius:8px;border:none;background:rgba(139,92,246,0.1);color:#8B5CF6;font-size:11px;font-weight:600;cursor:pointer;">Áp dụng</button>
@if (_appliedVoucher != null)
{
<button @onclick="ClearVoucher" style="padding:6px 8px;border-radius:8px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;font-size:11px;cursor:pointer;" title="Xóa voucher"><i data-lucide="x" style="width:12px;height:12px;"></i></button>
}
</div>
@if (_voucherMessage != null)
{
<div style="font-size:11px;margin-bottom:6px;color:@(_appliedVoucher != null ? "#16A34A" : "#EF4444");">@_voucherMessage</div>
}
@if (_appliedVoucher != null)
{
<div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:12px;">
<span style="color:var(--pos-text-tertiary);">Giảm giá (@_appliedVoucher.CampaignName)</span>
<span style="color:#16A34A;font-weight:600;">-@FormatPrice(_discountAmount)</span>
</div>
}
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(FinalTotal)</span>
</div>
@if (!string.IsNullOrEmpty(_orderError))
{
<div style="font-size:11px;margin-bottom:6px;color:#EF4444;">@_orderError</div>
}
<button class="pos-btn-checkout" style="height:56px;font-size:17px;" @onclick="StartPayment" disabled="@(!_cartItems.Any() || _orderCreating)">
@if (_orderCreating)
{
<span>Đang tạo đơn...</span>
}
else
{
<i data-lucide="credit-card" style="width:18px;height:18px;"></i>
<span>Thanh toán — @FormatPrice(FinalTotal)</span>
}
</button>
</div>
}
else if (_paymentStep == PayStep.MethodSelect)
{
@* ─── PAYMENT: METHOD SELECT ─── *@
<div class="pos-payment-panel">
<div class="pos-payment-header">
<button class="pos-payment-header__back" @onclick="CancelPayment">
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
</button>
<span style="font-weight:600;">Thanh toán</span>
<span style="margin-left:auto;font-size:18px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(FinalTotal)</span>
</div>
<div class="pos-payment-methods">
<button class="pos-payment-method-btn" @onclick='() => SelectPaymentMethod("cash")'>
<span class="pos-payment-method-btn__icon"><i data-lucide="wallet" style="width:28px;height:28px;"></i></span>
<span class="pos-payment-method-btn__label">Tiền mặt</span>
</button>
@if (_payCardEnabled)
{
<button class="pos-payment-method-btn" @onclick='() => SelectPaymentMethod("card")'>
<span class="pos-payment-method-btn__icon"><i data-lucide="credit-card" style="width:28px;height:28px;"></i></span>
<span class="pos-payment-method-btn__label">Thẻ</span>
</button>
}
@if (_payQrEnabled)
{
<button class="pos-payment-method-btn" @onclick='() => SelectPaymentMethod("qr")'>
<span class="pos-payment-method-btn__icon"><i data-lucide="smartphone" style="width:28px;height:28px;"></i></span>
<span class="pos-payment-method-btn__label">Mã QR</span>
</button>
}
@if (_payTransferEnabled)
{
<button class="pos-payment-method-btn" @onclick='() => SelectPaymentMethod("transfer")'>
<span class="pos-payment-method-btn__icon"><i data-lucide="building-2" style="width:28px;height:28px;"></i></span>
<span class="pos-payment-method-btn__label">Chuyển khoản</span>
</button>
}
</div>
</div>
}
else if (_paymentStep == PayStep.AmountInput)
{
@* ─── PAYMENT: CASH AMOUNT INPUT ─── *@
<div class="pos-payment-panel">
<div class="pos-payment-header">
<button class="pos-payment-header__back" @onclick="() => _paymentStep = PayStep.MethodSelect">
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
</button>
<span style="font-weight:600;display:flex;align-items:center;gap:6px;"><i data-lucide="wallet" style="width:16px;height:16px;"></i> Tiền mặt</span>
<span style="margin-left:auto;font-size:16px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(FinalTotal)</span>
</div>
<div class="pos-payment-amount-section">
<div style="font-size:13px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:10px;">Số tiền nhanh</div>
<div class="pos-payment-quick-amounts">
@foreach (var qa in GetQuickAmounts())
{
<button class="pos-payment-quick-btn @(_receivedAmount == qa.Value ? "pos-payment-quick-btn--selected" : "")"
@onclick="() => _receivedAmount = qa.Value">
@qa.Label
</button>
}
</div>
<div style="font-size:13px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:8px;">Nhập số tiền</div>
<input type="number" class="pos-payment-input" placeholder="Nhập số tiền..."
@bind="_customAmountInput" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(_customAmountInput))
{
<button style="width:100%;padding:10px;border-radius:var(--pos-radius);border:none;background:var(--pos-bg-interactive);color:var(--pos-text-primary);cursor:pointer;font-size:13px;font-weight:600;margin-bottom:16px;"
@onclick="ApplyCustomAmount">Áp dụng</button>
}
<div class="pos-payment-change">
<div class="pos-payment-change__row">
<span style="color:var(--pos-text-tertiary);">Khách đưa</span>
<span style="font-weight:600;">@FormatPrice(_receivedAmount)</span>
</div>
<div class="pos-payment-change__row" style="padding-top:8px;border-top:1px dashed var(--pos-border-subtle);">
<span style="color:var(--pos-text-tertiary);">Tiền thối</span>
<span style="font-size:18px;font-weight:700;color:@(ChangeAmount >= 0 ? "var(--pos-success)" : "var(--pos-danger)");">
@FormatPrice(ChangeAmount)
</span>
</div>
</div>
</div>
}
</div>
<div class="pos-cart-footer">
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(CartTotal)</span>
<div style="padding:16px;border-top:1px solid var(--pos-border-subtle);">
<button class="pos-btn-checkout" @onclick="ConfirmPayment" disabled="@(_receivedAmount < FinalTotal)">
Xác nhận thanh toán
</button>
</div>
</div>
<button class="pos-btn-checkout" style="height:56px;font-size:17px;" @onclick="Checkout">
Thanh toán — @FormatPrice(CartTotal)
</button>
</div>
}
else if (_paymentStep == PayStep.Processing)
{
@* ─── PAYMENT: QR/CARD/TRANSFER — Confirm ─── *@
<div class="pos-payment-panel">
<div class="pos-payment-header">
<button class="pos-payment-header__back" @onclick="() => _paymentStep = PayStep.MethodSelect">
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
</button>
<span style="font-weight:600;">@GetMethodLabel()</span>
</div>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:24px;">
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(FinalTotal)</div>
@if (_selectedMethod == "qr")
{
<div style="width:180px;height:180px;background:white;border-radius:12px;display:flex;align-items:center;justify-content:center;">
<div style="color:#333;font-size:14px;font-weight:600;text-align:center;">QR Code<br/>VietQR</div>
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">Quét mã bằng app Ngân hàng / MoMo / ZaloPay</div>
}
else if (_selectedMethod == "card")
{
<div><i data-lucide="credit-card" style="width:48px;height:48px;color:var(--pos-text-secondary);"></i></div>
<div style="font-size:14px;color:var(--pos-text-secondary);">Chạm, quẹt hoặc cắm thẻ</div>
}
else
{
<div><i data-lucide="building-2" style="width:48px;height:48px;color:var(--pos-text-secondary);"></i></div>
<div style="font-size:14px;color:var(--pos-text-secondary);">Xác nhận đã nhận chuyển khoản</div>
}
</div>
<div style="padding:16px;border-top:1px solid var(--pos-border-subtle);">
<button class="pos-btn-checkout" @onclick="ConfirmPayment">
Xác nhận đã thanh toán
</button>
</div>
</div>
}
else if (_paymentStep == PayStep.Success)
{
@* ─── PAYMENT: SUCCESS ─── *@
<div class="pos-payment-success">
<div class="pos-payment-success__circle">
<div class="pos-payment-success__inner">
<i data-lucide="check" style="width:28px;height:28px;color:#fff;stroke-width:3;"></i>
</div>
</div>
<div style="font-size:18px;font-weight:700;color:var(--pos-success);">Thanh toán thành công!</div>
<div style="font-size:14px;color:var(--pos-text-tertiary);">@FormatPrice(_lastOrderTotal)</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">Mã: @_lastTransactionId</div>
<div style="display:flex;gap:10px;margin-top:8px;">
<button style="padding:10px 20px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);background:transparent;color:var(--pos-text-primary);cursor:pointer;font-size:13px;font-weight:600;"
@onclick="PrintReceipt">
<i data-lucide="printer" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i>In hóa đơn
</button>
<button style="padding:10px 20px;border-radius:var(--pos-radius);border:none;background:var(--pos-orange-primary);color:#fff;cursor:pointer;font-size:13px;font-weight:600;"
@onclick="ResetAfterPayment">
<i data-lucide="plus" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i>Đơn mới
</button>
</div>
</div>
}
</div>
@code {
// EN: Loading state / VI: Trạng thái tải
// ═══════════════ SALE — Product & Cart ═══════════════
private bool _isLoading = true;
private bool _loadError;
// EN: Categories / VI: Danh mục
private string[] _categories = { "Tất cả" };
private string _selectedCategory = "Tất cả";
// EN: Product list / VI: Danh sách sản phẩm
private List<Product> _products = new();
// EN: Cart items / VI: Mục giỏ hàng
private readonly List<CartItem> _cartItems = new();
private IEnumerable<Product> FilteredProducts =>
_selectedCategory == "Tất cả" ? _products : _products.Where(p => p.Category == _selectedCategory);
private decimal CartTotal => _cartItems.Sum(i => i.Price * i.Qty);
private decimal FinalTotal => Math.Max(0, CartTotal - _discountAmount);
// Voucher state
private string _voucherCode = "";
private string? _voucherMessage;
private PosDataService.VoucherValidationInfo? _appliedVoucher;
private decimal _discountAmount;
protected override async Task OnInitializedAsync()
{
@@ -123,7 +315,7 @@
p.Id,
p.Name,
p.Price,
p.Category ?? "Khác"
p.CategoryName ?? "Khác"
)).ToList();
var catNames = apiCategories.Select(c => c.Name).ToList();
@@ -135,31 +327,330 @@
_categories = new[] { "Tất cả" }.Concat(productCats).ToArray();
}
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
catch { _loadError = true; }
finally { _isLoading = false; }
await LoadPaymentSettings();
await RestoreCartFromLocalStorage();
}
private void AddToCart(Product product)
private async Task AddToCart(Product product)
{
if (_paymentStep != PayStep.None) return;
var existing = _cartItems.FirstOrDefault(i => i.ProductId == product.Id);
if (existing != null) existing.Qty++;
else _cartItems.Add(new CartItem(product.Id, product.Name, product.Price));
await SaveCartToLocalStorage();
}
private void ChangeQty(CartItem item, int delta)
private async Task ChangeQty(CartItem item, int delta)
{
item.Qty += delta;
if (item.Qty <= 0) _cartItems.Remove(item);
await SaveCartToLocalStorage();
}
private void Checkout() => NavigateTo("cafe/order-customize");
private async Task ClearCart()
{
_cartItems.Clear();
await SaveCartToLocalStorage();
}
// ═══════════════ VOUCHER ═══════════════
private async Task ValidateVoucher()
{
_voucherMessage = null; _appliedVoucher = null; _discountAmount = 0;
if (string.IsNullOrWhiteSpace(_voucherCode)) { _voucherMessage = "Vui lòng nhập mã voucher."; return; }
var info = await DataService.ValidateVoucherAsync(_voucherCode.Trim());
if (info == null) { _voucherMessage = "Không thể kiểm tra voucher."; return; }
if (!info.IsValid) { _voucherMessage = info.ErrorMessage ?? "Mã voucher không hợp lệ."; return; }
_appliedVoucher = info;
var isPercentage = (info.CampaignName ?? "").Contains("[percentage]", StringComparison.OrdinalIgnoreCase);
if (isPercentage)
{
var pct = Math.Min(info.RemainingValue ?? 0, 100);
_discountAmount = Math.Round(CartTotal * pct / 100, 0);
_voucherMessage = $"Voucher {info.CampaignName?.Replace("[percentage]","").Trim()}: giảm {pct}% = {FormatPrice(_discountAmount)}";
}
else
{
_discountAmount = Math.Min(info.RemainingValue ?? 0, CartTotal);
_voucherMessage = $"Voucher {info.CampaignName}: giảm {FormatPrice(_discountAmount)}";
}
}
private void ClearVoucher() { _appliedVoucher = null; _discountAmount = 0; _voucherCode = ""; _voucherMessage = null; }
// ═══════════════ INLINE PAYMENT ═══════════════
private enum PayStep { None, MethodSelect, AmountInput, Processing, Success }
private PayStep _paymentStep = PayStep.None;
private string _selectedMethod = "";
private decimal _receivedAmount;
private string _customAmountInput = "";
private decimal _lastOrderTotal;
private string _lastTransactionId = "";
private string _lastPaymentMethod = "";
private List<(string Name, int Qty, decimal Price)> _lastReceiptItems = new();
private decimal ChangeAmount => _receivedAmount - FinalTotal;
// EN: Created order ID — set when order is submitted to API before payment
// VI: ID đơn hàng đã tạo — gán khi đơn được gửi lên API trước thanh toán
private Guid? _createdOrderId;
private string? _orderError;
private bool _orderCreating;
private async Task StartPayment()
{
if (!_cartItems.Any()) return;
if (_orderCreating) return;
_orderCreating = true;
_orderError = null;
StateHasChanged();
try
{
var orderReq = new PosDataService.CreatePosOrderRequest(
ShopId,
null,
_cartItems.Select(i => new PosDataService.PosOrderItemRequest(
i.ProductId, i.Name, i.Qty, i.Price)).ToList(),
_discountAmount > 0 ? _discountAmount : null,
_appliedVoucher != null ? "voucher" : null,
_appliedVoucher?.VoucherCode);
var result = await DataService.CreatePosOrderAsync(orderReq);
if (result != null)
{
_createdOrderId = result.OrderId;
_paymentStep = PayStep.MethodSelect;
}
else
{
_orderError = "Không thể tạo đơn hàng. Vui lòng thử lại.";
}
}
catch (Exception ex)
{
_orderError = $"Lỗi tạo đơn: {ex.Message}";
}
finally
{
_orderCreating = false;
StateHasChanged();
}
}
private async Task CancelPayment()
{
if (_createdOrderId.HasValue)
{
try { await DataService.CancelOrderAsync(_createdOrderId.Value); } catch { }
_createdOrderId = null;
}
_paymentStep = PayStep.None;
_selectedMethod = "";
_receivedAmount = 0;
_customAmountInput = "";
}
private void SelectPaymentMethod(string method)
{
_selectedMethod = method;
_receivedAmount = 0;
_customAmountInput = "";
if (method == "cash")
_paymentStep = PayStep.AmountInput;
else
_paymentStep = PayStep.Processing;
}
private string GetMethodLabel() => _selectedMethod switch
{
"cash" => "Tiền mặt",
"card" => "Thẻ",
"qr" => "Mã QR",
"transfer" => "Chuyển khoản",
_ => "Thanh toán"
};
private List<(string Label, decimal Value)> GetQuickAmounts()
{
var total = FinalTotal;
var amounts = new List<(string, decimal)>();
var roundUp = Math.Ceiling(total / 50_000) * 50_000;
if (roundUp == total) roundUp += 50_000;
amounts.Add(("Đúng tiền", total));
amounts.Add((FormatPrice(roundUp), roundUp));
amounts.Add((FormatPrice(roundUp + 50_000), roundUp + 50_000));
amounts.Add((FormatPrice(500_000), 500_000));
amounts.Add((FormatPrice(200_000), 200_000));
amounts.Add((FormatPrice(1_000_000), 1_000_000));
return amounts;
}
private void ApplyCustomAmount()
{
if (decimal.TryParse(_customAmountInput, out var val))
_receivedAmount = val;
}
private bool _paymentProcessing;
private async Task ConfirmPayment()
{
if (_paymentProcessing) return;
_paymentProcessing = true;
StateHasChanged();
_lastOrderTotal = FinalTotal;
_lastPaymentMethod = _selectedMethod;
_lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList();
try
{
if (_createdOrderId.HasValue)
{
await DataService.PayOrderAsync(_createdOrderId.Value, ShopId);
_lastTransactionId = _createdOrderId.Value.ToString()[..8].ToUpper();
}
else
{
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
}
if (_appliedVoucher?.VoucherId != null && _discountAmount > 0)
{
try { await DataService.RedeemVoucherAsync(_appliedVoucher.VoucherId.Value, _discountAmount); } catch { }
}
}
catch
{
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
}
_paymentProcessing = false;
_paymentStep = PayStep.Success;
StateHasChanged();
}
private async Task ResetAfterPayment()
{
_cartItems.Clear();
_paymentStep = PayStep.None;
_selectedMethod = "";
_receivedAmount = 0;
_customAmountInput = "";
_createdOrderId = null;
_orderError = null;
ClearVoucher();
await SaveCartToLocalStorage();
}
private async Task PrintReceipt()
{
var payLabel = _lastPaymentMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" };
var now = DateTime.Now;
var sb = new System.Text.StringBuilder();
foreach (var item in _lastReceiptItems)
{
sb.AppendLine($"<tr><td style='text-align:left;padding:3px 0;'>{System.Net.WebUtility.HtmlEncode(item.Name)}</td>");
sb.AppendLine($"<td style='text-align:center;padding:3px 4px;'>{item.Qty}</td>");
sb.AppendLine($"<td style='text-align:right;padding:3px 0;'>{item.Price:N0}</td>");
sb.AppendLine($"<td style='text-align:right;padding:3px 0;font-weight:600;'>{item.Qty * item.Price:N0}</td></tr>");
}
var receiptHtml = "<!DOCTYPE html><html><head><meta charset='utf-8'>" +
$"<title>Hóa đơn - {_lastTransactionId}</title>" +
"<style>" +
"@page { margin: 4mm; size: 80mm auto; }" +
"body { font-family: 'Courier New', monospace; font-size: 12px; width: 72mm; margin: 0 auto; color: #000; }" +
".c { text-align: center; } .b { font-weight: bold; }" +
".d { border-top: 1px dashed #000; margin: 6px 0; }" +
"table { width: 100%; border-collapse: collapse; }" +
"th { text-align: left; font-size: 11px; border-bottom: 1px solid #000; padding: 2px 0; }" +
".f { font-size: 10px; text-align: center; margin-top: 8px; color: #555; }" +
"</style></head><body>" +
"<div class='c b' style='font-size:16px;'>GoodGo POS</div>" +
"<div class='c' style='font-size:10px;margin-bottom:4px;'>Hệ thống quản lý bán hàng thông minh</div>" +
"<div class='d'></div>" +
$"<div><b>Mã đơn:</b> {_lastTransactionId}</div>" +
$"<div><b>Ngày:</b> {now:dd/MM/yyyy} — {now:HH:mm:ss}</div>" +
$"<div><b>Thanh toán:</b> {payLabel}</div>" +
"<div class='d'></div>" +
"<table><tr><th>Sản phẩm</th><th style='text-align:center;'>SL</th><th style='text-align:right;'>Đ.Giá</th><th style='text-align:right;'>T.Tiền</th></tr>" +
sb.ToString() +
"</table><div class='d'></div>" +
$"<div style='display:flex;justify-content:space-between;font-size:14px;font-weight:bold;'><span>TỔNG CỘNG</span><span>{_lastOrderTotal:N0}₫</span></div>" +
"<div class='d'></div>" +
"<div class='f'>Cảm ơn quý khách! Hẹn gặp lại</div>" +
"<div class='f'>Powered by GoodGo Platform</div>" +
"<script>window.onload=function(){window.print();window.onafterprint=function(){window.close();}}</script>" +
"</body></html>";
await JS.InvokeVoidAsync("printPosReceipt", receiptHtml);
}
// ═══════════════ PAYMENT SETTINGS ═══════════════
private bool _payCardEnabled = true;
private bool _payQrEnabled = true;
private bool _payTransferEnabled = true;
private async Task LoadPaymentSettings()
{
try
{
var json = await JS.InvokeAsync<string?>("localStorage.getItem", "pos_payment_settings");
if (!string.IsNullOrEmpty(json))
{
var doc = System.Text.Json.JsonDocument.Parse(json);
var r = doc.RootElement;
if (r.TryGetProperty("cardEnabled", out var ce)) _payCardEnabled = ce.GetBoolean();
if (r.TryGetProperty("qrEnabled", out var qe)) _payQrEnabled = qe.GetBoolean();
if (r.TryGetProperty("transferEnabled", out var te)) _payTransferEnabled = te.GetBoolean();
}
}
catch { /* first load — no settings yet */ }
}
// ═══════════════ LOCALSTORAGE PERSISTENCE ═══════════════
private string CartStorageKey => $"pos_cafe_cart_{ShopId}";
private async Task SaveCartToLocalStorage()
{
try
{
var cartData = _cartItems.Select(i => new { i.ProductId, i.Name, i.Price, i.Qty }).ToList();
await JS.InvokeVoidAsync("localStorage.setItem", CartStorageKey,
System.Text.Json.JsonSerializer.Serialize(cartData));
}
catch { }
}
private async Task RestoreCartFromLocalStorage()
{
try
{
var cartJson = await JS.InvokeAsync<string?>("localStorage.getItem", CartStorageKey);
if (!string.IsNullOrEmpty(cartJson))
{
var items = System.Text.Json.JsonSerializer.Deserialize<List<StoredCartItem>>(cartJson, _lsJsonOptions);
if (items != null)
foreach (var i in items)
_cartItems.Add(new CartItem(i.ProductId, i.Name, i.Price) { Qty = i.Qty });
}
}
catch { }
}
private static readonly System.Text.Json.JsonSerializerOptions _lsJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private record StoredCartItem(Guid ProductId, string Name, decimal Price, int Qty);
// ═══════════════ RECORDS ═══════════════
private record Product(Guid Id, string Name, decimal Price, string Category);
private class CartItem(Guid productId, string name, decimal price)
{

View File

@@ -1,12 +1,31 @@
@*
EN: Barista Queue — Kanban-style order queue: New / In Progress / Ready columns.
Connected to kitchen tickets API with auto-refresh every 10 seconds.
VI: Hàng đợi Barista — Bảng đơn hàng kiểu Kanban: Mới / Đang pha / Sẵn sàng.
Kết nối API kitchen tickets với auto-refresh mỗi 10 giây.
*@
@page "/pos/{ShopId:guid}/cafe/barista-queue"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@implements IDisposable
<div style="display:flex;gap:16px;padding:16px;height:100%;overflow-x:auto;">
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;width:100%;color:var(--pos-text-tertiary);">
Đang tải hàng đợi...
</div>
}
else if (_loadError)
{
<div style="display:flex;align-items:center;justify-content:center;width:100%;color:var(--pos-text-tertiary);">
Không thể tải dữ liệu. <button style="margin-left:8px;padding:6px 12px;border-radius:8px;border:none;background:var(--pos-orange-primary);color:#fff;cursor:pointer;" @onclick="async () => await LoadTicketsAsync()">Thử lại</button>
</div>
}
else
{
@* EN: Kanban columns / VI: Các cột Kanban *@
@foreach (var column in _columns)
{
@@ -16,36 +35,35 @@
<span style="width:10px;height:10px;border-radius:50%;background:@column.Color;"></span>
<span style="font-size:15px;font-weight:600;">@column.Title</span>
<span style="margin-left:auto;font-size:12px;color:var(--pos-text-tertiary);background:var(--pos-bg-interactive);padding:2px 8px;border-radius:6px;">
@_orders.Count(o => o.Status == column.Status)
@_tickets.Count(t => t.Status == column.Status)
</span>
</div>
@* EN: Order cards / VI: Thẻ đơn hàng *@
@* EN: Ticket cards / VI: Thẻ ticket *@
<div style="flex:1;display:flex;flex-direction:column;gap:8px;overflow-y:auto;">
@foreach (var order in _orders.Where(o => o.Status == column.Status))
@foreach (var ticket in _tickets.Where(t => t.Status == column.Status))
{
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;border-left:3px solid @column.Color;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-size:18px;font-weight:700;color:var(--pos-orange-primary);">#@order.Number</span>
<span style="font-size:11px;color:var(--pos-text-tertiary);">@order.Elapsed phút</span>
<span style="font-size:18px;font-weight:700;color:var(--pos-orange-primary);">#@ticket.Id.ToString()[..6].ToUpper()</span>
<span style="font-size:11px;color:var(--pos-text-tertiary);">@GetElapsedMinutes(ticket.CreatedAt) phút</span>
</div>
<div style="font-size:12px;color:var(--pos-text-secondary);margin-bottom:8px;">@order.Customer</div>
@foreach (var item in order.Items)
{
<div style="font-size:13px;color:var(--pos-text-primary);padding:2px 0;">• @item</div>
}
@if (order.Status != "ready")
<div style="font-size:12px;color:var(--pos-text-secondary);margin-bottom:8px;">@(ticket.Station ?? "Barista")</div>
<div style="font-size:13px;color:var(--pos-text-primary);padding:2px 0;">@ticket.ItemName</div>
@if (ticket.Status != "Completed")
{
<button style="margin-top:12px;width:100%;padding:8px;border-radius:8px;border:none;background:@column.ActionBg;color:@column.Color;font-size:13px;font-weight:600;cursor:pointer;"
@onclick="() => MoveOrder(order)">
@column.ActionText
disabled="@ticket.IsUpdating"
@onclick="() => MoveTicket(ticket, column.NextStatus)">
@(ticket.IsUpdating ? "Đang cập nhật..." : column.ActionText)
</button>
}
else
{
<button style="margin-top:12px;width:100%;padding:8px;border-radius:8px;border:none;background:rgba(34,197,94,0.15);color:var(--pos-success);font-size:13px;font-weight:600;cursor:pointer;"
@onclick="() => CompleteOrder(order)">
Đã giao
disabled="@ticket.IsUpdating"
@onclick="() => CompleteTicket(ticket)">
@(ticket.IsUpdating ? "Đang cập nhật..." : "Đã giao")
</button>
}
</div>
@@ -53,47 +71,153 @@
</div>
</div>
}
}
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
// EN: Loading state / VI: Trạng thái tải
private bool _isLoading = true;
private bool _loadError;
// EN: Column definitions / VI: Định nghĩa cột
// EN: Auto-refresh timer (10 seconds) / VI: Timer tự động refresh (10 giây)
private Timer? _refreshTimer;
// EN: Column definitions matching API statuses / VI: Định nghĩa cột khớp status API
private readonly List<QueueColumn> _columns = new()
{
new("Đơn mới", "new", "#3B82F6", "Bắt đầu pha", "rgba(59,130,246,0.15)"),
new("Đang pha", "progress", "#F59E0B", "Pha xong", "rgba(245,158,11,0.15)"),
new("Sẵn sàng", "ready", "#22C55E", "", ""),
new("Đơn mới", "Pending", "#3B82F6", "Bắt đầu pha", "rgba(59,130,246,0.15)", "InProgress"),
new("Đang pha", "InProgress", "#F59E0B", "Pha xong", "rgba(245,158,11,0.15)", "Completed"),
new("Sẵn sàng", "Completed", "#22C55E", "", "", ""),
};
// EN: Demo orders / VI: Đơn hàng mẫu
private readonly List<Order> _orders = new()
{
new(101, "Nguyễn Văn A", new[] { "Cà phê sữa đá x2", "Bánh mì x1" }, "new", 2),
new(102, "Trần Thị B", new[] { "Cappuccino x1", "Croissant x1" }, "new", 5),
new(103, "Lê Văn C", new[] { "Trà đào x1", "Sinh tố bơ x1" }, "progress", 8),
new(104, "Phạm Thị D", new[] { "Bạc xỉu x3" }, "progress", 12),
new(105, "Hoàng Văn E", new[] { "Latte x1" }, "ready", 15),
new(106, "Đỗ Thị F", new[] { "Espresso x2", "Cookie x2" }, "ready", 18),
};
// EN: Kitchen tickets loaded from API / VI: Kitchen tickets tải từ API
private List<TicketViewModel> _tickets = new();
private void MoveOrder(Order order)
protected override async Task OnInitializedAsync()
{
order.Status = order.Status == "new" ? "progress" : "ready";
await base.OnInitializedAsync();
await LoadTicketsAsync();
// EN: Start auto-refresh timer / VI: Bắt đầu timer tự động refresh
_refreshTimer = new Timer(async _ =>
{
await InvokeAsync(async () =>
{
await LoadTicketsAsync(silent: true);
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
}
private void CompleteOrder(Order order)
private async Task LoadTicketsAsync(bool silent = false)
{
_orders.Remove(order);
if (!silent) { _isLoading = true; _loadError = false; }
try
{
// EN: Fetch all statuses — Pending, InProgress, Completed
// VI: Lấy tất cả trạng thái — Pending, InProgress, Completed
var pendingTask = DataService.GetKitchenTicketsAsync(ShopId, "Pending");
var inProgressTask = DataService.GetKitchenTicketsAsync(ShopId, "InProgress");
var completedTask = DataService.GetKitchenTicketsAsync(ShopId, "Completed");
await Task.WhenAll(pendingTask, inProgressTask, completedTask);
var pending = await pendingTask;
var inProgress = await inProgressTask;
var completed = await completedTask;
// EN: Merge all tickets, preserve IsUpdating state for existing tickets
// VI: Gộp tất cả tickets, giữ trạng thái IsUpdating cho ticket đang có
var allApi = pending.Concat(inProgress).Concat(completed).ToList();
var existingMap = _tickets.ToDictionary(t => t.Id);
_tickets = allApi.Select(t =>
{
if (existingMap.TryGetValue(t.Id, out var existing) && existing.IsUpdating)
return existing;
return new TicketViewModel(t.Id, t.ItemName, t.Station, t.Status, t.CreatedAt, t.Priority);
}).ToList();
}
catch
{
if (!silent) _loadError = true;
}
finally
{
if (!silent) _isLoading = false;
}
}
private record QueueColumn(string Title, string Status, string Color, string ActionText, string ActionBg);
private class Order(int number, string customer, string[] items, string status, int elapsed)
private async Task MoveTicket(TicketViewModel ticket, string nextStatus)
{
public int Number { get; set; } = number;
public string Customer { get; set; } = customer;
public string[] Items { get; set; } = items;
if (string.IsNullOrEmpty(nextStatus) || ticket.IsUpdating) return;
ticket.IsUpdating = true;
StateHasChanged();
try
{
var success = await DataService.UpdateTicketStatusAsync(
ticket.Id, new PosDataService.UpdateTicketStatusRequest(nextStatus));
if (success)
{
ticket.Status = nextStatus;
}
}
catch { /* EN: Silently fail, next refresh will sync / VI: Bỏ qua lỗi, refresh kế tiếp sẽ đồng bộ */ }
finally
{
ticket.IsUpdating = false;
StateHasChanged();
}
}
private async Task CompleteTicket(TicketViewModel ticket)
{
if (ticket.IsUpdating) return;
ticket.IsUpdating = true;
StateHasChanged();
try
{
// EN: Mark ticket as delivered — remove from view
// VI: Đánh dấu ticket đã giao — xóa khỏi danh sách
var success = await DataService.UpdateTicketStatusAsync(
ticket.Id, new PosDataService.UpdateTicketStatusRequest("Delivered"));
if (success)
{
_tickets.Remove(ticket);
}
}
catch { }
finally
{
ticket.IsUpdating = false;
StateHasChanged();
}
}
private static int GetElapsedMinutes(DateTime createdAt)
{
return (int)(DateTime.UtcNow - createdAt).TotalMinutes;
}
public void Dispose()
{
_refreshTimer?.Dispose();
}
// EN: Column definition with next status for transitions / VI: Định nghĩa cột với status kế tiếp cho chuyển đổi
private record QueueColumn(string Title, string Status, string Color, string ActionText, string ActionBg, string NextStatus);
// EN: ViewModel wrapping API ticket with UI state / VI: ViewModel bọc API ticket với trạng thái UI
private class TicketViewModel(Guid id, string itemName, string? station, string status, DateTime createdAt, int priority)
{
public Guid Id { get; set; } = id;
public string ItemName { get; set; } = itemName;
public string? Station { get; set; } = station;
public string Status { get; set; } = status;
public int Elapsed { get; set; } = elapsed;
public DateTime CreatedAt { get; set; } = createdAt;
public int Priority { get; set; } = priority;
public bool IsUpdating { get; set; }
}
}

View File

@@ -1,25 +1,49 @@
@*
EN: Cafe Journey — End-to-end café workflow tracker: Đặt món → Thanh toán → Pha chế → Phục vụ → Hoàn tất.
VI: Hành trình Café — Theo dõi quy trình từ đầu đến cuối: Đặt món → Thanh toán → Pha chế → Phục vụ → Hoàn tất.
EN: Cafe Journey — End-to-end cafe workflow tracker: Order -> Payment -> Barista -> Serving -> Complete.
Connected to order detail & kitchen tickets APIs with auto-refresh every 5 seconds.
VI: Hanh trinh Cafe — Theo doi quy trinh tu dau den cuoi: Dat mon -> Thanh toan -> Pha che -> Phuc vu -> Hoan tat.
Ket noi API chi tiet don & kitchen tickets voi auto-refresh moi 5 giay.
*@
@page "/pos/{ShopId:guid}/cafe/cafe-journey"
@page "/pos/{ShopId:guid}/cafe/cafe-journey/{OrderId:guid}"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@implements IDisposable
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@* ═══ HEADER / TIEU DE ═══ *@
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);flex-shrink:0;">
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
@onclick="@(() => NavigateTo("cafe"))">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;">Hành trình Café</span>
<span style="font-size:16px;font-weight:700;">Hanh trinh Cafe</span>
<span style="flex:1;"></span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">Đơn #CF-0027</span>
@if (_orderDetail?.Order != null)
{
<span style="font-size:12px;color:var(--pos-text-tertiary);">Don #@(_orderDetail.Order.Id.ToString()[..8].ToUpper())</span>
}
</div>
@* ═══ STEP TRACKER / THANH BƯỚC ═══ *@
@if (_isLoading)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Dang tai...
</div>
}
else if (_loadError)
{
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:var(--pos-text-tertiary);">
<span>Khong the tai du lieu don hang</span>
<button style="padding:8px 16px;border-radius:8px;border:none;background:var(--pos-orange-primary);color:#fff;cursor:pointer;"
@onclick="async () => await LoadDataAsync()">Thu lai</button>
</div>
}
else
{
@* ═══ STEP TRACKER / THANH BUOC ═══ *@
<div style="padding:20px 16px;flex-shrink:0;">
<div style="display:flex;align-items:center;justify-content:center;gap:0;">
@for (int i = 0; i < _steps.Count; i++)
@@ -31,7 +55,7 @@
var bgColor = isCompleted ? "var(--pos-success)" : isActive ? "var(--pos-orange-primary)" : "var(--pos-bg-interactive)";
var fgColor = isCompleted || isActive ? "#FFF" : "var(--pos-text-tertiary)";
@* EN: Step circle / VI: Vòng tròn bước *@
@* EN: Step circle / VI: Vong tron buoc *@
<div style="display:flex;flex-direction:column;align-items:center;z-index:1;">
<div style="width:40px;height:40px;border-radius:50%;background:@bgColor;
display:flex;align-items:center;justify-content:center;color:@fgColor;
@@ -52,7 +76,7 @@
</div>
</div>
@* EN: Connector line / VI: Đường ni *@
@* EN: Connector line / VI: Duong noi *@
@if (stepIdx < _steps.Count - 1)
{
<div style="flex:1;height:2px;max-width:80px;margin:0 8px;margin-bottom:20px;
@@ -62,130 +86,155 @@
</div>
</div>
@* ═══ STEP CONTENT / NI DUNG BƯỚC ═══ *@
@* ═══ STEP CONTENT / NOI DUNG BUOC ═══ *@
<div style="flex:1;overflow-y:auto;padding:0 16px 16px;">
@switch (_currentStep)
{
case 0:
@* ═══ ĐẶT MÓN / ORDER STEP ═══ *@
@* ═══ DAT MON / ORDER STEP ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;">
<div style="font-size:15px;font-weight:700;margin-bottom:16px;">
<i data-lucide="clipboard-list" style="width:18px;height:18px;display:inline;"></i> Đặt món
<i data-lucide="clipboard-list" style="width:18px;height:18px;display:inline;"></i> Dat mon
</div>
<div style="display:flex;flex-direction:column;gap:8px;">
@foreach (var item in _orderItems)
@if (_orderItems.Any())
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px;
background:var(--pos-bg-interactive);border-radius:8px;">
<div>
<div style="font-size:13px;font-weight:600;">@item.Name</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">x@item.Qty</div>
@foreach (var item in _orderItems)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px;
background:var(--pos-bg-interactive);border-radius:8px;">
<div>
<div style="font-size:13px;font-weight:600;">@item.ProductName</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">x@item.Quantity</div>
</div>
<span style="font-size:14px;font-weight:600;">@FormatPrice(item.UnitPrice * item.Quantity)</span>
</div>
<span style="font-size:14px;font-weight:600;">@FormatPrice(item.Price * item.Qty)</span>
</div>
}
}
else
{
<div style="text-align:center;color:var(--pos-text-tertiary);padding:20px;">Khong co mon nao</div>
}
</div>
<div style="display:flex;justify-content:space-between;margin-top:12px;padding-top:12px;
border-top:1px solid var(--pos-border-subtle);font-size:15px;font-weight:700;">
<span>Tng (@_orderItems.Sum(i => i.Qty) món)</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(_orderItems.Sum(i => i.Price * i.Qty))</span>
<span>Tong (@_orderItems.Sum(i => i.Quantity) mon)</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(_orderDetail?.Order?.TotalAmount ?? 0)</span>
</div>
</div>
break;
case 1:
@* ═══ THANH TOÁN / PAYMENT STEP ═══ *@
@* ═══ THANH TOAN / PAYMENT STEP ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;">
<div style="font-size:15px;font-weight:700;margin-bottom:16px;">
<i data-lucide="credit-card" style="width:18px;height:18px;display:inline;"></i> Thanh toán
<i data-lucide="credit-card" style="width:18px;height:18px;display:inline;"></i> Thanh toan
</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div style="display:flex;justify-content:space-between;font-size:13px;padding:10px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="color:var(--pos-text-secondary);">Phương thc</span>
<span style="font-weight:600;">Tiền mặt</span>
<span style="color:var(--pos-text-secondary);">Phuong thuc</span>
<span style="font-weight:600;">@(_orderDetail?.Order?.PaymentMethod ?? "Chua thanh toan")</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;padding:10px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="color:var(--pos-text-secondary);">Tng tin</span>
<span style="font-weight:600;color:var(--pos-orange-primary);">@FormatPrice(125_000)</span>
<span style="color:var(--pos-text-secondary);">Tong tien</span>
<span style="font-weight:600;color:var(--pos-orange-primary);">@FormatPrice(_orderDetail?.Order?.TotalAmount ?? 0)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;padding:10px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="color:var(--pos-text-secondary);">Khách đưa</span>
<span style="font-weight:600;">@FormatPrice(150_000)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;padding:10px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="color:var(--pos-text-secondary);">Tiền thừa</span>
<span style="font-weight:600;color:var(--pos-success);">@FormatPrice(25_000)</span>
<span style="color:var(--pos-text-secondary);">Trang thai</span>
<span style="font-weight:600;color:@(_paymentCompleted ? "var(--pos-success)" : "var(--pos-warning)");">
@(_paymentCompleted ? "Da thanh toan" : "Chua thanh toan")
</span>
</div>
</div>
</div>
break;
case 2:
@* ═══ PHA CH / BARISTA STEP ═══ *@
@* ═══ PHA CHE / BARISTA STEP ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;">
<div style="font-size:15px;font-weight:700;margin-bottom:16px;">
<i data-lucide="coffee" style="width:18px;height:18px;display:inline;"></i> Pha chế
<i data-lucide="coffee" style="width:18px;height:18px;display:inline;"></i> Pha che
</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div style="display:flex;justify-content:space-between;font-size:13px;padding:10px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="color:var(--pos-text-secondary);">Barista</span>
<span style="font-weight:600;">Trần Minh Tú</span>
@if (_orderTickets.Any())
{
<div style="display:flex;flex-direction:column;gap:10px;">
@foreach (var ticket in _orderTickets)
{
<div style="display:flex;justify-content:space-between;font-size:13px;padding:10px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="color:var(--pos-text-secondary);">@ticket.ItemName</span>
<span style="font-weight:600;color:@TicketStatusColor(ticket.Status);">@TicketStatusLabel(ticket.Status)</span>
</div>
}
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;padding:10px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="color:var(--pos-text-secondary);">Thời gian ước tính</span>
<span style="font-weight:600;color:var(--pos-warning);">3 phút</span>
@if (_orderTickets.Any(t => t.Status is "Pending" or "InProgress"))
{
<div style="margin-top:16px;padding:12px;background:rgba(245,158,11,.1);border-radius:8px;border:1px solid rgba(245,158,11,.3);">
<div style="display:flex;align-items:center;gap:8px;">
<span style="width:8px;height:8px;border-radius:50%;background:var(--pos-warning);animation:pulse 1.5s infinite;"></span>
<span style="font-size:12px;color:var(--pos-warning);font-weight:600;">Dang pha @(_orderTickets.Count(t => t.Status is "Pending" or "InProgress")) mon...</span>
</div>
</div>
}
}
else
{
<div style="text-align:center;padding:20px;color:var(--pos-text-tertiary);">
Chua co kitchen ticket nao cho don nay
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;padding:10px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="color:var(--pos-text-secondary);">Trạng thái</span>
<span style="font-weight:600;color:var(--pos-orange-primary);">Đang pha chế</span>
</div>
</div>
<div style="margin-top:16px;padding:12px;background:rgba(245,158,11,.1);border-radius:8px;border:1px solid rgba(245,158,11,.3);">
<div style="display:flex;align-items:center;gap:8px;">
<span style="width:8px;height:8px;border-radius:50%;background:var(--pos-warning);animation:pulse 1.5s infinite;"></span>
<span style="font-size:12px;color:var(--pos-warning);font-weight:600;">Đang pha 3 món...</span>
</div>
</div>
}
</div>
break;
case 3:
@* ═══ PHC V / SERVING STEP ═══ *@
@* ═══ PHUC VU / SERVING STEP ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;text-align:center;">
<div style="font-size:15px;font-weight:700;margin-bottom:16px;">
<i data-lucide="bell" style="width:18px;height:18px;display:inline;"></i> Phc v
<i data-lucide="bell" style="width:18px;height:18px;display:inline;"></i> Phuc vu
</div>
<div style="width:100px;height:100px;border-radius:50%;background:rgba(255,92,0,.15);
<div style="width:100px;height:100px;border-radius:50%;background:@(_allTicketsCompleted ? "rgba(34,197,94,.15)" : "rgba(255,92,0,.15)");
display:flex;align-items:center;justify-content:center;margin:20px auto;">
<span style="font-size:36px;font-weight:800;color:var(--pos-orange-primary);">#027</span>
@if (_allTicketsCompleted)
{
<i data-lucide="check-circle" style="width:36px;height:36px;color:var(--pos-success);"></i>
}
else
{
<span style="font-size:36px;font-weight:800;color:var(--pos-orange-primary);">
#@(_orderDetail?.Order?.Id.ToString()[..4].ToUpper())
</span>
}
</div>
<div style="font-size:14px;color:var(--pos-text-secondary);margin-bottom:8px;">
@(_allTicketsCompleted ? "San sang phuc vu!" : "Dang cho pha che hoan tat")
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">
@(_allTicketsCompleted ? "Tat ca mon da hoan thanh" : "Vui long cho goi so tai quay")
</div>
<div style="font-size:14px;color:var(--pos-text-secondary);margin-bottom:8px;">Số thứ tự</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">Vui lòng chờ gọi số tại quầy</div>
</div>
break;
case 4:
@* ═══ HOÀN TT / COMPLETE STEP ═══ *@
@* ═══ HOAN TAT / COMPLETE STEP ═══ *@
<div style="text-align:center;padding:40px 20px;">
<div style="width:80px;height:80px;border-radius:50%;background:rgba(34,197,94,.15);
display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
<i data-lucide="check-circle" style="width:40px;height:40px;color:#22C55E;"></i>
</div>
<div style="font-size:20px;font-weight:700;color:var(--pos-success);margin-bottom:8px;">Hoàn tt!</div>
<div style="font-size:20px;font-weight:700;color:var(--pos-success);margin-bottom:8px;">Hoan tat!</div>
<div style="font-size:14px;color:var(--pos-text-secondary);margin-bottom:4px;">
Đơn hàng #CF-0027 đã hoàn thành
Don hang #@(_orderDetail?.Order?.Id.ToString()[..8].ToUpper()) da hoan thanh
</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);margin-bottom:20px;">
3 món · Tng: @FormatPrice(125_000) · Tiền mặt
@_orderItems.Sum(i => i.Quantity) mon · Tong: @FormatPrice(_orderDetail?.Order?.TotalAmount ?? 0) · @(_orderDetail?.Order?.PaymentMethod ?? "N/A")
</div>
<div style="display:flex;gap:12px;justify-content:center;">
<button style="padding:12px 24px;border-radius:var(--pos-radius);background:var(--pos-bg-interactive);
border:1px solid var(--pos-border-default);color:var(--pos-text-primary);cursor:pointer;font-size:13px;">
<i data-lucide="printer" style="width:14px;height:14px;display:inline;"></i> In hóa đơn
<i data-lucide="printer" style="width:14px;height:14px;display:inline;"></i> In hoa don
</button>
<button style="padding:12px 24px;border-radius:var(--pos-radius);background:var(--pos-bg-interactive);
border:1px solid var(--pos-border-default);color:var(--pos-text-primary);cursor:pointer;font-size:13px;">
<i data-lucide="star" style="width:14px;height:14px;display:inline;"></i> Đánh giá
<i data-lucide="star" style="width:14px;height:14px;display:inline;"></i> Danh gia
</button>
</div>
</div>
@@ -193,33 +242,34 @@
}
</div>
@* ═══ FOOTER ACTIONS / NÚT HÀNH ĐỘNG ═══ *@
@* ═══ FOOTER ACTIONS / NUT HANH DONG ═══ *@
<div style="padding:12px 16px;border-top:1px solid var(--pos-border-subtle);display:flex;gap:8px;flex-shrink:0;">
@if (_currentStep > 0)
{
<button style="padding:12px 20px;border-radius:var(--pos-radius);background:var(--pos-bg-interactive);
border:1px solid var(--pos-border-default);color:var(--pos-text-primary);cursor:pointer;font-size:13px;"
@onclick="() => _currentStep--">
<i data-lucide="arrow-left" style="width:14px;height:14px;display:inline;"></i> Quay li
<i data-lucide="arrow-left" style="width:14px;height:14px;display:inline;"></i> Quay lai
</button>
}
<span style="flex:1;"></span>
@if (_currentStep < _steps.Count - 1)
{
<button class="pos-btn-checkout" style="width:auto;padding:12px 24px;" @onclick="() => _currentStep++">
Tiếp <i data-lucide="arrow-right" style="width:14px;height:14px;display:inline;"></i>
Tiep <i data-lucide="arrow-right" style="width:14px;height:14px;display:inline;"></i>
</button>
}
else
{
<button class="pos-btn-checkout" style="width:auto;padding:12px 24px;" @onclick="@(() => NavigateTo("cafe"))">
<i data-lucide="home" style="width:14px;height:14px;display:inline;"></i> V trang chính
<i data-lucide="home" style="width:14px;height:14px;display:inline;"></i> Ve trang chinh
</button>
}
</div>
}
</div>
@* EN: Pulse animation / VI: Hiu ng nhp nháy *@
@* EN: Pulse animation / VI: Hieu ung nhap nhay *@
<style>
@@keyframes pulse {
0%, 100% { opacity: 1; }
@@ -228,28 +278,146 @@
</style>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
// EN: Route parameter for order tracking / VI: Tham so route de theo doi don hang
[Parameter] public Guid OrderId { get; set; }
// EN: Loading & error states / VI: Trang thai tai & loi
private bool _isLoading = true;
private bool _loadError;
// EN: Auto-refresh timer (5 seconds) / VI: Timer tu dong refresh (5 giay)
private Timer? _refreshTimer;
private int _currentStep = 0;
// EN: Journey steps / VI: Các bước hành trình
// EN: Journey steps / VI: Cac buoc hanh trinh
private readonly List<StepInfo> _steps = new()
{
new("Đặt món", "clipboard-list"),
new("Thanh toán", "credit-card"),
new("Pha chế", "coffee"),
new("Phc v", "bell"),
new("Hoàn tt", "check-circle"),
new("Dat mon", "clipboard-list"),
new("Thanh toan", "credit-card"),
new("Pha che", "coffee"),
new("Phuc vu", "bell"),
new("Hoan tat", "check-circle"),
};
// EN: Demo order items / VI: Các món trong đơn mẫu
private readonly List<OrderItem> _orderItems = new()
// EN: Order data from API / VI: Du lieu don hang tu API
private PosDataService.OrderDetailResponse? _orderDetail;
private List<PosDataService.OrderItemInfo> _orderItems = new();
private List<PosDataService.KitchenTicketInfo> _orderTickets = new();
// EN: Derived states / VI: Trang thai suy ra
private bool _paymentCompleted => _orderDetail?.Order?.Status is "Paid" or "Completed" or "Validated";
private bool _allTicketsCompleted => _orderTickets.Any() && _orderTickets.All(t => t.Status == "Completed");
protected override async Task OnInitializedAsync()
{
new("Cà phê sữa đá", 35_000, 2),
new("Bánh mì bơ tỏi", 25_000, 1),
new("Trà đào cam sả", 30_000, 1),
await base.OnInitializedAsync();
await LoadDataAsync();
// EN: Start auto-refresh timer / VI: Bat dau timer tu dong refresh
_refreshTimer = new Timer(async _ =>
{
await InvokeAsync(async () =>
{
await LoadDataAsync(silent: true);
AutoAdvanceStep();
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
private async Task LoadDataAsync(bool silent = false)
{
if (!silent) { _isLoading = true; _loadError = false; }
try
{
// EN: Load order details and kitchen tickets in parallel
// VI: Tai chi tiet don hang va kitchen tickets song song
var orderTask = DataService.GetOrderDetailAsync(OrderId, ShopId);
var pendingTask = DataService.GetKitchenTicketsAsync(ShopId, "Pending");
var inProgressTask = DataService.GetKitchenTicketsAsync(ShopId, "InProgress");
var completedTask = DataService.GetKitchenTicketsAsync(ShopId, "Completed");
await Task.WhenAll(orderTask, pendingTask, inProgressTask, completedTask);
_orderDetail = await orderTask;
_orderItems = _orderDetail?.Items ?? new();
// EN: Filter tickets by order items / VI: Loc tickets theo mon trong don
var allTickets = (await pendingTask).Concat(await inProgressTask).Concat(await completedTask).ToList();
var orderItemIds = _orderItems.Select(i => i.Id).ToHashSet();
_orderTickets = allTickets.Where(t => orderItemIds.Contains(t.OrderItemId)).ToList();
// EN: If no tickets matched by OrderItemId, try matching by item name
// VI: Neu khong co ticket nao khop theo OrderItemId, thu khop theo ten mon
if (!_orderTickets.Any() && _orderItems.Any())
{
var itemNames = _orderItems.Select(i => i.ProductName).Where(n => n != null).ToHashSet();
_orderTickets = allTickets.Where(t => itemNames.Contains(t.ItemName)).ToList();
}
if (!silent) AutoAdvanceStep();
}
catch
{
if (!silent) _loadError = true;
}
finally
{
if (!silent) _isLoading = false;
}
}
// EN: Auto-advance steps based on order/ticket status
// VI: Tu dong chuyen buoc dua tren trang thai don/ticket
private void AutoAdvanceStep()
{
if (_orderDetail?.Order == null) return;
// EN: Step 0 (Order) always done if we have items
// EN: Step 1 (Payment) done if paid
// EN: Step 2 (Barista) done if all tickets completed
// EN: Step 3 (Serving) done if all tickets completed and order marked complete
// EN: Step 4 (Complete) final
if (_orderDetail.Order.Status is "Completed")
{
_currentStep = 4;
}
else if (_allTicketsCompleted)
{
_currentStep = Math.Max(_currentStep, 3);
}
else if (_orderTickets.Any(t => t.Status is "InProgress" or "Completed"))
{
_currentStep = Math.Max(_currentStep, 2);
}
else if (_paymentCompleted)
{
_currentStep = Math.Max(_currentStep, 1);
}
}
private static string TicketStatusColor(string status) => status switch
{
"Completed" => "var(--pos-success)",
"InProgress" => "var(--pos-warning)",
"Pending" => "var(--pos-info)",
_ => "var(--pos-text-tertiary)"
};
private static string TicketStatusLabel(string status) => status switch
{
"Completed" => "Hoan thanh",
"InProgress" => "Dang pha",
"Pending" => "Cho",
_ => status
};
public void Dispose()
{
_refreshTimer?.Dispose();
}
private record StepInfo(string Label, string Icon);
private record OrderItem(string Name, decimal Price, int Qty);
}

View File

@@ -1,28 +1,32 @@
@*
EN: Customer Display — Customer-facing screen: current order, total, branding.
VI: Màn hình khách hàng — Hiển thị đơn hàng, tổng tiền, thương hiệu.
Connected to active orders API with auto-refresh.
VI: Man hinh khach hang — Hien thi don hang, tong tien, thuong hieu.
Ket noi API don hang voi tu dong lam moi.
*@
@page "/pos/{ShopId:guid}/cafe/customer-display"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@implements IDisposable
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:40px;gap:32px;">
@* EN: Branding / VI: Thương hiu *@
@* EN: Branding / VI: Thuong hieu *@
<div style="text-align:center;">
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);margin-bottom:8px;">
GoodGo Coffee
<i data-lucide="coffee" style="width:36px;height:36px;display:inline;"></i> GoodGo Coffee
</div>
<div style="font-size:16px;color:var(--pos-text-secondary);">
Chào mng quý khách — Welcome
Chao mung quy khach — Welcome
</div>
</div>
@if (_orderItems.Any())
{
@* EN: Order details / VI: Chi tiết đơn hàng *@
@* EN: Order details / VI: Chi tiet don hang *@
<div style="width:100%;max-width:500px;background:var(--pos-bg-elevated);border-radius:16px;overflow:hidden;">
<div style="padding:20px;border-bottom:1px solid var(--pos-border-subtle);text-align:center;">
<span style="font-size:18px;font-weight:600;">Đơn hàng ca bn</span>
<span style="font-size:18px;font-weight:600;">Don hang cua ban</span>
<span style="display:block;font-size:13px;color:var(--pos-text-tertiary);margin-top:4px;">Your Order</span>
</div>
@@ -31,55 +35,71 @@
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid var(--pos-border-subtle);">
<div>
<div style="font-size:16px;font-weight:500;">@item.Name</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);">x@item.Qty</div>
<div style="font-size:16px;font-weight:500;">@item.ProductName</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);">x@item.Quantity</div>
</div>
<span style="font-size:16px;font-weight:600;color:var(--pos-orange-primary);">
@FormatPrice(item.Price * item.Qty)
@FormatPrice(item.UnitPrice * item.Quantity)
</span>
</div>
}
</div>
@* EN: Total / VI: Tng cng *@
@* EN: Total / VI: Tong cong *@
<div style="padding:20px;background:var(--pos-bg-interactive);">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:18px;font-weight:600;">Tng cng / Total</span>
<span style="font-size:18px;font-weight:600;">Tong cong / Total</span>
<span style="font-size:28px;font-weight:700;color:var(--pos-orange-primary);">
@FormatPrice(_orderItems.Sum(i => i.Price * i.Qty))
@FormatPrice(_orderItems.Sum(i => i.UnitPrice * i.Quantity))
</span>
</div>
</div>
</div>
@* EN: Payment status / VI: Trng thái thanh toán *@
@* EN: Payment status / VI: Trang thai thanh toan *@
<div style="display:flex;align-items:center;gap:8px;color:var(--pos-warning);font-size:15px;">
<i data-lucide="loader" style="width:18px;height:18px;"></i>
Đang ch thanh toán — Awaiting payment
Dang cho thanh toan — Awaiting payment
</div>
}
else
{
@* EN: Idle state / VI: Trng thái ri *@
@* EN: Idle state / VI: Trang thai roi *@
<div style="text-align:center;color:var(--pos-text-tertiary);font-size:18px;">
<i data-lucide="monitor" style="width:48px;height:48px;margin-bottom:16px;display:block;margin-left:auto;margin-right:auto;"></i>
Hãy bt đầu gi món nhé!
Hay bat dau goi mon nhe!
<div style="font-size:14px;margin-top:4px;">Start ordering now!</div>
</div>
}
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
private List<WebClientTpos.Client.Services.PosDataService.ActiveTableOrderItemDto> _orderItems = new();
private System.Threading.Timer? _refreshTimer;
// EN: Demo order items / VI: Mục đơn hàng mẫu
private readonly List<DisplayItem> _orderItems = new()
protected override async Task OnInitializedAsync()
{
new("Cà phê sữa đá", 35_000, 2),
new("Cappuccino", 55_000, 1),
new("Croissant", 35_000, 1),
new("Trà đào", 45_000, 1),
};
await base.OnInitializedAsync();
await LoadLatestOrder();
private record DisplayItem(string Name, decimal Price, int Qty);
// EN: Auto-refresh every 3 seconds / VI: Tu dong lam moi moi 3 giay
_refreshTimer = new System.Threading.Timer(async _ =>
{
await LoadLatestOrder();
await InvokeAsync(StateHasChanged);
}, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3));
}
private async Task LoadLatestOrder()
{
try
{
var orders = await DataService.GetActiveTableOrdersAsync(ShopId);
var latestOrder = orders.OrderByDescending(o => o.CreatedAt).FirstOrDefault();
_orderItems = latestOrder?.Items?.ToList() ?? new();
}
catch { /* silent for display screen */ }
}
public void Dispose() => _refreshTimer?.Dispose();
}

View File

@@ -1,10 +1,14 @@
@*
EN: Daily Report — End-of-day summary: revenue, orders, popular items, payment breakdown.
Connected to POS dashboard API for real-time data.
VI: Báo cáo ngày — Tổng kết cuối ngày: doanh thu, đơn hàng, món phổ biến, phân tích thanh toán.
Kết nối API POS dashboard cho dữ liệu thời gian thực.
*@
@page "/pos/{ShopId:guid}/cafe/daily-report"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject PosDataService DataService
<div style="padding:24px;overflow-y:auto;height:100%;">
@* EN: Header / VI: Tiêu đề *@
@@ -19,6 +23,21 @@
</button>
</div>
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;padding:60px 0;color:var(--pos-text-tertiary);">
<div style="font-size:14px;">Đang tải báo cáo...</div>
</div>
}
else if (_loadError)
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 0;color:var(--pos-text-tertiary);">
<div style="font-size:14px;margin-bottom:12px;">Không thể tải dữ liệu báo cáo</div>
<button style="padding:8px 16px;border-radius:8px;border:none;background:var(--pos-orange-primary);color:#fff;cursor:pointer;font-size:13px;" @onclick="LoadReportAsync">Thử lại</button>
</div>
}
else
{
@* EN: Summary cards / VI: Thẻ tổng quan *@
<div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:16px;margin-bottom:24px;">
@foreach (var stat in _stats)
@@ -35,90 +54,135 @@
@* EN: Popular items / VI: Món phổ biến *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;">
<h3 style="font-size:15px;font-weight:600;margin:0 0 16px;">Món bán chạy</h3>
@foreach (var item in _popularItems)
@if (_popularItems.Any())
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid var(--pos-border-subtle);">
<div>
<div style="font-size:14px;font-weight:500;">@item.Name</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">@item.Qty đã bán</div>
@foreach (var item in _popularItems)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid var(--pos-border-subtle);">
<div>
<div style="font-size:14px;font-weight:500;">@item.Name</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">@item.Qty đã bán</div>
</div>
<span style="font-size:14px;font-weight:600;color:var(--pos-orange-primary);">@FormatPrice(item.Revenue)</span>
</div>
<span style="font-size:14px;font-weight:600;color:var(--pos-orange-primary);">@FormatPrice(item.Revenue)</span>
</div>
}
}
else
{
<div style="font-size:13px;color:var(--pos-text-tertiary);padding:16px 0;">Chưa có dữ liệu</div>
}
</div>
@* EN: Payment breakdown / VI: Phân tích thanh toán *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;">
<h3 style="font-size:15px;font-weight:600;margin:0 0 16px;">Hình thức thanh toán</h3>
@foreach (var payment in _payments)
@if (_payments.Any())
{
<div style="margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:13px;">@payment.Method</span>
<span style="font-size:13px;font-weight:600;">@FormatPrice(payment.Amount)</span>
</div>
<div style="height:6px;border-radius:3px;background:var(--pos-bg-interactive);">
<div style="height:100%;border-radius:3px;background:@payment.Color;width:@(payment.Percent)%;"></div>
</div>
</div>
}
@* EN: Hourly chart placeholder / VI: Biểu đồ theo giờ *@
<h3 style="font-size:15px;font-weight:600;margin:24px 0 16px;">Doanh thu theo giờ</h3>
<div style="display:flex;align-items:flex-end;gap:6px;height:120px;">
@foreach (var h in _hourlyData)
@foreach (var payment in _payments)
{
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;">
<div style="width:100%;background:var(--pos-orange-primary);border-radius:4px 4px 0 0;height:@(h.Pct)%;opacity:@(h.Pct > 0 ? 1 : 0.3);"></div>
<span style="font-size:9px;color:var(--pos-text-tertiary);">@h.Hour</span>
<div style="margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:13px;">@payment.Method</span>
<span style="font-size:13px;font-weight:600;">@FormatPrice(payment.Amount)</span>
</div>
<div style="height:6px;border-radius:3px;background:var(--pos-bg-interactive);">
<div style="height:100%;border-radius:3px;background:var(--pos-orange-primary);width:@(payment.Pct)%;"></div>
</div>
</div>
}
</div>
}
else
{
<div style="font-size:13px;color:var(--pos-text-tertiary);padding:16px 0;">Chưa có dữ liệu</div>
}
@* EN: Hourly chart / VI: Biểu đồ theo giờ *@
<h3 style="font-size:15px;font-weight:600;margin:24px 0 16px;">Doanh thu theo giờ</h3>
@if (_hourlyData.Any())
{
<div style="display:flex;align-items:flex-end;gap:6px;height:120px;">
@foreach (var h in _hourlyData)
{
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;">
<div style="width:100%;background:var(--pos-orange-primary);border-radius:4px 4px 0 0;height:@(h.Pct)%;opacity:@(h.Pct > 0 ? 1 : 0.3);"></div>
<span style="font-size:9px;color:var(--pos-text-tertiary);">@h.HourLabel</span>
</div>
}
</div>
}
else
{
<div style="font-size:13px;color:var(--pos-text-tertiary);padding:16px 0;">Chưa có dữ liệu</div>
}
</div>
</div>
}
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
// EN: Loading state / VI: Trạng thái tải
private bool _isLoading = true;
private bool _loadError;
// EN: Summary statistics / VI: Thống kê tổng quan
private readonly List<StatCard> _stats = new()
{
new("Doanh thu", "8,450,000₫", "var(--pos-orange-primary)", "+12% so với hôm qua"),
new("Đơn hàng", "156", "var(--pos-success)", "TB 54,167₫/đơn"),
new("Khách hàng", "132", "var(--pos-info)", "18% khách quay lại"),
new("Món bán ra", "347", "var(--pos-warning)", "2.2 món/đơn"),
};
// EN: Dashboard data from API / VI: Dữ liệu dashboard từ API
private List<StatCard> _stats = new();
private List<PosDataService.PopularItemInfo> _popularItems = new();
private List<PosDataService.PaymentBreakdownInfo> _payments = new();
private List<PosDataService.HourlyRevenueInfo> _hourlyData = new();
// EN: Popular items / VI: Món phổ biến
private readonly List<PopularItem> _popularItems = new()
protected override async Task OnInitializedAsync()
{
new("Cà phê sữa đá", 52, 1_820_000),
new("Bạc xỉu", 38, 1_482_000),
new("Trà đào", 29, 1_305_000),
new("Cappuccino", 24, 1_320_000),
new("Sinh tố bơ", 18, 990_000),
};
await base.OnInitializedAsync();
await LoadReportAsync();
}
// EN: Payment methods / VI: Hình thức thanh toán
private readonly List<PaymentInfo> _payments = new()
private async Task LoadReportAsync()
{
new("Tiền mặt", 4_650_000, 55, "var(--pos-success)"),
new("Chuyển khoản", 2_535_000, 30, "var(--pos-info)"),
new("Thẻ", 845_000, 10, "var(--pos-warning)"),
new("Ví điện tử", 420_000, 5, "var(--pos-orange-primary)"),
};
_isLoading = true;
_loadError = false;
StateHasChanged();
// EN: Hourly data / VI: Dữ liệu theo giờ
private readonly List<HourData> _hourlyData = new()
{
new("7h", 30), new("8h", 65), new("9h", 85), new("10h", 55),
new("11h", 90), new("12h", 100), new("13h", 70), new("14h", 45),
new("15h", 60), new("16h", 75), new("17h", 50), new("18h", 20),
};
try
{
// EN: Fetch dashboard data for today / VI: Lấy dữ liệu dashboard cho hôm nay
var data = await DataService.GetPosDashboardAsync(ShopId, "today");
// EN: Build summary stat cards from API data / VI: Tạo thẻ thống kê từ dữ liệu API
_stats = new List<StatCard>
{
new("Doanh thu", FormatPrice(data.Revenue), "var(--pos-orange-primary)",
data.AvgOrderValue > 0 ? $"TB {FormatPrice(data.AvgOrderValue)}/đơn" : "—"),
new("Đơn hàng", data.OrderCount.ToString("N0"), "var(--pos-success)",
data.OrderCount > 0 ? $"TB {FormatPrice(data.AvgOrderValue)}/đơn" : "—"),
new("Món bán ra", data.ItemsSold.ToString("N0"), "var(--pos-warning)",
data.OrderCount > 0 ? $"{(double)data.ItemsSold / data.OrderCount:F1} món/đơn" : "—"),
new("Đơn trung bình", FormatPrice(data.AvgOrderValue), "var(--pos-info)", "Giá trị trung bình"),
};
_popularItems = data.PopularItems ?? new();
// EN: Compute percentage for payment breakdown / VI: Tính phần trăm cho breakdown thanh toán
_payments = data.PaymentBreakdown ?? new();
var totalPayments = _payments.Sum(p => p.Amount);
foreach (var p in _payments)
p.Pct = totalPayments > 0 ? (int)(p.Amount / totalPayments * 100) : 0;
// EN: Compute percentage for hourly revenue (relative to max) / VI: Tính % doanh thu theo giờ (tương đối so với max)
_hourlyData = data.HourlyRevenue ?? new();
var maxHourly = _hourlyData.Any() ? _hourlyData.Max(h => h.Revenue) : 0;
foreach (var h in _hourlyData)
h.Pct = maxHourly > 0 ? (int)(h.Revenue / maxHourly * 100) : 0;
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
StateHasChanged();
}
}
private record StatCard(string Label, string Value, string Color, string Sub);
private record PopularItem(string Name, int Qty, decimal Revenue);
private record PaymentInfo(string Method, decimal Amount, int Percent, string Color);
private record HourData(string Hour, int Pct);
}

View File

@@ -1,59 +1,78 @@
@*
EN: Loyalty Stamp — Customer stamp card: points, stamps grid, reward progress.
VI: Thẻ tích điểm — Thẻ khách hàng: điểm, lưới tem, tiến trình thưởng.
Connected to membership API for customer search and experience management.
VI: The tich diem — The khach hang: diem, luoi tem, tien trinh thuong.
Ket noi API thanh vien de tim kiem khach hang va quan ly kinh nghiem.
*@
@page "/pos/{ShopId:guid}/cafe/loyalty-stamp"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject PosDataService DataService
<div style="display:flex;align-items:center;justify-content:center;height:100%;padding:24px;">
<div style="width:100%;max-width:480px;">
@* EN: Customer lookup / VI: Tìm khách hàng *@
@* EN: Customer lookup / VI: Tim khach hang *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;margin-bottom:16px;">
<div style="display:flex;gap:8px;">
<input type="text" placeholder="SĐT khách hàng..." value="@_phone"
<input type="text" placeholder="SDT khach hang..." value="@_phone"
style="flex:1;padding:12px 16px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:15px;outline:none;"
@onchange="@((ChangeEventArgs e) => _phone = e.Value?.ToString() ?? "")" />
<button style="padding:12px 20px;border-radius:var(--pos-radius);border:none;background:var(--pos-orange-primary);color:#fff;font-size:14px;font-weight:600;cursor:pointer;">
Tìm
<button style="padding:12px 20px;border-radius:var(--pos-radius);border:none;background:var(--pos-orange-primary);color:#fff;font-size:14px;font-weight:600;cursor:pointer;min-width:80px;"
disabled="@_isSearching"
@onclick="SearchCustomer">
@if (_isSearching)
{
<span>...</span>
}
else
{
<span>Tim</span>
}
</button>
</div>
@if (!string.IsNullOrEmpty(_searchError))
{
<div style="margin-top:8px;font-size:12px;color:var(--pos-danger);">@_searchError</div>
}
</div>
@* EN: Customer info / VI: Thông tin khách hàng *@
@if (_customer != null)
{
@* EN: Customer info / VI: Thong tin khach hang *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:24px;margin-bottom:16px;text-align:center;">
<div style="width:64px;height:64px;border-radius:50%;background:var(--pos-orange-primary);display:flex;align-items:center;justify-content:center;margin:0 auto 12px;font-size:24px;font-weight:700;">
@_customer.Name[..1]
<div style="width:64px;height:64px;border-radius:50%;background:var(--pos-orange-primary);display:flex;align-items:center;justify-content:center;margin:0 auto 12px;font-size:24px;font-weight:700;color:#fff;">
@(_customer.DisplayName ?? "?")[..1]
</div>
<div style="font-size:18px;font-weight:600;margin-bottom:4px;">@_customer.Name</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);">@_customer.Phone</div>
<div style="font-size:18px;font-weight:600;margin-bottom:4px;">@(_customer.DisplayName ?? "Khach hang")</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);">@(_customer.Phone ?? _phone)</div>
<div style="display:flex;justify-content:center;gap:24px;margin-top:16px;">
<div style="text-align:center;">
<div style="font-size:22px;font-weight:700;color:var(--pos-orange-primary);">@_customer.Points</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">Điểm</div>
<div style="font-size:22px;font-weight:700;color:var(--pos-orange-primary);">@_customer.CurrentExp</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">Diem</div>
</div>
<div style="text-align:center;">
<div style="font-size:22px;font-weight:700;color:var(--pos-success);">@_customer.Visits</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">Lần ghé</div>
<div style="font-size:22px;font-weight:700;color:var(--pos-success);">@_customer.TotalExpEarned</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">Tong diem</div>
</div>
<div style="text-align:center;">
<div style="font-size:22px;font-weight:700;">@_customer.Rank</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">Hng</div>
<div style="font-size:22px;font-weight:700;">@(_customer.LevelName ?? $"Lv.{_customer.CurrentLevel}")</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">Hang</div>
</div>
</div>
</div>
@* EN: Stamp grid / VI: Lưới tem *@
@* EN: Stamp grid / VI: Luoi tem *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:24px;margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<span style="font-size:15px;font-weight:600;">Th tích tem</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">@_customer.Stamps / @_totalStamps</span>
<span style="font-size:15px;font-weight:600;">The tich tem</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">@_stampCount / @_totalStamps</span>
</div>
<div style="display:grid;grid-template-columns:repeat(5, 1fr);gap:10px;">
@for (int i = 1; i <= _totalStamps; i++)
{
var idx = i;
var isStamped = idx <= _customer.Stamps;
var isStamped = idx <= _stampCount;
var isReward = idx == _totalStamps;
<div style="aspect-ratio:1;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:20px;
background:@(isStamped ? "var(--pos-orange-primary)" : "var(--pos-bg-interactive)");
@@ -61,11 +80,11 @@
color:@(isStamped ? "#fff" : "var(--pos-text-tertiary)");">
@if (isReward)
{
<span>🎁</span>
<span>&#127873;</span>
}
else if (isStamped)
{
<span></span>
<span>&#9749;</span>
}
else
{
@@ -75,54 +94,152 @@
}
</div>
@* EN: Progress bar / VI: Thanh tiến trình *@
@* EN: Progress bar / VI: Thanh tien trinh *@
<div style="margin-top:16px;">
<div style="height:6px;border-radius:3px;background:var(--pos-bg-interactive);">
<div style="height:100%;border-radius:3px;background:var(--pos-orange-primary);width:@(_customer.Stamps * 100 / _totalStamps)%;transition:width 0.3s;"></div>
<div style="height:100%;border-radius:3px;background:var(--pos-orange-primary);width:@(_stampCount * 100 / _totalStamps)%;transition:width 0.3s;"></div>
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:8px;text-align:center;">
Còn @(_totalStamps - _customer.Stamps) tem na để nhn 1 ly min phí!
Con @(_totalStamps - _stampCount) tem nua de nhan 1 ly mien phi!
</div>
</div>
</div>
@* EN: Add stamp button / VI: Nút thêm tem *@
<button class="pos-btn-checkout" @onclick="AddStamp">
<i data-lucide="stamp" style="width:18px;height:18px;"></i>
Tích tem (+1)
@* EN: Add stamp button / VI: Nut them tem *@
<button class="pos-btn-checkout" disabled="@_isAddingStamp" @onclick="AddStamp">
@if (_isAddingStamp)
{
<span>Dang xu ly...</span>
}
else
{
<i data-lucide="stamp" style="width:18px;height:18px;"></i>
<span>Tich tem (+1)</span>
}
</button>
@if (!string.IsNullOrEmpty(_stampMessage))
{
<div style="margin-top:8px;text-align:center;font-size:12px;color:var(--pos-success);">@_stampMessage</div>
}
}
else if (!_isSearching && _hasSearched)
{
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:40px;text-align:center;color:var(--pos-text-tertiary);">
Khong tim thay khach hang. Hay nhap SDT va nhan Tim.
</div>
}
</div>
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
// EN: Search state / VI: Trang thai tim kiem
private string _phone = "";
private bool _isSearching;
private bool _hasSearched;
private string? _searchError;
private string _phone = "0901234567";
// EN: Customer data from API / VI: Du lieu khach hang tu API
private PosDataService.MemberInfo? _customer;
private PosDataService.MemberProgressInfo? _progress;
// EN: Stamp state / VI: Trang thai tem
private int _totalStamps = 10;
private int _stampCount;
private bool _isAddingStamp;
private string? _stampMessage;
// EN: Demo customer / VI: Khách hàng mẫu
private CustomerInfo _customer = new("Nguyễn Minh Anh", "0901234567", 245, 28, "Vàng", 7);
private void AddStamp()
private async Task SearchCustomer()
{
if (_customer.Stamps < _totalStamps)
if (string.IsNullOrWhiteSpace(_phone)) return;
_isSearching = true;
_searchError = null;
_stampMessage = null;
_customer = null;
_progress = null;
_hasSearched = true;
try
{
_customer.Stamps++;
_customer.Points += 10;
// EN: Search using membership API / VI: Tim kiem su dung API thanh vien
var members = await DataService.SearchCustomersAsync(ShopId, _phone.Trim());
if (members.Any())
{
_customer = members.First();
// EN: Load member progress for level info / VI: Tai tien trinh thanh vien cho thong tin hang
try
{
_progress = await DataService.GetMemberProgressAsync(_customer.Id);
}
catch
{
// EN: Progress is optional / VI: Tien trinh la tuy chon
}
// EN: Calculate stamps from total exp (1 stamp per 10 exp points)
// VI: Tinh tem tu tong diem (1 tem moi 10 diem kinh nghiem)
_stampCount = (_customer.TotalExpEarned / 10) % _totalStamps;
}
}
else
catch
{
_customer.Stamps = 0; // EN: Reset after reward / VI: Reset sau khi nhận thưởng
_searchError = "Khong the tim kiem. Vui long thu lai.";
}
finally
{
_isSearching = false;
}
}
private class CustomerInfo(string name, string phone, int points, int visits, string rank, int stamps)
private async Task AddStamp()
{
public string Name { get; set; } = name;
public string Phone { get; set; } = phone;
public int Points { get; set; } = points;
public int Visits { get; set; } = visits;
public string Rank { get; set; } = rank;
public int Stamps { get; set; } = stamps;
if (_customer == null) return;
_isAddingStamp = true;
_stampMessage = null;
try
{
// EN: Add experience points via API (10 points = 1 stamp)
// VI: Them diem kinh nghiem qua API (10 diem = 1 tem)
var result = await DataService.AddExperienceAsync(_customer.Id, new PosDataService.AddExpRequest(
Points: 10,
SourceId: 1, // EN: SourceId 1 = POS purchase / VI: SourceId 1 = Mua hang POS
ReferenceId: $"stamp-{DateTime.UtcNow:yyyyMMddHHmmss}"
));
if (result != null)
{
_stampCount = (result.TotalExpEarned / 10) % _totalStamps;
_customer = _customer with { CurrentExp = result.CurrentExp, TotalExpEarned = result.TotalExpEarned, CurrentLevel = result.CurrentLevel };
if (result.LeveledUp)
{
_stampMessage = $"Chuc mung! Khach hang da len hang moi (Level {result.CurrentLevel})!";
}
else if (_stampCount == 0)
{
_stampMessage = "Chuc mung! Da tich du tem — 1 ly mien phi!";
}
else
{
_stampMessage = "Da tich 1 tem thanh cong!";
}
}
else
{
_stampMessage = "Khong the tich tem. Vui long thu lai.";
}
}
catch
{
_stampMessage = "Loi khi tich tem. Vui long thu lai.";
}
finally
{
_isAddingStamp = false;
}
}
}

View File

@@ -5,7 +5,8 @@
@page "/pos/{ShopId:guid}/cafe/menu-management"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@using WebClientTpos.Client.Services
@inject PosDataService DataService
<div style="display:flex;flex-direction:column;height:100%;padding:20px;gap:16px;">
@if (_isLoading)
@@ -28,7 +29,7 @@
<h2 style="font-size:20px;font-weight:700;margin:0;">Quản lý menu</h2>
<span style="font-size:13px;color:var(--pos-text-tertiary);">Cập nhật tình trạng món — @_items.Count(i => i.Available) / @_items.Count có sẵn</span>
</div>
<div style="display:flex;gap:8px;">
<div style="display:flex;gap:8px;align-items:center;">
@foreach (var cat in _filterCategories)
{
<button class="pos-category-tab @(_filterCategory == cat ? "pos-category-tab--active" : "")"
@@ -36,8 +37,28 @@
@cat
</button>
}
@* EN: Save Changes button / VI: Nút Lưu thay đổi *@
<button style="padding:8px 16px;border-radius:var(--pos-radius);border:none;background:var(--pos-orange-primary);color:#fff;font-size:13px;font-weight:600;cursor:pointer;margin-left:8px;"
disabled="@(!HasChanges || _isSaving)"
@onclick="SaveChangesAsync">
@if (_isSaving)
{
<span>Đang lưu...</span>
}
else
{
<i data-lucide="save" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i>
<span>Lưu thay đổi</span>
}
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(_saveMessage))
{
<div style="font-size:12px;padding:8px 12px;border-radius:8px;margin-top:8px;background:@(_saveIsError ? "rgba(239,68,68,0.1)" : "rgba(34,197,94,0.1)");color:@(_saveIsError ? "#EF4444" : "#16A34A");">
@_saveMessage
</div>
}
@* EN: Menu items table / VI: Bảng danh sách món *@
<div style="flex:1;overflow-y:auto;">
@@ -80,7 +101,7 @@
@if (item.IsEditingPrice)
{
<input type="number" value="@item.CurrentPrice" style="width:100px;padding:6px 8px;border-radius:6px;border:1px solid var(--pos-orange-primary);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:13px;text-align:right;outline:none;"
@onchange="@((ChangeEventArgs e) => { item.CurrentPrice = decimal.Parse(e.Value?.ToString() ?? "0"); item.IsEditingPrice = false; })" />
@onchange="@((ChangeEventArgs e) => { item.CurrentPrice = decimal.Parse(e.Value?.ToString() ?? "0"); item.IsEditingPrice = false; item.IsDirty = true; })" />
}
else
{
@@ -99,7 +120,7 @@
<div style="text-align:center;">
<button style="width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;position:relative;transition:background 0.2s;
background:@(item.Available ? "var(--pos-success)" : "var(--pos-bg-interactive)");"
@onclick="() => item.Available = !item.Available">
@onclick="() => ToggleAvailability(item)">
<span style="position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:#fff;transition:left 0.2s;
left:@(item.Available ? "22px" : "2px");"></span>
</button>
@@ -117,12 +138,20 @@
private bool _isLoading = true;
private bool _loadError;
// EN: Save state / VI: Trạng thái lưu
private bool _isSaving;
private string? _saveMessage;
private bool _saveIsError;
private string _filterCategory = "Tất cả";
private string[] _filterCategories = { "Tất cả" };
private IEnumerable<MenuItem> FilteredItems =>
_filterCategory == "Tất cả" ? _items : _items.Where(i => i.Category == _filterCategory);
// EN: Check if any item has unsaved changes / VI: Kiểm tra có item nào chưa lưu không
private bool HasChanges => _items.Any(i => i.IsDirty);
// EN: Menu items loaded from DB / VI: Danh sách menu tải từ DB
private List<MenuItem> _items = new();
@@ -140,9 +169,11 @@
var apiCategories = await categoriesTask;
_items = apiProducts.Select(p => new MenuItem(
p.Id,
p.Name,
p.Category ?? "Khác",
p.Price
p.Price,
p.CategoryId
)).ToList();
var catNames = apiCategories.Select(c => c.Name).ToList();
@@ -164,13 +195,89 @@
}
}
private class MenuItem(string name, string category, decimal price)
// EN: Toggle availability and mark item as dirty / VI: Bật/tắt khả dụng và đánh dấu item đã thay đổi
private void ToggleAvailability(MenuItem item)
{
item.Available = !item.Available;
item.IsDirty = true;
}
// EN: Save all changed items to API / VI: Lưu tất cả item đã thay đổi lên API
private async Task SaveChangesAsync()
{
if (_isSaving || !HasChanges) return;
_isSaving = true;
_saveMessage = null;
StateHasChanged();
var dirtyItems = _items.Where(i => i.IsDirty).ToList();
int successCount = 0;
int failCount = 0;
foreach (var item in dirtyItems)
{
try
{
// EN: Update product via admin API — send price and availability
// VI: Cập nhật sản phẩm qua admin API — gửi giá và trạng thái khả dụng
var req = new PosDataService.CreateProductRequest(
ShopId,
item.Name,
null, // description
item.CurrentPrice,
null, // type
null, // sku
null, // imageUrl
item.CategoryId
);
var success = await DataService.UpdateProductAsync(item.ProductId, req);
if (success)
{
item.OriginalPrice = item.CurrentPrice;
item.IsDirty = false;
successCount++;
}
else
{
failCount++;
}
}
catch
{
failCount++;
}
}
if (failCount == 0)
{
_saveMessage = $"Đã lưu thành công {successCount} món.";
_saveIsError = false;
}
else
{
_saveMessage = $"Lưu {successCount} thành công, {failCount} thất bại.";
_saveIsError = true;
}
_isSaving = false;
StateHasChanged();
// EN: Auto-hide message after 3 seconds / VI: Tự động ẩn thông báo sau 3 giây
await Task.Delay(3000);
_saveMessage = null;
StateHasChanged();
}
private class MenuItem(Guid productId, string name, string category, decimal price, Guid? categoryId = null)
{
public Guid ProductId { get; set; } = productId;
public string Name { get; set; } = name;
public string Category { get; set; } = category;
public decimal OriginalPrice { get; set; } = price;
public decimal CurrentPrice { get; set; } = price;
public bool Available { get; set; } = true;
public bool IsEditingPrice { get; set; }
public bool IsDirty { get; set; }
public Guid? CategoryId { get; set; } = categoryId;
}
}

View File

@@ -1,16 +1,30 @@
@*
EN: Order Customize — Drink customization: size, sugar, ice, toppings.
VI: Tùy chỉnh đơn hàng — Tùy chỉnh đồ uống: size, đường, đá, topping.
Loads real product info from API, persists customizations via query string.
VI: Tuy chinh don hang — Tuy chinh do uong: size, duong, da, topping.
Tai thong tin san pham tu API, luu tuy chinh qua query string.
*@
@page "/pos/{ShopId:guid}/cafe/order-customize/{ProductId:guid}"
@page "/pos/{ShopId:guid}/cafe/order-customize"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@inject IJSRuntime JS
<div style="display:flex;height:100%;">
@* EN: Customization panel / VI: Panel tùy chnh *@
@* EN: Customization panel / VI: Panel tuy chinh *@
<div style="flex:1;padding:24px;overflow-y:auto;">
<div style="max-width:560px;margin:0 auto;">
@* EN: Product header / VI: Tiêu đề sản phẩm *@
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:200px;color:var(--pos-text-tertiary);">
Dang tai san pham...
</div>
}
else
{
@* EN: Product header / VI: Tieu de san pham *@
<div style="display:flex;align-items:center;gap:16px;margin-bottom:28px;">
<div style="width:72px;height:72px;border-radius:var(--pos-radius);background:var(--pos-bg-elevated);display:flex;align-items:center;justify-content:center;">
<i data-lucide="coffee" style="width:32px;height:32px;color:var(--pos-orange-primary);"></i>
@@ -21,9 +35,9 @@
</div>
</div>
@* EN: Size selection / VI: Chn size *@
@* EN: Size selection / VI: Chon size *@
<div style="margin-bottom:24px;">
<div style="font-size:14px;font-weight:600;margin-bottom:12px;color:var(--pos-text-secondary);">Kích c</div>
<div style="font-size:14px;font-weight:600;margin-bottom:12px;color:var(--pos-text-secondary);">Kich co</div>
<div style="display:flex;gap:10px;">
@foreach (var size in _sizes)
{
@@ -41,9 +55,9 @@
</div>
</div>
@* EN: Sugar level / VI: Mc đường *@
@* EN: Sugar level / VI: Muc duong *@
<div style="margin-bottom:24px;">
<div style="font-size:14px;font-weight:600;margin-bottom:12px;color:var(--pos-text-secondary);">Mc đường</div>
<div style="font-size:14px;font-weight:600;margin-bottom:12px;color:var(--pos-text-secondary);">Muc duong</div>
<div style="display:flex;gap:8px;">
@foreach (var sugar in _sugarLevels)
{
@@ -56,9 +70,9 @@
</div>
</div>
@* EN: Ice level / VI: Mc đá *@
@* EN: Ice level / VI: Muc da *@
<div style="margin-bottom:24px;">
<div style="font-size:14px;font-weight:600;margin-bottom:12px;color:var(--pos-text-secondary);">Mc đá</div>
<div style="font-size:14px;font-weight:600;margin-bottom:12px;color:var(--pos-text-secondary);">Muc da</div>
<div style="display:flex;gap:8px;">
@foreach (var ice in _iceLevels)
{
@@ -87,59 +101,110 @@
}
</div>
</div>
}
</div>
</div>
@* EN: Confirm panel / VI: Panel xác nhn *@
@* EN: Confirm panel / VI: Panel xac nhan *@
<div style="width:300px;background:var(--pos-bg-elevated);border-left:1px solid var(--pos-border-subtle);padding:24px;display:flex;flex-direction:column;">
<div style="font-size:15px;font-weight:600;margin-bottom:16px;">Tóm tt</div>
<div style="font-size:15px;font-weight:600;margin-bottom:16px;">Tom tat</div>
<div style="flex:1;font-size:13px;color:var(--pos-text-secondary);display:flex;flex-direction:column;gap:8px;">
<div>Size: <strong style="color:var(--pos-text-primary);">@_selectedSize</strong></div>
<div>Đường: <strong style="color:var(--pos-text-primary);">@_selectedSugar</strong></div>
<div>Đá: <strong style="color:var(--pos-text-primary);">@_selectedIce</strong></div>
<div>Topping: <strong style="color:var(--pos-text-primary);">@(_selectedToppings.Any() ? string.Join(", ", _selectedToppings) : "Không")</strong></div>
<div>Duong: <strong style="color:var(--pos-text-primary);">@_selectedSugar</strong></div>
<div>Da: <strong style="color:var(--pos-text-primary);">@_selectedIce</strong></div>
<div>Topping: <strong style="color:var(--pos-text-primary);">@(_selectedToppings.Any() ? string.Join(", ", _selectedToppings) : "Khong")</strong></div>
</div>
<div style="border-top:1px solid var(--pos-border-subtle);padding-top:16px;margin-top:16px;">
<div class="pos-cart-total" style="margin-bottom:16px;">
<span class="pos-cart-total__label">Tng</span>
<span class="pos-cart-total__label">Tong</span>
<span class="pos-cart-total__value" style="font-size:20px;">@FormatPrice(_basePrice + _extraPrice)</span>
</div>
<button class="pos-btn-checkout" @onclick="Confirm">Xác nhn</button>
<button class="pos-btn-checkout" @onclick="Confirm">Xac nhan</button>
</div>
</div>
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
// EN: Route parameter for product / VI: Tham so route cho san pham
[Parameter] public Guid ProductId { get; set; }
private string _productName = "Cà phê sữa đá";
private decimal _basePrice = 35_000;
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private string _productName = "San pham";
private Guid _resolvedProductId = Guid.Empty;
private decimal _basePrice = 0;
private decimal _extraPrice = 0;
private string _selectedSize = "M";
private string _selectedSugar = "100%";
private string _selectedIce = "Đầy đá";
private string _selectedIce = "Day da";
private readonly HashSet<string> _selectedToppings = new();
private readonly List<SizeOption> _sizes = new()
{
new("S", "Nh", 0),
new("M", "Va", 0),
new("L", "Ln", 10_000),
new("S", "Nho", 0),
new("M", "Vua", 0),
new("L", "Lon", 10_000),
};
private readonly string[] _sugarLevels = { "100%", "70%", "50%", "0%" };
private readonly string[] _iceLevels = { "Đầy đá", "Ít đá", "Không đá" };
private readonly string[] _iceLevels = { "Day da", "It da", "Khong da" };
private readonly List<ToppingOption> _toppings = new()
{
new("Trân châu đen", 10_000),
new("Trân châu trng", 10_000),
new("Thch cà phê", 10_000),
new("Tran chau den", 10_000),
new("Tran chau trang", 10_000),
new("Thach ca phe", 10_000),
new("Kem cheese", 15_000),
new("Shot espresso", 15_000),
new("Sa da", 10_000),
new("Sua dua", 10_000),
};
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadProductAsync();
}
private async Task LoadProductAsync()
{
_isLoading = true;
try
{
if (ProductId != Guid.Empty)
{
_resolvedProductId = ProductId;
// EN: Load all products and find the matching one
// VI: Tai tat ca san pham va tim san pham khop
var products = await DataService.GetProductsAsync(ShopId);
var product = products.FirstOrDefault(p => p.Id == ProductId);
if (product != null)
{
_productName = product.Name;
_basePrice = product.Price;
}
}
else
{
// EN: Fallback — no product specified
// VI: Du phong — khong co san pham duoc chi dinh
_productName = "Ca phe sua da";
_basePrice = 35_000;
}
}
catch
{
// EN: Use default on error / VI: Dung mac dinh khi loi
_productName = "Ca phe sua da";
_basePrice = 35_000;
}
finally
{
_isLoading = false;
}
}
private void SelectSize(SizeOption size)
{
_selectedSize = size.Label;
@@ -160,7 +225,34 @@
_extraPrice = sizeExtra + toppingExtra;
}
private void Confirm() => NavigateTo("cafe");
private async Task Confirm()
{
// EN: Build customization record and persist to localStorage
// VI: Tao ban ghi tuy chinh va luu vao localStorage
var customization = new DrinkCustomization(
_resolvedProductId != Guid.Empty ? _resolvedProductId : ProductId,
_productName,
_basePrice,
_selectedSize,
_selectedSugar,
_selectedIce,
_selectedToppings.ToList(),
_basePrice + _extraPrice
);
// EN: Store in localStorage so the parent page can read it
// VI: Luu vao localStorage de trang cha co the doc
var json = System.Text.Json.JsonSerializer.Serialize(customization);
await JS.InvokeVoidAsync("localStorage.setItem", "pos_drink_customization", json);
// EN: Navigate back to cafe with query string indicating customization is ready
// VI: Quay ve trang cafe voi query string cho biet tuy chinh da san sang
NavigationManager.NavigateTo($"/pos/{ShopId}/cafe?customized=true&productId={customization.ProductId}");
}
// EN: Customization data record / VI: Ban ghi du lieu tuy chinh
private record DrinkCustomization(Guid ProductId, string ProductName, decimal BasePrice,
string Size, string Sugar, string Ice, List<string> Toppings, decimal TotalPrice);
private record SizeOption(string Label, string Desc, decimal Extra);
private record ToppingOption(string Name, decimal Price);

View File

@@ -1,62 +1,70 @@
@*
EN: Queue Display — Public-facing queue board: preparing list, ready list, large order numbers.
VI: Bảng hiển thị hàng đợi — Bảng công khai: danh sách đang pha, sẵn sàng, số đơn lớn.
Connected to kitchen tickets API with auto-refresh.
VI: Bang hien thi hang doi — Bang cong khai: danh sach dang pha, san sang, so don lon.
Ket noi API kitchen tickets voi tu dong lam moi.
*@
@page "/pos/{ShopId:guid}/cafe/queue-display"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@implements IDisposable
<div style="display:flex;height:100%;gap:2px;padding:16px;">
@* EN: Preparing column / VI: Ct đang pha *@
@* EN: Preparing column / VI: Cot dang pha *@
<div style="flex:1;display:flex;flex-direction:column;gap:16px;">
<div style="text-align:center;padding:16px;background:var(--pos-bg-elevated);border-radius:var(--pos-radius);">
<div style="font-size:14px;color:var(--pos-warning);font-weight:600;text-transform:uppercase;letter-spacing:1px;">
☕ Đang pha chế
<i data-lucide="coffee" style="width:16px;height:16px;display:inline;"></i> Dang pha che
</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:4px;">Preparing</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:4px;">Preparing (@_preparingGroups.Count)</div>
</div>
<div style="flex:1;display:grid;grid-template-columns:repeat(2, 1fr);gap:12px;align-content:start;">
@foreach (var order in _preparingOrders)
@{ int prepIdx = 0; }
@foreach (var group in _preparingGroups)
{
prepIdx++;
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;text-align:center;border:1px solid rgba(245,158,11,0.3);">
<div style="font-size:42px;font-weight:800;color:var(--pos-warning);line-height:1;">@order.Number</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:8px;">@order.Items</div>
<div style="font-size:42px;font-weight:800;color:var(--pos-warning);line-height:1;">#@prepIdx</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:8px;">@string.Join(", ", group.Items)</div>
<div style="display:flex;align-items:center;justify-content:center;gap:4px;margin-top:8px;">
<span style="width:6px;height:6px;border-radius:50%;background:var(--pos-warning);animation:pulse 1.5s infinite;"></span>
<span style="font-size:11px;color:var(--pos-warning);">@order.Time</span>
<span style="font-size:11px;color:var(--pos-warning);">@GetElapsed(group.CreatedAt)</span>
</div>
</div>
}
</div>
</div>
@* EN: Divider / VI: Đường phân cách *@
@* EN: Divider / VI: Duong phan cach *@
<div style="width:2px;background:var(--pos-border-subtle);margin:0 8px;"></div>
@* EN: Ready column / VI: Ct sn sàng *@
@* EN: Ready column / VI: Cot san sang *@
<div style="flex:1;display:flex;flex-direction:column;gap:16px;">
<div style="text-align:center;padding:16px;background:var(--pos-bg-elevated);border-radius:var(--pos-radius);">
<div style="font-size:14px;color:var(--pos-success);font-weight:600;text-transform:uppercase;letter-spacing:1px;">
Sn sàng
<i data-lucide="check-circle" style="width:16px;height:16px;display:inline;"></i> San sang
</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:4px;">Ready for pickup</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:4px;">Ready for pickup (@_readyGroups.Count)</div>
</div>
<div style="flex:1;display:grid;grid-template-columns:repeat(2, 1fr);gap:12px;align-content:start;">
@foreach (var order in _readyOrders)
@{ int readyIdx = 0; }
@foreach (var group in _readyGroups)
{
readyIdx++;
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;text-align:center;border:1px solid rgba(34,197,94,0.3);animation:readyGlow 2s ease-in-out infinite alternate;">
<div style="font-size:42px;font-weight:800;color:var(--pos-success);line-height:1;">@order.Number</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:8px;">@order.Customer</div>
<div style="font-size:11px;color:var(--pos-success);margin-top:6px;">Mi nhn đồ</div>
<div style="font-size:42px;font-weight:800;color:var(--pos-success);line-height:1;">#@readyIdx</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:8px;">@string.Join(", ", group.Items)</div>
<div style="font-size:11px;color:var(--pos-success);margin-top:6px;">Moi nhan do</div>
</div>
}
</div>
</div>
</div>
@* EN: Pulse animation / VI: Hiu ng nhp nháy *@
@* EN: Pulse animation / VI: Hieu ung nhap nhay *@
<style>
@@keyframes pulse {
0%, 100% { opacity: 1; }
@@ -69,27 +77,53 @@
</style>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
private List<TicketGroup> _preparingGroups = new();
private List<TicketGroup> _readyGroups = new();
private System.Threading.Timer? _refreshTimer;
// EN: Preparing orders / VI: Đơn đang pha
private readonly List<QueueOrder> _preparingOrders = new()
protected override async Task OnInitializedAsync()
{
new("101", "Cà phê sữa đá x2", "3 phút"),
new("103", "Trà đào x1, Sinh tố bơ x1", "5 phút"),
new("105", "Cappuccino x3", "2 phút"),
new("107", "Latte x1, Bạc xỉu x1", "1 phút"),
};
await base.OnInitializedAsync();
await LoadTickets();
// EN: Ready orders / VI: Đơn sẵn sàng
private readonly List<ReadyOrder> _readyOrders = new()
_refreshTimer = new System.Threading.Timer(async _ =>
{
await LoadTickets();
await InvokeAsync(StateHasChanged);
}, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
private async Task LoadTickets()
{
new("098", "Anh Minh"),
new("099", "Chị Lan"),
new("100", "Anh Khoa"),
new("102", "Chị Hương"),
new("104", "Anh Tùng"),
};
try
{
var tickets = await DataService.GetKitchenTicketsAsync(ShopId);
_preparingGroups = tickets
.Where(t => t.Status?.ToLower() is "pending" or "preparing" or "in-progress")
.GroupBy(t => t.SessionId)
.Select(g => new TicketGroup(g.Select(t => t.ItemName ?? "Mon").ToList(), g.Min(t => t.CreatedAt)))
.OrderBy(g => g.CreatedAt)
.ToList();
private record QueueOrder(string Number, string Items, string Time);
private record ReadyOrder(string Number, string Customer);
_readyGroups = tickets
.Where(t => t.Status?.ToLower() is "ready" or "completed")
.GroupBy(t => t.SessionId)
.Select(g => new TicketGroup(g.Select(t => t.ItemName ?? "Mon").ToList(), g.Min(t => t.CreatedAt)))
.OrderByDescending(g => g.CreatedAt)
.Take(10)
.ToList();
}
catch { /* silent for display screen */ }
}
private static string GetElapsed(DateTime createdAt)
{
var elapsed = DateTime.Now - createdAt;
if (elapsed.TotalMinutes < 1) return "vua xong";
return $"{(int)elapsed.TotalMinutes} phut";
}
public void Dispose() => _refreshTimer?.Dispose();
private record TicketGroup(List<string> Items, DateTime CreatedAt);
}

View File

@@ -62,7 +62,7 @@
</div>
</div>
@* EN: Status + timer / VI: Trng thái + gi *@
@* EN: Status + timer + F&B total / VI: Trang thai + gio + tong F&B *@
<div style="text-align:right;flex-shrink:0;">
<div style="font-size:12px;font-weight:600;color:@GetStatusFg(room.Status);">
@GetStatusLabel(room.Status)
@@ -73,6 +73,12 @@
@((DateTime.Now - room.SessionStart.Value).ToString(@"h\:mm"))
</div>
}
@if (_roomFnbTotals.TryGetValue(room.Id, out var fnbTotal) && fnbTotal > 0)
{
<div style="font-size:11px;color:var(--pos-orange-primary);font-weight:600;margin-top:2px;">
F&B: @FormatPrice(fnbTotal)
</div>
}
</div>
<i data-lucide="chevron-right" style="width:16px;height:16px;color:var(--pos-text-tertiary);flex-shrink:0;"></i>
@@ -107,9 +113,12 @@
private string _activeZone = "Tất cả";
private string[] _zones = { "Tất cả" };
// EN: Room data loaded from DB / VI: D liu phòng ti t DB
// EN: Room data loaded from DB / VI: Du lieu phong tai tu DB
private List<RoomInfo> _rooms = new();
// EN: F&B totals per room / VI: Tong F&B theo phong
private Dictionary<string, decimal> _roomFnbTotals = new();
private IEnumerable<RoomInfo> FilteredRooms =>
_activeZone == "Tất cả" ? _rooms : _rooms.Where(r => r.Zone == _activeZone);
@@ -128,11 +137,24 @@
t.Zone ?? "Standard",
t.Status,
t.Zone ?? "Tầng 1",
t.StartedAt
t.StartedAt,
t.HourlyRate
)).ToList();
var zoneNames = _rooms.Select(r => r.Zone).Distinct().ToList();
_zones = new[] { "Tất cả" }.Concat(zoneNames).ToArray();
// EN: Load active orders to show F&B totals per room
// VI: Tai don hang de hien thi tong F&B theo phong
try
{
var orders = await DataService.GetActiveTableOrdersAsync(ShopId);
_roomFnbTotals = orders
.Where(o => o.TableId.HasValue)
.GroupBy(o => o.TableId!.Value.ToString())
.ToDictionary(g => g.Key, g => g.Sum(o => o.TotalAmount));
}
catch { /* EN: Non-critical, continue without F&B totals / VI: Khong quan trong, tiep tuc khong co tong F&B */ }
}
catch
{
@@ -144,7 +166,17 @@
}
}
private void OpenRoom(RoomInfo room) => NavigateTo("karaoke/room-session");
// EN: Navigate based on room status — occupied rooms go to session, available rooms go to selection
// VI: Dieu huong theo trang thai — phong dang hat di den phien, phong trong di den chon phong
private void OpenRoom(RoomInfo room)
{
if (room.Status == "occupied")
NavigateTo($"karaoke/room-session/{room.Id}");
else if (room.Status == "available")
NavigateTo("karaoke/room-select");
else
NavigateTo($"karaoke/room-session/{room.Id}");
}
private static string GetStatusBg(string s) => s switch
{
@@ -171,5 +203,5 @@
"reserved" => "Đã đặt", "cleaning" => "Đang dọn", _ => s
};
private record RoomInfo(string Id, string Name, int Capacity, string Type, string Status, string Zone, DateTime? SessionStart);
private record RoomInfo(string Id, string Name, int Capacity, string Type, string Status, string Zone, DateTime? SessionStart, decimal HourlyRate = 0);
}

View File

@@ -97,16 +97,28 @@
</button>
</div>
@* EN: Current F&B / VI: F&B hiện tại *@
@* EN: F&B orders from DB / VI: Đơn F&B từ DB *@
<div style="font-size:11px;color:var(--pos-text-tertiary);font-weight:600;padding:8px 0;">ĐƠN F&B</div>
@foreach (var item in _demoFnb)
@if (GetRoomOrders(_selectedRoom!.Id).Any())
{
<div class="pos-cart-item">
<div class="pos-cart-item__info">
<span class="pos-cart-item__name">@item.Name</span>
<span class="pos-cart-item__price">@FormatPrice(item.Price)</span>
</div>
<span style="font-size:13px;font-weight:600;">x@item.Qty</span>
@foreach (var order in GetRoomOrders(_selectedRoom!.Id))
{
@foreach (var item in order.Items)
{
<div class="pos-cart-item">
<div class="pos-cart-item__info">
<span class="pos-cart-item__name">@item.ProductName</span>
<span class="pos-cart-item__price">@FormatPrice(item.UnitPrice)</span>
</div>
<span style="font-size:13px;font-weight:600;">x@item.Quantity</span>
</div>
}
}
}
else
{
<div style="text-align:center;padding:12px;color:var(--pos-text-tertiary);font-size:12px;">
Chưa có đơn F&B
</div>
}
}
@@ -119,9 +131,26 @@
</div>
<div class="pos-cart-footer">
@{
var fnbTotal = GetRoomTotal(_selectedRoom!.Id);
var roomCost = 0m;
if (_selectedRoom.HourlyRate > 0 && _selectedRoom.SessionStart.HasValue)
{
var elapsed = DateTime.Now - _selectedRoom.SessionStart.Value;
roomCost = Math.Ceiling((decimal)elapsed.TotalMinutes / 60m) * _selectedRoom.HourlyRate;
}
}
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:4px;">
<span style="color:var(--pos-text-secondary);">Tiền phòng</span>
<span>@FormatPrice(roomCost)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:8px;">
<span style="color:var(--pos-text-secondary);">Tiền F&B</span>
<span>@FormatPrice(fnbTotal)</span>
</div>
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(_selectedRoom.Status == "occupied" ? 520_000 : 0)</span>
<span class="pos-cart-total__value">@FormatPrice(_selectedRoom.Status == "occupied" ? roomCost + fnbTotal : 0)</span>
</div>
<button class="pos-btn-checkout" style="height:52px;font-size:16px;"
@onclick="@(() => NavigateTo("karaoke/room-session"))">
@@ -152,12 +181,8 @@
// EN: Room data loaded from DB / VI: Dữ liệu phòng tải từ DB
private List<RoomInfo> _rooms = new();
// EN: Demo F&B / VI: F&B mẫu
private readonly List<FnbItem> _demoFnb = new()
{
new("Bia Heineken", 40_000, 4), new("Trái cây dĩa", 120_000, 1),
new("Đậu phộng", 30_000, 2),
};
// EN: Active F&B orders loaded from DB / VI: Đơn F&B đang hoạt động tải từ DB
private List<WebClientTpos.Client.Services.PosDataService.ActiveTableOrderDto> _activeOrders = new();
private IEnumerable<RoomInfo> FilteredRooms =>
_activeZone == "Tất cả" ? _rooms : _rooms.Where(r => r.Zone == _activeZone);
@@ -168,7 +193,13 @@
try
{
var tables = await DataService.GetTablesAsync(ShopId);
// EN: Load tables and active orders in parallel / VI: Tải phòng và đơn hàng song song
var tablesTask = DataService.GetTablesAsync(ShopId);
var ordersTask = DataService.GetActiveTableOrdersAsync(ShopId);
await Task.WhenAll(tablesTask, ordersTask);
var tables = await tablesTask;
_activeOrders = await ordersTask;
_rooms = tables.Select(t => new RoomInfo(
t.Id.ToString(),
@@ -177,7 +208,8 @@
t.Zone ?? "Standard",
t.Status,
t.Zone ?? "Tầng 1",
t.StartedAt
t.StartedAt,
t.HourlyRate
)).ToList();
var zoneNames = _rooms.Select(r => r.Zone).Distinct().ToList();
@@ -206,6 +238,12 @@
"reserved" => "Đã đặt", "cleaning" => "Đang dọn", _ => s
};
private record RoomInfo(string Id, string Name, int Capacity, string Type, string Status, string Zone, DateTime? SessionStart);
private record FnbItem(string Name, decimal Price, int Qty);
// EN: Helper to get orders for a specific room / VI: Helper lấy đơn hàng cho phòng cụ thể
private List<WebClientTpos.Client.Services.PosDataService.ActiveTableOrderDto> GetRoomOrders(string roomId)
=> _activeOrders.Where(o => o.TableId.ToString() == roomId).ToList();
private decimal GetRoomTotal(string roomId)
=> _activeOrders.Where(o => o.TableId.ToString() == roomId).Sum(o => o.TotalAmount);
private record RoomInfo(string Id, string Name, int Capacity, string Type, string Status, string Zone, DateTime? SessionStart, decimal HourlyRate = 0);
}

View File

@@ -5,6 +5,7 @@
@page "/pos/{ShopId:guid}/karaoke/happy-hour"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@@ -17,6 +18,14 @@
<span style="font-size:16px;font-weight:700;">Happy Hour & Khuyến mãi</span>
</div>
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--pos-text-tertiary);">
Đang tải...
</div>
}
else
{
<div style="flex:1;overflow-y:auto;padding:16px;">
@* ═══ ACTIVE HAPPY HOUR / HAPPY HOUR ĐANG ÁP DỤNG ═══ *@
<div style="background:linear-gradient(135deg,rgba(255,92,0,.2),rgba(245,158,11,.15));
@@ -97,25 +106,19 @@
</div>
</div>
</div>
}
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
// EN: Loading state / VI: Trạng thái tải
private bool _isLoading = true;
// EN: Time-based rates / VI: Giá theo khung giờ
private readonly List<TimeRate> _timeRates = new()
{
new("08:00 12:00", true, 30, new() {
new("Standard", 50_000), new("VIP", 100_000), new("Deluxe", 140_000) }),
new("12:00 17:00", false, 20, new() {
new("Standard", 60_000), new("VIP", 120_000), new("Deluxe", 160_000) }),
new("17:00 22:00", false, 0, new() {
new("Standard", 80_000), new("VIP", 150_000), new("Deluxe", 200_000) }),
new("22:00 02:00", false, 10, new() {
new("Standard", 70_000), new("VIP", 130_000), new("Deluxe", 180_000) }),
};
// EN: Time-based rates — pricing dynamically built from real room rates
// VI: Giá theo khung giờ — giá được xây dựng động từ giá phòng thực
private List<TimeRate> _timeRates = new();
// EN: Active promotions / VI: Khuyến mãi đang áp dụng
// EN: Active promotions — kept static until promotions API is available
// VI: Khuyến mãi đang áp dụng — giữ tĩnh cho đến khi có promotions API
private readonly List<Promotion> _promotions = new()
{
new("Sinh nhật vui vẻ", "Giảm 50% phòng cho khách sinh nhật", "-50%",
@@ -128,6 +131,59 @@
"Đến 15/03", "users", "rgba(34,197,94,.15)", "#22C55E"),
};
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
var tables = await DataService.GetTablesAsync(ShopId);
// EN: Group rooms by zone/type and calculate average hourly rate per type
// VI: Nhóm phòng theo khu vực/loại và tính giá trung bình mỗi loại
var roomTypes = tables
.GroupBy(t => t.Zone ?? "Standard")
.Select(g => new RoomPrice(g.Key, g.Average(t => t.HourlyRate)))
.Where(rp => rp.Amount > 0)
.OrderBy(rp => rp.Amount)
.ToList();
// EN: If no rooms have hourly rates, use a reasonable fallback
// VI: Nếu không có phòng nào có giá theo giờ, dùng giá mặc định
if (!roomTypes.Any())
{
roomTypes = tables
.GroupBy(t => t.Zone ?? "Standard")
.Select(g => new RoomPrice(g.Key, 0))
.ToList();
}
// EN: Build time slots with dynamic pricing from real room rates
// VI: Tạo khung giờ với giá động từ giá phòng thực
var now = DateTime.Now.Hour;
_timeRates = new List<TimeRate>
{
new("08:00 12:00", now >= 8 && now < 12, 30,
roomTypes.Select(rp => new RoomPrice(rp.Type, Math.Round(rp.Amount * 0.7m, -3))).ToList()),
new("12:00 17:00", now >= 12 && now < 17, 20,
roomTypes.Select(rp => new RoomPrice(rp.Type, Math.Round(rp.Amount * 0.8m, -3))).ToList()),
new("17:00 22:00", now >= 17 && now < 22, 0,
roomTypes.Select(rp => new RoomPrice(rp.Type, rp.Amount)).ToList()),
new("22:00 02:00", now >= 22 || now < 2, 10,
roomTypes.Select(rp => new RoomPrice(rp.Type, Math.Round(rp.Amount * 0.9m, -3))).ToList()),
};
}
catch
{
// EN: Fallback to empty rates on error / VI: Dùng giá rỗng khi lỗi
_timeRates = new();
}
finally
{
_isLoading = false;
}
}
// EN: Models / VI: Mô hình dữ liệu
private record RoomPrice(string Type, decimal Amount);
private record TimeRate(string TimeSlot, bool IsActive, int Discount, List<RoomPrice> Prices);

View File

@@ -1,24 +1,47 @@
@*
EN: Karaoke Journey — End-to-end session workflow tracker with 6 steps from reception to payment.
VI: Hành trình Karaoke — Theo dõi quy trình phiên từ đón khách đến thanh toán qua 6 bước.
Loads real room and order data, auto-detects current step based on room status.
VI: Hanh trinh Karaoke — Theo doi quy trinh phien tu don khach den thanh toan qua 6 buoc.
Tai du lieu phong va don hang that, tu dong phat hien buoc hien tai theo trang thai phong.
*@
@page "/pos/{ShopId:guid}/karaoke/karaoke-journey"
@page "/pos/{ShopId:guid}/karaoke/karaoke-journey/{RoomId:guid}"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@implements IDisposable
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@* === HEADER / TIEU DE === *@
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);flex-shrink:0;">
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
@onclick="@(() => NavigateTo("karaoke"))">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;">Hành trình Karaoke</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">Bước @(_activeStep + 1)/6</span>
<span style="font-size:16px;font-weight:700;">Hanh trinh Karaoke</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">Buoc @(_activeStep + 1)/6</span>
@if (_room != null)
{
<span style="font-size:12px;color:var(--pos-orange-primary);margin-left:auto;font-weight:600;">@_room.TableNumber</span>
}
</div>
@* ═══ STEP INDICATOR / CHỈ BÁO BƯỚC ═══ *@
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--pos-text-tertiary);">
Dang tai...
</div>
}
else if (_loadError)
{
<div style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--pos-text-tertiary);">
Khong the tai du lieu phong
</div>
}
else
{
@* === STEP INDICATOR / CHI BAO BUOC === *@
<div style="display:flex;align-items:center;padding:16px;gap:4px;flex-shrink:0;overflow-x:auto;">
@for (var i = 0; i < _steps.Length; i++)
{
@@ -44,23 +67,23 @@
}
</div>
@* ═══ STEP CONTENT / NI DUNG BƯỚC ═══ *@
@* === STEP CONTENT / NOI DUNG BUOC === *@
<div style="flex:1;overflow-y:auto;padding:16px;">
@switch (_activeStep)
{
@* EN: Step 1 Guest reception / VI: Bước 1 — Đón khách *@
@* EN: Step 1 -- Guest reception / VI: Buoc 1 -- Don khach *@
case 0:
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;margin-bottom:16px;">
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">
<i data-lucide="users" style="width:16px;height:16px;display:inline;"></i> Đón khách
<i data-lucide="users" style="width:16px;height:16px;display:inline;"></i> Don khach
</div>
<div style="display:flex;gap:16px;margin-bottom:16px;">
<div style="flex:1;">
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:6px;">S khách</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:6px;">So khach</div>
<div style="display:flex;align-items:center;gap:12px;">
<button style="width:36px;height:36px;border-radius:8px;border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-primary);cursor:pointer;font-size:16px;"
@onclick="() => _guestCount = Math.Max(1, _guestCount - 1)"></button>
@onclick="() => _guestCount = Math.Max(1, _guestCount - 1)">-</button>
<span style="font-size:24px;font-weight:700;min-width:30px;text-align:center;">@_guestCount</span>
<button style="width:36px;height:36px;border-radius:8px;border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-primary);cursor:pointer;font-size:16px;"
@@ -68,12 +91,12 @@
</div>
</div>
<div style="flex:1;">
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:6px;">Th thành viên</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:6px;">The thanh vien</div>
<div style="display:flex;gap:8px;">
<div style="flex:1;display:flex;align-items:center;gap:8px;background:var(--pos-bg-interactive);
border-radius:8px;padding:0 12px;border:1px solid var(--pos-border-default);">
<i data-lucide="search" style="width:14px;height:14px;color:var(--pos-text-tertiary);"></i>
<input type="text" placeholder="SĐT hoc mã th..." @bind="_memberSearch"
<input type="text" placeholder="SDT hoac ma the..." @bind="_memberSearch"
style="flex:1;background:transparent;border:none;color:var(--pos-text-primary);
font-size:13px;padding:10px 0;outline:none;" />
</div>
@@ -82,208 +105,354 @@
{
<div style="font-size:12px;color:#22C55E;margin-top:6px;">
<i data-lucide="check-circle" style="width:12px;height:12px;display:inline;"></i>
Nguyễn Văn Minh — Gold • 2,450 điểm
Tim kiem thanh vien...
</div>
}
</div>
</div>
@if (_room != null)
{
<div style="font-size:12px;color:var(--pos-text-tertiary);padding:8px 12px;background:var(--pos-bg-interactive);border-radius:8px;">
Phong da chon: <b>@_room.TableNumber</b> — Suc chua: @_room.Capacity nguoi
@if (_room.GuestCount.HasValue && _room.GuestCount > 0)
{
<span> — Hien tai: @_room.GuestCount khach</span>
}
</div>
}
</div>
break;
@* EN: Step 2 Room selection / VI: Bước 2 Chn phòng *@
@* EN: Step 2 -- Room selection / VI: Buoc 2 -- Chon phong *@
case 1:
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;margin-bottom:16px;">
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">
<i data-lucide="door-open" style="width:16px;height:16px;display:inline;"></i> Chn phòng
<i data-lucide="door-open" style="width:16px;height:16px;display:inline;"></i> Chon phong
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div style="text-align:center;padding:16px;background:var(--pos-bg-interactive);border-radius:var(--pos-radius);">
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">Phòng</div>
<div style="font-size:18px;font-weight:700;">VIP 2</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">Phong</div>
<div style="font-size:18px;font-weight:700;">@(_room?.TableNumber ?? "---")</div>
</div>
<div style="text-align:center;padding:16px;background:var(--pos-bg-interactive);border-radius:var(--pos-radius);">
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">Sc cha</div>
<div style="font-size:18px;font-weight:700;">20 người</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">Suc chua</div>
<div style="font-size:18px;font-weight:700;">@(_room?.Capacity ?? 0) nguoi</div>
</div>
<div style="text-align:center;padding:16px;background:var(--pos-bg-interactive);border-radius:var(--pos-radius);">
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">Loi</div>
<div style="font-size:18px;font-weight:700;">Deluxe</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">Loai</div>
<div style="font-size:18px;font-weight:700;">@(_room?.Zone ?? "Standard")</div>
</div>
</div>
<div style="font-size:13px;color:var(--pos-text-secondary);margin-top:12px;text-align:center;">
Tầng 3 • Khu Deluxe • @FormatPrice(200_000)/gi
@(_room?.Zone ?? "Khu") • @FormatPrice(_room?.HourlyRate ?? 0)/gio
</div>
</div>
break;
@* EN: Step 3 Open room / VI: Bước 3 M phòng *@
@* EN: Step 3 -- Open room / VI: Buoc 3 -- Mo phong *@
case 2:
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;margin-bottom:16px;">
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">
<i data-lucide="play" style="width:16px;height:16px;display:inline;"></i> M phòng
<i data-lucide="play" style="width:16px;height:16px;display:inline;"></i> Mo phong
</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--pos-text-secondary);">Gi bt đầu</span>
<span style="font-weight:600;">19:30</span>
<span style="color:var(--pos-text-secondary);">Gio bat dau</span>
<span style="font-weight:600;">@(_sessionStart?.ToString("HH:mm") ?? DateTime.Now.ToString("HH:mm"))</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--pos-text-secondary);">Thi lượng đặt</span>
<span style="font-weight:600;">2.5 gi</span>
<span style="color:var(--pos-text-secondary);">Thoi luong du kien</span>
<span style="font-weight:600;">@_estimatedHours gio</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--pos-text-secondary);">Giá/gi</span>
<span style="font-weight:600;">@FormatPrice(200_000)</span>
<span style="color:var(--pos-text-secondary);">Gia/gio</span>
<span style="font-weight:600;">@FormatPrice(_hourlyRate)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--pos-text-secondary);">Gi kết thúc d kiến</span>
<span style="font-weight:600;">22:00</span>
<span style="color:var(--pos-text-secondary);">Gio ket thuc du kien</span>
<span style="font-weight:600;">@((_sessionStart ?? DateTime.Now).AddHours(_estimatedHours).ToString("HH:mm"))</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:14px;font-weight:600;
padding-top:10px;border-top:1px solid var(--pos-border-subtle);">
<span>Tm tính phòng</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(500_000)</span>
<span>Tam tinh phong</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(_hourlyRate * _estimatedHours)</span>
</div>
</div>
</div>
break;
@* EN: Step 4 In room / VI: Bước 4 Trong phòng *@
@* EN: Step 4 -- In room / VI: Buoc 4 -- Trong phong *@
case 3:
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:24px;
text-align:center;margin-bottom:16px;">
<div style="font-size:12px;color:var(--pos-text-tertiary);font-weight:600;margin-bottom:8px;">
THI GIAN S DNG
THOI GIAN SU DUNG
</div>
<div style="font-size:48px;font-weight:700;color:var(--pos-orange-primary);font-variant-numeric:tabular-nums;">
02:15:00
@_sessionElapsed.ToString(@"hh\:mm\:ss")
</div>
<div style="font-size:13px;color:var(--pos-text-secondary);margin-top:8px;">
Bt đầu: <b>19:30</b> • Dự kiến: <b>22:00</b>
Bat dau: <b>@(_sessionStart?.ToString("HH:mm") ?? "--:--")</b>
Du kien: <b>@((_sessionStart ?? DateTime.Now).AddHours(_estimatedHours).ToString("HH:mm"))</b>
</div>
</div>
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;margin-bottom:16px;">
<div style="font-size:13px;font-weight:600;margin-bottom:10px;">Đơn F&B hin ti</div>
<div style="font-size:13px;font-weight:600;margin-bottom:10px;">Don F&B hien tai</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">S món</span>
<span>6 món</span>
<span style="color:var(--pos-text-secondary);">So mon</span>
<span>@_fnbItemCount mon</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:14px;font-weight:600;
padding-top:8px;border-top:1px solid var(--pos-border-subtle);">
<span>Tng F&B</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(830_000)</span>
<span>Tong F&B</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(_fnbTotal)</span>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<button style="padding:14px;border-radius:var(--pos-radius);background:rgba(59,130,246,.15);
border:none;color:#3B82F6;cursor:pointer;font-size:13px;font-weight:500;">
border:none;color:#3B82F6;cursor:pointer;font-size:13px;font-weight:500;"
@onclick="@(() => NavigateTo($"karaoke/fnb-order/{RoomId}"))">
<i data-lucide="utensils" style="width:16px;height:16px;display:block;margin:0 auto 4px;"></i>
Gi thêm F&B
Goi them F&B
</button>
<button style="padding:14px;border-radius:var(--pos-radius);background:rgba(255,92,0,.15);
border:none;color:var(--pos-orange-primary);cursor:pointer;font-size:13px;font-weight:500;">
<i data-lucide="clock" style="width:16px;height:16px;display:block;margin:0 auto 4px;"></i>
Gia hn
Gia han
</button>
</div>
break;
@* EN: Step 5 Close room / VI: Bước 5 — Đóng phòng *@
@* EN: Step 5 -- Close room / VI: Buoc 5 -- Dong phong *@
case 4:
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;margin-bottom:16px;">
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">
<i data-lucide="lock" style="width:16px;height:16px;display:inline;"></i> Kết thúc phiên
<i data-lucide="lock" style="width:16px;height:16px;display:inline;"></i> Ket thuc phien
</div>
<div style="display:flex;flex-direction:column;gap:8px;">
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--pos-text-secondary);">Thi gian s dng</span>
<span style="font-weight:600;">2 giờ 30 phút</span>
<span style="color:var(--pos-text-secondary);">Thoi gian su dung</span>
<span style="font-weight:600;">@FormatDuration(_sessionElapsed)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--pos-text-secondary);">Tin phòng</span>
<span>@FormatPrice(500_000)</span>
<span style="color:var(--pos-text-secondary);">Tien phong</span>
<span>@FormatPrice(RoomCost)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--pos-text-secondary);">Tin F&B</span>
<span>@FormatPrice(830_000)</span>
<span style="color:var(--pos-text-secondary);">Tien F&B</span>
<span>@FormatPrice(_fnbTotal)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:15px;font-weight:700;
padding-top:10px;border-top:1px solid var(--pos-border-subtle);">
<span>Tng cng</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(1_330_000)</span>
<span>Tong cong</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(RoomCost + _fnbTotal)</span>
</div>
</div>
</div>
break;
@* EN: Step 6 Payment / VI: Bước 6 Thanh toán *@
@* EN: Step 6 -- Payment / VI: Buoc 6 -- Thanh toan *@
case 5:
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:24px;
text-align:center;margin-bottom:16px;">
<div style="font-size:12px;color:var(--pos-text-tertiary);font-weight:600;margin-bottom:8px;
text-transform:uppercase;letter-spacing:1px;">
TNG THANH TOÁN
TONG THANH TOAN
</div>
<div style="font-size:40px;font-weight:700;color:var(--pos-orange-primary);">
@FormatPrice(1_330_000)
@FormatPrice(RoomCost + _fnbTotal)
</div>
<div style="font-size:13px;color:var(--pos-text-secondary);margin-top:8px;">
Phòng VIP 2 • 2h30 • 6 món F&B
Phong @(_room?.TableNumber ?? "---") • @FormatDuration(_sessionElapsed) • @_fnbItemCount mon F&B
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px;">
<button style="padding:16px;border-radius:var(--pos-radius);background:rgba(34,197,94,.15);
border:none;color:#22C55E;cursor:pointer;font-size:14px;font-weight:600;">
border:none;color:#22C55E;cursor:pointer;font-size:14px;font-weight:600;"
@onclick="HandlePayment">
<i data-lucide="banknote" style="width:18px;height:18px;display:block;margin:0 auto 4px;"></i>
Tin mt
Tien mat
</button>
<button style="padding:16px;border-radius:var(--pos-radius);background:rgba(59,130,246,.15);
border:none;color:#3B82F6;cursor:pointer;font-size:14px;font-weight:600;">
border:none;color:#3B82F6;cursor:pointer;font-size:14px;font-weight:600;"
@onclick="HandlePayment">
<i data-lucide="credit-card" style="width:18px;height:18px;display:block;margin:0 auto 4px;"></i>
Th/Chuyn khon
The/Chuyen khoan
</button>
</div>
break;
}
</div>
@* ═══ NAVIGATION BUTTONS / NÚT ĐIỀU HƯỚNG ═══ *@
@* === NAVIGATION BUTTONS / NUT DIEU HUONG === *@
<div style="display:flex;gap:12px;padding:12px 16px;border-top:1px solid var(--pos-border-subtle);flex-shrink:0;">
<button style="flex:1;padding:14px;border-radius:var(--pos-radius);background:var(--pos-bg-interactive);
border:none;color:var(--pos-text-primary);cursor:pointer;font-size:14px;font-weight:600;
opacity:@(_activeStep == 0 ? "0.4" : "1");"
disabled="@(_activeStep == 0)"
@onclick="() => _activeStep = Math.Max(0, _activeStep - 1)">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i> Quay li
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i> Quay lai
</button>
<button style="flex:1;padding:14px;border-radius:var(--pos-radius);
background:@(_activeStep == _steps.Length - 1 ? "var(--pos-success)" : "var(--pos-orange-primary)");
border:none;color:#FFF;cursor:pointer;font-size:14px;font-weight:600;"
@onclick="() => _activeStep = Math.Min(_steps.Length - 1, _activeStep + 1)">
@(_activeStep == _steps.Length - 1 ? "Hoàn tt" : "Tiếp")
@onclick="HandleNextStep">
@(_activeStep == _steps.Length - 1 ? "Hoan tat" : "Tiep")
<i data-lucide="arrow-right" style="width:16px;height:16px;display:inline;"></i>
</button>
</div>
}
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
// EN: Room ID from route parameter / VI: ID phong tu route parameter
[Parameter] public Guid RoomId { get; set; }
// EN: Active step index / VI: Chỉ số bước hiện ti
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
// EN: Active step index / VI: Chi so buoc hien tai
private int _activeStep;
private int _guestCount = 8;
private string _memberSearch = "0901234567";
private int _guestCount = 2;
private string _memberSearch = "";
private int _estimatedHours = 2;
// EN: Journey steps / VI: Các bước hành trình
// EN: Room data from API / VI: Du lieu phong tu API
private WebClientTpos.Client.Services.PosDataService.TableInfo? _room;
private DateTime? _sessionStart;
private decimal _hourlyRate;
// EN: F&B order data / VI: Du lieu don F&B
private decimal _fnbTotal;
private int _fnbItemCount;
// EN: Session timer / VI: Dong ho phien
private TimeSpan _sessionElapsed = TimeSpan.Zero;
private Timer? _sessionTimer;
// EN: Room cost based on elapsed time / VI: Chi phi phong theo thoi gian su dung
private decimal RoomCost
{
get
{
if (_hourlyRate <= 0) return 0;
var hours = (decimal)Math.Ceiling(_sessionElapsed.TotalHours);
if (hours < 1) hours = 1;
return _hourlyRate * hours;
}
}
// EN: Journey steps / VI: Cac buoc hanh trinh
private readonly StepInfo[] _steps =
{
new("Đón khách", "users"),
new("Chn phòng", "door-open"),
new("M phòng", "play"),
new("Trong phòng", "music"),
new("Đóng phòng", "lock"),
new("Thanh toán", "credit-card"),
new("Don khach", "users"),
new("Chon phong", "door-open"),
new("Mo phong", "play"),
new("Trong phong", "music"),
new("Dong phong", "lock"),
new("Thanh toan", "credit-card"),
};
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
var tables = await DataService.GetTablesAsync(ShopId);
// EN: Find room by RoomId, or use first room if no RoomId provided
// VI: Tim phong theo RoomId, hoac dung phong dau tien neu khong co RoomId
if (RoomId != Guid.Empty)
{
_room = tables.FirstOrDefault(t => t.Id == RoomId);
}
_room ??= tables.FirstOrDefault();
if (_room == null)
{
_loadError = true;
return;
}
_hourlyRate = _room.HourlyRate > 0 ? _room.HourlyRate : 100_000;
_guestCount = _room.GuestCount ?? 2;
_sessionStart = _room.StartedAt;
// EN: Load F&B orders for this room / VI: Tai don F&B cho phong nay
var orders = await DataService.GetActiveTableOrdersAsync(ShopId);
var roomOrders = orders.Where(o => o.TableId == _room.Id).ToList();
_fnbTotal = roomOrders.Sum(o => o.TotalAmount);
_fnbItemCount = roomOrders.Sum(o => o.Items.Count);
// EN: Auto-detect step based on room status / VI: Tu dong phat hien buoc theo trang thai phong
_activeStep = _room.Status switch
{
"occupied" => 3, // In room
"cleaning" => 4, // Close room
"reserved" => 1, // Room selection
_ => 0 // Reception (available)
};
// EN: Start session timer if room is occupied / VI: Bat dong ho neu phong dang su dung
if (_sessionStart.HasValue && _room.Status == "occupied")
{
_sessionElapsed = DateTime.Now - _sessionStart.Value;
_sessionTimer = new Timer(_ =>
{
_sessionElapsed = DateTime.Now - _sessionStart!.Value;
InvokeAsync(StateHasChanged);
}, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
}
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
}
private void HandleNextStep()
{
if (_activeStep == _steps.Length - 1)
{
// EN: Final step - complete / VI: Buoc cuoi - hoan tat
NavigateTo("karaoke");
}
else
{
_activeStep = Math.Min(_steps.Length - 1, _activeStep + 1);
}
}
private void HandlePayment()
{
// EN: Navigate to payment flow / VI: Dieu huong den thanh toan
if (_room?.SessionId.HasValue == true)
{
NavigateTo($"karaoke/payment/{_room.SessionId}");
}
else
{
NavigateTo("karaoke");
}
}
private static string FormatDuration(TimeSpan ts)
{
if (ts.TotalHours >= 1)
return $"{(int)ts.TotalHours} gio {ts.Minutes} phut";
return $"{ts.Minutes} phut";
}
public void Dispose()
{
_sessionTimer?.Dispose();
}
private record StepInfo(string Label, string Icon);
}

View File

@@ -5,6 +5,7 @@
@page "/pos/{ShopId:guid}/karaoke/member-card"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;overflow:hidden;">
@* ═══ MEMBER LOOKUP (LEFT) / TRA CỨU THÀNH VIÊN (TRÁI) ═══ *@
@@ -25,15 +26,35 @@
border-radius:var(--pos-radius);padding:0 14px;border:1px solid var(--pos-border-default);">
<i data-lucide="search" style="width:16px;height:16px;color:var(--pos-text-tertiary);"></i>
<input type="text" placeholder="SĐT hoặc mã thẻ..." @bind="_searchTerm"
@onkeydown="HandleSearchKeyDown"
style="flex:1;background:transparent;border:none;color:var(--pos-text-primary);
font-size:14px;padding:12px 0;outline:none;" />
</div>
<button style="padding:12px 16px;border-radius:var(--pos-radius);background:var(--pos-orange-primary);
border:none;color:#FFF;cursor:pointer;font-size:13px;font-weight:600;">
<i data-lucide="scan-line" style="width:16px;height:16px;display:inline;"></i> Quét
border:none;color:#FFF;cursor:pointer;font-size:13px;font-weight:600;"
disabled="@_isSearching"
@onclick="SearchMember">
@if (_isSearching)
{
<text>...</text>
}
else
{
<i data-lucide="search" style="width:16px;height:16px;display:inline;"></i> <text>Tìm</text>
}
</button>
</div>
@* EN: Search status messages / VI: Thông báo trạng thái tìm kiếm *@
@if (!string.IsNullOrEmpty(_searchMessage))
{
<div style="padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:13px;
background:@(_searchError ? "rgba(239,68,68,.15)" : "rgba(59,130,246,.15)");
color:@(_searchError ? "#EF4444" : "#3B82F6");">
@_searchMessage
</div>
}
@* ═══ MEMBER CARD DISPLAY / HIỂN THỊ THẺ THÀNH VIÊN ═══ *@
@if (_activeMember is not null)
{
@@ -152,25 +173,22 @@
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
private string _searchTerm = "0901234567";
private string _searchTerm = "";
private bool _isSearching;
private string? _searchMessage;
private bool _searchError;
private RewardInfo? _selectedReward;
// EN: Active member (demo) / VI: Thành viên hiện tại (mẫu)
private MemberInfo? _activeMember = new("Nguyễn Văn Minh", "0901234567", "Gold",
2_450, 28, 15, "MBR-2024-0891", "15/06/2023");
// EN: Active member from API search / VI: Thành viên hiện tại từ API tìm kiếm
private MemberInfo? _activeMember;
// EN: Visit history / VI: Lịch sử đến
private readonly List<VisitInfo> _visitHistory = new()
{
new("Phòng VIP 2", "08/02/2025", "3 giờ", 850_000, 85),
new("Phòng 201", "02/02/2025", "2 giờ", 420_000, 42),
new("Phòng VIP 1", "25/01/2025", "4 giờ", 1_200_000, 120),
new("Phòng 102", "18/01/2025", "2 giờ", 350_000, 35),
};
// EN: Visit history — populated when member is found
// VI: Lịch sử đến — được điền khi tìm thấy thành viên
// TODO: Wire to member visit history API when available
private List<VisitInfo> _visitHistory = new();
// EN: Available rewards / VI: Ưu đãi khả dụng
// EN: Available rewards — static until rewards API is available
// VI: Ưu đãi khả dụng — tĩnh cho đến khi có rewards API
private readonly List<RewardInfo> _rewards = new()
{
new("RW1", "Giảm 20% phòng", "-20%", "Áp dụng cho mọi loại phòng", 500),
@@ -178,16 +196,105 @@
new("RW3", "Combo F&B", "-50,000₫", "Giảm 50K cho đơn F&B từ 200K", 300),
};
private static string GetTierBg(string t) => t switch
// EN: Membership level definitions for tier display / VI: Định nghĩa cấp thành viên để hiển thị hạng
private Dictionary<int, string> _levelNames = new();
protected override async Task OnInitializedAsync()
{
"Gold" => "rgba(245,158,11,.2)", "Platinum" => "rgba(168,162,158,.2)",
"Diamond" => "rgba(59,130,246,.2)", _ => "rgba(255,255,255,.1)"
await base.OnInitializedAsync();
try
{
// EN: Pre-load membership levels for tier name lookup / VI: Tải trước cấp thành viên
var levels = await DataService.GetMembershipLevelsAsync();
_levelNames = levels.ToDictionary(l => l.LevelNumber, l => l.Name);
}
catch { /* EN: Non-critical, tier names will fallback / VI: Không quan trọng, tên hạng sẽ dùng giá trị mặc định */ }
}
private async Task HandleSearchKeyDown(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e)
{
if (e.Key == "Enter")
await SearchMember();
}
/// <summary>
/// EN: Search for member via API using phone or card ID.
/// VI: Tìm kiếm thành viên qua API theo SĐT hoặc mã thẻ.
/// </summary>
private async Task SearchMember()
{
if (string.IsNullOrWhiteSpace(_searchTerm) || _isSearching) return;
_isSearching = true;
_searchMessage = null;
_activeMember = null;
_visitHistory = new();
_selectedReward = null;
StateHasChanged();
try
{
var members = await DataService.GetMembersAsync(_searchTerm);
if (members.Any())
{
var m = members.First();
var tierName = _levelNames.GetValueOrDefault(m.CurrentLevel) ?? m.LevelName ?? $"Level {m.CurrentLevel}";
// EN: Calculate discount based on tier level / VI: Tính giảm giá dựa trên cấp hạng
var discount = m.CurrentLevel switch
{
>= 4 => 20, // Diamond+
3 => 15, // Gold
2 => 10, // Silver
_ => 5 // Bronze/Basic
};
_activeMember = new MemberInfo(
m.DisplayName ?? $"Thành viên #{m.Id.ToString()[..8]}",
m.Phone ?? "N/A",
tierName,
m.CurrentExp,
m.TotalExpEarned / 100, // EN: Approximate visit count / VI: Ước lượng số lần đến
discount,
$"MBR-{m.Id.ToString()[..8].ToUpper()}",
m.CreatedAt.ToString("dd/MM/yyyy")
);
_searchMessage = null;
}
else
{
_searchMessage = "Không tìm thấy thành viên. Vui lòng kiểm tra lại SĐT hoặc mã thẻ.";
_searchError = true;
}
}
catch (Exception ex)
{
_searchMessage = $"Lỗi tìm kiếm: {ex.Message}";
_searchError = true;
}
finally
{
_isSearching = false;
}
}
private static string GetTierBg(string t) => t.ToLower() switch
{
var s when s.Contains("gold") => "rgba(245,158,11,.2)",
var s when s.Contains("platinum") || s.Contains("silver") => "rgba(168,162,158,.2)",
var s when s.Contains("diamond") => "rgba(59,130,246,.2)",
_ => "rgba(255,255,255,.1)"
};
private static string GetTierFg(string t) => t switch
private static string GetTierFg(string t) => t.ToLower() switch
{
"Gold" => "#F59E0B", "Platinum" => "#A8A29E",
"Diamond" => "#3B82F6", _ => "#ADADB0"
var s when s.Contains("gold") => "#F59E0B",
var s when s.Contains("platinum") || s.Contains("silver") => "#A8A29E",
var s when s.Contains("diamond") => "#3B82F6",
_ => "#ADADB0"
};
private record MemberInfo(string Name, string Phone, string Tier, int Points, int Visits, int Discount, string CardId, string JoinDate);

View File

@@ -1,13 +1,17 @@
@*
EN: Karaoke Peak Warning — Peak hours pricing comparison, room type multipliers, cost estimator.
VI: Cảnh báo giờ cao điểm Karaoke — So sánh giá giờ cao điểm, hệ số phòng, ước tính chi phí.
Loads real room data from API to build pricing tiers dynamically.
VI: Canh bao gio cao diem Karaoke — So sanh gia gio cao diem, he so phong, uoc tinh chi phi.
Tai du lieu phong that tu API de xay dung bang gia dong.
*@
@page "/pos/{ShopId:guid}/karaoke/peak-warning"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@implements IDisposable
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@* === HEADER / TIEU DE === *@
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);flex-shrink:0;">
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
@@ -16,29 +20,43 @@
</button>
<span style="font-size:16px;font-weight:700;">
<i data-lucide="alert-triangle" style="width:18px;height:18px;display:inline;color:var(--pos-warning);"></i>
Gi cao điểm
Gio cao diem
</span>
</div>
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--pos-text-tertiary);">
Dang tai...
</div>
}
else if (_loadError)
{
<div style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--pos-text-tertiary);">
Khong the tai du lieu
</div>
}
else
{
<div style="flex:1;overflow-y:auto;padding:16px;">
@* ═══ CURRENT TIME / THI GIAN HIN TI ═══ *@
@* === CURRENT TIME / THOI GIAN HIEN TAI === *@
<div style="background:linear-gradient(135deg,rgba(245,158,11,.2),rgba(239,68,68,.15));
border-radius:var(--pos-radius);padding:24px;margin-bottom:16px;text-align:center;
border:1px solid rgba(245,158,11,.3);">
<div style="font-size:12px;font-weight:600;color:var(--pos-warning);text-transform:uppercase;letter-spacing:1px;">
KHUNG GI HIN TI
KHUNG GIO HIEN TAI
</div>
<div style="font-size:36px;font-weight:700;color:var(--pos-warning);margin:8px 0;">
20:30 — Thứ 7
@_currentTime.ToString("HH:mm") — @GetDayOfWeekVi(_currentTime.DayOfWeek)
</div>
<div style="font-size:13px;color:var(--pos-text-secondary);">
Đang áp dng giá <b style="color:var(--pos-warning);">cuối tuần</b>
Dang ap dung gia <b style="color:var(--pos-warning);">@(ActiveRate?.Label ?? "thong thuong")</b>
</div>
</div>
@* ═══ PRICING TABLE / BNG GIÁ ═══ *@
@* === PRICING TABLE / BANG GIA === *@
<div style="margin-bottom:20px;">
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">Bng giá theo khung gi (Standard)</div>
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">Bang gia theo khung gio (Standard)</div>
<div style="display:flex;flex-direction:column;gap:8px;">
@foreach (var rate in _pricingRates)
{
@@ -54,118 +72,204 @@
</div>
<div style="text-align:right;">
<div style="font-size:14px;font-weight:700;color:@(rate.IsActive ? "var(--pos-warning)" : "var(--pos-text-primary)");">
@FormatPrice(rate.Price)/gi
@FormatPrice(rate.Price)/gio
</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">x@rate.Multiplier.ToString("0.0")</div>
</div>
@if (rate.IsActive)
{
<span style="font-size:11px;padding:3px 8px;border-radius:6px;font-weight:600;
background:rgba(245,158,11,.2);color:var(--pos-warning);">Hin ti</span>
background:rgba(245,158,11,.2);color:var(--pos-warning);">Hien tai</span>
}
</div>
}
</div>
</div>
@* ═══ ROOM TYPE SELECTOR / CHN LOI PHÒNG ═══ *@
@* === ROOM TYPE SELECTOR / CHON LOAI PHONG === *@
<div style="margin-bottom:20px;">
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">Loi phòng</div>
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">Loai phong</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;">
@foreach (var room in _roomTypes)
{
<button @onclick="() => _selectedRoomType = room"
style="padding:16px;border-radius:var(--pos-radius);text-align:center;cursor:pointer;
background:@(_selectedRoomType.Name == room.Name ? "var(--pos-orange-primary)" : "var(--pos-bg-elevated)");
color:@(_selectedRoomType.Name == room.Name ? "#FFF" : "var(--pos-text-primary)");
border:1px solid @(_selectedRoomType.Name == room.Name ? "var(--pos-orange-primary)" : "var(--pos-border-subtle)");">
background:@(_selectedRoomType?.Name == room.Name ? "var(--pos-orange-primary)" : "var(--pos-bg-elevated)");
color:@(_selectedRoomType?.Name == room.Name ? "#FFF" : "var(--pos-text-primary)");
border:1px solid @(_selectedRoomType?.Name == room.Name ? "var(--pos-orange-primary)" : "var(--pos-border-subtle)");">
<div style="font-size:14px;font-weight:700;">@room.Name</div>
<div style="font-size:12px;margin-top:4px;opacity:0.7;">x@room.Multiplier.ToString("0.0")</div>
<div style="font-size:12px;margin-top:4px;opacity:0.7;">@FormatPrice(room.BaseRate)/gio</div>
</button>
}
</div>
</div>
@* ═══ COST ESTIMATOR / ƯỚC TÍNH CHI PHÍ ═══ *@
@* === COST ESTIMATOR / UOC TINH CHI PHI === *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;">
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">Ước tính chi phí</div>
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">Uoc tinh chi phi</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
<span style="font-size:13px;color:var(--pos-text-secondary);">S gi:</span>
<span style="font-size:13px;color:var(--pos-text-secondary);">So gio:</span>
<button style="width:36px;height:36px;border-radius:8px;border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-primary);cursor:pointer;font-size:16px;"
@onclick="() => _estimateHours = Math.Max(1, _estimateHours - 1)"></button>
@onclick="() => _estimateHours = Math.Max(1, _estimateHours - 1)">-</button>
<span style="font-size:20px;font-weight:700;min-width:30px;text-align:center;">@_estimateHours</span>
<button style="width:36px;height:36px;border-radius:8px;border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-primary);cursor:pointer;font-size:16px;"
@onclick="() => _estimateHours++">+</button>
<span style="font-size:13px;color:var(--pos-text-tertiary);">gi</span>
<span style="font-size:13px;color:var(--pos-text-tertiary);">gio</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Giá hin ti (@_selectedRoomType.Name)</span>
<span>@FormatPrice(CurrentRatePrice)/gi</span>
<span style="color:var(--pos-text-secondary);">Gia hien tai (@(_selectedRoomType?.Name ?? "Standard"))</span>
<span>@FormatPrice(CurrentRatePrice)/gio</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">S gi</span>
<span>@_estimateHours gi</span>
<span style="color:var(--pos-text-secondary);">So gio</span>
<span>@_estimateHours gio</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:16px;font-weight:700;
padding-top:10px;border-top:1px solid var(--pos-border-subtle);">
<span>Tng ước tính</span>
<span>Tong uoc tinh</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(CurrentRatePrice * _estimateHours)</span>
</div>
</div>
</div>
@* ═══ CONFIRM BUTTON / NÚT XÁC NHN ═══ *@
@* === CONFIRM BUTTON / NUT XAC NHAN === *@
<div style="padding:12px 16px;border-top:1px solid var(--pos-border-subtle);flex-shrink:0;">
<button style="width:100%;padding:16px;border-radius:var(--pos-radius);background:var(--pos-orange-primary);
border:none;color:#FFF;cursor:pointer;font-size:15px;font-weight:700;"
@onclick="@(() => NavigateTo("karaoke/room-select"))">
<i data-lucide="check" style="width:18px;height:18px;display:inline;"></i> Xác nhn giá
<i data-lucide="check" style="width:18px;height:18px;display:inline;"></i> Xac nhan gia
</button>
</div>
}
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
// EN: Estimate hours / VI: Số giờ ước tính
// EN: Current time, updated every minute / VI: Thoi gian hien tai, cap nhat moi phut
private DateTime _currentTime = DateTime.Now;
private Timer? _timer;
// EN: Estimate hours / VI: So gio uoc tinh
private int _estimateHours = 2;
// EN: Pricing rates / VI: Bng giá
private readonly List<PricingRate> _pricingRates = new()
{
new("Giờ thường", "T2T5, 10:0017:00", 100_000, 1.0m, false),
new("Giờ cao điểm", "T2T5, 17:0023:00", 150_000, 1.5m, false),
new("Cuối tuần", "T6CN", 180_000, 1.8m, true),
new("Lễ/Tết", "Ngày lễ, Tết", 250_000, 2.5m, false),
};
// EN: Pricing rates built from room data / VI: Bang gia xay dung tu du lieu phong
private List<PricingRate> _pricingRates = new();
// EN: Room types / VI: Loi phòng
private readonly RoomType[] _roomTypes =
{
new("Standard", 1.0m),
new("Deluxe", 1.5m),
new("VIP", 2.0m),
};
// EN: Room types grouped from API data / VI: Loai phong nhom tu du lieu API
private List<RoomTypeInfo> _roomTypes = new();
private RoomTypeInfo? _selectedRoomType;
private RoomType _selectedRoomType = null!;
// EN: Active pricing rate based on current hour / VI: Muc gia dang ap dung theo gio hien tai
private PricingRate? ActiveRate => _pricingRates.FirstOrDefault(r => r.IsActive);
protected override void OnInitialized()
{
_selectedRoomType = _roomTypes[0];
}
// EN: Current active rate price adjusted for room type / VI: Giá hiện tại theo loại phòng
// EN: Current rate price adjusted for room type / VI: Gia hien tai theo loai phong
private decimal CurrentRatePrice
{
get
{
var activeRate = _pricingRates.FirstOrDefault(r => r.IsActive) ?? _pricingRates[0];
return activeRate.Price * _selectedRoomType.Multiplier;
var activeRate = ActiveRate ?? _pricingRates.FirstOrDefault();
if (activeRate == null || _selectedRoomType == null) return 0;
return _selectedRoomType.BaseRate * activeRate.Multiplier;
}
}
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
var tables = await DataService.GetTablesAsync(ShopId);
// EN: Group rooms by zone/type to derive pricing tiers
// VI: Nhom phong theo khu/loai de xay dung muc gia
var groups = tables
.GroupBy(t => t.Zone ?? "Standard")
.Select(g => new RoomTypeInfo(
g.Key,
g.Average(t => t.HourlyRate) > 0 ? Math.Round(g.Average(t => t.HourlyRate)) : 100_000m
))
.OrderBy(r => r.BaseRate)
.ToList();
_roomTypes = groups.Any() ? groups : new List<RoomTypeInfo>
{
new("Standard", 100_000),
new("Deluxe", 150_000),
new("VIP", 200_000),
};
_selectedRoomType = _roomTypes.First();
// EN: Build pricing rates based on time-of-day with base rate from cheapest room type
// VI: Xay dung bang gia theo khung gio voi gia co ban tu loai phong re nhat
var baseRate = _roomTypes.Min(r => r.BaseRate);
if (baseRate <= 0) baseRate = 100_000;
BuildPricingRates(baseRate);
// EN: Start clock timer / VI: Bat dau dong ho
_timer = new Timer(_ =>
{
_currentTime = DateTime.Now;
BuildPricingRates(baseRate);
InvokeAsync(StateHasChanged);
}, null, TimeSpan.FromSeconds(60 - DateTime.Now.Second), TimeSpan.FromMinutes(1));
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
}
private void BuildPricingRates(decimal baseRate)
{
var hour = _currentTime.Hour;
var dow = _currentTime.DayOfWeek;
var isWeekend = dow == DayOfWeek.Saturday || dow == DayOfWeek.Sunday;
_pricingRates = new List<PricingRate>
{
new("Gio thuong", "T2-T5, 10:00-17:00", baseRate * 1.0m, 1.0m, !isWeekend && hour >= 10 && hour < 17),
new("Gio cao diem", "T2-T5, 17:00-23:00", baseRate * 1.5m, 1.5m, !isWeekend && hour >= 17 && hour < 23),
new("Cuoi tuan", "T6-CN", baseRate * 1.8m, 1.8m, isWeekend),
new("Le/Tet", "Ngay le, Tet", baseRate * 2.5m, 2.5m, false),
};
// EN: Fallback: if no rate is active (e.g., before 10AM on weekday), activate "Gio thuong"
// VI: Du phong: neu khong co muc gia nao active, kich hoat "Gio thuong"
if (!_pricingRates.Any(r => r.IsActive))
{
_pricingRates[0] = _pricingRates[0] with { IsActive = true };
}
}
private static string GetDayOfWeekVi(DayOfWeek dow) => dow switch
{
DayOfWeek.Monday => "Thu 2",
DayOfWeek.Tuesday => "Thu 3",
DayOfWeek.Wednesday => "Thu 4",
DayOfWeek.Thursday => "Thu 5",
DayOfWeek.Friday => "Thu 6",
DayOfWeek.Saturday => "Thu 7",
DayOfWeek.Sunday => "CN",
_ => ""
};
public void Dispose()
{
_timer?.Dispose();
}
private record PricingRate(string Label, string TimeRange, decimal Price, decimal Multiplier, bool IsActive);
private record RoomType(string Name, decimal Multiplier);
private record RoomTypeInfo(string Name, decimal BaseRate);
}

View File

@@ -87,6 +87,16 @@
</div>
</div>
@* EN: Success/Error message / VI: Thông báo thành công/lỗi *@
@if (!string.IsNullOrEmpty(_statusMessage))
{
<div style="margin:0 16px;padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500;
background:@(_isError ? "rgba(239,68,68,.15)" : "rgba(34,197,94,.15)");
color:@(_isError ? "#EF4444" : "#22C55E");">
@_statusMessage
</div>
}
@* ═══ ACTION BUTTONS / NÚT HÀNH ĐỘNG ═══ *@
<div style="display:flex;gap:12px;padding:12px 16px;border-top:1px solid var(--pos-border-subtle);flex-shrink:0;">
<button style="flex:1;padding:14px;border-radius:var(--pos-radius);background:var(--pos-bg-interactive);
@@ -96,10 +106,17 @@
</button>
<button style="flex:1;padding:14px;border-radius:var(--pos-radius);background:var(--pos-orange-primary);
border:none;color:#FFF;cursor:pointer;font-size:14px;font-weight:600;
opacity:@(_selectedOption is null ? "0.4" : "1");"
disabled="@(_selectedOption is null)"
@onclick="@(() => NavigateTo($"karaoke/room-session/{RoomId}"))">
<i data-lucide="check" style="width:16px;height:16px;display:inline;"></i> Xác nhận gia hạn
opacity:@(_selectedOption is null || _isExtending ? "0.4" : "1");"
disabled="@(_selectedOption is null || _isExtending)"
@onclick="ConfirmExtend">
@if (_isExtending)
{
<text>Đang xử lý...</text>
}
else
{
<i data-lucide="check" style="width:16px;height:16px;display:inline;"></i> <text>Xác nhận gia hạn</text>
}
</button>
</div>
}
@@ -125,6 +142,9 @@
private ExtendOption? _selectedOption;
private int _customMinutes = 45;
private bool _isExtending;
private string? _statusMessage;
private bool _isError;
protected override async Task OnInitializedAsync()
{
@@ -159,5 +179,47 @@
_selectedOption = new(_customMinutes, $"+{_customMinutes} phút");
}
/// <summary>
/// EN: Call API to extend the session, then navigate back on success.
/// VI: Gọi API gia hạn phiên, sau đó quay lại khi thành công.
/// </summary>
private async Task ConfirmExtend()
{
if (_selectedOption is null || _isExtending) return;
_isExtending = true;
_statusMessage = null;
StateHasChanged();
try
{
var success = await DataService.ExtendSessionAsync(RoomId, _selectedOption.Minutes);
if (success)
{
_statusMessage = $"Gia hạn thành công {_selectedOption.Label}!";
_isError = false;
StateHasChanged();
// EN: Brief delay so user sees success message before navigating
// VI: Delay ngắn để user thấy thông báo thành công trước khi chuyển trang
await Task.Delay(800);
NavigateTo($"karaoke/room-session/{RoomId}");
}
else
{
_statusMessage = "Không thể gia hạn phiên. Vui lòng thử lại.";
_isError = true;
}
}
catch (Exception ex)
{
_statusMessage = $"Lỗi: {ex.Message}";
_isError = true;
}
finally
{
_isExtending = false;
}
}
private record ExtendOption(int Minutes, string Label);
}

View File

@@ -6,6 +6,7 @@
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@implements IDisposable
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@@ -112,7 +113,7 @@
{
<button style="padding:8px 14px;border-radius:8px;background:var(--pos-orange-primary);
border:none;color:#FFF;cursor:pointer;font-size:12px;font-weight:600;"
@onclick="@(() => req.Status = "processing")">
@onclick="@(() => UpdateRequestStatus(req, "processing"))">
Nhận
</button>
}
@@ -120,7 +121,7 @@
{
<button style="padding:8px 14px;border-radius:8px;background:rgba(34,197,94,.15);
border:none;color:#22C55E;cursor:pointer;font-size:12px;font-weight:600;"
@onclick="@(() => req.Status = "completed")">
@onclick="@(() => UpdateRequestStatus(req, "completed"))">
Xong
</button>
}
@@ -148,34 +149,89 @@
private string _activeFilter = "Tất cả";
private readonly string[] _filters = { "Tất cả", "Chờ xử lý", "Đang xử lý", "Hoàn thành" };
// EN: Room names loaded from DB for display / VI: Tên phòng tải từ DB để hiển thị
private List<string> _roomNames = new();
// EN: Room lookup map (tableId → roomName) loaded from DB / VI: Map tra cứu phòng (tableId → tên phòng) từ DB
private Dictionary<Guid, string> _roomLookup = new();
// EN: Service requests / VI: Yêu cầu phục vụ
private readonly List<ServiceRequest> _requests = new()
{
new("P.102","F&B","Gọi thêm 2 két bia Tiger","20:15","5 phút trước","pending"),
new("VIP 2","Kỹ thuật","Micro phòng bị rè, cần thay","20:12","8 phút trước","pending"),
new("P.201","F&B","Trái cây dĩa lớn x2","20:10","10 phút trước","processing"),
new("P.103","Dọn dẹp","Dọn ly và khăn giấy","20:05","15 phút trước","processing"),
new("VIP 1","F&B","Nước suối 10 chai","19:55","25 phút trước","completed"),
new("P.104","Kỹ thuật","Điều chỉnh âm thanh","19:50","30 phút trước","completed"),
new("P.202","F&B","Combo mồi nhậu","19:45","35 phút trước","completed"),
new("VIP 3","Dọn dẹp","Thay khăn lạnh","19:40","40 phút trước","completed"),
};
// EN: Service requests mapped from kitchen tickets / VI: Yêu cầu phục vụ map từ kitchen tickets
private List<ServiceRequest> _requests = new();
// EN: Auto-refresh timer (15s) / VI: Timer tự động refresh (15 giây)
private Timer? _refreshTimer;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadDataAsync();
// EN: Auto-refresh every 15 seconds / VI: Tự động refresh mỗi 15 giây
_refreshTimer = new Timer(async _ =>
{
await LoadDataAsync();
await InvokeAsync(StateHasChanged);
}, null, TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(15));
}
private async Task LoadDataAsync()
{
try
{
var tables = await DataService.GetTablesAsync(ShopId);
_roomNames = tables.Select(t => t.TableNumber).ToList();
// EN: Load tables for room name lookup and all ticket statuses in parallel
// VI: Tải danh sách phòng và tất cả trạng thái ticket song song
var tablesTask = DataService.GetTablesAsync(ShopId);
var pendingTask = DataService.GetKitchenTicketsAsync(ShopId, "Pending");
var processingTask = DataService.GetKitchenTicketsAsync(ShopId, "InProgress");
var completedTask = DataService.GetKitchenTicketsAsync(ShopId, "Completed");
await Task.WhenAll(tablesTask, pendingTask, processingTask, completedTask);
var tables = await tablesTask;
_roomLookup = tables.ToDictionary(t => t.Id, t => t.TableNumber);
var allTickets = new List<WebClientTpos.Client.Services.PosDataService.KitchenTicketInfo>();
allTickets.AddRange(await pendingTask);
allTickets.AddRange(await processingTask);
allTickets.AddRange(await completedTask);
// EN: Map kitchen tickets to ServiceRequest display model
// VI: Map kitchen tickets sang model hiển thị ServiceRequest
_requests = allTickets.OrderByDescending(t => t.CreatedAt).Select(t =>
{
var roomName = _roomLookup.TryGetValue(t.SessionId, out var name) ? name : t.SessionId.ToString()[..8];
var elapsed = DateTime.UtcNow - t.CreatedAt;
var elapsedStr = elapsed.TotalMinutes < 60
? $"{(int)elapsed.TotalMinutes} phút trước"
: $"{(int)elapsed.TotalHours}h{elapsed.Minutes:D2} trước";
var status = t.Status switch
{
"Pending" => "pending",
"InProgress" => "processing",
"Completed" => "completed",
_ => "pending"
};
var type = t.Station switch
{
"Kitchen" or "Bar" => "F&B",
"Technical" => "Kỹ thuật",
"Cleaning" => "Dọn dẹp",
_ => "F&B"
};
return new ServiceRequest(
t.Id,
roomName,
type,
t.ItemName ?? "Yêu cầu phục vụ",
t.CreatedAt.ToLocalTime().ToString("HH:mm"),
elapsedStr,
status
);
}).ToList();
}
catch
{
_loadError = true;
_loadError = _requests.Count == 0;
}
finally
{
@@ -183,6 +239,28 @@
}
}
/// <summary>
/// EN: Update ticket status via API when user clicks Nhận/Xong buttons.
/// VI: Cập nhật trạng thái ticket qua API khi user nhấn nút Nhận/Xong.
/// </summary>
private async Task UpdateRequestStatus(ServiceRequest req, string newStatus)
{
var apiStatus = newStatus switch
{
"processing" => "InProgress",
"completed" => "Completed",
_ => newStatus
};
var success = await DataService.UpdateTicketStatusAsync(req.TicketId,
new WebClientTpos.Client.Services.PosDataService.UpdateTicketStatusRequest(apiStatus));
if (success)
{
req.Status = newStatus;
}
}
private IEnumerable<ServiceRequest> FilteredRequests => _activeFilter switch
{
"Chờ xử lý" => _requests.Where(r => r.Status == "pending"),
@@ -212,8 +290,14 @@
"F&B" => "#FF5C00", "Kỹ thuật" => "#3B82F6", "Dọn dẹp" => "#F59E0B", _ => "var(--pos-border-default)"
};
private class ServiceRequest(string room, string type, string description, string time, string elapsed, string status)
public void Dispose()
{
_refreshTimer?.Dispose();
}
private class ServiceRequest(Guid ticketId, string room, string type, string description, string time, string elapsed, string status)
{
public Guid TicketId { get; set; } = ticketId;
public string Room { get; set; } = room;
public string Type { get; set; } = type;
public string Description { get; set; } = description;

View File

@@ -41,12 +41,14 @@
<div style="flex:1;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column;gap:8px;">
@foreach (var t in FilteredTables)
{
var effectiveStatus = GetEffectiveStatus(t);
var hasReservation = GetTableReservation(t) is not null;
<div @onclick="() => OpenTable(t)"
style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:14px 16px;
display:flex;align-items:center;gap:12px;cursor:pointer;
border-left:4px solid @StatusColor(t.Status);">
border-left:4px solid @StatusColor(effectiveStatus);">
@* EN: Table number badge / VI: Badge số bàn *@
<div style="width:44px;height:44px;border-radius:10px;background:@StatusBg(t.Status);
<div style="width:44px;height:44px;border-radius:10px;background:@StatusBg(effectiveStatus);
display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:700;">
@t.Number
</div>
@@ -57,13 +59,20 @@
</div>
</div>
<div style="text-align:right;">
<div style="font-size:11px;font-weight:600;color:@StatusColor(t.Status);">
@StatusLabel(t.Status)
<div style="font-size:11px;font-weight:600;color:@StatusColor(effectiveStatus);">
@StatusLabel(effectiveStatus)
</div>
@if (t.Status == "occupied")
@if (HasPendingOrder(t))
{
<div style="font-size:13px;font-weight:700;color:var(--pos-orange-primary);margin-top:2px;">
@FormatPrice(t.Amount)
@FormatPrice(GetTableOrderTotal(t))
</div>
}
@if (hasReservation)
{
var res = GetTableReservation(t)!;
<div style="font-size:10px;color:var(--pos-info);margin-top:2px;font-weight:600;">
Đặt @res.ReservationTime.ToString("HH:mm")
</div>
}
</div>
@@ -76,15 +85,15 @@
<div style="padding:12px 16px;border-top:1px solid var(--pos-border-subtle);display:flex;justify-content:space-around;
background:var(--pos-bg-elevated);font-size:12px;">
<div style="text-align:center;">
<div style="font-weight:700;font-size:16px;color:var(--pos-success);">@_tables.Count(t => t.Status == "available")</div>
<div style="font-weight:700;font-size:16px;color:var(--pos-success);">@_tables.Count(t => GetEffectiveStatus(t) == "available")</div>
<div style="color:var(--pos-text-tertiary);">Trống</div>
</div>
<div style="text-align:center;">
<div style="font-weight:700;font-size:16px;color:var(--pos-orange-primary);">@_tables.Count(t => t.Status == "occupied")</div>
<div style="font-weight:700;font-size:16px;color:var(--pos-orange-primary);">@_tables.Count(t => GetEffectiveStatus(t) == "occupied")</div>
<div style="color:var(--pos-text-tertiary);">Đang phục vụ</div>
</div>
<div style="text-align:center;">
<div style="font-weight:700;font-size:16px;color:var(--pos-info);">@_tables.Count(t => t.Status == "reserved")</div>
<div style="font-weight:700;font-size:16px;color:var(--pos-info);">@_tables.Count(t => GetEffectiveStatus(t) == "reserved")</div>
<div style="color:var(--pos-text-tertiary);">Đã đặt</div>
</div>
</div>
@@ -104,6 +113,12 @@
// EN: Table data from API / VI: Dữ liệu bàn từ API
private List<MobileTable> _tables = new();
// EN: Active table orders from DB / VI: Đơn hàng active từ DB
private readonly Dictionary<string, List<OrderItem>> _tableOrderMap = new();
// EN: Reservations / VI: Đặt bàn
private List<WebClientTpos.Client.Services.PosDataService.ReservationInfo> _reservations = new();
private IEnumerable<MobileTable> FilteredTables =>
_activeSection == "Tất cả" ? _tables : _tables.Where(t => t.Section == _activeSection);
@@ -113,17 +128,41 @@
try
{
var apiTables = await DataService.GetTablesAsync(ShopId);
var tablesTask = DataService.GetTablesAsync(ShopId);
var ordersTask = DataService.GetActiveTableOrdersAsync(ShopId);
var reservationsTask = DataService.GetReservationsAsync(ShopId, DateTime.Today.ToString("yyyy-MM-dd"));
await Task.WhenAll(tablesTask, ordersTask, reservationsTask);
var apiTables = await tablesTask;
var activeOrders = await ordersTask;
_reservations = await reservationsTask;
_tables = apiTables.Select(t => new MobileTable(
t.Id.ToString(),
int.TryParse(t.TableNumber, out var num) ? num : 0,
$"Bàn {t.TableNumber}",
t.Capacity,
t.Status ?? "available",
t.Zone ?? "Trong nhà",
0
t.Zone ?? "Trong nhà"
)).ToList();
// EN: Build order map per table / VI: Tạo map đơn theo bàn
foreach (var order in activeOrders)
{
if (order.TableId == null) continue;
var tableKey = order.TableId.Value.ToString();
if (!_tableOrderMap.ContainsKey(tableKey))
_tableOrderMap[tableKey] = new();
foreach (var item in order.Items)
{
var existing = _tableOrderMap[tableKey].FirstOrDefault(i => i.ProductId == item.ProductId);
if (existing != null)
existing.Qty += item.Quantity;
else
_tableOrderMap[tableKey].Add(new OrderItem(item.ProductId, item.ProductName, item.UnitPrice, item.Quantity));
}
}
var zones = _tables.Select(t => t.Section).Distinct().ToList();
_sections = new[] { "Tất cả" }.Concat(zones).ToArray();
}
@@ -139,6 +178,31 @@
private void OpenTable(MobileTable t) => NavigateTo("restaurant/waiter-pad");
// ═══ TABLE ORDER HELPERS (synced with Desktop) ═══
private bool HasPendingOrder(MobileTable table) =>
_tableOrderMap.ContainsKey(table.Id) && _tableOrderMap[table.Id].Any();
private decimal GetTableOrderTotal(MobileTable table) =>
_tableOrderMap.TryGetValue(table.Id, out var items) ? items.Sum(i => i.Price * i.Qty) : 0;
// ═══ RESERVATION HELPERS (synced with Desktop) ═══
private WebClientTpos.Client.Services.PosDataService.ReservationInfo? GetTableReservation(MobileTable table)
{
if (!Guid.TryParse(table.Id, out var tableGuid)) return null;
var now = DateTime.Now;
return _reservations.FirstOrDefault(r =>
r.TableId == tableGuid &&
r.Status is "confirmed" or "pending" &&
Math.Abs((r.ReservationTime - now).TotalHours) < 2);
}
private string GetEffectiveStatus(MobileTable table)
{
if (HasPendingOrder(table)) return "occupied";
if (GetTableReservation(table) != null) return "reserved";
return table.Status;
}
private static string StatusColor(string s) => s switch
{
"available" => "var(--pos-success)", "occupied" => "var(--pos-orange-primary)",
@@ -156,5 +220,12 @@
"available" => "Trống", "occupied" => "Đang phục vụ", "reserved" => "Đã đặt", _ => s
};
private record MobileTable(int Number, string Name, int Seats, string Status, string Section, decimal Amount);
private record MobileTable(string Id, int Number, string Name, int Seats, string Status, string Section);
private class OrderItem(Guid productId, string name, decimal price, int qty)
{
public Guid ProductId { get; } = productId;
public string Name { get; } = name;
public decimal Price { get; } = price;
public int Qty { get; set; } = qty;
}
}

View File

@@ -36,15 +36,30 @@
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:16px;padding:12px 0;">
@foreach (var t in FilteredTables)
{
<div @onclick="() => _selected = t"
style="background:@StatusBg(t.Status);border-radius:var(--pos-radius);padding:20px;
var effectiveStatus = GetEffectiveStatus(t);
var hasReservation = GetTableReservation(t) is not null;
<div @onclick="() => SelectTable(t)"
style="background:@StatusBg(effectiveStatus);border-radius:var(--pos-radius);padding:20px;
text-align:center;cursor:pointer;min-height:100px;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:6px;
align-items:center;justify-content:center;gap:6px;position:relative;
border:3px solid @(_selected?.Id == t.Id ? "var(--pos-orange-primary)" : "transparent");
transition:all .2s ease;">
<span style="font-size:22px;font-weight:700;">@t.Name</span>
<span style="font-size:13px;color:rgba(255,255,255,.65);">@t.Seats chỗ</span>
<span style="font-size:12px;font-weight:600;margin-top:4px;">@StatusLabel(t.Status)</span>
<span style="font-size:12px;font-weight:600;margin-top:4px;">@StatusLabel(effectiveStatus)</span>
@if (hasReservation)
{
var res = GetTableReservation(t)!;
<div style="position:absolute;top:4px;right:4px;background:rgba(99,102,241,.9);color:#fff;font-size:9px;padding:2px 6px;border-radius:4px;font-weight:600;">
@res.ReservationTime.ToString("HH:mm")
</div>
}
@if (HasPendingOrder(t))
{
<div style="position:absolute;top:4px;left:4px;background:rgba(255,92,0,.9);color:#fff;font-size:9px;padding:2px 6px;border-radius:4px;font-weight:600;">
@FormatPrice(GetTableOrderTotal(t))
</div>
}
</div>
}
</div>
@@ -60,7 +75,7 @@
</div>
<div class="pos-cart-items">
@if (_selected.Status == "occupied")
@if (HasPendingOrder(_selected))
{
@foreach (var item in _items)
{
@@ -84,7 +99,7 @@
<div class="pos-cart-footer">
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng</span>
<span class="pos-cart-total__value">@FormatPrice(_selected.Status == "occupied" ? _items.Sum(i => i.Price * i.Qty) : 0)</span>
<span class="pos-cart-total__value">@FormatPrice(_items.Any() ? _items.Sum(i => i.Price * i.Qty) : 0)</span>
</div>
<button class="pos-btn-checkout" style="height:52px;font-size:16px;"
@onclick="@(() => NavigateTo("restaurant/waiter-pad"))">
@@ -119,12 +134,14 @@
private IEnumerable<TableInfo> FilteredTables =>
_activeSection == "Tất cả" ? _tables : _tables.Where(t => t.Section == _activeSection);
// EN: Demo order items for occupied tables / VI: Món mẫu cho bàn đang phục vụ
private readonly List<OrderItem> _items = new()
{
new("Bún bò Huế", 80_000, 1), new("Nem rán", 50_000, 2),
new("Cá kho tộ", 120_000, 1), new("Nước mía", 15_000, 2),
};
// EN: Order items for selected table (from API) / VI: Món cho bàn đang chọn (từ API)
private List<OrderItem> _items = new();
// EN: All active table orders from API / VI: Tất cả đơn active từ API
private Dictionary<string, List<OrderItem>> _tableOrderMap = new();
// EN: Reservations / VI: Đặt bàn
private List<WebClientTpos.Client.Services.PosDataService.ReservationInfo> _reservations = new();
protected override async Task OnInitializedAsync()
{
@@ -132,7 +149,15 @@
try
{
var apiTables = await DataService.GetTablesAsync(ShopId);
// EN: Load tables, active orders, and reservations in parallel / VI: Tải bàn, đơn active, và đặt bàn song song
var tablesTask = DataService.GetTablesAsync(ShopId);
var ordersTask = DataService.GetActiveTableOrdersAsync(ShopId);
var reservationsTask = DataService.GetReservationsAsync(ShopId, DateTime.Today.ToString("yyyy-MM-dd"));
await Task.WhenAll(tablesTask, ordersTask, reservationsTask);
var apiTables = await tablesTask;
var activeOrders = await ordersTask;
_reservations = await reservationsTask;
_tables = apiTables.Select(t => new TableInfo(
t.Id.ToString(),
@@ -142,6 +167,23 @@
t.Zone ?? "Trong nhà"
)).ToList();
// EN: Build order map per table / VI: Tạo map đơn theo bàn
foreach (var order in activeOrders)
{
if (order.TableId == null) continue;
var tableKey = order.TableId.Value.ToString();
if (!_tableOrderMap.ContainsKey(tableKey))
_tableOrderMap[tableKey] = new();
foreach (var item in order.Items)
{
var existing = _tableOrderMap[tableKey].FirstOrDefault(i => i.Name == item.ProductName);
if (existing != null)
existing.Qty += item.Quantity;
else
_tableOrderMap[tableKey].Add(new OrderItem(item.ProductName, item.UnitPrice, item.Quantity));
}
}
var zones = _tables.Select(t => t.Section).Distinct().ToList();
_sections = new[] { "Tất cả" }.Concat(zones).ToArray();
}
@@ -166,6 +208,43 @@
"available" => "Trống", "occupied" => "Đang phục vụ", "reserved" => "Đã đặt", _ => s
};
// EN: Select table and load its order items / VI: Chọn bàn và tải các món của bàn đó
private void SelectTable(TableInfo table)
{
_selected = table;
_items = _tableOrderMap.TryGetValue(table.Id, out var items) ? items : new();
}
// ═══ TABLE ORDER HELPERS (synced with Desktop) ═══
private bool HasPendingOrder(TableInfo table) =>
_tableOrderMap.ContainsKey(table.Id) && _tableOrderMap[table.Id].Any();
private decimal GetTableOrderTotal(TableInfo table) =>
_tableOrderMap.TryGetValue(table.Id, out var items) ? items.Sum(i => i.Price * i.Qty) : 0;
// ═══ RESERVATION HELPERS (synced with Desktop) ═══
private WebClientTpos.Client.Services.PosDataService.ReservationInfo? GetTableReservation(TableInfo table)
{
if (!Guid.TryParse(table.Id, out var tableGuid)) return null;
var now = DateTime.Now;
return _reservations.FirstOrDefault(r =>
r.TableId == tableGuid &&
r.Status is "confirmed" or "pending" &&
Math.Abs((r.ReservationTime - now).TotalHours) < 2);
}
private string GetEffectiveStatus(TableInfo table)
{
if (HasPendingOrder(table)) return "occupied";
if (GetTableReservation(table) != null) return "reserved";
return table.Status;
}
private record TableInfo(string Id, string Name, int Seats, string Status, string Section);
private record OrderItem(string Name, decimal Price, int Qty);
private class OrderItem(string name, decimal price, int qty)
{
public string Name { get; } = name;
public decimal Price { get; } = price;
public int Qty { get; set; } = qty;
}
}

View File

@@ -1,36 +1,59 @@
@*
EN: Course Timing — Multi-course meal timing management with fire buttons and timeline.
VI: Quản lý thời gian course — Quản lý thời gian tiệc nhiều món với nút fire và timeline.
Connected to kitchen tickets API for real course data and status updates.
VI: Quan ly thoi gian course — Quan ly thoi gian tiec nhieu mon voi nut fire va timeline.
Ket noi API kitchen tickets cho du lieu course thuc va cap nhat trang thai.
*@
@page "/pos/{ShopId:guid}/restaurant/course-timing/{OrderId:guid}"
@page "/pos/{ShopId:guid}/restaurant/course-timing"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@implements IDisposable
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@* ═══ HEADER / TIEU DE ═══ *@
<div style="padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);display:flex;align-items:center;gap:12px;">
<button class="pos-category-tab" @onclick="@(() => NavigateTo("restaurant"))">
<i data-lucide="arrow-left" style="width:14px;"></i> Quay li
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lai
</button>
<span style="font-size:16px;font-weight:700;">
<i data-lucide="timer" style="width:18px;height:18px;display:inline;"></i> Bàn 7 — Tiệc 5 món
<i data-lucide="timer" style="width:18px;height:18px;display:inline;"></i>
@(_headerTitle)
</span>
<span style="flex:1;"></span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">
<i data-lucide="clock" style="width:12px;height:12px;display:inline;"></i> Bt đầu: 18:30
<i data-lucide="clock" style="width:12px;height:12px;display:inline;"></i> Bat dau: @(_startTime?.ToString("HH:mm") ?? "--:--")
</span>
</div>
@* ═══ AUTO-FIRE RULE / QUY TẮC TỰ ĐỘNG ═══ *@
@if (_isLoading)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Dang tai...
</div>
}
else if (_loadError)
{
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:var(--pos-text-tertiary);">
<span>Khong the tai du lieu</span>
<button style="padding:8px 16px;border-radius:8px;border:none;background:var(--pos-orange-primary);color:#fff;cursor:pointer;"
@onclick="async () => await LoadDataAsync()">Thu lai</button>
</div>
}
else
{
@* ═══ AUTO-FIRE RULE / QUY TAC TU DONG ═══ *@
<div style="padding:10px 16px;border-bottom:1px solid var(--pos-border-subtle);">
<div style="background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.3);border-radius:8px;padding:10px 14px;
display:flex;align-items:center;gap:8px;">
<i data-lucide="zap" style="width:14px;height:14px;color:var(--pos-info);"></i>
<span style="font-size:12px;color:var(--pos-info);font-weight:600;">Tự động: Phc v mi món cách 15 phút</span>
<span style="font-size:12px;color:var(--pos-info);font-weight:600;">Tu dong: Phuc vu moi mon cach 15 phut</span>
</div>
</div>
@* ═══ COURSE LIST / DANH SÁCH COURSE ═══ *@
@* ═══ COURSE LIST / DANH SACH COURSE ═══ *@
<div style="flex:1;overflow-y:auto;padding:16px;">
<div style="display:flex;flex-direction:column;gap:12px;">
@foreach (var course in _courses)
@@ -40,7 +63,7 @@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;
border-left:4px solid @statusColor;">
@* EN: Course header / VI: Tiêu đề course *@
@* EN: Course header / VI: Tieu de course *@
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<div style="width:36px;height:36px;border-radius:50%;background:@CourseStatusBg(course.Status);
display:flex;align-items:center;justify-content:center;">
@@ -58,10 +81,10 @@
</div>
</div>
@* EN: Timeline bar / VI: Thanh thi gian *@
@* EN: Timeline bar / VI: Thanh thoi gian *@
<div style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">
<span>@course.EstTime phút</span>
<span>@course.EstTime phut</span>
<span>@course.Progress%</span>
</div>
<div style="height:6px;border-radius:3px;background:var(--pos-bg-interactive);overflow:hidden;">
@@ -70,21 +93,39 @@
</div>
</div>
@* EN: Fire button / VI: Nút kích hot *@
@* EN: Fire button / VI: Nut kich hoat *@
@if (course.Status == "queued")
{
<button style="width:100%;padding:8px;border-radius:8px;border:1px solid @statusColor;
background:@CourseStatusBg(course.Status);color:@statusColor;font-size:13px;font-weight:600;cursor:pointer;"
disabled="@course.IsUpdating"
@onclick="() => FireCourse(course)">
<i data-lucide="flame" style="width:14px;height:14px;display:inline;"></i> Fire — Bắt đầu nấu
@if (course.IsUpdating)
{
<span>Dang xu ly...</span>
}
else
{
<i data-lucide="flame" style="width:14px;height:14px;display:inline;"></i>
<span>Fire — Bat dau nau</span>
}
</button>
}
else if (course.Status == "cooking")
{
<button style="width:100%;padding:8px;border-radius:8px;border:none;
background:rgba(34,197,94,.15);color:var(--pos-success);font-size:13px;font-weight:600;cursor:pointer;"
disabled="@course.IsUpdating"
@onclick="() => ServeCourse(course)">
<i data-lucide="check" style="width:14px;height:14px;display:inline;"></i> Đã phục vụ
@if (course.IsUpdating)
{
<span>Dang xu ly...</span>
}
else
{
<i data-lucide="check" style="width:14px;height:14px;display:inline;"></i>
<span>Da phuc vu</span>
}
</button>
}
</div>
@@ -92,38 +133,238 @@
</div>
</div>
@* ═══ FOOTER STATS / THNG KÊ ═══ *@
@* ═══ FOOTER STATS / THONG KE ═══ *@
<div style="padding:10px 16px;border-top:1px solid var(--pos-border-subtle);display:flex;gap:20px;font-size:12px;color:var(--pos-text-secondary);">
<span style="color:var(--pos-success);">Đã phc v: @_courses.Count(c => c.Status == "served")</span>
<span style="color:var(--pos-warning);">Đang nu: @_courses.Count(c => c.Status == "cooking")</span>
<span style="color:var(--pos-text-tertiary);">Ch: @_courses.Count(c => c.Status == "queued")</span>
<span style="margin-left:auto;font-weight:600;">Tng: @_courses.Count course</span>
<span style="color:var(--pos-success);">Da phuc vu: @_courses.Count(c => c.Status == "served")</span>
<span style="color:var(--pos-warning);">Dang nau: @_courses.Count(c => c.Status == "cooking")</span>
<span style="color:var(--pos-text-tertiary);">Cho: @_courses.Count(c => c.Status == "queued")</span>
<span style="margin-left:auto;font-weight:600;">Tong: @_courses.Count course</span>
</div>
}
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
// EN: Optional route parameter for specific order / VI: Tham so route tuy chon cho don cu the
[Parameter] public Guid OrderId { get; set; }
// EN: Course list / VI: Danh sách course
private readonly List<CourseInfo> _courses = new()
{
new("Khai vị", "Gỏi cuốn tôm, Chả giò chiên", "salad", 15, "served", 100),
new("Soup", "Súp cua thập cẩm", "soup", 10, "cooking", 60),
new("Món chính", "Cá kho tộ, Gà nướng mật ong", "beef", 25, "queued", 0),
new("Phụ", "Cơm chiên dương châu, Rau muống xào", "carrot", 15, "queued", 0),
new("Tráng miệng", "Chè thái, Bánh flan", "ice-cream-cone", 10, "queued", 0),
};
// EN: Loading & error states / VI: Trang thai tai & loi
private bool _isLoading = true;
private bool _loadError;
private void FireCourse(CourseInfo course)
// EN: Auto-refresh timer (5 seconds) / VI: Timer tu dong refresh (5 giay)
private Timer? _refreshTimer;
// EN: Header info / VI: Thong tin tieu de
private string _headerTitle = "Quan ly course";
private DateTime? _startTime;
// EN: Course list from API / VI: Danh sach course tu API
private readonly List<CourseInfo> _courses = new();
// EN: Ticket ID mapping for API calls / VI: Anh xa ID ticket cho API call
private readonly Dictionary<string, List<PosDataService.KitchenTicketInfo>> _courseTicketMap = new();
protected override async Task OnInitializedAsync()
{
course.Status = "cooking";
course.Progress = 20;
await base.OnInitializedAsync();
await LoadDataAsync();
// EN: Start auto-refresh timer / VI: Bat dau timer tu dong refresh
_refreshTimer = new Timer(async _ =>
{
await InvokeAsync(async () =>
{
await LoadDataAsync(silent: true);
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
private void ServeCourse(CourseInfo course)
private async Task LoadDataAsync(bool silent = false)
{
course.Status = "served";
course.Progress = 100;
if (!silent) { _isLoading = true; _loadError = false; }
try
{
// EN: Fetch all kitchen tickets / VI: Lay tat ca kitchen tickets
var pendingTask = DataService.GetKitchenTicketsAsync(ShopId, "Pending");
var inProgressTask = DataService.GetKitchenTicketsAsync(ShopId, "InProgress");
var completedTask = DataService.GetKitchenTicketsAsync(ShopId, "Completed");
await Task.WhenAll(pendingTask, inProgressTask, completedTask);
var allTickets = (await pendingTask).Concat(await inProgressTask).Concat(await completedTask).ToList();
// EN: Filter by order if OrderId provided / VI: Loc theo don neu co OrderId
if (OrderId != Guid.Empty)
{
// EN: Try to match tickets by loading order detail
// VI: Thu khop tickets bang cach tai chi tiet don
var orderDetail = await DataService.GetOrderDetailAsync(OrderId, ShopId);
if (orderDetail?.Items != null)
{
var orderItemIds = orderDetail.Items.Select(i => i.Id).ToHashSet();
var orderItemNames = orderDetail.Items.Select(i => i.ProductName).Where(n => n != null).ToHashSet();
allTickets = allTickets.Where(t => orderItemIds.Contains(t.OrderItemId) || orderItemNames.Contains(t.ItemName)).ToList();
_headerTitle = $"Don #{OrderId.ToString()[..6].ToUpper()} — {orderDetail.Items.Count} mon";
}
}
else
{
_headerTitle = $"Tat ca — {allTickets.Count} ticket";
}
if (allTickets.Any())
{
_startTime = allTickets.Min(t => t.CreatedAt);
}
// EN: Group tickets by station/category, or by creation time if no station
// VI: Nhom tickets theo station/danh muc, hoac theo thoi gian tao neu khong co station
BuildCourses(allTickets);
}
catch
{
if (!silent) _loadError = true;
}
finally
{
if (!silent) _isLoading = false;
}
}
private void BuildCourses(List<PosDataService.KitchenTicketInfo> tickets)
{
if (!tickets.Any()) return;
// EN: Group by station if available, otherwise group by time batches (15 min intervals)
// VI: Nhom theo station neu co, neu khong nhom theo dot thoi gian (moi 15 phut)
var groups = tickets
.GroupBy(t => !string.IsNullOrEmpty(t.Station) ? t.Station : $"Course {((t.CreatedAt - tickets.Min(x => x.CreatedAt)).TotalMinutes / 15) + 1:F0}")
.ToList();
_courses.Clear();
_courseTicketMap.Clear();
var icons = new[] { "salad", "soup", "beef", "carrot", "ice-cream-cone", "utensils", "fish", "egg" };
int iconIdx = 0;
foreach (var group in groups)
{
var groupTickets = group.ToList();
var itemNames = string.Join(", ", groupTickets.Select(t => t.ItemName).Distinct().Take(3));
if (groupTickets.Count > 3) itemNames += $" (+{groupTickets.Count - 3})";
// EN: Determine course status from ticket statuses
// VI: Xac dinh trang thai course tu trang thai ticket
string status;
int progress;
if (groupTickets.All(t => t.Status == "Completed"))
{
status = "served";
progress = 100;
}
else if (groupTickets.Any(t => t.Status is "InProgress" or "Completed"))
{
status = "cooking";
var completedCount = groupTickets.Count(t => t.Status == "Completed");
progress = groupTickets.Count > 0 ? (completedCount * 100 / groupTickets.Count) : 0;
progress = Math.Max(progress, 20); // EN: At least 20% if cooking / VI: It nhat 20% neu dang nau
}
else
{
status = "queued";
progress = 0;
}
var courseKey = group.Key;
var estTime = groupTickets.Count * 5; // EN: Estimate 5 min per item / VI: Uoc tinh 5 phut moi mon
// EN: Preserve existing course if it exists (to keep IsUpdating state)
// VI: Giu course hien tai neu ton tai (de giu trang thai IsUpdating)
var existing = _courses.FirstOrDefault(c => c.Key == courseKey);
if (existing != null)
{
existing.Status = status;
existing.Progress = progress;
existing.Items = itemNames;
}
else
{
_courses.Add(new CourseInfo(courseKey, group.Key, itemNames, icons[iconIdx % icons.Length], estTime, status, progress));
}
_courseTicketMap[courseKey] = groupTickets;
iconIdx++;
}
// EN: Remove courses that no longer exist / VI: Xoa course khong con ton tai
_courses.RemoveAll(c => !_courseTicketMap.ContainsKey(c.Key));
}
private async void FireCourse(CourseInfo course)
{
if (!_courseTicketMap.TryGetValue(course.Key, out var tickets)) return;
course.IsUpdating = true;
StateHasChanged();
try
{
// EN: Update all pending tickets in this course to InProgress
// VI: Cap nhat tat ca ticket cho trong course nay sang InProgress
var pendingTickets = tickets.Where(t => t.Status == "Pending").ToList();
var tasks = pendingTickets.Select(t =>
DataService.UpdateTicketStatusAsync(t.Id, new PosDataService.UpdateTicketStatusRequest("InProgress"))
);
await Task.WhenAll(tasks);
course.Status = "cooking";
course.Progress = 20;
}
catch
{
// EN: Fallback — update locally / VI: Du phong — cap nhat cuc bo
course.Status = "cooking";
course.Progress = 20;
}
finally
{
course.IsUpdating = false;
StateHasChanged();
}
}
private async void ServeCourse(CourseInfo course)
{
if (!_courseTicketMap.TryGetValue(course.Key, out var tickets)) return;
course.IsUpdating = true;
StateHasChanged();
try
{
// EN: Update all tickets in this course to Completed
// VI: Cap nhat tat ca ticket trong course nay sang Completed
var nonCompletedTickets = tickets.Where(t => t.Status != "Completed").ToList();
var tasks = nonCompletedTickets.Select(t =>
DataService.UpdateTicketStatusAsync(t.Id, new PosDataService.UpdateTicketStatusRequest("Completed"))
);
await Task.WhenAll(tasks);
course.Status = "served";
course.Progress = 100;
}
catch
{
// EN: Fallback — update locally / VI: Du phong — cap nhat cuc bo
course.Status = "served";
course.Progress = 100;
}
finally
{
course.IsUpdating = false;
StateHasChanged();
}
}
private static string CourseStatusColor(string s) => s switch
@@ -140,16 +381,23 @@
private static string CourseStatusLabel(string s) => s switch
{
"served" => "Đã phc v", "cooking" => "Đang nu", "queued" => "Ch", _ => s
"served" => "Da phuc vu", "cooking" => "Dang nau", "queued" => "Cho", _ => s
};
private class CourseInfo(string name, string items, string icon, int estTime, string status, int progress)
public void Dispose()
{
_refreshTimer?.Dispose();
}
private class CourseInfo(string key, string name, string items, string icon, int estTime, string status, int progress)
{
public string Key { get; set; } = key;
public string Name { get; set; } = name;
public string Items { get; set; } = items;
public string Icon { get; set; } = icon;
public int EstTime { get; set; } = estTime;
public string Status { get; set; } = status;
public int Progress { get; set; } = progress;
public bool IsUpdating { get; set; }
}
}

View File

@@ -5,6 +5,7 @@
@page "/pos/{ShopId:guid}/restaurant/eod-report"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@@ -18,6 +19,20 @@
<button class="pos-category-tab"><i data-lucide="printer" style="width:14px;"></i> In báo cáo</button>
</div>
@if (_isLoading)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Đang tải báo cáo...
</div>
}
else if (_loadError)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Không thể tải dữ liệu báo cáo
</div>
}
else
{
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:16px;">
@* ═══ SUMMARY CARDS / THẺ TỔNG QUAN ═══ *@
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;">
@@ -88,46 +103,101 @@
</div>
</div>
</div>
}
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
// EN: Loading state / VI: Trạng thái tải
private bool _isLoading = true;
private bool _loadError;
// EN: Summary cards / VI: Thẻ tổng quan
private readonly List<SummaryCard> _summaryCards = new()
{
new("Doanh thu", "12,450,000₫", "trending-up", "var(--pos-orange-primary)", "+18% so với hôm qua"),
new("Lượt khách", "142", "users", "var(--pos-info)", "+12 so với hôm qua"),
new("Hóa đơn TB", "350,000₫", "receipt", "var(--pos-success)", "+5% so với hôm qua"),
new("Tổng Tip", "1,240,000₫", "heart", "var(--pos-warning)", "TB 35,000₫/đơn"),
new("Bàn phục vụ", "38", "layout-grid", "var(--pos-text-primary)", "12 bàn/ca"),
new("Thời gian TB", "52 phút", "clock", "var(--pos-text-secondary)", "Nhanh hơn 5 phút"),
};
private List<SummaryCard> _summaryCards = new();
// EN: Section breakdown / VI: Phân tích khu vực
private readonly List<SectionReport> _sectionData = new()
{
new("Trong nhà", 6_200_000, 68, 100, "var(--pos-orange-primary)"),
new("VIP", 3_800_000, 32, 61, "var(--pos-warning)"),
new("Ngoài trời", 2_450_000, 42, 39, "var(--pos-success)"),
};
private List<SectionReport> _sectionData = new();
// EN: Hourly data / VI: Dữ liệu theo giờ
private readonly List<HourData> _hourlyData = new()
{
new("10h", 15), new("11h", 45), new("12h", 95), new("13h", 80),
new("14h", 30), new("15h", 20), new("16h", 25), new("17h", 55),
new("18h", 100), new("19h", 90), new("20h", 70), new("21h", 40),
};
private List<HourData> _hourlyData = new();
// EN: Payment methods / VI: Phương thức thanh toán
private readonly List<PaymentReport> _paymentData = new()
private List<PaymentReport> _paymentData = new();
protected override async Task OnInitializedAsync()
{
new("Tiền mặt", "banknote", 18, 5_600_000),
new("Thẻ ngân hàng", "credit-card", 12, 4_200_000),
new("Chuyển khoản", "smartphone", 6, 1_850_000),
new("Ví điện tử", "wallet", 2, 800_000),
};
await base.OnInitializedAsync();
try
{
// EN: Load dashboard data for today / VI: Tải dữ liệu dashboard cho hôm nay
var dashboard = await DataService.GetPosDashboardAsync(ShopId, "today");
// EN: Map dashboard response to summary cards / VI: Ánh xạ response dashboard sang thẻ tổng quan
_summaryCards = new()
{
new("Doanh thu", FormatPrice(dashboard.Revenue), "trending-up", "var(--pos-orange-primary)", $"{dashboard.OrderCount} đơn hôm nay"),
new("Số đơn", dashboard.OrderCount.ToString(), "shopping-bag", "var(--pos-info)", $"TB {FormatPrice(dashboard.AvgOrderValue)}/đơn"),
new("Hóa đơn TB", FormatPrice(dashboard.AvgOrderValue), "receipt", "var(--pos-success)", $"Tổng {dashboard.ItemsSold} món"),
new("Sản phẩm bán", dashboard.ItemsSold.ToString(), "package", "var(--pos-warning)", $"Doanh thu {FormatPrice(dashboard.Revenue)}"),
};
// EN: Map hourly revenue data / VI: Ánh xạ dữ liệu doanh thu theo giờ
if (dashboard.HourlyRevenue?.Any() == true)
{
var maxRevenue = dashboard.HourlyRevenue.Max(h => h.Revenue);
_hourlyData = dashboard.HourlyRevenue.Select(h => new HourData(
h.HourLabel,
maxRevenue > 0 ? (int)(h.Revenue / maxRevenue * 100) : 0
)).ToList();
}
// EN: Map payment breakdown / VI: Ánh xạ phân tích PTTT
if (dashboard.PaymentBreakdown?.Any() == true)
{
static string MapIcon(string method) => method.ToLowerInvariant() switch
{
"cash" or "tiền mặt" => "banknote",
"card" or "thẻ" => "credit-card",
"transfer" or "chuyển khoản" or "bank_transfer" => "smartphone",
"qr" => "smartphone",
_ => "wallet"
};
static string MapMethodName(string method) => method.ToLowerInvariant() switch
{
"cash" => "Tiền mặt",
"card" => "Thẻ ngân hàng",
"transfer" or "bank_transfer" => "Chuyển khoản",
"qr" => "QR Code",
_ => method
};
_paymentData = dashboard.PaymentBreakdown.Select(p =>
new PaymentReport(MapMethodName(p.Method), MapIcon(p.Method), p.Count, p.Amount)).ToList();
}
// EN: Section breakdown — use popular items as proxy if no section data
// VI: Phân tích khu vực — dùng sản phẩm phổ biến thay thế nếu không có dữ liệu khu vực
if (dashboard.PopularItems?.Any() == true)
{
var maxRev = dashboard.PopularItems.Max(p => p.Revenue);
var colors = new[] { "var(--pos-orange-primary)", "var(--pos-warning)", "var(--pos-success)", "var(--pos-info)", "var(--pos-danger)" };
_sectionData = dashboard.PopularItems.Take(5).Select((p, i) => new SectionReport(
p.Name,
p.Revenue,
p.Qty,
maxRev > 0 ? (int)(p.Revenue / maxRev * 100) : 0,
colors[i % colors.Length]
)).ToList();
}
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
}
private record SummaryCard(string Label, string Value, string Icon, string Color, string Comparison);
private record SectionReport(string Name, decimal Revenue, int Covers, int Percent, string Color);

View File

@@ -6,6 +6,7 @@
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@implements IDisposable
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@@ -81,21 +82,21 @@
@if (status.Key == "new")
{
<button class="pos-btn-checkout" style="height:36px;font-size:13px;background:var(--pos-warning);"
@onclick="@(() => ticket.Status = "cooking")">
@onclick="@(() => UpdateStatus(ticket, "cooking"))">
<i data-lucide="chef-hat" style="width:14px;"></i> Bắt đầu nấu
</button>
}
else if (status.Key == "cooking")
{
<button class="pos-btn-checkout" style="height:36px;font-size:13px;background:var(--pos-success);"
@onclick="@(() => ticket.Status = "ready")">
@onclick="@(() => UpdateStatus(ticket, "ready"))">
<i data-lucide="check" style="width:14px;"></i> Sẵn sàng
</button>
}
else
{
<button class="pos-btn-checkout" style="height:36px;font-size:13px;background:var(--pos-bg-interactive);color:var(--pos-text-secondary);"
@onclick="@(() => ticket.Status = "served")">
@onclick="@(() => UpdateStatus(ticket, "served"))">
<i data-lucide="truck" style="width:14px;"></i> Đã mang ra
</button>
}
@@ -114,22 +115,18 @@
// EN: Loading state / VI: Trạng thái tải
private bool _isLoading = true;
private bool _loadError;
private System.Threading.Timer? _refreshTimer;
private readonly Dictionary<string, string> _statuses = new()
{
["new"] = "Mới", ["cooking"] = "Đang nấu", ["ready"] = "Sẵn sàng"
};
// EN: Demo kitchen tickets — needs kitchen_tickets API / VI: Phiếu bếp mẫu — cần API kitchen_tickets
private readonly List<KitchenTicket> _tickets = new()
{
new("Bàn 2", "new", 2, new() { new(2, "Gỏi cuốn", ""), new(1, "Phở bò tái", "Ít hành") }),
new("Bàn 3", "new", 5, new() { new(1, "Cá kho tộ", ""), new(1, "Cơm tấm sườn", "Thêm nước mắm"), new(3, "Trà đá", "") }),
new("Bàn 7", "cooking", 12, new() { new(1, "Lẩu thái", "Cay ít"), new(2, "Bia Sài Gòn", "") }),
new("Bàn 11", "cooking", 18, new() { new(1, "Gà nướng mật ong", ""), new(2, "Nước mía", "") }),
new("Bàn 6", "ready", 25, new() { new(1, "Bún bò Huế", ""), new(1, "Chả giò", "") }),
new("Bàn 10", "ready", 8, new() { new(2, "Cơm tấm sườn", ""), new(1, "Cà phê sữa", "") }),
};
// EN: Kitchen tickets from API / VI: Phiếu bếp từ API
private List<KitchenTicket> _tickets = new();
// EN: Table lookup for mapping ticket to table name / VI: Tra cứu bàn để ánh xạ phiếu sang tên bàn
private Dictionary<Guid, string> _tableLookup = new();
protected override async Task OnInitializedAsync()
{
@@ -137,9 +134,23 @@
try
{
// EN: Preload tables for reference — kitchen_tickets API not yet available
// VI: Tải trước bàn để tham chiếu — API kitchen_tickets chưa có
await DataService.GetTablesAsync(ShopId);
// EN: Load tables for lookup / VI: Tải bàn để tra cứu
var tables = await DataService.GetTablesAsync(ShopId);
_tableLookup = tables.ToDictionary(t => t.Id, t => $"Bàn {t.TableNumber}");
// EN: Fetch tickets by status in parallel / VI: Tải phiếu theo trạng thái song song
await LoadTicketsAsync();
// EN: Auto-refresh every 10 seconds / VI: Tự động làm mới mỗi 10 giây
_refreshTimer = new System.Threading.Timer(async _ =>
{
try
{
await LoadTicketsAsync();
await InvokeAsync(StateHasChanged);
}
catch { /* silently ignore refresh errors */ }
}, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
}
catch
{
@@ -151,6 +162,79 @@
}
}
private async Task LoadTicketsAsync()
{
// EN: Fetch Pending, InProgress, and Completed tickets in parallel
// VI: Tải phiếu Pending, InProgress, và Completed song song
var pendingTask = DataService.GetKitchenTicketsAsync(ShopId, "Pending");
var inProgressTask = DataService.GetKitchenTicketsAsync(ShopId, "InProgress");
var completedTask = DataService.GetKitchenTicketsAsync(ShopId, "Completed");
await Task.WhenAll(pendingTask, inProgressTask, completedTask);
var pending = await pendingTask;
var inProgress = await inProgressTask;
var completed = await completedTask;
var allTickets = new List<KitchenTicket>();
// EN: Map API tickets to UI model, group by SessionId for multi-item tickets
// VI: Ánh xạ phiếu API sang model UI, nhóm theo SessionId cho phiếu nhiều món
foreach (var group in pending.GroupBy(t => t.SessionId))
{
var first = group.First();
var minutes = (int)(DateTime.UtcNow - first.CreatedAt).TotalMinutes;
var tableName = _tableLookup.GetValueOrDefault(first.SessionId, $"#{first.SessionId.ToString()[..6]}");
allTickets.Add(new KitchenTicket(
first.Id, tableName, "new", Math.Max(0, minutes),
group.Select(t => new TicketItem(1, t.ItemName, "")).ToList()));
}
foreach (var group in inProgress.GroupBy(t => t.SessionId))
{
var first = group.First();
var minutes = (int)(DateTime.UtcNow - first.CreatedAt).TotalMinutes;
var tableName = _tableLookup.GetValueOrDefault(first.SessionId, $"#{first.SessionId.ToString()[..6]}");
allTickets.Add(new KitchenTicket(
first.Id, tableName, "cooking", Math.Max(0, minutes),
group.Select(t => new TicketItem(1, t.ItemName, "")).ToList()));
}
foreach (var group in completed.GroupBy(t => t.SessionId))
{
var first = group.First();
var minutes = (int)(DateTime.UtcNow - first.CreatedAt).TotalMinutes;
var tableName = _tableLookup.GetValueOrDefault(first.SessionId, $"#{first.SessionId.ToString()[..6]}");
allTickets.Add(new KitchenTicket(
first.Id, tableName, "ready", Math.Max(0, minutes),
group.Select(t => new TicketItem(1, t.ItemName, "")).ToList()));
}
_tickets = allTickets;
}
// EN: Update ticket status via API / VI: Cập nhật trạng thái phiếu qua API
private async Task UpdateStatus(KitchenTicket ticket, string newStatus)
{
try
{
var apiStatus = newStatus switch
{
"cooking" => "InProgress",
"ready" => "Completed",
"served" => "Completed",
_ => newStatus
};
var success = await DataService.UpdateTicketStatusAsync(ticket.Id,
new WebClientTpos.Client.Services.PosDataService.UpdateTicketStatusRequest(apiStatus));
if (success)
{
// EN: Reload tickets after status change / VI: Tải lại phiếu sau khi đổi trạng thái
await LoadTicketsAsync();
}
}
catch { /* silently ignore — next refresh will correct */ }
}
private static string ColumnBg(string s) => s switch
{
"new" => "rgba(239,68,68,.15)", "cooking" => "rgba(245,158,11,.15)",
@@ -166,8 +250,9 @@
private static string TimerBg(int mins) => mins > 15 ? "rgba(239,68,68,.2)" : mins > 10 ? "rgba(245,158,11,.2)" : "rgba(34,197,94,.2)";
private static string TimerColor(int mins) => mins > 15 ? "var(--pos-danger)" : mins > 10 ? "var(--pos-warning)" : "var(--pos-success)";
private class KitchenTicket(string table, string status, int minutes, List<TicketItem> items)
private class KitchenTicket(Guid id, string table, string status, int minutes, List<TicketItem> items)
{
public Guid Id { get; set; } = id;
public string Table { get; set; } = table;
public string Status { get; set; } = status;
public int Minutes { get; set; } = minutes;
@@ -175,4 +260,9 @@
}
private record TicketItem(int Qty, string Name, string Note);
public void Dispose()
{
_refreshTimer?.Dispose();
}
}

View File

@@ -119,26 +119,8 @@
private string? _expandedId;
private readonly string[] _filters = { "Tất cả", "Tiền mặt", "Thẻ", "Chuyển khoản" };
// EN: Demo order history — needs orders API / VI: Lịch sử đơn mẫu — cần API orders
private readonly List<HistoryOrder> _orders = new()
{
new("DH001", "B3", "10:15", "Nguyễn Văn A", 3, 285_000, "Tiền mặt",
new() { new("Phở bò tái", 75_000, 2), new("Gỏi cuốn", 45_000, 1), new("Trà đá", 10_000, 3), new("Chả giò", 40_000, 1) }),
new("DH002", "B7", "11:30", "Trần Thị B", 2, 395_000, "Thẻ",
new() { new("Lẩu thái", 250_000, 1), new("Nước mía", 15_000, 2), new("Cơm tấm sườn", 65_000, 1), new("Bia Sài Gòn", 25_000, 2) }),
new("DH003", "B2", "12:00", "Lê Minh C", 4, 520_000, "Chuyển khoản",
new() { new("Cá kho tộ", 120_000, 1), new("Gà nướng mật ong", 180_000, 1), new("Bún bò Huế", 80_000, 1), new("Trà đá", 10_000, 4), new("Bia Sài Gòn", 25_000, 4) }),
new("DH004", "B11", "12:45", "Phạm Đức D", 2, 175_000, "Tiền mặt",
new() { new("Cơm tấm sườn", 65_000, 1), new("Bún bò Huế", 80_000, 1), new("Trà đá", 10_000, 3) }),
new("DH005", "B6", "13:15", "Nguyễn Văn A", 3, 310_000, "Thẻ",
new() { new("Phở bò tái", 75_000, 2), new("Gỏi cuốn", 45_000, 2), new("Cà phê sữa", 29_000, 1), new("Súp cua", 55_000, 1) }),
new("DH006", "B9", "14:00", "Hoàng Thị E", 5, 680_000, "Chuyển khoản",
new() { new("Lẩu thái", 250_000, 1), new("Gà nướng mật ong", 180_000, 1), new("Chả giò", 40_000, 2), new("Nộm bò bóp thấu", 65_000, 1), new("Trà đá", 10_000, 5), new("Bia Sài Gòn", 25_000, 3) }),
new("DH007", "B4", "17:30", "Vũ Thành F", 2, 195_000, "Tiền mặt",
new() { new("Phở bò tái", 75_000, 1), new("Cá kho tộ", 120_000, 1) }),
new("DH008", "B1", "18:15", "Trần Thị B", 3, 340_000, "Thẻ",
new() { new("Bún bò Huế", 80_000, 2), new("Gỏi cuốn", 45_000, 2), new("Cà phê sữa", 29_000, 2), new("Bánh flan", 25_000, 2) }),
};
// EN: Order history from API / VI: Lịch sử đơn từ API
private List<HistoryOrder> _orders = new();
protected override async Task OnInitializedAsync()
{
@@ -146,9 +128,51 @@
try
{
// EN: Preload tables for reference — orders API not yet available
// VI: Tải trước bàn đ tham chiếu — API orders chưa có
await DataService.GetTablesAsync(ShopId);
// EN: Load orders (today, paid/completed) and tables in parallel
// VI: Tải đơn (hôm nay, đã thanh toán) và bàn song song
var ordersTask = DataService.GetOrdersAsync(ShopId, "today");
var tablesTask = DataService.GetTablesAsync(ShopId);
await Task.WhenAll(ordersTask, tablesTask);
var apiOrders = await ordersTask;
var tables = await tablesTask;
var tableLookup = tables.ToDictionary(t => t.Id, t => $"B{t.TableNumber}");
// EN: Map API payment method to Vietnamese label / VI: Ánh xạ PTTT API sang nhãn tiếng Việt
static string MapPaymentMethod(string? method) => method?.ToLowerInvariant() switch
{
"cash" => "Tiền mặt",
"card" => "Thẻ",
"transfer" or "bank_transfer" => "Chuyển khoản",
"qr" => "Chuyển khoản",
_ => method ?? "Tiền mặt"
};
// EN: Load order details for each order to get items / VI: Tải chi tiết mỗi đơn để lấy danh sách món
var historyOrders = new List<HistoryOrder>();
foreach (var order in apiOrders.Where(o => o.Status is "Paid" or "Completed" or "Validated"))
{
var items = new List<OrderItem>();
try
{
var detail = await DataService.GetOrderDetailAsync(order.Id, ShopId);
if (detail?.Items != null)
items = detail.Items.Select(i => new OrderItem(i.ProductName ?? "Món", i.UnitPrice, i.Quantity)).ToList();
}
catch { /* use empty items if detail fails */ }
historyOrders.Add(new HistoryOrder(
order.Id.ToString()[..8].ToUpper(),
tableLookup.Values.FirstOrDefault() ?? "--",
order.CreatedAt.ToLocalTime().ToString("HH:mm"),
"POS",
order.ItemCount > 0 ? order.ItemCount : items.Count,
order.TotalAmount,
MapPaymentMethod(order.PaymentMethod),
items));
}
_orders = historyOrders;
}
catch
{

View File

@@ -3,8 +3,11 @@
VI: Chi tiết bàn — Xem hóa đơn với danh sách món, tạm tính, phí dịch vụ, VAT, tổng, tách hóa đơn.
*@
@page "/pos/{ShopId:guid}/restaurant/table-detail"
@page "/pos/{ShopId:guid}/restaurant/table-detail/{TableId:guid}"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@inject NavigationManager Navigation
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:24px;">
<div style="width:100%;max-width:480px;background:var(--pos-bg-elevated);border-radius:16px;
@@ -15,14 +18,34 @@
<i data-lucide="arrow-left" style="width:14px;"></i>
</button>
<div style="flex:1;">
<div style="font-size:16px;font-weight:700;">Bàn 3 — Hóa đơn</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">PV: Nguyễn Văn A · 18:45</div>
<div style="font-size:16px;font-weight:700;">@_tableName — Hóa đơn</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">POS · @DateTime.Now.ToString("HH:mm")</div>
</div>
<span style="font-size:12px;padding:4px 10px;border-radius:6px;background:rgba(255,92,0,.15);color:var(--pos-orange-primary);font-weight:600;">
6 khách
@_items.Count món
</span>
</div>
@if (_isLoading)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:40px;color:var(--pos-text-tertiary);">
Đang tải hóa đơn...
</div>
}
else if (_loadError)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:40px;color:var(--pos-text-tertiary);">
Không thể tải dữ liệu
</div>
}
else if (!_items.Any())
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:40px;color:var(--pos-text-tertiary);">
Chưa có món nào cho bàn này
</div>
}
else
{
@* ═══ ORDERED ITEMS / CÁC MÓN ĐÃ GỌI ═══ *@
<div style="flex:1;overflow-y:auto;padding:12px 20px;">
@foreach (var item in _items)
@@ -78,32 +101,112 @@
</div>
}
<button class="pos-btn-checkout">
<button class="pos-btn-checkout" @onclick="GoToPayment">
<i data-lucide="credit-card"></i> Thanh toán
</button>
</div>
}
</div>
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
// EN: Optional table ID from route / VI: ID bàn tùy chọn từ route
[Parameter] public Guid? TableId { get; set; }
// EN: Loading state / VI: Trạng thái tải
private bool _isLoading = true;
private bool _loadError;
private string _splitMode = "full";
private int _splitCount = 2;
private string _tableName = "Bàn";
// EN: Demo bill items / VI: Các món trong hóa đơn mẫu
private readonly List<BillItem> _items = new()
{
new("Gỏi cuốn", 45_000, 2), new("Chả giò", 40_000, 1),
new("Phở bò tái", 75_000, 2), new("Cá kho tộ", 120_000, 1),
new("Cơm tấm sườn", 65_000, 1), new("Lẩu thái", 250_000, 1),
new("Trà đá", 10_000, 4), new("Bia Sài Gòn", 25_000, 3),
};
// EN: Bill items loaded from API / VI: Các món hóa đơn tải từ API
private List<BillItem> _items = new();
// EN: Order IDs for payment / VI: ID đơn hàng cho thanh toán
private List<Guid> _orderIds = new();
private decimal Subtotal => _items.Sum(i => i.Price * i.Qty);
private decimal ServiceCharge => Math.Round(Subtotal * 0.05m);
private decimal Vat => Math.Round(Subtotal * 0.08m);
private decimal Total => Subtotal + ServiceCharge + Vat;
private record BillItem(string Name, decimal Price, int Qty);
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
// EN: Load tables to find table name / VI: Tải bàn để tìm tên bàn
var tablesTask = DataService.GetTablesAsync(ShopId);
var ordersTask = DataService.GetActiveTableOrdersAsync(ShopId);
await Task.WhenAll(tablesTask, ordersTask);
var tables = await tablesTask;
var activeOrders = await ordersTask;
// EN: If TableId provided, use it; otherwise try first table with orders
// VI: Nếu có TableId, dùng nó; ngược lại thử bàn đầu tiên có đơn
var targetTableId = TableId;
if (!targetTableId.HasValue)
{
// EN: Try to get table from query string / VI: Thử lấy bàn từ query string
var uri = new Uri(Navigation.Uri);
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
if (Guid.TryParse(query["tableId"], out var qsTableId))
targetTableId = qsTableId;
}
if (!targetTableId.HasValue)
{
// EN: Use first occupied table / VI: Dùng bàn đầu tiên đang phục vụ
targetTableId = activeOrders.FirstOrDefault(o => o.TableId.HasValue)?.TableId;
}
// EN: Find table name / VI: Tìm tên bàn
var table = tables.FirstOrDefault(t => t.Id == targetTableId);
if (table != null)
_tableName = $"Bàn {table.TableNumber}";
// EN: Filter active orders for the target table / VI: Lọc đơn active cho bàn mục tiêu
var tableOrders = activeOrders.Where(o => o.TableId == targetTableId).ToList();
_orderIds = tableOrders.Select(o => o.OrderId).ToList();
// EN: Aggregate items from all orders for this table / VI: Gộp món từ tất cả đơn cho bàn này
var itemMap = new Dictionary<Guid, BillItem>();
foreach (var order in tableOrders)
{
foreach (var item in order.Items)
{
if (itemMap.TryGetValue(item.ProductId, out var existing))
existing.Qty += item.Quantity;
else
itemMap[item.ProductId] = new BillItem(item.ProductName, item.UnitPrice, item.Quantity);
}
}
_items = itemMap.Values.ToList();
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
}
// EN: Navigate to payment flow / VI: Chuyển đến luồng thanh toán
private void GoToPayment()
{
NavigateTo("restaurant");
}
private class BillItem(string name, decimal price, int qty)
{
public string Name { get; } = name;
public decimal Price { get; } = price;
public int Qty { get; set; } = qty;
}
}

View File

@@ -1,6 +1,8 @@
@*
EN: Full Table Map — Drag arrangement, sections (Indoor/Outdoor/VIP), merge/split tables.
VI: Sơ đồ bàn đầy đủ — Kéo thả sắp xếp, khu vực (Trong nhà/Ngoài trời/VIP), gộp/tách bàn.
Connected to tables API with add/merge/split functionality.
VI: So do ban day du — Keo tha sap xep, khu vuc (Trong nha/Ngoai troi/VIP), gop/tach ban.
Ket noi API ban voi chuc nang them/gop/tach.
*@
@page "/pos/{ShopId:guid}/restaurant/table-map"
@layout PosLayout
@@ -8,30 +10,75 @@
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ TOOLBAR / THANH CÔNG C ═══ *@
@* ═══ TOOLBAR / THANH CONG CU ═══ *@
<div style="padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);display:flex;align-items:center;gap:12px;">
<button class="pos-category-tab pos-category-tab--active" @onclick="@(() => NavigateTo("restaurant"))">
<i data-lucide="arrow-left" style="width:14px;"></i> Quay li
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lai
</button>
<span style="flex:1;font-size:16px;font-weight:700;">Qun lý sơ đồ bàn</span>
<span style="flex:1;font-size:16px;font-weight:700;">Quan ly so do ban</span>
<div style="display:flex;gap:8px;">
<button class="pos-category-tab" @onclick="MergeTables"><i data-lucide="merge" style="width:14px;"></i> Gộp bàn</button>
<button class="pos-category-tab" @onclick="SplitTable"><i data-lucide="split" style="width:14px;"></i> Tách bàn</button>
<button class="pos-category-tab" @onclick="AddTable"><i data-lucide="plus" style="width:14px;"></i> Thêm bàn</button>
<button class="pos-category-tab" @onclick="MergeTables" disabled="@(_selectedIds.Count < 2)">
<i data-lucide="merge" style="width:14px;"></i> Gop ban
</button>
<button class="pos-category-tab" @onclick="SplitTable" disabled="@(_selectedIds.Count != 1)">
<i data-lucide="split" style="width:14px;"></i> Tach ban
</button>
<button class="pos-category-tab" @onclick="ToggleAddForm">
<i data-lucide="plus" style="width:14px;"></i> Them ban
</button>
</div>
</div>
@* ═══ SECTION TABS / TAB KHU VỰC ═══ *@
@* ═══ ADD TABLE FORM / FORM THEM BAN ═══ *@
@if (_showAddForm)
{
<div style="padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);background:var(--pos-bg-elevated);">
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
<div style="display:flex;flex-direction:column;gap:4px;">
<label style="font-size:11px;color:var(--pos-text-tertiary);">So ban</label>
<input type="text" @bind="_newTableNumber" placeholder="VD: 15"
style="padding:8px 12px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:13px;width:100px;" />
</div>
<div style="display:flex;flex-direction:column;gap:4px;">
<label style="font-size:11px;color:var(--pos-text-tertiary);">Suc chua</label>
<input type="number" @bind="_newTableCapacity" min="1" max="20"
style="padding:8px 12px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:13px;width:80px;" />
</div>
<div style="display:flex;flex-direction:column;gap:4px;">
<label style="font-size:11px;color:var(--pos-text-tertiary);">Khu vuc</label>
<select @bind="_newTableZone"
style="padding:8px 12px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:13px;width:140px;">
<option value="Trong nha">Trong nha</option>
<option value="Ngoai troi">Ngoai troi</option>
<option value="VIP">VIP</option>
<option value="San thuong">San thuong</option>
</select>
</div>
<button style="padding:8px 16px;border-radius:8px;border:none;background:var(--pos-orange-primary);color:#fff;font-size:13px;font-weight:600;cursor:pointer;min-width:90px;"
disabled="@_isCreating" @onclick="CreateTable">
@(_isCreating ? "Dang tao..." : "Tao ban")
</button>
<button style="padding:8px 16px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:13px;cursor:pointer;"
@onclick="() => _showAddForm = false">Huy</button>
</div>
@if (!string.IsNullOrEmpty(_formMessage))
{
<div style="margin-top:8px;font-size:12px;color:@(_formIsError ? "var(--pos-danger)" : "var(--pos-success)");">@_formMessage</div>
}
</div>
}
@* ═══ SECTION TABS / TAB KHU VUC ═══ *@
@if (_isLoading)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Đang ti...
Dang tai...
</div>
}
else if (_loadError)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Không th ti d liu
Khong the tai du lieu
</div>
}
else
@@ -44,7 +91,7 @@
}
</div>
@* ═══ TABLE GRID / LƯỚI BÀN ═══ *@
@* ═══ TABLE GRID / LUOI BAN ═══ *@
<div style="flex:1;overflow-y:auto;padding:16px;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:14px;">
@foreach (var t in FilteredTables)
@@ -63,7 +110,7 @@
}
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">@t.Section</div>
<div style="font-size:20px;font-weight:700;">@t.Name</div>
<div style="font-size:12px;color:rgba(255,255,255,.7);margin-top:4px;">@t.Seats ch · @t.Shape</div>
<div style="font-size:12px;color:rgba(255,255,255,.7);margin-top:4px;">@t.Seats cho · @t.Shape</div>
<div style="font-size:11px;font-weight:600;margin-top:6px;color:@StatusTextColor(t.Status);">
@StatusLabel(t.Status)
</div>
@@ -74,38 +121,74 @@
}
@* ═══ FOOTER STATS / THỐNG KÊ ═══ *@
@* ═══ MERGE CONFIRMATION / XAC NHAN GOP ═══ *@
@if (_showMergeConfirm)
{
<div style="padding:12px 16px;border-top:1px solid var(--pos-border-subtle);background:rgba(255,92,0,.08);">
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-size:13px;font-weight:600;color:var(--pos-orange-primary);">
Gop @_selectedIds.Count ban: @string.Join(" + ", _tables.Where(t => _selectedIds.Contains(t.Id)).Select(t => t.Name))
</span>
<span style="flex:1;"></span>
<button style="padding:8px 16px;border-radius:8px;border:none;background:var(--pos-orange-primary);color:#fff;font-size:13px;font-weight:600;cursor:pointer;"
@onclick="ConfirmMerge">Xac nhan gop</button>
<button style="padding:8px 16px;border-radius:8px;border:1px solid var(--pos-border-default);background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:13px;cursor:pointer;"
@onclick="CancelMerge">Huy</button>
</div>
</div>
}
@* ═══ FOOTER STATS / THONG KE ═══ *@
<div style="padding:10px 16px;border-top:1px solid var(--pos-border-subtle);display:flex;gap:20px;font-size:12px;color:var(--pos-text-secondary);">
<span>Tng: <b>@_tables.Count</b> bàn</span>
<span style="color:var(--pos-success);">Trng: @_tables.Count(t => t.Status == "available")</span>
<span style="color:var(--pos-orange-primary);">Đang PV: @_tables.Count(t => t.Status == "occupied")</span>
<span style="color:var(--pos-info);">Đã đặt: @_tables.Count(t => t.Status == "reserved")</span>
<span>Tong: <b>@_tables.Count</b> ban</span>
<span style="color:var(--pos-success);">Trong: @_tables.Count(t => t.Status == "available")</span>
<span style="color:var(--pos-orange-primary);">Dang PV: @_tables.Count(t => t.Status == "occupied")</span>
<span style="color:var(--pos-info);">Da dat: @_tables.Count(t => t.Status == "reserved")</span>
@if (_selectedIds.Count > 0)
{
<span style="margin-left:auto;color:var(--pos-orange-primary);font-weight:600;">Đã chn: @_selectedIds.Count bàn</span>
<span style="margin-left:auto;color:var(--pos-orange-primary);font-weight:600;">Da chon: @_selectedIds.Count ban</span>
}
</div>
</div>
@code {
// EN: Loading state / VI: Trng thái ti
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
private string _activeSection = "Tt c";
private string[] _sections = { "Tt c" };
private string _activeSection = "Tat ca";
private string[] _sections = { "Tat ca" };
private readonly HashSet<string> _selectedIds = new();
// EN: Table data from API / VI: Dữ liệu bàn từ API
// EN: Add table form state / VI: Trang thai form them ban
private bool _showAddForm;
private string _newTableNumber = "";
private int _newTableCapacity = 4;
private string _newTableZone = "Trong nha";
private bool _isCreating;
private string? _formMessage;
private bool _formIsError;
// EN: Merge state / VI: Trang thai gop ban
private bool _showMergeConfirm;
// EN: Table data from API / VI: Du lieu ban tu API
private List<MapTable> _tables = new();
private IEnumerable<MapTable> FilteredTables =>
_activeSection == "Tt c" ? _tables : _tables.Where(t => t.Section == _activeSection);
_activeSection == "Tat ca" ? _tables : _tables.Where(t => t.Section == _activeSection);
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadTablesAsync();
}
private async Task LoadTablesAsync()
{
_isLoading = true;
_loadError = false;
try
{
@@ -113,15 +196,15 @@
_tables = apiTables.Select(t => new MapTable(
t.Id.ToString(),
$"Bàn {t.TableNumber}",
$"Ban {t.TableNumber}",
t.Capacity,
t.Status ?? "available",
t.Zone ?? "Trong nhà",
t.Capacity <= 2 ? "Tròn" : t.Capacity <= 6 ? "Vuông" : "Ch nht"
t.Zone ?? "Trong nha",
t.Capacity <= 2 ? "Tron" : t.Capacity <= 6 ? "Vuong" : "Chu nhat"
)).ToList();
var zones = _tables.Select(t => t.Section).Distinct().ToList();
_sections = new[] { "Tt c" }.Concat(zones).ToArray();
_sections = new[] { "Tat ca" }.Concat(zones).ToArray();
}
catch
{
@@ -136,11 +219,114 @@
private void ToggleSelect(MapTable t)
{
if (!_selectedIds.Add(t.Id)) _selectedIds.Remove(t.Id);
// EN: Hide merge confirm if selection changes / VI: An xac nhan gop khi chon thay doi
_showMergeConfirm = false;
}
private void MergeTables() => _selectedIds.Clear();
private void SplitTable() => _selectedIds.Clear();
private void AddTable() { /* placeholder */ }
private void ToggleAddForm()
{
_showAddForm = !_showAddForm;
_formMessage = null;
_newTableNumber = "";
_newTableCapacity = 4;
_newTableZone = "Trong nha";
}
private async Task CreateTable()
{
if (string.IsNullOrWhiteSpace(_newTableNumber))
{
_formMessage = "Vui long nhap so ban";
_formIsError = true;
return;
}
_isCreating = true;
_formMessage = null;
try
{
var req = new WebClientTpos.Client.Services.PosDataService.CreateTableRequest(
ShopId,
_newTableNumber.Trim(),
_newTableCapacity,
_newTableZone
);
var success = await DataService.CreateTableAsync(req);
if (success)
{
_formMessage = $"Da tao ban {_newTableNumber} thanh cong!";
_formIsError = false;
_newTableNumber = "";
_newTableCapacity = 4;
// EN: Reload tables from API / VI: Tai lai ban tu API
await LoadTablesAsync();
}
else
{
_formMessage = "Khong the tao ban. Vui long thu lai.";
_formIsError = true;
}
}
catch
{
_formMessage = "Loi khi tao ban. Vui long thu lai.";
_formIsError = true;
}
finally
{
_isCreating = false;
}
}
private void MergeTables()
{
if (_selectedIds.Count < 2) return;
_showMergeConfirm = true;
}
private void ConfirmMerge()
{
// EN: Visual merge — combine selected table names into first table
// VI: Gop hinh — ghep ten cac ban duoc chon vao ban dau tien
var selectedTables = _tables.Where(t => _selectedIds.Contains(t.Id)).ToList();
if (selectedTables.Count < 2) return;
var mergedName = string.Join("+", selectedTables.Select(t => t.Name.Replace("Ban ", "")));
var totalSeats = selectedTables.Sum(t => t.Seats);
var primary = selectedTables.First();
// EN: Update primary table to show merged name
// VI: Cap nhat ban chinh de hien ten da gop
var primaryIdx = _tables.FindIndex(t => t.Id == primary.Id);
if (primaryIdx >= 0)
{
_tables[primaryIdx] = primary with { Name = $"Ban {mergedName}", Seats = totalSeats };
}
// EN: Remove other tables from visual list (they remain in DB)
// VI: Xoa cac ban khac khoi danh sach hien (chung van ton tai trong DB)
foreach (var t in selectedTables.Skip(1))
{
_tables.RemoveAll(x => x.Id == t.Id);
}
_selectedIds.Clear();
_showMergeConfirm = false;
}
private void CancelMerge()
{
_showMergeConfirm = false;
}
private void SplitTable()
{
if (_selectedIds.Count != 1) return;
// EN: Navigate to table-merge-split page / VI: Chuyen den trang gop/tach ban
NavigateTo("restaurant/table-merge-split");
}
private static string StatusBg(string s) => s switch
{
@@ -156,7 +342,7 @@
private static string StatusLabel(string s) => s switch
{
"available" => "Trng", "occupied" => "Đang phc v", "reserved" => "Đã đặt", _ => s
"available" => "Trong", "occupied" => "Dang phuc vu", "reserved" => "Da dat", _ => s
};
private record MapTable(string Id, string Name, int Seats, string Status, string Section, string Shape);

View File

@@ -1,37 +1,41 @@
@*
EN: Waiter Pad — Order taking by course with special requests, send to kitchen.
VI: Pad Phục vụ — Gọi món theo course với yêu cầu đặc biệt, gửi bếp.
Connected to POS order API for order creation with table association.
VI: Pad Phuc vu — Goi mon theo course voi yeu cau dac biet, gui bep.
Ket noi API POS order de tao don voi lien ket ban.
*@
@page "/pos/{ShopId:guid}/restaurant/waiter-pad/{TableId:guid}"
@page "/pos/{ShopId:guid}/restaurant/waiter-pad"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@using WebClientTpos.Client.Services
@inject PosDataService DataService
<div class="pos-product-panel" style="display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@* ═══ HEADER / TIEU DE ═══ *@
<div style="padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);display:flex;align-items:center;gap:12px;">
<button class="pos-category-tab" @onclick="@(() => NavigateTo("restaurant"))">
<i data-lucide="arrow-left" style="width:14px;"></i> Quay li
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lai
</button>
<span style="font-size:16px;font-weight:700;">Gi món — Bàn 3</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">PV: Nguyễn Văn A</span>
<span style="font-size:16px;font-weight:700;">Goi mon@(_tableName != null ? $" — {_tableName}" : "")</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">PV: @(string.IsNullOrEmpty(StaffName) ? "Nhan vien" : StaffName)</span>
</div>
@if (_isLoading)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Đang ti...
Dang tai...
</div>
}
else if (_loadError)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Không th ti d liu
Khong the tai du lieu
</div>
}
else
{
@* ═══ COURSE TABS / TAB MÓN THEO COURSE ═══ *@
@* ═══ COURSE TABS / TAB MON THEO COURSE ═══ *@
<div class="pos-category-tabs">
@foreach (var c in _courses)
{
@@ -40,7 +44,7 @@
}
</div>
@* ═══ MENU ITEMS / DANH SÁCH MÓN ═══ *@
@* ═══ MENU ITEMS / DANH SACH MON ═══ *@
<div class="pos-product-grid" style="grid-template-columns:repeat(auto-fill,minmax(150px,1fr));">
@foreach (var item in FilteredMenu)
{
@@ -56,12 +60,12 @@
}
</div>
@* ═══ ORDER PANEL / PANEL ĐƠN GI ═══ *@
@* ═══ ORDER PANEL / PANEL DON GOI ═══ *@
<div class="pos-cart-panel">
<div class="pos-cart-header">
<span class="pos-cart-header__title">Đơn gi (@_orderItems.Count món)</span>
<span class="pos-cart-header__title">Don goi (@_orderItems.Count mon)</span>
<button style="background:none;border:none;color:var(--pos-danger);font-size:12px;cursor:pointer;"
@onclick="ClearOrder">Xóa hết</button>
@onclick="ClearOrder">Xoa het</button>
</div>
<div class="pos-cart-items">
@@ -73,11 +77,11 @@
<span class="pos-cart-item__price">@FormatPrice(item.Price)</span>
@if (!string.IsNullOrEmpty(item.Note))
{
<span style="font-size:11px;color:var(--pos-warning);font-style:italic;">📝 @item.Note</span>
<span style="font-size:11px;color:var(--pos-warning);font-style:italic;">&#128221; @item.Note</span>
}
</div>
<div class="pos-cart-item__qty">
<button @onclick="() => ChangeQty(item, -1)"></button>
<button @onclick="() => ChangeQty(item, -1)">-</button>
<span style="font-size:13px;font-weight:600;">@item.Qty</span>
<button @onclick="() => ChangeQty(item, 1)">+</button>
</div>
@@ -85,39 +89,61 @@
}
</div>
@* ═══ SPECIAL REQUEST / YÊU CU ĐẶC BIT ═══ *@
@* ═══ SPECIAL REQUEST / YEU CAU DAC BIET ═══ *@
<div style="padding:8px 16px;">
<input type="text" @bind="_specialRequest" placeholder="Ghi chú đặc bit..."
<input type="text" @bind="_specialRequest" placeholder="Ghi chu dac biet..."
style="width:100%;padding:10px 12px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:13px;font-family:var(--pos-font);" />
</div>
@if (!string.IsNullOrEmpty(_submitMessage))
{
<div style="padding:0 16px 8px;font-size:12px;color:@(_submitIsError ? "var(--pos-danger)" : "var(--pos-success)");">@_submitMessage</div>
}
<div class="pos-cart-footer">
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tng</span>
<span class="pos-cart-total__label">Tong</span>
<span class="pos-cart-total__value">@FormatPrice(_orderItems.Sum(i => i.Price * i.Qty))</span>
</div>
<button class="pos-btn-checkout" @onclick="SendToKitchen">
<i data-lucide="send"></i> Gửi bếp
<button class="pos-btn-checkout" disabled="@(_isSending || !_orderItems.Any())" @onclick="SendToKitchen">
@if (_isSending)
{
<span>Dang gui...</span>
}
else
{
<i data-lucide="send"></i>
<span>Gui bep</span>
}
</button>
</div>
</div>
@code {
// EN: Loading state / VI: Trạng thái tải
// EN: Route parameter for table association / VI: Tham so route de lien ket ban
[Parameter] public Guid TableId { get; set; }
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
private string _activeCourse = "Tất cả";
private string _specialRequest = string.Empty;
private string[] _courses = { "Tất cả" };
// EN: Submit state / VI: Trang thai gui
private bool _isSending;
private string? _submitMessage;
private bool _submitIsError;
// EN: Menu items from API / VI: Thực đơn từ API
private string _activeCourse = "Tat ca";
private string _specialRequest = string.Empty;
private string[] _courses = { "Tat ca" };
private string? _tableName;
// EN: Menu items from API / VI: Thuc don tu API
private List<MenuItem> _menu = new();
private IEnumerable<MenuItem> FilteredMenu =>
_activeCourse == "Tt c" ? _menu : _menu.Where(m => m.Course == _activeCourse);
_activeCourse == "Tat ca" ? _menu : _menu.Where(m => m.Course == _activeCourse);
protected override async Task OnInitializedAsync()
{
@@ -125,21 +151,46 @@
try
{
var products = await DataService.GetProductsAsync(ShopId);
var categories = await DataService.GetCategoriesAsync(ShopId);
// EN: Load products and categories in parallel / VI: Tai san pham va danh muc song song
var productsTask = DataService.GetProductsAsync(ShopId);
var categoriesTask = DataService.GetCategoriesAsync(ShopId);
// EN: Load table info if TableId provided / VI: Tai thong tin ban neu co TableId
Task<List<PosDataService.TableInfo>>? tablesTask = null;
if (TableId != Guid.Empty)
{
tablesTask = DataService.GetTablesAsync(ShopId);
}
await Task.WhenAll(productsTask, categoriesTask);
if (tablesTask != null) await tablesTask;
var products = await productsTask;
var categories = await categoriesTask;
var categoryMap = categories.ToDictionary(c => c.Id, c => c.Name);
_menu = products.Select(p => new MenuItem(
p.Id,
p.Name,
p.Price,
p.Category ?? categoryMap.GetValueOrDefault(Guid.Empty, "Khác"),
p.Category ?? categoryMap.GetValueOrDefault(Guid.Empty, "Khac"),
"utensils"
)).ToList();
var courseNames = _menu.Select(m => m.Course).Distinct().ToList();
_courses = new[] { "Tt c" }.Concat(courseNames).ToArray();
_courses = new[] { "Tat ca" }.Concat(courseNames).ToArray();
_activeCourse = _courses.First();
// EN: Set table name if found / VI: Dat ten ban neu tim thay
if (tablesTask != null)
{
var tables = await tablesTask;
var table = tables.FirstOrDefault(t => t.Id == TableId);
if (table != null)
{
_tableName = $"Ban {table.TableNumber}";
}
}
}
catch
{
@@ -151,17 +202,14 @@
}
}
// EN: Current order / VI: Đơn gi hin ti
private readonly List<OrderLine> _orderItems = new()
{
new("Gỏi cuốn", 45_000, 2, ""), new("Phở bò tái", 75_000, 1, "Ít hành"),
};
// EN: Current order / VI: Don goi hien tai
private readonly List<OrderLine> _orderItems = new();
private void AddToOrder(MenuItem item)
{
var existing = _orderItems.FirstOrDefault(o => o.Name == item.Name);
var existing = _orderItems.FirstOrDefault(o => o.ProductId == item.ProductId);
if (existing is not null) existing.Qty++;
else _orderItems.Add(new OrderLine(item.Name, item.Price, 1, ""));
else _orderItems.Add(new OrderLine(item.ProductId, item.Name, item.Price, 1, ""));
}
private void ChangeQty(OrderLine item, int delta)
@@ -170,12 +218,68 @@
if (item.Qty <= 0) _orderItems.Remove(item);
}
private void ClearOrder() => _orderItems.Clear();
private void SendToKitchen() => NavigateTo("restaurant/kitchen-display");
private record MenuItem(string Name, decimal Price, string Course, string Icon);
private class OrderLine(string name, decimal price, int qty, string note)
private void ClearOrder()
{
_orderItems.Clear();
_submitMessage = null;
}
private async Task SendToKitchen()
{
if (!_orderItems.Any()) return;
_isSending = true;
_submitMessage = null;
try
{
// EN: Build order items with product IDs / VI: Tao danh sach mon voi ID san pham
var items = _orderItems.Select(o => new PosDataService.PosOrderItemRequest(
o.ProductId,
o.Name,
o.Qty,
o.Price,
"Physical"
)).ToList();
var req = new PosDataService.CreatePosOrderRequest(
ShopId,
null, // EN: No payment method yet / VI: Chua co phuong thuc thanh toan
items,
TableId: TableId != Guid.Empty ? TableId : null
);
var result = await DataService.CreatePosOrderAsync(req);
if (result != null)
{
// EN: Order created successfully — clear cart and navigate
// VI: Tao don thanh cong — xoa gio va chuyen trang
_orderItems.Clear();
_specialRequest = string.Empty;
NavigateTo("restaurant/kitchen-display");
}
else
{
_submitMessage = "Khong the tao don. Vui long thu lai.";
_submitIsError = true;
}
}
catch
{
_submitMessage = "Loi khi gui bep. Vui long thu lai.";
_submitIsError = true;
}
finally
{
_isSending = false;
}
}
private record MenuItem(Guid ProductId, string Name, decimal Price, string Course, string Icon);
private class OrderLine(Guid productId, string name, decimal price, int qty, string note)
{
public Guid ProductId { get; set; } = productId;
public string Name { get; set; } = name;
public decimal Price { get; set; } = price;
public int Qty { get; set; } = qty;

View File

@@ -3,200 +3,226 @@
VI: Tách hóa đơn — Chia đều, chia theo món, chia tùy chỉnh cho hóa đơn chung.
*@
@page "/pos/{ShopId:guid}/dialog/split-bill"
@page "/pos/{ShopId:guid}/dialog/split-bill/{OrderId:guid}"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject IJSRuntime JS
@inject PosDataService DataService
<div class="pos-dialog-overlay">
<div style="width:100%;max-width:720px;background:var(--pos-bg-elevated);border-radius:16px;
display:flex;flex-direction:column;max-height:92vh;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
<div style="padding:20px 24px;border-bottom:1px solid var(--pos-border-subtle);display:flex;align-items:center;gap:12px;">
<div style="width:40px;height:40px;border-radius:10px;background:rgba(59,130,246,.15);display:flex;align-items:center;justify-content:center;">
<i data-lucide="split" style="width:20px;height:20px;color:#3B82F6;"></i>
</div>
<div style="flex:1;">
<div style="font-size:18px;font-weight:700;">Tách hóa đơn</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">Tổng: @FormatPrice(_billTotal) · Bàn 3 · 6 khách</div>
</div>
<button style="background:none;border:none;color:var(--pos-text-tertiary);cursor:pointer;padding:8px;"
@onclick="@(async () => await JS.InvokeVoidAsync("history.back"))">
<i data-lucide="x" style="width:20px;height:20px;"></i>
</button>
@if (_isLoading)
{
<div class="pos-dialog-overlay">
<div style="text-align:center;">
<div style="width:48px;height:48px;border:4px solid var(--pos-border-subtle);border-top-color:var(--pos-orange-primary);
border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 16px;"></div>
<div style="color:var(--pos-text-tertiary);font-size:14px;">Đang tải đơn hàng...</div>
</div>
</div>
}
else
{
<div class="pos-dialog-overlay">
<div style="width:100%;max-width:720px;background:var(--pos-bg-elevated);border-radius:16px;
display:flex;flex-direction:column;max-height:92vh;overflow:hidden;">
@* ═══ SPLIT MODE TABS / TAB CHẾ ĐỘ CHIA ═══ *@
<div style="padding:12px 24px;border-bottom:1px solid var(--pos-border-subtle);display:flex;gap:8px;">
@foreach (var mode in _modes)
{
<button style="flex:1;padding:10px;border-radius:var(--pos-radius);border:2px solid @(_activeMode == mode.Key ? "var(--pos-orange-primary)" : "var(--pos-border-default)");
background:@(_activeMode == mode.Key ? "rgba(255,92,0,.1)" : "transparent");color:var(--pos-text-primary);
cursor:pointer;font-size:13px;font-weight:@(_activeMode == mode.Key ? "600" : "400");"
@onclick="() => _activeMode = mode.Key">
@mode.Label
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
<div style="padding:20px 24px;border-bottom:1px solid var(--pos-border-subtle);display:flex;align-items:center;gap:12px;">
<div style="width:40px;height:40px;border-radius:10px;background:rgba(59,130,246,.15);display:flex;align-items:center;justify-content:center;">
<i data-lucide="split" style="width:20px;height:20px;color:#3B82F6;"></i>
</div>
<div style="flex:1;">
<div style="font-size:18px;font-weight:700;">Tách hóa đơn</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">Tổng: @FormatPrice(_billTotal) · @_splitCount người</div>
</div>
<button style="background:none;border:none;color:var(--pos-text-tertiary);cursor:pointer;padding:8px;"
@onclick="@(async () => await JS.InvokeVoidAsync("history.back"))">
<i data-lucide="x" style="width:20px;height:20px;"></i>
</button>
}
</div>
</div>
<div style="flex:1;overflow-y:auto;padding:24px;">
@if (_activeMode == "equal")
{
@* ═══ EQUAL SPLIT / CHIA ĐỀU ═══ *@
<div style="text-align:center;margin-bottom:24px;">
<div style="font-size:14px;font-weight:600;margin-bottom:16px;">Số người chia</div>
<div style="display:flex;gap:8px;justify-content:center;margin-bottom:20px;">
@for (var i = 2; i <= 10; i++)
{
var count = i;
<button style="width:44px;height:44px;border-radius:10px;border:2px solid @(_splitCount == count ? "var(--pos-orange-primary)" : "var(--pos-border-default)");
background:@(_splitCount == count ? "rgba(255,92,0,.1)" : "var(--pos-bg-interactive)");
color:var(--pos-text-primary);cursor:pointer;font-size:16px;font-weight:600;"
@onclick="() => _splitCount = count">
@count
</button>
}
</div>
<div style="font-size:42px;font-weight:700;color:var(--pos-orange-primary);margin-bottom:8px;">
@FormatPrice(Math.Round(_billTotal / _splitCount))
</div>
<div style="font-size:14px;color:var(--pos-text-tertiary);">mỗi người</div>
</div>
@* EN: Per-person breakdown / VI: Chi tiết từng người *@
<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:10px;">
@for (var i = 1; i <= _splitCount; i++)
{
var personNum = i;
<div style="background:var(--pos-bg-interactive);border-radius:var(--pos-radius);padding:14px;text-align:center;">
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:6px;">Người @personNum</div>
<div style="font-size:16px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(Math.Round(_billTotal / _splitCount))</div>
</div>
}
</div>
}
else if (_activeMode == "byitem")
{
@* ═══ BY-ITEM SPLIT / CHIA THEO MÓN ═══ *@
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;">
@for (var p = 0; p < 3; p++)
{
var personIdx = p;
<div style="background:var(--pos-bg-interactive);border-radius:var(--pos-radius);padding:14px;">
<div style="font-size:14px;font-weight:600;margin-bottom:10px;text-align:center;
padding-bottom:8px;border-bottom:1px solid var(--pos-border-subtle);">
Người @(personIdx + 1)
</div>
@foreach (var item in _billItems.Where(i => i.AssignedTo == personIdx))
{
<div style="display:flex;justify-content:space-between;padding:6px 0;font-size:12px;">
<span>@item.Name</span>
<span style="font-weight:600;">@FormatPrice(item.Price)</span>
</div>
}
<div style="display:flex;justify-content:space-between;padding-top:8px;margin-top:8px;
border-top:1px solid var(--pos-border-subtle);font-size:14px;font-weight:700;">
<span>Tổng</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(_billItems.Where(i => i.AssignedTo == personIdx).Sum(i => i.Price))</span>
</div>
</div>
}
</div>
@* EN: Unassigned items / VI: Món chưa gán *@
@if (UnassignedItems.Any())
@* ═══ SPLIT MODE TABS / TAB CHẾ ĐỘ CHIA ═══ *@
<div style="padding:12px 24px;border-bottom:1px solid var(--pos-border-subtle);display:flex;gap:8px;">
@foreach (var mode in _modes)
{
<div style="margin-top:16px;padding:14px;background:rgba(245,158,11,.08);border-radius:var(--pos-radius);
border:1px dashed var(--pos-warning);">
<div style="font-size:13px;font-weight:600;color:var(--pos-warning);margin-bottom:8px;">Chưa phân</div>
@foreach (var item in UnassignedItems)
<button style="flex:1;padding:10px;border-radius:var(--pos-radius);border:2px solid @(_activeMode == mode.Key ? "var(--pos-orange-primary)" : "var(--pos-border-default)");
background:@(_activeMode == mode.Key ? "rgba(255,92,0,.1)" : "transparent");color:var(--pos-text-primary);
cursor:pointer;font-size:13px;font-weight:@(_activeMode == mode.Key ? "600" : "400");"
@onclick="() => _activeMode = mode.Key">
@mode.Label
</button>
}
</div>
<div style="flex:1;overflow-y:auto;padding:24px;">
@if (_activeMode == "equal")
{
@* ═══ EQUAL SPLIT / CHIA ĐỀU ═══ *@
<div style="text-align:center;margin-bottom:24px;">
<div style="font-size:14px;font-weight:600;margin-bottom:16px;">Số người chia</div>
<div style="display:flex;gap:8px;justify-content:center;margin-bottom:20px;">
@for (var i = 2; i <= 8; i++)
{
var count = i;
<button style="width:44px;height:44px;border-radius:10px;border:2px solid @(_splitCount == count ? "var(--pos-orange-primary)" : "var(--pos-border-default)");
background:@(_splitCount == count ? "rgba(255,92,0,.1)" : "var(--pos-bg-interactive)");
color:var(--pos-text-primary);cursor:pointer;font-size:16px;font-weight:600;"
@onclick="() => UpdateSplitCount(count)">
@count
</button>
}
</div>
<div style="font-size:42px;font-weight:700;color:var(--pos-orange-primary);margin-bottom:8px;">
@FormatPrice(Math.Round(_billTotal / _splitCount))
</div>
<div style="font-size:14px;color:var(--pos-text-tertiary);">mỗi người</div>
</div>
@* EN: Per-person breakdown / VI: Chi tiết từng người *@
<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:10px;">
@for (var i = 1; i <= _splitCount; i++)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;font-size:12px;">
<span>@item.Name — @FormatPrice(item.Price)</span>
<div style="display:flex;gap:4px;">
@for (var p = 0; p < 3; p++)
{
var targetPerson = p;
<button style="width:28px;height:28px;border-radius:6px;border:1px solid var(--pos-border-default);
background:var(--pos-bg-elevated);color:var(--pos-text-primary);cursor:pointer;font-size:11px;"
@onclick="() => item.AssignedTo = targetPerson">
@(targetPerson + 1)
</button>
}
</div>
var personNum = i;
<div style="background:var(--pos-bg-interactive);border-radius:var(--pos-radius);padding:14px;text-align:center;cursor:pointer;"
@onclick="() => PayForPerson(personNum)">
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:6px;">Người @personNum</div>
<div style="font-size:16px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(Math.Round(_billTotal / _splitCount))</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:4px;">Nhấn để thanh toán</div>
</div>
}
</div>
}
}
else
{
@* ═══ CUSTOM SPLIT / CHIA TÙY CHỈNH ═══ *@
<div style="display:flex;flex-direction:column;gap:12px;">
@for (var i = 0; i < 3; i++)
else if (_activeMode == "byitem")
{
@* ═══ BY-ITEM SPLIT / CHIA THEO MÓN ═══ *@
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;">
@for (var p = 0; p < 3; p++)
{
var personIdx = p;
<div style="background:var(--pos-bg-interactive);border-radius:var(--pos-radius);padding:14px;">
<div style="font-size:14px;font-weight:600;margin-bottom:10px;text-align:center;
padding-bottom:8px;border-bottom:1px solid var(--pos-border-subtle);">
Người @(personIdx + 1)
</div>
@foreach (var item in _billItems.Where(i => i.AssignedTo == personIdx))
{
<div style="display:flex;justify-content:space-between;padding:6px 0;font-size:12px;">
<span>@item.Name</span>
<span style="font-weight:600;">@FormatPrice(item.Price)</span>
</div>
}
<div style="display:flex;justify-content:space-between;padding-top:8px;margin-top:8px;
border-top:1px solid var(--pos-border-subtle);font-size:14px;font-weight:700;">
<span>Tổng</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(_billItems.Where(i => i.AssignedTo == personIdx).Sum(i => i.Price))</span>
</div>
</div>
}
</div>
@* EN: Unassigned items / VI: Món chưa gán *@
@if (UnassignedItems.Any())
{
var idx = i;
<div style="display:flex;align-items:center;gap:12px;background:var(--pos-bg-interactive);
border-radius:var(--pos-radius);padding:14px;">
<div style="width:36px;height:36px;border-radius:8px;background:var(--pos-bg-elevated);
display:flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;">
@(idx + 1)
</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;margin-bottom:4px;">Người @(idx + 1)</div>
<input type="number" @bind="_customAmounts[idx]" placeholder="Nhập số tiền..."
style="width:100%;padding:8px 12px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:var(--pos-bg-elevated);color:var(--pos-text-primary);font-size:14px;font-weight:600;" />
</div>
<div style="font-size:14px;font-weight:700;color:var(--pos-orange-primary);min-width:80px;text-align:right;">
@FormatPrice(_customAmounts[idx])
</div>
<div style="margin-top:16px;padding:14px;background:rgba(245,158,11,.08);border-radius:var(--pos-radius);
border:1px dashed var(--pos-warning);">
<div style="font-size:13px;font-weight:600;color:var(--pos-warning);margin-bottom:8px;">Chưa phân</div>
@foreach (var item in UnassignedItems)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;font-size:12px;">
<span>@item.Name — @FormatPrice(item.Price)</span>
<div style="display:flex;gap:4px;">
@for (var p = 0; p < 3; p++)
{
var targetPerson = p;
<button style="width:28px;height:28px;border-radius:6px;border:1px solid var(--pos-border-default);
background:var(--pos-bg-elevated);color:var(--pos-text-primary);cursor:pointer;font-size:11px;"
@onclick="() => item.AssignedTo = targetPerson">
@(targetPerson + 1)
</button>
}
</div>
</div>
}
</div>
}
</div>
}
else
{
@* ═══ CUSTOM SPLIT / CHIA TÙY CHỈNH ═══ *@
<div style="display:flex;flex-direction:column;gap:12px;">
@for (var i = 0; i < _splitCount; i++)
{
var idx = i;
<div style="display:flex;align-items:center;gap:12px;background:var(--pos-bg-interactive);
border-radius:var(--pos-radius);padding:14px;">
<div style="width:36px;height:36px;border-radius:8px;background:var(--pos-bg-elevated);
display:flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;">
@(idx + 1)
</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;margin-bottom:4px;">Người @(idx + 1)</div>
<input type="number" @bind="_customAmounts[idx]" placeholder="Nhập số tiền..."
style="width:100%;padding:8px 12px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:var(--pos-bg-elevated);color:var(--pos-text-primary);font-size:14px;font-weight:600;" />
</div>
<div style="font-size:14px;font-weight:700;color:var(--pos-orange-primary);min-width:80px;text-align:right;">
@FormatPrice(_customAmounts[idx])
</div>
</div>
}
</div>
@* EN: Remaining amount / VI: Số tiền còn lại *@
<div style="margin-top:16px;padding:14px;border-radius:var(--pos-radius);
background:@(CustomRemaining == 0 ? "rgba(34,197,94,.1)" : "rgba(245,158,11,.1)");
border:1px solid @(CustomRemaining == 0 ? "rgba(34,197,94,.3)" : "rgba(245,158,11,.3)");
display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:13px;font-weight:600;color:@(CustomRemaining == 0 ? "var(--pos-success)" : "var(--pos-warning)");">
@(CustomRemaining == 0 ? "Đã chia hết!" : "Còn thiếu")
</span>
<span style="font-size:16px;font-weight:700;color:@(CustomRemaining == 0 ? "var(--pos-success)" : "var(--pos-warning)");">
@FormatPrice(Math.Abs(CustomRemaining))
</span>
</div>
}
</div>
@* EN: Remaining amount / VI: Số tiền còn lại *@
<div style="margin-top:16px;padding:14px;border-radius:var(--pos-radius);
background:@(CustomRemaining == 0 ? "rgba(34,197,94,.1)" : "rgba(245,158,11,.1)");
border:1px solid @(CustomRemaining == 0 ? "rgba(34,197,94,.3)" : "rgba(245,158,11,.3)");
display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:13px;font-weight:600;color:@(CustomRemaining == 0 ? "var(--pos-success)" : "var(--pos-warning)");">
@(CustomRemaining == 0 ? "Đã chia hết!" : "Còn thiếu")
</span>
<span style="font-size:16px;font-weight:700;color:@(CustomRemaining == 0 ? "var(--pos-success)" : "var(--pos-warning)");">
@FormatPrice(Math.Abs(CustomRemaining))
</span>
</div>
}
</div>
@* ═══ FOOTER / CUỐI TRANG ═══ *@
<div style="padding:16px 24px;border-top:1px solid var(--pos-border-subtle);display:flex;gap:10px;">
<button style="flex:1;padding:14px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-primary);cursor:pointer;font-size:14px;"
@onclick="@(async () => await JS.InvokeVoidAsync("history.back"))">
Hủy
</button>
<button style="flex:2;padding:14px;border-radius:var(--pos-radius);border:none;
background:var(--pos-orange-primary);color:#fff;cursor:pointer;font-size:14px;font-weight:600;"
@onclick="GenerateSplitBills">
<i data-lucide="receipt" style="width:16px;height:16px;vertical-align:middle;margin-right:4px;"></i>
Tạo hóa đơn riêng
</button>
@* ═══ FOOTER / CUỐI TRANG ═══ *@
<div style="padding:16px 24px;border-top:1px solid var(--pos-border-subtle);display:flex;gap:10px;">
<button style="flex:1;padding:14px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-primary);cursor:pointer;font-size:14px;"
@onclick="@(async () => await JS.InvokeVoidAsync("history.back"))">
Hủy
</button>
<button style="flex:2;padding:14px;border-radius:var(--pos-radius);border:none;
background:var(--pos-orange-primary);color:#fff;cursor:pointer;font-size:14px;font-weight:600;"
@onclick="GenerateSplitBills">
<i data-lucide="receipt" style="width:16px;height:16px;vertical-align:middle;margin-right:4px;"></i>
Tạo hóa đơn riêng
</button>
</div>
</div>
</div>
</div>
}
<style>
@@keyframes spin { to { transform: rotate(360deg); } }
</style>
@code {
// EN: Dialog data — populated from current context (selected order/product).
// VI: Dữ liệu dialog — được điền từ context hiện tại (đơn hàng/sản phẩm đã chọn).
// TODO: Integrate with Order/Catalog/Inventory APIs when DDD Value Object mapping is fixed.
// EN: OrderId from route parameter or query string.
// VI: OrderId từ route parameter hoặc query string.
[Parameter] public Guid? OrderId { get; set; }
[SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; }
private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty;
private bool _isLoading = true;
// EN: Split state / VI: Trạng thái tách
private string _activeMode = "equal";
private int _splitCount = 3;
private decimal _billTotal = 850_000;
private int _splitCount = 2;
private decimal _billTotal;
// EN: Split mode definitions / VI: Định nghĩa chế độ chia
private readonly List<SplitMode> _modes = new()
@@ -207,28 +233,78 @@
};
// EN: Bill items for by-item split / VI: Danh sách món để chia theo món
private readonly List<SplitItem> _billItems = new()
{
new("Lẩu thái", 250_000, 0),
new("Phở bò tái", 75_000, 0),
new("Cơm tấm sườn", 65_000, 1),
new("Cá kho tộ", 120_000, 1),
new("Gỏi cuốn", 45_000, 2),
new("Chả giò", 40_000, 2),
new("Bia Sài Gòn", 75_000, -1),
new("Trà đá", 40_000, -1),
new("Bánh flan", 50_000, -1),
new("Nước mía", 90_000, -1),
};
private List<SplitItem> _billItems = new();
// EN: Custom amounts / VI: Số tiền tùy chỉnh
private decimal[] _customAmounts = { 300_000, 300_000, 250_000 };
private decimal[] _customAmounts = new decimal[8];
// EN: Computed properties for template / VI: Thuộc tính tính toán cho template
private List<SplitItem> UnassignedItems => _billItems.Where(i => i.AssignedTo < 0).ToList();
private decimal CustomRemaining => _billTotal - _customAmounts.Sum();
private decimal CustomRemaining => _billTotal - _customAmounts.Take(_splitCount).Sum();
private async Task GenerateSplitBills() => await JS.InvokeVoidAsync("history.back");
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadOrderAsync();
}
private async Task LoadOrderAsync()
{
_isLoading = true;
try
{
if (_resolvedOrderId == Guid.Empty) { _isLoading = false; return; }
var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId);
if (detail?.Order != null)
{
_billTotal = detail.Order.TotalAmount;
}
if (detail?.Items != null)
{
_billItems = detail.Items.Select(i => new SplitItem(
i.ProductName ?? "Sản phẩm",
i.Subtotal,
-1 // EN: Unassigned by default / VI: Chưa phân mặc định
)).ToList();
}
// EN: Initialize custom amounts with equal split default.
// VI: Khởi tạo số tiền tùy chỉnh với chia đều mặc định.
UpdateCustomAmountsDefault();
}
catch { /* graceful */ }
finally { _isLoading = false; }
}
private void UpdateSplitCount(int count)
{
_splitCount = count;
UpdateCustomAmountsDefault();
}
private void UpdateCustomAmountsDefault()
{
var perPerson = Math.Round(_billTotal / _splitCount);
for (var i = 0; i < _customAmounts.Length; i++)
_customAmounts[i] = i < _splitCount ? perPerson : 0;
// EN: Adjust last person to account for rounding.
// VI: Điều chỉnh người cuối để bù làm tròn.
if (_splitCount > 0)
_customAmounts[_splitCount - 1] = _billTotal - perPerson * (_splitCount - 1);
}
private void PayForPerson(int personNum)
{
// EN: Navigate to payment method select for this split portion.
// VI: Điều hướng đến chọn phương thức thanh toán cho phần chia này.
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}");
}
private void GenerateSplitBills()
{
// EN: Navigate first person to payment method select.
// VI: Điều hướng người đầu tiên đến chọn phương thức thanh toán.
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}");
}
private record SplitMode(string Key, string Label);

View File

@@ -3,78 +3,175 @@
VI: Chuyển khoản ngân hàng — Thông tin tài khoản, mã tham chiếu, xác minh chuyển khoản.
*@
@page "/pos/{ShopId:guid}/payment/bank-transfer"
@page "/pos/{ShopId:guid}/payment/bank-transfer/{OrderId:guid}"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject PosDataService DataService
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:32px;gap:24px;">
@* ═══ ORDER TOTAL ═══ *@
<div style="text-align:center;">
<div style="font-size:14px;color:var(--pos-text-tertiary);margin-bottom:4px;">Tổng thanh toán</div>
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(_orderTotal)</div>
</div>
@* ═══ BANK ACCOUNT INFO ═══ *@
<div style="width:100%;max-width:420px;background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:24px;display:flex;flex-direction:column;gap:16px;">
<div style="font-size:16px;font-weight:600;text-align:center;margin-bottom:4px;">Thông tin chuyển khoản</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="font-size:13px;color:var(--pos-text-tertiary);">Ngân hàng</span>
<span style="font-size:14px;font-weight:600;">@_bankName</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="font-size:13px;color:var(--pos-text-tertiary);">Số tài khoản</span>
<span style="font-size:14px;font-weight:600;letter-spacing:1px;">@_accountNumber</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="font-size:13px;color:var(--pos-text-tertiary);">Chủ tài khoản</span>
<span style="font-size:14px;font-weight:600;">@_accountHolder</span>
</div>
@* EN: Transfer reference / VI: Mã tham chiếu *@
<div style="padding:16px;background:rgba(255,92,0,0.08);border:1px dashed var(--pos-orange-primary);border-radius:8px;text-align:center;">
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:6px;">Nội dung chuyển khoản</div>
<div style="font-size:18px;font-weight:700;color:var(--pos-orange-primary);letter-spacing:2px;">@_referenceCode</div>
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;">
<div style="text-align:center;">
<div style="width:48px;height:48px;border:4px solid var(--pos-border-subtle);border-top-color:var(--pos-orange-primary);
border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 16px;"></div>
<div style="color:var(--pos-text-tertiary);font-size:14px;">Đang tải...</div>
</div>
</div>
@* ═══ STATUS ═══ *@
<div style="display:flex;align-items:center;gap:8px;color:var(--pos-warning);font-size:14px;">
<div style="width:8px;height:8px;border-radius:50%;background:var(--pos-warning);animation:pulse 2s ease-in-out infinite;"></div>
Chờ xác nhận chuyển khoản...
</div>
@* ═══ ACTIONS ═══ *@
<div style="display:flex;gap:12px;">
<button style="display:flex;align-items:center;gap:6px;padding:12px 24px;border-radius:var(--pos-radius);border:none;
background:var(--pos-success);color:#fff;cursor:pointer;font-size:14px;font-weight:600;"
@onclick="Verify">
<i data-lucide="check-circle" style="width:16px;height:16px;"></i> Xác nhận đã nhận
</button>
}
else if (!string.IsNullOrEmpty(_errorMessage))
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;">
<div style="color:var(--pos-danger);font-size:16px;font-weight:600;">@_errorMessage</div>
<button style="padding:12px 24px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:14px;"
@onclick="Cancel">
Hủy
</button>
@onclick="Cancel">Quay lại</button>
</div>
</div>
}
else
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:32px;gap:24px;">
@* ═══ ORDER TOTAL ═══ *@
<div style="text-align:center;">
<div style="font-size:14px;color:var(--pos-text-tertiary);margin-bottom:4px;">Tổng thanh toán</div>
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(_orderTotal)</div>
</div>
@* ═══ BANK ACCOUNT INFO ═══ *@
<div style="width:100%;max-width:420px;background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:24px;display:flex;flex-direction:column;gap:16px;">
<div style="font-size:16px;font-weight:600;text-align:center;margin-bottom:4px;">Thông tin chuyển khoản</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="font-size:13px;color:var(--pos-text-tertiary);">Ngân hàng</span>
<span style="font-size:14px;font-weight:600;">@_bankName</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="font-size:13px;color:var(--pos-text-tertiary);">Số tài khoản</span>
<span style="font-size:14px;font-weight:600;letter-spacing:1px;">@_accountNumber</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px;background:var(--pos-bg-interactive);border-radius:8px;">
<span style="font-size:13px;color:var(--pos-text-tertiary);">Chủ tài khoản</span>
<span style="font-size:14px;font-weight:600;">@_accountHolder</span>
</div>
@* EN: Transfer reference / VI: Mã tham chiếu *@
<div style="padding:16px;background:rgba(255,92,0,0.08);border:1px dashed var(--pos-orange-primary);border-radius:8px;text-align:center;">
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:6px;">Nội dung chuyển khoản</div>
<div style="font-size:18px;font-weight:700;color:var(--pos-orange-primary);letter-spacing:2px;">@_referenceCode</div>
</div>
</div>
@* ═══ STATUS ═══ *@
@if (_isProcessing)
{
<div style="display:flex;align-items:center;gap:8px;color:var(--pos-text-tertiary);font-size:14px;">
<div style="width:8px;height:8px;border-radius:50%;background:var(--pos-orange-primary);animation:pulse 2s ease-in-out infinite;"></div>
Đang xử lý thanh toán...
</div>
}
else
{
<div style="display:flex;align-items:center;gap:8px;color:var(--pos-warning);font-size:14px;">
<div style="width:8px;height:8px;border-radius:50%;background:var(--pos-warning);animation:pulse 2s ease-in-out infinite;"></div>
Chờ xác nhận chuyển khoản...
</div>
}
@* ═══ ACTIONS ═══ *@
<div style="display:flex;gap:12px;">
<button style="display:flex;align-items:center;gap:6px;padding:12px 24px;border-radius:var(--pos-radius);border:none;
background:var(--pos-success);color:#fff;cursor:pointer;font-size:14px;font-weight:600;"
disabled="@_isProcessing"
@onclick="Verify">
<i data-lucide="check-circle" style="width:16px;height:16px;"></i>
@(_isProcessing ? "Đang xử lý..." : "Xác nhận đã nhận")
</button>
<button style="padding:12px 24px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:14px;"
disabled="@_isProcessing"
@onclick="Cancel">
Hủy
</button>
</div>
</div>
}
<style>
@@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
@@keyframes spin { to { transform: rotate(360deg); } }
</style>
@code {
// EN: Payment workflow state — populated from current order context at runtime.
// VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy.
// TODO: Integrate with Order Service API when available.
// EN: OrderId from route parameter or query string.
// VI: OrderId từ route parameter hoặc query string.
[Parameter] public Guid? OrderId { get; set; }
[SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; }
// EN: Demo data / VI: Dữ liệu mẫu
private decimal _orderTotal = 285_000;
private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty;
private decimal _orderTotal;
private bool _isLoading = true;
private bool _isProcessing;
private string? _errorMessage;
// EN: Bank info — can be loaded from shop settings in future.
// VI: Thông tin ngân hàng — có thể tải từ cài đặt shop trong tương lai.
private string _bankName = "Vietcombank";
private string _accountNumber = "1017 6688 9900";
private string _accountHolder = "CONG TY TNHH GOODGO";
private string _referenceCode = "GG240215A1";
private string _referenceCode = "";
private void Verify() => NavigateTo("payment/success");
private void Cancel() => NavigateTo("payment/method-select");
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadOrderAsync();
}
private async Task LoadOrderAsync()
{
_isLoading = true;
try
{
if (_resolvedOrderId == Guid.Empty) { _errorMessage = "Không tìm thấy mã đơn hàng."; return; }
var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId);
if (detail?.Order == null) { _errorMessage = "Không thể tải thông tin đơn hàng."; return; }
_orderTotal = detail.Order.TotalAmount;
// EN: Generate reference code from order ID for transfer identification.
// VI: Tạo mã tham chiếu từ order ID để xác định chuyển khoản.
_referenceCode = $"GG{_resolvedOrderId.ToString("N")[..8].ToUpper()}";
}
catch { _errorMessage = "Lỗi khi tải đơn hàng. Vui lòng thử lại."; }
finally { _isLoading = false; }
}
private async Task Verify()
{
_isProcessing = true;
StateHasChanged();
try
{
var success = await DataService.PayOrderAsync(_resolvedOrderId, ShopId, "transfer");
if (success)
{
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=transfer");
}
else
{
_errorMessage = "Thanh toán thất bại. Vui lòng thử lại.";
}
}
catch
{
_errorMessage = "Lỗi kết nối. Vui lòng thử lại.";
}
finally
{
_isProcessing = false;
}
}
private void Cancel()
{
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}");
}
}

View File

@@ -3,56 +3,81 @@
VI: Thanh toán thẻ — Trạng thái đầu đọc thẻ, hướng dẫn chạm/quẹt/cắm.
*@
@page "/pos/{ShopId:guid}/payment/card"
@page "/pos/{ShopId:guid}/payment/card/{OrderId:guid}"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject PosDataService DataService
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:32px;gap:32px;">
@* ═══ ORDER TOTAL ═══ *@
<div style="text-align:center;">
<div style="font-size:14px;color:var(--pos-text-tertiary);margin-bottom:4px;">Tổng thanh toán</div>
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(_orderTotal)</div>
@if (_isLoadingOrder)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;">
<div style="text-align:center;">
<div style="width:48px;height:48px;border:4px solid var(--pos-border-subtle);border-top-color:var(--pos-orange-primary);
border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 16px;"></div>
<div style="color:var(--pos-text-tertiary);font-size:14px;">Đang tải...</div>
</div>
</div>
@* ═══ CARD READER STATUS ═══ *@
<div style="display:flex;flex-direction:column;align-items:center;gap:20px;padding:40px;background:var(--pos-bg-elevated);border-radius:16px;width:100%;max-width:400px;">
@if (_isProcessing)
{
@* EN: Processing animation / VI: Hiệu ứng đang xử lý *@
<div style="width:80px;height:80px;border-radius:50%;border:4px solid var(--pos-border-subtle);border-top-color:var(--pos-orange-primary);
animation:spin 1s linear infinite;"></div>
<div style="font-size:18px;font-weight:600;color:var(--pos-text-primary);">Đang xử lý...</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);">Vui lòng không rút thẻ</div>
}
else
{
@* EN: Waiting for card / VI: Chờ thẻ *@
<div style="width:80px;height:80px;border-radius:16px;background:var(--pos-bg-interactive);display:flex;align-items:center;justify-content:center;
animation:pulse 2s ease-in-out infinite;">
<i data-lucide="credit-card" style="width:40px;height:40px;color:var(--pos-orange-primary);"></i>
</div>
<div style="font-size:18px;font-weight:600;color:var(--pos-text-primary);">Chạm, quẹt hoặc cắm thẻ</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);">Tap, swipe or insert card</div>
}
</div>
@* ═══ ACTIONS ═══ *@
<div style="display:flex;gap:12px;">
@if (!_isProcessing)
{
<button style="padding:12px 32px;border-radius:var(--pos-radius);border:none;background:var(--pos-orange-primary);color:#fff;
cursor:pointer;font-size:14px;font-weight:600;"
@onclick="SimulateProcess">
Mô phỏng thanh toán
</button>
}
}
else if (!string.IsNullOrEmpty(_errorMessage))
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;">
<div style="color:var(--pos-danger);font-size:16px;font-weight:600;">@_errorMessage</div>
<button style="padding:12px 24px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:14px;"
@onclick="Cancel">
Hủy
</button>
@onclick="Cancel">Quay lại</button>
</div>
</div>
}
else
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:32px;gap:32px;">
@* ═══ ORDER TOTAL ═══ *@
<div style="text-align:center;">
<div style="font-size:14px;color:var(--pos-text-tertiary);margin-bottom:4px;">Tổng thanh toán</div>
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(_orderTotal)</div>
</div>
@* ═══ CARD READER STATUS ═══ *@
<div style="display:flex;flex-direction:column;align-items:center;gap:20px;padding:40px;background:var(--pos-bg-elevated);border-radius:16px;width:100%;max-width:400px;">
@if (_isProcessing)
{
@* EN: Processing animation / VI: Hiệu ứng đang xử lý *@
<div style="width:80px;height:80px;border-radius:50%;border:4px solid var(--pos-border-subtle);border-top-color:var(--pos-orange-primary);
animation:spin 1s linear infinite;"></div>
<div style="font-size:18px;font-weight:600;color:var(--pos-text-primary);">Đang xử lý...</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);">Vui lòng không rút thẻ</div>
}
else
{
@* EN: Waiting for card / VI: Chờ thẻ *@
<div style="width:80px;height:80px;border-radius:16px;background:var(--pos-bg-interactive);display:flex;align-items:center;justify-content:center;
animation:pulse 2s ease-in-out infinite;">
<i data-lucide="credit-card" style="width:40px;height:40px;color:var(--pos-orange-primary);"></i>
</div>
<div style="font-size:18px;font-weight:600;color:var(--pos-text-primary);">Chạm, quẹt hoặc cắm thẻ</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);">Tap, swipe or insert card</div>
}
</div>
@* ═══ ACTIONS ═══ *@
<div style="display:flex;gap:12px;">
@if (!_isProcessing)
{
<button style="padding:12px 32px;border-radius:var(--pos-radius);border:none;background:var(--pos-orange-primary);color:#fff;
cursor:pointer;font-size:14px;font-weight:600;"
@onclick="ProcessCardPayment">
Xác nhận thanh toán
</button>
}
<button style="padding:12px 24px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:14px;"
disabled="@_isProcessing"
@onclick="Cancel">
Hủy
</button>
</div>
</div>
}
@* EN: CSS animations / VI: Hiệu ứng CSS *@
<style>
@@ -61,21 +86,66 @@
</style>
@code {
// EN: Payment workflow state — populated from current order context at runtime.
// VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy.
// TODO: Integrate with Order Service API when available.
// EN: OrderId from route parameter or query string.
// VI: OrderId từ route parameter hoặc query string.
[Parameter] public Guid? OrderId { get; set; }
[SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; }
// EN: Demo order total / VI: Tổng đơn hàng mẫu
private decimal _orderTotal = 285_000;
private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty;
private decimal _orderTotal;
private bool _isLoadingOrder = true;
private bool _isProcessing = false;
private string? _errorMessage;
private async Task SimulateProcess()
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadOrderAsync();
}
private async Task LoadOrderAsync()
{
_isLoadingOrder = true;
try
{
if (_resolvedOrderId == Guid.Empty) { _errorMessage = "Không tìm thấy mã đơn hàng."; return; }
var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId);
if (detail?.Order == null) { _errorMessage = "Không thể tải thông tin đơn hàng."; return; }
_orderTotal = detail.Order.TotalAmount;
}
catch { _errorMessage = "Lỗi khi tải đơn hàng. Vui lòng thử lại."; }
finally { _isLoadingOrder = false; }
}
private async Task ProcessCardPayment()
{
_isProcessing = true;
StateHasChanged();
await Task.Delay(3000);
NavigateTo("payment/success");
try
{
var success = await DataService.PayOrderAsync(_resolvedOrderId, ShopId, "card");
if (success)
{
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=card");
}
else
{
_errorMessage = "Thanh toán thẻ thất bại. Vui lòng thử lại.";
}
}
catch
{
_errorMessage = "Lỗi kết nối. Vui lòng thử lại.";
}
finally
{
_isProcessing = false;
}
}
private void Cancel() => NavigateTo("payment/method-select");
private void Cancel()
{
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}");
}
}

View File

@@ -3,101 +3,171 @@
VI: Thanh toán tiền mặt — Nút số tiền nhanh và tính tiền thối.
*@
@page "/pos/{ShopId:guid}/payment/cash"
@page "/pos/{ShopId:guid}/payment/cash/{OrderId:guid}"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject PosDataService DataService
<div style="display:flex;height:100%;">
@* ═══ MAIN PANEL ═══ *@
<div style="flex:1;padding:32px;display:flex;flex-direction:column;align-items:center;gap:24px;overflow-y:auto;">
@* ═══ ORDER TOTAL ═══ *@
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;">
<div style="text-align:center;">
<div style="font-size:14px;color:var(--pos-text-tertiary);margin-bottom:4px;">Tổng thanh toán</div>
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(_orderTotal)</div>
</div>
@* ═══ QUICK AMOUNT BUTTONS ═══ *@
<div style="width:100%;max-width:480px;">
<div style="font-size:14px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:12px;">Số tiền nhanh</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;">
@foreach (var amount in _quickAmounts)
{
<button style="padding:16px;border-radius:var(--pos-radius);border:2px solid @(_receivedAmount == amount.Value ? "var(--pos-orange-primary)" : "var(--pos-border-default)");
background:@(_receivedAmount == amount.Value ? "rgba(255,92,0,0.1)" : "var(--pos-bg-elevated)");
color:var(--pos-text-primary);cursor:pointer;font-size:15px;font-weight:600;text-align:center;"
@onclick="() => SetAmount(amount.Value)">
@amount.Label
</button>
}
</div>
</div>
@* ═══ CUSTOM AMOUNT INPUT ═══ *@
<div style="width:100%;max-width:480px;">
<div style="font-size:14px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:8px;">Nhập số tiền khác</div>
<div style="display:flex;align-items:center;gap:8px;">
<input type="number" @bind="_customInput" @bind:event="oninput"
placeholder="Nhập số tiền..."
style="flex:1;padding:14px 16px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:var(--pos-bg-elevated);color:var(--pos-text-primary);font-size:16px;outline:none;" />
<button style="padding:14px 20px;border-radius:var(--pos-radius);border:none;background:var(--pos-bg-interactive);
color:var(--pos-text-primary);cursor:pointer;font-size:14px;font-weight:600;"
@onclick="ApplyCustom">
Áp dụng
</button>
</div>
</div>
@* ═══ CHANGE DISPLAY ═══ *@
<div style="width:100%;max-width:480px;background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;">
<div style="display:flex;justify-content:space-between;margin-bottom:12px;">
<span style="color:var(--pos-text-secondary);font-size:14px;">Khách đưa</span>
<span style="font-size:16px;font-weight:600;">@FormatPrice(_receivedAmount)</span>
</div>
<div style="display:flex;justify-content:space-between;padding-top:12px;border-top:1px dashed var(--pos-border-subtle);">
<span style="color:var(--pos-text-secondary);font-size:14px;">Tiền thối</span>
<span style="font-size:20px;font-weight:700;color:@(_changeAmount >= 0 ? "var(--pos-success)" : "var(--pos-danger)");">
@FormatPrice(_changeAmount)
</span>
</div>
<div style="width:48px;height:48px;border:4px solid var(--pos-border-subtle);border-top-color:var(--pos-orange-primary);
border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 16px;"></div>
<div style="color:var(--pos-text-tertiary);font-size:14px;">Đang tải...</div>
</div>
</div>
@* ═══ CONFIRM PANEL ═══ *@
<div style="width:280px;background:var(--pos-bg-elevated);border-left:1px solid var(--pos-border-subtle);padding:24px;display:flex;flex-direction:column;justify-content:flex-end;gap:12px;">
<button style="padding:8px 16px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
}
else if (!string.IsNullOrEmpty(_errorMessage))
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;">
<div style="color:var(--pos-danger);font-size:16px;font-weight:600;">@_errorMessage</div>
<button style="padding:12px 24px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:14px;"
@onclick="GoBack">
<i data-lucide="arrow-left" style="width:14px;height:14px;"></i> Quay lại
</button>
<button class="pos-btn-checkout" @onclick="Confirm"
disabled="@(_receivedAmount < _orderTotal)">
Xác nhận thanh toán
</button>
@onclick="GoBack">Quay lại</button>
</div>
</div>
}
else
{
<div style="display:flex;height:100%;">
@* ═══ MAIN PANEL ═══ *@
<div style="flex:1;padding:32px;display:flex;flex-direction:column;align-items:center;gap:24px;overflow-y:auto;">
@* ═══ ORDER TOTAL ═══ *@
<div style="text-align:center;">
<div style="font-size:14px;color:var(--pos-text-tertiary);margin-bottom:4px;">Tổng thanh toán</div>
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(_orderTotal)</div>
</div>
@* ═══ QUICK AMOUNT BUTTONS ═══ *@
<div style="width:100%;max-width:480px;">
<div style="font-size:14px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:12px;">Số tiền nhanh</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;">
@foreach (var amount in _quickAmounts)
{
<button style="padding:16px;border-radius:var(--pos-radius);border:2px solid @(_receivedAmount == amount.Value ? "var(--pos-orange-primary)" : "var(--pos-border-default)");
background:@(_receivedAmount == amount.Value ? "rgba(255,92,0,0.1)" : "var(--pos-bg-elevated)");
color:var(--pos-text-primary);cursor:pointer;font-size:15px;font-weight:600;text-align:center;"
@onclick="() => SetAmount(amount.Value)">
@amount.Label
</button>
}
</div>
</div>
@* ═══ CUSTOM AMOUNT INPUT ═══ *@
<div style="width:100%;max-width:480px;">
<div style="font-size:14px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:8px;">Nhập số tiền khác</div>
<div style="display:flex;align-items:center;gap:8px;">
<input type="number" @bind="_customInput" @bind:event="oninput"
placeholder="Nhập số tiền..."
style="flex:1;padding:14px 16px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:var(--pos-bg-elevated);color:var(--pos-text-primary);font-size:16px;outline:none;" />
<button style="padding:14px 20px;border-radius:var(--pos-radius);border:none;background:var(--pos-bg-interactive);
color:var(--pos-text-primary);cursor:pointer;font-size:14px;font-weight:600;"
@onclick="ApplyCustom">
Áp dụng
</button>
</div>
</div>
@* ═══ CHANGE DISPLAY ═══ *@
<div style="width:100%;max-width:480px;background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;">
<div style="display:flex;justify-content:space-between;margin-bottom:12px;">
<span style="color:var(--pos-text-secondary);font-size:14px;">Khách đưa</span>
<span style="font-size:16px;font-weight:600;">@FormatPrice(_receivedAmount)</span>
</div>
<div style="display:flex;justify-content:space-between;padding-top:12px;border-top:1px dashed var(--pos-border-subtle);">
<span style="color:var(--pos-text-secondary);font-size:14px;">Tiền thối</span>
<span style="font-size:20px;font-weight:700;color:@(_changeAmount >= 0 ? "var(--pos-success)" : "var(--pos-danger)");">
@FormatPrice(_changeAmount)
</span>
</div>
</div>
</div>
@* ═══ CONFIRM PANEL ═══ *@
<div style="width:280px;background:var(--pos-bg-elevated);border-left:1px solid var(--pos-border-subtle);padding:24px;display:flex;flex-direction:column;justify-content:flex-end;gap:12px;">
<button style="padding:8px 16px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:14px;"
@onclick="GoBack">
<i data-lucide="arrow-left" style="width:14px;height:14px;"></i> Quay lại
</button>
<button class="pos-btn-checkout" @onclick="Confirm"
disabled="@(_receivedAmount < _orderTotal || _isProcessing)">
@if (_isProcessing)
{
<span>Đang xử lý...</span>
}
else
{
<span>Xác nhận thanh toán</span>
}
</button>
</div>
</div>
}
<style>
@@keyframes spin { to { transform: rotate(360deg); } }
</style>
@code {
// EN: Payment workflow state — populated from current order context at runtime.
// VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy.
// TODO: Integrate with Order Service API when available.
// EN: OrderId from route parameter or query string.
// VI: OrderId từ route parameter hoặc query string.
[Parameter] public Guid? OrderId { get; set; }
[SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; }
// EN: Demo order total / VI: Tổng đơn hàng mẫu
private decimal _orderTotal = 285_000;
private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty;
private decimal _orderTotal;
private decimal _receivedAmount = 0;
private decimal _changeAmount => _receivedAmount - _orderTotal;
private string _customInput = "";
private bool _isLoading = true;
private bool _isProcessing;
private string? _errorMessage;
// EN: Quick amount options / VI: Tùy chọn số tiền nhanh
private readonly List<QuickAmount> _quickAmounts = new()
// EN: Quick amount options — dynamically generated based on order total.
// VI: Tùy chọn số tiền nhanh — tự động sinh dựa trên tổng đơn hàng.
private List<QuickAmount> _quickAmounts = new();
protected override async Task OnInitializedAsync()
{
new("300,000₫", 300_000),
new("350,000₫", 350_000),
new("400,000₫", 400_000),
new("500,000₫", 500_000),
new("1,000,000₫", 1_000_000),
new("Đúng tiền", 285_000),
};
await base.OnInitializedAsync();
await LoadOrderAsync();
}
private async Task LoadOrderAsync()
{
_isLoading = true;
try
{
if (_resolvedOrderId == Guid.Empty) { _errorMessage = "Không tìm thấy mã đơn hàng."; return; }
var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId);
if (detail?.Order == null) { _errorMessage = "Không thể tải thông tin đơn hàng."; return; }
_orderTotal = detail.Order.TotalAmount;
BuildQuickAmounts();
}
catch { _errorMessage = "Lỗi khi tải đơn hàng. Vui lòng thử lại."; }
finally { _isLoading = false; }
}
private void BuildQuickAmounts()
{
// EN: Generate sensible quick amounts based on order total.
// VI: Sinh các mức tiền nhanh hợp lý dựa trên tổng đơn hàng.
var rounded = Math.Ceiling(_orderTotal / 50_000) * 50_000;
_quickAmounts = new()
{
new(FormatPrice(rounded), rounded),
new(FormatPrice(rounded + 50_000), rounded + 50_000),
new(FormatPrice(rounded + 100_000), rounded + 100_000),
new(FormatPrice(rounded + 200_000), rounded + 200_000),
new("1,000,000₫", 1_000_000),
new("Đúng tiền", _orderTotal),
};
}
private void SetAmount(decimal amount) => _receivedAmount = amount;
@@ -107,8 +177,36 @@
_receivedAmount = val;
}
private void Confirm() => NavigateTo("payment/success");
private void GoBack() => NavigateTo("payment/method-select");
private async Task Confirm()
{
_isProcessing = true;
StateHasChanged();
try
{
var success = await DataService.PayOrderAsync(_resolvedOrderId, ShopId, "cash");
if (success)
{
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=cash&change={_changeAmount}");
}
else
{
_errorMessage = "Thanh toán thất bại. Vui lòng thử lại.";
}
}
catch
{
_errorMessage = "Lỗi kết nối. Vui lòng thử lại.";
}
finally
{
_isProcessing = false;
}
}
private void GoBack()
{
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}");
}
private record QuickAmount(string Label, decimal Value);
}

View File

@@ -1,51 +1,95 @@
@*
EN: Payment Method Select — Choose payment method: Cash, Card, QR Code, Gift Card.
VI: Chọn phương thức thanh toán — Tiền mặt, Thẻ, Mã QR, Thẻ quà tặng.
EN: Payment Method Select — Choose payment method: Cash, Card, QR Code, Bank Transfer.
VI: Chọn phương thức thanh toán — Tiền mặt, Thẻ, Mã QR, Chuyển khoản.
*@
@page "/pos/{ShopId:guid}/payment/method-select"
@page "/pos/{ShopId:guid}/payment-method-select/{OrderId:guid}"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject IJSRuntime JS
@inject PosDataService DataService
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:32px;gap:32px;">
@* ═══ ORDER TOTAL ═══ *@
<div style="text-align:center;">
<div style="font-size:14px;color:var(--pos-text-tertiary);margin-bottom:8px;">Tổng đơn hàng / Order Total</div>
<div style="font-size:36px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(_orderTotal)</div>
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;">
<div style="text-align:center;">
<div style="width:48px;height:48px;border:4px solid var(--pos-border-subtle);border-top-color:var(--pos-orange-primary);
border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 16px;"></div>
<div style="color:var(--pos-text-tertiary);font-size:14px;">Đang tải đơn hàng...</div>
</div>
</div>
}
else if (!string.IsNullOrEmpty(_errorMessage))
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;">
<div style="color:var(--pos-danger);font-size:16px;font-weight:600;">@_errorMessage</div>
<button style="padding:12px 24px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:14px;"
@onclick="GoBack">
Quay lại
</button>
</div>
}
else
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:32px;gap:32px;">
@* ═══ ORDER TOTAL ═══ *@
<div style="text-align:center;">
<div style="font-size:14px;color:var(--pos-text-tertiary);margin-bottom:8px;">Tổng đơn hàng / Order Total</div>
<div style="font-size:36px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(_orderTotal)</div>
</div>
@* ═══ PAYMENT METHODS ═══ *@
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px;width:100%;max-width:520px;">
@foreach (var method in _methods)
{
<button style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:32px 16px;border-radius:var(--pos-radius);
border:2px solid var(--pos-border-default);background:var(--pos-bg-elevated);color:var(--pos-text-primary);cursor:pointer;
transition:border-color 0.2s;"
@onclick="() => SelectMethod(method.Route)">
<span style="font-size:40px;">@method.Icon</span>
<span style="font-size:16px;font-weight:600;">@method.Label</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);text-align:center;">@method.Description</span>
@* ═══ PAYMENT METHODS ═══ *@
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px;width:100%;max-width:520px;">
@foreach (var method in _methods)
{
<button style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:32px 16px;border-radius:var(--pos-radius);
border:2px solid var(--pos-border-default);background:var(--pos-bg-elevated);color:var(--pos-text-primary);cursor:pointer;
transition:border-color 0.2s;"
@onclick="() => SelectMethod(method.Route)">
<span style="font-size:40px;">@method.Icon</span>
<span style="font-size:16px;font-weight:600;">@method.Label</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);text-align:center;">@method.Description</span>
</button>
}
</div>
@* ═══ CANCEL & BACK BUTTONS ═══ *@
<div style="display:flex;gap:12px;">
<button style="display:flex;align-items:center;gap:8px;padding:12px 24px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:14px;"
@onclick="GoBack">
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
Quay lại
</button>
}
<button style="display:flex;align-items:center;gap:8px;padding:12px 24px;border-radius:var(--pos-radius);border:1px solid var(--pos-danger);
background:transparent;color:var(--pos-danger);cursor:pointer;font-size:14px;"
disabled="@_isCancelling"
@onclick="CancelOrder">
<i data-lucide="x" style="width:16px;height:16px;"></i>
@(_isCancelling ? "Đang hủy..." : "Hủy đơn")
</button>
</div>
</div>
}
@* ═══ BACK BUTTON ═══ *@
<button style="display:flex;align-items:center;gap:8px;padding:12px 24px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:14px;"
@onclick="GoBack">
<i data-lucide="arrow-left" style="width:16px;height:16px;"></i>
Quay lại
</button>
</div>
<style>
@@keyframes spin { to { transform: rotate(360deg); } }
</style>
@code {
// EN: Payment workflow state — populated from current order context at runtime.
// VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy.
// TODO: Integrate with Order Service API when available.
// EN: OrderId from route parameter or query string.
// VI: OrderId từ route parameter hoặc query string.
[Parameter] public Guid? OrderId { get; set; }
[SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; }
// EN: Demo order total / VI: Tổng đơn hàng mẫu
private decimal _orderTotal = 285_000;
private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty;
private decimal _orderTotal;
private bool _isLoading = true;
private bool _isCancelling;
private string? _errorMessage;
// EN: Payment method definitions / VI: Định nghĩa phương thức thanh toán
private readonly List<PaymentMethod> _methods = new()
@@ -53,10 +97,63 @@
new("💵", "Tiền mặt", "Thanh toán bằng tiền mặt", "payment/cash"),
new("💳", "Thẻ", "Chạm, quẹt hoặc cắm thẻ", "payment/card"),
new("📱", "Mã QR", "VietQR, MoMo, ZaloPay", "payment/qr"),
new("🎁", "Thẻ quà tặng", "Sử dụng thẻ quà tặng", "payment/gift-card"),
new("🏦", "Chuyển khoản", "Chuyển khoản ngân hàng", "payment/bank-transfer"),
};
private void SelectMethod(string route) => NavigateTo(route);
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadOrderAsync();
}
private async Task LoadOrderAsync()
{
_isLoading = true;
try
{
if (_resolvedOrderId == Guid.Empty)
{
_errorMessage = "Không tìm thấy mã đơn hàng.";
return;
}
var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId);
if (detail?.Order == null)
{
_errorMessage = "Không thể tải thông tin đơn hàng.";
return;
}
_orderTotal = detail.Order.TotalAmount;
}
catch
{
_errorMessage = "Lỗi khi tải đơn hàng. Vui lòng thử lại.";
}
finally
{
_isLoading = false;
}
}
private void SelectMethod(string route)
{
NavigationManager.NavigateTo($"/pos/{ShopId}/{route}?orderId={_resolvedOrderId}");
}
private async Task CancelOrder()
{
_isCancelling = true;
StateHasChanged();
try
{
await DataService.CancelOrderAsync(_resolvedOrderId);
}
finally
{
_isCancelling = false;
}
await JS.InvokeVoidAsync("history.back");
}
private async Task GoBack() => await JS.InvokeVoidAsync("history.back");
private record PaymentMethod(string Icon, string Label, string Description, string Route);

View File

@@ -3,88 +3,182 @@
VI: Thanh toán thành công — Hiệu ứng thành công, chi tiết giao dịch, nút in/đơn mới.
*@
@page "/pos/{ShopId:guid}/payment/success"
@page "/pos/{ShopId:guid}/payment/success/{OrderId:guid}"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject IJSRuntime JS
@inject PosDataService DataService
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:32px;gap:24px;">
@* ═══ SUCCESS ANIMATION ═══ *@
<div style="width:100px;height:100px;border-radius:50%;background:rgba(34,197,94,0.15);display:flex;align-items:center;justify-content:center;
animation:scaleIn 0.5s ease-out;">
<div style="width:72px;height:72px;border-radius:50%;background:var(--pos-success);display:flex;align-items:center;justify-content:center;">
<i data-lucide="check" style="width:36px;height:36px;color:#fff;stroke-width:3;"></i>
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;">
<div style="text-align:center;">
<div style="width:48px;height:48px;border:4px solid var(--pos-border-subtle);border-top-color:var(--pos-orange-primary);
border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 16px;"></div>
<div style="color:var(--pos-text-tertiary);font-size:14px;">Đang tải...</div>
</div>
</div>
@* ═══ SUCCESS MESSAGE ═══ *@
<div style="text-align:center;">
<div style="font-size:24px;font-weight:700;color:var(--pos-success);margin-bottom:8px;">Thanh toán thành công!</div>
<div style="font-size:14px;color:var(--pos-text-tertiary);">Payment successful</div>
</div>
@* ═══ TRANSACTION DETAILS ═══ *@
<div style="width:100%;max-width:400px;background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;justify-content:space-between;font-size:14px;">
<span style="color:var(--pos-text-tertiary);">Tổng thanh toán</span>
<span style="font-weight:700;color:var(--pos-orange-primary);font-size:18px;">@FormatPrice(_orderTotal)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:14px;">
<span style="color:var(--pos-text-tertiary);">Phương thức</span>
<span style="font-weight:600;">@_paymentMethod</span>
</div>
@if (_changeAmount > 0)
{
<div style="display:flex;justify-content:space-between;font-size:14px;">
<span style="color:var(--pos-text-tertiary);">Tiền thối</span>
<span style="font-weight:600;color:var(--pos-success);">@FormatPrice(_changeAmount)</span>
}
else
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:32px;gap:24px;">
@* ═══ SUCCESS ANIMATION ═══ *@
<div style="width:100px;height:100px;border-radius:50%;background:rgba(34,197,94,0.15);display:flex;align-items:center;justify-content:center;
animation:scaleIn 0.5s ease-out;">
<div style="width:72px;height:72px;border-radius:50%;background:var(--pos-success);display:flex;align-items:center;justify-content:center;">
<i data-lucide="check" style="width:36px;height:36px;color:#fff;stroke-width:3;"></i>
</div>
}
<div style="display:flex;justify-content:space-between;font-size:14px;padding-top:12px;border-top:1px solid var(--pos-border-subtle);">
<span style="color:var(--pos-text-tertiary);">Mã giao dịch</span>
<span style="font-weight:500;color:var(--pos-text-secondary);font-size:12px;">@_transactionId</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:14px;">
<span style="color:var(--pos-text-tertiary);">Thời gian</span>
<span style="font-weight:500;color:var(--pos-text-secondary);font-size:12px;">@DateTime.Now.ToString("dd/MM/yyyy HH:mm:ss")</span>
</div>
</div>
@* ═══ ACTION BUTTONS ═══ *@
<div style="display:flex;gap:12px;">
<button style="display:flex;align-items:center;gap:8px;padding:14px 28px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:var(--pos-bg-elevated);color:var(--pos-text-primary);cursor:pointer;font-size:14px;font-weight:600;"
@onclick="PrintReceipt">
<i data-lucide="printer" style="width:18px;height:18px;"></i>
In hóa đơn
</button>
<button style="display:flex;align-items:center;gap:8px;padding:14px 28px;border-radius:var(--pos-radius);border:none;
background:var(--pos-orange-primary);color:#fff;cursor:pointer;font-size:14px;font-weight:600;"
@onclick="NewOrder">
<i data-lucide="plus" style="width:18px;height:18px;"></i>
Đơn mới
</button>
@* ═══ SUCCESS MESSAGE ═══ *@
<div style="text-align:center;">
<div style="font-size:24px;font-weight:700;color:var(--pos-success);margin-bottom:8px;">Thanh toán thành công!</div>
<div style="font-size:14px;color:var(--pos-text-tertiary);">Payment successful</div>
</div>
@* ═══ TRANSACTION DETAILS ═══ *@
<div style="width:100%;max-width:400px;background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;justify-content:space-between;font-size:14px;">
<span style="color:var(--pos-text-tertiary);">Tổng thanh toán</span>
<span style="font-weight:700;color:var(--pos-orange-primary);font-size:18px;">@FormatPrice(_orderTotal)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:14px;">
<span style="color:var(--pos-text-tertiary);">Phương thức</span>
<span style="font-weight:600;">@_paymentMethodDisplay</span>
</div>
@if (_changeAmount > 0)
{
<div style="display:flex;justify-content:space-between;font-size:14px;">
<span style="color:var(--pos-text-tertiary);">Tiền thối</span>
<span style="font-weight:600;color:var(--pos-success);">@FormatPrice(_changeAmount)</span>
</div>
}
@if (_orderItems.Any())
{
<div style="padding-top:12px;border-top:1px solid var(--pos-border-subtle);">
<div style="font-size:13px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:8px;">Chi tiết đơn hàng</div>
@foreach (var item in _orderItems)
{
<div style="display:flex;justify-content:space-between;font-size:13px;padding:4px 0;">
<span style="color:var(--pos-text-secondary);">@item.Quantity x @item.ProductName</span>
<span style="font-weight:500;">@FormatPrice(item.Subtotal)</span>
</div>
}
</div>
}
<div style="display:flex;justify-content:space-between;font-size:14px;padding-top:12px;border-top:1px solid var(--pos-border-subtle);">
<span style="color:var(--pos-text-tertiary);">Mã đơn hàng</span>
<span style="font-weight:500;color:var(--pos-text-secondary);font-size:12px;">@_resolvedOrderId.ToString("N")[..8].ToUpper()</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:14px;">
<span style="color:var(--pos-text-tertiary);">Thời gian</span>
<span style="font-weight:500;color:var(--pos-text-secondary);font-size:12px;">@_orderTime.ToString("dd/MM/yyyy HH:mm:ss")</span>
</div>
</div>
@* ═══ ACTION BUTTONS ═══ *@
<div style="display:flex;gap:12px;">
<button style="display:flex;align-items:center;gap:8px;padding:14px 28px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:var(--pos-bg-elevated);color:var(--pos-text-primary);cursor:pointer;font-size:14px;font-weight:600;"
@onclick="PrintReceipt">
<i data-lucide="printer" style="width:18px;height:18px;"></i>
In hóa đơn
</button>
<button style="display:flex;align-items:center;gap:8px;padding:14px 28px;border-radius:var(--pos-radius);border:none;
background:var(--pos-orange-primary);color:#fff;cursor:pointer;font-size:14px;font-weight:600;"
@onclick="NewOrder">
<i data-lucide="plus" style="width:18px;height:18px;"></i>
Đơn mới
</button>
</div>
</div>
</div>
}
<style>
@@keyframes scaleIn {
from { transform: scale(0); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@@keyframes spin { to { transform: rotate(360deg); } }
</style>
@code {
// EN: Payment workflow state — populated from current order context at runtime.
// VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy.
// TODO: Integrate with Order Service API when available.
// EN: OrderId from route parameter or query string.
// VI: OrderId từ route parameter hoặc query string.
[Parameter] public Guid? OrderId { get; set; }
[SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; }
[SupplyParameterFromQuery(Name = "method")] public string? MethodQuery { get; set; }
[SupplyParameterFromQuery(Name = "change")] public string? ChangeQuery { get; set; }
// EN: Demo data / VI: Dữ liệu mẫu
private decimal _orderTotal = 285_000;
private string _paymentMethod = "Tiền mặt";
private decimal _changeAmount = 15_000;
private string _transactionId = "TXN-20240215-001";
private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty;
private void PrintReceipt() => NavigateTo("payment/receipt");
private async Task NewOrder() => await JS.InvokeVoidAsync("history.back");
private decimal _orderTotal;
private string _paymentMethodDisplay = "";
private decimal _changeAmount;
private DateTime _orderTime = DateTime.Now;
private List<PosDataService.OrderItemInfo> _orderItems = new();
private bool _isLoading = true;
private static readonly Dictionary<string, string> _methodNames = new()
{
["cash"] = "Tiền mặt",
["card"] = "Thẻ",
["qr"] = "Mã QR",
["transfer"] = "Chuyển khoản",
};
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadOrderAsync();
}
private async Task LoadOrderAsync()
{
_isLoading = true;
try
{
// EN: Parse change amount from query string if present (for cash payments).
// VI: Phân tích số tiền thối từ query string nếu có (cho thanh toán tiền mặt).
if (decimal.TryParse(ChangeQuery, out var change))
_changeAmount = change;
// EN: Resolve payment method display name.
// VI: Xác định tên hiển thị phương thức thanh toán.
_paymentMethodDisplay = MethodQuery != null && _methodNames.TryGetValue(MethodQuery, out var name)
? name : "Không xác định";
if (_resolvedOrderId != Guid.Empty)
{
var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId);
if (detail?.Order != null)
{
_orderTotal = detail.Order.TotalAmount;
_orderTime = detail.Order.CreatedAt;
if (!string.IsNullOrEmpty(detail.Order.PaymentMethod))
{
_paymentMethodDisplay = _methodNames.TryGetValue(detail.Order.PaymentMethod, out var n)
? n : detail.Order.PaymentMethod;
}
}
if (detail?.Items != null)
_orderItems = detail.Items;
}
}
catch { /* graceful — show what we have from query params */ }
finally { _isLoading = false; }
}
private void PrintReceipt()
{
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/receipt?orderId={_resolvedOrderId}");
}
private void NewOrder()
{
// EN: Navigate back to the main POS page for this shop.
// VI: Điều hướng về trang POS chính của shop.
NavigationManager.NavigateTo($"/pos/{ShopId}");
}
}

View File

@@ -3,112 +3,208 @@
VI: Thanh toán QR — Hiển thị mã QR với tab nhà cung cấp, đếm ngược.
*@
@page "/pos/{ShopId:guid}/payment/qr"
@page "/pos/{ShopId:guid}/payment/qr/{OrderId:guid}"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@implements IDisposable
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:32px;gap:24px;">
@* ═══ ORDER TOTAL ═══ *@
<div style="text-align:center;">
<div style="font-size:14px;color:var(--pos-text-tertiary);margin-bottom:4px;">Tổng thanh toán</div>
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(_orderTotal)</div>
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;">
<div style="text-align:center;">
<div style="width:48px;height:48px;border:4px solid var(--pos-border-subtle);border-top-color:var(--pos-orange-primary);
border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 16px;"></div>
<div style="color:var(--pos-text-tertiary);font-size:14px;">Đang tải...</div>
</div>
</div>
}
else if (!string.IsNullOrEmpty(_errorMessage))
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;">
<div style="color:var(--pos-danger);font-size:16px;font-weight:600;">@_errorMessage</div>
<button style="padding:12px 24px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:14px;"
@onclick="Cancel">Quay lại</button>
</div>
}
else
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:32px;gap:24px;">
@* ═══ ORDER TOTAL ═══ *@
<div style="text-align:center;">
<div style="font-size:14px;color:var(--pos-text-tertiary);margin-bottom:4px;">Tổng thanh toán</div>
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(_orderTotal)</div>
</div>
@* ═══ QR PROVIDER TABS ═══ *@
<div style="display:flex;gap:8px;background:var(--pos-bg-elevated);padding:4px;border-radius:var(--pos-radius);">
@foreach (var provider in _providers)
@* ═══ QR PROVIDER TABS ═══ *@
<div style="display:flex;gap:8px;background:var(--pos-bg-elevated);padding:4px;border-radius:var(--pos-radius);">
@foreach (var provider in _providers)
{
<button style="padding:10px 20px;border-radius:8px;border:none;cursor:pointer;font-size:13px;font-weight:600;
background:@(_selectedProvider == provider ? "var(--pos-orange-primary)" : "transparent");
color:@(_selectedProvider == provider ? "#fff" : "var(--pos-text-secondary)");"
@onclick="() => _selectedProvider = provider">
@provider
</button>
}
</div>
@* ═══ QR CODE DISPLAY ═══ *@
<div style="width:240px;height:240px;background:var(--pos-bg-elevated);border-radius:16px;border:2px solid var(--pos-border-default);
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;">
<i data-lucide="qr-code" style="width:100px;height:100px;color:var(--pos-text-primary);"></i>
<span style="font-size:12px;color:var(--pos-text-tertiary);">@_selectedProvider</span>
</div>
@* ═══ TIMER ═══ *@
<div style="display:flex;align-items:center;gap:8px;color:var(--pos-warning);font-size:15px;">
<i data-lucide="clock" style="width:16px;height:16px;"></i>
<span style="font-weight:600;">@_timerDisplay</span>
</div>
@* ═══ STATUS ═══ *@
@if (_isProcessing)
{
<button style="padding:10px 20px;border-radius:8px;border:none;cursor:pointer;font-size:13px;font-weight:600;
background:@(_selectedProvider == provider ? "var(--pos-orange-primary)" : "transparent");
color:@(_selectedProvider == provider ? "#fff" : "var(--pos-text-secondary)");"
@onclick="() => _selectedProvider = provider">
@provider
</button>
<div style="display:flex;align-items:center;gap:8px;color:var(--pos-text-tertiary);font-size:14px;">
<div style="width:8px;height:8px;border-radius:50%;background:var(--pos-orange-primary);animation:pulse 2s ease-in-out infinite;"></div>
Đang xử lý thanh toán...
</div>
}
else if (_timerSeconds > 0)
{
<div style="display:flex;align-items:center;gap:8px;color:var(--pos-text-tertiary);font-size:14px;">
<div style="width:8px;height:8px;border-radius:50%;background:var(--pos-warning);animation:pulse 2s ease-in-out infinite;"></div>
Chờ xác nhận thanh toán...
</div>
}
else
{
<div style="display:flex;align-items:center;gap:8px;color:var(--pos-danger);font-size:14px;font-weight:600;">
<div style="width:8px;height:8px;border-radius:50%;background:var(--pos-danger);"></div>
Mã QR đã hết hạn. Nhấn "Làm mới" để tạo mã mới.
</div>
}
</div>
@* ═══ QR CODE DISPLAY ═══ *@
<div style="width:240px;height:240px;background:var(--pos-bg-elevated);border-radius:16px;border:2px solid var(--pos-border-default);
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;">
<i data-lucide="qr-code" style="width:100px;height:100px;color:var(--pos-text-primary);"></i>
<span style="font-size:12px;color:var(--pos-text-tertiary);">@_selectedProvider</span>
</div>
@* ═══ TIMER ═══ *@
<div style="display:flex;align-items:center;gap:8px;color:var(--pos-warning);font-size:15px;">
<i data-lucide="clock" style="width:16px;height:16px;"></i>
<span style="font-weight:600;">@_timerDisplay</span>
</div>
@* ═══ STATUS ═══ *@
@if (_timerSeconds > 0)
{
<div style="display:flex;align-items:center;gap:8px;color:var(--pos-text-tertiary);font-size:14px;">
<div style="width:8px;height:8px;border-radius:50%;background:var(--pos-warning);animation:pulse 2s ease-in-out infinite;"></div>
Chờ xác nhận thanh toán...
@* ═══ ACTIONS ═══ *@
<div style="display:flex;gap:12px;">
<button style="display:flex;align-items:center;gap:6px;padding:10px 20px;border-radius:var(--pos-radius);border:none;
background:var(--pos-orange-primary);color:#fff;cursor:pointer;font-size:13px;font-weight:600;"
disabled="@(_isProcessing)"
@onclick="ConfirmPayment">
Xác nhận thanh toán
</button>
<button style="display:flex;align-items:center;gap:6px;padding:10px 20px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:var(--pos-bg-elevated);color:var(--pos-text-secondary);cursor:pointer;font-size:13px;"
@onclick="Refresh">
<i data-lucide="refresh-cw" style="width:14px;height:14px;"></i> Làm mới
</button>
<button style="padding:10px 20px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:13px;"
@onclick="Cancel">
Hủy
</button>
</div>
}
else
{
<div style="display:flex;align-items:center;gap:8px;color:var(--pos-danger);font-size:14px;font-weight:600;">
<div style="width:8px;height:8px;border-radius:50%;background:var(--pos-danger);"></div>
Mã QR đã hết hạn. Nhấn "Làm mới" để tạo mã mới.
</div>
}
@* ═══ ACTIONS ═══ *@
<div style="display:flex;gap:12px;">
<button style="display:flex;align-items:center;gap:6px;padding:10px 20px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:var(--pos-bg-elevated);color:var(--pos-text-secondary);cursor:pointer;font-size:13px;"
@onclick="Refresh">
<i data-lucide="refresh-cw" style="width:14px;height:14px;"></i> Làm mới
</button>
<button style="padding:10px 20px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:13px;"
@onclick="Cancel">
Hủy
</button>
</div>
</div>
}
<style>
@@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
@@keyframes spin { to { transform: rotate(360deg); } }
</style>
@code {
// EN: Payment workflow state — populated from current order context at runtime.
// VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy.
// TODO: Integrate with Order Service API when available.
// EN: OrderId from route parameter or query string.
// VI: OrderId từ route parameter hoặc query string.
[Parameter] public Guid? OrderId { get; set; }
[SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; }
// EN: Demo order total / VI: Tổng đơn hàng mẫu
private decimal _orderTotal = 285_000;
private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty;
private decimal _orderTotal;
private string _selectedProvider = "VietQR";
private int _timerSeconds = 300;
private string _timerDisplay => $"{_timerSeconds / 60}:{(_timerSeconds % 60):D2}";
private Timer? _countdownTimer;
private bool _isLoading = true;
private bool _isProcessing;
private string? _errorMessage;
// EN: QR providers / VI: Nhà cung cấp QR
private readonly string[] _providers = { "VietQR", "MoMo", "ZaloPay" };
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
_countdownTimer = new Timer(_ =>
await base.OnInitializedAsync();
await LoadOrderAsync();
}
private async Task LoadOrderAsync()
{
_isLoading = true;
try
{
if (_timerSeconds > 0)
if (_resolvedOrderId == Guid.Empty) { _errorMessage = "Không tìm thấy mã đơn hàng."; return; }
var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId);
if (detail?.Order == null) { _errorMessage = "Không thể tải thông tin đơn hàng."; return; }
_orderTotal = detail.Order.TotalAmount;
}
catch { _errorMessage = "Lỗi khi tải đơn hàng. Vui lòng thử lại."; }
finally { _isLoading = false; }
}
protected override void OnAfterRender(bool firstRender)
{
if (firstRender && !_isLoading && _errorMessage == null)
{
_countdownTimer = new Timer(_ =>
{
_timerSeconds--;
InvokeAsync(StateHasChanged);
if (_timerSeconds > 0)
{
_timerSeconds--;
InvokeAsync(StateHasChanged);
}
}, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
}
private async Task ConfirmPayment()
{
_isProcessing = true;
StateHasChanged();
try
{
var success = await DataService.PayOrderAsync(_resolvedOrderId, ShopId, "qr");
if (success)
{
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=qr");
}
}, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
else
{
_errorMessage = "Thanh toán thất bại. Vui lòng thử lại.";
}
}
catch
{
_errorMessage = "Lỗi kết nối. Vui lòng thử lại.";
}
finally
{
_isProcessing = false;
}
}
private void Refresh()
{
_timerSeconds = 300;
_errorMessage = null;
}
private void Cancel() => NavigateTo("payment/method-select");
private void Cancel()
{
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}");
}
public void Dispose() => _countdownTimer?.Dispose();
}

View File

@@ -3,103 +3,116 @@
VI: Hóa đơn — Mẫu hóa đơn nhiệt 80mm với thông tin cửa hàng, sản phẩm, tổng tiền.
*@
@page "/pos/{ShopId:guid}/payment/receipt"
@page "/pos/{ShopId:guid}/payment/receipt/{OrderId:guid}"
@layout PosLayout
@inherits PosBase
@using WebClientTpos.Client.Services
@inject IJSRuntime JS
@inject PosDataService DataService
<div style="display:flex;align-items:flex-start;justify-content:center;height:100%;padding:24px;overflow-y:auto;">
@* ═══ RECEIPT PAPER ═══ *@
<div style="width:100%;max-width:300px;background:#fff;color:#000;border-radius:4px;padding:24px 20px;font-family:'Courier New',monospace;">
@* ═══ STORE HEADER ═══ *@
<div style="text-align:center;margin-bottom:16px;">
<div style="font-size:18px;font-weight:700;">GOODGO COFFEE</div>
<div style="font-size:11px;margin-top:4px;">123 Nguyễn Huệ, Q.1, TP.HCM</div>
<div style="font-size:11px;">ĐT: 028-1234-5678</div>
</div>
@* EN: Dashed separator / VI: Đường kẻ đứt *@
<div style="border-top:1px dashed #000;margin:12px 0;"></div>
@* ═══ ORDER INFO ═══ *@
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:8px;">
<span>Đơn #@_orderNumber</span>
<span>@_orderDate</span>
</div>
<div style="font-size:11px;margin-bottom:4px;">NV: @StaffName</div>
<div style="border-top:1px dashed #000;margin:12px 0;"></div>
@* ═══ ITEM LIST ═══ *@
@foreach (var item in _items)
{
<div style="display:flex;justify-content:space-between;font-size:12px;padding:3px 0;">
<span>@item.Qty x @item.Name</span>
<span>@FormatPrice(item.Price * item.Qty)</span>
</div>
}
<div style="border-top:1px dashed #000;margin:12px 0;"></div>
@* ═══ TOTALS ═══ *@
<div style="display:flex;flex-direction:column;gap:4px;font-size:12px;">
<div style="display:flex;justify-content:space-between;">
<span>Tạm tính</span>
<span>@FormatPrice(_subtotal)</span>
</div>
<div style="display:flex;justify-content:space-between;">
<span>Phí dịch vụ (5%)</span>
<span>@FormatPrice(_serviceCharge)</span>
</div>
<div style="display:flex;justify-content:space-between;">
<span>VAT (8%)</span>
<span>@FormatPrice(_vat)</span>
</div>
</div>
<div style="border-top:2px solid #000;margin:10px 0;"></div>
<div style="display:flex;justify-content:space-between;font-size:16px;font-weight:700;">
<span>TỔNG CỘNG</span>
<span>@FormatPrice(_total)</span>
</div>
<div style="border-top:1px dashed #000;margin:12px 0;"></div>
@* ═══ PAYMENT INFO ═══ *@
<div style="display:flex;flex-direction:column;gap:4px;font-size:12px;">
<div style="display:flex;justify-content:space-between;">
<span>Thanh toán</span>
<span>@_paymentMethod</span>
</div>
<div style="display:flex;justify-content:space-between;">
<span>Khách đưa</span>
<span>@FormatPrice(_amountPaid)</span>
</div>
<div style="display:flex;justify-content:space-between;">
<span>Tiền thối</span>
<span>@FormatPrice(_changeAmount)</span>
</div>
</div>
<div style="border-top:1px dashed #000;margin:12px 0;"></div>
@* ═══ TRANSACTION ID ═══ *@
<div style="text-align:center;font-size:11px;color:#666;">
<div>Mã GD: @_transactionId</div>
<div>@_orderDate @_orderTime</div>
</div>
<div style="border-top:1px dashed #000;margin:12px 0;"></div>
@* ═══ FOOTER ═══ *@
<div style="text-align:center;font-size:13px;font-weight:700;margin-top:8px;">
Cảm ơn quý khách!
</div>
<div style="text-align:center;font-size:10px;color:#666;margin-top:4px;">
Thank you & see you again!
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;">
<div style="text-align:center;">
<div style="width:48px;height:48px;border:4px solid var(--pos-border-subtle);border-top-color:var(--pos-orange-primary);
border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 16px;"></div>
<div style="color:var(--pos-text-tertiary);font-size:14px;">Đang tải hóa đơn...</div>
</div>
</div>
</div>
}
else
{
<div style="display:flex;align-items:flex-start;justify-content:center;height:100%;padding:24px;overflow-y:auto;">
@* ═══ RECEIPT PAPER ═══ *@
<div id="receipt-content" style="width:100%;max-width:300px;background:#fff;color:#000;border-radius:4px;padding:24px 20px;font-family:'Courier New',monospace;">
@* ═══ STORE HEADER ═══ *@
<div style="text-align:center;margin-bottom:16px;">
<div style="font-size:18px;font-weight:700;">@_shopName</div>
<div style="font-size:11px;margin-top:4px;">@_shopAddress</div>
</div>
@* EN: Dashed separator / VI: Đường kẻ đứt *@
<div style="border-top:1px dashed #000;margin:12px 0;"></div>
@* ═══ ORDER INFO ═══ *@
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:8px;">
<span>Đơn #@_orderNumber</span>
<span>@_orderDate</span>
</div>
<div style="font-size:11px;margin-bottom:4px;">NV: @StaffName</div>
<div style="border-top:1px dashed #000;margin:12px 0;"></div>
@* ═══ ITEM LIST ═══ *@
@foreach (var item in _items)
{
<div style="display:flex;justify-content:space-between;font-size:12px;padding:3px 0;">
<span>@item.Quantity x @item.ProductName</span>
<span>@FormatPrice(item.Subtotal)</span>
</div>
}
<div style="border-top:1px dashed #000;margin:12px 0;"></div>
@* ═══ TOTALS ═══ *@
<div style="display:flex;flex-direction:column;gap:4px;font-size:12px;">
<div style="display:flex;justify-content:space-between;">
<span>Tạm tính</span>
<span>@FormatPrice(_subtotal)</span>
</div>
@if (_serviceCharge > 0)
{
<div style="display:flex;justify-content:space-between;">
<span>Phí dịch vụ</span>
<span>@FormatPrice(_serviceCharge)</span>
</div>
}
@if (_vat > 0)
{
<div style="display:flex;justify-content:space-between;">
<span>VAT</span>
<span>@FormatPrice(_vat)</span>
</div>
}
</div>
<div style="border-top:2px solid #000;margin:10px 0;"></div>
<div style="display:flex;justify-content:space-between;font-size:16px;font-weight:700;">
<span>TỔNG CỘNG</span>
<span>@FormatPrice(_total)</span>
</div>
<div style="border-top:1px dashed #000;margin:12px 0;"></div>
@* ═══ PAYMENT INFO ═══ *@
<div style="display:flex;flex-direction:column;gap:4px;font-size:12px;">
<div style="display:flex;justify-content:space-between;">
<span>Thanh toán</span>
<span>@_paymentMethodDisplay</span>
</div>
</div>
<div style="border-top:1px dashed #000;margin:12px 0;"></div>
@* ═══ TRANSACTION ID ═══ *@
<div style="text-align:center;font-size:11px;color:#666;">
<div>Mã đơn: @_orderNumber</div>
<div>@_orderDate @_orderTime</div>
</div>
<div style="border-top:1px dashed #000;margin:12px 0;"></div>
@* ═══ FOOTER ═══ *@
<div style="text-align:center;font-size:13px;font-weight:700;margin-top:8px;">
Cảm ơn quý khách!
</div>
<div style="text-align:center;font-size:10px;color:#666;margin-top:4px;">
Thank you & see you again!
</div>
</div>
</div>
}
@* ═══ ACTION BUTTONS (fixed bottom) ═══ *@
<div style="position:fixed;bottom:0;left:0;right:0;padding:16px;display:flex;justify-content:center;gap:12px;background:var(--pos-bg-elevated);border-top:1px solid var(--pos-border-subtle);">
@@ -116,40 +129,96 @@
</button>
</div>
<style>
@@keyframes spin { to { transform: rotate(360deg); } }
</style>
@code {
// EN: Payment workflow state — populated from current order context at runtime.
// VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy.
// TODO: Integrate with Order Service API when available.
// EN: OrderId from route parameter or query string.
// VI: OrderId từ route parameter hoặc query string.
[Parameter] public Guid? OrderId { get; set; }
[SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; }
// EN: Demo receipt data / VI: Dữ liệu hóa đơn mẫu
private string _orderNumber = "1042";
private string _orderDate = "15/02/2024";
private string _orderTime = "14:35:22";
private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty;
private readonly List<ReceiptItem> _items = new()
private bool _isLoading = true;
// EN: Receipt data populated from API.
// VI: Dữ liệu hóa đơn được điền từ API.
private string _shopName = "GOODGO";
private string _shopAddress = "";
private string _orderNumber = "";
private string _orderDate = "";
private string _orderTime = "";
private List<PosDataService.OrderItemInfo> _items = new();
private decimal _subtotal;
private decimal _serviceCharge;
private decimal _vat;
private decimal _total;
private string _paymentMethodDisplay = "";
private static readonly Dictionary<string, string> _methodNames = new()
{
new("Cà phê sữa đá", 35_000, 2),
new("Cappuccino", 55_000, 1),
new("Croissant bơ", 35_000, 1),
new("Trà đào cam sả", 45_000, 1),
new("Bánh mì thịt", 30_000, 1),
["cash"] = "Tiền mặt",
["card"] = "Thẻ",
["qr"] = "Mã QR",
["transfer"] = "Chuyển khoản",
};
private decimal _subtotal => _items.Sum(i => i.Price * i.Qty);
private decimal _serviceCharge => Math.Round(_subtotal * 0.05m);
private decimal _vat => Math.Round(_subtotal * 0.08m);
private decimal _total => _subtotal + _serviceCharge + _vat;
private string _paymentMethod = "Tiền mặt";
private decimal _amountPaid = 300_000;
private decimal _changeAmount => _amountPaid - _total;
private string _transactionId = "TXN-20240215-001";
private void Print()
protected override async Task OnInitializedAsync()
{
// EN: Trigger browser print / VI: Kích hoạt in từ trình duyệt
await base.OnInitializedAsync();
await LoadReceiptAsync();
}
private void Close() => NavigateTo("payment/success");
private async Task LoadReceiptAsync()
{
_isLoading = true;
try
{
if (_resolvedOrderId == Guid.Empty) { _isLoading = false; return; }
var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId);
if (detail?.Order != null)
{
_orderNumber = _resolvedOrderId.ToString("N")[..8].ToUpper();
_orderDate = detail.Order.CreatedAt.ToString("dd/MM/yyyy");
_orderTime = detail.Order.CreatedAt.ToString("HH:mm:ss");
_total = detail.Order.TotalAmount;
private record ReceiptItem(string Name, decimal Price, int Qty);
if (!string.IsNullOrEmpty(detail.Order.PaymentMethod))
{
_paymentMethodDisplay = _methodNames.TryGetValue(detail.Order.PaymentMethod, out var n)
? n : detail.Order.PaymentMethod;
}
}
if (detail?.Items != null)
{
_items = detail.Items;
_subtotal = _items.Sum(i => i.Subtotal);
// EN: Calculate tax/service from difference between total and subtotal.
// VI: Tính thuế/phí dịch vụ từ chênh lệch giữa tổng và tạm tính.
var diff = _total - _subtotal;
if (diff > 0)
{
_serviceCharge = Math.Round(diff * 0.385m); // ~5/13 of diff
_vat = diff - _serviceCharge;
}
}
if (!string.IsNullOrEmpty(StoreName))
_shopName = StoreName;
}
catch { /* graceful — show partial data */ }
finally { _isLoading = false; }
}
private async Task Print()
{
// EN: Trigger browser print dialog / VI: Kích hoạt hộp thoại in từ trình duyệt
await JS.InvokeVoidAsync("window.print");
}
private void Close()
{
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}");
}
}

View File

@@ -129,8 +129,8 @@
@onclick="@(() => NavigateTo("spa/appointment-book"))">
<i data-lucide="calendar" style="width:16px;height:16px;display:inline;"></i> Đặt lịch
</button>
<button class="pos-btn-checkout" style="flex:1;" @onclick="Checkout">
<i data-lucide="credit-card" style="width:18px;height:18px;"></i> Thanh toán
<button class="pos-btn-checkout" style="flex:1;" @onclick="Checkout" disabled="@_isCheckingOut">
<i data-lucide="credit-card" style="width:18px;height:18px;"></i> @(_isCheckingOut ? "Đang xử lý..." : "Thanh toán")
</button>
</div>
</div>
@@ -146,10 +146,11 @@
private string[] _categories = { "Tất cả" };
private string _selectedCategory = "Tất cả";
// EN: Demo customer / VI: Khách hàng mẫu
private string? _customerName = "Nguyễn Thị Mai";
private string _customerPhone = "0901234567";
private string _customerTier = "Gold";
// EN: Customer info (selected from lookup) / VI: Thông tin khách hàng (chọn từ tra cứu)
private string? _customerName;
private string _customerPhone = "";
private string _customerTier = "";
private bool _isCheckingOut;
// EN: Service list from API / VI: Danh sách dịch vụ từ API
private List<SpaService> _services = new();
@@ -169,6 +170,7 @@
var apiProducts = await DataService.GetProductsAsync(ShopId);
_services = apiProducts.Select(p => new SpaService(
p.Id,
p.Name,
p.Price,
p.DurationMinutes ?? 60,
@@ -190,12 +192,41 @@
private void AddToAppointment(SpaService svc)
{
_appointmentItems.Add(new AppointmentItem(svc.Name, svc.Price, svc.Duration));
_appointmentItems.Add(new AppointmentItem(svc.Id, svc.Name, svc.Price, svc.Duration));
}
private void RemoveItem(AppointmentItem item) => _appointmentItems.Remove(item);
private void Checkout() => NavigateTo("spa/spa-journey");
private async Task Checkout()
{
if (!_appointmentItems.Any()) return;
_isCheckingOut = true;
try
{
var orderItems = _appointmentItems.Select(i =>
new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest(
i.ProductId, i.Name, 1, i.Price, "Service"
)).ToList();
var request = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest(
ShopId, null, orderItems);
var result = await DataService.CreatePosOrderAsync(request);
if (result is not null)
{
NavigateTo("spa/spa-journey");
}
}
catch
{
// EN: Order creation failed / VI: Tạo đơn hàng thất bại
}
finally
{
_isCheckingOut = false;
}
}
private static string GetCategoryIcon(string category) => category switch
{
@@ -204,6 +235,6 @@
};
// EN: Models / VI: Mô hình dữ liệu
private record SpaService(string Name, decimal Price, int Duration, string Category);
private record AppointmentItem(string Name, decimal Price, int Duration);
private record SpaService(Guid Id, string Name, decimal Price, int Duration, string Category);
private record AppointmentItem(Guid ProductId, string Name, decimal Price, int Duration);
}

View File

@@ -105,7 +105,9 @@
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(AppointmentTotal)</span>
</div>
<button class="pos-btn-checkout" @onclick="Checkout">Thanh toán</button>
<button class="pos-btn-checkout" @onclick="Checkout" disabled="@_isCheckingOut">
@(_isCheckingOut ? "Đang xử lý..." : "Thanh toán")
</button>
</div>
</div>
</div>
@@ -122,6 +124,7 @@
private string[] _categories = { "Tất cả" };
private string _selectedCategory = "Tất cả";
private bool _showSheet;
private bool _isCheckingOut;
// EN: Service list from API / VI: Danh sách dịch vụ từ API
private List<SpaService> _services = new();
@@ -141,6 +144,7 @@
var apiProducts = await DataService.GetProductsAsync(ShopId);
_services = apiProducts.Select(p => new SpaService(
p.Id,
p.Name,
p.Price,
p.DurationMinutes ?? 60,
@@ -162,10 +166,39 @@
private void AddToAppointment(SpaService svc)
{
_appointmentItems.Add(new AppointmentItem(svc.Name, svc.Price, svc.Duration));
_appointmentItems.Add(new AppointmentItem(svc.Id, svc.Name, svc.Price, svc.Duration));
}
private void Checkout() => NavigateTo("spa/spa-journey");
private async Task Checkout()
{
if (!_appointmentItems.Any()) return;
_isCheckingOut = true;
try
{
var orderItems = _appointmentItems.Select(i =>
new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest(
i.ProductId, i.Name, 1, i.Price, "Service"
)).ToList();
var request = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest(
ShopId, null, orderItems);
var result = await DataService.CreatePosOrderAsync(request);
if (result is not null)
{
NavigateTo("spa/spa-journey");
}
}
catch
{
// EN: Order creation failed / VI: Tạo đơn hàng thất bại
}
finally
{
_isCheckingOut = false;
}
}
private static string GetCategoryIcon(string category) => category switch
{
@@ -174,6 +207,6 @@
};
// EN: Models / VI: Mô hình dữ liệu
private record SpaService(string Name, decimal Price, int Duration, string Category);
private record AppointmentItem(string Name, decimal Price, int Duration);
private record SpaService(Guid Id, string Name, decimal Price, int Duration, string Category);
private record AppointmentItem(Guid ProductId, string Name, decimal Price, int Duration);
}

View File

@@ -94,8 +94,8 @@
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(AppointmentTotal)</span>
</div>
<button class="pos-btn-checkout" style="height:56px;font-size:17px;" @onclick="Checkout">
Thanh toán — @FormatPrice(AppointmentTotal)
<button class="pos-btn-checkout" style="height:56px;font-size:17px;" @onclick="Checkout" disabled="@_isCheckingOut">
@(_isCheckingOut ? "Đang xử lý..." : $"Thanh toán — {FormatPrice(AppointmentTotal)}")
</button>
</div>
</div>
@@ -109,6 +109,7 @@
// EN: Categories / VI: Danh mục
private string[] _categories = { "Tất cả" };
private string _selectedCategory = "Tất cả";
private bool _isCheckingOut;
// EN: Service list from API / VI: Danh sách dịch vụ từ API
private List<SpaService> _services = new();
@@ -128,6 +129,7 @@
var apiProducts = await DataService.GetProductsAsync(ShopId);
_services = apiProducts.Select(p => new SpaService(
p.Id,
p.Name,
p.Price,
p.DurationMinutes ?? 60,
@@ -149,10 +151,39 @@
private void AddToAppointment(SpaService svc)
{
_appointmentItems.Add(new AppointmentItem(svc.Name, svc.Price, svc.Duration));
_appointmentItems.Add(new AppointmentItem(svc.Id, svc.Name, svc.Price, svc.Duration));
}
private void Checkout() => NavigateTo("spa/spa-journey");
private async Task Checkout()
{
if (!_appointmentItems.Any()) return;
_isCheckingOut = true;
try
{
var orderItems = _appointmentItems.Select(i =>
new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest(
i.ProductId, i.Name, 1, i.Price, "Service"
)).ToList();
var request = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest(
ShopId, null, orderItems);
var result = await DataService.CreatePosOrderAsync(request);
if (result is not null)
{
NavigateTo("spa/spa-journey");
}
}
catch
{
// EN: Order creation failed / VI: Tạo đơn hàng thất bại
}
finally
{
_isCheckingOut = false;
}
}
private static string GetCategoryIcon(string category) => category switch
{
@@ -161,6 +192,6 @@
};
// EN: Models / VI: Mô hình dữ liệu
private record SpaService(string Name, decimal Price, int Duration, string Category);
private record AppointmentItem(string Name, decimal Price, int Duration);
private record SpaService(Guid Id, string Name, decimal Price, int Duration, string Category);
private record AppointmentItem(Guid ProductId, string Name, decimal Price, int Duration);
}

View File

@@ -44,7 +44,7 @@
background:@(day.Value == _selectedDate ? "var(--pos-orange-primary)" : "var(--pos-bg-elevated)");
color:@(day.Value == _selectedDate ? "#FFF" : "var(--pos-text-primary)");
border:1px solid @(day.Value == _selectedDate ? "var(--pos-orange-primary)" : "var(--pos-border-subtle)");"
@onclick="() => _selectedDate = day.Value">
@onclick="() => { _selectedDate = day.Value; _selectedDateValue = day.Date; }">
<div style="font-size:11px;opacity:.7;">@day.Label</div>
<div style="font-size:16px;font-weight:700;margin-top:2px;">@day.Day</div>
</button>
@@ -58,12 +58,13 @@
<div style="display:flex;gap:8px;flex-wrap:wrap;">
@foreach (var staff in _staffList)
{
var s = staff;
<button style="padding:8px 16px;border-radius:20px;cursor:pointer;font-size:13px;
background:@(staff == _selectedStaff ? "var(--pos-orange-primary)" : "var(--pos-bg-elevated)");
color:@(staff == _selectedStaff ? "#FFF" : "var(--pos-text-primary)");
border:1px solid @(staff == _selectedStaff ? "var(--pos-orange-primary)" : "var(--pos-border-subtle)");"
@onclick="() => _selectedStaff = staff">
@staff
background:@(s.Name == _selectedStaffName ? "var(--pos-orange-primary)" : "var(--pos-bg-elevated)");
color:@(s.Name == _selectedStaffName ? "#FFF" : "var(--pos-text-primary)");
border:1px solid @(s.Name == _selectedStaffName ? "var(--pos-orange-primary)" : "var(--pos-border-subtle)");"
@onclick="() => { _selectedStaffName = s.Name; _selectedStaffId = s.Id; }">
@s.Name
</button>
}
</div>
@@ -110,7 +111,7 @@
<div style="display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--pos-text-secondary);">Khách hàng</span>
<span style="font-weight:600;">Nguyễn Thị Mai</span>
<span style="font-weight:600;">Khách vãng lai</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--pos-text-secondary);">Ngày</span>
@@ -122,7 +123,7 @@
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--pos-text-secondary);">Nhân viên</span>
<span style="font-weight:600;">@_selectedStaff</span>
<span style="font-weight:600;">@_selectedStaffName</span>
</div>
</div>
@@ -151,8 +152,20 @@
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(_selectedServices.Sum(s => s.Price))</span>
</div>
<button class="pos-btn-checkout" @onclick="@(() => NavigateTo("spa/spa-journey"))">
<i data-lucide="calendar-check" style="width:18px;height:18px;"></i> Xác nhận đặt lịch
@if (_bookingError is not null)
{
<div style="padding:8px 12px;margin-bottom:8px;border-radius:var(--pos-radius);background:rgba(239,68,68,.1);color:var(--pos-danger);font-size:12px;">
@_bookingError
</div>
}
@if (_bookingSuccess)
{
<div style="padding:8px 12px;margin-bottom:8px;border-radius:var(--pos-radius);background:rgba(34,197,94,.1);color:var(--pos-success);font-size:12px;">
Đặt lịch thành công!
</div>
}
<button class="pos-btn-checkout" @onclick="ConfirmBooking" disabled="@(_isBooking || _selectedTime is null)">
<i data-lucide="calendar-check" style="width:18px;height:18px;"></i> @(_isBooking ? "Đang đặt lịch..." : "Xác nhận đặt lịch")
</button>
</div>
</div>
@@ -165,20 +178,19 @@
private bool _loadError;
private string _selectedDate = "Hôm nay";
private DateTime _selectedDateValue = DateTime.Today;
private string? _selectedTime = "10:00";
private string _selectedStaff = "Chị Hoa";
private string _selectedStaffName = "Bất kỳ";
private Guid? _selectedStaffId;
// EN: Date options / VI: Tùy chọn ngày
private readonly List<DateOption> _dateOptions = new()
{
new("Hôm nay", "T5", "Hôm nay"),
new("Ngày mai", "T6", "Ngày mai"),
new("22/02", "T7", "22/02"),
new("23/02", "CN", "23/02"),
};
// EN: Date options (dynamic) / VI: Tùy chọn ngày (động)
private List<DateOption> _dateOptions = new();
// EN: Staff list / VI: Danh sách nhân viên
private readonly string[] _staffList = { "Chị Hoa", "Anh Minh", "Chị Lan", "Chị Trang", "Bất kỳ" };
// EN: Staff list from API / VI: Danh sách nhân viên từ API
private List<StaffOption> _staffList = new();
private bool _isBooking;
private string? _bookingError;
private bool _bookingSuccess;
// EN: Time slots from API / VI: Khung giờ từ API
private List<TimeSlot> _timeSlots = new();
@@ -192,9 +204,32 @@
try
{
// EN: Build dynamic date options / VI: Tạo tùy chọn ngày động
var today = DateTime.Today;
var dayNames = new[] { "CN", "T2", "T3", "T4", "T5", "T6", "T7" };
_dateOptions = Enumerable.Range(0, 4).Select(i =>
{
var d = today.AddDays(i);
var label = i == 0 ? "Hôm nay" : i == 1 ? "Ngày mai" : d.ToString("dd/MM");
return new DateOption(label, dayNames[(int)d.DayOfWeek], label, d);
}).ToList();
_selectedDate = _dateOptions[0].Value;
_selectedDateValue = _dateOptions[0].Date;
// EN: Load staff from API / VI: Tải nhân viên từ API
var staffData = await DataService.GetStaffForShopAsync(ShopId);
_staffList = staffData.Select(s => new StaffOption(
s.Id,
$"{s.FirstName ?? ""} {s.LastName ?? ""}".Trim() is { Length: > 0 } name ? name : (s.EmployeeCode ?? s.Id.ToString()[..8])
)).ToList();
_staffList.Add(new StaffOption(null, "Bất kỳ"));
_selectedStaffName = "Bất kỳ";
// EN: Load appointments to mark booked slots / VI: Tải lịch hẹn để đánh dấu khung giờ đã đặt
var appointments = await DataService.GetAppointmentsAsync(ShopId);
var bookedTimes = appointments
.Where(a => a.StartTime.Date == _selectedDateValue.Date)
.Select(a => a.StartTime.ToString("HH:mm"))
.ToHashSet();
@@ -211,8 +246,10 @@
}
_timeSlots = slots;
// EN: Load services/products / VI: Tải dịch vụ/sản phẩm
var products = await DataService.GetProductsAsync(ShopId);
_selectedServices = products.Take(2).Select(p => new ServiceInfo(
p.Id,
p.Name,
p.Price,
p.DurationMinutes ?? 60
@@ -228,7 +265,59 @@
}
}
private record DateOption(string Label, string Day, string Value);
// EN: Confirm booking via API / VI: Xác nhận đặt lịch qua API
private async Task ConfirmBooking()
{
if (_selectedTime is null || !_selectedServices.Any()) return;
_isBooking = true;
_bookingError = null;
_bookingSuccess = false;
try
{
var timeParts = _selectedTime.Split(':');
var startTime = _selectedDateValue.Date
.AddHours(int.Parse(timeParts[0]))
.AddMinutes(int.Parse(timeParts[1]));
var totalDuration = _selectedServices.Sum(s => s.Duration);
var endTime = startTime.AddMinutes(totalDuration);
// EN: Create appointment for the first service (primary) / VI: Tạo lịch hẹn cho dịch vụ chính
var request = new WebClientTpos.Client.Services.PosDataService.CreateAppointmentRequest(
ShopId,
null, // CustomerId
_selectedStaffId,
null, // ResourceId
_selectedServices.First().Id,
startTime,
endTime
);
var success = await DataService.CreateAppointmentAsync(request);
if (success)
{
_bookingSuccess = true;
await Task.Delay(1000);
NavigateTo("spa");
}
else
{
_bookingError = "Không thể đặt lịch. Vui lòng thử lại.";
}
}
catch
{
_bookingError = "Lỗi kết nối. Vui lòng thử lại.";
}
finally
{
_isBooking = false;
}
}
private record DateOption(string Label, string Day, string Value, DateTime Date);
private record TimeSlot(string Time, string Status);
private record ServiceInfo(string Name, decimal Price, int Duration);
private record ServiceInfo(Guid Id, string Name, decimal Price, int Duration);
private record StaffOption(Guid? Id, string Name);
}

View File

@@ -5,6 +5,7 @@
@page "/pos/{ShopId:guid}/spa/customer-lookup"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@@ -24,18 +25,42 @@
border-radius:var(--pos-radius);padding:0 14px;border:1px solid var(--pos-border-default);">
<i data-lucide="search" style="width:16px;height:16px;color:var(--pos-text-tertiary);"></i>
<input type="text" placeholder="SĐT hoặc tên khách hàng..." @bind="_searchTerm"
@onkeydown="HandleSearchKeyDown"
style="flex:1;background:transparent;border:none;color:var(--pos-text-primary);
font-size:14px;padding:12px 0;outline:none;" />
</div>
<button style="padding:12px 20px;border-radius:var(--pos-radius);background:var(--pos-orange-primary);
border:none;color:#FFF;cursor:pointer;font-size:13px;font-weight:600;">
Tìm kiếm
border:none;color:#FFF;cursor:pointer;font-size:13px;font-weight:600;"
@onclick="SearchCustomers" disabled="@_isSearching">
@(_isSearching ? "Đang tìm..." : "Tìm kiếm")
</button>
</div>
</div>
@* ═══ SEARCH RESULTS / KẾT QUẢ TÌM KIẾM ═══ *@
<div style="flex:1;overflow-y:auto;padding:0 16px 16px;">
@if (_isSearching)
{
<div style="display:flex;align-items:center;justify-content:center;padding:40px 0;color:var(--pos-text-tertiary);">
Đang tìm kiếm...
</div>
}
else if (_searchPerformed && !_customers.Any())
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 0;color:var(--pos-text-tertiary);">
<i data-lucide="search-x" style="width:48px;height:48px;margin-bottom:12px;opacity:0.3;"></i>
<div style="font-size:14px;font-weight:600;">Không tìm thấy khách hàng</div>
<div style="font-size:12px;margin-top:4px;">Thử tìm với từ khóa khác hoặc tạo khách mới</div>
</div>
}
else if (_searchError is not null)
{
<div style="display:flex;align-items:center;justify-content:center;padding:40px 0;color:var(--pos-danger);">
@_searchError
</div>
}
else
{
<div style="display:flex;flex-direction:column;gap:8px;">
@foreach (var customer in _customers)
{
@@ -73,6 +98,8 @@
}
</div>
}
@* ═══ CREATE NEW CUSTOMER / TẠO KHÁCH MỚI ═══ *@
<div style="margin-top:16px;">
<button style="width:100%;padding:16px;border-radius:var(--pos-radius);background:var(--pos-bg-elevated);
@@ -86,17 +113,59 @@
</div>
@code {
// EN: Static UI configuration — does not require DB data (needs customer API) / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB (cần customer API)
// EN: Search state / VI: Trạng thái tìm kiếm
private string _searchTerm = "";
private bool _isSearching;
private bool _searchPerformed;
private string? _searchError;
private List<CustomerInfo> _customers = new();
private string _searchTerm = "Nguyễn";
private readonly List<CustomerInfo> _customers = new()
private async Task HandleSearchKeyDown(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e)
{
new("Nguyễn Thị Mai", "0901234567", "Gold", "15/02/2025", 28),
new("Trần Hương Giang", "0912345678", "Platinum", "12/02/2025", 45),
new("Lê Thị Lan", "0923456789", "Silver", "08/02/2025", 12),
new("Phạm Minh Châu", "0934567890", "Gold", "05/02/2025", 22),
new("Hoàng Thị Hoa", "0945678901", "Diamond", "14/02/2025", 68),
};
if (e.Key == "Enter")
await SearchCustomers();
}
private async Task SearchCustomers()
{
if (string.IsNullOrWhiteSpace(_searchTerm)) return;
_isSearching = true;
_searchError = null;
_searchPerformed = false;
try
{
var members = await DataService.SearchCustomersAsync(ShopId, _searchTerm);
_customers = members.Select(m =>
{
var name = m.DisplayName ?? "Khách";
var phone = m.Phone ?? "";
// EN: Map level to tier name / VI: Map cấp độ sang tên hạng
var tier = m.LevelName ?? (m.CurrentLevel switch
{
>= 4 => "Diamond",
3 => "Platinum",
2 => "Gold",
1 => "Silver",
_ => "Member"
});
var lastVisit = m.CreatedAt.ToString("dd/MM/yyyy");
return new CustomerInfo(name, phone, tier, lastVisit, m.TotalExpEarned);
}).ToList();
_searchPerformed = true;
}
catch
{
_searchError = "Lỗi kết nối. Vui lòng thử lại.";
}
finally
{
_isSearching = false;
}
}
private static string GetTierBg(string t) => t switch
{

View File

@@ -1,109 +1,171 @@
@*
EN: Spa Customer Profile — Photo, VIP tier badge, points, progress bar, visit history, favorites, rewards.
VI: Hồ sơ khách Spa — Ảnh, badge hạng VIP, điểm, thanh tiến trình, lịch sử ghé, yêu thích, phần thưởng.
Loads real member data from API with appointment history.
VI: Ho so khach Spa — Anh, badge hang VIP, diem, thanh tien trinh, lich su ghe, yeu thich, phan thuong.
Tai du lieu thanh vien that tu API voi lich su lich hen.
*@
@page "/pos/{ShopId:guid}/spa/customer-profile"
@page "/pos/{ShopId:guid}/spa/customer-profile/{CustomerId:guid}"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;overflow:hidden;">
@* ═══ PROFILE PANEL (LEFT) / PANEL HỒ SƠ (TRÁI) ═══ *@
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--pos-text-tertiary);">
Dang tai...
</div>
}
else if (_loadError || _member == null)
{
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;gap:12px;color:var(--pos-text-tertiary);">
<div>Khong the tai ho so khach hang</div>
<button style="padding:10px 20px;border-radius:8px;background:var(--pos-orange-primary);border:none;color:#FFF;cursor:pointer;"
@onclick="@(() => NavigateTo("spa/customer-lookup"))">
Tim khach hang
</button>
</div>
}
else
{
@* === PROFILE PANEL (LEFT) / PANEL HO SO (TRAI) === *@
<div class="pos-product-panel" style="padding:16px;overflow-y:auto;">
@* EN: Header / VI: Tiêu đề *@
@* EN: Header / VI: Tieu de *@
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
@onclick="@(() => NavigateTo("spa/customer-lookup"))">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;">H sơ khách hàng</span>
<span style="font-size:16px;font-weight:700;">Ho so khach hang</span>
</div>
@* ═══ PROFILE CARD / TH H SƠ ═══ *@
@* === PROFILE CARD / THE HO SO === *@
<div style="background:linear-gradient(135deg,#1A1A1D,#2A2A2E);border-radius:16px;padding:24px;
margin-bottom:16px;border:1px solid rgba(245,158,11,.3);text-align:center;">
<div style="width:72px;height:72px;border-radius:50%;background:var(--pos-orange-primary);display:flex;align-items:center;justify-content:center;margin:0 auto 12px;font-size:28px;font-weight:700;color:#fff;">
M
@_memberInitial
</div>
<div style="font-size:20px;font-weight:700;">Nguyễn Thị Mai</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);margin-top:2px;">0901234567</div>
<div style="font-size:20px;font-weight:700;">@_memberName</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);margin-top:2px;">@(_member.Phone ?? "---")</div>
<span style="display:inline-block;margin-top:8px;padding:4px 16px;border-radius:8px;font-size:12px;font-weight:700;
background:rgba(245,158,11,.2);color:#F59E0B;">
Gold
background:@GetLevelBadgeBg(_member.CurrentLevel);color:@GetLevelBadgeColor(_member.CurrentLevel);">
@(_member.LevelName ?? $"Level {_member.CurrentLevel}")
</span>
@* EN: Stats row / VI: Hàng thng kê *@
@* EN: Stats row / VI: Hang thong ke *@
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:16px;">
<div style="text-align:center;padding:12px;background:rgba(255,255,255,.05);border-radius:10px;">
<div style="font-size:20px;font-weight:700;color:var(--pos-orange-primary);">2,450</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">Điểm</div>
<div style="font-size:20px;font-weight:700;color:var(--pos-orange-primary);">@_member.CurrentExp.ToString("N0")</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">Diem</div>
</div>
<div style="text-align:center;padding:12px;background:rgba(255,255,255,.05);border-radius:10px;">
<div style="font-size:20px;font-weight:700;">28</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">Lượt ghé</div>
<div style="font-size:20px;font-weight:700;">@_visitCount</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">Luot ghe</div>
</div>
<div style="text-align:center;padding:12px;background:rgba(255,255,255,.05);border-radius:10px;">
<div style="font-size:20px;font-weight:700;color:#22C55E;">8.5M</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">Tng chi</div>
<div style="font-size:20px;font-weight:700;color:#22C55E;">@FormatCompact(_totalSpent)</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">Tong chi</div>
</div>
</div>
@* EN: Tier progress / VI: Tiến trình hng *@
<div style="margin-top:16px;">
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">
<span>Gold</span>
<span>2,450 / 5,000 điểm</span>
<span>Platinum</span>
@* EN: Tier progress / VI: Tien trinh hang *@
@if (_progress != null)
{
<div style="margin-top:16px;">
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">
<span>@(_progress.CurrentLevelName ?? $"Lv{_member.CurrentLevel}")</span>
<span>@_member.CurrentExp.ToString("N0") / @(_progress.ExpToNextLevel > 0 ? _progress.ExpToNextLevel.ToString("N0") : "MAX") diem</span>
<span>@(_progress.NextLevelName ?? "MAX")</span>
</div>
<div style="height:6px;border-radius:3px;background:rgba(255,255,255,.1);">
<div style="height:100%;border-radius:3px;background:linear-gradient(90deg,#F59E0B,#8B5CF6);width:@(_progressPct)%;"></div>
</div>
@if (_progress.ExpToNextLevel > _member.CurrentExp)
{
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:6px;">
Con @((_progress.ExpToNextLevel - _member.CurrentExp).ToString("N0")) diem de len hang @(_progress.NextLevelName ?? "tiep theo")
</div>
}
</div>
<div style="height:6px;border-radius:3px;background:rgba(255,255,255,.1);">
<div style="height:100%;border-radius:3px;background:linear-gradient(90deg,#F59E0B,#8B5CF6);width:49%;"></div>
</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:6px;">
Còn 2,550 điểm để lên hạng Platinum
</div>
</div>
}
</div>
@* ═══ VISIT HISTORY / LCH S GHÉ ═══ *@
<div style="font-size:14px;font-weight:600;margin-bottom:10px;">Lch s ghé gn đây</div>
@foreach (var visit in _visitHistory)
@* === VISIT HISTORY / LICH SU GHE === *@
<div style="font-size:14px;font-weight:600;margin-bottom:10px;">Lich su ghe gan day</div>
@if (_appointments.Any())
{
<div style="display:flex;align-items:center;gap:12px;padding:12px;
background:var(--pos-bg-elevated);border-radius:var(--pos-radius);
margin-bottom:8px;border:1px solid var(--pos-border-subtle);">
<div style="width:40px;height:40px;border-radius:10px;background:var(--pos-bg-interactive);
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="sparkles" style="width:18px;height:18px;color:var(--pos-text-tertiary);"></i>
@foreach (var appt in _appointments.Take(5))
{
<div style="display:flex;align-items:center;gap:12px;padding:12px;
background:var(--pos-bg-elevated);border-radius:var(--pos-radius);
margin-bottom:8px;border:1px solid var(--pos-border-subtle);">
<div style="width:40px;height:40px;border-radius:10px;background:var(--pos-bg-interactive);
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="sparkles" style="width:18px;height:18px;color:var(--pos-text-tertiary);"></i>
</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;">@(appt.ResourceName ?? "Dich vu")</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@appt.StartTime.ToString("dd/MM/yyyy HH:mm") • @appt.Status</div>
</div>
<div style="text-align:right;">
<div style="font-size:13px;font-weight:600;">
@((appt.EndTime - appt.StartTime).TotalMinutes.ToString("0")) phut
</div>
</div>
</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;">@visit.Services</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@visit.Date • @visit.Therapist</div>
</div>
<div style="text-align:right;">
<div style="font-size:13px;font-weight:600;">@FormatPrice(visit.Amount)</div>
<div style="font-size:11px;color:#22C55E;">+@visit.Points điểm</div>
}
}
else if (_expHistory.Any())
{
@foreach (var tx in _expHistory.Take(5))
{
<div style="display:flex;align-items:center;gap:12px;padding:12px;
background:var(--pos-bg-elevated);border-radius:var(--pos-radius);
margin-bottom:8px;border:1px solid var(--pos-border-subtle);">
<div style="width:40px;height:40px;border-radius:10px;background:var(--pos-bg-interactive);
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="sparkles" style="width:18px;height:18px;color:var(--pos-text-tertiary);"></i>
</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;">@(tx.Source ?? "Giao dich")</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@tx.CreatedAt.ToString("dd/MM/yyyy")</div>
</div>
<div style="text-align:right;">
<div style="font-size:11px;color:#22C55E;">+@tx.Points diem</div>
</div>
</div>
}
}
else
{
<div style="padding:16px;text-align:center;color:var(--pos-text-tertiary);font-size:13px;">
Chua co lich su
</div>
}
@* ═══ FAVORITE SERVICES / DCH V YÊU THÍCH ═══ *@
<div style="font-size:14px;font-weight:600;margin:16px 0 10px;">Dịch vụ yêu thích</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;">
@foreach (var fav in _favorites)
{
<span style="padding:6px 14px;border-radius:20px;font-size:12px;font-weight:500;
background:rgba(255,92,0,.1);color:var(--pos-orange-primary);
border:1px solid rgba(255,92,0,.2);">
@fav
</span>
}
</div>
@* === FAVORITE SERVICES / DICH VU YEU THICH === *@
@if (_favorites.Any())
{
<div style="font-size:14px;font-weight:600;margin:16px 0 10px;">Dich vu yeu thich</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;">
@foreach (var fav in _favorites)
{
<span style="padding:6px 14px;border-radius:20px;font-size:12px;font-weight:500;
background:rgba(255,92,0,.1);color:var(--pos-orange-primary);
border:1px solid rgba(255,92,0,.2);">
@fav
</span>
}
</div>
}
</div>
@* ═══ REWARDS PANEL (RIGHT) / PANEL PHN THƯỞNG (PHI) ═══ *@
@* === REWARDS PANEL (RIGHT) / PANEL PHAN THUONG (PHAI) === *@
<div class="pos-cart-panel">
<div class="pos-cart-header">
<span class="pos-cart-header__title">Phn thưởng</span>
<span class="pos-cart-header__title">Phan thuong</span>
</div>
<div class="pos-cart-items" style="padding:12px;">
@@ -118,7 +180,9 @@
<span style="font-size:12px;color:var(--pos-orange-primary);font-weight:600;">@reward.Value</span>
</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:4px;">@reward.Description</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">Cần @reward.PointCost điểm</div>
<div style="font-size:11px;color:@(reward.PointCost <= _member.CurrentExp ? "#22C55E" : "var(--pos-text-tertiary)");margin-top:2px;">
Can @reward.PointCost diem @(reward.PointCost <= _member.CurrentExp ? "(Du diem)" : "")
</div>
</div>
}
</div>
@@ -132,37 +196,170 @@
</div>
}
<button class="pos-btn-checkout" @onclick="@(() => NavigateTo("spa"))">
<i data-lucide="check" style="width:18px;height:18px;"></i> Chn khách hàng
<i data-lucide="check" style="width:18px;height:18px;"></i> Chon khach hang
</button>
</div>
</div>
}
</div>
@code {
// EN: Static UI configuration — does not require DB data (needs customer API) / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB (cần customer API)
// EN: Customer ID from route parameter / VI: ID khach hang tu route parameter
[Parameter] public Guid CustomerId { get; set; }
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
// EN: Member data from API / VI: Du lieu thanh vien tu API
private WebClientTpos.Client.Services.PosDataService.MemberInfo? _member;
private WebClientTpos.Client.Services.PosDataService.MemberProgressInfo? _progress;
private List<WebClientTpos.Client.Services.PosDataService.ExpTransactionInfo> _expHistory = new();
private List<WebClientTpos.Client.Services.PosDataService.AppointmentInfo> _appointments = new();
// EN: Derived display values / VI: Gia tri hien thi
private string _memberName = "";
private string _memberInitial = "?";
private int _visitCount;
private decimal _totalSpent;
private int _progressPct;
private List<string> _favorites = new();
private RewardInfo? _selectedReward;
private readonly List<VisitInfo> _visitHistory = new()
{
new("Massage toàn thân + Facial", "15/02/2025", "Chị Hoa", 850_000, 85),
new("Tắm trắng toàn thân", "08/02/2025", "Chị Lan", 800_000, 80),
new("Gội đầu dưỡng sinh + Massage chân", "01/02/2025", "Anh Minh", 450_000, 45),
new("Facial collagen", "25/01/2025", "Chị Hoa", 600_000, 60),
new("Sơn gel + Chăm sóc móng tay", "18/01/2025", "Chị Trang", 270_000, 27),
};
// EN: Favorite services / VI: Dịch vụ yêu thích
private readonly string[] _favorites = { "Massage toàn thân", "Facial collagen", "Gội đầu dưỡng sinh", "Sơn gel" };
// EN: Available rewards / VI: Phần thưởng khả dụng
// EN: Available rewards (config-based) / VI: Phan thuong kha dung (cau hinh)
private readonly List<RewardInfo> _rewards = new()
{
new("RW1", "Gim 20% dch v", "-20%", "Áp dng cho mi dch vụ đơn l", 500),
new("RW2", "Free Massage chân", "Min phí", "1 ln Massage chân 45 phút min phí", 800),
new("RW3", "Gim 100K", "-100,000", "Gim 100K cho hóa đơn t 500K", 400),
new("RW4", "Nâng hng dch v", "Upgrade", "Nâng Facial cơ bn lên Facial collagen", 600),
new("RW1", "Giam 20% dich vu", "-20%", "Ap dung cho moi dich vu don le", 500),
new("RW2", "Free Massage chan", "Mien phi", "1 lan Massage chan 45 phut mien phi", 800),
new("RW3", "Giam 100K", "-100,000d", "Giam 100K cho hoa don tu 500K", 400),
new("RW4", "Nang hang dich vu", "Upgrade", "Nang Facial co ban len Facial collagen", 600),
};
private record VisitInfo(string Services, string Date, string Therapist, decimal Amount, int Points);
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// EN: If no CustomerId provided, redirect to lookup / VI: Neu khong co CustomerId, chuyen den tim kiem
if (CustomerId == Guid.Empty)
{
// EN: Try to get first member as demo / VI: Thu lay thanh vien dau tien lam demo
try
{
var members = await DataService.GetMembersAsync();
if (members.Any())
{
_member = members.First();
CustomerId = _member.Id;
}
else
{
NavigateTo("spa/customer-lookup");
return;
}
}
catch
{
NavigateTo("spa/customer-lookup");
return;
}
}
try
{
// EN: Load member by ID — search in member list / VI: Tai thanh vien theo ID — tim trong danh sach
if (_member == null)
{
var members = await DataService.GetMembersAsync();
_member = members.FirstOrDefault(m => m.Id == CustomerId);
}
if (_member == null)
{
_loadError = true;
return;
}
// EN: Set display values / VI: Dat gia tri hien thi
_memberName = _member.DisplayName ?? $"Thanh vien #{_member.Id.ToString()[..8]}";
_memberInitial = string.IsNullOrEmpty(_memberName) ? "?" : _memberName[..1].ToUpper();
// EN: Load progress info / VI: Tai thong tin tien trinh
try
{
_progress = await DataService.GetMemberProgressAsync(_member.Id);
if (_progress != null && _progress.ExpToNextLevel > 0)
{
_progressPct = Math.Min(100, (int)((decimal)_member.CurrentExp / _progress.ExpToNextLevel * 100));
}
}
catch { /* EN: Non-critical / VI: Khong quan trong */ }
// EN: Load experience history / VI: Tai lich su kinh nghiem
try
{
_expHistory = await DataService.GetExperienceHistoryAsync(_member.Id);
_visitCount = _expHistory.Count;
_totalSpent = _expHistory.Sum(e => e.Points) * 10_000m; // EN: Rough estimate / VI: Uoc tinh
}
catch { /* EN: Non-critical / VI: Khong quan trong */ }
// EN: Load appointment history / VI: Tai lich su lich hen
try
{
var allAppts = await DataService.GetAppointmentsAsync(ShopId);
_appointments = allAppts
.Where(a => a.CustomerId == CustomerId)
.OrderByDescending(a => a.StartTime)
.ToList();
if (_appointments.Any())
{
_visitCount = Math.Max(_visitCount, _appointments.Count);
// EN: Build favorites from most common services / VI: Xay dung yeu thich tu dich vu pho bien
_favorites = _appointments
.Where(a => !string.IsNullOrEmpty(a.ResourceName))
.GroupBy(a => a.ResourceName!)
.OrderByDescending(g => g.Count())
.Take(4)
.Select(g => g.Key)
.ToList();
}
}
catch { /* EN: Non-critical / VI: Khong quan trong */ }
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
}
private static string GetLevelBadgeBg(int level) => level switch
{
>= 4 => "rgba(139,92,246,.2)",
3 => "rgba(245,158,11,.2)",
2 => "rgba(192,192,192,.2)",
_ => "rgba(139,90,43,.2)"
};
private static string GetLevelBadgeColor(int level) => level switch
{
>= 4 => "#8B5CF6",
3 => "#F59E0B",
2 => "#C0C0C0",
_ => "#8B5A2B"
};
private static string FormatCompact(decimal amount)
{
if (amount >= 1_000_000) return $"{amount / 1_000_000:0.#}M";
if (amount >= 1_000) return $"{amount / 1_000:0.#}K";
return amount.ToString("N0");
}
private record RewardInfo(string Id, string Name, string Value, string Description, int PointCost);
}

View File

@@ -1,40 +1,65 @@
@*
EN: Spa Service Combo — Active combos/promotions, bundle discounts, buy 2 get 1, timer, apply combo.
VI: Combo dch v Spa — Combo/khuyến mãi đang áp dụng, giảm giá gộp, mua 2 tặng 1, đếm ngược, áp dng.
EN: Spa Service Combo — Active combos/promotions built from real product data, bundle discounts, apply combo.
VI: Combo dich vu Spa — Combo/khuyen mai xay dung tu du lieu san pham that, giam gia gop, ap dung.
*@
@page "/pos/{ShopId:guid}/spa/service-combo"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@* === HEADER / TIEU DE === *@
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);flex-shrink:0;">
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
@onclick="@(() => NavigateTo("spa"))">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;">Combo & Khuyến mãi</span>
<span style="font-size:16px;font-weight:700;">Combo & Khuyen mai</span>
@if (!_isLoading)
{
<span style="font-size:12px;color:var(--pos-text-tertiary);">@_combos.Count combo</span>
}
</div>
<div style="flex:1;overflow-y:auto;padding:16px;">
@* ═══ FLASH SALE BANNER / BANNER FLASH SALE ═══ *@
<div style="background:linear-gradient(135deg,rgba(255,92,0,.2),rgba(239,68,68,.15));
border-radius:var(--pos-radius);padding:20px;margin-bottom:16px;text-align:center;
border:1px solid rgba(255,92,0,.3);">
<div style="font-size:12px;font-weight:600;color:var(--pos-warning);text-transform:uppercase;letter-spacing:1px;">
<i data-lucide="zap" style="width:14px;height:14px;display:inline;"></i>
FLASH SALE — KẾT THÚC TRONG
</div>
<div style="font-size:36px;font-weight:700;color:var(--pos-orange-primary);margin:8px 0;font-variant-numeric:tabular-nums;">
02:15:30
</div>
<div style="font-size:13px;color:var(--pos-text-secondary);">
Giảm thêm 15% cho tất cả combo hôm nay!
</div>
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--pos-text-tertiary);">
Dang tai...
</div>
}
else if (_loadError)
{
<div style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--pos-text-tertiary);">
Khong the tai du lieu
</div>
}
else
{
<div style="flex:1;overflow-y:auto;padding:16px;">
@* === PROMOTION BANNER / BANNER KHUYEN MAI === *@
@if (_activePromotion != null)
{
<div style="background:linear-gradient(135deg,rgba(255,92,0,.2),rgba(239,68,68,.15));
border-radius:var(--pos-radius);padding:20px;margin-bottom:16px;text-align:center;
border:1px solid rgba(255,92,0,.3);">
<div style="font-size:12px;font-weight:600;color:var(--pos-warning);text-transform:uppercase;letter-spacing:1px;">
<i data-lucide="zap" style="width:14px;height:14px;display:inline;"></i>
@_activePromotion.Name
</div>
@if (_activePromotion.EndDate.HasValue)
{
<div style="font-size:14px;font-weight:600;color:var(--pos-orange-primary);margin:8px 0;">
Ket thuc: @_activePromotion.EndDate.Value.ToString("dd/MM/yyyy HH:mm")
</div>
}
<div style="font-size:13px;color:var(--pos-text-secondary);">
@(_activePromotion.Description ?? "Giam gia dac biet hom nay!")
</div>
</div>
}
@* ═══ COMBO LIST / DANH SÁCH COMBO ═══ *@
@* === COMBO LIST / DANH SACH COMBO === *@
<div style="display:flex;flex-direction:column;gap:12px;">
@foreach (var combo in _combos)
{
@@ -48,17 +73,15 @@
<div style="flex:1;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<span style="font-size:15px;font-weight:700;">@combo.Name</span>
@if (combo.Limited)
@if (combo.ServiceCount >= 3)
{
<span style="font-size:10px;padding:2px 8px;border-radius:4px;font-weight:700;
background:rgba(239,68,68,.15);color:var(--pos-danger);">
<i data-lucide="timer" style="width:10px;height:10px;display:inline;"></i> Có hạn
</span>
background:rgba(255,92,0,.15);color:var(--pos-orange-primary);">HOT</span>
}
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:8px;">@combo.Description</div>
@* EN: Included services / VI: Dch v bao gm *@
@* EN: Included services / VI: Dich vu bao gom *@
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px;">
@foreach (var svc in combo.Services)
{
@@ -69,7 +92,7 @@
}
</div>
@* EN: Price comparison / VI: So sánh giá *@
@* EN: Price comparison / VI: So sanh gia *@
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-size:13px;text-decoration:line-through;color:var(--pos-text-tertiary);">
@FormatPrice(combo.OriginalPrice)
@@ -77,14 +100,17 @@
<span style="font-size:18px;font-weight:700;color:var(--pos-orange-primary);">
@FormatPrice(combo.ComboPrice)
</span>
<span style="font-size:12px;color:#22C55E;font-weight:600;">
-@(Math.Round((combo.OriginalPrice - combo.ComboPrice) * 100 / combo.OriginalPrice))%
</span>
@if (combo.OriginalPrice > 0)
{
<span style="font-size:12px;color:#22C55E;font-weight:600;">
-@(Math.Round((combo.OriginalPrice - combo.ComboPrice) * 100 / combo.OriginalPrice))%
</span>
}
</div>
</div>
</div>
@* EN: Apply button / VI: Nút áp dng *@
@* EN: Apply button / VI: Nut ap dung *@
<button style="width:100%;margin-top:12px;padding:10px;border-radius:var(--pos-radius);
background:@(_selectedCombo == combo.Id ? "var(--pos-success)" : "var(--pos-bg-interactive)");
border:1px solid @(_selectedCombo == combo.Id ? "var(--pos-success)" : "var(--pos-border-default)");
@@ -94,49 +120,127 @@
@if (_selectedCombo == combo.Id)
{
<i data-lucide="check" style="width:14px;height:14px;display:inline;"></i>
<span> Đã chn</span>
<span> Da chon</span>
}
else
{
<span>Áp dng combo</span>
<span>Ap dung combo</span>
}
</button>
</div>
}
</div>
@if (!_combos.Any())
{
<div style="text-align:center;padding:40px;color:var(--pos-text-tertiary);">
<i data-lucide="package" style="width:48px;height:48px;display:block;margin:0 auto 12px;opacity:0.3;"></i>
Chua co combo nao. Them san pham de tao combo tu dong.
</div>
}
</div>
@* ═══ FOOTER / CHÂN TRANG ═══ *@
@* === FOOTER / CHAN TRANG === *@
@if (_selectedCombo is not null)
{
<div style="padding:12px 16px;border-top:1px solid var(--pos-border-subtle);flex-shrink:0;">
<button class="pos-btn-checkout" @onclick="@(() => NavigateTo("spa"))">
<i data-lucide="arrow-right" style="width:18px;height:18px;"></i> Tiếp tc vi combo đã chn
<button class="pos-btn-checkout" @onclick="@(() => NavigateTo("spa/appointment-book"))">
<i data-lucide="arrow-right" style="width:18px;height:18px;"></i> Tiep tuc voi combo da chon
</button>
</div>
}
}
</div>
@code {
// EN: Static UI configuration — combo definitions are config, does not require DB data / VI: Cấu hình UI tĩnh — định nghĩa combo là cấu hình, không cần dữ liệu từ DB
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
private string? _selectedCombo;
private readonly List<ComboInfo> _combos = new()
// EN: Combos built from product data / VI: Combo xay dung tu du lieu san pham
private List<ComboInfo> _combos = new();
// EN: Active promotion from API / VI: Khuyen mai dang ap dung tu API
private WebClientTpos.Client.Services.PosDataService.PromotionInfo? _activePromotion;
// EN: Combo definitions — groups of services that form combos
// VI: Dinh nghia combo — nhom dich vu tao thanh combo
private static readonly List<ComboConfig> ComboConfigs = new()
{
new("CB1", "Mua 2 tng 1", "Mua 2 dch v Massage bt k, tng 1 Massage chân min phí",
1_000_000, 750_000, true, "gift", "rgba(255,92,0,.15)", "#FF5C00",
new() { "Massage toàn thân", "Massage đầu vai cổ", "Massage chân (FREE)" }),
new("CB2", "Combo Spa Day", "Trn gói thư giãn c ngày: Massage + Facial + Nail",
950_000, 750_000, true, "sun", "rgba(245,158,11,.15)", "#F59E0B",
new() { "Massage toàn thân", "Facial cơ bản", "Sơn gel" }),
new("CB3", "Combo Làm đẹp", "Tẩy tế bào chết + Tm trng giá đặc bit",
1_200_000, 950_000, false, "sparkles", "rgba(139,92,246,.15)", "#8B5CF6",
new() { "Ty tế bào chết", "Tắm trắng toàn thân" }),
new("CB4", "Combo Thứ 3 vui vẻ", "Giảm 30% mọi dịch vụ Facial vào thứ 3 hàng tuần",
600_000, 420_000, true, "calendar", "rgba(34,197,94,.15)", "#22C55E",
new() { "Facial cơ bản", "Facial collagen" }),
new("CB1", "Mua 2 tang 1", "Mua 2 dich vu Massage bat ky, tang 1 Massage chan mien phi",
0.75m, "gift", "rgba(255,92,0,.15)", "#FF5C00",
new() { "Massage", "massage" }),
new("CB2", "Combo Spa Day", "Tron goi thu gian ca ngay: Massage + Facial + Nail",
0.79m, "sun", "rgba(245,158,11,.15)", "#F59E0B",
new() { "Massage", "Facial", "Nail", "Son" }),
new("CB3", "Combo Lam dep", "Cham soc da + Tam trang gia dac biet",
0.80m, "sparkles", "rgba(139,92,246,.15)", "#8B5CF6",
new() { "Tam", "Tay te bao", "Facial" }),
};
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
var products = await DataService.GetProductsAsync(ShopId);
// EN: Try to load promotions / VI: Thu tai khuyen mai
try
{
var promos = await DataService.GetPromotionsAsync();
_activePromotion = promos
.Where(p => p.IsActive && (!p.EndDate.HasValue || p.EndDate > DateTime.Now))
.FirstOrDefault();
}
catch { /* EN: No promotions available / VI: Khong co khuyen mai */ }
// EN: Build combos from real products / VI: Xay dung combo tu san pham that
if (products.Any())
{
_combos = ComboConfigs.Select((cfg, idx) =>
{
// EN: Find products matching combo keywords / VI: Tim san pham khop voi tu khoa combo
var matchedProducts = products
.Where(p => cfg.Keywords.Any(k =>
p.Name.Contains(k, StringComparison.OrdinalIgnoreCase) ||
(p.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) ?? false)))
.Take(4)
.ToList();
if (!matchedProducts.Any())
{
// EN: Fallback — use random products for this combo / VI: Du phong — dung san pham ngau nhien
matchedProducts = products.Skip(idx * 2).Take(3).ToList();
}
var serviceNames = matchedProducts.Select(p => p.Name).ToList();
var originalPrice = matchedProducts.Sum(p => p.Price);
var comboPrice = Math.Round(originalPrice * cfg.DiscountFactor);
return new ComboInfo(cfg.Id, cfg.Name, cfg.Description,
originalPrice, comboPrice, serviceNames.Count,
cfg.Icon, cfg.BgColor, cfg.FgColor, serviceNames);
})
.Where(c => c.OriginalPrice > 0)
.ToList();
}
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
}
private record ComboConfig(string Id, string Name, string Description, decimal DiscountFactor,
string Icon, string BgColor, string FgColor, List<string> Keywords);
private record ComboInfo(string Id, string Name, string Description, decimal OriginalPrice,
decimal ComboPrice, bool Limited, string Icon, string BgColor, string FgColor, List<string> Services);
decimal ComboPrice, int ServiceCount, string Icon, string BgColor, string FgColor, List<string> Services);
}

View File

@@ -106,8 +106,8 @@
<button style="width:100%;margin-top:12px;padding:12px;border-radius:var(--pos-radius);
background:var(--pos-orange-primary);border:none;color:#FFF;
cursor:pointer;font-size:14px;font-weight:600;"
@onclick="@(() => NavigateTo("spa"))">
<i data-lucide="plus" style="width:16px;height:16px;display:inline;"></i> Thêm vào lch hn
@onclick="() => AddPackageToAppointment(pkg)">
<i data-lucide="plus" style="width:16px;height:16px;display:inline;"></i> Them vao lich hen
</button>
</div>
}
@@ -161,13 +161,19 @@
return new PackageService(name, 0, 0);
}).ToList();
var originalPrice = services.Sum(s => s.Price);
var packagePrice = Math.Round(originalPrice * cfg.DiscountFactor);
// EN: Filter out services with no data (price=0, duration=0)
// VI: Loc bo dich vu khong co du lieu (gia=0, thoi luong=0)
var validServices = services.Where(s => s.Price > 0).ToList();
var originalPrice = validServices.Sum(s => s.Price);
var packagePrice = originalPrice > 0 ? Math.Round(originalPrice * cfg.DiscountFactor) : 0;
var totalDuration = services.Sum(s => s.Duration);
return new PackageInfo(cfg.Id, cfg.Name, packagePrice, originalPrice,
services.Count, totalDuration, cfg.Popular, cfg.Icon, cfg.BgColor, cfg.FgColor, services);
}).ToList();
validServices.Count > 0 ? validServices.Count : services.Count,
totalDuration, cfg.Popular, cfg.Icon, cfg.BgColor, cfg.FgColor, services);
})
.Where(p => p.OriginalPrice > 0) // EN: Only show packages with real pricing / VI: Chi hien goi co gia that
.ToList();
}
catch
{
@@ -179,6 +185,15 @@
}
}
// EN: Navigate to appointment booking with selected package services
// VI: Dieu huong den dat lich hen voi dich vu goi da chon
private void AddPackageToAppointment(PackageInfo pkg)
{
// EN: Navigate to appointment-book page — package info passed via route
// VI: Chuyen den trang dat lich hen — thong tin goi truyen qua route
NavigateTo("spa/appointment-book");
}
private record PackageConfig(string Id, string Name, decimal DiscountFactor, bool Popular,
string Icon, string BgColor, string FgColor, List<string> ServiceNames);
private record PackageService(string Name, decimal Price, int Duration);

View File

@@ -5,6 +5,7 @@
@page "/pos/{ShopId:guid}/spa/staff-assign"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@@ -32,6 +33,26 @@
@* ═══ STAFF LIST / DANH SÁCH NHÂN VIÊN ═══ *@
<div style="flex:1;overflow-y:auto;padding:16px;">
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--pos-text-tertiary);">
Đang tải nhân viên...
</div>
}
else if (_loadError)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--pos-text-tertiary);">
Không thể tải dữ liệu nhân viên
</div>
}
else if (!_staff.Any())
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--pos-text-tertiary);">
Chưa có nhân viên nào
</div>
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px;">
@foreach (var staff in FilteredStaff)
{
@@ -98,6 +119,7 @@
</div>
}
</div>
}
</div>
@* ═══ FOOTER / CHÂN TRANG ═══ *@
@@ -112,28 +134,53 @@
</div>
@code {
// EN: Static UI configuration — does not require DB data (needs staff API) / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB (cần staff API)
// EN: Loading state / VI: Trạng thái tải
private bool _isLoading = true;
private bool _loadError;
private string _activeFilter = "Tất cả";
private string? _selectedStaff = "S01";
private string? _selectedStaff;
private readonly string[] _filters = { "Tất cả", "Rảnh", "Đang bận", "Nghỉ giải lao" };
// EN: Demo staff / VI: Nhân viên mẫu
private readonly List<StaffInfo> _staff = new()
// EN: Staff from API / VI: Nhân viên từ API
private List<StaffInfo> _staff = new();
protected override async Task OnInitializedAsync()
{
new("S01", "Trần Thị Hoa", "Chuyên viên Massage", "available", 5, 128,
new() { "Massage", "Body", "Thái", "Đá nóng" }),
new("S02", "Nguyễn Minh Tú", "Chuyên viên Facial", "available", 4, 95,
new() { "Facial", "Collagen", "Acne", "Whitening" }),
new("S03", "Lê Thị Lan", "Kỹ thuật viên Nail", "busy", 5, 156,
new() { "Nail art", "Gel", "Dip powder", "Acrylic" }),
new("S04", "Phạm Văn Minh", "Chuyên viên Massage", "available", 4, 82,
new() { "Massage", "Sport", "Shiatsu", "Reflexology" }),
new("S05", "Hoàng Thị Trang", "Chuyên viên Hair & Body", "break", 4, 67,
new() { "Hair", "Gội dưỡng", "Body scrub", "Tắm trng" }),
new("S06", "Đỗ Thanh Hằng", "Chuyên viên Facial & Body", "available", 5, 143,
new() { "Facial", "Body", "Detox", "Anti-aging" }),
};
await base.OnInitializedAsync();
try
{
var staffData = await DataService.GetStaffForShopAsync(ShopId);
_staff = staffData.Select(s =>
{
var name = $"{s.FirstName ?? ""} {s.LastName ?? ""}".Trim();
if (string.IsNullOrEmpty(name)) name = s.EmployeeCode ?? s.Id.ToString()[..8];
var status = (s.Status?.ToLower()) switch
{
"active" or "available" => "available",
"busy" or "occupied" => "busy",
"break" or "onbreak" => "break",
_ => "available"
};
var role = s.Role ?? "Nhân viên";
// EN: Placeholder skills/rating — staff API does not include these / VI: Kỹ năng/đánh giá giữ chỗ — API nhân viên không có
return new StaffInfo(s.Id.ToString(), name, role, status, 4, 0, new List<string> { role });
}).ToList();
if (_staff.Any())
_selectedStaff = _staff.First().Id;
}
catch
{
_loadError = true;
}
finally
{
_isLoading = false;
}
}
private IEnumerable<StaffInfo> FilteredStaff => _activeFilter switch
{

View File

@@ -5,6 +5,7 @@
@page "/pos/{ShopId:guid}/spa/therapist-schedule"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@@ -16,7 +17,7 @@
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;">Lịch kỹ thuật viên</span>
<span style="font-size:13px;color:var(--pos-text-tertiary);">Hôm nay, 20/02/2025</span>
<span style="font-size:13px;color:var(--pos-text-tertiary);">Hôm nay, @DateTime.Now.ToString("dd/MM/yyyy")</span>
</div>
@* EN: Legend / VI: Chú thích *@
@@ -37,6 +38,20 @@
</div>
@* ═══ SCHEDULE GRID / LƯỚI LỊCH ═══ *@
@if (_isLoading)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Đang tải lịch...
</div>
}
else if (_loadError)
{
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
Không thể tải dữ liệu lịch
</div>
}
else
{
<div style="flex:1;overflow:auto;position:relative;">
@* EN: Time header row / VI: Hàng tiêu đề giờ *@
<div style="display:flex;position:sticky;top:0;z-index:10;background:var(--pos-bg-elevated);border-bottom:1px solid var(--pos-border-subtle);">
@@ -107,56 +122,118 @@
</div>
</div>
}
@* ═══ SUMMARY / TÓM TẮT ═══ *@
<div style="padding:10px 16px;border-top:1px solid var(--pos-border-subtle);display:flex;gap:20px;font-size:12px;color:var(--pos-text-secondary);flex-shrink:0;">
<span>Tổng: <b>@_scheduleData.Sum(s => s.Appointments.Count)</b> lịch hẹn</span>
<span style="color:var(--pos-orange-primary);">Đang thực hiện: <b>3</b></span>
<span style="color:var(--pos-success);">Hoàn thành: <b>5</b></span>
<span style="color:var(--pos-text-tertiary);">Sắp tới: <b>8</b></span>
<span style="color:var(--pos-orange-primary);">Đang thực hiện: <b>@_inProgressCount</b></span>
<span style="color:var(--pos-success);">Hoàn thành: <b>@_completedCount</b></span>
<span style="color:var(--pos-text-tertiary);">Sắp tới: <b>@_upcomingCount</b></span>
</div>
</div>
@code {
// EN: Static UI configuration — does not require DB data (needs schedule API) / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB (cần schedule API)
// EN: Loading state / VI: Trạng thái tải
private bool _isLoading = true;
private bool _loadError;
// EN: Hours range / VI: Phạm vi giờ
private readonly int[] _hours = { 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 };
private readonly List<StaffSchedule> _scheduleData = new()
private List<StaffSchedule> _scheduleData = new();
// EN: Summary counters / VI: Bộ đếm tóm tắt
private int _inProgressCount;
private int _completedCount;
private int _upcomingCount;
protected override async Task OnInitializedAsync()
{
new("Trần Thị Hoa", "Massage", new()
await base.OnInitializedAsync();
try
{
new("Chị Mai", "Massage toàn thân", "Massage", 9, 0, 60),
new("Anh Tuấn", "Massage chân", "Massage", 10, 30, 45),
new("Chị Hương", "Massage đầu vai cổ", "Massage", 13, 0, 30),
new("Chị Lan", "Massage toàn thân", "Massage", 15, 0, 60),
}),
new("Nguyễn Minh Tú", "Facial", new()
// EN: Load staff and appointments in parallel / VI: Tải nhân viên và lịch hẹn song song
var staffTask = DataService.GetStaffForShopAsync(ShopId);
var appointmentsTask = DataService.GetAppointmentsAsync(ShopId);
var staffData = await staffTask;
var appointments = await appointmentsTask;
// EN: Filter today's appointments / VI: Lọc lịch hẹn hôm nay
var todayAppointments = appointments
.Where(a => a.StartTime.Date == DateTime.Today)
.ToList();
// EN: Count by status / VI: Đếm theo trạng thái
var now = DateTime.Now;
_completedCount = todayAppointments.Count(a => a.Status?.ToLower() == "completed");
_inProgressCount = todayAppointments.Count(a => a.StartTime <= now && a.EndTime >= now && a.Status?.ToLower() != "completed" && a.Status?.ToLower() != "cancelled");
_upcomingCount = todayAppointments.Count(a => a.StartTime > now && a.Status?.ToLower() != "cancelled");
// EN: Group appointments by staff / VI: Nhóm lịch hẹn theo nhân viên
var staffMap = staffData.ToDictionary(s => s.Id, s =>
{
var name = $"{s.FirstName ?? ""} {s.LastName ?? ""}".Trim();
if (string.IsNullOrEmpty(name)) name = s.EmployeeCode ?? s.Id.ToString()[..8];
return (Name: name, Role: s.Role ?? "Nhân viên");
});
// EN: Build schedule data — group by staff / VI: Xây dựng dữ liệu lịch — nhóm theo nhân viên
var grouped = todayAppointments
.Where(a => a.StaffId.HasValue)
.GroupBy(a => a.StaffId!.Value)
.ToList();
_scheduleData = new();
foreach (var group in grouped)
{
var staffName = staffMap.TryGetValue(group.Key, out var info) ? info.Name : group.Key.ToString()[..8];
var staffRole = staffMap.TryGetValue(group.Key, out var info2) ? info2.Role : "Nhân viên";
var blocks = group.Select(a => new AppointmentBlock(
a.ResourceName ?? "Khách",
a.ResourceName ?? "Dịch vụ",
GuessServiceType(a.ResourceName),
a.StartTime.Hour,
a.StartTime.Minute,
(int)(a.EndTime - a.StartTime).TotalMinutes
)).ToList();
_scheduleData.Add(new StaffSchedule(staffName, staffRole, blocks));
}
// EN: Add staff without appointments / VI: Thêm nhân viên chưa có lịch hẹn
var assignedStaffIds = grouped.Select(g => g.Key).ToHashSet();
foreach (var s in staffData.Where(s => !assignedStaffIds.Contains(s.Id)))
{
var name = $"{s.FirstName ?? ""} {s.LastName ?? ""}".Trim();
if (string.IsNullOrEmpty(name)) name = s.EmployeeCode ?? s.Id.ToString()[..8];
_scheduleData.Add(new StaffSchedule(name, s.Role ?? "Nhân viên", new()));
}
}
catch
{
new("Chị Châu", "Facial collagen", "Facial", 9, 30, 60),
new("Chị Ngọc", "Facial cơ bản", "Facial", 11, 0, 45),
new("Chị Linh", "Facial collagen", "Facial", 14, 0, 60),
}),
new("Phạm Văn Minh", "Massage", new()
_loadError = true;
}
finally
{
new("Anh Đức", "Massage toàn thân", "Massage", 10, 0, 60),
new("Chị Thảo", "Massage chân", "Massage", 12, 0, 45),
new("Anh Hùng", "Massage đầu vai cổ", "Massage", 14, 30, 30),
new("Chị Yến", "Massage toàn thân", "Massage", 16, 0, 60),
}),
new("Lê Thị Lan", "Nail", new()
{
new("Chị Mai", "Nail art cao cấp", "Nail", 9, 0, 60),
new("Chị Hoa", "Sơn gel", "Nail", 11, 0, 30),
new("Chị Trang", "Chăm sóc móng tay", "Nail", 13, 30, 30),
}),
new("Hoàng Thị Trang", "Hair & Body", new()
{
new("Chị Giang", "Tắm trắng toàn thân", "Body", 9, 30, 90),
new("Chị Phương", "Gội đầu dưỡng sinh", "Hair", 12, 0, 40),
new("Chị Hằng", "Tẩy tế bào chết", "Body", 14, 0, 45),
new("Chị Vy", "Ủ tóc phục hồi", "Hair", 16, 30, 45),
}),
};
_isLoading = false;
}
}
// EN: Guess service type from name for color coding / VI: Đoán loại dịch vụ từ tên để tô màu
private static string GuessServiceType(string? name)
{
if (name is null) return "Other";
var lower = name.ToLower();
if (lower.Contains("massage")) return "Massage";
if (lower.Contains("facial") || lower.Contains("mặt")) return "Facial";
if (lower.Contains("body") || lower.Contains("tắm") || lower.Contains("tẩy")) return "Body";
if (lower.Contains("nail") || lower.Contains("hair") || lower.Contains("tóc") || lower.Contains("móng")) return "Nail";
return "Other";
}
private static string GetServiceBg(string type) => type switch
{

View File

@@ -3,8 +3,11 @@
VI: Đồng hồ trị liệu Spa — Đếm ngược tròn lớn, thông tin dịch vụ, khách/KTV, gia hạn, hoàn thành, ghi chú.
*@
@page "/pos/{ShopId:guid}/spa/treatment-timer"
@page "/pos/{ShopId:guid}/spa/treatment-timer/{AppointmentId:guid}"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@implements IDisposable
<div style="flex:1;display:flex;overflow:hidden;">
@* ═══ TIMER PANEL (LEFT) / PANEL ĐỒNG HỒ (TRÁI) ═══ *@
@@ -43,8 +46,8 @@
@* EN: Progress bar / VI: Thanh tiến trình *@
<div style="margin-bottom:20px;">
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--pos-text-tertiary);margin-bottom:6px;">
<span>Bắt đầu: 14:00</span>
<span>Dự kiến: 15:00</span>
<span>Bắt đầu: @_startTime.ToString("HH:mm")</span>
<span>Dự kiến: @_endTime.ToString("HH:mm")</span>
</div>
<div style="height:8px;border-radius:4px;background:var(--pos-bg-interactive);">
<div style="height:100%;border-radius:4px;background:linear-gradient(90deg,var(--pos-orange-primary),#F59E0B);width:@(_progress * 100)%;transition:width 0.3s;"></div>
@@ -53,27 +56,27 @@
@* ═══ SERVICE INFO / THÔNG TIN DỊCH VỤ ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;margin-bottom:16px;">
<div style="font-size:18px;font-weight:700;margin-bottom:8px;">Massage toàn thân</div>
<div style="font-size:18px;font-weight:700;margin-bottom:8px;">@_serviceName</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div style="display:flex;align-items:center;gap:8px;font-size:13px;">
<i data-lucide="user" style="width:14px;height:14px;color:var(--pos-text-tertiary);"></i>
<span style="color:var(--pos-text-secondary);">Khách:</span>
<span style="font-weight:600;">Nguyễn Thị Mai</span>
<span style="font-weight:600;">@_customerName</span>
</div>
<div style="display:flex;align-items:center;gap:8px;font-size:13px;">
<i data-lucide="briefcase" style="width:14px;height:14px;color:var(--pos-text-tertiary);"></i>
<span style="color:var(--pos-text-secondary);">KTV:</span>
<span style="font-weight:600;">Trần Thị Hoa</span>
<span style="font-weight:600;">@_therapistName</span>
</div>
<div style="display:flex;align-items:center;gap:8px;font-size:13px;">
<i data-lucide="clock" style="width:14px;height:14px;color:var(--pos-text-tertiary);"></i>
<span style="color:var(--pos-text-secondary);">Thời gian:</span>
<span style="font-weight:600;">60 phút</span>
<span style="font-weight:600;">@_totalDuration phút</span>
</div>
<div style="display:flex;align-items:center;gap:8px;font-size:13px;">
<i data-lucide="tag" style="width:14px;height:14px;color:var(--pos-text-tertiary);"></i>
<span style="color:var(--pos-text-secondary);">Giá:</span>
<span style="font-weight:600;color:var(--pos-orange-primary);">@FormatPrice(500_000)</span>
<span style="font-weight:600;color:var(--pos-orange-primary);">@FormatPrice(_servicePrice)</span>
</div>
</div>
</div>
@@ -128,11 +131,11 @@
<div style="display:flex;flex-direction:column;gap:8px;">
<div style="display:flex;justify-content:space-between;font-size:13px;padding:8px 0;border-bottom:1px solid var(--pos-border-subtle);">
<span style="color:var(--pos-text-secondary);">Bắt đầu</span>
<span style="font-weight:600;">14:00</span>
<span style="font-weight:600;">@_startTime.ToString("HH:mm")</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;padding:8px 0;border-bottom:1px solid var(--pos-border-subtle);">
<span style="color:var(--pos-text-secondary);">Dự kiến kết thúc</span>
<span style="font-weight:600;">15:00</span>
<span style="font-weight:600;">@_endTime.ToString("HH:mm")</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;padding:8px 0;border-bottom:1px solid var(--pos-border-subtle);">
<span style="color:var(--pos-text-secondary);">Đã gia hạn</span>
@@ -148,31 +151,137 @@
<div class="pos-cart-footer">
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng phiên</span>
<span class="pos-cart-total__value">@FormatPrice(500_000 + _extendedMinutes / 15 * 125_000)</span>
<span class="pos-cart-total__value">@FormatPrice(_servicePrice + _extendedMinutes / 15 * 125_000)</span>
</div>
<button class="pos-btn-checkout" style="background:var(--pos-success);" @onclick="CompleteTreatment">
<i data-lucide="check-circle" style="width:18px;height:18px;"></i> Hoàn thành dịch vụ
<button class="pos-btn-checkout" style="background:var(--pos-success);" @onclick="CompleteTreatment" disabled="@_isCompleting">
<i data-lucide="check-circle" style="width:18px;height:18px;"></i> @(_isCompleting ? "Đang xử lý..." : "Hoàn thành dịch vụ")
</button>
</div>
</div>
</div>
@code {
// EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB
// EN: Route parameter for appointment ID / VI: Tham số route cho ID lịch hẹn
[Parameter] public Guid? AppointmentId { get; set; }
private string _remainingTime = "45:00";
// EN: Timer state / VI: Trạng thái đồng hồ
private string _remainingTime = "00:00";
private int _totalDuration = 60;
private double _progress = 0.25;
private double _progress = 0.0;
private int _extendedMinutes = 0;
private string _notes = "Khách yêu cầu lực massage vừa phải. Chú ý vùng vai bị đau.";
private string _notes = "";
private bool _isCompleting;
private System.Threading.Timer? _timer;
// EN: Appointment details / VI: Chi tiết lịch hẹn
private string _serviceName = "Dịch vụ";
private string _customerName = "Khách";
private string _therapistName = "KTV";
private decimal _servicePrice = 0;
private DateTime _startTime = DateTime.Now;
private DateTime _endTime = DateTime.Now.AddHours(1);
private readonly int[] _extendOptions = { 15, 30, 45 };
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
if (AppointmentId.HasValue)
{
// EN: Load appointment details / VI: Tải chi tiết lịch hẹn
var appt = await DataService.GetAppointmentByIdAsync(AppointmentId.Value);
if (appt is not null)
{
_startTime = appt.StartTime;
_endTime = appt.EndTime;
_totalDuration = (int)(appt.EndTime - appt.StartTime).TotalMinutes;
_serviceName = appt.ResourceName ?? "Dịch vụ";
// EN: Load staff name if available / VI: Tải tên nhân viên nếu có
if (appt.StaffId.HasValue)
{
var staffList = await DataService.GetStaffForShopAsync(ShopId);
var staff = staffList.FirstOrDefault(s => s.Id == appt.StaffId.Value);
if (staff is not null)
{
var name = $"{staff.FirstName ?? ""} {staff.LastName ?? ""}".Trim();
_therapistName = string.IsNullOrEmpty(name) ? (staff.EmployeeCode ?? "KTV") : name;
}
}
}
}
else
{
// EN: Fallback — use default 60 min from now / VI: Mặc định — 60 phút từ hiện tại
_startTime = DateTime.Now;
_totalDuration = 60;
_endTime = _startTime.AddMinutes(_totalDuration);
}
}
catch
{
// EN: Use defaults on error / VI: Dùng giá trị mặc định khi lỗi
}
// EN: Start real countdown timer / VI: Bắt đầu đồng hồ đếm ngược thật
UpdateTimerDisplay();
_timer = new System.Threading.Timer(TimerCallback, null, 0, 1000);
}
private void TimerCallback(object? state)
{
InvokeAsync(() =>
{
UpdateTimerDisplay();
StateHasChanged();
});
}
private void UpdateTimerDisplay()
{
var totalSeconds = (int)(_endTime.AddMinutes(_extendedMinutes) - DateTime.Now).TotalSeconds;
if (totalSeconds < 0) totalSeconds = 0;
var minutes = totalSeconds / 60;
var seconds = totalSeconds % 60;
_remainingTime = $"{minutes:D2}:{seconds:D2}";
var totalDurationSeconds = (_totalDuration + _extendedMinutes) * 60;
var elapsed = totalDurationSeconds - totalSeconds;
_progress = totalDurationSeconds > 0 ? Math.Clamp((double)elapsed / totalDurationSeconds, 0, 1) : 0;
}
private void ExtendTime(int minutes)
{
_extendedMinutes += minutes;
_totalDuration += minutes;
}
private void CompleteTreatment() => NavigateTo("spa/spa-journey");
private async Task CompleteTreatment()
{
_isCompleting = true;
try
{
if (AppointmentId.HasValue)
{
await DataService.UpdateAppointmentStatusAsync(AppointmentId.Value, "complete");
}
NavigateTo("spa/therapist-schedule");
}
catch
{
NavigateTo("spa/therapist-schedule");
}
finally
{
_isCompleting = false;
}
}
public void Dispose()
{
_timer?.Dispose();
}
}

View File

@@ -629,10 +629,11 @@ public class PosDataService
// ═══ PAY ORDER ═══
public async Task<bool> PayOrderAsync(Guid orderId, Guid shopId)
public async Task<bool> PayOrderAsync(Guid orderId, Guid shopId, string? paymentMethod = null)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/orders/{orderId}/pay?shopId={shopId}", new { }, _writeOptions);
var body = paymentMethod != null ? new { PaymentMethod = paymentMethod } : (object)new { };
var resp = await _http.PostAsJsonAsync($"api/bff/orders/{orderId}/pay?shopId={shopId}", body, _writeOptions);
return resp.IsSuccessStatusCode;
}
@@ -816,6 +817,18 @@ public class PosDataService
public async Task<bool> CancelAppointmentAsync(Guid apptId)
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/appointments/{apptId}/cancel"); return r.IsSuccessStatusCode; }
// EN: Get single appointment by ID / VI: Lấy appointment theo ID
public async Task<AppointmentInfo?> GetAppointmentByIdAsync(Guid appointmentId)
=> await GetObjectFromApiAsync<AppointmentInfo>($"api/bff/appointments/{appointmentId}");
// EN: Update appointment status (confirm, start, complete) / VI: Cập nhật trạng thái appointment
public async Task<bool> UpdateAppointmentStatusAsync(Guid appointmentId, string action)
{ AttachToken(); var r = await _http.PatchAsync($"api/bff/appointments/{appointmentId}/status", JsonContent.Create(new { action }, options: _writeOptions)); return r.IsSuccessStatusCode; }
// EN: Search customers/members / VI: Tìm kiếm khách hàng/thành viên
public async Task<List<MemberInfo>> SearchCustomersAsync(Guid shopId, string query)
=> await GetMembersAsync(query);
// ═══ RESOURCES CRUD ═══
public record CreateResourceRequest(Guid ShopId, string Name, string ResourceType, int Capacity);
@@ -1121,6 +1134,29 @@ public class PosDataService
return resp.IsSuccessStatusCode;
}
/// <summary>
/// EN: Extend an active session by additional minutes. Uses table update to persist extended time.
/// VI: Gia hạn phiên đang hoạt động thêm số phút. Dùng table update để lưu thời gian gia hạn.
/// </summary>
public async Task<bool> ExtendSessionAsync(Guid tableId, int additionalMinutes)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/tables/{tableId}/extend",
new { additionalMinutes }, _writeOptions);
// EN: Fallback — if dedicated extend endpoint doesn't exist, use PATCH status to signal extension
// VI: Dự phòng — nếu endpoint extend chưa có, dùng PATCH status để báo hiệu gia hạn
if ((int)resp.StatusCode == 404)
{
var patch = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/tables/{tableId}/status")
{
Content = JsonContent.Create(new { status = "occupied", extendMinutes = additionalMinutes }, options: _writeOptions)
};
var fallback = await _http.SendAsync(patch);
return fallback.IsSuccessStatusCode;
}
return resp.IsSuccessStatusCode;
}
// ═══ SHOP PUBLISH (draft → active) ═══
public async Task<bool> PublishShopAsync(Guid shopId)