@@ -778,8 +803,30 @@
}
break;
- // ═══ APPOINTMENTS (Spa / Thẩm mỹ) — Calendar View ═══
+ // ═══ APPOINTMENTS (Spa / Thẩm mỹ) — Calendar View + CRUD ═══
case "appointments":
+
+ { _showApptForm = !_showApptForm; _apptFormMessage = null; _newApptStart = DateTime.Today.AddHours(9); _newApptEnd = DateTime.Today.AddHours(10); }'>
+ Thêm lịch hẹn
+
+
+ @if (_showApptForm)
+ {
+
+
+
+
+
+ Lưu
+ _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;"> Hủy
+
+ @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")
+ {
+
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
+ }
}
@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") })
{
- { _kdsStation = st.Item1; StateHasChanged(); }'
- 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);")">
+ LoadKitchenTickets(st.Item1)'
+ 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
}
- 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")
+ {
+ 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
+ }
}
- }
-
-
-
-
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.Count(r => r.IsActive) Hoạt động
+
+
{ _editingResourceId = null; _newResourceName = ""; _newResourceType = "Room"; _newResourceCapacity = 1; _resourceFormMessage = null; _showResourceForm = !_showResourceForm; }'>
+ Thêm tài nguyên
+
+
+ @if (_showResourceForm)
+ {
+
+
+
+
+
Tên *
+
Loại
+
+ Phòng
+ Giường
+ Ghế
+ Thiết bị
+
+
+
Sức chứa
+
+
+ @(_editingResourceId.HasValue ? "Cập nhật" : "Lưu")
+ _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;"> Hủy
+
+ @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.Count Tổ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
-
-
-
+
Tên
Loại
Sức chứa
Trạng thái
+
@foreach (var r in _resources)
{
@@ -1062,6 +1141,12 @@
@(r.ResourceType ?? "—")
@r.Capacity
@(r.IsActive ? "Active" : "Inactive")
+
+
+ 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;">
+ 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;">
+
+
}
@@ -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.Count Ca làm việc
+
+
{ _showScheduleForm = !_showScheduleForm; _newSchedStaffId = Guid.Empty; _newSchedDay = 1; _newSchedStart = "08:00"; _newSchedEnd = "17:00"; _schedFormMessage = null; }'>
+ Thêm ca
+
+
+ @if (_showScheduleForm)
+ {
+
+
+
+
+
+ Lưu
+ _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;"> Hủy
+
+ @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.Count Ca làm việc
-
@@ -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
+ 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;">
}
@@ -1191,6 +1302,75 @@
}
break;
+ // ═══ RECIPES (Cafe/Restaurant — Công thức & Nguyên liệu) ═══
+ case "recipes":
+
+
@_recipes.Count công thức
+ { _showRecipeForm = !_showRecipeForm; _editingRecipeId = null; _newRecipeName = ""; _newRecipeInstructions = ""; _newRecipePrepTime = 5; _recipeIngredients = new(); _recipeFormMessage = null; }'>
+ Thêm công thức
+
+
+ @if (_showRecipeForm)
+ {
+
+
+
+
+
Tên công thức *
+
Thời gian chuẩn bị (phút)
+
Hướng dẫn
+
+
+
Nguyên liệu _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
+ @foreach (var (ing, idx) in _recipeIngredients.Select((x,i) => (x,i)))
+ {
+
+
+
+
+
+ _recipeIngredients.RemoveAt(idx)' style="padding:6px;border-radius:6px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;cursor:pointer;">✕
+
+ }
+
+
+ Lưu
+ _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;"> Hủy
+
+ @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;
+
{ _expandedRecipeId = isExpanded ? null : recipe.Id; StateHasChanged(); }'>
+
+
+
@recipe.Name
+
+ 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;">
+
+
+
@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);
}