From 870f1218f8095435acbc3092cfe1598bc41ea404 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 6 Mar 2026 17:03:55 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20frontend=20=E2=80=94=20Spa,?= =?UTF-8?q?=20Retail,=20Cafe=20Blazor=20UI=20pages=20and=20BFF=20proxies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spa/Beauty UI (booking-service integration): - TherapistManagement.razor: CRUD table, specialty multi-select, working hours - AppointmentCalendar.razor: daily calendar grouped by therapist, color-coded statuses - ShopTherapists embedded component for ShopPage, sidebar menu for spa/beauty - BookingController BFF: therapist CRUD + appointment proxy endpoints - Localization: vi-VN + en-US for "Nhân viên trị liệu" Retail POS UI (catalog + inventory + order integration): - RetailDesktop.razor: barcode input, API lookup, stock badges, cart warnings - ReturnDialog.razor: order lookup, return/exchange mode toggle, refund summary - StockOverview.razor: admin stock table, search/filter, threshold edit dialog - PosDataService: barcode lookup, bulk stock, return/exchange API methods Cafe UI (membership + fnb-engine integration): - StampCard.razor: visual stamp grid, animated fill, celebration UI, claim/reset - BaristaQueue.razor: 3-column Kanban, stats bar, auto-refresh 10s, pulse animation - CafeController BFF: stamp cards + barista queue proxy endpoints Infrastructure: - Traefik: added /api/v1/therapists + /api/v1/appointments to booking-service - ROADMAP: Phase 2 vertical tasks DONE, UI refinement IN-PROGRESS Co-Authored-By: Claude Opus 4.6 --- ROADMAP.md | 36 +- .../Pages/Admin/Shop/ShopPage.razor | 5 + .../Pages/Admin/Shop/ShopTherapists.razor | 284 ++++++++ .../Pages/Admin/Spa/AppointmentCalendar.razor | 609 ++++++++++++++++++ .../Pages/Admin/Spa/TherapistManagement.razor | 465 +++++++++++++ .../Pages/Admin/Store/StockOverview.razor | 438 +++++++++++++ .../Pages/Pos/Cafe/BaristaQueue.razor | 383 +++++++++++ .../Pages/Pos/Cafe/StampCard.razor | 430 +++++++++++++ .../Pages/Pos/Retail/RetailDesktop.razor | 302 +++++++-- .../Pages/Pos/Retail/ReturnDialog.razor | 434 +++++++++++++ .../Services/PosDataService.cs | 214 +++++- .../Services/ShopSidebarConfig.cs | 2 + .../wwwroot/locales/en-US.json | 1 + .../wwwroot/locales/vi-VN.json | 1 + .../Controllers/BookingController.cs | 77 ++- .../Controllers/CafeController.cs | 101 +++ infra/traefik/dynamic/routes.yml | 2 +- 17 files changed, 3709 insertions(+), 75 deletions(-) create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTherapists.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Spa/AppointmentCalendar.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Spa/TherapistManagement.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Store/StockOverview.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/BaristaQueue.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/StampCard.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Retail/ReturnDialog.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/CafeController.cs 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 ═══ *@ +
+
+ + +
+ +
+ +@* ═══ 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) + { +
+
+
+ @GetInitials(group.Key) +
+

@group.Key

+ @group.Count() lich hen +
+
+ @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 +

+
+ +
+ + @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)) + { + + } +
+ + @* EN: Stock status filter / VI: Loc trang thai ton kho *@ + +
+ + @* ═══ 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); +
+ @* 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 + +
+ + @* 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 +
+ +
+ + +
+ +
+ + +
+
+
+} + +@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 ═══ *@ +
+ + Hang doi Barista + + + Tu dong cap nhat moi 10 giay + +
+ + @if (_isLoading) + { +
+
+
+
Dang tai hang doi...
+
+
+ } + else if (_loadError) + { +
+ Khong the tai du lieu. + +
+ } + 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") + { + + } + else if (col.Status == "Preparing") + { + + } + else if (col.Status == "Ready") + { + + } +
+ } +
+
+ } +
+ } +
+ +@* EN: Barista name dialog overlay / VI: Hop thoai nhap ten barista *@ +@if (_showBaristaDialog) +{ +
+
+
Nhap ten Barista
+ +
+ + +
+
+
+} + +@* 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 ═══ *@ +
+ + The tich diem +
+ + @* ═══ CUSTOMER LOOKUP / TIM KHACH HANG ═══ *@ +
+
Tim khach hang
+
+ + +
+ @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. +
+ +
+ } + 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. +
+ +
+ } + else + { + @* EN: Normal state — add stamp button *@ + + } + + @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 *@
- + font-size:12px;font-weight:600;cursor:pointer;" @onclick="SearchBarcode">Tim
+ @* EN: Barcode lookup result alert / VI: Thong bao ket qua tra cuu ma vach *@ + @if (_barcodeNotFound) + { +
+ + Khong tim thay san pham + +
+ } + @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) {
-
+
+ @* 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 ═══ *@
- Giỏ hàng - @_cartItems.Count sản phẩm + Gio hang + @_cartItems.Count san pham
@@ -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)
- + @item.Qty
@@ -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: