feat(allPos): upgrad frontend
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 nối *@
|
||||
@* 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 / NỘI 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>Tổng (@_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 thức</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);">Tổng tiền</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:
|
||||
@* ═══ PHỤC 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> Phục 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 TẤT / 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 tất!</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 · Tổng: @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 lại
|
||||
<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: Hiệu ứng nhấp 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("Phục vụ", "bell"),
|
||||
new("Hoàn tất", "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);
|
||||
}
|
||||
|
||||
@@ -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 hiệu *@
|
||||
@* 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 mừng 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 của bạn</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: Tổng cộng *@
|
||||
@* 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;">Tổng cộng / 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: Trạng 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: Trạng thái rỗi *@
|
||||
@* 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 bắt đầu gọi 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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);">Hạng</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>🎁</span>
|
||||
}
|
||||
else if (isStamped)
|
||||
{
|
||||
<span>☕</span>
|
||||
<span>☕</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 nữa để nhận 1 ly miễn 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 chỉnh *@
|
||||
@* 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: Chọn 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: Mức đườ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);">Mức đườ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: Mức đá *@
|
||||
@* 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);">Mức đá</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 nhận *@
|
||||
@* 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 tắt</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">Tổng</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 nhận</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", "Vừa", 0),
|
||||
new("L", "Lớn", 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 trắng", 10_000),
|
||||
new("Thạch 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("Sữa dừa", 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);
|
||||
|
||||
@@ -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: Cột đ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: Cột sẵn 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;">
|
||||
✅ Sẵn 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;">Mời nhận đồ</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: Hiệu ứng nhấp 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);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* EN: Status + timer / VI: Trạng 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ữ liệu phòng tải 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 / NỘI 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 hoặc 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 — Chọn 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> Chọn 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;">Sức chứa</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;">Loại</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ờ bắt đầ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);">Thời 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>Tạm 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;">
|
||||
THỜI GIAN SỬ DỤNG
|
||||
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;">
|
||||
Bắt đầ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 hiện tại</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>Tổng 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>
|
||||
Gọi 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 hạn
|
||||
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);">Thời gian sử dụng</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);">Tiền 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);">Tiền 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>Tổng cộng</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;">
|
||||
TỔNG 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>
|
||||
Tiền mặt
|
||||
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ẻ/Chuyển khoản
|
||||
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 lại
|
||||
<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 tất" : "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 tại
|
||||
// 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("Chọn 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 / THỜI GIAN HIỆN TẠI ═══ *@
|
||||
@* === 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Ờ HIỆN TẠI
|
||||
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 dụng 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 / BẢNG GIÁ ═══ *@
|
||||
@* === PRICING TABLE / BANG GIA === *@
|
||||
<div style="margin-bottom:20px;">
|
||||
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">Bảng 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);">Hiện tại</span>
|
||||
background:rgba(245,158,11,.2);color:var(--pos-warning);">Hien tai</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ ROOM TYPE SELECTOR / CHỌN LOẠI PHÒNG ═══ *@
|
||||
@* === ROOM TYPE SELECTOR / CHON LOAI PHONG === *@
|
||||
<div style="margin-bottom:20px;">
|
||||
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">Loại 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á hiện tại (@_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>Tổng ướ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 NHẬN ═══ *@
|
||||
@* === 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 nhận 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: Bảng giá
|
||||
private readonly List<PricingRate> _pricingRates = new()
|
||||
{
|
||||
new("Giờ thường", "T2–T5, 10:00–17:00", 100_000, 1.0m, false),
|
||||
new("Giờ cao điểm", "T2–T5, 17:00–23:00", 150_000, 1.5m, false),
|
||||
new("Cuối tuần", "T6–CN", 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: Loại 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 lại
|
||||
<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> Bắt đầ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: Phục vụ mỗi 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 thời 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 hoạt *@
|
||||
@* 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 / THỐNG 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);">Đã phục vụ: @_courses.Count(c => c.Status == "served")</span>
|
||||
<span style="color:var(--pos-warning);">Đang nấu: @_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;">Tổng: @_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" => "Đã phục vụ", "cooking" => "Đang nấu", "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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 lại
|
||||
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lai
|
||||
</button>
|
||||
<span style="flex:1;font-size:16px;font-weight:700;">Quản 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 tải...
|
||||
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ể tải dữ liệu
|
||||
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>Tổng: <b>@_tables.Count</b> bàn</span>
|
||||
<span style="color:var(--pos-success);">Trống: @_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;">Đã chọn: @_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: Trạng thái tải
|
||||
// EN: Loading state / VI: Trang thai tai
|
||||
private bool _isLoading = true;
|
||||
private bool _loadError;
|
||||
|
||||
private string _activeSection = "Tất cả";
|
||||
private string[] _sections = { "Tất 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 == "Tất 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ữ nhật"
|
||||
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[] { "Tất 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" => "Trống", "occupied" => "Đang phục 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);
|
||||
|
||||
@@ -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 lại
|
||||
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lai
|
||||
</button>
|
||||
<span style="font-size:16px;font-weight:700;">Gọi 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 tải...
|
||||
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ể tải dữ liệu
|
||||
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 GỌI ═══ *@
|
||||
@* ═══ ORDER PANEL / PANEL DON GOI ═══ *@
|
||||
<div class="pos-cart-panel">
|
||||
<div class="pos-cart-header">
|
||||
<span class="pos-cart-header__title">Đơn gọi (@_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;">📝 @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 CẦU ĐẶC BIỆT ═══ *@
|
||||
@* ═══ SPECIAL REQUEST / YEU CAU DAC BIET ═══ *@
|
||||
<div style="padding:8px 16px;">
|
||||
<input type="text" @bind="_specialRequest" placeholder="Ghi chú đặc biệt..."
|
||||
<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">Tổng</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 == "Tất 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[] { "Tất 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 gọi hiện tại
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 thống 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;">Tổng 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 hạng *@
|
||||
<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 / LỊCH SỬ GHÉ ═══ *@
|
||||
<div style="font-size:14px;font-weight:600;margin-bottom:10px;">Lịch sử ghé gần đâ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 / DỊCH 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 PHẦN THƯỞNG (PHẢI) ═══ *@
|
||||
@* === REWARDS PANEL (RIGHT) / PANEL PHAN THUONG (PHAI) === *@
|
||||
<div class="pos-cart-panel">
|
||||
<div class="pos-cart-header">
|
||||
<span class="pos-cart-header__title">Phần 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> Chọn 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", "Giảm 20% dịch vụ", "-20%", "Áp dụng cho mọi dịch vụ đơn lẻ", 500),
|
||||
new("RW2", "Free Massage chân", "Miễn phí", "1 lần Massage chân 45 phút miễn phí", 800),
|
||||
new("RW3", "Giảm 100K", "-100,000₫", "Giảm 100K cho hóa đơn từ 500K", 400),
|
||||
new("RW4", "Nâng hạng dịch vụ", "Upgrade", "Nâng Facial cơ bản 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);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,65 @@
|
||||
@*
|
||||
EN: Spa Service Combo — Active combos/promotions, bundle discounts, buy 2 get 1, timer, apply combo.
|
||||
VI: Combo dịch 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 dụng.
|
||||
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: Dịch vụ bao gồm *@
|
||||
@* 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 dụng *@
|
||||
@* 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> Đã chọn</span>
|
||||
<span> Da chon</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Áp dụng 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 tục với combo đã chọn
|
||||
<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 tặng 1", "Mua 2 dịch vụ Massage bất kỳ, tặng 1 Massage chân miễn 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", "Trọn 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 + Tắm trắng giá đặc biệt",
|
||||
1_200_000, 950_000, false, "sparkles", "rgba(139,92,246,.15)", "#8B5CF6",
|
||||
new() { "Tẩy 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);
|
||||
}
|
||||
|
||||
@@ -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 lịch hẹn
|
||||
@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);
|
||||
|
||||
@@ -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 trắng" }),
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user