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:
Ho Ngoc Hai
2026-03-06 17:03:55 +07:00
parent 0d03feeffd
commit 870f1218f8
17 changed files with 3709 additions and 75 deletions

View File

@@ -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 |

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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; }
}
}

View File

@@ -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);
}

View File

@@ -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>&#127873;</span>
}
else if (isStamped)
{
<span>&#9749;</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;">&#127881;</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;">&#9989;</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;">&#128269;</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;
}
}

View File

@@ -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: Ô nhp mã vch *@
@* 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 ti...
<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 ti d liu
Khong the tai du lieu
</div>
}
else
{
@* EN: Category tabs / VI: Tab danh mc *@
@* 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 sn 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 sn phm</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: Tm 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>Tm 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">Tng cng</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: Trng thái ti
// EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
private bool _barcodeNotFound;
private bool _isSearching;
// EN: Categories / VI: Danh mc
private string[] _categories = { "Tt c" };
private string _selectedCategory = "Tt 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 == "Tt 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[] { "Tt 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
{
"Thi trang" => "shirt", "Ph kin" => "shopping-bag", "Điện t" => "headphones",
"Gia dng" => "cooking-pot", "M phm" => "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 liu
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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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"),

View File

@@ -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",

View File

@@ -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ụ",

View File

@@ -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ể.

View File

@@ -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();
}

View File

@@ -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: