feat(karaoke-pos): integrate session management and F&B ordering with backend APIs across the workflow pages.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user