diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/AdminLayout.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/AdminLayout.razor index 165c45a4..8bef10ea 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/AdminLayout.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/AdminLayout.razor @@ -191,12 +191,38 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { - // EN: Session restore is handled by AdminBase.OnInitializedAsync() — no need to duplicate here. - // VI: Khôi phục session được xử lý bởi AdminBase.OnInitializedAsync() — không cần gọi lại ở đây. - // EN: Re-init Lucide icons after every render (Blazor navigation replaces DOM) // VI: Khởi tạo lại Lucide icons sau mỗi lần render (Blazor navigation thay đổi DOM) try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { } + + if (firstRender) + { + // EN: Restore session from localStorage if AuthState lost after forceLoad navigation. + // JS interop is only available in OnAfterRenderAsync, not OnInitializedAsync. + // VI: Khôi phục session từ localStorage nếu AuthState mất sau forceLoad navigation. + // JS interop chỉ khả dụng trong OnAfterRenderAsync, không phải OnInitializedAsync. + if (!AuthState.IsAuthenticated) + { + try { await AuthSvc.TryRestoreSessionAsync(); } catch { } + } + + // EN: Resolve user display name from localStorage fallback + // VI: Lấy tên hiển thị từ localStorage nếu cần + if (_resolvedUserName == null) + { + try + { + var email = await JS.InvokeAsync("localStorage.getItem", "aPOS_email"); + if (!string.IsNullOrEmpty(email)) + _resolvedUserName = email.Split('@').FirstOrDefault() ?? "Admin"; + else if (AuthState.IsAuthenticated && !string.IsNullOrEmpty(AuthState.UserEmail)) + _resolvedUserName = AuthState.UserEmail.Split('@').FirstOrDefault() ?? "Admin"; + } + catch { /* JS interop not ready */ } + } + + StateHasChanged(); + } } private void OnAuthStateChanged() @@ -274,15 +300,16 @@ private void ToggleSidebar() => _sidebarOpen = !_sidebarOpen; private void CloseSidebar() => _sidebarOpen = false; - // EN: Compute user display info from AuthState - // VI: Tính toán thông tin hiển thị user từ AuthState - private string _userName => AuthState.IsAuthenticated - ? (AuthState.UserEmail?.Split('@').FirstOrDefault() ?? "User") - : "Guest"; - private string _userInitials => _userName.Length >= 2 - ? _userName[..2].ToUpper() + // EN: Compute user display info from AuthState with localStorage fallback + // VI: Tính toán thông tin hiển thị user từ AuthState với fallback localStorage + private string? _resolvedUserName; + private string _userName => _resolvedUserName + ?? (AuthState.IsAuthenticated ? (AuthState.UserEmail?.Split('@').FirstOrDefault() ?? "Admin") : null) + ?? "Admin"; + private string _userInitials => _userName.Length >= 2 + ? _userName[..2].ToUpper() : _userName.ToUpper(); - private string _userRole => AuthState.IsAuthenticated ? "Admin" : "Guest"; + private string _userRole => "Admin"; private async Task Logout() { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/StaffLayout.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/StaffLayout.razor index 236cbfcb..adc1e0fd 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/StaffLayout.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/StaffLayout.razor @@ -165,6 +165,7 @@ private string _shopName = "Cửa hàng"; private Guid? _shopId; private int _unreadNotifications = 0; + private string? _staffDisplayName; /// /// EN: Current staff role — accessible from child pages. @@ -173,17 +174,48 @@ public string StaffRole => _staffRole; public Guid? ShopId => _shopId; - protected override async Task OnInitializedAsync() + protected override void OnInitialized() { NavigationManager.LocationChanged += OnLocationChanged; AuthState.OnChange += OnAuthStateChanged; - - await LoadStaffProfile(); } protected override async Task OnAfterRenderAsync(bool firstRender) { try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { } + + if (firstRender) + { + // EN: Restore session from localStorage if AuthState lost after forceLoad navigation. + // JS interop is only available in OnAfterRenderAsync, not OnInitializedAsync. + // VI: Khôi phục session từ localStorage nếu AuthState mất sau forceLoad navigation. + // JS interop chỉ khả dụng trong OnAfterRenderAsync, không phải OnInitializedAsync. + if (!AuthState.IsAuthenticated) + { + try { await AuthSvc.TryRestoreSessionAsync(); } catch { } + } + + // EN: Load staff profile (role, shop) after session is restored + // VI: Load profile nhân viên (vai trò, shop) sau khi session đã được khôi phục + await LoadStaffProfile(); + + // EN: Resolve user display name from localStorage fallback + // VI: Lấy tên hiển thị từ localStorage nếu cần + if (_resolvedUserName == null) + { + try + { + var email = await JS.InvokeAsync("localStorage.getItem", "aPOS_email"); + if (!string.IsNullOrEmpty(email)) + _resolvedUserName = email.Split('@').FirstOrDefault() ?? "Staff"; + else if (AuthState.IsAuthenticated && !string.IsNullOrEmpty(AuthState.UserEmail)) + _resolvedUserName = AuthState.UserEmail.Split('@').FirstOrDefault() ?? "Staff"; + } + catch { /* JS interop not ready */ } + } + + StateHasChanged(); + } } private async Task LoadStaffProfile() @@ -196,6 +228,10 @@ _staffRole = profile.Role ?? "Staff"; _shopName = profile.ShopName ?? "Cửa hàng"; _shopId = profile.ShopId; + var fn = profile.FirstName?.Trim(); + var ln = profile.LastName?.Trim(); + if (!string.IsNullOrEmpty(fn) || !string.IsNullOrEmpty(ln)) + _staffDisplayName = $"{fn} {ln}".Trim(); } } catch @@ -219,9 +255,8 @@ private void ToggleSidebar() => _sidebarOpen = !_sidebarOpen; private void CloseSidebar() => _sidebarOpen = false; - private string _userName => AuthState.IsAuthenticated - ? (AuthState.UserEmail?.Split('@').FirstOrDefault() ?? "Staff") - : "Guest"; + private string? _resolvedUserName; + private string _userName => _staffDisplayName ?? _resolvedUserName ?? "Staff"; private string _userInitials => _userName.Length >= 2 ? _userName[..2].ToUpper() : _userName.ToUpper(); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/AuthBase.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/AuthBase.cs index 88d1327f..75d840ad 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/AuthBase.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/AuthBase.cs @@ -24,10 +24,10 @@ public abstract class AuthBase : ComponentBase { await JS.InvokeVoidAsync("lucide.createIcons"); } - catch (JSException) + catch { - // EN: Lucide script may not be loaded yet during prerender. - // VI: Script Lucide có thể chưa load xong khi prerender. + // EN: Lucide script may not be loaded yet during prerender or JS interop unavailable. + // VI: Script Lucide có thể chưa load xong khi prerender hoặc JS interop chưa sẵn sàng. } } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginStaff.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginStaff.razor index c3a42882..53736168 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginStaff.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginStaff.razor @@ -114,20 +114,28 @@ _isLoading = true; StateHasChanged(); - var (ok, error) = await AuthSvc.LoginAsync(_email, _password); + try + { + var (ok, error) = await AuthSvc.LoginAsync(_email, _password); - if (ok) - { - // EN: Route to staff dashboard — StaffLayout will detect role - // VI: Dieu huong den staff dashboard — StaffLayout se phat hien vai tro - _isLoading = false; - StateHasChanged(); - await Task.Delay(500); - Nav.NavigateTo("/staff/dashboard", forceLoad: true); + if (ok) + { + // EN: Route to staff dashboard — StaffLayout will detect role + // VI: Điều hướng đến staff dashboard — StaffLayout sẽ phát hiện vai trò + _isLoading = false; + StateHasChanged(); + await Task.Delay(500); + Nav.NavigateTo("/staff/dashboard", forceLoad: true); + } + else + { + _errorMessage = error ?? "Đăng nhập thất bại"; + _isLoading = false; + } } - else + catch (Exception) { - _errorMessage = error ?? "Đăng nhập thất bại"; + _errorMessage = "Lỗi kết nối. Vui lòng thử lại."; _isLoading = false; } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffKitchen.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffKitchen.razor index 28316af5..f860e86e 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffKitchen.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffKitchen.razor @@ -1,5 +1,6 @@ @page "/staff/kitchen" @layout StaffLayout +@implements IDisposable @using WebClientTpos.Client.Services @inject PosDataService DataService @inject NavigationManager Nav @@ -119,45 +120,71 @@ private int _pendingCount = 0; private int _inProgressCount = 0; private int _completedCount = 0; + private System.Threading.Timer? _autoRefreshTimer; - protected override async Task OnInitializedAsync() => await LoadTickets(); + protected override void OnInitialized() + { + // EN: Auto-refresh every 10 seconds for real-time kitchen updates + // VI: Tự động làm mới mỗi 10 giây cho cập nhật bếp thời gian thực + _autoRefreshTimer = new System.Threading.Timer(async _ => + { + await InvokeAsync(async () => { await LoadTickets(); StateHasChanged(); }); + }, null, Timeout.Infinite, Timeout.Infinite); + } protected override async Task OnAfterRenderAsync(bool firstRender) { try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { } + if (firstRender) + { + await LoadTickets(); + _autoRefreshTimer?.Change(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); + StateHasChanged(); + } } private async Task LoadTickets() { _loading = true; - // EN: Mock kitchen tickets — will be replaced with FnB Engine API - // VI: Mock phiếu bếp — sẽ được thay bằng FnB Engine API - _tickets = new List + try { - new("#001", "Bàn 3", "Pending", new() { new("Phở bò", 2), new("Trà đá", 2) }), - new("#002", "Bàn 7", "InProgress", new() { new("Cơm tấm sườn", 1), new("Café sữa đá", 1) }), - new("#003", "Bàn 1", "Pending", new() { new("Bánh mì thịt", 3) }), - }; + var apiTickets = await DataService.GetKitchenTicketsAsync(status: null); + _tickets = apiTickets.Select(t => new KitchenTicket( + t.Id, + $"#{t.OrderItemId.ToString()[..6]}", + t.Station ?? "Kitchen", + t.Status, + new List { new(t.ItemName ?? "Món", 1) } + )).ToList(); + } + catch + { + // EN: Fallback to empty if API unavailable + // VI: Để trống nếu API không khả dụng + _tickets = new(); + } _pendingCount = _tickets.Count(t => t.Status == "Pending"); _inProgressCount = _tickets.Count(t => t.Status == "InProgress"); - _completedCount = 0; + _completedCount = _tickets.Count(t => t.Status == "Ready"); _loading = false; } private async Task Refresh() => await LoadTickets(); - private void StartTicket(KitchenTicket ticket) + private async Task StartTicket(KitchenTicket ticket) { - ticket.Status = "InProgress"; + var ok = await DataService.UpdateTicketStatusAsync(ticket.Id, new PosDataService.UpdateTicketStatusRequest("InProgress")); + if (ok) ticket.Status = "InProgress"; _pendingCount = _tickets.Count(t => t.Status == "Pending"); _inProgressCount = _tickets.Count(t => t.Status == "InProgress"); } - private void CompleteTicket(KitchenTicket ticket) + private async Task CompleteTicket(KitchenTicket ticket) { - ticket.Status = "Ready"; + var ok = await DataService.UpdateTicketStatusAsync(ticket.Id, new PosDataService.UpdateTicketStatusRequest("Ready")); + if (ok) ticket.Status = "Ready"; _inProgressCount = _tickets.Count(t => t.Status == "InProgress"); - _completedCount++; + _completedCount = _tickets.Count(t => t.Status == "Ready"); } private static string GetTicketStatusCss(string s) => s switch @@ -176,8 +203,9 @@ _ => s }; - private class KitchenTicket(string orderNumber, string tableInfo, string status, List items) + private class KitchenTicket(Guid id, string orderNumber, string tableInfo, string status, List items) { + public Guid Id { get; } = id; public string OrderNumber { get; } = orderNumber; public string TableInfo { get; } = tableInfo; public string Status { get; set; } = status; @@ -185,4 +213,6 @@ } private record TicketItem(string Name, int Qty); + + public void Dispose() => _autoRefreshTimer?.Dispose(); } 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 5c42dfbe..c51747f8 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 @@ -967,11 +967,14 @@ public class PosDataService 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") + public async Task> GetKitchenTicketsAsync(Guid? shopId = null, string? status = null) { - if (!shopId.HasValue) return new(); - var url = $"api/bff/shops/{shopId}/kitchen-tickets?status={status}"; - return await GetListFromApiAsync(url); + // EN: Use staff-context BFF endpoint when no shopId — auto-resolves from staff profile + // VI: Dùng BFF endpoint theo context nhân viên khi không có shopId — tự lấy từ profile + var qs = !string.IsNullOrEmpty(status) ? $"?status={status}" : ""; + if (shopId.HasValue) + return await GetListFromApiAsync($"api/bff/shops/{shopId}/kitchen-tickets{qs}"); + return await GetListFromApiAsync($"api/bff/kitchen/tickets{qs}"); } public async Task UpdateTicketStatusAsync(Guid ticketId, UpdateTicketStatusRequest req) diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs index d90900d4..12e2d8d3 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs @@ -15,12 +15,14 @@ public class StaffController : ControllerBase private readonly HttpClient _merchant; private readonly HttpClient _booking; private readonly HttpClient _iam; + private readonly HttpClient _fnb; public StaffController(IHttpClientFactory httpClientFactory) { _merchant = httpClientFactory.CreateClient("MerchantService"); _booking = httpClientFactory.CreateClient("BookingService"); _iam = httpClientFactory.CreateClient("IamService"); + _fnb = httpClientFactory.CreateClient("FnbEngine"); } /// @@ -145,8 +147,9 @@ public class StaffController : ControllerBase [HttpGet("staff/me")] public async Task GetMyStaffProfile() { - // EN: Get all staff for this merchant, then match by email from auth token - // VI: Lấy tất cả nhân viên của merchant, sau đó match theo email từ auth token + // EN: Get all staff for this merchant — AuthForwardingHandler auto-forwards the Bearer token. + // VI: Lấy tất cả nhân viên của merchant — AuthForwardingHandler tự động chuyển tiếp Bearer token. + var authHeader = Request.Headers["Authorization"].FirstOrDefault(); var staffResp = await _merchant.GetAsync("/api/v1/merchants/me/staff"); if (!staffResp.IsSuccessStatusCode) return StatusCode((int)staffResp.StatusCode, await staffResp.Content.ReadAsStringAsync()); @@ -154,61 +157,31 @@ public class StaffController : ControllerBase var staffJson = await staffResp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(staffJson); - // EN: Extract items from response envelope - // VI: Trích items từ response envelope + // EN: Extract items from response envelope — merchant-service may return plain array or wrapped object. + // VI: Trích items từ response envelope — merchant-service có thể trả về plain array hoặc wrapped object. JsonElement items; - if (doc.RootElement.TryGetProperty("data", out var dataEl) && dataEl.TryGetProperty("items", out var di)) + if (doc.RootElement.ValueKind == JsonValueKind.Array) + items = doc.RootElement; + else if (doc.RootElement.TryGetProperty("data", out var dataEl) && dataEl.TryGetProperty("items", out var di)) items = di; else if (doc.RootElement.TryGetProperty("items", out var ri)) items = ri; - else if (doc.RootElement.ValueKind == JsonValueKind.Array) - items = doc.RootElement; else return NotFound(new { success = false, message = "No staff data found" }); - // EN: Try to match by Authorization header email (JWT sub claim parsed by merchant-service) - // VI: Thử match theo email từ Authorization header (JWT sub claim được merchant-service parse) - // For now, get email from query or forwarded header - var userEmail = Request.Headers["X-User-Email"].FirstOrDefault(); + // EN: Extract userId from JWT 'sub' claim to match staff by userId. + // VI: Trích userId từ JWT 'sub' claim để match nhân viên theo userId. + var userId = ExtractUserIdFromJwt(authHeader); - // EN: Fallback: try to extract from token claims (simplified — in production use proper JWT parsing) - // VI: Fallback: thử trích từ token claims - if (string.IsNullOrEmpty(userEmail)) - { - // EN: Forward the request with auth header to IAM /api/v1/users/me for email - // VI: Chuyển request với auth header đến IAM /api/v1/users/me để lấy email - try - { - var authHeader = Request.Headers["Authorization"].FirstOrDefault(); - if (!string.IsNullOrEmpty(authHeader)) - { - _iam.DefaultRequestHeaders.Clear(); - _iam.DefaultRequestHeaders.Add("Authorization", authHeader); - var meResp = await _iam.GetAsync("/api/v1/users/me"); - if (meResp.IsSuccessStatusCode) - { - var meJson = await meResp.Content.ReadAsStringAsync(); - using var meDoc = JsonDocument.Parse(meJson); - if (meDoc.RootElement.TryGetProperty("data", out var meData) && meData.TryGetProperty("email", out var emailProp)) - userEmail = emailProp.GetString(); - else if (meDoc.RootElement.TryGetProperty("email", out var emailDirect)) - userEmail = emailDirect.GetString(); - } - } - } - catch { /* fallback to first staff */ } - } - - // EN: Find matching staff member - // VI: Tìm nhân viên khớp + // EN: Find matching staff member by userId, then by email fallback + // VI: Tìm nhân viên khớp theo userId, fallback theo email JsonElement? matchedStaff = null; - if (items.ValueKind == JsonValueKind.Array) + if (items.ValueKind == JsonValueKind.Array && !string.IsNullOrEmpty(userId)) { foreach (var staff in items.EnumerateArray()) { - if (!string.IsNullOrEmpty(userEmail) && - staff.TryGetProperty("email", out var emailProp) && - string.Equals(emailProp.GetString(), userEmail, StringComparison.OrdinalIgnoreCase)) + if (staff.TryGetProperty("userId", out var uidProp) && + string.Equals(uidProp.GetString(), userId, StringComparison.OrdinalIgnoreCase)) { matchedStaff = staff; break; @@ -243,7 +216,7 @@ public class StaffController : ControllerBase { staffId = s.TryGetProperty("id", out var idP) ? idP.GetString() : null, userId = s.TryGetProperty("userId", out var uidP) ? uidP.GetString() : null, - email = s.TryGetProperty("email", out var emP) ? emP.GetString() : userEmail, + email = s.TryGetProperty("email", out var emP) ? emP.GetString() : null, firstName = s.TryGetProperty("firstName", out var fnP) ? fnP.GetString() : null, lastName = s.TryGetProperty("lastName", out var lnP) ? lnP.GetString() : null, role = s.TryGetProperty("role", out var rP) ? rP.GetString() : shopRole, @@ -288,40 +261,19 @@ public class StaffController : ControllerBase } /// - /// EN: Get attendance records for current staff. - /// VI: Lấy bản ghi chấm công của nhân viên hiện tại. + /// EN: Get attendance records for current staff — proxies to merchant-service. + /// VI: Lấy bản ghi chấm công của nhân viên hiện tại — proxy đến merchant-service. /// [HttpGet("staff/me/attendance")] - public IActionResult GetMyAttendance([FromQuery] int month = 0, [FromQuery] int year = 0) + public async Task GetMyAttendance([FromQuery] int month = 0, [FromQuery] int year = 0) { - // EN: Stub — returns mock data until merchant-service attendance module is built - // VI: Stub — trả về dữ liệu mẫu cho đến khi module chấm công được xây dựng - var now = DateTime.UtcNow; - var targetMonth = month > 0 ? month : now.Month; - var targetYear = year > 0 ? year : now.Year; - var daysInMonth = DateTime.DaysInMonth(targetYear, targetMonth); - var records = new List(); + var staffProfile = await ResolveStaffProfileAsync(); + if (staffProfile == null) + return NotFound(new { success = false, message = "Staff profile not found" }); - for (int d = 1; d <= Math.Min(daysInMonth, now.Day); d++) - { - var date = new DateTime(targetYear, targetMonth, d); - if (date > now.Date) break; - if (date.DayOfWeek == DayOfWeek.Sunday) continue; - - records.Add(new - { - id = Guid.NewGuid(), - staffId = Guid.Empty, - date = date.ToString("o"), - checkIn = date.AddHours(8).AddMinutes(new Random(d).Next(0, 15)).ToString("o"), - checkOut = date.Date == now.Date ? (string?)null : date.AddHours(17).AddMinutes(new Random(d + 100).Next(0, 30)).ToString("o"), - hoursWorked = date.Date == now.Date ? (decimal?)null : 8m + (decimal)(new Random(d + 200).Next(0, 120)) / 60m, - status = date.Date == now.Date ? "Working" : "Completed", - notes = (string?)null - }); - } - - return Ok(new { success = true, data = new { items = records } }); + var staffId = staffProfile.Value.staffId; + var qs = $"?month={month}&year={year}"; + return await _merchant.GetAsync($"/api/v1/attendance/staff/{staffId}{qs}").ProxyAsync(); } /// @@ -367,23 +319,33 @@ public class StaffController : ControllerBase } /// - /// EN: Check in attendance. - /// VI: Chấm công vào. + /// EN: Check in attendance — proxies to merchant-service. + /// VI: Chấm công vào — proxy đến merchant-service. /// [HttpPost("staff/me/attendance/check-in")] - public IActionResult CheckIn() + public async Task CheckIn() { - return Ok(new { success = true, data = new { id = Guid.NewGuid(), checkIn = DateTime.UtcNow.ToString("o") } }); + var staffProfile = await ResolveStaffProfileAsync(); + if (staffProfile == null) + return NotFound(new { success = false, message = "Staff profile not found" }); + + var payload = new { staffId = staffProfile.Value.staffId, shopId = staffProfile.Value.shopId }; + return await _merchant.PostAsJsonAsync("/api/v1/attendance/check-in", payload).ProxyAsync(); } /// - /// EN: Check out attendance. - /// VI: Chấm công ra. + /// EN: Check out attendance — proxies to merchant-service. + /// VI: Chấm công ra — proxy đến merchant-service. /// [HttpPost("staff/me/attendance/check-out")] - public IActionResult CheckOut() + public async Task CheckOut() { - return Ok(new { success = true, data = new { id = Guid.NewGuid(), checkOut = DateTime.UtcNow.ToString("o") } }); + var staffProfile = await ResolveStaffProfileAsync(); + if (staffProfile == null) + return NotFound(new { success = false, message = "Staff profile not found" }); + + var payload = new { staffId = staffProfile.Value.staffId }; + return await _merchant.PostAsJsonAsync("/api/v1/attendance/check-out", payload).ProxyAsync(); } /// @@ -428,4 +390,103 @@ public class StaffController : ControllerBase [HttpDelete("staff/schedules/{scheduleId:guid}")] public Task DeleteSchedule(Guid scheduleId) => _booking.DeleteAsync($"/api/v1/schedules/{scheduleId}").ProxyAsync(); + + // ═══ KITCHEN DISPLAY ═══ + + /// + /// EN: Get kitchen tickets for the current staff's shop — proxies to FnbEngine. + /// VI: Lấy phiếu bếp cho shop của nhân viên — proxy đến FnbEngine. + /// + [HttpGet("kitchen/tickets")] + public async Task GetKitchenTickets([FromQuery] string? status = null) + { + var staffProfile = await ResolveStaffProfileAsync(); + if (staffProfile == null) + return NotFound(new { success = false, message = "Staff profile not found" }); + + var qs = $"?shopId={staffProfile.Value.shopId}"; + if (!string.IsNullOrEmpty(status)) qs += $"&status={status}"; + return await _fnb.GetAsync($"/api/v1/kitchen/tickets{qs}").ProxyAsync(); + } + + /// + /// EN: Update kitchen ticket status — proxies to FnbEngine. + /// VI: Cập nhật trạng thái phiếu bếp — proxy đến FnbEngine. + /// + [HttpPatch("kitchen/tickets/{ticketId:guid}/status")] + public Task UpdateKitchenTicketStatus(Guid ticketId, [FromBody] JsonElement body) => + _fnb.PatchAsync($"/api/v1/kitchen/tickets/{ticketId}/status", + JsonContent.Create(body)).ProxyAsync(); + + /// + /// EN: Create kitchen ticket — proxies to FnbEngine. + /// VI: Tạo phiếu bếp — proxy đến FnbEngine. + /// + [HttpPost("kitchen/tickets")] + public Task CreateKitchenTicket([FromBody] JsonElement body) => + _fnb.PostAsJsonAsync("/api/v1/kitchen/tickets", body).ProxyAsync(); + + // ═══ HELPER METHODS ═══ + + /// + /// EN: Resolve current user's staff profile (staffId, shopId) by decoding JWT sub and matching in staff list. + /// VI: Giải mã staff profile (staffId, shopId) từ JWT sub và match trong danh sách nhân viên. + /// + private async Task<(Guid staffId, Guid shopId)?> ResolveStaffProfileAsync() + { + var authHeader = Request.Headers["Authorization"].FirstOrDefault(); + var userId = ExtractUserIdFromJwt(authHeader); + if (userId == null) return null; + + var staffResp = await _merchant.GetAsync("/api/v1/merchants/me/staff"); + if (!staffResp.IsSuccessStatusCode) return null; + + var staffJson = await staffResp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(staffJson); + + var items = doc.RootElement.ValueKind == JsonValueKind.Array + ? doc.RootElement + : doc.RootElement.TryGetProperty("data", out var d) && d.TryGetProperty("items", out var di) ? di + : doc.RootElement.TryGetProperty("items", out var ri) ? ri + : default; + + if (items.ValueKind != JsonValueKind.Array) return null; + + foreach (var staff in items.EnumerateArray()) + { + if (staff.TryGetProperty("userId", out var uidProp) && + string.Equals(uidProp.GetString(), userId, StringComparison.OrdinalIgnoreCase)) + { + var staffId = staff.TryGetProperty("id", out var sid) && Guid.TryParse(sid.GetString(), out var s) ? s : Guid.Empty; + Guid shopId = Guid.Empty; + if (staff.TryGetProperty("shopAssignments", out var sa) && sa.ValueKind == JsonValueKind.Array) + { + foreach (var a in sa.EnumerateArray()) + { + if (a.TryGetProperty("shopId", out var shopProp) && Guid.TryParse(shopProp.GetString(), out var sp)) + { shopId = sp; break; } + } + } + if (staffId != Guid.Empty) return (staffId, shopId); + } + } + return null; + } + + private static string? ExtractUserIdFromJwt(string? authHeader) + { + if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; + try + { + var token = authHeader["Bearer ".Length..]; + var parts = token.Split('.'); + if (parts.Length < 2) return null; + var payload = parts[1].Replace('-', '+').Replace('_', '/'); + switch (payload.Length % 4) { case 2: payload += "=="; break; case 3: payload += "="; break; } + using var jwtDoc = JsonDocument.Parse(Convert.FromBase64String(payload)); + return jwtDoc.RootElement.TryGetProperty("sub", out var sub) ? sub.GetString() : null; + } + catch { return null; } + } } diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Staff/StaffQueries.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Staff/StaffQueries.cs index 0bdad360..1af55b0e 100644 --- a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Staff/StaffQueries.cs +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Staff/StaffQueries.cs @@ -40,14 +40,26 @@ public class GetMyStaffQueryHandler : IRequestHandler> Handle(GetMyStaffQuery request, CancellationToken cancellationToken) { var userId = GetUserId(); - var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken) - ?? throw new DomainException("Merchant not found"); + var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken); + + // EN: If user is not a merchant owner, try to find merchant through staff membership + // VI: Nếu user không phải chủ merchant, thử tìm merchant qua tư cách nhân viên + if (merchant == null) + { + var staffMember = await _staffRepository.GetByUserIdAsync(userId, cancellationToken); + if (staffMember != null) + merchant = await _merchantRepository.GetByIdAsync(staffMember.MerchantId, cancellationToken); + } + + if (merchant == null) + throw new DomainException("Merchant not found"); var staffList = await _staffRepository.GetByMerchantIdAsync(merchant.Id, cancellationToken); return staffList.Select(s => new StaffDto { Id = s.Id, + UserId = s.UserId, Email = s.Email ?? string.Empty, EmployeeCode = s.EmployeeCode, Phone = s.Phone, @@ -93,6 +105,7 @@ public class GetMyStaffQueryHandler : IRequestHandler public static readonly StaffRole Admin = new(4, nameof(Admin)); + /// + /// EN: Kitchen - handles food preparation. + /// VI: Bếp - xử lý chế biến món ăn. + /// + public static readonly StaffRole Kitchen = new(5, nameof(Kitchen)); + + /// + /// EN: Barista - prepares beverages. + /// VI: Barista - pha chế đồ uống. + /// + public static readonly StaffRole Barista = new(6, nameof(Barista)); + public StaffRole(int id, string name) : base(id, name) { } @@ -167,6 +179,18 @@ public class ShopRole : Enumeration /// public static readonly ShopRole Owner = new(4, nameof(Owner)); + /// + /// EN: Kitchen role at shop level. + /// VI: Vai trò bếp ở cấp shop. + /// + public static readonly ShopRole Kitchen = new(5, nameof(Kitchen)); + + /// + /// EN: Barista role at shop level. + /// VI: Vai trò pha chế ở cấp shop. + /// + public static readonly ShopRole Barista = new(6, nameof(Barista)); + public ShopRole(int id, string name) : base(id, name) { } diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/AttendanceRepository.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/AttendanceRepository.cs index 954b43c5..25e1344d 100644 --- a/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/AttendanceRepository.cs +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/AttendanceRepository.cs @@ -21,26 +21,31 @@ public class AttendanceRepository : IAttendanceRepository { var today = DateTime.UtcNow.Date; return await _context.AttendanceRecords - .FirstOrDefaultAsync(a => a.StaffId == staffId && a.Date == today, ct); + .FirstOrDefaultAsync(a => EF.Property(a, "_staffId") == staffId + && EF.Property(a, "_date") == today, ct); } public async Task> GetByStaffAndMonthAsync(Guid staffId, int month, int year, CancellationToken ct = default) { - var startDate = new DateTime(year, month, 1); + var startDate = new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Utc); var endDate = startDate.AddMonths(1); return await _context.AttendanceRecords - .Where(a => a.StaffId == staffId && a.Date >= startDate && a.Date < endDate) - .OrderByDescending(a => a.Date) + .Where(a => EF.Property(a, "_staffId") == staffId + && EF.Property(a, "_date") >= startDate + && EF.Property(a, "_date") < endDate) + .OrderByDescending(a => EF.Property(a, "_date")) .ToListAsync(ct); } public async Task> GetByShopAndMonthAsync(Guid shopId, int month, int year, CancellationToken ct = default) { - var startDate = new DateTime(year, month, 1); + var startDate = new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Utc); var endDate = startDate.AddMonths(1); return await _context.AttendanceRecords - .Where(a => a.ShopId == shopId && a.Date >= startDate && a.Date < endDate) - .OrderByDescending(a => a.Date) + .Where(a => EF.Property(a, "_shopId") == shopId + && EF.Property(a, "_date") >= startDate + && EF.Property(a, "_date") < endDate) + .OrderByDescending(a => EF.Property(a, "_date")) .ToListAsync(ct); }