feat(staff): Integrate kitchen display system, add new staff roles, and enhance staff profile resolution with improved attendance proxying.

This commit is contained in:
Ho Ngoc Hai
2026-03-06 11:42:41 +07:00
parent 30b3f9a37c
commit 193b9edd23
10 changed files with 349 additions and 143 deletions

View File

@@ -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<string?>("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()
{

View File

@@ -165,6 +165,7 @@
private string _shopName = "Cửa hàng";
private Guid? _shopId;
private int _unreadNotifications = 0;
private string? _staffDisplayName;
/// <summary>
/// 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<string?>("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();

View File

@@ -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.
}
}
}

View File

@@ -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 hin 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;
}
}

View File

@@ -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<KitchenTicket>
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<TicketItem> { 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<TicketItem> items)
private class KitchenTicket(Guid id, string orderNumber, string tableInfo, string status, List<TicketItem> 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();
}

View File

@@ -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<List<KitchenTicketInfo>> GetKitchenTicketsAsync(Guid? shopId = null, string status = "Pending")
public async Task<List<KitchenTicketInfo>> 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<KitchenTicketInfo>(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<KitchenTicketInfo>($"api/bff/shops/{shopId}/kitchen-tickets{qs}");
return await GetListFromApiAsync<KitchenTicketInfo>($"api/bff/kitchen/tickets{qs}");
}
public async Task<bool> UpdateTicketStatusAsync(Guid ticketId, UpdateTicketStatusRequest req)

View File

@@ -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");
}
/// <summary>
@@ -145,8 +147,9 @@ public class StaffController : ControllerBase
[HttpGet("staff/me")]
public async Task<IActionResult> 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
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("staff/me/attendance")]
public IActionResult GetMyAttendance([FromQuery] int month = 0, [FromQuery] int year = 0)
public async Task<IActionResult> 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<object>();
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();
}
/// <summary>
@@ -367,23 +319,33 @@ public class StaffController : ControllerBase
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("staff/me/attendance/check-in")]
public IActionResult CheckIn()
public async Task<IActionResult> 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();
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("staff/me/attendance/check-out")]
public IActionResult CheckOut()
public async Task<IActionResult> 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();
}
/// <summary>
@@ -428,4 +390,103 @@ public class StaffController : ControllerBase
[HttpDelete("staff/schedules/{scheduleId:guid}")]
public Task<IActionResult> DeleteSchedule(Guid scheduleId) =>
_booking.DeleteAsync($"/api/v1/schedules/{scheduleId}").ProxyAsync();
// ═══ KITCHEN DISPLAY ═══
/// <summary>
/// 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.
/// </summary>
[HttpGet("kitchen/tickets")]
public async Task<IActionResult> 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();
}
/// <summary>
/// EN: Update kitchen ticket status — proxies to FnbEngine.
/// VI: Cập nhật trạng thái phiếu bếp — proxy đến FnbEngine.
/// </summary>
[HttpPatch("kitchen/tickets/{ticketId:guid}/status")]
public Task<IActionResult> UpdateKitchenTicketStatus(Guid ticketId, [FromBody] JsonElement body) =>
_fnb.PatchAsync($"/api/v1/kitchen/tickets/{ticketId}/status",
JsonContent.Create(body)).ProxyAsync();
/// <summary>
/// EN: Create kitchen ticket — proxies to FnbEngine.
/// VI: Tạo phiếu bếp — proxy đến FnbEngine.
/// </summary>
[HttpPost("kitchen/tickets")]
public Task<IActionResult> CreateKitchenTicket([FromBody] JsonElement body) =>
_fnb.PostAsJsonAsync("/api/v1/kitchen/tickets", body).ProxyAsync();
// ═══ HELPER METHODS ═══
/// <summary>
/// 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.
/// </summary>
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; }
}
}

View File

@@ -40,14 +40,26 @@ public class GetMyStaffQueryHandler : IRequestHandler<GetMyStaffQuery, IReadOnly
public async Task<IReadOnlyList<StaffDto>> 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<GetMyStaffQuery, IReadOnly
public record StaffDto
{
public Guid Id { get; init; }
public Guid? UserId { get; init; }
public string Email { get; init; } = null!;
public string? EmployeeCode { get; init; }
public string? Phone { get; init; }

View File

@@ -35,6 +35,18 @@ public class StaffRole : Enumeration
/// </summary>
public static readonly StaffRole Admin = new(4, nameof(Admin));
/// <summary>
/// EN: Kitchen - handles food preparation.
/// VI: Bếp - xử lý chế biến món ăn.
/// </summary>
public static readonly StaffRole Kitchen = new(5, nameof(Kitchen));
/// <summary>
/// EN: Barista - prepares beverages.
/// VI: Barista - pha chế đồ uống.
/// </summary>
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
/// </summary>
public static readonly ShopRole Owner = new(4, nameof(Owner));
/// <summary>
/// EN: Kitchen role at shop level.
/// VI: Vai trò bếp ở cấp shop.
/// </summary>
public static readonly ShopRole Kitchen = new(5, nameof(Kitchen));
/// <summary>
/// EN: Barista role at shop level.
/// VI: Vai trò pha chế ở cấp shop.
/// </summary>
public static readonly ShopRole Barista = new(6, nameof(Barista));
public ShopRole(int id, string name) : base(id, name)
{
}

View File

@@ -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<Guid>(a, "_staffId") == staffId
&& EF.Property<DateTime>(a, "_date") == today, ct);
}
public async Task<List<AttendanceRecord>> 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<Guid>(a, "_staffId") == staffId
&& EF.Property<DateTime>(a, "_date") >= startDate
&& EF.Property<DateTime>(a, "_date") < endDate)
.OrderByDescending(a => EF.Property<DateTime>(a, "_date"))
.ToListAsync(ct);
}
public async Task<List<AttendanceRecord>> 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<Guid>(a, "_shopId") == shopId
&& EF.Property<DateTime>(a, "_date") >= startDate
&& EF.Property<DateTime>(a, "_date") < endDate)
.OrderByDescending(a => EF.Property<DateTime>(a, "_date"))
.ToListAsync(ct);
}