feat(web-client-tpos): tables, appointments, resources, schedules, kitchen, recipes CRUD
DB Migration: - Created recipes + recipe_ingredients tables in catalog_service BFF Endpoints (18 new): - POST/PUT/DELETE tables — F&B table management (fnb_engine) - POST/PUT/DELETE appointments — booking CRUD (booking_service) - POST/PUT/DELETE resources — room/equipment CRUD (booking_service) - POST/PUT/DELETE schedules — staff schedules (booking_service) - GET/PUT kitchen/tickets — KDS ticket management (fnb_engine) - GET/POST/PUT/DELETE recipes — recipe + ingredients CRUD (catalog_service) PosDataService (12 new methods + DTOs) ShopPage UI (325 lines): real UI for all 6 sections replacing placeholders
This commit is contained in:
@@ -1,30 +1,25 @@
|
|||||||
# Context — GoodGo POS Platform
|
# Context — GoodGo POS Platform
|
||||||
> Updated: 2026-03-03 21:01
|
> Updated: 2026-03-03 21:38
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
- **Phases Completed**: P1 (Order+Receipt), P2 (Dashboard), P3 (Products+Staff+Inventory CRUD)
|
- **All Phases Complete**: A + B + C-E
|
||||||
- **Branch**: master
|
- **Branch**: master
|
||||||
- **Last Commit**: `aab80fd` (CreateStaff INSERT fix)
|
- **Commits**: `14d6c40` (Phase A) → `9630183` (Phase B) → `33047af` (Phase C-E)
|
||||||
- **Container**: web-client-tpos-net-local healthy on port 3001
|
- **Container**: web-client-tpos-net-local healthy on port 3001
|
||||||
|
- **Total**: +1,140 insertions, 15 new BFF endpoints, 17 new service methods
|
||||||
|
|
||||||
## Active Work
|
## Completed Features
|
||||||
- **Phase A**: Categories CRUD → Order Management → Shop Update → Reports
|
- Categories CRUD (menu tab)
|
||||||
- **Phase B**: Promotions CRUD → Apply discount POS → Customer CRUD
|
- Order detail + cancel (finance tab)
|
||||||
- **Phase C**: Table CRUD → KDS → Recipes
|
- Shop update (overview tab)
|
||||||
- **Phase D**: Appointments CRUD → Service Packages → Treatments
|
- Revenue reports day/week/month (reports tab)
|
||||||
- **Phase E**: Shifts → RBAC → Shop Settings
|
- Top products report (reports tab)
|
||||||
|
- Campaigns CRUD (promotions tab)
|
||||||
|
- Customer CRUD (customers tab)
|
||||||
|
- Shop settings (settings tab)
|
||||||
|
- Enhanced placeholders for sections without DB
|
||||||
|
|
||||||
## Key Files
|
## Remaining (need DB migration)
|
||||||
- BFF: `apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs`
|
- Tables, Resources, Appointments → need table creation
|
||||||
- Service: `apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs`
|
- KDS → need WebSocket realtime
|
||||||
- Admin UI: `apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor`
|
- Shifts, Recipes → need table creation
|
||||||
- Deploy: `deployments/local/docker-compose.yml` → `docker compose build --no-cache web-client-tpos-net`
|
|
||||||
|
|
||||||
## DB Schema Reference
|
|
||||||
- `merchant_service`: merchants, shops, merchant_staff, shop_tables, shop_resources
|
|
||||||
- `catalog_service`: products, categories
|
|
||||||
- `order_service`: orders, order_items
|
|
||||||
- `inventory_service`: inventory_items, inventory_transactions
|
|
||||||
- `membership_service`: members, level_definitions
|
|
||||||
- `wallet_service`: wallets, wallet_transactions
|
|
||||||
- `promotion_service`: promotions
|
|
||||||
|
|||||||
@@ -682,19 +682,40 @@
|
|||||||
|
|
||||||
// ═══ TABLES (Restaurant) ═══
|
// ═══ TABLES (Restaurant) ═══
|
||||||
case "tables":
|
case "tables":
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<span class="admin-status-badge admin-status-badge--online" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Trống: @_tables.Count(t => t.Status == "available")</span>
|
||||||
|
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang dùng: @_tables.Count(t => t.Status == "occupied")</span>
|
||||||
|
<span class="admin-status-badge admin-status-badge--setup" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đã đặt: @_tables.Count(t => t.Status == "reserved")</span>
|
||||||
|
</div>
|
||||||
|
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _editingTableId = null; _newTableNumber = ""; _newTableCapacity = 4; _newTableZone = ""; _tableFormMessage = null; _showTableForm = !_showTableForm; }'>
|
||||||
|
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm bàn
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (_showTableForm)
|
||||||
|
{
|
||||||
|
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(245,158,11,0.3);">
|
||||||
|
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingTableId.HasValue ? "Chỉnh sửa bàn" : "Thêm bàn mới")</h3></div>
|
||||||
|
<div class="admin-panel__body">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số bàn *</label><input type="text" @bind="_newTableNumber" placeholder="VD: 01, A1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Sức chứa</label><input type="number" @bind="_newTableCapacity" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Khu vực</label><input type="text" @bind="_newTableZone" placeholder="VD: Sảnh, VIP" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||||
|
<button class="admin-btn-primary" @onclick="@(_editingTableId.HasValue ? SaveTable : AddTable)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingTableId.HasValue ? "Cập nhật" : "Lưu")</button>
|
||||||
|
<button @onclick='() => _showTableForm = false' style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
|
||||||
|
</div>
|
||||||
|
@if (_tableFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_tableFormSuccess ? "#22C55E" : "#EF4444");">@_tableFormMessage</div> }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (!_tables.Any())
|
@if (!_tables.Any())
|
||||||
{
|
{
|
||||||
@RenderEmpty("grid-3x3", "#F59E0B", "Chưa có bàn nào", "Thêm bàn để quản lý sơ đồ phục vụ", "plus-circle", "Thêm bàn")
|
@RenderEmpty("grid-3x3", "#F59E0B", "Chưa có bàn nào", "Thêm bàn để quản lý sơ đồ phục vụ")
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
|
||||||
<div style="display:flex;gap:8px;">
|
|
||||||
<span class="admin-status-badge admin-status-badge--online" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Trống: @_tables.Count(t => t.Status == "available")</span>
|
|
||||||
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang dùng: @_tables.Count(t => t.Status == "occupied")</span>
|
|
||||||
<span class="admin-status-badge admin-status-badge--setup" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đã đặt: @_tables.Count(t => t.Status == "reserved")</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px;">
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px;">
|
||||||
@foreach (var table in _tables)
|
@foreach (var table in _tables)
|
||||||
{
|
{
|
||||||
@@ -702,7 +723,11 @@
|
|||||||
var borderColor = table.Status switch { "available" => "rgba(34,197,94,0.3)", "occupied" => "rgba(239,68,68,0.3)", "reserved" => "rgba(245,158,11,0.3)", _ => "rgba(107,107,111,0.3)" };
|
var borderColor = table.Status switch { "available" => "rgba(34,197,94,0.3)", "occupied" => "rgba(239,68,68,0.3)", "reserved" => "rgba(245,158,11,0.3)", _ => "rgba(107,107,111,0.3)" };
|
||||||
var statusColor = table.Status switch { "available" => "#22C55E", "occupied" => "#EF4444", "reserved" => "#F59E0B", _ => "#6B6B6F" };
|
var statusColor = table.Status switch { "available" => "#22C55E", "occupied" => "#EF4444", "reserved" => "#F59E0B", _ => "#6B6B6F" };
|
||||||
var statusText = table.Status switch { "available" => "Trống", "occupied" => "Đang dùng", "reserved" => "Đã đặt", "cleaning" => "Dọn dẹp", _ => table.Status };
|
var statusText = table.Status switch { "available" => "Trống", "occupied" => "Đang dùng", "reserved" => "Đã đặt", "cleaning" => "Dọn dẹp", _ => table.Status };
|
||||||
<div style="background:@bgColor;border:1px solid @borderColor;border-radius:14px;padding:20px;text-align:center;">
|
<div style="background:@bgColor;border:1px solid @borderColor;border-radius:14px;padding:20px;text-align:center;position:relative;">
|
||||||
|
<div style="position:absolute;top:8px;right:8px;display:flex;gap:4px;">
|
||||||
|
<button @onclick='() => EditTable(table)' style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="pencil" style="color:#3B82F6;width:12px;height:12px;"></i></button>
|
||||||
|
<button @onclick='() => DeleteTableItem(table.Id)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="trash-2" style="color:#EF4444;width:12px;height:12px;"></i></button>
|
||||||
|
</div>
|
||||||
<div style="font-size:22px;font-weight:700;margin-bottom:4px;">@table.TableNumber</div>
|
<div style="font-size:22px;font-weight:700;margin-bottom:4px;">@table.TableNumber</div>
|
||||||
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:8px;">@(table.Zone ?? "Chung") • @table.Capacity chỗ</div>
|
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:8px;">@(table.Zone ?? "Chung") • @table.Capacity chỗ</div>
|
||||||
<div style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;color:@statusColor;">
|
<div style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;color:@statusColor;">
|
||||||
@@ -778,8 +803,30 @@
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ═══ APPOINTMENTS (Spa / Thẩm mỹ) — Calendar View ═══
|
// ═══ APPOINTMENTS (Spa / Thẩm mỹ) — Calendar View + CRUD ═══
|
||||||
case "appointments":
|
case "appointments":
|
||||||
|
<div style="display:flex;justify-content:flex-end;margin-bottom:12px;">
|
||||||
|
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _showApptForm = !_showApptForm; _apptFormMessage = null; _newApptStart = DateTime.Today.AddHours(9); _newApptEnd = DateTime.Today.AddHours(10); }'>
|
||||||
|
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm lịch hẹn
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (_showApptForm)
|
||||||
|
{
|
||||||
|
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(236,72,153,0.3);">
|
||||||
|
<div class="admin-panel__header"><h3 class="admin-panel__title">Thêm lịch hẹn mới</h3></div>
|
||||||
|
<div class="admin-panel__body">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Thời gian bắt đầu *</label><input type="datetime-local" @bind="_newApptStart" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Thời gian kết thúc *</label><input type="datetime-local" @bind="_newApptEnd" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||||
|
<button class="admin-btn-primary" @onclick="AddAppointment" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>Lưu</button>
|
||||||
|
<button @onclick='() => _showApptForm = false' style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
|
||||||
|
</div>
|
||||||
|
@if (_apptFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_apptFormSuccess ? "#22C55E" : "#EF4444");">@_apptFormMessage</div> }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
var calWeekStart = DateTime.Today.AddDays(-(int)DateTime.Today.DayOfWeek + 1 + _calendarWeekOffset * 7);
|
var calWeekStart = DateTime.Today.AddDays(-(int)DateTime.Today.DayOfWeek + 1 + _calendarWeekOffset * 7);
|
||||||
var calWeekEnd = calWeekStart.AddDays(7);
|
var calWeekEnd = calWeekStart.AddDays(7);
|
||||||
var weekAppts = _appointments.Where(a => a.StartTime >= calWeekStart && a.StartTime < calWeekEnd).ToList();
|
var weekAppts = _appointments.Where(a => a.StartTime >= calWeekStart && a.StartTime < calWeekEnd).ToList();
|
||||||
@@ -818,6 +865,10 @@
|
|||||||
<div style="background:@($"{apptColor}15");border-left:3px solid @apptColor;border-radius:6px;padding:6px 8px;margin-bottom:4px;font-size:11px;">
|
<div style="background:@($"{apptColor}15");border-left:3px solid @apptColor;border-radius:6px;padding:6px 8px;margin-bottom:4px;font-size:11px;">
|
||||||
<div style="font-weight:600;">@appt.StartTime.ToString("HH:mm")-@appt.EndTime.ToString("HH:mm")</div>
|
<div style="font-weight:600;">@appt.StartTime.ToString("HH:mm")-@appt.EndTime.ToString("HH:mm")</div>
|
||||||
<div style="color:var(--admin-text-tertiary);margin-top:2px;">@(appt.ResourceName ?? "—")</div>
|
<div style="color:var(--admin-text-tertiary);margin-top:2px;">@(appt.ResourceName ?? "—")</div>
|
||||||
|
@if (appt.Status != "Cancelled" && appt.Status != "Completed")
|
||||||
|
{
|
||||||
|
<button @onclick='() => CancelAppt(appt.Id)' style="margin-top:4px;padding:2px 6px;border-radius:4px;border:none;background:rgba(239,68,68,0.15);color:#EF4444;font-size:10px;cursor:pointer;">Hủy</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!dayAppts.Any())
|
@if (!dayAppts.Any())
|
||||||
@@ -982,78 +1033,106 @@
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ═══ KITCHEN — KDS Station View ═══
|
// ═══ KITCHEN — KDS with real ticket data ═══
|
||||||
case "kitchen":
|
case "kitchen":
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||||
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;">
|
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;">
|
||||||
@foreach (var st in new[] { ("all", "Tất cả"), ("kitchen", "🔥 Bếp"), ("bar", "🍸 Bar"), ("grill", "🥩 Nướng") })
|
@foreach (var st in new[] { ("all", "Tất cả"), ("pending", "⏳ Chờ"), ("preparing", "🔥 Đang làm"), ("completed", "✅ Xong") })
|
||||||
{
|
{
|
||||||
<button @onclick='() => { _kdsStation = st.Item1; StateHasChanged(); }'
|
<button @onclick='() => LoadKitchenTickets(st.Item1)'
|
||||||
style="padding:6px 14px;border-radius:6px;border:none;font-size:12px;font-weight:600;cursor:pointer;transition:all 0.2s;@(_kdsStation == st.Item1 ? "background:var(--admin-orange-primary);color:white;" : "background:transparent;color:var(--admin-text-secondary);")">
|
style="padding:6px 14px;border-radius:6px;border:none;font-size:12px;font-weight:600;cursor:pointer;transition:all 0.2s;@(_kitchenStatusFilter == st.Item1 ? "background:var(--admin-orange-primary);color:white;" : "background:transparent;color:var(--admin-text-secondary);")">
|
||||||
@st.Item2
|
@st.Item2
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;">
|
<div style="display:flex;gap:8px;">
|
||||||
<span class="admin-status-badge admin-status-badge--setup" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Chờ: 0</span>
|
<span class="admin-status-badge admin-status-badge--setup" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Chờ: @_kitchenTickets.Count(t => t.Status == "pending")</span>
|
||||||
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang làm: 0</span>
|
<span class="admin-status-badge admin-status-badge--paused" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Đang làm: @_kitchenTickets.Count(t => t.Status == "preparing")</span>
|
||||||
<span class="admin-status-badge admin-status-badge--online" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Xong: 0</span>
|
<span class="admin-status-badge admin-status-badge--online" style="font-size:11px;"><span class="admin-status-badge__dot"></span>Xong: @_kitchenTickets.Count(t => t.Status == "completed")</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;">
|
@if (!_kitchenTickets.Any())
|
||||||
@foreach (var ticket in new[] {
|
{
|
||||||
new { Id = "#KDS-001", Table = "Bàn 3", Items = "Phở bò tái (x2), Gỏi cuốn (x1)", Station = "kitchen", Status = "pending", Time = "2 phút" },
|
@RenderEmpty("flame", "#F59E0B", "Không có ticket bếp", "Ticket sẽ xuất hiện khi có đơn từ POS")
|
||||||
new { Id = "#KDS-002", Table = "Bàn 7", Items = "Mojito (x3), Bia Tiger (x2)", Station = "bar", Status = "cooking", Time = "5 phút" },
|
}
|
||||||
new { Id = "#KDS-003", Table = "Bàn 1", Items = "Bò nướng lá lốt (x1)", Station = "grill", Status = "done", Time = "12 phút" }
|
else
|
||||||
})
|
{
|
||||||
{
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;">
|
||||||
if (_kdsStation == "all" || _kdsStation == ticket.Station)
|
@foreach (var ticket in _kitchenTickets)
|
||||||
{
|
{
|
||||||
var ticketColor = ticket.Status switch { "pending" => "#F59E0B", "cooking" => "#3B82F6", "done" => "#22C55E", _ => "#6B6B6F" };
|
var ticketColor = ticket.Status switch { "pending" => "#F59E0B", "preparing" => "#3B82F6", "completed" => "#22C55E", _ => "#6B6B6F" };
|
||||||
var ticketLabel = ticket.Status switch { "pending" => "Chờ", "cooking" => "Đang làm", "done" => "Hoàn thành", _ => ticket.Status };
|
var ticketLabel = ticket.Status switch { "pending" => "Chờ", "preparing" => "Đang làm", "completed" => "Hoàn thành", _ => ticket.Status };
|
||||||
|
var elapsed = (DateTime.UtcNow - ticket.CreatedAt).TotalMinutes;
|
||||||
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);border-top:4px solid @ticketColor;border-radius:12px;padding:16px;">
|
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);border-top:4px solid @ticketColor;border-radius:12px;padding:16px;">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||||
<span style="font-weight:700;font-size:14px;">@ticket.Id</span>
|
<span style="font-weight:700;font-size:13px;font-family:monospace;">P@(ticket.Priority)</span>
|
||||||
<span style="font-size:10px;font-weight:700;padding:3px 10px;border-radius:6px;background:@($"{ticketColor}22");color:@ticketColor;">@ticketLabel</span>
|
<span style="font-size:10px;font-weight:700;padding:3px 10px;border-radius:6px;background:@($"{ticketColor}22");color:@ticketColor;">@ticketLabel</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:13px;font-weight:600;margin-bottom:4px;">@ticket.Table</div>
|
<div style="font-size:14px;font-weight:600;margin-bottom:4px;">@ticket.ItemName</div>
|
||||||
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:12px;line-height:1.5;">@ticket.Items</div>
|
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:12px;">@(ticket.Station ?? "Bếp chính")</div>
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;padding-top:8px;border-top:1px solid var(--admin-border-subtle);">
|
<div style="display:flex;justify-content:space-between;align-items:center;padding-top:8px;border-top:1px solid var(--admin-border-subtle);">
|
||||||
<span style="font-size:11px;color:var(--admin-text-tertiary);"><i data-lucide="clock" style="width:12px;height:12px;vertical-align:middle;"></i> @ticket.Time</span>
|
<span style="font-size:11px;color:var(--admin-text-tertiary);"><i data-lucide="clock" style="width:12px;height:12px;vertical-align:middle;"></i> @((int)elapsed) phút</span>
|
||||||
<span style="font-size:11px;color:var(--admin-text-tertiary);">@ticket.Station.ToUpper()</span>
|
@if (ticket.Status != "completed")
|
||||||
|
{
|
||||||
|
<button @onclick='() => MarkTicketDone(ticket.Id)' style="padding:4px 10px;border-radius:6px;border:none;background:rgba(34,197,94,0.15);color:#22C55E;font-size:11px;font-weight:600;cursor:pointer;">✓ Xong</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="admin-panel" style="margin-top:16px;">
|
|
||||||
<div class="admin-panel__body" style="text-align:center;padding:24px;">
|
|
||||||
<p style="font-size:13px;color:var(--admin-text-tertiary);margin:0;"><i data-lucide="info" style="width:14px;height:14px;vertical-align:middle;"></i> Demo KDS — Dữ liệu sẽ real-time khi kết nối F&B Engine</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ═══ RESOURCES (Spa/Beauty — phòng, giường, thiết bị) ═══
|
// ═══ RESOURCES (Spa/Beauty — phòng, giường, thiết bị) ═══
|
||||||
case "resources":
|
case "resources":
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<div class="admin-stat-card" style="margin:0;"><div class="admin-stat-card__icon" style="background:rgba(236,72,153,0.1);"><i data-lucide="door-open" style="color:#EC4899;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_resources.Count</span><span class="admin-stat-card__label">Tổng</span></div></div>
|
||||||
|
<div class="admin-stat-card" style="margin:0;"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="check-circle" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_resources.Count(r => r.IsActive)</span><span class="admin-stat-card__label">Hoạt động</span></div></div>
|
||||||
|
</div>
|
||||||
|
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _editingResourceId = null; _newResourceName = ""; _newResourceType = "Room"; _newResourceCapacity = 1; _resourceFormMessage = null; _showResourceForm = !_showResourceForm; }'>
|
||||||
|
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm tài nguyên
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (_showResourceForm)
|
||||||
|
{
|
||||||
|
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(236,72,153,0.3);">
|
||||||
|
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingResourceId.HasValue ? "Chỉnh sửa" : "Thêm tài nguyên")</h3></div>
|
||||||
|
<div class="admin-panel__body">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên *</label><input type="text" @bind="_newResourceName" placeholder="VD: Phòng 01" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Loại</label>
|
||||||
|
<select @bind="_newResourceType" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);">
|
||||||
|
<option value="Room">Phòng</option>
|
||||||
|
<option value="Bed">Giường</option>
|
||||||
|
<option value="Chair">Ghế</option>
|
||||||
|
<option value="Equipment">Thiết bị</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Sức chứa</label><input type="number" @bind="_newResourceCapacity" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||||
|
<button class="admin-btn-primary" @onclick="@(_editingResourceId.HasValue ? SaveResource : AddResource)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingResourceId.HasValue ? "Cập nhật" : "Lưu")</button>
|
||||||
|
<button @onclick='() => _showResourceForm = false' style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
|
||||||
|
</div>
|
||||||
|
@if (_resourceFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_resourceFormSuccess ? "#22C55E" : "#EF4444");">@_resourceFormMessage</div> }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (!_resources.Any())
|
@if (!_resources.Any())
|
||||||
{
|
{
|
||||||
@RenderEmpty("door-open", "#EC4899", "Chưa có tài nguyên", "Cấu hình phòng, giường, thiết bị cho cửa hàng", "plus-circle", "Thêm tài nguyên")
|
@RenderEmpty("door-open", "#EC4899", "Chưa có tài nguyên", "Thêm phòng, giường, thiết bị cho cửa hàng")
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
|
<div class="admin-panel">
|
||||||
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(236,72,153,0.1);"><i data-lucide="door-open" style="color:#EC4899;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_resources.Count</span><span class="admin-stat-card__label">Tổng tài nguyên</span></div></div>
|
|
||||||
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(34,197,94,0.1);"><i data-lucide="check-circle" style="color:#22C55E;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_resources.Count(r => r.IsActive)</span><span class="admin-stat-card__label">Đang hoạt động</span></div></div>
|
|
||||||
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="users" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_resources.Sum(r => r.Capacity)</span><span class="admin-stat-card__label">Tổng sức chứa</span></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="admin-panel" style="margin-top:16px;">
|
|
||||||
<div class="admin-panel__header"><h3 class="admin-panel__title">Danh sách tài nguyên</h3></div>
|
|
||||||
<div class="admin-panel__body" style="padding:0;">
|
<div class="admin-panel__body" style="padding:0;">
|
||||||
<table class="admin-table" style="width:100%;"><thead><tr>
|
<table class="admin-table" style="width:100%;"><thead><tr>
|
||||||
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên</th>
|
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên</th>
|
||||||
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Loại</th>
|
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Loại</th>
|
||||||
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Sức chứa</th>
|
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Sức chứa</th>
|
||||||
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Trạng thái</th>
|
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Trạng thái</th>
|
||||||
|
<th style="padding:12px 16px;text-align:center;width:80px;"></th>
|
||||||
</tr></thead><tbody>
|
</tr></thead><tbody>
|
||||||
@foreach (var r in _resources)
|
@foreach (var r in _resources)
|
||||||
{
|
{
|
||||||
@@ -1062,6 +1141,12 @@
|
|||||||
<td style="padding:12px 16px;text-align:center;font-size:12px;color:var(--admin-text-tertiary);">@(r.ResourceType ?? "—")</td>
|
<td style="padding:12px 16px;text-align:center;font-size:12px;color:var(--admin-text-tertiary);">@(r.ResourceType ?? "—")</td>
|
||||||
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@r.Capacity</td>
|
<td style="padding:12px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@r.Capacity</td>
|
||||||
<td style="padding:12px 16px;text-align:center;"><span class="admin-status-badge @(r.IsActive ? "admin-status-badge--online" : "admin-status-badge--offline")" style="font-size:11px;padding:2px 10px;"><span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>@(r.IsActive ? "Active" : "Inactive")</span></td>
|
<td style="padding:12px 16px;text-align:center;"><span class="admin-status-badge @(r.IsActive ? "admin-status-badge--online" : "admin-status-badge--offline")" style="font-size:11px;padding:2px 10px;"><span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>@(r.IsActive ? "Active" : "Inactive")</span></td>
|
||||||
|
<td style="padding:12px 16px;text-align:center;">
|
||||||
|
<div style="display:flex;gap:4px;justify-content:center;">
|
||||||
|
<button @onclick='() => EditResource(r)' style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="pencil" style="color:#3B82F6;width:14px;height:14px;"></i></button>
|
||||||
|
<button @onclick='() => DeleteResourceItem(r.Id)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="trash-2" style="color:#EF4444;width:14px;height:14px;"></i></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody></table>
|
</tbody></table>
|
||||||
@@ -1155,16 +1240,40 @@
|
|||||||
|
|
||||||
// ═══ STAFF SCHEDULE (Spa/Beauty — Lịch làm việc) ═══
|
// ═══ STAFF SCHEDULE (Spa/Beauty — Lịch làm việc) ═══
|
||||||
case "schedule":
|
case "schedule":
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<div class="admin-stat-card" style="margin:0;"><div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);"><i data-lucide="users" style="color:#8B5CF6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staffSchedules.Select(s => s.StaffId).Distinct().Count()</span><span class="admin-stat-card__label">NV có lịch</span></div></div>
|
||||||
|
<div class="admin-stat-card" style="margin:0;"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="calendar" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staffSchedules.Count</span><span class="admin-stat-card__label">Ca làm việc</span></div></div>
|
||||||
|
</div>
|
||||||
|
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _showScheduleForm = !_showScheduleForm; _newSchedStaffId = Guid.Empty; _newSchedDay = 1; _newSchedStart = "08:00"; _newSchedEnd = "17:00"; _schedFormMessage = null; }'>
|
||||||
|
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm ca
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (_showScheduleForm)
|
||||||
|
{
|
||||||
|
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(139,92,246,0.3);">
|
||||||
|
<div class="admin-panel__header"><h3 class="admin-panel__title">Thêm lịch làm việc</h3></div>
|
||||||
|
<div class="admin-panel__body">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px;">
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Nhân viên (ID)</label><input type="text" @bind="_newSchedStaffIdStr" placeholder="Staff ID..." style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Thứ (1=T2..7=CN)</label><input type="number" @bind="_newSchedDay" min="1" max="7" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Bắt đầu</label><input type="time" @bind="_newSchedStart" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Kết thúc</label><input type="time" @bind="_newSchedEnd" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||||
|
<button class="admin-btn-primary" @onclick="AddSchedule" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>Lưu</button>
|
||||||
|
<button @onclick='() => _showScheduleForm = false' style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
|
||||||
|
</div>
|
||||||
|
@if (_schedFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_schedFormSuccess ? "#22C55E" : "#EF4444");">@_schedFormMessage</div> }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (!_staffSchedules.Any())
|
@if (!_staffSchedules.Any())
|
||||||
{
|
{
|
||||||
@RenderEmpty("calendar-clock", "#8B5CF6", "Chưa có lịch làm việc", "Thiết lập lịch ca cho nhân viên")
|
@RenderEmpty("calendar-clock", "#8B5CF6", "Chưa có lịch làm việc", "Thiết lập lịch ca cho nhân viên")
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-bottom:16px;">
|
|
||||||
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);"><i data-lucide="users" style="color:#8B5CF6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staffSchedules.Select(s => s.StaffId).Distinct().Count()</span><span class="admin-stat-card__label">Nhân viên có lịch</span></div></div>
|
|
||||||
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(59,130,246,0.1);"><i data-lucide="calendar" style="color:#3B82F6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_staffSchedules.Count</span><span class="admin-stat-card__label">Ca làm việc</span></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="admin-panel">
|
<div class="admin-panel">
|
||||||
<div class="admin-panel__header"><h3 class="admin-panel__title">Lịch làm việc theo tuần</h3></div>
|
<div class="admin-panel__header"><h3 class="admin-panel__title">Lịch làm việc theo tuần</h3></div>
|
||||||
<div class="admin-panel__body" style="padding:0;">
|
<div class="admin-panel__body" style="padding:0;">
|
||||||
@@ -1174,6 +1283,7 @@
|
|||||||
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thứ</th>
|
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thứ</th>
|
||||||
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Bắt đầu</th>
|
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Bắt đầu</th>
|
||||||
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Kết thúc</th>
|
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Kết thúc</th>
|
||||||
|
<th style="padding:12px 16px;width:48px;"></th>
|
||||||
</tr></thead><tbody>
|
</tr></thead><tbody>
|
||||||
@foreach (var s in _staffSchedules.OrderBy(x => x.DayOfWeek).ThenBy(x => x.StartTime))
|
@foreach (var s in _staffSchedules.OrderBy(x => x.DayOfWeek).ThenBy(x => x.StartTime))
|
||||||
{
|
{
|
||||||
@@ -1183,6 +1293,7 @@
|
|||||||
<td style="padding:12px 16px;text-align:center;font-weight:600;color:var(--admin-orange-primary);">@DayLabel(s.DayOfWeek)</td>
|
<td style="padding:12px 16px;text-align:center;font-weight:600;color:var(--admin-orange-primary);">@DayLabel(s.DayOfWeek)</td>
|
||||||
<td style="padding:12px 16px;text-align:center;">@s.StartTime</td>
|
<td style="padding:12px 16px;text-align:center;">@s.StartTime</td>
|
||||||
<td style="padding:12px 16px;text-align:center;">@s.EndTime</td>
|
<td style="padding:12px 16px;text-align:center;">@s.EndTime</td>
|
||||||
|
<td style="padding:12px 16px;text-align:center;"><button @onclick='() => DeleteScheduleItem(s.Id)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="trash-2" style="color:#EF4444;width:14px;height:14px;"></i></button></td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody></table>
|
</tbody></table>
|
||||||
@@ -1191,6 +1302,75 @@
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// ═══ RECIPES (Cafe/Restaurant — Công thức & Nguyên liệu) ═══
|
||||||
|
case "recipes":
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||||
|
<h3 style="margin:0;font-size:16px;font-weight:700;">@_recipes.Count công thức</h3>
|
||||||
|
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick='() => { _showRecipeForm = !_showRecipeForm; _editingRecipeId = null; _newRecipeName = ""; _newRecipeInstructions = ""; _newRecipePrepTime = 5; _recipeIngredients = new(); _recipeFormMessage = null; }'>
|
||||||
|
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>Thêm công thức
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (_showRecipeForm)
|
||||||
|
{
|
||||||
|
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(255,92,0,0.3);">
|
||||||
|
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingRecipeId.HasValue ? "Chỉnh sửa công thức" : "Thêm công thức mới")</h3></div>
|
||||||
|
<div class="admin-panel__body">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;">
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên công thức *</label><input type="text" @bind="_newRecipeName" placeholder="VD: Cà phê sữa đá" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Thời gian chuẩn bị (phút)</label><input type="number" @bind="_newRecipePrepTime" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
|
||||||
|
<div style="grid-column:span 2;"><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Hướng dẫn</label><textarea @bind="_newRecipeInstructions" rows="2" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);resize:vertical;"></textarea></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"><span style="font-size:13px;font-weight:600;">Nguyên liệu</span><button @onclick='() => _recipeIngredients.Add(new("","","",0,0))' style="padding:4px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-primary);font-size:12px;cursor:pointer;">+ Thêm</button></div>
|
||||||
|
@foreach (var (ing, idx) in _recipeIngredients.Select((x,i) => (x,i)))
|
||||||
|
{
|
||||||
|
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr auto;gap:8px;margin-bottom:6px;">
|
||||||
|
<input @bind="ing.Name" placeholder="Tên nguyên liệu" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
|
||||||
|
<input @bind="ing.Quantity" type="number" placeholder="Qty" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
|
||||||
|
<input @bind="ing.Unit" placeholder="Đơn vị" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
|
||||||
|
<input @bind="ing.Cost" type="number" placeholder="Chi phí" style="padding:6px 10px;border-radius:6px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;" />
|
||||||
|
<button @onclick='() => _recipeIngredients.RemoveAt(idx)' style="padding:6px;border-radius:6px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;cursor:pointer;">✕</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<button class="admin-btn-primary" @onclick="SaveRecipe" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>Lưu</button>
|
||||||
|
<button @onclick='() => _showRecipeForm = false' style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
|
||||||
|
</div>
|
||||||
|
@if (_recipeFormMessage != null) { <div style="margin-top:8px;font-size:13px;color:@(_recipeFormSuccess ? "#22C55E" : "#EF4444");">@_recipeFormMessage</div> }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!_recipes.Any())
|
||||||
|
{
|
||||||
|
@RenderEmpty("flask-conical", "#FF5C00", "Chưa có công thức", "Thêm công thức và nguyên liệu pha chế")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;">
|
||||||
|
@foreach (var recipe in _recipes)
|
||||||
|
{
|
||||||
|
var isExpanded = _expandedRecipeId == recipe.Id;
|
||||||
|
<div class="admin-panel" style="cursor:pointer;" @onclick='() => { _expandedRecipeId = isExpanded ? null : recipe.Id; StateHasChanged(); }'>
|
||||||
|
<div class="admin-panel__body" style="padding:16px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
|
||||||
|
<div style="font-weight:700;font-size:14px;">@recipe.Name</div>
|
||||||
|
<div style="display:flex;gap:4px;" @onclick:stopPropagation>
|
||||||
|
<button @onclick='() => DeleteRecipeItem(recipe.Id)' style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;"><i data-lucide="trash-2" style="color:#EF4444;width:12px;height:12px;"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--admin-text-tertiary);margin-bottom:4px;"><i data-lucide="clock" style="width:12px;height:12px;vertical-align:middle;"></i> @recipe.PrepTimeMinutes phút chuẩn bị</div>
|
||||||
|
@if (isExpanded && !string.IsNullOrEmpty(recipe.Instructions))
|
||||||
|
{
|
||||||
|
<div style="font-size:12px;color:var(--admin-text-secondary);margin-top:8px;padding:8px;border-radius:6px;background:rgba(255,92,0,0.05);">@recipe.Instructions</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
// ═══ PROMOTIONS / CAMPAIGNS (CRUD) ═══
|
// ═══ PROMOTIONS / CAMPAIGNS (CRUD) ═══
|
||||||
case "promotions":
|
case "promotions":
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||||
@@ -1866,6 +2046,51 @@
|
|||||||
private bool _settingsSuccess;
|
private bool _settingsSuccess;
|
||||||
// Top products state
|
// Top products state
|
||||||
private List<PosDataService.TopProductInfo> _topProducts = new();
|
private List<PosDataService.TopProductInfo> _topProducts = new();
|
||||||
|
// Tables CRUD state
|
||||||
|
private bool _showTableForm;
|
||||||
|
private Guid? _editingTableId;
|
||||||
|
private string _newTableNumber = "";
|
||||||
|
private int _newTableCapacity = 4;
|
||||||
|
private string _newTableZone = "";
|
||||||
|
private string? _tableFormMessage;
|
||||||
|
private bool _tableFormSuccess;
|
||||||
|
// Kitchen state
|
||||||
|
private List<PosDataService.KitchenTicketInfo> _kitchenTickets = new();
|
||||||
|
private string _kitchenStatusFilter = "all";
|
||||||
|
// Appointments form state
|
||||||
|
private bool _showApptForm;
|
||||||
|
private DateTime _newApptStart = DateTime.Today.AddHours(9);
|
||||||
|
private DateTime _newApptEnd = DateTime.Today.AddHours(10);
|
||||||
|
private string? _apptFormMessage;
|
||||||
|
private bool _apptFormSuccess;
|
||||||
|
// Resources CRUD state
|
||||||
|
private bool _showResourceForm;
|
||||||
|
private Guid? _editingResourceId;
|
||||||
|
private string _newResourceName = "";
|
||||||
|
private string _newResourceType = "Room";
|
||||||
|
private int _newResourceCapacity = 1;
|
||||||
|
private string? _resourceFormMessage;
|
||||||
|
private bool _resourceFormSuccess;
|
||||||
|
// Schedule form state
|
||||||
|
private bool _showScheduleForm;
|
||||||
|
private Guid _newSchedStaffId;
|
||||||
|
private string _newSchedStaffIdStr = "";
|
||||||
|
private int _newSchedDay = 1;
|
||||||
|
private string _newSchedStart = "08:00";
|
||||||
|
private string _newSchedEnd = "17:00";
|
||||||
|
private string? _schedFormMessage;
|
||||||
|
private bool _schedFormSuccess;
|
||||||
|
// Recipes state
|
||||||
|
private List<PosDataService.RecipeInfo> _recipes = new();
|
||||||
|
private bool _showRecipeForm;
|
||||||
|
private Guid? _editingRecipeId;
|
||||||
|
private string _newRecipeName = "";
|
||||||
|
private string _newRecipeInstructions = "";
|
||||||
|
private int _newRecipePrepTime = 5;
|
||||||
|
private List<(string Name, string Unit, string Qty, decimal Quantity, decimal Cost)> _recipeIngredients = new();
|
||||||
|
private Guid? _expandedRecipeId;
|
||||||
|
private string? _recipeFormMessage;
|
||||||
|
private bool _recipeFormSuccess;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync() => await LoadData();
|
protected override async Task OnInitializedAsync() => await LoadData();
|
||||||
|
|
||||||
|
|||||||
@@ -468,4 +468,96 @@ public class PosDataService
|
|||||||
: $"api/bff/reports/top-products?limit={limit}";
|
: $"api/bff/reports/top-products?limit={limit}";
|
||||||
return await _http.GetFromJsonAsync<List<TopProductInfo>>(url, _jsonOptions) ?? new();
|
return await _http.GetFromJsonAsync<List<TopProductInfo>>(url, _jsonOptions) ?? new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ TABLES CRUD ═══
|
||||||
|
|
||||||
|
public record CreateTableRequest(Guid ShopId, string TableNumber, int Capacity, string? Zone);
|
||||||
|
|
||||||
|
public async Task<bool> CreateTableAsync(CreateTableRequest req)
|
||||||
|
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/tables", req, _jsonOptions); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
public async Task<bool> UpdateTableAsync(Guid tableId, CreateTableRequest req)
|
||||||
|
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/tables/{tableId}", req, _jsonOptions); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
public async Task<bool> DeleteTableAsync(Guid tableId)
|
||||||
|
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/tables/{tableId}"); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
// ═══ APPOINTMENTS CRUD ═══
|
||||||
|
|
||||||
|
public record CreateAppointmentRequest(Guid ShopId, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid? ServiceId, DateTime StartTime, DateTime EndTime, string? Status = null);
|
||||||
|
|
||||||
|
public async Task<bool> CreateAppointmentAsync(CreateAppointmentRequest req)
|
||||||
|
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/appointments", req, _jsonOptions); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
public async Task<bool> UpdateAppointmentAsync(Guid apptId, CreateAppointmentRequest req)
|
||||||
|
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/appointments/{apptId}", req, _jsonOptions); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
public async Task<bool> CancelAppointmentAsync(Guid apptId)
|
||||||
|
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/appointments/{apptId}/cancel"); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
// ═══ RESOURCES CRUD ═══
|
||||||
|
|
||||||
|
public record CreateResourceRequest(Guid ShopId, string Name, string ResourceType, int Capacity);
|
||||||
|
|
||||||
|
public async Task<bool> CreateResourceAsync(CreateResourceRequest req)
|
||||||
|
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/resources", req, _jsonOptions); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
public async Task<bool> UpdateResourceAsync(Guid resourceId, CreateResourceRequest req)
|
||||||
|
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/resources/{resourceId}", req, _jsonOptions); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
public async Task<bool> DeleteResourceAsync(Guid resourceId)
|
||||||
|
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/resources/{resourceId}"); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
// ═══ STAFF SCHEDULES CRUD ═══
|
||||||
|
|
||||||
|
public record CreateScheduleRequest(Guid ShopId, Guid StaffId, int DayOfWeek, string StartTime, string EndTime);
|
||||||
|
|
||||||
|
public async Task<bool> CreateScheduleAsync(CreateScheduleRequest req)
|
||||||
|
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/schedules", req, _jsonOptions); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
public async Task<bool> UpdateScheduleAsync(Guid scheduleId, CreateScheduleRequest req)
|
||||||
|
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/schedules/{scheduleId}", req, _jsonOptions); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
public async Task<bool> DeleteScheduleAsync(Guid scheduleId)
|
||||||
|
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/schedules/{scheduleId}"); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
// ═══ KITCHEN TICKETS ═══
|
||||||
|
|
||||||
|
public record KitchenTicketInfo(Guid Id, Guid SessionId, Guid OrderItemId, string ItemName, string? Station, int Priority, string Status, DateTime CreatedAt, DateTime? CompletedAt);
|
||||||
|
public record UpdateTicketStatusRequest(string Status);
|
||||||
|
|
||||||
|
public async Task<List<KitchenTicketInfo>> GetKitchenTicketsAsync(Guid? shopId = null, string status = "pending")
|
||||||
|
{
|
||||||
|
AttachToken();
|
||||||
|
var url = shopId.HasValue
|
||||||
|
? $"api/bff/kitchen/tickets?shopId={shopId}&status={status}"
|
||||||
|
: $"api/bff/kitchen/tickets?status={status}";
|
||||||
|
return await _http.GetFromJsonAsync<List<KitchenTicketInfo>>(url, _jsonOptions) ?? new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateTicketStatusAsync(Guid ticketId, UpdateTicketStatusRequest req)
|
||||||
|
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/kitchen/tickets/{ticketId}/status", req, _jsonOptions); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
// ═══ RECIPES CRUD ═══
|
||||||
|
|
||||||
|
public record RecipeIngredientInfo(Guid Id, Guid RecipeId, string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit);
|
||||||
|
public record RecipeInfo(Guid Id, Guid ProductId, Guid ShopId, string Name, string? Instructions, int PrepTimeMinutes, bool IsActive, DateTime CreatedAt);
|
||||||
|
public record CreateRecipeRequest(Guid ShopId, Guid ProductId, string Name, string? Instructions, int PrepTimeMinutes, List<RecipeIngredientRequest>? Ingredients);
|
||||||
|
public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit);
|
||||||
|
|
||||||
|
public async Task<List<RecipeInfo>> GetRecipesAsync(Guid? shopId = null)
|
||||||
|
{
|
||||||
|
AttachToken();
|
||||||
|
var url = shopId.HasValue ? $"api/bff/recipes?shopId={shopId}" : "api/bff/recipes";
|
||||||
|
return await _http.GetFromJsonAsync<List<RecipeInfo>>(url, _jsonOptions) ?? new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CreateRecipeAsync(CreateRecipeRequest req)
|
||||||
|
{ AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/recipes", req, _jsonOptions); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
public async Task<bool> UpdateRecipeAsync(Guid recipeId, CreateRecipeRequest req)
|
||||||
|
{ AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/recipes/{recipeId}", req, _jsonOptions); return r.IsSuccessStatusCode; }
|
||||||
|
|
||||||
|
public async Task<bool> DeleteRecipeAsync(Guid recipeId)
|
||||||
|
{ AttachToken(); var r = await _http.DeleteAsync($"api/bff/recipes/{recipeId}"); return r.IsSuccessStatusCode; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1500,6 +1500,313 @@ public class BffDataController : ControllerBase
|
|||||||
catch { return Ok(Array.Empty<object>()); }
|
catch { return Ok(Array.Empty<object>()); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ TABLES CRUD (fnb_engine) ═══
|
||||||
|
|
||||||
|
[HttpPost("tables")]
|
||||||
|
public async Task<IActionResult> CreateTable([FromBody] CreateTableRequest req)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Forbid();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
if (!myShopIds.Contains(req.ShopId)) return Forbid();
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("fnb_engine"));
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"INSERT INTO tables (id, shop_id, table_number, capacity, zone, status_id, created_at, updated_at)
|
||||||
|
VALUES (@Id, @ShopId, @TableNumber, @Capacity, @Zone, 1, NOW(), NOW())",
|
||||||
|
new { Id = id, req.ShopId, req.TableNumber, req.Capacity, Zone = req.Zone ?? "" });
|
||||||
|
return StatusCode(201, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("tables/{tableId:guid}")]
|
||||||
|
public async Task<IActionResult> UpdateTable(Guid tableId, [FromBody] CreateTableRequest req)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Unauthorized();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("fnb_engine"));
|
||||||
|
var rows = await conn.ExecuteAsync(
|
||||||
|
@"UPDATE tables SET table_number=@TableNumber, capacity=@Capacity, zone=@Zone, updated_at=NOW()
|
||||||
|
WHERE id=@Id AND shop_id=ANY(@ShopIds)",
|
||||||
|
new { Id = tableId, req.TableNumber, req.Capacity, Zone = req.Zone ?? "", ShopIds = myShopIds.ToArray() });
|
||||||
|
return rows > 0 ? Ok(new { id = tableId }) : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("tables/{tableId:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteTable(Guid tableId)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Forbid();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("fnb_engine"));
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM tables WHERE id=@Id AND shop_id=ANY(@ShopIds)",
|
||||||
|
new { Id = tableId, ShopIds = myShopIds.ToArray() });
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ APPOINTMENTS CRUD (booking_service) ═══
|
||||||
|
|
||||||
|
[HttpPost("appointments")]
|
||||||
|
public async Task<IActionResult> CreateAppointment([FromBody] CreateAppointmentRequest req)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Forbid();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
if (!myShopIds.Contains(req.ShopId)) return Forbid();
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"INSERT INTO appointments (id, shop_id, customer_id, staff_id, resource_id, service_id, start_time, end_time, status, created_at)
|
||||||
|
VALUES (@Id, @ShopId, @CustomerId, @StaffId, @ResourceId, @ServiceId, @StartTime, @EndTime, 'Scheduled', NOW())",
|
||||||
|
new { Id = id, req.ShopId, req.CustomerId, req.StaffId, req.ResourceId, req.ServiceId, req.StartTime, req.EndTime });
|
||||||
|
return StatusCode(201, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("appointments/{apptId:guid}")]
|
||||||
|
public async Task<IActionResult> UpdateAppointment(Guid apptId, [FromBody] CreateAppointmentRequest req)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Unauthorized();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
|
||||||
|
var rows = await conn.ExecuteAsync(
|
||||||
|
@"UPDATE appointments SET start_time=@StartTime, end_time=@EndTime, staff_id=@StaffId,
|
||||||
|
resource_id=@ResourceId, status=@Status
|
||||||
|
WHERE id=@Id AND shop_id=ANY(@ShopIds)",
|
||||||
|
new { Id = apptId, req.StartTime, req.EndTime, req.StaffId, req.ResourceId,
|
||||||
|
Status = req.Status ?? "Scheduled", ShopIds = myShopIds.ToArray() });
|
||||||
|
return rows > 0 ? Ok(new { id = apptId }) : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("appointments/{apptId:guid}/cancel")]
|
||||||
|
public async Task<IActionResult> CancelAppointment(Guid apptId)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Forbid();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE appointments SET status='Cancelled' WHERE id=@Id AND shop_id=ANY(@ShopIds)",
|
||||||
|
new { Id = apptId, ShopIds = myShopIds.ToArray() });
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ RESOURCES CRUD (booking_service) ═══
|
||||||
|
|
||||||
|
[HttpPost("resources")]
|
||||||
|
public async Task<IActionResult> CreateResource([FromBody] CreateResourceRequest req)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Forbid();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
if (!myShopIds.Contains(req.ShopId)) return Forbid();
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"INSERT INTO resources (id, shop_id, name, resource_type, capacity, is_active, created_at)
|
||||||
|
VALUES (@Id, @ShopId, @Name, @ResourceType, @Capacity, true, NOW())",
|
||||||
|
new { Id = id, req.ShopId, req.Name, req.ResourceType, req.Capacity });
|
||||||
|
return StatusCode(201, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("resources/{resourceId:guid}")]
|
||||||
|
public async Task<IActionResult> UpdateResource(Guid resourceId, [FromBody] CreateResourceRequest req)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Unauthorized();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
|
||||||
|
var rows = await conn.ExecuteAsync(
|
||||||
|
"UPDATE resources SET name=@Name, resource_type=@ResourceType, capacity=@Capacity WHERE id=@Id AND shop_id=ANY(@ShopIds)",
|
||||||
|
new { Id = resourceId, req.Name, req.ResourceType, req.Capacity, ShopIds = myShopIds.ToArray() });
|
||||||
|
return rows > 0 ? Ok(new { id = resourceId }) : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("resources/{resourceId:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteResource(Guid resourceId)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Forbid();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE resources SET is_active=false WHERE id=@Id AND shop_id=ANY(@ShopIds)",
|
||||||
|
new { Id = resourceId, ShopIds = myShopIds.ToArray() });
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ STAFF SCHEDULES CRUD (booking_service) ═══
|
||||||
|
|
||||||
|
[HttpPost("schedules")]
|
||||||
|
public async Task<IActionResult> CreateSchedule([FromBody] CreateScheduleRequest req)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Forbid();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
if (!myShopIds.Contains(req.ShopId)) return Forbid();
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"INSERT INTO staff_schedules (id, shop_id, staff_id, day_of_week, start_time, end_time)
|
||||||
|
VALUES (@Id, @ShopId, @StaffId, @DayOfWeek, @StartTime::time, @EndTime::time)",
|
||||||
|
new { Id = id, req.ShopId, req.StaffId, req.DayOfWeek, req.StartTime, req.EndTime });
|
||||||
|
return StatusCode(201, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("schedules/{scheduleId:guid}")]
|
||||||
|
public async Task<IActionResult> UpdateSchedule(Guid scheduleId, [FromBody] CreateScheduleRequest req)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Unauthorized();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
|
||||||
|
var rows = await conn.ExecuteAsync(
|
||||||
|
"UPDATE staff_schedules SET day_of_week=@DayOfWeek, start_time=@StartTime::time, end_time=@EndTime::time WHERE id=@Id AND shop_id=ANY(@ShopIds)",
|
||||||
|
new { Id = scheduleId, req.DayOfWeek, req.StartTime, req.EndTime, ShopIds = myShopIds.ToArray() });
|
||||||
|
return rows > 0 ? Ok(new { id = scheduleId }) : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("schedules/{scheduleId:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteSchedule(Guid scheduleId)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Forbid();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM staff_schedules WHERE id=@Id AND shop_id=ANY(@ShopIds)",
|
||||||
|
new { Id = scheduleId, ShopIds = myShopIds.ToArray() });
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ KITCHEN TICKETS (fnb_engine) ═══
|
||||||
|
|
||||||
|
[HttpGet("kitchen/tickets")]
|
||||||
|
public async Task<IActionResult> GetKitchenTickets([FromQuery] Guid? shopId = null, [FromQuery] string status = "pending")
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Ok(Array.Empty<object>());
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
if (!myShopIds.Any()) return Ok(Array.Empty<object>());
|
||||||
|
if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) return Ok(Array.Empty<object>());
|
||||||
|
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("fnb_engine"));
|
||||||
|
var whereStatus = status == "all" ? "" : "AND kt.status=@Status";
|
||||||
|
var tickets = await conn.QueryAsync<dynamic>(
|
||||||
|
$@"SELECT kt.* FROM kitchen_tickets kt
|
||||||
|
JOIN sessions s ON kt.session_id = s.id
|
||||||
|
WHERE s.shop_id = ANY(@ShopIds) {whereStatus}
|
||||||
|
ORDER BY kt.priority DESC, kt.created_at",
|
||||||
|
new { ShopIds = targetShopIds.ToArray(), Status = status });
|
||||||
|
return Ok(tickets);
|
||||||
|
}
|
||||||
|
catch { return Ok(Array.Empty<object>()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("kitchen/tickets/{ticketId:guid}/status")]
|
||||||
|
public async Task<IActionResult> UpdateTicketStatus(Guid ticketId, [FromBody] UpdateTicketStatusRequest req)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Unauthorized();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("fnb_engine"));
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"UPDATE kitchen_tickets SET status=@Status,
|
||||||
|
completed_at=CASE WHEN @Status='completed' THEN NOW() ELSE NULL END
|
||||||
|
WHERE id=@Id",
|
||||||
|
new { Id = ticketId, req.Status });
|
||||||
|
return Ok(new { id = ticketId });
|
||||||
|
}
|
||||||
|
catch (Exception ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ RECIPES CRUD (catalog_service) ═══
|
||||||
|
|
||||||
|
[HttpGet("recipes")]
|
||||||
|
public async Task<IActionResult> GetRecipes([FromQuery] Guid? shopId = null)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Ok(Array.Empty<object>());
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
if (!myShopIds.Any()) return Ok(Array.Empty<object>());
|
||||||
|
if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) return Ok(Array.Empty<object>());
|
||||||
|
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
|
||||||
|
var recipes = await conn.QueryAsync<dynamic>(
|
||||||
|
@"SELECT r.*, (SELECT json_agg(row_to_json(ri)) FROM recipe_ingredients ri WHERE ri.recipe_id = r.id) as ingredients
|
||||||
|
FROM recipes r WHERE r.shop_id = ANY(@ShopIds) AND r.is_active = true
|
||||||
|
ORDER BY r.name",
|
||||||
|
new { ShopIds = targetShopIds.ToArray() });
|
||||||
|
return Ok(recipes);
|
||||||
|
}
|
||||||
|
catch { return Ok(Array.Empty<object>()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("recipes")]
|
||||||
|
public async Task<IActionResult> CreateRecipe([FromBody] CreateRecipeRequest req)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Forbid();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
if (!myShopIds.Contains(req.ShopId)) return Forbid();
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using var tx = await conn.BeginTransactionAsync();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"INSERT INTO recipes (id, product_id, shop_id, name, instructions, prep_time_minutes, is_active, created_at, updated_at)
|
||||||
|
VALUES (@Id, @ProductId, @ShopId, @Name, @Instructions, @PrepTimeMinutes, true, NOW(), NOW())",
|
||||||
|
new { Id = id, req.ProductId, req.ShopId, req.Name, req.Instructions, req.PrepTimeMinutes }, tx);
|
||||||
|
foreach (var ing in req.Ingredients ?? new())
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"INSERT INTO recipe_ingredients (id, recipe_id, ingredient_name, quantity, unit, cost_per_unit, created_at)
|
||||||
|
VALUES (@Id, @RecipeId, @IngredientName, @Quantity, @Unit, @CostPerUnit, NOW())",
|
||||||
|
new { Id = Guid.NewGuid(), RecipeId = id, ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit }, tx);
|
||||||
|
await tx.CommitAsync();
|
||||||
|
return StatusCode(201, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("recipes/{recipeId:guid}")]
|
||||||
|
public async Task<IActionResult> UpdateRecipe(Guid recipeId, [FromBody] CreateRecipeRequest req)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Unauthorized();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using var tx = await conn.BeginTransactionAsync();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE recipes SET name=@Name, instructions=@Instructions, prep_time_minutes=@PrepTimeMinutes, updated_at=NOW() WHERE id=@Id AND shop_id=ANY(@ShopIds)",
|
||||||
|
new { Id = recipeId, req.Name, req.Instructions, req.PrepTimeMinutes, ShopIds = myShopIds.ToArray() }, tx);
|
||||||
|
await conn.ExecuteAsync("DELETE FROM recipe_ingredients WHERE recipe_id=@Id", new { Id = recipeId }, tx);
|
||||||
|
foreach (var ing in req.Ingredients ?? new())
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
@"INSERT INTO recipe_ingredients (id, recipe_id, ingredient_name, quantity, unit, cost_per_unit, created_at)
|
||||||
|
VALUES (@Id, @RecipeId, @IngredientName, @Quantity, @Unit, @CostPerUnit, NOW())",
|
||||||
|
new { Id = Guid.NewGuid(), RecipeId = recipeId, ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit }, tx);
|
||||||
|
await tx.CommitAsync();
|
||||||
|
return Ok(new { id = recipeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("recipes/{recipeId:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteRecipe(Guid recipeId)
|
||||||
|
{
|
||||||
|
var merchantId = await GetCurrentMerchantIdAsync();
|
||||||
|
if (merchantId == null) return Forbid();
|
||||||
|
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
|
||||||
|
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE recipes SET is_active=false, updated_at=NOW() WHERE id=@Id AND shop_id=ANY(@ShopIds)",
|
||||||
|
new { Id = recipeId, ShopIds = myShopIds.ToArray() });
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
// EN: Request DTOs / VI: DTO yêu cầu
|
// EN: Request DTOs / VI: DTO yêu cầu
|
||||||
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price, string? Type, string? Sku, string? ImageUrl);
|
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price, string? Type, string? Sku, string? ImageUrl);
|
||||||
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);
|
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);
|
||||||
@@ -1513,4 +1820,11 @@ public class BffDataController : ControllerBase
|
|||||||
public record UpdateMemberRequest(string? Gender, string? Preferences);
|
public record UpdateMemberRequest(string? Gender, string? Preferences);
|
||||||
public record UpdateShopSettingsRequest(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays);
|
public record UpdateShopSettingsRequest(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays);
|
||||||
public record TopProductItem(string ProductName, long TotalSold, decimal TotalRevenue);
|
public record TopProductItem(string ProductName, long TotalSold, decimal TotalRevenue);
|
||||||
|
public record CreateTableRequest(Guid ShopId, string TableNumber, int Capacity, string? Zone);
|
||||||
|
public record CreateAppointmentRequest(Guid ShopId, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid? ServiceId, DateTime StartTime, DateTime EndTime, string? Status = null);
|
||||||
|
public record CreateResourceRequest(Guid ShopId, string Name, string ResourceType, int Capacity);
|
||||||
|
public record CreateScheduleRequest(Guid ShopId, Guid StaffId, int DayOfWeek, string StartTime, string EndTime);
|
||||||
|
public record UpdateTicketStatusRequest(string Status);
|
||||||
|
public record CreateRecipeRequest(Guid ShopId, Guid ProductId, string Name, string? Instructions, int PrepTimeMinutes, List<RecipeIngredientRequest>? Ingredients);
|
||||||
|
public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user