From e748c43b22b9158451c10284e3f13bd3c1bb0b47 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 5 Mar 2026 08:44:47 +0700 Subject: [PATCH] feat(shop-admin): add happy hour and promotion configuration UI and enhance room management with add/edit/delete functionality. --- .../Pages/Admin/Shop/ShopHappyHour.razor | 297 ++++++++++++++++++ .../Pages/Admin/Shop/ShopPage.razor | 2 +- .../Pages/Admin/Shop/ShopTables.razor | 56 +++- .../Pages/Pos/Karaoke/KaraokeDesktop.razor | 29 +- .../Pos/Karaoke/Workflow/HappyHour.razor | 4 +- 5 files changed, 354 insertions(+), 34 deletions(-) create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopHappyHour.razor diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopHappyHour.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopHappyHour.razor new file mode 100644 index 00000000..07dbd3ab --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopHappyHour.razor @@ -0,0 +1,297 @@ +@using WebClientTpos.Client.Services +@using WebClientTpos.Client.Pages.Admin.Shop +@inject IJSRuntime JS + +@* ═══ TIME-BASED PRICING ═══ *@ +
+
+

Bảng giá theo khung giờ

+ +
+
+ @if (_showTimeSlotForm) + { +
+
@(_editingSlotIdx >= 0 ? "Chỉnh sửa khung giờ" : "Thêm khung giờ mới")
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ } + + @if (!_timeSlots.Any()) + { +
+ +

Chưa có khung giờ nào. Nhấn "Thêm khung giờ" để bắt đầu.

+
+ } + else + { +
+ @for (var i = 0; i < _timeSlots.Count; i++) + { + var idx = i; + var slot = _timeSlots[i]; + var isActive = IsActiveSlot(slot.TimeRange); +
+
+ + +
+
+ @slot.TimeRange + @if (isActive) + { + Đang áp dụng + } +
+
+
+
Standard
+
@ShopHelpers.FormatVND(slot.Standard)
+
+
+
VIP
+
@ShopHelpers.FormatVND(slot.Vip)
+
+
+
Deluxe
+
@ShopHelpers.FormatVND(slot.Deluxe)
+
+
+ @if (slot.Discount > 0) + { +
+ Giảm @slot.Discount% giá phòng +
+ } +
+ } +
+ } +
+
+ +@* ═══ PROMOTIONS ═══ *@ +
+
+

Khuyến mãi

+ +
+
+ @if (_showPromoForm) + { +
+
+
+
+
+
+
+
+
+
+ + +
+
+ } + + @if (!_promotions.Any()) + { +
+ +

Chưa có khuyến mãi nào.

+
+ } + else + { +
+ @for (var i = 0; i < _promotions.Count; i++) + { + var idx = i; + var promo = _promotions[i]; + var iconColors = new[] { ("#FF5C00", "rgba(255,92,0,.15)"), ("#F59E0B", "rgba(245,158,11,.15)"), ("#3B82F6", "rgba(59,130,246,.15)"), ("#22C55E", "rgba(34,197,94,.15)") }; + var color = iconColors[idx % iconColors.Length]; + var icons = new[] { "cake", "wine", "crown", "users" }; +
+
+ +
+
+
@promo.Title
+
@promo.Description
+
+
+
@promo.Value
+
@promo.ValidUntil
+
+ +
+ } +
+ } +
+
+ +@code { + [Parameter] public Guid ShopId { get; set; } + + // Time slots + private List _timeSlots = new(); + private bool _showTimeSlotForm; + private int _editingSlotIdx = -1; + private string _slotTimeRange = ""; + private int _slotDiscount; + private decimal _slotStandard = 80000; + private decimal _slotVip = 150000; + private decimal _slotDeluxe = 200000; + + // Promotions + private List _promotions = new(); + private bool _showPromoForm; + private string _promoTitle = ""; + private string _promoDesc = ""; + private string _promoValue = ""; + private string _promoExpiry = ""; + + private string StorageKey => $"happy_hour_{ShopId}"; + + protected override async Task OnInitializedAsync() + { + await LoadFromStorage(); + if (!_timeSlots.Any()) + { + _timeSlots = new() + { + new("08:00 – 12:00", 30, 50000, 100000, 140000), + new("12:00 – 17:00", 20, 60000, 120000, 160000), + new("17:00 – 22:00", 0, 80000, 150000, 200000), + new("22:00 – 02:00", 10, 70000, 130000, 180000), + }; + _promotions = new() + { + new("Sinh nhật vui vẻ", "Giảm 50% phòng cho khách sinh nhật", "-50%", "Đến 31/03"), + new("Combo phòng + đồ uống", "2 giờ Standard + 1 két đồ uống", "350,000₫", "Đến 28/02"), + new("Thành viên Gold", "Giảm thêm 15% cho thành viên Gold", "-15%", "Thường trực"), + new("Nhóm 10+", "Miễn phí 1 giờ cho nhóm từ 10 khách", "Free 1h", "Đến 15/03"), + }; + await SaveToStorage(); + } + } + + private bool IsActiveSlot(string timeRange) + { + try + { + var parts = timeRange.Split('–', '—', '-'); + if (parts.Length != 2) return false; + var now = DateTime.Now.TimeOfDay; + var start = TimeSpan.Parse(parts[0].Trim()); + var end = TimeSpan.Parse(parts[1].Trim()); + if (end < start) return now >= start || now < end; + return now >= start && now < end; + } + catch { return false; } + } + + private void ToggleTimeSlotForm() + { + _showTimeSlotForm = !_showTimeSlotForm; + if (_showTimeSlotForm) { _editingSlotIdx = -1; _slotTimeRange = ""; _slotDiscount = 0; _slotStandard = 80000; _slotVip = 150000; _slotDeluxe = 200000; } + } + + private void EditSlot(int idx) + { + var slot = _timeSlots[idx]; + _editingSlotIdx = idx; + _slotTimeRange = slot.TimeRange; + _slotDiscount = slot.Discount; + _slotStandard = slot.Standard; + _slotVip = slot.Vip; + _slotDeluxe = slot.Deluxe; + _showTimeSlotForm = true; + } + + private async Task SaveTimeSlot() + { + if (string.IsNullOrWhiteSpace(_slotTimeRange)) return; + var slot = new TimeSlotConfig(_slotTimeRange.Trim(), _slotDiscount, _slotStandard, _slotVip, _slotDeluxe); + if (_editingSlotIdx >= 0 && _editingSlotIdx < _timeSlots.Count) + _timeSlots[_editingSlotIdx] = slot; + else + _timeSlots.Add(slot); + _showTimeSlotForm = false; + await SaveToStorage(); + } + + private async Task DeleteSlot(int idx) + { + if (idx >= 0 && idx < _timeSlots.Count) { _timeSlots.RemoveAt(idx); await SaveToStorage(); } + } + + private void TogglePromoForm() + { + _showPromoForm = !_showPromoForm; + if (_showPromoForm) { _promoTitle = ""; _promoDesc = ""; _promoValue = ""; _promoExpiry = ""; } + } + + private async Task SavePromo() + { + if (string.IsNullOrWhiteSpace(_promoTitle)) return; + _promotions.Add(new PromoConfig(_promoTitle.Trim(), _promoDesc.Trim(), _promoValue.Trim(), _promoExpiry.Trim())); + _showPromoForm = false; + await SaveToStorage(); + } + + private async Task DeletePromo(int idx) + { + if (idx >= 0 && idx < _promotions.Count) { _promotions.RemoveAt(idx); await SaveToStorage(); } + } + + private async Task SaveToStorage() + { + try + { + var data = System.Text.Json.JsonSerializer.Serialize(new HappyHourData(_timeSlots, _promotions)); + await JS.InvokeVoidAsync("localStorage.setItem", StorageKey, data); + } + catch { } + } + + private async Task LoadFromStorage() + { + try + { + var json = await JS.InvokeAsync("localStorage.getItem", StorageKey); + if (!string.IsNullOrEmpty(json)) + { + var data = System.Text.Json.JsonSerializer.Deserialize(json); + if (data != null) { _timeSlots = data.TimeSlots ?? new(); _promotions = data.Promotions ?? new(); } + } + } + catch { } + } + + private record TimeSlotConfig(string TimeRange, int Discount, decimal Standard, decimal Vip, decimal Deluxe); + private record PromoConfig(string Title, string Description, string Value, string ValidUntil); + private record HappyHourData(List TimeSlots, List Promotions); +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor index d495ca51..12f4cc3d 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor @@ -308,7 +308,7 @@ break; case "happy-hour": - @RenderStubSection("clock", "#F59E0B", "Happy Hour", "Cấu hình khung giờ giảm giá — tính năng đang phát triển.") + break; case "packages": diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor index 130578ce..1c2d531f 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor @@ -142,20 +142,40 @@ } else if (SubSection == "rooms") { +
+
+ Trống: @_tables.Count(t => t.Status == "available") + Đang hát: @_tables.Count(t => t.Status == "occupied") + Đã đặt: @_tables.Count(t => t.Status == "reserved") +
+ +
+ @if (_showTableForm) + { +
+

@(_editingTableId.HasValue ? "Chỉnh sửa phòng" : "Thêm phòng mới")

+
+
+
+
+
+
+
+ + +
+ @if (_tableFormMessage != null) {
@_tableFormMessage
} +
+
+ } @if (!_tables.Any()) { - @RenderEmpty("door-open", "#8B5CF6", "Chưa có phòng nào", "Thêm phòng để quản lý Karaoke", "plus-circle", "Thêm phòng") + @RenderEmpty("door-open", "#8B5CF6", "Chưa có phòng nào", "Nhấn 'Thêm phòng' ở trên để tạo phòng đầu tiên") } else { -
-
- Trống: @_tables.Count(t => t.Status == "available") - Đang hát: @_tables.Count(t => t.Status == "occupied") - Đã đặt: @_tables.Count(t => t.Status == "reserved") -
-
@_tables.Count phòng
-
@foreach (var room in _tables) { @@ -164,15 +184,17 @@ else if (SubSection == "rooms") var statusColor = room.Status switch { "available" => "#8B5CF6", "occupied" => "#EF4444", "reserved" => "#F59E0B", _ => "#6B6B6F" }; var statusText = room.Status switch { "available" => "Trống", "occupied" => "Đang hát", "reserved" => "Đã đặt", "cleaning" => "Dọn dẹp", _ => room.Status }; var roomType = (room.Zone ?? "").ToLower() switch { var z when z.Contains("vip") => ("VIP", "#F59E0B", 200000m), var z when z.Contains("party") => ("Party", "#EC4899", 350000m), _ => ("Standard", "#8B5CF6", 120000m) }; -
-
-
- - Phòng @room.TableNumber -
- @roomType.Item1 +
+
+ + +
-
@(room.Zone ?? "Chung") • @room.Capacity chỗ
+
+ + Phòng @room.TableNumber +
+
@(room.Zone ?? "Standard") • @room.Capacity chỗ
@ShopHelpers.FormatVND(roomType.Item3)/giờ
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/KaraokeDesktop.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/KaraokeDesktop.razor index e0b03e10..80c823e5 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/KaraokeDesktop.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/KaraokeDesktop.razor @@ -1,12 +1,10 @@ @* EN: Karaoke POS Desktop — Room map grid + session panel for karaoke room management. - TODO: Replace mock room data with API call to FnB engine rooms endpoint - (e.g. DataService.GetTablesAsync with room-type filter) when the karaoke-specific - table/room schema is implemented in the FnB engine database. + Rooms loaded from DB via DataService.GetTablesAsync. Products loaded for F&B panel. + TODO: Implement session tracking (table_sessions) for real-time F&B order display. VI: POS Karaoke Desktop — Lưới sơ đồ phòng + panel phiên hát cho quản lý phòng karaoke. - TODO: Thay dữ liệu phòng giả bằng API call đến FnB engine rooms endpoint - (ví dụ DataService.GetTablesAsync với bộ lọc loại phòng) khi schema bàn/phòng - karaoke được triển khai trong cơ sở dữ liệu FnB engine. + Phòng tải từ DB qua DataService.GetTablesAsync. Sản phẩm tải cho panel F&B. + TODO: Triển khai theo dõi phiên (table_sessions) để hiển thị đơn F&B thời gian thực. *@ @page "/pos/{ShopId:guid}/karaoke" @layout PosLayout @@ -106,7 +104,7 @@ @* EN: F&B orders / VI: Đơn F&B *@
ĐƠN F&B
- @foreach (var item in _demoFnbItems) + @foreach (var item in _fnbItems) {
@@ -131,7 +129,7 @@ Tổng cộng @FormatPrice(SelectedRoom.Status == "occupied" - ? _roomRate + _demoFnbItems.Sum(i => i.Price * i.Qty) + ? _roomRate + _fnbItems.Sum(i => i.Price * i.Qty) : 0)
@@ -176,12 +174,12 @@ // EN: Room data loaded from DB / VI: Dữ liệu phòng tải từ DB private List _rooms = new(); - // EN: Demo F&B items / VI: Mục F&B mẫu - private readonly List _demoFnbItems = new() - { - new("Bia Tiger", 35_000, 6), new("Trái cây dĩa", 120_000, 1), - new("Khô mực nướng", 85_000, 2), new("Nước ngọt", 20_000, 4), - }; + // EN: F&B items for active session (loaded from orders when session tracking is available) + // VI: Mục F&B cho phiên đang hoạt động (tải từ đơn hàng khi có session tracking) + private readonly List _fnbItems = new(); + + // EN: Products loaded from DB / VI: Sản phẩm tải từ DB + private List _products = new(); private IEnumerable FilteredRooms => _activeZone == "Tất cả" ? _rooms : _rooms.Where(r => r.Zone == _activeZone); @@ -206,6 +204,9 @@ var zoneNames = _rooms.Select(r => r.Zone).Distinct().ToList(); _zones = new[] { "Tất cả" }.Concat(zoneNames).ToArray(); + + // Load F&B products from DB + _products = await DataService.GetProductsAsync(ShopId); } catch { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/HappyHour.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/HappyHour.razor index 88c57036..e671a028 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/HappyHour.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Karaoke/Workflow/HappyHour.razor @@ -119,9 +119,9 @@ private readonly List _promotions = new() { new("Sinh nhật vui vẻ", "Giảm 50% phòng cho khách sinh nhật", "-50%", - "Đến 31/03", "party-popper", "rgba(255,92,0,.15)", "#FF5C00"), + "Đến 31/03", "cake", "rgba(255,92,0,.15)", "#FF5C00"), new("Combo bia + phòng", "2 giờ Standard + 1 két bia Tiger", "350,000₫", - "Đến 28/02", "beer", "rgba(245,158,11,.15)", "#F59E0B"), + "Đến 28/02", "wine", "rgba(245,158,11,.15)", "#F59E0B"), new("Thành viên Gold", "Giảm thêm 15% cho thành viên Gold", "-15%", "Thường trực", "crown", "rgba(59,130,246,.15)", "#3B82F6"), new("Nhóm 10+", "Miễn phí 1 giờ cho nhóm từ 10 khách", "Free 1h",