feat(shop-admin): add happy hour and promotion configuration UI and enhance room management with add/edit/delete functionality.

This commit is contained in:
Ho Ngoc Hai
2026-03-05 08:44:47 +07:00
parent cd979970e7
commit e748c43b22
5 changed files with 354 additions and 34 deletions

View File

@@ -0,0 +1,297 @@
@using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop
@inject IJSRuntime JS
@* ═══ TIME-BASED PRICING ═══ *@
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 class="admin-panel__title"><i data-lucide="clock" style="width:16px;height:16px;vertical-align:middle;margin-right:4px;"></i>Bảng giá theo khung giờ</h3>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick="ToggleTimeSlotForm">
<i data-lucide="@(_showTimeSlotForm ? "x" : "plus")" style="width:14px;height:14px;margin-right:4px;"></i>@(_showTimeSlotForm ? "Đóng" : "Thêm khung giờ")
</button>
</div>
<div class="admin-panel__body">
@if (_showTimeSlotForm)
{
<div style="margin-bottom:16px;padding:16px;border-radius:10px;border:1px solid rgba(245,158,11,0.3);background:var(--admin-bg-elevated);">
<div style="font-weight:600;font-size:13px;margin-bottom:8px;">@(_editingSlotIdx >= 0 ? "Chỉnh sửa khung giờ" : "Thêm khung giờ mới")</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Khung giờ *</label><input type="text" @bind="_slotTimeRange" placeholder="VD: 08:00 12:00" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giảm giá (%)</label><input type="number" @bind="_slotDiscount" min="0" max="100" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:8px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Standard (VNĐ)</label><input type="number" @bind="_slotStandard" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">VIP (VNĐ)</label><input type="number" @bind="_slotVip" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Deluxe (VNĐ)</label><input type="number" @bind="_slotDeluxe" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" style="padding:8px 16px;font-size:12px;" @onclick="SaveTimeSlot"><i data-lucide="check" style="width:14px;height:14px;margin-right:4px;"></i>Lưu</button>
<button @onclick='() => _showTimeSlotForm = false' style="padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;font-size:12px;"><i data-lucide="x" style="width:14px;height:14px;"></i></button>
</div>
</div>
}
@if (!_timeSlots.Any())
{
<div style="text-align:center;padding:40px;color:var(--admin-text-tertiary);">
<i data-lucide="clock" style="width:36px;height:36px;margin:0 auto 12px;display:block;opacity:0.3;"></i>
<p>Chưa có khung giờ nào. Nhấn "Thêm khung giờ" để bắt đầu.</p>
</div>
}
else
{
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;">
@for (var i = 0; i < _timeSlots.Count; i++)
{
var idx = i;
var slot = _timeSlots[i];
var isActive = IsActiveSlot(slot.TimeRange);
<div style="background:var(--admin-bg-elevated);border-radius:12px;padding:16px;border:1px solid @(isActive ? "var(--admin-orange-primary)" : "var(--admin-border-subtle)");position:relative;">
<div style="position:absolute;top:8px;right:8px;display:flex;gap:4px;">
<button @onclick='() => EditSlot(idx)' style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:24px;height:24px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="pencil" style="color:#3B82F6;width:11px;height:11px;"></i></button>
<button @onclick='() => DeleteSlot(idx)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:24px;height:24px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="trash-2" style="color:#EF4444;width:11px;height:11px;"></i></button>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span style="font-size:14px;font-weight:600;">@slot.TimeRange</span>
@if (isActive)
{
<span style="font-size:10px;padding:2px 8px;border-radius:6px;font-weight:600;background:rgba(34,197,94,.15);color:#22C55E;">Đang áp dụng</span>
}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;">
<div style="text-align:center;padding:8px;background:var(--admin-bg-main);border-radius:8px;">
<div style="font-size:10px;color:var(--admin-text-tertiary);margin-bottom:2px;">Standard</div>
<div style="font-size:13px;font-weight:600;color:@(isActive ? "var(--admin-orange-primary)" : "var(--admin-text-primary)");">@ShopHelpers.FormatVND(slot.Standard)</div>
</div>
<div style="text-align:center;padding:8px;background:var(--admin-bg-main);border-radius:8px;">
<div style="font-size:10px;color:var(--admin-text-tertiary);margin-bottom:2px;">VIP</div>
<div style="font-size:13px;font-weight:600;color:@(isActive ? "var(--admin-orange-primary)" : "var(--admin-text-primary)");">@ShopHelpers.FormatVND(slot.Vip)</div>
</div>
<div style="text-align:center;padding:8px;background:var(--admin-bg-main);border-radius:8px;">
<div style="font-size:10px;color:var(--admin-text-tertiary);margin-bottom:2px;">Deluxe</div>
<div style="font-size:13px;font-weight:600;color:@(isActive ? "var(--admin-orange-primary)" : "var(--admin-text-primary)");">@ShopHelpers.FormatVND(slot.Deluxe)</div>
</div>
</div>
@if (slot.Discount > 0)
{
<div style="font-size:12px;color:var(--admin-orange-primary);margin-top:8px;font-weight:500;display:flex;align-items:center;gap:4px;">
<i data-lucide="tag" style="width:12px;height:12px;"></i>Giảm @slot.Discount% giá phòng
</div>
}
</div>
}
</div>
}
</div>
</div>
@* ═══ PROMOTIONS ═══ *@
<div class="admin-panel">
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 class="admin-panel__title"><i data-lucide="gift" style="width:16px;height:16px;vertical-align:middle;margin-right:4px;"></i>Khuyến mãi</h3>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick="TogglePromoForm">
<i data-lucide="@(_showPromoForm ? "x" : "plus")" style="width:14px;height:14px;margin-right:4px;"></i>@(_showPromoForm ? "Đóng" : "Thêm khuyến mãi")
</button>
</div>
<div class="admin-panel__body">
@if (_showPromoForm)
{
<div style="margin-bottom:16px;padding:16px;border-radius:10px;border:1px solid rgba(245,158,11,0.3);background:var(--admin-bg-elevated);">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên *</label><input type="text" @bind="_promoTitle" placeholder="VD: Sinh nhật vui vẻ" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Giá trị</label><input type="text" @bind="_promoValue" placeholder="VD: -50%, 350,000₫" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
</div>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:12px;margin-top:8px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mô tả</label><input type="text" @bind="_promoDesc" placeholder="Chi tiết khuyến mãi" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Hết hạn</label><input type="text" @bind="_promoExpiry" placeholder="VD: 31/03, Thường trực" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" style="padding:8px 16px;font-size:12px;" @onclick="SavePromo"><i data-lucide="check" style="width:14px;height:14px;margin-right:4px;"></i>Lưu</button>
<button @onclick='() => _showPromoForm = false' style="padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;font-size:12px;"><i data-lucide="x" style="width:14px;height:14px;"></i></button>
</div>
</div>
}
@if (!_promotions.Any())
{
<div style="text-align:center;padding:40px;color:var(--admin-text-tertiary);">
<i data-lucide="gift" style="width:36px;height:36px;margin:0 auto 12px;display:block;opacity:0.3;"></i>
<p>Chưa có khuyến mãi nào.</p>
</div>
}
else
{
<div style="display:flex;flex-direction:column;gap:10px;">
@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" };
<div style="background:var(--admin-bg-elevated);border-radius:12px;padding:16px;display:flex;align-items:center;gap:14px;border:1px solid var(--admin-border-subtle);">
<div style="width:44px;height:44px;border-radius:12px;background:@color.Item2;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="@icons[idx % icons.Length]" style="width:20px;height:20px;color:@color.Item1;"></i>
</div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;">@promo.Title</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-top:2px;">@promo.Description</div>
</div>
<div style="text-align:right;flex-shrink:0;">
<div style="font-size:14px;font-weight:700;color:var(--admin-orange-primary);">@promo.Value</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">@promo.ValidUntil</div>
</div>
<button @onclick='() => DeletePromo(idx)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;">
<i data-lucide="trash-2" style="color:#EF4444;width:12px;height:12px;"></i>
</button>
</div>
}
</div>
}
</div>
</div>
@code {
[Parameter] public Guid ShopId { get; set; }
// Time slots
private List<TimeSlotConfig> _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<PromoConfig> _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<string?>("localStorage.getItem", StorageKey);
if (!string.IsNullOrEmpty(json))
{
var data = System.Text.Json.JsonSerializer.Deserialize<HappyHourData>(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<TimeSlotConfig> TimeSlots, List<PromoConfig> Promotions);
}

View File

@@ -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.")
<ShopHappyHour ShopId="@(_shopGuid ?? Guid.Empty)" />
break;
case "packages":

View File

@@ -142,20 +142,40 @@
}
else if (SubSection == "rooms")
{
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<div style="display:flex;gap:8px;">
<span class="admin-status-badge admin-status-badge--online" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Trống: @_tables.Count(t => t.Status == "available")</span>
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang hát: @_tables.Count(t => t.Status == "occupied")</span>
<span class="admin-status-badge admin-status-badge--setup" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đã đặt: @_tables.Count(t => t.Status == "reserved")</span>
</div>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _editingTableId = null; _newTableNumber = ""; _newTableCapacity = 8; _newTableZone = ""; _tableFormMessage = null; _showTableForm = !_showTableForm; }'>
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm phòng
</button>
</div>
@if (_showTableForm)
{
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(139,92,246,0.3);">
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingTableId.HasValue ? "Chỉnh sửa phòng" : "Thêm phòng mới")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số phòng *</label><input type="text" @bind="_newTableNumber" placeholder="VD: 01, A1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Sức chứa</label><input type="number" @bind="_newTableCapacity" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Loại phòng</label><select @bind="_newTableZone" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);"><option value="Standard">Standard</option><option value="VIP">VIP</option><option value="Party">Party</option><option value="Deluxe">Deluxe</option></select></div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" @onclick="@(_editingTableId.HasValue ? SaveTable : AddTable)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingTableId.HasValue ? "Cập nhật" : "Lưu")</button>
<button @onclick='() => _showTableForm = false' style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
</div>
@if (_tableFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_tableFormSuccess ? "#22C55E" : "#EF4444");">@_tableFormMessage</div> }
</div>
</div>
}
@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
{
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<div style="display:flex;gap:8px;">
<span class="admin-status-badge admin-status-badge--online" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Trống: @_tables.Count(t => t.Status == "available")</span>
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang hát: @_tables.Count(t => t.Status == "occupied")</span>
<span class="admin-status-badge admin-status-badge--setup" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đã đặt: @_tables.Count(t => t.Status == "reserved")</span>
</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);">@_tables.Count phòng</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;">
@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) };
<div style="background:@bgColor;border:1px solid @borderColor;border-radius:14px;padding:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="display:flex;align-items:center;gap:10px;">
<i data-lucide="door-open" style="width:20px;height:20px;color:@statusColor;"></i>
<span style="font-size:18px;font-weight:700;">Phòng @room.TableNumber</span>
</div>
<span style="font-size:10px;font-weight:700;padding:2px 8px;border-radius:6px;background:@($"{roomType.Item2}22");color:@roomType.Item2;">@roomType.Item1</span>
<div style="background:@bgColor;border:1px solid @borderColor;border-radius:14px;padding:20px;position:relative;">
<div style="position:absolute;top:8px;right:8px;display:flex;gap:4px;">
<button @onclick='() => ShowQrModal(room)' style="background:rgba(139,92,246,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Mã QR"><i data-lucide="qr-code" style="color:#8B5CF6;width:12px;height:12px;"></i></button>
<button @onclick='() => EditTable(room)' style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="pencil" style="color:#3B82F6;width:12px;height:12px;"></i></button>
<button @onclick='() => DeleteTableItem(room.Id)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="trash-2" style="color:#EF4444;width:12px;height:12px;"></i></button>
</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:4px;">@(room.Zone ?? "Chung") • @room.Capacity chỗ</div>
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;margin-top:4px;">
<i data-lucide="door-open" style="width:20px;height:20px;color:@statusColor;"></i>
<span style="font-size:18px;font-weight:700;">Phòng @room.TableNumber</span>
</div>
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:4px;">@(room.Zone ?? "Standard") • @room.Capacity chỗ</div>
<div style="font-size:12px;font-weight:600;color:var(--admin-orange-primary);margin-bottom:8px;">@ShopHelpers.FormatVND(roomType.Item3)/giờ</div>
<div style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;color:@statusColor;">
<span style="width:6px;height:6px;border-radius:50%;background:@statusColor;"></span>

View File

@@ -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 *@
<div style="padding:0 8px;">
<div style="font-size:12px;color:var(--pos-text-tertiary);padding:8px 0;font-weight:600;">ĐƠN F&B</div>
@foreach (var item in _demoFnbItems)
@foreach (var item in _fnbItems)
{
<div class="pos-cart-item">
<div class="pos-cart-item__info">
@@ -131,7 +129,7 @@
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">
@FormatPrice(SelectedRoom.Status == "occupied"
? _roomRate + _demoFnbItems.Sum(i => i.Price * i.Qty)
? _roomRate + _fnbItems.Sum(i => i.Price * i.Qty)
: 0)
</span>
</div>
@@ -176,12 +174,12 @@
// EN: Room data loaded from DB / VI: Dữ liệu phòng tải từ DB
private List<RoomInfo> _rooms = new();
// EN: Demo F&B items / VI: Mục F&B mẫu
private readonly List<FnbItem> _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<FnbItem> _fnbItems = new();
// EN: Products loaded from DB / VI: Sản phẩm tải từ DB
private List<WebClientTpos.Client.Services.PosDataService.ProductInfo> _products = new();
private IEnumerable<RoomInfo> 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
{

View File

@@ -119,9 +119,9 @@
private readonly List<Promotion> _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",