feat: Phase 2 frontend — Spa, Retail, Cafe Blazor UI pages and BFF proxies
Spa/Beauty UI (booking-service integration): - TherapistManagement.razor: CRUD table, specialty multi-select, working hours - AppointmentCalendar.razor: daily calendar grouped by therapist, color-coded statuses - ShopTherapists embedded component for ShopPage, sidebar menu for spa/beauty - BookingController BFF: therapist CRUD + appointment proxy endpoints - Localization: vi-VN + en-US for "Nhân viên trị liệu" Retail POS UI (catalog + inventory + order integration): - RetailDesktop.razor: barcode input, API lookup, stock badges, cart warnings - ReturnDialog.razor: order lookup, return/exchange mode toggle, refund summary - StockOverview.razor: admin stock table, search/filter, threshold edit dialog - PosDataService: barcode lookup, bulk stock, return/exchange API methods Cafe UI (membership + fnb-engine integration): - StampCard.razor: visual stamp grid, animated fill, celebration UI, claim/reset - BaristaQueue.razor: 3-column Kanban, stats bar, auto-refresh 10s, pulse animation - CafeController BFF: stamp cards + barista queue proxy endpoints Infrastructure: - Traefik: added /api/v1/therapists + /api/v1/appointments to booking-service - ROADMAP: Phase 2 vertical tasks DONE, UI refinement IN-PROGRESS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
36
ROADMAP.md
36
ROADMAP.md
@@ -12,7 +12,7 @@
|
||||
|--------|:-------:|:--------------:|:--------------:|:--------------:|
|
||||
| Services production-ready | 8/24 | 12/24 | 16/24 | 20/24 |
|
||||
| Test coverage (estimated) | ~50% | 70% | 80% | 85% |
|
||||
| POS verticals fully working | 2/5 | 2/5 (stable) | 4/5 | 5/5 |
|
||||
| POS verticals fully working | 2/5 | 2/5 (stable) | 5/5 | 5/5 |
|
||||
| Payment methods live | 0 | 2 | 3 | 4+ |
|
||||
| Real-time features | 0 | KDS + Orders | Full POS | Full |
|
||||
| Mobile apps | Template | Template | iOS v1 | iOS + Android |
|
||||
@@ -42,7 +42,7 @@
|
||||
| fnb-engine | Controllers OK | Kitchen/Reservation/Session/Table controllers | Handler logic, 0 tests | P0 |
|
||||
| catalog-service | Basic CRUD | Products, Categories controllers | Variants, advanced queries | P1 |
|
||||
| inventory-service | Basic CRUD | Create/Update/Delete items | Auto-deduction, alerts, tests | P0 |
|
||||
| booking-service | 7 controllers | API structure, entities defined | Handler completion | P1 |
|
||||
| booking-service | 7 controllers | API structure, entities defined, handler logic complete, therapist + appointment CRUD | Handler completion | P1 |
|
||||
| social-service | Core domain | Relationships, Blocks, Follows | API integration | P2 |
|
||||
| mining-service | Skeleton | Controllers + entities defined | Business logic | P3 |
|
||||
| mission-service | Controllers exist | Missions, CheckIns | Workflow handlers | P2 |
|
||||
@@ -79,9 +79,9 @@
|
||||
|----------|:-------:|:--------:|:----------:|:-------:|:------:|
|
||||
| Karaoke | DONE | DONE | DONE | UI-ONLY | WORKING |
|
||||
| Restaurant | DONE | DONE | DONE | UI-ONLY | WORKING |
|
||||
| Cafe | DONE | DONE | IN-PROGRESS | UI-ONLY | PARTIAL |
|
||||
| Spa/Beauty | IN-PROGRESS | DONE | PARTIAL | UI-ONLY | PARTIAL |
|
||||
| Retail | IN-PROGRESS | UI-ONLY | TODO | UI-ONLY | TODO |
|
||||
| Cafe | DONE | DONE | DONE | UI-ONLY | PARTIAL |
|
||||
| Spa/Beauty | DONE | DONE | DONE | UI-ONLY | PARTIAL |
|
||||
| Retail | DONE | UI-ONLY | DONE | UI-ONLY | TODO |
|
||||
|
||||
---
|
||||
|
||||
@@ -104,11 +104,11 @@
|
||||
|
||||
| # | Gap | Status | Owner | Sprint | Notes |
|
||||
|:-:|-----|:------:|-------|:------:|-------|
|
||||
| 9 | Retail POS Workflow | `TODO` | Backend + Frontend | Phase 2 / W5-6 | Scan, stock, return/exchange |
|
||||
| 10 | Spa Backend Domain Logic | `TODO` | Backend | Phase 2 / W5-6 | Appointments, therapist scheduling |
|
||||
| 9 | Retail POS Workflow | `DONE` | Backend + Frontend | Phase 2 / W5-6 | Scan, stock, return/exchange |
|
||||
| 10 | Spa Backend Domain Logic | `DONE` | Backend | Phase 2 / W5-6 | Appointments, therapist scheduling |
|
||||
| 11 | EOD Reports + Daily Close | `TODO` | Frontend Blazor | Phase 1 / W4 | order-service queries |
|
||||
| 12 | FnB Engine Test Coverage | `DONE` | QA Engineer | Phase 1 / W3 | 96 tests (57 domain + 39 handler) |
|
||||
| 13 | Cafe Workflow Completion | `TODO` | Backend + Frontend | Phase 2 / W5-6 | Loyalty stamps, barista queue |
|
||||
| 13 | Cafe Workflow Completion | `DONE` | Backend + Frontend | Phase 2 / W5-6 | Loyalty stamps, barista queue |
|
||||
| 14 | Critical Path Unit Tests (inventory, payment, events) | `DONE` | QA Engineer | Phase 1 / W4 | Deduction, payment callback, domain event handlers |
|
||||
|
||||
### P2 — Enhancement
|
||||
@@ -171,10 +171,10 @@
|
||||
|
||||
| Task | Agent | Status | Depends On |
|
||||
|------|-------|:------:|:----------:|
|
||||
| Spa domain logic (appointments, therapists) | Senior Backend | `IN-PROGRESS` | booking-service |
|
||||
| Retail POS workflow (scan, stock, returns) | Senior Backend | `IN-PROGRESS` | catalog, inventory |
|
||||
| Cafe-specific (loyalty stamps, barista queue) | Senior Backend | `IN-PROGRESS` | membership |
|
||||
| Vertical-specific UI refinement | Senior Frontend | `TODO` | Backend done |
|
||||
| Spa domain logic (appointments, therapists) | Senior Backend | `DONE` | booking-service |
|
||||
| Retail POS workflow (scan, stock, returns) | Senior Backend | `DONE` | catalog, inventory |
|
||||
| Cafe-specific (loyalty stamps, barista queue) | Senior Backend | `DONE` | membership |
|
||||
| Vertical-specific UI refinement | Senior Frontend | `IN-PROGRESS` | Backend done |
|
||||
| Multi-branch management features | Senior Backend | `TODO` | merchant-service |
|
||||
|
||||
#### Week 7-8: Advanced Features
|
||||
@@ -236,6 +236,18 @@
|
||||
| Traefik Route (subscriptions) | DevOps | /api/v1/subscriptions → merchant-service |
|
||||
| Admin Settings 5-Tab UI | Frontend | Tai khoan, Bao mat, Goi dich vu, Thong bao, He thong |
|
||||
|
||||
### 2026-03-06 (Phase 2 Sprint 1 — Multi-Vertical)
|
||||
|
||||
| Task | Agent | Details |
|
||||
|------|-------|---------|
|
||||
| Spa Therapist Management | Backend | Therapist aggregate (specialties text[], workingHours jsonb), CRUD, 9 validators |
|
||||
| Spa Appointment Scheduling | Backend | Appointment notes, Pending status, MarkNoShow, availability slots |
|
||||
| Retail Barcode/SKU | Backend | Product barcode field, lookup query, POS scanner endpoint |
|
||||
| Retail Stock Check | Backend | Bulk stock levels, low stock alerts, SetReorderLevel behavior |
|
||||
| Retail Return/Exchange | Backend | ProcessReturn, CreateReturn/Exchange commands, Returned status, 2 domain events |
|
||||
| Cafe Loyalty Stamps | Backend | StampCard aggregate, auto-create, AddStamp/ClaimReward/Reset, 4 domain events |
|
||||
| Cafe Barista Queue | Backend | BaristaQueueItem (5-status workflow), queue stats, 5 commands |
|
||||
|
||||
### 2026-03-06 (Code Review Fixes)
|
||||
|
||||
| Task | Agent | Details |
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
<ShopAppointments ShopId="@(_shopGuid ?? Guid.Empty)" />
|
||||
break;
|
||||
|
||||
case "therapists":
|
||||
<ShopTherapists ShopId="@(_shopGuid ?? Guid.Empty)" />
|
||||
break;
|
||||
|
||||
case "kitchen":
|
||||
<ShopKitchen ShopId="@(_shopGuid ?? Guid.Empty)" />
|
||||
break;
|
||||
@@ -473,6 +477,7 @@
|
||||
case "kitchen": _sectionTitle = "Bếp (Kitchen)"; _sectionIcon = "flame"; _sectionDescription = "Màn hình hiển thị đơn cho bếp."; break;
|
||||
case "rooms": _sectionTitle = "Phòng"; _sectionIcon = "door-open"; _sectionDescription = "Quản lý phòng karaoke."; break;
|
||||
case "appointments": _sectionTitle = "Lịch hẹn"; _sectionIcon = "calendar"; _sectionDescription = "Quản lý lịch hẹn khách hàng."; break;
|
||||
case "therapists": _sectionTitle = "Nhân viên trị liệu"; _sectionIcon = "user-check"; _sectionDescription = "Quản lý nhân viên trị liệu, chuyên môn và trạng thái."; break;
|
||||
case "services": _sectionTitle = "Dịch vụ"; _sectionIcon = "sparkles"; _sectionDescription = "Quản lý danh mục dịch vụ."; break;
|
||||
case "resources": _sectionTitle = "Tài nguyên"; _sectionIcon = "door-open"; _sectionDescription = "Quản lý phòng, giường, thiết bị."; break;
|
||||
case "treatments": _sectionTitle = "Liệu trình"; _sectionIcon = "clipboard-list"; _sectionDescription = "Theo dõi liệu trình điều trị."; break;
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
@*
|
||||
EN: Therapist management component — CRUD for spa/beauty therapists embedded in ShopPage.
|
||||
VI: Component quan ly nhan vien tri lieu — CRUD cho nhan vien spa/beauty nhung trong ShopPage.
|
||||
*@
|
||||
@using WebClientTpos.Client.Services
|
||||
@using MudBlazor
|
||||
@inject PosDataService DataService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@* ═══ TOP ACTIONS ═══ *@
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<div style="position:relative;">
|
||||
<i data-lucide="search" style="position:absolute;left:12px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
|
||||
<input type="text" @bind="_searchQuery" @bind:event="oninput"
|
||||
placeholder="Tim theo ten..."
|
||||
style="padding:8px 12px 8px 36px;border-radius:10px;border:1px solid var(--admin-border);background:var(--admin-bg-interactive);color:var(--admin-text-primary);font-size:13px;width:220px;outline:none;" />
|
||||
</div>
|
||||
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="OpenCreateDialog">
|
||||
<i data-lucide="user-plus" style="width:16px;height:16px;"></i>Them nhan vien
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* ═══ TABLE ═══ *@
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="display:flex;justify-content:center;padding:48px;">
|
||||
<MudProgressCircular Size="Size.Large" Indeterminate="true" Color="Color.Primary" />
|
||||
</div>
|
||||
}
|
||||
else if (!FilteredTherapists.Any())
|
||||
{
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__body" style="text-align:center;padding:40px 20px;">
|
||||
<i data-lucide="user-check" style="width:48px;height:48px;color:var(--admin-text-tertiary);margin-bottom:12px;display:inline-block;"></i>
|
||||
<p style="color:var(--admin-text-tertiary);font-size:14px;">
|
||||
@if (string.IsNullOrWhiteSpace(_searchQuery))
|
||||
{
|
||||
<span>Chua co nhan vien nao. Nhan "Them nhan vien" de bat dau.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Khong tim thay nhan vien nao phu hop.</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__body" style="padding:0;">
|
||||
<MudTable Items="@FilteredTherapists"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Elevation="0"
|
||||
Class="mud-table-dark">
|
||||
<HeaderContent>
|
||||
<MudTh Style="font-weight:700;">Ten</MudTh>
|
||||
<MudTh Style="font-weight:700;">Chuyen mon</MudTh>
|
||||
<MudTh Style="font-weight:700;">Lien he</MudTh>
|
||||
<MudTh Style="font-weight:700;">Trang thai</MudTh>
|
||||
<MudTh Style="font-weight:700;text-align:right;">Thao tac</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg, #FF5C00 0%, #FF8A3D 100%);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff;flex-shrink:0;">
|
||||
@GetInitials(context.Name)
|
||||
</div>
|
||||
<span style="font-weight:600;font-size:13px;">@context.Name</span>
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px;">
|
||||
@if (context.Specialties != null)
|
||||
{
|
||||
@foreach (var s in context.Specialties)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Variant="Variant.Outlined" Style="font-size:10px;">@s</MudChip>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div style="font-size:12px;color:var(--admin-text-secondary);">
|
||||
@if (!string.IsNullOrEmpty(context.Phone)) { <div>@context.Phone</div> }
|
||||
@if (!string.IsNullOrEmpty(context.Email)) { <div>@context.Email</div> }
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
@if (context.IsActive)
|
||||
{
|
||||
<span style="font-size:11px;padding:3px 10px;border-radius:12px;background:rgba(34,197,94,0.15);color:#22C55E;">Hoat dong</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span style="font-size:11px;padding:3px 10px;border-radius:12px;background:rgba(107,114,128,0.15);color:#6B7280;">Ngung</span>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Style="text-align:right;">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Primary"
|
||||
OnClick="@(() => OpenEditDialog(context))" Title="Chinh sua" />
|
||||
@if (context.IsActive)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.PersonOff" Size="Size.Small" Color="Color.Error"
|
||||
OnClick="@(() => ConfirmDeactivate(context))" Title="Vo hieu hoa" />
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ═══ CREATE/EDIT DIALOG ═══ *@
|
||||
<MudDialog @bind-Visible="_dialogVisible" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_editingId.HasValue ? "Chinh sua nhan vien" : "Them nhan vien moi")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="_form">
|
||||
<MudTextField @bind-Value="_formName" Label="Ten nhan vien" Required="true"
|
||||
RequiredError="Vui long nhap ten" Variant="Variant.Outlined" Class="mb-3" />
|
||||
<MudTextField @bind-Value="_formPhone" Label="So dien thoai" Variant="Variant.Outlined" Class="mb-3" />
|
||||
<MudTextField @bind-Value="_formEmail" Label="Email" Variant="Variant.Outlined" Class="mb-3" />
|
||||
|
||||
<MudText Typo="Typo.body2" Class="mb-2" Style="color:var(--admin-text-secondary);">Chuyen mon</MudText>
|
||||
<MudChipSet T="string" @bind-SelectedValues="_selectedSpecialties" SelectionMode="SelectionMode.MultiSelection"
|
||||
Color="Color.Primary" Variant="Variant.Outlined" Class="mb-3">
|
||||
@foreach (var spec in _availableSpecialties)
|
||||
{
|
||||
<MudChip T="string" Value="@spec">@spec</MudChip>
|
||||
}
|
||||
</MudChipSet>
|
||||
|
||||
<MudTextField @bind-Value="_formCustomSpecialty" Label="Chuyen mon khac (Enter de them)"
|
||||
Variant="Variant.Outlined" Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Add"
|
||||
OnAdornmentClick="AddCustomSpecialty" OnKeyUp="OnSpecialtyKeyUp" Class="mb-3" />
|
||||
<MudTextField @bind-Value="_formWorkingHours" Label="Gio lam viec (vd: 08:00 - 17:00)"
|
||||
Variant="Variant.Outlined" Class="mb-3" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog" Variant="Variant.Text">Huy</MudButton>
|
||||
<MudButton OnClick="SaveTherapistAsync" Variant="Variant.Filled" Color="Color.Primary" Disabled="_saving">
|
||||
@if (_saving) { <MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Surface" Class="mr-2" /> }
|
||||
@(_editingId.HasValue ? "Cap nhat" : "Tao moi")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid ShopId { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private bool _saving = false;
|
||||
private List<PosDataService.TherapistInfo> _therapists = new();
|
||||
private string _searchQuery = string.Empty;
|
||||
|
||||
// Dialog state
|
||||
private bool _dialogVisible = false;
|
||||
private MudForm? _form;
|
||||
private Guid? _editingId;
|
||||
private string _formName = string.Empty;
|
||||
private string _formPhone = string.Empty;
|
||||
private string _formEmail = string.Empty;
|
||||
private string _formWorkingHours = string.Empty;
|
||||
private string _formCustomSpecialty = string.Empty;
|
||||
private IReadOnlyCollection<string> _selectedSpecialties = new List<string>();
|
||||
|
||||
private static readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true, CloseOnEscapeKey = true };
|
||||
|
||||
private static readonly string[] _availableSpecialties = new[]
|
||||
{
|
||||
"Massage", "Facial", "Body Scrub", "Nail Art", "Hair Styling",
|
||||
"Waxing", "Aromatherapy", "Hot Stone", "Acupuncture", "Skin Care"
|
||||
};
|
||||
|
||||
private IEnumerable<PosDataService.TherapistInfo> FilteredTherapists =>
|
||||
string.IsNullOrWhiteSpace(_searchQuery) ? _therapists
|
||||
: _therapists.Where(t => t.Name.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (ShopId != Guid.Empty)
|
||||
{
|
||||
try { _therapists = await DataService.GetTherapistsAsync(ShopId); }
|
||||
catch { _therapists = new(); }
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
_editingId = null;
|
||||
_formName = _formPhone = _formEmail = _formWorkingHours = _formCustomSpecialty = string.Empty;
|
||||
_selectedSpecialties = new List<string>();
|
||||
_dialogVisible = true;
|
||||
}
|
||||
|
||||
private void OpenEditDialog(PosDataService.TherapistInfo t)
|
||||
{
|
||||
_editingId = t.Id;
|
||||
_formName = t.Name;
|
||||
_formPhone = t.Phone ?? string.Empty;
|
||||
_formEmail = t.Email ?? string.Empty;
|
||||
_formWorkingHours = t.WorkingHours ?? string.Empty;
|
||||
_formCustomSpecialty = string.Empty;
|
||||
_selectedSpecialties = t.Specialties?.ToList() ?? new List<string>();
|
||||
_dialogVisible = true;
|
||||
}
|
||||
|
||||
private void CloseDialog() => _dialogVisible = false;
|
||||
|
||||
private void AddCustomSpecialty()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_formCustomSpecialty))
|
||||
{
|
||||
var current = _selectedSpecialties.ToList();
|
||||
var trimmed = _formCustomSpecialty.Trim();
|
||||
if (!current.Contains(trimmed)) { current.Add(trimmed); _selectedSpecialties = current; }
|
||||
_formCustomSpecialty = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSpecialtyKeyUp(KeyboardEventArgs e) { if (e.Key == "Enter") AddCustomSpecialty(); }
|
||||
|
||||
private async Task SaveTherapistAsync()
|
||||
{
|
||||
if (_form != null) { await _form.Validate(); if (!_form.IsValid) return; }
|
||||
if (string.IsNullOrWhiteSpace(_formName) || ShopId == Guid.Empty) { Snackbar.Add("Vui long nhap ten nhan vien.", Severity.Warning); return; }
|
||||
|
||||
_saving = true; StateHasChanged();
|
||||
try
|
||||
{
|
||||
var specialties = _selectedSpecialties.ToArray();
|
||||
var req = new PosDataService.CreateTherapistRequest(ShopId, _formName.Trim(),
|
||||
specialties.Length > 0 ? specialties : null,
|
||||
string.IsNullOrWhiteSpace(_formPhone) ? null : _formPhone.Trim(),
|
||||
string.IsNullOrWhiteSpace(_formEmail) ? null : _formEmail.Trim(),
|
||||
string.IsNullOrWhiteSpace(_formWorkingHours) ? null : _formWorkingHours.Trim());
|
||||
|
||||
bool ok = _editingId.HasValue
|
||||
? await DataService.UpdateTherapistAsync(_editingId.Value, req)
|
||||
: await DataService.CreateTherapistAsync(req);
|
||||
|
||||
if (ok)
|
||||
{
|
||||
Snackbar.Add(_editingId.HasValue ? "Da cap nhat." : "Da them nhan vien.", Severity.Success);
|
||||
CloseDialog();
|
||||
_therapists = await DataService.GetTherapistsAsync(ShopId);
|
||||
}
|
||||
else { Snackbar.Add("Thao tac that bai.", Severity.Error); }
|
||||
}
|
||||
catch (Exception ex) { Snackbar.Add($"Loi: {ex.Message}", Severity.Error); }
|
||||
finally { _saving = false; StateHasChanged(); }
|
||||
}
|
||||
|
||||
private async Task ConfirmDeactivate(PosDataService.TherapistInfo t)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox("Vo hieu hoa nhan vien",
|
||||
$"Ban co chac muon vo hieu hoa '{t.Name}'?", yesText: "Vo hieu hoa", cancelText: "Huy");
|
||||
if (confirmed != true) return;
|
||||
try
|
||||
{
|
||||
if (await DataService.DeactivateTherapistAsync(t.Id))
|
||||
{
|
||||
Snackbar.Add($"Da vo hieu hoa '{t.Name}'.", Severity.Success);
|
||||
_therapists = await DataService.GetTherapistsAsync(ShopId);
|
||||
}
|
||||
else { Snackbar.Add("That bai.", Severity.Error); }
|
||||
}
|
||||
catch (Exception ex) { Snackbar.Add($"Loi: {ex.Message}", Severity.Error); }
|
||||
}
|
||||
|
||||
private static string GetInitials(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return "??";
|
||||
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length >= 2 ? $"{parts[0][0]}{parts[^1][0]}".ToUpper() : name[..Math.Min(2, name.Length)].ToUpper();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
@page "/admin/spa/appointments"
|
||||
@layout AdminLayout
|
||||
@inherits AdminBase
|
||||
@inject PosDataService DataService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@using WebClientTpos.Client.Services
|
||||
@using MudBlazor
|
||||
|
||||
@*
|
||||
EN: Appointment Calendar page — daily timeline view of spa/beauty appointments grouped by therapist.
|
||||
VI: Trang lich hen — hien thi lich hen spa/beauty theo ngay, nhom theo nhan vien tri lieu.
|
||||
*@
|
||||
|
||||
<PageTitle>Lich hen — GoodGo POS</PageTitle>
|
||||
|
||||
@* ═══ TOP BAR ═══ *@
|
||||
<div class="admin-topbar">
|
||||
<div class="admin-topbar__left">
|
||||
<h1 class="admin-topbar__title">Lich hen</h1>
|
||||
<p class="admin-topbar__subtitle">Quan ly lich hen hang ngay cho nhan vien</p>
|
||||
</div>
|
||||
<div class="admin-topbar__right" style="display:flex;align-items:center;gap:12px;">
|
||||
<MudDatePicker @bind-Date="_selectedDate"
|
||||
Label="Ngay"
|
||||
Variant="Variant.Outlined"
|
||||
DateFormat="dd/MM/yyyy"
|
||||
Style="max-width:180px;"
|
||||
Color="Color.Primary"
|
||||
Class="mud-input-dark"
|
||||
DateChanged="OnDateChanged" />
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="LoadAppointmentsAsync"
|
||||
Disabled="_loading"
|
||||
StartIcon="@Icons.Material.Filled.Refresh">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Surface" Class="mr-2" />
|
||||
}
|
||||
Tai lai
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="OpenCreateAppointmentDialog"
|
||||
StartIcon="@Icons.Material.Filled.Add">
|
||||
Dat lich moi
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ CONTENT ═══ *@
|
||||
<div class="admin-content" style="display:flex;flex-direction:column;gap:20px;">
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="display:flex;justify-content:center;padding:48px;">
|
||||
<MudProgressCircular Size="Size.Large" Indeterminate="true" Color="Color.Primary" />
|
||||
</div>
|
||||
}
|
||||
else if (!_appointments.Any())
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
|
||||
Khong co lich hen nao trong ngay @(_selectedDate?.ToString("dd/MM/yyyy") ?? "hom nay"). Nhan "Dat lich moi" de tao lich hen.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── SUMMARY CARDS ── *@
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;">
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value">@_appointments.Count</span>
|
||||
<span class="admin-stat-card__label">Tong lich hen</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value" style="color:#F59E0B;">@_appointments.Count(a => a.Status.Equals("Pending", StringComparison.OrdinalIgnoreCase))</span>
|
||||
<span class="admin-stat-card__label">Cho xac nhan</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value" style="color:#3B82F6;">@_appointments.Count(a => a.Status.Equals("Confirmed", StringComparison.OrdinalIgnoreCase))</span>
|
||||
<span class="admin-stat-card__label">Da xac nhan</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value" style="color:#22C55E;">@_appointments.Count(a => a.Status.Equals("InProgress", StringComparison.OrdinalIgnoreCase) || a.Status.Equals("Completed", StringComparison.OrdinalIgnoreCase))</span>
|
||||
<span class="admin-stat-card__label">Dang/Da hoan thanh</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── APPOINTMENTS GROUPED BY THERAPIST ── *@
|
||||
@foreach (var group in GroupedAppointments)
|
||||
{
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header" style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg, #FF5C00 0%, #FF8A3D 100%);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff;flex-shrink:0;">
|
||||
@GetInitials(group.Key)
|
||||
</div>
|
||||
<h3 class="admin-panel__title" style="margin:0;">@group.Key</h3>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Default"
|
||||
Style="font-size:11px;margin-left:auto;">@group.Count() lich hen</MudChip>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="padding:0;">
|
||||
@foreach (var appt in group.OrderBy(a => a.StartTime))
|
||||
{
|
||||
<div style="display:flex;align-items:center;gap:16px;padding:14px 20px;border-bottom:1px solid var(--admin-border-subtle);">
|
||||
@* Time *@
|
||||
<div style="min-width:100px;text-align:center;">
|
||||
<div style="font-size:14px;font-weight:700;color:var(--admin-text-primary);">
|
||||
@appt.StartTime.ToString("HH:mm")
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--admin-text-tertiary);">
|
||||
@appt.EndTime.ToString("HH:mm")
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Info *@
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:14px;color:var(--admin-text-primary);">
|
||||
@(appt.ServiceName ?? "Dich vu")
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--admin-text-secondary);margin-top:2px;">
|
||||
@if (!string.IsNullOrEmpty(appt.CustomerName))
|
||||
{
|
||||
<span><i data-lucide="user" style="width:12px;height:12px;display:inline;vertical-align:middle;margin-right:4px;"></i>@appt.CustomerName</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(appt.Notes))
|
||||
{
|
||||
<span style="margin-left:12px;"><i data-lucide="message-square" style="width:12px;height:12px;display:inline;vertical-align:middle;margin-right:4px;"></i>@appt.Notes</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Status Badge *@
|
||||
<div style="min-width:110px;text-align:center;">
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Filled"
|
||||
Style="@GetStatusChipStyle(appt.Status)"
|
||||
>@GetStatusLabel(appt.Status)</MudChip>
|
||||
</div>
|
||||
|
||||
@* Actions *@
|
||||
<div style="display:flex;gap:4px;flex-shrink:0;">
|
||||
@if (appt.Status.Equals("Pending", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info"
|
||||
OnClick="@(() => UpdateStatusAsync(appt.Id, "confirm"))"
|
||||
Style="font-size:11px;min-width:0;padding:4px 8px;">
|
||||
Xac nhan
|
||||
</MudButton>
|
||||
}
|
||||
@if (appt.Status.Equals("Confirmed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Success"
|
||||
OnClick="@(() => UpdateStatusAsync(appt.Id, "start"))"
|
||||
Style="font-size:11px;min-width:0;padding:4px 8px;">
|
||||
Bat dau
|
||||
</MudButton>
|
||||
}
|
||||
@if (appt.Status.Equals("InProgress", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Success"
|
||||
OnClick="@(() => UpdateStatusAsync(appt.Id, "complete"))"
|
||||
Style="font-size:11px;min-width:0;padding:4px 8px;">
|
||||
Hoan thanh
|
||||
</MudButton>
|
||||
}
|
||||
@if (!appt.Status.Equals("Completed", StringComparison.OrdinalIgnoreCase)
|
||||
&& !appt.Status.Equals("Cancelled", StringComparison.OrdinalIgnoreCase)
|
||||
&& !appt.Status.Equals("NoShow", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.PersonOff"
|
||||
Size="Size.Small"
|
||||
Color="Color.Dark"
|
||||
OnClick="@(() => UpdateStatusAsync(appt.Id, "noshow"))"
|
||||
Title="Vang mat" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Cancel"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => OpenCancelDialog(appt))"
|
||||
Title="Huy lich" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ═══ CANCEL DIALOG ═══ *@
|
||||
<MudDialog @bind-Visible="_cancelDialogVisible" Options="_smallDialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Huy lich hen</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body2" Class="mb-3" Style="color:var(--admin-text-secondary);">
|
||||
Vui long nhap ly do huy lich hen nay.
|
||||
</MudText>
|
||||
<MudTextField @bind-Value="_cancelReason"
|
||||
Label="Ly do huy"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="3"
|
||||
Required="true"
|
||||
RequiredError="Vui long nhap ly do" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _cancelDialogVisible = false)" Variant="Variant.Text">Quay lai</MudButton>
|
||||
<MudButton OnClick="ConfirmCancelAsync"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
Disabled="@(string.IsNullOrWhiteSpace(_cancelReason) || _saving)">
|
||||
@if (_saving)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Surface" Class="mr-2" />
|
||||
}
|
||||
Xac nhan huy
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@* ═══ CREATE APPOINTMENT DIALOG ═══ *@
|
||||
<MudDialog @bind-Visible="_createDialogVisible" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Dat lich moi</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="_createForm">
|
||||
<MudSelect T="Guid?" @bind-Value="_newApptTherapistId"
|
||||
Label="Nhan vien"
|
||||
Variant="Variant.Outlined"
|
||||
AnchorOrigin="Origin.BottomCenter"
|
||||
Class="mb-3">
|
||||
<MudSelectItem T="Guid?" Value="@((Guid?)null)">-- Chua chon --</MudSelectItem>
|
||||
@foreach (var t in _therapists.Where(t => t.IsActive))
|
||||
{
|
||||
<MudSelectItem T="Guid?" Value="@((Guid?)t.Id)">@t.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="_newApptServiceName"
|
||||
Label="Dich vu"
|
||||
Required="true"
|
||||
RequiredError="Vui long nhap ten dich vu"
|
||||
Variant="Variant.Outlined"
|
||||
Class="mb-3" />
|
||||
|
||||
<MudTextField @bind-Value="_newApptCustomerName"
|
||||
Label="Ten khach hang"
|
||||
Variant="Variant.Outlined"
|
||||
Class="mb-3" />
|
||||
|
||||
<div style="display:flex;gap:12px;">
|
||||
<MudTimePicker @bind-Time="_newApptStartTime"
|
||||
Label="Gio bat dau"
|
||||
Variant="Variant.Outlined"
|
||||
Class="mb-3"
|
||||
Style="flex:1;" />
|
||||
<MudTimePicker @bind-Time="_newApptEndTime"
|
||||
Label="Gio ket thuc"
|
||||
Variant="Variant.Outlined"
|
||||
Class="mb-3"
|
||||
Style="flex:1;" />
|
||||
</div>
|
||||
|
||||
<MudTextField @bind-Value="_newApptNotes"
|
||||
Label="Ghi chu"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="2"
|
||||
Class="mb-3" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _createDialogVisible = false)" Variant="Variant.Text">Huy</MudButton>
|
||||
<MudButton OnClick="CreateAppointmentAsync"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
Disabled="_saving">
|
||||
@if (_saving)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Surface" Class="mr-2" />
|
||||
}
|
||||
Dat lich
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
// EN: Page state / VI: Trang thai trang
|
||||
private bool _loading = true;
|
||||
private bool _saving = false;
|
||||
private DateTime? _selectedDate = DateTime.Today;
|
||||
private List<PosDataService.AppointmentInfo> _appointments = new();
|
||||
private List<PosDataService.TherapistInfo> _therapists = new();
|
||||
|
||||
// EN: Cancel dialog / VI: Dialog huy
|
||||
private bool _cancelDialogVisible = false;
|
||||
private Guid? _cancellingApptId;
|
||||
private string _cancelReason = string.Empty;
|
||||
|
||||
// EN: Create dialog / VI: Dialog tao moi
|
||||
private bool _createDialogVisible = false;
|
||||
private MudForm? _createForm;
|
||||
private Guid? _newApptTherapistId;
|
||||
private string _newApptServiceName = string.Empty;
|
||||
private string _newApptCustomerName = string.Empty;
|
||||
private TimeSpan? _newApptStartTime = new TimeSpan(9, 0, 0);
|
||||
private TimeSpan? _newApptEndTime = new TimeSpan(10, 0, 0);
|
||||
private string _newApptNotes = string.Empty;
|
||||
|
||||
private static readonly DialogOptions _dialogOptions = new()
|
||||
{
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true,
|
||||
CloseOnEscapeKey = true
|
||||
};
|
||||
|
||||
private static readonly DialogOptions _smallDialogOptions = new()
|
||||
{
|
||||
MaxWidth = MaxWidth.ExtraSmall,
|
||||
FullWidth = true,
|
||||
CloseOnEscapeKey = true
|
||||
};
|
||||
|
||||
// EN: Group appointments by therapist name / VI: Nhom lich hen theo ten nhan vien
|
||||
private IEnumerable<IGrouping<string, PosDataService.AppointmentInfo>> GroupedAppointments =>
|
||||
_appointments
|
||||
.GroupBy(a => a.TherapistName ?? "Chua phan cong")
|
||||
.OrderBy(g => g.Key);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get the current shop ID from query string.
|
||||
/// VI: Lay shop ID tu query string.
|
||||
/// </summary>
|
||||
private Guid? GetCurrentShopId()
|
||||
{
|
||||
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
if (Guid.TryParse(query["shopId"], out var qsShopId))
|
||||
return qsShopId;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Load appointments and therapists in parallel.
|
||||
/// VI: Tai lich hen va nhan vien dong thoi.
|
||||
/// </summary>
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
var shopId = GetCurrentShopId();
|
||||
if (!shopId.HasValue)
|
||||
{
|
||||
Snackbar.Add("Vui long chon shop truoc.", Severity.Warning);
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var date = _selectedDate ?? DateTime.Today;
|
||||
var apptTask = DataService.GetAppointmentsByDateAsync(shopId.Value, date);
|
||||
var therapistTask = DataService.GetTherapistsAsync(shopId.Value);
|
||||
|
||||
await Task.WhenAll(apptTask, therapistTask);
|
||||
|
||||
_appointments = apptTask.Result;
|
||||
_therapists = therapistTask.Result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Loi tai du lieu: {ex.Message}", Severity.Error);
|
||||
_appointments = new();
|
||||
_therapists = new();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAppointmentsAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task OnDateChanged(DateTime? newDate)
|
||||
{
|
||||
_selectedDate = newDate;
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
// ═══ STATUS ACTIONS ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update appointment status (confirm/start/complete/noshow).
|
||||
/// VI: Cap nhat trang thai lich hen (xac nhan/bat dau/hoan thanh/vang mat).
|
||||
/// </summary>
|
||||
private async Task UpdateStatusAsync(Guid appointmentId, string action)
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await DataService.UpdateAppointmentStatusAsync(appointmentId, action);
|
||||
if (success)
|
||||
{
|
||||
var label = action switch
|
||||
{
|
||||
"confirm" => "Da xac nhan",
|
||||
"start" => "Da bat dau",
|
||||
"complete" => "Da hoan thanh",
|
||||
"noshow" => "Da danh dau vang mat",
|
||||
_ => "Da cap nhat"
|
||||
};
|
||||
Snackbar.Add($"{label} lich hen.", Severity.Success);
|
||||
await LoadAppointmentsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Cap nhat trang thai that bai.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Loi: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ CANCEL FLOW ═══
|
||||
|
||||
private void OpenCancelDialog(PosDataService.AppointmentInfo appt)
|
||||
{
|
||||
_cancellingApptId = appt.Id;
|
||||
_cancelReason = string.Empty;
|
||||
_cancelDialogVisible = true;
|
||||
}
|
||||
|
||||
private async Task ConfirmCancelAsync()
|
||||
{
|
||||
if (!_cancellingApptId.HasValue || string.IsNullOrWhiteSpace(_cancelReason)) return;
|
||||
|
||||
_saving = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var success = await DataService.CancelAppointmentWithReasonAsync(_cancellingApptId.Value, _cancelReason.Trim());
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add("Da huy lich hen.", Severity.Success);
|
||||
_cancelDialogVisible = false;
|
||||
await LoadAppointmentsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Huy lich hen that bai.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Loi: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ CREATE APPOINTMENT ═══
|
||||
|
||||
private void OpenCreateAppointmentDialog()
|
||||
{
|
||||
_newApptTherapistId = null;
|
||||
_newApptServiceName = string.Empty;
|
||||
_newApptCustomerName = string.Empty;
|
||||
_newApptStartTime = new TimeSpan(9, 0, 0);
|
||||
_newApptEndTime = new TimeSpan(10, 0, 0);
|
||||
_newApptNotes = string.Empty;
|
||||
_createDialogVisible = true;
|
||||
}
|
||||
|
||||
private async Task CreateAppointmentAsync()
|
||||
{
|
||||
if (_createForm != null)
|
||||
{
|
||||
await _createForm.Validate();
|
||||
if (!_createForm.IsValid) return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_newApptServiceName))
|
||||
{
|
||||
Snackbar.Add("Vui long nhap ten dich vu.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var shopId = GetCurrentShopId();
|
||||
if (!shopId.HasValue)
|
||||
{
|
||||
Snackbar.Add("Vui long chon shop truoc.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_newApptStartTime.HasValue || !_newApptEndTime.HasValue)
|
||||
{
|
||||
Snackbar.Add("Vui long chon gio bat dau va ket thuc.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
_saving = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var date = _selectedDate ?? DateTime.Today;
|
||||
var startTime = date.Add(_newApptStartTime.Value);
|
||||
var endTime = date.Add(_newApptEndTime.Value);
|
||||
|
||||
var req = new PosDataService.CreateAppointmentRequest(
|
||||
ShopId: shopId.Value,
|
||||
CustomerId: null,
|
||||
StaffId: _newApptTherapistId,
|
||||
ResourceId: null,
|
||||
ServiceId: null,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
Status: "Pending"
|
||||
);
|
||||
|
||||
var success = await DataService.CreateAppointmentAsync(req);
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add("Da dat lich hen moi.", Severity.Success);
|
||||
_createDialogVisible = false;
|
||||
await LoadAppointmentsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Dat lich that bai. Vui long thu lai.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Loi: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ HELPERS ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get Vietnamese status label.
|
||||
/// VI: Lay nhan trang thai tieng Viet.
|
||||
/// </summary>
|
||||
private static string GetStatusLabel(string status) => status.ToLowerInvariant() switch
|
||||
{
|
||||
"pending" => "Cho xac nhan",
|
||||
"confirmed" => "Da xac nhan",
|
||||
"inprogress" => "Dang thuc hien",
|
||||
"completed" => "Hoan thanh",
|
||||
"cancelled" => "Da huy",
|
||||
"noshow" => "Vang mat",
|
||||
_ => status
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get inline style for status chip based on status value.
|
||||
/// VI: Lay style inline cho chip trang thai dua tren gia tri trang thai.
|
||||
/// </summary>
|
||||
private static string GetStatusChipStyle(string status) => status.ToLowerInvariant() switch
|
||||
{
|
||||
"pending" => "background:rgba(245,158,11,0.15);color:#F59E0B;font-size:11px;",
|
||||
"confirmed" => "background:rgba(59,130,246,0.15);color:#3B82F6;font-size:11px;",
|
||||
"inprogress" => "background:rgba(34,197,94,0.15);color:#22C55E;font-size:11px;",
|
||||
"completed" => "background:rgba(156,163,175,0.15);color:#9CA3AF;font-size:11px;",
|
||||
"cancelled" => "background:rgba(239,68,68,0.15);color:#EF4444;font-size:11px;",
|
||||
"noshow" => "background:rgba(107,114,128,0.25);color:#6B7280;font-size:11px;",
|
||||
_ => "font-size:11px;"
|
||||
};
|
||||
|
||||
private static string GetInitials(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return "??";
|
||||
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2)
|
||||
return $"{parts[0][0]}{parts[^1][0]}".ToUpper();
|
||||
return name[..Math.Min(2, name.Length)].ToUpper();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
@page "/admin/spa/therapists"
|
||||
@layout AdminLayout
|
||||
@inherits AdminBase
|
||||
@inject PosDataService DataService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@using WebClientTpos.Client.Services
|
||||
@using MudBlazor
|
||||
|
||||
@*
|
||||
EN: Therapist Management page — CRUD for spa/beauty therapists with specialties and status.
|
||||
VI: Trang quan ly nhan vien tri lieu — CRUD cho nhan vien spa/beauty voi chuyen mon va trang thai.
|
||||
*@
|
||||
|
||||
<PageTitle>Quan ly nhan vien — GoodGo POS</PageTitle>
|
||||
|
||||
@* ═══ TOP BAR ═══ *@
|
||||
<div class="admin-topbar">
|
||||
<div class="admin-topbar__left">
|
||||
<h1 class="admin-topbar__title">Quan ly nhan vien</h1>
|
||||
<p class="admin-topbar__subtitle">Danh sach nhan vien tri lieu, chuyen mon va trang thai</p>
|
||||
</div>
|
||||
<div class="admin-topbar__right" style="display:flex;align-items:center;gap:12px;">
|
||||
<div style="position:relative;">
|
||||
<i data-lucide="search" style="position:absolute;left:12px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
|
||||
<input type="text" @bind="_searchQuery" @bind:event="oninput"
|
||||
placeholder="Tim theo ten..."
|
||||
style="padding:8px 12px 8px 36px;border-radius:10px;border:1px solid var(--admin-border);background:var(--admin-bg-interactive);color:var(--admin-text-primary);font-size:13px;width:220px;outline:none;" />
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="OpenCreateDialog"
|
||||
StartIcon="@Icons.Material.Filled.PersonAdd">
|
||||
Them nhan vien
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ CONTENT ═══ *@
|
||||
<div class="admin-content">
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="display:flex;justify-content:center;padding:48px;">
|
||||
<MudProgressCircular Size="Size.Large" Indeterminate="true" Color="Color.Primary" />
|
||||
</div>
|
||||
}
|
||||
else if (!FilteredTherapists.Any())
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
|
||||
@if (string.IsNullOrWhiteSpace(_searchQuery))
|
||||
{
|
||||
<span>Chua co nhan vien nao. Nhan "Them nhan vien" de bat dau.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Khong tim thay nhan vien nao phu hop voi "@_searchQuery".</span>
|
||||
}
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__body" style="padding:0;">
|
||||
<MudTable Items="@FilteredTherapists"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Elevation="0"
|
||||
Class="mud-table-dark">
|
||||
<HeaderContent>
|
||||
<MudTh Style="font-weight:700;">Ten</MudTh>
|
||||
<MudTh Style="font-weight:700;">Chuyen mon</MudTh>
|
||||
<MudTh Style="font-weight:700;">Lien he</MudTh>
|
||||
<MudTh Style="font-weight:700;">Trang thai</MudTh>
|
||||
<MudTh Style="font-weight:700;text-align:right;">Thao tac</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg, #FF5C00 0%, #FF8A3D 100%);display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff;flex-shrink:0;">
|
||||
@GetInitials(context.Name)
|
||||
</div>
|
||||
<span style="font-weight:600;">@context.Name</span>
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px;">
|
||||
@if (context.Specialties != null)
|
||||
{
|
||||
@foreach (var s in context.Specialties)
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Outlined"
|
||||
Style="font-size:11px;">@s</MudChip>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div style="font-size:12px;color:var(--admin-text-secondary);">
|
||||
@if (!string.IsNullOrEmpty(context.Phone))
|
||||
{
|
||||
<div>@context.Phone</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(context.Email))
|
||||
{
|
||||
<div>@context.Email</div>
|
||||
}
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
@if (context.IsActive)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled"
|
||||
Style="font-size:11px;">Hoat dong</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Filled"
|
||||
Style="font-size:11px;">Ngung hoat dong</MudChip>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Style="text-align:right;">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
OnClick="@(() => OpenEditDialog(context))"
|
||||
Title="Chinh sua" />
|
||||
@if (context.IsActive)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.PersonOff"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => ConfirmDeactivate(context))"
|
||||
Title="Vo hieu hoa" />
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ═══ CREATE/EDIT DIALOG ═══ *@
|
||||
<MudDialog @bind-Visible="_dialogVisible" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_editingId.HasValue ? "Chinh sua nhan vien" : "Them nhan vien moi")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm @ref="_form">
|
||||
<MudTextField @bind-Value="_formName"
|
||||
Label="Ten nhan vien"
|
||||
Required="true"
|
||||
RequiredError="Vui long nhap ten nhan vien"
|
||||
Variant="Variant.Outlined"
|
||||
Class="mb-3" />
|
||||
|
||||
<MudTextField @bind-Value="_formPhone"
|
||||
Label="So dien thoai"
|
||||
Variant="Variant.Outlined"
|
||||
Class="mb-3" />
|
||||
|
||||
<MudTextField @bind-Value="_formEmail"
|
||||
Label="Email"
|
||||
Variant="Variant.Outlined"
|
||||
Class="mb-3" />
|
||||
|
||||
<MudText Typo="Typo.body2" Class="mb-2" Style="color:var(--admin-text-secondary);">Chuyen mon</MudText>
|
||||
<MudChipSet T="string" @bind-SelectedValues="_selectedSpecialties" SelectionMode="SelectionMode.MultiSelection" Color="Color.Primary" Variant="Variant.Outlined" Class="mb-3">
|
||||
@foreach (var spec in _availableSpecialties)
|
||||
{
|
||||
<MudChip T="string" Value="@spec">@spec</MudChip>
|
||||
}
|
||||
</MudChipSet>
|
||||
|
||||
<MudTextField @bind-Value="_formCustomSpecialty"
|
||||
Label="Chuyen mon khac (nhap va nhan Enter)"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentIcon="@Icons.Material.Filled.Add"
|
||||
OnAdornmentClick="AddCustomSpecialty"
|
||||
OnKeyUp="OnSpecialtyKeyUp"
|
||||
Class="mb-3" />
|
||||
|
||||
<MudTextField @bind-Value="_formWorkingHours"
|
||||
Label="Gio lam viec (vd: 08:00 - 17:00)"
|
||||
Variant="Variant.Outlined"
|
||||
Class="mb-3" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDialog" Variant="Variant.Text">Huy</MudButton>
|
||||
<MudButton OnClick="SaveTherapistAsync"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
Disabled="_saving">
|
||||
@if (_saving)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Surface" Class="mr-2" />
|
||||
}
|
||||
@(_editingId.HasValue ? "Cap nhat" : "Tao moi")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
// EN: State fields / VI: Cac truong trang thai
|
||||
private bool _loading = true;
|
||||
private bool _saving = false;
|
||||
private List<PosDataService.TherapistInfo> _therapists = new();
|
||||
private string _searchQuery = string.Empty;
|
||||
|
||||
// EN: Dialog state / VI: Trang thai dialog
|
||||
private bool _dialogVisible = false;
|
||||
private MudForm? _form;
|
||||
private Guid? _editingId;
|
||||
private string _formName = string.Empty;
|
||||
private string _formPhone = string.Empty;
|
||||
private string _formEmail = string.Empty;
|
||||
private string _formWorkingHours = string.Empty;
|
||||
private string _formCustomSpecialty = string.Empty;
|
||||
private IReadOnlyCollection<string> _selectedSpecialties = new List<string>();
|
||||
|
||||
private static readonly DialogOptions _dialogOptions = new()
|
||||
{
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true,
|
||||
CloseOnEscapeKey = true
|
||||
};
|
||||
|
||||
// EN: Common spa specialties / VI: Cac chuyen mon spa pho bien
|
||||
private static readonly string[] _availableSpecialties = new[]
|
||||
{
|
||||
"Massage", "Facial", "Body Scrub", "Nail Art", "Hair Styling",
|
||||
"Waxing", "Aromatherapy", "Hot Stone", "Acupuncture", "Skin Care"
|
||||
};
|
||||
|
||||
// EN: Filtered therapist list based on search query
|
||||
// VI: Danh sach nhan vien da loc theo tu khoa tim kiem
|
||||
private IEnumerable<PosDataService.TherapistInfo> FilteredTherapists =>
|
||||
string.IsNullOrWhiteSpace(_searchQuery)
|
||||
? _therapists
|
||||
: _therapists.Where(t => t.Name.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get the current shop ID from query string.
|
||||
/// VI: Lay shop ID tu query string.
|
||||
/// </summary>
|
||||
private Guid? GetCurrentShopId()
|
||||
{
|
||||
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
if (Guid.TryParse(query["shopId"], out var qsShopId))
|
||||
return qsShopId;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
await LoadTherapistsAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Load therapists from API.
|
||||
/// VI: Tai danh sach nhan vien tu API.
|
||||
/// </summary>
|
||||
private async Task LoadTherapistsAsync()
|
||||
{
|
||||
var shopId = GetCurrentShopId();
|
||||
if (!shopId.HasValue)
|
||||
{
|
||||
Snackbar.Add("Vui long chon shop truoc.", Severity.Warning);
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
_therapists = await DataService.GetTherapistsAsync(shopId.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Loi tai danh sach: {ex.Message}", Severity.Error);
|
||||
_therapists = new();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ DIALOG METHODS ═══
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
_editingId = null;
|
||||
_formName = string.Empty;
|
||||
_formPhone = string.Empty;
|
||||
_formEmail = string.Empty;
|
||||
_formWorkingHours = string.Empty;
|
||||
_formCustomSpecialty = string.Empty;
|
||||
_selectedSpecialties = new List<string>();
|
||||
_dialogVisible = true;
|
||||
}
|
||||
|
||||
private void OpenEditDialog(PosDataService.TherapistInfo therapist)
|
||||
{
|
||||
_editingId = therapist.Id;
|
||||
_formName = therapist.Name;
|
||||
_formPhone = therapist.Phone ?? string.Empty;
|
||||
_formEmail = therapist.Email ?? string.Empty;
|
||||
_formWorkingHours = therapist.WorkingHours ?? string.Empty;
|
||||
_formCustomSpecialty = string.Empty;
|
||||
_selectedSpecialties = therapist.Specialties?.ToList() ?? new List<string>();
|
||||
_dialogVisible = true;
|
||||
}
|
||||
|
||||
private void CloseDialog()
|
||||
{
|
||||
_dialogVisible = false;
|
||||
}
|
||||
|
||||
private void AddCustomSpecialty()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_formCustomSpecialty))
|
||||
{
|
||||
var current = _selectedSpecialties.ToList();
|
||||
var trimmed = _formCustomSpecialty.Trim();
|
||||
if (!current.Contains(trimmed))
|
||||
{
|
||||
current.Add(trimmed);
|
||||
_selectedSpecialties = current;
|
||||
}
|
||||
_formCustomSpecialty = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSpecialtyKeyUp(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
AddCustomSpecialty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Save therapist (create or update).
|
||||
/// VI: Luu nhan vien (tao moi hoac cap nhat).
|
||||
/// </summary>
|
||||
private async Task SaveTherapistAsync()
|
||||
{
|
||||
if (_form != null)
|
||||
{
|
||||
await _form.Validate();
|
||||
if (!_form.IsValid) return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_formName))
|
||||
{
|
||||
Snackbar.Add("Vui long nhap ten nhan vien.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var shopId = GetCurrentShopId();
|
||||
if (!shopId.HasValue)
|
||||
{
|
||||
Snackbar.Add("Vui long chon shop truoc.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
_saving = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var specialties = _selectedSpecialties.ToArray();
|
||||
var req = new PosDataService.CreateTherapistRequest(
|
||||
shopId.Value,
|
||||
_formName.Trim(),
|
||||
specialties.Length > 0 ? specialties : null,
|
||||
string.IsNullOrWhiteSpace(_formPhone) ? null : _formPhone.Trim(),
|
||||
string.IsNullOrWhiteSpace(_formEmail) ? null : _formEmail.Trim(),
|
||||
string.IsNullOrWhiteSpace(_formWorkingHours) ? null : _formWorkingHours.Trim()
|
||||
);
|
||||
|
||||
bool success;
|
||||
if (_editingId.HasValue)
|
||||
{
|
||||
success = await DataService.UpdateTherapistAsync(_editingId.Value, req);
|
||||
}
|
||||
else
|
||||
{
|
||||
success = await DataService.CreateTherapistAsync(req);
|
||||
}
|
||||
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add(_editingId.HasValue ? "Da cap nhat nhan vien." : "Da them nhan vien moi.", Severity.Success);
|
||||
CloseDialog();
|
||||
await LoadTherapistsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Thao tac that bai. Vui long thu lai.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Loi: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Confirm and deactivate a therapist.
|
||||
/// VI: Xac nhan va vo hieu hoa nhan vien.
|
||||
/// </summary>
|
||||
private async Task ConfirmDeactivate(PosDataService.TherapistInfo therapist)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"Vo hieu hoa nhan vien",
|
||||
$"Ban co chac chan muon vo hieu hoa nhan vien '{therapist.Name}'? Nhan vien se khong nhan lich hen moi.",
|
||||
yesText: "Vo hieu hoa",
|
||||
cancelText: "Huy");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var success = await DataService.DeactivateTherapistAsync(therapist.Id);
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add($"Da vo hieu hoa nhan vien '{therapist.Name}'.", Severity.Success);
|
||||
await LoadTherapistsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Vo hieu hoa that bai. Vui long thu lai.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Loi: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetInitials(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return "??";
|
||||
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2)
|
||||
return $"{parts[0][0]}{parts[^1][0]}".ToUpper();
|
||||
return name[..Math.Min(2, name.Length)].ToUpper();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
@*
|
||||
EN: Stock Overview — Admin page showing all products with stock levels, color-coded rows,
|
||||
search/filter, low stock threshold settings, and summary cards.
|
||||
VI: Tong quan ton kho — Trang admin hien thi tat ca san pham voi muc ton kho, hang ma mau,
|
||||
tim kiem/loc, cai dat nguong ton kho thap, va the tom tat.
|
||||
*@
|
||||
@page "/admin/store/{ShopId:guid}/stock"
|
||||
@layout AdminLayout
|
||||
@inherits AdminBase
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<div style="padding:0;">
|
||||
@* ═══ HEADER / TIEU DE ═══ *@
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
|
||||
<div>
|
||||
<h2 style="margin:0;font-size:22px;font-weight:700;color:var(--admin-text-primary, #FFF);">
|
||||
<i data-lucide="warehouse" style="width:22px;height:22px;margin-right:8px;color:var(--admin-orange-primary);display:inline;"></i>
|
||||
Tong quan ton kho
|
||||
</h2>
|
||||
<p style="margin:4px 0 0;font-size:13px;color:var(--admin-text-tertiary);">
|
||||
Theo doi muc ton kho va canh bao het hang
|
||||
</p>
|
||||
</div>
|
||||
<button @onclick="RefreshData"
|
||||
style="padding:8px 16px;background:var(--admin-orange-primary);color:#FFF;border:none;border-radius:8px;
|
||||
font-size:13px;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:6px;">
|
||||
<i data-lucide="refresh-cw" style="width:14px;height:14px;"></i> Lam moi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div style="display:flex;align-items:center;justify-content:center;padding:60px;">
|
||||
<MudProgressCircular Indeterminate="true" Color="Color.Warning" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ═══ SUMMARY CARDS / THE TOM TAT ═══ *@
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-bottom:20px;">
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);">
|
||||
<i data-lucide="package" style="color:#3B82F6;"></i>
|
||||
</div>
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value">@_totalProducts</span>
|
||||
<span class="admin-stat-card__label">Tong san pham</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);">
|
||||
<i data-lucide="check-circle" style="color:#22C55E;"></i>
|
||||
</div>
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value">@_inStockCount</span>
|
||||
<span class="admin-stat-card__label">Con hang</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__icon" style="background:rgba(245,158,11,0.1);">
|
||||
<i data-lucide="alert-triangle" style="color:#F59E0B;"></i>
|
||||
</div>
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value">@_lowStockCount</span>
|
||||
<span class="admin-stat-card__label">Sap het</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-card__icon" style="background:rgba(239,68,68,0.1);">
|
||||
<i data-lucide="x-circle" style="color:#EF4444;"></i>
|
||||
</div>
|
||||
<div class="admin-stat-card__content">
|
||||
<span class="admin-stat-card__value">@_outOfStockCount</span>
|
||||
<span class="admin-stat-card__label">Het hang</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ SEARCH & FILTER / TIM KIEM VA LOC ═══ *@
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
|
||||
<div style="flex:1;display:flex;align-items:center;gap:8px;background:var(--admin-bg-elevated, #1E1E2E);
|
||||
border:1px solid var(--admin-border-subtle, #333);border-radius:8px;padding:0 12px;">
|
||||
<i data-lucide="search" style="width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
|
||||
<input type="text" @bind="_searchQuery" @bind:event="oninput"
|
||||
placeholder="Tim theo ten, ma SKU hoac ma vach..."
|
||||
style="flex:1;background:transparent;border:none;color:var(--admin-text-primary, #FFF);font-size:14px;
|
||||
padding:10px 0;outline:none;font-family:inherit;" />
|
||||
@if (!string.IsNullOrEmpty(_searchQuery))
|
||||
{
|
||||
<button style="background:none;border:none;color:var(--admin-text-tertiary);cursor:pointer;"
|
||||
@onclick="() => _searchQuery = string.Empty">
|
||||
<i data-lucide="x" style="width:14px;height:14px;"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* EN: Stock status filter / VI: Loc trang thai ton kho *@
|
||||
<select @bind="_stockFilter"
|
||||
style="background:var(--admin-bg-elevated, #1E1E2E);border:1px solid var(--admin-border-subtle, #333);
|
||||
border-radius:8px;color:var(--admin-text-primary, #FFF);padding:10px 12px;font-size:13px;font-family:inherit;">
|
||||
<option value="all">Tat ca</option>
|
||||
<option value="in_stock">Con hang</option>
|
||||
<option value="low">Sap het</option>
|
||||
<option value="out">Het hang</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@* ═══ STOCK TABLE / BANG TON KHO ═══ *@
|
||||
<div style="border:1px solid var(--admin-border-subtle, #333);border-radius:12px;overflow:hidden;">
|
||||
@* EN: Table header / VI: Header bang *@
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 100px 100px 100px 80px;
|
||||
background:var(--admin-bg-elevated, #1E1E2E);padding:12px 16px;font-size:12px;font-weight:600;
|
||||
color:var(--admin-text-tertiary);border-bottom:1px solid var(--admin-border-subtle, #333);">
|
||||
<span>San pham</span>
|
||||
<span>Ma SKU</span>
|
||||
<span>Ma vach</span>
|
||||
<span style="text-align:center;">Ton kho</span>
|
||||
<span style="text-align:center;">Da dat</span>
|
||||
<span style="text-align:center;">Muc toi thieu</span>
|
||||
<span style="text-align:center;">Trang thai</span>
|
||||
</div>
|
||||
|
||||
@if (!FilteredItems.Any())
|
||||
{
|
||||
<div style="text-align:center;padding:40px;color:var(--admin-text-tertiary);font-size:14px;">
|
||||
Khong tim thay san pham nao
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var item in FilteredItems)
|
||||
{
|
||||
var rowBg = GetRowBackground(item);
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 100px 100px 100px 80px;
|
||||
padding:12px 16px;font-size:13px;align-items:center;
|
||||
border-bottom:1px solid var(--admin-border-subtle, #333);
|
||||
background:@rowBg;transition:background .2s ease;"
|
||||
@onmouseover="@(() => {})" @onmouseout="@(() => {})">
|
||||
@* EN: Product name + category / VI: Ten san pham + danh muc *@
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--admin-text-primary, #FFF);">@item.ProductName</div>
|
||||
<div style="font-size:11px;color:var(--admin-text-tertiary);margin-top:2px;">@(item.Category ?? "")</div>
|
||||
</div>
|
||||
|
||||
<span style="color:var(--admin-text-secondary);font-family:monospace;">@(item.Sku ?? "-")</span>
|
||||
<span style="color:var(--admin-text-secondary);font-family:monospace;">@(item.Barcode ?? "-")</span>
|
||||
|
||||
<span style="text-align:center;font-weight:700;color:@GetStockColor(item);">@item.Available</span>
|
||||
<span style="text-align:center;color:var(--admin-text-tertiary);">@item.Reserved</span>
|
||||
|
||||
@* EN: Minimum level with edit button / VI: Muc toi thieu voi nut chinh sua *@
|
||||
<div style="text-align:center;display:flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<span>@item.Minimum</span>
|
||||
<button style="background:none;border:none;cursor:pointer;color:var(--admin-text-tertiary);padding:2px;"
|
||||
@onclick="() => OpenThresholdDialog(item)"
|
||||
title="Cai dat canh bao">
|
||||
<i data-lucide="settings" style="width:12px;height:12px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* EN: Status badge / VI: Badge trang thai *@
|
||||
<div style="text-align:center;">
|
||||
<span style="font-size:11px;padding:3px 8px;border-radius:6px;font-weight:600;
|
||||
background:@GetStatusBg(item);color:@GetStatusColor(item);">
|
||||
@GetStatusLabel(item)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@* EN: Result count / VI: So ket qua *@
|
||||
<div style="margin-top:12px;font-size:12px;color:var(--admin-text-tertiary);text-align:right;">
|
||||
Hien thi @FilteredItems.Count() / @_stockItems.Count san pham
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ═══ THRESHOLD EDIT DIALOG / DIALOG CHINH SUA NGUONG ═══ *@
|
||||
@if (_showThresholdDialog && _editingItem != null)
|
||||
{
|
||||
<div style="position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;"
|
||||
@onclick="CloseThresholdDialog">
|
||||
<div style="background:var(--admin-bg-surface, #1A1A2E);border-radius:16px;padding:24px;width:400px;max-width:90vw;"
|
||||
@onclick:stopPropagation="true">
|
||||
<div style="font-size:16px;font-weight:700;margin-bottom:16px;color:var(--admin-text-primary, #FFF);">
|
||||
<i data-lucide="settings" style="width:18px;height:18px;margin-right:8px;color:var(--admin-orange-primary);display:inline;"></i>
|
||||
Cai dat canh bao ton kho
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:12px;font-size:13px;color:var(--admin-text-secondary);">
|
||||
San pham: <strong>@_editingItem.ProductName</strong>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;font-size:13px;color:var(--admin-text-secondary);">
|
||||
Ton kho hien tai: <strong>@_editingItem.Available</strong>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--admin-text-secondary);">
|
||||
Muc ton kho toi thieu
|
||||
</label>
|
||||
<input type="number" @bind="_thresholdValue" min="0"
|
||||
style="width:100%;padding:10px 12px;border:1px solid var(--admin-border-subtle, #333);border-radius:8px;
|
||||
font-size:14px;background:var(--admin-bg-elevated, #1E1E2E);color:var(--admin-text-primary, #FFF);" />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button @onclick="CloseThresholdDialog"
|
||||
style="padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle, #333);
|
||||
background:transparent;color:var(--admin-text-secondary);font-size:13px;cursor:pointer;">
|
||||
Huy
|
||||
</button>
|
||||
<button @onclick="SaveThreshold"
|
||||
style="padding:8px 16px;border-radius:8px;border:none;background:var(--admin-orange-primary);
|
||||
color:#FFF;font-size:13px;font-weight:600;cursor:pointer;">
|
||||
Luu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
// EN: ShopId from route parameter / VI: ShopId tu route parameter
|
||||
[Parameter] public Guid ShopId { get; set; }
|
||||
|
||||
private bool _isLoading = true;
|
||||
private string _searchQuery = "";
|
||||
private string _stockFilter = "all";
|
||||
|
||||
// EN: Stock data / VI: Du lieu ton kho
|
||||
private List<StockItemViewModel> _stockItems = new();
|
||||
|
||||
// EN: Summary counts / VI: So lieu tom tat
|
||||
private int _totalProducts;
|
||||
private int _inStockCount;
|
||||
private int _lowStockCount;
|
||||
private int _outOfStockCount;
|
||||
|
||||
// EN: Threshold dialog / VI: Dialog nguong
|
||||
private bool _showThresholdDialog;
|
||||
private StockItemViewModel? _editingItem;
|
||||
private int _thresholdValue;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// EN: Load products and inventory in parallel / VI: Tai san pham va ton kho song song
|
||||
var productsTask = DataService.GetProductsAsync(ShopId);
|
||||
var inventoryTask = DataService.GetInventoryAsync(ShopId);
|
||||
|
||||
await Task.WhenAll(productsTask, inventoryTask);
|
||||
|
||||
var products = productsTask.Result;
|
||||
var inventory = inventoryTask.Result;
|
||||
|
||||
// EN: Also try to load stock levels / VI: Cung thu tai muc ton kho
|
||||
List<PosDataService.StockLevelInfo> stockLevels = new();
|
||||
try
|
||||
{
|
||||
var productIds = products.Select(p => p.Id).ToList();
|
||||
if (productIds.Any())
|
||||
stockLevels = await DataService.GetStockLevelsAsync(ShopId, productIds);
|
||||
}
|
||||
catch { /* Stock levels optional */ }
|
||||
|
||||
var stockMap = stockLevels.ToDictionary(s => s.ProductId);
|
||||
var invMap = inventory.ToDictionary(i => i.ProductId);
|
||||
|
||||
_stockItems = products.Select(p =>
|
||||
{
|
||||
var hasStock = stockMap.TryGetValue(p.Id, out var stock);
|
||||
var hasInv = invMap.TryGetValue(p.Id, out var inv);
|
||||
|
||||
return new StockItemViewModel
|
||||
{
|
||||
ProductId = p.Id,
|
||||
ProductName = p.Name,
|
||||
Sku = p.Sku,
|
||||
Barcode = p.Sku, // EN: Use SKU as barcode fallback / VI: Dung SKU thay ma vach
|
||||
Category = p.Category,
|
||||
Available = hasStock ? stock!.Available : (hasInv ? inv!.Quantity : 0),
|
||||
Reserved = hasStock ? stock!.Reserved : (hasInv ? inv!.ReservedQuantity : 0),
|
||||
Minimum = hasStock ? stock!.Minimum : (hasInv ? inv!.ReorderLevel : 0),
|
||||
IsLowStock = hasStock ? stock!.IsLowStock : (hasInv && inv!.Quantity <= inv.ReorderLevel),
|
||||
InventoryId = hasInv ? inv!.Id : (Guid?)null
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
// EN: Calculate summary / VI: Tinh tom tat
|
||||
_totalProducts = _stockItems.Count;
|
||||
_outOfStockCount = _stockItems.Count(i => i.Available <= 0);
|
||||
_lowStockCount = _stockItems.Count(i => i.Available > 0 && i.IsLowStock);
|
||||
_inStockCount = _stockItems.Count(i => i.Available > 0 && !i.IsLowStock);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Snackbar.Add("Khong the tai du lieu ton kho", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshData() => await LoadDataAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Filtered items based on search query and stock filter.
|
||||
/// VI: San pham da loc theo truy van tim kiem va bo loc ton kho.
|
||||
/// </summary>
|
||||
private IEnumerable<StockItemViewModel> FilteredItems
|
||||
{
|
||||
get
|
||||
{
|
||||
var result = _stockItems.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_searchQuery))
|
||||
{
|
||||
var q = _searchQuery.Trim();
|
||||
result = result.Where(i =>
|
||||
(i.ProductName?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(i.Sku?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(i.Barcode?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||
}
|
||||
|
||||
result = _stockFilter switch
|
||||
{
|
||||
"in_stock" => result.Where(i => i.Available > 0 && !i.IsLowStock),
|
||||
"low" => result.Where(i => i.Available > 0 && i.IsLowStock),
|
||||
"out" => result.Where(i => i.Available <= 0),
|
||||
_ => result
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Row background color for low/out of stock / VI: Mau nen hang cho het/sap het hang
|
||||
private static string GetRowBackground(StockItemViewModel item) =>
|
||||
item.Available <= 0 ? "rgba(239,68,68,.06)" :
|
||||
item.IsLowStock ? "rgba(245,158,11,.04)" : "transparent";
|
||||
|
||||
private static string GetStockColor(StockItemViewModel item) =>
|
||||
item.Available <= 0 ? "#EF4444" : item.IsLowStock ? "#F59E0B" : "#22C55E";
|
||||
|
||||
private static string GetStatusBg(StockItemViewModel item) =>
|
||||
item.Available <= 0 ? "rgba(239,68,68,.15)" :
|
||||
item.IsLowStock ? "rgba(245,158,11,.15)" : "rgba(34,197,94,.15)";
|
||||
|
||||
private static string GetStatusColor(StockItemViewModel item) =>
|
||||
item.Available <= 0 ? "#EF4444" : item.IsLowStock ? "#F59E0B" : "#22C55E";
|
||||
|
||||
private static string GetStatusLabel(StockItemViewModel item) =>
|
||||
item.Available <= 0 ? "Het hang" : item.IsLowStock ? "Sap het" : "Du hang";
|
||||
|
||||
// EN: Threshold dialog methods / VI: Cac phuong thuc dialog nguong
|
||||
private void OpenThresholdDialog(StockItemViewModel item)
|
||||
{
|
||||
_editingItem = item;
|
||||
_thresholdValue = item.Minimum;
|
||||
_showThresholdDialog = true;
|
||||
}
|
||||
|
||||
private void CloseThresholdDialog()
|
||||
{
|
||||
_showThresholdDialog = false;
|
||||
_editingItem = null;
|
||||
}
|
||||
|
||||
private async Task SaveThreshold()
|
||||
{
|
||||
if (_editingItem == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingItem.InventoryId.HasValue)
|
||||
{
|
||||
var ok = await DataService.UpdateInventoryAsync(_editingItem.InventoryId.Value,
|
||||
new PosDataService.UpdateInventoryRequest(_editingItem.Available, _thresholdValue));
|
||||
|
||||
if (ok)
|
||||
{
|
||||
_editingItem.Minimum = _thresholdValue;
|
||||
_editingItem.IsLowStock = _editingItem.Available <= _thresholdValue;
|
||||
// EN: Recalculate summaries / VI: Tinh lai tom tat
|
||||
_outOfStockCount = _stockItems.Count(i => i.Available <= 0);
|
||||
_lowStockCount = _stockItems.Count(i => i.Available > 0 && i.IsLowStock);
|
||||
_inStockCount = _stockItems.Count(i => i.Available > 0 && !i.IsLowStock);
|
||||
Snackbar.Add("Da cap nhat muc canh bao", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Khong the cap nhat", Severity.Error);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("San pham chua co trong ton kho", Severity.Warning);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Snackbar.Add("Loi khi cap nhat nguong", Severity.Error);
|
||||
}
|
||||
|
||||
CloseThresholdDialog();
|
||||
}
|
||||
|
||||
// EN: ViewModel for stock table row / VI: ViewModel cho hang trong bang ton kho
|
||||
private class StockItemViewModel
|
||||
{
|
||||
public Guid ProductId { get; set; }
|
||||
public string ProductName { get; set; } = "";
|
||||
public string? Sku { get; set; }
|
||||
public string? Barcode { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public int Available { get; set; }
|
||||
public int Reserved { get; set; }
|
||||
public int Minimum { get; set; }
|
||||
public bool IsLowStock { get; set; }
|
||||
public Guid? InventoryId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
@*
|
||||
EN: Barista Queue Dashboard — Kanban-style drink preparation queue with stats, auto-refresh,
|
||||
and barista assignment. Connected to fnb-engine barista queue API.
|
||||
VI: Bang Dieu Khien Hang Doi Barista — Hang doi pha che kieu Kanban voi thong ke, tu dong lam moi,
|
||||
va phan cong barista. Ket noi API hang doi barista cua fnb-engine.
|
||||
*@
|
||||
@page "/pos/{ShopId:guid}/cafe/barista"
|
||||
@layout PosLayout
|
||||
@inherits PosBase
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
@implements IDisposable
|
||||
|
||||
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
||||
|
||||
@* ═══ HEADER / TIEU DE ═══ *@
|
||||
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);flex-shrink:0;">
|
||||
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
|
||||
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
|
||||
@onclick="@(() => NavigateTo("cafe"))">
|
||||
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
|
||||
</button>
|
||||
<span style="font-size:16px;font-weight:700;">Hang doi Barista</span>
|
||||
<span style="flex:1;"></span>
|
||||
<span style="font-size:11px;color:var(--pos-text-tertiary);">
|
||||
Tu dong cap nhat moi 10 giay
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
|
||||
<div style="text-align:center;">
|
||||
<div style="width:40px;height:40px;border:3px solid var(--pos-border-subtle);border-top-color:var(--pos-orange-primary);
|
||||
border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 12px;"></div>
|
||||
<div>Dang tai hang doi...</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_loadError)
|
||||
{
|
||||
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--pos-text-tertiary);">
|
||||
Khong the tai du lieu.
|
||||
<button style="margin-left:8px;padding:6px 12px;border-radius:8px;border:none;background:var(--pos-orange-primary);
|
||||
color:#fff;cursor:pointer;" @onclick="async () => await LoadQueueAsync()">Thu lai</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ═══ STATS BAR / THANH THONG KE ═══ *@
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:12px 16px;flex-shrink:0;">
|
||||
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:14px;text-align:center;">
|
||||
<div style="font-size:24px;font-weight:700;color:var(--pos-orange-primary);">@_stats.TotalQueued</div>
|
||||
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">Cho pha</div>
|
||||
</div>
|
||||
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:14px;text-align:center;">
|
||||
<div style="font-size:24px;font-weight:700;color:#3B82F6;">@_stats.Preparing</div>
|
||||
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">Dang pha</div>
|
||||
</div>
|
||||
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:14px;text-align:center;">
|
||||
<div style="font-size:24px;font-weight:700;color:var(--pos-success);">@_stats.Ready</div>
|
||||
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">San sang</div>
|
||||
</div>
|
||||
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:14px;text-align:center;">
|
||||
<div style="font-size:24px;font-weight:700;color:var(--pos-text-primary);">@_stats.AvgPrepTimeMinutes.ToString("F1")</div>
|
||||
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:2px;">TB phut</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ KANBAN COLUMNS / COT KANBAN ═══ *@
|
||||
<div style="flex:1;display:flex;gap:16px;padding:0 16px 16px;overflow-x:auto;">
|
||||
@foreach (var col in _columns)
|
||||
{
|
||||
var items = _queueItems.Where(q => q.Status == col.Status).OrderByDescending(q => q.Priority).ThenBy(q => q.CreatedAt).ToList();
|
||||
|
||||
<div style="flex:1;min-width:280px;display:flex;flex-direction:column;gap:10px;">
|
||||
@* EN: Column header / VI: Tieu de cot *@
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:12px 16px;
|
||||
background:@col.HeaderBg;border-radius:var(--pos-radius);">
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:@col.Color;"></span>
|
||||
<span style="font-size:14px;font-weight:700;color:@col.Color;">@col.Title</span>
|
||||
<span style="margin-left:auto;font-size:12px;color:var(--pos-text-tertiary);
|
||||
background:var(--pos-bg-interactive);padding:2px 8px;border-radius:6px;">
|
||||
@items.Count
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@* EN: Drink cards / VI: The ly *@
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:8px;overflow-y:auto;">
|
||||
@foreach (var item in items)
|
||||
{
|
||||
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:14px;
|
||||
border-left:3px solid @col.Color;
|
||||
@(col.Status == "Ready" ? "animation:readyPulse 2s ease-in-out infinite;" : "")">
|
||||
|
||||
@* EN: Drink header / VI: Tieu de ly *@
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">
|
||||
<div style="font-size:15px;font-weight:700;color:var(--pos-text-primary);">@item.DrinkName</div>
|
||||
@if (item.Priority > 0)
|
||||
{
|
||||
<span style="font-size:10px;font-weight:700;padding:2px 6px;border-radius:4px;
|
||||
background:rgba(239,68,68,0.2);color:var(--pos-danger);">
|
||||
Uu tien
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* EN: Customizations / VI: Tuy chinh *@
|
||||
@if (!string.IsNullOrEmpty(item.Customizations))
|
||||
{
|
||||
<div style="font-size:12px;color:var(--pos-text-secondary);margin-bottom:6px;
|
||||
padding:4px 8px;background:var(--pos-bg-interactive);border-radius:6px;">
|
||||
@item.Customizations
|
||||
</div>
|
||||
}
|
||||
|
||||
@* EN: Meta row / VI: Dong thong tin *@
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;
|
||||
color:var(--pos-text-tertiary);margin-bottom:10px;">
|
||||
<span>@GetElapsedMinutes(item.CreatedAt) phut truoc</span>
|
||||
<span>~@item.EstimatedMinutes phut</span>
|
||||
</div>
|
||||
|
||||
@* EN: Assigned barista (Preparing column) / VI: Barista duoc phan cong (cot Dang pha) *@
|
||||
@if (!string.IsNullOrEmpty(item.AssignedTo) && col.Status == "Preparing")
|
||||
{
|
||||
<div style="font-size:12px;color:#3B82F6;margin-bottom:8px;font-weight:600;">
|
||||
<i data-lucide="user" style="width:12px;height:12px;display:inline;"></i> @item.AssignedTo
|
||||
</div>
|
||||
}
|
||||
|
||||
@* EN: Action button / VI: Nut hanh dong *@
|
||||
@if (col.Status == "Queued")
|
||||
{
|
||||
<button style="width:100%;padding:8px;border-radius:8px;border:none;
|
||||
background:rgba(59,130,246,0.15);color:#3B82F6;
|
||||
font-size:13px;font-weight:600;cursor:pointer;"
|
||||
disabled="@_processingIds.Contains(item.Id)"
|
||||
@onclick="() => ShowBaristaDialog(item)">
|
||||
@(_processingIds.Contains(item.Id) ? "Dang xu ly..." : "Bat dau pha")
|
||||
</button>
|
||||
}
|
||||
else if (col.Status == "Preparing")
|
||||
{
|
||||
<button style="width:100%;padding:8px;border-radius:8px;border:none;
|
||||
background:rgba(34,197,94,0.15);color:var(--pos-success);
|
||||
font-size:13px;font-weight:600;cursor:pointer;"
|
||||
disabled="@_processingIds.Contains(item.Id)"
|
||||
@onclick="() => MarkReadyAsync(item)">
|
||||
@(_processingIds.Contains(item.Id) ? "Dang xu ly..." : "Hoan thanh")
|
||||
</button>
|
||||
}
|
||||
else if (col.Status == "Ready")
|
||||
{
|
||||
<button style="width:100%;padding:8px;border-radius:8px;border:none;
|
||||
background:rgba(245,158,11,0.15);color:var(--pos-warning);
|
||||
font-size:13px;font-weight:600;cursor:pointer;"
|
||||
disabled="@_processingIds.Contains(item.Id)"
|
||||
@onclick="() => MarkDeliveredAsync(item)">
|
||||
@(_processingIds.Contains(item.Id) ? "Dang xu ly..." : "Da giao")
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* EN: Barista name dialog overlay / VI: Hop thoai nhap ten barista *@
|
||||
@if (_showBaristaDialog)
|
||||
{
|
||||
<div style="position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100;"
|
||||
@onclick="() => _showBaristaDialog = false">
|
||||
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:24px;
|
||||
width:320px;max-width:90vw;" @onclick:stopPropagation="true">
|
||||
<div style="font-size:15px;font-weight:700;margin-bottom:16px;">Nhap ten Barista</div>
|
||||
<input type="text" placeholder="Ten barista..." value="@_baristaName"
|
||||
style="width:100%;padding:12px;border-radius:8px;border:1px solid var(--pos-border-default);
|
||||
background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:14px;
|
||||
outline:none;box-sizing:border-box;margin-bottom:16px;"
|
||||
@onchange="@((ChangeEventArgs e) => _baristaName = e.Value?.ToString() ?? "")" />
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button style="flex:1;padding:10px;border-radius:8px;border:1px solid var(--pos-border-default);
|
||||
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:13px;"
|
||||
@onclick="() => _showBaristaDialog = false">Huy</button>
|
||||
<button style="flex:1;padding:10px;border-radius:8px;border:none;
|
||||
background:var(--pos-orange-primary);color:#fff;cursor:pointer;font-size:13px;font-weight:600;"
|
||||
disabled="@string.IsNullOrWhiteSpace(_baristaName)"
|
||||
@onclick="ConfirmStartPreparing">Xac nhan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* EN: CSS animation for ready pulse / VI: CSS animation cho nhap nhay san sang *@
|
||||
<style>
|
||||
@@keyframes readyPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(34,197,94,0); }
|
||||
50% { box-shadow: 0 0 12px 2px rgba(34,197,94,0.3); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
// EN: Loading / error state / VI: Trang thai tai / loi
|
||||
private bool _isLoading = true;
|
||||
private bool _loadError;
|
||||
private bool _disposed;
|
||||
|
||||
// EN: Auto-refresh timer (10 seconds) / VI: Timer tu dong lam moi (10 giay)
|
||||
private Timer? _refreshTimer;
|
||||
|
||||
// EN: Queue data / VI: Du lieu hang doi
|
||||
private List<PosDataService.BaristaQueueItemInfo> _queueItems = new();
|
||||
private PosDataService.BaristaQueueStatsInfo _stats = new(0, 0, 0, 0);
|
||||
|
||||
// EN: Processing state per item / VI: Trang thai xu ly theo mon
|
||||
private readonly HashSet<Guid> _processingIds = new();
|
||||
|
||||
// EN: Barista name dialog state / VI: Trang thai hop thoai ten barista
|
||||
private bool _showBaristaDialog;
|
||||
private string _baristaName = "";
|
||||
private PosDataService.BaristaQueueItemInfo? _selectedItem;
|
||||
|
||||
// EN: Column definitions / VI: Dinh nghia cot
|
||||
private static readonly QueueColumnDef[] _columns = new[]
|
||||
{
|
||||
new QueueColumnDef("Cho pha", "Queued", "#F59E0B", "rgba(245,158,11,0.1)"),
|
||||
new QueueColumnDef("Dang pha", "Preparing", "#3B82F6", "rgba(59,130,246,0.1)"),
|
||||
new QueueColumnDef("San sang", "Ready", "#22C55E", "rgba(34,197,94,0.1)"),
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
await LoadQueueAsync();
|
||||
|
||||
// EN: Start auto-refresh with _disposed guard / VI: Bat dau tu dong lam moi voi guard _disposed
|
||||
_refreshTimer = new Timer(async _ =>
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
await LoadQueueAsync(silent: true);
|
||||
if (!_disposed) await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch { /* EN: Silently ignore refresh errors / VI: Bo qua loi lam moi */ }
|
||||
}, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Load queue items and stats from the barista queue API.
|
||||
/// VI: Tai danh sach mon va thong ke tu API hang doi barista.
|
||||
/// </summary>
|
||||
private async Task LoadQueueAsync(bool silent = false)
|
||||
{
|
||||
if (!silent) { _isLoading = true; _loadError = false; }
|
||||
|
||||
try
|
||||
{
|
||||
// EN: Load queue items and stats in parallel / VI: Tai mon va thong ke song song
|
||||
var itemsTask = DataService.GetBaristaQueueAsync(ShopId);
|
||||
var statsTask = DataService.GetBaristaQueueStatsAsync(ShopId);
|
||||
await Task.WhenAll(itemsTask, statsTask);
|
||||
|
||||
var newItems = await itemsTask;
|
||||
var newStats = await statsTask;
|
||||
|
||||
// EN: Preserve processing state for items being updated
|
||||
// VI: Giu trang thai xu ly cho cac mon dang cap nhat
|
||||
_queueItems = newItems;
|
||||
_stats = newStats ?? new PosDataService.BaristaQueueStatsInfo(
|
||||
newItems.Count(q => q.Status == "Queued"),
|
||||
newItems.Count(q => q.Status == "Preparing"),
|
||||
newItems.Count(q => q.Status == "Ready"),
|
||||
0);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!silent) _loadError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!silent) _isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Show the barista name dialog before starting preparation.
|
||||
/// VI: Hien thi hop thoai nhap ten barista truoc khi bat dau pha che.
|
||||
/// </summary>
|
||||
private void ShowBaristaDialog(PosDataService.BaristaQueueItemInfo item)
|
||||
{
|
||||
_selectedItem = item;
|
||||
_baristaName = "";
|
||||
_showBaristaDialog = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Confirm barista name and start preparing the drink.
|
||||
/// VI: Xac nhan ten barista va bat dau pha che ly.
|
||||
/// </summary>
|
||||
private async Task ConfirmStartPreparing()
|
||||
{
|
||||
if (_selectedItem == null || string.IsNullOrWhiteSpace(_baristaName)) return;
|
||||
|
||||
_showBaristaDialog = false;
|
||||
_processingIds.Add(_selectedItem.Id);
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var success = await DataService.StartPreparingDrinkAsync(_selectedItem.Id, _baristaName.Trim());
|
||||
if (success)
|
||||
{
|
||||
await LoadQueueAsync(silent: true);
|
||||
}
|
||||
}
|
||||
catch { /* EN: Next refresh will sync / VI: Lan lam moi ke tiep se dong bo */ }
|
||||
finally
|
||||
{
|
||||
_processingIds.Remove(_selectedItem.Id);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark a drink as ready for pickup.
|
||||
/// VI: Danh dau ly da san sang de lay.
|
||||
/// </summary>
|
||||
private async Task MarkReadyAsync(PosDataService.BaristaQueueItemInfo item)
|
||||
{
|
||||
_processingIds.Add(item.Id);
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var success = await DataService.MarkDrinkReadyAsync(item.Id);
|
||||
if (success) await LoadQueueAsync(silent: true);
|
||||
}
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
_processingIds.Remove(item.Id);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark a drink as delivered and remove from queue.
|
||||
/// VI: Danh dau ly da giao va xoa khoi hang doi.
|
||||
/// </summary>
|
||||
private async Task MarkDeliveredAsync(PosDataService.BaristaQueueItemInfo item)
|
||||
{
|
||||
_processingIds.Add(item.Id);
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var success = await DataService.MarkDrinkDeliveredAsync(item.Id);
|
||||
if (success) await LoadQueueAsync(silent: true);
|
||||
}
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
_processingIds.Remove(item.Id);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetElapsedMinutes(DateTime createdAt)
|
||||
=> Math.Max(0, (int)(DateTime.UtcNow - createdAt).TotalMinutes);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_refreshTimer?.Dispose();
|
||||
}
|
||||
|
||||
private record QueueColumnDef(string Title, string Status, string Color, string HeaderBg);
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
@*
|
||||
EN: Stamp Card — Visual stamp card with customer lookup, stamp grid, reward claim flow.
|
||||
Connected to membership-service stamp card API for real stamp card management.
|
||||
VI: The Tich Diem — The tem truc quan voi tim kiem khach hang, luoi tem, quy trinh nhan thuong.
|
||||
Ket noi API the tem cua membership-service de quan ly the tem thuc.
|
||||
*@
|
||||
@page "/pos/{ShopId:guid}/cafe/stamps"
|
||||
@layout PosLayout
|
||||
@inherits PosBase
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
|
||||
<div style="display:flex;align-items:flex-start;justify-content:center;height:100%;padding:24px;overflow-y:auto;">
|
||||
<div style="width:100%;max-width:520px;">
|
||||
|
||||
@* ═══ HEADER / TIEU DE ═══ *@
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;">
|
||||
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
|
||||
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
|
||||
@onclick="@(() => NavigateTo("cafe"))">
|
||||
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
|
||||
</button>
|
||||
<span style="font-size:18px;font-weight:700;">The tich diem</span>
|
||||
</div>
|
||||
|
||||
@* ═══ CUSTOMER LOOKUP / TIM KHACH HANG ═══ *@
|
||||
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;margin-bottom:16px;">
|
||||
<div style="font-size:13px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:10px;">Tim khach hang</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" placeholder="SDT hoac ma thanh vien..." value="@_searchQuery"
|
||||
style="flex:1;padding:12px 16px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
|
||||
background:var(--pos-bg-interactive);color:var(--pos-text-primary);font-size:15px;outline:none;"
|
||||
@onchange="@((ChangeEventArgs e) => _searchQuery = e.Value?.ToString() ?? "")"
|
||||
@onkeydown="@(async (e) => { if (e.Key == "Enter") await SearchCustomerAsync(); })" />
|
||||
<button style="padding:12px 20px;border-radius:var(--pos-radius);border:none;background:var(--pos-orange-primary);
|
||||
color:#fff;font-size:14px;font-weight:600;cursor:pointer;min-width:80px;"
|
||||
disabled="@_isSearching"
|
||||
@onclick="SearchCustomerAsync">
|
||||
@(_isSearching ? "..." : "Tim")
|
||||
</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(_searchError))
|
||||
{
|
||||
<div style="margin-top:8px;font-size:12px;color:var(--pos-danger);">@_searchError</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_customer != null && _stampCard != null)
|
||||
{
|
||||
@* ═══ CUSTOMER INFO / THONG TIN KHACH HANG ═══ *@
|
||||
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:24px;margin-bottom:16px;text-align:center;">
|
||||
<div style="width:56px;height:56px;border-radius:50%;background:var(--pos-orange-primary);
|
||||
display:flex;align-items:center;justify-content:center;margin:0 auto 12px;
|
||||
font-size:22px;font-weight:700;color:#fff;">
|
||||
@((_customer.DisplayName ?? "?")[..1].ToUpper())
|
||||
</div>
|
||||
<div style="font-size:17px;font-weight:600;margin-bottom:2px;">@(_customer.DisplayName ?? "Khach hang")</div>
|
||||
<div style="font-size:13px;color:var(--pos-text-tertiary);margin-bottom:4px;">@(_customer.Phone ?? _searchQuery)</div>
|
||||
<div style="font-size:12px;color:var(--pos-text-tertiary);">@_stampCard.CardName</div>
|
||||
</div>
|
||||
|
||||
@* ═══ STAMP GRID / LUOI TEM ═══ *@
|
||||
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:24px;margin-bottom:16px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<span style="font-size:15px;font-weight:600;">The tich tem</span>
|
||||
<span style="font-size:13px;color:var(--pos-text-tertiary);font-weight:600;">
|
||||
@_stampCard.CurrentStamps / @_stampCard.TotalStampsRequired
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@* EN: Visual stamp circles / VI: Cac vong tron tem truc quan *@
|
||||
<div style="display:grid;grid-template-columns:repeat(5, 1fr);gap:10px;">
|
||||
@for (int i = 1; i <= _stampCard.TotalStampsRequired; i++)
|
||||
{
|
||||
var idx = i;
|
||||
var isStamped = idx <= _stampCard.CurrentStamps;
|
||||
var isReward = idx == _stampCard.TotalStampsRequired;
|
||||
var isNewlyAdded = idx == _stampCard.CurrentStamps && _justAddedStamp;
|
||||
|
||||
<div style="aspect-ratio:1;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:20px;
|
||||
transition:all 0.4s ease;
|
||||
@(isNewlyAdded ? "transform:scale(1.15);box-shadow:0 0 12px rgba(255,92,0,0.5);" : "")
|
||||
background:@(isStamped ? "var(--pos-orange-primary)" : "var(--pos-bg-interactive)");
|
||||
border:@(isReward && !isStamped ? "2px dashed var(--pos-orange-primary)" : "none");
|
||||
color:@(isStamped ? "#fff" : "var(--pos-text-tertiary)");">
|
||||
@if (isReward && !isStamped)
|
||||
{
|
||||
<span>🎁</span>
|
||||
}
|
||||
else if (isStamped)
|
||||
{
|
||||
<span>☕</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span style="font-size:14px;">@idx</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* EN: Progress bar / VI: Thanh tien trinh *@
|
||||
<div style="margin-top:16px;">
|
||||
<div style="height:6px;border-radius:3px;background:var(--pos-bg-interactive);">
|
||||
<div style="height:100%;border-radius:3px;background:var(--pos-orange-primary);
|
||||
width:@(_stampCard.TotalStampsRequired > 0 ? _stampCard.CurrentStamps * 100 / _stampCard.TotalStampsRequired : 0)%;
|
||||
transition:width 0.4s ease;"></div>
|
||||
</div>
|
||||
@if (!_stampCard.IsCompleted)
|
||||
{
|
||||
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:8px;text-align:center;">
|
||||
Con @(_stampCard.TotalStampsRequired - _stampCard.CurrentStamps) tem nua de nhan 1 ly mien phi!
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ REWARD / ACTION SECTION ═══ *@
|
||||
@if (_stampCard.IsCompleted && !_stampCard.RewardClaimed)
|
||||
{
|
||||
@* EN: Card complete — show celebration + claim button *@
|
||||
<div style="background:linear-gradient(135deg, rgba(255,92,0,0.15), rgba(245,158,11,0.15));
|
||||
border:1px solid var(--pos-orange-primary);border-radius:var(--pos-radius);padding:24px;
|
||||
margin-bottom:16px;text-align:center;">
|
||||
<div style="font-size:28px;margin-bottom:8px;">🎉</div>
|
||||
<div style="font-size:16px;font-weight:700;color:var(--pos-orange-primary);margin-bottom:4px;">
|
||||
Chuc mung! Da du tem!
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--pos-text-secondary);margin-bottom:16px;">
|
||||
Khach hang du dieu kien nhan 1 ly mien phi.
|
||||
</div>
|
||||
<button class="pos-btn-checkout" disabled="@_isClaiming" @onclick="ClaimRewardAsync"
|
||||
style="background:var(--pos-success);">
|
||||
@if (_isClaiming)
|
||||
{
|
||||
<span>Dang xu ly...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i data-lucide="gift" style="width:18px;height:18px;"></i>
|
||||
<span>Nhan thuong</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else if (_stampCard.RewardClaimed)
|
||||
{
|
||||
@* EN: Reward already claimed — show reset option *@
|
||||
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:24px;
|
||||
margin-bottom:16px;text-align:center;">
|
||||
<div style="font-size:24px;margin-bottom:8px;">✅</div>
|
||||
<div style="font-size:14px;color:var(--pos-success);font-weight:600;margin-bottom:4px;">
|
||||
Da nhan thuong thanh cong!
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:16px;">
|
||||
The tem da hoan tat. Tim khach hang moi hoac bat dau lai.
|
||||
</div>
|
||||
<button style="padding:12px 24px;border-radius:var(--pos-radius);border:1px solid var(--pos-border-default);
|
||||
background:transparent;color:var(--pos-text-secondary);cursor:pointer;font-size:14px;font-weight:600;"
|
||||
@onclick="ResetSearch">
|
||||
<i data-lucide="refresh-cw" style="width:14px;height:14px;display:inline;"></i>
|
||||
Bat dau lai
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* EN: Normal state — add stamp button *@
|
||||
<button class="pos-btn-checkout" disabled="@_isAddingStamp" @onclick="AddStampAsync">
|
||||
@if (_isAddingStamp)
|
||||
{
|
||||
<span>Dang xu ly...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i data-lucide="stamp" style="width:18px;height:18px;"></i>
|
||||
<span>Them tem (+1)</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_actionMessage))
|
||||
{
|
||||
<div style="margin-top:8px;text-align:center;font-size:13px;
|
||||
color:@(_actionIsError ? "var(--pos-danger)" : "var(--pos-success)");">
|
||||
@_actionMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ═══ STAMP HISTORY / LICH SU TICH TEM ═══ *@
|
||||
@if (_expHistory.Any())
|
||||
{
|
||||
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;margin-top:16px;">
|
||||
<div style="font-size:13px;font-weight:600;color:var(--pos-text-secondary);margin-bottom:12px;">
|
||||
Lich su tich tem gan day
|
||||
</div>
|
||||
@foreach (var tx in _expHistory.Take(5))
|
||||
{
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;
|
||||
border-bottom:1px solid var(--pos-border-subtle);">
|
||||
<div>
|
||||
<div style="font-size:13px;color:var(--pos-text-primary);">+@tx.Points diem</div>
|
||||
<div style="font-size:11px;color:var(--pos-text-tertiary);">@tx.CreatedAt.ToString("dd/MM/yyyy HH:mm")</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(tx.ReferenceId))
|
||||
{
|
||||
<div style="font-size:11px;color:var(--pos-text-tertiary);font-family:monospace;">
|
||||
@tx.ReferenceId[..Math.Min(12, tx.ReferenceId.Length)]
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (!_isSearching && _hasSearched)
|
||||
{
|
||||
@* EN: No customer found / VI: Khong tim thay khach hang *@
|
||||
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:40px;
|
||||
text-align:center;color:var(--pos-text-tertiary);">
|
||||
<div style="font-size:32px;margin-bottom:12px;">🔍</div>
|
||||
<div>Khong tim thay khach hang. Hay nhap SDT va nhan Tim.</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// EN: Search state / VI: Trang thai tim kiem
|
||||
private string _searchQuery = "";
|
||||
private bool _isSearching;
|
||||
private bool _hasSearched;
|
||||
private string? _searchError;
|
||||
|
||||
// EN: Customer and stamp card data / VI: Du lieu khach hang va the tem
|
||||
private PosDataService.MemberInfo? _customer;
|
||||
private PosDataService.StampCardInfo? _stampCard;
|
||||
private List<PosDataService.ExpTransactionInfo> _expHistory = new();
|
||||
|
||||
// EN: Action state / VI: Trang thai hanh dong
|
||||
private bool _isAddingStamp;
|
||||
private bool _isClaiming;
|
||||
private bool _justAddedStamp;
|
||||
private string? _actionMessage;
|
||||
private bool _actionIsError;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Search for customer by phone or member ID.
|
||||
/// VI: Tim kiem khach hang theo SDT hoac ma thanh vien.
|
||||
/// </summary>
|
||||
private async Task SearchCustomerAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_searchQuery)) return;
|
||||
|
||||
_isSearching = true;
|
||||
_searchError = null;
|
||||
_actionMessage = null;
|
||||
_customer = null;
|
||||
_stampCard = null;
|
||||
_expHistory = new();
|
||||
_hasSearched = true;
|
||||
_justAddedStamp = false;
|
||||
|
||||
try
|
||||
{
|
||||
var members = await DataService.SearchCustomersAsync(ShopId, _searchQuery.Trim());
|
||||
if (members.Any())
|
||||
{
|
||||
_customer = members.First();
|
||||
|
||||
// EN: Try to load real stamp card from membership-service
|
||||
// VI: Thu tai the tem thuc tu membership-service
|
||||
_stampCard = await DataService.GetStampCardAsync(ShopId, _customer.Id);
|
||||
|
||||
// EN: Fallback — synthesize a stamp card from experience points if API not ready
|
||||
// VI: Du phong — tao the tem tu diem kinh nghiem neu API chua san sang
|
||||
if (_stampCard == null)
|
||||
{
|
||||
var totalStamps = 10;
|
||||
var currentStamps = (_customer.TotalExpEarned / 10) % totalStamps;
|
||||
var isComplete = currentStamps == 0 && _customer.TotalExpEarned > 0;
|
||||
_stampCard = new PosDataService.StampCardInfo(
|
||||
Guid.Empty, _customer.Id, "The Tich Tem Cafe",
|
||||
totalStamps, isComplete ? totalStamps : currentStamps,
|
||||
isComplete, false);
|
||||
}
|
||||
|
||||
// EN: Load recent stamp history
|
||||
// VI: Tai lich su tich tem gan day
|
||||
try { _expHistory = await DataService.GetExperienceHistoryAsync(_customer.Id); }
|
||||
catch { /* optional data */ }
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_searchError = "Khong the tim kiem. Vui long thu lai.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add one stamp to the customer's card.
|
||||
/// VI: Them mot tem vao the khach hang.
|
||||
/// </summary>
|
||||
private async Task AddStampAsync()
|
||||
{
|
||||
if (_customer == null || _stampCard == null) return;
|
||||
|
||||
_isAddingStamp = true;
|
||||
_actionMessage = null;
|
||||
_actionIsError = false;
|
||||
_justAddedStamp = false;
|
||||
|
||||
try
|
||||
{
|
||||
// EN: Try real stamp card API first
|
||||
// VI: Thu API the tem thuc truoc
|
||||
var updated = await DataService.AddStampAsync(ShopId, _customer.Id);
|
||||
if (updated != null)
|
||||
{
|
||||
_stampCard = updated;
|
||||
_justAddedStamp = true;
|
||||
_actionMessage = updated.IsCompleted
|
||||
? "Da tich du tem! Khach hang co the nhan thuong."
|
||||
: "Da tich 1 tem thanh cong!";
|
||||
}
|
||||
else
|
||||
{
|
||||
// EN: Fallback — add via experience API
|
||||
// VI: Du phong — them qua API kinh nghiem
|
||||
var result = await DataService.AddExperienceAsync(_customer.Id, new PosDataService.AddExpRequest(
|
||||
Points: 10, SourceId: 1,
|
||||
ReferenceId: $"stamp-{DateTime.UtcNow:yyyyMMddHHmmss}"));
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
var currentStamps = (result.TotalExpEarned / 10) % _stampCard.TotalStampsRequired;
|
||||
var isComplete = currentStamps == 0 && result.TotalExpEarned > 0;
|
||||
_stampCard = _stampCard with
|
||||
{
|
||||
CurrentStamps = isComplete ? _stampCard.TotalStampsRequired : currentStamps,
|
||||
IsCompleted = isComplete
|
||||
};
|
||||
_customer = _customer with { CurrentExp = result.CurrentExp, TotalExpEarned = result.TotalExpEarned };
|
||||
_justAddedStamp = true;
|
||||
|
||||
_actionMessage = isComplete
|
||||
? "Da tich du tem! Khach hang co the nhan thuong."
|
||||
: result.LeveledUp
|
||||
? $"Da tich tem + len hang moi (Level {result.CurrentLevel})!"
|
||||
: "Da tich 1 tem thanh cong!";
|
||||
}
|
||||
else
|
||||
{
|
||||
_actionMessage = "Khong the tich tem. Vui long thu lai.";
|
||||
_actionIsError = true;
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Refresh history
|
||||
// VI: Lam moi lich su
|
||||
try { _expHistory = await DataService.GetExperienceHistoryAsync(_customer.Id); }
|
||||
catch { }
|
||||
}
|
||||
catch
|
||||
{
|
||||
_actionMessage = "Loi khi tich tem. Vui long thu lai.";
|
||||
_actionIsError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isAddingStamp = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Claim the reward for a completed stamp card.
|
||||
/// VI: Nhan thuong cho the tem da hoan tat.
|
||||
/// </summary>
|
||||
private async Task ClaimRewardAsync()
|
||||
{
|
||||
if (_stampCard == null) return;
|
||||
|
||||
_isClaiming = true;
|
||||
_actionMessage = null;
|
||||
_actionIsError = false;
|
||||
|
||||
try
|
||||
{
|
||||
var success = await DataService.ClaimRewardAsync(_stampCard.Id);
|
||||
if (success)
|
||||
{
|
||||
_stampCard = _stampCard with { RewardClaimed = true };
|
||||
_actionMessage = "Nhan thuong thanh cong! Khach hang duoc 1 ly mien phi.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_actionMessage = "Khong the nhan thuong. Vui long thu lai.";
|
||||
_actionIsError = true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_actionMessage = "Loi khi nhan thuong. Vui long thu lai.";
|
||||
_actionIsError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isClaiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reset search state for a new customer.
|
||||
/// VI: Xoa trang thai tim kiem cho khach hang moi.
|
||||
/// </summary>
|
||||
private void ResetSearch()
|
||||
{
|
||||
_searchQuery = "";
|
||||
_customer = null;
|
||||
_stampCard = null;
|
||||
_expHistory = new();
|
||||
_hasSearched = false;
|
||||
_actionMessage = null;
|
||||
_justAddedStamp = false;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,60 @@
|
||||
@*
|
||||
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.
|
||||
EN: Retail POS Desktop — Barcode scanning, product grid with stock indicators, cart with quantity controls,
|
||||
stock level badges, returns/exchange access. Products and stock loaded from API.
|
||||
VI: POS Ban le Desktop — Quet ma vach, luoi san pham voi chi bao ton kho, gio hang voi dieu khien so luong,
|
||||
badge muc ton kho, truy cap tra/doi hang. San pham va ton kho tai tu API.
|
||||
*@
|
||||
@page "/pos/{ShopId:guid}/retail"
|
||||
@layout PosLayout
|
||||
@inherits PosBase
|
||||
@inject WebClientTpos.Client.Services.PosDataService DataService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@* ═══ PRODUCT PANEL ═══ *@
|
||||
<div class="pos-product-panel">
|
||||
@* EN: Barcode input / VI: Ô nhập mã vạch *@
|
||||
@* EN: Barcode input with auto-focus / VI: O nhap ma vach voi auto-focus *@
|
||||
<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..."
|
||||
<input @ref="_barcodeRef" type="text" @bind="_barcodeInput" @bind:event="oninput"
|
||||
@onkeydown="OnBarcodeKeyDown"
|
||||
placeholder="Quet ma vach hoac nhap 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>
|
||||
font-size:12px;font-weight:600;cursor:pointer;" @onclick="SearchBarcode">Tim</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* EN: Barcode lookup result alert / VI: Thong bao ket qua tra cuu ma vach *@
|
||||
@if (_barcodeNotFound)
|
||||
{
|
||||
<div style="margin:8px 16px;padding:10px 14px;background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.25);
|
||||
border-radius:8px;display:flex;align-items:center;gap:8px;">
|
||||
<i data-lucide="alert-triangle" style="width:16px;height:16px;color:var(--pos-warning);"></i>
|
||||
<span style="font-size:13px;color:var(--pos-warning);font-weight:600;">Khong tim thay san pham</span>
|
||||
<button style="margin-left:auto;background:none;border:none;color:var(--pos-text-tertiary);cursor:pointer;"
|
||||
@onclick="() => _barcodeNotFound = false">
|
||||
<i data-lucide="x" style="width:14px;height:14px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--pos-text-tertiary);">
|
||||
Đang tải...
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Warning" Class="mr-2" /> Dang tai...
|
||||
</div>
|
||||
}
|
||||
else if (_loadError)
|
||||
{
|
||||
<div style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--pos-text-tertiary);">
|
||||
Không thể tải dữ liệu
|
||||
Khong the tai du lieu
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* EN: Category tabs / VI: Tab danh mục *@
|
||||
@* EN: Category tabs / VI: Tab danh muc *@
|
||||
<div class="pos-category-tabs">
|
||||
@foreach (var cat in _categories)
|
||||
{
|
||||
@@ -46,13 +65,22 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@* EN: Product grid / VI: Lưới sản phẩm *@
|
||||
@* EN: Product grid with stock indicators / VI: Luoi san pham voi chi bao ton kho *@
|
||||
<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;">
|
||||
<div class="pos-product-card__image" style="display:flex;align-items:center;justify-content:center;position:relative;">
|
||||
<i data-lucide="@product.Icon" style="width:32px;height:32px;color:var(--pos-text-tertiary);"></i>
|
||||
@* EN: Stock badge / VI: Badge ton kho *@
|
||||
@if (product.StockInfo != null)
|
||||
{
|
||||
<span style="position:absolute;top:4px;right:4px;font-size:9px;padding:2px 6px;border-radius:4px;font-weight:700;
|
||||
background:@(product.StockInfo.IsLowStock ? "rgba(239,68,68,.15)" : "rgba(34,197,94,.15)");
|
||||
color:@(product.StockInfo.IsLowStock ? "var(--pos-danger)" : "var(--pos-success)");">
|
||||
@(product.StockInfo.Available <= 0 ? "Het" : product.StockInfo.IsLowStock ? $"Con {product.StockInfo.Available}" : $"{product.StockInfo.Available}")
|
||||
</span>
|
||||
}
|
||||
</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;">
|
||||
@@ -68,8 +96,8 @@
|
||||
@* ═══ 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>
|
||||
<span class="pos-cart-header__title">Gio hang</span>
|
||||
<span style="font-size:12px;color:var(--pos-text-tertiary);">@_cartItems.Count san pham</span>
|
||||
</div>
|
||||
|
||||
<div class="pos-cart-items">
|
||||
@@ -78,11 +106,22 @@
|
||||
<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>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<span style="font-size:11px;color:var(--pos-text-tertiary);">@item.Sku</span>
|
||||
@* EN: Stock indicator chip / VI: Chip chi bao ton kho *@
|
||||
@if (item.StockAvailable.HasValue)
|
||||
{
|
||||
<span style="font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;
|
||||
background:@(item.StockAvailable.Value <= 0 ? "rgba(239,68,68,.15)" : item.Qty > item.StockAvailable.Value ? "rgba(245,158,11,.15)" : "rgba(34,197,94,.15)");
|
||||
color:@(item.StockAvailable.Value <= 0 ? "var(--pos-danger)" : item.Qty > item.StockAvailable.Value ? "var(--pos-warning)" : "var(--pos-success)");">
|
||||
Ton: @item.StockAvailable
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<span class="pos-cart-item__price">@FormatPrice(item.Price)</span>
|
||||
</div>
|
||||
<div class="pos-cart-item__qty">
|
||||
<button @onclick="() => ChangeQty(item, -1)">−</button>
|
||||
<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>
|
||||
@@ -91,46 +130,75 @@
|
||||
</div>
|
||||
|
||||
<div class="pos-cart-footer">
|
||||
@* EN: Subtotals / VI: Tạm tính *@
|
||||
@* EN: Subtotals / VI: Tam tinh *@
|
||||
<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>
|
||||
<span>Tam tinh (@_cartItems.Sum(i => i.Qty) SP)</span>
|
||||
<span>@FormatPrice(CartSubtotal)</span>
|
||||
</div>
|
||||
@if (_discountAmount > 0)
|
||||
{
|
||||
<div style="display:flex;justify-content:space-between;color:var(--pos-success);margin-bottom:4px;">
|
||||
<span>Giam gia</span>
|
||||
<span>-@FormatPrice(_discountAmount)</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>
|
||||
<span>@FormatPrice((CartSubtotal - _discountAmount) * 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>
|
||||
<span class="pos-cart-total__label">Tong cong</span>
|
||||
<span class="pos-cart-total__value">@FormatPrice(CartTotal)</span>
|
||||
</div>
|
||||
<button class="pos-btn-checkout" @onclick="Checkout">
|
||||
|
||||
@* EN: Action buttons / VI: Nut hanh dong *@
|
||||
<div style="display:flex;gap:8px;padding:0 12px 8px;">
|
||||
<button style="flex:1;padding:10px;border-radius:8px;border:1px solid var(--pos-border-default);background:transparent;
|
||||
color:var(--pos-text-secondary);font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:4px;"
|
||||
@onclick="ClearCart">
|
||||
<i data-lucide="trash-2" style="width:14px;height:14px;"></i> Xoa gio
|
||||
</button>
|
||||
<button style="flex:1;padding:10px;border-radius:8px;border:1px solid var(--pos-border-default);background:transparent;
|
||||
color:var(--pos-text-secondary);font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:4px;"
|
||||
@onclick="@(() => NavigateTo("retail/return-exchange"))">
|
||||
<i data-lucide="rotate-ccw" style="width:14px;height:14px;"></i> Tra hang
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="pos-btn-checkout" @onclick="Checkout" disabled="@(!_cartItems.Any())">
|
||||
<i data-lucide="credit-card" style="width:18px;height:18px;"></i>
|
||||
Thanh toán
|
||||
Thanh toan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// EN: Loading state / VI: Trạng thái tải
|
||||
// EN: Loading state / VI: Trang thai tai
|
||||
private bool _isLoading = true;
|
||||
private bool _loadError;
|
||||
private bool _barcodeNotFound;
|
||||
private bool _isSearching;
|
||||
|
||||
// EN: Categories / VI: Danh mục
|
||||
private string[] _categories = { "Tất cả" };
|
||||
private string _selectedCategory = "Tất cả";
|
||||
// EN: Categories / VI: Danh muc
|
||||
private string[] _categories = { "Tat ca" };
|
||||
private string _selectedCategory = "Tat ca";
|
||||
private string _barcodeInput = "";
|
||||
private ElementReference _barcodeRef;
|
||||
|
||||
// EN: Product list from API / VI: Danh sách sản phẩm từ API
|
||||
private List<Product> _products = new();
|
||||
// EN: Discount / VI: Giam gia
|
||||
private decimal _discountAmount;
|
||||
|
||||
// EN: Cart items / VI: Mục giỏ hàng
|
||||
// EN: Product list from API with stock info / VI: Danh sach san pham tu API voi thong tin ton kho
|
||||
private List<ProductWithStock> _products = new();
|
||||
|
||||
// EN: Cart items / VI: Muc gio hang
|
||||
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 IEnumerable<ProductWithStock> FilteredProducts =>
|
||||
_selectedCategory == "Tat ca" ? _products : _products.Where(p => p.Category == _selectedCategory);
|
||||
private decimal CartSubtotal => _cartItems.Sum(i => i.Price * i.Qty);
|
||||
private decimal CartTotal => (CartSubtotal - _discountAmount) * 1.1m;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -138,18 +206,24 @@
|
||||
|
||||
try
|
||||
{
|
||||
// EN: Load products from catalog / VI: Tai san pham tu catalog
|
||||
var apiProducts = await DataService.GetProductsAsync(ShopId);
|
||||
|
||||
_products = apiProducts.Select(p => new Product(
|
||||
_products = apiProducts.Select(p => new ProductWithStock(
|
||||
p.Id,
|
||||
p.Name,
|
||||
p.Sku ?? "",
|
||||
p.Price,
|
||||
p.Category ?? "Khác",
|
||||
GetCategoryIcon(p.Category ?? "Khác")
|
||||
p.Category ?? "Khac",
|
||||
GetCategoryIcon(p.Category ?? "Khac"),
|
||||
null
|
||||
)).ToList();
|
||||
|
||||
var cats = _products.Select(p => p.Category).Distinct().ToList();
|
||||
_categories = new[] { "Tất cả" }.Concat(cats).ToArray();
|
||||
_categories = new[] { "Tat ca" }.Concat(cats).ToArray();
|
||||
|
||||
// EN: Load stock levels for all products / VI: Tai muc ton kho cho tat ca san pham
|
||||
await LoadStockLevelsAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -161,41 +235,169 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void AddToCart(Product product)
|
||||
/// <summary>
|
||||
/// EN: Load stock levels for all products in the grid.
|
||||
/// VI: Tai muc ton kho cho tat ca san pham trong luoi.
|
||||
/// </summary>
|
||||
private async Task LoadStockLevelsAsync()
|
||||
{
|
||||
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));
|
||||
try
|
||||
{
|
||||
var productIds = _products.Select(p => p.Id).ToList();
|
||||
if (!productIds.Any()) return;
|
||||
|
||||
var stockLevels = await DataService.GetStockLevelsAsync(ShopId, productIds);
|
||||
var stockMap = stockLevels.ToDictionary(s => s.ProductId);
|
||||
|
||||
_products = _products.Select(p =>
|
||||
stockMap.TryGetValue(p.Id, out var stock)
|
||||
? p with { StockInfo = stock }
|
||||
: p
|
||||
).ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// EN: Stock levels are optional; continue without them
|
||||
// VI: Muc ton kho la tuy chon; tiep tuc khong co
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handle Enter key in barcode input for quick scan.
|
||||
/// VI: Xu ly phim Enter trong o nhap ma vach de quet nhanh.
|
||||
/// </summary>
|
||||
private async Task OnBarcodeKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_barcodeInput))
|
||||
{
|
||||
await SearchBarcode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Search product by barcode/SKU — first check local list, then call API.
|
||||
/// VI: Tim san pham theo ma vach/SKU — kiem tra danh sach local truoc, sau do goi API.
|
||||
/// </summary>
|
||||
private async Task SearchBarcode()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_barcodeInput)) return;
|
||||
|
||||
_barcodeNotFound = false;
|
||||
_isSearching = true;
|
||||
StateHasChanged();
|
||||
|
||||
// EN: First try local match by SKU / VI: Thu khop local theo SKU truoc
|
||||
var found = _products.FirstOrDefault(p =>
|
||||
p.Sku.Equals(_barcodeInput, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (found != null)
|
||||
{
|
||||
AddToCart(found);
|
||||
_barcodeInput = "";
|
||||
_isSearching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// EN: Then try API barcode lookup / VI: Sau do thu tra cuu API theo ma vach
|
||||
try
|
||||
{
|
||||
var lookupResult = await DataService.LookupProductByBarcodeAsync(ShopId, _barcodeInput);
|
||||
if (lookupResult != null)
|
||||
{
|
||||
// EN: Check if product already in grid / VI: Kiem tra san pham da co trong luoi chua
|
||||
var existing = _products.FirstOrDefault(p => p.Id == lookupResult.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
AddToCart(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
// EN: Add to grid and cart / VI: Them vao luoi va gio hang
|
||||
var newProduct = new ProductWithStock(lookupResult.Id, lookupResult.Name,
|
||||
lookupResult.Sku ?? lookupResult.Barcode ?? "", lookupResult.Price,
|
||||
"Khac", "package", null);
|
||||
_products.Add(newProduct);
|
||||
AddToCart(newProduct);
|
||||
}
|
||||
_barcodeInput = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
_barcodeNotFound = true;
|
||||
Snackbar.Add("Khong tim thay san pham voi ma vach nay", Severity.Warning);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_barcodeNotFound = true;
|
||||
Snackbar.Add("Loi khi tra cuu san pham", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddToCart(ProductWithStock product)
|
||||
{
|
||||
// EN: Check stock availability / VI: Kiem tra ton kho
|
||||
if (product.StockInfo != null && product.StockInfo.Available <= 0)
|
||||
{
|
||||
Snackbar.Add($"{product.Name} da het hang", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = _cartItems.FirstOrDefault(i => i.ProductId == product.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Qty++;
|
||||
// EN: Warn if exceeding stock / VI: Canh bao neu vuot ton kho
|
||||
if (existing.StockAvailable.HasValue && existing.Qty > existing.StockAvailable.Value)
|
||||
Snackbar.Add($"{product.Name}: so luong vuot ton kho ({existing.StockAvailable})", Severity.Warning);
|
||||
}
|
||||
else
|
||||
{
|
||||
_cartItems.Add(new CartItem(product.Id, product.Name, product.Sku, product.Price,
|
||||
product.StockInfo?.Available));
|
||||
}
|
||||
}
|
||||
|
||||
private void ChangeQty(CartItem item, int delta)
|
||||
{
|
||||
item.Qty += delta;
|
||||
if (item.Qty <= 0) _cartItems.Remove(item);
|
||||
else if (item.StockAvailable.HasValue && item.Qty > item.StockAvailable.Value)
|
||||
Snackbar.Add($"{item.Name}: so luong vuot ton kho ({item.StockAvailable})", Severity.Warning);
|
||||
}
|
||||
|
||||
private void SearchBarcode()
|
||||
private void ClearCart()
|
||||
{
|
||||
var found = _products.FirstOrDefault(p => p.Sku.Equals(_barcodeInput, StringComparison.OrdinalIgnoreCase));
|
||||
if (found != null) AddToCart(found);
|
||||
_barcodeInput = "";
|
||||
_cartItems.Clear();
|
||||
_discountAmount = 0;
|
||||
}
|
||||
|
||||
private void Checkout() => NavigateTo("payment-method-select");
|
||||
private void Checkout()
|
||||
{
|
||||
if (!_cartItems.Any()) return;
|
||||
NavigateTo("payment-method-select");
|
||||
}
|
||||
|
||||
private static string GetCategoryIcon(string category) => category switch
|
||||
{
|
||||
"Thời trang" => "shirt", "Phụ kiện" => "shopping-bag", "Điện tử" => "headphones",
|
||||
"Gia dụng" => "cooking-pot", "Mỹ phẩm" => "sparkles", _ => "package"
|
||||
"Thoi trang" => "shirt", "Phu kien" => "shopping-bag", "Dien tu" => "headphones",
|
||||
"Gia dung" => "cooking-pot", "My pham" => "sparkles", _ => "package"
|
||||
};
|
||||
|
||||
// EN: Models / VI: Mô hình dữ liệu
|
||||
private record Product(string Name, string Sku, decimal Price, string Category, string Icon);
|
||||
private class CartItem(string name, string sku, decimal price)
|
||||
// EN: Models / VI: Mo hinh du lieu
|
||||
private record ProductWithStock(Guid Id, string Name, string Sku, decimal Price, string Category, string Icon,
|
||||
PosDataService.StockLevelInfo? StockInfo);
|
||||
private class CartItem(Guid productId, string name, string sku, decimal price, int? stockAvailable)
|
||||
{
|
||||
public Guid ProductId { get; set; } = productId;
|
||||
public string Name { get; set; } = name;
|
||||
public string Sku { get; set; } = sku;
|
||||
public decimal Price { get; set; } = price;
|
||||
public int Qty { get; set; } = 1;
|
||||
public int? StockAvailable { get; set; } = stockAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
@*
|
||||
EN: Return/Exchange Dialog — Reusable MudDialog for processing returns and exchanges.
|
||||
Search original order, select items to return, choose return or exchange mode,
|
||||
scan new items for exchange, calculate refund/price difference.
|
||||
VI: Dialog Tra/Doi hang — MudDialog tai su dung de xu ly tra va doi hang.
|
||||
Tim don goc, chon san pham tra, chon che do tra hoac doi,
|
||||
quet san pham moi de doi, tinh hoan tien/chenh lech.
|
||||
*@
|
||||
@inject WebClientTpos.Client.Services.PosDataService DataService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<i data-lucide="rotate-ccw" style="width:20px;height:20px;color:var(--pos-orange-primary);"></i>
|
||||
<span style="font-weight:700;">Tra hang / Doi hang</span>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<div style="min-width:700px;max-height:70vh;overflow-y:auto;">
|
||||
|
||||
@* ═══ ORDER LOOKUP / TRA CUU DON HANG ═══ *@
|
||||
<div style="margin-bottom:16px;">
|
||||
<MudTextField @bind-Value="_orderIdInput" Label="Ma don goc" Placeholder="Nhap ma don hang..."
|
||||
Variant="Variant.Outlined" Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
OnAdornmentClick="LookupOrder" OnKeyDown="OnOrderKeyDown"
|
||||
FullWidth="true" Margin="Margin.Dense" />
|
||||
</div>
|
||||
|
||||
@if (_isSearching)
|
||||
{
|
||||
<div style="text-align:center;padding:20px;">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Warning" />
|
||||
<div style="margin-top:8px;font-size:13px;color:var(--pos-text-tertiary);">Dang tim don hang...</div>
|
||||
</div>
|
||||
}
|
||||
else if (_orderNotFound)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
|
||||
Khong tim thay don hang voi ma nay
|
||||
</MudAlert>
|
||||
}
|
||||
else if (_orderDetail != null)
|
||||
{
|
||||
@* ═══ ORDER INFO / THONG TIN DON HANG ═══ *@
|
||||
<MudPaper Outlined="true" Class="pa-3 mb-3">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<MudText Typo="Typo.subtitle1" Style="font-weight:700;">Don hang #@_orderDetail.Order?.Id.ToString()[..8]</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Default">@_orderDetail.Order?.CreatedAt.ToString("dd/MM/yyyy HH:mm") - @_orderDetail.Order?.Status</MudText>
|
||||
</div>
|
||||
<MudText Typo="Typo.subtitle1" Color="Color.Warning" Style="font-weight:700;">
|
||||
@FormatPrice(_orderDetail.Order?.TotalAmount ?? 0)
|
||||
</MudText>
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@* ═══ MODE TOGGLE / CHUYEN CHE DO ═══ *@
|
||||
<div style="display:flex;gap:8px;margin-bottom:16px;">
|
||||
<MudButton Variant="@(_mode == ReturnMode.Return ? Variant.Filled : Variant.Outlined)"
|
||||
Color="Color.Warning" OnClick="@(() => _mode = ReturnMode.Return)"
|
||||
StartIcon="@Icons.Material.Filled.AssignmentReturn" Size="Size.Small">
|
||||
Tra hang
|
||||
</MudButton>
|
||||
<MudButton Variant="@(_mode == ReturnMode.Exchange ? Variant.Filled : Variant.Outlined)"
|
||||
Color="Color.Info" OnClick="@(() => _mode = ReturnMode.Exchange)"
|
||||
StartIcon="@Icons.Material.Filled.SwapHoriz" Size="Size.Small">
|
||||
Doi hang
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@* ═══ SELECT RETURN ITEMS / CHON SAN PHAM TRA ═══ *@
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2" Style="font-weight:600;">Chon san pham tra lai</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mb-3" Style="background:transparent;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px;"></th>
|
||||
<th>San pham</th>
|
||||
<th style="width:80px;text-align:center;">SL goc</th>
|
||||
<th style="width:100px;text-align:center;">So luong tra</th>
|
||||
<th style="width:120px;text-align:right;">Thanh tien</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_orderDetail.Items != null)
|
||||
{
|
||||
@foreach (var item in _returnItems)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<MudCheckBox @bind-Value="item.Selected" Color="Color.Warning" Dense="true" />
|
||||
</td>
|
||||
<td>
|
||||
<MudText Typo="Typo.body2">@item.ProductName</MudText>
|
||||
</td>
|
||||
<td style="text-align:center;">@item.OriginalQty</td>
|
||||
<td style="text-align:center;">
|
||||
@if (item.Selected)
|
||||
{
|
||||
<MudNumericField @bind-Value="item.ReturnQty" Min="1" Max="item.OriginalQty"
|
||||
Variant="Variant.Outlined" Margin="Margin.Dense"
|
||||
Style="max-width:80px;" HideSpinButtons="false" />
|
||||
}
|
||||
</td>
|
||||
<td style="text-align:right;font-weight:600;color:var(--pos-danger);">
|
||||
@if (item.Selected)
|
||||
{
|
||||
<span>-@FormatPrice(item.UnitPrice * item.ReturnQty)</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
|
||||
@* ═══ EXCHANGE: NEW ITEMS SCANNER / DOI: QUET SAN PHAM MOI ═══ *@
|
||||
@if (_mode == ReturnMode.Exchange)
|
||||
{
|
||||
<MudDivider Class="my-3" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2" Style="font-weight:600;">San pham doi moi</MudText>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;">
|
||||
<MudTextField @bind-Value="_exchangeBarcodeInput" Label="Quet ma vach san pham moi"
|
||||
Variant="Variant.Outlined" Margin="Margin.Dense" FullWidth="true"
|
||||
OnKeyDown="OnExchangeBarcodeKeyDown" />
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Info" OnClick="SearchExchangeProduct"
|
||||
StartIcon="@Icons.Material.Filled.Search" Size="Size.Small">
|
||||
Tim
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_exchangeItems.Any())
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mb-3" Style="background:transparent;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>San pham moi</th>
|
||||
<th style="width:100px;text-align:center;">So luong</th>
|
||||
<th style="width:120px;text-align:right;">Don gia</th>
|
||||
<th style="width:120px;text-align:right;">Thanh tien</th>
|
||||
<th style="width:40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in _exchangeItems)
|
||||
{
|
||||
<tr>
|
||||
<td><MudText Typo="Typo.body2">@item.Name</MudText></td>
|
||||
<td style="text-align:center;">
|
||||
<MudNumericField @bind-Value="item.Qty" Min="1"
|
||||
Variant="Variant.Outlined" Margin="Margin.Dense"
|
||||
Style="max-width:80px;" HideSpinButtons="false" />
|
||||
</td>
|
||||
<td style="text-align:right;">@FormatPrice(item.UnitPrice)</td>
|
||||
<td style="text-align:right;font-weight:600;color:var(--pos-success);">
|
||||
+@FormatPrice(item.UnitPrice * item.Qty)
|
||||
</td>
|
||||
<td>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small"
|
||||
Color="Color.Error" OnClick="@(() => _exchangeItems.Remove(item))" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
}
|
||||
|
||||
@* ═══ REASON / LY DO ═══ *@
|
||||
<MudTextField @bind-Value="_reason" Label="Ly do tra/doi hang *" Placeholder="Nhap ly do..."
|
||||
Variant="Variant.Outlined" FullWidth="true" Margin="Margin.Dense" Required="true"
|
||||
Lines="2" Class="mb-3" />
|
||||
|
||||
@* ═══ SUMMARY / TOM TAT ═══ *@
|
||||
<MudPaper Outlined="true" Class="pa-3" Style="background:var(--pos-bg-interactive);">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:8px;">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">Tong tra:</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Error" Style="font-weight:700;">
|
||||
-@FormatPrice(ReturnTotal)
|
||||
</MudText>
|
||||
</div>
|
||||
@if (_mode == ReturnMode.Exchange)
|
||||
{
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:8px;">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">Tong doi moi:</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Success" Style="font-weight:700;">
|
||||
+@FormatPrice(ExchangeTotal)
|
||||
</MudText>
|
||||
</div>
|
||||
}
|
||||
<MudDivider Class="my-2" />
|
||||
<div style="display:flex;justify-content:space-between;">
|
||||
<MudText Typo="Typo.subtitle1" Style="font-weight:700;">
|
||||
@(_mode == ReturnMode.Return ? "Hoan tien:" : (Difference >= 0 ? "Khach tra them:" : "Hoan tien:"))
|
||||
</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Style="font-weight:800;font-size:20px;"
|
||||
Color="@(Difference >= 0 ? Color.Warning : Color.Error)">
|
||||
@(Difference >= 0 ? "" : "-")@FormatPrice(Math.Abs(Difference))
|
||||
</MudText>
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel" Variant="Variant.Text">Huy</MudButton>
|
||||
<MudButton OnClick="Confirm" Variant="Variant.Filled" Color="Color.Warning"
|
||||
Disabled="@(!CanConfirm)" StartIcon="@Icons.Material.Filled.Check">
|
||||
Xac nhan @(_mode == ReturnMode.Return ? "tra hang" : "doi hang")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Shop ID for API calls.
|
||||
/// VI: ID cua hang cho cac cuoc goi API.
|
||||
/// </summary>
|
||||
[Parameter] public Guid ShopId { get; set; }
|
||||
|
||||
// EN: State / VI: Trang thai
|
||||
private string _orderIdInput = "";
|
||||
private bool _isSearching;
|
||||
private bool _orderNotFound;
|
||||
private bool _isProcessing;
|
||||
private ReturnMode _mode = ReturnMode.Return;
|
||||
private string _reason = "";
|
||||
private string _exchangeBarcodeInput = "";
|
||||
|
||||
private PosDataService.OrderDetailResponse? _orderDetail;
|
||||
private List<ReturnItemModel> _returnItems = new();
|
||||
private readonly List<ExchangeItemModel> _exchangeItems = new();
|
||||
|
||||
// EN: Calculated totals / VI: Tong tinh toan
|
||||
private decimal ReturnTotal => _returnItems.Where(i => i.Selected).Sum(i => i.UnitPrice * i.ReturnQty);
|
||||
private decimal ExchangeTotal => _exchangeItems.Sum(i => i.UnitPrice * i.Qty);
|
||||
private decimal Difference => _mode == ReturnMode.Return ? -ReturnTotal : ExchangeTotal - ReturnTotal;
|
||||
|
||||
private bool CanConfirm =>
|
||||
_orderDetail != null &&
|
||||
_returnItems.Any(i => i.Selected) &&
|
||||
!string.IsNullOrWhiteSpace(_reason) &&
|
||||
!_isProcessing &&
|
||||
(_mode == ReturnMode.Return || _exchangeItems.Any());
|
||||
|
||||
private static string FormatPrice(decimal price) => price.ToString("N0") + "d";
|
||||
|
||||
private async Task OnOrderKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter") await LookupOrder();
|
||||
}
|
||||
|
||||
private async Task OnExchangeBarcodeKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter") await SearchExchangeProduct();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Lookup original order by ID.
|
||||
/// VI: Tra cuu don hang goc theo ma.
|
||||
/// </summary>
|
||||
private async Task LookupOrder()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_orderIdInput)) return;
|
||||
|
||||
_isSearching = true;
|
||||
_orderNotFound = false;
|
||||
_orderDetail = null;
|
||||
_returnItems.Clear();
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// EN: Try to parse as GUID first / VI: Thu parse thanh GUID truoc
|
||||
Guid orderId;
|
||||
if (!Guid.TryParse(_orderIdInput, out orderId))
|
||||
{
|
||||
_orderNotFound = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var detail = await DataService.GetOrderDetailAsync(orderId, ShopId);
|
||||
if (detail?.Order == null)
|
||||
{
|
||||
_orderNotFound = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_orderDetail = detail;
|
||||
_returnItems = detail.Items?.Select(i => new ReturnItemModel
|
||||
{
|
||||
OrderItemId = i.Id,
|
||||
ProductName = i.ProductName ?? "San pham",
|
||||
OriginalQty = i.Quantity,
|
||||
UnitPrice = i.UnitPrice,
|
||||
ReturnQty = i.Quantity,
|
||||
Selected = false
|
||||
}).ToList() ?? new();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_orderNotFound = true;
|
||||
Snackbar.Add("Loi khi tra cuu don hang", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Search and add a product for exchange.
|
||||
/// VI: Tim va them san pham de doi.
|
||||
/// </summary>
|
||||
private async Task SearchExchangeProduct()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_exchangeBarcodeInput)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var product = await DataService.LookupProductByBarcodeAsync(ShopId, _exchangeBarcodeInput);
|
||||
if (product != null)
|
||||
{
|
||||
var existing = _exchangeItems.FirstOrDefault(i => i.ProductId == product.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Qty++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_exchangeItems.Add(new ExchangeItemModel
|
||||
{
|
||||
ProductId = product.Id,
|
||||
Name = product.Name,
|
||||
UnitPrice = product.Price,
|
||||
Qty = 1
|
||||
});
|
||||
}
|
||||
_exchangeBarcodeInput = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Khong tim thay san pham", Severity.Warning);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Snackbar.Add("Loi khi tim san pham", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Confirm return or exchange.
|
||||
/// VI: Xac nhan tra hoac doi hang.
|
||||
/// </summary>
|
||||
private async Task Confirm()
|
||||
{
|
||||
if (!CanConfirm) return;
|
||||
|
||||
_isProcessing = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var selectedItems = _returnItems.Where(i => i.Selected).Select(i =>
|
||||
new PosDataService.ReturnItemInfo(i.OrderItemId, i.ReturnQty, _reason)).ToList();
|
||||
|
||||
if (_mode == ReturnMode.Return)
|
||||
{
|
||||
var (ok, error) = await DataService.CreateReturnAsync(ShopId, _orderDetail!.Order!.Id, selectedItems, _reason);
|
||||
if (ok)
|
||||
{
|
||||
Snackbar.Add("Tra hang thanh cong!", Severity.Success);
|
||||
MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add(error ?? "Khong the xu ly tra hang", Severity.Error);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var newItems = _exchangeItems.Select(i =>
|
||||
new PosDataService.ExchangeItemInfo(i.ProductId, i.Qty, i.UnitPrice)).ToList();
|
||||
|
||||
var (ok, error) = await DataService.CreateExchangeAsync(ShopId, _orderDetail!.Order!.Id,
|
||||
selectedItems, newItems, _reason);
|
||||
if (ok)
|
||||
{
|
||||
Snackbar.Add("Doi hang thanh cong!", Severity.Success);
|
||||
MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add(error ?? "Khong the xu ly doi hang", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Snackbar.Add("Loi he thong khi xu ly", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
|
||||
// EN: Models / VI: Mo hinh du lieu
|
||||
private enum ReturnMode { Return, Exchange }
|
||||
|
||||
private class ReturnItemModel
|
||||
{
|
||||
public Guid OrderItemId { get; set; }
|
||||
public string ProductName { get; set; } = "";
|
||||
public int OriginalQty { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public int ReturnQty { get; set; }
|
||||
public bool Selected { get; set; }
|
||||
}
|
||||
|
||||
private class ExchangeItemModel
|
||||
{
|
||||
public Guid ProductId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public decimal UnitPrice { get; set; }
|
||||
public int Qty { get; set; } = 1;
|
||||
}
|
||||
}
|
||||
@@ -191,7 +191,8 @@ public class PosDataService
|
||||
}
|
||||
public record CategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder, string? ImageUrl = null);
|
||||
public record TableInfo(Guid Id, string TableNumber, int Capacity, string? Zone, string Status, Guid? SessionId, int? GuestCount, DateTime? StartedAt, decimal HourlyRate = 0, int? PositionX = null, int? PositionY = null, string? QrToken = null);
|
||||
public record AppointmentInfo(Guid Id, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid ServiceId, DateTime StartTime, DateTime EndTime, string Status, string? ResourceName);
|
||||
public record AppointmentInfo(Guid Id, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid ServiceId, DateTime StartTime, DateTime EndTime, string Status, string? ResourceName,
|
||||
string? TherapistName = null, string? ServiceName = null, string? CustomerName = null, string? Notes = null);
|
||||
public record ShopAssignmentInfo(Guid ShopId, string? ShopRole, Guid? BranchId);
|
||||
public record StaffInfo(Guid Id, Guid? UserId, string? EmployeeCode, string? Phone, string? Email, DateTime? JoinedAt, DateTime? TerminatedAt, string? Role, string? Status, string? ShopName,
|
||||
string? FirstName = null, string? LastName = null, string? Address = null, string? ProfilePhotoUrl = null, string? DocumentFrontUrl = null, string? DocumentBackUrl = null,
|
||||
@@ -1013,6 +1014,43 @@ public class PosDataService
|
||||
public async Task<List<MemberInfo>> SearchCustomersAsync(Guid shopId, string query)
|
||||
=> await GetMembersAsync(query);
|
||||
|
||||
// ═══ THERAPIST / SPA STAFF CRUD ═══
|
||||
|
||||
// EN: Therapist record for Spa/Beauty management
|
||||
// VI: Record nhân viên trị liệu cho quản lý Spa/Beauty
|
||||
public record TherapistInfo(Guid Id, string Name, string[]? Specialties, bool IsActive, string? Phone = null, string? Email = null, string? WorkingHours = null);
|
||||
public record CreateTherapistRequest(Guid ShopId, string Name, string[]? Specialties, string? Phone = null, string? Email = null, string? WorkingHours = null);
|
||||
|
||||
public async Task<List<TherapistInfo>> GetTherapistsAsync(Guid shopId)
|
||||
=> await GetListFromApiAsync<TherapistInfo>($"api/bff/shops/{shopId}/therapists");
|
||||
|
||||
public async Task<bool> CreateTherapistAsync(CreateTherapistRequest req)
|
||||
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/therapists", req, _writeOptions); return r.IsSuccessStatusCode; }
|
||||
|
||||
public async Task<bool> UpdateTherapistAsync(Guid therapistId, CreateTherapistRequest req)
|
||||
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/therapists/{therapistId}", req, _writeOptions); return r.IsSuccessStatusCode; }
|
||||
|
||||
public async Task<bool> DeactivateTherapistAsync(Guid therapistId)
|
||||
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/therapists/{therapistId}"); return r.IsSuccessStatusCode; }
|
||||
|
||||
// ═══ ENHANCED APPOINTMENT METHODS (SPA) ═══
|
||||
|
||||
// EN: Get appointments for a specific date / VI: Lấy lịch hẹn theo ngày cụ thể
|
||||
public async Task<List<AppointmentInfo>> GetAppointmentsByDateAsync(Guid shopId, DateTime date)
|
||||
=> await GetListFromApiAsync<AppointmentInfo>($"api/bff/shops/{shopId}/appointments?date={date:yyyy-MM-dd}");
|
||||
|
||||
// EN: Cancel appointment with reason / VI: Hủy lịch hẹn kèm lý do
|
||||
public async Task<bool> CancelAppointmentWithReasonAsync(Guid apptId, string reason)
|
||||
{
|
||||
AttachToken();
|
||||
var request = new HttpRequestMessage(HttpMethod.Delete, $"api/bff/appointments/{apptId}/cancel")
|
||||
{
|
||||
Content = JsonContent.Create(new { reason }, options: _writeOptions)
|
||||
};
|
||||
var r = await _http.SendAsync(request);
|
||||
return r.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
// ═══ RESOURCES CRUD ═══
|
||||
|
||||
public record CreateResourceRequest(Guid ShopId, string Name, string ResourceType, int Capacity);
|
||||
@@ -1399,6 +1437,180 @@ public class PosDataService
|
||||
return await PostAndGetAsync<CloseDayResultInfo>(url, new { shopId, closeDate = date.ToString("yyyy-MM-dd") });
|
||||
}
|
||||
|
||||
// ═══ RETAIL POS — BARCODE LOOKUP, STOCK LEVELS, RETURNS/EXCHANGES ═══
|
||||
|
||||
// EN: Product lookup info from catalog-service (barcode/SKU search).
|
||||
// VI: Thong tin san pham tra cuu tu catalog-service (tim theo ma vach/SKU).
|
||||
public record ProductLookupInfo(Guid Id, string Name, string? Sku, string? Barcode, decimal Price, string? ImageUrl);
|
||||
|
||||
// EN: Stock level info from inventory-service (bulk stock check).
|
||||
// VI: Thong tin ton kho tu inventory-service (kiem tra hang loat).
|
||||
public record StockLevelInfo(Guid ProductId, int Available, int Reserved, int Minimum, bool IsLowStock);
|
||||
|
||||
// EN: Return item info — items being returned from an order.
|
||||
// VI: Thong tin san pham tra — san pham dang tra tu don hang.
|
||||
public record ReturnItemInfo(Guid OrderItemId, int Quantity, string? Reason);
|
||||
|
||||
// EN: Exchange item info — new items replacing returned items.
|
||||
// VI: Thong tin san pham doi — san pham moi thay the san pham tra.
|
||||
public record ExchangeItemInfo(Guid ProductId, int Quantity, decimal UnitPrice);
|
||||
|
||||
// EN: Return order info — history of returns for an order.
|
||||
// VI: Thong tin don tra — lich su tra hang cua don hang.
|
||||
public record ReturnOrderInfo(Guid Id, DateTime ReturnedAt, string Reason, decimal TotalAmount, List<ReturnItemInfo> Items);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Lookup product by barcode or SKU from catalog-service.
|
||||
/// VI: Tra cuu san pham theo ma vach hoac SKU tu catalog-service.
|
||||
/// </summary>
|
||||
public async Task<ProductLookupInfo?> LookupProductByBarcodeAsync(Guid shopId, string barcode)
|
||||
=> await GetObjectFromApiAsync<ProductLookupInfo>($"api/bff/shops/{shopId}/products/lookup?barcode={Uri.EscapeDataString(barcode)}");
|
||||
|
||||
/// <summary>
|
||||
/// EN: Bulk stock check for multiple products from inventory-service.
|
||||
/// VI: Kiem tra ton kho hang loat cho nhieu san pham tu inventory-service.
|
||||
/// </summary>
|
||||
public async Task<List<StockLevelInfo>> GetStockLevelsAsync(Guid shopId, List<Guid> productIds)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync($"api/bff/shops/{shopId}/inventory/stock-levels",
|
||||
new { productIds }, _writeOptions);
|
||||
if (!resp.IsSuccessStatusCode) return new();
|
||||
var json = await resp.Content.ReadAsStringAsync();
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Array)
|
||||
return JsonSerializer.Deserialize<List<StockLevelInfo>>(data.GetRawText(), _jsonOptions) ?? new();
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
return JsonSerializer.Deserialize<List<StockLevelInfo>>(json, _jsonOptions) ?? new();
|
||||
return new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a return for an order (process refund).
|
||||
/// VI: Tao don tra hang cho don hang (xu ly hoan tien).
|
||||
/// </summary>
|
||||
public async Task<(bool Ok, string? Error)> CreateReturnAsync(Guid shopId, Guid originalOrderId, List<ReturnItemInfo> items, string reason)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync("api/bff/orders/returns",
|
||||
new { shopId, originalOrderId, items, reason }, _writeOptions);
|
||||
if (resp.IsSuccessStatusCode) return (true, null);
|
||||
return (false, await TryExtractError(resp));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create an exchange for an order (return + new items).
|
||||
/// VI: Tao don doi hang cho don hang (tra + san pham moi).
|
||||
/// </summary>
|
||||
public async Task<(bool Ok, string? Error)> CreateExchangeAsync(Guid shopId, Guid originalOrderId,
|
||||
List<ReturnItemInfo> returnItems, List<ExchangeItemInfo> newItems, string reason)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync("api/bff/orders/exchanges",
|
||||
new { shopId, originalOrderId, returnItems, newItems, reason }, _writeOptions);
|
||||
if (resp.IsSuccessStatusCode) return (true, null);
|
||||
return (false, await TryExtractError(resp));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get return history for a specific order.
|
||||
/// VI: Lay lich su tra hang cho don hang cu the.
|
||||
/// </summary>
|
||||
public async Task<List<ReturnOrderInfo>> GetOrderReturnsAsync(Guid orderId)
|
||||
=> await GetListFromApiAsync<ReturnOrderInfo>($"api/bff/orders/{orderId}/returns");
|
||||
|
||||
// ═══ CAFE — STAMP CARDS (membership-service) ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Stamp card info — customer's current stamp card state.
|
||||
/// VI: Thong tin the tem — trang thai the tem hien tai cua khach hang.
|
||||
/// </summary>
|
||||
public record StampCardInfo(Guid Id, Guid MemberId, string CardName, int TotalStampsRequired,
|
||||
int CurrentStamps, bool IsCompleted, bool RewardClaimed);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Barista queue item — a single drink in the preparation queue.
|
||||
/// VI: Mon trong hang doi barista — mot ly trong hang doi pha che.
|
||||
/// </summary>
|
||||
public record BaristaQueueItemInfo(Guid Id, string DrinkName, string? Customizations,
|
||||
string Status, string? AssignedTo, int EstimatedMinutes, int Priority,
|
||||
DateTime CreatedAt, DateTime? StartedAt);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Barista queue stats — aggregate counts for the queue dashboard.
|
||||
/// VI: Thong ke hang doi barista — so lieu tong hop cho bang dieu khien hang doi.
|
||||
/// </summary>
|
||||
public record BaristaQueueStatsInfo(int TotalQueued, int Preparing, int Ready, double AvgPrepTimeMinutes);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a customer's stamp card for a specific shop.
|
||||
/// VI: Lay the tem cua khach hang cho mot cua hang cu the.
|
||||
/// </summary>
|
||||
public async Task<StampCardInfo?> GetStampCardAsync(Guid shopId, Guid memberId)
|
||||
=> await GetObjectFromApiAsync<StampCardInfo>($"api/bff/cafe/{shopId}/stamp-cards/{memberId}");
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a stamp to the customer's card (optionally linked to an order).
|
||||
/// VI: Them tem vao the khach hang (tuy chon lien ket voi don hang).
|
||||
/// </summary>
|
||||
public async Task<StampCardInfo?> AddStampAsync(Guid shopId, Guid memberId, Guid? orderId = null)
|
||||
=> await PostAndGetAsync<StampCardInfo>($"api/bff/cafe/{shopId}/stamp-cards/{memberId}/stamps",
|
||||
new { orderId });
|
||||
|
||||
/// <summary>
|
||||
/// EN: Claim the free-drink reward when the stamp card is completed.
|
||||
/// VI: Nhan thuong ly mien phi khi the tem da day.
|
||||
/// </summary>
|
||||
public async Task<bool> ClaimRewardAsync(Guid stampCardId)
|
||||
=> await PostAsync($"api/bff/cafe/stamp-cards/{stampCardId}/claim", new { });
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all items currently in the barista queue for a shop.
|
||||
/// VI: Lay tat ca mon dang trong hang doi barista cua cua hang.
|
||||
/// </summary>
|
||||
public async Task<List<BaristaQueueItemInfo>> GetBaristaQueueAsync(Guid shopId)
|
||||
=> await GetListFromApiAsync<BaristaQueueItemInfo>($"api/bff/cafe/{shopId}/barista-queue");
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get aggregate stats for the barista queue.
|
||||
/// VI: Lay thong ke tong hop cho hang doi barista.
|
||||
/// </summary>
|
||||
public async Task<BaristaQueueStatsInfo?> GetBaristaQueueStatsAsync(Guid shopId)
|
||||
=> await GetObjectFromApiAsync<BaristaQueueStatsInfo>($"api/bff/cafe/{shopId}/barista-queue/stats");
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a drink to the barista queue.
|
||||
/// VI: Them ly vao hang doi barista.
|
||||
/// </summary>
|
||||
public record QueueDrinkRequest(string DrinkName, string? Customizations, int EstimatedMinutes, int Priority = 0, Guid? OrderId = null);
|
||||
|
||||
public async Task<BaristaQueueItemInfo?> QueueDrinkAsync(Guid shopId, QueueDrinkRequest req)
|
||||
=> await PostAndGetAsync<BaristaQueueItemInfo>($"api/bff/cafe/{shopId}/barista-queue", req);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Start preparing a drink — assigns barista name.
|
||||
/// VI: Bat dau pha che — gan ten barista.
|
||||
/// </summary>
|
||||
public async Task<bool> StartPreparingDrinkAsync(Guid queueItemId, string baristaName)
|
||||
=> await PostAsync($"api/bff/cafe/barista-queue/{queueItemId}/start",
|
||||
new { baristaName });
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark a drink as ready for pickup.
|
||||
/// VI: Danh dau ly da san sang de lay.
|
||||
/// </summary>
|
||||
public async Task<bool> MarkDrinkReadyAsync(Guid queueItemId)
|
||||
=> await PostAsync($"api/bff/cafe/barista-queue/{queueItemId}/ready", new { });
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark a drink as delivered to the customer.
|
||||
/// VI: Danh dau ly da giao cho khach hang.
|
||||
/// </summary>
|
||||
public async Task<bool> MarkDrinkDeliveredAsync(Guid queueItemId)
|
||||
=> await PostAsync($"api/bff/cafe/barista-queue/{queueItemId}/delivered", new { });
|
||||
|
||||
// ═══ SERVICE HEALTH CHECK ═══
|
||||
|
||||
public record ServiceHealthInfo(string Name, string Icon, bool IsOnline, int? LatencyMs);
|
||||
|
||||
@@ -81,6 +81,7 @@ public static class ShopSidebarConfig
|
||||
new("Shop_Menu_Overview", "layout-dashboard", "overview"),
|
||||
new("Shop_Menu_POS", "monitor", "pos"),
|
||||
new("Shop_Menu_Appointments", "calendar", "appointments"),
|
||||
new("Shop_Menu_Therapists", "user-check", "therapists"),
|
||||
new("Shop_Menu_Services", "sparkles", "services"),
|
||||
new("Shop_Menu_Packages", "gift", "packages"),
|
||||
new("Shop_Menu_Combos", "layers", "combos"),
|
||||
@@ -92,6 +93,7 @@ public static class ShopSidebarConfig
|
||||
new("Shop_Menu_Overview", "layout-dashboard", "overview"),
|
||||
new("Shop_Menu_POS", "monitor", "pos"),
|
||||
new("Shop_Menu_Appointments", "calendar", "appointments"),
|
||||
new("Shop_Menu_Therapists", "user-check", "therapists"),
|
||||
new("Shop_Menu_Services", "sparkles", "services"),
|
||||
new("Shop_Menu_Treatments", "clipboard-list", "treatments"),
|
||||
new("Shop_Menu_Consent", "file-check", "consent"),
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"Shop_Menu_Rooms": "Rooms",
|
||||
"Shop_Menu_HappyHour": "Happy Hour",
|
||||
"Shop_Menu_Appointments": "Appointments",
|
||||
"Shop_Menu_Therapists": "Therapists",
|
||||
"Shop_Menu_Services": "Services",
|
||||
"Shop_Menu_Packages": "Service Packages",
|
||||
"Shop_Menu_Combos": "Service Combos",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"Shop_Menu_Rooms": "Phòng",
|
||||
"Shop_Menu_HappyHour": "Happy Hour",
|
||||
"Shop_Menu_Appointments": "Lịch hẹn",
|
||||
"Shop_Menu_Therapists": "Nhân viên trị liệu",
|
||||
"Shop_Menu_Services": "Dịch vụ",
|
||||
"Shop_Menu_Packages": "Gói dịch vụ",
|
||||
"Shop_Menu_Combos": "Combo dịch vụ",
|
||||
|
||||
@@ -19,14 +19,6 @@ public class BookingController : ControllerBase
|
||||
_booking = httpClientFactory.CreateClient("BookingService");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get appointments for a specific shop.
|
||||
/// VI: Lấy lịch hẹn của một cửa hàng cụ thể.
|
||||
/// </summary>
|
||||
[HttpGet("shops/{shopId}/appointments")]
|
||||
public Task<IActionResult> GetAppointments(Guid shopId) =>
|
||||
_booking.GetAsync($"/api/v1/appointments?shopId={shopId}").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create an appointment.
|
||||
/// VI: Tạo lịch hẹn.
|
||||
@@ -44,17 +36,80 @@ public class BookingController : ControllerBase
|
||||
_booking.PutAsJsonAsync($"/api/v1/appointments/{apptId}", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancel an appointment.
|
||||
/// VI: Hủy lịch hẹn.
|
||||
/// EN: Cancel an appointment — forwards reason from body if provided.
|
||||
/// VI: Hủy lịch hẹn — chuyển tiếp lý do từ body nếu có.
|
||||
/// </summary>
|
||||
[HttpDelete("appointments/{apptId:guid}/cancel")]
|
||||
public async Task<IActionResult> CancelAppointment(Guid apptId)
|
||||
{
|
||||
string? reason = "Cancelled from POS";
|
||||
try
|
||||
{
|
||||
using var bodyDoc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (bodyDoc.RootElement.TryGetProperty("reason", out var reasonProp))
|
||||
reason = reasonProp.GetString() ?? reason;
|
||||
}
|
||||
catch { /* no body or invalid JSON — use default reason */ }
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/appointments/{apptId}");
|
||||
request.Content = System.Net.Http.Json.JsonContent.Create(new { reason = "Cancelled from POS" });
|
||||
request.Content = System.Net.Http.Json.JsonContent.Create(new { reason });
|
||||
return await _booking.SendAsync(request).ProxyAsync();
|
||||
}
|
||||
|
||||
// ═══ THERAPIST / SPA STAFF ENDPOINTS ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get therapists for a specific shop.
|
||||
/// VI: Lấy danh sách nhân viên trị liệu của một cửa hàng.
|
||||
/// </summary>
|
||||
[HttpGet("shops/{shopId}/therapists")]
|
||||
public Task<IActionResult> GetTherapists(Guid shopId) =>
|
||||
_booking.GetAsync($"/api/v1/therapists?shopId={shopId}").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a therapist.
|
||||
/// VI: Tạo nhân viên trị liệu.
|
||||
/// </summary>
|
||||
[HttpPost("therapists")]
|
||||
public Task<IActionResult> CreateTherapist([FromBody] JsonElement body) =>
|
||||
_booking.PostAsJsonAsync("/api/v1/therapists", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update a therapist.
|
||||
/// VI: Cập nhật nhân viên trị liệu.
|
||||
/// </summary>
|
||||
[HttpPut("therapists/{therapistId:guid}")]
|
||||
public Task<IActionResult> UpdateTherapist(Guid therapistId, [FromBody] JsonElement body) =>
|
||||
_booking.PutAsJsonAsync($"/api/v1/therapists/{therapistId}", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Deactivate (soft-delete) a therapist.
|
||||
/// VI: Vô hiệu hóa (xóa mềm) nhân viên trị liệu.
|
||||
/// </summary>
|
||||
[HttpDelete("therapists/{therapistId:guid}")]
|
||||
public Task<IActionResult> DeactivateTherapist(Guid therapistId) =>
|
||||
_booking.DeleteAsync($"/api/v1/therapists/{therapistId}").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get appointments for a specific shop, optionally filtered by date.
|
||||
/// VI: Lấy lịch hẹn của một cửa hàng, có thể lọc theo ngày.
|
||||
/// </summary>
|
||||
[HttpGet("shops/{shopId}/appointments")]
|
||||
public Task<IActionResult> GetAppointments(Guid shopId, [FromQuery] string? date = null)
|
||||
{
|
||||
var qs = !string.IsNullOrEmpty(date) ? $"&date={date}" : "";
|
||||
return _booking.GetAsync($"/api/v1/appointments?shopId={shopId}{qs}").ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update appointment status (confirm, start, complete, noshow).
|
||||
/// VI: Cập nhật trạng thái lịch hẹn (xác nhận, bắt đầu, hoàn thành, vắng mặt).
|
||||
/// </summary>
|
||||
[HttpPatch("appointments/{apptId:guid}/status")]
|
||||
public Task<IActionResult> UpdateAppointmentStatus(Guid apptId, [FromBody] JsonElement body) =>
|
||||
_booking.PatchAsync($"/api/v1/appointments/{apptId}/status",
|
||||
System.Net.Http.Json.JsonContent.Create(body)).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get resources for a specific shop.
|
||||
/// VI: Lấy tài nguyên của một cửa hàng cụ thể.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WebClientTpos.Server.Infrastructure;
|
||||
|
||||
namespace WebClientTpos.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cafe controller — proxies stamp card endpoints to MembershipService
|
||||
/// and barista queue endpoints to FnbEngine.
|
||||
/// VI: Controller Cafe — proxy endpoint the tem den MembershipService
|
||||
/// va endpoint hang doi barista den FnbEngine.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/bff/cafe")]
|
||||
public class CafeController : ControllerBase
|
||||
{
|
||||
private readonly HttpClient _membership;
|
||||
private readonly HttpClient _fnb;
|
||||
|
||||
public CafeController(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_membership = httpClientFactory.CreateClient("MembershipService");
|
||||
_fnb = httpClientFactory.CreateClient("FnbEngine");
|
||||
}
|
||||
|
||||
// ═══ STAMP CARDS (membership-service) ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a customer's stamp card for a specific shop.
|
||||
/// VI: Lay the tem cua khach hang cho mot cua hang cu the.
|
||||
/// </summary>
|
||||
[HttpGet("{shopId:guid}/stamp-cards/{memberId:guid}")]
|
||||
public Task<IActionResult> GetStampCard(Guid shopId, Guid memberId) =>
|
||||
_membership.GetAsync($"/api/v1/stamp-cards?shopId={shopId}&memberId={memberId}").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a stamp to the customer's card.
|
||||
/// VI: Them tem vao the khach hang.
|
||||
/// </summary>
|
||||
[HttpPost("{shopId:guid}/stamp-cards/{memberId:guid}/stamps")]
|
||||
public Task<IActionResult> AddStamp(Guid shopId, Guid memberId, [FromBody] JsonElement body) =>
|
||||
_membership.PostAsJsonAsync($"/api/v1/stamp-cards/{shopId}/{memberId}/stamps", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Claim reward for a completed stamp card.
|
||||
/// VI: Nhan thuong cho the tem da hoan tat.
|
||||
/// </summary>
|
||||
[HttpPost("stamp-cards/{stampCardId:guid}/claim")]
|
||||
public Task<IActionResult> ClaimReward(Guid stampCardId, [FromBody] JsonElement body) =>
|
||||
_membership.PostAsJsonAsync($"/api/v1/stamp-cards/{stampCardId}/claim", body).ProxyAsync();
|
||||
|
||||
// ═══ BARISTA QUEUE (fnb-engine) ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all items in the barista queue for a shop.
|
||||
/// VI: Lay tat ca mon trong hang doi barista cua cua hang.
|
||||
/// </summary>
|
||||
[HttpGet("{shopId:guid}/barista-queue")]
|
||||
public Task<IActionResult> GetBaristaQueue(Guid shopId) =>
|
||||
_fnb.GetAsync($"/api/v1/barista-queue?shopId={shopId}").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get aggregate stats for the barista queue.
|
||||
/// VI: Lay thong ke tong hop cho hang doi barista.
|
||||
/// </summary>
|
||||
[HttpGet("{shopId:guid}/barista-queue/stats")]
|
||||
public Task<IActionResult> GetBaristaQueueStats(Guid shopId) =>
|
||||
_fnb.GetAsync($"/api/v1/barista-queue/stats?shopId={shopId}").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a drink to the barista queue.
|
||||
/// VI: Them ly vao hang doi barista.
|
||||
/// </summary>
|
||||
[HttpPost("{shopId:guid}/barista-queue")]
|
||||
public Task<IActionResult> QueueDrink(Guid shopId, [FromBody] JsonElement body) =>
|
||||
_fnb.PostAsJsonAsync($"/api/v1/barista-queue?shopId={shopId}", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Start preparing a drink — assigns barista name.
|
||||
/// VI: Bat dau pha che — gan ten barista.
|
||||
/// </summary>
|
||||
[HttpPost("barista-queue/{queueItemId:guid}/start")]
|
||||
public Task<IActionResult> StartPreparing(Guid queueItemId, [FromBody] JsonElement body) =>
|
||||
_fnb.PostAsJsonAsync($"/api/v1/barista-queue/{queueItemId}/start", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark a drink as ready for pickup.
|
||||
/// VI: Danh dau ly da san sang de lay.
|
||||
/// </summary>
|
||||
[HttpPost("barista-queue/{queueItemId:guid}/ready")]
|
||||
public Task<IActionResult> MarkReady(Guid queueItemId, [FromBody] JsonElement body) =>
|
||||
_fnb.PostAsJsonAsync($"/api/v1/barista-queue/{queueItemId}/ready", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark a drink as delivered to the customer.
|
||||
/// VI: Danh dau ly da giao cho khach hang.
|
||||
/// </summary>
|
||||
[HttpPost("barista-queue/{queueItemId:guid}/delivered")]
|
||||
public Task<IActionResult> MarkDelivered(Guid queueItemId, [FromBody] JsonElement body) =>
|
||||
_fnb.PostAsJsonAsync($"/api/v1/barista-queue/{queueItemId}/delivered", body).ProxyAsync();
|
||||
}
|
||||
@@ -172,7 +172,7 @@ http:
|
||||
# EN: Booking Service - Booking & Reservation Management
|
||||
# VI: Booking Service - Quản lý Đặt lịch & Đặt chỗ
|
||||
booking-service-router:
|
||||
rule: "PathPrefix(`/api/v1/bookings`) || PathPrefix(`/api/v1/reservations`)"
|
||||
rule: "PathPrefix(`/api/v1/bookings`) || PathPrefix(`/api/v1/reservations`) || PathPrefix(`/api/v1/therapists`) || PathPrefix(`/api/v1/appointments`)"
|
||||
service: booking-service
|
||||
priority: 100
|
||||
middlewares:
|
||||
|
||||
Reference in New Issue
Block a user