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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user