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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user