feat(staff): Integrate kitchen display system, add new staff roles, and enhance staff profile resolution with improved attendance proxying.
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user