fix: resolve HR module bugs — leave approval, staff auth timing, EF Core mapping

- BFF: extract approver/rejector userId from JWT instead of accepting Guid.Empty from client
- Staff pages (Dashboard, Leave, Attendance): move data loading to OnAfterRenderAsync
  to fix token timing bug where OnInitializedAsync runs before auth session is restored
- EF Core: fix AttendanceRepository to use public properties after HasField() migration
- LeaveRequest: fix DateTime UTC kind for Npgsql 10 compatibility
- merchant-service: add debug seed endpoints for staff/shop test data
- EF configs: migrate to HasField() pattern for private field mapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-13 15:23:35 +07:00
parent aba5ee1162
commit 8086bc627f
14 changed files with 477 additions and 109 deletions

View File

@@ -109,9 +109,7 @@
protected override async Task OnInitializedAsync()
{
// EN: Mock — will be connected to real API
// VI: Mock — sẽ kết nối API thực
_loading = false;
await LoadLeaveRequests();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -119,14 +117,58 @@
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
}
private void Approve(PosDataService.LeaveRequest r)
private async Task LoadLeaveRequests()
{
Snackbar.Add("Đã duyệt yêu cầu nghỉ phép", Severity.Success);
_loading = true;
try
{
_requests = await DataService.GetListFromApiAsync<PosDataService.LeaveRequest>($"api/bff/shops/{ShopId}/leave-requests");
_pendingCount = _requests.Count(r => r.Status == "Pending");
_approvedCount = _requests.Count(r => r.Status == "Approved");
_rejectedCount = _requests.Count(r => r.Status == "Rejected");
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi tải danh sách nghỉ phép: {ex.Message}", Severity.Error);
}
_loading = false;
}
private void Reject(PosDataService.LeaveRequest r)
private async Task Approve(PosDataService.LeaveRequest r)
{
Snackbar.Add("Đã từ chối yêu cầu nghỉ phép", Severity.Warning);
try
{
// EN: BFF extracts approver userId from JWT — no need to send from client.
// VI: BFF trích userId người duyệt từ JWT — không cần gửi từ client.
var ok = await DataService.PostAsync($"api/bff/leave-requests/{r.Id}/approve", new { });
if (ok)
{
Snackbar.Add("Đã duyệt yêu cầu nghỉ phép", Severity.Success);
await LoadLeaveRequests();
}
else
Snackbar.Add("Không thể duyệt yêu cầu", Severity.Error);
}
catch (Exception ex) { Snackbar.Add($"Lỗi: {ex.Message}", Severity.Error); }
}
private async Task Reject(PosDataService.LeaveRequest r)
{
try
{
// EN: BFF extracts rejector userId from JWT — only send reason from client.
// VI: BFF trích userId người từ chối từ JWT — chỉ gửi lý do từ client.
var ok = await DataService.PostAsync($"api/bff/leave-requests/{r.Id}/reject",
new { reason = "Không được duyệt" });
if (ok)
{
Snackbar.Add("Đã từ chối yêu cầu nghỉ phép", Severity.Warning);
await LoadLeaveRequests();
}
else
Snackbar.Add("Không thể từ chối yêu cầu", Severity.Error);
}
catch (Exception ex) { Snackbar.Add($"Lỗi: {ex.Message}", Severity.Error); }
}
private static string GetLeaveTypeLabel(string t) => t switch

View File

@@ -2,6 +2,7 @@
@layout StaffLayout
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@inject AuthService AuthSvc
@inject IJSRuntime JS
@*
@@ -101,11 +102,18 @@
private int _totalDays = 0;
private decimal _totalHours = 0;
protected override async Task OnInitializedAsync() => await LoadData();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
if (firstRender)
{
// EN: Restore auth session before loading data — token may not be in AuthState yet after forceLoad.
// VI: Khôi phục session auth trước khi tải data — token có thể chưa có trong AuthState sau forceLoad.
await AuthSvc.TryRestoreSessionAsync();
await LoadData();
StateHasChanged();
}
}
private async Task LoadData()

View File

@@ -3,6 +3,7 @@
@layout StaffLayout
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@inject AuthService AuthSvc
@inject WebClientTpos.Client.Services.AuthStateService AuthState
@inject NavigationManager Nav
@inject IJSRuntime JS
@@ -145,7 +146,21 @@
private string _displayName => _profile?.FirstName ?? AuthState.UserEmail?.Split('@').FirstOrDefault() ?? "Staff";
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
if (firstRender)
{
// EN: Restore auth session before loading data — token may not be in AuthState yet after forceLoad.
// VI: Khôi phục session auth trước khi tải data — token có thể chưa có trong AuthState sau forceLoad.
await AuthSvc.TryRestoreSessionAsync();
await LoadDashboardData();
StateHasChanged();
}
}
private async Task LoadDashboardData()
{
try
{
@@ -177,11 +192,6 @@
finally { _loading = false; }
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
}
private async Task CheckIn()
{
_actionLoading = true;

View File

@@ -2,6 +2,7 @@
@layout StaffLayout
@using WebClientTpos.Client.Services
@inject PosDataService DataService
@inject AuthService AuthSvc
@inject IJSRuntime JS
@inject ISnackbar Snackbar
@@ -135,8 +136,23 @@
private DateTime? _endDate;
private string _reason = "";
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
if (firstRender)
{
// EN: Restore auth session before loading data — token may not be in AuthState yet after forceLoad.
// VI: Khôi phục session auth trước khi tải data — token có thể chưa có trong AuthState sau forceLoad.
await AuthSvc.TryRestoreSessionAsync();
await LoadLeaveRequests();
StateHasChanged();
}
}
private async Task LoadLeaveRequests()
{
_loading = true;
try
{
_requests = await DataService.GetMyLeaveRequestsAsync();
@@ -147,11 +163,6 @@
finally { _loading = false; }
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
}
private void ShowCreateForm()
{
_showForm = true;

View File

@@ -119,7 +119,7 @@ public class PosDataService
/// EN: Robust list deserialization — handles plain arrays, PagedResult wrappers, and ApiResponse envelopes.
/// VI: Deserialize list linh hoạt — xử lý array thuần, PagedResult wrapper, và ApiResponse envelope.
/// </summary>
private async Task<List<T>> GetListFromApiAsync<T>(string url)
public async Task<List<T>> GetListFromApiAsync<T>(string url)
{
AttachToken();
var resp = await _http.GetAsync(url);

View File

@@ -277,27 +277,93 @@ public class StaffController : ControllerBase
}
/// <summary>
/// EN: Get leave requests for current staff.
/// VI: Lấy yêu cầu nghỉ phép của nhân viên hiện tại.
/// EN: Get leave requests for current staff — proxies to merchant-service.
/// Uses staffId from query param or resolves from JWT.
/// VI: Lấy yêu cầu nghỉ phép của nhân viên hiện tại — proxy đến merchant-service.
/// Dùng staffId từ query param hoặc resolve từ JWT.
/// </summary>
[HttpGet("staff/me/leave-requests")]
public IActionResult GetMyLeaveRequests()
public async Task<IActionResult> GetMyLeaveRequests([FromQuery] Guid? staffId = null)
{
// EN: Stub — returns mock data
// VI: Stub — trả về dữ liệu mẫu
return Ok(new { success = true, data = new { items = new List<object>() } });
var resolvedStaffId = staffId;
if (!resolvedStaffId.HasValue || resolvedStaffId == Guid.Empty)
{
var staffProfile = await ResolveStaffProfileAsync();
if (staffProfile == null)
return Ok(new { success = true, data = new { items = new List<object>() } });
resolvedStaffId = staffProfile.Value.staffId;
}
return await _merchant.GetAsync($"/api/v1/leave-requests/staff/{resolvedStaffId}").ProxyAsync();
}
/// <summary>
/// EN: Create a leave request.
/// VI: Tạo yêu cầu nghỉ phép.
/// EN: Create a leave request — proxies to merchant-service.
/// Accepts staffId/shopId from body or resolves from JWT.
/// VI: Tạo yêu cầu nghỉ phép — proxy đến merchant-service.
/// Nhận staffId/shopId từ body hoặc resolve từ JWT.
/// </summary>
[HttpPost("staff/me/leave-requests")]
public IActionResult CreateLeaveRequest([FromBody] JsonElement body)
public async Task<IActionResult> CreateLeaveRequest([FromBody] JsonElement body)
{
// EN: Stub — returns success
// VI: Stub — trả về thành công
return Ok(new { success = true, data = new { id = Guid.NewGuid() } });
Guid? staffId = body.TryGetProperty("staffId", out var si) && Guid.TryParse(si.GetString(), out var sp) ? sp : null;
Guid? shopId = body.TryGetProperty("shopId", out var shi) && Guid.TryParse(shi.GetString(), out var shp) ? shp : null;
if (!staffId.HasValue || staffId == Guid.Empty || !shopId.HasValue || shopId == Guid.Empty)
{
var staffProfile = await ResolveStaffProfileAsync();
if (staffProfile != null)
{
staffId ??= staffProfile.Value.staffId;
shopId ??= staffProfile.Value.shopId;
}
else
return NotFound(new { success = false, message = "Staff profile not found. Please provide staffId and shopId." });
}
var payload = new
{
staffId,
shopId,
leaveType = body.TryGetProperty("leaveType", out var lt) ? lt.GetString() : "Annual",
startDate = body.TryGetProperty("startDate", out var sd) ? sd.GetString() : null,
endDate = body.TryGetProperty("endDate", out var ed) ? ed.GetString() : null,
reason = body.TryGetProperty("reason", out var rn) ? rn.GetString() : null
};
return await _merchant.PostAsJsonAsync("/api/v1/leave-requests", payload).ProxyAsync();
}
/// <summary>
/// EN: Get leave requests for a shop (admin) — proxies to merchant-service.
/// VI: Lấy yêu cầu nghỉ phép theo shop (admin) — proxy đến merchant-service.
/// </summary>
[HttpGet("shops/{shopId:guid}/leave-requests")]
public Task<IActionResult> GetShopLeaveRequests(Guid shopId) =>
_merchant.GetAsync($"/api/v1/leave-requests/shop/{shopId}").ProxyAsync();
/// <summary>
/// EN: Approve a leave request (admin) — extracts userId from JWT and proxies to merchant-service.
/// VI: Duyệt yêu cầu nghỉ phép (admin) — trích userId từ JWT và proxy đến merchant-service.
/// </summary>
[HttpPost("leave-requests/{id:guid}/approve")]
public Task<IActionResult> ApproveLeaveRequest(Guid id)
{
var userId = ExtractUserIdFromJwt(Request.Headers.Authorization.FirstOrDefault());
var approvedBy = !string.IsNullOrEmpty(userId) && Guid.TryParse(userId, out var uid) ? uid : Guid.NewGuid();
return _merchant.PostAsJsonAsync($"/api/v1/leave-requests/{id}/approve", new { approvedBy }).ProxyAsync();
}
/// <summary>
/// EN: Reject a leave request (admin) — extracts userId from JWT and proxies to merchant-service.
/// VI: Từ chối yêu cầu nghỉ phép (admin) — trích userId từ JWT và proxy đến merchant-service.
/// </summary>
[HttpPost("leave-requests/{id:guid}/reject")]
public Task<IActionResult> RejectLeaveRequest(Guid id, [FromBody] JsonElement body)
{
var userId = ExtractUserIdFromJwt(Request.Headers.Authorization.FirstOrDefault());
var rejectedBy = !string.IsNullOrEmpty(userId) && Guid.TryParse(userId, out var uid) ? uid : Guid.NewGuid();
var reason = body.TryGetProperty("reason", out var r) ? r.GetString() : null;
return _merchant.PostAsJsonAsync($"/api/v1/leave-requests/{id}/reject", new { rejectedBy, reason }).ProxyAsync();
}
/// <summary>
@@ -435,8 +501,8 @@ public class StaffController : ControllerBase
private async Task<(Guid staffId, Guid shopId)?> ResolveStaffProfileAsync()
{
var authHeader = Request.Headers["Authorization"].FirstOrDefault();
var userId = ExtractUserIdFromJwt(authHeader);
if (userId == null) return null;
var (userId, email) = ExtractUserClaimsFromJwt(authHeader);
if (userId == null && email == null) return null;
var staffResp = await _merchant.GetAsync("/api/v1/merchants/me/staff");
if (!staffResp.IsSuccessStatusCode) return null;
@@ -452,41 +518,94 @@ public class StaffController : ControllerBase
if (items.ValueKind != JsonValueKind.Array) return null;
// EN: Try matching by userId first, then fallback to email
// VI: Thử match theo userId trước, sau đó fallback theo email
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 ExtractStaffProfile(staff);
}
}
// EN: Fallback: match by email in staff list
// VI: Fallback: match theo email trong danh sách nhân viên
if (email != null)
{
foreach (var staff in items.EnumerateArray())
{
if (staff.TryGetProperty("email", out var emailProp) &&
string.Equals(emailProp.GetString(), email, StringComparison.OrdinalIgnoreCase))
{
return ExtractStaffProfile(staff);
}
}
}
// EN: Final fallback: use staff lookup endpoint by email (handles cross-env userId mismatch)
// VI: Fallback cuối: dùng staff lookup theo email (xử lý userId khác nhau giữa các môi trường)
if (email != null)
{
try
{
var lookupResp = await _merchant.GetAsync($"/api/v1/staff/lookup?email={Uri.EscapeDataString(email)}");
if (lookupResp.IsSuccessStatusCode)
{
var lookupJson = await lookupResp.Content.ReadAsStringAsync();
using var lookupDoc = JsonDocument.Parse(lookupJson);
if (lookupDoc.RootElement.TryGetProperty("data", out var data))
{
var sId = data.TryGetProperty("staffId", out var si) && Guid.TryParse(si.GetString(), out var sp) ? sp : Guid.Empty;
var shId = data.TryGetProperty("shopId", out var shi) && Guid.TryParse(shi.GetString(), out var shp) ? shp : Guid.Empty;
if (sId != Guid.Empty) return (sId, shId);
}
}
}
catch { /* non-fatal */ }
}
return null;
}
private static string? ExtractUserIdFromJwt(string? authHeader)
private static (Guid staffId, Guid shopId)? ExtractStaffProfile(JsonElement staff)
{
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;
}
/// <summary>
/// EN: Extract userId (sub) and email from JWT token.
/// VI: Trích userId (sub) và email từ JWT token.
/// </summary>
private static (string? userId, string? email) ExtractUserClaimsFromJwt(string? authHeader)
{
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return null;
return (null, null);
try
{
var token = authHeader["Bearer ".Length..];
var parts = token.Split('.');
if (parts.Length < 2) return null;
if (parts.Length < 2) return (null, 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;
var sub = jwtDoc.RootElement.TryGetProperty("sub", out var s) ? s.GetString() : null;
var email = jwtDoc.RootElement.TryGetProperty("email", out var e) ? e.GetString() : null;
return (sub, email);
}
catch { return null; }
catch { return (null, null); }
}
private static string? ExtractUserIdFromJwt(string? authHeader) => ExtractUserClaimsFromJwt(authHeader).userId;
}