feat(spa): add 12 Blazor Razor files for Spa/Beauty POS vertical

Add new Spa POS vertical with appointment-based beauty/spa services:

Main POS screens (3 device variants):
- SpaDesktop.razor: Desktop 2-panel layout with service categories/grid + appointment panel
- SpaTablet.razor: Tablet touch-friendly layout with 340px appointment sidebar
- SpaMobile.razor: Mobile single-column with floating appointment button + bottom sheet

Workflow screens (9 files):
- CustomerLookup.razor: Search by phone/name with VIP tier display
- CustomerProfile.razor: Full profile with tier progress, visit history, rewards
- AppointmentBook.razor: Date picker, time slots grid (9:00-20:00), staff selection
- ServicePackage.razor: Package list with expandable details and savings
- ServiceCombo.razor: Active combos/promotions with flash sale timer
- StaffAssign.razor: Staff list with ratings, skills, availability status
- TherapistSchedule.razor: Calendar day view with horizontal timeline
- TreatmentTimer.razor: Circular countdown timer with extend/complete actions
- SpaJourney.razor: 5-step journey tracker (Check-in → Dịch vụ → Thực hiện → Thanh toán → Hoàn tất)

All files follow existing Cafe/Karaoke patterns:
- @layout PosLayout, @inherits PosBase
- Bilingual EN/VI comments, section separators
- CSS variables, Lucide icons, FormatPrice/NavigateTo helpers
- Vietnamese UI labels, VND prices, demo data

Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-02-26 15:48:43 +00:00
parent e72e36e75d
commit e3668e7a18
23 changed files with 3762 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
@*
EN: Retail POS Desktop — Left panel: category tabs + product grid with barcode input. Right panel: cart/bill.
VI: POS Bán lẻ Desktop — Panel trái: tab danh mục + lưới sản phẩm với quét mã vạch. Panel phải: giỏ hàng.
*@
@page "/pos/retail"
@layout PosLayout
@inherits PosBase
@* ═══ PRODUCT PANEL ═══ *@
<div class="pos-product-panel">
@* EN: Barcode input / VI: Ô nhập mã vạch *@
<div style="padding:10px 16px;border-bottom:1px solid var(--pos-border-subtle);">
<div style="display:flex;align-items:center;gap:8px;background:var(--pos-bg-interactive);border-radius:var(--pos-radius);padding:0 12px;">
<i data-lucide="scan-barcode" style="width:16px;height:16px;color:var(--pos-text-tertiary);"></i>
<input type="text" @bind="_barcodeInput" @bind:event="oninput" placeholder="Quét mã vạch hoặc nhập SKU..."
style="flex:1;background:transparent;border:none;color:var(--pos-text-primary);font-size:13px;
padding:10px 0;outline:none;font-family:inherit;" />
<button style="background:var(--pos-orange-primary);border:none;color:#fff;padding:6px 12px;border-radius:6px;
font-size:12px;font-weight:600;cursor:pointer;" @onclick="SearchBarcode">Tìm</button>
</div>
</div>
@* EN: Category tabs / VI: Tab danh mục *@
<div class="pos-category-tabs">
@foreach (var cat in _categories)
{
<button class="pos-category-tab @(cat == _selectedCategory ? "pos-category-tab--active" : "")"
@onclick="() => _selectedCategory = cat">
@cat
</button>
}
</div>
@* EN: Product grid / VI: Lưới sản phẩm *@
<div class="pos-product-grid">
@foreach (var product in FilteredProducts)
{
<div class="pos-product-card" @onclick="() => AddToCart(product)">
<div class="pos-product-card__image" style="display:flex;align-items:center;justify-content:center;">
<i data-lucide="@product.Icon" style="width:32px;height:32px;color:var(--pos-text-tertiary);"></i>
</div>
<span class="pos-product-card__name">@product.Name</span>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%;padding:0 4px;">
<span class="pos-product-card__price">@FormatPrice(product.Price)</span>
<span style="font-size:10px;color:var(--pos-text-tertiary);">Kho: @product.Stock</span>
</div>
<span style="font-size:10px;color:var(--pos-text-tertiary);">@product.Sku</span>
</div>
}
</div>
</div>
@* ═══ CART PANEL ═══ *@
<div class="pos-cart-panel">
<div class="pos-cart-header">
<span class="pos-cart-header__title">Giỏ hàng</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">@_cartItems.Count sản phẩm</span>
</div>
<div class="pos-cart-items">
@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 style="font-size:11px;color:var(--pos-text-tertiary);">@item.Sku</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;min-width:20px;text-align:center;">@item.Qty</span>
<button @onclick="() => ChangeQty(item, 1)">+</button>
</div>
</div>
}
</div>
<div class="pos-cart-footer">
@* EN: Subtotals / VI: Tạm tính *@
<div style="padding:0 12px 8px;font-size:12px;">
<div style="display:flex;justify-content:space-between;color:var(--pos-text-tertiary);margin-bottom:4px;">
<span>Tạm tính (@_cartItems.Sum(i => i.Qty) SP)</span>
<span>@FormatPrice(CartTotal)</span>
</div>
<div style="display:flex;justify-content:space-between;color:var(--pos-text-tertiary);">
<span>VAT (10%)</span>
<span>@FormatPrice(CartTotal * 0.1m)</span>
</div>
</div>
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(CartTotal * 1.1m)</span>
</div>
<button class="pos-btn-checkout" @onclick="Checkout">
<i data-lucide="credit-card" style="width:18px;height:18px;"></i>
Thanh toán
</button>
</div>
</div>
@code {
// EN: Categories / VI: Danh mục
private readonly string[] _categories = { "Tất cả", "Thời trang", "Phụ kiện", "Điện tử", "Gia dụng", "Mỹ phẩm" };
private string _selectedCategory = "Tất cả";
private string _barcodeInput = "";
// EN: Retail product list / VI: Danh sách sản phẩm bán lẻ
private readonly List<Product> _products = new()
{
new("Áo thun nam basic", "SKU-TT001", 199_000, 45, "Thời trang", "shirt"),
new("Quần jean nữ slim", "SKU-TT002", 450_000, 22, "Thời trang", "shirt"),
new("Áo khoác gió unisex", "SKU-TT003", 350_000, 18, "Thời trang", "shirt"),
new("Váy liền công sở", "SKU-TT004", 520_000, 12, "Thời trang", "shirt"),
new("Túi xách da nữ", "SKU-PK001", 890_000, 8, "Phụ kiện", "shopping-bag"),
new("Ví da nam", "SKU-PK002", 350_000, 30, "Phụ kiện", "wallet"),
new("Kính mát thời trang", "SKU-PK003", 280_000, 25, "Phụ kiện", "glasses"),
new("Tai nghe Bluetooth", "SKU-DT001", 650_000, 15, "Điện tử", "headphones"),
new("Sạc dự phòng 10000mAh", "SKU-DT002", 380_000, 40, "Điện tử", "battery-charging"),
new("Chuột không dây", "SKU-DT003", 250_000, 35, "Điện tử", "mouse"),
new("Nồi cơm điện 1.8L", "SKU-GD001", 890_000, 10, "Gia dụng", "cooking-pot"),
new("Bình giữ nhiệt 500ml", "SKU-GD002", 180_000, 50, "Gia dụng", "cup-soda"),
new("Son môi cao cấp", "SKU-MP001", 320_000, 28, "Mỹ phẩm", "sparkles"),
new("Kem chống nắng SPF50", "SKU-MP002", 280_000, 35, "Mỹ phẩm", "sun"),
new("Nước hoa mini 30ml", "SKU-MP003", 450_000, 20, "Mỹ phẩm", "droplets"),
};
// 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 void AddToCart(Product product)
{
var existing = _cartItems.FirstOrDefault(i => i.Sku == product.Sku);
if (existing != null) existing.Qty++;
else _cartItems.Add(new CartItem(product.Name, product.Sku, product.Price));
}
private void ChangeQty(CartItem item, int delta)
{
item.Qty += delta;
if (item.Qty <= 0) _cartItems.Remove(item);
}
private void SearchBarcode()
{
var found = _products.FirstOrDefault(p => p.Sku.Equals(_barcodeInput, StringComparison.OrdinalIgnoreCase));
if (found != null) AddToCart(found);
_barcodeInput = "";
}
private void Checkout() { }
// EN: Models / VI: Mô hình dữ liệu
private record Product(string Name, string Sku, decimal Price, int Stock, string Category, string Icon);
private class CartItem(string name, string sku, decimal price)
{
public string Name { get; set; } = name;
public string Sku { get; set; } = sku;
public decimal Price { get; set; } = price;
public int Qty { get; set; } = 1;
}
}

View File

@@ -0,0 +1,153 @@
@*
EN: Retail POS Mobile — Single column, floating cart button, bottom sheet cart.
VI: POS Bán lẻ Mobile — Một cột, nút giỏ hàng nổi, giỏ hàng dạng sheet dưới.
*@
@page "/pos/retail/mobile"
@layout PosLayout
@inherits PosBase
<div style="display:flex;flex-direction:column;width:100%;height:100%;overflow:hidden;">
@* EN: Barcode input / VI: Ô nhập mã vạch *@
<div style="padding:8px 12px;border-bottom:1px solid var(--pos-border-subtle);">
<div style="display:flex;align-items:center;gap:8px;background:var(--pos-bg-interactive);border-radius:var(--pos-radius);padding:0 10px;">
<i data-lucide="scan-barcode" style="width:14px;height:14px;color:var(--pos-text-tertiary);"></i>
<input type="text" @bind="_barcodeInput" @bind:event="oninput" placeholder="Quét mã vạch..."
style="flex:1;background:transparent;border:none;color:var(--pos-text-primary);font-size:13px;
padding:8px 0;outline:none;font-family:inherit;" />
</div>
</div>
@* EN: Category tabs / VI: Tab danh mục *@
<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;"
@onclick="() => _selectedCategory = cat">
@cat
</button>
}
</div>
@* EN: Product grid / VI: Lưới sản phẩm *@
<div class="pos-product-grid" style="grid-template-columns:repeat(2, 1fr);gap:10px;padding:12px;">
@foreach (var product in FilteredProducts)
{
<div class="pos-product-card" style="padding:10px;" @onclick="() => AddToCart(product)">
<div class="pos-product-card__image" style="aspect-ratio:1.2;display:flex;align-items:center;justify-content:center;">
<i data-lucide="@product.Icon" style="width:28px;height:28px;color:var(--pos-text-tertiary);"></i>
</div>
<span class="pos-product-card__name" style="font-size:12px;">@product.Name</span>
<span class="pos-product-card__price" style="font-size:13px;">@FormatPrice(product.Price)</span>
<span style="font-size:10px;color:var(--pos-text-tertiary);">Kho: @product.Stock</span>
</div>
}
</div>
@* EN: Floating cart button / VI: Nút giỏ hàng nổi *@
@if (_cartItems.Any())
{
<button style="position:fixed;bottom:20px;right:20px;width:64px;height:64px;border-radius:50%;background:var(--pos-orange-primary);border:none;color:#fff;font-size:20px;cursor:pointer;box-shadow:0 4px 20px rgba(255,92,0,0.4);display:flex;align-items:center;justify-content:center;z-index:100;"
@onclick="() => _showCart = !_showCart">
<i data-lucide="shopping-cart" 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;">
@_cartItems.Sum(i => i.Qty)
</span>
</button>
}
@* EN: Bottom sheet cart / VI: Giỏ hàng dạng sheet dưới *@
@if (_showCart)
{
<div class="pos-dialog-overlay" @onclick="() => _showCart = 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 kéo *@
<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">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>
<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>
<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 * 1.1m)</span>
</div>
<button class="pos-btn-checkout" @onclick="Checkout">Thanh toán</button>
</div>
</div>
</div>
}
</div>
@code {
private readonly string[] _categories = { "Tất cả", "Thời trang", "Phụ kiện", "Điện tử", "Gia dụng", "Mỹ phẩm" };
private string _selectedCategory = "Tất cả";
private string _barcodeInput = "";
private bool _showCart;
private readonly List<Product> _products = new()
{
new("Áo thun nam basic", "SKU-TT001", 199_000, 45, "Thời trang", "shirt"),
new("Quần jean nữ slim", "SKU-TT002", 450_000, 22, "Thời trang", "shirt"),
new("Áo khoác gió unisex", "SKU-TT003", 350_000, 18, "Thời trang", "shirt"),
new("Túi xách da nữ", "SKU-PK001", 890_000, 8, "Phụ kiện", "shopping-bag"),
new("Ví da nam", "SKU-PK002", 350_000, 30, "Phụ kiện", "wallet"),
new("Tai nghe Bluetooth", "SKU-DT001", 650_000, 15, "Điện tử", "headphones"),
new("Sạc dự phòng 10000mAh", "SKU-DT002", 380_000, 40, "Điện tử", "battery-charging"),
new("Nồi cơm điện 1.8L", "SKU-GD001", 890_000, 10, "Gia dụng", "cooking-pot"),
new("Son môi cao cấp", "SKU-MP001", 320_000, 28, "Mỹ phẩm", "sparkles"),
new("Kem chống nắng SPF50", "SKU-MP002", 280_000, 35, "Mỹ phẩm", "sun"),
};
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 void AddToCart(Product product)
{
var existing = _cartItems.FirstOrDefault(i => i.Sku == product.Sku);
if (existing != null) existing.Qty++;
else _cartItems.Add(new CartItem(product.Name, product.Sku, product.Price));
}
private void ChangeQty(CartItem item, int delta)
{
item.Qty += delta;
if (item.Qty <= 0) _cartItems.Remove(item);
}
private void Checkout() { }
private record Product(string Name, string Sku, decimal Price, int Stock, string Category, string Icon);
private class CartItem(string name, string sku, decimal price)
{
public string Name { get; set; } = name;
public string Sku { get; set; } = sku;
public decimal Price { get; set; } = price;
public int Qty { get; set; } = 1;
}
}

View File

@@ -0,0 +1,146 @@
@*
EN: Retail POS Tablet — Touch-optimized 2-column: products + cart sidebar (340px), larger elements.
VI: POS Bán lẻ Tablet — 2 cột tối ưu cảm ứng: sản phẩm + giỏ hàng bên (340px), phần tử lớn hơn.
*@
@page "/pos/retail/tablet"
@layout PosLayout
@inherits PosBase
@* ═══ PRODUCT PANEL ═══ *@
<div class="pos-product-panel">
@* EN: Barcode input / VI: Ô nhập mã vạch *@
<div style="padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);">
<div style="display:flex;align-items:center;gap:8px;background:var(--pos-bg-interactive);border-radius:var(--pos-radius);padding:0 14px;">
<i data-lucide="scan-barcode" style="width:18px;height:18px;color:var(--pos-text-tertiary);"></i>
<input type="text" @bind="_barcodeInput" @bind:event="oninput" placeholder="Quét mã vạch hoặc nhập SKU..."
style="flex:1;background:transparent;border:none;color:var(--pos-text-primary);font-size:15px;
padding:12px 0;outline:none;font-family:inherit;" />
</div>
</div>
@* EN: Category tabs / VI: Tab danh mục *@
<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;"
@onclick="() => _selectedCategory = cat">
@cat
</button>
}
</div>
@* EN: Product grid (larger for tablet) / VI: Lưới sản phẩm (lớn hơn cho tablet) *@
<div class="pos-product-grid" style="grid-template-columns:repeat(auto-fill, minmax(160px, 1fr));gap:16px;padding:20px;">
@foreach (var product in FilteredProducts)
{
<div class="pos-product-card" style="padding:16px;" @onclick="() => AddToCart(product)">
<div class="pos-product-card__image" style="display:flex;align-items:center;justify-content:center;">
<i data-lucide="@product.Icon" style="width:36px;height:36px;color:var(--pos-text-tertiary);"></i>
</div>
<span class="pos-product-card__name" style="font-size:15px;">@product.Name</span>
<span class="pos-product-card__price" style="font-size:16px;">@FormatPrice(product.Price)</span>
<span style="font-size:11px;color:var(--pos-text-tertiary);">Kho: @product.Stock · @product.Sku</span>
</div>
}
</div>
</div>
@* ═══ CART 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;">Giỏ 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>
<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 style="font-size:11px;color:var(--pos-text-tertiary);">@item.Sku</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>
<div class="pos-cart-footer">
<div style="padding:0 12px 8px;font-size:13px;">
<div style="display:flex;justify-content:space-between;color:var(--pos-text-tertiary);margin-bottom:4px;">
<span>Tạm tính</span>
<span>@FormatPrice(CartTotal)</span>
</div>
<div style="display:flex;justify-content:space-between;color:var(--pos-text-tertiary);">
<span>VAT (10%)</span>
<span>@FormatPrice(CartTotal * 0.1m)</span>
</div>
</div>
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(CartTotal * 1.1m)</span>
</div>
<button class="pos-btn-checkout" style="height:56px;font-size:17px;" @onclick="Checkout">
Thanh toán — @FormatPrice(CartTotal * 1.1m)
</button>
</div>
</div>
@code {
private readonly string[] _categories = { "Tất cả", "Thời trang", "Phụ kiện", "Điện tử", "Gia dụng", "Mỹ phẩm" };
private string _selectedCategory = "Tất cả";
private string _barcodeInput = "";
private readonly List<Product> _products = new()
{
new("Áo thun nam basic", "SKU-TT001", 199_000, 45, "Thời trang", "shirt"),
new("Quần jean nữ slim", "SKU-TT002", 450_000, 22, "Thời trang", "shirt"),
new("Áo khoác gió unisex", "SKU-TT003", 350_000, 18, "Thời trang", "shirt"),
new("Túi xách da nữ", "SKU-PK001", 890_000, 8, "Phụ kiện", "shopping-bag"),
new("Ví da nam", "SKU-PK002", 350_000, 30, "Phụ kiện", "wallet"),
new("Kính mát thời trang", "SKU-PK003", 280_000, 25, "Phụ kiện", "glasses"),
new("Tai nghe Bluetooth", "SKU-DT001", 650_000, 15, "Điện tử", "headphones"),
new("Sạc dự phòng 10000mAh", "SKU-DT002", 380_000, 40, "Điện tử", "battery-charging"),
new("Nồi cơm điện 1.8L", "SKU-GD001", 890_000, 10, "Gia dụng", "cooking-pot"),
new("Son môi cao cấp", "SKU-MP001", 320_000, 28, "Mỹ phẩm", "sparkles"),
new("Kem chống nắng SPF50", "SKU-MP002", 280_000, 35, "Mỹ phẩm", "sun"),
};
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 void AddToCart(Product product)
{
var existing = _cartItems.FirstOrDefault(i => i.Sku == product.Sku);
if (existing != null) existing.Qty++;
else _cartItems.Add(new CartItem(product.Name, product.Sku, product.Price));
}
private void ChangeQty(CartItem item, int delta)
{
item.Qty += delta;
if (item.Qty <= 0) _cartItems.Remove(item);
}
private void Checkout() { }
private record Product(string Name, string Sku, decimal Price, int Stock, string Category, string Icon);
private class CartItem(string name, string sku, decimal price)
{
public string Name { get; set; } = name;
public string Sku { get; set; } = sku;
public decimal Price { get; set; } = price;
public int Qty { get; set; } = 1;
}
}

View File

@@ -0,0 +1,178 @@
@*
EN: Product Search — Search by name, SKU, barcode with filters. Add to cart from results.
VI: Tìm kiếm sản phẩm — Tìm theo tên, SKU, mã vạch với bộ lọc. Thêm vào giỏ từ kết quả.
*@
@page "/pos/retail/product-search"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER ═══ *@
<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("retail"))">
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lại
</button>
<span style="font-size:16px;font-weight:700;">Tìm kiếm sản phẩm</span>
</div>
@* ═══ SEARCH BAR / THANH TÌM KIẾM ═══ *@
<div style="padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);">
<div style="display:flex;align-items:center;gap:8px;background:var(--pos-bg-interactive);border-radius:var(--pos-radius);padding:0 14px;">
<i data-lucide="search" style="width:16px;height:16px;color:var(--pos-text-tertiary);"></i>
<input type="text" @bind="_searchQuery" @bind:event="oninput"
placeholder="Tìm theo tên, SKU hoặc mã vạch..."
style="flex:1;background:transparent;border:none;color:var(--pos-text-primary);font-size:14px;
padding:12px 0;outline:none;font-family:inherit;" />
@if (!string.IsNullOrEmpty(_searchQuery))
{
<button style="background:none;border:none;color:var(--pos-text-tertiary);cursor:pointer;"
@onclick="() => _searchQuery = string.Empty">
<i data-lucide="x" style="width:16px;height:16px;"></i>
</button>
}
</div>
</div>
@* ═══ FILTERS / BỘ LỌC ═══ *@
<div style="padding:10px 16px;border-bottom:1px solid var(--pos-border-subtle);display:flex;gap:12px;align-items:center;">
@* EN: Category filter / VI: Lọc danh mục *@
<div class="pos-category-tabs" style="padding:0;flex:1;">
@foreach (var cat in _categories)
{
<button class="pos-category-tab @(cat == _filterCategory ? "pos-category-tab--active" : "")"
style="font-size:12px;padding:6px 12px;" @onclick="() => _filterCategory = cat">@cat</button>
}
</div>
@* EN: Price range / VI: Khoảng giá *@
<select @bind="_priceRange"
style="background:var(--pos-bg-interactive);border:1px solid var(--pos-border-default);border-radius:6px;
color:var(--pos-text-primary);padding:6px 10px;font-size:12px;font-family:inherit;">
<option value="all">Tất cả giá</option>
<option value="under200">Dưới 200K</option>
<option value="200to500">200K - 500K</option>
<option value="over500">Trên 500K</option>
</select>
</div>
@* ═══ SEARCH RESULTS / KẾT QUẢ TÌM KIẾM ═══ *@
<div style="flex:1;overflow-y:auto;padding:12px 16px;">
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:12px;">
@FilteredResults.Count() kết quả @(!string.IsNullOrEmpty(_searchQuery) ? $"cho \"{_searchQuery}\"" : "")
</div>
@foreach (var product in FilteredResults)
{
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:14px 16px;margin-bottom:8px;
display:flex;align-items:center;gap:14px;">
@* EN: Product image placeholder / VI: Ảnh sản phẩm (placeholder) *@
<div style="width:56px;height:56px;border-radius:10px;background:var(--pos-bg-interactive);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="@product.Icon" style="width:24px;height:24px;color:var(--pos-text-tertiary);"></i>
</div>
@* EN: Product info / VI: Thông tin sản phẩm *@
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;">@product.Name</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:2px;">@product.Sku · @product.Category</div>
<div style="display:flex;align-items:center;gap:12px;margin-top:4px;">
<span style="font-size:15px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(product.Price)</span>
<span style="font-size:11px;color:@(product.Stock > 10 ? "var(--pos-success)" : "var(--pos-warning)");">
Kho: @product.Stock
</span>
</div>
</div>
@* EN: Add to cart / VI: Thêm vào giỏ *@
<button style="padding:10px 18px;border-radius:8px;border:none;background:var(--pos-orange-primary);color:#fff;
font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px;"
@onclick="() => AddToCart(product)">
<i data-lucide="plus" style="width:14px;height:14px;"></i> Thêm
</button>
</div>
}
</div>
@* ═══ CART SUMMARY BAR / THANH TÓM TẮT GIỎ ═══ *@
@if (_cartItems.Any())
{
<div style="padding:12px 16px;border-top:1px solid var(--pos-border-subtle);background:var(--pos-bg-elevated);
display:flex;align-items:center;justify-content:space-between;">
<div>
<span style="font-size:13px;color:var(--pos-text-secondary);">Giỏ hàng: @_cartItems.Sum(i => i.Qty) sản phẩm</span>
<span style="font-size:15px;font-weight:700;color:var(--pos-orange-primary);margin-left:12px;">
@FormatPrice(_cartItems.Sum(i => i.Price * i.Qty))
</span>
</div>
<button style="padding:10px 20px;border-radius:8px;border:none;background:var(--pos-orange-primary);color:#fff;
font-size:13px;font-weight:600;cursor:pointer;" @onclick="@(() => NavigateTo("retail"))">
Xem giỏ hàng
</button>
</div>
}
</div>
@code {
private string _searchQuery = "ao";
private string _filterCategory = "Tất cả";
private string _priceRange = "all";
private readonly string[] _categories = { "Tất cả", "Thời trang", "Phụ kiện", "Điện tử", "Gia dụng", "Mỹ phẩm" };
// EN: All products / VI: Tất cả sản phẩm
private readonly List<Product> _products = new()
{
new("Áo thun nam basic", "SKU-TT001", 199_000, 45, "Thời trang", "shirt"),
new("Áo khoác gió unisex", "SKU-TT003", 350_000, 18, "Thời trang", "shirt"),
new("Áo polo nam", "SKU-TT005", 280_000, 32, "Thời trang", "shirt"),
new("Áo sơ mi nữ", "SKU-TT006", 320_000, 14, "Thời trang", "shirt"),
new("Quần jean nữ slim", "SKU-TT002", 450_000, 22, "Thời trang", "shirt"),
new("Váy liền công sở", "SKU-TT004", 520_000, 12, "Thời trang", "shirt"),
new("Túi xách da nữ", "SKU-PK001", 890_000, 8, "Phụ kiện", "shopping-bag"),
new("Ví da nam", "SKU-PK002", 350_000, 30, "Phụ kiện", "wallet"),
new("Kính mát thời trang", "SKU-PK003", 280_000, 25, "Phụ kiện", "glasses"),
new("Tai nghe Bluetooth", "SKU-DT001", 650_000, 15, "Điện tử", "headphones"),
new("Sạc dự phòng 10000mAh", "SKU-DT002", 380_000, 40, "Điện tử", "battery-charging"),
new("Chuột không dây", "SKU-DT003", 250_000, 35, "Điện tử", "mouse"),
new("Nồi cơm điện 1.8L", "SKU-GD001", 890_000, 10, "Gia dụng", "cooking-pot"),
new("Bình giữ nhiệt 500ml", "SKU-GD002", 180_000, 50, "Gia dụng", "cup-soda"),
new("Son môi cao cấp", "SKU-MP001", 320_000, 28, "Mỹ phẩm", "sparkles"),
};
private readonly List<CartItem> _cartItems = new();
private IEnumerable<Product> FilteredResults
{
get
{
var result = _products.AsEnumerable();
if (!string.IsNullOrWhiteSpace(_searchQuery))
result = result.Where(p => p.Name.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase)
|| p.Sku.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase));
if (_filterCategory != "Tất cả")
result = result.Where(p => p.Category == _filterCategory);
result = _priceRange switch
{
"under200" => result.Where(p => p.Price < 200_000),
"200to500" => result.Where(p => p.Price >= 200_000 && p.Price <= 500_000),
"over500" => result.Where(p => p.Price > 500_000),
_ => result
};
return result;
}
}
private void AddToCart(Product product)
{
var existing = _cartItems.FirstOrDefault(i => i.Sku == product.Sku);
if (existing != null) existing.Qty++;
else _cartItems.Add(new CartItem(product.Name, product.Sku, product.Price));
}
private record Product(string Name, string Sku, decimal Price, int Stock, string Category, string Icon);
private class CartItem(string name, string sku, decimal price)
{
public string Name { get; set; } = name;
public string Sku { get; set; } = sku;
public decimal Price { get; set; } = price;
public int Qty { get; set; } = 1;
}
}

View File

@@ -0,0 +1,192 @@
@*
EN: Returns & Exchanges — Receipt lookup, original order items, select returns, refund method, confirm.
VI: Đổi trả hàng — Tra cứu hóa đơn, món gốc, chọn trả, phương thức hoàn tiền, xác nhận.
*@
@page "/pos/retail/return-exchange"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER ═══ *@
<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("retail"))">
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lại
</button>
<span style="font-size:16px;font-weight:700;">Đổi trả hàng</span>
</div>
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;gap:16px;">
@* ═══ LEFT: RECEIPT LOOKUP + ITEMS / TRÁI: TRA CỨU HÓA ĐƠN + DANH SÁCH MÓN ═══ *@
<div style="flex:1;display:flex;flex-direction:column;gap:16px;">
@* EN: Receipt lookup / VI: Tra cứu hóa đơn *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;">
<div style="font-size:14px;font-weight:600;margin-bottom:10px;">Tra cứu hóa đơn</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;">
<i data-lucide="receipt" style="width:14px;height:14px;color:var(--pos-text-tertiary);"></i>
<input type="text" @bind="_receiptInput" placeholder="Nhập số hóa đơn hoặc quét mã..."
style="flex:1;background:transparent;border:none;color:var(--pos-text-primary);font-size:13px;
padding:10px 0;outline:none;font-family:inherit;" />
</div>
<button style="padding:10px 18px;border-radius:8px;border:none;background:var(--pos-orange-primary);color:#fff;
font-size:13px;font-weight:600;cursor:pointer;" @onclick="LookupReceipt">
<i data-lucide="search" style="width:14px;height:14px;display:inline;"></i> Tìm
</button>
</div>
</div>
@if (_receiptFound)
{
@* EN: Original order info / VI: Thông tin đơn gốc *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<div>
<div style="font-size:15px;font-weight:700;">Hóa đơn #@_receipt.Id</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">@_receipt.Date · @_receipt.Staff</div>
</div>
<span style="font-size:14px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(_receipt.Total)</span>
</div>
@* EN: Select items to return / VI: Chọn sản phẩm trả lại *@
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Chọn sản phẩm đổi trả</div>
@foreach (var item in _receipt.Items)
{
<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--pos-border-subtle);">
<input type="checkbox" checked="@item.Selected"
@onchange="() => item.Selected = !item.Selected"
style="width:18px;height:18px;accent-color:var(--pos-orange-primary);cursor:pointer;" />
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;">@item.Name</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@item.Sku · SL: @item.Qty</div>
</div>
<span style="font-size:13px;font-weight:600;">@FormatPrice(item.Price * item.Qty)</span>
</div>
}
</div>
}
</div>
@* ═══ RIGHT: RETURN DETAILS / PHẢI: CHI TIẾT ĐỔI TRẢ ═══ *@
<div class="pos-cart-panel" style="width:340px;min-width:340px;">
<div class="pos-cart-header">
<span class="pos-cart-header__title">Chi tiết đổi trả</span>
</div>
<div class="pos-cart-items" style="padding:16px;">
@if (_receiptFound && SelectedItems.Any())
{
@* EN: Selected return items / VI: Sản phẩm đã chọn trả *@
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Sản phẩm trả (@SelectedItems.Count())</div>
@foreach (var item in SelectedItems)
{
<div style="display:flex;justify-content:space-between;padding:6px 0;font-size:13px;
border-bottom:1px solid var(--pos-border-subtle);">
<span>@item.Name x@item.Qty</span>
<span style="color:var(--pos-danger);">-@FormatPrice(item.Price * item.Qty)</span>
</div>
}
</div>
@* EN: Return reason / VI: Lý do đổi trả *@
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Lý do</div>
<select @bind="_returnReason"
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:13px;font-family:inherit;">
<option value="">Chọn lý do...</option>
<option value="defective">Lỗi sản phẩm</option>
<option value="wrong_size">Sai kích cỡ</option>
<option value="wrong_item">Sai sản phẩm</option>
<option value="changed_mind">Đổi ý</option>
<option value="other">Lý do khác</option>
</select>
</div>
@* EN: Refund method / VI: Phương thức hoàn tiền *@
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Phương thức hoàn tiền</div>
<div style="display:flex;flex-direction:column;gap:8px;">
@foreach (var method in _refundMethods)
{
<button style="padding:12px;border-radius:8px;text-align:left;cursor:pointer;
border:1px solid @(_selectedRefundMethod == method.Key ? "var(--pos-orange-primary)" : "var(--pos-border-default)");
background:@(_selectedRefundMethod == method.Key ? "rgba(255,92,0,.08)" : "transparent");
color:var(--pos-text-primary);font-size:13px;"
@onclick="() => _selectedRefundMethod = method.Key">
<div style="font-weight:600;">@method.Label</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">@method.Description</div>
</button>
}
</div>
</div>
@* EN: Refund amount / VI: Số tiền hoàn *@
<div style="background:var(--pos-bg-interactive);border-radius:8px;padding:14px;text-align:center;">
<div style="font-size:12px;color:var(--pos-text-tertiary);">Số tiền hoàn trả</div>
<div style="font-size:24px;font-weight:800;color:var(--pos-danger);margin-top:4px;">
@FormatPrice(RefundAmount)
</div>
</div>
}
else
{
<div style="text-align:center;padding:32px 16px;color:var(--pos-text-tertiary);font-size:13px;">
Tra cứu hóa đơn và chọn sản phẩm để đổi trả
</div>
}
</div>
@if (_receiptFound && SelectedItems.Any())
{
<div class="pos-cart-footer">
<button class="pos-btn-checkout" @onclick="ConfirmReturn">
<i data-lucide="rotate-ccw" style="width:18px;height:18px;"></i>
Xác nhận đổi trả
</button>
</div>
}
</div>
</div>
</div>
@code {
private string _receiptInput = "R2024001234";
private bool _receiptFound = true;
private string _returnReason = "";
private string _selectedRefundMethod = "original";
// EN: Refund methods / VI: Phương thức hoàn tiền
private readonly List<RefundMethod> _refundMethods = new()
{
new("original", "Hoàn về phương thức gốc", "Hoàn tiền về thẻ/TK ban đầu"),
new("cash", "Tiền mặt", "Hoàn tiền mặt tại quầy"),
new("credit", "Tín dụng cửa hàng", "Cộng vào tài khoản khách hàng"),
};
// EN: Demo receipt / VI: Hóa đơn mẫu
private readonly Receipt _receipt = new("R2024001234", "15/01/2024 · 14:30", "Trần Thị B", 1_619_000, new()
{
new("Áo thun nam basic", "SKU-TT001", 199_000, 2, true),
new("Túi xách da nữ", "SKU-PK001", 890_000, 1, false),
new("Kính mát thời trang", "SKU-PK003", 280_000, 1, false),
new("Bình giữ nhiệt 500ml", "SKU-GD002", 180_000, 1, false),
});
private IEnumerable<ReceiptItem> SelectedItems => _receipt.Items.Where(i => i.Selected);
private decimal RefundAmount => SelectedItems.Sum(i => i.Price * i.Qty);
private void LookupReceipt() => _receiptFound = true;
private void ConfirmReturn() { }
private record RefundMethod(string Key, string Label, string Description);
private record Receipt(string Id, string Date, string Staff, decimal Total, List<ReceiptItem> Items);
private class ReceiptItem(string name, string sku, decimal price, int qty, bool selected)
{
public string Name { get; set; } = name;
public string Sku { get; set; } = sku;
public decimal Price { get; set; } = price;
public int Qty { get; set; } = qty;
public bool Selected { get; set; } = selected;
}
}

View File

@@ -0,0 +1,185 @@
@*
EN: Stock Check — Product search/scan, stock across branches, recent movements, reorder suggestion.
VI: Kiểm kho — Tìm/quét sản phẩm, tồn kho theo chi nhánh, biến động gần đây, gợi ý đặt hàng.
*@
@page "/pos/retail/stock-check"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER ═══ *@
<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("retail"))">
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lại
</button>
<span style="font-size:16px;font-weight:700;">Kiểm kho nhanh</span>
</div>
@* ═══ SEARCH BAR / THANH TÌM KIẾM ═══ *@
<div style="padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);">
<div style="display:flex;gap:8px;">
<div style="flex:1;display:flex;align-items:center;gap:8px;background:var(--pos-bg-interactive);border-radius:var(--pos-radius);padding:0 12px;">
<i data-lucide="search" style="width:16px;height:16px;color:var(--pos-text-tertiary);"></i>
<input type="text" @bind="_searchQuery" @bind:event="oninput"
placeholder="Tìm sản phẩm hoặc quét mã vạch..."
style="flex:1;background:transparent;border:none;color:var(--pos-text-primary);font-size:14px;
padding:10px 0;outline:none;font-family:inherit;" />
</div>
<button style="padding:10px 18px;border-radius:var(--pos-radius);border:none;background:var(--pos-orange-primary);
color:#fff;font-size:13px;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:6px;">
<i data-lucide="scan-barcode" style="width:16px;height:16px;"></i> Quét
</button>
</div>
</div>
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:16px;">
@* ═══ PRODUCT DETAILS / CHI TIẾT SẢN PHẨM ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;display:flex;gap:16px;align-items:center;">
<div style="width:80px;height:80px;border-radius:12px;background:var(--pos-bg-interactive);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="shirt" style="width:36px;height:36px;color:var(--pos-text-tertiary);"></i>
</div>
<div style="flex:1;">
<div style="font-size:18px;font-weight:700;">@_product.Name</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);margin-top:4px;">@_product.Sku · @_product.Category</div>
<div style="display:flex;gap:16px;margin-top:8px;">
<span style="font-size:16px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(_product.Price)</span>
<span style="font-size:13px;padding:4px 10px;border-radius:6px;font-weight:600;
background:rgba(34,197,94,.15);color:var(--pos-success);">
Tổng: @_product.TotalStock SP
</span>
</div>
</div>
</div>
@* ═══ STOCK BY BRANCH / TỒN KHO THEO CHI NHÁNH ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px;">
<i data-lucide="warehouse" style="width:18px;height:18px;color:var(--pos-text-tertiary);"></i>
<span style="font-size:15px;font-weight:700;">Tồn kho theo chi nhánh</span>
</div>
@* EN: Branch stock table / VI: Bảng tồn kho chi nhánh *@
<div style="border:1px solid var(--pos-border-subtle);border-radius:8px;overflow:hidden;">
<div style="display:grid;grid-template-columns:1fr 100px 100px 100px;background:var(--pos-bg-interactive);padding:10px 14px;font-size:12px;font-weight:600;color:var(--pos-text-tertiary);">
<span>Chi nhánh</span>
<span style="text-align:center;">Tồn kho</span>
<span style="text-align:center;">Đã đặt</span>
<span style="text-align:center;">Trạng thái</span>
</div>
@foreach (var branch in _branchStock)
{
<div style="display:grid;grid-template-columns:1fr 100px 100px 100px;padding:12px 14px;
border-top:1px solid var(--pos-border-subtle);font-size:13px;align-items:center;">
<div>
<div style="font-weight:600;">@branch.Name</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@branch.Address</div>
</div>
<span style="text-align:center;font-weight:600;">@branch.Stock</span>
<span style="text-align:center;color:var(--pos-text-tertiary);">@branch.Reserved</span>
<div style="text-align:center;">
<span style="font-size:11px;padding:3px 8px;border-radius:6px;font-weight:600;
background:@GetStockBg(branch.Stock);color:@GetStockColor(branch.Stock);">
@GetStockLabel(branch.Stock)
</span>
</div>
</div>
}
</div>
</div>
@* ═══ RECENT STOCK MOVEMENTS / BIẾN ĐỘNG KHO GẦN ĐÂY ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px;">
<i data-lucide="arrow-left-right" style="width:18px;height:18px;color:var(--pos-text-tertiary);"></i>
<span style="font-size:15px;font-weight:700;">Biến động gần đây</span>
</div>
@foreach (var movement in _movements)
{
<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--pos-border-subtle);">
<div style="width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;
background:@(movement.Qty > 0 ? "rgba(34,197,94,.12)" : "rgba(239,68,68,.12)");">
<i data-lucide="@(movement.Qty > 0 ? "arrow-down" : "arrow-up")"
style="width:14px;height:14px;color:@(movement.Qty > 0 ? "var(--pos-success)" : "var(--pos-danger)");"></i>
</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;">@movement.Description</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@movement.Date · @movement.Branch</div>
</div>
<span style="font-size:13px;font-weight:700;color:@(movement.Qty > 0 ? "var(--pos-success)" : "var(--pos-danger)");">
@(movement.Qty > 0 ? "+" : "")@movement.Qty
</span>
</div>
}
</div>
@* ═══ REORDER SUGGESTION / GỢI Ý ĐẶT HÀNG ═══ *@
<div style="background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.2);border-radius:var(--pos-radius);padding:16px;
display:flex;align-items:center;gap:14px;">
<div style="width:44px;height:44px;border-radius:12px;background:rgba(245,158,11,.15);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="alert-triangle" style="width:22px;height:22px;color:var(--pos-warning);"></i>
</div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:700;color:var(--pos-warning);">Gợi ý đặt hàng</div>
<div style="font-size:12px;color:var(--pos-text-secondary);margin-top:2px;">
Chi nhánh Quận 3 sắp hết hàng (còn 5 SP). Đề xuất nhập thêm 30 SP dựa trên tốc độ bán trung bình 15 SP/tuần.
</div>
</div>
<button style="padding:10px 18px;border-radius:8px;border:none;background:var(--pos-warning);color:#fff;
font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;">
Tạo đơn nhập
</button>
</div>
</div>
</div>
@code {
private string _searchQuery = "Áo thun nam";
// EN: Demo product / VI: Sản phẩm mẫu
private readonly StockProduct _product = new("Áo thun nam basic", "SKU-TT001", "Thời trang", 199_000, 72);
// EN: Branch stock data / VI: Dữ liệu tồn kho chi nhánh
private readonly List<BranchStock> _branchStock = new()
{
new("Chi nhánh Quận 1", "123 Nguyễn Huệ, Q.1", 32, 5),
new("Chi nhánh Quận 3", "45 Võ Văn Tần, Q.3", 5, 2),
new("Chi nhánh Quận 7", "789 Nguyễn Thị Thập, Q.7", 35, 8),
};
// EN: Recent movements / VI: Biến động gần đây
private readonly List<StockMovement> _movements = new()
{
new("Bán hàng — Đơn #DH089", "25/01/2024", "Chi nhánh Q.1", -3),
new("Nhập kho từ NCC", "24/01/2024", "Chi nhánh Q.7", 20),
new("Bán hàng — Đơn #DH085", "24/01/2024", "Chi nhánh Q.3", -2),
new("Chuyển kho Q.1 → Q.3", "23/01/2024", "Chi nhánh Q.3", 10),
new("Chuyển kho Q.1 → Q.3", "23/01/2024", "Chi nhánh Q.1", -10),
new("Bán hàng — Đơn #DH080", "22/01/2024", "Chi nhánh Q.1", -5),
};
private static string GetStockColor(int stock) => stock switch
{
<= 10 => "var(--pos-danger)",
<= 20 => "var(--pos-warning)",
_ => "var(--pos-success)"
};
private static string GetStockBg(int stock) => stock switch
{
<= 10 => "rgba(239,68,68,.15)",
<= 20 => "rgba(245,158,11,.15)",
_ => "rgba(34,197,94,.15)"
};
private static string GetStockLabel(int stock) => stock switch
{
<= 10 => "Sắp hết",
<= 20 => "Thấp",
_ => "Đủ"
};
private record StockProduct(string Name, string Sku, string Category, decimal Price, int TotalStock);
private record BranchStock(string Name, string Address, int Stock, int Reserved);
private record StockMovement(string Description, string Date, string Branch, int Qty);
}

View File

@@ -0,0 +1,144 @@
@*
EN: Cash Drawer — Denomination breakdown, quick count with +/- buttons, expected vs actual totals, variance.
VI: Quản lý ngăn kéo tiền — Bảng mệnh giá, đếm nhanh +/-, so sánh dự kiến/thực tế, chênh lệch.
*@
@page "/pos/operations/cash-drawer"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER ═══ *@
<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("operations/shift"))">
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lại
</button>
<span style="font-size:16px;font-weight:700;">Kiểm tiền ngăn kéo</span>
<span style="flex:1;"></span>
<span style="font-size:12px;padding:4px 12px;border-radius:6px;font-weight:600;
background:@(_drawerOpen ? "rgba(34,197,94,.15)" : "rgba(239,68,68,.15)");
color:@(_drawerOpen ? "var(--pos-success)" : "var(--pos-danger)");">
@(_drawerOpen ? "Đang mở" : "Đã đóng")
</span>
</div>
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;gap:16px;">
@* ═══ DENOMINATION BREAKDOWN (LEFT) / BẢNG MỆNH GIÁ (TRÁI) ═══ *@
<div style="flex:1;display:flex;flex-direction:column;gap:12px;">
<div style="font-size:15px;font-weight:700;margin-bottom:4px;">Bảng mệnh giá</div>
@foreach (var denom in _denominations)
{
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:12px 16px;
display:flex;align-items:center;gap:12px;">
@* EN: Denomination label / VI: Nhãn mệnh giá *@
<div style="min-width:100px;">
<div style="font-size:14px;font-weight:600;">@FormatPrice(denom.Value)</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@denom.Label</div>
</div>
@* EN: Quick count controls / VI: Điều khiển đếm nhanh *@
<div style="flex:1;display:flex;align-items:center;justify-content:center;gap:10px;">
<button style="width:36px;height:36px;border-radius:8px;border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-primary);font-size:18px;cursor:pointer;"
@onclick="() => ChangeDenomCount(denom, -1)"></button>
<span style="font-size:16px;font-weight:700;min-width:40px;text-align:center;">@denom.Count</span>
<button style="width:36px;height:36px;border-radius:8px;border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-primary);font-size:18px;cursor:pointer;"
@onclick="() => ChangeDenomCount(denom, 1)">+</button>
</div>
@* EN: Subtotal / VI: Thành tiền *@
<div style="min-width:120px;text-align:right;font-size:14px;font-weight:600;color:var(--pos-orange-primary);">
@FormatPrice(denom.Value * denom.Count)
</div>
</div>
}
</div>
@* ═══ SUMMARY PANEL (RIGHT) / PANEL TỔNG KẾT (PHẢI) ═══ *@
<div style="width:320px;min-width:320px;display:flex;flex-direction:column;gap:16px;">
@* EN: Expected vs actual / VI: Dự kiến so với thực tế *@
<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;">Tổng kết</div>
<div style="display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--pos-border-subtle);">
<span style="font-size:13px;color:var(--pos-text-secondary);">Dự kiến</span>
<span style="font-size:14px;font-weight:600;">@FormatPrice(_expectedCash)</span>
</div>
<div style="display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--pos-border-subtle);">
<span style="font-size:13px;color:var(--pos-text-secondary);">Thực đếm</span>
<span style="font-size:14px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(ActualTotal)</span>
</div>
<div style="display:flex;justify-content:space-between;padding:10px 0;">
<span style="font-size:13px;font-weight:600;">Chênh lệch</span>
<span style="font-size:14px;font-weight:700;color:@(Variance >= 0 ? "var(--pos-success)" : "var(--pos-danger)");">
@(Variance >= 0 ? "+" : "")@FormatPrice(Variance)
</span>
</div>
</div>
@* EN: Variance status / VI: Trạng thái chênh lệch *@
<div style="background:@(Math.Abs(Variance) < 1000 ? "rgba(34,197,94,.1)" : "rgba(239,68,68,.1)");
border-radius:var(--pos-radius);padding:16px;text-align:center;">
<i data-lucide="@(Math.Abs(Variance) < 1000 ? "check-circle" : "alert-triangle")"
style="width:24px;height:24px;color:@(Math.Abs(Variance) < 1000 ? "var(--pos-success)" : "var(--pos-danger)");display:block;margin:0 auto 8px;"></i>
<div style="font-size:13px;font-weight:600;color:@(Math.Abs(Variance) < 1000 ? "var(--pos-success)" : "var(--pos-danger)");">
@(Math.Abs(Variance) < 1000 ? "Cân bằng" : "Có chênh lệch")
</div>
</div>
@* EN: Drawer toggle / VI: Nút mở/đóng ngăn kéo *@
<button style="padding:14px;border-radius:var(--pos-radius);border:none;background:var(--pos-bg-interactive);
color:var(--pos-text-primary);font-size:14px;font-weight:600;cursor:pointer;
display:flex;align-items:center;justify-content:center;gap:8px;"
@onclick="() => _drawerOpen = !_drawerOpen">
<i data-lucide="@(_drawerOpen ? "lock" : "unlock")" style="width:16px;height:16px;"></i>
@(_drawerOpen ? "Đóng ngăn kéo" : "Mở ngăn kéo")
</button>
@* EN: Save count button / VI: Nút lưu kiểm đếm *@
<button class="pos-btn-checkout" style="width:100%;" @onclick="SaveCount">
<i data-lucide="save" style="width:18px;height:18px;"></i>
Lưu kiểm đếm
</button>
</div>
</div>
</div>
@code {
// EN: Drawer state / VI: Trạng thái ngăn kéo
private bool _drawerOpen = true;
private readonly decimal _expectedCash = 2_450_000;
// EN: Denomination data / VI: Dữ liệu mệnh giá
private readonly List<Denomination> _denominations = new()
{
new(500_000, "Tờ 500K", 2),
new(200_000, "Tờ 200K", 3),
new(100_000, "Tờ 100K", 5),
new(50_000, "Tờ 50K", 4),
new(20_000, "Tờ 20K", 3),
new(10_000, "Tờ 10K", 5),
new(5_000, "Tờ 5K", 2),
new(2_000, "Tờ 2K", 3),
new(1_000, "Tờ 1K", 4),
new(500, "Xu 500", 6),
};
private decimal ActualTotal => _denominations.Sum(d => d.Value * d.Count);
private decimal Variance => ActualTotal - _expectedCash;
private void ChangeDenomCount(Denomination denom, int delta)
{
denom.Count = Math.Max(0, denom.Count + delta);
}
private void SaveCount() { }
private class Denomination(decimal value, string label, int count)
{
public decimal Value { get; set; } = value;
public string Label { get; set; } = label;
public int Count { get; set; } = count;
}
}

View File

@@ -0,0 +1,110 @@
@*
EN: Staff Clock In/Out — Real-time clock, staff info, toggle clock button, today's log.
VI: Chấm công nhân viên — Đồng hồ thời gian thực, thông tin NV, nút chấm công, nhật ký hôm nay.
*@
@page "/pos/operations/clock-in-out"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER ═══ *@
<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("operations"))">
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lại
</button>
<span style="font-size:16px;font-weight:700;">Chấm công</span>
</div>
<div style="flex:1;overflow-y:auto;padding:24px;display:flex;flex-direction:column;align-items:center;gap:24px;">
@* ═══ CURRENT TIME DISPLAY / ĐỒNG HỒ HIỆN TẠI ═══ *@
<div style="text-align:center;padding:32px 0;">
<div style="font-size:64px;font-weight:800;color:var(--pos-text-primary);letter-spacing:2px;">
@_currentTime.ToString("HH:mm:ss")
</div>
<div style="font-size:14px;color:var(--pos-text-tertiary);margin-top:8px;">
@_currentTime.ToString("dddd, dd/MM/yyyy")
</div>
</div>
@* ═══ STAFF INFO / THÔNG TIN NHÂN VIÊN ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px 32px;text-align:center;width:100%;max-width:400px;">
<div style="width:64px;height:64px;border-radius:50%;background:var(--pos-bg-interactive);display:flex;align-items:center;justify-content:center;margin:0 auto 12px;">
<i data-lucide="user" style="width:28px;height:28px;color:var(--pos-orange-primary);"></i>
</div>
<div style="font-size:18px;font-weight:700;color:var(--pos-text-primary);">@_staffName</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);margin-top:4px;">@_staffRole</div>
<div style="margin-top:12px;padding:6px 16px;border-radius:20px;display:inline-block;font-size:12px;font-weight:600;
background:@(_isClockedIn ? "rgba(34,197,94,.15)" : "rgba(239,68,68,.15)");
color:@(_isClockedIn ? "var(--pos-success)" : "var(--pos-danger)");">
@(_isClockedIn ? "Đang làm việc" : "Chưa chấm công")
</div>
</div>
@* ═══ CLOCK IN/OUT BUTTON / NÚT CHẤM CÔNG ═══ *@
<button style="width:180px;height:180px;border-radius:50%;border:4px solid @(_isClockedIn ? "var(--pos-danger)" : "var(--pos-success)");
background:@(_isClockedIn ? "rgba(239,68,68,.1)" : "rgba(34,197,94,.1)");
color:@(_isClockedIn ? "var(--pos-danger)" : "var(--pos-success)");
font-size:18px;font-weight:700;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;
transition:all .2s ease;"
@onclick="ToggleClock">
<i data-lucide="@(_isClockedIn ? "log-out" : "log-in")" style="width:32px;height:32px;"></i>
@(_isClockedIn ? "Clock Out" : "Clock In")
</button>
@* ═══ TODAY'S LOG / NHẬT KÝ HÔM NAY ═══ *@
<div style="width:100%;max-width:500px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<span style="font-size:15px;font-weight:600;">Nhật ký hôm nay</span>
<span style="font-size:13px;color:var(--pos-orange-primary);font-weight:600;">
Tổng: @_totalHours.ToString("F1") giờ
</span>
</div>
@foreach (var entry in _clockEntries)
{
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:14px 16px;margin-bottom:8px;
display:flex;align-items:center;gap:12px;">
<div style="width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;
background:@(entry.Type == "in" ? "rgba(34,197,94,.15)" : "rgba(239,68,68,.15)");">
<i data-lucide="@(entry.Type == "in" ? "log-in" : "log-out")"
style="width:16px;height:16px;color:@(entry.Type == "in" ? "var(--pos-success)" : "var(--pos-danger)");"></i>
</div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;">@(entry.Type == "in" ? "Vào ca" : "Ra ca")</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">@entry.Time</div>
</div>
<span style="font-size:11px;padding:3px 10px;border-radius:6px;font-weight:600;
background:@(entry.Type == "in" ? "rgba(34,197,94,.12)" : "rgba(239,68,68,.12)");
color:@(entry.Type == "in" ? "var(--pos-success)" : "var(--pos-danger)");">
@(entry.Type == "in" ? "IN" : "OUT")
</span>
</div>
}
</div>
</div>
</div>
@code {
// EN: Staff info / VI: Thông tin nhân viên
private readonly string _staffName = "Nguyễn Văn A";
private readonly string _staffRole = "Thu ngân — Cashier";
private bool _isClockedIn = true;
private DateTime _currentTime = DateTime.Now;
private double _totalHours = 5.5;
// EN: Today's clock entries / VI: Bản ghi chấm công hôm nay
private readonly List<ClockEntry> _clockEntries = new()
{
new("08:00", "in"),
new("12:00", "out"),
new("13:30", "in"),
};
private void ToggleClock()
{
_isClockedIn = !_isClockedIn;
_clockEntries.Add(new(DateTime.Now.ToString("HH:mm"), _isClockedIn ? "in" : "out"));
}
private record ClockEntry(string Time, string Type);
}

View File

@@ -0,0 +1,181 @@
@*
EN: Pending Orders — Active orders list with status filters, table/customer, items, elapsed time, quick actions.
VI: Đơn hàng đang chờ — Danh sách đơn đang xử lý với bộ lọc trạng thái, bàn/khách, món, thời gian, thao tác nhanh.
*@
@page "/pos/operations/pending-orders"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER ═══ *@
<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("operations"))">
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lại
</button>
<span style="font-size:16px;font-weight:700;">Đơn hàng đang chờ</span>
<span style="flex:1;"></span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">@FilteredOrders.Count() đơn</span>
</div>
@* ═══ STATUS FILTER TABS / TAB LỌC TRẠNG THÁI ═══ *@
<div style="padding:10px 16px;border-bottom:1px solid var(--pos-border-subtle);">
<div class="pos-category-tabs" style="padding:0;">
@foreach (var tab in _statusTabs)
{
<button class="pos-category-tab @(tab.Key == _activeStatus ? "pos-category-tab--active" : "")"
style="font-size:13px;" @onclick="() => _activeStatus = tab.Key">
@tab.Label
<span style="margin-left:4px;font-size:11px;padding:1px 6px;border-radius:4px;
background:var(--pos-bg-interactive);">
@_orders.Count(o => tab.Key == "all" || o.Status == tab.Key)
</span>
</button>
}
</div>
</div>
@* ═══ ORDER LIST / DANH SÁCH ĐƠN HÀNG ═══ *@
<div style="flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:10px;">
@foreach (var order in FilteredOrders)
{
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;
border-left:4px solid @GetStatusColor(order.Status);">
@* EN: Order header / VI: Tiêu đề đơn *@
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<div style="width:40px;height:40px;border-radius:10px;background:var(--pos-bg-interactive);
display:flex;align-items:center;justify-content:center;">
<i data-lucide="@(string.IsNullOrEmpty(order.Table) ? "user" : "layout-grid")"
style="width:18px;height:18px;color:var(--pos-orange-primary);"></i>
</div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:700;">
#@order.Id
@if (!string.IsNullOrEmpty(order.Table))
{
<span style="color:var(--pos-text-tertiary);font-weight:500;"> — @order.Table</span>
}
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">@order.Customer</div>
</div>
<div style="text-align:right;">
<div style="font-size:14px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(order.Total)</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@order.Elapsed phút trước</div>
</div>
<span style="font-size:11px;padding:4px 10px;border-radius:6px;font-weight:600;
background:@GetStatusBg(order.Status);color:@GetStatusColor(order.Status);">
@GetStatusLabel(order.Status)
</span>
</div>
@* EN: Order items / VI: Danh sách món *@
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px;">
@foreach (var item in order.Items)
{
<span style="font-size:12px;padding:4px 10px;border-radius:6px;background:var(--pos-bg-interactive);
color:var(--pos-text-secondary);">
@item
</span>
}
</div>
@* EN: Quick actions / VI: Thao tác nhanh *@
<div style="display:flex;gap:8px;">
<button style="padding:6px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;
border:1px solid var(--pos-border-default);background:transparent;color:var(--pos-text-primary);"
@onclick="() => ViewOrder(order)">
<i data-lucide="eye" style="width:12px;height:12px;display:inline;"></i> Xem
</button>
@if (order.Status != "ready")
{
<button style="padding:6px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;
border:none;background:rgba(255,92,0,.12);color:var(--pos-orange-primary);"
@onclick="() => UpdateStatus(order)">
<i data-lucide="refresh-cw" style="width:12px;height:12px;display:inline;"></i> Cập nhật
</button>
}
<button style="padding:6px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;
border:none;background:rgba(239,68,68,.1);color:var(--pos-danger);"
@onclick="() => CancelOrder(order)">
<i data-lucide="x" style="width:12px;height:12px;display:inline;"></i> Hủy
</button>
</div>
</div>
}
</div>
</div>
@code {
// EN: Status filter / VI: Bộ lọc trạng thái
private string _activeStatus = "all";
private readonly List<StatusTab> _statusTabs = new()
{
new("all", "Tất cả"),
new("pending", "Chờ xử lý"),
new("preparing", "Đang làm"),
new("ready", "Sẵn sàng"),
};
// EN: Demo pending orders / VI: Đơn hàng mẫu đang chờ
private readonly List<PendingOrder> _orders = new()
{
new("PO001", "Bàn 3", "Trần Văn B", new[] { "Phở bò x2", "Gỏi cuốn x1" }, 285_000, "pending", 3),
new("PO002", "Bàn 7", "Lê Thị C", new[] { "Cơm tấm x1", "Trà đá x2" }, 85_000, "preparing", 8),
new("PO003", "", "Nguyễn Văn D (Mang đi)", new[] { "Bún bò Huế x1", "Chả giò x2" }, 160_000, "pending", 5),
new("PO004", "Bàn 11", "Phạm Thị E", new[] { "Lẩu thái x1", "Nước mía x3" }, 295_000, "preparing", 15),
new("PO005", "Bàn 2", "Hoàng Văn F", new[] { "Cà phê sữa x3", "Bánh mì x2" }, 155_000, "ready", 20),
new("PO006", "Bàn 9", "Đỗ Minh G", new[] { "Gà nướng x1", "Cơm trắng x2", "Canh chua x1" }, 320_000, "pending", 1),
};
private IEnumerable<PendingOrder> FilteredOrders =>
_activeStatus == "all" ? _orders : _orders.Where(o => o.Status == _activeStatus);
private void ViewOrder(PendingOrder order) { }
private void UpdateStatus(PendingOrder order)
{
order.Status = order.Status switch
{
"pending" => "preparing",
"preparing" => "ready",
_ => order.Status
};
}
private void CancelOrder(PendingOrder order) => _orders.Remove(order);
private static string GetStatusColor(string status) => status switch
{
"pending" => "#F59E0B",
"preparing" => "#3B82F6",
"ready" => "#22C55E",
_ => "var(--pos-text-tertiary)"
};
private static string GetStatusBg(string status) => status switch
{
"pending" => "rgba(245,158,11,.15)",
"preparing" => "rgba(59,130,246,.15)",
"ready" => "rgba(34,197,94,.15)",
_ => "var(--pos-bg-interactive)"
};
private static string GetStatusLabel(string status) => status switch
{
"pending" => "Chờ xử lý",
"preparing" => "Đang làm",
"ready" => "Sẵn sàng",
_ => status
};
private record StatusTab(string Key, string Label);
private class PendingOrder(string id, string table, string customer, string[] items, decimal total, string status, int elapsed)
{
public string Id { get; set; } = id;
public string Table { get; set; } = table;
public string Customer { get; set; } = customer;
public string[] Items { get; set; } = items;
public decimal Total { get; set; } = total;
public string Status { get; set; } = status;
public int Elapsed { get; set; } = elapsed;
}
}

View File

@@ -0,0 +1,150 @@
@*
EN: Quick Sale — Numpad amount entry, category quick buttons, note field, pay button.
VI: Bán nhanh — Bàn phím số nhập tiền, nút danh mục nhanh, ghi chú, nút thanh toán.
*@
@page "/pos/operations/quick-sale"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER ═══ *@
<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("operations"))">
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lại
</button>
<span style="font-size:16px;font-weight:700;">Bán nhanh</span>
</div>
<div style="flex:1;display:flex;overflow:hidden;">
@* ═══ NUMPAD PANEL (LEFT) / BẢNG SỐ (TRÁI) ═══ *@
<div style="flex:1;display:flex;flex-direction:column;padding:24px;align-items:center;justify-content:center;gap:24px;">
@* EN: Amount display / VI: Hiển thị số tiền *@
<div style="width:100%;max-width:420px;background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:24px;text-align:right;">
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:4px;">Số tiền</div>
<div style="font-size:40px;font-weight:800;color:var(--pos-orange-primary);letter-spacing:1px;">
@FormatPrice(decimal.Parse(_amountStr == "" ? "0" : _amountStr))
</div>
</div>
@* EN: Numpad grid / VI: Lưới bàn phím số *@
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;width:100%;max-width:420px;">
@foreach (var key in _numpadKeys)
{
<button style="height:64px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
background:@(key == "C" ? "rgba(239,68,68,.1)" : key == "⌫" ? "rgba(245,158,11,.1)" : "var(--pos-bg-elevated)");
color:@(key == "C" ? "var(--pos-danger)" : key == "⌫" ? "var(--pos-warning)" : "var(--pos-text-primary)");
font-size:@(key == "00" || key == "000" ? "16px" : "20px");font-weight:600;cursor:pointer;
transition:all .15s ease;"
@onclick="() => OnKeyPress(key)">
@if (key == "⌫")
{
<i data-lucide="delete" style="width:20px;height:20px;"></i>
}
else
{
@key
}
</button>
}
</div>
</div>
@* ═══ RIGHT PANEL / PANEL PHẢI ═══ *@
<div class="pos-cart-panel" style="width:340px;min-width:340px;">
<div class="pos-cart-header">
<span class="pos-cart-header__title">Thông tin</span>
</div>
<div class="pos-cart-items" style="padding:16px;">
@* EN: Category quick buttons / VI: Nút danh mục nhanh *@
<div style="margin-bottom:20px;">
<div style="font-size:13px;font-weight:600;margin-bottom:10px;">Danh mục</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;">
@foreach (var cat in _quickCategories)
{
<button style="padding:10px 18px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;
border:none;background:@(_selectedCategory == cat ? "var(--pos-orange-primary)" : "var(--pos-bg-interactive)");
color:@(_selectedCategory == cat ? "#FFF" : "var(--pos-text-secondary)");"
@onclick="() => _selectedCategory = cat">
@cat
</button>
}
</div>
</div>
@* EN: Note input / VI: Ghi chú *@
<div style="margin-bottom:20px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Ghi chú</div>
<textarea @bind="_note" placeholder="Nhập ghi chú cho đơn hàng..."
style="width:100%;min-height:80px;padding:12px;border-radius:8px;border:1px solid var(--pos-border-default);
background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:13px;
font-family:inherit;resize:vertical;outline:none;" />
</div>
@* EN: Quick amount buttons / VI: Nút số tiền nhanh *@
<div>
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Số tiền nhanh</div>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:8px;">
@foreach (var amount in _quickAmounts)
{
<button style="padding:10px;border-radius:8px;border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-primary);font-size:13px;font-weight:600;cursor:pointer;"
@onclick="() => _amountStr = ((int)amount).ToString()">
@FormatPrice(amount)
</button>
}
</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(decimal.Parse(_amountStr == "" ? "0" : _amountStr))</span>
</div>
<button class="pos-btn-checkout" @onclick="Pay" disabled="@(_amountStr == "" || _amountStr == "0")">
<i data-lucide="credit-card" style="width:18px;height:18px;"></i>
Thanh toán
</button>
</div>
</div>
</div>
</div>
@code {
// EN: Amount input state / VI: Trạng thái nhập số tiền
private string _amountStr = "";
private string _selectedCategory = "Khác";
private string _note = "";
// EN: Numpad keys / VI: Phím bàn số
private readonly string[] _numpadKeys = { "7", "8", "9", "4", "5", "6", "1", "2", "3", "00", "0", "000", "C", "⌫", "." };
// EN: Quick categories / VI: Danh mục nhanh
private readonly string[] _quickCategories = { "Khác", "Phí dịch vụ", "Phụ thu" };
// EN: Quick amount presets / VI: Số tiền mẫu nhanh
private readonly decimal[] _quickAmounts = { 50_000, 100_000, 200_000, 500_000 };
private void OnKeyPress(string key)
{
switch (key)
{
case "C":
_amountStr = "";
break;
case "⌫":
if (_amountStr.Length > 0)
_amountStr = _amountStr[..^1];
break;
case ".":
break;
default:
if (_amountStr.Length < 12)
_amountStr += key;
break;
}
}
private void Pay() { }
}

View File

@@ -0,0 +1,154 @@
@*
EN: Shift Management — Current shift info, opening cash, quick actions, shift summary.
VI: Quản lý ca — Thông tin ca hiện tại, tiền mở ca, thao tác nhanh, tổng kết ca.
*@
@page "/pos/operations/shift"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER ═══ *@
<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("operations"))">
<i data-lucide="arrow-left" style="width:14px;"></i> Quay lại
</button>
<span style="font-size:16px;font-weight:700;">Quản lý ca</span>
<span style="flex:1;"></span>
<span style="font-size:12px;padding:4px 12px;border-radius:6px;font-weight:600;background:rgba(34,197,94,.15);color:var(--pos-success);">
Đang hoạt động
</span>
</div>
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:16px;">
@* ═══ CURRENT SHIFT INFO / THÔNG TIN CA HIỆN TẠI ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
<div style="width:44px;height:44px;border-radius:12px;background:rgba(255,92,0,.12);display:flex;align-items:center;justify-content:center;">
<i data-lucide="clock" style="width:22px;height:22px;color:var(--pos-orange-primary);"></i>
</div>
<div>
<div style="font-size:16px;font-weight:700;">Ca sáng</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">Bắt đầu: 08:00 — Hôm nay</div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
<div style="background:var(--pos-bg-interactive);border-radius:8px;padding:12px;text-align:center;">
<div style="font-size:12px;color:var(--pos-text-tertiary);">Nhân viên</div>
<div style="font-size:14px;font-weight:600;margin-top:4px;">Nguyễn Văn A</div>
</div>
<div style="background:var(--pos-bg-interactive);border-radius:8px;padding:12px;text-align:center;">
<div style="font-size:12px;color:var(--pos-text-tertiary);">Máy thu ngân</div>
<div style="font-size:14px;font-weight:600;margin-top:4px;">POS-01</div>
</div>
<div style="background:var(--pos-bg-interactive);border-radius:8px;padding:12px;text-align:center;">
<div style="font-size:12px;color:var(--pos-text-tertiary);">Tiền mở ca</div>
<div style="font-size:14px;font-weight:600;margin-top:4px;color:var(--pos-orange-primary);">@FormatPrice(_openingCash)</div>
</div>
</div>
</div>
@* ═══ QUICK ACTIONS / THAO TÁC NHANH ═══ *@
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
<button style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.2);border-radius:var(--pos-radius);
padding:20px;text-align:center;cursor:pointer;color:var(--pos-danger);" @onclick="EndShift">
<i data-lucide="power" style="width:24px;height:24px;display:block;margin:0 auto 8px;"></i>
<div style="font-size:14px;font-weight:600;">Kết ca</div>
<div style="font-size:11px;opacity:.7;margin-top:2px;">End Shift</div>
</button>
<button style="background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.2);border-radius:var(--pos-radius);
padding:20px;text-align:center;cursor:pointer;color:var(--pos-warning);" @onclick="CashDrop">
<i data-lucide="banknote" style="width:24px;height:24px;display:block;margin:0 auto 8px;"></i>
<div style="font-size:14px;font-weight:600;">Rút tiền</div>
<div style="font-size:11px;opacity:.7;margin-top:2px;">Cash Drop</div>
</button>
<button style="background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.2);border-radius:var(--pos-radius);
padding:20px;text-align:center;cursor:pointer;color:#3B82F6;" @onclick="@(() => NavigateTo("operations/cash-drawer"))">
<i data-lucide="calculator" style="width:24px;height:24px;display:block;margin:0 auto 8px;"></i>
<div style="font-size:14px;font-weight:600;">Kiểm tiền</div>
<div style="font-size:11px;opacity:.7;margin-top:2px;">Cash Count</div>
</button>
</div>
@* ═══ SHIFT SUMMARY / TỔNG KẾT CA ═══ *@
<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;">Tổng kết ca</div>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:12px;">
<div style="background:var(--pos-bg-interactive);border-radius:8px;padding:16px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<i data-lucide="receipt" style="width:16px;height:16px;color:var(--pos-text-tertiary);"></i>
<span style="font-size:12px;color:var(--pos-text-tertiary);">Tổng đơn hàng</span>
</div>
<div style="font-size:24px;font-weight:800;color:var(--pos-text-primary);">@_totalOrders</div>
</div>
<div style="background:var(--pos-bg-interactive);border-radius:8px;padding:16px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<i data-lucide="trending-up" style="width:16px;height:16px;color:var(--pos-text-tertiary);"></i>
<span style="font-size:12px;color:var(--pos-text-tertiary);">Tổng doanh thu</span>
</div>
<div style="font-size:24px;font-weight:800;color:var(--pos-orange-primary);">@FormatPrice(_totalRevenue)</div>
</div>
<div style="background:var(--pos-bg-interactive);border-radius:8px;padding:16px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<i data-lucide="banknote" style="width:16px;height:16px;color:var(--pos-text-tertiary);"></i>
<span style="font-size:12px;color:var(--pos-text-tertiary);">Tiền mặt</span>
</div>
<div style="font-size:20px;font-weight:700;color:var(--pos-success);">@FormatPrice(_cashTotal)</div>
</div>
<div style="background:var(--pos-bg-interactive);border-radius:8px;padding:16px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<i data-lucide="credit-card" style="width:16px;height:16px;color:var(--pos-text-tertiary);"></i>
<span style="font-size:12px;color:var(--pos-text-tertiary);">Thẻ</span>
</div>
<div style="font-size:20px;font-weight:700;color:#3B82F6;">@FormatPrice(_cardTotal)</div>
</div>
</div>
</div>
@* ═══ RECENT ACTIVITY / HOẠT ĐỘNG GẦN ĐÂY ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;">
<div style="font-size:15px;font-weight:700;margin-bottom:12px;">Hoạt động gần đây</div>
@foreach (var activity in _activities)
{
<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--pos-border-subtle);">
<div style="width:32px;height:32px;border-radius:8px;background:var(--pos-bg-interactive);display:flex;align-items:center;justify-content:center;">
<i data-lucide="@activity.Icon" style="width:14px;height:14px;color:var(--pos-text-tertiary);"></i>
</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;">@activity.Description</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@activity.Time</div>
</div>
@if (activity.Amount > 0)
{
<span style="font-size:13px;font-weight:600;color:var(--pos-orange-primary);">@FormatPrice(activity.Amount)</span>
}
</div>
}
</div>
</div>
</div>
@code {
// EN: Shift data / VI: Dữ liệu ca
private readonly decimal _openingCash = 2_000_000;
private readonly int _totalOrders = 15;
private readonly decimal _totalRevenue = 4_500_000;
private readonly decimal _cashTotal = 2_800_000;
private readonly decimal _cardTotal = 1_700_000;
// EN: Recent activity log / VI: Nhật ký hoạt động gần đây
private readonly List<ActivityEntry> _activities = new()
{
new("Đơn hàng #DH015 hoàn thành", "14:32", "receipt", 350_000),
new("Rút tiền mặt", "13:00", "banknote", 1_000_000),
new("Đơn hàng #DH014 hoàn thành", "12:45", "receipt", 285_000),
new("Đơn hàng #DH013 hoàn thành", "12:15", "receipt", 520_000),
new("Mở ca", "08:00", "play", 0),
};
private void EndShift() { }
private void CashDrop() { }
private record ActivityEntry(string Description, string Time, string Icon, decimal Amount);
}

View File

@@ -0,0 +1,163 @@
@*
EN: Spa POS Desktop — 2-panel layout: service categories + grid (left), current appointment/bill (right).
VI: POS Spa Desktop — Bố cục 2 panel: danh mục dịch vụ + lưới (trái), lịch hẹn/hóa đơn hiện tại (phải).
*@
@page "/pos/spa"
@layout PosLayout
@inherits PosBase
@* ═══ SERVICE PANEL (LEFT) / PANEL DỊCH VỤ (TRÁI) ═══ *@
<div class="pos-product-panel" style="padding:16px;overflow-y:auto;">
@* EN: Category tabs / VI: Tab danh mục *@
<div class="pos-category-tabs">
@foreach (var cat in _categories)
{
<button class="pos-category-tab @(cat == _selectedCategory ? "pos-category-tab--active" : "")"
@onclick="() => _selectedCategory = cat">
@cat
</button>
}
</div>
@* ═══ SERVICE GRID / LƯỚI DỊCH VỤ ═══ *@
<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:var(--pos-text-tertiary);"></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 phút
</span>
</div>
}
</div>
</div>
@* ═══ APPOINTMENT PANEL (RIGHT) / PANEL LỊCH HẸN (PHẢI) ═══ *@
<div class="pos-cart-panel">
<div class="pos-cart-header">
<span class="pos-cart-header__title">Lịch hẹn</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">@_appointmentItems.Count dịch vụ</span>
</div>
@* EN: Customer info / VI: Thông tin khách *@
@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:var(--pos-orange-primary);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 • @_customerTier</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("spa/customer-lookup"))">
<i data-lucide="user-plus" style="width:14px;height:14px;display:inline;"></i> Chọn khách hàng
</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 phút</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: Tổng thời gian *@
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Tổng thời gian</span>
<span>@_appointmentItems.Sum(i => i.Duration) phút</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(AppointmentTotal)</span>
</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("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>
</div>
</div>
</div>
@code {
// EN: Categories / VI: Danh mục
private readonly string[] _categories = { "Tất cả", "Massage", "Facial", "Body", "Nail", "Hair" };
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: Service list / VI: Danh sách dịch vụ
private readonly List<SpaService> _services = new()
{
new("Massage toàn thân", 500_000, 60, "Massage"),
new("Massage chân", 250_000, 45, "Massage"),
new("Massage đầu vai cổ", 300_000, 30, "Massage"),
new("Facial cơ bản", 350_000, 45, "Facial"),
new("Facial collagen", 600_000, 60, "Facial"),
new("Tắm trắng toàn thân", 800_000, 90, "Body"),
new("Tẩy tế bào chết", 400_000, 45, "Body"),
new("Sơn gel", 150_000, 30, "Nail"),
new("Nail art cao cấp", 300_000, 60, "Nail"),
new("Chăm sóc móng tay", 120_000, 30, "Nail"),
new("Gội đầu dưỡng sinh", 200_000, 40, "Hair"),
new("Ủ tóc phục hồi", 350_000, 45, "Hair"),
};
// EN: Appointment items / VI: Mục lịch hẹn
private readonly List<AppointmentItem> _appointmentItems = new();
private IEnumerable<SpaService> FilteredServices =>
_selectedCategory == "Tất cả" ? _services : _services.Where(s => s.Category == _selectedCategory);
private decimal AppointmentTotal => _appointmentItems.Sum(i => i.Price);
private void AddToAppointment(SpaService svc)
{
_appointmentItems.Add(new AppointmentItem(svc.Name, svc.Price, svc.Duration));
}
private void RemoveItem(AppointmentItem item) => _appointmentItems.Remove(item);
private void Checkout() => NavigateTo("spa/spa-journey");
private static string GetCategoryIcon(string category) => category switch
{
"Massage" => "hand", "Facial" => "sparkles", "Body" => "bath",
"Nail" => "paintbrush", "Hair" => "scissors", _ => "heart"
};
// 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);
}

View File

@@ -0,0 +1,140 @@
@*
EN: Spa POS Mobile — Single column: categories, service grid, floating appointment button, bottom sheet.
VI: POS Spa Mobile — Một cột: danh mục, lưới dịch vụ, nút lịch hẹn nổi, sheet dưới.
*@
@page "/pos/spa/mobile"
@layout PosLayout
@inherits PosBase
<div style="display:flex;flex-direction:column;width:100%;height:100%;overflow:hidden;">
@* EN: Category tabs / VI: Tab danh mục *@
<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;"
@onclick="() => _selectedCategory = cat">
@cat
</button>
}
</div>
@* EN: Service grid / VI: Lưới dịch vụ *@
<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:var(--pos-text-tertiary);"></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 phút</span>
</div>
}
</div>
@* EN: Floating appointment button / VI: Nút lịch hẹn nổi *@
@if (_appointmentItems.Any())
{
<button style="position:fixed;bottom:20px;right:20px;width:64px;height:64px;border-radius:50%;background:var(--pos-orange-primary);border:none;color:#fff;font-size:20px;cursor:pointer;box-shadow:0 4px 20px rgba(255,92,0,0.4);display:flex;align-items:center;justify-content:center;z-index:100;"
@onclick="() => _showSheet = !_showSheet">
<i data-lucide="calendar-check" 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: Lịch hẹn dạng sheet dưới *@
@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 kéo *@
<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">Lịch hẹn</span>
<button style="background:none;border:none;color:var(--pos-danger);font-size:13px;cursor:pointer;"
@onclick="() => { _appointmentItems.Clear(); _showSheet = false; }">Xóa</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 phút</span>
</div>
</div>
<button style="background:none;border:none;color:var(--pos-danger);cursor:pointer;font-size:16px;"
@onclick="() => _appointmentItems.Remove(item)">×</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);">Tổng thời gian</span>
<span>@_appointmentItems.Sum(i => i.Duration) phút</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(AppointmentTotal)</span>
</div>
<button class="pos-btn-checkout" @onclick="Checkout">Thanh toán</button>
</div>
</div>
</div>
}
</div>
@code {
private readonly string[] _categories = { "Tất cả", "Massage", "Facial", "Body", "Nail", "Hair" };
private string _selectedCategory = "Tất cả";
private bool _showSheet;
private readonly List<SpaService> _services = new()
{
new("Massage toàn thân", 500_000, 60, "Massage"),
new("Massage chân", 250_000, 45, "Massage"),
new("Massage đầu vai cổ", 300_000, 30, "Massage"),
new("Facial cơ bản", 350_000, 45, "Facial"),
new("Facial collagen", 600_000, 60, "Facial"),
new("Tắm trắng toàn thân", 800_000, 90, "Body"),
new("Tẩy tế bào chết", 400_000, 45, "Body"),
new("Sơn gel", 150_000, 30, "Nail"),
new("Nail art cao cấp", 300_000, 60, "Nail"),
new("Chăm sóc móng tay", 120_000, 30, "Nail"),
new("Gội đầu dưỡng sinh", 200_000, 40, "Hair"),
new("Ủ tóc phục hồi", 350_000, 45, "Hair"),
};
private readonly List<AppointmentItem> _appointmentItems = new();
private IEnumerable<SpaService> FilteredServices =>
_selectedCategory == "Tất cả" ? _services : _services.Where(s => s.Category == _selectedCategory);
private decimal AppointmentTotal => _appointmentItems.Sum(i => i.Price);
private void AddToAppointment(SpaService svc)
{
_appointmentItems.Add(new AppointmentItem(svc.Name, svc.Price, svc.Duration));
}
private void Checkout() => NavigateTo("spa/spa-journey");
private static string GetCategoryIcon(string category) => category switch
{
"Massage" => "hand", "Facial" => "sparkles", "Body" => "bath",
"Nail" => "paintbrush", "Hair" => "scissors", _ => "heart"
};
private record SpaService(string Name, decimal Price, int Duration, string Category);
private record AppointmentItem(string Name, decimal Price, int Duration);
}

View File

@@ -0,0 +1,127 @@
@*
EN: Spa POS Tablet — 2-column layout: service grid + appointment sidebar (340px), touch-friendly.
VI: POS Spa Tablet — Bố cục 2 cột: lưới dịch vụ + sidebar lịch hẹn (340px), thân thiện cảm ứng.
*@
@page "/pos/spa/tablet"
@layout PosLayout
@inherits PosBase
@* ═══ SERVICE PANEL / PANEL DỊCH VỤ ═══ *@
<div class="pos-product-panel">
<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;"
@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:var(--pos-text-tertiary);"></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 phút
</span>
</div>
}
</div>
</div>
@* ═══ APPOINTMENT SIDEBAR / SIDEBAR LỊCH HẸN ═══ *@
<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;">Lịch hẹn</span>
<button style="background:none;border:none;color:var(--pos-danger);font-size:13px;cursor:pointer;"
@onclick="() => _appointmentItems.Clear()">Xóa tất cả</button>
</div>
@* EN: Customer selection / VI: Chọn khách hàng *@
<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("spa/customer-lookup"))">
<i data-lucide="user-plus" style="width:16px;height:16px;display:inline;"></i> Chọn khách hàng
</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 phút</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)">×</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);">Tổng thời gian</span>
<span>@_appointmentItems.Sum(i => i.Duration) phút</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(AppointmentTotal)</span>
</div>
<button class="pos-btn-checkout" style="height:56px;font-size:17px;" @onclick="Checkout">
Thanh toán — @FormatPrice(AppointmentTotal)
</button>
</div>
</div>
@code {
private readonly string[] _categories = { "Tất cả", "Massage", "Facial", "Body", "Nail", "Hair" };
private string _selectedCategory = "Tất cả";
private readonly List<SpaService> _services = new()
{
new("Massage toàn thân", 500_000, 60, "Massage"),
new("Massage chân", 250_000, 45, "Massage"),
new("Massage đầu vai cổ", 300_000, 30, "Massage"),
new("Facial cơ bản", 350_000, 45, "Facial"),
new("Facial collagen", 600_000, 60, "Facial"),
new("Tắm trắng toàn thân", 800_000, 90, "Body"),
new("Tẩy tế bào chết", 400_000, 45, "Body"),
new("Sơn gel", 150_000, 30, "Nail"),
new("Nail art cao cấp", 300_000, 60, "Nail"),
new("Chăm sóc móng tay", 120_000, 30, "Nail"),
new("Gội đầu dưỡng sinh", 200_000, 40, "Hair"),
new("Ủ tóc phục hồi", 350_000, 45, "Hair"),
};
private readonly List<AppointmentItem> _appointmentItems = new();
private IEnumerable<SpaService> FilteredServices =>
_selectedCategory == "Tất cả" ? _services : _services.Where(s => s.Category == _selectedCategory);
private decimal AppointmentTotal => _appointmentItems.Sum(i => i.Price);
private void AddToAppointment(SpaService svc)
{
_appointmentItems.Add(new AppointmentItem(svc.Name, svc.Price, svc.Duration));
}
private void Checkout() => NavigateTo("spa/spa-journey");
private static string GetCategoryIcon(string category) => category switch
{
"Massage" => "hand", "Facial" => "sparkles", "Body" => "bath",
"Nail" => "paintbrush", "Hair" => "scissors", _ => "heart"
};
private record SpaService(string Name, decimal Price, int Duration, string Category);
private record AppointmentItem(string Name, decimal Price, int Duration);
}

View File

@@ -0,0 +1,185 @@
@*
EN: Spa Appointment Book — Date picker, time slots grid (9:00-20:00), staff selection, service, confirm.
VI: Đặt lịch hẹn Spa — Chọn ngày, lưới khung giờ (9:00-20:00), chọn nhân viên, dịch vụ, xác nhận.
*@
@page "/pos/spa/appointment-book"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;overflow:hidden;">
@* ═══ SCHEDULE PANEL (LEFT) / PANEL LỊCH (TRÁI) ═══ *@
<div class="pos-product-panel" style="padding:16px;overflow-y:auto;">
@* EN: Header / VI: Tiêu đề *@
<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"))">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;">Đặt lịch hẹn</span>
</div>
@* ═══ DATE PICKER / CHỌN NGÀY ═══ *@
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Chọn ngày</div>
<div style="display:flex;gap:8px;">
@foreach (var day in _dateOptions)
{
<button style="flex:1;padding:12px;border-radius:var(--pos-radius);cursor:pointer;text-align:center;
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">
<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>
}
</div>
</div>
@* ═══ STAFF SELECTION / CHỌN NHÂN VIÊN ═══ *@
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Chọn nhân viên</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
@foreach (var staff in _staffList)
{
<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
</button>
}
</div>
</div>
@* ═══ TIME SLOTS GRID / LƯỚI KHUNG GIỜ ═══ *@
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Chọn giờ</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;">
@foreach (var slot in _timeSlots)
{
var bgColor = slot.Status == "booked" ? "rgba(239,68,68,.1)"
: slot.Time == _selectedTime ? "var(--pos-orange-primary)"
: "var(--pos-bg-elevated)";
var fgColor = slot.Status == "booked" ? "var(--pos-danger)"
: slot.Time == _selectedTime ? "#FFF"
: "var(--pos-text-primary)";
var borderColor = slot.Status == "booked" ? "rgba(239,68,68,.3)"
: slot.Time == _selectedTime ? "var(--pos-orange-primary)"
: "var(--pos-border-subtle)";
<button style="padding:10px;border-radius:var(--pos-radius);text-align:center;cursor:@(slot.Status == "booked" ? "not-allowed" : "pointer");
background:@bgColor;color:@fgColor;border:1px solid @borderColor;font-size:13px;font-weight:500;"
disabled="@(slot.Status == "booked")"
@onclick="() => _selectedTime = slot.Time">
@slot.Time
@if (slot.Status == "booked")
{
<div style="font-size:10px;opacity:.7;">Đã đặt</div>
}
</button>
}
</div>
</div>
@* ═══ BOOKING SUMMARY (RIGHT) / TÓM TẮT ĐẶT LỊCH (PHẢI) ═══ *@
<div class="pos-cart-panel">
<div class="pos-cart-header">
<span class="pos-cart-header__title">Xác nhận đặt lịch</span>
</div>
<div class="pos-cart-items" style="padding:16px;">
@* EN: Booking details / VI: Chi tiết đặt lịch *@
<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>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--pos-text-secondary);">Ngày</span>
<span style="font-weight:600;">@_selectedDate</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--pos-text-secondary);">Giờ</span>
<span style="font-weight:600;color:var(--pos-orange-primary);">@(_selectedTime ?? "Chưa chọn")</span>
</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>
</div>
</div>
@* EN: Selected services / VI: Dịch vụ đã chọn *@
<div style="margin-top:20px;padding-top:16px;border-top:1px solid var(--pos-border-subtle);">
<div style="font-size:13px;font-weight:600;margin-bottom:10px;">Dịch vụ</div>
@foreach (var svc in _selectedServices)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;font-size:13px;">
<div>
<div style="font-weight:500;">@svc.Name</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@svc.Duration phút</div>
</div>
<span style="font-weight:600;">@FormatPrice(svc.Price)</span>
</div>
}
</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);">Tổng thời gian</span>
<span>@_selectedServices.Sum(s => s.Duration) phút</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(_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
</button>
</div>
</div>
</div>
@code {
private string _selectedDate = "Hôm nay";
private string? _selectedTime = "10:00";
private string _selectedStaff = "Chị Hoa";
// 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: 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: Time slots / VI: Khung giờ
private readonly List<TimeSlot> _timeSlots = new()
{
new("09:00", "available"), new("09:30", "available"), new("10:00", "available"),
new("10:30", "booked"), new("11:00", "booked"), new("11:30", "available"),
new("12:00", "available"), new("12:30", "available"), new("13:00", "available"),
new("13:30", "booked"), new("14:00", "available"), new("14:30", "available"),
new("15:00", "available"), new("15:30", "booked"), new("16:00", "available"),
new("16:30", "available"), new("17:00", "available"), new("17:30", "available"),
new("18:00", "booked"), new("18:30", "available"), new("19:00", "available"),
new("19:30", "available"), new("20:00", "available"),
};
// EN: Demo selected services / VI: Dịch vụ đã chọn mẫu
private readonly List<ServiceInfo> _selectedServices = new()
{
new("Massage toàn thân", 500_000, 60),
new("Facial collagen", 600_000, 60),
};
private record DateOption(string Label, string Day, string Value);
private record TimeSlot(string Time, string Status);
private record ServiceInfo(string Name, decimal Price, int Duration);
}

View File

@@ -0,0 +1,115 @@
@*
EN: Spa Customer Lookup — Search by phone/name, results with VIP tier, last visit, create new customer.
VI: Tra cứu khách Spa — Tìm theo SĐT/tên, kết quả với hạng VIP, lần ghé cuối, tạo khách mới.
*@
@page "/pos/spa/customer-lookup"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
<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;">Tra cứu khách hàng</span>
</div>
@* ═══ SEARCH BAR / THANH TÌM KIẾM ═══ *@
<div style="padding:16px;flex-shrink:0;">
<div style="display:flex;gap:8px;">
<div style="flex:1;display:flex;align-items:center;gap:8px;background:var(--pos-bg-elevated);
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"
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
</button>
</div>
</div>
@* ═══ SEARCH RESULTS / KẾT QUẢ TÌM KIẾM ═══ *@
<div style="flex:1;overflow-y:auto;padding:0 16px 16px;">
<div style="display:flex;flex-direction:column;gap:8px;">
@foreach (var customer in _customers)
{
<div style="display:flex;align-items:center;gap:14px;padding:14px 16px;
background:var(--pos-bg-elevated);border-radius:var(--pos-radius);
cursor:pointer;border:1px solid var(--pos-border-subtle);"
@onclick="@(() => NavigateTo("spa/customer-profile"))">
@* EN: Avatar / VI: Ảnh đại diện *@
<div style="width:48px;height:48px;border-radius:50%;background:@GetTierBg(customer.Tier);
display:flex;align-items:center;justify-content:center;flex-shrink:0;
font-size:18px;font-weight:700;color:@GetTierFg(customer.Tier);">
@customer.Name[..1]
</div>
@* EN: Customer info / VI: Thông tin khách *@
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:14px;font-weight:600;">@customer.Name</span>
<span style="font-size:11px;padding:2px 8px;border-radius:5px;font-weight:600;
background:@GetTierBg(customer.Tier);color:@GetTierFg(customer.Tier);">
@customer.Tier
</span>
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:2px;">
<i data-lucide="phone" style="width:11px;height:11px;display:inline;"></i> @customer.Phone
</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">
Lần ghé cuối: @customer.LastVisit • @customer.TotalVisits lần ghé
</div>
</div>
@* EN: Arrow / VI: Mũi tên *@
<i data-lucide="chevron-right" style="width:18px;height:18px;color:var(--pos-text-tertiary);flex-shrink:0;"></i>
</div>
}
</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);
border:2px dashed var(--pos-border-default);color:var(--pos-text-secondary);
cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;gap:8px;">
<i data-lucide="user-plus" style="width:18px;height:18px;"></i>
Tạo khách mới
</button>
</div>
</div>
</div>
@code {
private string _searchTerm = "Nguyễn";
// EN: Demo customers / VI: Khách hàng mẫu
private readonly List<CustomerInfo> _customers = new()
{
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),
};
private static string GetTierBg(string t) => t switch
{
"Silver" => "rgba(168,162,158,.2)", "Gold" => "rgba(245,158,11,.2)",
"Platinum" => "rgba(139,92,246,.2)", "Diamond" => "rgba(59,130,246,.2)",
_ => "rgba(255,255,255,.1)"
};
private static string GetTierFg(string t) => t switch
{
"Silver" => "#A8A29E", "Gold" => "#F59E0B",
"Platinum" => "#8B5CF6", "Diamond" => "#3B82F6", _ => "#ADADB0"
};
private record CustomerInfo(string Name, string Phone, string Tier, string LastVisit, int TotalVisits);
}

View File

@@ -0,0 +1,168 @@
@*
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.
*@
@page "/pos/spa/customer-profile"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;overflow:hidden;">
@* ═══ PROFILE PANEL (LEFT) / PANEL HỒ SƠ (TRÁI) ═══ *@
<div class="pos-product-panel" style="padding:16px;overflow-y:auto;">
@* EN: Header / VI: Tiêu đề *@
<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>
</div>
@* ═══ PROFILE CARD / THẺ HỒ SƠ ═══ *@
<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
</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>
<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
</span>
@* EN: Stats row / VI: Hàng thống kê *@
<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>
<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>
<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>
</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>
</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)
{
<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;">@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>
</div>
</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>
</div>
@* ═══ REWARDS PANEL (RIGHT) / PANEL PHẦN THƯỞNG (PHẢI) ═══ *@
<div class="pos-cart-panel">
<div class="pos-cart-header">
<span class="pos-cart-header__title">Phần thưởng</span>
</div>
<div class="pos-cart-items" style="padding:12px;">
@foreach (var reward in _rewards)
{
<div @onclick="() => _selectedReward = reward"
style="padding:14px;background:var(--pos-bg-interactive);border-radius:var(--pos-radius);
margin-bottom:8px;cursor:pointer;
border:2px solid @(_selectedReward?.Id == reward.Id ? "var(--pos-orange-primary)" : "transparent");">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:13px;font-weight:600;">@reward.Name</span>
<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>
}
</div>
<div class="pos-cart-footer">
@if (_selectedReward is not null)
{
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:8px;">
<span style="color:var(--pos-text-secondary);">@_selectedReward.Name</span>
<span style="color:#22C55E;">@_selectedReward.Value</span>
</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
</button>
</div>
</div>
</div>
@code {
private RewardInfo? _selectedReward;
// EN: Visit history / VI: Lịch sử ghé
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
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),
};
private record VisitInfo(string Services, string Date, string Therapist, decimal Amount, int Points);
private record RewardInfo(string Id, string Name, string Value, string Description, int PointCost);
}

View File

@@ -0,0 +1,142 @@
@*
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.
*@
@page "/pos/spa/service-combo"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
<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>
</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>
</div>
@* ═══ COMBO LIST / DANH SÁCH COMBO ═══ *@
<div style="display:flex;flex-direction:column;gap:12px;">
@foreach (var combo in _combos)
{
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;
border:1px solid @(_selectedCombo == combo.Id ? "var(--pos-orange-primary)" : "var(--pos-border-subtle)");">
<div style="display:flex;align-items:start;gap:14px;">
<div style="width:48px;height:48px;border-radius:12px;background:@combo.BgColor;
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="@combo.Icon" style="width:22px;height:22px;color:@combo.FgColor;"></i>
</div>
<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)
{
<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>
}
</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 *@
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px;">
@foreach (var svc in combo.Services)
{
<span style="font-size:11px;padding:4px 10px;border-radius:6px;
background:var(--pos-bg-interactive);color:var(--pos-text-secondary);">
@svc
</span>
}
</div>
@* EN: Price comparison / VI: So sánh giá *@
<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)
</span>
<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>
</div>
</div>
</div>
@* EN: Apply button / VI: Nút áp dụng *@
<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)");
color:@(_selectedCombo == combo.Id ? "#FFF" : "var(--pos-text-primary)");
cursor:pointer;font-size:13px;font-weight:600;"
@onclick="() => _selectedCombo = _selectedCombo == combo.Id ? null : combo.Id">
@if (_selectedCombo == combo.Id)
{
<i data-lucide="check" style="width:14px;height:14px;display:inline;"></i>
<span> Đã chọn</span>
}
else
{
<span>Áp dụng combo</span>
}
</button>
</div>
}
</div>
</div>
@* ═══ FOOTER / CHÂN 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>
</div>
}
</div>
@code {
private string? _selectedCombo;
// EN: Demo combos / VI: Combo mẫu
private readonly List<ComboInfo> _combos = 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" }),
};
private record ComboInfo(string Id, string Name, string Description, decimal OriginalPrice,
decimal ComboPrice, bool Limited, string Icon, string BgColor, string FgColor, List<string> Services);
}

View File

@@ -0,0 +1,143 @@
@*
EN: Spa Service Package — Package list with included services, price, savings, expand details, add to appointment.
VI: Gói dịch vụ Spa — Danh sách gói với dịch vụ bao gồm, giá, tiết kiệm, mở rộng chi tiết, thêm vào lịch hẹn.
*@
@page "/pos/spa/service-package"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
<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;">Gói dịch vụ</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);">@_packages.Count gói khả dụng</span>
</div>
@* ═══ PACKAGE LIST / DANH SÁCH GÓI ═══ *@
<div style="flex:1;overflow-y:auto;padding:16px;">
<div style="display:flex;flex-direction:column;gap:12px;">
@foreach (var pkg in _packages)
{
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);overflow:hidden;
border:1px solid @(_expandedId == pkg.Id ? "var(--pos-orange-primary)" : "var(--pos-border-subtle)");">
@* EN: Package header / VI: Tiêu đề gói *@
<div style="padding:16px;cursor:pointer;" @onclick="() => _expandedId = _expandedId == pkg.Id ? null : pkg.Id">
<div style="display:flex;align-items:center;gap:14px;">
<div style="width:52px;height:52px;border-radius:14px;background:@pkg.BgColor;
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="@pkg.Icon" style="width:24px;height:24px;color:@pkg.FgColor;"></i>
</div>
<div style="flex:1;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:16px;font-weight:700;">@pkg.Name</span>
@if (pkg.Popular)
{
<span style="font-size:10px;padding:2px 8px;border-radius:4px;font-weight:700;
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-top:2px;">
@pkg.ServiceCount dịch vụ • @pkg.TotalDuration phút
</div>
</div>
<div style="text-align:right;flex-shrink:0;">
<div style="font-size:18px;font-weight:700;color:var(--pos-orange-primary);">@FormatPrice(pkg.Price)</div>
<div style="font-size:12px;color:#22C55E;font-weight:600;">
Tiết kiệm @FormatPrice(pkg.OriginalPrice - pkg.Price)
</div>
</div>
<i data-lucide="@(_expandedId == pkg.Id ? "chevron-up" : "chevron-down")"
style="width:18px;height:18px;color:var(--pos-text-tertiary);flex-shrink:0;"></i>
</div>
</div>
@* EN: Expanded details / VI: Chi tiết mở rộng *@
@if (_expandedId == pkg.Id)
{
<div style="padding:0 16px 16px;border-top:1px solid var(--pos-border-subtle);">
<div style="font-size:12px;color:var(--pos-text-tertiary);padding:12px 0 8px;font-weight:600;">
DỊCH VỤ BAO GỒM
</div>
@foreach (var svc in pkg.Services)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;
font-size:13px;border-bottom:1px solid var(--pos-border-subtle);">
<div style="display:flex;align-items:center;gap:8px;">
<i data-lucide="check" style="width:14px;height:14px;color:#22C55E;"></i>
<span>@svc.Name</span>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-size:11px;color:var(--pos-text-tertiary);">@svc.Duration phút</span>
<span style="font-weight:500;">@FormatPrice(svc.Price)</span>
</div>
</div>
}
@* EN: Price comparison / VI: So sánh giá *@
<div style="display:flex;justify-content:space-between;font-size:13px;margin-top:12px;">
<span style="color:var(--pos-text-secondary);">Giá lẻ</span>
<span style="text-decoration:line-through;color:var(--pos-text-tertiary);">@FormatPrice(pkg.OriginalPrice)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:14px;font-weight:600;margin-top:4px;">
<span>Giá gói</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(pkg.Price)</span>
</div>
<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
</button>
</div>
}
</div>
}
</div>
</div>
</div>
@code {
private string? _expandedId = "PKG1";
// EN: Demo packages / VI: Gói dịch vụ mẫu
private readonly List<PackageInfo> _packages = new()
{
new("PKG1", "Gói Thư giãn", 900_000, 1_050_000, 3, 135, true, "leaf", "rgba(34,197,94,.15)", "#22C55E", new()
{
new("Massage toàn thân", 500_000, 60),
new("Facial cơ bản", 350_000, 45),
new("Gội đầu dưỡng sinh", 200_000, 30),
}),
new("PKG2", "Gói VIP", 1_800_000, 2_250_000, 5, 225, true, "crown", "rgba(245,158,11,.15)", "#F59E0B", new()
{
new("Massage toàn thân", 500_000, 60),
new("Facial collagen", 600_000, 60),
new("Tắm trắng toàn thân", 800_000, 90),
new("Sơn gel", 150_000, 30),
new("Gội đầu dưỡng sinh", 200_000, 30),
}),
new("PKG3", "Gói Cặp đôi", 1_500_000, 1_800_000, 4, 180, false, "heart", "rgba(239,68,68,.15)", "#EF4444", new()
{
new("Massage toàn thân x2", 1_000_000, 60),
new("Facial cơ bản x2", 700_000, 45),
new("Trà thảo mộc x2", 100_000, 15),
}),
new("PKG4", "Gói Làm đẹp", 1_200_000, 1_450_000, 4, 165, false, "sparkles", "rgba(139,92,246,.15)", "#8B5CF6", new()
{
new("Facial collagen", 600_000, 60),
new("Tẩy tế bào chết", 400_000, 45),
new("Sơn gel", 150_000, 30),
new("Ủ tóc phục hồi", 300_000, 30),
}),
};
private record PackageService(string Name, decimal Price, int Duration);
private record PackageInfo(string Id, string Name, decimal Price, decimal OriginalPrice, int ServiceCount,
int TotalDuration, bool Popular, string Icon, string BgColor, string FgColor, List<PackageService> Services);
}

View File

@@ -0,0 +1,309 @@
@*
EN: Spa Journey Tracker — Horizontal step tracker: Check-in → Dịch vụ → Thực hiện → Thanh toán → Hoàn tất.
VI: Theo dõi hành trình Spa — Thanh bước ngang: Check-in → Dịch vụ → Thực hiện → Thanh toán → Hoàn tất.
*@
@page "/pos/spa/spa-journey"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
<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;">Hành trình Spa</span>
<span style="flex:1;"></span>
<span style="font-size:12px;padding:4px 12px;border-radius:6px;font-weight:600;
background:rgba(245,158,11,.15);color:#F59E0B;">
<i data-lucide="crown" style="width:12px;height:12px;display:inline;"></i> Gold
</span>
</div>
@* ═══ STEP TRACKER / THANH BƯỚC ═══ *@
<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++)
{
var step = _steps[i];
var stepIdx = i;
var isActive = stepIdx == _currentStep;
var isCompleted = stepIdx < _currentStep;
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 *@
<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;
font-size:14px;font-weight:700;cursor:pointer;"
@onclick="() => _currentStep = stepIdx">
@if (isCompleted)
{
<i data-lucide="check" style="width:18px;height:18px;"></i>
}
else
{
<span>@(stepIdx + 1)</span>
}
</div>
<div style="font-size:11px;margin-top:6px;font-weight:@(isActive ? "700" : "500");
color:@(isActive ? "var(--pos-orange-primary)" : isCompleted ? "var(--pos-success)" : "var(--pos-text-tertiary)");">
@step.Label
</div>
</div>
@* EN: Connector line / VI: Đường nối *@
@if (stepIdx < _steps.Count - 1)
{
<div style="flex:1;height:2px;max-width:80px;margin:0 8px;margin-bottom:20px;
background:@(stepIdx < _currentStep ? "var(--pos-success)" : "var(--pos-border-default)");"></div>
}
}
</div>
</div>
@* ═══ STEP CONTENT / NỘI DUNG BƯỚC ═══ *@
<div style="flex:1;overflow-y:auto;padding:0 16px 16px;">
@* EN: Customer info card / VI: Thẻ thông tin khách *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;margin-bottom:16px;
display:flex;align-items:center;gap:14px;">
<div style="width:48px;height:48px;border-radius:50%;background:var(--pos-orange-primary);display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:700;color:#fff;flex-shrink:0;">
M
</div>
<div style="flex:1;">
<div style="font-size:15px;font-weight:700;">Nguyễn Thị Mai</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">0901234567 • Gold • 2,450 điểm</div>
</div>
<div style="text-align:right;">
<div style="font-size:11px;color:var(--pos-text-tertiary);">Mã đơn</div>
<div style="font-size:13px;font-weight:600;font-family:monospace;">#SP-20250220-001</div>
</div>
</div>
@* EN: Step-specific content / VI: Nội dung theo bước *@
@switch (_currentStep)
{
case 0:
@* ═══ CHECK-IN STEP / BƯỚC CHECK-IN ═══ *@
<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="log-in" style="width:18px;height:18px;display:inline;"></i> Check-in
</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);">Giờ hẹn</span>
<span style="font-weight:600;">14:00</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);">Giờ đến</span>
<span style="font-weight:600;color:var(--pos-success);">13:55 (sớm 5 phút)</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);">Phòng chờ</span>
<span style="font-weight:600;">Phòng chờ VIP</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);">Nước uống</span>
<span style="font-weight:600;">Trà thảo mộc (đã phục vụ)</span>
</div>
</div>
</div>
break;
case 1:
@* ═══ SERVICE STEP / BƯỚC DỊCH VỤ ═══ *@
<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="list-checks" style="width:18px;height:18px;display:inline;"></i> Dịch vụ đã chọn
</div>
@foreach (var svc in _bookedServices)
{
<div style="display:flex;align-items:center;gap:12px;padding:12px;
background:var(--pos-bg-interactive);border-radius:8px;margin-bottom:8px;">
<div style="width:40px;height:40px;border-radius:10px;background:@GetServiceBg(svc.Type);
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="@GetServiceIcon(svc.Type)" style="width:18px;height:18px;color:@GetServiceFg(svc.Type);"></i>
</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;">@svc.Name</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">@svc.Duration phút • KTV: @svc.Therapist</div>
</div>
<span style="font-size:14px;font-weight:600;">@FormatPrice(svc.Price)</span>
</div>
}
</div>
break;
case 2:
@* ═══ IN-PROGRESS STEP / BƯỚC THỰC HIỆN ═══ *@
<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="activity" style="width:18px;height:18px;display:inline;"></i> Đang thực hiện
</div>
@foreach (var svc in _bookedServices)
{
var statusColor = svc.Status == "done" ? "var(--pos-success)"
: svc.Status == "active" ? "var(--pos-orange-primary)" : "var(--pos-text-tertiary)";
var statusLabel = svc.Status == "done" ? "Hoàn thành"
: svc.Status == "active" ? "Đang thực hiện" : "Chờ";
<div style="display:flex;align-items:center;gap:12px;padding:12px;
background:var(--pos-bg-interactive);border-radius:8px;margin-bottom:8px;
border-left:3px solid @statusColor;">
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;">@svc.Name</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">KTV: @svc.Therapist</div>
</div>
<span style="font-size:12px;font-weight:600;color:@statusColor;">@statusLabel</span>
</div>
}
</div>
break;
case 3:
@* ═══ PAYMENT STEP / BƯỚC THANH TOÁN ═══ *@
<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
</div>
<div style="display:flex;flex-direction:column;gap:8px;">
@foreach (var svc in _bookedServices)
{
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span>@svc.Name</span>
<span style="font-weight:600;">@FormatPrice(svc.Price)</span>
</div>
}
<div style="border-top:1px solid var(--pos-border-subtle);padding-top:10px;margin-top:4px;">
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Tạm tính</span>
<span>@FormatPrice(_bookedServices.Sum(s => s.Price))</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:#22C55E;">Giảm giá Gold (-10%)</span>
<span style="color:#22C55E;">-@FormatPrice(_bookedServices.Sum(s => s.Price) * 0.1m)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:16px;font-weight:700;padding-top:8px;border-top:1px solid var(--pos-border-subtle);">
<span>Tổng thanh toán</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(_bookedServices.Sum(s => s.Price) * 0.9m)</span>
</div>
</div>
</div>
@* EN: Payment methods / VI: Phương thức thanh toán *@
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:16px;">
<button style="padding:14px;border-radius:var(--pos-radius);background:var(--pos-bg-interactive);
border:1px solid var(--pos-border-default);color:var(--pos-text-primary);cursor:pointer;text-align:center;font-size:12px;">
<i data-lucide="banknote" style="width:20px;height:20px;display:block;margin:0 auto 4px;"></i>
Tiền mặt
</button>
<button style="padding:14px;border-radius:var(--pos-radius);background:rgba(59,130,246,.1);
border:1px solid rgba(59,130,246,.3);color:#3B82F6;cursor:pointer;text-align:center;font-size:12px;">
<i data-lucide="credit-card" style="width:20px;height:20px;display:block;margin:0 auto 4px;"></i>
Thẻ
</button>
<button style="padding:14px;border-radius:var(--pos-radius);background:rgba(34,197,94,.1);
border:1px solid rgba(34,197,94,.3);color:#22C55E;cursor:pointer;text-align:center;font-size:12px;">
<i data-lucide="smartphone" style="width:20px;height:20px;display:block;margin:0 auto 4px;"></i>
Chuyển khoản
</button>
</div>
</div>
break;
case 4:
@* ═══ COMPLETE STEP / BƯỚC HOÀN TẤT ═══ *@
<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:14px;color:var(--pos-text-secondary);margin-bottom:4px;">
Cảm ơn quý khách Nguyễn Thị Mai đã sử dụng dịch vụ
</div>
<div style="font-size:13px;color:var(--pos-text-tertiary);margin-bottom:20px;">
+99 điểm tích lũy • Tổng: 2,549 điểm
</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
</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á
</button>
</div>
</div>
break;
}
</div>
@* ═══ FOOTER ACTIONS / NÚT HÀNH ĐỘNG ═══ *@
<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
</button>
}
<span style="flex:1;"></span>
@if (_currentStep < _steps.Count - 1)
{
<button class="pos-btn-checkout" style="width:auto;padding:12px 24px;" @onclick="() => _currentStep++">
@_steps[_currentStep].Action <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("spa"))">
<i data-lucide="home" style="width:14px;height:14px;display:inline;"></i> Về trang chính
</button>
}
</div>
</div>
@code {
private int _currentStep = 2;
// EN: Journey steps / VI: Các bước hành trình
private readonly List<StepInfo> _steps = new()
{
new("Check-in", "check-in", "Tiếp tục"),
new("Dịch vụ", "service", "Bắt đầu"),
new("Thực hiện", "progress", "Thanh toán"),
new("Thanh toán", "payment", "Hoàn tất"),
new("Hoàn tất", "complete", "Xong"),
};
// EN: Booked services / VI: Dịch vụ đã đặt
private readonly List<BookedService> _bookedServices = new()
{
new("Massage toàn thân", 500_000, 60, "Trần Thị Hoa", "Massage", "done"),
new("Facial collagen", 600_000, 60, "Nguyễn Minh Tú", "Facial", "active"),
};
private static string GetServiceBg(string type) => type switch
{
"Massage" => "rgba(255,92,0,.15)", "Facial" => "rgba(139,92,246,.15)",
"Body" => "rgba(34,197,94,.15)", _ => "rgba(59,130,246,.15)"
};
private static string GetServiceFg(string type) => type switch
{
"Massage" => "#FF5C00", "Facial" => "#8B5CF6", "Body" => "#22C55E", _ => "#3B82F6"
};
private static string GetServiceIcon(string type) => type switch
{
"Massage" => "hand", "Facial" => "sparkles", "Body" => "bath", _ => "scissors"
};
private record StepInfo(string Label, string Id, string Action);
private record BookedService(string Name, decimal Price, int Duration, string Therapist, string Type, string Status);
}

View File

@@ -0,0 +1,162 @@
@*
EN: Spa Staff Assignment — Staff list with photo, name, specialization, rating, availability, skills tags.
VI: Phân công nhân viên Spa — Danh sách nhân viên với ảnh, tên, chuyên môn, đánh giá, tình trạng, thẻ kỹ năng.
*@
@page "/pos/spa/staff-assign"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
<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;">Chọn nhân viên / Kỹ thuật viên</span>
</div>
@* ═══ FILTER TABS / TAB LỌC ═══ *@
<div style="padding:10px 16px;border-bottom:1px solid var(--pos-border-subtle);display:flex;gap:6px;flex-shrink:0;">
@foreach (var filter in _filters)
{
<button style="padding:6px 14px;border-radius:8px;font-size:12px;cursor:pointer;border:none;
background:@(filter == _activeFilter ? "var(--pos-orange-primary)" : "var(--pos-bg-interactive)");
color:@(filter == _activeFilter ? "#FFF" : "var(--pos-text-secondary)");"
@onclick="() => _activeFilter = filter">
@filter
</button>
}
</div>
@* ═══ STAFF LIST / DANH SÁCH NHÂN VIÊN ═══ *@
<div style="flex:1;overflow-y:auto;padding:16px;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px;">
@foreach (var staff in FilteredStaff)
{
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;
display:flex;gap:14px;border:2px solid @(_selectedStaff == staff.Id ? "var(--pos-orange-primary)" : "var(--pos-border-subtle)");
cursor:pointer;"
@onclick="() => _selectedStaff = staff.Id">
@* EN: Photo placeholder / VI: Ảnh đại diện *@
<div style="position:relative;flex-shrink:0;">
<div style="width:56px;height:56px;border-radius:50%;background:var(--pos-bg-interactive);
display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:700;">
@staff.Name[..1]
</div>
<div style="position:absolute;bottom:-2px;right:-2px;width:14px;height:14px;border-radius:50%;
background:@GetStatusColor(staff.Status);border:2px solid var(--pos-bg-elevated);"></div>
</div>
<div style="flex:1;min-width:0;">
@* EN: Name + rating / VI: Tên + đánh giá *@
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<span style="font-size:15px;font-weight:600;">@staff.Name</span>
<span style="font-size:12px;padding:2px 8px;border-radius:5px;font-weight:600;
background:@GetStatusBg(staff.Status);color:@GetStatusColor(staff.Status);">
@GetStatusLabel(staff.Status)
</span>
</div>
@* EN: Specialization / VI: Chuyên môn *@
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:6px;">
@staff.Specialization
</div>
@* EN: Rating stars / VI: Sao đánh giá *@
<div style="display:flex;align-items:center;gap:4px;margin-bottom:8px;">
@for (int i = 1; i <= 5; i++)
{
var starIdx = i;
<i data-lucide="star" style="width:14px;height:14px;color:@(starIdx <= staff.Rating ? "#F59E0B" : "var(--pos-border-default)");
fill:@(starIdx <= staff.Rating ? "#F59E0B" : "none");"></i>
}
<span style="font-size:11px;color:var(--pos-text-tertiary);margin-left:4px;">(@staff.ReviewCount)</span>
</div>
@* EN: Skill tags / VI: Thẻ kỹ năng *@
<div style="display:flex;flex-wrap:wrap;gap:4px;">
@foreach (var skill in staff.Skills)
{
<span style="font-size:10px;padding:3px 8px;border-radius:4px;
background:var(--pos-bg-interactive);color:var(--pos-text-secondary);">
@skill
</span>
}
</div>
</div>
@* EN: Select indicator / VI: Chỉ báo chọn *@
@if (_selectedStaff == staff.Id)
{
<div style="width:28px;height:28px;border-radius:50%;background:var(--pos-orange-primary);
display:flex;align-items:center;justify-content:center;flex-shrink:0;align-self:center;">
<i data-lucide="check" style="width:16px;height:16px;color:#FFF;"></i>
</div>
}
</div>
}
</div>
</div>
@* ═══ FOOTER / CHÂN TRANG ═══ *@
@if (_selectedStaff 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/appointment-book"))">
<i data-lucide="check" style="width:18px;height:18px;"></i> Xác nhận chọn nhân viên
</button>
</div>
}
</div>
@code {
private string _activeFilter = "Tất cả";
private string? _selectedStaff = "S01";
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()
{
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" }),
};
private IEnumerable<StaffInfo> FilteredStaff => _activeFilter switch
{
"Rảnh" => _staff.Where(s => s.Status == "available"),
"Đang bận" => _staff.Where(s => s.Status == "busy"),
"Nghỉ giải lao" => _staff.Where(s => s.Status == "break"),
_ => _staff
};
private static string GetStatusColor(string s) => s switch
{
"available" => "#22C55E", "busy" => "var(--pos-orange-primary)", "break" => "#F59E0B", _ => "#ADADB0"
};
private static string GetStatusBg(string s) => s switch
{
"available" => "rgba(34,197,94,.15)", "busy" => "rgba(255,92,0,.15)",
"break" => "rgba(245,158,11,.15)", _ => "var(--pos-bg-interactive)"
};
private static string GetStatusLabel(string s) => s switch
{
"available" => "Rảnh", "busy" => "Đang bận", "break" => "Nghỉ", _ => s
};
private record StaffInfo(string Id, string Name, string Specialization, string Status,
int Rating, int ReviewCount, List<string> Skills);
}

View File

@@ -0,0 +1,175 @@
@*
EN: Therapist Schedule — Calendar day view with horizontal timeline, staff rows, appointment blocks, current time.
VI: Lịch kỹ thuật viên — Xem theo ngày với timeline ngang, hàng nhân viên, khối lịch hẹn, thời gian hiện tại.
*@
@page "/pos/spa/therapist-schedule"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
<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("spa"))">
<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>
</div>
@* EN: Legend / VI: Chú thích *@
<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(255,92,0,.3);"></span> Massage
</span>
<span style="display:flex;align-items:center;gap:4px;">
<span style="width:10px;height:10px;border-radius:3px;background:rgba(139,92,246,.3);"></span> Facial
</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> Body
</span>
<span style="display:flex;align-items:center;gap:4px;">
<span style="width:10px;height:10px;border-radius:3px;background:rgba(59,130,246,.3);"></span> Nail/Hair
</span>
</div>
</div>
@* ═══ SCHEDULE GRID / LƯỚI LỊCH ═══ *@
<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);">
<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);">
Nhân viên
</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: Staff rows / VI: Hàng nhân viên *@
@foreach (var staff in _scheduleData)
{
<div style="display:flex;border-bottom:1px solid var(--pos-border-subtle);min-height:64px;">
@* EN: Staff name column / VI: Cột tên nhân viên *@
<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:var(--pos-bg-interactive);
display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex-shrink:0;">
@staff.Name[..1]
</div>
<div>
<div style="font-size:12px;font-weight:600;">@staff.Name</div>
<div style="font-size:10px;color:var(--pos-text-tertiary);">@staff.Role</div>
</div>
</div>
@* EN: Timeline with blocks / VI: Timeline với khối lịch *@
<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: Khối lịch hẹn *@
@foreach (var appt in staff.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("spa/treatment-timer"))">
<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: Chỉ báo thời gian hiện tại *@
<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:var(--pos-danger);z-index:5;">
<div style="position:absolute;top:-4px;left:-4px;width:10px;height:10px;border-radius:50%;background:var(--pos-danger);"></div>
</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>
</div>
</div>
@code {
// EN: Hours range / VI: Phạm vi giờ
private readonly int[] _hours = { 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 };
// EN: Demo schedule / VI: Lịch mẫu
private readonly List<StaffSchedule> _scheduleData = new()
{
new("Trần Thị Hoa", "Massage", new()
{
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()
{
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()
{
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),
}),
};
private static string GetServiceBg(string type) => type switch
{
"Massage" => "rgba(255,92,0,.15)", "Facial" => "rgba(139,92,246,.15)",
"Body" => "rgba(34,197,94,.15)", _ => "rgba(59,130,246,.15)"
};
private static string GetServiceColor(string type) => type switch
{
"Massage" => "#FF5C00", "Facial" => "#8B5CF6",
"Body" => "#22C55E", _ => "#3B82F6"
};
private record AppointmentBlock(string CustomerName, string Service, string Type, int StartHour, int StartMin, int DurationMin);
private record StaffSchedule(string Name, string Role, List<AppointmentBlock> Appointments);
}

View File

@@ -0,0 +1,176 @@
@*
EN: Spa Treatment Timer — Large circular countdown, service info, customer/therapist, extend time, complete, notes.
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/spa/treatment-timer"
@layout PosLayout
@inherits PosBase
<div style="flex:1;display:flex;overflow:hidden;">
@* ═══ TIMER PANEL (LEFT) / PANEL ĐỒNG HỒ (TRÁI) ═══ *@
<div class="pos-product-panel" style="padding:16px;overflow-y:auto;">
@* EN: Header / VI: Tiêu đề *@
<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/therapist-schedule"))">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;">Đang thực hiện dịch vụ</span>
</div>
@* ═══ CIRCULAR TIMER / ĐỒNG HỒ TRÒN ═══ *@
<div style="display:flex;justify-content:center;margin-bottom:24px;">
<div style="position:relative;width:240px;height:240px;">
@* EN: Background circle / VI: Vòng tròn nền *@
<svg viewBox="0 0 240 240" style="width:100%;height:100%;transform:rotate(-90deg);">
<circle cx="120" cy="120" r="108" fill="none" stroke="var(--pos-bg-interactive)" stroke-width="8" />
<circle cx="120" cy="120" r="108" fill="none" stroke="var(--pos-orange-primary)" stroke-width="8"
stroke-dasharray="@(2 * Math.PI * 108)" stroke-dashoffset="@(2 * Math.PI * 108 * (1 - _progress))"
stroke-linecap="round" />
</svg>
@* EN: Timer text / VI: Chữ đồng hồ *@
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;">
<div style="font-size:11px;color:var(--pos-text-tertiary);font-weight:600;text-transform:uppercase;">CÒN LẠI</div>
<div style="font-size:44px;font-weight:700;color:var(--pos-orange-primary);font-variant-numeric:tabular-nums;margin:4px 0;">
@_remainingTime
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">/ @_totalDuration phút</div>
</div>
</div>
</div>
@* 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>
</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>
</div>
</div>
@* ═══ 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="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>
</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>
</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>
</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>
</div>
</div>
</div>
@* ═══ EXTEND TIME / GIA HẠN THỜI GIAN ═══ *@
<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;">Gia hạn thêm</div>
<div style="display:flex;gap:8px;">
@foreach (var ext in _extendOptions)
{
<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;text-align:center;"
@onclick="() => ExtendTime(ext)">
<div style="font-weight:600;">+@ext phút</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">
+@FormatPrice(ext == 15 ? 125_000 : ext == 30 ? 250_000 : 375_000)
</div>
</button>
}
</div>
</div>
@* ═══ NOTES / GHI CHÚ ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Ghi chú</div>
<textarea @bind="_notes" placeholder="Ghi chú cho buổi trị liệu..."
style="width:100%;min-height:80px;padding:12px;border-radius:8px;background:var(--pos-bg-interactive);
border:1px solid var(--pos-border-default);color:var(--pos-text-primary);font-size:13px;
resize:vertical;outline:none;font-family:inherit;"></textarea>
</div>
</div>
@* ═══ ACTIONS PANEL (RIGHT) / PANEL HÀNH ĐỘNG (PHẢI) ═══ *@
<div class="pos-cart-panel">
<div class="pos-cart-header">
<span class="pos-cart-header__title">Trạng thái</span>
</div>
<div class="pos-cart-items" style="padding:16px;">
@* EN: Status indicator / VI: Chỉ báo trạng thái *@
<div style="text-align:center;padding:20px 0;margin-bottom:16px;">
<div style="width:64px;height:64px;border-radius:50%;background:rgba(255,92,0,.15);
display:flex;align-items:center;justify-content:center;margin:0 auto 12px;">
<i data-lucide="activity" style="width:28px;height:28px;color:var(--pos-orange-primary);"></i>
</div>
<div style="font-size:16px;font-weight:700;color:var(--pos-orange-primary);">Đang thực hiện</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:4px;">Phòng 3 • Giường 2</div>
</div>
@* EN: Session details / VI: Chi tiết phiên *@
<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>
</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>
</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>
<span style="font-weight:600;">@_extendedMinutes phút</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;padding:8px 0;">
<span style="color:var(--pos-text-secondary);">Dịch vụ tiếp theo</span>
<span style="font-weight:600;">Facial collagen</span>
</div>
</div>
</div>
<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>
</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>
</div>
</div>
</div>
@code {
private string _remainingTime = "45:00";
private int _totalDuration = 60;
private double _progress = 0.25;
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 readonly int[] _extendOptions = { 15, 30, 45 };
private void ExtendTime(int minutes)
{
_extendedMinutes += minutes;
_totalDuration += minutes;
}
private void CompleteTreatment() => NavigateTo("spa/spa-journey");
}