From e4bedf2cd32c5543a2aa8e0f0e4492c68143b20a Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 5 Mar 2026 15:33:23 +0700 Subject: [PATCH] feat(allPos): upgrad frontend --- .../Pages/Pos/Beauty/BeautyDesktop.razor | 264 ++++++++ .../Pages/Pos/Beauty/BeautyMobile.razor | 228 +++++++ .../Pages/Pos/Beauty/BeautyTablet.razor | 217 +++++++ .../Pos/Beauty/Workflow/ConsentForm.razor | 201 ++++++ .../Pos/Beauty/Workflow/DoctorSchedule.razor | 262 ++++++++ .../Pages/Pos/Beauty/Workflow/FollowUp.razor | 287 +++++++++ .../Pos/Beauty/Workflow/TreatmentPlan.razor | 229 +++++++ .../Pages/Pos/Cafe/CafeDesktop.razor | 103 ++- .../Pages/Pos/Cafe/CafeMobile.razor | 584 +++++++++++++++-- .../Pages/Pos/Cafe/CafeTablet.razor | 593 ++++++++++++++++-- .../Pos/Cafe/Workflow/BaristaQueue.razor | 204 ++++-- .../Pages/Pos/Cafe/Workflow/CafeJourney.razor | 336 +++++++--- .../Pos/Cafe/Workflow/CustomerDisplay.razor | 70 ++- .../Pages/Pos/Cafe/Workflow/DailyReport.razor | 186 ++++-- .../Pos/Cafe/Workflow/LoyaltyStamp.razor | 211 +++++-- .../Pos/Cafe/Workflow/MenuManagement.razor | 119 +++- .../Pos/Cafe/Workflow/OrderCustomize.razor | 150 ++++- .../Pos/Cafe/Workflow/QueueDisplay.razor | 104 +-- .../Pages/Pos/Karaoke/KaraokeMobile.razor | 42 +- .../Pages/Pos/Karaoke/KaraokeTablet.razor | 76 ++- .../Pos/Karaoke/Workflow/HappyHour.razor | 84 ++- .../Pos/Karaoke/Workflow/KaraokeJourney.razor | 327 +++++++--- .../Pos/Karaoke/Workflow/MemberCard.razor | 153 ++++- .../Pos/Karaoke/Workflow/PeakWarning.razor | 216 +++++-- .../Pos/Karaoke/Workflow/RoomExtend.razor | 70 ++- .../Pos/Karaoke/Workflow/ServiceDisplay.razor | 124 +++- .../Pos/Restaurant/RestaurantMobile.razor | 97 ++- .../Pos/Restaurant/RestaurantTablet.razor | 107 +++- .../Restaurant/Workflow/CourseTiming.razor | 322 ++++++++-- .../Pos/Restaurant/Workflow/EodReport.razor | 126 +++- .../Restaurant/Workflow/KitchenDisplay.razor | 124 +++- .../Restaurant/Workflow/OrderHistory.razor | 70 ++- .../Pos/Restaurant/Workflow/TableDetail.razor | 131 +++- .../Pos/Restaurant/Workflow/TableMap.razor | 248 +++++++- .../Pos/Restaurant/Workflow/WaiterPad.razor | 188 ++++-- .../Pages/Pos/Shared/Dialogs/SplitBill.razor | 442 +++++++------ .../Pos/Shared/Payment/BankTransfer.razor | 205 ++++-- .../Pos/Shared/Payment/CardPayment.razor | 172 +++-- .../Pos/Shared/Payment/CashPayment.razor | 258 +++++--- .../Pos/Shared/Payment/MethodSelect.razor | 165 ++++- .../Pos/Shared/Payment/PaymentSuccess.razor | 218 +++++-- .../Pages/Pos/Shared/Payment/QrPayment.razor | 232 +++++-- .../Pages/Pos/Shared/Payment/Receipt.razor | 303 +++++---- .../Pages/Pos/Spa/SpaDesktop.razor | 51 +- .../Pages/Pos/Spa/SpaMobile.razor | 43 +- .../Pages/Pos/Spa/SpaTablet.razor | 43 +- .../Pos/Spa/Workflow/AppointmentBook.razor | 135 +++- .../Pos/Spa/Workflow/CustomerLookup.razor | 91 ++- .../Pos/Spa/Workflow/CustomerProfile.razor | 357 ++++++++--- .../Pages/Pos/Spa/Workflow/ServiceCombo.razor | 204 ++++-- .../Pos/Spa/Workflow/ServicePackage.razor | 27 +- .../Pages/Pos/Spa/Workflow/StaffAssign.razor | 81 ++- .../Pos/Spa/Workflow/TherapistSchedule.razor | 151 +++-- .../Pos/Spa/Workflow/TreatmentTimer.razor | 145 ++++- .../Services/PosDataService.cs | 40 +- 55 files changed, 8422 insertions(+), 1794 deletions(-) create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/BeautyDesktop.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/BeautyMobile.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/BeautyTablet.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/ConsentForm.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/DoctorSchedule.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/FollowUp.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/TreatmentPlan.razor diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/BeautyDesktop.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/BeautyDesktop.razor new file mode 100644 index 00000000..065a4acd --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/BeautyDesktop.razor @@ -0,0 +1,264 @@ +@* + EN: Beauty Salon POS Desktop — 2-panel layout: service categories + grid (left), current appointment/bill (right). + VI: POS Tham My Desktop — Bo cuc 2 panel: danh muc dich vu + luoi (trai), lich hen/hoa don hien tai (phai). +*@ +@page "/pos/{ShopId:guid}/beauty" +@layout PosLayout +@inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService + +@* === SERVICE PANEL (LEFT) / PANEL DICH VU (TRAI) === *@ +
+ @if (_isLoading) + { +
+ Dang tai... +
+ } + else if (_loadError) + { +
+ Khong the tai du lieu +
+ } + else + { + @* EN: Category tabs / VI: Tab danh muc *@ +
+ @foreach (var cat in _categories) + { + + } +
+ + @* === SERVICE GRID / LUOI DICH VU === *@ + @if (!_services.Any()) + { +
+ +
Chua co dich vu nao
+
Vui long them dich vu trong phan Quan ly cua hang
+
+ } + else + { +
+ @foreach (var svc in FilteredServices) + { +
+
+ +
+ @svc.Name + @FormatPrice(svc.Price) + + @svc.Duration phut + +
+ } +
+ } + } +
+ +@* === APPOINTMENT PANEL (RIGHT) / PANEL LICH HEN (PHAI) === *@ +
+
+ + + Dich Vu Tham My + + @_appointmentItems.Count dich vu +
+ + @* EN: Customer info / VI: Thong tin khach *@ + @if (_customerName is not null) + { +
+
+ @_customerName[..1] +
+
+
@_customerName
+
@_customerPhone
+
+
+ } + else + { +
+ +
+ } + +
+ @foreach (var item in _appointmentItems) + { +
+
+ @item.Name +
+ @FormatPrice(item.Price) + @item.Duration phut +
+
+
+ +
+
+ } +
+ + +
+ +@code { + + // EN: Loading state / VI: Trang thai tai + private bool _isLoading = true; + private bool _loadError; + + // EN: Categories / VI: Danh muc + private string[] _categories = { "Tat ca" }; + private string _selectedCategory = "Tat ca"; + + // EN: Customer info / VI: Thong tin khach hang + private string? _customerName; + private string _customerPhone = ""; + private bool _isCheckingOut; + private bool _consentChecked; + + // EN: Service list from API / VI: Danh sach dich vu tu API + private List _services = new(); + + // EN: Appointment items / VI: Muc lich hen + private readonly List _appointmentItems = new(); + private IEnumerable FilteredServices => + _selectedCategory == "Tat ca" ? _services : _services.Where(s => s.Category == _selectedCategory); + private decimal AppointmentTotal => _appointmentItems.Sum(i => i.Price); + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + try + { + var apiProducts = await DataService.GetProductsAsync(ShopId); + + _services = apiProducts.Select(p => new BeautyService( + p.Id, + p.Name, + p.Price, + p.DurationMinutes ?? 60, + p.Category ?? "Khac" + )).ToList(); + + var cats = _services.Select(s => s.Category).Distinct().ToList(); + _categories = new[] { "Tat ca" }.Concat(cats).ToArray(); + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + } + } + + private void AddToAppointment(BeautyService svc) + { + _appointmentItems.Add(new AppointmentItem(svc.Id, svc.Name, svc.Price, svc.Duration)); + } + + private void RemoveItem(AppointmentItem item) => _appointmentItems.Remove(item); + + private async Task Checkout() + { + if (!_appointmentItems.Any() || !_consentChecked) return; + + _isCheckingOut = true; + try + { + var orderItems = _appointmentItems.Select(i => + new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest( + i.ProductId, i.Name, 1, i.Price, "Service" + )).ToList(); + + var request = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest( + ShopId, null, orderItems); + + var result = await DataService.CreatePosOrderAsync(request); + if (result is not null) + { + NavigateTo("beauty/follow-up"); + } + } + catch + { + // EN: Order creation failed / VI: Tao don hang that bai + } + finally + { + _isCheckingOut = false; + } + } + + private static string GetCategoryIcon(string category) => category switch + { + "Botox" => "syringe", "Filler" => "droplets", "Laser" => "zap", + "Skincare" => "sparkles", "Phau thuat" => "stethoscope", _ => "scissors" + }; + + // EN: Models / VI: Mo hinh du lieu + private record BeautyService(Guid Id, string Name, decimal Price, int Duration, string Category); + private record AppointmentItem(Guid ProductId, string Name, decimal Price, int Duration); +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/BeautyMobile.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/BeautyMobile.razor new file mode 100644 index 00000000..43f58c6e --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/BeautyMobile.razor @@ -0,0 +1,228 @@ +@* + EN: Beauty Salon POS Mobile — Single column: categories, service grid, floating appointment button, bottom sheet. + VI: POS Tham My Mobile — Mot cot: danh muc, luoi dich vu, nut lich hen noi, sheet duoi. +*@ +@page "/pos/{ShopId:guid}/beauty/mobile" +@layout PosLayout +@inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService + +
+ @if (_isLoading) + { +
+ Dang tai... +
+ } + else if (_loadError) + { +
+ Khong the tai du lieu +
+ } + else + { + @* EN: Category tabs / VI: Tab danh muc *@ +
+ @foreach (var cat in _categories) + { + + } +
+ + @* EN: Service grid / VI: Luoi dich vu *@ +
+ @foreach (var svc in FilteredServices) + { +
+
+ +
+ @svc.Name + @FormatPrice(svc.Price) + @svc.Duration phut +
+ } +
+ } + + @* EN: Floating appointment button / VI: Nut lich hen noi *@ + @if (_appointmentItems.Any()) + { + + } + + @* EN: Bottom sheet appointment / VI: Lich hen dang sheet duoi *@ + @if (_showSheet) + { +
+
+ @* EN: Handle bar / VI: Thanh keo *@ +
+
+
+ +
+ + + Dich Vu Tham My + + +
+ +
+ @foreach (var item in _appointmentItems) + { +
+
+ @item.Name +
+ @FormatPrice(item.Price) + @item.Duration phut +
+
+ +
+ } +
+ + +
+
+ } +
+ +@code { + + // EN: Loading state / VI: Trang thai tai + private bool _isLoading = true; + private bool _loadError; + + // EN: Categories / VI: Danh muc + private string[] _categories = { "Tat ca" }; + private string _selectedCategory = "Tat ca"; + private bool _showSheet; + private bool _consentChecked; + private bool _isCheckingOut; + + // EN: Service list from API / VI: Danh sach dich vu tu API + private List _services = new(); + + // EN: Appointment items / VI: Muc lich hen + private readonly List _appointmentItems = new(); + private IEnumerable FilteredServices => + _selectedCategory == "Tat ca" ? _services : _services.Where(s => s.Category == _selectedCategory); + private decimal AppointmentTotal => _appointmentItems.Sum(i => i.Price); + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + try + { + var apiProducts = await DataService.GetProductsAsync(ShopId); + + _services = apiProducts.Select(p => new BeautyService( + p.Id, + p.Name, + p.Price, + p.DurationMinutes ?? 60, + p.Category ?? "Khac" + )).ToList(); + + var cats = _services.Select(s => s.Category).Distinct().ToList(); + _categories = new[] { "Tat ca" }.Concat(cats).ToArray(); + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + } + } + + private void AddToAppointment(BeautyService svc) + { + _appointmentItems.Add(new AppointmentItem(svc.Id, svc.Name, svc.Price, svc.Duration)); + } + + private async Task Checkout() + { + if (!_appointmentItems.Any() || !_consentChecked) return; + + _isCheckingOut = true; + try + { + var orderItems = _appointmentItems.Select(i => + new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest( + i.ProductId, i.Name, 1, i.Price, "Service" + )).ToList(); + + var request = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest( + ShopId, null, orderItems); + + var result = await DataService.CreatePosOrderAsync(request); + if (result is not null) + { + NavigateTo("beauty/follow-up"); + } + } + catch + { + // EN: Order creation failed / VI: Tao don hang that bai + } + finally + { + _isCheckingOut = false; + } + } + + private static string GetCategoryIcon(string category) => category switch + { + "Botox" => "syringe", "Filler" => "droplets", "Laser" => "zap", + "Skincare" => "sparkles", "Phau thuat" => "stethoscope", _ => "scissors" + }; + + // EN: Models / VI: Mo hinh du lieu + private record BeautyService(Guid Id, string Name, decimal Price, int Duration, string Category); + private record AppointmentItem(Guid ProductId, string Name, decimal Price, int Duration); +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/BeautyTablet.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/BeautyTablet.razor new file mode 100644 index 00000000..6b6d5826 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/BeautyTablet.razor @@ -0,0 +1,217 @@ +@* + EN: Beauty Salon POS Tablet — 2-column layout: service grid + appointment sidebar (340px), touch-friendly. + VI: POS Tham My Tablet — Bo cuc 2 cot: luoi dich vu + sidebar lich hen (340px), than thien cam ung. +*@ +@page "/pos/{ShopId:guid}/beauty/tablet" +@layout PosLayout +@inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService + +@* === SERVICE PANEL / PANEL DICH VU === *@ +
+ @if (_isLoading) + { +
+ Dang tai... +
+ } + else if (_loadError) + { +
+ Khong the tai du lieu +
+ } + else + { +
+ @foreach (var cat in _categories) + { + + } +
+ +
+ @foreach (var svc in FilteredServices) + { +
+
+ +
+ @svc.Name + @FormatPrice(svc.Price) + + @svc.Duration phut + +
+ } +
+ } +
+ +@* === APPOINTMENT SIDEBAR / SIDEBAR LICH HEN === *@ +
+
+ + + Dich Vu Tham My + + +
+ + @* EN: Customer selection / VI: Chon khach hang *@ +
+ +
+ +
+ @foreach (var item in _appointmentItems) + { +
+
+ @item.Name +
+ @FormatPrice(item.Price) + @item.Duration phut +
+
+ +
+ } +
+ + +
+ +@code { + + // EN: Loading state / VI: Trang thai tai + private bool _isLoading = true; + private bool _loadError; + + // EN: Categories / VI: Danh muc + private string[] _categories = { "Tat ca" }; + private string _selectedCategory = "Tat ca"; + private bool _consentChecked; + private bool _isCheckingOut; + + // EN: Service list from API / VI: Danh sach dich vu tu API + private List _services = new(); + + // EN: Appointment items / VI: Muc lich hen + private readonly List _appointmentItems = new(); + private IEnumerable FilteredServices => + _selectedCategory == "Tat ca" ? _services : _services.Where(s => s.Category == _selectedCategory); + private decimal AppointmentTotal => _appointmentItems.Sum(i => i.Price); + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + try + { + var apiProducts = await DataService.GetProductsAsync(ShopId); + + _services = apiProducts.Select(p => new BeautyService( + p.Id, + p.Name, + p.Price, + p.DurationMinutes ?? 60, + p.Category ?? "Khac" + )).ToList(); + + var cats = _services.Select(s => s.Category).Distinct().ToList(); + _categories = new[] { "Tat ca" }.Concat(cats).ToArray(); + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + } + } + + private void AddToAppointment(BeautyService svc) + { + _appointmentItems.Add(new AppointmentItem(svc.Id, svc.Name, svc.Price, svc.Duration)); + } + + private async Task Checkout() + { + if (!_appointmentItems.Any() || !_consentChecked) return; + + _isCheckingOut = true; + try + { + var orderItems = _appointmentItems.Select(i => + new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest( + i.ProductId, i.Name, 1, i.Price, "Service" + )).ToList(); + + var request = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest( + ShopId, null, orderItems); + + var result = await DataService.CreatePosOrderAsync(request); + if (result is not null) + { + NavigateTo("beauty/follow-up"); + } + } + catch + { + // EN: Order creation failed / VI: Tao don hang that bai + } + finally + { + _isCheckingOut = false; + } + } + + private static string GetCategoryIcon(string category) => category switch + { + "Botox" => "syringe", "Filler" => "droplets", "Laser" => "zap", + "Skincare" => "sparkles", "Phau thuat" => "stethoscope", _ => "scissors" + }; + + // EN: Models / VI: Mo hinh du lieu + private record BeautyService(Guid Id, string Name, decimal Price, int Duration, string Category); + private record AppointmentItem(Guid ProductId, string Name, decimal Price, int Duration); +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/ConsentForm.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/ConsentForm.razor new file mode 100644 index 00000000..b55f1a87 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/ConsentForm.razor @@ -0,0 +1,201 @@ +@* + EN: Consent Form — Digital consent form before medical/cosmetic procedures at beauty salon. + VI: Phieu Dong Y — Phieu dong y dien tu truoc khi thuc hien thu tuc y te/tham my. +*@ +@page "/pos/{ShopId:guid}/beauty/consent-form" +@layout PosLayout +@inherits PosBase + +@* TODO: Integrate with backend API for persisting consent forms *@ + +
+ @* === HEADER / TIEU DE === *@ +
+
+ + + + Phieu Dong Y Dieu Tri + +
+
+ +
+ + @* === SERVICE & CUSTOMER INFO / THONG TIN DICH VU & KHACH HANG === *@ +
+
+ Thong tin dieu tri +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + @* === CONSENT SECTIONS / PHAN DONG Y === *@ +
+
+ Cam ket dong y +
+ +
+ @* Consent 1: Counseled about procedure *@ + + + @* Consent 2: Understand risks *@ + + + @* Consent 3: Agree to treatment *@ + + + @* Consent 4: Health info accurate *@ + +
+
+ + @* === MEDICAL HISTORY / TIEN SU Y TE === *@ +
+
+ Tien su y te +
+
+
+ + +
+
+ + +
+
+
+ + @* === SIGNATURE / CHU KY === *@ +
+
+ Chu ky dien tu +
+
+ + +
+
+ Ngay ky: @DateTime.Now.ToString("dd/MM/yyyy HH:mm") +
+
+ + @* === CONFIRM BUTTON / NUT XAC NHAN === *@ +
+ +
+
+
+ +@code { + // TODO: Replace local state with API calls when backend consent form endpoints are available + + // EN: Service/customer/doctor info / VI: Thong tin dich vu/khach hang/bac si + private string _serviceName = ""; + private string _customerName = ""; + private string _doctorName = ""; + + // EN: Consent checkboxes / VI: Cac muc dong y + private bool _consent1; + private bool _consent2; + private bool _consent3; + private bool _consent4; + + // EN: Medical history / VI: Tien su y te + private string _allergies = ""; + private string _currentMedications = ""; + + // EN: Signature / VI: Chu ky + private string _signature = ""; + + private bool AllConsentsChecked => _consent1 && _consent2 && _consent3 && _consent4; + private bool IsSignatureValid => !string.IsNullOrWhiteSpace(_signature); + private bool CanConfirm => AllConsentsChecked && IsSignatureValid; + + private void ConfirmConsent() + { + if (!CanConfirm) return; + + // TODO: Save consent form data to backend API + // var consentData = new { ServiceName = _serviceName, CustomerName = _customerName, ... }; + + NavigateTo("beauty"); + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/DoctorSchedule.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/DoctorSchedule.razor new file mode 100644 index 00000000..7b330ce5 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/DoctorSchedule.razor @@ -0,0 +1,262 @@ +@* + EN: Doctor Schedule — Calendar day view with horizontal timeline, doctor/specialist rows, appointment blocks, current time. + VI: Lich Bac Si — Xem theo ngay voi timeline ngang, hang bac si/chuyen gia, khoi lich hen, thoi gian hien tai. +*@ +@page "/pos/{ShopId:guid}/beauty/doctor-schedule" +@layout PosLayout +@inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService + +
+ @* === HEADER / TIEU DE === *@ +
+
+ + + + Lich Bac Si + + Hom nay, @DateTime.Now.ToString("dd/MM/yyyy") +
+ + @* EN: Legend / VI: Chu thich *@ +
+ + Botox + + + Filler + + + Laser + + + Skincare + +
+
+ + @* === SCHEDULE GRID / LUOI LICH === *@ + @if (_isLoading) + { +
+ Dang tai lich... +
+ } + else if (_loadError) + { +
+ Khong the tai du lieu lich +
+ } + else + { +
+ @* EN: Time header row / VI: Hang tieu de gio *@ +
+
+ Bac si +
+
+ @foreach (var hour in _hours) + { +
+ @hour:00 +
+ } +
+
+ + @* EN: Doctor rows / VI: Hang bac si *@ + @foreach (var doctor in _scheduleData) + { +
+ @* EN: Doctor name column / VI: Cot ten bac si *@ +
+
+ @doctor.Name[..1] +
+
+
@doctor.Name
+
@doctor.Role
+
+
+ + @* EN: Timeline with blocks / VI: Timeline voi khoi lich *@ +
+ @foreach (var hour in _hours) + { +
+ } + + @* EN: Appointment blocks / VI: Khoi lich hen *@ + @foreach (var appt in doctor.Appointments) + { + var leftPos = (appt.StartHour - 9) * 100 + (appt.StartMin / 30.0 * 50); + var widthVal = appt.DurationMin / 30.0 * 50; +
+
+ @appt.CustomerName +
+
+ @appt.Service +
+
+ } +
+
+ } + + @* EN: Current time indicator / VI: Chi bao thoi gian hien tai *@ +
+
+
+
+ + } + + @* === SUMMARY / TOM TAT === *@ +
+ Tong: @_scheduleData.Sum(s => s.Appointments.Count) lich hen + Dang thuc hien: @_inProgressCount + Hoan thanh: @_completedCount + Sap toi: @_upcomingCount +
+
+ +@code { + // EN: Loading state / VI: Trang thai tai + private bool _isLoading = true; + private bool _loadError; + + // EN: Hours range / VI: Pham vi gio + private readonly int[] _hours = { 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 }; + private List _scheduleData = new(); + + // EN: Summary counters / VI: Bo dem tom tat + private int _inProgressCount; + private int _completedCount; + private int _upcomingCount; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + try + { + // EN: Load staff and appointments in parallel / VI: Tai nhan vien va lich hen song song + var staffTask = DataService.GetStaffForShopAsync(ShopId); + var appointmentsTask = DataService.GetAppointmentsAsync(ShopId); + + var staffData = await staffTask; + var appointments = await appointmentsTask; + + // EN: Filter today's appointments / VI: Loc lich hen hom nay + var todayAppointments = appointments + .Where(a => a.StartTime.Date == DateTime.Today) + .ToList(); + + // EN: Count by status / VI: Dem theo trang thai + var now = DateTime.Now; + _completedCount = todayAppointments.Count(a => a.Status?.ToLower() == "completed"); + _inProgressCount = todayAppointments.Count(a => a.StartTime <= now && a.EndTime >= now && a.Status?.ToLower() != "completed" && a.Status?.ToLower() != "cancelled"); + _upcomingCount = todayAppointments.Count(a => a.StartTime > now && a.Status?.ToLower() != "cancelled"); + + // EN: Group appointments by staff / VI: Nhom lich hen theo nhan vien + var staffMap = staffData.ToDictionary(s => s.Id, s => + { + var name = $"{s.FirstName ?? ""} {s.LastName ?? ""}".Trim(); + if (string.IsNullOrEmpty(name)) name = s.EmployeeCode ?? s.Id.ToString()[..8]; + return (Name: name, Role: s.Role ?? "Bac si"); + }); + + // EN: Build schedule data — group by staff / VI: Xay dung du lieu lich — nhom theo nhan vien + var grouped = todayAppointments + .Where(a => a.StaffId.HasValue) + .GroupBy(a => a.StaffId!.Value) + .ToList(); + + _scheduleData = new(); + + foreach (var group in grouped) + { + var doctorName = staffMap.TryGetValue(group.Key, out var info) ? info.Name : group.Key.ToString()[..8]; + var doctorRole = staffMap.TryGetValue(group.Key, out var info2) ? info2.Role : "Bac si"; + + var blocks = group.Select(a => new AppointmentBlock( + a.ResourceName ?? "Khach", + a.ResourceName ?? "Dich vu", + GuessServiceType(a.ResourceName), + a.StartTime.Hour, + a.StartTime.Minute, + (int)(a.EndTime - a.StartTime).TotalMinutes + )).ToList(); + + _scheduleData.Add(new DoctorScheduleRow(doctorName, doctorRole, blocks)); + } + + // EN: Add staff without appointments / VI: Them nhan vien chua co lich hen + var assignedStaffIds = grouped.Select(g => g.Key).ToHashSet(); + foreach (var s in staffData.Where(s => !assignedStaffIds.Contains(s.Id))) + { + var name = $"{s.FirstName ?? ""} {s.LastName ?? ""}".Trim(); + if (string.IsNullOrEmpty(name)) name = s.EmployeeCode ?? s.Id.ToString()[..8]; + _scheduleData.Add(new DoctorScheduleRow(name, s.Role ?? "Bac si", new())); + } + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + } + } + + // EN: Guess service type from name for color coding / VI: Doan loai dich vu tu ten de to mau + private static string GuessServiceType(string? name) + { + if (name is null) return "Other"; + var lower = name.ToLower(); + if (lower.Contains("botox") || lower.Contains("tiem")) return "Botox"; + if (lower.Contains("filler") || lower.Contains("chat lan")) return "Filler"; + if (lower.Contains("laser") || lower.Contains("tri nam") || lower.Contains("triet long")) return "Laser"; + if (lower.Contains("skincare") || lower.Contains("da") || lower.Contains("duong")) return "Skincare"; + return "Other"; + } + + // EN: Beauty service type colors / VI: Mau loai dich vu tham my + private static string GetServiceBg(string type) => type switch + { + "Botox" => "rgba(168,85,247,.15)", // #A855F7 purple + "Filler" => "rgba(236,72,153,.15)", // #EC4899 pink + "Laser" => "rgba(245,158,11,.15)", // #F59E0B amber + "Skincare" => "rgba(34,197,94,.15)", // #22C55E green + _ => "rgba(148,163,184,.15)" + }; + + private static string GetServiceColor(string type) => type switch + { + "Botox" => "#A855F7", + "Filler" => "#EC4899", + "Laser" => "#F59E0B", + "Skincare" => "#22C55E", + _ => "#94A3B8" + }; + + private record AppointmentBlock(string CustomerName, string Service, string Type, int StartHour, int StartMin, int DurationMin); + private record DoctorScheduleRow(string Name, string Role, List Appointments); +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/FollowUp.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/FollowUp.razor new file mode 100644 index 00000000..268f2d66 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/FollowUp.razor @@ -0,0 +1,287 @@ +@* + EN: Follow-Up Management — Manage follow-up appointments after beauty treatments. + VI: Quan Ly Tai Kham — Quan ly cac lich hen tai kham sau dieu tri tham my. +*@ +@page "/pos/{ShopId:guid}/beauty/follow-up" +@layout PosLayout +@inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService + +
+ @* === HEADER / TIEU DE === *@ +
+
+ + + + Tai Kham + +
+ + @* EN: Summary stats / VI: Thong ke tom tat *@ +
+ + Tong: @_followUps.Count + + + Qua han: @_followUps.Count(f => f.Status == "overdue") + + + Hoan thanh hom nay: @_followUps.Count(f => f.Status == "completed" && f.FollowUpDate.Date == DateTime.Today) + +
+
+ + @* === FILTER TABS / TAB LOC === *@ +
+ @foreach (var tab in _tabs) + { + + } +
+ + @* === FOLLOW-UP LIST / DANH SACH TAI KHAM === *@ + @if (_isLoading) + { +
+ Dang tai... +
+ } + else if (_loadError) + { +
+ Khong the tai du lieu +
+ } + else + { +
+ @if (!FilteredFollowUps.Any()) + { +
+ +
Khong co lich tai kham
+
Khong co lich tai kham nao trong muc nay
+
+ } + + @foreach (var followUp in FilteredFollowUps) + { +
+
+
+
@followUp.CustomerName
+
+ @followUp.CustomerPhone +
+
+ + @GetStatusLabel(followUp.Status) + +
+ +
+
+ Dieu tri goc: +
@followUp.TreatmentName
+
+
+ Ngay dieu tri: +
@followUp.TreatmentDate.ToString("dd/MM/yyyy")
+
+
+ Ngay tai kham: +
+ @followUp.FollowUpDate.ToString("dd/MM/yyyy") + @if (followUp.FollowUpDate.Date < DateTime.Today && followUp.Status != "completed") + { + (qua han) + } +
+
+
+ Bac si: +
@followUp.DoctorName
+
+
+ + @* EN: Action buttons / VI: Nut hanh dong *@ +
+ + + +
+
+ } +
+ } +
+ +@code { + // EN: Loading state / VI: Trang thai tai + private bool _isLoading = true; + private bool _loadError; + + private string _selectedTab = "today"; + private List _followUps = new(); + + private record TabItem(string Key, string Label, int Count); + private TabItem[] _tabs = Array.Empty(); + + private IEnumerable FilteredFollowUps => _selectedTab switch + { + "today" => _followUps.Where(f => f.FollowUpDate.Date == DateTime.Today), + "week" => _followUps.Where(f => f.FollowUpDate.Date >= DateTime.Today && f.FollowUpDate.Date <= DateTime.Today.AddDays(7)), + "overdue" => _followUps.Where(f => f.FollowUpDate.Date < DateTime.Today && f.Status != "completed"), + _ => _followUps + }; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + try + { + var appointments = await DataService.GetAppointmentsAsync(ShopId); + + // EN: Map appointments to follow-up context / VI: Map lich hen sang ngu canh tai kham + _followUps = appointments + .Where(a => a.Status?.ToLower() != "cancelled") + .Select(a => + { + var followUpDate = a.EndTime.AddDays(14); // EN: Default 2-week follow-up / VI: Mac dinh tai kham sau 2 tuan + var status = DetermineFollowUpStatus(a, followUpDate); + + return new FollowUpItem + { + Id = a.Id, + CustomerName = a.ResourceName ?? "Khach hang", + CustomerPhone = "0901-xxx-xxx", + TreatmentName = a.ResourceName ?? "Dich vu tham my", + TreatmentDate = a.StartTime, + FollowUpDate = followUpDate, + DoctorName = "Bac si", + Status = status + }; + }) + .OrderBy(f => f.FollowUpDate) + .ToList(); + + UpdateTabs(); + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + } + } + + private static string DetermineFollowUpStatus(WebClientTpos.Client.Services.PosDataService.AppointmentInfo a, DateTime followUpDate) + { + if (a.Status?.ToLower() == "completed" && followUpDate.Date <= DateTime.Today) + return "completed"; + if (followUpDate.Date < DateTime.Today) + return "overdue"; + if (followUpDate.Date == DateTime.Today) + return "scheduled"; + return "scheduled"; + } + + private void UpdateTabs() + { + _tabs = new[] + { + new TabItem("today", "Hom nay", _followUps.Count(f => f.FollowUpDate.Date == DateTime.Today)), + new TabItem("week", "Tuan nay", _followUps.Count(f => f.FollowUpDate.Date >= DateTime.Today && f.FollowUpDate.Date <= DateTime.Today.AddDays(7))), + new TabItem("overdue", "Qua han", _followUps.Count(f => f.FollowUpDate.Date < DateTime.Today && f.Status != "completed")), + }; + } + + private void CallCustomer(FollowUpItem followUp) + { + followUp.Status = "contacted"; + UpdateTabs(); + } + + private void Reschedule(FollowUpItem followUp) + { + followUp.FollowUpDate = followUp.FollowUpDate.AddDays(7); + followUp.Status = "scheduled"; + UpdateTabs(); + } + + private void MarkComplete(FollowUpItem followUp) + { + followUp.Status = "completed"; + UpdateTabs(); + } + + private static string GetStatusColor(string status) => status switch + { + "completed" => "#22C55E", + "contacted" => "#3B82F6", + "scheduled" => "#A855F7", + "overdue" => "#EF4444", + "missed" => "#EF4444", + _ => "#94A3B8" + }; + + private static string GetStatusBg(string status) => status switch + { + "completed" => "rgba(34,197,94,0.1)", + "contacted" => "rgba(59,130,246,0.1)", + "scheduled" => "rgba(168,85,247,0.1)", + "overdue" => "rgba(239,68,68,0.1)", + "missed" => "rgba(239,68,68,0.1)", + _ => "rgba(148,163,184,0.1)" + }; + + private static string GetStatusLabel(string status) => status switch + { + "completed" => "Hoan thanh", + "contacted" => "Da lien he", + "scheduled" => "Da hen", + "overdue" => "Qua han", + "missed" => "Bo lo", + _ => "Chua xac dinh" + }; + + private class FollowUpItem + { + public Guid Id { get; set; } + public string CustomerName { get; set; } = ""; + public string CustomerPhone { get; set; } = ""; + public string TreatmentName { get; set; } = ""; + public DateTime TreatmentDate { get; set; } + public DateTime FollowUpDate { get; set; } + public string DoctorName { get; set; } = ""; + public string Status { get; set; } = "scheduled"; + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/TreatmentPlan.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/TreatmentPlan.razor new file mode 100644 index 00000000..30f3b9de --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Beauty/Workflow/TreatmentPlan.razor @@ -0,0 +1,229 @@ +@* + EN: Treatment Plan — Create and manage treatment plans (phac do dieu tri) for beauty salon customers. + VI: Phac Do Dieu Tri — Tao va quan ly phac do dieu tri cho khach hang tham my vien. +*@ +@page "/pos/{ShopId:guid}/beauty/treatment-plan" +@layout PosLayout +@inherits PosBase + +@* TODO: Integrate with backend API for treatment plan CRUD operations *@ + +
+ @* === HEADER / TIEU DE === *@ +
+
+ + + + Phac Do Dieu Tri + +
+ +
+ +
+ @* === LEFT PANEL: Customer + Treatment Plan / PANEL TRAI: Khach hang + Phac do === *@ +
+ + @* EN: Customer info section / VI: Phan thong tin khach hang *@ +
+
+ Thong tin khach hang +
+
+
+ + +
+
+ + +
+
+
+ + @* EN: Treatment sessions / VI: Cac buoi dieu tri *@ +
+ Ke hoach dieu tri (@_sessions.Count buoi) +
+ + @if (!_sessions.Any()) + { +
+ +
Chua co buoi dieu tri nao
+
Bam "Them buoi dieu tri" de bat dau
+
+ } + + @foreach (var (session, index) in _sessions.Select((s, i) => (s, i))) + { +
+
+ + Buoi @(index + 1): @session.ServiceName + +
+ + @GetStatusLabel(session.Status) + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ } +
+ + @* === RIGHT PANEL: Treatment History / PANEL PHAI: Lich su dieu tri === *@ +
+
+ Lich su dieu tri +
+ + @if (!_sessions.Where(s => s.Status == "completed").Any()) + { +
+ +
Chua co lich su dieu tri
+
+ } + + @foreach (var (session, index) in _sessions.Where(s => s.Status == "completed").Select((s, i) => (s, i))) + { +
+
+ @session.ServiceName + Hoan thanh +
+
+
@session.Date.ToString("dd/MM/yyyy")
+
@(string.IsNullOrEmpty(session.DoctorName) ? "Chua chi dinh" : session.DoctorName)
+ @if (!string.IsNullOrEmpty(session.Notes)) + { +
@session.Notes
+ } +
+
+ } + + @* EN: Summary stats / VI: Thong ke tom tat *@ +
+
Tong quan
+
+ Tong buoi + @_sessions.Count +
+
+ Hoan thanh + @_sessions.Count(s => s.Status == "completed") +
+
+ Dang thuc hien + @_sessions.Count(s => s.Status == "in-progress") +
+
+ Ke hoach + @_sessions.Count(s => s.Status == "planned") +
+
+
+
+
+ +@code { + // TODO: Replace local state with API calls when backend treatment plan endpoints are available + + private const string StatusPlanned = "planned"; + private const string StatusInProgress = "in-progress"; + private const string StatusCompleted = "completed"; + + private void SetSessionStatus(TreatmentSession session, string status) { session.Status = status; } + + private string _customerName = ""; + private string _customerPhone = ""; + + private readonly List _sessions = new(); + + private void AddSession() + { + _sessions.Add(new TreatmentSession + { + ServiceName = "Dich vu moi", + Date = DateTime.Today.AddDays(_sessions.Count * 14), + DoctorName = "", + Notes = "", + Status = "planned" + }); + } + + private static string GetStatusColor(string status) => status switch + { + "completed" => "#22C55E", + "in-progress" => "#A855F7", + "planned" => "#94A3B8", + _ => "#94A3B8" + }; + + private static string GetStatusBg(string status) => status switch + { + "completed" => "rgba(34,197,94,0.1)", + "in-progress" => "rgba(168,85,247,0.1)", + "planned" => "rgba(148,163,184,0.1)", + _ => "rgba(148,163,184,0.1)" + }; + + private static string GetStatusLabel(string status) => status switch + { + "completed" => "Hoan thanh", + "in-progress" => "Dang thuc hien", + "planned" => "Ke hoach", + _ => "Ke hoach" + }; + + private class TreatmentSession + { + public string ServiceName { get; set; } = ""; + public DateTime Date { get; set; } = DateTime.Today; + public string DoctorName { get; set; } = ""; + public string Notes { get; set; } = ""; + public string Status { get; set; } = "planned"; + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor index e122d037..15a2a0cc 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor @@ -107,9 +107,20 @@ Tổng cộng @FormatPrice(FinalTotal) - } @@ -812,14 +823,64 @@ private List<(string Name, int Qty, decimal Price)> _lastReceiptItems = new(); private decimal ChangeAmount => _receivedAmount - FinalTotal; - private void StartPayment() + // EN: Created order ID — set when order is submitted to API before payment + // VI: ID đơn hàng đã tạo — gán khi đơn được gửi lên API trước thanh toán + private Guid? _createdOrderId; + private string? _orderError; + private bool _orderCreating; + + private async Task StartPayment() { if (!_cartItems.Any()) return; - _paymentStep = PayStep.MethodSelect; + if (_orderCreating) return; + _orderCreating = true; + _orderError = null; + StateHasChanged(); + + try + { + // EN: Create order in DB before proceeding to payment method selection + // VI: Tạo đơn hàng trong DB trước khi chọn phương thức thanh toán + var orderReq = new PosDataService.CreatePosOrderRequest( + ShopId, + null, // EN: Payment method not yet selected / VI: Chưa chọn phương thức thanh toán + _cartItems.Select(i => new PosDataService.PosOrderItemRequest( + i.ProductId, i.Name, i.Qty, i.Price)).ToList(), + _discountAmount > 0 ? _discountAmount : null, + _appliedVoucher != null ? "voucher" : null, + _appliedVoucher?.VoucherCode); + + var result = await DataService.CreatePosOrderAsync(orderReq); + if (result != null) + { + _createdOrderId = result.OrderId; + _paymentStep = PayStep.MethodSelect; + } + else + { + _orderError = "Không thể tạo đơn hàng. Vui lòng thử lại."; + } + } + catch (Exception ex) + { + _orderError = $"Lỗi tạo đơn: {ex.Message}"; + } + finally + { + _orderCreating = false; + StateHasChanged(); + } } - private void CancelPayment() + private async Task CancelPayment() { + // EN: Cancel the created order if payment is abandoned + // VI: Hủy đơn hàng đã tạo nếu bỏ thanh toán + if (_createdOrderId.HasValue) + { + try { await DataService.CancelOrderAsync(_createdOrderId.Value); } catch { } + _createdOrderId = null; + } _paymentStep = PayStep.None; _selectedMethod = ""; _receivedAmount = 0; @@ -881,21 +942,25 @@ _lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList(); var methodLabel = _selectedMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" }; - // EN: Call API to create real order in DB - // VI: Gọi API tạo đơn hàng thật trong DB + // EN: Call API to mark order as paid (order was already created in StartPayment) + // VI: Gọi API đánh dấu đơn đã thanh toán (đơn đã được tạo ở StartPayment) try { - var orderReq = new PosDataService.CreatePosOrderRequest( - ShopId, - _selectedMethod, - _cartItems.Select(i => new PosDataService.PosOrderItemRequest( - i.ProductId, i.Name, i.Qty, i.Price)).ToList(), - _discountAmount > 0 ? _discountAmount : null, - _appliedVoucher != null ? "voucher" : null, - _appliedVoucher?.VoucherCode); + if (_createdOrderId.HasValue) + { + await DataService.PayOrderAsync(_createdOrderId.Value, ShopId); + _lastTransactionId = _createdOrderId.Value.ToString()[..8].ToUpper(); + } + else + { + _lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; + } - var result = await DataService.CreatePosOrderAsync(orderReq); - _lastTransactionId = result?.TransactionId ?? $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; + // EN: Redeem voucher if applied / VI: Sử dụng voucher nếu đã áp dụng + if (_appliedVoucher?.VoucherId != null && _discountAmount > 0) + { + try { await DataService.RedeemVoucherAsync(_appliedVoucher.VoucherId.Value, _discountAmount); } catch { } + } } catch { @@ -932,6 +997,8 @@ _selectedMethod = ""; _receivedAmount = 0; _customAmountInput = ""; + _createdOrderId = null; + _orderError = null; ClearVoucher(); await SaveCartToLocalStorage(); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeMobile.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeMobile.razor index 126580a8..525cdada 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeMobile.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeMobile.razor @@ -1,11 +1,15 @@ @* EN: Café POS Mobile — Single column: categories, product grid, floating cart button. + All data fetched from API — no hardcoded demo data. VI: POS Café Mobile — Một cột: danh mục, lưới sản phẩm, nút giỏ hàng nổi. + Toàn bộ dữ liệu lấy từ API — không có dữ liệu demo cứng. *@ @page "/pos/{ShopId:guid}/cafe/mobile" @layout PosLayout @inherits PosBase -@inject WebClientTpos.Client.Services.PosDataService DataService +@using WebClientTpos.Client.Services +@inject PosDataService DataService +@inject IJSRuntime JS
@if (_isLoading) @@ -71,36 +75,224 @@
-
- Giỏ hàng - -
+ @if (_paymentStep == PayStep.None) + { + @* ─── NORMAL CART MODE ─── *@ +
+ Giỏ hàng + +
-
- @foreach (var item in _cartItems) - { -
-
- @item.Name - @FormatPrice(item.Price) +
+ @foreach (var item in _cartItems) + { +
+
+ @item.Name + @FormatPrice(item.Price) +
+
+ + @item.Qty + +
-
- - @item.Qty - + } +
+ + + } + else if (_paymentStep == PayStep.MethodSelect) + { + @* ─── PAYMENT: METHOD SELECT ─── *@ +
+
+ + Thanh toán + @FormatPrice(FinalTotal) +
+
+ + @if (_payCardEnabled) + { + + } + @if (_payQrEnabled) + { + + } + @if (_payTransferEnabled) + { + + } +
+
+ } + else if (_paymentStep == PayStep.AmountInput) + { + @* ─── PAYMENT: CASH AMOUNT INPUT ─── *@ +
+
+ + Tiền mặt + @FormatPrice(FinalTotal) +
+
+
Số tiền nhanh
+
+ @foreach (var qa in GetQuickAmounts()) + { + + } +
+
Nhập số tiền
+ + @if (!string.IsNullOrEmpty(_customAmountInput)) + { + + } +
+
+ Khách đưa + @FormatPrice(_receivedAmount) +
+
+ Tiền thối + + @FormatPrice(ChangeAmount) + +
- } -
- - + } + else if (_paymentStep == PayStep.Processing) + { + @* ─── PAYMENT: QR/CARD/TRANSFER — Confirm ─── *@ +
+
+ + @GetMethodLabel() +
+
+
@FormatPrice(FinalTotal)
+ @if (_selectedMethod == "qr") + { +
+
QR Code
VietQR
+
+
Quét mã bằng app Ngân hàng / MoMo / ZaloPay
+ } + else if (_selectedMethod == "card") + { +
+
Chạm, quẹt hoặc cắm thẻ
+ } + else + { +
+
Xác nhận đã nhận chuyển khoản
+ } +
+
+ +
+
+ } + else if (_paymentStep == PayStep.Success) + { + @* ─── PAYMENT: SUCCESS ─── *@ +
+
+
+ +
+
+
Thanh toán thành công!
+
@FormatPrice(_lastOrderTotal)
+
Mã: @_lastTransactionId
+
+ + +
+
+ }
} @@ -109,23 +301,24 @@ @code { - // EN: Loading state / VI: Trạng thái tải + // ═══════════════ SALE — Product & Cart ═══════════════ private bool _isLoading = true; private bool _loadError; - - // EN: Categories / VI: Danh mục private string[] _categories = { "Tất cả" }; private string _selectedCategory = "Tất cả"; private bool _showCart; - - // EN: Product list / VI: Danh sách sản phẩm private List _products = new(); - - // EN: Cart items / VI: Mục giỏ hàng 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 decimal FinalTotal => Math.Max(0, CartTotal - _discountAmount); + + // Voucher state + private string _voucherCode = ""; + private string? _voucherMessage; + private PosDataService.VoucherValidationInfo? _appliedVoucher; + private decimal _discountAmount; protected override async Task OnInitializedAsync() { @@ -144,7 +337,7 @@ p.Id, p.Name, p.Price, - p.Category ?? "Khác" + p.CategoryName ?? "Khác" )).ToList(); var catNames = apiCategories.Select(c => c.Name).ToList(); @@ -156,31 +349,332 @@ _categories = new[] { "Tất cả" }.Concat(productCats).ToArray(); } } - catch - { - _loadError = true; - } - finally - { - _isLoading = false; - } + catch { _loadError = true; } + finally { _isLoading = false; } + + await LoadPaymentSettings(); + await RestoreCartFromLocalStorage(); } - private void AddToCart(Product product) + private async Task AddToCart(Product product) { + if (_paymentStep != PayStep.None) return; var existing = _cartItems.FirstOrDefault(i => i.ProductId == product.Id); if (existing != null) existing.Qty++; else _cartItems.Add(new CartItem(product.Id, product.Name, product.Price)); + await SaveCartToLocalStorage(); } - private void ChangeQty(CartItem item, int delta) + private async Task ChangeQty(CartItem item, int delta) { item.Qty += delta; if (item.Qty <= 0) _cartItems.Remove(item); + await SaveCartToLocalStorage(); } - private void Checkout() => NavigateTo("cafe/order-customize"); + private async Task ClearCart() + { + _cartItems.Clear(); + _showCart = false; + await SaveCartToLocalStorage(); + } + // ═══════════════ VOUCHER ═══════════════ + private async Task ValidateVoucher() + { + _voucherMessage = null; _appliedVoucher = null; _discountAmount = 0; + if (string.IsNullOrWhiteSpace(_voucherCode)) { _voucherMessage = "Vui lòng nhập mã voucher."; return; } + var info = await DataService.ValidateVoucherAsync(_voucherCode.Trim()); + if (info == null) { _voucherMessage = "Không thể kiểm tra voucher."; return; } + if (!info.IsValid) { _voucherMessage = info.ErrorMessage ?? "Mã voucher không hợp lệ."; return; } + _appliedVoucher = info; + var isPercentage = (info.CampaignName ?? "").Contains("[percentage]", StringComparison.OrdinalIgnoreCase); + if (isPercentage) + { + var pct = Math.Min(info.RemainingValue ?? 0, 100); + _discountAmount = Math.Round(CartTotal * pct / 100, 0); + _voucherMessage = $"Voucher {info.CampaignName?.Replace("[percentage]","").Trim()}: giảm {pct}% = {FormatPrice(_discountAmount)}"; + } + else + { + _discountAmount = Math.Min(info.RemainingValue ?? 0, CartTotal); + _voucherMessage = $"Voucher {info.CampaignName}: giảm {FormatPrice(_discountAmount)}"; + } + } + private void ClearVoucher() { _appliedVoucher = null; _discountAmount = 0; _voucherCode = ""; _voucherMessage = null; } + + // ═══════════════ INLINE PAYMENT ═══════════════ + private enum PayStep { None, MethodSelect, AmountInput, Processing, Success } + private PayStep _paymentStep = PayStep.None; + private string _selectedMethod = ""; + private decimal _receivedAmount; + private string _customAmountInput = ""; + private decimal _lastOrderTotal; + private string _lastTransactionId = ""; + private string _lastPaymentMethod = ""; + private List<(string Name, int Qty, decimal Price)> _lastReceiptItems = new(); + private decimal ChangeAmount => _receivedAmount - FinalTotal; + + // EN: Created order ID — set when order is submitted to API before payment + // VI: ID đơn hàng đã tạo — gán khi đơn được gửi lên API trước thanh toán + private Guid? _createdOrderId; + private string? _orderError; + private bool _orderCreating; + + private async Task StartPayment() + { + if (!_cartItems.Any()) return; + if (_orderCreating) return; + _orderCreating = true; + _orderError = null; + StateHasChanged(); + + try + { + var orderReq = new PosDataService.CreatePosOrderRequest( + ShopId, + null, + _cartItems.Select(i => new PosDataService.PosOrderItemRequest( + i.ProductId, i.Name, i.Qty, i.Price)).ToList(), + _discountAmount > 0 ? _discountAmount : null, + _appliedVoucher != null ? "voucher" : null, + _appliedVoucher?.VoucherCode); + + var result = await DataService.CreatePosOrderAsync(orderReq); + if (result != null) + { + _createdOrderId = result.OrderId; + _paymentStep = PayStep.MethodSelect; + } + else + { + _orderError = "Không thể tạo đơn hàng. Vui lòng thử lại."; + } + } + catch (Exception ex) + { + _orderError = $"Lỗi tạo đơn: {ex.Message}"; + } + finally + { + _orderCreating = false; + StateHasChanged(); + } + } + + private async Task CancelPayment() + { + if (_createdOrderId.HasValue) + { + try { await DataService.CancelOrderAsync(_createdOrderId.Value); } catch { } + _createdOrderId = null; + } + _paymentStep = PayStep.None; + _selectedMethod = ""; + _receivedAmount = 0; + _customAmountInput = ""; + } + + private void SelectPaymentMethod(string method) + { + _selectedMethod = method; + _receivedAmount = 0; + _customAmountInput = ""; + + if (method == "cash") + _paymentStep = PayStep.AmountInput; + else + _paymentStep = PayStep.Processing; + } + + private string GetMethodLabel() => _selectedMethod switch + { + "cash" => "Tiền mặt", + "card" => "Thẻ", + "qr" => "Mã QR", + "transfer" => "Chuyển khoản", + _ => "Thanh toán" + }; + + private List<(string Label, decimal Value)> GetQuickAmounts() + { + var total = FinalTotal; + var amounts = new List<(string, decimal)>(); + var roundUp = Math.Ceiling(total / 50_000) * 50_000; + if (roundUp == total) roundUp += 50_000; + amounts.Add(("Đúng tiền", total)); + amounts.Add((FormatPrice(roundUp), roundUp)); + amounts.Add((FormatPrice(roundUp + 50_000), roundUp + 50_000)); + amounts.Add((FormatPrice(500_000), 500_000)); + amounts.Add((FormatPrice(200_000), 200_000)); + amounts.Add((FormatPrice(1_000_000), 1_000_000)); + return amounts; + } + + private void ApplyCustomAmount() + { + if (decimal.TryParse(_customAmountInput, out var val)) + _receivedAmount = val; + } + + private bool _paymentProcessing; + + private async Task ConfirmPayment() + { + if (_paymentProcessing) return; + _paymentProcessing = true; + StateHasChanged(); + + _lastOrderTotal = FinalTotal; + _lastPaymentMethod = _selectedMethod; + _lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList(); + + try + { + if (_createdOrderId.HasValue) + { + await DataService.PayOrderAsync(_createdOrderId.Value, ShopId); + _lastTransactionId = _createdOrderId.Value.ToString()[..8].ToUpper(); + } + else + { + _lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; + } + + if (_appliedVoucher?.VoucherId != null && _discountAmount > 0) + { + try { await DataService.RedeemVoucherAsync(_appliedVoucher.VoucherId.Value, _discountAmount); } catch { } + } + } + catch + { + _lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; + } + + _paymentProcessing = false; + _paymentStep = PayStep.Success; + StateHasChanged(); + } + + private async Task ResetAfterPayment() + { + _cartItems.Clear(); + _paymentStep = PayStep.None; + _selectedMethod = ""; + _receivedAmount = 0; + _customAmountInput = ""; + _createdOrderId = null; + _orderError = null; + _showCart = false; + ClearVoucher(); + await SaveCartToLocalStorage(); + } + + private async Task PrintReceipt() + { + var payLabel = _lastPaymentMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" }; + var now = DateTime.Now; + + var sb = new System.Text.StringBuilder(); + foreach (var item in _lastReceiptItems) + { + sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(item.Name)}"); + sb.AppendLine($"{item.Qty}"); + sb.AppendLine($"{item.Price:N0}"); + sb.AppendLine($"{item.Qty * item.Price:N0}"); + } + + var receiptHtml = "" + + $"Hóa đơn - {_lastTransactionId}" + + "" + + "
GoodGo POS
" + + "
Hệ thống quản lý bán hàng thông minh
" + + "
" + + $"
Mã đơn: {_lastTransactionId}
" + + $"
Ngày: {now:dd/MM/yyyy} — {now:HH:mm:ss}
" + + $"
Thanh toán: {payLabel}
" + + "
" + + "" + + sb.ToString() + + "
Sản phẩmSLĐ.GiáT.Tiền
" + + $"
TỔNG CỘNG{_lastOrderTotal:N0}₫
" + + "
" + + "
Cảm ơn quý khách! Hẹn gặp lại
" + + "
Powered by GoodGo Platform
" + + "" + + ""; + + await JS.InvokeVoidAsync("printPosReceipt", receiptHtml); + } + + // ═══════════════ PAYMENT SETTINGS ═══════════════ + private bool _payCardEnabled = true; + private bool _payQrEnabled = true; + private bool _payTransferEnabled = true; + + private async Task LoadPaymentSettings() + { + try + { + var json = await JS.InvokeAsync("localStorage.getItem", "pos_payment_settings"); + if (!string.IsNullOrEmpty(json)) + { + var doc = System.Text.Json.JsonDocument.Parse(json); + var r = doc.RootElement; + if (r.TryGetProperty("cardEnabled", out var ce)) _payCardEnabled = ce.GetBoolean(); + if (r.TryGetProperty("qrEnabled", out var qe)) _payQrEnabled = qe.GetBoolean(); + if (r.TryGetProperty("transferEnabled", out var te)) _payTransferEnabled = te.GetBoolean(); + } + } + catch { /* first load — no settings yet */ } + } + + // ═══════════════ LOCALSTORAGE PERSISTENCE ═══════════════ + private string CartStorageKey => $"pos_cafe_cart_{ShopId}"; + + private async Task SaveCartToLocalStorage() + { + try + { + var cartData = _cartItems.Select(i => new { i.ProductId, i.Name, i.Price, i.Qty }).ToList(); + await JS.InvokeVoidAsync("localStorage.setItem", CartStorageKey, + System.Text.Json.JsonSerializer.Serialize(cartData)); + } + catch { } + } + + private async Task RestoreCartFromLocalStorage() + { + try + { + var cartJson = await JS.InvokeAsync("localStorage.getItem", CartStorageKey); + if (!string.IsNullOrEmpty(cartJson)) + { + var items = System.Text.Json.JsonSerializer.Deserialize>(cartJson, _lsJsonOptions); + if (items != null) + foreach (var i in items) + _cartItems.Add(new CartItem(i.ProductId, i.Name, i.Price) { Qty = i.Qty }); + } + } + catch { } + } + + private static readonly System.Text.Json.JsonSerializerOptions _lsJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private record StoredCartItem(Guid ProductId, string Name, decimal Price, int Qty); + + // ═══════════════ RECORDS ═══════════════ private record Product(Guid Id, string Name, decimal Price, string Category); private class CartItem(Guid productId, string name, decimal price) { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeTablet.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeTablet.razor index 5d580985..3c1bdc51 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeTablet.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeTablet.razor @@ -1,14 +1,18 @@ @* EN: Café POS Tablet — 2-column layout: product grid + cart sidebar, touch-friendly. + All data fetched from API — no hardcoded demo data. VI: POS Café Tablet — Bố cục 2 cột: lưới sản phẩm + giỏ hàng bên, thân thiện cảm ứng. + Toàn bộ dữ liệu lấy từ API — không có dữ liệu demo cứng. *@ @page "/pos/{ShopId:guid}/cafe/tablet" @layout PosLayout @inherits PosBase -@inject WebClientTpos.Client.Services.PosDataService DataService +@using WebClientTpos.Client.Services +@inject PosDataService DataService +@inject IJSRuntime JS @* ═══ PRODUCT PANEL ═══ *@ -
+
@if (_isLoading) {
@@ -49,62 +53,250 @@ }
-@* ═══ CART SIDEBAR ═══ *@ +@* ═══ CART / PAYMENT SIDEBAR ═══ *@
-
- Đơn hàng - -
+ @if (_paymentStep == PayStep.None) + { + @* ─── NORMAL CART MODE ─── *@ +
+ Đơn hàng + +
-
- @foreach (var item in _cartItems) - { -
-
- @item.Name - @FormatPrice(item.Price) +
+ @foreach (var item in _cartItems) + { +
+
+ @item.Name + @FormatPrice(item.Price) +
+
+ + @item.Qty + +
-
- - @item.Qty - + } +
+ + + } + else if (_paymentStep == PayStep.MethodSelect) + { + @* ─── PAYMENT: METHOD SELECT ─── *@ +
+
+ + Thanh toán + @FormatPrice(FinalTotal) +
+
+ + @if (_payCardEnabled) + { + + } + @if (_payQrEnabled) + { + + } + @if (_payTransferEnabled) + { + + } +
+
+ } + else if (_paymentStep == PayStep.AmountInput) + { + @* ─── PAYMENT: CASH AMOUNT INPUT ─── *@ +
+
+ + Tiền mặt + @FormatPrice(FinalTotal) +
+
+
Số tiền nhanh
+
+ @foreach (var qa in GetQuickAmounts()) + { + + } +
+
Nhập số tiền
+ + @if (!string.IsNullOrEmpty(_customAmountInput)) + { + + } +
+
+ Khách đưa + @FormatPrice(_receivedAmount) +
+
+ Tiền thối + + @FormatPrice(ChangeAmount) + +
- } -
- - + } + else if (_paymentStep == PayStep.Processing) + { + @* ─── PAYMENT: QR/CARD/TRANSFER — Confirm ─── *@ +
+
+ + @GetMethodLabel() +
+
+
@FormatPrice(FinalTotal)
+ @if (_selectedMethod == "qr") + { +
+
QR Code
VietQR
+
+
Quét mã bằng app Ngân hàng / MoMo / ZaloPay
+ } + else if (_selectedMethod == "card") + { +
+
Chạm, quẹt hoặc cắm thẻ
+ } + else + { +
+
Xác nhận đã nhận chuyển khoản
+ } +
+
+ +
+
+ } + else if (_paymentStep == PayStep.Success) + { + @* ─── PAYMENT: SUCCESS ─── *@ +
+
+
+ +
+
+
Thanh toán thành công!
+
@FormatPrice(_lastOrderTotal)
+
Mã: @_lastTransactionId
+
+ + +
+
+ }
@code { - // EN: Loading state / VI: Trạng thái tải + // ═══════════════ SALE — Product & Cart ═══════════════ private bool _isLoading = true; private bool _loadError; - - // EN: Categories / VI: Danh mục private string[] _categories = { "Tất cả" }; private string _selectedCategory = "Tất cả"; - - // EN: Product list / VI: Danh sách sản phẩm private List _products = new(); - - // EN: Cart items / VI: Mục giỏ hàng 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 decimal FinalTotal => Math.Max(0, CartTotal - _discountAmount); + + // Voucher state + private string _voucherCode = ""; + private string? _voucherMessage; + private PosDataService.VoucherValidationInfo? _appliedVoucher; + private decimal _discountAmount; protected override async Task OnInitializedAsync() { @@ -123,7 +315,7 @@ p.Id, p.Name, p.Price, - p.Category ?? "Khác" + p.CategoryName ?? "Khác" )).ToList(); var catNames = apiCategories.Select(c => c.Name).ToList(); @@ -135,31 +327,330 @@ _categories = new[] { "Tất cả" }.Concat(productCats).ToArray(); } } - catch - { - _loadError = true; - } - finally - { - _isLoading = false; - } + catch { _loadError = true; } + finally { _isLoading = false; } + + await LoadPaymentSettings(); + await RestoreCartFromLocalStorage(); } - private void AddToCart(Product product) + private async Task AddToCart(Product product) { + if (_paymentStep != PayStep.None) return; var existing = _cartItems.FirstOrDefault(i => i.ProductId == product.Id); if (existing != null) existing.Qty++; else _cartItems.Add(new CartItem(product.Id, product.Name, product.Price)); + await SaveCartToLocalStorage(); } - private void ChangeQty(CartItem item, int delta) + private async Task ChangeQty(CartItem item, int delta) { item.Qty += delta; if (item.Qty <= 0) _cartItems.Remove(item); + await SaveCartToLocalStorage(); } - private void Checkout() => NavigateTo("cafe/order-customize"); + private async Task ClearCart() + { + _cartItems.Clear(); + await SaveCartToLocalStorage(); + } + // ═══════════════ VOUCHER ═══════════════ + private async Task ValidateVoucher() + { + _voucherMessage = null; _appliedVoucher = null; _discountAmount = 0; + if (string.IsNullOrWhiteSpace(_voucherCode)) { _voucherMessage = "Vui lòng nhập mã voucher."; return; } + var info = await DataService.ValidateVoucherAsync(_voucherCode.Trim()); + if (info == null) { _voucherMessage = "Không thể kiểm tra voucher."; return; } + if (!info.IsValid) { _voucherMessage = info.ErrorMessage ?? "Mã voucher không hợp lệ."; return; } + _appliedVoucher = info; + var isPercentage = (info.CampaignName ?? "").Contains("[percentage]", StringComparison.OrdinalIgnoreCase); + if (isPercentage) + { + var pct = Math.Min(info.RemainingValue ?? 0, 100); + _discountAmount = Math.Round(CartTotal * pct / 100, 0); + _voucherMessage = $"Voucher {info.CampaignName?.Replace("[percentage]","").Trim()}: giảm {pct}% = {FormatPrice(_discountAmount)}"; + } + else + { + _discountAmount = Math.Min(info.RemainingValue ?? 0, CartTotal); + _voucherMessage = $"Voucher {info.CampaignName}: giảm {FormatPrice(_discountAmount)}"; + } + } + private void ClearVoucher() { _appliedVoucher = null; _discountAmount = 0; _voucherCode = ""; _voucherMessage = null; } + + // ═══════════════ INLINE PAYMENT ═══════════════ + private enum PayStep { None, MethodSelect, AmountInput, Processing, Success } + private PayStep _paymentStep = PayStep.None; + private string _selectedMethod = ""; + private decimal _receivedAmount; + private string _customAmountInput = ""; + private decimal _lastOrderTotal; + private string _lastTransactionId = ""; + private string _lastPaymentMethod = ""; + private List<(string Name, int Qty, decimal Price)> _lastReceiptItems = new(); + private decimal ChangeAmount => _receivedAmount - FinalTotal; + + // EN: Created order ID — set when order is submitted to API before payment + // VI: ID đơn hàng đã tạo — gán khi đơn được gửi lên API trước thanh toán + private Guid? _createdOrderId; + private string? _orderError; + private bool _orderCreating; + + private async Task StartPayment() + { + if (!_cartItems.Any()) return; + if (_orderCreating) return; + _orderCreating = true; + _orderError = null; + StateHasChanged(); + + try + { + var orderReq = new PosDataService.CreatePosOrderRequest( + ShopId, + null, + _cartItems.Select(i => new PosDataService.PosOrderItemRequest( + i.ProductId, i.Name, i.Qty, i.Price)).ToList(), + _discountAmount > 0 ? _discountAmount : null, + _appliedVoucher != null ? "voucher" : null, + _appliedVoucher?.VoucherCode); + + var result = await DataService.CreatePosOrderAsync(orderReq); + if (result != null) + { + _createdOrderId = result.OrderId; + _paymentStep = PayStep.MethodSelect; + } + else + { + _orderError = "Không thể tạo đơn hàng. Vui lòng thử lại."; + } + } + catch (Exception ex) + { + _orderError = $"Lỗi tạo đơn: {ex.Message}"; + } + finally + { + _orderCreating = false; + StateHasChanged(); + } + } + + private async Task CancelPayment() + { + if (_createdOrderId.HasValue) + { + try { await DataService.CancelOrderAsync(_createdOrderId.Value); } catch { } + _createdOrderId = null; + } + _paymentStep = PayStep.None; + _selectedMethod = ""; + _receivedAmount = 0; + _customAmountInput = ""; + } + + private void SelectPaymentMethod(string method) + { + _selectedMethod = method; + _receivedAmount = 0; + _customAmountInput = ""; + + if (method == "cash") + _paymentStep = PayStep.AmountInput; + else + _paymentStep = PayStep.Processing; + } + + private string GetMethodLabel() => _selectedMethod switch + { + "cash" => "Tiền mặt", + "card" => "Thẻ", + "qr" => "Mã QR", + "transfer" => "Chuyển khoản", + _ => "Thanh toán" + }; + + private List<(string Label, decimal Value)> GetQuickAmounts() + { + var total = FinalTotal; + var amounts = new List<(string, decimal)>(); + var roundUp = Math.Ceiling(total / 50_000) * 50_000; + if (roundUp == total) roundUp += 50_000; + amounts.Add(("Đúng tiền", total)); + amounts.Add((FormatPrice(roundUp), roundUp)); + amounts.Add((FormatPrice(roundUp + 50_000), roundUp + 50_000)); + amounts.Add((FormatPrice(500_000), 500_000)); + amounts.Add((FormatPrice(200_000), 200_000)); + amounts.Add((FormatPrice(1_000_000), 1_000_000)); + return amounts; + } + + private void ApplyCustomAmount() + { + if (decimal.TryParse(_customAmountInput, out var val)) + _receivedAmount = val; + } + + private bool _paymentProcessing; + + private async Task ConfirmPayment() + { + if (_paymentProcessing) return; + _paymentProcessing = true; + StateHasChanged(); + + _lastOrderTotal = FinalTotal; + _lastPaymentMethod = _selectedMethod; + _lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList(); + + try + { + if (_createdOrderId.HasValue) + { + await DataService.PayOrderAsync(_createdOrderId.Value, ShopId); + _lastTransactionId = _createdOrderId.Value.ToString()[..8].ToUpper(); + } + else + { + _lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; + } + + if (_appliedVoucher?.VoucherId != null && _discountAmount > 0) + { + try { await DataService.RedeemVoucherAsync(_appliedVoucher.VoucherId.Value, _discountAmount); } catch { } + } + } + catch + { + _lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; + } + + _paymentProcessing = false; + _paymentStep = PayStep.Success; + StateHasChanged(); + } + + private async Task ResetAfterPayment() + { + _cartItems.Clear(); + _paymentStep = PayStep.None; + _selectedMethod = ""; + _receivedAmount = 0; + _customAmountInput = ""; + _createdOrderId = null; + _orderError = null; + ClearVoucher(); + await SaveCartToLocalStorage(); + } + + private async Task PrintReceipt() + { + var payLabel = _lastPaymentMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" }; + var now = DateTime.Now; + + var sb = new System.Text.StringBuilder(); + foreach (var item in _lastReceiptItems) + { + sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(item.Name)}"); + sb.AppendLine($"{item.Qty}"); + sb.AppendLine($"{item.Price:N0}"); + sb.AppendLine($"{item.Qty * item.Price:N0}"); + } + + var receiptHtml = "" + + $"Hóa đơn - {_lastTransactionId}" + + "" + + "
GoodGo POS
" + + "
Hệ thống quản lý bán hàng thông minh
" + + "
" + + $"
Mã đơn: {_lastTransactionId}
" + + $"
Ngày: {now:dd/MM/yyyy} — {now:HH:mm:ss}
" + + $"
Thanh toán: {payLabel}
" + + "
" + + "" + + sb.ToString() + + "
Sản phẩmSLĐ.GiáT.Tiền
" + + $"
TỔNG CỘNG{_lastOrderTotal:N0}₫
" + + "
" + + "
Cảm ơn quý khách! Hẹn gặp lại
" + + "
Powered by GoodGo Platform
" + + "" + + ""; + + await JS.InvokeVoidAsync("printPosReceipt", receiptHtml); + } + + // ═══════════════ PAYMENT SETTINGS ═══════════════ + private bool _payCardEnabled = true; + private bool _payQrEnabled = true; + private bool _payTransferEnabled = true; + + private async Task LoadPaymentSettings() + { + try + { + var json = await JS.InvokeAsync("localStorage.getItem", "pos_payment_settings"); + if (!string.IsNullOrEmpty(json)) + { + var doc = System.Text.Json.JsonDocument.Parse(json); + var r = doc.RootElement; + if (r.TryGetProperty("cardEnabled", out var ce)) _payCardEnabled = ce.GetBoolean(); + if (r.TryGetProperty("qrEnabled", out var qe)) _payQrEnabled = qe.GetBoolean(); + if (r.TryGetProperty("transferEnabled", out var te)) _payTransferEnabled = te.GetBoolean(); + } + } + catch { /* first load — no settings yet */ } + } + + // ═══════════════ LOCALSTORAGE PERSISTENCE ═══════════════ + private string CartStorageKey => $"pos_cafe_cart_{ShopId}"; + + private async Task SaveCartToLocalStorage() + { + try + { + var cartData = _cartItems.Select(i => new { i.ProductId, i.Name, i.Price, i.Qty }).ToList(); + await JS.InvokeVoidAsync("localStorage.setItem", CartStorageKey, + System.Text.Json.JsonSerializer.Serialize(cartData)); + } + catch { } + } + + private async Task RestoreCartFromLocalStorage() + { + try + { + var cartJson = await JS.InvokeAsync("localStorage.getItem", CartStorageKey); + if (!string.IsNullOrEmpty(cartJson)) + { + var items = System.Text.Json.JsonSerializer.Deserialize>(cartJson, _lsJsonOptions); + if (items != null) + foreach (var i in items) + _cartItems.Add(new CartItem(i.ProductId, i.Name, i.Price) { Qty = i.Qty }); + } + } + catch { } + } + + private static readonly System.Text.Json.JsonSerializerOptions _lsJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private record StoredCartItem(Guid ProductId, string Name, decimal Price, int Qty); + + // ═══════════════ RECORDS ═══════════════ private record Product(Guid Id, string Name, decimal Price, string Category); private class CartItem(Guid productId, string name, decimal price) { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/BaristaQueue.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/BaristaQueue.razor index 563f4475..93ed0a68 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/BaristaQueue.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/BaristaQueue.razor @@ -1,12 +1,31 @@ @* EN: Barista Queue — Kanban-style order queue: New / In Progress / Ready columns. + Connected to kitchen tickets API with auto-refresh every 10 seconds. VI: Hàng đợi Barista — Bảng đơn hàng kiểu Kanban: Mới / Đang pha / Sẵn sàng. + Kết nối API kitchen tickets với auto-refresh mỗi 10 giây. *@ @page "/pos/{ShopId:guid}/cafe/barista-queue" @layout PosLayout @inherits PosBase +@using WebClientTpos.Client.Services +@inject PosDataService DataService +@implements IDisposable
+ @if (_isLoading) + { +
+ Đang tải hàng đợi... +
+ } + else if (_loadError) + { +
+ Không thể tải dữ liệu. +
+ } + else + { @* EN: Kanban columns / VI: Các cột Kanban *@ @foreach (var column in _columns) { @@ -16,36 +35,35 @@ @column.Title - @_orders.Count(o => o.Status == column.Status) + @_tickets.Count(t => t.Status == column.Status)
- @* EN: Order cards / VI: Thẻ đơn hàng *@ + @* EN: Ticket cards / VI: Thẻ ticket *@
- @foreach (var order in _orders.Where(o => o.Status == column.Status)) + @foreach (var ticket in _tickets.Where(t => t.Status == column.Status)) {
- #@order.Number - @order.Elapsed phút + #@ticket.Id.ToString()[..6].ToUpper() + @GetElapsedMinutes(ticket.CreatedAt) phút
-
@order.Customer
- @foreach (var item in order.Items) - { -
• @item
- } - @if (order.Status != "ready") +
@(ticket.Station ?? "Barista")
+
@ticket.ItemName
+ @if (ticket.Status != "Completed") { } else { }
@@ -53,47 +71,153 @@
} + }
@code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + // EN: Loading state / VI: Trạng thái tải + private bool _isLoading = true; + private bool _loadError; - // EN: Column definitions / VI: Định nghĩa cột + // EN: Auto-refresh timer (10 seconds) / VI: Timer tự động refresh (10 giây) + private Timer? _refreshTimer; + + // EN: Column definitions matching API statuses / VI: Định nghĩa cột khớp status API private readonly List _columns = new() { - new("Đơn mới", "new", "#3B82F6", "Bắt đầu pha", "rgba(59,130,246,0.15)"), - new("Đang pha", "progress", "#F59E0B", "Pha xong", "rgba(245,158,11,0.15)"), - new("Sẵn sàng", "ready", "#22C55E", "", ""), + new("Đơn mới", "Pending", "#3B82F6", "Bắt đầu pha", "rgba(59,130,246,0.15)", "InProgress"), + new("Đang pha", "InProgress", "#F59E0B", "Pha xong", "rgba(245,158,11,0.15)", "Completed"), + new("Sẵn sàng", "Completed", "#22C55E", "", "", ""), }; - // EN: Demo orders / VI: Đơn hàng mẫu - private readonly List _orders = new() - { - new(101, "Nguyễn Văn A", new[] { "Cà phê sữa đá x2", "Bánh mì x1" }, "new", 2), - new(102, "Trần Thị B", new[] { "Cappuccino x1", "Croissant x1" }, "new", 5), - new(103, "Lê Văn C", new[] { "Trà đào x1", "Sinh tố bơ x1" }, "progress", 8), - new(104, "Phạm Thị D", new[] { "Bạc xỉu x3" }, "progress", 12), - new(105, "Hoàng Văn E", new[] { "Latte x1" }, "ready", 15), - new(106, "Đỗ Thị F", new[] { "Espresso x2", "Cookie x2" }, "ready", 18), - }; + // EN: Kitchen tickets loaded from API / VI: Kitchen tickets tải từ API + private List _tickets = new(); - private void MoveOrder(Order order) + protected override async Task OnInitializedAsync() { - order.Status = order.Status == "new" ? "progress" : "ready"; + await base.OnInitializedAsync(); + await LoadTicketsAsync(); + + // EN: Start auto-refresh timer / VI: Bắt đầu timer tự động refresh + _refreshTimer = new Timer(async _ => + { + await InvokeAsync(async () => + { + await LoadTicketsAsync(silent: true); + StateHasChanged(); + }); + }, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); } - private void CompleteOrder(Order order) + private async Task LoadTicketsAsync(bool silent = false) { - _orders.Remove(order); + if (!silent) { _isLoading = true; _loadError = false; } + + try + { + // EN: Fetch all statuses — Pending, InProgress, Completed + // VI: Lấy tất cả trạng thái — Pending, InProgress, Completed + var pendingTask = DataService.GetKitchenTicketsAsync(ShopId, "Pending"); + var inProgressTask = DataService.GetKitchenTicketsAsync(ShopId, "InProgress"); + var completedTask = DataService.GetKitchenTicketsAsync(ShopId, "Completed"); + await Task.WhenAll(pendingTask, inProgressTask, completedTask); + + var pending = await pendingTask; + var inProgress = await inProgressTask; + var completed = await completedTask; + + // EN: Merge all tickets, preserve IsUpdating state for existing tickets + // VI: Gộp tất cả tickets, giữ trạng thái IsUpdating cho ticket đang có + var allApi = pending.Concat(inProgress).Concat(completed).ToList(); + var existingMap = _tickets.ToDictionary(t => t.Id); + + _tickets = allApi.Select(t => + { + if (existingMap.TryGetValue(t.Id, out var existing) && existing.IsUpdating) + return existing; + return new TicketViewModel(t.Id, t.ItemName, t.Station, t.Status, t.CreatedAt, t.Priority); + }).ToList(); + } + catch + { + if (!silent) _loadError = true; + } + finally + { + if (!silent) _isLoading = false; + } } - private record QueueColumn(string Title, string Status, string Color, string ActionText, string ActionBg); - private class Order(int number, string customer, string[] items, string status, int elapsed) + private async Task MoveTicket(TicketViewModel ticket, string nextStatus) { - public int Number { get; set; } = number; - public string Customer { get; set; } = customer; - public string[] Items { get; set; } = items; + if (string.IsNullOrEmpty(nextStatus) || ticket.IsUpdating) return; + ticket.IsUpdating = true; + StateHasChanged(); + + try + { + var success = await DataService.UpdateTicketStatusAsync( + ticket.Id, new PosDataService.UpdateTicketStatusRequest(nextStatus)); + if (success) + { + ticket.Status = nextStatus; + } + } + catch { /* EN: Silently fail, next refresh will sync / VI: Bỏ qua lỗi, refresh kế tiếp sẽ đồng bộ */ } + finally + { + ticket.IsUpdating = false; + StateHasChanged(); + } + } + + private async Task CompleteTicket(TicketViewModel ticket) + { + if (ticket.IsUpdating) return; + ticket.IsUpdating = true; + StateHasChanged(); + + try + { + // EN: Mark ticket as delivered — remove from view + // VI: Đánh dấu ticket đã giao — xóa khỏi danh sách + var success = await DataService.UpdateTicketStatusAsync( + ticket.Id, new PosDataService.UpdateTicketStatusRequest("Delivered")); + if (success) + { + _tickets.Remove(ticket); + } + } + catch { } + finally + { + ticket.IsUpdating = false; + StateHasChanged(); + } + } + + private static int GetElapsedMinutes(DateTime createdAt) + { + return (int)(DateTime.UtcNow - createdAt).TotalMinutes; + } + + public void Dispose() + { + _refreshTimer?.Dispose(); + } + + // EN: Column definition with next status for transitions / VI: Định nghĩa cột với status kế tiếp cho chuyển đổi + private record QueueColumn(string Title, string Status, string Color, string ActionText, string ActionBg, string NextStatus); + + // EN: ViewModel wrapping API ticket with UI state / VI: ViewModel bọc API ticket với trạng thái UI + private class TicketViewModel(Guid id, string itemName, string? station, string status, DateTime createdAt, int priority) + { + public Guid Id { get; set; } = id; + public string ItemName { get; set; } = itemName; + public string? Station { get; set; } = station; public string Status { get; set; } = status; - public int Elapsed { get; set; } = elapsed; + public DateTime CreatedAt { get; set; } = createdAt; + public int Priority { get; set; } = priority; + public bool IsUpdating { get; set; } } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/CafeJourney.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/CafeJourney.razor index 35bcfb6b..95bc5aed 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/CafeJourney.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/CafeJourney.razor @@ -1,25 +1,49 @@ @* - EN: Cafe Journey — End-to-end café workflow tracker: Đặt món → Thanh toán → Pha chế → Phục vụ → Hoàn tất. - VI: Hành trình Café — Theo dõi quy trình từ đầu đến cuối: Đặt món → Thanh toán → Pha chế → Phục vụ → Hoàn tất. + EN: Cafe Journey — End-to-end cafe workflow tracker: Order -> Payment -> Barista -> Serving -> Complete. + Connected to order detail & kitchen tickets APIs with auto-refresh every 5 seconds. + VI: Hanh trinh Cafe — Theo doi quy trinh tu dau den cuoi: Dat mon -> Thanh toan -> Pha che -> Phuc vu -> Hoan tat. + Ket noi API chi tiet don & kitchen tickets voi auto-refresh moi 5 giay. *@ -@page "/pos/{ShopId:guid}/cafe/cafe-journey" +@page "/pos/{ShopId:guid}/cafe/cafe-journey/{OrderId:guid}" @layout PosLayout @inherits PosBase +@using WebClientTpos.Client.Services +@inject PosDataService DataService +@implements IDisposable
- @* ═══ HEADER / TIÊU ĐỀ ═══ *@ + @* ═══ HEADER / TIEU DE ═══ *@
- Hành trình Café + Hanh trinh Cafe - Đơn #CF-0027 + @if (_orderDetail?.Order != null) + { + Don #@(_orderDetail.Order.Id.ToString()[..8].ToUpper()) + }
- @* ═══ STEP TRACKER / THANH BƯỚC ═══ *@ + @if (_isLoading) + { +
+ Dang tai... +
+ } + else if (_loadError) + { +
+ Khong the tai du lieu don hang + +
+ } + else + { + @* ═══ STEP TRACKER / THANH BUOC ═══ *@
@for (int i = 0; i < _steps.Count; i++) @@ -31,7 +55,7 @@ var bgColor = isCompleted ? "var(--pos-success)" : isActive ? "var(--pos-orange-primary)" : "var(--pos-bg-interactive)"; var fgColor = isCompleted || isActive ? "#FFF" : "var(--pos-text-tertiary)"; - @* EN: Step circle / VI: Vòng tròn bước *@ + @* EN: Step circle / VI: Vong tron buoc *@
- @* ═══ STEP CONTENT / NỘI DUNG BƯỚC ═══ *@ + @* ═══ STEP CONTENT / NOI DUNG BUOC ═══ *@
@switch (_currentStep) { case 0: - @* ═══ ĐẶT MÓN / ORDER STEP ═══ *@ + @* ═══ DAT MON / ORDER STEP ═══ *@
- Đặt món + Dat mon
- @foreach (var item in _orderItems) + @if (_orderItems.Any()) { -
-
-
@item.Name
-
x@item.Qty
+ @foreach (var item in _orderItems) + { +
+
+
@item.ProductName
+
x@item.Quantity
+
+ @FormatPrice(item.UnitPrice * item.Quantity)
- @FormatPrice(item.Price * item.Qty) -
+ } + } + else + { +
Khong co mon nao
}
- Tổng (@_orderItems.Sum(i => i.Qty) món) - @FormatPrice(_orderItems.Sum(i => i.Price * i.Qty)) + Tong (@_orderItems.Sum(i => i.Quantity) mon) + @FormatPrice(_orderDetail?.Order?.TotalAmount ?? 0)
break; case 1: - @* ═══ THANH TOÁN / PAYMENT STEP ═══ *@ + @* ═══ THANH TOAN / PAYMENT STEP ═══ *@
- Thanh toán + Thanh toan
- Phương thức - Tiền mặt + Phuong thuc + @(_orderDetail?.Order?.PaymentMethod ?? "Chua thanh toan")
- Tổng tiền - @FormatPrice(125_000) + Tong tien + @FormatPrice(_orderDetail?.Order?.TotalAmount ?? 0)
- Khách đưa - @FormatPrice(150_000) -
-
- Tiền thừa - @FormatPrice(25_000) + Trang thai + + @(_paymentCompleted ? "Da thanh toan" : "Chua thanh toan") +
break; case 2: - @* ═══ PHA CHẾ / BARISTA STEP ═══ *@ + @* ═══ PHA CHE / BARISTA STEP ═══ *@
- Pha chế + Pha che
-
-
- Barista - Trần Minh Tú + @if (_orderTickets.Any()) + { +
+ @foreach (var ticket in _orderTickets) + { +
+ @ticket.ItemName + @TicketStatusLabel(ticket.Status) +
+ }
-
- Thời gian ước tính - 3 phút + @if (_orderTickets.Any(t => t.Status is "Pending" or "InProgress")) + { +
+
+ + Dang pha @(_orderTickets.Count(t => t.Status is "Pending" or "InProgress")) mon... +
+
+ } + } + else + { +
+ Chua co kitchen ticket nao cho don nay
-
- Trạng thái - Đang pha chế -
-
-
-
- - Đang pha 3 món... -
-
+ }
break; case 3: - @* ═══ PHỤC VỤ / SERVING STEP ═══ *@ + @* ═══ PHUC VU / SERVING STEP ═══ *@
- Phục vụ + Phuc vu
-
- #027 + @if (_allTicketsCompleted) + { + + } + else + { + + #@(_orderDetail?.Order?.Id.ToString()[..4].ToUpper()) + + } +
+
+ @(_allTicketsCompleted ? "San sang phuc vu!" : "Dang cho pha che hoan tat") +
+
+ @(_allTicketsCompleted ? "Tat ca mon da hoan thanh" : "Vui long cho goi so tai quay")
-
Số thứ tự
-
Vui lòng chờ gọi số tại quầy
break; case 4: - @* ═══ HOÀN TẤT / COMPLETE STEP ═══ *@ + @* ═══ HOAN TAT / COMPLETE STEP ═══ *@
-
Hoàn tất!
+
Hoan tat!
- Đơn hàng #CF-0027 đã hoàn thành + Don hang #@(_orderDetail?.Order?.Id.ToString()[..8].ToUpper()) da hoan thanh
- 3 món · Tổng: @FormatPrice(125_000) · Tiền mặt + @_orderItems.Sum(i => i.Quantity) mon · Tong: @FormatPrice(_orderDetail?.Order?.TotalAmount ?? 0) · @(_orderDetail?.Order?.PaymentMethod ?? "N/A")
@@ -193,33 +242,34 @@ }
- @* ═══ FOOTER ACTIONS / NÚT HÀNH ĐỘNG ═══ *@ + @* ═══ FOOTER ACTIONS / NUT HANH DONG ═══ *@
@if (_currentStep > 0) { } @if (_currentStep < _steps.Count - 1) { } else { }
+ }
-@* EN: Pulse animation / VI: Hiệu ứng nhấp nháy *@ +@* EN: Pulse animation / VI: Hieu ung nhap nhay *@ @code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + // EN: Route parameter for order tracking / VI: Tham so route de theo doi don hang + [Parameter] public Guid OrderId { get; set; } + + // EN: Loading & error states / VI: Trang thai tai & loi + private bool _isLoading = true; + private bool _loadError; + + // EN: Auto-refresh timer (5 seconds) / VI: Timer tu dong refresh (5 giay) + private Timer? _refreshTimer; private int _currentStep = 0; - // EN: Journey steps / VI: Các bước hành trình + // EN: Journey steps / VI: Cac buoc hanh trinh private readonly List _steps = new() { - new("Đặt món", "clipboard-list"), - new("Thanh toán", "credit-card"), - new("Pha chế", "coffee"), - new("Phục vụ", "bell"), - new("Hoàn tất", "check-circle"), + new("Dat mon", "clipboard-list"), + new("Thanh toan", "credit-card"), + new("Pha che", "coffee"), + new("Phuc vu", "bell"), + new("Hoan tat", "check-circle"), }; - // EN: Demo order items / VI: Các món trong đơn mẫu - private readonly List _orderItems = new() + // EN: Order data from API / VI: Du lieu don hang tu API + private PosDataService.OrderDetailResponse? _orderDetail; + private List _orderItems = new(); + private List _orderTickets = new(); + + // EN: Derived states / VI: Trang thai suy ra + private bool _paymentCompleted => _orderDetail?.Order?.Status is "Paid" or "Completed" or "Validated"; + private bool _allTicketsCompleted => _orderTickets.Any() && _orderTickets.All(t => t.Status == "Completed"); + + protected override async Task OnInitializedAsync() { - new("Cà phê sữa đá", 35_000, 2), - new("Bánh mì bơ tỏi", 25_000, 1), - new("Trà đào cam sả", 30_000, 1), + await base.OnInitializedAsync(); + await LoadDataAsync(); + + // EN: Start auto-refresh timer / VI: Bat dau timer tu dong refresh + _refreshTimer = new Timer(async _ => + { + await InvokeAsync(async () => + { + await LoadDataAsync(silent: true); + AutoAdvanceStep(); + StateHasChanged(); + }); + }, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + } + + private async Task LoadDataAsync(bool silent = false) + { + if (!silent) { _isLoading = true; _loadError = false; } + + try + { + // EN: Load order details and kitchen tickets in parallel + // VI: Tai chi tiet don hang va kitchen tickets song song + var orderTask = DataService.GetOrderDetailAsync(OrderId, ShopId); + var pendingTask = DataService.GetKitchenTicketsAsync(ShopId, "Pending"); + var inProgressTask = DataService.GetKitchenTicketsAsync(ShopId, "InProgress"); + var completedTask = DataService.GetKitchenTicketsAsync(ShopId, "Completed"); + await Task.WhenAll(orderTask, pendingTask, inProgressTask, completedTask); + + _orderDetail = await orderTask; + _orderItems = _orderDetail?.Items ?? new(); + + // EN: Filter tickets by order items / VI: Loc tickets theo mon trong don + var allTickets = (await pendingTask).Concat(await inProgressTask).Concat(await completedTask).ToList(); + var orderItemIds = _orderItems.Select(i => i.Id).ToHashSet(); + _orderTickets = allTickets.Where(t => orderItemIds.Contains(t.OrderItemId)).ToList(); + + // EN: If no tickets matched by OrderItemId, try matching by item name + // VI: Neu khong co ticket nao khop theo OrderItemId, thu khop theo ten mon + if (!_orderTickets.Any() && _orderItems.Any()) + { + var itemNames = _orderItems.Select(i => i.ProductName).Where(n => n != null).ToHashSet(); + _orderTickets = allTickets.Where(t => itemNames.Contains(t.ItemName)).ToList(); + } + + if (!silent) AutoAdvanceStep(); + } + catch + { + if (!silent) _loadError = true; + } + finally + { + if (!silent) _isLoading = false; + } + } + + // EN: Auto-advance steps based on order/ticket status + // VI: Tu dong chuyen buoc dua tren trang thai don/ticket + private void AutoAdvanceStep() + { + if (_orderDetail?.Order == null) return; + + // EN: Step 0 (Order) always done if we have items + // EN: Step 1 (Payment) done if paid + // EN: Step 2 (Barista) done if all tickets completed + // EN: Step 3 (Serving) done if all tickets completed and order marked complete + // EN: Step 4 (Complete) final + + if (_orderDetail.Order.Status is "Completed") + { + _currentStep = 4; + } + else if (_allTicketsCompleted) + { + _currentStep = Math.Max(_currentStep, 3); + } + else if (_orderTickets.Any(t => t.Status is "InProgress" or "Completed")) + { + _currentStep = Math.Max(_currentStep, 2); + } + else if (_paymentCompleted) + { + _currentStep = Math.Max(_currentStep, 1); + } + } + + private static string TicketStatusColor(string status) => status switch + { + "Completed" => "var(--pos-success)", + "InProgress" => "var(--pos-warning)", + "Pending" => "var(--pos-info)", + _ => "var(--pos-text-tertiary)" }; + private static string TicketStatusLabel(string status) => status switch + { + "Completed" => "Hoan thanh", + "InProgress" => "Dang pha", + "Pending" => "Cho", + _ => status + }; + + public void Dispose() + { + _refreshTimer?.Dispose(); + } + private record StepInfo(string Label, string Icon); - private record OrderItem(string Name, decimal Price, int Qty); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/CustomerDisplay.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/CustomerDisplay.razor index af8c43d7..07882ae8 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/CustomerDisplay.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/CustomerDisplay.razor @@ -1,28 +1,32 @@ @* EN: Customer Display — Customer-facing screen: current order, total, branding. - VI: Màn hình khách hàng — Hiển thị đơn hàng, tổng tiền, thương hiệu. + Connected to active orders API with auto-refresh. + VI: Man hinh khach hang — Hien thi don hang, tong tien, thuong hieu. + Ket noi API don hang voi tu dong lam moi. *@ @page "/pos/{ShopId:guid}/cafe/customer-display" @layout PosLayout @inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService +@implements IDisposable
- @* EN: Branding / VI: Thương hiệu *@ + @* EN: Branding / VI: Thuong hieu *@
- ☕ GoodGo Coffee + GoodGo Coffee
- Chào mừng quý khách — Welcome + Chao mung quy khach — Welcome
@if (_orderItems.Any()) { - @* EN: Order details / VI: Chi tiết đơn hàng *@ + @* EN: Order details / VI: Chi tiet don hang *@
- Đơn hàng của bạn + Don hang cua ban Your Order
@@ -31,55 +35,71 @@ {
-
@item.Name
-
x@item.Qty
+
@item.ProductName
+
x@item.Quantity
- @FormatPrice(item.Price * item.Qty) + @FormatPrice(item.UnitPrice * item.Quantity)
}
- @* EN: Total / VI: Tổng cộng *@ + @* EN: Total / VI: Tong cong *@
- Tổng cộng / Total + Tong cong / Total - @FormatPrice(_orderItems.Sum(i => i.Price * i.Qty)) + @FormatPrice(_orderItems.Sum(i => i.UnitPrice * i.Quantity))
- @* EN: Payment status / VI: Trạng thái thanh toán *@ + @* EN: Payment status / VI: Trang thai thanh toan *@
- Đang chờ thanh toán — Awaiting payment + Dang cho thanh toan — Awaiting payment
} else { - @* EN: Idle state / VI: Trạng thái rỗi *@ + @* EN: Idle state / VI: Trang thai roi *@
- Hãy bắt đầu gọi món nhé! + Hay bat dau goi mon nhe!
Start ordering now!
}
@code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + private List _orderItems = new(); + private System.Threading.Timer? _refreshTimer; - // EN: Demo order items / VI: Mục đơn hàng mẫu - private readonly List _orderItems = new() + protected override async Task OnInitializedAsync() { - new("Cà phê sữa đá", 35_000, 2), - new("Cappuccino", 55_000, 1), - new("Croissant", 35_000, 1), - new("Trà đào", 45_000, 1), - }; + await base.OnInitializedAsync(); + await LoadLatestOrder(); - private record DisplayItem(string Name, decimal Price, int Qty); + // EN: Auto-refresh every 3 seconds / VI: Tu dong lam moi moi 3 giay + _refreshTimer = new System.Threading.Timer(async _ => + { + await LoadLatestOrder(); + await InvokeAsync(StateHasChanged); + }, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3)); + } + + private async Task LoadLatestOrder() + { + try + { + var orders = await DataService.GetActiveTableOrdersAsync(ShopId); + var latestOrder = orders.OrderByDescending(o => o.CreatedAt).FirstOrDefault(); + _orderItems = latestOrder?.Items?.ToList() ?? new(); + } + catch { /* silent for display screen */ } + } + + public void Dispose() => _refreshTimer?.Dispose(); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/DailyReport.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/DailyReport.razor index c6c46f45..13cb7288 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/DailyReport.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/DailyReport.razor @@ -1,10 +1,14 @@ @* EN: Daily Report — End-of-day summary: revenue, orders, popular items, payment breakdown. + Connected to POS dashboard API for real-time data. VI: Báo cáo ngày — Tổng kết cuối ngày: doanh thu, đơn hàng, món phổ biến, phân tích thanh toán. + Kết nối API POS dashboard cho dữ liệu thời gian thực. *@ @page "/pos/{ShopId:guid}/cafe/daily-report" @layout PosLayout @inherits PosBase +@using WebClientTpos.Client.Services +@inject PosDataService DataService
@* EN: Header / VI: Tiêu đề *@ @@ -19,6 +23,21 @@
+ @if (_isLoading) + { +
+
Đang tải báo cáo...
+
+ } + else if (_loadError) + { +
+
Không thể tải dữ liệu báo cáo
+ +
+ } + else + { @* EN: Summary cards / VI: Thẻ tổng quan *@
@foreach (var stat in _stats) @@ -35,90 +54,135 @@ @* EN: Popular items / VI: Món phổ biến *@

Món bán chạy

- @foreach (var item in _popularItems) + @if (_popularItems.Any()) { -
-
-
@item.Name
-
@item.Qty đã bán
+ @foreach (var item in _popularItems) + { +
+
+
@item.Name
+
@item.Qty đã bán
+
+ @FormatPrice(item.Revenue)
- @FormatPrice(item.Revenue) -
+ } + } + else + { +
Chưa có dữ liệu
}
@* EN: Payment breakdown / VI: Phân tích thanh toán *@

Hình thức thanh toán

- @foreach (var payment in _payments) + @if (_payments.Any()) { -
-
- @payment.Method - @FormatPrice(payment.Amount) -
-
-
-
-
- } - - @* EN: Hourly chart placeholder / VI: Biểu đồ theo giờ *@ -

Doanh thu theo giờ

-
- @foreach (var h in _hourlyData) + @foreach (var payment in _payments) { -
-
- @h.Hour +
+
+ @payment.Method + @FormatPrice(payment.Amount) +
+
+
+
} -
+ } + else + { +
Chưa có dữ liệu
+ } + + @* EN: Hourly chart / VI: Biểu đồ theo giờ *@ +

Doanh thu theo giờ

+ @if (_hourlyData.Any()) + { +
+ @foreach (var h in _hourlyData) + { +
+
+ @h.HourLabel +
+ } +
+ } + else + { +
Chưa có dữ liệu
+ }
+ }
@code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + // EN: Loading state / VI: Trạng thái tải + private bool _isLoading = true; + private bool _loadError; - // EN: Summary statistics / VI: Thống kê tổng quan - private readonly List _stats = new() - { - new("Doanh thu", "8,450,000₫", "var(--pos-orange-primary)", "+12% so với hôm qua"), - new("Đơn hàng", "156", "var(--pos-success)", "TB 54,167₫/đơn"), - new("Khách hàng", "132", "var(--pos-info)", "18% khách quay lại"), - new("Món bán ra", "347", "var(--pos-warning)", "2.2 món/đơn"), - }; + // EN: Dashboard data from API / VI: Dữ liệu dashboard từ API + private List _stats = new(); + private List _popularItems = new(); + private List _payments = new(); + private List _hourlyData = new(); - // EN: Popular items / VI: Món phổ biến - private readonly List _popularItems = new() + protected override async Task OnInitializedAsync() { - new("Cà phê sữa đá", 52, 1_820_000), - new("Bạc xỉu", 38, 1_482_000), - new("Trà đào", 29, 1_305_000), - new("Cappuccino", 24, 1_320_000), - new("Sinh tố bơ", 18, 990_000), - }; + await base.OnInitializedAsync(); + await LoadReportAsync(); + } - // EN: Payment methods / VI: Hình thức thanh toán - private readonly List _payments = new() + private async Task LoadReportAsync() { - new("Tiền mặt", 4_650_000, 55, "var(--pos-success)"), - new("Chuyển khoản", 2_535_000, 30, "var(--pos-info)"), - new("Thẻ", 845_000, 10, "var(--pos-warning)"), - new("Ví điện tử", 420_000, 5, "var(--pos-orange-primary)"), - }; + _isLoading = true; + _loadError = false; + StateHasChanged(); - // EN: Hourly data / VI: Dữ liệu theo giờ - private readonly List _hourlyData = new() - { - new("7h", 30), new("8h", 65), new("9h", 85), new("10h", 55), - new("11h", 90), new("12h", 100), new("13h", 70), new("14h", 45), - new("15h", 60), new("16h", 75), new("17h", 50), new("18h", 20), - }; + try + { + // EN: Fetch dashboard data for today / VI: Lấy dữ liệu dashboard cho hôm nay + var data = await DataService.GetPosDashboardAsync(ShopId, "today"); + + // EN: Build summary stat cards from API data / VI: Tạo thẻ thống kê từ dữ liệu API + _stats = new List + { + new("Doanh thu", FormatPrice(data.Revenue), "var(--pos-orange-primary)", + data.AvgOrderValue > 0 ? $"TB {FormatPrice(data.AvgOrderValue)}/đơn" : "—"), + new("Đơn hàng", data.OrderCount.ToString("N0"), "var(--pos-success)", + data.OrderCount > 0 ? $"TB {FormatPrice(data.AvgOrderValue)}/đơn" : "—"), + new("Món bán ra", data.ItemsSold.ToString("N0"), "var(--pos-warning)", + data.OrderCount > 0 ? $"{(double)data.ItemsSold / data.OrderCount:F1} món/đơn" : "—"), + new("Đơn trung bình", FormatPrice(data.AvgOrderValue), "var(--pos-info)", "Giá trị trung bình"), + }; + + _popularItems = data.PopularItems ?? new(); + + // EN: Compute percentage for payment breakdown / VI: Tính phần trăm cho breakdown thanh toán + _payments = data.PaymentBreakdown ?? new(); + var totalPayments = _payments.Sum(p => p.Amount); + foreach (var p in _payments) + p.Pct = totalPayments > 0 ? (int)(p.Amount / totalPayments * 100) : 0; + + // EN: Compute percentage for hourly revenue (relative to max) / VI: Tính % doanh thu theo giờ (tương đối so với max) + _hourlyData = data.HourlyRevenue ?? new(); + var maxHourly = _hourlyData.Any() ? _hourlyData.Max(h => h.Revenue) : 0; + foreach (var h in _hourlyData) + h.Pct = maxHourly > 0 ? (int)(h.Revenue / maxHourly * 100) : 0; + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + StateHasChanged(); + } + } private record StatCard(string Label, string Value, string Color, string Sub); - private record PopularItem(string Name, int Qty, decimal Revenue); - private record PaymentInfo(string Method, decimal Amount, int Percent, string Color); - private record HourData(string Hour, int Pct); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/LoyaltyStamp.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/LoyaltyStamp.razor index 82ce6b03..81302e1b 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/LoyaltyStamp.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/LoyaltyStamp.razor @@ -1,59 +1,78 @@ @* EN: Loyalty Stamp — Customer stamp card: points, stamps grid, reward progress. - VI: Thẻ tích điểm — Thẻ khách hàng: điểm, lưới tem, tiến trình thưởng. + Connected to membership API for customer search and experience management. + VI: The tich diem — The khach hang: diem, luoi tem, tien trinh thuong. + Ket noi API thanh vien de tim kiem khach hang va quan ly kinh nghiem. *@ @page "/pos/{ShopId:guid}/cafe/loyalty-stamp" @layout PosLayout @inherits PosBase +@using WebClientTpos.Client.Services +@inject PosDataService DataService
- @* EN: Customer lookup / VI: Tìm khách hàng *@ + @* EN: Customer lookup / VI: Tim khach hang *@
- -
+ @if (!string.IsNullOrEmpty(_searchError)) + { +
@_searchError
+ }
- @* EN: Customer info / VI: Thông tin khách hàng *@ + @if (_customer != null) + { + @* EN: Customer info / VI: Thong tin khach hang *@
-
- @_customer.Name[..1] +
+ @(_customer.DisplayName ?? "?")[..1]
-
@_customer.Name
-
@_customer.Phone
+
@(_customer.DisplayName ?? "Khach hang")
+
@(_customer.Phone ?? _phone)
-
@_customer.Points
-
Điểm
+
@_customer.CurrentExp
+
Diem
-
@_customer.Visits
-
Lần ghé
+
@_customer.TotalExpEarned
+
Tong diem
-
@_customer.Rank
-
Hạng
+
@(_customer.LevelName ?? $"Lv.{_customer.CurrentLevel}")
+
Hang
- @* EN: Stamp grid / VI: Lưới tem *@ + @* EN: Stamp grid / VI: Luoi tem *@
- Thẻ tích tem - @_customer.Stamps / @_totalStamps + The tich tem + @_stampCount / @_totalStamps
@for (int i = 1; i <= _totalStamps; i++) { var idx = i; - var isStamped = idx <= _customer.Stamps; + var isStamped = idx <= _stampCount; var isReward = idx == _totalStamps;
@if (isReward) { - 🎁 + 🎁 } else if (isStamped) { - + } else { @@ -75,54 +94,152 @@ }
- @* EN: Progress bar / VI: Thanh tiến trình *@ + @* EN: Progress bar / VI: Thanh tien trinh *@
-
+
- Còn @(_totalStamps - _customer.Stamps) tem nữa để nhận 1 ly miễn phí! + Con @(_totalStamps - _stampCount) tem nua de nhan 1 ly mien phi!
- @* EN: Add stamp button / VI: Nút thêm tem *@ - + @if (!string.IsNullOrEmpty(_stampMessage)) + { +
@_stampMessage
+ } + } + else if (!_isSearching && _hasSearched) + { +
+ Khong tim thay khach hang. Hay nhap SDT va nhan Tim. +
+ }
@code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + // EN: Search state / VI: Trang thai tim kiem + private string _phone = ""; + private bool _isSearching; + private bool _hasSearched; + private string? _searchError; - private string _phone = "0901234567"; + // EN: Customer data from API / VI: Du lieu khach hang tu API + private PosDataService.MemberInfo? _customer; + private PosDataService.MemberProgressInfo? _progress; + + // EN: Stamp state / VI: Trang thai tem private int _totalStamps = 10; + private int _stampCount; + private bool _isAddingStamp; + private string? _stampMessage; - // EN: Demo customer / VI: Khách hàng mẫu - private CustomerInfo _customer = new("Nguyễn Minh Anh", "0901234567", 245, 28, "Vàng", 7); - - private void AddStamp() + private async Task SearchCustomer() { - if (_customer.Stamps < _totalStamps) + if (string.IsNullOrWhiteSpace(_phone)) return; + + _isSearching = true; + _searchError = null; + _stampMessage = null; + _customer = null; + _progress = null; + _hasSearched = true; + + try { - _customer.Stamps++; - _customer.Points += 10; + // EN: Search using membership API / VI: Tim kiem su dung API thanh vien + var members = await DataService.SearchCustomersAsync(ShopId, _phone.Trim()); + + if (members.Any()) + { + _customer = members.First(); + + // EN: Load member progress for level info / VI: Tai tien trinh thanh vien cho thong tin hang + try + { + _progress = await DataService.GetMemberProgressAsync(_customer.Id); + } + catch + { + // EN: Progress is optional / VI: Tien trinh la tuy chon + } + + // EN: Calculate stamps from total exp (1 stamp per 10 exp points) + // VI: Tinh tem tu tong diem (1 tem moi 10 diem kinh nghiem) + _stampCount = (_customer.TotalExpEarned / 10) % _totalStamps; + } } - else + catch { - _customer.Stamps = 0; // EN: Reset after reward / VI: Reset sau khi nhận thưởng + _searchError = "Khong the tim kiem. Vui long thu lai."; + } + finally + { + _isSearching = false; } } - private class CustomerInfo(string name, string phone, int points, int visits, string rank, int stamps) + private async Task AddStamp() { - public string Name { get; set; } = name; - public string Phone { get; set; } = phone; - public int Points { get; set; } = points; - public int Visits { get; set; } = visits; - public string Rank { get; set; } = rank; - public int Stamps { get; set; } = stamps; + if (_customer == null) return; + + _isAddingStamp = true; + _stampMessage = null; + + try + { + // EN: Add experience points via API (10 points = 1 stamp) + // VI: Them diem kinh nghiem qua API (10 diem = 1 tem) + var result = await DataService.AddExperienceAsync(_customer.Id, new PosDataService.AddExpRequest( + Points: 10, + SourceId: 1, // EN: SourceId 1 = POS purchase / VI: SourceId 1 = Mua hang POS + ReferenceId: $"stamp-{DateTime.UtcNow:yyyyMMddHHmmss}" + )); + + if (result != null) + { + _stampCount = (result.TotalExpEarned / 10) % _totalStamps; + _customer = _customer with { CurrentExp = result.CurrentExp, TotalExpEarned = result.TotalExpEarned, CurrentLevel = result.CurrentLevel }; + + if (result.LeveledUp) + { + _stampMessage = $"Chuc mung! Khach hang da len hang moi (Level {result.CurrentLevel})!"; + } + else if (_stampCount == 0) + { + _stampMessage = "Chuc mung! Da tich du tem — 1 ly mien phi!"; + } + else + { + _stampMessage = "Da tich 1 tem thanh cong!"; + } + } + else + { + _stampMessage = "Khong the tich tem. Vui long thu lai."; + } + } + catch + { + _stampMessage = "Loi khi tich tem. Vui long thu lai."; + } + finally + { + _isAddingStamp = false; + } } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/MenuManagement.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/MenuManagement.razor index 3e177214..9710e727 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/MenuManagement.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/MenuManagement.razor @@ -5,7 +5,8 @@ @page "/pos/{ShopId:guid}/cafe/menu-management" @layout PosLayout @inherits PosBase -@inject WebClientTpos.Client.Services.PosDataService DataService +@using WebClientTpos.Client.Services +@inject PosDataService DataService
@if (_isLoading) @@ -28,7 +29,7 @@

Quản lý menu

Cập nhật tình trạng món — @_items.Count(i => i.Available) / @_items.Count có sẵn
-
+
@foreach (var cat in _filterCategories) {
+ @if (!string.IsNullOrEmpty(_saveMessage)) + { +
+ @_saveMessage +
+ } @* EN: Menu items table / VI: Bảng danh sách món *@
@@ -80,7 +101,7 @@ @if (item.IsEditingPrice) { + @onchange="@((ChangeEventArgs e) => { item.CurrentPrice = decimal.Parse(e.Value?.ToString() ?? "0"); item.IsEditingPrice = false; item.IsDirty = true; })" /> } else { @@ -99,7 +120,7 @@
@@ -117,12 +138,20 @@ private bool _isLoading = true; private bool _loadError; + // EN: Save state / VI: Trạng thái lưu + private bool _isSaving; + private string? _saveMessage; + private bool _saveIsError; + private string _filterCategory = "Tất cả"; private string[] _filterCategories = { "Tất cả" }; private IEnumerable FilteredItems => _filterCategory == "Tất cả" ? _items : _items.Where(i => i.Category == _filterCategory); + // EN: Check if any item has unsaved changes / VI: Kiểm tra có item nào chưa lưu không + private bool HasChanges => _items.Any(i => i.IsDirty); + // EN: Menu items loaded from DB / VI: Danh sách menu tải từ DB private List _items = new(); @@ -140,9 +169,11 @@ var apiCategories = await categoriesTask; _items = apiProducts.Select(p => new MenuItem( + p.Id, p.Name, p.Category ?? "Khác", - p.Price + p.Price, + p.CategoryId )).ToList(); var catNames = apiCategories.Select(c => c.Name).ToList(); @@ -164,13 +195,89 @@ } } - private class MenuItem(string name, string category, decimal price) + // EN: Toggle availability and mark item as dirty / VI: Bật/tắt khả dụng và đánh dấu item đã thay đổi + private void ToggleAvailability(MenuItem item) { + item.Available = !item.Available; + item.IsDirty = true; + } + + // EN: Save all changed items to API / VI: Lưu tất cả item đã thay đổi lên API + private async Task SaveChangesAsync() + { + if (_isSaving || !HasChanges) return; + _isSaving = true; + _saveMessage = null; + StateHasChanged(); + + var dirtyItems = _items.Where(i => i.IsDirty).ToList(); + int successCount = 0; + int failCount = 0; + + foreach (var item in dirtyItems) + { + try + { + // EN: Update product via admin API — send price and availability + // VI: Cập nhật sản phẩm qua admin API — gửi giá và trạng thái khả dụng + var req = new PosDataService.CreateProductRequest( + ShopId, + item.Name, + null, // description + item.CurrentPrice, + null, // type + null, // sku + null, // imageUrl + item.CategoryId + ); + var success = await DataService.UpdateProductAsync(item.ProductId, req); + if (success) + { + item.OriginalPrice = item.CurrentPrice; + item.IsDirty = false; + successCount++; + } + else + { + failCount++; + } + } + catch + { + failCount++; + } + } + + if (failCount == 0) + { + _saveMessage = $"Đã lưu thành công {successCount} món."; + _saveIsError = false; + } + else + { + _saveMessage = $"Lưu {successCount} thành công, {failCount} thất bại."; + _saveIsError = true; + } + + _isSaving = false; + StateHasChanged(); + + // EN: Auto-hide message after 3 seconds / VI: Tự động ẩn thông báo sau 3 giây + await Task.Delay(3000); + _saveMessage = null; + StateHasChanged(); + } + + private class MenuItem(Guid productId, string name, string category, decimal price, Guid? categoryId = null) + { + public Guid ProductId { get; set; } = productId; public string Name { get; set; } = name; public string Category { get; set; } = category; public decimal OriginalPrice { get; set; } = price; public decimal CurrentPrice { get; set; } = price; public bool Available { get; set; } = true; public bool IsEditingPrice { get; set; } + public bool IsDirty { get; set; } + public Guid? CategoryId { get; set; } = categoryId; } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/OrderCustomize.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/OrderCustomize.razor index 163d4dbb..7946ed21 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/OrderCustomize.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/OrderCustomize.razor @@ -1,16 +1,30 @@ @* EN: Order Customize — Drink customization: size, sugar, ice, toppings. - VI: Tùy chỉnh đơn hàng — Tùy chỉnh đồ uống: size, đường, đá, topping. + Loads real product info from API, persists customizations via query string. + VI: Tuy chinh don hang — Tuy chinh do uong: size, duong, da, topping. + Tai thong tin san pham tu API, luu tuy chinh qua query string. *@ +@page "/pos/{ShopId:guid}/cafe/order-customize/{ProductId:guid}" @page "/pos/{ShopId:guid}/cafe/order-customize" @layout PosLayout @inherits PosBase +@using WebClientTpos.Client.Services +@inject PosDataService DataService +@inject IJSRuntime JS
- @* EN: Customization panel / VI: Panel tùy chỉnh *@ + @* EN: Customization panel / VI: Panel tuy chinh *@
- @* EN: Product header / VI: Tiêu đề sản phẩm *@ + @if (_isLoading) + { +
+ Dang tai san pham... +
+ } + else + { + @* EN: Product header / VI: Tieu de san pham *@
@@ -21,9 +35,9 @@
- @* EN: Size selection / VI: Chọn size *@ + @* EN: Size selection / VI: Chon size *@
-
Kích cỡ
+
Kich co
@foreach (var size in _sizes) { @@ -41,9 +55,9 @@
- @* EN: Sugar level / VI: Mức đường *@ + @* EN: Sugar level / VI: Muc duong *@
-
Mức đường
+
Muc duong
@foreach (var sugar in _sugarLevels) { @@ -56,9 +70,9 @@
- @* EN: Ice level / VI: Mức đá *@ + @* EN: Ice level / VI: Muc da *@
-
Mức đá
+
Muc da
@foreach (var ice in _iceLevels) { @@ -87,59 +101,110 @@ }
+ }
- @* EN: Confirm panel / VI: Panel xác nhận *@ + @* EN: Confirm panel / VI: Panel xac nhan *@
-
Tóm tắt
+
Tom tat
Size: @_selectedSize
-
Đường: @_selectedSugar
-
Đá: @_selectedIce
-
Topping: @(_selectedToppings.Any() ? string.Join(", ", _selectedToppings) : "Không")
+
Duong: @_selectedSugar
+
Da: @_selectedIce
+
Topping: @(_selectedToppings.Any() ? string.Join(", ", _selectedToppings) : "Khong")
- Tổng + Tong @FormatPrice(_basePrice + _extraPrice)
- +
@code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + // EN: Route parameter for product / VI: Tham so route cho san pham + [Parameter] public Guid ProductId { get; set; } - private string _productName = "Cà phê sữa đá"; - private decimal _basePrice = 35_000; + // EN: Loading state / VI: Trang thai tai + private bool _isLoading = true; + + private string _productName = "San pham"; + private Guid _resolvedProductId = Guid.Empty; + private decimal _basePrice = 0; private decimal _extraPrice = 0; private string _selectedSize = "M"; private string _selectedSugar = "100%"; - private string _selectedIce = "Đầy đá"; + private string _selectedIce = "Day da"; private readonly HashSet _selectedToppings = new(); private readonly List _sizes = new() { - new("S", "Nhỏ", 0), - new("M", "Vừa", 0), - new("L", "Lớn", 10_000), + new("S", "Nho", 0), + new("M", "Vua", 0), + new("L", "Lon", 10_000), }; private readonly string[] _sugarLevels = { "100%", "70%", "50%", "0%" }; - private readonly string[] _iceLevels = { "Đầy đá", "Ít đá", "Không đá" }; + private readonly string[] _iceLevels = { "Day da", "It da", "Khong da" }; private readonly List _toppings = new() { - new("Trân châu đen", 10_000), - new("Trân châu trắng", 10_000), - new("Thạch cà phê", 10_000), + new("Tran chau den", 10_000), + new("Tran chau trang", 10_000), + new("Thach ca phe", 10_000), new("Kem cheese", 15_000), new("Shot espresso", 15_000), - new("Sữa dừa", 10_000), + new("Sua dua", 10_000), }; + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + await LoadProductAsync(); + } + + private async Task LoadProductAsync() + { + _isLoading = true; + + try + { + if (ProductId != Guid.Empty) + { + _resolvedProductId = ProductId; + // EN: Load all products and find the matching one + // VI: Tai tat ca san pham va tim san pham khop + var products = await DataService.GetProductsAsync(ShopId); + var product = products.FirstOrDefault(p => p.Id == ProductId); + if (product != null) + { + _productName = product.Name; + _basePrice = product.Price; + } + } + else + { + // EN: Fallback — no product specified + // VI: Du phong — khong co san pham duoc chi dinh + _productName = "Ca phe sua da"; + _basePrice = 35_000; + } + } + catch + { + // EN: Use default on error / VI: Dung mac dinh khi loi + _productName = "Ca phe sua da"; + _basePrice = 35_000; + } + finally + { + _isLoading = false; + } + } + private void SelectSize(SizeOption size) { _selectedSize = size.Label; @@ -160,7 +225,34 @@ _extraPrice = sizeExtra + toppingExtra; } - private void Confirm() => NavigateTo("cafe"); + private async Task Confirm() + { + // EN: Build customization record and persist to localStorage + // VI: Tao ban ghi tuy chinh va luu vao localStorage + var customization = new DrinkCustomization( + _resolvedProductId != Guid.Empty ? _resolvedProductId : ProductId, + _productName, + _basePrice, + _selectedSize, + _selectedSugar, + _selectedIce, + _selectedToppings.ToList(), + _basePrice + _extraPrice + ); + + // EN: Store in localStorage so the parent page can read it + // VI: Luu vao localStorage de trang cha co the doc + var json = System.Text.Json.JsonSerializer.Serialize(customization); + await JS.InvokeVoidAsync("localStorage.setItem", "pos_drink_customization", json); + + // EN: Navigate back to cafe with query string indicating customization is ready + // VI: Quay ve trang cafe voi query string cho biet tuy chinh da san sang + NavigationManager.NavigateTo($"/pos/{ShopId}/cafe?customized=true&productId={customization.ProductId}"); + } + + // EN: Customization data record / VI: Ban ghi du lieu tuy chinh + private record DrinkCustomization(Guid ProductId, string ProductName, decimal BasePrice, + string Size, string Sugar, string Ice, List Toppings, decimal TotalPrice); private record SizeOption(string Label, string Desc, decimal Extra); private record ToppingOption(string Name, decimal Price); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/QueueDisplay.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/QueueDisplay.razor index 10273cd6..78f44f74 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/QueueDisplay.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/Workflow/QueueDisplay.razor @@ -1,62 +1,70 @@ @* EN: Queue Display — Public-facing queue board: preparing list, ready list, large order numbers. - VI: Bảng hiển thị hàng đợi — Bảng công khai: danh sách đang pha, sẵn sàng, số đơn lớn. + Connected to kitchen tickets API with auto-refresh. + VI: Bang hien thi hang doi — Bang cong khai: danh sach dang pha, san sang, so don lon. + Ket noi API kitchen tickets voi tu dong lam moi. *@ @page "/pos/{ShopId:guid}/cafe/queue-display" @layout PosLayout @inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService +@implements IDisposable
- @* EN: Preparing column / VI: Cột đang pha *@ + @* EN: Preparing column / VI: Cot dang pha *@
- ☕ Đang pha chế + Dang pha che
-
Preparing
+
Preparing (@_preparingGroups.Count)
- @foreach (var order in _preparingOrders) + @{ int prepIdx = 0; } + @foreach (var group in _preparingGroups) { + prepIdx++;
-
@order.Number
-
@order.Items
+
#@prepIdx
+
@string.Join(", ", group.Items)
- @order.Time + @GetElapsed(group.CreatedAt)
}
- @* EN: Divider / VI: Đường phân cách *@ + @* EN: Divider / VI: Duong phan cach *@
- @* EN: Ready column / VI: Cột sẵn sàng *@ + @* EN: Ready column / VI: Cot san sang *@
- ✅ Sẵn sàng + San sang
-
Ready for pickup
+
Ready for pickup (@_readyGroups.Count)
- @foreach (var order in _readyOrders) + @{ int readyIdx = 0; } + @foreach (var group in _readyGroups) { + readyIdx++;
-
@order.Number
-
@order.Customer
-
Mời nhận đồ
+
#@readyIdx
+
@string.Join(", ", group.Items)
+
Moi nhan do
}
-@* EN: Pulse animation / VI: Hiệu ứng nhấp nháy *@ +@* EN: Pulse animation / VI: Hieu ung nhap nhay *@ @code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + private List _preparingGroups = new(); + private List _readyGroups = new(); + private System.Threading.Timer? _refreshTimer; - // EN: Preparing orders / VI: Đơn đang pha - private readonly List _preparingOrders = new() + protected override async Task OnInitializedAsync() { - new("101", "Cà phê sữa đá x2", "3 phút"), - new("103", "Trà đào x1, Sinh tố bơ x1", "5 phút"), - new("105", "Cappuccino x3", "2 phút"), - new("107", "Latte x1, Bạc xỉu x1", "1 phút"), - }; + await base.OnInitializedAsync(); + await LoadTickets(); - // EN: Ready orders / VI: Đơn sẵn sàng - private readonly List _readyOrders = new() + _refreshTimer = new System.Threading.Timer(async _ => + { + await LoadTickets(); + await InvokeAsync(StateHasChanged); + }, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + } + + private async Task LoadTickets() { - new("098", "Anh Minh"), - new("099", "Chị Lan"), - new("100", "Anh Khoa"), - new("102", "Chị Hương"), - new("104", "Anh Tùng"), - }; + try + { + var tickets = await DataService.GetKitchenTicketsAsync(ShopId); + _preparingGroups = tickets + .Where(t => t.Status?.ToLower() is "pending" or "preparing" or "in-progress") + .GroupBy(t => t.SessionId) + .Select(g => new TicketGroup(g.Select(t => t.ItemName ?? "Mon").ToList(), g.Min(t => t.CreatedAt))) + .OrderBy(g => g.CreatedAt) + .ToList(); - private record QueueOrder(string Number, string Items, string Time); - private record ReadyOrder(string Number, string Customer); + _readyGroups = tickets + .Where(t => t.Status?.ToLower() is "ready" or "completed") + .GroupBy(t => t.SessionId) + .Select(g => new TicketGroup(g.Select(t => t.ItemName ?? "Mon").ToList(), g.Min(t => t.CreatedAt))) + .OrderByDescending(g => g.CreatedAt) + .Take(10) + .ToList(); + } + catch { /* silent for display screen */ } + } + + private static string GetElapsed(DateTime createdAt) + { + var elapsed = DateTime.Now - createdAt; + if (elapsed.TotalMinutes < 1) return "vua xong"; + return $"{(int)elapsed.TotalMinutes} phut"; + } + + public void Dispose() => _refreshTimer?.Dispose(); + + private record TicketGroup(List Items, DateTime CreatedAt); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/KaraokeMobile.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/KaraokeMobile.razor index ab2c03eb..031913e9 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/KaraokeMobile.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/KaraokeMobile.razor @@ -62,7 +62,7 @@
- @* EN: Status + timer / VI: Trạng thái + giờ *@ + @* EN: Status + timer + F&B total / VI: Trang thai + gio + tong F&B *@
@GetStatusLabel(room.Status) @@ -73,6 +73,12 @@ @((DateTime.Now - room.SessionStart.Value).ToString(@"h\:mm"))
} + @if (_roomFnbTotals.TryGetValue(room.Id, out var fnbTotal) && fnbTotal > 0) + { +
+ F&B: @FormatPrice(fnbTotal) +
+ }
@@ -107,9 +113,12 @@ private string _activeZone = "Tất cả"; private string[] _zones = { "Tất cả" }; - // EN: Room data loaded from DB / VI: Dữ liệu phòng tải từ DB + // EN: Room data loaded from DB / VI: Du lieu phong tai tu DB private List _rooms = new(); + // EN: F&B totals per room / VI: Tong F&B theo phong + private Dictionary _roomFnbTotals = new(); + private IEnumerable FilteredRooms => _activeZone == "Tất cả" ? _rooms : _rooms.Where(r => r.Zone == _activeZone); @@ -128,11 +137,24 @@ t.Zone ?? "Standard", t.Status, t.Zone ?? "Tầng 1", - t.StartedAt + t.StartedAt, + t.HourlyRate )).ToList(); var zoneNames = _rooms.Select(r => r.Zone).Distinct().ToList(); _zones = new[] { "Tất cả" }.Concat(zoneNames).ToArray(); + + // EN: Load active orders to show F&B totals per room + // VI: Tai don hang de hien thi tong F&B theo phong + try + { + var orders = await DataService.GetActiveTableOrdersAsync(ShopId); + _roomFnbTotals = orders + .Where(o => o.TableId.HasValue) + .GroupBy(o => o.TableId!.Value.ToString()) + .ToDictionary(g => g.Key, g => g.Sum(o => o.TotalAmount)); + } + catch { /* EN: Non-critical, continue without F&B totals / VI: Khong quan trong, tiep tuc khong co tong F&B */ } } catch { @@ -144,7 +166,17 @@ } } - private void OpenRoom(RoomInfo room) => NavigateTo("karaoke/room-session"); + // EN: Navigate based on room status — occupied rooms go to session, available rooms go to selection + // VI: Dieu huong theo trang thai — phong dang hat di den phien, phong trong di den chon phong + private void OpenRoom(RoomInfo room) + { + if (room.Status == "occupied") + NavigateTo($"karaoke/room-session/{room.Id}"); + else if (room.Status == "available") + NavigateTo("karaoke/room-select"); + else + NavigateTo($"karaoke/room-session/{room.Id}"); + } private static string GetStatusBg(string s) => s switch { @@ -171,5 +203,5 @@ "reserved" => "Đã đặt", "cleaning" => "Đang dọn", _ => s }; - private record RoomInfo(string Id, string Name, int Capacity, string Type, string Status, string Zone, DateTime? SessionStart); + private record RoomInfo(string Id, string Name, int Capacity, string Type, string Status, string Zone, DateTime? SessionStart, decimal HourlyRate = 0); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/KaraokeTablet.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/KaraokeTablet.razor index 6f8c5c9d..0bb4e380 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/KaraokeTablet.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/KaraokeTablet.razor @@ -97,16 +97,28 @@
- @* EN: Current F&B / VI: F&B hiện tại *@ + @* EN: F&B orders from DB / VI: Đơn F&B từ DB *@
ĐƠN F&B
- @foreach (var item in _demoFnb) + @if (GetRoomOrders(_selectedRoom!.Id).Any()) { -
-
- @item.Name - @FormatPrice(item.Price) -
- x@item.Qty + @foreach (var order in GetRoomOrders(_selectedRoom!.Id)) + { + @foreach (var item in order.Items) + { +
+
+ @item.ProductName + @FormatPrice(item.UnitPrice) +
+ x@item.Quantity +
+ } + } + } + else + { +
+ Chưa có đơn F&B
} } @@ -119,9 +131,26 @@
- @* ═══ STEP INDICATOR / CHỈ BÁO BƯỚC ═══ *@ + @if (_isLoading) + { +
+ Dang tai... +
+ } + else if (_loadError) + { +
+ Khong the tai du lieu phong +
+ } + else + { + @* === STEP INDICATOR / CHI BAO BUOC === *@
@for (var i = 0; i < _steps.Length; i++) { @@ -44,23 +67,23 @@ }
- @* ═══ STEP CONTENT / NỘI DUNG BƯỚC ═══ *@ + @* === STEP CONTENT / NOI DUNG BUOC === *@
@switch (_activeStep) { - @* EN: Step 1 — Guest reception / VI: Bước 1 — Đón khách *@ + @* EN: Step 1 -- Guest reception / VI: Buoc 1 -- Don khach *@ case 0:
- Đón khách + Don khach
-
Số khách
+
So khach
+ @onclick="() => _guestCount = Math.Max(1, _guestCount - 1)">- @_guestCount
-
Thẻ thành viên
+
The thanh vien
-
@@ -82,208 +105,354 @@ {
- Nguyễn Văn Minh — Gold • 2,450 điểm + Tim kiem thanh vien...
}
+ @if (_room != null) + { +
+ Phong da chon: @_room.TableNumber — Suc chua: @_room.Capacity nguoi + @if (_room.GuestCount.HasValue && _room.GuestCount > 0) + { + — Hien tai: @_room.GuestCount khach + } +
+ }
break; - @* EN: Step 2 — Room selection / VI: Bước 2 — Chọn phòng *@ + @* EN: Step 2 -- Room selection / VI: Buoc 2 -- Chon phong *@ case 1:
- Chọn phòng + Chon phong
-
Phòng
-
VIP 2
+
Phong
+
@(_room?.TableNumber ?? "---")
-
Sức chứa
-
20 người
+
Suc chua
+
@(_room?.Capacity ?? 0) nguoi
-
Loại
-
Deluxe
+
Loai
+
@(_room?.Zone ?? "Standard")
- Tầng 3 • Khu Deluxe • @FormatPrice(200_000)/giờ + @(_room?.Zone ?? "Khu") • @FormatPrice(_room?.HourlyRate ?? 0)/gio
break; - @* EN: Step 3 — Open room / VI: Bước 3 — Mở phòng *@ + @* EN: Step 3 -- Open room / VI: Buoc 3 -- Mo phong *@ case 2:
- Mở phòng + Mo phong
- Giờ bắt đầu - 19:30 + Gio bat dau + @(_sessionStart?.ToString("HH:mm") ?? DateTime.Now.ToString("HH:mm"))
- Thời lượng đặt - 2.5 giờ + Thoi luong du kien + @_estimatedHours gio
- Giá/giờ - @FormatPrice(200_000) + Gia/gio + @FormatPrice(_hourlyRate)
- Giờ kết thúc dự kiến - 22:00 + Gio ket thuc du kien + @((_sessionStart ?? DateTime.Now).AddHours(_estimatedHours).ToString("HH:mm"))
- Tạm tính phòng - @FormatPrice(500_000) + Tam tinh phong + @FormatPrice(_hourlyRate * _estimatedHours)
break; - @* EN: Step 4 — In room / VI: Bước 4 — Trong phòng *@ + @* EN: Step 4 -- In room / VI: Buoc 4 -- Trong phong *@ case 3:
- THỜI GIAN SỬ DỤNG + THOI GIAN SU DUNG
- 02:15:00 + @_sessionElapsed.ToString(@"hh\:mm\:ss")
- Bắt đầu: 19:30 • Dự kiến: 22:00 + Bat dau: @(_sessionStart?.ToString("HH:mm") ?? "--:--") • + Du kien: @((_sessionStart ?? DateTime.Now).AddHours(_estimatedHours).ToString("HH:mm"))
-
Đơn F&B hiện tại
+
Don F&B hien tai
- Số món - 6 món + So mon + @_fnbItemCount mon
- Tổng F&B - @FormatPrice(830_000) + Tong F&B + @FormatPrice(_fnbTotal)
break; - @* EN: Step 5 — Close room / VI: Bước 5 — Đóng phòng *@ + @* EN: Step 5 -- Close room / VI: Buoc 5 -- Dong phong *@ case 4:
- Kết thúc phiên + Ket thuc phien
- Thời gian sử dụng - 2 giờ 30 phút + Thoi gian su dung + @FormatDuration(_sessionElapsed)
- Tiền phòng - @FormatPrice(500_000) + Tien phong + @FormatPrice(RoomCost)
- Tiền F&B - @FormatPrice(830_000) + Tien F&B + @FormatPrice(_fnbTotal)
- Tổng cộng - @FormatPrice(1_330_000) + Tong cong + @FormatPrice(RoomCost + _fnbTotal)
break; - @* EN: Step 6 — Payment / VI: Bước 6 — Thanh toán *@ + @* EN: Step 6 -- Payment / VI: Buoc 6 -- Thanh toan *@ case 5:
- TỔNG THANH TOÁN + TONG THANH TOAN
- @FormatPrice(1_330_000) + @FormatPrice(RoomCost + _fnbTotal)
- Phòng VIP 2 • 2h30 • 6 món F&B + Phong @(_room?.TableNumber ?? "---") • @FormatDuration(_sessionElapsed) • @_fnbItemCount mon F&B
break; }
- @* ═══ NAVIGATION BUTTONS / NÚT ĐIỀU HƯỚNG ═══ *@ + @* === NAVIGATION BUTTONS / NUT DIEU HUONG === *@
+ }
@code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + // EN: Room ID from route parameter / VI: ID phong tu route parameter + [Parameter] public Guid RoomId { get; set; } - // EN: Active step index / VI: Chỉ số bước hiện tại + // EN: Loading state / VI: Trang thai tai + private bool _isLoading = true; + private bool _loadError; + + // EN: Active step index / VI: Chi so buoc hien tai private int _activeStep; - private int _guestCount = 8; - private string _memberSearch = "0901234567"; + private int _guestCount = 2; + private string _memberSearch = ""; + private int _estimatedHours = 2; - // EN: Journey steps / VI: Các bước hành trình + // EN: Room data from API / VI: Du lieu phong tu API + private WebClientTpos.Client.Services.PosDataService.TableInfo? _room; + private DateTime? _sessionStart; + private decimal _hourlyRate; + + // EN: F&B order data / VI: Du lieu don F&B + private decimal _fnbTotal; + private int _fnbItemCount; + + // EN: Session timer / VI: Dong ho phien + private TimeSpan _sessionElapsed = TimeSpan.Zero; + private Timer? _sessionTimer; + + // EN: Room cost based on elapsed time / VI: Chi phi phong theo thoi gian su dung + private decimal RoomCost + { + get + { + if (_hourlyRate <= 0) return 0; + var hours = (decimal)Math.Ceiling(_sessionElapsed.TotalHours); + if (hours < 1) hours = 1; + return _hourlyRate * hours; + } + } + + // EN: Journey steps / VI: Cac buoc hanh trinh private readonly StepInfo[] _steps = { - new("Đón khách", "users"), - new("Chọn phòng", "door-open"), - new("Mở phòng", "play"), - new("Trong phòng", "music"), - new("Đóng phòng", "lock"), - new("Thanh toán", "credit-card"), + new("Don khach", "users"), + new("Chon phong", "door-open"), + new("Mo phong", "play"), + new("Trong phong", "music"), + new("Dong phong", "lock"), + new("Thanh toan", "credit-card"), }; + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + try + { + var tables = await DataService.GetTablesAsync(ShopId); + + // EN: Find room by RoomId, or use first room if no RoomId provided + // VI: Tim phong theo RoomId, hoac dung phong dau tien neu khong co RoomId + if (RoomId != Guid.Empty) + { + _room = tables.FirstOrDefault(t => t.Id == RoomId); + } + _room ??= tables.FirstOrDefault(); + + if (_room == null) + { + _loadError = true; + return; + } + + _hourlyRate = _room.HourlyRate > 0 ? _room.HourlyRate : 100_000; + _guestCount = _room.GuestCount ?? 2; + _sessionStart = _room.StartedAt; + + // EN: Load F&B orders for this room / VI: Tai don F&B cho phong nay + var orders = await DataService.GetActiveTableOrdersAsync(ShopId); + var roomOrders = orders.Where(o => o.TableId == _room.Id).ToList(); + _fnbTotal = roomOrders.Sum(o => o.TotalAmount); + _fnbItemCount = roomOrders.Sum(o => o.Items.Count); + + // EN: Auto-detect step based on room status / VI: Tu dong phat hien buoc theo trang thai phong + _activeStep = _room.Status switch + { + "occupied" => 3, // In room + "cleaning" => 4, // Close room + "reserved" => 1, // Room selection + _ => 0 // Reception (available) + }; + + // EN: Start session timer if room is occupied / VI: Bat dong ho neu phong dang su dung + if (_sessionStart.HasValue && _room.Status == "occupied") + { + _sessionElapsed = DateTime.Now - _sessionStart.Value; + _sessionTimer = new Timer(_ => + { + _sessionElapsed = DateTime.Now - _sessionStart!.Value; + InvokeAsync(StateHasChanged); + }, null, TimeSpan.Zero, TimeSpan.FromSeconds(1)); + } + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + } + } + + private void HandleNextStep() + { + if (_activeStep == _steps.Length - 1) + { + // EN: Final step - complete / VI: Buoc cuoi - hoan tat + NavigateTo("karaoke"); + } + else + { + _activeStep = Math.Min(_steps.Length - 1, _activeStep + 1); + } + } + + private void HandlePayment() + { + // EN: Navigate to payment flow / VI: Dieu huong den thanh toan + if (_room?.SessionId.HasValue == true) + { + NavigateTo($"karaoke/payment/{_room.SessionId}"); + } + else + { + NavigateTo("karaoke"); + } + } + + private static string FormatDuration(TimeSpan ts) + { + if (ts.TotalHours >= 1) + return $"{(int)ts.TotalHours} gio {ts.Minutes} phut"; + return $"{ts.Minutes} phut"; + } + + public void Dispose() + { + _sessionTimer?.Dispose(); + } + private record StepInfo(string Label, string Icon); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/MemberCard.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/MemberCard.razor index 1cf86c97..b193a869 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/MemberCard.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/MemberCard.razor @@ -5,6 +5,7 @@ @page "/pos/{ShopId:guid}/karaoke/member-card" @layout PosLayout @inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService
@* ═══ MEMBER LOOKUP (LEFT) / TRA CỨU THÀNH VIÊN (TRÁI) ═══ *@ @@ -25,15 +26,35 @@ border-radius:var(--pos-radius);padding:0 14px;border:1px solid var(--pos-border-default);">
+ @* EN: Search status messages / VI: Thông báo trạng thái tìm kiếm *@ + @if (!string.IsNullOrEmpty(_searchMessage)) + { +
+ @_searchMessage +
+ } + @* ═══ MEMBER CARD DISPLAY / HIỂN THỊ THẺ THÀNH VIÊN ═══ *@ @if (_activeMember is not null) { @@ -152,25 +173,22 @@
@code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB - - private string _searchTerm = "0901234567"; + private string _searchTerm = ""; + private bool _isSearching; + private string? _searchMessage; + private bool _searchError; private RewardInfo? _selectedReward; - // EN: Active member (demo) / VI: Thành viên hiện tại (mẫu) - private MemberInfo? _activeMember = new("Nguyễn Văn Minh", "0901234567", "Gold", - 2_450, 28, 15, "MBR-2024-0891", "15/06/2023"); + // EN: Active member from API search / VI: Thành viên hiện tại từ API tìm kiếm + private MemberInfo? _activeMember; - // EN: Visit history / VI: Lịch sử đến - private readonly List _visitHistory = new() - { - new("Phòng VIP 2", "08/02/2025", "3 giờ", 850_000, 85), - new("Phòng 201", "02/02/2025", "2 giờ", 420_000, 42), - new("Phòng VIP 1", "25/01/2025", "4 giờ", 1_200_000, 120), - new("Phòng 102", "18/01/2025", "2 giờ", 350_000, 35), - }; + // EN: Visit history — populated when member is found + // VI: Lịch sử đến — được điền khi tìm thấy thành viên + // TODO: Wire to member visit history API when available + private List _visitHistory = new(); - // EN: Available rewards / VI: Ưu đãi khả dụng + // EN: Available rewards — static until rewards API is available + // VI: Ưu đãi khả dụng — tĩnh cho đến khi có rewards API private readonly List _rewards = new() { new("RW1", "Giảm 20% phòng", "-20%", "Áp dụng cho mọi loại phòng", 500), @@ -178,16 +196,105 @@ new("RW3", "Combo F&B", "-50,000₫", "Giảm 50K cho đơn F&B từ 200K", 300), }; - private static string GetTierBg(string t) => t switch + // EN: Membership level definitions for tier display / VI: Định nghĩa cấp thành viên để hiển thị hạng + private Dictionary _levelNames = new(); + + protected override async Task OnInitializedAsync() { - "Gold" => "rgba(245,158,11,.2)", "Platinum" => "rgba(168,162,158,.2)", - "Diamond" => "rgba(59,130,246,.2)", _ => "rgba(255,255,255,.1)" + await base.OnInitializedAsync(); + + try + { + // EN: Pre-load membership levels for tier name lookup / VI: Tải trước cấp thành viên + var levels = await DataService.GetMembershipLevelsAsync(); + _levelNames = levels.ToDictionary(l => l.LevelNumber, l => l.Name); + } + catch { /* EN: Non-critical, tier names will fallback / VI: Không quan trọng, tên hạng sẽ dùng giá trị mặc định */ } + } + + private async Task HandleSearchKeyDown(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e) + { + if (e.Key == "Enter") + await SearchMember(); + } + + /// + /// EN: Search for member via API using phone or card ID. + /// VI: Tìm kiếm thành viên qua API theo SĐT hoặc mã thẻ. + /// + private async Task SearchMember() + { + if (string.IsNullOrWhiteSpace(_searchTerm) || _isSearching) return; + + _isSearching = true; + _searchMessage = null; + _activeMember = null; + _visitHistory = new(); + _selectedReward = null; + StateHasChanged(); + + try + { + var members = await DataService.GetMembersAsync(_searchTerm); + + if (members.Any()) + { + var m = members.First(); + var tierName = _levelNames.GetValueOrDefault(m.CurrentLevel) ?? m.LevelName ?? $"Level {m.CurrentLevel}"; + + // EN: Calculate discount based on tier level / VI: Tính giảm giá dựa trên cấp hạng + var discount = m.CurrentLevel switch + { + >= 4 => 20, // Diamond+ + 3 => 15, // Gold + 2 => 10, // Silver + _ => 5 // Bronze/Basic + }; + + _activeMember = new MemberInfo( + m.DisplayName ?? $"Thành viên #{m.Id.ToString()[..8]}", + m.Phone ?? "N/A", + tierName, + m.CurrentExp, + m.TotalExpEarned / 100, // EN: Approximate visit count / VI: Ước lượng số lần đến + discount, + $"MBR-{m.Id.ToString()[..8].ToUpper()}", + m.CreatedAt.ToString("dd/MM/yyyy") + ); + + _searchMessage = null; + } + else + { + _searchMessage = "Không tìm thấy thành viên. Vui lòng kiểm tra lại SĐT hoặc mã thẻ."; + _searchError = true; + } + } + catch (Exception ex) + { + _searchMessage = $"Lỗi tìm kiếm: {ex.Message}"; + _searchError = true; + } + finally + { + _isSearching = false; + } + } + + private static string GetTierBg(string t) => t.ToLower() switch + { + var s when s.Contains("gold") => "rgba(245,158,11,.2)", + var s when s.Contains("platinum") || s.Contains("silver") => "rgba(168,162,158,.2)", + var s when s.Contains("diamond") => "rgba(59,130,246,.2)", + _ => "rgba(255,255,255,.1)" }; - private static string GetTierFg(string t) => t switch + private static string GetTierFg(string t) => t.ToLower() switch { - "Gold" => "#F59E0B", "Platinum" => "#A8A29E", - "Diamond" => "#3B82F6", _ => "#ADADB0" + var s when s.Contains("gold") => "#F59E0B", + var s when s.Contains("platinum") || s.Contains("silver") => "#A8A29E", + var s when s.Contains("diamond") => "#3B82F6", + _ => "#ADADB0" }; private record MemberInfo(string Name, string Phone, string Tier, int Points, int Visits, int Discount, string CardId, string JoinDate); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/PeakWarning.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/PeakWarning.razor index 42bbbeb4..88ba3a8c 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/PeakWarning.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/PeakWarning.razor @@ -1,13 +1,17 @@ @* EN: Karaoke Peak Warning — Peak hours pricing comparison, room type multipliers, cost estimator. - VI: Cảnh báo giờ cao điểm Karaoke — So sánh giá giờ cao điểm, hệ số phòng, ước tính chi phí. + Loads real room data from API to build pricing tiers dynamically. + VI: Canh bao gio cao diem Karaoke — So sanh gia gio cao diem, he so phong, uoc tinh chi phi. + Tai du lieu phong that tu API de xay dung bang gia dong. *@ @page "/pos/{ShopId:guid}/karaoke/peak-warning" @layout PosLayout @inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService +@implements IDisposable
- @* ═══ HEADER / TIÊU ĐỀ ═══ *@ + @* === HEADER / TIEU DE === *@
+ @if (_isLoading) + { +
+ Dang tai... +
+ } + else if (_loadError) + { +
+ Khong the tai du lieu +
+ } + else + {
- @* ═══ CURRENT TIME / THỜI GIAN HIỆN TẠI ═══ *@ + @* === CURRENT TIME / THOI GIAN HIEN TAI === *@
- KHUNG GIỜ HIỆN TẠI + KHUNG GIO HIEN TAI
- 20:30 — Thứ 7 + @_currentTime.ToString("HH:mm") — @GetDayOfWeekVi(_currentTime.DayOfWeek)
- Đang áp dụng giá cuối tuần + Dang ap dung gia @(ActiveRate?.Label ?? "thong thuong")
- @* ═══ PRICING TABLE / BẢNG GIÁ ═══ *@ + @* === PRICING TABLE / BANG GIA === *@
-
Bảng giá theo khung giờ (Standard)
+
Bang gia theo khung gio (Standard)
@foreach (var rate in _pricingRates) { @@ -54,118 +72,204 @@
- @FormatPrice(rate.Price)/giờ + @FormatPrice(rate.Price)/gio
x@rate.Multiplier.ToString("0.0")
@if (rate.IsActive) { Hiện tại + background:rgba(245,158,11,.2);color:var(--pos-warning);">Hien tai }
}
- @* ═══ ROOM TYPE SELECTOR / CHỌN LOẠI PHÒNG ═══ *@ + @* === ROOM TYPE SELECTOR / CHON LOAI PHONG === *@
-
Loại phòng
+
Loai phong
@foreach (var room in _roomTypes) { }
- @* ═══ COST ESTIMATOR / ƯỚC TÍNH CHI PHÍ ═══ *@ + @* === COST ESTIMATOR / UOC TINH CHI PHI === *@
-
Ước tính chi phí
+
Uoc tinh chi phi
- Số giờ: + So gio: + @onclick="() => _estimateHours = Math.Max(1, _estimateHours - 1)">- @_estimateHours - giờ + gio
- Giá hiện tại (@_selectedRoomType.Name) - @FormatPrice(CurrentRatePrice)/giờ + Gia hien tai (@(_selectedRoomType?.Name ?? "Standard")) + @FormatPrice(CurrentRatePrice)/gio
- Số giờ - @_estimateHours giờ + So gio + @_estimateHours gio
- Tổng ước tính + Tong uoc tinh @FormatPrice(CurrentRatePrice * _estimateHours)
- @* ═══ CONFIRM BUTTON / NÚT XÁC NHẬN ═══ *@ + @* === CONFIRM BUTTON / NUT XAC NHAN === *@
+ }
@code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + // EN: Loading state / VI: Trang thai tai + private bool _isLoading = true; + private bool _loadError; - // EN: Estimate hours / VI: Số giờ ước tính + // EN: Current time, updated every minute / VI: Thoi gian hien tai, cap nhat moi phut + private DateTime _currentTime = DateTime.Now; + private Timer? _timer; + + // EN: Estimate hours / VI: So gio uoc tinh private int _estimateHours = 2; - // EN: Pricing rates / VI: Bảng giá - private readonly List _pricingRates = new() - { - new("Giờ thường", "T2–T5, 10:00–17:00", 100_000, 1.0m, false), - new("Giờ cao điểm", "T2–T5, 17:00–23:00", 150_000, 1.5m, false), - new("Cuối tuần", "T6–CN", 180_000, 1.8m, true), - new("Lễ/Tết", "Ngày lễ, Tết", 250_000, 2.5m, false), - }; + // EN: Pricing rates built from room data / VI: Bang gia xay dung tu du lieu phong + private List _pricingRates = new(); - // EN: Room types / VI: Loại phòng - private readonly RoomType[] _roomTypes = - { - new("Standard", 1.0m), - new("Deluxe", 1.5m), - new("VIP", 2.0m), - }; + // EN: Room types grouped from API data / VI: Loai phong nhom tu du lieu API + private List _roomTypes = new(); + private RoomTypeInfo? _selectedRoomType; - private RoomType _selectedRoomType = null!; + // EN: Active pricing rate based on current hour / VI: Muc gia dang ap dung theo gio hien tai + private PricingRate? ActiveRate => _pricingRates.FirstOrDefault(r => r.IsActive); - protected override void OnInitialized() - { - _selectedRoomType = _roomTypes[0]; - } - - // EN: Current active rate price adjusted for room type / VI: Giá hiện tại theo loại phòng + // EN: Current rate price adjusted for room type / VI: Gia hien tai theo loai phong private decimal CurrentRatePrice { get { - var activeRate = _pricingRates.FirstOrDefault(r => r.IsActive) ?? _pricingRates[0]; - return activeRate.Price * _selectedRoomType.Multiplier; + var activeRate = ActiveRate ?? _pricingRates.FirstOrDefault(); + if (activeRate == null || _selectedRoomType == null) return 0; + return _selectedRoomType.BaseRate * activeRate.Multiplier; } } + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + try + { + var tables = await DataService.GetTablesAsync(ShopId); + + // EN: Group rooms by zone/type to derive pricing tiers + // VI: Nhom phong theo khu/loai de xay dung muc gia + var groups = tables + .GroupBy(t => t.Zone ?? "Standard") + .Select(g => new RoomTypeInfo( + g.Key, + g.Average(t => t.HourlyRate) > 0 ? Math.Round(g.Average(t => t.HourlyRate)) : 100_000m + )) + .OrderBy(r => r.BaseRate) + .ToList(); + + _roomTypes = groups.Any() ? groups : new List + { + new("Standard", 100_000), + new("Deluxe", 150_000), + new("VIP", 200_000), + }; + + _selectedRoomType = _roomTypes.First(); + + // EN: Build pricing rates based on time-of-day with base rate from cheapest room type + // VI: Xay dung bang gia theo khung gio voi gia co ban tu loai phong re nhat + var baseRate = _roomTypes.Min(r => r.BaseRate); + if (baseRate <= 0) baseRate = 100_000; + + BuildPricingRates(baseRate); + + // EN: Start clock timer / VI: Bat dau dong ho + _timer = new Timer(_ => + { + _currentTime = DateTime.Now; + BuildPricingRates(baseRate); + InvokeAsync(StateHasChanged); + }, null, TimeSpan.FromSeconds(60 - DateTime.Now.Second), TimeSpan.FromMinutes(1)); + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + } + } + + private void BuildPricingRates(decimal baseRate) + { + var hour = _currentTime.Hour; + var dow = _currentTime.DayOfWeek; + var isWeekend = dow == DayOfWeek.Saturday || dow == DayOfWeek.Sunday; + + _pricingRates = new List + { + new("Gio thuong", "T2-T5, 10:00-17:00", baseRate * 1.0m, 1.0m, !isWeekend && hour >= 10 && hour < 17), + new("Gio cao diem", "T2-T5, 17:00-23:00", baseRate * 1.5m, 1.5m, !isWeekend && hour >= 17 && hour < 23), + new("Cuoi tuan", "T6-CN", baseRate * 1.8m, 1.8m, isWeekend), + new("Le/Tet", "Ngay le, Tet", baseRate * 2.5m, 2.5m, false), + }; + + // EN: Fallback: if no rate is active (e.g., before 10AM on weekday), activate "Gio thuong" + // VI: Du phong: neu khong co muc gia nao active, kich hoat "Gio thuong" + if (!_pricingRates.Any(r => r.IsActive)) + { + _pricingRates[0] = _pricingRates[0] with { IsActive = true }; + } + } + + private static string GetDayOfWeekVi(DayOfWeek dow) => dow switch + { + DayOfWeek.Monday => "Thu 2", + DayOfWeek.Tuesday => "Thu 3", + DayOfWeek.Wednesday => "Thu 4", + DayOfWeek.Thursday => "Thu 5", + DayOfWeek.Friday => "Thu 6", + DayOfWeek.Saturday => "Thu 7", + DayOfWeek.Sunday => "CN", + _ => "" + }; + + public void Dispose() + { + _timer?.Dispose(); + } + private record PricingRate(string Label, string TimeRange, decimal Price, decimal Multiplier, bool IsActive); - private record RoomType(string Name, decimal Multiplier); + private record RoomTypeInfo(string Name, decimal BaseRate); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/RoomExtend.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/RoomExtend.razor index dde43418..884e6a7a 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/RoomExtend.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/RoomExtend.razor @@ -87,6 +87,16 @@
+ @* EN: Success/Error message / VI: Thông báo thành công/lỗi *@ + @if (!string.IsNullOrEmpty(_statusMessage)) + { +
+ @_statusMessage +
+ } + @* ═══ ACTION BUTTONS / NÚT HÀNH ĐỘNG ═══ *@
} @@ -125,6 +142,9 @@ private ExtendOption? _selectedOption; private int _customMinutes = 45; + private bool _isExtending; + private string? _statusMessage; + private bool _isError; protected override async Task OnInitializedAsync() { @@ -159,5 +179,47 @@ _selectedOption = new(_customMinutes, $"+{_customMinutes} phút"); } + /// + /// EN: Call API to extend the session, then navigate back on success. + /// VI: Gọi API gia hạn phiên, sau đó quay lại khi thành công. + /// + private async Task ConfirmExtend() + { + if (_selectedOption is null || _isExtending) return; + + _isExtending = true; + _statusMessage = null; + StateHasChanged(); + + try + { + var success = await DataService.ExtendSessionAsync(RoomId, _selectedOption.Minutes); + if (success) + { + _statusMessage = $"Gia hạn thành công {_selectedOption.Label}!"; + _isError = false; + StateHasChanged(); + // EN: Brief delay so user sees success message before navigating + // VI: Delay ngắn để user thấy thông báo thành công trước khi chuyển trang + await Task.Delay(800); + NavigateTo($"karaoke/room-session/{RoomId}"); + } + else + { + _statusMessage = "Không thể gia hạn phiên. Vui lòng thử lại."; + _isError = true; + } + } + catch (Exception ex) + { + _statusMessage = $"Lỗi: {ex.Message}"; + _isError = true; + } + finally + { + _isExtending = false; + } + } + private record ExtendOption(int Minutes, string Label); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/ServiceDisplay.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/ServiceDisplay.razor index 74850661..fd5f0fc3 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/ServiceDisplay.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/ServiceDisplay.razor @@ -6,6 +6,7 @@ @layout PosLayout @inherits PosBase @inject WebClientTpos.Client.Services.PosDataService DataService +@implements IDisposable
@* ═══ HEADER / TIÊU ĐỀ ═══ *@ @@ -112,7 +113,7 @@ { } @@ -120,7 +121,7 @@ { } @@ -148,34 +149,89 @@ private string _activeFilter = "Tất cả"; private readonly string[] _filters = { "Tất cả", "Chờ xử lý", "Đang xử lý", "Hoàn thành" }; - // EN: Room names loaded from DB for display / VI: Tên phòng tải từ DB để hiển thị - private List _roomNames = new(); + // EN: Room lookup map (tableId → roomName) loaded from DB / VI: Map tra cứu phòng (tableId → tên phòng) từ DB + private Dictionary _roomLookup = new(); - // EN: Service requests / VI: Yêu cầu phục vụ - private readonly List _requests = new() - { - new("P.102","F&B","Gọi thêm 2 két bia Tiger","20:15","5 phút trước","pending"), - new("VIP 2","Kỹ thuật","Micro phòng bị rè, cần thay","20:12","8 phút trước","pending"), - new("P.201","F&B","Trái cây dĩa lớn x2","20:10","10 phút trước","processing"), - new("P.103","Dọn dẹp","Dọn ly và khăn giấy","20:05","15 phút trước","processing"), - new("VIP 1","F&B","Nước suối 10 chai","19:55","25 phút trước","completed"), - new("P.104","Kỹ thuật","Điều chỉnh âm thanh","19:50","30 phút trước","completed"), - new("P.202","F&B","Combo mồi nhậu","19:45","35 phút trước","completed"), - new("VIP 3","Dọn dẹp","Thay khăn lạnh","19:40","40 phút trước","completed"), - }; + // EN: Service requests mapped from kitchen tickets / VI: Yêu cầu phục vụ map từ kitchen tickets + private List _requests = new(); + + // EN: Auto-refresh timer (15s) / VI: Timer tự động refresh (15 giây) + private Timer? _refreshTimer; protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); + await LoadDataAsync(); + // EN: Auto-refresh every 15 seconds / VI: Tự động refresh mỗi 15 giây + _refreshTimer = new Timer(async _ => + { + await LoadDataAsync(); + await InvokeAsync(StateHasChanged); + }, null, TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(15)); + } + + private async Task LoadDataAsync() + { try { - var tables = await DataService.GetTablesAsync(ShopId); - _roomNames = tables.Select(t => t.TableNumber).ToList(); + // EN: Load tables for room name lookup and all ticket statuses in parallel + // VI: Tải danh sách phòng và tất cả trạng thái ticket song song + var tablesTask = DataService.GetTablesAsync(ShopId); + var pendingTask = DataService.GetKitchenTicketsAsync(ShopId, "Pending"); + var processingTask = DataService.GetKitchenTicketsAsync(ShopId, "InProgress"); + var completedTask = DataService.GetKitchenTicketsAsync(ShopId, "Completed"); + + await Task.WhenAll(tablesTask, pendingTask, processingTask, completedTask); + + var tables = await tablesTask; + _roomLookup = tables.ToDictionary(t => t.Id, t => t.TableNumber); + + var allTickets = new List(); + allTickets.AddRange(await pendingTask); + allTickets.AddRange(await processingTask); + allTickets.AddRange(await completedTask); + + // EN: Map kitchen tickets to ServiceRequest display model + // VI: Map kitchen tickets sang model hiển thị ServiceRequest + _requests = allTickets.OrderByDescending(t => t.CreatedAt).Select(t => + { + var roomName = _roomLookup.TryGetValue(t.SessionId, out var name) ? name : t.SessionId.ToString()[..8]; + var elapsed = DateTime.UtcNow - t.CreatedAt; + var elapsedStr = elapsed.TotalMinutes < 60 + ? $"{(int)elapsed.TotalMinutes} phút trước" + : $"{(int)elapsed.TotalHours}h{elapsed.Minutes:D2} trước"; + + var status = t.Status switch + { + "Pending" => "pending", + "InProgress" => "processing", + "Completed" => "completed", + _ => "pending" + }; + + var type = t.Station switch + { + "Kitchen" or "Bar" => "F&B", + "Technical" => "Kỹ thuật", + "Cleaning" => "Dọn dẹp", + _ => "F&B" + }; + + return new ServiceRequest( + t.Id, + roomName, + type, + t.ItemName ?? "Yêu cầu phục vụ", + t.CreatedAt.ToLocalTime().ToString("HH:mm"), + elapsedStr, + status + ); + }).ToList(); } catch { - _loadError = true; + _loadError = _requests.Count == 0; } finally { @@ -183,6 +239,28 @@ } } + /// + /// EN: Update ticket status via API when user clicks Nhận/Xong buttons. + /// VI: Cập nhật trạng thái ticket qua API khi user nhấn nút Nhận/Xong. + /// + private async Task UpdateRequestStatus(ServiceRequest req, string newStatus) + { + var apiStatus = newStatus switch + { + "processing" => "InProgress", + "completed" => "Completed", + _ => newStatus + }; + + var success = await DataService.UpdateTicketStatusAsync(req.TicketId, + new WebClientTpos.Client.Services.PosDataService.UpdateTicketStatusRequest(apiStatus)); + + if (success) + { + req.Status = newStatus; + } + } + private IEnumerable FilteredRequests => _activeFilter switch { "Chờ xử lý" => _requests.Where(r => r.Status == "pending"), @@ -212,8 +290,14 @@ "F&B" => "#FF5C00", "Kỹ thuật" => "#3B82F6", "Dọn dẹp" => "#F59E0B", _ => "var(--pos-border-default)" }; - private class ServiceRequest(string room, string type, string description, string time, string elapsed, string status) + public void Dispose() { + _refreshTimer?.Dispose(); + } + + private class ServiceRequest(Guid ticketId, string room, string type, string description, string time, string elapsed, string status) + { + public Guid TicketId { get; set; } = ticketId; public string Room { get; set; } = room; public string Type { get; set; } = type; public string Description { get; set; } = description; diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/RestaurantMobile.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/RestaurantMobile.razor index 31953449..83b8463d 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/RestaurantMobile.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/RestaurantMobile.razor @@ -41,12 +41,14 @@
@foreach (var t in FilteredTables) { + var effectiveStatus = GetEffectiveStatus(t); + var hasReservation = GetTableReservation(t) is not null;
+ border-left:4px solid @StatusColor(effectiveStatus);"> @* EN: Table number badge / VI: Badge số bàn *@ -
@t.Number
@@ -57,13 +59,20 @@
-
- @StatusLabel(t.Status) +
+ @StatusLabel(effectiveStatus)
- @if (t.Status == "occupied") + @if (HasPendingOrder(t)) {
- @FormatPrice(t.Amount) + @FormatPrice(GetTableOrderTotal(t)) +
+ } + @if (hasReservation) + { + var res = GetTableReservation(t)!; +
+ Đặt @res.ReservationTime.ToString("HH:mm")
}
@@ -76,15 +85,15 @@
-
@_tables.Count(t => t.Status == "available")
+
@_tables.Count(t => GetEffectiveStatus(t) == "available")
Trống
-
@_tables.Count(t => t.Status == "occupied")
+
@_tables.Count(t => GetEffectiveStatus(t) == "occupied")
Đang phục vụ
-
@_tables.Count(t => t.Status == "reserved")
+
@_tables.Count(t => GetEffectiveStatus(t) == "reserved")
Đã đặt
@@ -104,6 +113,12 @@ // EN: Table data from API / VI: Dữ liệu bàn từ API private List _tables = new(); + // EN: Active table orders from DB / VI: Đơn hàng active từ DB + private readonly Dictionary> _tableOrderMap = new(); + + // EN: Reservations / VI: Đặt bàn + private List _reservations = new(); + private IEnumerable FilteredTables => _activeSection == "Tất cả" ? _tables : _tables.Where(t => t.Section == _activeSection); @@ -113,17 +128,41 @@ try { - var apiTables = await DataService.GetTablesAsync(ShopId); + var tablesTask = DataService.GetTablesAsync(ShopId); + var ordersTask = DataService.GetActiveTableOrdersAsync(ShopId); + var reservationsTask = DataService.GetReservationsAsync(ShopId, DateTime.Today.ToString("yyyy-MM-dd")); + await Task.WhenAll(tablesTask, ordersTask, reservationsTask); + + var apiTables = await tablesTask; + var activeOrders = await ordersTask; + _reservations = await reservationsTask; _tables = apiTables.Select(t => new MobileTable( + t.Id.ToString(), int.TryParse(t.TableNumber, out var num) ? num : 0, $"Bàn {t.TableNumber}", t.Capacity, t.Status ?? "available", - t.Zone ?? "Trong nhà", - 0 + t.Zone ?? "Trong nhà" )).ToList(); + // EN: Build order map per table / VI: Tạo map đơn theo bàn + foreach (var order in activeOrders) + { + if (order.TableId == null) continue; + var tableKey = order.TableId.Value.ToString(); + if (!_tableOrderMap.ContainsKey(tableKey)) + _tableOrderMap[tableKey] = new(); + foreach (var item in order.Items) + { + var existing = _tableOrderMap[tableKey].FirstOrDefault(i => i.ProductId == item.ProductId); + if (existing != null) + existing.Qty += item.Quantity; + else + _tableOrderMap[tableKey].Add(new OrderItem(item.ProductId, item.ProductName, item.UnitPrice, item.Quantity)); + } + } + var zones = _tables.Select(t => t.Section).Distinct().ToList(); _sections = new[] { "Tất cả" }.Concat(zones).ToArray(); } @@ -139,6 +178,31 @@ private void OpenTable(MobileTable t) => NavigateTo("restaurant/waiter-pad"); + // ═══ TABLE ORDER HELPERS (synced with Desktop) ═══ + private bool HasPendingOrder(MobileTable table) => + _tableOrderMap.ContainsKey(table.Id) && _tableOrderMap[table.Id].Any(); + + private decimal GetTableOrderTotal(MobileTable table) => + _tableOrderMap.TryGetValue(table.Id, out var items) ? items.Sum(i => i.Price * i.Qty) : 0; + + // ═══ RESERVATION HELPERS (synced with Desktop) ═══ + private WebClientTpos.Client.Services.PosDataService.ReservationInfo? GetTableReservation(MobileTable table) + { + if (!Guid.TryParse(table.Id, out var tableGuid)) return null; + var now = DateTime.Now; + return _reservations.FirstOrDefault(r => + r.TableId == tableGuid && + r.Status is "confirmed" or "pending" && + Math.Abs((r.ReservationTime - now).TotalHours) < 2); + } + + private string GetEffectiveStatus(MobileTable table) + { + if (HasPendingOrder(table)) return "occupied"; + if (GetTableReservation(table) != null) return "reserved"; + return table.Status; + } + private static string StatusColor(string s) => s switch { "available" => "var(--pos-success)", "occupied" => "var(--pos-orange-primary)", @@ -156,5 +220,12 @@ "available" => "Trống", "occupied" => "Đang phục vụ", "reserved" => "Đã đặt", _ => s }; - private record MobileTable(int Number, string Name, int Seats, string Status, string Section, decimal Amount); + private record MobileTable(string Id, int Number, string Name, int Seats, string Status, string Section); + private class OrderItem(Guid productId, string name, decimal price, int qty) + { + public Guid ProductId { get; } = productId; + public string Name { get; } = name; + public decimal Price { get; } = price; + public int Qty { get; set; } = qty; + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/RestaurantTablet.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/RestaurantTablet.razor index ecc762a9..39a77d73 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/RestaurantTablet.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/RestaurantTablet.razor @@ -36,15 +36,30 @@
@foreach (var t in FilteredTables) { -
SelectTable(t)" + style="background:@StatusBg(effectiveStatus);border-radius:var(--pos-radius);padding:20px; text-align:center;cursor:pointer;min-height:100px;display:flex;flex-direction:column; - align-items:center;justify-content:center;gap:6px; + align-items:center;justify-content:center;gap:6px;position:relative; border:3px solid @(_selected?.Id == t.Id ? "var(--pos-orange-primary)" : "transparent"); transition:all .2s ease;"> @t.Name @t.Seats chỗ - @StatusLabel(t.Status) + @StatusLabel(effectiveStatus) + @if (hasReservation) + { + var res = GetTableReservation(t)!; +
+ @res.ReservationTime.ToString("HH:mm") +
+ } + @if (HasPendingOrder(t)) + { +
+ @FormatPrice(GetTableOrderTotal(t)) +
+ }
}
@@ -60,7 +75,7 @@
- @if (_selected.Status == "occupied") + @if (HasPendingOrder(_selected)) { @foreach (var item in _items) { @@ -84,7 +99,7 @@ - @* ═══ AUTO-FIRE RULE / QUY TẮC TỰ ĐỘNG ═══ *@ + @if (_isLoading) + { +
+ Dang tai... +
+ } + else if (_loadError) + { +
+ Khong the tai du lieu + +
+ } + else + { + @* ═══ AUTO-FIRE RULE / QUY TAC TU DONG ═══ *@
- Tự động: Phục vụ mỗi món cách 15 phút + Tu dong: Phuc vu moi mon cach 15 phut
- @* ═══ COURSE LIST / DANH SÁCH COURSE ═══ *@ + @* ═══ COURSE LIST / DANH SACH COURSE ═══ *@
@foreach (var course in _courses) @@ -40,7 +63,7 @@
- @* EN: Course header / VI: Tiêu đề course *@ + @* EN: Course header / VI: Tieu de course *@
@@ -58,10 +81,10 @@
- @* EN: Timeline bar / VI: Thanh thời gian *@ + @* EN: Timeline bar / VI: Thanh thoi gian *@
- @course.EstTime phút + @course.EstTime phut @course.Progress%
@@ -70,21 +93,39 @@
- @* EN: Fire button / VI: Nút kích hoạt *@ + @* EN: Fire button / VI: Nut kich hoat *@ @if (course.Status == "queued") { } else if (course.Status == "cooking") { }
@@ -92,38 +133,238 @@
- @* ═══ FOOTER STATS / THỐNG KÊ ═══ *@ + @* ═══ FOOTER STATS / THONG KE ═══ *@
- Đã phục vụ: @_courses.Count(c => c.Status == "served") - Đang nấu: @_courses.Count(c => c.Status == "cooking") - Chờ: @_courses.Count(c => c.Status == "queued") - Tổng: @_courses.Count course + Da phuc vu: @_courses.Count(c => c.Status == "served") + Dang nau: @_courses.Count(c => c.Status == "cooking") + Cho: @_courses.Count(c => c.Status == "queued") + Tong: @_courses.Count course
+ }
@code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + // EN: Optional route parameter for specific order / VI: Tham so route tuy chon cho don cu the + [Parameter] public Guid OrderId { get; set; } - // EN: Course list / VI: Danh sách course - private readonly List _courses = new() - { - new("Khai vị", "Gỏi cuốn tôm, Chả giò chiên", "salad", 15, "served", 100), - new("Soup", "Súp cua thập cẩm", "soup", 10, "cooking", 60), - new("Món chính", "Cá kho tộ, Gà nướng mật ong", "beef", 25, "queued", 0), - new("Phụ", "Cơm chiên dương châu, Rau muống xào", "carrot", 15, "queued", 0), - new("Tráng miệng", "Chè thái, Bánh flan", "ice-cream-cone", 10, "queued", 0), - }; + // EN: Loading & error states / VI: Trang thai tai & loi + private bool _isLoading = true; + private bool _loadError; - private void FireCourse(CourseInfo course) + // EN: Auto-refresh timer (5 seconds) / VI: Timer tu dong refresh (5 giay) + private Timer? _refreshTimer; + + // EN: Header info / VI: Thong tin tieu de + private string _headerTitle = "Quan ly course"; + private DateTime? _startTime; + + // EN: Course list from API / VI: Danh sach course tu API + private readonly List _courses = new(); + + // EN: Ticket ID mapping for API calls / VI: Anh xa ID ticket cho API call + private readonly Dictionary> _courseTicketMap = new(); + + protected override async Task OnInitializedAsync() { - course.Status = "cooking"; - course.Progress = 20; + await base.OnInitializedAsync(); + await LoadDataAsync(); + + // EN: Start auto-refresh timer / VI: Bat dau timer tu dong refresh + _refreshTimer = new Timer(async _ => + { + await InvokeAsync(async () => + { + await LoadDataAsync(silent: true); + StateHasChanged(); + }); + }, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); } - private void ServeCourse(CourseInfo course) + private async Task LoadDataAsync(bool silent = false) { - course.Status = "served"; - course.Progress = 100; + if (!silent) { _isLoading = true; _loadError = false; } + + try + { + // EN: Fetch all kitchen tickets / VI: Lay tat ca kitchen tickets + var pendingTask = DataService.GetKitchenTicketsAsync(ShopId, "Pending"); + var inProgressTask = DataService.GetKitchenTicketsAsync(ShopId, "InProgress"); + var completedTask = DataService.GetKitchenTicketsAsync(ShopId, "Completed"); + await Task.WhenAll(pendingTask, inProgressTask, completedTask); + + var allTickets = (await pendingTask).Concat(await inProgressTask).Concat(await completedTask).ToList(); + + // EN: Filter by order if OrderId provided / VI: Loc theo don neu co OrderId + if (OrderId != Guid.Empty) + { + // EN: Try to match tickets by loading order detail + // VI: Thu khop tickets bang cach tai chi tiet don + var orderDetail = await DataService.GetOrderDetailAsync(OrderId, ShopId); + if (orderDetail?.Items != null) + { + var orderItemIds = orderDetail.Items.Select(i => i.Id).ToHashSet(); + var orderItemNames = orderDetail.Items.Select(i => i.ProductName).Where(n => n != null).ToHashSet(); + allTickets = allTickets.Where(t => orderItemIds.Contains(t.OrderItemId) || orderItemNames.Contains(t.ItemName)).ToList(); + _headerTitle = $"Don #{OrderId.ToString()[..6].ToUpper()} — {orderDetail.Items.Count} mon"; + } + } + else + { + _headerTitle = $"Tat ca — {allTickets.Count} ticket"; + } + + if (allTickets.Any()) + { + _startTime = allTickets.Min(t => t.CreatedAt); + } + + // EN: Group tickets by station/category, or by creation time if no station + // VI: Nhom tickets theo station/danh muc, hoac theo thoi gian tao neu khong co station + BuildCourses(allTickets); + } + catch + { + if (!silent) _loadError = true; + } + finally + { + if (!silent) _isLoading = false; + } + } + + private void BuildCourses(List tickets) + { + if (!tickets.Any()) return; + + // EN: Group by station if available, otherwise group by time batches (15 min intervals) + // VI: Nhom theo station neu co, neu khong nhom theo dot thoi gian (moi 15 phut) + var groups = tickets + .GroupBy(t => !string.IsNullOrEmpty(t.Station) ? t.Station : $"Course {((t.CreatedAt - tickets.Min(x => x.CreatedAt)).TotalMinutes / 15) + 1:F0}") + .ToList(); + + _courses.Clear(); + _courseTicketMap.Clear(); + + var icons = new[] { "salad", "soup", "beef", "carrot", "ice-cream-cone", "utensils", "fish", "egg" }; + int iconIdx = 0; + + foreach (var group in groups) + { + var groupTickets = group.ToList(); + var itemNames = string.Join(", ", groupTickets.Select(t => t.ItemName).Distinct().Take(3)); + if (groupTickets.Count > 3) itemNames += $" (+{groupTickets.Count - 3})"; + + // EN: Determine course status from ticket statuses + // VI: Xac dinh trang thai course tu trang thai ticket + string status; + int progress; + if (groupTickets.All(t => t.Status == "Completed")) + { + status = "served"; + progress = 100; + } + else if (groupTickets.Any(t => t.Status is "InProgress" or "Completed")) + { + status = "cooking"; + var completedCount = groupTickets.Count(t => t.Status == "Completed"); + progress = groupTickets.Count > 0 ? (completedCount * 100 / groupTickets.Count) : 0; + progress = Math.Max(progress, 20); // EN: At least 20% if cooking / VI: It nhat 20% neu dang nau + } + else + { + status = "queued"; + progress = 0; + } + + var courseKey = group.Key; + var estTime = groupTickets.Count * 5; // EN: Estimate 5 min per item / VI: Uoc tinh 5 phut moi mon + + // EN: Preserve existing course if it exists (to keep IsUpdating state) + // VI: Giu course hien tai neu ton tai (de giu trang thai IsUpdating) + var existing = _courses.FirstOrDefault(c => c.Key == courseKey); + if (existing != null) + { + existing.Status = status; + existing.Progress = progress; + existing.Items = itemNames; + } + else + { + _courses.Add(new CourseInfo(courseKey, group.Key, itemNames, icons[iconIdx % icons.Length], estTime, status, progress)); + } + + _courseTicketMap[courseKey] = groupTickets; + iconIdx++; + } + + // EN: Remove courses that no longer exist / VI: Xoa course khong con ton tai + _courses.RemoveAll(c => !_courseTicketMap.ContainsKey(c.Key)); + } + + private async void FireCourse(CourseInfo course) + { + if (!_courseTicketMap.TryGetValue(course.Key, out var tickets)) return; + + course.IsUpdating = true; + StateHasChanged(); + + try + { + // EN: Update all pending tickets in this course to InProgress + // VI: Cap nhat tat ca ticket cho trong course nay sang InProgress + var pendingTickets = tickets.Where(t => t.Status == "Pending").ToList(); + var tasks = pendingTickets.Select(t => + DataService.UpdateTicketStatusAsync(t.Id, new PosDataService.UpdateTicketStatusRequest("InProgress")) + ); + await Task.WhenAll(tasks); + + course.Status = "cooking"; + course.Progress = 20; + } + catch + { + // EN: Fallback — update locally / VI: Du phong — cap nhat cuc bo + course.Status = "cooking"; + course.Progress = 20; + } + finally + { + course.IsUpdating = false; + StateHasChanged(); + } + } + + private async void ServeCourse(CourseInfo course) + { + if (!_courseTicketMap.TryGetValue(course.Key, out var tickets)) return; + + course.IsUpdating = true; + StateHasChanged(); + + try + { + // EN: Update all tickets in this course to Completed + // VI: Cap nhat tat ca ticket trong course nay sang Completed + var nonCompletedTickets = tickets.Where(t => t.Status != "Completed").ToList(); + var tasks = nonCompletedTickets.Select(t => + DataService.UpdateTicketStatusAsync(t.Id, new PosDataService.UpdateTicketStatusRequest("Completed")) + ); + await Task.WhenAll(tasks); + + course.Status = "served"; + course.Progress = 100; + } + catch + { + // EN: Fallback — update locally / VI: Du phong — cap nhat cuc bo + course.Status = "served"; + course.Progress = 100; + } + finally + { + course.IsUpdating = false; + StateHasChanged(); + } } private static string CourseStatusColor(string s) => s switch @@ -140,16 +381,23 @@ private static string CourseStatusLabel(string s) => s switch { - "served" => "Đã phục vụ", "cooking" => "Đang nấu", "queued" => "Chờ", _ => s + "served" => "Da phuc vu", "cooking" => "Dang nau", "queued" => "Cho", _ => s }; - private class CourseInfo(string name, string items, string icon, int estTime, string status, int progress) + public void Dispose() { + _refreshTimer?.Dispose(); + } + + private class CourseInfo(string key, string name, string items, string icon, int estTime, string status, int progress) + { + public string Key { get; set; } = key; public string Name { get; set; } = name; public string Items { get; set; } = items; public string Icon { get; set; } = icon; public int EstTime { get; set; } = estTime; public string Status { get; set; } = status; public int Progress { get; set; } = progress; + public bool IsUpdating { get; set; } } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/EodReport.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/EodReport.razor index 507f0bc4..bdaa167a 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/EodReport.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/EodReport.razor @@ -5,6 +5,7 @@ @page "/pos/{ShopId:guid}/restaurant/eod-report" @layout PosLayout @inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService
@* ═══ HEADER / TIÊU ĐỀ ═══ *@ @@ -18,6 +19,20 @@
+ @if (_isLoading) + { +
+ Đang tải báo cáo... +
+ } + else if (_loadError) + { +
+ Không thể tải dữ liệu báo cáo +
+ } + else + {
@* ═══ SUMMARY CARDS / THẺ TỔNG QUAN ═══ *@
@@ -88,46 +103,101 @@
+ }
@code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + // EN: Loading state / VI: Trạng thái tải + private bool _isLoading = true; + private bool _loadError; // EN: Summary cards / VI: Thẻ tổng quan - private readonly List _summaryCards = new() - { - new("Doanh thu", "12,450,000₫", "trending-up", "var(--pos-orange-primary)", "+18% so với hôm qua"), - new("Lượt khách", "142", "users", "var(--pos-info)", "+12 so với hôm qua"), - new("Hóa đơn TB", "350,000₫", "receipt", "var(--pos-success)", "+5% so với hôm qua"), - new("Tổng Tip", "1,240,000₫", "heart", "var(--pos-warning)", "TB 35,000₫/đơn"), - new("Bàn phục vụ", "38", "layout-grid", "var(--pos-text-primary)", "12 bàn/ca"), - new("Thời gian TB", "52 phút", "clock", "var(--pos-text-secondary)", "Nhanh hơn 5 phút"), - }; + private List _summaryCards = new(); // EN: Section breakdown / VI: Phân tích khu vực - private readonly List _sectionData = new() - { - new("Trong nhà", 6_200_000, 68, 100, "var(--pos-orange-primary)"), - new("VIP", 3_800_000, 32, 61, "var(--pos-warning)"), - new("Ngoài trời", 2_450_000, 42, 39, "var(--pos-success)"), - }; + private List _sectionData = new(); // EN: Hourly data / VI: Dữ liệu theo giờ - private readonly List _hourlyData = new() - { - new("10h", 15), new("11h", 45), new("12h", 95), new("13h", 80), - new("14h", 30), new("15h", 20), new("16h", 25), new("17h", 55), - new("18h", 100), new("19h", 90), new("20h", 70), new("21h", 40), - }; + private List _hourlyData = new(); // EN: Payment methods / VI: Phương thức thanh toán - private readonly List _paymentData = new() + private List _paymentData = new(); + + protected override async Task OnInitializedAsync() { - new("Tiền mặt", "banknote", 18, 5_600_000), - new("Thẻ ngân hàng", "credit-card", 12, 4_200_000), - new("Chuyển khoản", "smartphone", 6, 1_850_000), - new("Ví điện tử", "wallet", 2, 800_000), - }; + await base.OnInitializedAsync(); + + try + { + // EN: Load dashboard data for today / VI: Tải dữ liệu dashboard cho hôm nay + var dashboard = await DataService.GetPosDashboardAsync(ShopId, "today"); + + // EN: Map dashboard response to summary cards / VI: Ánh xạ response dashboard sang thẻ tổng quan + _summaryCards = new() + { + new("Doanh thu", FormatPrice(dashboard.Revenue), "trending-up", "var(--pos-orange-primary)", $"{dashboard.OrderCount} đơn hôm nay"), + new("Số đơn", dashboard.OrderCount.ToString(), "shopping-bag", "var(--pos-info)", $"TB {FormatPrice(dashboard.AvgOrderValue)}/đơn"), + new("Hóa đơn TB", FormatPrice(dashboard.AvgOrderValue), "receipt", "var(--pos-success)", $"Tổng {dashboard.ItemsSold} món"), + new("Sản phẩm bán", dashboard.ItemsSold.ToString(), "package", "var(--pos-warning)", $"Doanh thu {FormatPrice(dashboard.Revenue)}"), + }; + + // EN: Map hourly revenue data / VI: Ánh xạ dữ liệu doanh thu theo giờ + if (dashboard.HourlyRevenue?.Any() == true) + { + var maxRevenue = dashboard.HourlyRevenue.Max(h => h.Revenue); + _hourlyData = dashboard.HourlyRevenue.Select(h => new HourData( + h.HourLabel, + maxRevenue > 0 ? (int)(h.Revenue / maxRevenue * 100) : 0 + )).ToList(); + } + + // EN: Map payment breakdown / VI: Ánh xạ phân tích PTTT + if (dashboard.PaymentBreakdown?.Any() == true) + { + static string MapIcon(string method) => method.ToLowerInvariant() switch + { + "cash" or "tiền mặt" => "banknote", + "card" or "thẻ" => "credit-card", + "transfer" or "chuyển khoản" or "bank_transfer" => "smartphone", + "qr" => "smartphone", + _ => "wallet" + }; + static string MapMethodName(string method) => method.ToLowerInvariant() switch + { + "cash" => "Tiền mặt", + "card" => "Thẻ ngân hàng", + "transfer" or "bank_transfer" => "Chuyển khoản", + "qr" => "QR Code", + _ => method + }; + _paymentData = dashboard.PaymentBreakdown.Select(p => + new PaymentReport(MapMethodName(p.Method), MapIcon(p.Method), p.Count, p.Amount)).ToList(); + } + + // EN: Section breakdown — use popular items as proxy if no section data + // VI: Phân tích khu vực — dùng sản phẩm phổ biến thay thế nếu không có dữ liệu khu vực + if (dashboard.PopularItems?.Any() == true) + { + var maxRev = dashboard.PopularItems.Max(p => p.Revenue); + var colors = new[] { "var(--pos-orange-primary)", "var(--pos-warning)", "var(--pos-success)", "var(--pos-info)", "var(--pos-danger)" }; + _sectionData = dashboard.PopularItems.Take(5).Select((p, i) => new SectionReport( + p.Name, + p.Revenue, + p.Qty, + maxRev > 0 ? (int)(p.Revenue / maxRev * 100) : 0, + colors[i % colors.Length] + )).ToList(); + } + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + } + } private record SummaryCard(string Label, string Value, string Icon, string Color, string Comparison); private record SectionReport(string Name, decimal Revenue, int Covers, int Percent, string Color); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/KitchenDisplay.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/KitchenDisplay.razor index e2b50cdc..ae18d82d 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/KitchenDisplay.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/KitchenDisplay.razor @@ -6,6 +6,7 @@ @layout PosLayout @inherits PosBase @inject WebClientTpos.Client.Services.PosDataService DataService +@implements IDisposable
@* ═══ HEADER / TIÊU ĐỀ ═══ *@ @@ -81,21 +82,21 @@ @if (status.Key == "new") { } else if (status.Key == "cooking") { } else { } @@ -114,22 +115,18 @@ // EN: Loading state / VI: Trạng thái tải private bool _isLoading = true; private bool _loadError; + private System.Threading.Timer? _refreshTimer; private readonly Dictionary _statuses = new() { ["new"] = "Mới", ["cooking"] = "Đang nấu", ["ready"] = "Sẵn sàng" }; - // EN: Demo kitchen tickets — needs kitchen_tickets API / VI: Phiếu bếp mẫu — cần API kitchen_tickets - private readonly List _tickets = new() - { - new("Bàn 2", "new", 2, new() { new(2, "Gỏi cuốn", ""), new(1, "Phở bò tái", "Ít hành") }), - new("Bàn 3", "new", 5, new() { new(1, "Cá kho tộ", ""), new(1, "Cơm tấm sườn", "Thêm nước mắm"), new(3, "Trà đá", "") }), - new("Bàn 7", "cooking", 12, new() { new(1, "Lẩu thái", "Cay ít"), new(2, "Bia Sài Gòn", "") }), - new("Bàn 11", "cooking", 18, new() { new(1, "Gà nướng mật ong", ""), new(2, "Nước mía", "") }), - new("Bàn 6", "ready", 25, new() { new(1, "Bún bò Huế", ""), new(1, "Chả giò", "") }), - new("Bàn 10", "ready", 8, new() { new(2, "Cơm tấm sườn", ""), new(1, "Cà phê sữa", "") }), - }; + // EN: Kitchen tickets from API / VI: Phiếu bếp từ API + private List _tickets = new(); + + // EN: Table lookup for mapping ticket to table name / VI: Tra cứu bàn để ánh xạ phiếu sang tên bàn + private Dictionary _tableLookup = new(); protected override async Task OnInitializedAsync() { @@ -137,9 +134,23 @@ try { - // EN: Preload tables for reference — kitchen_tickets API not yet available - // VI: Tải trước bàn để tham chiếu — API kitchen_tickets chưa có - await DataService.GetTablesAsync(ShopId); + // EN: Load tables for lookup / VI: Tải bàn để tra cứu + var tables = await DataService.GetTablesAsync(ShopId); + _tableLookup = tables.ToDictionary(t => t.Id, t => $"Bàn {t.TableNumber}"); + + // EN: Fetch tickets by status in parallel / VI: Tải phiếu theo trạng thái song song + await LoadTicketsAsync(); + + // EN: Auto-refresh every 10 seconds / VI: Tự động làm mới mỗi 10 giây + _refreshTimer = new System.Threading.Timer(async _ => + { + try + { + await LoadTicketsAsync(); + await InvokeAsync(StateHasChanged); + } + catch { /* silently ignore refresh errors */ } + }, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); } catch { @@ -151,6 +162,79 @@ } } + private async Task LoadTicketsAsync() + { + // EN: Fetch Pending, InProgress, and Completed tickets in parallel + // VI: Tải phiếu Pending, InProgress, và Completed song song + var pendingTask = DataService.GetKitchenTicketsAsync(ShopId, "Pending"); + var inProgressTask = DataService.GetKitchenTicketsAsync(ShopId, "InProgress"); + var completedTask = DataService.GetKitchenTicketsAsync(ShopId, "Completed"); + await Task.WhenAll(pendingTask, inProgressTask, completedTask); + + var pending = await pendingTask; + var inProgress = await inProgressTask; + var completed = await completedTask; + + var allTickets = new List(); + + // EN: Map API tickets to UI model, group by SessionId for multi-item tickets + // VI: Ánh xạ phiếu API sang model UI, nhóm theo SessionId cho phiếu nhiều món + foreach (var group in pending.GroupBy(t => t.SessionId)) + { + var first = group.First(); + var minutes = (int)(DateTime.UtcNow - first.CreatedAt).TotalMinutes; + var tableName = _tableLookup.GetValueOrDefault(first.SessionId, $"#{first.SessionId.ToString()[..6]}"); + allTickets.Add(new KitchenTicket( + first.Id, tableName, "new", Math.Max(0, minutes), + group.Select(t => new TicketItem(1, t.ItemName, "")).ToList())); + } + + foreach (var group in inProgress.GroupBy(t => t.SessionId)) + { + var first = group.First(); + var minutes = (int)(DateTime.UtcNow - first.CreatedAt).TotalMinutes; + var tableName = _tableLookup.GetValueOrDefault(first.SessionId, $"#{first.SessionId.ToString()[..6]}"); + allTickets.Add(new KitchenTicket( + first.Id, tableName, "cooking", Math.Max(0, minutes), + group.Select(t => new TicketItem(1, t.ItemName, "")).ToList())); + } + + foreach (var group in completed.GroupBy(t => t.SessionId)) + { + var first = group.First(); + var minutes = (int)(DateTime.UtcNow - first.CreatedAt).TotalMinutes; + var tableName = _tableLookup.GetValueOrDefault(first.SessionId, $"#{first.SessionId.ToString()[..6]}"); + allTickets.Add(new KitchenTicket( + first.Id, tableName, "ready", Math.Max(0, minutes), + group.Select(t => new TicketItem(1, t.ItemName, "")).ToList())); + } + + _tickets = allTickets; + } + + // EN: Update ticket status via API / VI: Cập nhật trạng thái phiếu qua API + private async Task UpdateStatus(KitchenTicket ticket, string newStatus) + { + try + { + var apiStatus = newStatus switch + { + "cooking" => "InProgress", + "ready" => "Completed", + "served" => "Completed", + _ => newStatus + }; + var success = await DataService.UpdateTicketStatusAsync(ticket.Id, + new WebClientTpos.Client.Services.PosDataService.UpdateTicketStatusRequest(apiStatus)); + if (success) + { + // EN: Reload tickets after status change / VI: Tải lại phiếu sau khi đổi trạng thái + await LoadTicketsAsync(); + } + } + catch { /* silently ignore — next refresh will correct */ } + } + private static string ColumnBg(string s) => s switch { "new" => "rgba(239,68,68,.15)", "cooking" => "rgba(245,158,11,.15)", @@ -166,8 +250,9 @@ private static string TimerBg(int mins) => mins > 15 ? "rgba(239,68,68,.2)" : mins > 10 ? "rgba(245,158,11,.2)" : "rgba(34,197,94,.2)"; private static string TimerColor(int mins) => mins > 15 ? "var(--pos-danger)" : mins > 10 ? "var(--pos-warning)" : "var(--pos-success)"; - private class KitchenTicket(string table, string status, int minutes, List items) + private class KitchenTicket(Guid id, string table, string status, int minutes, List items) { + public Guid Id { get; set; } = id; public string Table { get; set; } = table; public string Status { get; set; } = status; public int Minutes { get; set; } = minutes; @@ -175,4 +260,9 @@ } private record TicketItem(int Qty, string Name, string Note); + + public void Dispose() + { + _refreshTimer?.Dispose(); + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/OrderHistory.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/OrderHistory.razor index e98e078c..8e318724 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/OrderHistory.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/OrderHistory.razor @@ -119,26 +119,8 @@ private string? _expandedId; private readonly string[] _filters = { "Tất cả", "Tiền mặt", "Thẻ", "Chuyển khoản" }; - // EN: Demo order history — needs orders API / VI: Lịch sử đơn mẫu — cần API orders - private readonly List _orders = new() - { - new("DH001", "B3", "10:15", "Nguyễn Văn A", 3, 285_000, "Tiền mặt", - new() { new("Phở bò tái", 75_000, 2), new("Gỏi cuốn", 45_000, 1), new("Trà đá", 10_000, 3), new("Chả giò", 40_000, 1) }), - new("DH002", "B7", "11:30", "Trần Thị B", 2, 395_000, "Thẻ", - new() { new("Lẩu thái", 250_000, 1), new("Nước mía", 15_000, 2), new("Cơm tấm sườn", 65_000, 1), new("Bia Sài Gòn", 25_000, 2) }), - new("DH003", "B2", "12:00", "Lê Minh C", 4, 520_000, "Chuyển khoản", - new() { new("Cá kho tộ", 120_000, 1), new("Gà nướng mật ong", 180_000, 1), new("Bún bò Huế", 80_000, 1), new("Trà đá", 10_000, 4), new("Bia Sài Gòn", 25_000, 4) }), - new("DH004", "B11", "12:45", "Phạm Đức D", 2, 175_000, "Tiền mặt", - new() { new("Cơm tấm sườn", 65_000, 1), new("Bún bò Huế", 80_000, 1), new("Trà đá", 10_000, 3) }), - new("DH005", "B6", "13:15", "Nguyễn Văn A", 3, 310_000, "Thẻ", - new() { new("Phở bò tái", 75_000, 2), new("Gỏi cuốn", 45_000, 2), new("Cà phê sữa", 29_000, 1), new("Súp cua", 55_000, 1) }), - new("DH006", "B9", "14:00", "Hoàng Thị E", 5, 680_000, "Chuyển khoản", - new() { new("Lẩu thái", 250_000, 1), new("Gà nướng mật ong", 180_000, 1), new("Chả giò", 40_000, 2), new("Nộm bò bóp thấu", 65_000, 1), new("Trà đá", 10_000, 5), new("Bia Sài Gòn", 25_000, 3) }), - new("DH007", "B4", "17:30", "Vũ Thành F", 2, 195_000, "Tiền mặt", - new() { new("Phở bò tái", 75_000, 1), new("Cá kho tộ", 120_000, 1) }), - new("DH008", "B1", "18:15", "Trần Thị B", 3, 340_000, "Thẻ", - new() { new("Bún bò Huế", 80_000, 2), new("Gỏi cuốn", 45_000, 2), new("Cà phê sữa", 29_000, 2), new("Bánh flan", 25_000, 2) }), - }; + // EN: Order history from API / VI: Lịch sử đơn từ API + private List _orders = new(); protected override async Task OnInitializedAsync() { @@ -146,9 +128,51 @@ try { - // EN: Preload tables for reference — orders API not yet available - // VI: Tải trước bàn để tham chiếu — API orders chưa có - await DataService.GetTablesAsync(ShopId); + // EN: Load orders (today, paid/completed) and tables in parallel + // VI: Tải đơn (hôm nay, đã thanh toán) và bàn song song + var ordersTask = DataService.GetOrdersAsync(ShopId, "today"); + var tablesTask = DataService.GetTablesAsync(ShopId); + await Task.WhenAll(ordersTask, tablesTask); + + var apiOrders = await ordersTask; + var tables = await tablesTask; + var tableLookup = tables.ToDictionary(t => t.Id, t => $"B{t.TableNumber}"); + + // EN: Map API payment method to Vietnamese label / VI: Ánh xạ PTTT API sang nhãn tiếng Việt + static string MapPaymentMethod(string? method) => method?.ToLowerInvariant() switch + { + "cash" => "Tiền mặt", + "card" => "Thẻ", + "transfer" or "bank_transfer" => "Chuyển khoản", + "qr" => "Chuyển khoản", + _ => method ?? "Tiền mặt" + }; + + // EN: Load order details for each order to get items / VI: Tải chi tiết mỗi đơn để lấy danh sách món + var historyOrders = new List(); + foreach (var order in apiOrders.Where(o => o.Status is "Paid" or "Completed" or "Validated")) + { + var items = new List(); + try + { + var detail = await DataService.GetOrderDetailAsync(order.Id, ShopId); + if (detail?.Items != null) + items = detail.Items.Select(i => new OrderItem(i.ProductName ?? "Món", i.UnitPrice, i.Quantity)).ToList(); + } + catch { /* use empty items if detail fails */ } + + historyOrders.Add(new HistoryOrder( + order.Id.ToString()[..8].ToUpper(), + tableLookup.Values.FirstOrDefault() ?? "--", + order.CreatedAt.ToLocalTime().ToString("HH:mm"), + "POS", + order.ItemCount > 0 ? order.ItemCount : items.Count, + order.TotalAmount, + MapPaymentMethod(order.PaymentMethod), + items)); + } + + _orders = historyOrders; } catch { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/TableDetail.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/TableDetail.razor index 7d278104..23fd0296 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/TableDetail.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/TableDetail.razor @@ -3,8 +3,11 @@ VI: Chi tiết bàn — Xem hóa đơn với danh sách món, tạm tính, phí dịch vụ, VAT, tổng, tách hóa đơn. *@ @page "/pos/{ShopId:guid}/restaurant/table-detail" +@page "/pos/{ShopId:guid}/restaurant/table-detail/{TableId:guid}" @layout PosLayout @inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService +@inject NavigationManager Navigation
-
Bàn 3 — Hóa đơn
-
PV: Nguyễn Văn A · 18:45
+
@_tableName — Hóa đơn
+
POS · @DateTime.Now.ToString("HH:mm")
- 6 khách + @_items.Count món
+ @if (_isLoading) + { +
+ Đang tải hóa đơn... +
+ } + else if (_loadError) + { +
+ Không thể tải dữ liệu +
+ } + else if (!_items.Any()) + { +
+ Chưa có món nào cho bàn này +
+ } + else + { @* ═══ ORDERED ITEMS / CÁC MÓN ĐÃ GỌI ═══ *@
@foreach (var item in _items) @@ -78,32 +101,112 @@
} -
+ }
@code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + // EN: Optional table ID from route / VI: ID bàn tùy chọn từ route + [Parameter] public Guid? TableId { get; set; } + + // EN: Loading state / VI: Trạng thái tải + private bool _isLoading = true; + private bool _loadError; private string _splitMode = "full"; private int _splitCount = 2; + private string _tableName = "Bàn"; - // EN: Demo bill items / VI: Các món trong hóa đơn mẫu - private readonly List _items = new() - { - new("Gỏi cuốn", 45_000, 2), new("Chả giò", 40_000, 1), - new("Phở bò tái", 75_000, 2), new("Cá kho tộ", 120_000, 1), - new("Cơm tấm sườn", 65_000, 1), new("Lẩu thái", 250_000, 1), - new("Trà đá", 10_000, 4), new("Bia Sài Gòn", 25_000, 3), - }; + // EN: Bill items loaded from API / VI: Các món hóa đơn tải từ API + private List _items = new(); + + // EN: Order IDs for payment / VI: ID đơn hàng cho thanh toán + private List _orderIds = new(); private decimal Subtotal => _items.Sum(i => i.Price * i.Qty); private decimal ServiceCharge => Math.Round(Subtotal * 0.05m); private decimal Vat => Math.Round(Subtotal * 0.08m); private decimal Total => Subtotal + ServiceCharge + Vat; - private record BillItem(string Name, decimal Price, int Qty); + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + try + { + // EN: Load tables to find table name / VI: Tải bàn để tìm tên bàn + var tablesTask = DataService.GetTablesAsync(ShopId); + var ordersTask = DataService.GetActiveTableOrdersAsync(ShopId); + await Task.WhenAll(tablesTask, ordersTask); + + var tables = await tablesTask; + var activeOrders = await ordersTask; + + // EN: If TableId provided, use it; otherwise try first table with orders + // VI: Nếu có TableId, dùng nó; ngược lại thử bàn đầu tiên có đơn + var targetTableId = TableId; + if (!targetTableId.HasValue) + { + // EN: Try to get table from query string / VI: Thử lấy bàn từ query string + var uri = new Uri(Navigation.Uri); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + if (Guid.TryParse(query["tableId"], out var qsTableId)) + targetTableId = qsTableId; + } + + if (!targetTableId.HasValue) + { + // EN: Use first occupied table / VI: Dùng bàn đầu tiên đang phục vụ + targetTableId = activeOrders.FirstOrDefault(o => o.TableId.HasValue)?.TableId; + } + + // EN: Find table name / VI: Tìm tên bàn + var table = tables.FirstOrDefault(t => t.Id == targetTableId); + if (table != null) + _tableName = $"Bàn {table.TableNumber}"; + + // EN: Filter active orders for the target table / VI: Lọc đơn active cho bàn mục tiêu + var tableOrders = activeOrders.Where(o => o.TableId == targetTableId).ToList(); + _orderIds = tableOrders.Select(o => o.OrderId).ToList(); + + // EN: Aggregate items from all orders for this table / VI: Gộp món từ tất cả đơn cho bàn này + var itemMap = new Dictionary(); + foreach (var order in tableOrders) + { + foreach (var item in order.Items) + { + if (itemMap.TryGetValue(item.ProductId, out var existing)) + existing.Qty += item.Quantity; + else + itemMap[item.ProductId] = new BillItem(item.ProductName, item.UnitPrice, item.Quantity); + } + } + _items = itemMap.Values.ToList(); + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + } + } + + // EN: Navigate to payment flow / VI: Chuyển đến luồng thanh toán + private void GoToPayment() + { + NavigateTo("restaurant"); + } + + private class BillItem(string name, decimal price, int qty) + { + public string Name { get; } = name; + public decimal Price { get; } = price; + public int Qty { get; set; } = qty; + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/TableMap.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/TableMap.razor index 038ff3e2..516298a7 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/TableMap.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/TableMap.razor @@ -1,6 +1,8 @@ @* EN: Full Table Map — Drag arrangement, sections (Indoor/Outdoor/VIP), merge/split tables. - VI: Sơ đồ bàn đầy đủ — Kéo thả sắp xếp, khu vực (Trong nhà/Ngoài trời/VIP), gộp/tách bàn. + Connected to tables API with add/merge/split functionality. + VI: So do ban day du — Keo tha sap xep, khu vuc (Trong nha/Ngoai troi/VIP), gop/tach ban. + Ket noi API ban voi chuc nang them/gop/tach. *@ @page "/pos/{ShopId:guid}/restaurant/table-map" @layout PosLayout @@ -8,30 +10,75 @@ @inject WebClientTpos.Client.Services.PosDataService DataService
- @* ═══ TOOLBAR / THANH CÔNG CỤ ═══ *@ + @* ═══ TOOLBAR / THANH CONG CU ═══ *@
- Quản lý sơ đồ bàn + Quan ly so do ban
- - - + + +
- @* ═══ SECTION TABS / TAB KHU VỰC ═══ *@ + @* ═══ ADD TABLE FORM / FORM THEM BAN ═══ *@ + @if (_showAddForm) + { +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+ @if (!string.IsNullOrEmpty(_formMessage)) + { +
@_formMessage
+ } +
+ } + + @* ═══ SECTION TABS / TAB KHU VUC ═══ *@ @if (_isLoading) {
- Đang tải... + Dang tai...
} else if (_loadError) {
- Không thể tải dữ liệu + Khong the tai du lieu
} else @@ -44,7 +91,7 @@ }
- @* ═══ TABLE GRID / LƯỚI BÀN ═══ *@ + @* ═══ TABLE GRID / LUOI BAN ═══ *@
@foreach (var t in FilteredTables) @@ -63,7 +110,7 @@ }
@t.Section
@t.Name
-
@t.Seats chỗ · @t.Shape
+
@t.Seats cho · @t.Shape
@StatusLabel(t.Status)
@@ -74,38 +121,74 @@ } - @* ═══ FOOTER STATS / THỐNG KÊ ═══ *@ + @* ═══ MERGE CONFIRMATION / XAC NHAN GOP ═══ *@ + @if (_showMergeConfirm) + { +
+
+ + Gop @_selectedIds.Count ban: @string.Join(" + ", _tables.Where(t => _selectedIds.Contains(t.Id)).Select(t => t.Name)) + + + + +
+
+ } + + @* ═══ FOOTER STATS / THONG KE ═══ *@
- Tổng: @_tables.Count bàn - Trống: @_tables.Count(t => t.Status == "available") - Đang PV: @_tables.Count(t => t.Status == "occupied") - Đã đặt: @_tables.Count(t => t.Status == "reserved") + Tong: @_tables.Count ban + Trong: @_tables.Count(t => t.Status == "available") + Dang PV: @_tables.Count(t => t.Status == "occupied") + Da dat: @_tables.Count(t => t.Status == "reserved") @if (_selectedIds.Count > 0) { - Đã chọn: @_selectedIds.Count bàn + Da chon: @_selectedIds.Count ban }
@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 string _activeSection = "Tất cả"; - private string[] _sections = { "Tất cả" }; + private string _activeSection = "Tat ca"; + private string[] _sections = { "Tat ca" }; private readonly HashSet _selectedIds = new(); - // EN: Table data from API / VI: Dữ liệu bàn từ API + // EN: Add table form state / VI: Trang thai form them ban + private bool _showAddForm; + private string _newTableNumber = ""; + private int _newTableCapacity = 4; + private string _newTableZone = "Trong nha"; + private bool _isCreating; + private string? _formMessage; + private bool _formIsError; + + // EN: Merge state / VI: Trang thai gop ban + private bool _showMergeConfirm; + + // EN: Table data from API / VI: Du lieu ban tu API private List _tables = new(); private IEnumerable FilteredTables => - _activeSection == "Tất cả" ? _tables : _tables.Where(t => t.Section == _activeSection); + _activeSection == "Tat ca" ? _tables : _tables.Where(t => t.Section == _activeSection); protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); + await LoadTablesAsync(); + } + + private async Task LoadTablesAsync() + { + _isLoading = true; + _loadError = false; try { @@ -113,15 +196,15 @@ _tables = apiTables.Select(t => new MapTable( t.Id.ToString(), - $"Bàn {t.TableNumber}", + $"Ban {t.TableNumber}", t.Capacity, t.Status ?? "available", - t.Zone ?? "Trong nhà", - t.Capacity <= 2 ? "Tròn" : t.Capacity <= 6 ? "Vuông" : "Chữ nhật" + t.Zone ?? "Trong nha", + t.Capacity <= 2 ? "Tron" : t.Capacity <= 6 ? "Vuong" : "Chu nhat" )).ToList(); var zones = _tables.Select(t => t.Section).Distinct().ToList(); - _sections = new[] { "Tất cả" }.Concat(zones).ToArray(); + _sections = new[] { "Tat ca" }.Concat(zones).ToArray(); } catch { @@ -136,11 +219,114 @@ private void ToggleSelect(MapTable t) { if (!_selectedIds.Add(t.Id)) _selectedIds.Remove(t.Id); + // EN: Hide merge confirm if selection changes / VI: An xac nhan gop khi chon thay doi + _showMergeConfirm = false; } - private void MergeTables() => _selectedIds.Clear(); - private void SplitTable() => _selectedIds.Clear(); - private void AddTable() { /* placeholder */ } + private void ToggleAddForm() + { + _showAddForm = !_showAddForm; + _formMessage = null; + _newTableNumber = ""; + _newTableCapacity = 4; + _newTableZone = "Trong nha"; + } + + private async Task CreateTable() + { + if (string.IsNullOrWhiteSpace(_newTableNumber)) + { + _formMessage = "Vui long nhap so ban"; + _formIsError = true; + return; + } + + _isCreating = true; + _formMessage = null; + + try + { + var req = new WebClientTpos.Client.Services.PosDataService.CreateTableRequest( + ShopId, + _newTableNumber.Trim(), + _newTableCapacity, + _newTableZone + ); + + var success = await DataService.CreateTableAsync(req); + if (success) + { + _formMessage = $"Da tao ban {_newTableNumber} thanh cong!"; + _formIsError = false; + _newTableNumber = ""; + _newTableCapacity = 4; + // EN: Reload tables from API / VI: Tai lai ban tu API + await LoadTablesAsync(); + } + else + { + _formMessage = "Khong the tao ban. Vui long thu lai."; + _formIsError = true; + } + } + catch + { + _formMessage = "Loi khi tao ban. Vui long thu lai."; + _formIsError = true; + } + finally + { + _isCreating = false; + } + } + + private void MergeTables() + { + if (_selectedIds.Count < 2) return; + _showMergeConfirm = true; + } + + private void ConfirmMerge() + { + // EN: Visual merge — combine selected table names into first table + // VI: Gop hinh — ghep ten cac ban duoc chon vao ban dau tien + var selectedTables = _tables.Where(t => _selectedIds.Contains(t.Id)).ToList(); + if (selectedTables.Count < 2) return; + + var mergedName = string.Join("+", selectedTables.Select(t => t.Name.Replace("Ban ", ""))); + var totalSeats = selectedTables.Sum(t => t.Seats); + var primary = selectedTables.First(); + + // EN: Update primary table to show merged name + // VI: Cap nhat ban chinh de hien ten da gop + var primaryIdx = _tables.FindIndex(t => t.Id == primary.Id); + if (primaryIdx >= 0) + { + _tables[primaryIdx] = primary with { Name = $"Ban {mergedName}", Seats = totalSeats }; + } + + // EN: Remove other tables from visual list (they remain in DB) + // VI: Xoa cac ban khac khoi danh sach hien (chung van ton tai trong DB) + foreach (var t in selectedTables.Skip(1)) + { + _tables.RemoveAll(x => x.Id == t.Id); + } + + _selectedIds.Clear(); + _showMergeConfirm = false; + } + + private void CancelMerge() + { + _showMergeConfirm = false; + } + + private void SplitTable() + { + if (_selectedIds.Count != 1) return; + // EN: Navigate to table-merge-split page / VI: Chuyen den trang gop/tach ban + NavigateTo("restaurant/table-merge-split"); + } private static string StatusBg(string s) => s switch { @@ -156,7 +342,7 @@ private static string StatusLabel(string s) => s switch { - "available" => "Trống", "occupied" => "Đang phục vụ", "reserved" => "Đã đặt", _ => s + "available" => "Trong", "occupied" => "Dang phuc vu", "reserved" => "Da dat", _ => s }; private record MapTable(string Id, string Name, int Seats, string Status, string Section, string Shape); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/WaiterPad.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/WaiterPad.razor index 70b720c6..a5be4f46 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/WaiterPad.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Restaurant/Workflow/WaiterPad.razor @@ -1,37 +1,41 @@ @* EN: Waiter Pad — Order taking by course with special requests, send to kitchen. - VI: Pad Phục vụ — Gọi món theo course với yêu cầu đặc biệt, gửi bếp. + Connected to POS order API for order creation with table association. + VI: Pad Phuc vu — Goi mon theo course voi yeu cau dac biet, gui bep. + Ket noi API POS order de tao don voi lien ket ban. *@ +@page "/pos/{ShopId:guid}/restaurant/waiter-pad/{TableId:guid}" @page "/pos/{ShopId:guid}/restaurant/waiter-pad" @layout PosLayout @inherits PosBase -@inject WebClientTpos.Client.Services.PosDataService DataService +@using WebClientTpos.Client.Services +@inject PosDataService DataService
- @* ═══ HEADER / TIÊU ĐỀ ═══ *@ + @* ═══ HEADER / TIEU DE ═══ *@
- Gọi món — Bàn 3 - PV: Nguyễn Văn A + Goi mon@(_tableName != null ? $" — {_tableName}" : "") + PV: @(string.IsNullOrEmpty(StaffName) ? "Nhan vien" : StaffName)
@if (_isLoading) {
- Đang tải... + Dang tai...
} else if (_loadError) {
- Không thể tải dữ liệu + Khong the tai du lieu
} else { - @* ═══ COURSE TABS / TAB MÓN THEO COURSE ═══ *@ + @* ═══ COURSE TABS / TAB MON THEO COURSE ═══ *@
@foreach (var c in _courses) { @@ -40,7 +44,7 @@ }
- @* ═══ MENU ITEMS / DANH SÁCH MÓN ═══ *@ + @* ═══ MENU ITEMS / DANH SACH MON ═══ *@
@foreach (var item in FilteredMenu) { @@ -56,12 +60,12 @@ }
-@* ═══ ORDER PANEL / PANEL ĐƠN GỌI ═══ *@ +@* ═══ ORDER PANEL / PANEL DON GOI ═══ *@
- Đơn gọi (@_orderItems.Count món) + Don goi (@_orderItems.Count mon) + @onclick="ClearOrder">Xoa het
@@ -73,11 +77,11 @@ @FormatPrice(item.Price) @if (!string.IsNullOrEmpty(item.Note)) { - 📝 @item.Note + 📝 @item.Note }
- + @item.Qty
@@ -85,39 +89,61 @@ }
- @* ═══ SPECIAL REQUEST / YÊU CẦU ĐẶC BIỆT ═══ *@ + @* ═══ SPECIAL REQUEST / YEU CAU DAC BIET ═══ *@
-
+ @if (!string.IsNullOrEmpty(_submitMessage)) + { +
@_submitMessage
+ } +
@code { - // EN: Loading state / VI: Trạng thái tải + // EN: Route parameter for table association / VI: Tham so route de lien ket ban + [Parameter] public Guid TableId { get; set; } + + // EN: Loading state / VI: Trang thai tai private bool _isLoading = true; private bool _loadError; - private string _activeCourse = "Tất cả"; - private string _specialRequest = string.Empty; - private string[] _courses = { "Tất cả" }; + // EN: Submit state / VI: Trang thai gui + private bool _isSending; + private string? _submitMessage; + private bool _submitIsError; - // EN: Menu items from API / VI: Thực đơn từ API + private string _activeCourse = "Tat ca"; + private string _specialRequest = string.Empty; + private string[] _courses = { "Tat ca" }; + private string? _tableName; + + // EN: Menu items from API / VI: Thuc don tu API private List _menu = new(); private IEnumerable FilteredMenu => - _activeCourse == "Tất cả" ? _menu : _menu.Where(m => m.Course == _activeCourse); + _activeCourse == "Tat ca" ? _menu : _menu.Where(m => m.Course == _activeCourse); protected override async Task OnInitializedAsync() { @@ -125,21 +151,46 @@ try { - var products = await DataService.GetProductsAsync(ShopId); - var categories = await DataService.GetCategoriesAsync(ShopId); + // EN: Load products and categories in parallel / VI: Tai san pham va danh muc song song + var productsTask = DataService.GetProductsAsync(ShopId); + var categoriesTask = DataService.GetCategoriesAsync(ShopId); + // EN: Load table info if TableId provided / VI: Tai thong tin ban neu co TableId + Task>? tablesTask = null; + if (TableId != Guid.Empty) + { + tablesTask = DataService.GetTablesAsync(ShopId); + } + + await Task.WhenAll(productsTask, categoriesTask); + if (tablesTask != null) await tablesTask; + + var products = await productsTask; + var categories = await categoriesTask; var categoryMap = categories.ToDictionary(c => c.Id, c => c.Name); _menu = products.Select(p => new MenuItem( + p.Id, p.Name, p.Price, - p.Category ?? categoryMap.GetValueOrDefault(Guid.Empty, "Khác"), + p.Category ?? categoryMap.GetValueOrDefault(Guid.Empty, "Khac"), "utensils" )).ToList(); var courseNames = _menu.Select(m => m.Course).Distinct().ToList(); - _courses = new[] { "Tất cả" }.Concat(courseNames).ToArray(); + _courses = new[] { "Tat ca" }.Concat(courseNames).ToArray(); _activeCourse = _courses.First(); + + // EN: Set table name if found / VI: Dat ten ban neu tim thay + if (tablesTask != null) + { + var tables = await tablesTask; + var table = tables.FirstOrDefault(t => t.Id == TableId); + if (table != null) + { + _tableName = $"Ban {table.TableNumber}"; + } + } } catch { @@ -151,17 +202,14 @@ } } - // EN: Current order / VI: Đơn gọi hiện tại - private readonly List _orderItems = new() - { - new("Gỏi cuốn", 45_000, 2, ""), new("Phở bò tái", 75_000, 1, "Ít hành"), - }; + // EN: Current order / VI: Don goi hien tai + private readonly List _orderItems = new(); private void AddToOrder(MenuItem item) { - var existing = _orderItems.FirstOrDefault(o => o.Name == item.Name); + var existing = _orderItems.FirstOrDefault(o => o.ProductId == item.ProductId); if (existing is not null) existing.Qty++; - else _orderItems.Add(new OrderLine(item.Name, item.Price, 1, "")); + else _orderItems.Add(new OrderLine(item.ProductId, item.Name, item.Price, 1, "")); } private void ChangeQty(OrderLine item, int delta) @@ -170,12 +218,68 @@ if (item.Qty <= 0) _orderItems.Remove(item); } - private void ClearOrder() => _orderItems.Clear(); - private void SendToKitchen() => NavigateTo("restaurant/kitchen-display"); - - private record MenuItem(string Name, decimal Price, string Course, string Icon); - private class OrderLine(string name, decimal price, int qty, string note) + private void ClearOrder() { + _orderItems.Clear(); + _submitMessage = null; + } + + private async Task SendToKitchen() + { + if (!_orderItems.Any()) return; + + _isSending = true; + _submitMessage = null; + + try + { + // EN: Build order items with product IDs / VI: Tao danh sach mon voi ID san pham + var items = _orderItems.Select(o => new PosDataService.PosOrderItemRequest( + o.ProductId, + o.Name, + o.Qty, + o.Price, + "Physical" + )).ToList(); + + var req = new PosDataService.CreatePosOrderRequest( + ShopId, + null, // EN: No payment method yet / VI: Chua co phuong thuc thanh toan + items, + TableId: TableId != Guid.Empty ? TableId : null + ); + + var result = await DataService.CreatePosOrderAsync(req); + + if (result != null) + { + // EN: Order created successfully — clear cart and navigate + // VI: Tao don thanh cong — xoa gio va chuyen trang + _orderItems.Clear(); + _specialRequest = string.Empty; + NavigateTo("restaurant/kitchen-display"); + } + else + { + _submitMessage = "Khong the tao don. Vui long thu lai."; + _submitIsError = true; + } + } + catch + { + _submitMessage = "Loi khi gui bep. Vui long thu lai."; + _submitIsError = true; + } + finally + { + _isSending = false; + } + } + + private record MenuItem(Guid ProductId, string Name, decimal Price, string Course, string Icon); + private class OrderLine(Guid productId, string name, decimal price, int qty, string note) + { + public Guid ProductId { get; set; } = productId; public string Name { get; set; } = name; public decimal Price { get; set; } = price; public int Qty { get; set; } = qty; diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Dialogs/SplitBill.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Dialogs/SplitBill.razor index 820af16a..9befe730 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Dialogs/SplitBill.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Dialogs/SplitBill.razor @@ -3,200 +3,226 @@ VI: Tách hóa đơn — Chia đều, chia theo món, chia tùy chỉnh cho hóa đơn chung. *@ @page "/pos/{ShopId:guid}/dialog/split-bill" +@page "/pos/{ShopId:guid}/dialog/split-bill/{OrderId:guid}" @layout PosLayout @inherits PosBase @using WebClientTpos.Client.Services @inject IJSRuntime JS +@inject PosDataService DataService -
-
- - @* ═══ HEADER / TIÊU ĐỀ ═══ *@ -
-
- -
-
-
Tách hóa đơn
-
Tổng: @FormatPrice(_billTotal) · Bàn 3 · 6 khách
-
- +@if (_isLoading) +{ +
+
+
+
Đang tải đơn hàng...
+
+} +else +{ +
+
- @* ═══ SPLIT MODE TABS / TAB CHẾ ĐỘ CHIA ═══ *@ -
- @foreach (var mode in _modes) - { - - } -
+
-
- @if (_activeMode == "equal") - { - @* ═══ EQUAL SPLIT / CHIA ĐỀU ═══ *@ -
-
Số người chia
-
- @for (var i = 2; i <= 10; i++) - { - var count = i; - - } -
-
- @FormatPrice(Math.Round(_billTotal / _splitCount)) -
-
mỗi người
-
- - @* EN: Per-person breakdown / VI: Chi tiết từng người *@ -
- @for (var i = 1; i <= _splitCount; i++) - { - var personNum = i; -
-
Người @personNum
-
@FormatPrice(Math.Round(_billTotal / _splitCount))
-
- } -
- } - else if (_activeMode == "byitem") - { - @* ═══ BY-ITEM SPLIT / CHIA THEO MÓN ═══ *@ -
- @for (var p = 0; p < 3; p++) - { - var personIdx = p; -
-
- Người @(personIdx + 1) -
- @foreach (var item in _billItems.Where(i => i.AssignedTo == personIdx)) - { -
- @item.Name - @FormatPrice(item.Price) -
- } -
- Tổng - @FormatPrice(_billItems.Where(i => i.AssignedTo == personIdx).Sum(i => i.Price)) -
-
- } -
- - @* EN: Unassigned items / VI: Món chưa gán *@ - @if (UnassignedItems.Any()) + @* ═══ SPLIT MODE TABS / TAB CHẾ ĐỘ CHIA ═══ *@ +
+ @foreach (var mode in _modes) { -
-
Chưa phân
- @foreach (var item in UnassignedItems) + + } +
+ +
+ @if (_activeMode == "equal") + { + @* ═══ EQUAL SPLIT / CHIA ĐỀU ═══ *@ +
+
Số người chia
+
+ @for (var i = 2; i <= 8; i++) + { + var count = i; + + } +
+
+ @FormatPrice(Math.Round(_billTotal / _splitCount)) +
+
mỗi người
+
+ + @* EN: Per-person breakdown / VI: Chi tiết từng người *@ +
+ @for (var i = 1; i <= _splitCount; i++) { -
- @item.Name — @FormatPrice(item.Price) -
- @for (var p = 0; p < 3; p++) - { - var targetPerson = p; - - } -
+ var personNum = i; +
+
Người @personNum
+
@FormatPrice(Math.Round(_billTotal / _splitCount))
+
Nhấn để thanh toán
}
} - } - else - { - @* ═══ CUSTOM SPLIT / CHIA TÙY CHỈNH ═══ *@ -
- @for (var i = 0; i < 3; i++) + else if (_activeMode == "byitem") + { + @* ═══ BY-ITEM SPLIT / CHIA THEO MÓN ═══ *@ +
+ @for (var p = 0; p < 3; p++) + { + var personIdx = p; +
+
+ Người @(personIdx + 1) +
+ @foreach (var item in _billItems.Where(i => i.AssignedTo == personIdx)) + { +
+ @item.Name + @FormatPrice(item.Price) +
+ } +
+ Tổng + @FormatPrice(_billItems.Where(i => i.AssignedTo == personIdx).Sum(i => i.Price)) +
+
+ } +
+ + @* EN: Unassigned items / VI: Món chưa gán *@ + @if (UnassignedItems.Any()) { - var idx = i; -
-
- @(idx + 1) -
-
-
Người @(idx + 1)
- -
-
- @FormatPrice(_customAmounts[idx]) -
+
+
Chưa phân
+ @foreach (var item in UnassignedItems) + { +
+ @item.Name — @FormatPrice(item.Price) +
+ @for (var p = 0; p < 3; p++) + { + var targetPerson = p; + + } +
+
+ }
} -
+ } + else + { + @* ═══ CUSTOM SPLIT / CHIA TÙY CHỈNH ═══ *@ +
+ @for (var i = 0; i < _splitCount; i++) + { + var idx = i; +
+
+ @(idx + 1) +
+
+
Người @(idx + 1)
+ +
+
+ @FormatPrice(_customAmounts[idx]) +
+
+ } +
- @* EN: Remaining amount / VI: Số tiền còn lại *@ -
- - @(CustomRemaining == 0 ? "Đã chia hết!" : "Còn thiếu") - - - @FormatPrice(Math.Abs(CustomRemaining)) - -
- } -
+ @* EN: Remaining amount / VI: Số tiền còn lại *@ +
+ + @(CustomRemaining == 0 ? "Đã chia hết!" : "Còn thiếu") + + + @FormatPrice(Math.Abs(CustomRemaining)) + +
+ } +
- @* ═══ FOOTER / CUỐI TRANG ═══ *@ -
- - + @* ═══ FOOTER / CUỐI TRANG ═══ *@ +
+ + +
-
+} + + @code { - // EN: Dialog data — populated from current context (selected order/product). - // VI: Dữ liệu dialog — được điền từ context hiện tại (đơn hàng/sản phẩm đã chọn). - // TODO: Integrate with Order/Catalog/Inventory APIs when DDD Value Object mapping is fixed. + // EN: OrderId from route parameter or query string. + // VI: OrderId từ route parameter hoặc query string. + [Parameter] public Guid? OrderId { get; set; } + [SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; } + + private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty; + + private bool _isLoading = true; // EN: Split state / VI: Trạng thái tách private string _activeMode = "equal"; - private int _splitCount = 3; - private decimal _billTotal = 850_000; + private int _splitCount = 2; + private decimal _billTotal; // EN: Split mode definitions / VI: Định nghĩa chế độ chia private readonly List _modes = new() @@ -207,28 +233,78 @@ }; // EN: Bill items for by-item split / VI: Danh sách món để chia theo món - private readonly List _billItems = new() - { - new("Lẩu thái", 250_000, 0), - new("Phở bò tái", 75_000, 0), - new("Cơm tấm sườn", 65_000, 1), - new("Cá kho tộ", 120_000, 1), - new("Gỏi cuốn", 45_000, 2), - new("Chả giò", 40_000, 2), - new("Bia Sài Gòn", 75_000, -1), - new("Trà đá", 40_000, -1), - new("Bánh flan", 50_000, -1), - new("Nước mía", 90_000, -1), - }; + private List _billItems = new(); // EN: Custom amounts / VI: Số tiền tùy chỉnh - private decimal[] _customAmounts = { 300_000, 300_000, 250_000 }; + private decimal[] _customAmounts = new decimal[8]; // EN: Computed properties for template / VI: Thuộc tính tính toán cho template private List UnassignedItems => _billItems.Where(i => i.AssignedTo < 0).ToList(); - private decimal CustomRemaining => _billTotal - _customAmounts.Sum(); + private decimal CustomRemaining => _billTotal - _customAmounts.Take(_splitCount).Sum(); - private async Task GenerateSplitBills() => await JS.InvokeVoidAsync("history.back"); + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + await LoadOrderAsync(); + } + + private async Task LoadOrderAsync() + { + _isLoading = true; + try + { + if (_resolvedOrderId == Guid.Empty) { _isLoading = false; return; } + var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId); + if (detail?.Order != null) + { + _billTotal = detail.Order.TotalAmount; + } + if (detail?.Items != null) + { + _billItems = detail.Items.Select(i => new SplitItem( + i.ProductName ?? "Sản phẩm", + i.Subtotal, + -1 // EN: Unassigned by default / VI: Chưa phân mặc định + )).ToList(); + } + // EN: Initialize custom amounts with equal split default. + // VI: Khởi tạo số tiền tùy chỉnh với chia đều mặc định. + UpdateCustomAmountsDefault(); + } + catch { /* graceful */ } + finally { _isLoading = false; } + } + + private void UpdateSplitCount(int count) + { + _splitCount = count; + UpdateCustomAmountsDefault(); + } + + private void UpdateCustomAmountsDefault() + { + var perPerson = Math.Round(_billTotal / _splitCount); + for (var i = 0; i < _customAmounts.Length; i++) + _customAmounts[i] = i < _splitCount ? perPerson : 0; + // EN: Adjust last person to account for rounding. + // VI: Điều chỉnh người cuối để bù làm tròn. + if (_splitCount > 0) + _customAmounts[_splitCount - 1] = _billTotal - perPerson * (_splitCount - 1); + } + + private void PayForPerson(int personNum) + { + // EN: Navigate to payment method select for this split portion. + // VI: Điều hướng đến chọn phương thức thanh toán cho phần chia này. + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}"); + } + + private void GenerateSplitBills() + { + // EN: Navigate first person to payment method select. + // VI: Điều hướng người đầu tiên đến chọn phương thức thanh toán. + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}"); + } private record SplitMode(string Key, string Label); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/BankTransfer.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/BankTransfer.razor index 4914920f..6a4413fe 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/BankTransfer.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/BankTransfer.razor @@ -3,78 +3,175 @@ VI: Chuyển khoản ngân hàng — Thông tin tài khoản, mã tham chiếu, xác minh chuyển khoản. *@ @page "/pos/{ShopId:guid}/payment/bank-transfer" +@page "/pos/{ShopId:guid}/payment/bank-transfer/{OrderId:guid}" @layout PosLayout @inherits PosBase @using WebClientTpos.Client.Services +@inject PosDataService DataService -
- @* ═══ ORDER TOTAL ═══ *@ -
-
Tổng thanh toán
-
@FormatPrice(_orderTotal)
-
- - @* ═══ BANK ACCOUNT INFO ═══ *@ -
-
Thông tin chuyển khoản
- -
- Ngân hàng - @_bankName -
-
- Số tài khoản - @_accountNumber -
-
- Chủ tài khoản - @_accountHolder -
- - @* EN: Transfer reference / VI: Mã tham chiếu *@ -
-
Nội dung chuyển khoản
-
@_referenceCode
+@if (_isLoading) +{ +
+
+
+
Đang tải...
- - @* ═══ STATUS ═══ *@ -
-
- Chờ xác nhận chuyển khoản... -
- - @* ═══ ACTIONS ═══ *@ -
- +} +else if (!string.IsNullOrEmpty(_errorMessage)) +{ +
+
@_errorMessage
+ @onclick="Cancel">Quay lại
-
+} +else +{ +
+ @* ═══ ORDER TOTAL ═══ *@ +
+
Tổng thanh toán
+
@FormatPrice(_orderTotal)
+
+ + @* ═══ BANK ACCOUNT INFO ═══ *@ +
+
Thông tin chuyển khoản
+ +
+ Ngân hàng + @_bankName +
+
+ Số tài khoản + @_accountNumber +
+
+ Chủ tài khoản + @_accountHolder +
+ + @* EN: Transfer reference / VI: Mã tham chiếu *@ +
+
Nội dung chuyển khoản
+
@_referenceCode
+
+
+ + @* ═══ STATUS ═══ *@ + @if (_isProcessing) + { +
+
+ Đang xử lý thanh toán... +
+ } + else + { +
+
+ Chờ xác nhận chuyển khoản... +
+ } + + @* ═══ ACTIONS ═══ *@ +
+ + +
+
+} @code { - // EN: Payment workflow state — populated from current order context at runtime. - // VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy. - // TODO: Integrate with Order Service API when available. + // EN: OrderId from route parameter or query string. + // VI: OrderId từ route parameter hoặc query string. + [Parameter] public Guid? OrderId { get; set; } + [SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; } - // EN: Demo data / VI: Dữ liệu mẫu - private decimal _orderTotal = 285_000; + private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty; + + private decimal _orderTotal; + private bool _isLoading = true; + private bool _isProcessing; + private string? _errorMessage; + + // EN: Bank info — can be loaded from shop settings in future. + // VI: Thông tin ngân hàng — có thể tải từ cài đặt shop trong tương lai. private string _bankName = "Vietcombank"; private string _accountNumber = "1017 6688 9900"; private string _accountHolder = "CONG TY TNHH GOODGO"; - private string _referenceCode = "GG240215A1"; + private string _referenceCode = ""; - private void Verify() => NavigateTo("payment/success"); - private void Cancel() => NavigateTo("payment/method-select"); + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + await LoadOrderAsync(); + } + + private async Task LoadOrderAsync() + { + _isLoading = true; + try + { + if (_resolvedOrderId == Guid.Empty) { _errorMessage = "Không tìm thấy mã đơn hàng."; return; } + var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId); + if (detail?.Order == null) { _errorMessage = "Không thể tải thông tin đơn hàng."; return; } + _orderTotal = detail.Order.TotalAmount; + // EN: Generate reference code from order ID for transfer identification. + // VI: Tạo mã tham chiếu từ order ID để xác định chuyển khoản. + _referenceCode = $"GG{_resolvedOrderId.ToString("N")[..8].ToUpper()}"; + } + catch { _errorMessage = "Lỗi khi tải đơn hàng. Vui lòng thử lại."; } + finally { _isLoading = false; } + } + + private async Task Verify() + { + _isProcessing = true; + StateHasChanged(); + try + { + var success = await DataService.PayOrderAsync(_resolvedOrderId, ShopId, "transfer"); + if (success) + { + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=transfer"); + } + else + { + _errorMessage = "Thanh toán thất bại. Vui lòng thử lại."; + } + } + catch + { + _errorMessage = "Lỗi kết nối. Vui lòng thử lại."; + } + finally + { + _isProcessing = false; + } + } + + private void Cancel() + { + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}"); + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CardPayment.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CardPayment.razor index b72649f6..94538e22 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CardPayment.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CardPayment.razor @@ -3,56 +3,81 @@ VI: Thanh toán thẻ — Trạng thái đầu đọc thẻ, hướng dẫn chạm/quẹt/cắm. *@ @page "/pos/{ShopId:guid}/payment/card" +@page "/pos/{ShopId:guid}/payment/card/{OrderId:guid}" @layout PosLayout @inherits PosBase @using WebClientTpos.Client.Services +@inject PosDataService DataService -
- @* ═══ ORDER TOTAL ═══ *@ -
-
Tổng thanh toán
-
@FormatPrice(_orderTotal)
+@if (_isLoadingOrder) +{ +
+
+
+
Đang tải...
+
- - @* ═══ CARD READER STATUS ═══ *@ -
- @if (_isProcessing) - { - @* EN: Processing animation / VI: Hiệu ứng đang xử lý *@ -
-
Đang xử lý...
-
Vui lòng không rút thẻ
- } - else - { - @* EN: Waiting for card / VI: Chờ thẻ *@ -
- -
-
Chạm, quẹt hoặc cắm thẻ
-
Tap, swipe or insert card
- } -
- - @* ═══ ACTIONS ═══ *@ -
- @if (!_isProcessing) - { - - } +} +else if (!string.IsNullOrEmpty(_errorMessage)) +{ +
+
@_errorMessage
+ @onclick="Cancel">Quay lại
-
+} +else +{ +
+ @* ═══ ORDER TOTAL ═══ *@ +
+
Tổng thanh toán
+
@FormatPrice(_orderTotal)
+
+ + @* ═══ CARD READER STATUS ═══ *@ +
+ @if (_isProcessing) + { + @* EN: Processing animation / VI: Hiệu ứng đang xử lý *@ +
+
Đang xử lý...
+
Vui lòng không rút thẻ
+ } + else + { + @* EN: Waiting for card / VI: Chờ thẻ *@ +
+ +
+
Chạm, quẹt hoặc cắm thẻ
+
Tap, swipe or insert card
+ } +
+ + @* ═══ ACTIONS ═══ *@ +
+ @if (!_isProcessing) + { + + } + +
+
+} @* EN: CSS animations / VI: Hiệu ứng CSS *@ @code { - // EN: Payment workflow state — populated from current order context at runtime. - // VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy. - // TODO: Integrate with Order Service API when available. + // EN: OrderId from route parameter or query string. + // VI: OrderId từ route parameter hoặc query string. + [Parameter] public Guid? OrderId { get; set; } + [SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; } - // EN: Demo order total / VI: Tổng đơn hàng mẫu - private decimal _orderTotal = 285_000; + private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty; + + private decimal _orderTotal; + private bool _isLoadingOrder = true; private bool _isProcessing = false; + private string? _errorMessage; - private async Task SimulateProcess() + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + await LoadOrderAsync(); + } + + private async Task LoadOrderAsync() + { + _isLoadingOrder = true; + try + { + if (_resolvedOrderId == Guid.Empty) { _errorMessage = "Không tìm thấy mã đơn hàng."; return; } + var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId); + if (detail?.Order == null) { _errorMessage = "Không thể tải thông tin đơn hàng."; return; } + _orderTotal = detail.Order.TotalAmount; + } + catch { _errorMessage = "Lỗi khi tải đơn hàng. Vui lòng thử lại."; } + finally { _isLoadingOrder = false; } + } + + private async Task ProcessCardPayment() { _isProcessing = true; StateHasChanged(); - await Task.Delay(3000); - NavigateTo("payment/success"); + try + { + var success = await DataService.PayOrderAsync(_resolvedOrderId, ShopId, "card"); + if (success) + { + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=card"); + } + else + { + _errorMessage = "Thanh toán thẻ thất bại. Vui lòng thử lại."; + } + } + catch + { + _errorMessage = "Lỗi kết nối. Vui lòng thử lại."; + } + finally + { + _isProcessing = false; + } } - private void Cancel() => NavigateTo("payment/method-select"); + private void Cancel() + { + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}"); + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CashPayment.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CashPayment.razor index 38304407..23489130 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CashPayment.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/CashPayment.razor @@ -3,101 +3,171 @@ VI: Thanh toán tiền mặt — Nút số tiền nhanh và tính tiền thối. *@ @page "/pos/{ShopId:guid}/payment/cash" +@page "/pos/{ShopId:guid}/payment/cash/{OrderId:guid}" @layout PosLayout @inherits PosBase @using WebClientTpos.Client.Services +@inject PosDataService DataService -
- @* ═══ MAIN PANEL ═══ *@ -
- @* ═══ ORDER TOTAL ═══ *@ +@if (_isLoading) +{ +
-
Tổng thanh toán
-
@FormatPrice(_orderTotal)
-
- - @* ═══ QUICK AMOUNT BUTTONS ═══ *@ -
-
Số tiền nhanh
-
- @foreach (var amount in _quickAmounts) - { - - } -
-
- - @* ═══ CUSTOM AMOUNT INPUT ═══ *@ -
-
Nhập số tiền khác
-
- - -
-
- - @* ═══ CHANGE DISPLAY ═══ *@ -
-
- Khách đưa - @FormatPrice(_receivedAmount) -
-
- Tiền thối - - @FormatPrice(_changeAmount) - -
+
+
Đang tải...
- - @* ═══ CONFIRM PANEL ═══ *@ -
- - + @onclick="GoBack">Quay lại
-
+} +else +{ +
+ @* ═══ MAIN PANEL ═══ *@ +
+ @* ═══ ORDER TOTAL ═══ *@ +
+
Tổng thanh toán
+
@FormatPrice(_orderTotal)
+
+ + @* ═══ QUICK AMOUNT BUTTONS ═══ *@ +
+
Số tiền nhanh
+
+ @foreach (var amount in _quickAmounts) + { + + } +
+
+ + @* ═══ CUSTOM AMOUNT INPUT ═══ *@ +
+
Nhập số tiền khác
+
+ + +
+
+ + @* ═══ CHANGE DISPLAY ═══ *@ +
+
+ Khách đưa + @FormatPrice(_receivedAmount) +
+
+ Tiền thối + + @FormatPrice(_changeAmount) + +
+
+
+ + @* ═══ CONFIRM PANEL ═══ *@ +
+ + +
+
+} + + @code { - // EN: Payment workflow state — populated from current order context at runtime. - // VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy. - // TODO: Integrate with Order Service API when available. + // EN: OrderId from route parameter or query string. + // VI: OrderId từ route parameter hoặc query string. + [Parameter] public Guid? OrderId { get; set; } + [SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; } - // EN: Demo order total / VI: Tổng đơn hàng mẫu - private decimal _orderTotal = 285_000; + private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty; + + private decimal _orderTotal; private decimal _receivedAmount = 0; private decimal _changeAmount => _receivedAmount - _orderTotal; private string _customInput = ""; + private bool _isLoading = true; + private bool _isProcessing; + private string? _errorMessage; - // EN: Quick amount options / VI: Tùy chọn số tiền nhanh - private readonly List _quickAmounts = new() + // EN: Quick amount options — dynamically generated based on order total. + // VI: Tùy chọn số tiền nhanh — tự động sinh dựa trên tổng đơn hàng. + private List _quickAmounts = new(); + + protected override async Task OnInitializedAsync() { - new("300,000₫", 300_000), - new("350,000₫", 350_000), - new("400,000₫", 400_000), - new("500,000₫", 500_000), - new("1,000,000₫", 1_000_000), - new("Đúng tiền", 285_000), - }; + await base.OnInitializedAsync(); + await LoadOrderAsync(); + } + + private async Task LoadOrderAsync() + { + _isLoading = true; + try + { + if (_resolvedOrderId == Guid.Empty) { _errorMessage = "Không tìm thấy mã đơn hàng."; return; } + var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId); + if (detail?.Order == null) { _errorMessage = "Không thể tải thông tin đơn hàng."; return; } + _orderTotal = detail.Order.TotalAmount; + BuildQuickAmounts(); + } + catch { _errorMessage = "Lỗi khi tải đơn hàng. Vui lòng thử lại."; } + finally { _isLoading = false; } + } + + private void BuildQuickAmounts() + { + // EN: Generate sensible quick amounts based on order total. + // VI: Sinh các mức tiền nhanh hợp lý dựa trên tổng đơn hàng. + var rounded = Math.Ceiling(_orderTotal / 50_000) * 50_000; + _quickAmounts = new() + { + new(FormatPrice(rounded), rounded), + new(FormatPrice(rounded + 50_000), rounded + 50_000), + new(FormatPrice(rounded + 100_000), rounded + 100_000), + new(FormatPrice(rounded + 200_000), rounded + 200_000), + new("1,000,000₫", 1_000_000), + new("Đúng tiền", _orderTotal), + }; + } private void SetAmount(decimal amount) => _receivedAmount = amount; @@ -107,8 +177,36 @@ _receivedAmount = val; } - private void Confirm() => NavigateTo("payment/success"); - private void GoBack() => NavigateTo("payment/method-select"); + private async Task Confirm() + { + _isProcessing = true; + StateHasChanged(); + try + { + var success = await DataService.PayOrderAsync(_resolvedOrderId, ShopId, "cash"); + if (success) + { + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=cash&change={_changeAmount}"); + } + else + { + _errorMessage = "Thanh toán thất bại. Vui lòng thử lại."; + } + } + catch + { + _errorMessage = "Lỗi kết nối. Vui lòng thử lại."; + } + finally + { + _isProcessing = false; + } + } + + private void GoBack() + { + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}"); + } private record QuickAmount(string Label, decimal Value); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/MethodSelect.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/MethodSelect.razor index 80069eaa..5aa191f2 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/MethodSelect.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/MethodSelect.razor @@ -1,51 +1,95 @@ @* - EN: Payment Method Select — Choose payment method: Cash, Card, QR Code, Gift Card. - VI: Chọn phương thức thanh toán — Tiền mặt, Thẻ, Mã QR, Thẻ quà tặng. + EN: Payment Method Select — Choose payment method: Cash, Card, QR Code, Bank Transfer. + VI: Chọn phương thức thanh toán — Tiền mặt, Thẻ, Mã QR, Chuyển khoản. *@ @page "/pos/{ShopId:guid}/payment/method-select" +@page "/pos/{ShopId:guid}/payment-method-select/{OrderId:guid}" @layout PosLayout @inherits PosBase @using WebClientTpos.Client.Services @inject IJSRuntime JS +@inject PosDataService DataService -
- @* ═══ ORDER TOTAL ═══ *@ -
-
Tổng đơn hàng / Order Total
-
@FormatPrice(_orderTotal)
+@if (_isLoading) +{ +
+
+
+
Đang tải đơn hàng...
+
+} +else if (!string.IsNullOrEmpty(_errorMessage)) +{ +
+
@_errorMessage
+ +
+} +else +{ +
+ @* ═══ ORDER TOTAL ═══ *@ +
+
Tổng đơn hàng / Order Total
+
@FormatPrice(_orderTotal)
+
- @* ═══ PAYMENT METHODS ═══ *@ -
- @foreach (var method in _methods) - { - + } +
+ + @* ═══ CANCEL & BACK BUTTONS ═══ *@ +
+ - } + +
+} - @* ═══ BACK BUTTON ═══ *@ - -
+ @code { - // EN: Payment workflow state — populated from current order context at runtime. - // VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy. - // TODO: Integrate with Order Service API when available. + // EN: OrderId from route parameter or query string. + // VI: OrderId từ route parameter hoặc query string. + [Parameter] public Guid? OrderId { get; set; } + [SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; } - // EN: Demo order total / VI: Tổng đơn hàng mẫu - private decimal _orderTotal = 285_000; + private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty; + + private decimal _orderTotal; + private bool _isLoading = true; + private bool _isCancelling; + private string? _errorMessage; // EN: Payment method definitions / VI: Định nghĩa phương thức thanh toán private readonly List _methods = new() @@ -53,10 +97,63 @@ new("💵", "Tiền mặt", "Thanh toán bằng tiền mặt", "payment/cash"), new("💳", "Thẻ", "Chạm, quẹt hoặc cắm thẻ", "payment/card"), new("📱", "Mã QR", "VietQR, MoMo, ZaloPay", "payment/qr"), - new("🎁", "Thẻ quà tặng", "Sử dụng thẻ quà tặng", "payment/gift-card"), + new("🏦", "Chuyển khoản", "Chuyển khoản ngân hàng", "payment/bank-transfer"), }; - private void SelectMethod(string route) => NavigateTo(route); + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + await LoadOrderAsync(); + } + + private async Task LoadOrderAsync() + { + _isLoading = true; + try + { + if (_resolvedOrderId == Guid.Empty) + { + _errorMessage = "Không tìm thấy mã đơn hàng."; + return; + } + var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId); + if (detail?.Order == null) + { + _errorMessage = "Không thể tải thông tin đơn hàng."; + return; + } + _orderTotal = detail.Order.TotalAmount; + } + catch + { + _errorMessage = "Lỗi khi tải đơn hàng. Vui lòng thử lại."; + } + finally + { + _isLoading = false; + } + } + + private void SelectMethod(string route) + { + NavigationManager.NavigateTo($"/pos/{ShopId}/{route}?orderId={_resolvedOrderId}"); + } + + private async Task CancelOrder() + { + _isCancelling = true; + StateHasChanged(); + try + { + await DataService.CancelOrderAsync(_resolvedOrderId); + } + finally + { + _isCancelling = false; + } + await JS.InvokeVoidAsync("history.back"); + } + private async Task GoBack() => await JS.InvokeVoidAsync("history.back"); private record PaymentMethod(string Icon, string Label, string Description, string Route); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/PaymentSuccess.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/PaymentSuccess.razor index 5d3348d4..d3cdf81a 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/PaymentSuccess.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/PaymentSuccess.razor @@ -3,88 +3,182 @@ VI: Thanh toán thành công — Hiệu ứng thành công, chi tiết giao dịch, nút in/đơn mới. *@ @page "/pos/{ShopId:guid}/payment/success" +@page "/pos/{ShopId:guid}/payment/success/{OrderId:guid}" @layout PosLayout @inherits PosBase @using WebClientTpos.Client.Services @inject IJSRuntime JS +@inject PosDataService DataService -
- @* ═══ SUCCESS ANIMATION ═══ *@ -
-
- +@if (_isLoading) +{ +
+
+
+
Đang tải...
- - @* ═══ SUCCESS MESSAGE ═══ *@ -
-
Thanh toán thành công!
-
Payment successful
-
- - @* ═══ TRANSACTION DETAILS ═══ *@ -
-
- Tổng thanh toán - @FormatPrice(_orderTotal) -
-
- Phương thức - @_paymentMethod -
- @if (_changeAmount > 0) - { -
- Tiền thối - @FormatPrice(_changeAmount) +} +else +{ +
+ @* ═══ SUCCESS ANIMATION ═══ *@ +
+
+
- } -
- Mã giao dịch - @_transactionId
-
- Thời gian - @DateTime.Now.ToString("dd/MM/yyyy HH:mm:ss") -
-
- @* ═══ ACTION BUTTONS ═══ *@ -
- - + @* ═══ SUCCESS MESSAGE ═══ *@ +
+
Thanh toán thành công!
+
Payment successful
+
+ + @* ═══ TRANSACTION DETAILS ═══ *@ +
+
+ Tổng thanh toán + @FormatPrice(_orderTotal) +
+
+ Phương thức + @_paymentMethodDisplay +
+ @if (_changeAmount > 0) + { +
+ Tiền thối + @FormatPrice(_changeAmount) +
+ } + @if (_orderItems.Any()) + { +
+
Chi tiết đơn hàng
+ @foreach (var item in _orderItems) + { +
+ @item.Quantity x @item.ProductName + @FormatPrice(item.Subtotal) +
+ } +
+ } +
+ Mã đơn hàng + @_resolvedOrderId.ToString("N")[..8].ToUpper() +
+
+ Thời gian + @_orderTime.ToString("dd/MM/yyyy HH:mm:ss") +
+
+ + @* ═══ ACTION BUTTONS ═══ *@ +
+ + +
-
+} @code { - // EN: Payment workflow state — populated from current order context at runtime. - // VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy. - // TODO: Integrate with Order Service API when available. + // EN: OrderId from route parameter or query string. + // VI: OrderId từ route parameter hoặc query string. + [Parameter] public Guid? OrderId { get; set; } + [SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; } + [SupplyParameterFromQuery(Name = "method")] public string? MethodQuery { get; set; } + [SupplyParameterFromQuery(Name = "change")] public string? ChangeQuery { get; set; } - // EN: Demo data / VI: Dữ liệu mẫu - private decimal _orderTotal = 285_000; - private string _paymentMethod = "Tiền mặt"; - private decimal _changeAmount = 15_000; - private string _transactionId = "TXN-20240215-001"; + private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty; - private void PrintReceipt() => NavigateTo("payment/receipt"); - private async Task NewOrder() => await JS.InvokeVoidAsync("history.back"); + private decimal _orderTotal; + private string _paymentMethodDisplay = ""; + private decimal _changeAmount; + private DateTime _orderTime = DateTime.Now; + private List _orderItems = new(); + private bool _isLoading = true; + + private static readonly Dictionary _methodNames = new() + { + ["cash"] = "Tiền mặt", + ["card"] = "Thẻ", + ["qr"] = "Mã QR", + ["transfer"] = "Chuyển khoản", + }; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + await LoadOrderAsync(); + } + + private async Task LoadOrderAsync() + { + _isLoading = true; + try + { + // EN: Parse change amount from query string if present (for cash payments). + // VI: Phân tích số tiền thối từ query string nếu có (cho thanh toán tiền mặt). + if (decimal.TryParse(ChangeQuery, out var change)) + _changeAmount = change; + + // EN: Resolve payment method display name. + // VI: Xác định tên hiển thị phương thức thanh toán. + _paymentMethodDisplay = MethodQuery != null && _methodNames.TryGetValue(MethodQuery, out var name) + ? name : "Không xác định"; + + if (_resolvedOrderId != Guid.Empty) + { + var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId); + if (detail?.Order != null) + { + _orderTotal = detail.Order.TotalAmount; + _orderTime = detail.Order.CreatedAt; + if (!string.IsNullOrEmpty(detail.Order.PaymentMethod)) + { + _paymentMethodDisplay = _methodNames.TryGetValue(detail.Order.PaymentMethod, out var n) + ? n : detail.Order.PaymentMethod; + } + } + if (detail?.Items != null) + _orderItems = detail.Items; + } + } + catch { /* graceful — show what we have from query params */ } + finally { _isLoading = false; } + } + + private void PrintReceipt() + { + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/receipt?orderId={_resolvedOrderId}"); + } + + private void NewOrder() + { + // EN: Navigate back to the main POS page for this shop. + // VI: Điều hướng về trang POS chính của shop. + NavigationManager.NavigateTo($"/pos/{ShopId}"); + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/QrPayment.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/QrPayment.razor index eb606bf1..9ab5e1e8 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/QrPayment.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/QrPayment.razor @@ -3,112 +3,208 @@ VI: Thanh toán QR — Hiển thị mã QR với tab nhà cung cấp, đếm ngược. *@ @page "/pos/{ShopId:guid}/payment/qr" +@page "/pos/{ShopId:guid}/payment/qr/{OrderId:guid}" @layout PosLayout @inherits PosBase @using WebClientTpos.Client.Services +@inject PosDataService DataService @implements IDisposable -
- @* ═══ ORDER TOTAL ═══ *@ -
-
Tổng thanh toán
-
@FormatPrice(_orderTotal)
+@if (_isLoading) +{ +
+
+
+
Đang tải...
+
+} +else if (!string.IsNullOrEmpty(_errorMessage)) +{ +
+
@_errorMessage
+ +
+} +else +{ +
+ @* ═══ ORDER TOTAL ═══ *@ +
+
Tổng thanh toán
+
@FormatPrice(_orderTotal)
+
- @* ═══ QR PROVIDER TABS ═══ *@ -
- @foreach (var provider in _providers) + @* ═══ QR PROVIDER TABS ═══ *@ +
+ @foreach (var provider in _providers) + { + + } +
+ + @* ═══ QR CODE DISPLAY ═══ *@ +
+ + @_selectedProvider +
+ + @* ═══ TIMER ═══ *@ +
+ + @_timerDisplay +
+ + @* ═══ STATUS ═══ *@ + @if (_isProcessing) { - +
+
+ Đang xử lý thanh toán... +
+ } + else if (_timerSeconds > 0) + { +
+
+ Chờ xác nhận thanh toán... +
+ } + else + { +
+
+ Mã QR đã hết hạn. Nhấn "Làm mới" để tạo mã mới. +
} -
- @* ═══ QR CODE DISPLAY ═══ *@ -
- - @_selectedProvider -
- - @* ═══ TIMER ═══ *@ -
- - @_timerDisplay -
- - @* ═══ STATUS ═══ *@ - @if (_timerSeconds > 0) - { -
-
- Chờ xác nhận thanh toán... + @* ═══ ACTIONS ═══ *@ +
+ + +
- } - else - { -
-
- Mã QR đã hết hạn. Nhấn "Làm mới" để tạo mã mới. -
- } - - @* ═══ ACTIONS ═══ *@ -
- -
-
+} @code { - // EN: Payment workflow state — populated from current order context at runtime. - // VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy. - // TODO: Integrate with Order Service API when available. + // EN: OrderId from route parameter or query string. + // VI: OrderId từ route parameter hoặc query string. + [Parameter] public Guid? OrderId { get; set; } + [SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; } - // EN: Demo order total / VI: Tổng đơn hàng mẫu - private decimal _orderTotal = 285_000; + private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty; + + private decimal _orderTotal; private string _selectedProvider = "VietQR"; private int _timerSeconds = 300; private string _timerDisplay => $"{_timerSeconds / 60}:{(_timerSeconds % 60):D2}"; private Timer? _countdownTimer; + private bool _isLoading = true; + private bool _isProcessing; + private string? _errorMessage; // EN: QR providers / VI: Nhà cung cấp QR private readonly string[] _providers = { "VietQR", "MoMo", "ZaloPay" }; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { - _countdownTimer = new Timer(_ => + await base.OnInitializedAsync(); + await LoadOrderAsync(); + } + + private async Task LoadOrderAsync() + { + _isLoading = true; + try { - if (_timerSeconds > 0) + if (_resolvedOrderId == Guid.Empty) { _errorMessage = "Không tìm thấy mã đơn hàng."; return; } + var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId); + if (detail?.Order == null) { _errorMessage = "Không thể tải thông tin đơn hàng."; return; } + _orderTotal = detail.Order.TotalAmount; + } + catch { _errorMessage = "Lỗi khi tải đơn hàng. Vui lòng thử lại."; } + finally { _isLoading = false; } + } + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender && !_isLoading && _errorMessage == null) + { + _countdownTimer = new Timer(_ => { - _timerSeconds--; - InvokeAsync(StateHasChanged); + if (_timerSeconds > 0) + { + _timerSeconds--; + InvokeAsync(StateHasChanged); + } + }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); + } + } + + private async Task ConfirmPayment() + { + _isProcessing = true; + StateHasChanged(); + try + { + var success = await DataService.PayOrderAsync(_resolvedOrderId, ShopId, "qr"); + if (success) + { + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}&method=qr"); } - }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); + else + { + _errorMessage = "Thanh toán thất bại. Vui lòng thử lại."; + } + } + catch + { + _errorMessage = "Lỗi kết nối. Vui lòng thử lại."; + } + finally + { + _isProcessing = false; + } } private void Refresh() { _timerSeconds = 300; + _errorMessage = null; } - private void Cancel() => NavigateTo("payment/method-select"); + private void Cancel() + { + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}"); + } public void Dispose() => _countdownTimer?.Dispose(); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/Receipt.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/Receipt.razor index 7e7bf7d3..0fb03fe9 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/Receipt.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/Receipt.razor @@ -3,103 +3,116 @@ VI: Hóa đơn — Mẫu hóa đơn nhiệt 80mm với thông tin cửa hàng, sản phẩm, tổng tiền. *@ @page "/pos/{ShopId:guid}/payment/receipt" +@page "/pos/{ShopId:guid}/payment/receipt/{OrderId:guid}" @layout PosLayout @inherits PosBase @using WebClientTpos.Client.Services +@inject IJSRuntime JS +@inject PosDataService DataService -
- @* ═══ RECEIPT PAPER ═══ *@ -
- @* ═══ STORE HEADER ═══ *@ -
-
GOODGO COFFEE
-
123 Nguyễn Huệ, Q.1, TP.HCM
-
ĐT: 028-1234-5678
-
- - @* EN: Dashed separator / VI: Đường kẻ đứt *@ -
- - @* ═══ ORDER INFO ═══ *@ -
- Đơn #@_orderNumber - @_orderDate -
-
NV: @StaffName
- -
- - @* ═══ ITEM LIST ═══ *@ - @foreach (var item in _items) - { -
- @item.Qty x @item.Name - @FormatPrice(item.Price * item.Qty) -
- } - -
- - @* ═══ TOTALS ═══ *@ -
-
- Tạm tính - @FormatPrice(_subtotal) -
-
- Phí dịch vụ (5%) - @FormatPrice(_serviceCharge) -
-
- VAT (8%) - @FormatPrice(_vat) -
-
- -
- -
- TỔNG CỘNG - @FormatPrice(_total) -
- -
- - @* ═══ PAYMENT INFO ═══ *@ -
-
- Thanh toán - @_paymentMethod -
-
- Khách đưa - @FormatPrice(_amountPaid) -
-
- Tiền thối - @FormatPrice(_changeAmount) -
-
- -
- - @* ═══ TRANSACTION ID ═══ *@ -
-
Mã GD: @_transactionId
-
@_orderDate @_orderTime
-
- -
- - @* ═══ FOOTER ═══ *@ -
- Cảm ơn quý khách! -
-
- Thank you & see you again! +@if (_isLoading) +{ +
+
+
+
Đang tải hóa đơn...
-
+} +else +{ +
+ @* ═══ RECEIPT PAPER ═══ *@ +
+ @* ═══ STORE HEADER ═══ *@ +
+
@_shopName
+
@_shopAddress
+
+ + @* EN: Dashed separator / VI: Đường kẻ đứt *@ +
+ + @* ═══ ORDER INFO ═══ *@ +
+ Đơn #@_orderNumber + @_orderDate +
+
NV: @StaffName
+ +
+ + @* ═══ ITEM LIST ═══ *@ + @foreach (var item in _items) + { +
+ @item.Quantity x @item.ProductName + @FormatPrice(item.Subtotal) +
+ } + +
+ + @* ═══ TOTALS ═══ *@ +
+
+ Tạm tính + @FormatPrice(_subtotal) +
+ @if (_serviceCharge > 0) + { +
+ Phí dịch vụ + @FormatPrice(_serviceCharge) +
+ } + @if (_vat > 0) + { +
+ VAT + @FormatPrice(_vat) +
+ } +
+ +
+ +
+ TỔNG CỘNG + @FormatPrice(_total) +
+ +
+ + @* ═══ PAYMENT INFO ═══ *@ +
+
+ Thanh toán + @_paymentMethodDisplay +
+
+ +
+ + @* ═══ TRANSACTION ID ═══ *@ +
+
Mã đơn: @_orderNumber
+
@_orderDate @_orderTime
+
+ +
+ + @* ═══ FOOTER ═══ *@ +
+ Cảm ơn quý khách! +
+
+ Thank you & see you again! +
+
+
+} @* ═══ ACTION BUTTONS (fixed bottom) ═══ *@
@@ -116,40 +129,96 @@
+ + @code { - // EN: Payment workflow state — populated from current order context at runtime. - // VI: Trạng thái thanh toán — được điền từ context đơn hàng hiện tại khi chạy. - // TODO: Integrate with Order Service API when available. + // EN: OrderId from route parameter or query string. + // VI: OrderId từ route parameter hoặc query string. + [Parameter] public Guid? OrderId { get; set; } + [SupplyParameterFromQuery(Name = "orderId")] public Guid? OrderIdQuery { get; set; } - // EN: Demo receipt data / VI: Dữ liệu hóa đơn mẫu - private string _orderNumber = "1042"; - private string _orderDate = "15/02/2024"; - private string _orderTime = "14:35:22"; + private Guid _resolvedOrderId => OrderId ?? OrderIdQuery ?? Guid.Empty; - private readonly List _items = new() + private bool _isLoading = true; + + // EN: Receipt data populated from API. + // VI: Dữ liệu hóa đơn được điền từ API. + private string _shopName = "GOODGO"; + private string _shopAddress = ""; + private string _orderNumber = ""; + private string _orderDate = ""; + private string _orderTime = ""; + private List _items = new(); + private decimal _subtotal; + private decimal _serviceCharge; + private decimal _vat; + private decimal _total; + private string _paymentMethodDisplay = ""; + + private static readonly Dictionary _methodNames = new() { - new("Cà phê sữa đá", 35_000, 2), - new("Cappuccino", 55_000, 1), - new("Croissant bơ", 35_000, 1), - new("Trà đào cam sả", 45_000, 1), - new("Bánh mì thịt", 30_000, 1), + ["cash"] = "Tiền mặt", + ["card"] = "Thẻ", + ["qr"] = "Mã QR", + ["transfer"] = "Chuyển khoản", }; - private decimal _subtotal => _items.Sum(i => i.Price * i.Qty); - private decimal _serviceCharge => Math.Round(_subtotal * 0.05m); - private decimal _vat => Math.Round(_subtotal * 0.08m); - private decimal _total => _subtotal + _serviceCharge + _vat; - private string _paymentMethod = "Tiền mặt"; - private decimal _amountPaid = 300_000; - private decimal _changeAmount => _amountPaid - _total; - private string _transactionId = "TXN-20240215-001"; - - private void Print() + protected override async Task OnInitializedAsync() { - // EN: Trigger browser print / VI: Kích hoạt in từ trình duyệt + await base.OnInitializedAsync(); + await LoadReceiptAsync(); } - private void Close() => NavigateTo("payment/success"); + private async Task LoadReceiptAsync() + { + _isLoading = true; + try + { + if (_resolvedOrderId == Guid.Empty) { _isLoading = false; return; } + var detail = await DataService.GetOrderDetailAsync(_resolvedOrderId, ShopId); + if (detail?.Order != null) + { + _orderNumber = _resolvedOrderId.ToString("N")[..8].ToUpper(); + _orderDate = detail.Order.CreatedAt.ToString("dd/MM/yyyy"); + _orderTime = detail.Order.CreatedAt.ToString("HH:mm:ss"); + _total = detail.Order.TotalAmount; - private record ReceiptItem(string Name, decimal Price, int Qty); + if (!string.IsNullOrEmpty(detail.Order.PaymentMethod)) + { + _paymentMethodDisplay = _methodNames.TryGetValue(detail.Order.PaymentMethod, out var n) + ? n : detail.Order.PaymentMethod; + } + } + if (detail?.Items != null) + { + _items = detail.Items; + _subtotal = _items.Sum(i => i.Subtotal); + // EN: Calculate tax/service from difference between total and subtotal. + // VI: Tính thuế/phí dịch vụ từ chênh lệch giữa tổng và tạm tính. + var diff = _total - _subtotal; + if (diff > 0) + { + _serviceCharge = Math.Round(diff * 0.385m); // ~5/13 of diff + _vat = diff - _serviceCharge; + } + } + if (!string.IsNullOrEmpty(StoreName)) + _shopName = StoreName; + } + catch { /* graceful — show partial data */ } + finally { _isLoading = false; } + } + + private async Task Print() + { + // EN: Trigger browser print dialog / VI: Kích hoạt hộp thoại in từ trình duyệt + await JS.InvokeVoidAsync("window.print"); + } + + private void Close() + { + NavigationManager.NavigateTo($"/pos/{ShopId}/payment/success?orderId={_resolvedOrderId}"); + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/SpaDesktop.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/SpaDesktop.razor index 734c1023..d1392323 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/SpaDesktop.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/SpaDesktop.razor @@ -129,8 +129,8 @@ @onclick="@(() => NavigateTo("spa/appointment-book"))"> Đặt lịch -
@@ -146,10 +146,11 @@ private string[] _categories = { "Tất cả" }; private string _selectedCategory = "Tất cả"; - // EN: Demo customer / VI: Khách hàng mẫu - private string? _customerName = "Nguyễn Thị Mai"; - private string _customerPhone = "0901234567"; - private string _customerTier = "Gold"; + // EN: Customer info (selected from lookup) / VI: Thông tin khách hàng (chọn từ tra cứu) + private string? _customerName; + private string _customerPhone = ""; + private string _customerTier = ""; + private bool _isCheckingOut; // EN: Service list from API / VI: Danh sách dịch vụ từ API private List _services = new(); @@ -169,6 +170,7 @@ var apiProducts = await DataService.GetProductsAsync(ShopId); _services = apiProducts.Select(p => new SpaService( + p.Id, p.Name, p.Price, p.DurationMinutes ?? 60, @@ -190,12 +192,41 @@ private void AddToAppointment(SpaService svc) { - _appointmentItems.Add(new AppointmentItem(svc.Name, svc.Price, svc.Duration)); + _appointmentItems.Add(new AppointmentItem(svc.Id, svc.Name, svc.Price, svc.Duration)); } private void RemoveItem(AppointmentItem item) => _appointmentItems.Remove(item); - private void Checkout() => NavigateTo("spa/spa-journey"); + private async Task Checkout() + { + if (!_appointmentItems.Any()) return; + + _isCheckingOut = true; + try + { + var orderItems = _appointmentItems.Select(i => + new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest( + i.ProductId, i.Name, 1, i.Price, "Service" + )).ToList(); + + var request = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest( + ShopId, null, orderItems); + + var result = await DataService.CreatePosOrderAsync(request); + if (result is not null) + { + NavigateTo("spa/spa-journey"); + } + } + catch + { + // EN: Order creation failed / VI: Tạo đơn hàng thất bại + } + finally + { + _isCheckingOut = false; + } + } private static string GetCategoryIcon(string category) => category switch { @@ -204,6 +235,6 @@ }; // EN: Models / VI: Mô hình dữ liệu - private record SpaService(string Name, decimal Price, int Duration, string Category); - private record AppointmentItem(string Name, decimal Price, int Duration); + private record SpaService(Guid Id, string Name, decimal Price, int Duration, string Category); + private record AppointmentItem(Guid ProductId, string Name, decimal Price, int Duration); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/SpaMobile.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/SpaMobile.razor index bfe32be3..397c4815 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/SpaMobile.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/SpaMobile.razor @@ -105,7 +105,9 @@ Tổng cộng @FormatPrice(AppointmentTotal)
- +
@@ -122,6 +124,7 @@ private string[] _categories = { "Tất cả" }; private string _selectedCategory = "Tất cả"; private bool _showSheet; + private bool _isCheckingOut; // EN: Service list from API / VI: Danh sách dịch vụ từ API private List _services = new(); @@ -141,6 +144,7 @@ var apiProducts = await DataService.GetProductsAsync(ShopId); _services = apiProducts.Select(p => new SpaService( + p.Id, p.Name, p.Price, p.DurationMinutes ?? 60, @@ -162,10 +166,39 @@ private void AddToAppointment(SpaService svc) { - _appointmentItems.Add(new AppointmentItem(svc.Name, svc.Price, svc.Duration)); + _appointmentItems.Add(new AppointmentItem(svc.Id, svc.Name, svc.Price, svc.Duration)); } - private void Checkout() => NavigateTo("spa/spa-journey"); + private async Task Checkout() + { + if (!_appointmentItems.Any()) return; + + _isCheckingOut = true; + try + { + var orderItems = _appointmentItems.Select(i => + new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest( + i.ProductId, i.Name, 1, i.Price, "Service" + )).ToList(); + + var request = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest( + ShopId, null, orderItems); + + var result = await DataService.CreatePosOrderAsync(request); + if (result is not null) + { + NavigateTo("spa/spa-journey"); + } + } + catch + { + // EN: Order creation failed / VI: Tạo đơn hàng thất bại + } + finally + { + _isCheckingOut = false; + } + } private static string GetCategoryIcon(string category) => category switch { @@ -174,6 +207,6 @@ }; // EN: Models / VI: Mô hình dữ liệu - private record SpaService(string Name, decimal Price, int Duration, string Category); - private record AppointmentItem(string Name, decimal Price, int Duration); + private record SpaService(Guid Id, string Name, decimal Price, int Duration, string Category); + private record AppointmentItem(Guid ProductId, string Name, decimal Price, int Duration); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/SpaTablet.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/SpaTablet.razor index 1a056280..84f9ebd8 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/SpaTablet.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/SpaTablet.razor @@ -94,8 +94,8 @@ Tổng cộng @FormatPrice(AppointmentTotal)
-
@@ -109,6 +109,7 @@ // EN: Categories / VI: Danh mục private string[] _categories = { "Tất cả" }; private string _selectedCategory = "Tất cả"; + private bool _isCheckingOut; // EN: Service list from API / VI: Danh sách dịch vụ từ API private List _services = new(); @@ -128,6 +129,7 @@ var apiProducts = await DataService.GetProductsAsync(ShopId); _services = apiProducts.Select(p => new SpaService( + p.Id, p.Name, p.Price, p.DurationMinutes ?? 60, @@ -149,10 +151,39 @@ private void AddToAppointment(SpaService svc) { - _appointmentItems.Add(new AppointmentItem(svc.Name, svc.Price, svc.Duration)); + _appointmentItems.Add(new AppointmentItem(svc.Id, svc.Name, svc.Price, svc.Duration)); } - private void Checkout() => NavigateTo("spa/spa-journey"); + private async Task Checkout() + { + if (!_appointmentItems.Any()) return; + + _isCheckingOut = true; + try + { + var orderItems = _appointmentItems.Select(i => + new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest( + i.ProductId, i.Name, 1, i.Price, "Service" + )).ToList(); + + var request = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest( + ShopId, null, orderItems); + + var result = await DataService.CreatePosOrderAsync(request); + if (result is not null) + { + NavigateTo("spa/spa-journey"); + } + } + catch + { + // EN: Order creation failed / VI: Tạo đơn hàng thất bại + } + finally + { + _isCheckingOut = false; + } + } private static string GetCategoryIcon(string category) => category switch { @@ -161,6 +192,6 @@ }; // EN: Models / VI: Mô hình dữ liệu - private record SpaService(string Name, decimal Price, int Duration, string Category); - private record AppointmentItem(string Name, decimal Price, int Duration); + private record SpaService(Guid Id, string Name, decimal Price, int Duration, string Category); + private record AppointmentItem(Guid ProductId, string Name, decimal Price, int Duration); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/AppointmentBook.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/AppointmentBook.razor index 164a5ec9..69d3ab2d 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/AppointmentBook.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/AppointmentBook.razor @@ -44,7 +44,7 @@ background:@(day.Value == _selectedDate ? "var(--pos-orange-primary)" : "var(--pos-bg-elevated)"); color:@(day.Value == _selectedDate ? "#FFF" : "var(--pos-text-primary)"); border:1px solid @(day.Value == _selectedDate ? "var(--pos-orange-primary)" : "var(--pos-border-subtle)");" - @onclick="() => _selectedDate = day.Value"> + @onclick="() => { _selectedDate = day.Value; _selectedDateValue = day.Date; }">
@day.Label
@day.Day
@@ -58,12 +58,13 @@
@foreach (var staff in _staffList) { + var s = staff; }
@@ -110,7 +111,7 @@
Khách hàng - Nguyễn Thị Mai + Khách vãng lai
Ngày @@ -122,7 +123,7 @@
Nhân viên - @_selectedStaff + @_selectedStaffName
@@ -151,8 +152,20 @@ Tổng cộng @FormatPrice(_selectedServices.Sum(s => s.Price))
-
@@ -165,20 +178,19 @@ private bool _loadError; private string _selectedDate = "Hôm nay"; + private DateTime _selectedDateValue = DateTime.Today; private string? _selectedTime = "10:00"; - private string _selectedStaff = "Chị Hoa"; + private string _selectedStaffName = "Bất kỳ"; + private Guid? _selectedStaffId; - // EN: Date options / VI: Tùy chọn ngày - private readonly List _dateOptions = new() - { - new("Hôm nay", "T5", "Hôm nay"), - new("Ngày mai", "T6", "Ngày mai"), - new("22/02", "T7", "22/02"), - new("23/02", "CN", "23/02"), - }; + // EN: Date options (dynamic) / VI: Tùy chọn ngày (động) + private List _dateOptions = new(); - // EN: Staff list / VI: Danh sách nhân viên - private readonly string[] _staffList = { "Chị Hoa", "Anh Minh", "Chị Lan", "Chị Trang", "Bất kỳ" }; + // EN: Staff list from API / VI: Danh sách nhân viên từ API + private List _staffList = new(); + private bool _isBooking; + private string? _bookingError; + private bool _bookingSuccess; // EN: Time slots from API / VI: Khung giờ từ API private List _timeSlots = new(); @@ -192,9 +204,32 @@ try { + // EN: Build dynamic date options / VI: Tạo tùy chọn ngày động + var today = DateTime.Today; + var dayNames = new[] { "CN", "T2", "T3", "T4", "T5", "T6", "T7" }; + _dateOptions = Enumerable.Range(0, 4).Select(i => + { + var d = today.AddDays(i); + var label = i == 0 ? "Hôm nay" : i == 1 ? "Ngày mai" : d.ToString("dd/MM"); + return new DateOption(label, dayNames[(int)d.DayOfWeek], label, d); + }).ToList(); + _selectedDate = _dateOptions[0].Value; + _selectedDateValue = _dateOptions[0].Date; + + // EN: Load staff from API / VI: Tải nhân viên từ API + var staffData = await DataService.GetStaffForShopAsync(ShopId); + _staffList = staffData.Select(s => new StaffOption( + s.Id, + $"{s.FirstName ?? ""} {s.LastName ?? ""}".Trim() is { Length: > 0 } name ? name : (s.EmployeeCode ?? s.Id.ToString()[..8]) + )).ToList(); + _staffList.Add(new StaffOption(null, "Bất kỳ")); + _selectedStaffName = "Bất kỳ"; + + // EN: Load appointments to mark booked slots / VI: Tải lịch hẹn để đánh dấu khung giờ đã đặt var appointments = await DataService.GetAppointmentsAsync(ShopId); var bookedTimes = appointments + .Where(a => a.StartTime.Date == _selectedDateValue.Date) .Select(a => a.StartTime.ToString("HH:mm")) .ToHashSet(); @@ -211,8 +246,10 @@ } _timeSlots = slots; + // EN: Load services/products / VI: Tải dịch vụ/sản phẩm var products = await DataService.GetProductsAsync(ShopId); _selectedServices = products.Take(2).Select(p => new ServiceInfo( + p.Id, p.Name, p.Price, p.DurationMinutes ?? 60 @@ -228,7 +265,59 @@ } } - private record DateOption(string Label, string Day, string Value); + // EN: Confirm booking via API / VI: Xác nhận đặt lịch qua API + private async Task ConfirmBooking() + { + if (_selectedTime is null || !_selectedServices.Any()) return; + + _isBooking = true; + _bookingError = null; + _bookingSuccess = false; + + try + { + var timeParts = _selectedTime.Split(':'); + var startTime = _selectedDateValue.Date + .AddHours(int.Parse(timeParts[0])) + .AddMinutes(int.Parse(timeParts[1])); + var totalDuration = _selectedServices.Sum(s => s.Duration); + var endTime = startTime.AddMinutes(totalDuration); + + // EN: Create appointment for the first service (primary) / VI: Tạo lịch hẹn cho dịch vụ chính + var request = new WebClientTpos.Client.Services.PosDataService.CreateAppointmentRequest( + ShopId, + null, // CustomerId + _selectedStaffId, + null, // ResourceId + _selectedServices.First().Id, + startTime, + endTime + ); + + var success = await DataService.CreateAppointmentAsync(request); + if (success) + { + _bookingSuccess = true; + await Task.Delay(1000); + NavigateTo("spa"); + } + else + { + _bookingError = "Không thể đặt lịch. Vui lòng thử lại."; + } + } + catch + { + _bookingError = "Lỗi kết nối. Vui lòng thử lại."; + } + finally + { + _isBooking = false; + } + } + + private record DateOption(string Label, string Day, string Value, DateTime Date); private record TimeSlot(string Time, string Status); - private record ServiceInfo(string Name, decimal Price, int Duration); + private record ServiceInfo(Guid Id, string Name, decimal Price, int Duration); + private record StaffOption(Guid? Id, string Name); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/CustomerLookup.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/CustomerLookup.razor index db611a81..acfdf243 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/CustomerLookup.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/CustomerLookup.razor @@ -5,6 +5,7 @@ @page "/pos/{ShopId:guid}/spa/customer-lookup" @layout PosLayout @inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService
@* ═══ HEADER / TIÊU ĐỀ ═══ *@ @@ -24,18 +25,42 @@ border-radius:var(--pos-radius);padding:0 14px;border:1px solid var(--pos-border-default);">
@* ═══ SEARCH RESULTS / KẾT QUẢ TÌM KIẾM ═══ *@
+ @if (_isSearching) + { +
+ Đang tìm kiếm... +
+ } + else if (_searchPerformed && !_customers.Any()) + { +
+ +
Không tìm thấy khách hàng
+
Thử tìm với từ khóa khác hoặc tạo khách mới
+
+ } + else if (_searchError is not null) + { +
+ @_searchError +
+ } + else + {
@foreach (var customer in _customers) { @@ -73,6 +98,8 @@ }
+ } + @* ═══ CREATE NEW CUSTOMER / TẠO KHÁCH MỚI ═══ *@
+
+ } + else + { + @* === PROFILE PANEL (LEFT) / PANEL HO SO (TRAI) === *@
- @* EN: Header / VI: Tiêu đề *@ + @* EN: Header / VI: Tieu de *@
- Hồ sơ khách hàng + Ho so khach hang
- @* ═══ PROFILE CARD / THẺ HỒ SƠ ═══ *@ + @* === PROFILE CARD / THE HO SO === *@
- M + @_memberInitial
-
Nguyễn Thị Mai
-
0901234567
+
@_memberName
+
@(_member.Phone ?? "---")
- Gold + background:@GetLevelBadgeBg(_member.CurrentLevel);color:@GetLevelBadgeColor(_member.CurrentLevel);"> + @(_member.LevelName ?? $"Level {_member.CurrentLevel}") - @* EN: Stats row / VI: Hàng thống kê *@ + @* EN: Stats row / VI: Hang thong ke *@
-
2,450
-
Điểm
+
@_member.CurrentExp.ToString("N0")
+
Diem
-
28
-
Lượt ghé
+
@_visitCount
+
Luot ghe
-
8.5M
-
Tổng chi
+
@FormatCompact(_totalSpent)
+
Tong chi
- @* EN: Tier progress / VI: Tiến trình hạng *@ -
-
- Gold - 2,450 / 5,000 điểm - Platinum + @* EN: Tier progress / VI: Tien trinh hang *@ + @if (_progress != null) + { +
+
+ @(_progress.CurrentLevelName ?? $"Lv{_member.CurrentLevel}") + @_member.CurrentExp.ToString("N0") / @(_progress.ExpToNextLevel > 0 ? _progress.ExpToNextLevel.ToString("N0") : "MAX") diem + @(_progress.NextLevelName ?? "MAX") +
+
+
+
+ @if (_progress.ExpToNextLevel > _member.CurrentExp) + { +
+ Con @((_progress.ExpToNextLevel - _member.CurrentExp).ToString("N0")) diem de len hang @(_progress.NextLevelName ?? "tiep theo") +
+ }
-
-
-
-
- Còn 2,550 điểm để lên hạng Platinum -
-
+ }
- @* ═══ VISIT HISTORY / LỊCH SỬ GHÉ ═══ *@ -
Lịch sử ghé gần đây
- @foreach (var visit in _visitHistory) + @* === VISIT HISTORY / LICH SU GHE === *@ +
Lich su ghe gan day
+ @if (_appointments.Any()) { -
-
- + @foreach (var appt in _appointments.Take(5)) + { +
+
+ +
+
+
@(appt.ResourceName ?? "Dich vu")
+
@appt.StartTime.ToString("dd/MM/yyyy HH:mm") • @appt.Status
+
+
+
+ @((appt.EndTime - appt.StartTime).TotalMinutes.ToString("0")) phut +
+
-
-
@visit.Services
-
@visit.Date • @visit.Therapist
-
-
-
@FormatPrice(visit.Amount)
-
+@visit.Points điểm
+ } + } + else if (_expHistory.Any()) + { + @foreach (var tx in _expHistory.Take(5)) + { +
+
+ +
+
+
@(tx.Source ?? "Giao dich")
+
@tx.CreatedAt.ToString("dd/MM/yyyy")
+
+
+
+@tx.Points diem
+
+ } + } + else + { +
+ Chua co lich su
} - @* ═══ FAVORITE SERVICES / DỊCH VỤ YÊU THÍCH ═══ *@ -
Dịch vụ yêu thích
-
- @foreach (var fav in _favorites) - { - - @fav - - } -
+ @* === FAVORITE SERVICES / DICH VU YEU THICH === *@ + @if (_favorites.Any()) + { +
Dich vu yeu thich
+
+ @foreach (var fav in _favorites) + { + + @fav + + } +
+ }
- @* ═══ REWARDS PANEL (RIGHT) / PANEL PHẦN THƯỞNG (PHẢI) ═══ *@ + @* === REWARDS PANEL (RIGHT) / PANEL PHAN THUONG (PHAI) === *@
- Phần thưởng + Phan thuong
@@ -118,7 +180,9 @@ @reward.Value
@reward.Description
-
Cần @reward.PointCost điểm
+
+ Can @reward.PointCost diem @(reward.PointCost <= _member.CurrentExp ? "(Du diem)" : "") +
}
@@ -132,37 +196,170 @@
}
+ }
@code { - // EN: Static UI configuration — does not require DB data (needs customer API) / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB (cần customer API) + // EN: Customer ID from route parameter / VI: ID khach hang tu route parameter + [Parameter] public Guid CustomerId { get; set; } + + // EN: Loading state / VI: Trang thai tai + private bool _isLoading = true; + private bool _loadError; + + // EN: Member data from API / VI: Du lieu thanh vien tu API + private WebClientTpos.Client.Services.PosDataService.MemberInfo? _member; + private WebClientTpos.Client.Services.PosDataService.MemberProgressInfo? _progress; + private List _expHistory = new(); + private List _appointments = new(); + + // EN: Derived display values / VI: Gia tri hien thi + private string _memberName = ""; + private string _memberInitial = "?"; + private int _visitCount; + private decimal _totalSpent; + private int _progressPct; + private List _favorites = new(); private RewardInfo? _selectedReward; - private readonly List _visitHistory = new() - { - new("Massage toàn thân + Facial", "15/02/2025", "Chị Hoa", 850_000, 85), - new("Tắm trắng toàn thân", "08/02/2025", "Chị Lan", 800_000, 80), - new("Gội đầu dưỡng sinh + Massage chân", "01/02/2025", "Anh Minh", 450_000, 45), - new("Facial collagen", "25/01/2025", "Chị Hoa", 600_000, 60), - new("Sơn gel + Chăm sóc móng tay", "18/01/2025", "Chị Trang", 270_000, 27), - }; - // EN: Favorite services / VI: Dịch vụ yêu thích - private readonly string[] _favorites = { "Massage toàn thân", "Facial collagen", "Gội đầu dưỡng sinh", "Sơn gel" }; - - // EN: Available rewards / VI: Phần thưởng khả dụng + // EN: Available rewards (config-based) / VI: Phan thuong kha dung (cau hinh) private readonly List _rewards = new() { - new("RW1", "Giảm 20% dịch vụ", "-20%", "Áp dụng cho mọi dịch vụ đơn lẻ", 500), - new("RW2", "Free Massage chân", "Miễn phí", "1 lần Massage chân 45 phút miễn phí", 800), - new("RW3", "Giảm 100K", "-100,000₫", "Giảm 100K cho hóa đơn từ 500K", 400), - new("RW4", "Nâng hạng dịch vụ", "Upgrade", "Nâng Facial cơ bản lên Facial collagen", 600), + new("RW1", "Giam 20% dich vu", "-20%", "Ap dung cho moi dich vu don le", 500), + new("RW2", "Free Massage chan", "Mien phi", "1 lan Massage chan 45 phut mien phi", 800), + new("RW3", "Giam 100K", "-100,000d", "Giam 100K cho hoa don tu 500K", 400), + new("RW4", "Nang hang dich vu", "Upgrade", "Nang Facial co ban len Facial collagen", 600), }; - private record VisitInfo(string Services, string Date, string Therapist, decimal Amount, int Points); + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + // EN: If no CustomerId provided, redirect to lookup / VI: Neu khong co CustomerId, chuyen den tim kiem + if (CustomerId == Guid.Empty) + { + // EN: Try to get first member as demo / VI: Thu lay thanh vien dau tien lam demo + try + { + var members = await DataService.GetMembersAsync(); + if (members.Any()) + { + _member = members.First(); + CustomerId = _member.Id; + } + else + { + NavigateTo("spa/customer-lookup"); + return; + } + } + catch + { + NavigateTo("spa/customer-lookup"); + return; + } + } + + try + { + // EN: Load member by ID — search in member list / VI: Tai thanh vien theo ID — tim trong danh sach + if (_member == null) + { + var members = await DataService.GetMembersAsync(); + _member = members.FirstOrDefault(m => m.Id == CustomerId); + } + + if (_member == null) + { + _loadError = true; + return; + } + + // EN: Set display values / VI: Dat gia tri hien thi + _memberName = _member.DisplayName ?? $"Thanh vien #{_member.Id.ToString()[..8]}"; + _memberInitial = string.IsNullOrEmpty(_memberName) ? "?" : _memberName[..1].ToUpper(); + + // EN: Load progress info / VI: Tai thong tin tien trinh + try + { + _progress = await DataService.GetMemberProgressAsync(_member.Id); + if (_progress != null && _progress.ExpToNextLevel > 0) + { + _progressPct = Math.Min(100, (int)((decimal)_member.CurrentExp / _progress.ExpToNextLevel * 100)); + } + } + catch { /* EN: Non-critical / VI: Khong quan trong */ } + + // EN: Load experience history / VI: Tai lich su kinh nghiem + try + { + _expHistory = await DataService.GetExperienceHistoryAsync(_member.Id); + _visitCount = _expHistory.Count; + _totalSpent = _expHistory.Sum(e => e.Points) * 10_000m; // EN: Rough estimate / VI: Uoc tinh + } + catch { /* EN: Non-critical / VI: Khong quan trong */ } + + // EN: Load appointment history / VI: Tai lich su lich hen + try + { + var allAppts = await DataService.GetAppointmentsAsync(ShopId); + _appointments = allAppts + .Where(a => a.CustomerId == CustomerId) + .OrderByDescending(a => a.StartTime) + .ToList(); + + if (_appointments.Any()) + { + _visitCount = Math.Max(_visitCount, _appointments.Count); + // EN: Build favorites from most common services / VI: Xay dung yeu thich tu dich vu pho bien + _favorites = _appointments + .Where(a => !string.IsNullOrEmpty(a.ResourceName)) + .GroupBy(a => a.ResourceName!) + .OrderByDescending(g => g.Count()) + .Take(4) + .Select(g => g.Key) + .ToList(); + } + } + catch { /* EN: Non-critical / VI: Khong quan trong */ } + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + } + } + + private static string GetLevelBadgeBg(int level) => level switch + { + >= 4 => "rgba(139,92,246,.2)", + 3 => "rgba(245,158,11,.2)", + 2 => "rgba(192,192,192,.2)", + _ => "rgba(139,90,43,.2)" + }; + + private static string GetLevelBadgeColor(int level) => level switch + { + >= 4 => "#8B5CF6", + 3 => "#F59E0B", + 2 => "#C0C0C0", + _ => "#8B5A2B" + }; + + private static string FormatCompact(decimal amount) + { + if (amount >= 1_000_000) return $"{amount / 1_000_000:0.#}M"; + if (amount >= 1_000) return $"{amount / 1_000:0.#}K"; + return amount.ToString("N0"); + } + private record RewardInfo(string Id, string Name, string Value, string Description, int PointCost); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/ServiceCombo.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/ServiceCombo.razor index e609f1c3..acb7bad9 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/ServiceCombo.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/ServiceCombo.razor @@ -1,40 +1,65 @@ @* - EN: Spa Service Combo — Active combos/promotions, bundle discounts, buy 2 get 1, timer, apply combo. - VI: Combo dịch vụ Spa — Combo/khuyến mãi đang áp dụng, giảm giá gộp, mua 2 tặng 1, đếm ngược, áp dụng. + EN: Spa Service Combo — Active combos/promotions built from real product data, bundle discounts, apply combo. + VI: Combo dich vu Spa — Combo/khuyen mai xay dung tu du lieu san pham that, giam gia gop, ap dung. *@ @page "/pos/{ShopId:guid}/spa/service-combo" @layout PosLayout @inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService
- @* ═══ HEADER / TIÊU ĐỀ ═══ *@ + @* === HEADER / TIEU DE === *@
- Combo & Khuyến mãi + Combo & Khuyen mai + @if (!_isLoading) + { + @_combos.Count combo + }
-
- @* ═══ FLASH SALE BANNER / BANNER FLASH SALE ═══ *@ -
-
- - FLASH SALE — KẾT THÚC TRONG -
-
- 02:15:30 -
-
- Giảm thêm 15% cho tất cả combo hôm nay! -
+ @if (_isLoading) + { +
+ Dang tai...
+ } + else if (_loadError) + { +
+ Khong the tai du lieu +
+ } + else + { +
+ @* === PROMOTION BANNER / BANNER KHUYEN MAI === *@ + @if (_activePromotion != null) + { +
+
+ + @_activePromotion.Name +
+ @if (_activePromotion.EndDate.HasValue) + { +
+ Ket thuc: @_activePromotion.EndDate.Value.ToString("dd/MM/yyyy HH:mm") +
+ } +
+ @(_activePromotion.Description ?? "Giam gia dac biet hom nay!") +
+
+ } - @* ═══ COMBO LIST / DANH SÁCH COMBO ═══ *@ + @* === COMBO LIST / DANH SACH COMBO === *@
@foreach (var combo in _combos) { @@ -48,17 +73,15 @@
@combo.Name - @if (combo.Limited) + @if (combo.ServiceCount >= 3) { - Có hạn - + background:rgba(255,92,0,.15);color:var(--pos-orange-primary);">HOT }
@combo.Description
- @* EN: Included services / VI: Dịch vụ bao gồm *@ + @* EN: Included services / VI: Dich vu bao gom *@
@foreach (var svc in combo.Services) { @@ -69,7 +92,7 @@ }
- @* EN: Price comparison / VI: So sánh giá *@ + @* EN: Price comparison / VI: So sanh gia *@
@FormatPrice(combo.OriginalPrice) @@ -77,14 +100,17 @@ @FormatPrice(combo.ComboPrice) - - -@(Math.Round((combo.OriginalPrice - combo.ComboPrice) * 100 / combo.OriginalPrice))% - + @if (combo.OriginalPrice > 0) + { + + -@(Math.Round((combo.OriginalPrice - combo.ComboPrice) * 100 / combo.OriginalPrice))% + + }
- @* EN: Apply button / VI: Nút áp dụng *@ + @* EN: Apply button / VI: Nut ap dung *@
}
+ + @if (!_combos.Any()) + { +
+ + Chua co combo nao. Them san pham de tao combo tu dong. +
+ }
- @* ═══ FOOTER / CHÂN TRANG ═══ *@ + @* === FOOTER / CHAN TRANG === *@ @if (_selectedCombo is not null) {
-
} + }
@code { - // EN: Static UI configuration — combo definitions are config, does not require DB data / VI: Cấu hình UI tĩnh — định nghĩa combo là cấu hình, không cần dữ liệu từ DB + // EN: Loading state / VI: Trang thai tai + private bool _isLoading = true; + private bool _loadError; private string? _selectedCombo; - private readonly List _combos = new() + + // EN: Combos built from product data / VI: Combo xay dung tu du lieu san pham + private List _combos = new(); + + // EN: Active promotion from API / VI: Khuyen mai dang ap dung tu API + private WebClientTpos.Client.Services.PosDataService.PromotionInfo? _activePromotion; + + // EN: Combo definitions — groups of services that form combos + // VI: Dinh nghia combo — nhom dich vu tao thanh combo + private static readonly List ComboConfigs = new() { - new("CB1", "Mua 2 tặng 1", "Mua 2 dịch vụ Massage bất kỳ, tặng 1 Massage chân miễn phí", - 1_000_000, 750_000, true, "gift", "rgba(255,92,0,.15)", "#FF5C00", - new() { "Massage toàn thân", "Massage đầu vai cổ", "Massage chân (FREE)" }), - new("CB2", "Combo Spa Day", "Trọn gói thư giãn cả ngày: Massage + Facial + Nail", - 950_000, 750_000, true, "sun", "rgba(245,158,11,.15)", "#F59E0B", - new() { "Massage toàn thân", "Facial cơ bản", "Sơn gel" }), - new("CB3", "Combo Làm đẹp", "Tẩy tế bào chết + Tắm trắng giá đặc biệt", - 1_200_000, 950_000, false, "sparkles", "rgba(139,92,246,.15)", "#8B5CF6", - new() { "Tẩy tế bào chết", "Tắm trắng toàn thân" }), - new("CB4", "Combo Thứ 3 vui vẻ", "Giảm 30% mọi dịch vụ Facial vào thứ 3 hàng tuần", - 600_000, 420_000, true, "calendar", "rgba(34,197,94,.15)", "#22C55E", - new() { "Facial cơ bản", "Facial collagen" }), + new("CB1", "Mua 2 tang 1", "Mua 2 dich vu Massage bat ky, tang 1 Massage chan mien phi", + 0.75m, "gift", "rgba(255,92,0,.15)", "#FF5C00", + new() { "Massage", "massage" }), + new("CB2", "Combo Spa Day", "Tron goi thu gian ca ngay: Massage + Facial + Nail", + 0.79m, "sun", "rgba(245,158,11,.15)", "#F59E0B", + new() { "Massage", "Facial", "Nail", "Son" }), + new("CB3", "Combo Lam dep", "Cham soc da + Tam trang gia dac biet", + 0.80m, "sparkles", "rgba(139,92,246,.15)", "#8B5CF6", + new() { "Tam", "Tay te bao", "Facial" }), }; + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + try + { + var products = await DataService.GetProductsAsync(ShopId); + + // EN: Try to load promotions / VI: Thu tai khuyen mai + try + { + var promos = await DataService.GetPromotionsAsync(); + _activePromotion = promos + .Where(p => p.IsActive && (!p.EndDate.HasValue || p.EndDate > DateTime.Now)) + .FirstOrDefault(); + } + catch { /* EN: No promotions available / VI: Khong co khuyen mai */ } + + // EN: Build combos from real products / VI: Xay dung combo tu san pham that + if (products.Any()) + { + _combos = ComboConfigs.Select((cfg, idx) => + { + // EN: Find products matching combo keywords / VI: Tim san pham khop voi tu khoa combo + var matchedProducts = products + .Where(p => cfg.Keywords.Any(k => + p.Name.Contains(k, StringComparison.OrdinalIgnoreCase) || + (p.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) ?? false))) + .Take(4) + .ToList(); + + if (!matchedProducts.Any()) + { + // EN: Fallback — use random products for this combo / VI: Du phong — dung san pham ngau nhien + matchedProducts = products.Skip(idx * 2).Take(3).ToList(); + } + + var serviceNames = matchedProducts.Select(p => p.Name).ToList(); + var originalPrice = matchedProducts.Sum(p => p.Price); + var comboPrice = Math.Round(originalPrice * cfg.DiscountFactor); + + return new ComboInfo(cfg.Id, cfg.Name, cfg.Description, + originalPrice, comboPrice, serviceNames.Count, + cfg.Icon, cfg.BgColor, cfg.FgColor, serviceNames); + }) + .Where(c => c.OriginalPrice > 0) + .ToList(); + } + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + } + } + + private record ComboConfig(string Id, string Name, string Description, decimal DiscountFactor, + string Icon, string BgColor, string FgColor, List Keywords); private record ComboInfo(string Id, string Name, string Description, decimal OriginalPrice, - decimal ComboPrice, bool Limited, string Icon, string BgColor, string FgColor, List Services); + decimal ComboPrice, int ServiceCount, string Icon, string BgColor, string FgColor, List Services); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/ServicePackage.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/ServicePackage.razor index e00f1742..c0279f4e 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/ServicePackage.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/ServicePackage.razor @@ -106,8 +106,8 @@
} @@ -161,13 +161,19 @@ return new PackageService(name, 0, 0); }).ToList(); - var originalPrice = services.Sum(s => s.Price); - var packagePrice = Math.Round(originalPrice * cfg.DiscountFactor); + // EN: Filter out services with no data (price=0, duration=0) + // VI: Loc bo dich vu khong co du lieu (gia=0, thoi luong=0) + var validServices = services.Where(s => s.Price > 0).ToList(); + var originalPrice = validServices.Sum(s => s.Price); + var packagePrice = originalPrice > 0 ? Math.Round(originalPrice * cfg.DiscountFactor) : 0; var totalDuration = services.Sum(s => s.Duration); return new PackageInfo(cfg.Id, cfg.Name, packagePrice, originalPrice, - services.Count, totalDuration, cfg.Popular, cfg.Icon, cfg.BgColor, cfg.FgColor, services); - }).ToList(); + validServices.Count > 0 ? validServices.Count : services.Count, + totalDuration, cfg.Popular, cfg.Icon, cfg.BgColor, cfg.FgColor, services); + }) + .Where(p => p.OriginalPrice > 0) // EN: Only show packages with real pricing / VI: Chi hien goi co gia that + .ToList(); } catch { @@ -179,6 +185,15 @@ } } + // EN: Navigate to appointment booking with selected package services + // VI: Dieu huong den dat lich hen voi dich vu goi da chon + private void AddPackageToAppointment(PackageInfo pkg) + { + // EN: Navigate to appointment-book page — package info passed via route + // VI: Chuyen den trang dat lich hen — thong tin goi truyen qua route + NavigateTo("spa/appointment-book"); + } + private record PackageConfig(string Id, string Name, decimal DiscountFactor, bool Popular, string Icon, string BgColor, string FgColor, List ServiceNames); private record PackageService(string Name, decimal Price, int Duration); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/StaffAssign.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/StaffAssign.razor index dc29f2b1..01482144 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/StaffAssign.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/StaffAssign.razor @@ -5,6 +5,7 @@ @page "/pos/{ShopId:guid}/spa/staff-assign" @layout PosLayout @inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService
@* ═══ HEADER / TIÊU ĐỀ ═══ *@ @@ -32,6 +33,26 @@ @* ═══ STAFF LIST / DANH SÁCH NHÂN VIÊN ═══ *@
+ @if (_isLoading) + { +
+ Đang tải nhân viên... +
+ } + else if (_loadError) + { +
+ Không thể tải dữ liệu nhân viên +
+ } + else if (!_staff.Any()) + { +
+ Chưa có nhân viên nào +
+ } + else + {
@foreach (var staff in FilteredStaff) { @@ -98,6 +119,7 @@
}
+ }
@* ═══ FOOTER / CHÂN TRANG ═══ *@ @@ -112,28 +134,53 @@
@code { - // EN: Static UI configuration — does not require DB data (needs staff API) / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB (cần staff API) + // EN: Loading state / VI: Trạng thái tải + private bool _isLoading = true; + private bool _loadError; private string _activeFilter = "Tất cả"; - private string? _selectedStaff = "S01"; + private string? _selectedStaff; private readonly string[] _filters = { "Tất cả", "Rảnh", "Đang bận", "Nghỉ giải lao" }; - // EN: Demo staff / VI: Nhân viên mẫu - private readonly List _staff = new() + // EN: Staff from API / VI: Nhân viên từ API + private List _staff = new(); + + protected override async Task OnInitializedAsync() { - new("S01", "Trần Thị Hoa", "Chuyên viên Massage", "available", 5, 128, - new() { "Massage", "Body", "Thái", "Đá nóng" }), - new("S02", "Nguyễn Minh Tú", "Chuyên viên Facial", "available", 4, 95, - new() { "Facial", "Collagen", "Acne", "Whitening" }), - new("S03", "Lê Thị Lan", "Kỹ thuật viên Nail", "busy", 5, 156, - new() { "Nail art", "Gel", "Dip powder", "Acrylic" }), - new("S04", "Phạm Văn Minh", "Chuyên viên Massage", "available", 4, 82, - new() { "Massage", "Sport", "Shiatsu", "Reflexology" }), - new("S05", "Hoàng Thị Trang", "Chuyên viên Hair & Body", "break", 4, 67, - new() { "Hair", "Gội dưỡng", "Body scrub", "Tắm trắng" }), - new("S06", "Đỗ Thanh Hằng", "Chuyên viên Facial & Body", "available", 5, 143, - new() { "Facial", "Body", "Detox", "Anti-aging" }), - }; + await base.OnInitializedAsync(); + + try + { + var staffData = await DataService.GetStaffForShopAsync(ShopId); + + _staff = staffData.Select(s => + { + var name = $"{s.FirstName ?? ""} {s.LastName ?? ""}".Trim(); + if (string.IsNullOrEmpty(name)) name = s.EmployeeCode ?? s.Id.ToString()[..8]; + var status = (s.Status?.ToLower()) switch + { + "active" or "available" => "available", + "busy" or "occupied" => "busy", + "break" or "onbreak" => "break", + _ => "available" + }; + var role = s.Role ?? "Nhân viên"; + // EN: Placeholder skills/rating — staff API does not include these / VI: Kỹ năng/đánh giá giữ chỗ — API nhân viên không có + return new StaffInfo(s.Id.ToString(), name, role, status, 4, 0, new List { role }); + }).ToList(); + + if (_staff.Any()) + _selectedStaff = _staff.First().Id; + } + catch + { + _loadError = true; + } + finally + { + _isLoading = false; + } + } private IEnumerable FilteredStaff => _activeFilter switch { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/TherapistSchedule.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/TherapistSchedule.razor index 4de490aa..fbe9c8ed 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/TherapistSchedule.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/TherapistSchedule.razor @@ -5,6 +5,7 @@ @page "/pos/{ShopId:guid}/spa/therapist-schedule" @layout PosLayout @inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService
@* ═══ HEADER / TIÊU ĐỀ ═══ *@ @@ -16,7 +17,7 @@ Lịch kỹ thuật viên - Hôm nay, 20/02/2025 + Hôm nay, @DateTime.Now.ToString("dd/MM/yyyy")
@* EN: Legend / VI: Chú thích *@ @@ -37,6 +38,20 @@
@* ═══ SCHEDULE GRID / LƯỚI LỊCH ═══ *@ + @if (_isLoading) + { +
+ Đang tải lịch... +
+ } + else if (_loadError) + { +
+ Không thể tải dữ liệu lịch +
+ } + else + {
@* EN: Time header row / VI: Hàng tiêu đề giờ *@
@@ -107,56 +122,118 @@
+ } + @* ═══ SUMMARY / TÓM TẮT ═══ *@
Tổng: @_scheduleData.Sum(s => s.Appointments.Count) lịch hẹn - Đang thực hiện: 3 - Hoàn thành: 5 - Sắp tới: 8 + Đang thực hiện: @_inProgressCount + Hoàn thành: @_completedCount + Sắp tới: @_upcomingCount
@code { - // EN: Static UI configuration — does not require DB data (needs schedule API) / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB (cần schedule API) + // EN: Loading state / VI: Trạng thái tải + private bool _isLoading = true; + private bool _loadError; // EN: Hours range / VI: Phạm vi giờ private readonly int[] _hours = { 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 }; - private readonly List _scheduleData = new() + private List _scheduleData = new(); + + // EN: Summary counters / VI: Bộ đếm tóm tắt + private int _inProgressCount; + private int _completedCount; + private int _upcomingCount; + + protected override async Task OnInitializedAsync() { - new("Trần Thị Hoa", "Massage", new() + await base.OnInitializedAsync(); + + try { - new("Chị Mai", "Massage toàn thân", "Massage", 9, 0, 60), - new("Anh Tuấn", "Massage chân", "Massage", 10, 30, 45), - new("Chị Hương", "Massage đầu vai cổ", "Massage", 13, 0, 30), - new("Chị Lan", "Massage toàn thân", "Massage", 15, 0, 60), - }), - new("Nguyễn Minh Tú", "Facial", new() + // EN: Load staff and appointments in parallel / VI: Tải nhân viên và lịch hẹn song song + var staffTask = DataService.GetStaffForShopAsync(ShopId); + var appointmentsTask = DataService.GetAppointmentsAsync(ShopId); + + var staffData = await staffTask; + var appointments = await appointmentsTask; + + // EN: Filter today's appointments / VI: Lọc lịch hẹn hôm nay + var todayAppointments = appointments + .Where(a => a.StartTime.Date == DateTime.Today) + .ToList(); + + // EN: Count by status / VI: Đếm theo trạng thái + var now = DateTime.Now; + _completedCount = todayAppointments.Count(a => a.Status?.ToLower() == "completed"); + _inProgressCount = todayAppointments.Count(a => a.StartTime <= now && a.EndTime >= now && a.Status?.ToLower() != "completed" && a.Status?.ToLower() != "cancelled"); + _upcomingCount = todayAppointments.Count(a => a.StartTime > now && a.Status?.ToLower() != "cancelled"); + + // EN: Group appointments by staff / VI: Nhóm lịch hẹn theo nhân viên + var staffMap = staffData.ToDictionary(s => s.Id, s => + { + var name = $"{s.FirstName ?? ""} {s.LastName ?? ""}".Trim(); + if (string.IsNullOrEmpty(name)) name = s.EmployeeCode ?? s.Id.ToString()[..8]; + return (Name: name, Role: s.Role ?? "Nhân viên"); + }); + + // EN: Build schedule data — group by staff / VI: Xây dựng dữ liệu lịch — nhóm theo nhân viên + var grouped = todayAppointments + .Where(a => a.StaffId.HasValue) + .GroupBy(a => a.StaffId!.Value) + .ToList(); + + _scheduleData = new(); + + foreach (var group in grouped) + { + var staffName = staffMap.TryGetValue(group.Key, out var info) ? info.Name : group.Key.ToString()[..8]; + var staffRole = staffMap.TryGetValue(group.Key, out var info2) ? info2.Role : "Nhân viên"; + + var blocks = group.Select(a => new AppointmentBlock( + a.ResourceName ?? "Khách", + a.ResourceName ?? "Dịch vụ", + GuessServiceType(a.ResourceName), + a.StartTime.Hour, + a.StartTime.Minute, + (int)(a.EndTime - a.StartTime).TotalMinutes + )).ToList(); + + _scheduleData.Add(new StaffSchedule(staffName, staffRole, blocks)); + } + + // EN: Add staff without appointments / VI: Thêm nhân viên chưa có lịch hẹn + var assignedStaffIds = grouped.Select(g => g.Key).ToHashSet(); + foreach (var s in staffData.Where(s => !assignedStaffIds.Contains(s.Id))) + { + var name = $"{s.FirstName ?? ""} {s.LastName ?? ""}".Trim(); + if (string.IsNullOrEmpty(name)) name = s.EmployeeCode ?? s.Id.ToString()[..8]; + _scheduleData.Add(new StaffSchedule(name, s.Role ?? "Nhân viên", new())); + } + } + catch { - new("Chị Châu", "Facial collagen", "Facial", 9, 30, 60), - new("Chị Ngọc", "Facial cơ bản", "Facial", 11, 0, 45), - new("Chị Linh", "Facial collagen", "Facial", 14, 0, 60), - }), - new("Phạm Văn Minh", "Massage", new() + _loadError = true; + } + finally { - new("Anh Đức", "Massage toàn thân", "Massage", 10, 0, 60), - new("Chị Thảo", "Massage chân", "Massage", 12, 0, 45), - new("Anh Hùng", "Massage đầu vai cổ", "Massage", 14, 30, 30), - new("Chị Yến", "Massage toàn thân", "Massage", 16, 0, 60), - }), - new("Lê Thị Lan", "Nail", new() - { - new("Chị Mai", "Nail art cao cấp", "Nail", 9, 0, 60), - new("Chị Hoa", "Sơn gel", "Nail", 11, 0, 30), - new("Chị Trang", "Chăm sóc móng tay", "Nail", 13, 30, 30), - }), - new("Hoàng Thị Trang", "Hair & Body", new() - { - new("Chị Giang", "Tắm trắng toàn thân", "Body", 9, 30, 90), - new("Chị Phương", "Gội đầu dưỡng sinh", "Hair", 12, 0, 40), - new("Chị Hằng", "Tẩy tế bào chết", "Body", 14, 0, 45), - new("Chị Vy", "Ủ tóc phục hồi", "Hair", 16, 30, 45), - }), - }; + _isLoading = false; + } + } + + // EN: Guess service type from name for color coding / VI: Đoán loại dịch vụ từ tên để tô màu + private static string GuessServiceType(string? name) + { + if (name is null) return "Other"; + var lower = name.ToLower(); + if (lower.Contains("massage")) return "Massage"; + if (lower.Contains("facial") || lower.Contains("mặt")) return "Facial"; + if (lower.Contains("body") || lower.Contains("tắm") || lower.Contains("tẩy")) return "Body"; + if (lower.Contains("nail") || lower.Contains("hair") || lower.Contains("tóc") || lower.Contains("móng")) return "Nail"; + return "Other"; + } private static string GetServiceBg(string type) => type switch { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/TreatmentTimer.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/TreatmentTimer.razor index f4c36e9e..73a513a9 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/TreatmentTimer.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Spa/Workflow/TreatmentTimer.razor @@ -3,8 +3,11 @@ VI: Đồng hồ trị liệu Spa — Đếm ngược tròn lớn, thông tin dịch vụ, khách/KTV, gia hạn, hoàn thành, ghi chú. *@ @page "/pos/{ShopId:guid}/spa/treatment-timer" +@page "/pos/{ShopId:guid}/spa/treatment-timer/{AppointmentId:guid}" @layout PosLayout @inherits PosBase +@inject WebClientTpos.Client.Services.PosDataService DataService +@implements IDisposable
@* ═══ TIMER PANEL (LEFT) / PANEL ĐỒNG HỒ (TRÁI) ═══ *@ @@ -43,8 +46,8 @@ @* EN: Progress bar / VI: Thanh tiến trình *@
- Bắt đầu: 14:00 - Dự kiến: 15:00 + Bắt đầu: @_startTime.ToString("HH:mm") + Dự kiến: @_endTime.ToString("HH:mm")
@@ -53,27 +56,27 @@ @* ═══ SERVICE INFO / THÔNG TIN DỊCH VỤ ═══ *@
-
Massage toàn thân
+
@_serviceName
Khách: - Nguyễn Thị Mai + @_customerName
KTV: - Trần Thị Hoa + @_therapistName
Thời gian: - 60 phút + @_totalDuration phút
Giá: - @FormatPrice(500_000) + @FormatPrice(_servicePrice)
@@ -128,11 +131,11 @@
Bắt đầu - 14:00 + @_startTime.ToString("HH:mm")
Dự kiến kết thúc - 15:00 + @_endTime.ToString("HH:mm")
Đã gia hạn @@ -148,31 +151,137 @@
@code { - // EN: Static UI configuration — does not require DB data / VI: Cấu hình UI tĩnh — không cần dữ liệu từ DB + // EN: Route parameter for appointment ID / VI: Tham số route cho ID lịch hẹn + [Parameter] public Guid? AppointmentId { get; set; } - private string _remainingTime = "45:00"; + // EN: Timer state / VI: Trạng thái đồng hồ + private string _remainingTime = "00:00"; private int _totalDuration = 60; - private double _progress = 0.25; + private double _progress = 0.0; private int _extendedMinutes = 0; - private string _notes = "Khách yêu cầu lực massage vừa phải. Chú ý vùng vai bị đau."; + private string _notes = ""; + private bool _isCompleting; + private System.Threading.Timer? _timer; + + // EN: Appointment details / VI: Chi tiết lịch hẹn + private string _serviceName = "Dịch vụ"; + private string _customerName = "Khách"; + private string _therapistName = "KTV"; + private decimal _servicePrice = 0; + private DateTime _startTime = DateTime.Now; + private DateTime _endTime = DateTime.Now.AddHours(1); private readonly int[] _extendOptions = { 15, 30, 45 }; + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + try + { + if (AppointmentId.HasValue) + { + // EN: Load appointment details / VI: Tải chi tiết lịch hẹn + var appt = await DataService.GetAppointmentByIdAsync(AppointmentId.Value); + if (appt is not null) + { + _startTime = appt.StartTime; + _endTime = appt.EndTime; + _totalDuration = (int)(appt.EndTime - appt.StartTime).TotalMinutes; + _serviceName = appt.ResourceName ?? "Dịch vụ"; + + // EN: Load staff name if available / VI: Tải tên nhân viên nếu có + if (appt.StaffId.HasValue) + { + var staffList = await DataService.GetStaffForShopAsync(ShopId); + var staff = staffList.FirstOrDefault(s => s.Id == appt.StaffId.Value); + if (staff is not null) + { + var name = $"{staff.FirstName ?? ""} {staff.LastName ?? ""}".Trim(); + _therapistName = string.IsNullOrEmpty(name) ? (staff.EmployeeCode ?? "KTV") : name; + } + } + } + } + else + { + // EN: Fallback — use default 60 min from now / VI: Mặc định — 60 phút từ hiện tại + _startTime = DateTime.Now; + _totalDuration = 60; + _endTime = _startTime.AddMinutes(_totalDuration); + } + } + catch + { + // EN: Use defaults on error / VI: Dùng giá trị mặc định khi lỗi + } + + // EN: Start real countdown timer / VI: Bắt đầu đồng hồ đếm ngược thật + UpdateTimerDisplay(); + _timer = new System.Threading.Timer(TimerCallback, null, 0, 1000); + } + + private void TimerCallback(object? state) + { + InvokeAsync(() => + { + UpdateTimerDisplay(); + StateHasChanged(); + }); + } + + private void UpdateTimerDisplay() + { + var totalSeconds = (int)(_endTime.AddMinutes(_extendedMinutes) - DateTime.Now).TotalSeconds; + if (totalSeconds < 0) totalSeconds = 0; + + var minutes = totalSeconds / 60; + var seconds = totalSeconds % 60; + _remainingTime = $"{minutes:D2}:{seconds:D2}"; + + var totalDurationSeconds = (_totalDuration + _extendedMinutes) * 60; + var elapsed = totalDurationSeconds - totalSeconds; + _progress = totalDurationSeconds > 0 ? Math.Clamp((double)elapsed / totalDurationSeconds, 0, 1) : 0; + } + private void ExtendTime(int minutes) { _extendedMinutes += minutes; - _totalDuration += minutes; } - private void CompleteTreatment() => NavigateTo("spa/spa-journey"); + private async Task CompleteTreatment() + { + _isCompleting = true; + try + { + if (AppointmentId.HasValue) + { + await DataService.UpdateAppointmentStatusAsync(AppointmentId.Value, "complete"); + } + NavigateTo("spa/therapist-schedule"); + } + catch + { + NavigateTo("spa/therapist-schedule"); + } + finally + { + _isCompleting = false; + } + } + + public void Dispose() + { + _timer?.Dispose(); + } } 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 478dcaa2..c93f5f46 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 @@ -629,10 +629,11 @@ public class PosDataService // ═══ PAY ORDER ═══ - public async Task PayOrderAsync(Guid orderId, Guid shopId) + public async Task PayOrderAsync(Guid orderId, Guid shopId, string? paymentMethod = null) { AttachToken(); - var resp = await _http.PostAsJsonAsync($"api/bff/orders/{orderId}/pay?shopId={shopId}", new { }, _writeOptions); + var body = paymentMethod != null ? new { PaymentMethod = paymentMethod } : (object)new { }; + var resp = await _http.PostAsJsonAsync($"api/bff/orders/{orderId}/pay?shopId={shopId}", body, _writeOptions); return resp.IsSuccessStatusCode; } @@ -816,6 +817,18 @@ public class PosDataService public async Task CancelAppointmentAsync(Guid apptId) { AttachToken(); var r = await _http.DeleteAsync($"api/bff/appointments/{apptId}/cancel"); return r.IsSuccessStatusCode; } + // EN: Get single appointment by ID / VI: Lấy appointment theo ID + public async Task GetAppointmentByIdAsync(Guid appointmentId) + => await GetObjectFromApiAsync($"api/bff/appointments/{appointmentId}"); + + // EN: Update appointment status (confirm, start, complete) / VI: Cập nhật trạng thái appointment + public async Task UpdateAppointmentStatusAsync(Guid appointmentId, string action) + { AttachToken(); var r = await _http.PatchAsync($"api/bff/appointments/{appointmentId}/status", JsonContent.Create(new { action }, options: _writeOptions)); return r.IsSuccessStatusCode; } + + // EN: Search customers/members / VI: Tìm kiếm khách hàng/thành viên + public async Task> SearchCustomersAsync(Guid shopId, string query) + => await GetMembersAsync(query); + // ═══ RESOURCES CRUD ═══ public record CreateResourceRequest(Guid ShopId, string Name, string ResourceType, int Capacity); @@ -1121,6 +1134,29 @@ public class PosDataService return resp.IsSuccessStatusCode; } + /// + /// EN: Extend an active session by additional minutes. Uses table update to persist extended time. + /// VI: Gia hạn phiên đang hoạt động thêm số phút. Dùng table update để lưu thời gian gia hạn. + /// + public async Task ExtendSessionAsync(Guid tableId, int additionalMinutes) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync($"api/bff/tables/{tableId}/extend", + new { additionalMinutes }, _writeOptions); + // EN: Fallback — if dedicated extend endpoint doesn't exist, use PATCH status to signal extension + // VI: Dự phòng — nếu endpoint extend chưa có, dùng PATCH status để báo hiệu gia hạn + if ((int)resp.StatusCode == 404) + { + var patch = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/tables/{tableId}/status") + { + Content = JsonContent.Create(new { status = "occupied", extendMinutes = additionalMinutes }, options: _writeOptions) + }; + var fallback = await _http.SendAsync(patch); + return fallback.IsSuccessStatusCode; + } + return resp.IsSuccessStatusCode; + } + // ═══ SHOP PUBLISH (draft → active) ═══ public async Task PublishShopAsync(Guid shopId)