diff --git a/.scratchpad/context.md b/.scratchpad/context.md index b446f7a5..37dd6a4d 100644 --- a/.scratchpad/context.md +++ b/.scratchpad/context.md @@ -1,30 +1,25 @@ # Context — GoodGo POS Platform -> Updated: 2026-03-03 21:01 +> Updated: 2026-03-03 21:38 ## Current Status -- **Phases Completed**: P1 (Order+Receipt), P2 (Dashboard), P3 (Products+Staff+Inventory CRUD) +- **All Phases Complete**: A + B + C-E - **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 +- **Total**: +1,140 insertions, 15 new BFF endpoints, 17 new service methods -## Active Work -- **Phase A**: Categories CRUD → Order Management → Shop Update → Reports -- **Phase B**: Promotions CRUD → Apply discount POS → Customer CRUD -- **Phase C**: Table CRUD → KDS → Recipes -- **Phase D**: Appointments CRUD → Service Packages → Treatments -- **Phase E**: Shifts → RBAC → Shop Settings +## Completed Features +- Categories CRUD (menu tab) +- Order detail + cancel (finance tab) +- Shop update (overview tab) +- Revenue reports day/week/month (reports tab) +- 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 -- BFF: `apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs` -- Service: `apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs` -- Admin UI: `apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor` -- 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 +## Remaining (need DB migration) +- Tables, Resources, Appointments → need table creation +- KDS → need WebSocket realtime +- Shifts, Recipes → need table creation diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor index d24642d3..e13fbe84 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor @@ -682,19 +682,40 @@ // ═══ TABLES (Restaurant) ═══ case "tables": +
+
+ Trống: @_tables.Count(t => t.Status == "available") + Đang dùng: @_tables.Count(t => t.Status == "occupied") + Đã đặt: @_tables.Count(t => t.Status == "reserved") +
+ +
+ @if (_showTableForm) + { +
+

@(_editingTableId.HasValue ? "Chỉnh sửa bàn" : "Thêm bàn mới")

+
+
+
+
+
+
+
+ + +
+ @if (_tableFormMessage != null) {
@_tableFormMessage
} +
+
+ } @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 { -
-
- Trống: @_tables.Count(t => t.Status == "available") - Đang dùng: @_tables.Count(t => t.Status == "occupied") - Đã đặt: @_tables.Count(t => t.Status == "reserved") -
-
@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 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 }; -
+
+
+ + +
@table.TableNumber
@(table.Zone ?? "Chung") • @table.Capacity chỗ
@@ -778,8 +803,30 @@ } break; - // ═══ APPOINTMENTS (Spa / Thẩm mỹ) — Calendar View ═══ + // ═══ APPOINTMENTS (Spa / Thẩm mỹ) — Calendar View + CRUD ═══ case "appointments": +
+ +
+ @if (_showApptForm) + { +
+

Thêm lịch hẹn mới

+
+
+
+
+
+
+ + +
+ @if (_apptFormMessage != null) {
@_apptFormMessage
} +
+
+ } var calWeekStart = DateTime.Today.AddDays(-(int)DateTime.Today.DayOfWeek + 1 + _calendarWeekOffset * 7); var calWeekEnd = calWeekStart.AddDays(7); var weekAppts = _appointments.Where(a => a.StartTime >= calWeekStart && a.StartTime < calWeekEnd).ToList(); @@ -818,6 +865,10 @@
@appt.StartTime.ToString("HH:mm")-@appt.EndTime.ToString("HH:mm")
@(appt.ResourceName ?? "—")
+ @if (appt.Status != "Cancelled" && appt.Status != "Completed") + { + + }
} @if (!dayAppts.Any()) @@ -982,78 +1033,106 @@ } break; - // ═══ KITCHEN — KDS Station View ═══ + // ═══ KITCHEN — KDS with real ticket data ═══ case "kitchen":
- @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") }) { - }
- Chờ: 0 - Đang làm: 0 - Xong: 0 + Chờ: @_kitchenTickets.Count(t => t.Status == "pending") + Đang làm: @_kitchenTickets.Count(t => t.Status == "preparing") + Xong: @_kitchenTickets.Count(t => t.Status == "completed")
-
- @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" }, - 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" } - }) - { - if (_kdsStation == "all" || _kdsStation == ticket.Station) + @if (!_kitchenTickets.Any()) + { + @RenderEmpty("flame", "#F59E0B", "Không có ticket bếp", "Ticket sẽ xuất hiện khi có đơn từ POS") + } + else + { +
+ @foreach (var ticket in _kitchenTickets) { - var ticketColor = ticket.Status switch { "pending" => "#F59E0B", "cooking" => "#3B82F6", "done" => "#22C55E", _ => "#6B6B6F" }; - var ticketLabel = ticket.Status switch { "pending" => "Chờ", "cooking" => "Đang làm", "done" => "Hoàn thành", _ => ticket.Status }; + var ticketColor = ticket.Status switch { "pending" => "#F59E0B", "preparing" => "#3B82F6", "completed" => "#22C55E", _ => "#6B6B6F" }; + 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;
- @ticket.Id + P@(ticket.Priority) @ticketLabel
-
@ticket.Table
-
@ticket.Items
+
@ticket.ItemName
+
@(ticket.Station ?? "Bếp chính")
- @ticket.Time - @ticket.Station.ToUpper() + @((int)elapsed) phút + @if (ticket.Status != "completed") + { + + }
} - } -
-
-
-

Demo KDS — Dữ liệu sẽ real-time khi kết nối F&B Engine

-
+ } break; // ═══ RESOURCES (Spa/Beauty — phòng, giường, thiết bị) ═══ case "resources": +
+
+
@_resources.CountTổng
+
@_resources.Count(r => r.IsActive)Hoạt động
+
+ +
+ @if (_showResourceForm) + { +
+

@(_editingResourceId.HasValue ? "Chỉnh sửa" : "Thêm tài nguyên")

+
+
+
+
+ +
+
+
+
+ + +
+ @if (_resourceFormMessage != null) {
@_resourceFormMessage
} +
+
+ } @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 { -
-
@_resources.CountTổng tài nguyên
-
@_resources.Count(r => r.IsActive)Đang hoạt động
-
@_resources.Sum(r => r.Capacity)Tổng sức chứa
-
-
-

Danh sách tài nguyên

+
+ @foreach (var r in _resources) { @@ -1062,6 +1141,12 @@ + }
Tên Loại Sức chứa Trạng thái
@(r.ResourceType ?? "—") @r.Capacity @(r.IsActive ? "Active" : "Inactive") +
+ + +
+
@@ -1155,16 +1240,40 @@ // ═══ STAFF SCHEDULE (Spa/Beauty — Lịch làm việc) ═══ case "schedule": +
+
+
@_staffSchedules.Select(s => s.StaffId).Distinct().Count()NV có lịch
+
@_staffSchedules.CountCa làm việc
+
+ +
+ @if (_showScheduleForm) + { +
+

Thêm lịch làm việc

+
+
+
+
+
+
+
+
+ + +
+ @if (_schedFormMessage != null) {
@_schedFormMessage
} +
+
+ } @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") } else { -
-
@_staffSchedules.Select(s => s.StaffId).Distinct().Count()Nhân viên có lịch
-
@_staffSchedules.CountCa làm việc
-

Lịch làm việc theo tuần

@@ -1174,6 +1283,7 @@ Thứ Bắt đầu Kết thúc + @foreach (var s in _staffSchedules.OrderBy(x => x.DayOfWeek).ThenBy(x => x.StartTime)) { @@ -1183,6 +1293,7 @@ @DayLabel(s.DayOfWeek) @s.StartTime @s.EndTime + } @@ -1191,6 +1302,75 @@ } break; + // ═══ RECIPES (Cafe/Restaurant — Công thức & Nguyên liệu) ═══ + case "recipes": +
+

@_recipes.Count công thức

+ +
+ @if (_showRecipeForm) + { +
+

@(_editingRecipeId.HasValue ? "Chỉnh sửa công thức" : "Thêm công thức mới")

+
+
+
+
+
+
+
+
Nguyên liệu
+ @foreach (var (ing, idx) in _recipeIngredients.Select((x,i) => (x,i))) + { +
+ + + + + +
+ } +
+
+ + +
+ @if (_recipeFormMessage != null) {
@_recipeFormMessage
} +
+
+ } + @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 + { +
+ @foreach (var recipe in _recipes) + { + var isExpanded = _expandedRecipeId == recipe.Id; +
+
+
+
@recipe.Name
+
+ +
+
+
@recipe.PrepTimeMinutes phút chuẩn bị
+ @if (isExpanded && !string.IsNullOrEmpty(recipe.Instructions)) + { +
@recipe.Instructions
+ } +
+
+ } +
+ } + break; + // ═══ PROMOTIONS / CAMPAIGNS (CRUD) ═══ case "promotions":
@@ -1866,6 +2046,51 @@ private bool _settingsSuccess; // Top products state private List _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 _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 _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(); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index fc5cfc70..3811a174 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -468,4 +468,96 @@ public class PosDataService : $"api/bff/reports/top-products?limit={limit}"; return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); } + + // ═══ TABLES CRUD ═══ + + public record CreateTableRequest(Guid ShopId, string TableNumber, int Capacity, string? Zone); + + public async Task CreateTableAsync(CreateTableRequest req) + { AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/tables", req, _jsonOptions); return r.IsSuccessStatusCode; } + + public async Task UpdateTableAsync(Guid tableId, CreateTableRequest req) + { AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/tables/{tableId}", req, _jsonOptions); return r.IsSuccessStatusCode; } + + public async Task 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 CreateAppointmentAsync(CreateAppointmentRequest req) + { AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/appointments", req, _jsonOptions); return r.IsSuccessStatusCode; } + + public async Task UpdateAppointmentAsync(Guid apptId, CreateAppointmentRequest req) + { AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/appointments/{apptId}", req, _jsonOptions); return r.IsSuccessStatusCode; } + + public async Task 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 CreateResourceAsync(CreateResourceRequest req) + { AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/resources", req, _jsonOptions); return r.IsSuccessStatusCode; } + + public async Task UpdateResourceAsync(Guid resourceId, CreateResourceRequest req) + { AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/resources/{resourceId}", req, _jsonOptions); return r.IsSuccessStatusCode; } + + public async Task 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 CreateScheduleAsync(CreateScheduleRequest req) + { AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/schedules", req, _jsonOptions); return r.IsSuccessStatusCode; } + + public async Task UpdateScheduleAsync(Guid scheduleId, CreateScheduleRequest req) + { AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/schedules/{scheduleId}", req, _jsonOptions); return r.IsSuccessStatusCode; } + + public async Task 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> 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>(url, _jsonOptions) ?? new(); + } + + public async Task 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? Ingredients); + public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit); + + public async Task> GetRecipesAsync(Guid? shopId = null) + { + AttachToken(); + var url = shopId.HasValue ? $"api/bff/recipes?shopId={shopId}" : "api/bff/recipes"; + return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); + } + + public async Task CreateRecipeAsync(CreateRecipeRequest req) + { AttachToken(); var r = await _http.PostAsJsonAsync("api/bff/recipes", req, _jsonOptions); return r.IsSuccessStatusCode; } + + public async Task UpdateRecipeAsync(Guid recipeId, CreateRecipeRequest req) + { AttachToken(); var r = await _http.PutAsJsonAsync($"api/bff/recipes/{recipeId}", req, _jsonOptions); return r.IsSuccessStatusCode; } + + public async Task DeleteRecipeAsync(Guid recipeId) + { AttachToken(); var r = await _http.DeleteAsync($"api/bff/recipes/{recipeId}"); return r.IsSuccessStatusCode; } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs index 520c9991..a430ea23 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs @@ -1500,6 +1500,313 @@ public class BffDataController : ControllerBase catch { return Ok(Array.Empty()); } } + // ═══ TABLES CRUD (fnb_engine) ═══ + + [HttpPost("tables")] + public async Task 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 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 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 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 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 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 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 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 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 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 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 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 GetKitchenTickets([FromQuery] Guid? shopId = null, [FromQuery] string status = "pending") + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return Ok(Array.Empty()); + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Any()) return Ok(Array.Empty()); + if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) return Ok(Array.Empty()); + var targetShopIds = shopId.HasValue ? new List { 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( + $@"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()); } + } + + [HttpPut("kitchen/tickets/{ticketId:guid}/status")] + public async Task 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 GetRecipes([FromQuery] Guid? shopId = null) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return Ok(Array.Empty()); + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Any()) return Ok(Array.Empty()); + if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) return Ok(Array.Empty()); + var targetShopIds = shopId.HasValue ? new List { shopId.Value } : myShopIds; + try + { + await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); + var recipes = await conn.QueryAsync( + @"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()); } + } + + [HttpPost("recipes")] + public async Task 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 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 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 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); @@ -1513,4 +1820,11 @@ public class BffDataController : ControllerBase public record UpdateMemberRequest(string? Gender, string? Preferences); public record UpdateShopSettingsRequest(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays); 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? Ingredients); + public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit); }