feat(karaoke-pos): integrate session management and F&B ordering with backend APIs across the workflow pages.

This commit is contained in:
Ho Ngoc Hai
2026-03-05 11:15:46 +07:00
parent e748c43b22
commit c70248fdec
8 changed files with 498 additions and 311 deletions

View File

@@ -1,10 +1,8 @@
@*
EN: Karaoke POS Desktop — Room map grid + session panel for karaoke room management.
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.
Rooms loaded from DB via DataService.GetTablesAsync. Active orders loaded for occupied rooms.
VI: POS Karaoke Desktop — Lưới sơ đồ phòng + panel phiên hát cho quản lý phòng karaoke.
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.
Phòng tải từ DB qua DataService.GetTablesAsync. Đơn hàng active tải cho phòng đang sử dụng.
*@
@page "/pos/{ShopId:guid}/karaoke"
@layout PosLayout
@@ -97,21 +95,41 @@
<div style="text-align:center;padding:16px;">
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">THỜI GIAN SỬ DỤNG</div>
<div style="font-size:32px;font-weight:700;color:var(--pos-orange-primary);">
@((DateTime.Now - SelectedRoom.SessionStart!.Value).ToString(@"hh\:mm\:ss"))
@if (SelectedRoom.SessionStart.HasValue)
{
@((DateTime.Now - SelectedRoom.SessionStart.Value).ToString(@"hh\:mm\:ss"))
}
else
{
<text>--:--:--</text>
}
</div>
</div>
@* EN: F&B orders / VI: Đơn F&B *@
@* EN: F&B orders from DB / VI: Đơn F&B từ DB *@
<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 _fnbItems)
@{ var roomOrders = _activeOrders.Where(o => o.TableId.ToString() == SelectedRoom.Id).ToList(); }
@if (roomOrders.Any())
{
<div class="pos-cart-item">
<div class="pos-cart-item__info">
<span class="pos-cart-item__name">@item.Name</span>
<span class="pos-cart-item__price">@FormatPrice(item.Price)</span>
</div>
<span style="font-size:13px;font-weight:600;">x@item.Qty</span>
@foreach (var order in roomOrders)
{
@foreach (var item in order.Items)
{
<div class="pos-cart-item">
<div class="pos-cart-item__info">
<span class="pos-cart-item__name">@item.ProductName</span>
<span class="pos-cart-item__price">@FormatPrice(item.UnitPrice)</span>
</div>
<span style="font-size:13px;font-weight:600;">x@item.Quantity</span>
</div>
}
}
}
else
{
<div style="text-align:center;padding:12px;color:var(--pos-text-tertiary);font-size:12px;">
Chưa có đơn F&B
</div>
}
</div>
@@ -126,11 +144,14 @@
<div class="pos-cart-footer">
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__label">Tổng F&B</span>
<span class="pos-cart-total__value">
@FormatPrice(SelectedRoom.Status == "occupied"
? _roomRate + _fnbItems.Sum(i => i.Price * i.Qty)
: 0)
@{
var totalFnb = _activeOrders
.Where(o => o.TableId.ToString() == SelectedRoom?.Id)
.Sum(o => o.TotalAmount);
}
@FormatPrice(totalFnb)
</span>
</div>
@if (SelectedRoom.Status == "available")
@@ -141,7 +162,7 @@
}
else if (SelectedRoom.Status == "occupied")
{
<button class="pos-btn-checkout" @onclick="@(() => NavigateTo("karaoke/room-session"))">
<button class="pos-btn-checkout" @onclick="@(() => NavigateTo($"karaoke/room-session/{SelectedRoom.Id}"))">
<i data-lucide="receipt" style="width:18px;height:18px;"></i> Xem chi tiết
</button>
}
@@ -157,29 +178,16 @@
@code {
// EN: Loading state / VI: Trạng thái tải
private bool _isLoading = true;
private bool _loadError;
// EN: Zone filter / VI: Bộ lọc khu vực
private string _activeZone = "Tất cả";
private string[] _zones = { "Tất cả" };
// EN: Selected room / VI: Phòng đang chọn
private RoomInfo? SelectedRoom { get; set; }
// EN: Demo room rate / VI: Giá phòng mẫu
private readonly decimal _roomRate = 150_000;
// EN: Room data loaded from DB / VI: Dữ liệu phòng tải từ DB
private List<RoomInfo> _rooms = new();
// 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 List<WebClientTpos.Client.Services.PosDataService.ActiveTableOrderDto> _activeOrders = new();
private IEnumerable<RoomInfo> FilteredRooms =>
_activeZone == "Tất cả" ? _rooms : _rooms.Where(r => r.Zone == _activeZone);
@@ -190,7 +198,12 @@
try
{
var tables = await DataService.GetTablesAsync(ShopId);
var tablesTask = DataService.GetTablesAsync(ShopId);
var ordersTask = DataService.GetActiveTableOrdersAsync(ShopId);
await Task.WhenAll(tablesTask, ordersTask);
var tables = await tablesTask;
_activeOrders = await ordersTask;
_rooms = tables.Select(t => new RoomInfo(
t.Id.ToString(),
@@ -204,9 +217,6 @@
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
{
@@ -233,7 +243,5 @@
"reserved" => "Đã đặt", "cleaning" => "Đang dọn", _ => status
};
// EN: Models / VI: Mô hình dữ liệu
private record RoomInfo(string Id, string Name, int Capacity, string Status, string Type, string Zone, DateTime? SessionStart);
private record FnbItem(string Name, decimal Price, int Qty);
}

View File

@@ -1,8 +1,8 @@
@*
EN: Karaoke F&B Order — Food & beverage ordering for active room session.
VI: Gọi F&B Karaoke — Đặt đồ ăn thức uống cho phiên phòng đang hoạt động.
EN: Karaoke F&B Order — Food & beverage ordering for active room session. Creates real orders via API.
VI: Gọi F&B Karaoke — Đặt đồ ăn thức uống cho phiên phòng. Tạo đơn hàng thật qua API.
*@
@page "/pos/{ShopId:guid}/karaoke/order-fnb"
@page "/pos/{ShopId:guid}/karaoke/order-fnb/{RoomId:guid}"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@@ -13,11 +13,11 @@
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);">
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
@onclick="@(() => NavigateTo("karaoke/room-session"))">
@onclick="@(() => NavigateTo($"karaoke/room-session/{RoomId}"))">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;">Gọi F&B</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);margin-left:auto;">Phòng VIP 2</span>
<span style="font-size:12px;color:var(--pos-text-tertiary);margin-left:auto;">@_roomName</span>
</div>
@if (_isLoading)
@@ -96,36 +96,40 @@
</div>
<div class="pos-cart-footer">
@if (_orderItems.Any())
@if (!string.IsNullOrEmpty(_statusMsg))
{
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--pos-text-tertiary);margin-bottom:8px;">
<span>Ghi chú: Gửi vào phòng VIP 2</span>
</div>
<div style="font-size:12px;margin-bottom:8px;color:@(_orderSuccess ? "#22C55E" : "#EF4444");">@_statusMsg</div>
}
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng F&B</span>
<span class="pos-cart-total__value">@FormatPrice(_orderItems.Sum(i => i.Price * i.Qty))</span>
</div>
<button class="pos-btn-checkout" @onclick="@(() => NavigateTo("karaoke/room-session"))">
<i data-lucide="send" style="width:18px;height:18px;"></i> Gửi đơn
<button class="pos-btn-checkout" disabled="@(_submitting || !_orderItems.Any())" @onclick="SubmitOrder">
@if (_submitting)
{
<text>Đang gửi...</text>
}
else
{
<i data-lucide="send" style="width:18px;height:18px;"></i> <text> Gửi đơn</text>
}
</button>
</div>
</div>
@code {
[Parameter] public Guid RoomId { get; set; }
// EN: Loading state / VI: Trạng thái tải
private bool _isLoading = true;
private bool _loadError;
private bool _submitting;
private bool _orderSuccess;
private string? _statusMsg;
private string _roomName = "";
// EN: Category filter / VI: Bộ lọc danh mục
private string _activeCategory = "Tất cả";
private string[] _categories = { "Tất cả" };
// EN: Product list loaded from DB / VI: Danh sách sản phẩm tải từ DB
private List<Product> _products = new();
// EN: Order items / VI: Mục đơn hàng
private readonly List<OrderItem> _orderItems = new();
private IEnumerable<Product> FilteredProducts =>
@@ -137,17 +141,21 @@
try
{
var tablesTask = DataService.GetTablesAsync(ShopId);
var productsTask = DataService.GetProductsAsync(ShopId);
var categoriesTask = DataService.GetCategoriesAsync(ShopId);
await Task.WhenAll(productsTask, categoriesTask);
await Task.WhenAll(tablesTask, productsTask, categoriesTask);
// Get room name
var tables = await tablesTask;
var room = tables.FirstOrDefault(t => t.Id == RoomId);
_roomName = room?.TableNumber ?? "Phòng";
var apiProducts = await productsTask;
var apiCategories = await categoriesTask;
_products = apiProducts.Select(p => new Product(
p.Name,
p.Price,
p.Category ?? "Khác"
p.Id, p.Name, p.Price, p.Category ?? "Khác"
)).ToList();
var catNames = apiCategories.Select(c => c.Name).ToList();
@@ -171,9 +179,9 @@
private void AddToOrder(Product p)
{
var existing = _orderItems.FirstOrDefault(i => i.Name == p.Name);
var existing = _orderItems.FirstOrDefault(i => i.ProductId == p.Id);
if (existing != null) existing.Qty++;
else _orderItems.Add(new OrderItem(p.Name, p.Price));
else _orderItems.Add(new OrderItem(p.Id, p.Name, p.Price));
}
private void ChangeQty(OrderItem item, int delta)
@@ -182,14 +190,51 @@
if (item.Qty <= 0) _orderItems.Remove(item);
}
private async Task SubmitOrder()
{
if (!_orderItems.Any()) return;
_submitting = true;
_statusMsg = null;
try
{
var items = _orderItems.Select(i => new WebClientTpos.Client.Services.PosDataService.PosOrderItemRequest(
i.ProductId, i.Name, i.Qty, i.Price, "PreparedFood")).ToList();
var req = new WebClientTpos.Client.Services.PosDataService.CreatePosOrderRequest(
ShopId, null, items, null, null, null, RoomId);
var result = await DataService.CreatePosOrderAsync(req);
if (result != null)
{
_orderSuccess = true;
_statusMsg = "Đơn F&B đã gửi thành công!";
_orderItems.Clear();
}
else
{
_orderSuccess = false;
_statusMsg = "Không thể gửi đơn. Vui lòng thử lại.";
}
}
catch
{
_orderSuccess = false;
_statusMsg = "Lỗi khi gửi đơn.";
}
finally
{
_submitting = false;
}
}
private static string GetCategoryIcon(string cat) => cat switch
{
"Đồ uống" => "beer", "Đồ ăn" => "utensils", "Mồi" => "popcorn", _ => "package"
"Đồ uống" => "wine", "Đồ ăn" => "utensils", "Mồi" => "popcorn", _ => "package"
};
private record Product(string Name, decimal Price, string Category);
private class OrderItem(string name, decimal price)
private record Product(Guid Id, string Name, decimal Price, string Category);
private class OrderItem(Guid productId, string name, decimal price)
{
public Guid ProductId { get; set; } = productId;
public string Name { get; set; } = name;
public decimal Price { get; set; } = price;
public int Qty { get; set; } = 1;

View File

@@ -1,67 +1,66 @@
@*
EN: Karaoke Room Extend — Extension dialog with time options, new end time preview, peak warning.
VI: Gia hạn phòng Karaoke — Dialog gia hạn với tùy chọn thời gian, xem trước giờ kết thúc, cảnh báo giờ cao điểm.
EN: Karaoke Room Extend — Extension dialog with time options, loads room info from DB.
VI: Gia hạn phòng Karaoke — Dialog gia hạn với tùy chọn thời gian, tải thông tin phòng từ DB.
*@
@page "/pos/{ShopId:guid}/karaoke/room-extend"
@page "/pos/{ShopId:guid}/karaoke/room-extend/{RoomId:guid}"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--pos-border-subtle);flex-shrink:0;">
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;"
@onclick="@(() => NavigateTo("karaoke/room-session"))">
@onclick="@(() => NavigateTo($"karaoke/room-session/{RoomId}"))">
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<span style="font-size:16px;font-weight:700;">Gia hạn phòng</span>
</div>
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--pos-text-tertiary);">
Đang tải...
</div>
}
else
{
<div style="flex:1;overflow-y:auto;padding:16px;">
@* ═══ CURRENT SESSION INFO / THÔNG TIN PHIÊN HIỆN TẠI ═══ *@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:12px;">
<div>
<div style="font-size:18px;font-weight:700;">Phòng VIP 2</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">Deluxe • 20 người • Tầng 3</div>
<div style="font-size:18px;font-weight:700;">@_roomName</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">@_roomType • @_roomCapacity người</div>
</div>
<span style="font-size:12px;padding:4px 10px;border-radius:6px;font-weight:600;
background:rgba(34,197,94,.15);color:#22C55E;">Đang hoạt động</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div style="text-align:center;padding:12px;background:var(--pos-bg-interactive);border-radius:8px;">
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">Bắt đầu</div>
<div style="font-size:16px;font-weight:700;">19:30</div>
<div style="font-size:16px;font-weight:700;">@(_sessionStart?.ToLocalTime().ToString("HH:mm") ?? "--:--")</div>
</div>
<div style="text-align:center;padding:12px;background:var(--pos-bg-interactive);border-radius:8px;">
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">Đã dùng</div>
<div style="font-size:16px;font-weight:700;color:var(--pos-orange-primary);">2h15</div>
<div style="font-size:16px;font-weight:700;color:var(--pos-orange-primary);">@_elapsedStr</div>
</div>
<div style="text-align:center;padding:12px;background:var(--pos-bg-interactive);border-radius:8px;">
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-bottom:4px;">Giá/giờ</div>
<div style="font-size:16px;font-weight:700;">@FormatPrice(200_000)</div>
</div>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-top:12px;
padding-top:10px;border-top:1px solid var(--pos-border-subtle);">
<span style="color:var(--pos-text-secondary);">Tiền phòng hiện tại</span>
<span style="font-weight:600;">@FormatPrice(450_000)</span>
</div>
</div>
@* ═══ EXTENSION OPTIONS / TÙY CHỌN GIA HẠN ═══ *@
<div style="margin-bottom:16px;">
<div style="font-size:14px;font-weight:600;margin-bottom:12px;">Chọn thời gian gia hạn</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;">
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px;">
@foreach (var opt in _extendOptions)
{
<button @onclick="() => SelectExtension(opt)"
<button @onclick="() => _selectedOption = opt"
style="padding:20px 12px;border-radius:var(--pos-radius);text-align:center;cursor:pointer;
background:@(_selectedOption?.Minutes == opt.Minutes ? "var(--pos-orange-primary)" : "var(--pos-bg-elevated)");
color:@(_selectedOption?.Minutes == opt.Minutes ? "#FFF" : "var(--pos-text-primary)");
border:1px solid @(_selectedOption?.Minutes == opt.Minutes ? "var(--pos-orange-primary)" : "var(--pos-border-subtle)");">
<div style="font-size:16px;font-weight:700;">@opt.Label</div>
<div style="font-size:13px;margin-top:4px;opacity:0.8;">+@FormatPrice(opt.Cost)</div>
</button>
}
</div>
@@ -73,11 +72,11 @@
<div style="display:flex;align-items:center;gap:12px;">
<button style="width:36px;height:36px;border-radius:8px;border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-primary);cursor:pointer;font-size:16px;"
@onclick="() => AdjustCustom(-15)"></button>
@onclick="() => _customMinutes = Math.Max(15, _customMinutes - 15)"></button>
<span style="font-size:20px;font-weight:700;min-width:50px;text-align:center;">@_customMinutes</span>
<button style="width:36px;height:36px;border-radius:8px;border:1px solid var(--pos-border-default);
background:transparent;color:var(--pos-text-primary);cursor:pointer;font-size:16px;"
@onclick="() => AdjustCustom(15)">+</button>
@onclick="() => _customMinutes += 15">+</button>
<span style="font-size:13px;color:var(--pos-text-tertiary);">phút</span>
<button style="padding:8px 14px;border-radius:8px;background:var(--pos-bg-interactive);
border:none;color:var(--pos-text-primary);cursor:pointer;font-size:12px;font-weight:600;"
@@ -86,108 +85,79 @@
</button>
</div>
</div>
@* ═══ PREVIEW / XEM TRƯỚC ═══ *@
@if (_selectedOption is not null)
{
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;margin-bottom:16px;">
<div style="font-size:13px;font-weight:600;margin-bottom:10px;">Xem trước sau gia hạn</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Giờ kết thúc mới</span>
<span style="font-weight:600;">@_newEndTime</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Thời gian thêm</span>
<span style="font-weight:600;">+@_selectedOption.Label</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Phí gia hạn</span>
<span style="font-weight:600;">+@FormatPrice(_selectedOption.Cost)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:15px;font-weight:700;
padding-top:10px;border-top:1px solid var(--pos-border-subtle);">
<span>Tổng tiền phòng mới</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(450_000 + _selectedOption.Cost)</span>
</div>
</div>
@* EN: Peak warning if applicable / VI: Cảnh báo giờ cao điểm nếu có *@
@if (_showPeakWarning)
{
<div style="background:rgba(245,158,11,.12);border-radius:var(--pos-radius);padding:14px 16px;
margin-bottom:16px;display:flex;align-items:center;gap:10px;
border:1px solid rgba(245,158,11,.3);">
<i data-lucide="alert-triangle" style="width:20px;height:20px;color:var(--pos-warning);flex-shrink:0;"></i>
<div>
<div style="font-size:13px;font-weight:600;color:var(--pos-warning);">Cảnh báo giờ cao điểm</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:2px;">
Gia hạn vào khung giờ cao điểm (sau 22:00). Giá có thể tăng.
</div>
</div>
</div>
}
}
</div>
@* ═══ ACTION BUTTONS / NÚT HÀNH ĐỘNG ═══ *@
<div style="display:flex;gap:12px;padding:12px 16px;border-top:1px solid var(--pos-border-subtle);flex-shrink:0;">
<button style="flex:1;padding:14px;border-radius:var(--pos-radius);background:var(--pos-bg-interactive);
border:none;color:var(--pos-text-primary);cursor:pointer;font-size:14px;font-weight:600;"
@onclick="@(() => NavigateTo("karaoke/room-session"))">
@onclick="@(() => NavigateTo($"karaoke/room-session/{RoomId}"))">
Hủy
</button>
<button style="flex:1;padding:14px;border-radius:var(--pos-radius);background:var(--pos-orange-primary);
border:none;color:#FFF;cursor:pointer;font-size:14px;font-weight:600;
opacity:@(_selectedOption is null ? "0.4" : "1");"
disabled="@(_selectedOption is null)"
@onclick="@(() => NavigateTo("karaoke/room-session"))">
@onclick="@(() => NavigateTo($"karaoke/room-session/{RoomId}"))">
<i data-lucide="check" style="width:16px;height:16px;display:inline;"></i> Xác nhận gia hạn
</button>
</div>
}
</div>
@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
[Parameter] public Guid RoomId { get; set; }
private bool _isLoading = true;
private string _roomName = "";
private string _roomType = "";
private int _roomCapacity;
private DateTime? _sessionStart;
private string _elapsedStr = "--";
// EN: Extension options / VI: Tùy chọn gia hạn
private readonly List<ExtendOption> _extendOptions = new()
{
new(30, "+30 phút", 100_000),
new(60, "+1 giờ", 200_000),
new(90, "+1.5 giờ", 300_000),
new(120, "+2 giờ", 400_000),
new(30, "+30 phút"),
new(60, "+1 giờ"),
new(90, "+1.5 giờ"),
new(120, "+2 giờ"),
};
private ExtendOption? _selectedOption;
private int _customMinutes = 45;
private string _newEndTime = "22:00";
private bool _showPeakWarning;
private void SelectExtension(ExtendOption opt)
protected override async Task OnInitializedAsync()
{
_selectedOption = opt;
UpdatePreview(opt.Minutes);
}
await base.OnInitializedAsync();
private void AdjustCustom(int delta)
{
_customMinutes = Math.Max(15, _customMinutes + delta);
try
{
var tables = await DataService.GetTablesAsync(ShopId);
var table = tables.FirstOrDefault(t => t.Id == RoomId);
if (table != null)
{
_roomName = table.TableNumber;
_roomType = table.Zone ?? "Standard";
_roomCapacity = table.Capacity;
_sessionStart = table.StartedAt;
if (_sessionStart.HasValue)
{
var elapsed = DateTime.Now - _sessionStart.Value;
_elapsedStr = $"{(int)elapsed.TotalHours}h{elapsed.Minutes:D2}";
}
}
}
catch { }
finally
{
_isLoading = false;
}
}
private void ApplyCustom()
{
var cost = (decimal)_customMinutes / 60 * 200_000;
_selectedOption = new(_customMinutes, $"+{_customMinutes} phút", Math.Round(cost, -3));
UpdatePreview(_customMinutes);
_selectedOption = new(_customMinutes, $"+{_customMinutes} phút");
}
private void UpdatePreview(int minutes)
{
var baseEnd = new TimeOnly(22, 0);
var newEnd = baseEnd.AddMinutes(minutes);
_newEndTime = newEnd.ToString("HH:mm");
_showPeakWarning = newEnd.Hour >= 22 || newEnd.Hour < 2;
}
private record ExtendOption(int Minutes, string Label, decimal Cost);
private record ExtendOption(int Minutes, string Label);
}

View File

@@ -1,10 +1,11 @@
@*
EN: Karaoke Room Reset — Cleanup checklist after session ends, progress tracking, staff assignment.
VI: Reset phòng Karaoke — Danh sách dọn dẹp sau phiên, theo dõi tiến độ, phân công nhân viên.
EN: Karaoke Room Reset — Cleanup checklist after session ends, loads room info from DB.
VI: Reset phòng Karaoke — Danh sách dọn dẹp sau phiên, tải thông tin phòng từ DB.
*@
@page "/pos/{ShopId:guid}/karaoke/room-reset"
@page "/pos/{ShopId:guid}/karaoke/room-reset/{RoomId:guid}"
@layout PosLayout
@inherits PosBase
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
@* ═══ HEADER / TIÊU ĐỀ ═══ *@
@@ -27,38 +28,8 @@
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:20px;margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:start;">
<div>
<div style="font-size:18px;font-weight:700;">Phòng VIP 2</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:2px;">Deluxe • 20 người • Tầng 3</div>
</div>
<div style="text-align:right;">
<div style="font-size:12px;color:var(--pos-text-tertiary);">Phiên trước kết thúc</div>
<div style="font-size:16px;font-weight:700;">22:15</div>
</div>
</div>
</div>
@* ═══ STAFF & TIME / NHÂN VIÊN & THỜI GIAN ═══ *@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:14px;
display:flex;align-items:center;gap:10px;">
<div style="width:36px;height:36px;border-radius:10px;background:rgba(59,130,246,.15);
display:flex;align-items:center;justify-content:center;">
<i data-lucide="user" style="width:18px;height:18px;color:#3B82F6;"></i>
</div>
<div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">Nhân viên</div>
<div style="font-size:13px;font-weight:600;">Trần Thị Hoa</div>
</div>
</div>
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:14px;
display:flex;align-items:center;gap:10px;">
<div style="width:36px;height:36px;border-radius:10px;background:rgba(255,92,0,.15);
display:flex;align-items:center;justify-content:center;">
<i data-lucide="clock" style="width:18px;height:18px;color:var(--pos-orange-primary);"></i>
</div>
<div>
<div style="font-size:11px;color:var(--pos-text-tertiary);">Bắt đầu dọn</div>
<div style="font-size:13px;font-weight:600;">22:18 • 12 phút</div>
<div style="font-size:18px;font-weight:700;">@_roomName</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:2px;">@_roomType • @_roomCapacity người</div>
</div>
</div>
</div>
@@ -97,7 +68,6 @@
color:@(item.Checked ? "var(--pos-text-tertiary)" : "var(--pos-text-primary)");">
@item.Label
</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-top:2px;">@item.Description</div>
</div>
<i data-lucide="@item.Icon" style="width:18px;height:18px;color:var(--pos-text-tertiary);flex-shrink:0;"></i>
</div>
@@ -107,42 +77,95 @@
@* ═══ COMPLETE BUTTON / NÚT HOÀN TẤT ═══ *@
<div style="padding:12px 16px;border-top:1px solid var(--pos-border-subtle);flex-shrink:0;">
@if (!string.IsNullOrEmpty(_errorMsg))
{
<div style="margin-bottom:8px;font-size:13px;color:#EF4444;">@_errorMsg</div>
}
<button style="width:100%;padding:16px;border-radius:var(--pos-radius);
background:@(AllChecked ? "var(--pos-success)" : "var(--pos-bg-interactive)");
border:none;color:@(AllChecked ? "#FFF" : "var(--pos-text-tertiary)");
cursor:@(AllChecked ? "pointer" : "not-allowed");font-size:15px;font-weight:700;"
disabled="@(!AllChecked)"
@onclick="@(() => NavigateTo("karaoke"))">
disabled="@(!AllChecked || _completing)"
@onclick="CompleteReset">
<i data-lucide="@(AllChecked ? "check-circle" : "loader")" style="width:18px;height:18px;display:inline;"></i>
Hoàn tất reset
@(_completing ? "Đang hoàn tất..." : "Hoàn tất reset")
</button>
</div>
</div>
@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
[Parameter] public Guid RoomId { get; set; }
private string _roomName = "";
private string _roomType = "";
private int _roomCapacity;
private bool _completing;
private string? _errorMsg;
// EN: Checklist items / VI: Các mục kiểm tra
private readonly List<CheckItem> _checkItems = new()
{
new("Dọn bàn ghế", "Clean tables/chairs", "armchair", false),
new("Vệ sinh micro", "Clean microphones", "mic", false),
new("Kiểm tra remote", "Check remote controls", "tv", false),
new("Bổ sung nước uống", "Restock beverages", "cup-soda", false),
new("Kiểm tra ánh sáng", "Check lighting", "lightbulb", false),
new("Hệ thống âm thanh", "Sound system check", "volume-2", false),
new("Kiểm tra thiết bị", "Equipment check", "monitor-speaker", false),
new("Vệ sinh toilet", "Clean restroom", "bath", false),
new("Dọn bàn ghế", "armchair", false),
new("Vệ sinh micro", "mic", false),
new("Kiểm tra remote", "tv", false),
new("Bổ sung nước uống", "cup-soda", false),
new("Kiểm tra ánh sáng", "lightbulb", false),
new("Hệ thống âm thanh", "volume-2", false),
new("Kiểm tra thiết bị", "monitor", false),
new("Vệ sinh phòng", "sparkles", false),
};
private int CompletedCount => _checkItems.Count(i => i.Checked);
private bool AllChecked => _checkItems.All(i => i.Checked);
private int ProgressPercent => _checkItems.Count > 0 ? CompletedCount * 100 / _checkItems.Count : 0;
private class CheckItem(string label, string description, string icon, bool isChecked)
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
var tables = await DataService.GetTablesAsync(ShopId);
var table = tables.FirstOrDefault(t => t.Id == RoomId);
if (table != null)
{
_roomName = table.TableNumber;
_roomType = table.Zone ?? "Standard";
_roomCapacity = table.Capacity;
}
}
catch { }
}
private async Task CompleteReset()
{
_completing = true;
_errorMsg = null;
try
{
var success = await DataService.UpdateTableStatusAsync(RoomId, "available");
if (success)
{
NavigateTo("karaoke");
}
else
{
_errorMsg = "Không thể cập nhật trạng thái phòng.";
}
}
catch
{
_errorMsg = "Lỗi khi hoàn tất reset.";
}
finally
{
_completing = false;
}
}
private class CheckItem(string label, string icon, bool isChecked)
{
public string Label { get; set; } = label;
public string Description { get; set; } = description;
public string Icon { get; set; } = icon;
public bool Checked { get; set; } = isChecked;
}

View File

@@ -1,6 +1,6 @@
@*
EN: Karaoke Room Select — Filter by type/capacity, available rooms, price per hour, start session.
VI: Chọn phòng Karaoke — Lọc theo loại/sức chứa, phòng trống, giá/giờ, bắt đầu phiên.
EN: Karaoke Room Select — Filter by type/capacity, available rooms, start session via API.
VI: Chọn phòng Karaoke — Lọc theo loại/sức chứa, phòng trống, bắt đầu phiên qua API.
*@
@page "/pos/{ShopId:guid}/karaoke/room-select"
@layout PosLayout
@@ -76,9 +76,6 @@
<div style="font-size:12px;color:var(--pos-text-tertiary);margin-bottom:4px;">
@room.Type • @room.Capacity người
</div>
<div style="font-size:14px;font-weight:600;color:var(--pos-orange-primary);">
@FormatPrice(room.PricePerHour)/giờ
</div>
<div style="font-size:11px;color:var(--pos-text-tertiary);margin-top:4px;">
@room.Zone
</div>
@@ -133,26 +130,28 @@
</div>
</div>
@* EN: Price summary / VI: Tóm tắt giá *@
<div style="border-top:1px solid var(--pos-border-subtle);padding-top:12px;">
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Giá/giờ</span>
<span>@FormatPrice(_selectedRoom.PricePerHour)</span>
@if (!string.IsNullOrEmpty(_errorMsg))
{
<div style="padding:10px;border-radius:8px;background:rgba(239,68,68,.15);color:#EF4444;font-size:13px;margin-bottom:12px;">
@_errorMsg
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Số giờ</span>
<span>@_selectedHours giờ</span>
</div>
</div>
}
</div>
<div class="pos-cart-footer">
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(_selectedRoom.PricePerHour * _selectedHours)</span>
<span class="pos-cart-total__label">Phòng @_selectedRoom.Name</span>
<span class="pos-cart-total__value">@_selectedHours giờ • @_guests khách</span>
</div>
<button class="pos-btn-checkout" @onclick="@(() => NavigateTo("karaoke/room-session"))">
<i data-lucide="play" style="width:18px;height:18px;"></i> Bắt đầu phiên
<button class="pos-btn-checkout" disabled="@_starting" @onclick="StartSession">
@if (_starting)
{
<text>Đang mở phiên...</text>
}
else
{
<i data-lucide="play" style="width:18px;height:18px;"></i> <text> Bắt đầu phiên</text>
}
</button>
</div>
}
@@ -167,20 +166,19 @@
@code {
// EN: Loading state / VI: Trạng thái tải
private bool _isLoading = true;
private bool _loadError;
// EN: Filters / VI: Bộ lọc
private string _activeType = "Tất cả";
private string[] _types = { "Tất cả", "Standard", "VIP", "Deluxe" };
private string[] _types = { "Tất cả" };
private readonly int[] _capacities = { 4, 8, 12, 15 };
private int? _selectedCapacity;
private RoomInfo? _selectedRoom;
private int _selectedHours = 2;
private int _guests = 4;
private bool _starting;
private string? _errorMsg;
// EN: Available rooms loaded from DB / VI: Phòng trống tải từ DB
private List<RoomInfo> _rooms = new();
protected override async Task OnInitializedAsync()
@@ -194,11 +192,10 @@
_rooms = tables
.Where(t => t.Status == "available")
.Select(t => new RoomInfo(
t.Id.ToString(),
t.Id,
t.TableNumber,
t.Capacity,
t.Zone ?? "Standard",
100_000,
t.Zone ?? "Tầng 1"
)).ToList();
@@ -215,6 +212,35 @@
}
}
private async Task StartSession()
{
if (_selectedRoom == null) return;
_starting = true;
_errorMsg = null;
try
{
var session = await DataService.OpenSessionAsync(_selectedRoom.Id, ShopId, _guests);
if (session != null)
{
await DataService.UpdateTableStatusAsync(_selectedRoom.Id, "occupied");
NavigateTo($"karaoke/room-session/{_selectedRoom.Id}");
}
else
{
_errorMsg = "Không thể mở phiên. Vui lòng thử lại.";
}
}
catch
{
_errorMsg = "Lỗi khi mở phiên. Vui lòng thử lại.";
}
finally
{
_starting = false;
}
}
private IEnumerable<RoomInfo> FilteredRooms
{
get
@@ -226,5 +252,5 @@
}
}
private record RoomInfo(string Id, string Name, int Capacity, string Type, decimal PricePerHour, string Zone);
private record RoomInfo(Guid Id, string Name, int Capacity, string Type, string Zone);
}

View File

@@ -1,15 +1,30 @@
@*
EN: Karaoke Room Session — Active room timer, F&B orders, extend/end session, total.
VI: Phiên phòng Karaoke — Đồng hồ phòng, đơn F&B, gia hạn/kết thúc, tổng cộng.
EN: Karaoke Room Session — Active room timer, F&B orders from DB, extend/end session via API.
VI: Phiên phòng Karaoke — Đồng hồ phòng, đơn F&B từ DB, gia hạn/kết thúc phiên qua API.
*@
@page "/pos/{ShopId:guid}/karaoke/room-session"
@page "/pos/{ShopId:guid}/karaoke/room-session/{RoomId:guid}"
@layout PosLayout
@inherits PosBase
@implements IDisposable
@inject WebClientTpos.Client.Services.PosDataService DataService
<div style="flex:1;display:flex;overflow:hidden;">
@* ═══ SESSION DETAILS (LEFT) / CHI TIẾT PHIÊN (TRÁI) ═══ *@
<div class="pos-product-panel" style="padding:16px;overflow-y:auto;">
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--pos-text-tertiary);">
Đang tải...
</div>
}
else if (_loadError)
{
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--pos-text-tertiary);">
Không thể tải dữ liệu phòng
</div>
}
else
{
@* EN: Back + Room header / VI: Quay lại + Tiêu đề phòng *@
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
<button style="background:var(--pos-bg-interactive);border:none;color:var(--pos-text-primary);
@@ -18,8 +33,8 @@
<i data-lucide="arrow-left" style="width:16px;height:16px;display:inline;"></i>
</button>
<div>
<div style="font-size:18px;font-weight:700;">Phòng VIP 2</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">Deluxe • 20 người • Tầng 3</div>
<div style="font-size:18px;font-weight:700;">@_room?.Name</div>
<div style="font-size:12px;color:var(--pos-text-tertiary);">@_room?.Type • @_room?.Capacity người • @_room?.Zone</div>
</div>
</div>
@@ -30,13 +45,14 @@
THỜI GIAN SỬ DỤNG
</div>
<div style="font-size:48px;font-weight:700;color:var(--pos-orange-primary);font-variant-numeric:tabular-nums;">
02:30:15
</div>
<div style="display:flex;justify-content:center;gap:24px;margin-top:12px;font-size:13px;color:var(--pos-text-secondary);">
<span>Bắt đầu: <b>19:30</b></span>
<span>Dự kiến: <b>22:00</b></span>
<span>Còn lại: <b style="color:#22C55E;">00:29:45</b></span>
@_elapsed.ToString(@"hh\:mm\:ss")
</div>
@if (_room?.SessionStart != null)
{
<div style="display:flex;justify-content:center;gap:24px;margin-top:12px;font-size:13px;color:var(--pos-text-secondary);">
<span>Bắt đầu: <b>@_room.SessionStart.Value.ToLocalTime().ToString("HH:mm")</b></span>
</div>
}
</div>
@* ═══ ROOM RATE / GIÁ PHÒNG ═══ *@
@@ -44,20 +60,11 @@
<div style="font-size:13px;font-weight:600;margin-bottom:12px;">Giá phòng</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Loại phòng</span>
<span>Deluxe</span>
<span>@(_room?.Type ?? "-")</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Giá/giờ</span>
<span>@FormatPrice(200_000)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Số giờ</span>
<span>2.5 giờ</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:14px;font-weight:600;
padding-top:8px;border-top:1px solid var(--pos-border-subtle);">
<span>Tiền phòng</span>
<span style="color:var(--pos-orange-primary);">@FormatPrice(500_000)</span>
<span style="color:var(--pos-text-secondary);">Thời gian</span>
<span>@_elapsed.ToString(@"h\:mm") giờ</span>
</div>
</div>
@@ -65,40 +72,32 @@
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;">
<button style="padding:14px 8px;border-radius:var(--pos-radius);background:rgba(59,130,246,.15);
border:none;color:#3B82F6;cursor:pointer;font-size:13px;font-weight:500;"
@onclick="() => _showExtend = !_showExtend">
@onclick="@(() => NavigateTo($"karaoke/room-extend/{RoomId}"))">
<i data-lucide="clock" style="width:18px;height:18px;display:block;margin:0 auto 4px;"></i>
Gia hạn
</button>
<button style="padding:14px 8px;border-radius:var(--pos-radius);background:rgba(34,197,94,.15);
border:none;color:#22C55E;cursor:pointer;font-size:13px;font-weight:500;"
@onclick="@(() => NavigateTo("karaoke/order-fnb"))">
@onclick="@(() => NavigateTo($"karaoke/order-fnb/{RoomId}"))">
<i data-lucide="utensils" style="width:18px;height:18px;display:block;margin:0 auto 4px;"></i>
Gọi F&B
</button>
<button style="padding:14px 8px;border-radius:var(--pos-radius);background:rgba(239,68,68,.15);
border:none;color:#EF4444;cursor:pointer;font-size:13px;font-weight:500;"
@onclick="() => _showEnd = !_showEnd">
disabled="@_ending"
@onclick="EndSession">
<i data-lucide="square" style="width:18px;height:18px;display:block;margin:0 auto 4px;"></i>
Kết thúc
@(_ending ? "Đang..." : "Kết thúc")
</button>
</div>
@* EN: Extend time dialog / VI: Dialog gia hạn *@
@if (_showExtend)
@if (!string.IsNullOrEmpty(_errorMsg))
{
<div style="background:var(--pos-bg-elevated);border-radius:var(--pos-radius);padding:16px;margin-top:12px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Gia hạn thêm</div>
<div style="display:flex;gap:8px;">
@foreach (var mins in new[] { 30, 60, 90, 120 })
{
<button style="flex:1;padding:10px;border-radius:8px;background:var(--pos-bg-interactive);
border:none;color:var(--pos-text-primary);cursor:pointer;font-size:13px;">
@(mins)p
</button>
}
</div>
<div style="margin-top:12px;padding:10px;border-radius:8px;background:rgba(239,68,68,.15);color:#EF4444;font-size:13px;">
@_errorMsg
</div>
}
}
</div>
@* ═══ F&B ORDER PANEL (RIGHT) / PANEL ĐƠN F&B (PHẢI) ═══ *@
@@ -109,19 +108,7 @@
</div>
<div class="pos-cart-items">
@if (_isLoading)
{
<div style="display:flex;align-items:center;justify-content:center;padding:32px;color:var(--pos-text-tertiary);">
Đang tải...
</div>
}
else if (_loadError)
{
<div style="display:flex;align-items:center;justify-content:center;padding:32px;color:var(--pos-text-tertiary);">
Không thể tải dữ liệu
</div>
}
else
@if (_fnbItems.Any())
{
@foreach (var item in _fnbItems)
{
@@ -130,48 +117,46 @@
<span class="pos-cart-item__name">@item.Name</span>
<span class="pos-cart-item__price">@FormatPrice(item.Price)</span>
</div>
<div class="pos-cart-item__qty">
<button @onclick="() => item.Qty = Math.Max(0, item.Qty - 1)"></button>
<span style="font-size:14px;font-weight:600;min-width:20px;text-align:center;">@item.Qty</span>
<button @onclick="() => item.Qty++">+</button>
</div>
<span style="font-size:13px;font-weight:600;">x@item.Qty</span>
</div>
}
}
else
{
<div style="text-align:center;padding:32px 16px;color:var(--pos-text-tertiary);font-size:13px;">
Chưa có đơn F&B
</div>
}
</div>
<div class="pos-cart-footer">
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px;">
<span style="color:var(--pos-text-secondary);">Tiền phòng</span>
<span>@FormatPrice(500_000)</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:8px;">
<span style="color:var(--pos-text-secondary);">Tiền F&B</span>
<span>@FormatPrice(_fnbItems.Sum(i => i.Price * i.Qty))</span>
</div>
<div class="pos-cart-total">
<span class="pos-cart-total__label">Tổng cộng</span>
<span class="pos-cart-total__value">@FormatPrice(500_000 + _fnbItems.Sum(i => i.Price * i.Qty))</span>
<span class="pos-cart-total__value">@FormatPrice(_fnbItems.Sum(i => i.Price * i.Qty))</span>
</div>
<button class="pos-btn-checkout">
<i data-lucide="credit-card" style="width:18px;height:18px;"></i> Thanh toán
<button class="pos-btn-checkout" @onclick="@(() => NavigateTo($"karaoke/order-fnb/{RoomId}"))">
<i data-lucide="plus" style="width:18px;height:18px;"></i> Gọi thêm F&B
</button>
</div>
</div>
</div>
@code {
[Parameter] public Guid RoomId { get; set; }
// EN: Loading state / VI: Trạng thái tải
private bool _isLoading = true;
private bool _loadError;
private bool _ending;
private string? _errorMsg;
// EN: UI toggles / VI: Bật tắt giao diện
private bool _showExtend;
private bool _showEnd;
// EN: F&B items loaded from DB / VI: Mục F&B tải từ DB
private RoomInfo? _room;
private List<FnbItem> _fnbItems = new();
private TimeSpan _elapsed;
private System.Threading.Timer? _timer;
protected override async Task OnInitializedAsync()
{
@@ -179,9 +164,42 @@
try
{
var products = await DataService.GetProductsAsync(ShopId);
var tablesTask = DataService.GetTablesAsync(ShopId);
var ordersTask = DataService.GetActiveTableOrdersAsync(ShopId);
await Task.WhenAll(tablesTask, ordersTask);
_fnbItems = products.Take(6).Select(p => new FnbItem(p.Name, p.Price, 1)).ToList();
var tables = await tablesTask;
var table = tables.FirstOrDefault(t => t.Id == RoomId);
if (table != null)
{
_room = new RoomInfo(table.Id, table.TableNumber, table.Capacity,
table.Zone ?? "Standard", table.Zone ?? "Tầng 1", table.StartedAt);
}
else
{
_loadError = true;
return;
}
// Load F&B orders for this room
var allOrders = await ordersTask;
var roomOrders = allOrders.Where(o => o.TableId == RoomId).ToList();
_fnbItems = roomOrders
.SelectMany(o => o.Items)
.GroupBy(i => new { i.ProductName, i.UnitPrice })
.Select(g => new FnbItem(g.Key.ProductName, g.Key.UnitPrice, g.Sum(i => i.Quantity)))
.ToList();
// Start timer
if (_room.SessionStart.HasValue)
{
_elapsed = DateTime.Now - _room.SessionStart.Value;
_timer = new System.Threading.Timer(_ =>
{
_elapsed = DateTime.Now - _room.SessionStart!.Value;
InvokeAsync(StateHasChanged);
}, null, 0, 1000);
}
}
catch
{
@@ -193,10 +211,27 @@
}
}
private class FnbItem(string name, decimal price, int qty)
private async Task EndSession()
{
public string Name { get; set; } = name;
public decimal Price { get; set; } = price;
public int Qty { get; set; } = qty;
_ending = true;
_errorMsg = null;
try
{
await DataService.UpdateTableStatusAsync(RoomId, "cleaning");
NavigateTo($"karaoke/room-reset/{RoomId}");
}
catch
{
_errorMsg = "Lỗi khi kết thúc phiên.";
_ending = false;
}
}
public void Dispose()
{
_timer?.Dispose();
}
private record RoomInfo(Guid Id, string Name, int Capacity, string Type, string Zone, DateTime? SessionStart);
private record FnbItem(string Name, decimal Price, int Qty);
}

View File

@@ -1066,4 +1066,58 @@ public class PosDataService
var r = await _http.SendAsync(request);
return r.IsSuccessStatusCode;
}
// ═══ TABLE STATUS ═══
public async Task<bool> UpdateTableStatusAsync(Guid tableId, string status)
{
AttachToken();
var request = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/tables/{tableId}/status")
{
Content = JsonContent.Create(new { status }, options: _writeOptions)
};
var r = await _http.SendAsync(request);
return r.IsSuccessStatusCode;
}
// ═══ SESSIONS ═══
public record SessionInfo(Guid Id, Guid TableId, Guid ShopId, int GuestCount, DateTime StartedAt, DateTime? ClosedAt, string Status);
public async Task<SessionInfo?> OpenSessionAsync(Guid tableId, Guid shopId, int guestCount = 1)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/sessions",
new { tableId, shopId, guestCount }, _writeOptions);
if (resp.IsSuccessStatusCode)
{
var json = await resp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>(_jsonOptions);
if (json.TryGetProperty("data", out var data))
{
var sessionId = data.GetProperty("sessionId").GetGuid();
return new SessionInfo(sessionId, tableId, shopId, guestCount, DateTime.UtcNow, null, "Open");
}
}
return null;
}
public async Task<SessionInfo?> GetSessionAsync(Guid sessionId)
{
AttachToken();
var resp = await _http.GetAsync($"api/bff/sessions/{sessionId}");
if (resp.IsSuccessStatusCode)
{
var json = await resp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>(_jsonOptions);
if (json.TryGetProperty("data", out var data))
return System.Text.Json.JsonSerializer.Deserialize<SessionInfo>(data.GetRawText(), _jsonOptions);
}
return null;
}
public async Task<bool> CloseSessionAsync(Guid sessionId)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/sessions/{sessionId}/close", new { }, _writeOptions);
return resp.IsSuccessStatusCode;
}
}

View File

@@ -138,4 +138,30 @@ public class FnbController : ControllerBase
};
return _fnb.SendAsync(request).ProxyAsync();
}
// ═══ TABLE STATUS ═══
[HttpPatch("tables/{tableId:guid}/status")]
public Task<IActionResult> UpdateTableStatus(Guid tableId, [FromBody] JsonElement body)
{
var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/tables/{tableId}/status")
{
Content = JsonContent.Create(body)
};
return _fnb.SendAsync(request).ProxyAsync();
}
// ═══ SESSIONS ═══
[HttpPost("sessions")]
public Task<IActionResult> OpenSession([FromBody] JsonElement body) =>
_fnb.PostAsJsonAsync("/api/v1/sessions", body).ProxyAsync();
[HttpGet("sessions/{id:guid}")]
public Task<IActionResult> GetSession(Guid id) =>
_fnb.GetAsync($"/api/v1/sessions/{id}").ProxyAsync();
[HttpPost("sessions/{id:guid}/close")]
public Task<IActionResult> CloseSession(Guid id) =>
_fnb.PostAsJsonAsync($"/api/v1/sessions/{id}/close", new { }).ProxyAsync();
}