diff --git a/ROADMAP.md b/ROADMAP.md
index 268890ed..d5d1e0f8 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -12,7 +12,7 @@
|--------|:-------:|:--------------:|:--------------:|:--------------:|
| Services production-ready | 8/24 | 12/24 | 16/24 | 20/24 |
| Test coverage (estimated) | ~50% | 70% | 80% | 85% |
-| POS verticals fully working | 2/5 | 2/5 (stable) | 4/5 | 5/5 |
+| POS verticals fully working | 2/5 | 2/5 (stable) | 5/5 | 5/5 |
| Payment methods live | 0 | 2 | 3 | 4+ |
| Real-time features | 0 | KDS + Orders | Full POS | Full |
| Mobile apps | Template | Template | iOS v1 | iOS + Android |
@@ -42,7 +42,7 @@
| fnb-engine | Controllers OK | Kitchen/Reservation/Session/Table controllers | Handler logic, 0 tests | P0 |
| catalog-service | Basic CRUD | Products, Categories controllers | Variants, advanced queries | P1 |
| inventory-service | Basic CRUD | Create/Update/Delete items | Auto-deduction, alerts, tests | P0 |
-| booking-service | 7 controllers | API structure, entities defined | Handler completion | P1 |
+| booking-service | 7 controllers | API structure, entities defined, handler logic complete, therapist + appointment CRUD | Handler completion | P1 |
| social-service | Core domain | Relationships, Blocks, Follows | API integration | P2 |
| mining-service | Skeleton | Controllers + entities defined | Business logic | P3 |
| mission-service | Controllers exist | Missions, CheckIns | Workflow handlers | P2 |
@@ -79,9 +79,9 @@
|----------|:-------:|:--------:|:----------:|:-------:|:------:|
| Karaoke | DONE | DONE | DONE | UI-ONLY | WORKING |
| Restaurant | DONE | DONE | DONE | UI-ONLY | WORKING |
-| Cafe | DONE | DONE | IN-PROGRESS | UI-ONLY | PARTIAL |
-| Spa/Beauty | IN-PROGRESS | DONE | PARTIAL | UI-ONLY | PARTIAL |
-| Retail | IN-PROGRESS | UI-ONLY | TODO | UI-ONLY | TODO |
+| Cafe | DONE | DONE | DONE | UI-ONLY | PARTIAL |
+| Spa/Beauty | DONE | DONE | DONE | UI-ONLY | PARTIAL |
+| Retail | DONE | UI-ONLY | DONE | UI-ONLY | TODO |
---
@@ -104,11 +104,11 @@
| # | Gap | Status | Owner | Sprint | Notes |
|:-:|-----|:------:|-------|:------:|-------|
-| 9 | Retail POS Workflow | `TODO` | Backend + Frontend | Phase 2 / W5-6 | Scan, stock, return/exchange |
-| 10 | Spa Backend Domain Logic | `TODO` | Backend | Phase 2 / W5-6 | Appointments, therapist scheduling |
+| 9 | Retail POS Workflow | `DONE` | Backend + Frontend | Phase 2 / W5-6 | Scan, stock, return/exchange |
+| 10 | Spa Backend Domain Logic | `DONE` | Backend | Phase 2 / W5-6 | Appointments, therapist scheduling |
| 11 | EOD Reports + Daily Close | `TODO` | Frontend Blazor | Phase 1 / W4 | order-service queries |
| 12 | FnB Engine Test Coverage | `DONE` | QA Engineer | Phase 1 / W3 | 96 tests (57 domain + 39 handler) |
-| 13 | Cafe Workflow Completion | `TODO` | Backend + Frontend | Phase 2 / W5-6 | Loyalty stamps, barista queue |
+| 13 | Cafe Workflow Completion | `DONE` | Backend + Frontend | Phase 2 / W5-6 | Loyalty stamps, barista queue |
| 14 | Critical Path Unit Tests (inventory, payment, events) | `DONE` | QA Engineer | Phase 1 / W4 | Deduction, payment callback, domain event handlers |
### P2 — Enhancement
@@ -171,10 +171,10 @@
| Task | Agent | Status | Depends On |
|------|-------|:------:|:----------:|
-| Spa domain logic (appointments, therapists) | Senior Backend | `IN-PROGRESS` | booking-service |
-| Retail POS workflow (scan, stock, returns) | Senior Backend | `IN-PROGRESS` | catalog, inventory |
-| Cafe-specific (loyalty stamps, barista queue) | Senior Backend | `IN-PROGRESS` | membership |
-| Vertical-specific UI refinement | Senior Frontend | `TODO` | Backend done |
+| Spa domain logic (appointments, therapists) | Senior Backend | `DONE` | booking-service |
+| Retail POS workflow (scan, stock, returns) | Senior Backend | `DONE` | catalog, inventory |
+| Cafe-specific (loyalty stamps, barista queue) | Senior Backend | `DONE` | membership |
+| Vertical-specific UI refinement | Senior Frontend | `IN-PROGRESS` | Backend done |
| Multi-branch management features | Senior Backend | `TODO` | merchant-service |
#### Week 7-8: Advanced Features
@@ -236,6 +236,18 @@
| Traefik Route (subscriptions) | DevOps | /api/v1/subscriptions → merchant-service |
| Admin Settings 5-Tab UI | Frontend | Tai khoan, Bao mat, Goi dich vu, Thong bao, He thong |
+### 2026-03-06 (Phase 2 Sprint 1 — Multi-Vertical)
+
+| Task | Agent | Details |
+|------|-------|---------|
+| Spa Therapist Management | Backend | Therapist aggregate (specialties text[], workingHours jsonb), CRUD, 9 validators |
+| Spa Appointment Scheduling | Backend | Appointment notes, Pending status, MarkNoShow, availability slots |
+| Retail Barcode/SKU | Backend | Product barcode field, lookup query, POS scanner endpoint |
+| Retail Stock Check | Backend | Bulk stock levels, low stock alerts, SetReorderLevel behavior |
+| Retail Return/Exchange | Backend | ProcessReturn, CreateReturn/Exchange commands, Returned status, 2 domain events |
+| Cafe Loyalty Stamps | Backend | StampCard aggregate, auto-create, AddStamp/ClaimReward/Reset, 4 domain events |
+| Cafe Barista Queue | Backend | BaristaQueueItem (5-status workflow), queue stats, 5 commands |
+
### 2026-03-06 (Code Review Fixes)
| Task | Agent | Details |
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor
index 12f4cc3d..b0c01f21 100644
--- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor
@@ -227,6 +227,10 @@
break;
+ case "therapists":
+
+ break;
+
case "kitchen":
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;
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTherapists.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTherapists.razor
new file mode 100644
index 00000000..591e10e6
--- /dev/null
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTherapists.razor
@@ -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 ═══ *@
+
+
+
+
+
+
+ Them nhan vien
+
+
+
+@* ═══ TABLE ═══ *@
+@if (_loading)
+{
+
+
+
+}
+else if (!FilteredTherapists.Any())
+{
+
+
+
+
+ @if (string.IsNullOrWhiteSpace(_searchQuery))
+ {
+ Chua co nhan vien nao. Nhan "Them nhan vien" de bat dau.
+ }
+ else
+ {
+ Khong tim thay nhan vien nao phu hop.
+ }
+
+
+
+}
+else
+{
+
+
+
+
+ Ten
+ Chuyen mon
+ Lien he
+ Trang thai
+ Thao tac
+
+
+
+
+
+ @GetInitials(context.Name)
+
+
@context.Name
+
+
+
+
+ @if (context.Specialties != null)
+ {
+ @foreach (var s in context.Specialties)
+ {
+ @s
+ }
+ }
+
+
+
+
+ @if (!string.IsNullOrEmpty(context.Phone)) {
@context.Phone
}
+ @if (!string.IsNullOrEmpty(context.Email)) {
@context.Email
}
+
+
+
+ @if (context.IsActive)
+ {
+ Hoat dong
+ }
+ else
+ {
+ Ngung
+ }
+
+
+
+ @if (context.IsActive)
+ {
+
+ }
+
+
+
+
+
+}
+
+@* ═══ CREATE/EDIT DIALOG ═══ *@
+
+
+ @(_editingId.HasValue ? "Chinh sua nhan vien" : "Them nhan vien moi")
+
+
+
+
+
+
+
+ Chuyen mon
+
+ @foreach (var spec in _availableSpecialties)
+ {
+ @spec
+ }
+
+
+
+
+
+
+
+ Huy
+
+ @if (_saving) { }
+ @(_editingId.HasValue ? "Cap nhat" : "Tao moi")
+
+
+
+
+@code {
+ [Parameter] public Guid ShopId { get; set; }
+
+ private bool _loading = true;
+ private bool _saving = false;
+ private List _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 _selectedSpecialties = new List();
+
+ 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 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();
+ _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();
+ _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();
+ }
+}
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Spa/AppointmentCalendar.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Spa/AppointmentCalendar.razor
new file mode 100644
index 00000000..b11fca76
--- /dev/null
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Spa/AppointmentCalendar.razor
@@ -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.
+*@
+
+Lich hen — GoodGo POS
+
+@* ═══ TOP BAR ═══ *@
+
+
+
Lich hen
+
Quan ly lich hen hang ngay cho nhan vien
+
+
+
+
+ @if (_loading)
+ {
+
+ }
+ Tai lai
+
+
+ Dat lich moi
+
+
+
+
+@* ═══ CONTENT ═══ *@
+
+
+ @if (_loading)
+ {
+
+
+
+ }
+ else if (!_appointments.Any())
+ {
+
+ Khong co lich hen nao trong ngay @(_selectedDate?.ToString("dd/MM/yyyy") ?? "hom nay"). Nhan "Dat lich moi" de tao lich hen.
+
+ }
+ else
+ {
+ @* ── SUMMARY CARDS ── *@
+
+
+
+ @_appointments.Count
+ Tong lich hen
+
+
+
+
+ @_appointments.Count(a => a.Status.Equals("Pending", StringComparison.OrdinalIgnoreCase))
+ Cho xac nhan
+
+
+
+
+ @_appointments.Count(a => a.Status.Equals("Confirmed", StringComparison.OrdinalIgnoreCase))
+ Da xac nhan
+
+
+
+
+ @_appointments.Count(a => a.Status.Equals("InProgress", StringComparison.OrdinalIgnoreCase) || a.Status.Equals("Completed", StringComparison.OrdinalIgnoreCase))
+ Dang/Da hoan thanh
+
+
+
+
+ @* ── APPOINTMENTS GROUPED BY THERAPIST ── *@
+ @foreach (var group in GroupedAppointments)
+ {
+
+
+
+ @foreach (var appt in group.OrderBy(a => a.StartTime))
+ {
+
+ @* Time *@
+
+
+ @appt.StartTime.ToString("HH:mm")
+
+
+ @appt.EndTime.ToString("HH:mm")
+
+
+
+ @* Info *@
+
+
+ @(appt.ServiceName ?? "Dich vu")
+
+
+ @if (!string.IsNullOrEmpty(appt.CustomerName))
+ {
+ @appt.CustomerName
+ }
+ @if (!string.IsNullOrEmpty(appt.Notes))
+ {
+ @appt.Notes
+ }
+
+
+
+ @* Status Badge *@
+
+ @GetStatusLabel(appt.Status)
+
+
+ @* Actions *@
+
+ @if (appt.Status.Equals("Pending", StringComparison.OrdinalIgnoreCase))
+ {
+
+ Xac nhan
+
+ }
+ @if (appt.Status.Equals("Confirmed", StringComparison.OrdinalIgnoreCase))
+ {
+
+ Bat dau
+
+ }
+ @if (appt.Status.Equals("InProgress", StringComparison.OrdinalIgnoreCase))
+ {
+
+ Hoan thanh
+
+ }
+ @if (!appt.Status.Equals("Completed", StringComparison.OrdinalIgnoreCase)
+ && !appt.Status.Equals("Cancelled", StringComparison.OrdinalIgnoreCase)
+ && !appt.Status.Equals("NoShow", StringComparison.OrdinalIgnoreCase))
+ {
+
+
+ }
+
+
+ }
+
+
+ }
+ }
+
+
+@* ═══ CANCEL DIALOG ═══ *@
+
+
+ Huy lich hen
+
+
+
+ Vui long nhap ly do huy lich hen nay.
+
+
+
+
+ Quay lai
+
+ @if (_saving)
+ {
+
+ }
+ Xac nhan huy
+
+
+
+
+@* ═══ CREATE APPOINTMENT DIALOG ═══ *@
+
+
+ Dat lich moi
+
+
+
+
+ -- Chua chon --
+ @foreach (var t in _therapists.Where(t => t.IsActive))
+ {
+ @t.Name
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Huy
+
+ @if (_saving)
+ {
+
+ }
+ Dat lich
+
+
+
+
+@code {
+ // EN: Page state / VI: Trang thai trang
+ private bool _loading = true;
+ private bool _saving = false;
+ private DateTime? _selectedDate = DateTime.Today;
+ private List _appointments = new();
+ private List _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> GroupedAppointments =>
+ _appointments
+ .GroupBy(a => a.TherapistName ?? "Chua phan cong")
+ .OrderBy(g => g.Key);
+
+ ///
+ /// EN: Get the current shop ID from query string.
+ /// VI: Lay shop ID tu query string.
+ ///
+ 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();
+ }
+
+ ///
+ /// EN: Load appointments and therapists in parallel.
+ /// VI: Tai lich hen va nhan vien dong thoi.
+ ///
+ 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 ═══
+
+ ///
+ /// EN: Update appointment status (confirm/start/complete/noshow).
+ /// VI: Cap nhat trang thai lich hen (xac nhan/bat dau/hoan thanh/vang mat).
+ ///
+ 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 ═══
+
+ ///
+ /// EN: Get Vietnamese status label.
+ /// VI: Lay nhan trang thai tieng Viet.
+ ///
+ 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
+ };
+
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+}
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Spa/TherapistManagement.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Spa/TherapistManagement.razor
new file mode 100644
index 00000000..98cf379b
--- /dev/null
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Spa/TherapistManagement.razor
@@ -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.
+*@
+
+Quan ly nhan vien — GoodGo POS
+
+@* ═══ TOP BAR ═══ *@
+
+
+
Quan ly nhan vien
+
Danh sach nhan vien tri lieu, chuyen mon va trang thai
+
+
+
+
+
+
+
+ Them nhan vien
+
+
+
+
+@* ═══ CONTENT ═══ *@
+
+ @if (_loading)
+ {
+
+
+
+ }
+ else if (!FilteredTherapists.Any())
+ {
+
+ @if (string.IsNullOrWhiteSpace(_searchQuery))
+ {
+ Chua co nhan vien nao. Nhan "Them nhan vien" de bat dau.
+ }
+ else
+ {
+ Khong tim thay nhan vien nao phu hop voi "@_searchQuery".
+ }
+
+ }
+ else
+ {
+
+
+
+
+ Ten
+ Chuyen mon
+ Lien he
+ Trang thai
+ Thao tac
+
+
+
+
+
+ @GetInitials(context.Name)
+
+
@context.Name
+
+
+
+
+ @if (context.Specialties != null)
+ {
+ @foreach (var s in context.Specialties)
+ {
+ @s
+ }
+ }
+
+
+
+
+ @if (!string.IsNullOrEmpty(context.Phone))
+ {
+
@context.Phone
+ }
+ @if (!string.IsNullOrEmpty(context.Email))
+ {
+
@context.Email
+ }
+
+
+
+ @if (context.IsActive)
+ {
+ Hoat dong
+ }
+ else
+ {
+ Ngung hoat dong
+ }
+
+
+
+ @if (context.IsActive)
+ {
+
+ }
+
+
+
+
+
+ }
+
+
+@* ═══ CREATE/EDIT DIALOG ═══ *@
+
+
+ @(_editingId.HasValue ? "Chinh sua nhan vien" : "Them nhan vien moi")
+
+
+
+
+
+
+
+
+
+ Chuyen mon
+
+ @foreach (var spec in _availableSpecialties)
+ {
+ @spec
+ }
+
+
+
+
+
+
+
+
+ Huy
+
+ @if (_saving)
+ {
+
+ }
+ @(_editingId.HasValue ? "Cap nhat" : "Tao moi")
+
+
+
+
+@code {
+ // EN: State fields / VI: Cac truong trang thai
+ private bool _loading = true;
+ private bool _saving = false;
+ private List _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 _selectedSpecialties = new List();
+
+ 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 FilteredTherapists =>
+ string.IsNullOrWhiteSpace(_searchQuery)
+ ? _therapists
+ : _therapists.Where(t => t.Name.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// EN: Get the current shop ID from query string.
+ /// VI: Lay shop ID tu query string.
+ ///
+ 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();
+ }
+
+ ///
+ /// EN: Load therapists from API.
+ /// VI: Tai danh sach nhan vien tu API.
+ ///
+ 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();
+ _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();
+ _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();
+ }
+
+ ///
+ /// EN: Save therapist (create or update).
+ /// VI: Luu nhan vien (tao moi hoac cap nhat).
+ ///
+ 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();
+ }
+ }
+
+ ///
+ /// EN: Confirm and deactivate a therapist.
+ /// VI: Xac nhan va vo hieu hoa nhan vien.
+ ///
+ 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();
+ }
+}
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Store/StockOverview.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Store/StockOverview.razor
new file mode 100644
index 00000000..de9a8e36
--- /dev/null
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Store/StockOverview.razor
@@ -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
+
+
+ @* ═══ HEADER / TIEU DE ═══ *@
+
+
+
+
+ Tong quan ton kho
+
+
+ Theo doi muc ton kho va canh bao het hang
+
+
+
+ Lam moi
+
+
+
+ @if (_isLoading)
+ {
+
+
+
+ }
+ else
+ {
+ @* ═══ SUMMARY CARDS / THE TOM TAT ═══ *@
+
+
+
+
+
+
+ @_totalProducts
+ Tong san pham
+
+
+
+
+
+
+
+ @_inStockCount
+ Con hang
+
+
+
+
+
+
+
+ @_lowStockCount
+ Sap het
+
+
+
+
+
+
+
+ @_outOfStockCount
+ Het hang
+
+
+
+
+ @* ═══ SEARCH & FILTER / TIM KIEM VA LOC ═══ *@
+
+
+
+
+ @if (!string.IsNullOrEmpty(_searchQuery))
+ {
+ _searchQuery = string.Empty">
+
+
+ }
+
+
+ @* EN: Stock status filter / VI: Loc trang thai ton kho *@
+
+ Tat ca
+ Con hang
+ Sap het
+ Het hang
+
+
+
+ @* ═══ STOCK TABLE / BANG TON KHO ═══ *@
+
+ @* EN: Table header / VI: Header bang *@
+
+ San pham
+ Ma SKU
+ Ma vach
+ Ton kho
+ Da dat
+ Muc toi thieu
+ Trang thai
+
+
+ @if (!FilteredItems.Any())
+ {
+
+ Khong tim thay san pham nao
+
+ }
+ else
+ {
+ @foreach (var item in FilteredItems)
+ {
+ var rowBg = GetRowBackground(item);
+
{})" @onmouseout="@(() => {})">
+ @* EN: Product name + category / VI: Ten san pham + danh muc *@
+
+
@item.ProductName
+
@(item.Category ?? "")
+
+
+
@(item.Sku ?? "-")
+
@(item.Barcode ?? "-")
+
+
@item.Available
+
@item.Reserved
+
+ @* EN: Minimum level with edit button / VI: Muc toi thieu voi nut chinh sua *@
+
+ @item.Minimum
+ OpenThresholdDialog(item)"
+ title="Cai dat canh bao">
+
+
+
+
+ @* EN: Status badge / VI: Badge trang thai *@
+
+
+ @GetStatusLabel(item)
+
+
+
+ }
+ }
+
+
+ @* EN: Result count / VI: So ket qua *@
+
+ Hien thi @FilteredItems.Count() / @_stockItems.Count san pham
+
+ }
+
+
+@* ═══ THRESHOLD EDIT DIALOG / DIALOG CHINH SUA NGUONG ═══ *@
+@if (_showThresholdDialog && _editingItem != null)
+{
+
+
+
+
+ Cai dat canh bao ton kho
+
+
+
+ San pham: @_editingItem.ProductName
+
+
+ Ton kho hien tai: @_editingItem.Available
+
+
+
+
+ Muc ton kho toi thieu
+
+
+
+
+
+
+ Huy
+
+
+ Luu
+
+
+
+
+}
+
+@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 _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 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();
+
+ ///
+ /// 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.
+ ///
+ private IEnumerable 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; }
+ }
+}
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/BaristaQueue.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/BaristaQueue.razor
new file mode 100644
index 00000000..7824c64c
--- /dev/null
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/BaristaQueue.razor
@@ -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
+
+
+
+ @* ═══ HEADER / TIEU DE ═══ *@
+
+ NavigateTo("cafe"))">
+
+
+ Hang doi Barista
+
+
+ Tu dong cap nhat moi 10 giay
+
+
+
+ @if (_isLoading)
+ {
+
+
+
+
Dang tai hang doi...
+
+
+ }
+ else if (_loadError)
+ {
+
+ Khong the tai du lieu.
+ await LoadQueueAsync()">Thu lai
+
+ }
+ else
+ {
+ @* ═══ STATS BAR / THANH THONG KE ═══ *@
+
+
+
@_stats.TotalQueued
+
Cho pha
+
+
+
@_stats.Preparing
+
Dang pha
+
+
+
@_stats.Ready
+
San sang
+
+
+
@_stats.AvgPrepTimeMinutes.ToString("F1")
+
TB phut
+
+
+
+ @* ═══ KANBAN COLUMNS / COT KANBAN ═══ *@
+
+ @foreach (var col in _columns)
+ {
+ var items = _queueItems.Where(q => q.Status == col.Status).OrderByDescending(q => q.Priority).ThenBy(q => q.CreatedAt).ToList();
+
+
+ @* EN: Column header / VI: Tieu de cot *@
+
+
+ @col.Title
+
+ @items.Count
+
+
+
+ @* EN: Drink cards / VI: The ly *@
+
+ @foreach (var item in items)
+ {
+
+
+ @* EN: Drink header / VI: Tieu de ly *@
+
+
@item.DrinkName
+ @if (item.Priority > 0)
+ {
+
+ Uu tien
+
+ }
+
+
+ @* EN: Customizations / VI: Tuy chinh *@
+ @if (!string.IsNullOrEmpty(item.Customizations))
+ {
+
+ @item.Customizations
+
+ }
+
+ @* EN: Meta row / VI: Dong thong tin *@
+
+ @GetElapsedMinutes(item.CreatedAt) phut truoc
+ ~@item.EstimatedMinutes phut
+
+
+ @* EN: Assigned barista (Preparing column) / VI: Barista duoc phan cong (cot Dang pha) *@
+ @if (!string.IsNullOrEmpty(item.AssignedTo) && col.Status == "Preparing")
+ {
+
+ @item.AssignedTo
+
+ }
+
+ @* EN: Action button / VI: Nut hanh dong *@
+ @if (col.Status == "Queued")
+ {
+
ShowBaristaDialog(item)">
+ @(_processingIds.Contains(item.Id) ? "Dang xu ly..." : "Bat dau pha")
+
+ }
+ else if (col.Status == "Preparing")
+ {
+
MarkReadyAsync(item)">
+ @(_processingIds.Contains(item.Id) ? "Dang xu ly..." : "Hoan thanh")
+
+ }
+ else if (col.Status == "Ready")
+ {
+
MarkDeliveredAsync(item)">
+ @(_processingIds.Contains(item.Id) ? "Dang xu ly..." : "Da giao")
+
+ }
+
+ }
+
+
+ }
+
+ }
+
+
+@* EN: Barista name dialog overlay / VI: Hop thoai nhap ten barista *@
+@if (_showBaristaDialog)
+{
+ _showBaristaDialog = false">
+
+
Nhap ten Barista
+
_baristaName = e.Value?.ToString() ?? "")" />
+
+ _showBaristaDialog = false">Huy
+ Xac nhan
+
+
+
+}
+
+@* EN: CSS animation for ready pulse / VI: CSS animation cho nhap nhay san sang *@
+
+
+@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 _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 _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));
+ }
+
+ ///
+ /// EN: Load queue items and stats from the barista queue API.
+ /// VI: Tai danh sach mon va thong ke tu API hang doi barista.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// EN: Show the barista name dialog before starting preparation.
+ /// VI: Hien thi hop thoai nhap ten barista truoc khi bat dau pha che.
+ ///
+ private void ShowBaristaDialog(PosDataService.BaristaQueueItemInfo item)
+ {
+ _selectedItem = item;
+ _baristaName = "";
+ _showBaristaDialog = true;
+ }
+
+ ///
+ /// EN: Confirm barista name and start preparing the drink.
+ /// VI: Xac nhan ten barista va bat dau pha che ly.
+ ///
+ 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();
+ }
+ }
+
+ ///
+ /// EN: Mark a drink as ready for pickup.
+ /// VI: Danh dau ly da san sang de lay.
+ ///
+ 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();
+ }
+ }
+
+ ///
+ /// EN: Mark a drink as delivered and remove from queue.
+ /// VI: Danh dau ly da giao va xoa khoi hang doi.
+ ///
+ 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);
+}
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/StampCard.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/StampCard.razor
new file mode 100644
index 00000000..fcc1191b
--- /dev/null
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/StampCard.razor
@@ -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
+
+
+
+
+ @* ═══ HEADER / TIEU DE ═══ *@
+
+ NavigateTo("cafe"))">
+
+
+ The tich diem
+
+
+ @* ═══ CUSTOMER LOOKUP / TIM KHACH HANG ═══ *@
+
+
Tim khach hang
+
+ _searchQuery = e.Value?.ToString() ?? "")"
+ @onkeydown="@(async (e) => { if (e.Key == "Enter") await SearchCustomerAsync(); })" />
+
+ @(_isSearching ? "..." : "Tim")
+
+
+ @if (!string.IsNullOrEmpty(_searchError))
+ {
+
@_searchError
+ }
+
+
+ @if (_customer != null && _stampCard != null)
+ {
+ @* ═══ CUSTOMER INFO / THONG TIN KHACH HANG ═══ *@
+
+
+ @((_customer.DisplayName ?? "?")[..1].ToUpper())
+
+
@(_customer.DisplayName ?? "Khach hang")
+
@(_customer.Phone ?? _searchQuery)
+
@_stampCard.CardName
+
+
+ @* ═══ STAMP GRID / LUOI TEM ═══ *@
+
+
+ The tich tem
+
+ @_stampCard.CurrentStamps / @_stampCard.TotalStampsRequired
+
+
+
+ @* EN: Visual stamp circles / VI: Cac vong tron tem truc quan *@
+
+ @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;
+
+
+ @if (isReward && !isStamped)
+ {
+ 🎁
+ }
+ else if (isStamped)
+ {
+ ☕
+ }
+ else
+ {
+ @idx
+ }
+
+ }
+
+
+ @* EN: Progress bar / VI: Thanh tien trinh *@
+
+
+ @if (!_stampCard.IsCompleted)
+ {
+
+ Con @(_stampCard.TotalStampsRequired - _stampCard.CurrentStamps) tem nua de nhan 1 ly mien phi!
+
+ }
+
+
+
+ @* ═══ REWARD / ACTION SECTION ═══ *@
+ @if (_stampCard.IsCompleted && !_stampCard.RewardClaimed)
+ {
+ @* EN: Card complete — show celebration + claim button *@
+
+
🎉
+
+ Chuc mung! Da du tem!
+
+
+ Khach hang du dieu kien nhan 1 ly mien phi.
+
+
+ @if (_isClaiming)
+ {
+ Dang xu ly...
+ }
+ else
+ {
+
+ Nhan thuong
+ }
+
+
+ }
+ else if (_stampCard.RewardClaimed)
+ {
+ @* EN: Reward already claimed — show reset option *@
+
+
✅
+
+ Da nhan thuong thanh cong!
+
+
+ The tem da hoan tat. Tim khach hang moi hoac bat dau lai.
+
+
+
+ Bat dau lai
+
+
+ }
+ else
+ {
+ @* EN: Normal state — add stamp button *@
+
+ @if (_isAddingStamp)
+ {
+ Dang xu ly...
+ }
+ else
+ {
+
+ Them tem (+1)
+ }
+
+ }
+
+ @if (!string.IsNullOrEmpty(_actionMessage))
+ {
+
+ @_actionMessage
+
+ }
+
+ @* ═══ STAMP HISTORY / LICH SU TICH TEM ═══ *@
+ @if (_expHistory.Any())
+ {
+
+
+ Lich su tich tem gan day
+
+ @foreach (var tx in _expHistory.Take(5))
+ {
+
+
+
+@tx.Points diem
+
@tx.CreatedAt.ToString("dd/MM/yyyy HH:mm")
+
+ @if (!string.IsNullOrEmpty(tx.ReferenceId))
+ {
+
+ @tx.ReferenceId[..Math.Min(12, tx.ReferenceId.Length)]
+
+ }
+
+ }
+
+ }
+ }
+ else if (!_isSearching && _hasSearched)
+ {
+ @* EN: No customer found / VI: Khong tim thay khach hang *@
+
+
🔍
+
Khong tim thay khach hang. Hay nhap SDT va nhan Tim.
+
+ }
+
+
+
+@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 _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;
+
+ ///
+ /// EN: Search for customer by phone or member ID.
+ /// VI: Tim kiem khach hang theo SDT hoac ma thanh vien.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// EN: Add one stamp to the customer's card.
+ /// VI: Them mot tem vao the khach hang.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// EN: Claim the reward for a completed stamp card.
+ /// VI: Nhan thuong cho the tem da hoan tat.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// EN: Reset search state for a new customer.
+ /// VI: Xoa trang thai tim kiem cho khach hang moi.
+ ///
+ private void ResetSearch()
+ {
+ _searchQuery = "";
+ _customer = null;
+ _stampCard = null;
+ _expHistory = new();
+ _hasSearched = false;
+ _actionMessage = null;
+ _justAddedStamp = false;
+ }
+}
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Retail/RetailDesktop.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Retail/RetailDesktop.razor
index d6dd62f9..c8f160b4 100644
--- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Retail/RetailDesktop.razor
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Retail/RetailDesktop.razor
@@ -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 ═══ *@
- @* EN: Barcode input / VI: Ô nhập mã vạch *@
+ @* EN: Barcode input with auto-focus / VI: O nhap ma vach voi auto-focus *@
+ @* EN: Barcode lookup result alert / VI: Thong bao ket qua tra cuu ma vach *@
+ @if (_barcodeNotFound)
+ {
+
+
+ Khong tim thay san pham
+ _barcodeNotFound = false">
+
+
+
+ }
+
@if (_isLoading)
{
- Đang tải...
+ Dang tai...
}
else if (_loadError)
{
- Không thể tải dữ liệu
+ Khong the tai du lieu
}
else
{
- @* EN: Category tabs / VI: Tab danh mục *@
+ @* EN: Category tabs / VI: Tab danh muc *@
@foreach (var cat in _categories)
{
@@ -46,13 +65,22 @@
}
- @* EN: Product grid / VI: Lưới sản phẩm *@
+ @* EN: Product grid with stock indicators / VI: Luoi san pham voi chi bao ton kho *@
@foreach (var product in FilteredProducts)
{
AddToCart(product)">
-
+
+ @* EN: Stock badge / VI: Badge ton kho *@
+ @if (product.StockInfo != null)
+ {
+
+ @(product.StockInfo.Available <= 0 ? "Het" : product.StockInfo.IsLowStock ? $"Con {product.StockInfo.Available}" : $"{product.StockInfo.Available}")
+
+ }
@product.Name
@@ -68,8 +96,8 @@
@* ═══ CART PANEL ═══ *@
@@ -78,11 +106,22 @@
@item.Name
-
@item.Sku
+
+ @item.Sku
+ @* EN: Stock indicator chip / VI: Chip chi bao ton kho *@
+ @if (item.StockAvailable.HasValue)
+ {
+ 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
+
+ }
+
@FormatPrice(item.Price)
- ChangeQty(item, -1)">−
+ ChangeQty(item, -1)">-
@item.Qty
ChangeQty(item, 1)">+
@@ -91,46 +130,75 @@
@code {
- // EN: Loading state / VI: Trạng thái tải
+ // EN: Loading state / VI: Trang thai tai
private bool _isLoading = true;
private bool _loadError;
+ private bool _barcodeNotFound;
+ private bool _isSearching;
- // EN: Categories / VI: Danh mục
- private string[] _categories = { "Tất cả" };
- private string _selectedCategory = "Tất cả";
+ // EN: Categories / VI: Danh muc
+ private string[] _categories = { "Tat ca" };
+ private string _selectedCategory = "Tat ca";
private string _barcodeInput = "";
+ private ElementReference _barcodeRef;
- // EN: Product list from API / VI: Danh sách sản phẩm từ API
- private List
_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 _products = new();
+
+ // EN: Cart items / VI: Muc gio hang
private readonly List _cartItems = new();
- private IEnumerable FilteredProducts =>
- _selectedCategory == "Tất cả" ? _products : _products.Where(p => p.Category == _selectedCategory);
- private decimal CartTotal => _cartItems.Sum(i => i.Price * i.Qty);
+ private IEnumerable FilteredProducts =>
+ _selectedCategory == "Tat ca" ? _products : _products.Where(p => p.Category == _selectedCategory);
+ private decimal CartSubtotal => _cartItems.Sum(i => i.Price * i.Qty);
+ private decimal CartTotal => (CartSubtotal - _discountAmount) * 1.1m;
protected override async Task OnInitializedAsync()
{
@@ -138,18 +206,24 @@
try
{
+ // EN: Load products from catalog / VI: Tai san pham tu catalog
var apiProducts = await DataService.GetProductsAsync(ShopId);
- _products = apiProducts.Select(p => new Product(
+ _products = apiProducts.Select(p => new ProductWithStock(
+ p.Id,
p.Name,
p.Sku ?? "",
p.Price,
- p.Category ?? "Khác",
- GetCategoryIcon(p.Category ?? "Khác")
+ p.Category ?? "Khac",
+ GetCategoryIcon(p.Category ?? "Khac"),
+ null
)).ToList();
var cats = _products.Select(p => p.Category).Distinct().ToList();
- _categories = new[] { "Tất cả" }.Concat(cats).ToArray();
+ _categories = new[] { "Tat ca" }.Concat(cats).ToArray();
+
+ // EN: Load stock levels for all products / VI: Tai muc ton kho cho tat ca san pham
+ await LoadStockLevelsAsync();
}
catch
{
@@ -161,41 +235,169 @@
}
}
- private void AddToCart(Product product)
+ ///
+ /// EN: Load stock levels for all products in the grid.
+ /// VI: Tai muc ton kho cho tat ca san pham trong luoi.
+ ///
+ 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
+ }
+ }
+
+ ///
+ /// EN: Handle Enter key in barcode input for quick scan.
+ /// VI: Xu ly phim Enter trong o nhap ma vach de quet nhanh.
+ ///
+ private async Task OnBarcodeKeyDown(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_barcodeInput))
+ {
+ await SearchBarcode();
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ private async Task SearchBarcode()
+ {
+ if (string.IsNullOrWhiteSpace(_barcodeInput)) return;
+
+ _barcodeNotFound = false;
+ _isSearching = true;
+ StateHasChanged();
+
+ // EN: First try local match by SKU / VI: Thu khop local theo SKU truoc
+ var found = _products.FirstOrDefault(p =>
+ p.Sku.Equals(_barcodeInput, StringComparison.OrdinalIgnoreCase));
+
+ if (found != null)
+ {
+ AddToCart(found);
+ _barcodeInput = "";
+ _isSearching = false;
+ return;
+ }
+
+ // EN: Then try API barcode lookup / VI: Sau do thu tra cuu API theo ma vach
+ try
+ {
+ var lookupResult = await DataService.LookupProductByBarcodeAsync(ShopId, _barcodeInput);
+ if (lookupResult != null)
+ {
+ // EN: Check if product already in grid / VI: Kiem tra san pham da co trong luoi chua
+ var existing = _products.FirstOrDefault(p => p.Id == lookupResult.Id);
+ if (existing != null)
+ {
+ AddToCart(existing);
+ }
+ else
+ {
+ // EN: Add to grid and cart / VI: Them vao luoi va gio hang
+ var newProduct = new ProductWithStock(lookupResult.Id, lookupResult.Name,
+ lookupResult.Sku ?? lookupResult.Barcode ?? "", lookupResult.Price,
+ "Khac", "package", null);
+ _products.Add(newProduct);
+ AddToCart(newProduct);
+ }
+ _barcodeInput = "";
+ }
+ else
+ {
+ _barcodeNotFound = true;
+ Snackbar.Add("Khong tim thay san pham voi ma vach nay", Severity.Warning);
+ }
+ }
+ catch
+ {
+ _barcodeNotFound = true;
+ Snackbar.Add("Loi khi tra cuu san pham", Severity.Error);
+ }
+ finally
+ {
+ _isSearching = false;
+ }
+ }
+
+ private void AddToCart(ProductWithStock product)
+ {
+ // EN: Check stock availability / VI: Kiem tra ton kho
+ if (product.StockInfo != null && product.StockInfo.Available <= 0)
+ {
+ Snackbar.Add($"{product.Name} da het hang", Severity.Warning);
+ return;
+ }
+
+ var existing = _cartItems.FirstOrDefault(i => i.ProductId == product.Id);
+ if (existing != null)
+ {
+ existing.Qty++;
+ // EN: Warn if exceeding stock / VI: Canh bao neu vuot ton kho
+ if (existing.StockAvailable.HasValue && existing.Qty > existing.StockAvailable.Value)
+ Snackbar.Add($"{product.Name}: so luong vuot ton kho ({existing.StockAvailable})", Severity.Warning);
+ }
+ else
+ {
+ _cartItems.Add(new CartItem(product.Id, product.Name, product.Sku, product.Price,
+ product.StockInfo?.Available));
+ }
}
private void ChangeQty(CartItem item, int delta)
{
item.Qty += delta;
if (item.Qty <= 0) _cartItems.Remove(item);
+ else if (item.StockAvailable.HasValue && item.Qty > item.StockAvailable.Value)
+ Snackbar.Add($"{item.Name}: so luong vuot ton kho ({item.StockAvailable})", Severity.Warning);
}
- private void SearchBarcode()
+ private void ClearCart()
{
- var found = _products.FirstOrDefault(p => p.Sku.Equals(_barcodeInput, StringComparison.OrdinalIgnoreCase));
- if (found != null) AddToCart(found);
- _barcodeInput = "";
+ _cartItems.Clear();
+ _discountAmount = 0;
}
- private void Checkout() => NavigateTo("payment-method-select");
+ private void Checkout()
+ {
+ if (!_cartItems.Any()) return;
+ NavigateTo("payment-method-select");
+ }
private static string GetCategoryIcon(string category) => category switch
{
- "Thời trang" => "shirt", "Phụ kiện" => "shopping-bag", "Điện tử" => "headphones",
- "Gia dụng" => "cooking-pot", "Mỹ phẩm" => "sparkles", _ => "package"
+ "Thoi trang" => "shirt", "Phu kien" => "shopping-bag", "Dien tu" => "headphones",
+ "Gia dung" => "cooking-pot", "My pham" => "sparkles", _ => "package"
};
- // EN: Models / VI: Mô hình dữ liệu
- private record Product(string Name, string Sku, decimal Price, string Category, string Icon);
- private class CartItem(string name, string sku, decimal price)
+ // EN: Models / VI: Mo hinh du lieu
+ private record ProductWithStock(Guid Id, string Name, string Sku, decimal Price, string Category, string Icon,
+ PosDataService.StockLevelInfo? StockInfo);
+ private class CartItem(Guid productId, string name, string sku, decimal price, int? stockAvailable)
{
+ public Guid ProductId { get; set; } = productId;
public string Name { get; set; } = name;
public string Sku { get; set; } = sku;
public decimal Price { get; set; } = price;
public int Qty { get; set; } = 1;
+ public int? StockAvailable { get; set; } = stockAvailable;
}
}
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Retail/ReturnDialog.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Retail/ReturnDialog.razor
new file mode 100644
index 00000000..8411f3ce
--- /dev/null
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Retail/ReturnDialog.razor
@@ -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
+
+
+
+
+
+ Tra hang / Doi hang
+
+
+
+
+
+ @* ═══ ORDER LOOKUP / TRA CUU DON HANG ═══ *@
+
+
+
+
+ @if (_isSearching)
+ {
+
+
+
Dang tim don hang...
+
+ }
+ else if (_orderNotFound)
+ {
+
+ Khong tim thay don hang voi ma nay
+
+ }
+ else if (_orderDetail != null)
+ {
+ @* ═══ ORDER INFO / THONG TIN DON HANG ═══ *@
+
+
+
+ Don hang #@_orderDetail.Order?.Id.ToString()[..8]
+ @_orderDetail.Order?.CreatedAt.ToString("dd/MM/yyyy HH:mm") - @_orderDetail.Order?.Status
+
+
+ @FormatPrice(_orderDetail.Order?.TotalAmount ?? 0)
+
+
+
+
+ @* ═══ MODE TOGGLE / CHUYEN CHE DO ═══ *@
+
+
+ Tra hang
+
+
+ Doi hang
+
+
+
+ @* ═══ SELECT RETURN ITEMS / CHON SAN PHAM TRA ═══ *@
+
Chon san pham tra lai
+
+
+
+
+ San pham
+ SL goc
+ So luong tra
+ Thanh tien
+
+
+
+ @if (_orderDetail.Items != null)
+ {
+ @foreach (var item in _returnItems)
+ {
+
+
+
+
+
+ @item.ProductName
+
+ @item.OriginalQty
+
+ @if (item.Selected)
+ {
+
+ }
+
+
+ @if (item.Selected)
+ {
+ -@FormatPrice(item.UnitPrice * item.ReturnQty)
+ }
+
+
+ }
+ }
+
+
+
+ @* ═══ EXCHANGE: NEW ITEMS SCANNER / DOI: QUET SAN PHAM MOI ═══ *@
+ @if (_mode == ReturnMode.Exchange)
+ {
+
+
San pham doi moi
+
+
+
+
+ Tim
+
+
+
+ @if (_exchangeItems.Any())
+ {
+
+
+
+ San pham moi
+ So luong
+ Don gia
+ Thanh tien
+
+
+
+
+ @foreach (var item in _exchangeItems)
+ {
+
+ @item.Name
+
+
+
+ @FormatPrice(item.UnitPrice)
+
+ +@FormatPrice(item.UnitPrice * item.Qty)
+
+
+
+
+
+ }
+
+
+ }
+ }
+
+ @* ═══ REASON / LY DO ═══ *@
+
+
+ @* ═══ SUMMARY / TOM TAT ═══ *@
+
+
+ Tong tra:
+
+ -@FormatPrice(ReturnTotal)
+
+
+ @if (_mode == ReturnMode.Exchange)
+ {
+
+ Tong doi moi:
+
+ +@FormatPrice(ExchangeTotal)
+
+
+ }
+
+
+
+ @(_mode == ReturnMode.Return ? "Hoan tien:" : (Difference >= 0 ? "Khach tra them:" : "Hoan tien:"))
+
+
+ @(Difference >= 0 ? "" : "-")@FormatPrice(Math.Abs(Difference))
+
+
+
+ }
+
+
+
+ Huy
+
+ Xac nhan @(_mode == ReturnMode.Return ? "tra hang" : "doi hang")
+
+
+
+
+@code {
+ [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!;
+
+ ///
+ /// EN: Shop ID for API calls.
+ /// VI: ID cua hang cho cac cuoc goi API.
+ ///
+ [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 _returnItems = new();
+ private readonly List _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();
+ }
+
+ ///
+ /// EN: Lookup original order by ID.
+ /// VI: Tra cuu don hang goc theo ma.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// EN: Search and add a product for exchange.
+ /// VI: Tim va them san pham de doi.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// EN: Confirm return or exchange.
+ /// VI: Xac nhan tra hoac doi hang.
+ ///
+ 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;
+ }
+}
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs
index caff00c8..824b9a4c 100644
--- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs
@@ -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> 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> GetTherapistsAsync(Guid shopId)
+ => await GetListFromApiAsync($"api/bff/shops/{shopId}/therapists");
+
+ public async Task CreateTherapistAsync(CreateTherapistRequest req)
+ { AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/therapists", req, _writeOptions); return r.IsSuccessStatusCode; }
+
+ public async Task UpdateTherapistAsync(Guid therapistId, CreateTherapistRequest req)
+ { AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/therapists/{therapistId}", req, _writeOptions); return r.IsSuccessStatusCode; }
+
+ public async Task 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> GetAppointmentsByDateAsync(Guid shopId, DateTime date)
+ => await GetListFromApiAsync($"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 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(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 Items);
+
+ ///
+ /// EN: Lookup product by barcode or SKU from catalog-service.
+ /// VI: Tra cuu san pham theo ma vach hoac SKU tu catalog-service.
+ ///
+ public async Task LookupProductByBarcodeAsync(Guid shopId, string barcode)
+ => await GetObjectFromApiAsync($"api/bff/shops/{shopId}/products/lookup?barcode={Uri.EscapeDataString(barcode)}");
+
+ ///
+ /// EN: Bulk stock check for multiple products from inventory-service.
+ /// VI: Kiem tra ton kho hang loat cho nhieu san pham tu inventory-service.
+ ///
+ public async Task> GetStockLevelsAsync(Guid shopId, List 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>(data.GetRawText(), _jsonOptions) ?? new();
+ if (root.ValueKind == JsonValueKind.Array)
+ return JsonSerializer.Deserialize>(json, _jsonOptions) ?? new();
+ return new();
+ }
+
+ ///
+ /// EN: Create a return for an order (process refund).
+ /// VI: Tao don tra hang cho don hang (xu ly hoan tien).
+ ///
+ public async Task<(bool Ok, string? Error)> CreateReturnAsync(Guid shopId, Guid originalOrderId, List 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));
+ }
+
+ ///
+ /// EN: Create an exchange for an order (return + new items).
+ /// VI: Tao don doi hang cho don hang (tra + san pham moi).
+ ///
+ public async Task<(bool Ok, string? Error)> CreateExchangeAsync(Guid shopId, Guid originalOrderId,
+ List returnItems, List 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));
+ }
+
+ ///
+ /// EN: Get return history for a specific order.
+ /// VI: Lay lich su tra hang cho don hang cu the.
+ ///
+ public async Task> GetOrderReturnsAsync(Guid orderId)
+ => await GetListFromApiAsync($"api/bff/orders/{orderId}/returns");
+
+ // ═══ CAFE — STAMP CARDS (membership-service) ═══
+
+ ///
+ /// EN: Stamp card info — customer's current stamp card state.
+ /// VI: Thong tin the tem — trang thai the tem hien tai cua khach hang.
+ ///
+ public record StampCardInfo(Guid Id, Guid MemberId, string CardName, int TotalStampsRequired,
+ int CurrentStamps, bool IsCompleted, bool RewardClaimed);
+
+ ///
+ /// EN: Barista queue item — a single drink in the preparation queue.
+ /// VI: Mon trong hang doi barista — mot ly trong hang doi pha che.
+ ///
+ public record BaristaQueueItemInfo(Guid Id, string DrinkName, string? Customizations,
+ string Status, string? AssignedTo, int EstimatedMinutes, int Priority,
+ DateTime CreatedAt, DateTime? StartedAt);
+
+ ///
+ /// 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.
+ ///
+ public record BaristaQueueStatsInfo(int TotalQueued, int Preparing, int Ready, double AvgPrepTimeMinutes);
+
+ ///
+ /// EN: Get a customer's stamp card for a specific shop.
+ /// VI: Lay the tem cua khach hang cho mot cua hang cu the.
+ ///
+ public async Task GetStampCardAsync(Guid shopId, Guid memberId)
+ => await GetObjectFromApiAsync($"api/bff/cafe/{shopId}/stamp-cards/{memberId}");
+
+ ///
+ /// 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).
+ ///
+ public async Task AddStampAsync(Guid shopId, Guid memberId, Guid? orderId = null)
+ => await PostAndGetAsync($"api/bff/cafe/{shopId}/stamp-cards/{memberId}/stamps",
+ new { orderId });
+
+ ///
+ /// EN: Claim the free-drink reward when the stamp card is completed.
+ /// VI: Nhan thuong ly mien phi khi the tem da day.
+ ///
+ public async Task ClaimRewardAsync(Guid stampCardId)
+ => await PostAsync($"api/bff/cafe/stamp-cards/{stampCardId}/claim", new { });
+
+ ///
+ /// 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.
+ ///
+ public async Task> GetBaristaQueueAsync(Guid shopId)
+ => await GetListFromApiAsync($"api/bff/cafe/{shopId}/barista-queue");
+
+ ///
+ /// EN: Get aggregate stats for the barista queue.
+ /// VI: Lay thong ke tong hop cho hang doi barista.
+ ///
+ public async Task GetBaristaQueueStatsAsync(Guid shopId)
+ => await GetObjectFromApiAsync($"api/bff/cafe/{shopId}/barista-queue/stats");
+
+ ///
+ /// EN: Add a drink to the barista queue.
+ /// VI: Them ly vao hang doi barista.
+ ///
+ public record QueueDrinkRequest(string DrinkName, string? Customizations, int EstimatedMinutes, int Priority = 0, Guid? OrderId = null);
+
+ public async Task QueueDrinkAsync(Guid shopId, QueueDrinkRequest req)
+ => await PostAndGetAsync($"api/bff/cafe/{shopId}/barista-queue", req);
+
+ ///
+ /// EN: Start preparing a drink — assigns barista name.
+ /// VI: Bat dau pha che — gan ten barista.
+ ///
+ public async Task StartPreparingDrinkAsync(Guid queueItemId, string baristaName)
+ => await PostAsync($"api/bff/cafe/barista-queue/{queueItemId}/start",
+ new { baristaName });
+
+ ///
+ /// EN: Mark a drink as ready for pickup.
+ /// VI: Danh dau ly da san sang de lay.
+ ///
+ public async Task MarkDrinkReadyAsync(Guid queueItemId)
+ => await PostAsync($"api/bff/cafe/barista-queue/{queueItemId}/ready", new { });
+
+ ///
+ /// EN: Mark a drink as delivered to the customer.
+ /// VI: Danh dau ly da giao cho khach hang.
+ ///
+ public async Task 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);
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ShopSidebarConfig.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ShopSidebarConfig.cs
index 73b561da..9cc520e4 100644
--- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ShopSidebarConfig.cs
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ShopSidebarConfig.cs
@@ -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"),
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/en-US.json b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/en-US.json
index 60f6da92..f1e0709d 100644
--- a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/en-US.json
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/en-US.json
@@ -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",
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json
index 5e47a2d7..4b6c6b4c 100644
--- a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json
@@ -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ụ",
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs
index 024a72be..16ceedd1 100644
--- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs
@@ -19,14 +19,6 @@ public class BookingController : ControllerBase
_booking = httpClientFactory.CreateClient("BookingService");
}
- ///
- /// EN: Get appointments for a specific shop.
- /// VI: Lấy lịch hẹn của một cửa hàng cụ thể.
- ///
- [HttpGet("shops/{shopId}/appointments")]
- public Task GetAppointments(Guid shopId) =>
- _booking.GetAsync($"/api/v1/appointments?shopId={shopId}").ProxyAsync();
-
///
/// 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();
///
- /// 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ó.
///
[HttpDelete("appointments/{apptId:guid}/cancel")]
public async Task 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 ═══
+
+ ///
+ /// 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.
+ ///
+ [HttpGet("shops/{shopId}/therapists")]
+ public Task GetTherapists(Guid shopId) =>
+ _booking.GetAsync($"/api/v1/therapists?shopId={shopId}").ProxyAsync();
+
+ ///
+ /// EN: Create a therapist.
+ /// VI: Tạo nhân viên trị liệu.
+ ///
+ [HttpPost("therapists")]
+ public Task CreateTherapist([FromBody] JsonElement body) =>
+ _booking.PostAsJsonAsync("/api/v1/therapists", body).ProxyAsync();
+
+ ///
+ /// EN: Update a therapist.
+ /// VI: Cập nhật nhân viên trị liệu.
+ ///
+ [HttpPut("therapists/{therapistId:guid}")]
+ public Task UpdateTherapist(Guid therapistId, [FromBody] JsonElement body) =>
+ _booking.PutAsJsonAsync($"/api/v1/therapists/{therapistId}", body).ProxyAsync();
+
+ ///
+ /// EN: Deactivate (soft-delete) a therapist.
+ /// VI: Vô hiệu hóa (xóa mềm) nhân viên trị liệu.
+ ///
+ [HttpDelete("therapists/{therapistId:guid}")]
+ public Task DeactivateTherapist(Guid therapistId) =>
+ _booking.DeleteAsync($"/api/v1/therapists/{therapistId}").ProxyAsync();
+
+ ///
+ /// 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.
+ ///
+ [HttpGet("shops/{shopId}/appointments")]
+ public Task GetAppointments(Guid shopId, [FromQuery] string? date = null)
+ {
+ var qs = !string.IsNullOrEmpty(date) ? $"&date={date}" : "";
+ return _booking.GetAsync($"/api/v1/appointments?shopId={shopId}{qs}").ProxyAsync();
+ }
+
+ ///
+ /// 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).
+ ///
+ [HttpPatch("appointments/{apptId:guid}/status")]
+ public Task UpdateAppointmentStatus(Guid apptId, [FromBody] JsonElement body) =>
+ _booking.PatchAsync($"/api/v1/appointments/{apptId}/status",
+ System.Net.Http.Json.JsonContent.Create(body)).ProxyAsync();
+
///
/// EN: Get resources for a specific shop.
/// VI: Lấy tài nguyên của một cửa hàng cụ thể.
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/CafeController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/CafeController.cs
new file mode 100644
index 00000000..e96b5c9a
--- /dev/null
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/CafeController.cs
@@ -0,0 +1,101 @@
+using System.Text.Json;
+using Microsoft.AspNetCore.Mvc;
+using WebClientTpos.Server.Infrastructure;
+
+namespace WebClientTpos.Server.Controllers;
+
+///
+/// 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.
+///
+[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) ═══
+
+ ///
+ /// EN: Get a customer's stamp card for a specific shop.
+ /// VI: Lay the tem cua khach hang cho mot cua hang cu the.
+ ///
+ [HttpGet("{shopId:guid}/stamp-cards/{memberId:guid}")]
+ public Task GetStampCard(Guid shopId, Guid memberId) =>
+ _membership.GetAsync($"/api/v1/stamp-cards?shopId={shopId}&memberId={memberId}").ProxyAsync();
+
+ ///
+ /// EN: Add a stamp to the customer's card.
+ /// VI: Them tem vao the khach hang.
+ ///
+ [HttpPost("{shopId:guid}/stamp-cards/{memberId:guid}/stamps")]
+ public Task AddStamp(Guid shopId, Guid memberId, [FromBody] JsonElement body) =>
+ _membership.PostAsJsonAsync($"/api/v1/stamp-cards/{shopId}/{memberId}/stamps", body).ProxyAsync();
+
+ ///
+ /// EN: Claim reward for a completed stamp card.
+ /// VI: Nhan thuong cho the tem da hoan tat.
+ ///
+ [HttpPost("stamp-cards/{stampCardId:guid}/claim")]
+ public Task ClaimReward(Guid stampCardId, [FromBody] JsonElement body) =>
+ _membership.PostAsJsonAsync($"/api/v1/stamp-cards/{stampCardId}/claim", body).ProxyAsync();
+
+ // ═══ BARISTA QUEUE (fnb-engine) ═══
+
+ ///
+ /// EN: Get all items in the barista queue for a shop.
+ /// VI: Lay tat ca mon trong hang doi barista cua cua hang.
+ ///
+ [HttpGet("{shopId:guid}/barista-queue")]
+ public Task GetBaristaQueue(Guid shopId) =>
+ _fnb.GetAsync($"/api/v1/barista-queue?shopId={shopId}").ProxyAsync();
+
+ ///
+ /// EN: Get aggregate stats for the barista queue.
+ /// VI: Lay thong ke tong hop cho hang doi barista.
+ ///
+ [HttpGet("{shopId:guid}/barista-queue/stats")]
+ public Task GetBaristaQueueStats(Guid shopId) =>
+ _fnb.GetAsync($"/api/v1/barista-queue/stats?shopId={shopId}").ProxyAsync();
+
+ ///
+ /// EN: Add a drink to the barista queue.
+ /// VI: Them ly vao hang doi barista.
+ ///
+ [HttpPost("{shopId:guid}/barista-queue")]
+ public Task QueueDrink(Guid shopId, [FromBody] JsonElement body) =>
+ _fnb.PostAsJsonAsync($"/api/v1/barista-queue?shopId={shopId}", body).ProxyAsync();
+
+ ///
+ /// EN: Start preparing a drink — assigns barista name.
+ /// VI: Bat dau pha che — gan ten barista.
+ ///
+ [HttpPost("barista-queue/{queueItemId:guid}/start")]
+ public Task StartPreparing(Guid queueItemId, [FromBody] JsonElement body) =>
+ _fnb.PostAsJsonAsync($"/api/v1/barista-queue/{queueItemId}/start", body).ProxyAsync();
+
+ ///
+ /// EN: Mark a drink as ready for pickup.
+ /// VI: Danh dau ly da san sang de lay.
+ ///
+ [HttpPost("barista-queue/{queueItemId:guid}/ready")]
+ public Task MarkReady(Guid queueItemId, [FromBody] JsonElement body) =>
+ _fnb.PostAsJsonAsync($"/api/v1/barista-queue/{queueItemId}/ready", body).ProxyAsync();
+
+ ///
+ /// EN: Mark a drink as delivered to the customer.
+ /// VI: Danh dau ly da giao cho khach hang.
+ ///
+ [HttpPost("barista-queue/{queueItemId:guid}/delivered")]
+ public Task MarkDelivered(Guid queueItemId, [FromBody] JsonElement body) =>
+ _fnb.PostAsJsonAsync($"/api/v1/barista-queue/{queueItemId}/delivered", body).ProxyAsync();
+}
diff --git a/infra/traefik/dynamic/routes.yml b/infra/traefik/dynamic/routes.yml
index 71484602..60b5224d 100644
--- a/infra/traefik/dynamic/routes.yml
+++ b/infra/traefik/dynamic/routes.yml
@@ -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: