Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbb610fc8b | |||
| 99aa395fb1 | |||
| cb41d503c4 | |||
| b4993b202d |
+1
Submodule .claude/worktrees/agent-a98805dc618e0f67c added at b4993b202d
@@ -27,13 +27,15 @@ public sealed class AuthController : ControllerBase
|
|||||||
private readonly IAuthRecoveryService _recovery;
|
private readonly IAuthRecoveryService _recovery;
|
||||||
private readonly IAuthAltService _alt;
|
private readonly IAuthAltService _alt;
|
||||||
private readonly IRoleService _roles;
|
private readonly IRoleService _roles;
|
||||||
|
private readonly IAuditService _audit;
|
||||||
|
|
||||||
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
|
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
|
||||||
{
|
{
|
||||||
_users = users;
|
_users = users;
|
||||||
_recovery = recovery;
|
_recovery = recovery;
|
||||||
_alt = alt;
|
_alt = alt;
|
||||||
_roles = roles;
|
_roles = roles;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -68,6 +70,7 @@ public sealed class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
var result = await _users.LoginAsync(request, ct);
|
var result = await _users.LoginAsync(request, ct);
|
||||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||||
|
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
|
||||||
return Ok(result.Body);
|
return Ok(result.Body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +81,13 @@ public sealed class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
var result = await _users.VerifyOtpForLoginAsync(request, ct);
|
var result = await _users.VerifyOtpForLoginAsync(request, ct);
|
||||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||||
|
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
|
||||||
return Ok(result.Body);
|
return Ok(result.Body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string? ClientIp() => HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
private string? ClientUserAgent() => Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null;
|
||||||
|
|
||||||
[HttpPost("refresh-token")]
|
[HttpPost("refresh-token")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class BranchesController : ApiControllerBase
|
|||||||
await _branches.SetStatusAsync(branchId, request.Status, ct);
|
await _branches.SetStatusAsync(branchId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{branchId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int branchId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _branches.DeleteAsync(branchId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,14 @@ public sealed class DepartmentsController : ApiControllerBase
|
|||||||
await _departments.SetStatusAsync(departmentId, request.Status, ct);
|
await _departments.SetStatusAsync(departmentId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{departmentId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int departmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _departments.DeleteAsync(departmentId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class DesignationsController : ApiControllerBase
|
|||||||
await _designations.SetStatusAsync(designationId, request.Status, ct);
|
await _designations.SetStatusAsync(designationId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{designationId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int designationId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _designations.DeleteAsync(designationId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).</summary>
|
|
||||||
[HttpPatch("{employeeId:int}/status")]
|
[HttpPatch("{employeeId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -93,6 +92,17 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
|
||||||
|
[HttpDelete("{employeeId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int employeeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _employees.DeleteAsync(employeeId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{employeeId:int}/link-user")]
|
[HttpPost("{employeeId:int}/link-user")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
|||||||
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
|
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{employmentTypeId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int employmentTypeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _employmentTypes.DeleteAsync(employmentTypeId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
|||||||
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
|
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{hrDocumentTypeId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int hrDocumentTypeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _types.DeleteAsync(hrDocumentTypeId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class LeaveTypesController : ApiControllerBase
|
|||||||
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
|
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{leaveTypeId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int leaveTypeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _leaveTypes.DeleteAsync(leaveTypeId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
|||||||
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
|
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{salaryComponentId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int salaryComponentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _components.DeleteAsync(salaryComponentId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ public sealed class WorkShiftsController : ApiControllerBase
|
|||||||
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
|
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{workShiftId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int workShiftId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _shifts.DeleteAsync(workShiftId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Audit;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
using ERPCore.Dtos.Users;
|
using ERPCore.Dtos.Users;
|
||||||
@@ -16,12 +18,14 @@ public sealed class UsersController : ApiControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IUserManagementService _users;
|
private readonly IUserManagementService _users;
|
||||||
private readonly IEmployeeUserLinkService _links;
|
private readonly IEmployeeUserLinkService _links;
|
||||||
|
private readonly IAuditService _audit;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
|
|
||||||
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, ICurrentUser currentUser)
|
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, IAuditService audit, ICurrentUser currentUser)
|
||||||
{
|
{
|
||||||
_users = users;
|
_users = users;
|
||||||
_links = links;
|
_links = links;
|
||||||
|
_audit = audit;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,8 +37,8 @@ public sealed class UsersController : ApiControllerBase
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
|
||||||
=> Ok(await _users.ListAsync(query, ct));
|
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
|
||||||
|
|
||||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||||
[HttpGet("user-types")]
|
[HttpGet("user-types")]
|
||||||
@@ -65,4 +69,18 @@ public sealed class UsersController : ApiControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
||||||
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
||||||
|
|
||||||
|
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
|
||||||
|
[HttpPatch("{userId:int}/status")]
|
||||||
|
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
|
||||||
|
|
||||||
|
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
|
||||||
|
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
|
||||||
|
[HttpGet("{userId:int}/login-devices")]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<LoginActivityDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<LoginActivityDto>>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ public sealed class WarehousesController : ApiControllerBase
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List(
|
||||||
=> Ok(await _warehouses.ListAsync(query, ct));
|
[FromQuery] PageQuery query, [FromQuery] int? branchId, CancellationToken ct)
|
||||||
|
=> Ok(await _warehouses.ListAsync(query, branchId, ct));
|
||||||
|
|
||||||
[HttpGet("{warehouseId:int}")]
|
[HttpGet("{warehouseId:int}")]
|
||||||
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ namespace ERPCore.Domain.Entities;
|
|||||||
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
|
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
|
||||||
/// account): not every employee has a login, and not every login belongs to an
|
/// account): not every employee has a login, and not every login belongs to an
|
||||||
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
|
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
|
||||||
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Never hard-deleted —
|
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Normal separation is
|
||||||
/// separation is recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/>.
|
/// recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/> rather than deletion;
|
||||||
|
/// an explicit hard delete is also available (<see cref="Services.Interfaces.IEmployeeService.DeleteAsync"/>)
|
||||||
|
/// and cascades to every dependent HR record, blocked only if payroll history exists.
|
||||||
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
|
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
|
||||||
/// HR departments keep their own legacy numbering scheme, and NumberSequence's
|
/// HR departments keep their own legacy numbering scheme, and NumberSequence's
|
||||||
/// year-scoping is the wrong shape for an identifier that must never look "reset".
|
/// year-scoping is the wrong shape for an identifier that must never look "reset".
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per successful sign-in, captured directly by ERPCore (not AuthHex) so an
|
||||||
|
/// admin can see a user's device/login history — AuthHex's own session listing only
|
||||||
|
/// ever reports the currently-authenticated caller's own sessions, with no admin-scoped
|
||||||
|
/// call to inspect anyone else's. This is a history of past logins, not a live "currently
|
||||||
|
/// active sessions" view (that would need AuthHex to expose one).
|
||||||
|
/// </summary>
|
||||||
|
public class LoginActivity
|
||||||
|
{
|
||||||
|
public int LoginActivityId { get; set; }
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public User? User { get; set; }
|
||||||
|
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
public string? UserAgent { get; set; }
|
||||||
|
/// <summary>Best-effort "Browser on OS" label parsed from <see cref="UserAgent"/> for display.</summary>
|
||||||
|
public string? DeviceLabel { get; set; }
|
||||||
|
|
||||||
|
public DateTime LoginAt { get; set; }
|
||||||
|
}
|
||||||
@@ -5,5 +5,8 @@ public enum AuditAction
|
|||||||
{
|
{
|
||||||
Create,
|
Create,
|
||||||
Update,
|
Update,
|
||||||
Delete
|
Delete,
|
||||||
|
/// <summary>A successful sign-in — written explicitly (not via ErpDbContext's automatic
|
||||||
|
/// entity-change capture, since a login mutates nothing) by AuthController.</summary>
|
||||||
|
Login
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,7 @@ public sealed record AuditLogDto(
|
|||||||
/// <summary>A GL-ready journal stub emitted per stock movement (FR-STK-13).</summary>
|
/// <summary>A GL-ready journal stub emitted per stock movement (FR-STK-13).</summary>
|
||||||
public sealed record JournalEntryStubDto(
|
public sealed record JournalEntryStubDto(
|
||||||
int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
|
int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
|
||||||
|
|
||||||
|
/// <summary>A captured past sign-in — device history, not a live "currently active sessions" view.</summary>
|
||||||
|
public sealed record LoginActivityDto(
|
||||||
|
int LoginActivityId, string? IpAddress, string? DeviceLabel, DateTime LoginAt);
|
||||||
|
|||||||
@@ -53,5 +53,10 @@ public sealed class UpdateUserRoleRequest
|
|||||||
[Required] public int RoleId { get; set; }
|
[Required] public int RoleId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateUserStatusRequest
|
||||||
|
{
|
||||||
|
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
|
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
|
||||||
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
|
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace ERPCore.Infra.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort "Browser on OS" label from a raw User-Agent string, for display in the
|
||||||
|
/// login-device history — not a full parsing library, just enough to be readable
|
||||||
|
/// (docs call this out: exact device model detection is out of scope).
|
||||||
|
/// </summary>
|
||||||
|
public static class UserAgentLabeler
|
||||||
|
{
|
||||||
|
public static string Label(string? userAgent)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(userAgent)) return "Unknown device";
|
||||||
|
|
||||||
|
var os = DetectOs(userAgent);
|
||||||
|
var browser = DetectBrowser(userAgent);
|
||||||
|
return $"{browser} on {os}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DetectOs(string ua)
|
||||||
|
{
|
||||||
|
if (ua.Contains("Windows", StringComparison.OrdinalIgnoreCase)) return "Windows";
|
||||||
|
if (ua.Contains("Mac OS X", StringComparison.OrdinalIgnoreCase) || ua.Contains("Macintosh", StringComparison.OrdinalIgnoreCase)) return "macOS";
|
||||||
|
if (ua.Contains("Android", StringComparison.OrdinalIgnoreCase)) return "Android";
|
||||||
|
if (ua.Contains("iPhone", StringComparison.OrdinalIgnoreCase) || ua.Contains("iPad", StringComparison.OrdinalIgnoreCase)) return "iOS";
|
||||||
|
if (ua.Contains("Linux", StringComparison.OrdinalIgnoreCase)) return "Linux";
|
||||||
|
return "an unknown OS";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DetectBrowser(string ua)
|
||||||
|
{
|
||||||
|
// Order matters: Edge/OPR/Chrome all also contain "Safari"/"Chrome" tokens.
|
||||||
|
if (ua.Contains("Edg/", StringComparison.OrdinalIgnoreCase)) return "Edge";
|
||||||
|
if (ua.Contains("OPR/", StringComparison.OrdinalIgnoreCase) || ua.Contains("Opera", StringComparison.OrdinalIgnoreCase)) return "Opera";
|
||||||
|
if (ua.Contains("Firefox", StringComparison.OrdinalIgnoreCase)) return "Firefox";
|
||||||
|
if (ua.Contains("CriOS", StringComparison.OrdinalIgnoreCase) || ua.Contains("Chrome", StringComparison.OrdinalIgnoreCase)) return "Chrome";
|
||||||
|
if (ua.Contains("Safari", StringComparison.OrdinalIgnoreCase)) return "Safari";
|
||||||
|
return "an unknown browser";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ public static class AuditScribe
|
|||||||
private static readonly HashSet<Type> Excluded =
|
private static readonly HashSet<Type> Excluded =
|
||||||
[
|
[
|
||||||
typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence),
|
typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence),
|
||||||
typeof(StockLedger), typeof(StockLayer),
|
typeof(StockLedger), typeof(StockLayer), typeof(LoginActivity),
|
||||||
];
|
];
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions Json = new()
|
private static readonly JsonSerializerOptions Json = new()
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
public sealed class LoginActivityConfiguration : IEntityTypeConfiguration<LoginActivity>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<LoginActivity> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("login_activities");
|
||||||
|
builder.HasKey(l => l.LoginActivityId);
|
||||||
|
|
||||||
|
builder.Property(l => l.IpAddress).HasMaxLength(64);
|
||||||
|
builder.Property(l => l.UserAgent).HasMaxLength(500);
|
||||||
|
builder.Property(l => l.DeviceLabel).HasMaxLength(200);
|
||||||
|
builder.Property(l => l.LoginAt).IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(l => l.User).WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasIndex(l => new { l.UserId, l.LoginAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
|||||||
builder.HasData(
|
builder.HasData(
|
||||||
new NavItem { NavItemId = 1, Code = "dashboard", Label = "Dashboard", Href = "/dashboard", SortOrder = 1 },
|
new NavItem { NavItemId = 1, Code = "dashboard", Label = "Dashboard", Href = "/dashboard", SortOrder = 1 },
|
||||||
new NavItem { NavItemId = 2, Code = "products", Label = "Products", Href = "/dashboard/products", SortOrder = 2 },
|
new NavItem { NavItemId = 2, Code = "products", Label = "Products", Href = "/dashboard/products", SortOrder = 2 },
|
||||||
new NavItem { NavItemId = 3, Code = "vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 3 },
|
// NavItemId 3 ("vendors") retired 2026-09-09 — Vendors moved under Procurement as a
|
||||||
|
// sub-item (see SubNavItemConfiguration/PermissionConfiguration), matching the sidebar.
|
||||||
new NavItem { NavItemId = 4, Code = "procurement", Label = "Procurement", Href = "/dashboard/procurement", SortOrder = 4 },
|
new NavItem { NavItemId = 4, Code = "procurement", Label = "Procurement", Href = "/dashboard/procurement", SortOrder = 4 },
|
||||||
new NavItem { NavItemId = 5, Code = "receiving", Label = "Receiving", Href = "/dashboard/receiving/grn", SortOrder = 5 },
|
new NavItem { NavItemId = 5, Code = "receiving", Label = "Receiving", Href = "/dashboard/receiving/grn", SortOrder = 5 },
|
||||||
new NavItem { NavItemId = 6, Code = "stock", Label = "Stock", Href = "/dashboard/stock", SortOrder = 6 },
|
new NavItem { NavItemId = 6, Code = "stock", Label = "Stock", Href = "/dashboard/stock", SortOrder = 6 },
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
|||||||
builder.HasData(
|
builder.HasData(
|
||||||
new Permission { PermissionId = 1, Code = "NAV:dashboard", NavItemId = 1 },
|
new Permission { PermissionId = 1, Code = "NAV:dashboard", NavItemId = 1 },
|
||||||
new Permission { PermissionId = 2, Code = "NAV:products", NavItemId = 2 },
|
new Permission { PermissionId = 2, Code = "NAV:products", NavItemId = 2 },
|
||||||
new Permission { PermissionId = 3, Code = "NAV:vendors", NavItemId = 3 },
|
// Moved under Procurement as a sub-item (2026-09-09) — same PermissionId (3), just
|
||||||
|
// repointed to the new SubNavItem, so any role already granted this permission keeps it.
|
||||||
|
new Permission { PermissionId = 3, Code = "NAV:procurement.vendors", SubNavItemId = 30 },
|
||||||
new Permission { PermissionId = 4, Code = "NAV:procurement", NavItemId = 4 },
|
new Permission { PermissionId = 4, Code = "NAV:procurement", NavItemId = 4 },
|
||||||
new Permission { PermissionId = 5, Code = "NAV:receiving", NavItemId = 5 },
|
new Permission { PermissionId = 5, Code = "NAV:receiving", NavItemId = 5 },
|
||||||
new Permission { PermissionId = 6, Code = "NAV:stock", NavItemId = 6 },
|
new Permission { PermissionId = 6, Code = "NAV:stock", NavItemId = 6 },
|
||||||
@@ -67,7 +69,21 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
|||||||
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
|
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
|
||||||
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
|
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
|
||||||
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 },
|
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 },
|
||||||
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 }
|
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 },
|
||||||
|
new Permission { PermissionId = 41, Code = "NAV:stock.ledger", SubNavItemId = 31 },
|
||||||
|
new Permission { PermissionId = 42, Code = "NAV:stock.enquiry", SubNavItemId = 32 },
|
||||||
|
new Permission { PermissionId = 43, Code = "NAV:stock.valuation", SubNavItemId = 33 },
|
||||||
|
new Permission { PermissionId = 44, Code = "NAV:stock.transfers", SubNavItemId = 34 },
|
||||||
|
new Permission { PermissionId = 45, Code = "NAV:stock.adjustments", SubNavItemId = 35 },
|
||||||
|
new Permission { PermissionId = 46, Code = "NAV:stock.counts", SubNavItemId = 36 },
|
||||||
|
new Permission { PermissionId = 47, Code = "NAV:stock.reorder-alerts", SubNavItemId = 37 },
|
||||||
|
new Permission { PermissionId = 48, Code = "NAV:stock.wastage", SubNavItemId = 38 },
|
||||||
|
// Sales (NavItemId 13) children — SubNavItemId 23 had no permission row before now
|
||||||
|
// (mirrors SubNavItemConfiguration's rename of that row to sales.bundle-templates).
|
||||||
|
new Permission { PermissionId = 49, Code = "NAV:sales.bundle-templates", SubNavItemId = 23 },
|
||||||
|
new Permission { PermissionId = 50, Code = "NAV:sales.invoices", SubNavItemId = 40 },
|
||||||
|
new Permission { PermissionId = 51, Code = "NAV:sales.free-issues", SubNavItemId = 41 },
|
||||||
|
new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,19 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
|||||||
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
||||||
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
||||||
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
|
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
|
||||||
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
|
// Sales (NavItemId 13) children — mirror the sidebar order (AppSidebar.tsx). SubNavItemId
|
||||||
|
// 23 is renamed in place (was "sales.bundle-sales" / Bundle Sales) rather than
|
||||||
|
// delete+recreate, so any role already granted this permission keeps it; the rest are new.
|
||||||
|
new SubNavItem { SubNavItemId = 40, NavItemId = 13, Code = "sales.invoices", Label = "Sales", Href = "/dashboard/sales/invoices", SortOrder = 1 },
|
||||||
|
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-templates", Label = "Bundle Templates", Href = "/dashboard/sales/bundles/templates", SortOrder = 2 },
|
||||||
|
new SubNavItem { SubNavItemId = 41, NavItemId = 13, Code = "sales.free-issues", Label = "Free Issues", Href = "/dashboard/sales/free-issues", SortOrder = 3 },
|
||||||
|
new SubNavItem { SubNavItemId = 42, NavItemId = 13, Code = "sales.day-end", Label = "Day End", Href = "/dashboard/sales/day-end", SortOrder = 4 },
|
||||||
// Procurement (NavItemId 4) children — mirror the hub page order.
|
// Procurement (NavItemId 4) children — mirror the hub page order.
|
||||||
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
|
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
|
||||||
// these procurement rows were never actually migrated into the database before now.
|
// these procurement rows were never actually migrated into the database before now.
|
||||||
|
// Vendors (SubNavItemId 30) reuses PermissionId 3, which used to point at the retired
|
||||||
|
// top-level "vendors" NavItem — so any role already granted this permission keeps it.
|
||||||
|
new SubNavItem { SubNavItemId = 30, NavItemId = 4, Code = "procurement.vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 0 },
|
||||||
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
||||||
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||||
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||||
@@ -64,7 +73,18 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
|||||||
// FixProductionTemplatesSubNavItemIdConflict for the data repair.
|
// FixProductionTemplatesSubNavItemIdConflict for the data repair.
|
||||||
new SubNavItem { SubNavItemId = 27, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
|
new SubNavItem { SubNavItemId = 27, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
|
||||||
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
|
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
|
||||||
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
|
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 },
|
||||||
|
// Stock (NavItemId 6) children — mirror the sidebar order (AppSidebar.tsx). These rows
|
||||||
|
// never existed in the database before now, so "stock" is still bypassed frontend-side
|
||||||
|
// in AppSidebar.tsx until roles are actually granted these permissions.
|
||||||
|
new SubNavItem { SubNavItemId = 31, NavItemId = 6, Code = "stock.ledger", Label = "Stock Ledger", Href = "/dashboard/stock/ledger", SortOrder = 1 },
|
||||||
|
new SubNavItem { SubNavItemId = 32, NavItemId = 6, Code = "stock.enquiry", Label = "Stock Enquiry", Href = "/dashboard/stock/enquiry", SortOrder = 2 },
|
||||||
|
new SubNavItem { SubNavItemId = 33, NavItemId = 6, Code = "stock.valuation", Label = "Valuation", Href = "/dashboard/stock/valuation", SortOrder = 3 },
|
||||||
|
new SubNavItem { SubNavItemId = 34, NavItemId = 6, Code = "stock.transfers", Label = "Transfers", Href = "/dashboard/stock/transfers", SortOrder = 4 },
|
||||||
|
new SubNavItem { SubNavItemId = 35, NavItemId = 6, Code = "stock.adjustments", Label = "Adjustments", Href = "/dashboard/stock/adjustments", SortOrder = 5 },
|
||||||
|
new SubNavItem { SubNavItemId = 36, NavItemId = 6, Code = "stock.counts", Label = "Counts", Href = "/dashboard/stock/counts", SortOrder = 6 },
|
||||||
|
new SubNavItem { SubNavItemId = 37, NavItemId = 6, Code = "stock.reorder-alerts", Label = "Reorder Alerts", Href = "/dashboard/stock/reorder-alerts", SortOrder = 7 },
|
||||||
|
new SubNavItem { SubNavItemId = 38, NavItemId = 6, Code = "stock.wastage", Label = "Wastage", Href = "/dashboard/stock/wastage", SortOrder = 8 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ public class ErpDbContext : DbContext
|
|||||||
// --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) ---
|
// --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) ---
|
||||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||||
public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>();
|
public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>();
|
||||||
|
public DbSet<LoginActivity> LoginActivities => Set<LoginActivity>();
|
||||||
|
|
||||||
// --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) ---
|
// --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) ---
|
||||||
public DbSet<Branch> Branches => Set<Branch>();
|
public DbSet<Branch> Branches => Set<Branch>();
|
||||||
|
|||||||
+8005
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MoveVendorsUnderProcurement : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Order matters: Permission.NavItemId cascades on delete, so the permission row must be
|
||||||
|
// repointed off NavItemId 3 (via the new sub_nav_item) before that nav_item is deleted —
|
||||||
|
// otherwise the delete cascades and removes the permission row first.
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||||
|
values: new object[] { 30, "procurement.vendors", "/dashboard/vendors", null, "Vendors", 4, 0 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 3,
|
||||||
|
columns: new[] { "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[] { "NAV:procurement.vendors", null, 30 });
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "nav_items",
|
||||||
|
keyColumn: "NavItemId",
|
||||||
|
keyValue: 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 30);
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "nav_items",
|
||||||
|
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder", "Status" },
|
||||||
|
values: new object[] { 3, "vendors", "/dashboard/vendors", null, "Vendors", 3, "Active" });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 3,
|
||||||
|
columns: new[] { "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[] { "NAV:vendors", 3, null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddStockSubNavItems : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 31, "stock.ledger", "/dashboard/stock/ledger", null, "Stock Ledger", 6, 1 },
|
||||||
|
{ 32, "stock.enquiry", "/dashboard/stock/enquiry", null, "Stock Enquiry", 6, 2 },
|
||||||
|
{ 33, "stock.valuation", "/dashboard/stock/valuation", null, "Valuation", 6, 3 },
|
||||||
|
{ 34, "stock.transfers", "/dashboard/stock/transfers", null, "Transfers", 6, 4 },
|
||||||
|
{ 35, "stock.adjustments", "/dashboard/stock/adjustments", null, "Adjustments", 6, 5 },
|
||||||
|
{ 36, "stock.counts", "/dashboard/stock/counts", null, "Counts", 6, 6 },
|
||||||
|
{ 37, "stock.reorder-alerts", "/dashboard/stock/reorder-alerts", null, "Reorder Alerts", 6, 7 },
|
||||||
|
{ 38, "stock.wastage", "/dashboard/stock/wastage", null, "Wastage", 6, 8 }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "permissions",
|
||||||
|
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 41, "NAV:stock.ledger", null, 31 },
|
||||||
|
{ 42, "NAV:stock.enquiry", null, 32 },
|
||||||
|
{ 43, "NAV:stock.valuation", null, 33 },
|
||||||
|
{ 44, "NAV:stock.transfers", null, 34 },
|
||||||
|
{ 45, "NAV:stock.adjustments", null, 35 },
|
||||||
|
{ 46, "NAV:stock.counts", null, 36 },
|
||||||
|
{ 47, "NAV:stock.reorder-alerts", null, 37 },
|
||||||
|
{ 48, "NAV:stock.wastage", null, 38 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 41);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 42);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 43);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 44);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 45);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 46);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 47);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 48);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 31);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 32);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 33);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 34);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 35);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 36);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 37);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 38);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSalesSubNavItems : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "permissions",
|
||||||
|
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[] { 49, "NAV:sales.bundle-templates", null, 23 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 23,
|
||||||
|
columns: new[] { "Code", "Href", "Label", "SortOrder" },
|
||||||
|
values: new object[] { "sales.bundle-templates", "/dashboard/sales/bundles/templates", "Bundle Templates", 2 });
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 40, "sales.invoices", "/dashboard/sales/invoices", null, "Sales", 13, 1 },
|
||||||
|
{ 41, "sales.free-issues", "/dashboard/sales/free-issues", null, "Free Issues", 13, 3 },
|
||||||
|
{ 42, "sales.day-end", "/dashboard/sales/day-end", null, "Day End", 13, 4 }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "permissions",
|
||||||
|
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 50, "NAV:sales.invoices", null, 40 },
|
||||||
|
{ 51, "NAV:sales.free-issues", null, 41 },
|
||||||
|
{ 52, "NAV:sales.day-end", null, 42 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 49);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 50);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 51);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "permissions",
|
||||||
|
keyColumn: "PermissionId",
|
||||||
|
keyValue: 52);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 40);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 41);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 42);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
keyColumn: "SubNavItemId",
|
||||||
|
keyValue: 23,
|
||||||
|
columns: new[] { "Code", "Href", "Label", "SortOrder" },
|
||||||
|
values: new object[] { "sales.bundle-sales", "/dashboard/sales/bundles", "Bundle Sales", 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLoginActivity : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "login_activities",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
LoginActivityId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
UserId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
IpAddress = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
UserAgent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
DeviceLabel = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
LoginAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_login_activities", x => x.LoginActivityId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_login_activities_users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_login_activities_UserId_LoginAt",
|
||||||
|
table: "login_activities",
|
||||||
|
columns: new[] { "UserId", "LoginAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "login_activities");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2171,6 +2171,39 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("hr_loan_installments", (string)null);
|
b.ToTable("hr_loan_installments", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("LoginActivityId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("LoginActivityId"));
|
||||||
|
|
||||||
|
b.Property<string>("DeviceLabel")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("LoginActivityId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "LoginAt");
|
||||||
|
|
||||||
|
b.ToTable("login_activities", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("NavItemId")
|
b.Property<int>("NavItemId")
|
||||||
@@ -2234,15 +2267,6 @@ namespace ERPCore.Migrations
|
|||||||
Status = "Active"
|
Status = "Active"
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
|
||||||
NavItemId = 3,
|
|
||||||
Code = "vendors",
|
|
||||||
Href = "/dashboard/vendors",
|
|
||||||
Label = "Vendors",
|
|
||||||
SortOrder = 3,
|
|
||||||
Status = "Active"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
{
|
||||||
NavItemId = 4,
|
NavItemId = 4,
|
||||||
Code = "procurement",
|
Code = "procurement",
|
||||||
@@ -2766,8 +2790,8 @@ namespace ERPCore.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
PermissionId = 3,
|
PermissionId = 3,
|
||||||
Code = "NAV:vendors",
|
Code = "NAV:procurement.vendors",
|
||||||
NavItemId = 3
|
SubNavItemId = 30
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
@@ -2990,6 +3014,78 @@ namespace ERPCore.Migrations
|
|||||||
PermissionId = 40,
|
PermissionId = 40,
|
||||||
Code = "NAV:settings.branches",
|
Code = "NAV:settings.branches",
|
||||||
SubNavItemId = 29
|
SubNavItemId = 29
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 41,
|
||||||
|
Code = "NAV:stock.ledger",
|
||||||
|
SubNavItemId = 31
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 42,
|
||||||
|
Code = "NAV:stock.enquiry",
|
||||||
|
SubNavItemId = 32
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 43,
|
||||||
|
Code = "NAV:stock.valuation",
|
||||||
|
SubNavItemId = 33
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 44,
|
||||||
|
Code = "NAV:stock.transfers",
|
||||||
|
SubNavItemId = 34
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 45,
|
||||||
|
Code = "NAV:stock.adjustments",
|
||||||
|
SubNavItemId = 35
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 46,
|
||||||
|
Code = "NAV:stock.counts",
|
||||||
|
SubNavItemId = 36
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 47,
|
||||||
|
Code = "NAV:stock.reorder-alerts",
|
||||||
|
SubNavItemId = 37
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 48,
|
||||||
|
Code = "NAV:stock.wastage",
|
||||||
|
SubNavItemId = 38
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 49,
|
||||||
|
Code = "NAV:sales.bundle-templates",
|
||||||
|
SubNavItemId = 23
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 50,
|
||||||
|
Code = "NAV:sales.invoices",
|
||||||
|
SubNavItemId = 40
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 51,
|
||||||
|
Code = "NAV:sales.free-issues",
|
||||||
|
SubNavItemId = 41
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 52,
|
||||||
|
Code = "NAV:sales.day-end",
|
||||||
|
SubNavItemId = 42
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5502,15 +5598,55 @@ namespace ERPCore.Migrations
|
|||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
SubNavItemId = 23,
|
SubNavItemId = 40,
|
||||||
Code = "sales.bundle-sales",
|
Code = "sales.invoices",
|
||||||
Href = "/dashboard/sales/bundles",
|
Href = "/dashboard/sales/invoices",
|
||||||
Label = "Bundle Sales",
|
Label = "Sales",
|
||||||
NavItemId = 13,
|
NavItemId = 13,
|
||||||
SortOrder = 1,
|
SortOrder = 1,
|
||||||
Status = "Active"
|
Status = "Active"
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 23,
|
||||||
|
Code = "sales.bundle-templates",
|
||||||
|
Href = "/dashboard/sales/bundles/templates",
|
||||||
|
Label = "Bundle Templates",
|
||||||
|
NavItemId = 13,
|
||||||
|
SortOrder = 2,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 41,
|
||||||
|
Code = "sales.free-issues",
|
||||||
|
Href = "/dashboard/sales/free-issues",
|
||||||
|
Label = "Free Issues",
|
||||||
|
NavItemId = 13,
|
||||||
|
SortOrder = 3,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 42,
|
||||||
|
Code = "sales.day-end",
|
||||||
|
Href = "/dashboard/sales/day-end",
|
||||||
|
Label = "Day End",
|
||||||
|
NavItemId = 13,
|
||||||
|
SortOrder = 4,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 30,
|
||||||
|
Code = "procurement.vendors",
|
||||||
|
Href = "/dashboard/vendors",
|
||||||
|
Label = "Vendors",
|
||||||
|
NavItemId = 4,
|
||||||
|
SortOrder = 0,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
{
|
{
|
||||||
SubNavItemId = 17,
|
SubNavItemId = 17,
|
||||||
Code = "procurement.requisitions",
|
Code = "procurement.requisitions",
|
||||||
@@ -5689,6 +5825,86 @@ namespace ERPCore.Migrations
|
|||||||
NavItemId = 14,
|
NavItemId = 14,
|
||||||
SortOrder = 3,
|
SortOrder = 3,
|
||||||
Status = "Active"
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 31,
|
||||||
|
Code = "stock.ledger",
|
||||||
|
Href = "/dashboard/stock/ledger",
|
||||||
|
Label = "Stock Ledger",
|
||||||
|
NavItemId = 6,
|
||||||
|
SortOrder = 1,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 32,
|
||||||
|
Code = "stock.enquiry",
|
||||||
|
Href = "/dashboard/stock/enquiry",
|
||||||
|
Label = "Stock Enquiry",
|
||||||
|
NavItemId = 6,
|
||||||
|
SortOrder = 2,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 33,
|
||||||
|
Code = "stock.valuation",
|
||||||
|
Href = "/dashboard/stock/valuation",
|
||||||
|
Label = "Valuation",
|
||||||
|
NavItemId = 6,
|
||||||
|
SortOrder = 3,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 34,
|
||||||
|
Code = "stock.transfers",
|
||||||
|
Href = "/dashboard/stock/transfers",
|
||||||
|
Label = "Transfers",
|
||||||
|
NavItemId = 6,
|
||||||
|
SortOrder = 4,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 35,
|
||||||
|
Code = "stock.adjustments",
|
||||||
|
Href = "/dashboard/stock/adjustments",
|
||||||
|
Label = "Adjustments",
|
||||||
|
NavItemId = 6,
|
||||||
|
SortOrder = 5,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 36,
|
||||||
|
Code = "stock.counts",
|
||||||
|
Href = "/dashboard/stock/counts",
|
||||||
|
Label = "Counts",
|
||||||
|
NavItemId = 6,
|
||||||
|
SortOrder = 6,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 37,
|
||||||
|
Code = "stock.reorder-alerts",
|
||||||
|
Href = "/dashboard/stock/reorder-alerts",
|
||||||
|
Label = "Reorder Alerts",
|
||||||
|
NavItemId = 6,
|
||||||
|
SortOrder = 7,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 38,
|
||||||
|
Code = "stock.wastage",
|
||||||
|
Href = "/dashboard/stock/wastage",
|
||||||
|
Label = "Wastage",
|
||||||
|
NavItemId = 6,
|
||||||
|
SortOrder = 8,
|
||||||
|
Status = "Active"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -6618,6 +6834,17 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("PayrollRun");
|
b.Navigation("PayrollRun");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Audit;
|
using ERPCore.Dtos.Audit;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -12,11 +15,19 @@ public sealed class AuditService : IAuditService
|
|||||||
{
|
{
|
||||||
private readonly IRepository<AuditLog> _logs;
|
private readonly IRepository<AuditLog> _logs;
|
||||||
private readonly IRepository<JournalEntryStub> _journal;
|
private readonly IRepository<JournalEntryStub> _journal;
|
||||||
|
private readonly IRepository<User> _users;
|
||||||
|
private readonly IRepository<LoginActivity> _loginActivities;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public AuditService(IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal)
|
public AuditService(
|
||||||
|
IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal, IRepository<User> users,
|
||||||
|
IRepository<LoginActivity> loginActivities, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_logs = logs;
|
_logs = logs;
|
||||||
_journal = journal;
|
_journal = journal;
|
||||||
|
_users = users;
|
||||||
|
_loginActivities = loginActivities;
|
||||||
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<AuditLogDto>> ListLogsAsync(
|
public async Task<PagedResponse<AuditLogDto>> ListLogsAsync(
|
||||||
@@ -55,4 +66,48 @@ public sealed class AuditService : IAuditService
|
|||||||
|
|
||||||
return PagedResponse<JournalEntryStubDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<JournalEntryStubDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task RecordLoginAsync(Guid authUserId, string? ipAddress, string? userAgent, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var userId = await _users.Query().AsNoTracking()
|
||||||
|
.Where(u => u.AuthUserId == authUserId)
|
||||||
|
.Select(u => (int?)u.UserId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
// JIT provisioning may not have created the local shadow row yet on a brand-new
|
||||||
|
// account's very first login — nothing to attach the log to in that case.
|
||||||
|
if (userId is null) return;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
await _logs.AddAsync(new AuditLog
|
||||||
|
{
|
||||||
|
UserId = userId.Value,
|
||||||
|
EntityType = "User",
|
||||||
|
EntityId = userId.Value,
|
||||||
|
Action = AuditAction.Login,
|
||||||
|
ChangeSet = "{}",
|
||||||
|
CreatedAt = now
|
||||||
|
}, ct);
|
||||||
|
await _loginActivities.AddAsync(new LoginActivity
|
||||||
|
{
|
||||||
|
UserId = userId.Value,
|
||||||
|
IpAddress = ipAddress,
|
||||||
|
UserAgent = userAgent,
|
||||||
|
DeviceLabel = UserAgentLabeler.Label(userAgent),
|
||||||
|
LoginAt = now
|
||||||
|
}, ct);
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<LoginActivityDto>> ListLoginDevicesAsync(int userId, PageQuery query, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _loginActivities.Query().AsNoTracking().Where(l => l.UserId == userId);
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var rows = await q.OrderByDescending(l => l.LoginActivityId)
|
||||||
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
|
.Select(l => new LoginActivityDto(l.LoginActivityId, l.IpAddress, l.DeviceLabel, l.LoginAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return PagedResponse<LoginActivityDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,5 +123,21 @@ public sealed class BranchService : IBranchService
|
|||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int branchId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var branch = await _branches.GetByIdAsync(branchId, ct)
|
||||||
|
?? throw new NotFoundException($"Branch {branchId} was not found.");
|
||||||
|
|
||||||
|
_branches.Remove(branch);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.MasterInUse, "This branch is referenced by other records and cannot be deleted.", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt);
|
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,22 @@ public sealed class DepartmentService : IDepartmentService
|
|||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int departmentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var dept = await _departments.GetByIdAsync(departmentId, ct)
|
||||||
|
?? throw new NotFoundException($"Department {departmentId} was not found.");
|
||||||
|
|
||||||
|
_departments.Remove(dept);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.MasterInUse, "This department is referenced by other records (sub-departments or employees) and cannot be deleted.", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Walks up from <paramref name="newParentId"/>; throws if it ever reaches <paramref name="departmentId"/>.</summary>
|
/// <summary>Walks up from <paramref name="newParentId"/>; throws if it ever reaches <paramref name="departmentId"/>.</summary>
|
||||||
private async Task EnsureNoCycleAsync(int departmentId, int newParentId, CancellationToken ct)
|
private async Task EnsureNoCycleAsync(int departmentId, int newParentId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -101,5 +101,21 @@ public sealed class DesignationService : IDesignationService
|
|||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int designationId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var designation = await _designations.GetByIdAsync(designationId, ct)
|
||||||
|
?? throw new NotFoundException($"Designation {designationId} was not found.");
|
||||||
|
|
||||||
|
_designations.Remove(designation);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.MasterInUse, "This designation is referenced by other records and cannot be deleted.", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static DesignationDto Map(Designation d) => new(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt);
|
private static DesignationDto Map(Designation d) => new(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using ERPCore.Domain.Entities;
|
|||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Storage;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -21,17 +22,31 @@ public sealed class EmployeeService : IEmployeeService
|
|||||||
private readonly IRepository<Employee> _employees;
|
private readonly IRepository<Employee> _employees;
|
||||||
private readonly IRepository<User> _users;
|
private readonly IRepository<User> _users;
|
||||||
private readonly IRepository<EmployeeBankDetail> _bankDetails;
|
private readonly IRepository<EmployeeBankDetail> _bankDetails;
|
||||||
|
private readonly IRepository<EmployeeDocument> _documents;
|
||||||
|
private readonly IRepository<EmployeeSalaryStructure> _salaryStructures;
|
||||||
|
private readonly IRepository<EmployeeLoan> _loans;
|
||||||
|
private readonly IRepository<LeaveBalance> _leaveBalances;
|
||||||
|
private readonly IRepository<PayrollLine> _payrollLines;
|
||||||
|
private readonly IFileStorageService _fileStorage;
|
||||||
private readonly IBranchConfigService _branchConfig;
|
private readonly IBranchConfigService _branchConfig;
|
||||||
private readonly IBranchAccessService _branchAccess;
|
private readonly IBranchAccessService _branchAccess;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public EmployeeService(
|
public EmployeeService(
|
||||||
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails,
|
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails,
|
||||||
IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
|
IRepository<EmployeeDocument> documents, IRepository<EmployeeSalaryStructure> salaryStructures,
|
||||||
|
IRepository<EmployeeLoan> loans, IRepository<LeaveBalance> leaveBalances, IRepository<PayrollLine> payrollLines,
|
||||||
|
IFileStorageService fileStorage, IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_employees = employees;
|
_employees = employees;
|
||||||
_users = users;
|
_users = users;
|
||||||
_bankDetails = bankDetails;
|
_bankDetails = bankDetails;
|
||||||
|
_documents = documents;
|
||||||
|
_salaryStructures = salaryStructures;
|
||||||
|
_loans = loans;
|
||||||
|
_leaveBalances = leaveBalances;
|
||||||
|
_payrollLines = payrollLines;
|
||||||
|
_fileStorage = fileStorage;
|
||||||
_branchConfig = branchConfig;
|
_branchConfig = branchConfig;
|
||||||
_branchAccess = branchAccess;
|
_branchAccess = branchAccess;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
@@ -197,6 +212,48 @@ public sealed class EmployeeService : IEmployeeService
|
|||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int employeeId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var employee = await _employees.GetByIdAsync(employeeId, ct)
|
||||||
|
?? throw new NotFoundException($"Employee {employeeId} was not found.");
|
||||||
|
|
||||||
|
if (await _payrollLines.Query().AnyAsync(l => l.EmployeeId == employeeId, ct))
|
||||||
|
throw new DomainException(ErrorCodes.EmployeeHasPayrollHistory, "This employee has payroll history and cannot be deleted.", 409);
|
||||||
|
|
||||||
|
// A linked system-user account survives the employee row (Employee.UserId → User is
|
||||||
|
// Restrict, so cascading it away isn't even an option) — deactivate it (same as the
|
||||||
|
// Active/Deactivate toggle on Settings > Users) rather than leaving a login standing
|
||||||
|
// with no staff record behind it. The User row itself is never removed.
|
||||||
|
if (employee.UserId is not null)
|
||||||
|
{
|
||||||
|
var linkedUser = await _users.GetByIdAsync(employee.UserId.Value, ct);
|
||||||
|
if (linkedUser is not null) linkedUser.Status = EntityStatus.Inactive;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bank details and documents cascade at the DB level, but documents also have a
|
||||||
|
// physical file on disk that cascading the row would silently orphan — delete those
|
||||||
|
// explicitly first. Salary structures, loans, and leave balances are Restrict FKs, so
|
||||||
|
// they must be removed before the employee row itself will delete.
|
||||||
|
var documents = await _documents.Query().Where(d => d.EmployeeId == employeeId).ToListAsync(ct);
|
||||||
|
foreach (var doc in documents)
|
||||||
|
{
|
||||||
|
await _fileStorage.DeleteAsync(doc.RelativePath, ct);
|
||||||
|
_documents.Remove(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
var salaryStructures = await _salaryStructures.Query().Where(s => s.EmployeeId == employeeId).ToListAsync(ct);
|
||||||
|
foreach (var structure in salaryStructures) _salaryStructures.Remove(structure);
|
||||||
|
|
||||||
|
var loans = await _loans.Query().Where(l => l.EmployeeId == employeeId).ToListAsync(ct);
|
||||||
|
foreach (var loan in loans) _loans.Remove(loan);
|
||||||
|
|
||||||
|
var leaveBalances = await _leaveBalances.Query().Where(b => b.EmployeeId == employeeId).ToListAsync(ct);
|
||||||
|
foreach (var balance in leaveBalances) _leaveBalances.Remove(balance);
|
||||||
|
|
||||||
|
_employees.Remove(employee);
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default)
|
public async Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return await _bankDetails.Query().AsNoTracking()
|
return await _bankDetails.Query().AsNoTracking()
|
||||||
|
|||||||
@@ -101,5 +101,21 @@ public sealed class EmploymentTypeService : IEmploymentTypeService
|
|||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int employmentTypeId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var et = await _employmentTypes.GetByIdAsync(employmentTypeId, ct)
|
||||||
|
?? throw new NotFoundException($"Employment type {employmentTypeId} was not found.");
|
||||||
|
|
||||||
|
_employmentTypes.Remove(et);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.MasterInUse, "This employment type is referenced by other records and cannot be deleted.", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static EmploymentTypeDto Map(EmploymentType e) => new(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt);
|
private static EmploymentTypeDto Map(EmploymentType e) => new(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,22 @@ public sealed class HrDocumentTypeService : IHrDocumentTypeService
|
|||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int hrDocumentTypeId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var type = await _types.GetByIdAsync(hrDocumentTypeId, ct)
|
||||||
|
?? throw new NotFoundException($"Document type {hrDocumentTypeId} was not found.");
|
||||||
|
|
||||||
|
_types.Remove(type);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.MasterInUse, "This document type is referenced by other records and cannot be deleted.", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static HrDocumentTypeDto Map(HrDocumentType t) => new(
|
private static HrDocumentTypeDto Map(HrDocumentType t) => new(
|
||||||
t.HrDocumentTypeId, t.Code, t.Name, t.Category, t.RequiredAtOnboarding, t.ExpiryTracked, t.Status, t.CreatedAt, t.UpdatedAt);
|
t.HrDocumentTypeId, t.Code, t.Name, t.Category, t.RequiredAtOnboarding, t.ExpiryTracked, t.Status, t.CreatedAt, t.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ public sealed class LeaveRequestService : ILeaveRequestService
|
|||||||
var q = _requests.Query().AsNoTracking().Include(r => r.Employee).Include(r => r.LeaveType).AsQueryable();
|
var q = _requests.Query().AsNoTracking().Include(r => r.Employee).Include(r => r.LeaveType).AsQueryable();
|
||||||
if (employeeId is not null) q = q.Where(r => r.EmployeeId == employeeId);
|
if (employeeId is not null) q = q.Where(r => r.EmployeeId == employeeId);
|
||||||
if (status is not null) q = q.Where(r => r.Status == status);
|
if (status is not null) q = q.Where(r => r.Status == status);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
|
{
|
||||||
|
var term = query.Q.Trim();
|
||||||
|
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%") || EF.Functions.ILike(r.Employee!.FullName, $"%{term}%"));
|
||||||
|
}
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderByDescending(r => r.CreatedAt)
|
var rows = await q.OrderByDescending(r => r.CreatedAt)
|
||||||
|
|||||||
@@ -113,6 +113,22 @@ public sealed class LeaveTypeService : ILeaveTypeService
|
|||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int leaveTypeId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var type = await _types.GetByIdAsync(leaveTypeId, ct)
|
||||||
|
?? throw new NotFoundException($"Leave type {leaveTypeId} was not found.");
|
||||||
|
|
||||||
|
_types.Remove(type);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.MasterInUse, "This leave type is referenced by other records and cannot be deleted.", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static LeaveTypeDto Map(LeaveType t) => new(
|
private static LeaveTypeDto Map(LeaveType t) => new(
|
||||||
t.LeaveTypeId, t.Code, t.Name, t.IsPaid, t.CountsAsNoPay, t.AccrualPerYear,
|
t.LeaveTypeId, t.Code, t.Name, t.IsPaid, t.CountsAsNoPay, t.AccrualPerYear,
|
||||||
t.CarryForwardAllowed, t.MaxCarryForwardDays, t.RequiresApproval, t.Status, t.CreatedAt, t.UpdatedAt);
|
t.CarryForwardAllowed, t.MaxCarryForwardDays, t.RequiresApproval, t.Status, t.CreatedAt, t.UpdatedAt);
|
||||||
|
|||||||
@@ -107,6 +107,22 @@ public sealed class SalaryComponentService : ISalaryComponentService
|
|||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int salaryComponentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var component = await _components.GetByIdAsync(salaryComponentId, ct)
|
||||||
|
?? throw new NotFoundException($"Salary component {salaryComponentId} was not found.");
|
||||||
|
|
||||||
|
_components.Remove(component);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.MasterInUse, "This salary component is referenced by other records and cannot be deleted.", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static SalaryComponentDto Map(SalaryComponent c) => new(
|
private static SalaryComponentDto Map(SalaryComponent c) => new(
|
||||||
c.SalaryComponentId, c.Code, c.Name, c.ComponentType, c.IsTaxable, c.IsEpfEtfApplicable, c.Status, c.CreatedAt, c.UpdatedAt);
|
c.SalaryComponentId, c.Code, c.Name, c.ComponentType, c.IsTaxable, c.IsEpfEtfApplicable, c.Status, c.CreatedAt, c.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,22 @@ public sealed class WorkShiftService : IWorkShiftService
|
|||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int workShiftId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var shift = await _shifts.GetByIdAsync(workShiftId, ct)
|
||||||
|
?? throw new NotFoundException($"Work shift {workShiftId} was not found.");
|
||||||
|
|
||||||
|
_shifts.Remove(shift);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.MasterInUse, "This work shift is referenced by other records and cannot be deleted.", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkShiftDto Map(WorkShift w) => new(
|
private static WorkShiftDto Map(WorkShift w) => new(
|
||||||
w.WorkShiftId, w.Code, w.Name, w.StartTime, w.EndTime, w.IsOvernight,
|
w.WorkShiftId, w.Code, w.Name, w.StartTime, w.EndTime, w.IsOvernight,
|
||||||
w.GraceMinutes, w.BreakMinutes, w.StandardWorkingMinutes, w.OtMultiplier, w.WorkingDaysMask,
|
w.GraceMinutes, w.BreakMinutes, w.StandardWorkingMinutes, w.OtMultiplier, w.WorkingDaysMask,
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read access to the audit trail and GL-ready journal stubs (auditor role,
|
/// Read access to the audit trail and GL-ready journal stubs (auditor role,
|
||||||
/// 02-SECURITY B.2.3). Both are append-only; no write API.
|
/// 02-SECURITY B.2.3). Both are append-only — entity mutations are captured
|
||||||
|
/// automatically by ErpDbContext.SaveChangesAsync and never written here directly.
|
||||||
|
/// The one deliberate exception is <see cref="RecordLoginAsync"/>: a successful
|
||||||
|
/// sign-in changes no entity, so nothing would otherwise appear in the trail for it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAuditService
|
public interface IAuditService
|
||||||
{
|
{
|
||||||
@@ -14,4 +17,12 @@ public interface IAuditService
|
|||||||
|
|
||||||
Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
|
Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
|
||||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
|
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Writes a Login audit row and a login-device history entry for the local User backing this
|
||||||
|
/// AuthHex identity, if one exists yet.</summary>
|
||||||
|
Task RecordLoginAsync(Guid authUserId, string? ipAddress, string? userAgent, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Past logins for a user — a history ERPCore captures itself, not a live "currently active
|
||||||
|
/// sessions" view (AuthHex has no admin-scoped call to inspect another user's sessions).</summary>
|
||||||
|
Task<PagedResponse<LoginActivityDto>> ListLoginDevicesAsync(int userId, PageQuery query, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ public interface IBranchService
|
|||||||
Task<ETagged<BranchDto>> CreateAsync(CreateBranchRequest request, CancellationToken ct = default);
|
Task<ETagged<BranchDto>> CreateAsync(CreateBranchRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default);
|
Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int branchId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ public interface IDepartmentService
|
|||||||
Task<ETagged<DepartmentDto>> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default);
|
Task<ETagged<DepartmentDto>> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<DepartmentDto>> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<DepartmentDto>> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default);
|
Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int departmentId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ public interface IDesignationService
|
|||||||
Task<ETagged<DesignationDto>> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default);
|
Task<ETagged<DesignationDto>> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<DesignationDto>> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<DesignationDto>> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default);
|
Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int designationId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ public interface IEmployeeService
|
|||||||
Task<ETagged<EmployeeDetailDto>> UpdateAsync(int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default);
|
Task<ETagged<EmployeeDetailDto>> UpdateAsync(int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default);
|
||||||
Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default);
|
Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Permanently deletes the employee and every dependent record (bank details, documents
|
||||||
|
/// incl. their stored files, salary structure history, loans, leave balances). Blocked with
|
||||||
|
/// 409 if the employee has any payroll history, since that's financial/audit record that
|
||||||
|
/// must never be destroyed.
|
||||||
|
/// </summary>
|
||||||
|
Task DeleteAsync(int employeeId, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default);
|
Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default);
|
||||||
Task<List<EmployeeBankDetailDto>> ReplaceBankDetailsAsync(int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default);
|
Task<List<EmployeeBankDetailDto>> ReplaceBankDetailsAsync(int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ public interface IEmploymentTypeService
|
|||||||
Task<ETagged<EmploymentTypeDto>> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default);
|
Task<ETagged<EmploymentTypeDto>> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<EmploymentTypeDto>> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<EmploymentTypeDto>> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default);
|
Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int employmentTypeId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ public interface IHrDocumentTypeService
|
|||||||
Task<ETagged<HrDocumentTypeDto>> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default);
|
Task<ETagged<HrDocumentTypeDto>> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<HrDocumentTypeDto>> UpdateAsync(int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<HrDocumentTypeDto>> UpdateAsync(int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default);
|
Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int hrDocumentTypeId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ public interface ILeaveTypeService
|
|||||||
Task<ETagged<LeaveTypeDto>> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default);
|
Task<ETagged<LeaveTypeDto>> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<LeaveTypeDto>> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<LeaveTypeDto>> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default);
|
Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int leaveTypeId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ public interface ISalaryComponentService
|
|||||||
Task<ETagged<SalaryComponentDto>> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default);
|
Task<ETagged<SalaryComponentDto>> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<SalaryComponentDto>> UpdateAsync(int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<SalaryComponentDto>> UpdateAsync(int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default);
|
Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int salaryComponentId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Users;
|
using ERPCore.Dtos.Users;
|
||||||
|
|
||||||
@@ -10,11 +11,21 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IUserManagementService
|
public interface IUserManagementService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default);
|
||||||
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
|
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
|
||||||
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
|
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
|
||||||
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
|
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flips the local shadow row's status (Active/Inactive) — this is what
|
||||||
|
/// BranchAccessService and the rest of ERPCore gate on. AuthHex has no
|
||||||
|
/// admin-scoped "deactivate someone else's account" call (its ChangeUserStatus
|
||||||
|
/// is self-service, acting on whoever's bearer token is passed), so the
|
||||||
|
/// credential itself stays live upstream; this only revokes the account's
|
||||||
|
/// standing inside ERPCore.
|
||||||
|
/// </summary>
|
||||||
|
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||||
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
|
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
/// <summary>Warehouse & bin master business logic (docs/11-BACKEND-PHASE1.md §2.5).</summary>
|
/// <summary>Warehouse & bin master business logic (docs/11-BACKEND-PHASE1.md §2.5).</summary>
|
||||||
public interface IWarehouseService
|
public interface IWarehouseService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default);
|
||||||
Task<WarehouseDto?> GetAsync(int warehouseId, CancellationToken ct = default);
|
Task<WarehouseDto?> GetAsync(int warehouseId, CancellationToken ct = default);
|
||||||
Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default);
|
Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ public interface IWorkShiftService
|
|||||||
Task<ETagged<WorkShiftDto>> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default);
|
Task<ETagged<WorkShiftDto>> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<WorkShiftDto>> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<WorkShiftDto>> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default);
|
Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int workShiftId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,27 +18,25 @@ public sealed class UserManagementService : IUserManagementService
|
|||||||
private readonly IRepository<User> _users;
|
private readonly IRepository<User> _users;
|
||||||
private readonly IRepository<Role> _roles;
|
private readonly IRepository<Role> _roles;
|
||||||
private readonly IRepository<Employee> _employees;
|
private readonly IRepository<Employee> _employees;
|
||||||
private readonly IAuthUserService _authUsers;
|
|
||||||
private readonly IAuthHexClient _authHex;
|
private readonly IAuthHexClient _authHex;
|
||||||
private readonly IEmployeeUserLinkService _links;
|
private readonly IEmployeeUserLinkService _links;
|
||||||
private readonly IEmployeeService _employeeService;
|
private readonly IEmployeeService _employeeService;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public UserManagementService(
|
public UserManagementService(
|
||||||
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees, IAuthUserService authUsers,
|
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees,
|
||||||
IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
|
IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_users = users;
|
_users = users;
|
||||||
_roles = roles;
|
_roles = roles;
|
||||||
_employees = employees;
|
_employees = employees;
|
||||||
_authUsers = authUsers;
|
|
||||||
_authHex = authHex;
|
_authHex = authHex;
|
||||||
_links = links;
|
_links = links;
|
||||||
_employeeService = employeeService;
|
_employeeService = employeeService;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
|
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
@@ -46,6 +44,8 @@ public sealed class UserManagementService : IUserManagementService
|
|||||||
var term = query.Q.Trim();
|
var term = query.Q.Trim();
|
||||||
q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%"));
|
q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%"));
|
||||||
}
|
}
|
||||||
|
if (status is not null) q = q.Where(u => u.Status == status);
|
||||||
|
if (!string.IsNullOrWhiteSpace(roleCode)) q = q.Where(u => u.Role != null && EF.Functions.ILike(u.Role.Code, roleCode));
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderBy(u => u.Username)
|
var rows = await q.OrderBy(u => u.Username)
|
||||||
@@ -93,8 +93,13 @@ public sealed class UserManagementService : IUserManagementService
|
|||||||
var authUserId = Guid.NewGuid();
|
var authUserId = Guid.NewGuid();
|
||||||
|
|
||||||
// Source of truth: AuthHex creates the credential + emails it (registerUser,
|
// Source of truth: AuthHex creates the credential + emails it (registerUser,
|
||||||
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs).
|
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs). Calls the raw
|
||||||
await _authUsers.RegisterAsync(new RegisterRequest
|
// AuthHex client directly rather than IAuthUserService.RegisterAsync — that
|
||||||
|
// wrapper is shaped for self-registration (AuthController.Register) and demands
|
||||||
|
// a session (access/refresh tokens) in the response, which makes no sense here:
|
||||||
|
// an admin creating an account for someone else never logs in as them, so
|
||||||
|
// AuthHex's registerUser response for this case carries no tokens to demand.
|
||||||
|
await _authHex.RegisterAsync(new RegisterRequest
|
||||||
{
|
{
|
||||||
UserId = authUserId,
|
UserId = authUserId,
|
||||||
RoleId = role.AuthRoleId,
|
RoleId = role.AuthRoleId,
|
||||||
@@ -167,6 +172,17 @@ public sealed class UserManagementService : IUserManagementService
|
|||||||
return Map(user);
|
return Map(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
|
||||||
|
?? throw new NotFoundException($"User {userId} was not found.");
|
||||||
|
|
||||||
|
user.Status = status;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Map(user);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
|
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var userTypes = await _authHex.ListUserTypesAsync(ct);
|
var userTypes = await _authHex.ListUserTypesAsync(ct);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public sealed class WarehouseService : IWarehouseService
|
|||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
IQueryable<Warehouse> q = _warehouses.Query().AsNoTracking().Include(w => w.Branch);
|
IQueryable<Warehouse> q = _warehouses.Query().AsNoTracking().Include(w => w.Branch);
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
@@ -36,6 +36,7 @@ public sealed class WarehouseService : IWarehouseService
|
|||||||
var term = query.Q.Trim();
|
var term = query.Q.Trim();
|
||||||
q = q.Where(w => EF.Functions.ILike(w.Code, $"%{term}%") || EF.Functions.ILike(w.Name, $"%{term}%"));
|
q = q.Where(w => EF.Functions.ILike(w.Code, $"%{term}%") || EF.Functions.ILike(w.Name, $"%{term}%"));
|
||||||
}
|
}
|
||||||
|
if (branchId is not null) q = q.Where(w => w.BranchId == branchId);
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderBy(w => w.Code)
|
var rows = await q.OrderBy(w => w.Code)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public static class ErrorCodes
|
|||||||
public const string SalaryStructureOverlap = "SALARY_STRUCTURE_OVERLAP";
|
public const string SalaryStructureOverlap = "SALARY_STRUCTURE_OVERLAP";
|
||||||
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
|
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
|
||||||
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
|
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
|
||||||
|
public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY";
|
||||||
|
|
||||||
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
|
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
|
||||||
public const string TemplateInUse = "TEMPLATE_IN_USE";
|
public const string TemplateInUse = "TEMPLATE_IN_USE";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams } from "next/navigation"
|
import { useParams, useSearchParams } from "next/navigation"
|
||||||
import { Link2, Plus, Upload } from "lucide-react"
|
import { Briefcase, Download, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react"
|
||||||
|
|
||||||
import { CreateEmployeeLoanRequest, CreateSalaryStructureRequest, employeesApi } from "@/lib/api/employees"
|
import { CreateEmployeeLoanRequest, CreateSalaryStructureRequest, employeesApi } from "@/lib/api/employees"
|
||||||
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, hrDocumentTypesApi, salaryComponentsApi, workShiftsApi } from "@/lib/api/hrm-masters"
|
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, hrDocumentTypesApi, salaryComponentsApi, workShiftsApi } from "@/lib/api/hrm-masters"
|
||||||
@@ -25,13 +25,14 @@ import {
|
|||||||
} from "@/types/hrm"
|
} from "@/types/hrm"
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ function money(n: number): string {
|
|||||||
|
|
||||||
export default function EmployeeDetailPage() {
|
export default function EmployeeDetailPage() {
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const employeeId = Number(params.id)
|
const employeeId = Number(params.id)
|
||||||
const { isAdmin, branchId: myBranchId, branchingEnabled } = useAuth()
|
const { isAdmin, branchId: myBranchId, branchingEnabled } = useAuth()
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ export default function EmployeeDetailPage() {
|
|||||||
const [employee, setEmployee] = useState<EmployeeDetail | null>(null)
|
const [employee, setEmployee] = useState<EmployeeDetail | null>(null)
|
||||||
const [etag, setEtag] = useState<string | null>(null)
|
const [etag, setEtag] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isEditing, setIsEditing] = useState(searchParams.get("edit") === "1")
|
||||||
|
|
||||||
const [departments, setDepartments] = useState<Department[]>([])
|
const [departments, setDepartments] = useState<Department[]>([])
|
||||||
const [designations, setDesignations] = useState<Designation[]>([])
|
const [designations, setDesignations] = useState<Designation[]>([])
|
||||||
@@ -58,8 +61,11 @@ export default function EmployeeDetailPage() {
|
|||||||
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
|
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
|
||||||
const [branches, setBranches] = useState<Branch[]>([])
|
const [branches, setBranches] = useState<Branch[]>([])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [statusPending, setStatusPending] = useState(false)
|
||||||
|
|
||||||
const [bankDetails, setBankDetails] = useState<EmployeeBankDetail[]>([])
|
const [bankDetails, setBankDetails] = useState<EmployeeBankDetail[]>([])
|
||||||
|
const [bankDraft, setBankDraft] = useState<EmployeeBankDetail[]>([])
|
||||||
|
const [savingBank, setSavingBank] = useState(false)
|
||||||
const [documents, setDocuments] = useState<EmployeeDocument[]>([])
|
const [documents, setDocuments] = useState<EmployeeDocument[]>([])
|
||||||
const [documentTypes, setDocumentTypes] = useState<HrDocumentType[]>([])
|
const [documentTypes, setDocumentTypes] = useState<HrDocumentType[]>([])
|
||||||
const [uploadTypeId, setUploadTypeId] = useState<string>("")
|
const [uploadTypeId, setUploadTypeId] = useState<string>("")
|
||||||
@@ -100,7 +106,11 @@ export default function EmployeeDetailPage() {
|
|||||||
}, [branchingEnabled])
|
}, [branchingEnabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab === "Bank Details") employeesApi.listBankDetails(employeeId).then(setBankDetails).catch((err) => toast.error("Could not load bank details", errorMessage(err)))
|
if (tab === "Bank Details") {
|
||||||
|
employeesApi.listBankDetails(employeeId)
|
||||||
|
.then((items) => { setBankDetails(items); setBankDraft(items.map((b) => ({ ...b }))) })
|
||||||
|
.catch((err) => toast.error("Could not load bank details", errorMessage(err)))
|
||||||
|
}
|
||||||
if (tab === "Documents") employeesApi.listDocuments(employeeId).then(setDocuments).catch((err) => toast.error("Could not load documents", errorMessage(err)))
|
if (tab === "Documents") employeesApi.listDocuments(employeeId).then(setDocuments).catch((err) => toast.error("Could not load documents", errorMessage(err)))
|
||||||
if (tab === "Salary & Loans") {
|
if (tab === "Salary & Loans") {
|
||||||
employeesApi.salaryStructureHistory(employeeId).then(setSalaryStructures).catch((err) => toast.error("Could not load salary history", errorMessage(err)))
|
employeesApi.salaryStructureHistory(employeeId).then(setSalaryStructures).catch((err) => toast.error("Could not load salary history", errorMessage(err)))
|
||||||
@@ -109,6 +119,15 @@ export default function EmployeeDetailPage() {
|
|||||||
}
|
}
|
||||||
}, [tab, employeeId])
|
}, [tab, employeeId])
|
||||||
|
|
||||||
|
// The active structure has no `effectiveTo`; fall back to the most recent one if none is marked Active yet.
|
||||||
|
const currentStructure = salaryStructures.find((s) => s.status === "Active") ?? salaryStructures[0]
|
||||||
|
const componentTypeById = new Map(salaryComponents.map((c) => [c.salaryComponentId, c.componentType]))
|
||||||
|
const structureLinesTyped = currentStructure?.lines ?? []
|
||||||
|
const earningLines = structureLinesTyped.filter((l) => componentTypeById.get(l.salaryComponentId) !== "Deduction")
|
||||||
|
const deductionLines = structureLinesTyped.filter((l) => componentTypeById.get(l.salaryComponentId) === "Deduction")
|
||||||
|
const grossPay = (currentStructure?.basicSalary ?? 0) + earningLines.reduce((sum, l) => sum + l.amount, 0)
|
||||||
|
const netPay = grossPay - deductionLines.reduce((sum, l) => sum + l.amount, 0)
|
||||||
|
|
||||||
function addStructureLine() {
|
function addStructureLine() {
|
||||||
setStructureLines((prev) => [...prev, { salaryComponentId: "", amount: 0 }])
|
setStructureLines((prev) => [...prev, { salaryComponentId: "", amount: 0 }])
|
||||||
}
|
}
|
||||||
@@ -190,6 +209,7 @@ export default function EmployeeDetailPage() {
|
|||||||
}, etag)
|
}, etag)
|
||||||
setEmployee(res.data)
|
setEmployee(res.data)
|
||||||
setEtag(res.etag)
|
setEtag(res.etag)
|
||||||
|
setIsEditing(false)
|
||||||
toast.success("Employee updated")
|
toast.success("Employee updated")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not save", errorMessage(err))
|
toast.error("Could not save", errorMessage(err))
|
||||||
@@ -198,6 +218,62 @@ export default function EmployeeDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCancelEdit() {
|
||||||
|
setIsEditing(false)
|
||||||
|
setBankDraft(bankDetails.map((b) => ({ ...b })))
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Inactive" here maps to Suspended — a reversible off-switch, distinct from the
|
||||||
|
// one-way separation statuses (Resigned/Terminated/Retired) which need their own flow.
|
||||||
|
async function handleToggleStatus() {
|
||||||
|
if (!employee) return
|
||||||
|
const nextStatus = employee.status === "Active" ? "Suspended" : "Active"
|
||||||
|
setStatusPending(true)
|
||||||
|
try {
|
||||||
|
await employeesApi.updateStatus(employeeId, nextStatus)
|
||||||
|
setEmployee({ ...employee, status: nextStatus })
|
||||||
|
toast.success(nextStatus === "Active" ? "Employee activated" : "Employee deactivated")
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not update status", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setStatusPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLANK_BANK_DETAIL: EmployeeBankDetail = {
|
||||||
|
employeeBankDetailId: null, bankName: "", branchName: "", accountNumber: "", accountHolderName: "", swiftCode: null, isPrimary: true, status: "Active",
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBankRow(index: number, patch: Partial<EmployeeBankDetail>) {
|
||||||
|
setBankDraft((prev) => {
|
||||||
|
const base = prev[index] ?? BLANK_BANK_DETAIL
|
||||||
|
const next = [...prev]
|
||||||
|
next[index] = { ...base, ...patch }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveBank() {
|
||||||
|
const b = bankDraft[0]
|
||||||
|
if (!b || !b.bankName.trim() || !b.branchName.trim() || !b.accountNumber.trim() || !b.accountHolderName.trim()) {
|
||||||
|
toast.error("Bank name, branch, account number, and account holder name are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingBank(true)
|
||||||
|
try {
|
||||||
|
const saved = await employeesApi.replaceBankDetails(employeeId, bankDraft)
|
||||||
|
setBankDetails(saved)
|
||||||
|
setBankDraft(saved.map((b) => ({ ...b })))
|
||||||
|
toast.success("Bank details updated")
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not save bank details", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSavingBank(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUpload(file: File) {
|
async function handleUpload(file: File) {
|
||||||
if (!uploadTypeId) { toast.error("Select a document type first"); return }
|
if (!uploadTypeId) { toast.error("Select a document type first"); return }
|
||||||
try {
|
try {
|
||||||
@@ -214,27 +290,44 @@ export default function EmployeeDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">{employee.fullName}</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
<p className="text-base text-muted-foreground">
|
<h1 className="text-2xl font-bold text-foreground">{employee.fullName}</h1>
|
||||||
{employee.employeeCode}
|
<Badge variant="outline" className={cn("h-6 px-2.5 text-xs", employee.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{employee.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground">
|
||||||
|
<span className="font-mono">{employee.employeeCode}</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
{employee.userId ? (
|
{employee.userId ? (
|
||||||
<span className="ml-2 inline-flex items-center gap-1 text-primary"><Link2 className="size-3.5" />Linked to a system user</span>
|
<span className="inline-flex items-center gap-1 text-primary"><Link2 className="size-3.5" />Linked to a system user</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="ml-2 text-muted-foreground">No system login</span>
|
<span>No system login</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className={cn("h-7 px-3 text-sm", employee.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{employee.status}</Badge>
|
|
||||||
|
{(employee.status === "Active" || employee.status === "Suspended") && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn("text-sm font-medium", employee.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
|
||||||
|
{employee.status === "Active" ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={employee.status === "Active"}
|
||||||
|
onCheckedChange={handleToggleStatus}
|
||||||
|
disabled={statusPending}
|
||||||
|
aria-label={employee.status === "Active" ? "Deactivate employee" : "Activate employee"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 rounded-xl bg-muted p-1 w-fit">
|
<div className="flex w-full gap-1 overflow-x-auto rounded-xl bg-muted p-1">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
className={cn("rounded-lg px-4 py-2 text-sm font-medium transition-colors", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
|
className={cn("flex-1 shrink-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
|
||||||
>
|
>
|
||||||
{t}
|
{t}
|
||||||
</button>
|
</button>
|
||||||
@@ -242,132 +335,231 @@ export default function EmployeeDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "Overview" && (
|
{tab === "Overview" && (
|
||||||
<FieldGroup className="max-w-2xl">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
<Field><FieldLabel>Full name</FieldLabel><Input value={employee.fullName} onChange={(e) => setEmployee({ ...employee, fullName: e.target.value })} /></Field>
|
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||||
<Field><FieldLabel>Email</FieldLabel><Input type="email" value={employee.email ?? ""} onChange={(e) => setEmployee({ ...employee, email: e.target.value })} /></Field>
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<Field><FieldLabel>NIC</FieldLabel><Input value={employee.nic ?? ""} onChange={(e) => setEmployee({ ...employee, nic: e.target.value })} /></Field>
|
<User className="size-4.5 text-primary" />
|
||||||
<Field><FieldLabel>Personal mobile</FieldLabel><Input value={employee.personalMobile ?? ""} onChange={(e) => setEmployee({ ...employee, personalMobile: e.target.value })} /></Field>
|
</div>
|
||||||
<Field>
|
<div>
|
||||||
<FieldLabel>Department</FieldLabel>
|
<h2 className="text-base font-semibold text-foreground">Personal details</h2>
|
||||||
<Select value={String(employee.departmentId)} onValueChange={(v) => setEmployee({ ...employee, departmentId: Number(v) })}>
|
<p className="text-sm text-muted-foreground">Contact and identity information.</p>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
</div>
|
||||||
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
|
</div>
|
||||||
</Select>
|
<FieldGroup>
|
||||||
</Field>
|
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Field>
|
<Field><FieldLabel>Full name</FieldLabel><Input disabled={!isEditing} value={employee.fullName} onChange={(e) => setEmployee({ ...employee, fullName: e.target.value })} /></Field>
|
||||||
<FieldLabel>Designation</FieldLabel>
|
<Field><FieldLabel>NIC</FieldLabel><Input disabled={!isEditing} value={employee.nic ?? ""} onChange={(e) => setEmployee({ ...employee, nic: e.target.value })} /></Field>
|
||||||
<Select value={String(employee.designationId)} onValueChange={(v) => setEmployee({ ...employee, designationId: Number(v) })}>
|
<Field><FieldLabel>Email</FieldLabel><Input disabled={!isEditing} type="email" value={employee.email ?? ""} onChange={(e) => setEmployee({ ...employee, email: e.target.value })} /></Field>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<Field><FieldLabel>Personal mobile</FieldLabel><Input disabled={!isEditing} value={employee.personalMobile ?? ""} onChange={(e) => setEmployee({ ...employee, personalMobile: e.target.value })} /></Field>
|
||||||
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
|
</div>
|
||||||
</Select>
|
</FieldGroup>
|
||||||
</Field>
|
</section>
|
||||||
<Field>
|
|
||||||
<FieldLabel>Employment type</FieldLabel>
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
<Select value={String(employee.employmentTypeId)} onValueChange={(v) => setEmployee({ ...employee, employmentTypeId: Number(v) })}>
|
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
|
<Briefcase className="size-4.5 text-primary" />
|
||||||
</Select>
|
</div>
|
||||||
</Field>
|
<div>
|
||||||
<Field>
|
<h2 className="text-base font-semibold text-foreground">Employment details</h2>
|
||||||
<FieldLabel>Work shift</FieldLabel>
|
<p className="text-sm text-muted-foreground">Where and how this employee works.</p>
|
||||||
<Select value={String(employee.workShiftId)} onValueChange={(v) => setEmployee({ ...employee, workShiftId: Number(v) })}>
|
</div>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
</div>
|
||||||
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
|
<FieldGroup>
|
||||||
</Select>
|
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
</Field>
|
{branchingEnabled && (
|
||||||
{branchingEnabled && (
|
<Field>
|
||||||
<Field>
|
<FieldLabel>Branch</FieldLabel>
|
||||||
<FieldLabel>Branch</FieldLabel>
|
<Select value={String(employee.branchId)} onValueChange={(v) => setEmployee({ ...employee, branchId: Number(v) })}>
|
||||||
<Select value={String(employee.branchId)} onValueChange={(v) => setEmployee({ ...employee, branchId: Number(v) })}>
|
<SelectTrigger disabled={!isEditing || !isAdmin}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
<SelectTrigger disabled={!isAdmin}><SelectValue placeholder="Select" /></SelectTrigger>
|
<SelectContent>{(isAdmin ? branches : branches.filter((b) => b.branchId === myBranchId || b.branchId === employee.branchId)).map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
||||||
<SelectContent>{(isAdmin ? branches : branches.filter((b) => b.branchId === myBranchId || b.branchId === employee.branchId)).map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
</Select>
|
||||||
</Select>
|
</Field>
|
||||||
</Field>
|
)}
|
||||||
)}
|
<Field>
|
||||||
<Field><FieldLabel>EPF number</FieldLabel><Input value={employee.epfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, epfNumber: e.target.value })} /></Field>
|
<FieldLabel>Department</FieldLabel>
|
||||||
<Field><FieldLabel>ETF number</FieldLabel><Input value={employee.etfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, etfNumber: e.target.value })} /></Field>
|
<Select value={String(employee.departmentId)} onValueChange={(v) => setEmployee({ ...employee, departmentId: Number(v) })}>
|
||||||
</div>
|
<SelectTrigger disabled={!isEditing}><SelectValue /></SelectTrigger>
|
||||||
<div className="flex justify-end pt-2">
|
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
|
||||||
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
</FieldGroup>
|
<Field>
|
||||||
|
<FieldLabel>Designation</FieldLabel>
|
||||||
|
<Select value={String(employee.designationId)} onValueChange={(v) => setEmployee({ ...employee, designationId: Number(v) })}>
|
||||||
|
<SelectTrigger disabled={!isEditing}><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Employment type</FieldLabel>
|
||||||
|
<Select value={String(employee.employmentTypeId)} onValueChange={(v) => setEmployee({ ...employee, employmentTypeId: Number(v) })}>
|
||||||
|
<SelectTrigger disabled={!isEditing}><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Work shift</FieldLabel>
|
||||||
|
<Select value={String(employee.workShiftId)} onValueChange={(v) => setEmployee({ ...employee, workShiftId: Number(v) })}>
|
||||||
|
<SelectTrigger disabled={!isEditing}><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<ShieldCheck className="size-4.5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Statutory details</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Government registration numbers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 sm:max-w-md">
|
||||||
|
<Field><FieldLabel>EPF number</FieldLabel><Input disabled={!isEditing} value={employee.epfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, epfNumber: e.target.value })} /></Field>
|
||||||
|
<Field><FieldLabel>ETF number</FieldLabel><Input disabled={!isEditing} value={employee.etfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, etfNumber: e.target.value })} /></Field>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={handleCancelEdit} disabled={saving}>
|
||||||
|
<X className="size-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "Bank Details" && (
|
{tab === "Bank Details" && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Table className="text-base">
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
<TableHeader>
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<TableRow>
|
<Field><FieldLabel>Bank name</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.bankName ?? ""} onChange={(e) => updateBankRow(0, { bankName: e.target.value })} /></Field>
|
||||||
<TableHead className="h-12 px-3 text-sm">Bank</TableHead>
|
<Field><FieldLabel>Branch name</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.branchName ?? ""} onChange={(e) => updateBankRow(0, { branchName: e.target.value })} /></Field>
|
||||||
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
|
<Field><FieldLabel>Account number</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.accountNumber ?? ""} onChange={(e) => updateBankRow(0, { accountNumber: e.target.value })} /></Field>
|
||||||
<TableHead className="h-12 px-3 text-sm">Account</TableHead>
|
<Field><FieldLabel>Account holder name</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.accountHolderName ?? ""} onChange={(e) => updateBankRow(0, { accountHolderName: e.target.value })} /></Field>
|
||||||
<TableHead className="h-12 px-3 text-sm">Primary</TableHead>
|
<Field><FieldLabel>SWIFT code (optional)</FieldLabel><Input disabled={!isEditing} value={bankDraft[0]?.swiftCode ?? ""} onChange={(e) => updateBankRow(0, { swiftCode: e.target.value || null })} /></Field>
|
||||||
</TableRow>
|
</div>
|
||||||
</TableHeader>
|
</section>
|
||||||
<TableBody>
|
|
||||||
{bankDetails.map((b, i) => (
|
{isEditing && (
|
||||||
<TableRow key={b.employeeBankDetailId ?? i}>
|
<div className="flex justify-end gap-2">
|
||||||
<TableCell className="px-3 py-3.5">{b.bankName}</TableCell>
|
<Button variant="outline" onClick={handleCancelEdit} disabled={savingBank}>
|
||||||
<TableCell className="px-3 py-3.5">{b.branchName}</TableCell>
|
<X className="size-4" />
|
||||||
<TableCell className="px-3 py-3.5">{b.accountNumber}</TableCell>
|
Cancel
|
||||||
<TableCell className="px-3 py-3.5">{b.isPrimary ? "Yes" : "—"}</TableCell>
|
</Button>
|
||||||
</TableRow>
|
<Button onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button>
|
||||||
))}
|
</div>
|
||||||
{bankDetails.length === 0 && (
|
)}
|
||||||
<TableRow><TableCell colSpan={4} className="px-3 py-8 text-center text-muted-foreground">No bank details on file.</TableCell></TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "Documents" && (
|
{tab === "Documents" && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
<Select value={uploadTypeId} onValueChange={(v) => setUploadTypeId(v ?? "")}>
|
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||||
<SelectTrigger className="w-56"><SelectValue placeholder="Document type" /></SelectTrigger>
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<SelectContent>{documentTypes.map((t) => <SelectItem key={t.hrDocumentTypeId} value={String(t.hrDocumentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
|
<Upload className="size-4.5 text-primary" />
|
||||||
</Select>
|
</div>
|
||||||
<label className={cn("inline-flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted")}>
|
<div>
|
||||||
<Upload className="size-4" />
|
<h2 className="text-base font-semibold text-foreground">Upload a document</h2>
|
||||||
Upload
|
<p className="text-sm text-muted-foreground">NIC, contracts, certificates — PDF, image, or Word file.</p>
|
||||||
<input type="file" className="hidden" accept=".pdf,.jpg,.jpeg,.png,.docx" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = "" }} />
|
</div>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
<Table className="text-base">
|
<Field className="w-full sm:max-w-64">
|
||||||
<TableHeader>
|
<FieldLabel htmlFor="doc-upload-type">Document type</FieldLabel>
|
||||||
<TableRow>
|
<Select value={uploadTypeId} onValueChange={(v) => setUploadTypeId(v ?? "")}>
|
||||||
<TableHead className="h-12 px-3 text-sm">File</TableHead>
|
<SelectTrigger id="doc-upload-type"><SelectValue placeholder="Select a type" /></SelectTrigger>
|
||||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
<SelectContent>{documentTypes.map((t) => <SelectItem key={t.hrDocumentTypeId} value={String(t.hrDocumentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
|
||||||
<TableHead className="h-12 px-3 text-sm">Uploaded</TableHead>
|
</Select>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
</Field>
|
||||||
<TableHead className="h-12 px-3 text-sm">Download</TableHead>
|
<label
|
||||||
</TableRow>
|
className={cn(
|
||||||
</TableHeader>
|
"inline-flex h-9 w-fit items-center gap-2 rounded-lg border border-black px-4 text-sm font-medium",
|
||||||
<TableBody>
|
uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60"
|
||||||
{documents.map((d) => (
|
)}
|
||||||
<TableRow key={d.employeeDocumentId}>
|
title={uploadTypeId ? undefined : "Select a document type first"}
|
||||||
<TableCell className="px-3 py-3.5">{d.originalFileName}</TableCell>
|
>
|
||||||
<TableCell className="px-3 py-3.5">{d.hrDocumentTypeName ?? "—"}</TableCell>
|
<Upload className="size-4" />
|
||||||
<TableCell className="px-3 py-3.5">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
|
Choose file to upload
|
||||||
<TableCell className="px-3 py-3.5">{d.status}</TableCell>
|
<input
|
||||||
<TableCell className="px-3 py-3.5">
|
type="file"
|
||||||
<a href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)} className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer">Download</a>
|
className="hidden"
|
||||||
</TableCell>
|
disabled={!uploadTypeId}
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.docx"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = "" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-11 px-4 text-sm">File</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
|
||||||
|
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
{documents.length === 0 && (
|
<TableBody>
|
||||||
<TableRow><TableCell colSpan={5} className="px-3 py-8 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
|
{documents.map((d) => (
|
||||||
)}
|
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
|
||||||
</TableBody>
|
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell>
|
||||||
</Table>
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
|
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{d.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<a
|
||||||
|
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
aria-label={`Download ${d.originalFileName}`}
|
||||||
|
title={`Download ${d.originalFileName}`}
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{documents.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "Salary & Loans" && (
|
{tab === "Salary & Loans" && (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-6">
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">Salary Structure</h2>
|
<h2 className="text-lg font-semibold text-foreground">Salary Structure</h2>
|
||||||
<Dialog open={structureOpen} onOpenChange={(v) => { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}>
|
<Dialog open={structureOpen} onOpenChange={(v) => { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}>
|
||||||
@@ -405,32 +597,83 @@ export default function EmployeeDetailPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
<Table className="text-base">
|
{currentStructure && (
|
||||||
<TableHeader>
|
<div className="rounded-xl border p-4 sm:p-5">
|
||||||
<TableRow>
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
<TableHead className="h-12 px-3 text-sm">Effective from</TableHead>
|
<h3 className="text-sm font-semibold text-foreground">Current breakdown</h3>
|
||||||
<TableHead className="h-12 px-3 text-sm">Effective to</TableHead>
|
<span className="text-xs text-muted-foreground">Effective {new Date(currentStructure.effectiveFrom).toLocaleDateString()}</span>
|
||||||
<TableHead className="h-12 px-3 text-sm">Basic</TableHead>
|
</div>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
|
||||||
</TableRow>
|
<div className="mt-3 flex flex-col gap-1.5 text-sm">
|
||||||
</TableHeader>
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<TableBody>
|
<span className="text-muted-foreground">Basic salary</span>
|
||||||
{salaryStructures.map((s) => (
|
<span className="text-right font-medium text-foreground">{money(currentStructure.basicSalary)}</span>
|
||||||
<TableRow key={s.employeeSalaryStructureId}>
|
</div>
|
||||||
<TableCell className="px-3 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
{earningLines.map((l) => (
|
||||||
<TableCell className="px-3 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
|
<div key={l.salaryComponentId} className="grid grid-cols-2 gap-2">
|
||||||
<TableCell className="px-3 py-3.5">{money(s.basicSalary)}</TableCell>
|
<span className="text-muted-foreground">{l.salaryComponentName ?? "Allowance"}</span>
|
||||||
<TableCell className="px-3 py-3.5">{s.status}</TableCell>
|
<span className="text-right font-medium text-foreground">+{money(l.amount)}</span>
|
||||||
</TableRow>
|
</div>
|
||||||
))}
|
))}
|
||||||
{salaryStructures.length === 0 && (
|
</div>
|
||||||
<TableRow><TableCell colSpan={4} className="px-3 py-8 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
|
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-border pt-3 text-sm">
|
||||||
|
<span className="font-semibold text-foreground">Gross pay</span>
|
||||||
|
<span className="text-right font-semibold text-foreground">{money(grossPay)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deductionLines.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 flex flex-col gap-1.5 text-sm">
|
||||||
|
{deductionLines.map((l) => (
|
||||||
|
<div key={l.salaryComponentId} className="grid grid-cols-2 gap-2">
|
||||||
|
<span className="text-muted-foreground">{l.salaryComponentName ?? "Deduction"}</span>
|
||||||
|
<span className="text-right font-medium text-destructive">−{money(l.amount)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-border pt-3 text-sm">
|
||||||
|
<span className="font-semibold text-foreground">Net pay</span>
|
||||||
|
<span className="text-right font-semibold text-foreground">{money(netPay)}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
<p className="mt-3 text-xs text-muted-foreground">
|
||||||
</Table>
|
OT, Late/No-Pay, loan installments, EPF/ETF, and tax are computed at payroll time and aren't part of this structure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Effective to</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{salaryStructures.map((s) => (
|
||||||
|
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20">
|
||||||
|
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">{s.status}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{salaryStructures.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={4} className="px-4 py-10 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">Loans & Advances</h2>
|
<h2 className="text-lg font-semibold text-foreground">Loans & Advances</h2>
|
||||||
<Dialog open={loanOpen} onOpenChange={setLoanOpen}>
|
<Dialog open={loanOpen} onOpenChange={setLoanOpen}>
|
||||||
@@ -467,31 +710,35 @@ export default function EmployeeDetailPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Principal</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Outstanding</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Principal</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 px-4 text-sm">Outstanding</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||||
<TableBody>
|
|
||||||
{loans.map((l) => (
|
|
||||||
<TableRow key={l.employeeLoanId}>
|
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{l.docNo}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{l.loanKind}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{money(l.principalAmount)}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{money(l.outstandingBalance)}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{l.status}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
{loans.length === 0 && (
|
<TableBody>
|
||||||
<TableRow><TableCell colSpan={5} className="px-3 py-8 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
|
{loans.map((l) => (
|
||||||
)}
|
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20">
|
||||||
</TableBody>
|
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell>
|
||||||
</Table>
|
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">{l.status}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{loans.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Briefcase, KeyRound, Link2, Save, User } from "lucide-react"
|
||||||
|
|
||||||
|
import { employeesApi } from "@/lib/api/employees"
|
||||||
|
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
|
||||||
|
import { rolesApi } from "@/lib/api/roles"
|
||||||
|
import { usersApi } from "@/lib/api/users"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Branch, CreateEmployeeRequest, Department, Designation, EmploymentType, UserMatch, WorkShift } from "@/types/hrm"
|
||||||
|
import { Role } from "@/types/rbac"
|
||||||
|
import { UserTypeOption } from "@/types/users"
|
||||||
|
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const emptyForm: CreateEmployeeRequest = {
|
||||||
|
employeeCode: "",
|
||||||
|
fullName: "",
|
||||||
|
email: "",
|
||||||
|
hireDate: new Date().toISOString().slice(0, 10),
|
||||||
|
departmentId: 0,
|
||||||
|
designationId: 0,
|
||||||
|
employmentTypeId: 0,
|
||||||
|
branchId: 0,
|
||||||
|
workShiftId: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewEmployeePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||||
|
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||||
|
const lockedBranchName = isAdmin ? activeBranchName : myBranchName
|
||||||
|
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||||
|
|
||||||
|
const [departments, setDepartments] = useState<Department[]>([])
|
||||||
|
const [designations, setDesignations] = useState<Designation[]>([])
|
||||||
|
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
|
||||||
|
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([])
|
||||||
|
|
||||||
|
const [form, setForm] = useState<CreateEmployeeRequest>(emptyForm)
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
// Advisory cross-link suggestion: does a System User already exist with this email?
|
||||||
|
const [userMatch, setUserMatch] = useState<UserMatch | null>(null)
|
||||||
|
const [checkingEmail, setCheckingEmail] = useState(false)
|
||||||
|
|
||||||
|
// Optional: create a login account for this employee in the same step.
|
||||||
|
const [grantLogin, setGrantLogin] = useState(false)
|
||||||
|
const [loginUsername, setLoginUsername] = useState("")
|
||||||
|
const [loginRoleId, setLoginRoleId] = useState("")
|
||||||
|
const [loginUserTypeId, setLoginUserTypeId] = useState("")
|
||||||
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
|
const [userTypes, setUserTypes] = useState<UserTypeOption[]>([])
|
||||||
|
|
||||||
|
// Admins have access to every branch regardless of which one their staff record names —
|
||||||
|
// don't make the creator pick one, just quietly default it (the Employee row still needs
|
||||||
|
// a home branch on file; it just never gates what an Admin login can see or do).
|
||||||
|
const selectedLoginRole = roles.find((r) => String(r.roleId) === loginRoleId)
|
||||||
|
const loginIsAdmin = grantLogin && !userMatch && selectedLoginRole?.code?.toUpperCase() === "ADMIN"
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lockedBranchId) setForm((f) => ({ ...f, branchId: lockedBranchId }))
|
||||||
|
}, [lockedBranchId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loginIsAdmin && !form.branchId) {
|
||||||
|
const fallbackBranchId = lockedBranchId ?? branches[0]?.branchId
|
||||||
|
if (fallbackBranchId) setForm((f) => ({ ...f, branchId: fallbackBranchId }))
|
||||||
|
}
|
||||||
|
}, [loginIsAdmin, lockedBranchId, branches, form.branchId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([]))
|
||||||
|
designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([]))
|
||||||
|
employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([]))
|
||||||
|
workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([]))
|
||||||
|
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||||
|
rolesApi.list({ pageSize: 200, status: "Active" }).then((r) => setRoles(r.items)).catch(() => setRoles([]))
|
||||||
|
usersApi.userTypes().then(setUserTypes).catch(() => setUserTypes([]))
|
||||||
|
}, [branchingEnabled])
|
||||||
|
|
||||||
|
async function checkEmail(email: string) {
|
||||||
|
if (!email.trim()) { setUserMatch(null); return }
|
||||||
|
setCheckingEmail(true)
|
||||||
|
try {
|
||||||
|
const { match } = await employeesApi.emailLookup(email.trim())
|
||||||
|
setUserMatch(match)
|
||||||
|
} catch {
|
||||||
|
setUserMatch(null)
|
||||||
|
} finally {
|
||||||
|
setCheckingEmail(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!form.employeeCode.trim()) nextErrors.employeeCode = "Employee code is required"
|
||||||
|
if (!form.fullName.trim()) nextErrors.fullName = "Full name is required"
|
||||||
|
if (!form.hireDate) nextErrors.hireDate = "Hire date is required"
|
||||||
|
if (!form.departmentId) nextErrors.departmentId = "Department is required"
|
||||||
|
if (!form.designationId) nextErrors.designationId = "Designation is required"
|
||||||
|
if (!form.employmentTypeId) nextErrors.employmentTypeId = "Employment type is required"
|
||||||
|
if (!form.workShiftId) nextErrors.workShiftId = "Work shift is required"
|
||||||
|
if (branchingEnabled && !loginIsAdmin && !form.branchId) nextErrors.branchId = "Branch is required"
|
||||||
|
if (grantLogin && !userMatch) {
|
||||||
|
if (!form.email?.trim()) nextErrors.email = "Email is required to grant login access"
|
||||||
|
if (!loginUsername.trim()) nextErrors.loginUsername = "Username is required"
|
||||||
|
if (!loginRoleId) nextErrors.loginRoleId = "Role is required"
|
||||||
|
if (!loginUserTypeId) nextErrors.loginUserTypeId = "User type is required"
|
||||||
|
}
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const created = await employeesApi.create({
|
||||||
|
...form,
|
||||||
|
email: form.email || null,
|
||||||
|
linkUserId: userMatch ? userMatch.userId : null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Grant login access in the same step — no separate "Add User" flow needed.
|
||||||
|
if (grantLogin && !userMatch) {
|
||||||
|
try {
|
||||||
|
await usersApi.create({
|
||||||
|
username: loginUsername.trim(),
|
||||||
|
fullName: form.fullName.trim(),
|
||||||
|
email: form.email!.trim(),
|
||||||
|
roleId: Number(loginRoleId),
|
||||||
|
userTypeId: loginUserTypeId,
|
||||||
|
mobileNumber: form.personalMobile || undefined,
|
||||||
|
nic: form.nic || undefined,
|
||||||
|
linkEmployeeId: created.data.employeeId,
|
||||||
|
})
|
||||||
|
toast.success("Employee created", "Login credentials have been emailed to the employee.")
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Employee created, but login account failed", errorMessage(err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.success("Employee created")
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/dashboard/hrm/employees/${created.data.employeeId}`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not create employee", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-6 pb-24">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">New employee</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Add a staff record — grant login access at the end if this employee needs one.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-5 rounded-2xl border bg-card p-6 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<User className="size-4.5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Personal details</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Basic identity and contact information.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.employeeCode}>
|
||||||
|
<FieldLabel htmlFor="e-code">Employee code</FieldLabel>
|
||||||
|
<Input id="e-code" value={form.employeeCode} onChange={(e) => setForm((f) => ({ ...f, employeeCode: e.target.value }))} aria-invalid={!!errors.employeeCode} />
|
||||||
|
<FieldError errors={[errors.employeeCode ? { message: errors.employeeCode } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.hireDate}>
|
||||||
|
<FieldLabel htmlFor="e-hire">Hire date</FieldLabel>
|
||||||
|
<Input id="e-hire" type="date" value={form.hireDate} onChange={(e) => setForm((f) => ({ ...f, hireDate: e.target.value }))} aria-invalid={!!errors.hireDate} />
|
||||||
|
<FieldError errors={[errors.hireDate ? { message: errors.hireDate } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field data-invalid={!!errors.fullName}>
|
||||||
|
<FieldLabel htmlFor="e-name">Full name</FieldLabel>
|
||||||
|
<Input id="e-name" value={form.fullName} onChange={(e) => setForm((f) => ({ ...f, fullName: e.target.value }))} aria-invalid={!!errors.fullName} />
|
||||||
|
<FieldError errors={[errors.fullName ? { message: errors.fullName } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.email}>
|
||||||
|
<FieldLabel htmlFor="e-email">Email {grantLogin ? "" : "(optional)"}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="e-email"
|
||||||
|
type="email"
|
||||||
|
value={form.email ?? ""}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
|
onBlur={(e) => checkEmail(e.target.value)}
|
||||||
|
aria-invalid={!!errors.email}
|
||||||
|
/>
|
||||||
|
{checkingEmail && <p className="text-sm text-muted-foreground">Checking for an existing system user…</p>}
|
||||||
|
{userMatch && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm">
|
||||||
|
<Link2 className="size-4 shrink-0 text-primary" />
|
||||||
|
<span>
|
||||||
|
System user <strong>{userMatch.username}</strong> ({userMatch.displayName}) matches this email — it will be linked to this employee.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-5 rounded-2xl border bg-card p-6 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Briefcase className="size-4.5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Employment</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Where this employee sits in the org.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.departmentId}>
|
||||||
|
<FieldLabel htmlFor="e-dept">Department</FieldLabel>
|
||||||
|
<Select value={form.departmentId ? String(form.departmentId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, departmentId: v ? Number(v) : 0 }))}>
|
||||||
|
<SelectTrigger id="e-dept" className="h-10 w-full"><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.departmentId ? { message: errors.departmentId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.designationId}>
|
||||||
|
<FieldLabel htmlFor="e-desig">Designation</FieldLabel>
|
||||||
|
<Select value={form.designationId ? String(form.designationId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, designationId: v ? Number(v) : 0 }))}>
|
||||||
|
<SelectTrigger id="e-desig" className="h-10 w-full"><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.designationId ? { message: errors.designationId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.employmentTypeId}>
|
||||||
|
<FieldLabel htmlFor="e-etype">Employment type</FieldLabel>
|
||||||
|
<Select value={form.employmentTypeId ? String(form.employmentTypeId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, employmentTypeId: v ? Number(v) : 0 }))}>
|
||||||
|
<SelectTrigger id="e-etype" className="h-10 w-full"><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.employmentTypeId ? { message: errors.employmentTypeId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.workShiftId}>
|
||||||
|
<FieldLabel htmlFor="e-shift">Work shift</FieldLabel>
|
||||||
|
<Select value={form.workShiftId ? String(form.workShiftId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, workShiftId: v ? Number(v) : 0 }))}>
|
||||||
|
<SelectTrigger id="e-shift" className="h-10 w-full"><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.workShiftId ? { message: errors.workShiftId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
{branchingEnabled && loginIsAdmin && (
|
||||||
|
<Field className="sm:col-span-2">
|
||||||
|
<FieldLabel>Branch</FieldLabel>
|
||||||
|
<div className="flex h-9 items-center rounded-lg border border-primary/30 bg-primary/5 px-3 text-sm text-muted-foreground">
|
||||||
|
Not needed — the Admin role grants access to every branch.
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{branchingEnabled && !loginIsAdmin && (
|
||||||
|
<Field data-invalid={!!errors.branchId}>
|
||||||
|
<FieldLabel htmlFor="e-branch">Branch</FieldLabel>
|
||||||
|
{showBranchPicker ? (
|
||||||
|
<Select value={form.branchId ? String(form.branchId) : undefined} onValueChange={(v) => setForm((f) => ({ ...f, branchId: v ? Number(v) : 0 }))}>
|
||||||
|
<SelectTrigger id="e-branch" className="h-10 w-full" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div id="e-branch" className="flex h-9 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||||
|
{lockedBranchName ?? "No branch assigned — contact an admin"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!userMatch && (
|
||||||
|
<section className="flex flex-col gap-5 rounded-2xl border bg-card p-6 shadow-sm">
|
||||||
|
<label className="flex cursor-pointer items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg transition-colors", grantLogin ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground")}>
|
||||||
|
<KeyRound className="size-4.5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Grant login access</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Optional — creates a system user account for this employee automatically.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch checked={grantLogin} onCheckedChange={(v) => setGrantLogin(v === true)} aria-label="Grant login access" />
|
||||||
|
</label>
|
||||||
|
{grantLogin && (
|
||||||
|
<FieldGroup className="border-t pt-5">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.loginUsername}>
|
||||||
|
<FieldLabel htmlFor="e-login-username">Username</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="e-login-username"
|
||||||
|
value={loginUsername}
|
||||||
|
onChange={(e) => setLoginUsername(e.target.value)}
|
||||||
|
aria-invalid={!!errors.loginUsername}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.loginUsername ? { message: errors.loginUsername } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.loginRoleId}>
|
||||||
|
<FieldLabel htmlFor="e-login-role">Role</FieldLabel>
|
||||||
|
<Select value={loginRoleId || undefined} onValueChange={(v) => setLoginRoleId(v ?? "")}>
|
||||||
|
<SelectTrigger id="e-login-role" className="h-10 w-full" aria-invalid={!!errors.loginRoleId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{roles.map((r) => <SelectItem key={r.roleId} value={String(r.roleId)}>{r.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.loginRoleId ? { message: errors.loginRoleId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.loginUserTypeId} className="sm:col-span-2">
|
||||||
|
<FieldLabel htmlFor="e-login-usertype">User type</FieldLabel>
|
||||||
|
<Select value={loginUserTypeId || undefined} onValueChange={(v) => setLoginUserTypeId(v ?? "")}>
|
||||||
|
<SelectTrigger id="e-login-usertype" className="h-10 w-full" aria-invalid={!!errors.loginUserTypeId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
|
<SelectContent>{userTypes.map((t) => <SelectItem key={t.userTypeId} value={t.userTypeId}>{t.code ?? t.description ?? t.userTypeId}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.loginUserTypeId ? { message: errors.loginUserTypeId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">A password will be auto-generated and emailed to the employee.</p>
|
||||||
|
</FieldGroup>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 -mx-4 flex justify-end gap-3 border-t bg-background/95 px-4 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:-mx-6 sm:px-6">
|
||||||
|
<Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<Button onClick={handleCreate} disabled={submitting} className="min-w-40">
|
||||||
|
<Save className="size-4" />
|
||||||
|
{submitting ? "Creating…" : "Create employee"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,136 +2,85 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react"
|
import { ChevronLeft, ChevronRight, Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon, X } from "lucide-react"
|
||||||
|
|
||||||
import { employeesApi } from "@/lib/api/employees"
|
import { employeesApi } from "@/lib/api/employees"
|
||||||
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
|
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { Branch, CreateEmployeeRequest, Department, Designation, EmployeeListItem, EmploymentType, UserMatch, WorkShift } from "@/types/hrm"
|
import { EmployeeListItem } from "@/types/hrm"
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
|
|
||||||
const emptyForm: CreateEmployeeRequest = {
|
|
||||||
employeeCode: "",
|
|
||||||
fullName: "",
|
|
||||||
email: "",
|
|
||||||
hireDate: new Date().toISOString().slice(0, 10),
|
|
||||||
departmentId: 0,
|
|
||||||
designationId: 0,
|
|
||||||
employmentTypeId: 0,
|
|
||||||
branchId: 0,
|
|
||||||
workShiftId: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EmployeesPage() {
|
export default function EmployeesPage() {
|
||||||
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
// The branch to pin the new hire to without asking: an Admin's currently-selected
|
|
||||||
// header branch, or a non-admin's own branch. Only when an Admin is on "All Branches"
|
|
||||||
// (no single branch implied) do they need the dropdown to pick one explicitly.
|
|
||||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
|
||||||
const lockedBranchName = isAdmin ? activeBranchName : myBranchName
|
|
||||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
|
||||||
|
|
||||||
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
|
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [q, setQ] = useState("")
|
const [pageSize, setPageSize] = useState(5)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [departments, setDepartments] = useState<Department[]>([])
|
const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null)
|
||||||
const [designations, setDesignations] = useState<Designation[]>([])
|
|
||||||
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
|
|
||||||
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
|
|
||||||
const [branches, setBranches] = useState<Branch[]>([])
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [form, setForm] = useState<CreateEmployeeRequest>(emptyForm)
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
|
|
||||||
// Advisory cross-link suggestion: does a System User already exist with this email?
|
|
||||||
const [userMatch, setUserMatch] = useState<UserMatch | null>(null)
|
|
||||||
const [checkingEmail, setCheckingEmail] = useState(false)
|
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
employeesApi
|
employeesApi
|
||||||
.list({ page, pageSize: PAGE_SIZE, q: q || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
|
.list({ page, pageSize, q: debouncedSearch || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
|
||||||
.then((res) => { setEmployees(res.items); setPagination(res.pagination) })
|
.then((res) => { setEmployees(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
useEffect(load, [page, q, activeBranchId, branchingEnabled])
|
useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled])
|
||||||
// Switching branch changes what "page 1" even means — stay on a page past the
|
// Switching branch changes what "page 1" even means — stay on a page past the
|
||||||
// new filtered result set otherwise.
|
// new filtered result set otherwise.
|
||||||
useEffect(() => setPage(1), [activeBranchId])
|
useEffect(() => setPage(1), [activeBranchId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([]))
|
const t = setTimeout(() => {
|
||||||
designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([]))
|
setPage(1)
|
||||||
employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([]))
|
setDebouncedSearch(search.trim())
|
||||||
workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([]))
|
}, 300)
|
||||||
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
return () => clearTimeout(t)
|
||||||
}, [branchingEnabled])
|
}, [search])
|
||||||
|
|
||||||
async function checkEmail(email: string) {
|
function loadStats() {
|
||||||
if (!email.trim()) { setUserMatch(null); return }
|
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||||
setCheckingEmail(true)
|
Promise.all([
|
||||||
try {
|
employeesApi.list({ page: 1, pageSize: 1, branchId }),
|
||||||
const { match } = await employeesApi.emailLookup(email.trim())
|
employeesApi.list({ page: 1, pageSize: 1, status: "Active", branchId }),
|
||||||
setUserMatch(match)
|
employeesApi.list({ page: 1, pageSize: 1, status: "Terminated", branchId }),
|
||||||
} catch {
|
])
|
||||||
setUserMatch(null)
|
.then(([totalRes, activeRes, terminatedRes]) =>
|
||||||
} finally {
|
setStats({ total: totalRes.pagination.totalItems, active: activeRes.pagination.totalItems, terminated: terminatedRes.pagination.totalItems })
|
||||||
setCheckingEmail(false)
|
)
|
||||||
}
|
.catch(() => setStats(null))
|
||||||
}
|
}
|
||||||
|
useEffect(loadStats, [activeBranchId, branchingEnabled])
|
||||||
|
|
||||||
function resetForm() {
|
// Permanent delete — blocked by the backend (409) if the employee has payroll history.
|
||||||
setForm(emptyForm)
|
async function handleDelete(e: EmployeeListItem) {
|
||||||
setUserMatch(null)
|
setActionPendingId(e.employeeId)
|
||||||
setErrors({})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
const nextErrors: Record<string, string> = {}
|
|
||||||
if (!form.employeeCode.trim()) nextErrors.employeeCode = "Employee code is required"
|
|
||||||
if (!form.fullName.trim()) nextErrors.fullName = "Full name is required"
|
|
||||||
if (!form.hireDate) nextErrors.hireDate = "Hire date is required"
|
|
||||||
if (!form.departmentId) nextErrors.departmentId = "Department is required"
|
|
||||||
if (!form.designationId) nextErrors.designationId = "Designation is required"
|
|
||||||
if (!form.employmentTypeId) nextErrors.employmentTypeId = "Employment type is required"
|
|
||||||
if (!form.workShiftId) nextErrors.workShiftId = "Work shift is required"
|
|
||||||
if (branchingEnabled && !form.branchId) nextErrors.branchId = "Branch is required"
|
|
||||||
setErrors(nextErrors)
|
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
|
||||||
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
try {
|
||||||
await employeesApi.create({
|
await employeesApi.remove(e.employeeId)
|
||||||
...form,
|
toast.success("Employee deleted", `${e.fullName} has been permanently removed.`)
|
||||||
email: form.email || null,
|
|
||||||
linkUserId: userMatch ? userMatch.userId : null,
|
|
||||||
})
|
|
||||||
toast.success("Employee created")
|
|
||||||
setOpen(false)
|
|
||||||
resetForm()
|
|
||||||
load()
|
load()
|
||||||
|
loadStats()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not create employee", errorMessage(err))
|
toast.error("Could not delete employee", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setActionPendingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,182 +88,261 @@ export default function EmployeesPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Employees</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Employees</h1>
|
||||||
|
{pagination && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{pagination.totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-base text-muted-foreground">Staff records — separate from system login accounts (see the link chip below).</p>
|
<p className="text-base text-muted-foreground">Staff records — separate from system login accounts (see the link chip below).</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog
|
|
||||||
open={open}
|
<Link href="/dashboard/hrm/employees/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
onOpenChange={(v) => {
|
<Plus className="size-5" />
|
||||||
setOpen(v)
|
New Employee
|
||||||
if (!v) resetForm()
|
</Link>
|
||||||
else if (lockedBranchId) setForm((f) => ({ ...f, branchId: lockedBranchId }))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Employee</Button>} />
|
|
||||||
<DialogContent className="sm:max-w-lg">
|
|
||||||
<DialogHeader className="items-center text-center">
|
|
||||||
<DialogTitle>New Employee</DialogTitle>
|
|
||||||
<DialogDescription>Not every employee needs a login — a system user account is optional and separate.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<FieldGroup>
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<Field data-invalid={!!errors.employeeCode}>
|
|
||||||
<FieldLabel htmlFor="e-code">Employee code</FieldLabel>
|
|
||||||
<Input id="e-code" value={form.employeeCode} onChange={(e) => setForm((f) => ({ ...f, employeeCode: e.target.value }))} aria-invalid={!!errors.employeeCode} />
|
|
||||||
<FieldError errors={[errors.employeeCode ? { message: errors.employeeCode } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.hireDate}>
|
|
||||||
<FieldLabel htmlFor="e-hire">Hire date</FieldLabel>
|
|
||||||
<Input id="e-hire" type="date" value={form.hireDate} onChange={(e) => setForm((f) => ({ ...f, hireDate: e.target.value }))} aria-invalid={!!errors.hireDate} />
|
|
||||||
<FieldError errors={[errors.hireDate ? { message: errors.hireDate } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<Field data-invalid={!!errors.fullName}>
|
|
||||||
<FieldLabel htmlFor="e-name">Full name</FieldLabel>
|
|
||||||
<Input id="e-name" value={form.fullName} onChange={(e) => setForm((f) => ({ ...f, fullName: e.target.value }))} aria-invalid={!!errors.fullName} />
|
|
||||||
<FieldError errors={[errors.fullName ? { message: errors.fullName } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="e-email">Email (optional)</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="e-email"
|
|
||||||
type="email"
|
|
||||||
value={form.email ?? ""}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
|
||||||
onBlur={(e) => checkEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
{checkingEmail && <p className="text-sm text-muted-foreground">Checking for an existing system user…</p>}
|
|
||||||
{userMatch && (
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm">
|
|
||||||
<Link2 className="size-4 shrink-0 text-primary" />
|
|
||||||
<span>
|
|
||||||
System user <strong>{userMatch.username}</strong> ({userMatch.displayName}) matches this email — it will be linked to this employee.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<Field data-invalid={!!errors.departmentId}>
|
|
||||||
<FieldLabel htmlFor="e-dept">Department</FieldLabel>
|
|
||||||
<Select value={String(form.departmentId)} onValueChange={(v) => setForm((f) => ({ ...f, departmentId: Number(v) }))}>
|
|
||||||
<SelectTrigger id="e-dept"><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.departmentId ? { message: errors.departmentId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.designationId}>
|
|
||||||
<FieldLabel htmlFor="e-desig">Designation</FieldLabel>
|
|
||||||
<Select value={String(form.designationId)} onValueChange={(v) => setForm((f) => ({ ...f, designationId: Number(v) }))}>
|
|
||||||
<SelectTrigger id="e-desig"><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.designationId ? { message: errors.designationId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.employmentTypeId}>
|
|
||||||
<FieldLabel htmlFor="e-etype">Employment type</FieldLabel>
|
|
||||||
<Select value={String(form.employmentTypeId)} onValueChange={(v) => setForm((f) => ({ ...f, employmentTypeId: Number(v) }))}>
|
|
||||||
<SelectTrigger id="e-etype"><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.employmentTypeId ? { message: errors.employmentTypeId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.workShiftId}>
|
|
||||||
<FieldLabel htmlFor="e-shift">Work shift</FieldLabel>
|
|
||||||
<Select value={String(form.workShiftId)} onValueChange={(v) => setForm((f) => ({ ...f, workShiftId: Number(v) }))}>
|
|
||||||
<SelectTrigger id="e-shift"><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.workShiftId ? { message: errors.workShiftId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
{branchingEnabled && (
|
|
||||||
<Field data-invalid={!!errors.branchId}>
|
|
||||||
<FieldLabel htmlFor="e-branch">Branch</FieldLabel>
|
|
||||||
{showBranchPicker ? (
|
|
||||||
<Select value={String(form.branchId)} onValueChange={(v) => setForm((f) => ({ ...f, branchId: v ? Number(v) : 0 }))}>
|
|
||||||
<SelectTrigger id="e-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<div id="e-branch" className="flex h-9 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
|
|
||||||
{lockedBranchName ?? "No branch assigned — contact an admin"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FieldGroup>
|
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
|
||||||
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input placeholder="Search by name or employee code…" value={q} onChange={(e) => { setPage(1); setQ(e.target.value) }} className="max-w-sm" />
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
{stats ? (
|
||||||
|
<>
|
||||||
|
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} />
|
||||||
|
<StatCard label="Active" value={stats.active} icon={UserCheck} />
|
||||||
|
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
|
||||||
|
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by name or employee code…"
|
||||||
|
className="h-10 pl-9 pr-9"
|
||||||
|
aria-label="Search employees"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!error && employees === null && (
|
{error && (
|
||||||
<div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && employees !== null && employees.length === 0 && (
|
{!error && employees === null && (
|
||||||
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||||
|
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="ml-4 h-7 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && employees !== null && employees.length === 0 && debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||||
|
<Search className="size-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No employees match "{debouncedSearch}"</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Try a different name or code.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && employees !== null && employees.length === 0 && !debouncedSearch && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
<UsersIcon className="size-12 text-muted-foreground" />
|
<UsersIcon className="size-12 text-muted-foreground" />
|
||||||
<p className="text-base text-muted-foreground">No employees yet.</p>
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No employees yet</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Add your first employee record to get started.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/hrm/employees/new" className={cn(buttonVariants())}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New Employee
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && employees !== null && employees.length > 0 && (
|
{!error && employees !== null && employees.length > 0 && (
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Department</TableHead>
|
<TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Designation</TableHead>
|
<TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Login</TableHead>
|
<TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
|
||||||
<TableBody>
|
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
|
||||||
{employees.map((e) => (
|
</TableRow>
|
||||||
<TableRow key={e.employeeId}>
|
</TableHeader>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{e.employeeCode}</TableCell>
|
<TableBody>
|
||||||
<TableCell className="px-3 py-3.5">{e.fullName}</TableCell>
|
{employees.map((e) => (
|
||||||
<TableCell className="px-3 py-3.5">{e.departmentName ?? "—"}</TableCell>
|
<TableRow key={e.employeeId} className="group align-top hover:bg-muted/20">
|
||||||
<TableCell className="px-3 py-3.5">{e.designationName ?? "—"}</TableCell>
|
<TableCell className="px-3 py-4">
|
||||||
<TableCell className="px-3 py-3.5">
|
<Link
|
||||||
{e.hasUserLink ? (
|
href={`/dashboard/hrm/employees/${e.employeeId}`}
|
||||||
<Badge variant="outline" className="h-6 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2.5 text-sm text-primary"><Link2 className="size-3.5" />Linked</Badge>
|
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
) : (
|
>
|
||||||
<span className="text-sm text-muted-foreground">No login</span>
|
{e.fullName}
|
||||||
)}
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", e.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{e.status}</Badge>
|
<TableCell className="px-3 py-4">
|
||||||
</TableCell>
|
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
|
||||||
<TableCell className="px-3 py-3.5">
|
</TableCell>
|
||||||
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`}>
|
|
||||||
<Pencil className="size-4" />
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.departmentName ?? "—"}</TableCell>
|
||||||
</Link>
|
|
||||||
</TableCell>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.designationName ?? "—"}</TableCell>
|
||||||
</TableRow>
|
|
||||||
))}
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
</TableBody>
|
{new Date(e.hireDate).toLocaleDateString(undefined, {
|
||||||
</Table>
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="px-3 py-4">
|
||||||
|
{e.hasUserLink ? (
|
||||||
|
<Badge variant="outline" className="h-5 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2 text-xs text-primary"><Link2 className="size-3.5" />Linked</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">No login</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="px-3 py-4">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
|
e.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{e.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="px-3 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${e.fullName}`}
|
||||||
|
title={`Delete ${e.fullName}`}
|
||||||
|
disabled={actionPendingId === e.employeeId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title={`Delete ${e.fullName}?`}
|
||||||
|
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => handleDelete(e)}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> employees
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
<Button
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
type="button"
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Plus } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Plus, Search, X, XCircle } from "lucide-react"
|
||||||
|
|
||||||
import { employeesApi } from "@/lib/api/employees"
|
import { employeesApi } from "@/lib/api/employees"
|
||||||
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
||||||
@@ -14,14 +14,17 @@ import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
|
const STATUSES: LeaveRequestStatus[] = ["Draft", "Submitted", "Approved", "Rejected", "Cancelled"]
|
||||||
|
const ALL_STATUS = "__all__"
|
||||||
|
|
||||||
const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
|
const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
|
||||||
Draft: "bg-muted text-muted-foreground",
|
Draft: "bg-muted text-muted-foreground",
|
||||||
@@ -36,6 +39,13 @@ export default function LeaveRequestsPage() {
|
|||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(5)
|
||||||
|
const [statusFilter, setStatusFilter] = useState(ALL_STATUS)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch
|
||||||
|
|
||||||
const [employees, setEmployees] = useState<EmployeeListItem[]>([])
|
const [employees, setEmployees] = useState<EmployeeListItem[]>([])
|
||||||
const [leaveTypes, setLeaveTypes] = useState<LeaveType[]>([])
|
const [leaveTypes, setLeaveTypes] = useState<LeaveType[]>([])
|
||||||
@@ -48,13 +58,32 @@ export default function LeaveRequestsPage() {
|
|||||||
const [reason, setReason] = useState("")
|
const [reason, setReason] = useState("")
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const [rejectTarget, setRejectTarget] = useState<LeaveRequest | null>(null)
|
||||||
|
const [rejectReason, setRejectReason] = useState("")
|
||||||
|
const [rejectError, setRejectError] = useState<string | null>(null)
|
||||||
|
const [rejecting, setRejecting] = useState(false)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
leaveRequestsApi
|
leaveRequestsApi
|
||||||
.list({ page, pageSize: PAGE_SIZE })
|
.list({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
q: debouncedSearch || undefined,
|
||||||
|
status: statusFilter === ALL_STATUS ? undefined : (statusFilter as LeaveRequestStatus),
|
||||||
|
})
|
||||||
.then((res) => { setRequests(res.items); setPagination(res.pagination) })
|
.then((res) => { setRequests(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
useEffect(load, [page])
|
|
||||||
|
useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setPage(1)
|
||||||
|
setDebouncedSearch(search.trim())
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
employeesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmployees(r.items)).catch(() => setEmployees([]))
|
employeesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmployees(r.items)).catch(() => setEmployees([]))
|
||||||
@@ -79,24 +108,38 @@ export default function LeaveRequestsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function approve(id: number) {
|
async function approve(id: number) {
|
||||||
|
setActionPendingId(id)
|
||||||
try {
|
try {
|
||||||
await leaveRequestsApi.approve(id)
|
await leaveRequestsApi.approve(id)
|
||||||
toast.success("Leave approved")
|
toast.success("Leave approved")
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not approve", errorMessage(err))
|
toast.error("Could not approve", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reject(id: number) {
|
function openReject(request: LeaveRequest) {
|
||||||
const reasonText = window.prompt("Reason for rejection:")
|
setRejectTarget(request)
|
||||||
if (!reasonText) return
|
setRejectReason("")
|
||||||
|
setRejectError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject() {
|
||||||
|
if (!rejectTarget) return
|
||||||
|
if (!rejectReason.trim()) { setRejectError("Reason is required"); return }
|
||||||
|
|
||||||
|
setRejecting(true)
|
||||||
try {
|
try {
|
||||||
await leaveRequestsApi.reject(id, reasonText)
|
await leaveRequestsApi.reject(rejectTarget.leaveRequestId, rejectReason.trim())
|
||||||
toast.success("Leave rejected")
|
toast.success("Leave rejected")
|
||||||
|
setRejectTarget(null)
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not reject", errorMessage(err))
|
toast.error("Could not reject", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setRejecting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +147,14 @@ export default function LeaveRequestsPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Leave</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Leave</h1>
|
||||||
|
{pagination && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{pagination.totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-base text-muted-foreground">Approved leave feeds Attendance's OnLeave status and Payroll's No-Pay calculation.</p>
|
<p className="text-base text-muted-foreground">Approved leave feeds Attendance's OnLeave status and Payroll's No-Pay calculation.</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -143,53 +193,228 @@ export default function LeaveRequestsPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={!!rejectTarget} onOpenChange={(v) => !v && setRejectTarget(null)}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>Reject leave request</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{rejectTarget && <>Rejecting {rejectTarget.docNo} for {rejectTarget.employeeName ?? "this employee"}. The employee will see this reason.</>}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={!!rejectError}>
|
||||||
|
<FieldLabel htmlFor="lv-reject-reason">Reason for rejection</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
id="lv-reject-reason"
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => { setRejectReason(e.target.value); setRejectError(null) }}
|
||||||
|
aria-invalid={!!rejectError}
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<FieldError errors={[rejectError ? { message: rejectError } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setRejectTarget(null)} disabled={rejecting}>Cancel</Button>
|
||||||
|
<Button variant="destructive" className="w-full sm:w-auto sm:min-w-32" onClick={handleReject} disabled={rejecting}>
|
||||||
|
{rejecting ? "Rejecting…" : "Reject"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
|
||||||
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by doc no or employee…"
|
||||||
|
className="h-10 pl-9 pr-9"
|
||||||
|
aria-label="Search leave requests"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => { setPage(1); setStatusFilter(ALL_STATUS); setSearch("") }}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex w-full flex-col gap-1.5 sm:ml-auto sm:max-w-52">
|
||||||
|
<label htmlFor="lv-filter-status" className="text-xs font-medium text-muted-foreground">Status</label>
|
||||||
|
<Select value={statusFilter} onValueChange={(v) => { setPage(1); setStatusFilter(v ?? ALL_STATUS) }}>
|
||||||
|
<SelectTrigger id="lv-filter-status" aria-label="Filter by status"><SelectValue placeholder="All statuses" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL_STATUS}>All statuses</SelectItem>
|
||||||
|
{STATUSES.map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
{!error && requests === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
|
||||||
|
{!error && requests === null && (
|
||||||
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4 border-b px-4 py-4 last:border-b-0">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-4 w-10" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!error && requests !== null && requests.length === 0 && (
|
{!error && requests !== null && requests.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No leave requests yet.</p></div>
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<p className="text-base text-muted-foreground">No leave requests yet.</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && requests !== null && requests.length > 0 && (
|
{!error && requests !== null && requests.length > 0 && (
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Dates</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Days</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Dates</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Days</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
|
||||||
<TableBody>
|
</TableRow>
|
||||||
{requests.map((r) => (
|
</TableHeader>
|
||||||
<TableRow key={r.leaveRequestId}>
|
<TableBody>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
|
{requests.map((r) => (
|
||||||
<TableCell className="px-3 py-3.5">{r.employeeName ?? "—"}</TableCell>
|
<TableRow key={r.leaveRequestId} className="hover:bg-muted/20">
|
||||||
<TableCell className="px-3 py-3.5">{r.leaveTypeName ?? "—"}</TableCell>
|
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}</TableCell>
|
<TableCell className="px-4 py-4 text-sm text-foreground">{r.employeeName ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{r.daysCount}</TableCell>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.leaveTypeName ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>{r.status}</Badge></TableCell>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
<TableCell className="px-3 py-3.5">
|
{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
|
||||||
{r.status === "Submitted" && (
|
</TableCell>
|
||||||
<div className="flex gap-1">
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.daysCount}</TableCell>
|
||||||
<Button variant="ghost" size="sm" onClick={() => approve(r.leaveRequestId)}>Approve</Button>
|
<TableCell className="px-4 py-4">
|
||||||
<Button variant="ghost" size="sm" onClick={() => reject(r.leaveRequestId)}>Reject</Button>
|
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
|
||||||
</div>
|
{r.status}
|
||||||
)}
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
<TableCell className="px-4 py-4">
|
||||||
))}
|
{r.status === "Submitted" ? (
|
||||||
</TableBody>
|
<div className="flex items-center justify-center gap-1">
|
||||||
</Table>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-success hover:bg-success/10"
|
||||||
|
aria-label={`Approve ${r.docNo}`}
|
||||||
|
title={`Approve ${r.docNo}`}
|
||||||
|
disabled={actionPendingId === r.leaveRequestId}
|
||||||
|
onClick={() => approve(r.leaveRequestId)}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10"
|
||||||
|
aria-label={`Reject ${r.docNo}`}
|
||||||
|
title={`Reject ${r.docNo}`}
|
||||||
|
disabled={actionPendingId === r.leaveRequestId}
|
||||||
|
onClick={() => openReject(r)}
|
||||||
|
>
|
||||||
|
<XCircle className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="flex justify-center text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{pagination && pagination.totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between">
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave requests
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
<Button
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
type="button"
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
import { useParams } from "next/navigation"
|
import { useParams } from "next/navigation"
|
||||||
|
import { ArrowLeft, Download, Printer } from "lucide-react"
|
||||||
|
|
||||||
import { payrollRunsApi } from "@/lib/api/payroll"
|
import { payrollRunsApi } from "@/lib/api/payroll"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { PayrollLineDetail } from "@/types/hrm"
|
import { buildSimplePdf, downloadPdf, PdfLine } from "@/lib/simple-pdf"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { PayrollLineDetail, PayrollRun } from "@/types/hrm"
|
||||||
|
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
function money(n: number): string {
|
function money(n: number): string {
|
||||||
@@ -18,43 +23,119 @@ export default function PayrollLineDetailPage() {
|
|||||||
const runId = Number(params.id)
|
const runId = Number(params.id)
|
||||||
const lineId = Number(params.lineId)
|
const lineId = Number(params.lineId)
|
||||||
|
|
||||||
|
const [run, setRun] = useState<PayrollRun | null>(null)
|
||||||
const [detail, setDetail] = useState<PayrollLineDetail | null>(null)
|
const [detail, setDetail] = useState<PayrollLineDetail | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
payrollRunsApi.getLine(runId, lineId).then(setDetail).catch((err) => setError(errorMessage(err)))
|
Promise.all([payrollRunsApi.get(runId), payrollRunsApi.getLine(runId, lineId)])
|
||||||
|
.then(([r, d]) => { setRun(r); setDetail(d) })
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [runId, lineId])
|
}, [runId, lineId])
|
||||||
|
|
||||||
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
if (!detail) return <div className="flex flex-col gap-3">{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}</div>
|
if (!detail || !run) return <div className="flex flex-col gap-3">{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}</div>
|
||||||
|
|
||||||
const { line, components } = detail
|
const { line, components } = detail
|
||||||
const earnings = components.filter((c) => c.componentCategory === "Earning")
|
const earnings = components.filter((c) => c.componentCategory === "Earning")
|
||||||
const deductions = components.filter((c) => c.componentCategory === "Deduction")
|
const deductions = components.filter((c) => c.componentCategory === "Deduction")
|
||||||
const employerContributions = components.filter((c) => c.componentCategory === "EmployerContribution")
|
const employerContributions = components.filter((c) => c.componentCategory === "EmployerContribution")
|
||||||
|
const period = `${run.periodMonth.toString().padStart(2, "0")}/${run.periodYear}`
|
||||||
|
|
||||||
|
function handleDownload() {
|
||||||
|
if (!run) return
|
||||||
|
const left = 56
|
||||||
|
const right = 556
|
||||||
|
const muted: [number, number, number] = [0.45, 0.45, 0.45]
|
||||||
|
const destructive: [number, number, number] = [0.7, 0.15, 0.15]
|
||||||
|
const lines: PdfLine[] = []
|
||||||
|
let y = 740
|
||||||
|
|
||||||
|
lines.push({ text: "Salary Breakdown", x: left, y, size: 18, bold: true })
|
||||||
|
y -= 22
|
||||||
|
lines.push({ text: `${line.employeeName ?? ""} (${line.employeeCode ?? ""})`, x: left, y, size: 11, color: muted })
|
||||||
|
lines.push({ text: `Period ${period}`, x: right - 90, y, size: 11, color: muted })
|
||||||
|
y -= 14
|
||||||
|
lines.push({ text: run.docNo, x: right - 90, y, size: 9, color: muted })
|
||||||
|
y -= 28
|
||||||
|
|
||||||
|
const row = (label: string, amount: number, opts: { bold?: boolean; size?: number; color?: [number, number, number] } = {}) => {
|
||||||
|
lines.push({ text: label, x: left, y, size: opts.size ?? 11, bold: opts.bold, color: opts.color })
|
||||||
|
lines.push({ text: money(amount), x: right - money(amount).length * 6, y, size: opts.size ?? 11, bold: opts.bold, color: opts.color })
|
||||||
|
y -= 18
|
||||||
|
}
|
||||||
|
|
||||||
|
earnings.forEach((c) => row(c.label, c.amount))
|
||||||
|
row("Gross Salary", line.grossSalary, { bold: true })
|
||||||
|
y -= 10
|
||||||
|
lines.push({ text: "Deductions", x: left, y, size: 10, bold: true, color: muted })
|
||||||
|
y -= 18
|
||||||
|
deductions.forEach((c) => row(c.label, c.amount, { color: destructive }))
|
||||||
|
row("Net Salary", line.netSalary, { bold: true, size: 13 })
|
||||||
|
|
||||||
|
if (employerContributions.length > 0) {
|
||||||
|
y -= 10
|
||||||
|
lines.push({ text: "Employer Contributions (informational — not deducted)", x: left, y, size: 9, bold: true, color: muted })
|
||||||
|
y -= 16
|
||||||
|
employerContributions.forEach((c) => row(c.label, c.amount, { size: 10, color: muted }))
|
||||||
|
}
|
||||||
|
|
||||||
|
y -= 20
|
||||||
|
lines.push({
|
||||||
|
text: `Present ${line.presentDays} Absent ${line.absentDays} Leave ${line.leaveDays} OT ${line.otMinutesTotal} min Late ${line.lateMinutesTotal} min`,
|
||||||
|
x: left, y, size: 9, color: muted,
|
||||||
|
})
|
||||||
|
|
||||||
|
const blob = buildSimplePdf(lines)
|
||||||
|
downloadPdf(blob, `salary-breakdown-${line.employeeCode ?? line.employeeId}-${period.replace("/", "-")}.pdf`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-lg flex-col gap-6">
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||||||
<div>
|
<div className="flex flex-col gap-3 print:hidden sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1>
|
<Link href={`/dashboard/hrm/payroll/${runId}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }), "w-fit")}>
|
||||||
<p className="text-base text-muted-foreground">{line.employeeName} ({line.employeeCode})</p>
|
<ArrowLeft className="size-4" />
|
||||||
|
Back to payroll run
|
||||||
|
</Link>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => window.print()}>
|
||||||
|
<Printer className="size-4" />
|
||||||
|
Print
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border bg-card p-6 shadow-sm">
|
<div className="rounded-2xl border bg-card p-6 shadow-sm sm:p-8 print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
||||||
<table className="w-full text-base">
|
<div className="flex items-start justify-between gap-4 border-b border-border pb-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1>
|
||||||
|
<p className="mt-1 text-base text-muted-foreground">{line.employeeName} <span className="font-mono">({line.employeeCode})</span></p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-muted-foreground">Period</p>
|
||||||
|
<p className="font-semibold text-foreground">{period}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="mt-5 w-full text-base">
|
||||||
<tbody>
|
<tbody>
|
||||||
{earnings.map((c, i) => (
|
{earnings.map((c, i) => (
|
||||||
<tr key={i}><td className="py-1.5">{c.label}</td><td className="py-1.5 text-right">{money(c.amount)}</td></tr>
|
<tr key={i}><td className="py-1.5 text-muted-foreground">{c.label}</td><td className="py-1.5 text-right font-medium text-foreground">{money(c.amount)}</td></tr>
|
||||||
))}
|
))}
|
||||||
<tr className="border-t font-semibold"><td className="py-2">Gross Salary</td><td className="py-2 text-right">{money(line.grossSalary)}</td></tr>
|
<tr className="border-t border-border font-semibold text-foreground"><td className="py-2.5">Gross Salary</td><td className="py-2.5 text-right">{money(line.grossSalary)}</td></tr>
|
||||||
<tr><td className="pt-4 text-sm font-semibold text-muted-foreground" colSpan={2}>Deductions</td></tr>
|
<tr><td className="pt-5 pb-1 text-sm font-semibold text-muted-foreground" colSpan={2}>Deductions</td></tr>
|
||||||
{deductions.map((c, i) => (
|
{deductions.map((c, i) => (
|
||||||
<tr key={i}><td className="py-1.5">{c.label}</td><td className="py-1.5 text-right">{money(c.amount)}</td></tr>
|
<tr key={i}><td className="py-1.5 text-muted-foreground">{c.label}</td><td className="py-1.5 text-right font-medium text-destructive">−{money(c.amount)}</td></tr>
|
||||||
))}
|
))}
|
||||||
<tr className="border-t font-bold"><td className="py-2">Net Salary</td><td className="py-2 text-right">{money(line.netSalary)}</td></tr>
|
<tr className="border-t border-border text-lg font-bold text-foreground"><td className="py-3">Net Salary</td><td className="py-3 text-right">{money(line.netSalary)}</td></tr>
|
||||||
{employerContributions.length > 0 && (
|
{employerContributions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<tr><td className="pt-4 text-sm font-semibold text-muted-foreground" colSpan={2}>Employer Contributions (informational — not deducted)</td></tr>
|
<tr><td className="pt-5 pb-1 text-sm font-semibold text-muted-foreground" colSpan={2}>Employer Contributions (informational — not deducted)</td></tr>
|
||||||
{employerContributions.map((c, i) => (
|
{employerContributions.map((c, i) => (
|
||||||
<tr key={i} className="text-muted-foreground"><td className="py-1.5">{c.label}</td><td className="py-1.5 text-right">{money(c.amount)}</td></tr>
|
<tr key={i} className="text-muted-foreground"><td className="py-1.5">{c.label}</td><td className="py-1.5 text-right">{money(c.amount)}</td></tr>
|
||||||
))}
|
))}
|
||||||
@@ -64,7 +145,7 @@ export default function PayrollLineDetailPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm text-muted-foreground sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 rounded-xl border border-dashed p-4 text-sm text-muted-foreground sm:grid-cols-5 print:break-inside-avoid">
|
||||||
<div>Present days: {line.presentDays}</div>
|
<div>Present days: {line.presentDays}</div>
|
||||||
<div>Absent days: {line.absentDays}</div>
|
<div>Absent days: {line.absentDays}</div>
|
||||||
<div>Leave days: {line.leaveDays}</div>
|
<div>Leave days: {line.leaveDays}</div>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useParams } from "next/navigation"
|
import { useParams } from "next/navigation"
|
||||||
|
import { ChevronLeft, ChevronRight, Eye, FileText, Search, X } from "lucide-react"
|
||||||
|
|
||||||
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
|
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { PayrollLine, PayrollRun, Payslip } from "@/types/hrm"
|
import { PayrollLine, PayrollRun, Payslip } from "@/types/hrm"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
@@ -18,6 +19,8 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||||
|
|
||||||
function money(n: number): string {
|
function money(n: number): string {
|
||||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
}
|
}
|
||||||
@@ -33,6 +36,9 @@ export default function PayrollRunDetailPage() {
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [unlockOpen, setUnlockOpen] = useState(false)
|
const [unlockOpen, setUnlockOpen] = useState(false)
|
||||||
const [unlockReason, setUnlockReason] = useState("")
|
const [unlockReason, setUnlockReason] = useState("")
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(5)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
Promise.all([payrollRunsApi.get(runId), payrollRunsApi.listLines(runId)])
|
Promise.all([payrollRunsApi.get(runId), payrollRunsApi.listLines(runId)])
|
||||||
@@ -86,6 +92,15 @@ export default function PayrollRunDetailPage() {
|
|||||||
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
if (!run || !lines) return <div className="flex flex-col gap-3">{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
|
if (!run || !lines) return <div className="flex flex-col gap-3">{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
|
||||||
|
|
||||||
|
const filteredLines = lines.filter((l) => {
|
||||||
|
const term = search.trim().toLowerCase()
|
||||||
|
if (!term) return true
|
||||||
|
return (l.employeeName ?? "").toLowerCase().includes(term) || (l.employeeCode ?? "").toLowerCase().includes(term)
|
||||||
|
})
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredLines.length / pageSize))
|
||||||
|
const currentPage = Math.min(page, totalPages)
|
||||||
|
const pagedLines = filteredLines.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -128,43 +143,150 @@ export default function PayrollRunDetailPage() {
|
|||||||
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Deductions</p><p className="text-xl font-bold">{money(run.totalGross - run.totalNet)}</p></div>
|
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Deductions</p><p className="text-xl font-bold">{money(run.totalGross - run.totalNet)}</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table className="text-base">
|
<div className="flex items-center gap-2.5">
|
||||||
<TableHeader>
|
<h2 className="text-lg font-semibold text-foreground">Payroll lines</h2>
|
||||||
<TableRow>
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">{lines.length}</span>
|
||||||
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
|
</div>
|
||||||
<TableHead className="h-12 px-3 text-sm">Basic</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">OT</TableHead>
|
<div className="relative w-full sm:max-w-xs">
|
||||||
<TableHead className="h-12 px-3 text-sm">Allowances</TableHead>
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<TableHead className="h-12 px-3 text-sm">Deductions</TableHead>
|
<Input
|
||||||
<TableHead className="h-12 px-3 text-sm">Net Salary</TableHead>
|
value={search}
|
||||||
<TableHead className="h-12 px-3 text-sm">Details</TableHead>
|
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||||
</TableRow>
|
placeholder="Search by name or employee code…"
|
||||||
</TableHeader>
|
className="h-10 pl-9 pr-9"
|
||||||
<TableBody>
|
aria-label="Search payroll lines"
|
||||||
{lines.map((l) => {
|
/>
|
||||||
const totalDeductions = l.grossSalary - l.netSalary
|
{search && (
|
||||||
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
|
<button
|
||||||
return (
|
type="button"
|
||||||
<TableRow key={l.payrollLineId}>
|
onClick={() => { setSearch(""); setPage(1) }}
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{l.employeeName} <span className="text-muted-foreground">({l.employeeCode})</span></TableCell>
|
aria-label="Clear search"
|
||||||
<TableCell className="px-3 py-3.5">{money(l.basicSalary)}</TableCell>
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
<TableCell className="px-3 py-3.5">{money(l.overtimeAmount)}</TableCell>
|
>
|
||||||
<TableCell className="px-3 py-3.5">{money(l.totalAllowances)}</TableCell>
|
<X className="size-4" />
|
||||||
<TableCell className="px-3 py-3.5">{money(totalDeductions)}</TableCell>
|
</button>
|
||||||
<TableCell className="px-3 py-3.5 font-semibold">{money(l.netSalary)}</TableCell>
|
)}
|
||||||
<TableCell className="px-3 py-3.5">
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`} className="text-primary underline underline-offset-2">Breakdown</Link>
|
{filteredLines.length === 0 ? (
|
||||||
{payslip && (
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||||
<a href={payslipViewUrl(payslip.payslipId)} target="_blank" rel="noreferrer" className={cn("text-primary underline underline-offset-2")}>Payslip</a>
|
<Search className="size-10 text-muted-foreground" />
|
||||||
)}
|
<p className="text-base text-muted-foreground">
|
||||||
</div>
|
{search ? <>No lines match "{search}".</> : "No payroll lines yet."}
|
||||||
</TableCell>
|
</p>
|
||||||
</TableRow>
|
</div>
|
||||||
)
|
) : (
|
||||||
})}
|
<div className="overflow-hidden rounded-xl border">
|
||||||
</TableBody>
|
<div className="overflow-x-auto">
|
||||||
</Table>
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">OT</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Allowances</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Deductions</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Net Salary</TableHead>
|
||||||
|
<TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pagedLines.map((l) => {
|
||||||
|
const totalDeductions = l.grossSalary - l.netSalary
|
||||||
|
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
|
||||||
|
return (
|
||||||
|
<TableRow key={l.payrollLineId} className="hover:bg-muted/20">
|
||||||
|
<TableCell className="px-4 py-3.5 font-medium text-foreground">
|
||||||
|
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3.5">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`View breakdown for ${l.employeeName}`}
|
||||||
|
title="View breakdown"
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
|
{payslip && (
|
||||||
|
<a
|
||||||
|
href={payslipViewUrl(payslip.payslipId)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`View payslip for ${l.employeeName}`}
|
||||||
|
title="View payslip"
|
||||||
|
>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredLines.length > 0 && (
|
||||||
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing <span className="font-medium text-foreground">{(currentPage - 1) * pageSize + 1}</span>–
|
||||||
|
<span className="font-medium text-foreground">{Math.min(currentPage * pageSize, filteredLines.length)}</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{filteredLines.length}</span> lines
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Eye, Plus } from "lucide-react"
|
import { ChevronLeft, ChevronRight, Eye, Plus } from "lucide-react"
|
||||||
|
|
||||||
import { payrollRunsApi } from "@/lib/api/payroll"
|
import { payrollRunsApi } from "@/lib/api/payroll"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -15,11 +15,14 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
|
const STATUSES: PayrollRunStatus[] = ["Draft", "Approved", "Locked"]
|
||||||
|
const ALL_STATUS = "__all__"
|
||||||
|
|
||||||
const STATUS_STYLE: Record<PayrollRunStatus, string> = {
|
const STATUS_STYLE: Record<PayrollRunStatus, string> = {
|
||||||
Draft: "bg-muted text-muted-foreground",
|
Draft: "bg-muted text-muted-foreground",
|
||||||
@@ -32,6 +35,8 @@ export default function PayrollRunsPage() {
|
|||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(5)
|
||||||
|
const [statusFilter, setStatusFilter] = useState(ALL_STATUS)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -41,11 +46,12 @@ export default function PayrollRunsPage() {
|
|||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
payrollRunsApi
|
payrollRunsApi
|
||||||
.list({ page, pageSize: PAGE_SIZE })
|
.list({ page, pageSize, status: statusFilter === ALL_STATUS ? undefined : (statusFilter as PayrollRunStatus) })
|
||||||
.then((res) => { setRuns(res.items); setPagination(res.pagination) })
|
.then((res) => { setRuns(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
useEffect(load, [page])
|
|
||||||
|
useEffect(load, [page, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
async function handleGenerate() {
|
async function handleGenerate() {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
@@ -65,7 +71,14 @@ export default function PayrollRunsPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Payroll</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Payroll</h1>
|
||||||
|
{pagination && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{pagination.totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-base text-muted-foreground">Generate → Review → Approve → Lock → Payslips.</p>
|
<p className="text-base text-muted-foreground">Generate → Review → Approve → Lock → Payslips.</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -89,48 +102,140 @@ export default function PayrollRunsPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full sm:max-w-xs">
|
||||||
|
<Select value={statusFilter} onValueChange={(v) => { setPage(1); setStatusFilter(v ?? ALL_STATUS) }}>
|
||||||
|
<SelectTrigger aria-label="Filter by status"><SelectValue placeholder="All statuses" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL_STATUS}>All statuses</SelectItem>
|
||||||
|
{STATUSES.map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
{!error && runs === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
|
||||||
|
{!error && runs === null && (
|
||||||
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4 border-b px-4 py-4 last:border-b-0">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!error && runs !== null && runs.length === 0 && (
|
{!error && runs !== null && runs.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No payroll runs yet.</p></div>
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<p className="text-base text-muted-foreground">No payroll runs yet.</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && runs !== null && runs.length > 0 && (
|
{!error && runs !== null && runs.length > 0 && (
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Employees</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Gross</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Period</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Net</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Employees</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
|
||||||
<TableBody>
|
</TableRow>
|
||||||
{runs.map((r) => (
|
</TableHeader>
|
||||||
<TableRow key={r.payrollRunId}>
|
<TableBody>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
|
{runs.map((r) => (
|
||||||
<TableCell className="px-3 py-3.5">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
|
<TableRow key={r.payrollRunId} className="hover:bg-muted/20">
|
||||||
<TableCell className="px-3 py-3.5">{r.employeeCount}</TableCell>
|
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
<TableCell className="px-4 py-4 text-sm text-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>{r.status}</Badge></TableCell>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell>
|
||||||
<Link href={`/dashboard/hrm/payroll/${r.payrollRunId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label="View"><Eye className="size-4" /></Link>
|
<TableCell className="px-4 py-4">
|
||||||
</TableCell>
|
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
|
||||||
</TableRow>
|
{r.status}
|
||||||
))}
|
</Badge>
|
||||||
</TableBody>
|
</TableCell>
|
||||||
</Table>
|
<TableCell className="px-4 py-4">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/hrm/payroll/${r.payrollRunId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`View ${r.docNo}`}
|
||||||
|
title={`View ${r.docNo}`}
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{pagination && pagination.totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between">
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> payroll runs
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
<Button
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
type="button"
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { BranchDetailPage } from "@/components/settings/BranchDetailPage"
|
||||||
|
|
||||||
|
export default function BranchDetail() {
|
||||||
|
return <BranchDetailPage />
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Plus } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||||
|
|
||||||
import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
|
import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { Branch, Department } from "@/types/hrm"
|
import { Branch, Department } from "@/types/hrm"
|
||||||
|
|
||||||
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -19,7 +20,7 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
const NONE = "__none__"
|
const NONE = "__none__"
|
||||||
|
|
||||||
export default function DepartmentsPage() {
|
export default function DepartmentsPage() {
|
||||||
@@ -28,6 +29,10 @@ export default function DepartmentsPage() {
|
|||||||
const [branches, setBranches] = useState<Branch[]>([])
|
const [branches, setBranches] = useState<Branch[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(5)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
@@ -37,9 +42,18 @@ export default function DepartmentsPage() {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const [editItem, setEditItem] = useState<Department | null>(null)
|
||||||
|
const [editEtag, setEditEtag] = useState<string | null>(null)
|
||||||
|
const [editName, setEditName] = useState("")
|
||||||
|
const [editParentDepartmentId, setEditParentDepartmentId] = useState(NONE)
|
||||||
|
const [editBranchId, setEditBranchId] = useState(NONE)
|
||||||
|
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||||
|
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||||
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
departmentsApi
|
departmentsApi
|
||||||
.list({ page, pageSize: PAGE_SIZE })
|
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setItems(res.items)
|
setItems(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
@@ -47,7 +61,16 @@ export default function DepartmentsPage() {
|
|||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page])
|
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setPage(1)
|
||||||
|
setDebouncedSearch(search.trim())
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((res) => setBranches(res.items)).catch(() => setBranches([]))
|
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((res) => setBranches(res.items)).catch(() => setBranches([]))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -81,12 +104,71 @@ export default function DepartmentsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openEdit(item: Department) {
|
||||||
|
setEditItem(item)
|
||||||
|
setEditName(item.name)
|
||||||
|
setEditParentDepartmentId(item.parentDepartmentId ? String(item.parentDepartmentId) : NONE)
|
||||||
|
setEditBranchId(item.branchId ? String(item.branchId) : NONE)
|
||||||
|
setEditErrors({})
|
||||||
|
setEditEtag(null)
|
||||||
|
setEditLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await departmentsApi.get(item.departmentId)
|
||||||
|
setEditEtag(res.etag)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not load department", errorMessage(err))
|
||||||
|
setEditItem(null)
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave() {
|
||||||
|
if (!editItem || !editEtag) return
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!editName.trim()) nextErrors.name = "Name is required"
|
||||||
|
setEditErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setEditSubmitting(true)
|
||||||
|
try {
|
||||||
|
await departmentsApi.update(editItem.departmentId, {
|
||||||
|
name: editName.trim(),
|
||||||
|
parentDepartmentId: editParentDepartmentId === NONE ? null : Number(editParentDepartmentId),
|
||||||
|
branchId: editBranchId === NONE ? null : Number(editBranchId),
|
||||||
|
}, editEtag)
|
||||||
|
toast.success("Department updated")
|
||||||
|
setEditItem(null)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not save", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setEditSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleStatus(item: Department) {
|
async function toggleStatus(item: Department) {
|
||||||
|
setActionPendingId(item.departmentId)
|
||||||
try {
|
try {
|
||||||
await departmentsApi.updateStatus(item.departmentId, item.status === "Active" ? "Inactive" : "Active")
|
await departmentsApi.updateStatus(item.departmentId, item.status === "Active" ? "Inactive" : "Active")
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not update status", errorMessage(err))
|
toast.error("Could not update status", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: Department) {
|
||||||
|
setActionPendingId(item.departmentId)
|
||||||
|
try {
|
||||||
|
await departmentsApi.remove(item.departmentId)
|
||||||
|
toast.success("Department deleted")
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not delete department", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +176,14 @@ export default function DepartmentsPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Departments</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Departments</h1>
|
||||||
|
{pagination && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{pagination.totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-base text-muted-foreground">Org structure — unlimited nesting, optionally scoped to a branch.</p>
|
<p className="text-base text-muted-foreground">Org structure — unlimited nesting, optionally scoped to a branch.</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -148,48 +237,268 @@ export default function DepartmentsPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>Edit Department</DialogTitle>
|
||||||
|
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="d-edit-code">Code</FieldLabel>
|
||||||
|
<Input id="d-edit-code" value={editItem?.code ?? ""} disabled />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!editErrors.name}>
|
||||||
|
<FieldLabel htmlFor="d-edit-name">Name</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="d-edit-name"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
aria-invalid={!!editErrors.name}
|
||||||
|
disabled={editLoading}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="d-edit-parent">Parent department (optional)</FieldLabel>
|
||||||
|
<Select value={editParentDepartmentId} onValueChange={(v) => setEditParentDepartmentId(v ?? NONE)} disabled={editLoading}>
|
||||||
|
<SelectTrigger id="d-edit-parent"><SelectValue placeholder="None" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE}>None</SelectItem>
|
||||||
|
{(items ?? []).filter((d) => d.departmentId !== editItem?.departmentId).map((d) => (
|
||||||
|
<SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
|
||||||
|
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
|
||||||
|
<SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE}>None</SelectItem>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
|
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||||
|
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||||
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by code or name…"
|
||||||
|
className="h-10 pl-9 pr-9"
|
||||||
|
aria-label="Search departments"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
|
||||||
{!error && items !== null && items.length === 0 && (
|
{!error && items === null && (
|
||||||
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||||
|
<Search className="size-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No departments match "{debouncedSearch}"</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
<p className="text-base text-muted-foreground">No departments yet.</p>
|
<p className="text-base text-muted-foreground">No departments yet.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="table-fixed text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Parent</TableHead>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||||
<TableBody>
|
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||||
{items.map((d) => (
|
</TableRow>
|
||||||
<TableRow key={d.departmentId}>
|
</TableHeader>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{d.code}</TableCell>
|
<TableBody>
|
||||||
<TableCell className="px-3 py-3.5">{d.name}</TableCell>
|
{items.map((d) => (
|
||||||
<TableCell className="px-3 py-3.5">{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}</TableCell>
|
<TableRow key={d.departmentId} className="group align-top hover:bg-muted/20">
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{d.code}</TableCell>
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{d.status}</Badge>
|
<TableCell className="px-4 py-4 text-sm text-foreground">{d.name}</TableCell>
|
||||||
</TableCell>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
<TableCell className="px-3 py-3.5">
|
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
|
||||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(d)}>{d.status === "Active" ? "Deactivate" : "Activate"}</Button>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="px-4 py-4">
|
||||||
</TableRow>
|
<Badge
|
||||||
))}
|
variant="outline"
|
||||||
</TableBody>
|
className={cn(
|
||||||
</Table>
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
|
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{d.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
{new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Edit ${d.name}`}
|
||||||
|
title={`Edit ${d.name}`}
|
||||||
|
onClick={() => openEdit(d)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
d.status === "Active"
|
||||||
|
? "text-destructive hover:bg-destructive/10"
|
||||||
|
: "text-success hover:bg-success/10"
|
||||||
|
)}
|
||||||
|
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
||||||
|
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
|
||||||
|
disabled={actionPendingId === d.departmentId}
|
||||||
|
onClick={() => toggleStatus(d)}
|
||||||
|
>
|
||||||
|
{d.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${d.name}`}
|
||||||
|
title={`Delete ${d.name}`}
|
||||||
|
disabled={actionPendingId === d.departmentId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title={`Delete ${d.name}?`}
|
||||||
|
description="This permanently removes this department. This cannot be undone. Blocked if it has sub-departments or employees assigned — deactivate it instead in that case."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => handleDelete(d)}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{pagination && pagination.totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between">
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> departments
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
<Button
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
type="button"
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Plus } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||||
|
|
||||||
import { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
|
import { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { HrDocumentCategory, HrDocumentType } from "@/types/hrm"
|
import { HrDocumentCategory, HrDocumentType } from "@/types/hrm"
|
||||||
|
|
||||||
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -20,7 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
|
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
|
||||||
|
|
||||||
export default function DocumentTypesPage() {
|
export default function DocumentTypesPage() {
|
||||||
@@ -28,6 +29,10 @@ export default function DocumentTypesPage() {
|
|||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(5)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
@@ -38,13 +43,32 @@ export default function DocumentTypesPage() {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const [editItem, setEditItem] = useState<HrDocumentType | null>(null)
|
||||||
|
const [editEtag, setEditEtag] = useState<string | null>(null)
|
||||||
|
const [editName, setEditName] = useState("")
|
||||||
|
const [editCategory, setEditCategory] = useState<HrDocumentCategory>("Identity")
|
||||||
|
const [editRequiredAtOnboarding, setEditRequiredAtOnboarding] = useState(false)
|
||||||
|
const [editExpiryTracked, setEditExpiryTracked] = useState(false)
|
||||||
|
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||||
|
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||||
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
hrDocumentTypesApi
|
hrDocumentTypesApi
|
||||||
.list({ page, pageSize: PAGE_SIZE })
|
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||||
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
useEffect(load, [page])
|
|
||||||
|
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setPage(1)
|
||||||
|
setDebouncedSearch(search.trim())
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
@@ -68,12 +92,73 @@ export default function DocumentTypesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openEdit(item: HrDocumentType) {
|
||||||
|
setEditItem(item)
|
||||||
|
setEditName(item.name)
|
||||||
|
setEditCategory(item.category)
|
||||||
|
setEditRequiredAtOnboarding(item.requiredAtOnboarding)
|
||||||
|
setEditExpiryTracked(item.expiryTracked)
|
||||||
|
setEditErrors({})
|
||||||
|
setEditEtag(null)
|
||||||
|
setEditLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await hrDocumentTypesApi.get(item.hrDocumentTypeId)
|
||||||
|
setEditEtag(res.etag)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not load document type", errorMessage(err))
|
||||||
|
setEditItem(null)
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave() {
|
||||||
|
if (!editItem || !editEtag) return
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!editName.trim()) nextErrors.name = "Name is required"
|
||||||
|
setEditErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setEditSubmitting(true)
|
||||||
|
try {
|
||||||
|
await hrDocumentTypesApi.update(editItem.hrDocumentTypeId, {
|
||||||
|
name: editName.trim(),
|
||||||
|
category: editCategory,
|
||||||
|
requiredAtOnboarding: editRequiredAtOnboarding,
|
||||||
|
expiryTracked: editExpiryTracked,
|
||||||
|
}, editEtag)
|
||||||
|
toast.success("Document type updated")
|
||||||
|
setEditItem(null)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not save", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setEditSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleStatus(item: HrDocumentType) {
|
async function toggleStatus(item: HrDocumentType) {
|
||||||
|
setActionPendingId(item.hrDocumentTypeId)
|
||||||
try {
|
try {
|
||||||
await hrDocumentTypesApi.updateStatus(item.hrDocumentTypeId, item.status === "Active" ? "Inactive" : "Active")
|
await hrDocumentTypesApi.updateStatus(item.hrDocumentTypeId, item.status === "Active" ? "Inactive" : "Active")
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not update status", errorMessage(err))
|
toast.error("Could not update status", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: HrDocumentType) {
|
||||||
|
setActionPendingId(item.hrDocumentTypeId)
|
||||||
|
try {
|
||||||
|
await hrDocumentTypesApi.remove(item.hrDocumentTypeId)
|
||||||
|
toast.success("Document type deleted")
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not delete document type", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +166,14 @@ export default function DocumentTypesPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Document Types</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Document Types</h1>
|
||||||
|
{pagination && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{pagination.totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-base text-muted-foreground">The staff document catalog — NIC, contracts, certificates, etc.</p>
|
<p className="text-base text-muted-foreground">The staff document catalog — NIC, contracts, certificates, etc.</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -128,46 +220,259 @@ export default function DocumentTypesPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>Edit Document Type</DialogTitle>
|
||||||
|
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="dt-edit-code">Code</FieldLabel>
|
||||||
|
<Input id="dt-edit-code" value={editItem?.code ?? ""} disabled />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!editErrors.name}>
|
||||||
|
<FieldLabel htmlFor="dt-edit-name">Name</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="dt-edit-name"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
aria-invalid={!!editErrors.name}
|
||||||
|
disabled={editLoading}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="dt-edit-category">Category</FieldLabel>
|
||||||
|
<Select value={editCategory} onValueChange={(v) => setEditCategory((v as HrDocumentCategory) ?? "Other")} disabled={editLoading}>
|
||||||
|
<SelectTrigger id="dt-edit-category"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="dt-edit-required" checked={editRequiredAtOnboarding} onCheckedChange={(v) => setEditRequiredAtOnboarding(v === true)} disabled={editLoading} />
|
||||||
|
<FieldLabel htmlFor="dt-edit-required">Required at onboarding</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="dt-edit-expiry" checked={editExpiryTracked} onCheckedChange={(v) => setEditExpiryTracked(v === true)} disabled={editLoading} />
|
||||||
|
<FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
|
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||||
|
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||||
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by code or name…"
|
||||||
|
className="h-10 pl-9 pr-9"
|
||||||
|
aria-label="Search document types"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
|
||||||
{!error && items !== null && items.length === 0 && (
|
{!error && items === null && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No document types yet.</p></div>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||||
|
<Search className="size-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No document types match "{debouncedSearch}"</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<p className="text-base text-muted-foreground">No document types yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="table-fixed text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Category</TableHead>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||||
<TableBody>
|
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||||
{items.map((t) => (
|
</TableRow>
|
||||||
<TableRow key={t.hrDocumentTypeId}>
|
</TableHeader>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{t.code}</TableCell>
|
<TableBody>
|
||||||
<TableCell className="px-3 py-3.5">{t.name}</TableCell>
|
{items.map((t) => (
|
||||||
<TableCell className="px-3 py-3.5">{t.category}</TableCell>
|
<TableRow key={t.hrDocumentTypeId} className="group align-top hover:bg-muted/20">
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{t.status}</Badge>
|
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
|
||||||
</TableCell>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.category}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-4 py-4">
|
||||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(t)}>{t.status === "Active" ? "Deactivate" : "Activate"}</Button>
|
<Badge
|
||||||
</TableCell>
|
variant="outline"
|
||||||
</TableRow>
|
className={cn(
|
||||||
))}
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
</TableBody>
|
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
</Table>
|
)}
|
||||||
|
>
|
||||||
|
{t.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Edit ${t.name}`}
|
||||||
|
title={`Edit ${t.name}`}
|
||||||
|
onClick={() => openEdit(t)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
t.status === "Active"
|
||||||
|
? "text-destructive hover:bg-destructive/10"
|
||||||
|
: "text-success hover:bg-success/10"
|
||||||
|
)}
|
||||||
|
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||||
|
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||||
|
disabled={actionPendingId === t.hrDocumentTypeId}
|
||||||
|
onClick={() => toggleStatus(t)}
|
||||||
|
>
|
||||||
|
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${t.name}`}
|
||||||
|
title={`Delete ${t.name}`}
|
||||||
|
disabled={actionPendingId === t.hrDocumentTypeId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title={`Delete ${t.name}?`}
|
||||||
|
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => handleDelete(t)}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{pagination && pagination.totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between">
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> document types
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
<Button
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
type="button"
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Plus } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||||
|
|
||||||
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { LeaveType } from "@/types/hrm"
|
import { LeaveType } from "@/types/hrm"
|
||||||
|
|
||||||
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -19,13 +20,17 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
|
|
||||||
export default function LeaveTypesPage() {
|
export default function LeaveTypesPage() {
|
||||||
const [items, setItems] = useState<LeaveType[] | null>(null)
|
const [items, setItems] = useState<LeaveType[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(5)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
@@ -37,13 +42,34 @@ export default function LeaveTypesPage() {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const [editItem, setEditItem] = useState<LeaveType | null>(null)
|
||||||
|
const [editEtag, setEditEtag] = useState<string | null>(null)
|
||||||
|
const [editName, setEditName] = useState("")
|
||||||
|
const [editIsPaid, setEditIsPaid] = useState(true)
|
||||||
|
const [editCountsAsNoPay, setEditCountsAsNoPay] = useState(false)
|
||||||
|
const [editAccrualPerYear, setEditAccrualPerYear] = useState(14)
|
||||||
|
const [editCarryForwardAllowed, setEditCarryForwardAllowed] = useState(false)
|
||||||
|
const [editRequiresApproval, setEditRequiresApproval] = useState(true)
|
||||||
|
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||||
|
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||||
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
leaveTypesApi
|
leaveTypesApi
|
||||||
.list({ page, pageSize: PAGE_SIZE })
|
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||||
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
useEffect(load, [page])
|
|
||||||
|
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setPage(1)
|
||||||
|
setDebouncedSearch(search.trim())
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
@@ -70,12 +96,78 @@ export default function LeaveTypesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openEdit(item: LeaveType) {
|
||||||
|
setEditItem(item)
|
||||||
|
setEditName(item.name)
|
||||||
|
setEditIsPaid(item.isPaid)
|
||||||
|
setEditCountsAsNoPay(item.countsAsNoPay)
|
||||||
|
setEditAccrualPerYear(item.accrualPerYear)
|
||||||
|
setEditCarryForwardAllowed(item.carryForwardAllowed)
|
||||||
|
setEditRequiresApproval(item.requiresApproval)
|
||||||
|
setEditErrors({})
|
||||||
|
setEditEtag(null)
|
||||||
|
setEditLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await leaveTypesApi.get(item.leaveTypeId)
|
||||||
|
setEditEtag(res.etag)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not load leave type", errorMessage(err))
|
||||||
|
setEditItem(null)
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave() {
|
||||||
|
if (!editItem || !editEtag) return
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!editName.trim()) nextErrors.name = "Name is required"
|
||||||
|
setEditErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setEditSubmitting(true)
|
||||||
|
try {
|
||||||
|
await leaveTypesApi.update(editItem.leaveTypeId, {
|
||||||
|
name: editName.trim(),
|
||||||
|
isPaid: editIsPaid,
|
||||||
|
countsAsNoPay: editCountsAsNoPay,
|
||||||
|
accrualPerYear: editAccrualPerYear,
|
||||||
|
carryForwardAllowed: editCarryForwardAllowed,
|
||||||
|
maxCarryForwardDays: editItem.maxCarryForwardDays,
|
||||||
|
requiresApproval: editRequiresApproval,
|
||||||
|
}, editEtag)
|
||||||
|
toast.success("Leave type updated")
|
||||||
|
setEditItem(null)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not save", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setEditSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleStatus(item: LeaveType) {
|
async function toggleStatus(item: LeaveType) {
|
||||||
|
setActionPendingId(item.leaveTypeId)
|
||||||
try {
|
try {
|
||||||
await leaveTypesApi.updateStatus(item.leaveTypeId, item.status === "Active" ? "Inactive" : "Active")
|
await leaveTypesApi.updateStatus(item.leaveTypeId, item.status === "Active" ? "Inactive" : "Active")
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not update status", errorMessage(err))
|
toast.error("Could not update status", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: LeaveType) {
|
||||||
|
setActionPendingId(item.leaveTypeId)
|
||||||
|
try {
|
||||||
|
await leaveTypesApi.remove(item.leaveTypeId)
|
||||||
|
toast.success("Leave type deleted")
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not delete leave type", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +175,14 @@ export default function LeaveTypesPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Leave Types</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Leave Types</h1>
|
||||||
|
{pagination && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{pagination.totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-base text-muted-foreground">Annual, Casual, Medical, Unpaid, etc. — drives Attendance's OnLeave classification and Payroll's No-Pay calc.</p>
|
<p className="text-base text-muted-foreground">Annual, Casual, Medical, Unpaid, etc. — drives Attendance's OnLeave classification and Payroll's No-Pay calc.</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -129,48 +228,272 @@ export default function LeaveTypesPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>Edit Leave Type</DialogTitle>
|
||||||
|
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="lt-edit-code">Code</FieldLabel>
|
||||||
|
<Input id="lt-edit-code" value={editItem?.code ?? ""} disabled />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!editErrors.name}>
|
||||||
|
<FieldLabel htmlFor="lt-edit-name">Name</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="lt-edit-name"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
aria-invalid={!!editErrors.name}
|
||||||
|
disabled={editLoading}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="lt-edit-accrual">Days per year</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="lt-edit-accrual"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={editAccrualPerYear}
|
||||||
|
onChange={(e) => setEditAccrualPerYear(Number(e.target.value))}
|
||||||
|
disabled={editLoading}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="lt-edit-paid" checked={editIsPaid} onCheckedChange={(v) => setEditIsPaid(v === true)} disabled={editLoading} />
|
||||||
|
<FieldLabel htmlFor="lt-edit-paid">Paid leave</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="lt-edit-nopay" checked={editCountsAsNoPay} onCheckedChange={(v) => setEditCountsAsNoPay(v === true)} disabled={editLoading} />
|
||||||
|
<FieldLabel htmlFor="lt-edit-nopay">Counts as No-Pay in payroll</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="lt-edit-carry" checked={editCarryForwardAllowed} onCheckedChange={(v) => setEditCarryForwardAllowed(v === true)} disabled={editLoading} />
|
||||||
|
<FieldLabel htmlFor="lt-edit-carry">Carry-forward allowed</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="lt-edit-approval" checked={editRequiresApproval} onCheckedChange={(v) => setEditRequiresApproval(v === true)} disabled={editLoading} />
|
||||||
|
<FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
|
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||||
|
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||||
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by code or name…"
|
||||||
|
className="h-10 pl-9 pr-9"
|
||||||
|
aria-label="Search leave types"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
|
||||||
{!error && items !== null && items.length === 0 && (
|
{!error && items === null && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No leave types yet.</p></div>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||||
|
<Search className="size-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No leave types match "{debouncedSearch}"</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<p className="text-base text-muted-foreground">No leave types yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="table-fixed text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Days/yr</TableHead>
|
<TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Paid</TableHead>
|
<TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 w-[14%] px-4 text-sm">Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 w-[14%] px-4 text-sm">Created</TableHead>
|
||||||
<TableBody>
|
<TableHead className="h-11 w-[18%] px-4 text-center text-sm">Actions</TableHead>
|
||||||
{items.map((t) => (
|
</TableRow>
|
||||||
<TableRow key={t.leaveTypeId}>
|
</TableHeader>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{t.code}</TableCell>
|
<TableBody>
|
||||||
<TableCell className="px-3 py-3.5">{t.name}</TableCell>
|
{items.map((t) => (
|
||||||
<TableCell className="px-3 py-3.5">{t.accrualPerYear}</TableCell>
|
<TableRow key={t.leaveTypeId} className="group align-top hover:bg-muted/20">
|
||||||
<TableCell className="px-3 py-3.5">{t.isPaid ? "Yes" : "No"}</TableCell>
|
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{t.status}</Badge>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.accrualPerYear}</TableCell>
|
||||||
</TableCell>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.isPaid ? "Yes" : "No"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-4 py-4">
|
||||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(t)}>{t.status === "Active" ? "Deactivate" : "Activate"}</Button>
|
<Badge
|
||||||
</TableCell>
|
variant="outline"
|
||||||
</TableRow>
|
className={cn(
|
||||||
))}
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
</TableBody>
|
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
</Table>
|
)}
|
||||||
|
>
|
||||||
|
{t.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Edit ${t.name}`}
|
||||||
|
title={`Edit ${t.name}`}
|
||||||
|
onClick={() => openEdit(t)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
t.status === "Active"
|
||||||
|
? "text-destructive hover:bg-destructive/10"
|
||||||
|
: "text-success hover:bg-success/10"
|
||||||
|
)}
|
||||||
|
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||||
|
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
|
||||||
|
disabled={actionPendingId === t.leaveTypeId}
|
||||||
|
onClick={() => toggleStatus(t)}
|
||||||
|
>
|
||||||
|
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${t.name}`}
|
||||||
|
title={`Delete ${t.name}`}
|
||||||
|
disabled={actionPendingId === t.leaveTypeId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title={`Delete ${t.name}?`}
|
||||||
|
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => handleDelete(t)}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{pagination && pagination.totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between">
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave types
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
<Button
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
type="button"
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Plus } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||||
|
|
||||||
import { salaryComponentsApi } from "@/lib/api/hrm-masters"
|
import { salaryComponentsApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { SalaryComponent, SalaryComponentType } from "@/types/hrm"
|
import { SalaryComponent, SalaryComponentType } from "@/types/hrm"
|
||||||
|
|
||||||
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -20,13 +21,17 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
|
|
||||||
export default function SalaryComponentsPage() {
|
export default function SalaryComponentsPage() {
|
||||||
const [items, setItems] = useState<SalaryComponent[] | null>(null)
|
const [items, setItems] = useState<SalaryComponent[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(5)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
@@ -37,13 +42,31 @@ export default function SalaryComponentsPage() {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const [editItem, setEditItem] = useState<SalaryComponent | null>(null)
|
||||||
|
const [editEtag, setEditEtag] = useState<string | null>(null)
|
||||||
|
const [editName, setEditName] = useState("")
|
||||||
|
const [editIsTaxable, setEditIsTaxable] = useState(true)
|
||||||
|
const [editIsEpfEtfApplicable, setEditIsEpfEtfApplicable] = useState(true)
|
||||||
|
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||||
|
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||||
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
salaryComponentsApi
|
salaryComponentsApi
|
||||||
.list({ page, pageSize: PAGE_SIZE })
|
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||||
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
.then((res) => { setItems(res.items); setPagination(res.pagination) })
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
useEffect(load, [page])
|
|
||||||
|
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setPage(1)
|
||||||
|
setDebouncedSearch(search.trim())
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
@@ -67,12 +90,72 @@ export default function SalaryComponentsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openEdit(item: SalaryComponent) {
|
||||||
|
setEditItem(item)
|
||||||
|
setEditName(item.name)
|
||||||
|
setEditIsTaxable(item.isTaxable)
|
||||||
|
setEditIsEpfEtfApplicable(item.isEpfEtfApplicable)
|
||||||
|
setEditErrors({})
|
||||||
|
setEditEtag(null)
|
||||||
|
setEditLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await salaryComponentsApi.get(item.salaryComponentId)
|
||||||
|
setEditEtag(res.etag)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not load salary component", errorMessage(err))
|
||||||
|
setEditItem(null)
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave() {
|
||||||
|
if (!editItem || !editEtag) return
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!editName.trim()) nextErrors.name = "Name is required"
|
||||||
|
setEditErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setEditSubmitting(true)
|
||||||
|
try {
|
||||||
|
await salaryComponentsApi.update(editItem.salaryComponentId, {
|
||||||
|
name: editName.trim(),
|
||||||
|
componentType: editItem.componentType,
|
||||||
|
isTaxable: editIsTaxable,
|
||||||
|
isEpfEtfApplicable: editIsEpfEtfApplicable,
|
||||||
|
}, editEtag)
|
||||||
|
toast.success("Salary component updated")
|
||||||
|
setEditItem(null)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not save", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setEditSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleStatus(item: SalaryComponent) {
|
async function toggleStatus(item: SalaryComponent) {
|
||||||
|
setActionPendingId(item.salaryComponentId)
|
||||||
try {
|
try {
|
||||||
await salaryComponentsApi.updateStatus(item.salaryComponentId, item.status === "Active" ? "Inactive" : "Active")
|
await salaryComponentsApi.updateStatus(item.salaryComponentId, item.status === "Active" ? "Inactive" : "Active")
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not update status", errorMessage(err))
|
toast.error("Could not update status", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: SalaryComponent) {
|
||||||
|
setActionPendingId(item.salaryComponentId)
|
||||||
|
try {
|
||||||
|
await salaryComponentsApi.remove(item.salaryComponentId)
|
||||||
|
toast.success("Salary component deleted")
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not delete salary component", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +163,14 @@ export default function SalaryComponentsPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Salary Components</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Salary Components</h1>
|
||||||
|
{pagination && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{pagination.totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-base text-muted-foreground">Allowances and ad hoc other deductions — OT/Late/No-Pay/Loan/EPF/ETF/Tax are computed automatically, not components.</p>
|
<p className="text-base text-muted-foreground">Allowances and ad hoc other deductions — OT/Late/No-Pay/Loan/EPF/ETF/Tax are computed automatically, not components.</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -128,46 +218,254 @@ export default function SalaryComponentsPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>Edit Salary Component</DialogTitle>
|
||||||
|
<DialogDescription>Code and type can't be changed after creation.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="sc-edit-code">Code</FieldLabel>
|
||||||
|
<Input id="sc-edit-code" value={editItem?.code ?? ""} disabled />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!editErrors.name}>
|
||||||
|
<FieldLabel htmlFor="sc-edit-name">Name</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="sc-edit-name"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
aria-invalid={!!editErrors.name}
|
||||||
|
disabled={editLoading}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="sc-edit-type">Type</FieldLabel>
|
||||||
|
<Input id="sc-edit-type" value={editItem?.componentType ?? ""} disabled />
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="sc-edit-taxable" checked={editIsTaxable} onCheckedChange={(v) => setEditIsTaxable(v === true)} disabled={editLoading} />
|
||||||
|
<FieldLabel htmlFor="sc-edit-taxable">Taxable</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="sc-edit-epfetf" checked={editIsEpfEtfApplicable} onCheckedChange={(v) => setEditIsEpfEtfApplicable(v === true)} disabled={editLoading} />
|
||||||
|
<FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
|
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||||
|
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||||
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by code or name…"
|
||||||
|
className="h-10 pl-9 pr-9"
|
||||||
|
aria-label="Search salary components"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
|
||||||
{!error && items !== null && items.length === 0 && (
|
{!error && items === null && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No salary components yet.</p></div>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||||
|
<Search className="size-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No salary components match "{debouncedSearch}"</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<p className="text-base text-muted-foreground">No salary components yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="table-fixed text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||||
<TableBody>
|
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||||
{items.map((c) => (
|
</TableRow>
|
||||||
<TableRow key={c.salaryComponentId}>
|
</TableHeader>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{c.code}</TableCell>
|
<TableBody>
|
||||||
<TableCell className="px-3 py-3.5">{c.name}</TableCell>
|
{items.map((c) => (
|
||||||
<TableCell className="px-3 py-3.5">{c.componentType}</TableCell>
|
<TableRow key={c.salaryComponentId} className="group align-top hover:bg-muted/20">
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{c.code}</TableCell>
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{c.status}</Badge>
|
<TableCell className="px-4 py-4 text-sm text-foreground">{c.name}</TableCell>
|
||||||
</TableCell>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{c.componentType}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-4 py-4">
|
||||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(c)}>{c.status === "Active" ? "Deactivate" : "Activate"}</Button>
|
<Badge
|
||||||
</TableCell>
|
variant="outline"
|
||||||
</TableRow>
|
className={cn(
|
||||||
))}
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
</TableBody>
|
c.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
</Table>
|
)}
|
||||||
|
>
|
||||||
|
{c.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Edit ${c.name}`}
|
||||||
|
title={`Edit ${c.name}`}
|
||||||
|
onClick={() => openEdit(c)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
c.status === "Active"
|
||||||
|
? "text-destructive hover:bg-destructive/10"
|
||||||
|
: "text-success hover:bg-success/10"
|
||||||
|
)}
|
||||||
|
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
||||||
|
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
|
||||||
|
disabled={actionPendingId === c.salaryComponentId}
|
||||||
|
onClick={() => toggleStatus(c)}
|
||||||
|
>
|
||||||
|
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${c.name}`}
|
||||||
|
title={`Delete ${c.name}`}
|
||||||
|
disabled={actionPendingId === c.salaryComponentId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title={`Delete ${c.name}?`}
|
||||||
|
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => handleDelete(c)}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{pagination && pagination.totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between">
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> salary components
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
<Button
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
type="button"
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
export default function StatutorySettingsPage() {
|
export default function StatutorySettingsPage() {
|
||||||
const [settings, setSettings] = useState<PayrollStatutorySetting[]>([])
|
const [settings, setSettings] = useState<PayrollStatutorySetting[] | null>(null)
|
||||||
const [slabs, setSlabs] = useState<TaxSlab[]>([])
|
const [slabs, setSlabs] = useState<TaxSlab[] | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [settingOpen, setSettingOpen] = useState(false)
|
const [settingOpen, setSettingOpen] = useState(false)
|
||||||
@@ -86,7 +87,14 @@ export default function StatutorySettingsPage() {
|
|||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">EPF / ETF Rates</h2>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">EPF / ETF Rates</h2>
|
||||||
|
{settings && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{settings.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Dialog open={settingOpen} onOpenChange={setSettingOpen}>
|
<Dialog open={settingOpen} onOpenChange={setSettingOpen}>
|
||||||
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Setting</Button>} />
|
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Setting</Button>} />
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
@@ -108,33 +116,72 @@ export default function StatutorySettingsPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
{settings === null && (
|
||||||
<TableRow>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHead className="h-12 px-3 text-sm">Effective from</TableHead>
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
<TableHead className="h-12 px-3 text-sm">EPF (Employee)</TableHead>
|
<Skeleton className="h-3.5 w-24" />
|
||||||
<TableHead className="h-12 px-3 text-sm">EPF (Employer)</TableHead>
|
</div>
|
||||||
<TableHead className="h-12 px-3 text-sm">ETF (Employer)</TableHead>
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<TableHead className="h-12 px-3 text-sm">OT multiplier</TableHead>
|
<div key={i} className="flex items-center gap-4 border-b px-4 py-4 last:border-b-0">
|
||||||
</TableRow>
|
<Skeleton className="h-4 w-32" />
|
||||||
</TableHeader>
|
<Skeleton className="h-4 w-16" />
|
||||||
<TableBody>
|
<Skeleton className="h-4 w-16" />
|
||||||
{settings.map((s) => (
|
<Skeleton className="h-4 w-16" />
|
||||||
<TableRow key={s.payrollStatutorySettingId}>
|
<Skeleton className="h-4 w-16" />
|
||||||
<TableCell className="px-3 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}</TableCell>
|
</div>
|
||||||
<TableCell className="px-3 py-3.5">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{s.otMultiplierDefault}x</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
)}
|
||||||
|
|
||||||
|
{settings !== null && settings.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-16 text-center">
|
||||||
|
<p className="text-base text-muted-foreground">No statutory settings recorded yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings !== null && settings.length > 0 && (
|
||||||
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{settings.map((s) => (
|
||||||
|
<TableRow key={s.payrollStatutorySettingId} className="hover:bg-muted/20">
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-foreground">
|
||||||
|
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell>
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell>
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.otMultiplierDefault}x</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">Tax Slabs</h2>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Tax Slabs</h2>
|
||||||
|
{slabs && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{slabs.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Dialog open={slabOpen} onOpenChange={setSlabOpen}>
|
<Dialog open={slabOpen} onOpenChange={setSlabOpen}>
|
||||||
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Slab</Button>} />
|
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Slab</Button>} />
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
@@ -155,24 +202,52 @@ export default function StatutorySettingsPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
{slabs === null && (
|
||||||
<TableRow>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHead className="h-12 px-3 text-sm">Effective from</TableHead>
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
<TableHead className="h-12 px-3 text-sm">Range</TableHead>
|
<Skeleton className="h-3.5 w-24" />
|
||||||
<TableHead className="h-12 px-3 text-sm">Rate</TableHead>
|
</div>
|
||||||
</TableRow>
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
</TableHeader>
|
<div key={i} className="flex items-center gap-4 border-b px-4 py-4 last:border-b-0">
|
||||||
<TableBody>
|
<Skeleton className="h-4 w-32" />
|
||||||
{slabs.map((s) => (
|
<Skeleton className="h-4 w-32" />
|
||||||
<TableRow key={s.taxSlabId}>
|
<Skeleton className="h-4 w-16" />
|
||||||
<TableCell className="px-3 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
</div>
|
||||||
<TableCell className="px-3 py-3.5">{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">{(s.rate * 100).toFixed(1)}%</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
)}
|
||||||
|
|
||||||
|
{slabs !== null && slabs.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-16 text-center">
|
||||||
|
<p className="text-base text-muted-foreground">No tax slabs recorded yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{slabs !== null && slabs.length > 0 && (
|
||||||
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Range</TableHead>
|
||||||
|
<TableHead className="h-11 px-4 text-sm">Rate</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{slabs.map((s) => (
|
||||||
|
<TableRow key={s.taxSlabId} className="hover:bg-muted/20">
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</TableCell>
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.rate * 100).toFixed(1)}%</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Plus } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||||
|
|
||||||
import { workShiftsApi } from "@/lib/api/hrm-masters"
|
import { workShiftsApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { WorkShift } from "@/types/hrm"
|
import { WorkShift } from "@/types/hrm"
|
||||||
|
|
||||||
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -19,14 +20,22 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
|
||||||
|
function maskToDays(mask: number): boolean[] {
|
||||||
|
return DAYS.map((_, i) => (mask & (1 << i)) !== 0)
|
||||||
|
}
|
||||||
|
|
||||||
export default function WorkShiftsPage() {
|
export default function WorkShiftsPage() {
|
||||||
const [items, setItems] = useState<WorkShift[] | null>(null)
|
const [items, setItems] = useState<WorkShift[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(5)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
@@ -42,9 +51,24 @@ export default function WorkShiftsPage() {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const [editItem, setEditItem] = useState<WorkShift | null>(null)
|
||||||
|
const [editEtag, setEditEtag] = useState<string | null>(null)
|
||||||
|
const [editName, setEditName] = useState("")
|
||||||
|
const [editStartTime, setEditStartTime] = useState("08:00")
|
||||||
|
const [editEndTime, setEditEndTime] = useState("17:00")
|
||||||
|
const [editIsOvernight, setEditIsOvernight] = useState(false)
|
||||||
|
const [editGraceMinutes, setEditGraceMinutes] = useState(15)
|
||||||
|
const [editBreakMinutes, setEditBreakMinutes] = useState(60)
|
||||||
|
const [editStandardWorkingMinutes, setEditStandardWorkingMinutes] = useState(480)
|
||||||
|
const [editOtMultiplier, setEditOtMultiplier] = useState(1.5)
|
||||||
|
const [editWorkingDays, setEditWorkingDays] = useState<boolean[]>([true, true, true, true, true, false, false])
|
||||||
|
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||||
|
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||||
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
workShiftsApi
|
workShiftsApi
|
||||||
.list({ page, pageSize: PAGE_SIZE })
|
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setItems(res.items)
|
setItems(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
@@ -52,7 +76,15 @@ export default function WorkShiftsPage() {
|
|||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page])
|
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setPage(1)
|
||||||
|
setDebouncedSearch(search.trim())
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
@@ -88,12 +120,84 @@ export default function WorkShiftsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openEdit(item: WorkShift) {
|
||||||
|
setEditItem(item)
|
||||||
|
setEditName(item.name)
|
||||||
|
setEditStartTime(item.startTime.slice(0, 5))
|
||||||
|
setEditEndTime(item.endTime.slice(0, 5))
|
||||||
|
setEditIsOvernight(item.isOvernight)
|
||||||
|
setEditGraceMinutes(item.graceMinutes)
|
||||||
|
setEditBreakMinutes(item.breakMinutes)
|
||||||
|
setEditStandardWorkingMinutes(item.standardWorkingMinutes)
|
||||||
|
setEditOtMultiplier(item.otMultiplier)
|
||||||
|
setEditWorkingDays(maskToDays(item.workingDaysMask))
|
||||||
|
setEditErrors({})
|
||||||
|
setEditEtag(null)
|
||||||
|
setEditLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await workShiftsApi.get(item.workShiftId)
|
||||||
|
setEditEtag(res.etag)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not load work shift", errorMessage(err))
|
||||||
|
setEditItem(null)
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave() {
|
||||||
|
if (!editItem || !editEtag) return
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!editName.trim()) nextErrors.name = "Name is required"
|
||||||
|
setEditErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
const mask = editWorkingDays.reduce((m, on, i) => (on ? m | (1 << i) : m), 0)
|
||||||
|
setEditSubmitting(true)
|
||||||
|
try {
|
||||||
|
await workShiftsApi.update(editItem.workShiftId, {
|
||||||
|
name: editName.trim(),
|
||||||
|
startTime: `${editStartTime}:00`,
|
||||||
|
endTime: `${editEndTime}:00`,
|
||||||
|
isOvernight: editIsOvernight,
|
||||||
|
graceMinutes: editGraceMinutes,
|
||||||
|
breakMinutes: editBreakMinutes,
|
||||||
|
standardWorkingMinutes: editStandardWorkingMinutes,
|
||||||
|
otMultiplier: editOtMultiplier,
|
||||||
|
workingDaysMask: mask,
|
||||||
|
}, editEtag)
|
||||||
|
toast.success("Work shift updated")
|
||||||
|
setEditItem(null)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not save", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setEditSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleStatus(item: WorkShift) {
|
async function toggleStatus(item: WorkShift) {
|
||||||
|
setActionPendingId(item.workShiftId)
|
||||||
try {
|
try {
|
||||||
await workShiftsApi.updateStatus(item.workShiftId, item.status === "Active" ? "Inactive" : "Active")
|
await workShiftsApi.updateStatus(item.workShiftId, item.status === "Active" ? "Inactive" : "Active")
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not update status", errorMessage(err))
|
toast.error("Could not update status", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: WorkShift) {
|
||||||
|
setActionPendingId(item.workShiftId)
|
||||||
|
try {
|
||||||
|
await workShiftsApi.remove(item.workShiftId)
|
||||||
|
toast.success("Work shift deleted")
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not delete work shift", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +205,14 @@ export default function WorkShiftsPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Work Shifts</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Work Shifts</h1>
|
||||||
|
{pagination && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{pagination.totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-base text-muted-foreground">The baseline Attendance computes Late/Early/OT against.</p>
|
<p className="text-base text-muted-foreground">The baseline Attendance computes Late/Early/OT against.</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -174,46 +285,289 @@ export default function WorkShiftsPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>Edit Work Shift</DialogTitle>
|
||||||
|
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="w-edit-code">Code</FieldLabel>
|
||||||
|
<Input id="w-edit-code" value={editItem?.code ?? ""} disabled />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!editErrors.name}>
|
||||||
|
<FieldLabel htmlFor="w-edit-name">Name</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="w-edit-name"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
aria-invalid={!!editErrors.name}
|
||||||
|
disabled={editLoading}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="w-edit-start">Start time</FieldLabel>
|
||||||
|
<Input id="w-edit-start" type="time" value={editStartTime} onChange={(e) => setEditStartTime(e.target.value)} disabled={editLoading} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="w-edit-end">End time</FieldLabel>
|
||||||
|
<Input id="w-edit-end" type="time" value={editEndTime} onChange={(e) => setEditEndTime(e.target.value)} disabled={editLoading} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="w-edit-overnight" checked={editIsOvernight} onCheckedChange={(v) => setEditIsOvernight(v === true)} disabled={editLoading} />
|
||||||
|
<FieldLabel htmlFor="w-edit-overnight">Overnight shift (end time rolls past midnight)</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="w-edit-grace">Grace (minutes)</FieldLabel>
|
||||||
|
<Input id="w-edit-grace" type="number" min={0} value={editGraceMinutes} onChange={(e) => setEditGraceMinutes(Number(e.target.value))} disabled={editLoading} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="w-edit-break">Break (minutes)</FieldLabel>
|
||||||
|
<Input id="w-edit-break" type="number" min={0} value={editBreakMinutes} onChange={(e) => setEditBreakMinutes(Number(e.target.value))} disabled={editLoading} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="w-edit-standard">Standard working minutes</FieldLabel>
|
||||||
|
<Input id="w-edit-standard" type="number" min={1} value={editStandardWorkingMinutes} onChange={(e) => setEditStandardWorkingMinutes(Number(e.target.value))} disabled={editLoading} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="w-edit-ot">OT multiplier</FieldLabel>
|
||||||
|
<Input id="w-edit-ot" type="number" step="0.1" min={1} value={editOtMultiplier} onChange={(e) => setEditOtMultiplier(Number(e.target.value))} disabled={editLoading} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Working days</FieldLabel>
|
||||||
|
<div className="flex flex-wrap gap-3 pt-1">
|
||||||
|
{DAYS.map((d, i) => (
|
||||||
|
<label key={d} className="flex items-center gap-1.5 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={editWorkingDays[i]}
|
||||||
|
onCheckedChange={(v) => setEditWorkingDays((prev) => prev.map((p, idx) => (idx === i ? v === true : p)))}
|
||||||
|
disabled={editLoading}
|
||||||
|
/>
|
||||||
|
{d}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
|
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||||
|
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||||
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by code or name…"
|
||||||
|
className="h-10 pl-9 pr-9"
|
||||||
|
aria-label="Search work shifts"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
{!error && items === null && <div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>}
|
|
||||||
{!error && items !== null && items.length === 0 && (
|
{!error && items === null && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No work shifts yet.</p></div>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||||
|
<Search className="size-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No work shifts match "{debouncedSearch}"</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<p className="text-base text-muted-foreground">No work shifts yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="table-fixed text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Hours</TableHead>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
|
||||||
<TableBody>
|
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
|
||||||
{items.map((w) => (
|
</TableRow>
|
||||||
<TableRow key={w.workShiftId}>
|
</TableHeader>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{w.code}</TableCell>
|
<TableBody>
|
||||||
<TableCell className="px-3 py-3.5">{w.name}</TableCell>
|
{items.map((w) => (
|
||||||
<TableCell className="px-3 py-3.5">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell>
|
<TableRow key={w.workShiftId} className="group align-top hover:bg-muted/20">
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{w.code}</TableCell>
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", w.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>{w.status}</Badge>
|
<TableCell className="px-4 py-4 text-sm text-foreground">{w.name}</TableCell>
|
||||||
</TableCell>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-4 py-4">
|
||||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(w)}>{w.status === "Active" ? "Deactivate" : "Activate"}</Button>
|
<Badge
|
||||||
</TableCell>
|
variant="outline"
|
||||||
</TableRow>
|
className={cn(
|
||||||
))}
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
</TableBody>
|
w.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
</Table>
|
)}
|
||||||
|
>
|
||||||
|
{w.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Edit ${w.name}`}
|
||||||
|
title={`Edit ${w.name}`}
|
||||||
|
onClick={() => openEdit(w)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
w.status === "Active"
|
||||||
|
? "text-destructive hover:bg-destructive/10"
|
||||||
|
: "text-success hover:bg-success/10"
|
||||||
|
)}
|
||||||
|
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
||||||
|
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
|
||||||
|
disabled={actionPendingId === w.workShiftId}
|
||||||
|
onClick={() => toggleStatus(w)}
|
||||||
|
>
|
||||||
|
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${w.name}`}
|
||||||
|
title={`Delete ${w.name}`}
|
||||||
|
disabled={actionPendingId === w.workShiftId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title={`Delete ${w.name}?`}
|
||||||
|
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => handleDelete(w)}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{pagination && pagination.totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between">
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> work shifts
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
<Button
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
type="button"
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ export default function DashboardLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<div className="flex h-screen overflow-hidden bg-background print:block print:h-auto print:overflow-visible">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="min-w-0 flex-1 overflow-auto">
|
<div className="min-w-0 flex-1 overflow-auto print:overflow-visible">
|
||||||
<div className="min-w-0 px-6 pt-3 pb-6 lg:px-8 lg:pt-4 lg:pb-8">
|
<div className="min-w-0 px-6 pt-3 pb-6 lg:px-8 lg:pt-4 lg:pb-8 print:p-0">
|
||||||
<Breadcrumbs />
|
<Breadcrumbs />
|
||||||
<div className="rounded-xl bg-card border border-border shadow-sm">
|
<div className="rounded-xl bg-card border border-border shadow-sm print:rounded-none print:border-0 print:shadow-none">
|
||||||
<div className="p-6">
|
<div className="p-6 print:p-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -297,18 +297,18 @@ export default function GrnDetailPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-end gap-8 border-t border-border pt-4 text-base">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-col gap-1 rounded-xl border border-border bg-white p-5 shadow-sm">
|
||||||
<span className="text-muted-foreground">Stock value (excl. VAT)</span>
|
<span className="text-sm text-muted-foreground">Stock value (excl. VAT)</span>
|
||||||
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)}</span>
|
<span className="text-xl font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-col gap-1 rounded-xl border border-border bg-white p-5 shadow-sm">
|
||||||
<span className="text-muted-foreground">VAT</span>
|
<span className="text-sm text-muted-foreground">VAT</span>
|
||||||
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.vatAmount, 0).toFixed(2)}</span>
|
<span className="text-xl font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.vatAmount, 0).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-col gap-1 rounded-xl border border-border bg-white p-5 shadow-sm">
|
||||||
<span className="text-muted-foreground">Document total</span>
|
<span className="text-sm text-muted-foreground">Document total</span>
|
||||||
<span className="font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
|
<span className="text-xl font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { BranchDetailPage } from "@/components/settings/BranchDetailPage"
|
||||||
|
|
||||||
|
export default function BranchDetail() {
|
||||||
|
return <BranchDetailPage />
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@ import { Building2, LayoutTemplate, MapPin, SlidersHorizontal } from "lucide-rea
|
|||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
title: "Product Configuration",
|
title: "Company Profile",
|
||||||
href: "/dashboard/products/settings",
|
href: "/dashboard/settings/company-profile",
|
||||||
icon: SlidersHorizontal,
|
icon: Building2,
|
||||||
description: "Configure product and master-data options",
|
description: "Invoice header, tax, and bank details",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Branch Configuration",
|
title: "Branch Configuration",
|
||||||
@@ -14,18 +14,18 @@ const cards = [
|
|||||||
icon: MapPin,
|
icon: MapPin,
|
||||||
description: "Turn multi-branch mode on or off for this deployment",
|
description: "Turn multi-branch mode on or off for this deployment",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Product Configuration",
|
||||||
|
href: "/dashboard/products/settings",
|
||||||
|
icon: SlidersHorizontal,
|
||||||
|
description: "Configure product and master-data options",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Invoice Template",
|
title: "Invoice Template",
|
||||||
href: "/dashboard/settings/invoice-template",
|
href: "/dashboard/settings/invoice-template",
|
||||||
icon: LayoutTemplate,
|
icon: LayoutTemplate,
|
||||||
description: "Logo, footer, terms, and template style for printed invoices",
|
description: "Logo, footer, terms, and template style for printed invoices",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Company Profile",
|
|
||||||
href: "/dashboard/settings/company-profile",
|
|
||||||
icon: Building2,
|
|
||||||
description: "Invoice header, tax, and bank details",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function ConfigurationPage() {
|
export default function ConfigurationPage() {
|
||||||
@@ -38,7 +38,7 @@ export default function ConfigurationPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<Link
|
<Link
|
||||||
key={card.href}
|
key={card.href}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { AlertTriangle, Save } from "lucide-react"
|
import { AlertTriangle, Power, Save } from "lucide-react"
|
||||||
|
|
||||||
import { navApi } from "@/lib/api/nav"
|
import { navApi } from "@/lib/api/nav"
|
||||||
import { rolesApi } from "@/lib/api/roles"
|
import { rolesApi } from "@/lib/api/roles"
|
||||||
@@ -40,6 +40,7 @@ export default function RoleDetailPage() {
|
|||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [savingPermissions, setSavingPermissions] = useState(false)
|
const [savingPermissions, setSavingPermissions] = useState(false)
|
||||||
|
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
@@ -93,6 +94,21 @@ export default function RoleDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleToggleStatus() {
|
||||||
|
if (!role) return
|
||||||
|
const nextStatus = role.status === "Active" ? "Inactive" : "Active"
|
||||||
|
setTogglingStatus(true)
|
||||||
|
try {
|
||||||
|
await rolesApi.updateStatus(roleId, nextStatus)
|
||||||
|
setRole((prev) => (prev ? { ...prev, status: nextStatus } : prev))
|
||||||
|
toast.success(nextStatus === "Active" ? "Role activated" : "Role deactivated", `${role.code} is now ${nextStatus.toLowerCase()}.`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not update status", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setTogglingStatus(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSavePermissions() {
|
async function handleSavePermissions() {
|
||||||
setSavingPermissions(true)
|
setSavingPermissions(true)
|
||||||
try {
|
try {
|
||||||
@@ -148,6 +164,17 @@ export default function RoleDetailPage() {
|
|||||||
<p className="text-base text-muted-foreground">{role.name}</p>
|
<p className="text-base text-muted-foreground">{role.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={role.status === "Active" ? "text-destructive hover:bg-destructive/10" : "text-success hover:bg-success/10"}
|
||||||
|
onClick={handleToggleStatus}
|
||||||
|
disabled={togglingStatus || role.isSystemRole}
|
||||||
|
title={role.isSystemRole ? "System roles can't be deactivated" : undefined}
|
||||||
|
>
|
||||||
|
<Power className="size-4" />
|
||||||
|
{togglingStatus ? "Updating…" : role.status === "Active" ? "Deactivate" : "Activate"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{conflict && (
|
{conflict && (
|
||||||
@@ -166,7 +193,7 @@ export default function RoleDetailPage() {
|
|||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
<h2 className="text-lg font-semibold text-foreground">Details</h2>
|
<h2 className="text-lg font-semibold text-foreground">Details</h2>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -187,7 +214,7 @@ export default function RoleDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Sidebar permissions</h2>
|
<h2 className="text-lg font-semibold text-foreground">Sidebar permissions</h2>
|
||||||
<p className="text-sm text-muted-foreground">Choose which sections a user with this role can see.</p>
|
<p className="text-sm text-muted-foreground">Choose which sections a user with this role can see.</p>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Save } from "lucide-react"
|
||||||
|
|
||||||
|
import { navApi } from "@/lib/api/nav"
|
||||||
|
import { rolesApi } from "@/lib/api/roles"
|
||||||
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { NavItem } from "@/types/rbac"
|
||||||
|
|
||||||
|
import { RolePermissionTree } from "@/components/auth/RolePermissionTree"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { FieldError } from "@/components/ui/field"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
/** Code is derived from Name, never typed directly (e.g. "Store Manager" -> "STORE_MANAGER"). */
|
||||||
|
function deriveCode(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewRolePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const [tree, setTree] = useState<NavItem[] | null>(null)
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [navItemIds, setNavItemIds] = useState<Set<number>>(new Set())
|
||||||
|
const [subNavItemIds, setSubNavItemIds] = useState<Set<number>>(new Set())
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navApi.tree().then(setTree).catch(() => setTree([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const code = deriveCode(name)
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!name.trim()) nextErrors.name = "Role name is required"
|
||||||
|
else if (!code) nextErrors.name = "Role name must contain at least one letter or number"
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const result = await rolesApi.create({ code, name: name.trim() })
|
||||||
|
if (navItemIds.size > 0 || subNavItemIds.size > 0) {
|
||||||
|
await rolesApi.assignPermissions(result.data.roleId, {
|
||||||
|
navItemIds: Array.from(navItemIds),
|
||||||
|
subNavItemIds: Array.from(subNavItemIds),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toast.success("Role created", `${result.data.code} — ${result.data.name}`)
|
||||||
|
router.push(`/dashboard/settings/roles/${result.data.roleId}`)
|
||||||
|
} catch (err) {
|
||||||
|
const fe = fieldErrors(err)
|
||||||
|
if (fe?.code) setErrors((prev) => ({ ...prev, code: fe.code }))
|
||||||
|
toast.error("Could not create role", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">New role</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Created in both the auth service and here.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Details</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base" htmlFor="r-name">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="r-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Manager"
|
||||||
|
aria-invalid={!!errors.name}
|
||||||
|
className="h-12 text-base"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base" htmlFor="r-code">
|
||||||
|
Code (auto-generated)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="r-code"
|
||||||
|
value={code}
|
||||||
|
disabled
|
||||||
|
readOnly
|
||||||
|
placeholder="—"
|
||||||
|
className="h-12 text-base text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Sidebar permissions</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Choose which sections a user with this role can see.</p>
|
||||||
|
</div>
|
||||||
|
{tree === null ? (
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
) : (
|
||||||
|
<RolePermissionTree
|
||||||
|
tree={tree}
|
||||||
|
selectedNavItemIds={navItemIds}
|
||||||
|
selectedSubNavItemIds={subNavItemIds}
|
||||||
|
onChange={(nav, sub) => {
|
||||||
|
setNavItemIds(nav)
|
||||||
|
setSubNavItemIds(sub)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<Button onClick={handleCreate} disabled={submitting}>
|
||||||
|
<Save className="size-4" />
|
||||||
|
{submitting ? "Creating…" : "Create role"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,63 +2,36 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Pencil, Plus, ShieldCheck, Trash2 } from "lucide-react"
|
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
|
||||||
|
|
||||||
import { navApi } from "@/lib/api/nav"
|
|
||||||
import { rolesApi } from "@/lib/api/roles"
|
import { rolesApi } from "@/lib/api/roles"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { NavItem, Role } from "@/types/rbac"
|
import { Role } from "@/types/rbac"
|
||||||
|
|
||||||
import { RolePermissionTree } from "@/components/auth/RolePermissionTree"
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
/** Code is derived from Name, never typed directly (e.g. "Store Manager" -> "STORE_MANAGER"). */
|
|
||||||
function deriveCode(name: string): string {
|
|
||||||
return name
|
|
||||||
.trim()
|
|
||||||
.toUpperCase()
|
|
||||||
.replace(/[^A-Z0-9]+/g, "_")
|
|
||||||
.replace(/^_+|_+$/g, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RolesPage() {
|
export default function RolesPage() {
|
||||||
const [roles, setRoles] = useState<Role[] | null>(null)
|
const [roles, setRoles] = useState<Role[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
const [tree, setTree] = useState<NavItem[] | null>(null)
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [navItemIds, setNavItemIds] = useState<Set<number>>(new Set())
|
|
||||||
const [subNavItemIds, setSubNavItemIds] = useState<Set<number>>(new Set())
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
rolesApi
|
rolesApi
|
||||||
.list({ page, pageSize: PAGE_SIZE })
|
.list({ page, pageSize: PAGE_SIZE, q: debouncedSearch || undefined })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setRoles(res.items)
|
setRoles(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
@@ -66,47 +39,15 @@ export default function RolesPage() {
|
|||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page])
|
useEffect(load, [page, debouncedSearch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navApi.tree().then(setTree).catch(() => setTree([]))
|
const t = setTimeout(() => {
|
||||||
}, [])
|
setPage(1)
|
||||||
|
setDebouncedSearch(search.trim())
|
||||||
function resetForm() {
|
}, 300)
|
||||||
setName("")
|
return () => clearTimeout(t)
|
||||||
setNavItemIds(new Set())
|
}, [search])
|
||||||
setSubNavItemIds(new Set())
|
|
||||||
setErrors({})
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = deriveCode(name)
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
const nextErrors: Record<string, string> = {}
|
|
||||||
if (!name.trim()) nextErrors.name = "Role name is required"
|
|
||||||
else if (!code) nextErrors.name = "Role name must contain at least one letter or number"
|
|
||||||
setErrors(nextErrors)
|
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
|
||||||
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
|
||||||
const result = await rolesApi.create({ code, name: name.trim() })
|
|
||||||
if (navItemIds.size > 0 || subNavItemIds.size > 0) {
|
|
||||||
await rolesApi.assignPermissions(result.data.roleId, {
|
|
||||||
navItemIds: Array.from(navItemIds),
|
|
||||||
subNavItemIds: Array.from(subNavItemIds),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
toast.success("Role created", `${result.data.code} — ${result.data.name}`)
|
|
||||||
setOpen(false)
|
|
||||||
resetForm()
|
|
||||||
load()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error("Could not create role", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(role: Role) {
|
async function handleDelete(role: Role) {
|
||||||
setActionPendingId(role.roleId)
|
setActionPendingId(role.roleId)
|
||||||
@@ -125,73 +66,44 @@ export default function RolesPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Roles</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Roles</h1>
|
||||||
|
{pagination && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{pagination.totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-base text-muted-foreground">
|
||||||
Manage roles and which sidebar sections each one can see.
|
Manage roles and which sidebar sections each one can see.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Link href="/dashboard/settings/roles/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
open={open}
|
<Plus className="size-5" />
|
||||||
onOpenChange={(v) => {
|
New Role
|
||||||
setOpen(v)
|
</Link>
|
||||||
if (!v) resetForm()
|
</div>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger
|
|
||||||
render={
|
|
||||||
<Button size="lg">
|
|
||||||
<Plus className="size-5" />
|
|
||||||
New Role
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DialogContent className="sm:max-w-lg">
|
|
||||||
<DialogHeader className="items-center text-center">
|
|
||||||
<DialogTitle>New role</DialogTitle>
|
|
||||||
<DialogDescription>Created in both the auth service and here.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<FieldGroup>
|
|
||||||
<Field data-invalid={!!errors.name}>
|
|
||||||
<FieldLabel htmlFor="r-name">Name</FieldLabel>
|
|
||||||
<Input id="r-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Manager" aria-invalid={!!errors.name} />
|
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="r-code">Code (auto-generated)</FieldLabel>
|
|
||||||
<Input id="r-code" value={code} disabled readOnly placeholder="—" className="text-muted-foreground" />
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="relative w-full sm:max-w-xs">
|
||||||
<FieldLabel>Sidebar permissions</FieldLabel>
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
{tree === null ? (
|
<Input
|
||||||
<Skeleton className="h-32 w-full" />
|
value={search}
|
||||||
) : (
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<div className="max-h-80 overflow-auto">
|
placeholder="Search roles by name or code…"
|
||||||
<RolePermissionTree
|
className="h-10 pl-9 pr-9"
|
||||||
tree={tree}
|
aria-label="Search roles"
|
||||||
selectedNavItemIds={navItemIds}
|
/>
|
||||||
selectedSubNavItemIds={subNavItemIds}
|
{search && (
|
||||||
onChange={(nav, sub) => {
|
<button
|
||||||
setNavItemIds(nav)
|
type="button"
|
||||||
setSubNavItemIds(sub)
|
onClick={() => setSearch("")}
|
||||||
}}
|
aria-label="Clear search"
|
||||||
/>
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
</div>
|
>
|
||||||
)}
|
<X className="size-4" />
|
||||||
</div>
|
</button>
|
||||||
|
)}
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
|
||||||
{submitting ? "Creating…" : "Create"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -199,97 +111,184 @@ export default function RolesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && roles === null && (
|
{!error && roles === null && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
<Skeleton key={i} className="h-14 w-full" />
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||||
|
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="ml-4 h-7 w-16" />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && roles !== null && roles.length === 0 && (
|
{!error && roles !== null && roles.length === 0 && debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||||
|
<Search className="size-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No roles match "{debouncedSearch}"</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Try a different name or code.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && roles !== null && roles.length === 0 && !debouncedSearch && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
<ShieldCheck className="size-12 text-muted-foreground" />
|
<ShieldCheck className="size-12 text-muted-foreground" />
|
||||||
<p className="text-base text-muted-foreground">No roles yet.</p>
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No roles yet</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Create your first role to start assigning permissions.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/settings/roles/new" className={cn(buttonVariants())}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New Role
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && roles !== null && roles.length > 0 && (
|
{!error && roles !== null && roles.length > 0 && (
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 w-56 px-3 text-sm">Role</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 w-40 px-3 text-sm">Code</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 w-32 px-3 text-sm">Created Date</TableHead>
|
||||||
<TableBody>
|
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
|
||||||
{roles.map((r) => (
|
</TableRow>
|
||||||
<TableRow key={r.roleId}>
|
</TableHeader>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{r.code}</TableCell>
|
<TableBody>
|
||||||
<TableCell className="px-3 py-3.5">{r.name}</TableCell>
|
{roles.map((r) => (
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableRow key={r.roleId} className="group align-top hover:bg-muted/20">
|
||||||
<Badge
|
<TableCell className="px-3 py-4">
|
||||||
variant="outline"
|
<Link
|
||||||
className={cn(
|
href={`/dashboard/settings/roles/${r.roleId}`}
|
||||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
className="flex items-start gap-3 rounded-md -m-1 p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
r.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{r.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/settings/roles/${r.roleId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
|
||||||
aria-label={`Edit ${r.code}`}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-destructive hover:bg-destructive/10"
|
|
||||||
aria-label={`Delete ${r.code}`}
|
|
||||||
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-white text-foreground">
|
||||||
</AlertDialogTrigger>
|
<ShieldCheck className="size-4.5" />
|
||||||
<AlertDialogContent
|
</div>
|
||||||
variant="destructive"
|
<div className="flex flex-col gap-1 pt-0.5">
|
||||||
title={`Delete ${r.code}?`}
|
<span className="font-semibold leading-tight text-foreground group-hover:underline">{r.name}</span>
|
||||||
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
|
{r.isSystemRole && (
|
||||||
confirmLabel="Delete"
|
<Badge variant="outline" className="h-5 w-fit justify-center border-input bg-white px-2 text-xs text-muted-foreground">
|
||||||
onConfirm={() => handleDelete(r)}
|
System
|
||||||
/>
|
</Badge>
|
||||||
</AlertDialog>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</Link>
|
||||||
</TableRow>
|
</TableCell>
|
||||||
))}
|
|
||||||
</TableBody>
|
<TableCell className="px-3 py-4">
|
||||||
</Table>
|
<span className="font-mono text-xs text-muted-foreground">{r.code}</span>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="px-3 py-4">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
|
r.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
|
{new Date(r.createdAt).toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="px-3 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/settings/roles/${r.roleId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`View ${r.code}`}
|
||||||
|
title={`View ${r.code}`}
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/settings/roles/${r.roleId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`Edit ${r.code}`}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${r.code}`}
|
||||||
|
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
|
||||||
|
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title={`Delete ${r.code}?`}
|
||||||
|
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => handleDelete(r)}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && pagination.totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
<span className="font-medium text-foreground">
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> roles
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
Page {pagination.page} of {pagination.totalPages}
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -300,6 +299,7 @@ export default function RolesPage() {
|
|||||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,64 +1,62 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Save } from "lucide-react"
|
import { Clock, IdCard, Laptop, ShieldCheck } from "lucide-react"
|
||||||
|
|
||||||
import { rolesApi } from "@/lib/api/roles"
|
import { auditLogsApi } from "@/lib/api/audit-logs"
|
||||||
import { usersApi } from "@/lib/api/users"
|
import { usersApi } from "@/lib/api/users"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Role } from "@/types/rbac"
|
import { AuditLog, LoginActivity } from "@/types/audit"
|
||||||
import { ManagedUser } from "@/types/users"
|
import { ManagedUser } from "@/types/users"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const ACTIVITY_PAGE_SIZE = 8
|
||||||
|
const DEVICES_PAGE_SIZE = 8
|
||||||
|
|
||||||
export default function UserDetailPage() {
|
export default function UserDetailPage() {
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
const router = useRouter()
|
|
||||||
const userId = Number(params.id)
|
const userId = Number(params.id)
|
||||||
|
|
||||||
const [user, setUser] = useState<ManagedUser | null>(null)
|
const [user, setUser] = useState<ManagedUser | null>(null)
|
||||||
const [roles, setRoles] = useState<Role[]>([])
|
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
const [roleId, setRoleId] = useState<string>("")
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
function load() {
|
const [activity, setActivity] = useState<AuditLog[] | null>(null)
|
||||||
setLoadError(null)
|
const [activityError, setActivityError] = useState<string | null>(null)
|
||||||
Promise.all([usersApi.get(userId), rolesApi.list({ pageSize: 200, status: "Active" })])
|
|
||||||
.then(([u, roleList]) => {
|
const [devices, setDevices] = useState<LoginActivity[] | null>(null)
|
||||||
setUser(u)
|
const [devicesError, setDevicesError] = useState<string | null>(null)
|
||||||
setRoles(roleList.items)
|
|
||||||
setRoleId(u.roleId ? String(u.roleId) : "")
|
const [statusPending, setStatusPending] = useState(false)
|
||||||
})
|
|
||||||
.catch((err) => setLoadError(errorMessage(err)))
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Number.isFinite(userId)) load()
|
if (!Number.isFinite(userId)) return
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
setLoadError(null)
|
||||||
|
usersApi.get(userId).then(setUser).catch((err) => setLoadError(errorMessage(err)))
|
||||||
}, [userId])
|
}, [userId])
|
||||||
|
|
||||||
async function handleSave() {
|
useEffect(() => {
|
||||||
if (!roleId) return
|
if (!Number.isFinite(userId)) return
|
||||||
setSaving(true)
|
auditLogsApi
|
||||||
try {
|
.list({ userId, page: 1, pageSize: ACTIVITY_PAGE_SIZE })
|
||||||
const result = await usersApi.updateRole(userId, { roleId: Number(roleId) })
|
.then((res) => setActivity(res.items))
|
||||||
setUser(result)
|
.catch((err) => setActivityError(errorMessage(err)))
|
||||||
toast.success("Role updated", `${result.username} is now assigned to ${result.roleName}.`)
|
}, [userId])
|
||||||
} catch (err) {
|
|
||||||
toast.error("Could not update role", errorMessage(err))
|
useEffect(() => {
|
||||||
} finally {
|
if (!Number.isFinite(userId)) return
|
||||||
setSaving(false)
|
usersApi
|
||||||
}
|
.loginDevices(userId, { page: 1, pageSize: DEVICES_PAGE_SIZE })
|
||||||
}
|
.then((res) => setDevices(res.items))
|
||||||
|
.catch((err) => setDevicesError(errorMessage(err)))
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
if (loadError && !user) {
|
if (loadError && !user) {
|
||||||
return (
|
return (
|
||||||
@@ -80,9 +78,24 @@ export default function UserDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleToggleStatus() {
|
||||||
|
if (!user) return
|
||||||
|
const nextStatus = user.status === "Active" ? "Inactive" : "Active"
|
||||||
|
setStatusPending(true)
|
||||||
|
try {
|
||||||
|
const updated = await usersApi.updateStatus(user.userId, { status: nextStatus })
|
||||||
|
setUser(updated)
|
||||||
|
toast.success(nextStatus === "Active" ? "User activated" : "User deactivated")
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not update status", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setStatusPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-foreground">{user.username}</h1>
|
<h1 className="text-2xl font-bold text-foreground">{user.username}</h1>
|
||||||
@@ -98,33 +111,159 @@ export default function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-base text-muted-foreground">{user.displayName}</p>
|
<p className="text-base text-muted-foreground">{user.displayName}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn("text-sm font-medium", user.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
|
||||||
|
{user.status === "Active" ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={user.status === "Active"}
|
||||||
|
onCheckedChange={handleToggleStatus}
|
||||||
|
disabled={statusPending}
|
||||||
|
aria-label={user.status === "Active" ? "Deactivate user" : "Activate user"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:max-w-sm">
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
<Label className="text-base">Role</Label>
|
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||||
<Select value={roleId || undefined} onValueChange={(v) => setRoleId(v ?? "")}>
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<SelectTrigger>
|
<IdCard className="size-4.5 text-primary" />
|
||||||
<SelectValue placeholder="Select a role" />
|
</div>
|
||||||
</SelectTrigger>
|
<div>
|
||||||
<SelectContent>
|
<h2 className="text-base font-semibold text-foreground">User details</h2>
|
||||||
{roles.map((r) => (
|
<p className="text-sm text-muted-foreground">Account identity as recorded in this app.</p>
|
||||||
<SelectItem key={r.roleId} value={String(r.roleId)}>
|
</div>
|
||||||
{r.name}
|
</div>
|
||||||
</SelectItem>
|
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
))}
|
<div className="flex flex-col gap-1">
|
||||||
</SelectContent>
|
<span className="text-sm text-muted-foreground">Username</span>
|
||||||
</Select>
|
<span className="font-mono text-sm text-foreground">{user.username}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Display name</span>
|
||||||
|
<span className="text-sm text-foreground">{user.displayName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Email</span>
|
||||||
|
<span className="text-sm text-foreground">{user.email ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">User ID</span>
|
||||||
|
<span className="font-mono text-sm text-foreground">{user.userId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/settings/users")}>
|
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||||
Cancel
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
</Button>
|
<ShieldCheck className="size-4.5 text-primary" />
|
||||||
<Button size="lg" onClick={handleSave} disabled={saving || !roleId}>
|
</div>
|
||||||
<Save className="size-5" />
|
<div>
|
||||||
{saving ? "Saving…" : "Save role"}
|
<h2 className="text-base font-semibold text-foreground">Role & access</h2>
|
||||||
</Button>
|
<p className="text-sm text-muted-foreground">Controls what this user can see and do.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex flex-col gap-1 sm:max-w-sm">
|
||||||
|
<span className="text-sm text-muted-foreground">Role</span>
|
||||||
|
<span className="text-sm text-foreground">{user.roleName ?? "—"}</span>
|
||||||
|
{user.roleCode && <p className="text-xs text-muted-foreground">Role code: {user.roleCode}</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Clock className="size-4.5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Recent activity</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">The last changes this user made, from the audit trail.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
{activityError && (
|
||||||
|
<p className="text-sm text-destructive">{activityError}</p>
|
||||||
|
)}
|
||||||
|
{!activityError && activity === null && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!activityError && activity !== null && activity.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No recorded activity yet.</p>
|
||||||
|
)}
|
||||||
|
{!activityError && activity !== null && activity.length > 0 && (
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{activity.map((a) => (
|
||||||
|
<li key={a.auditId} className="flex items-center justify-between gap-3 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-fit justify-center border-transparent px-2 text-xs",
|
||||||
|
a.action === "Create" && "bg-success/10 text-success",
|
||||||
|
a.action === "Update" && "bg-primary/10 text-primary",
|
||||||
|
a.action === "Delete" && "bg-destructive/10 text-destructive",
|
||||||
|
a.action === "Login" && "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{a.action}
|
||||||
|
</Badge>
|
||||||
|
{a.action === "Login" ? (
|
||||||
|
<span className="text-foreground">Signed in</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-foreground">{a.entityType}</span>
|
||||||
|
<span className="text-muted-foreground">#{a.entityId}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">{new Date(a.createdAt).toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Laptop className="size-4.5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Login devices</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Devices and locations this account has signed in from.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
{devicesError && <p className="text-sm text-destructive">{devicesError}</p>}
|
||||||
|
{!devicesError && devices === null && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!devicesError && devices !== null && devices.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No logins recorded yet.</p>
|
||||||
|
)}
|
||||||
|
{!devicesError && devices !== null && devices.length > 0 && (
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{devices.map((d) => (
|
||||||
|
<li key={d.loginActivityId} className="flex items-center justify-between gap-3 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Laptop className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-foreground">{d.deviceLabel ?? "Unknown device"}</span>
|
||||||
|
{d.ipAddress && <span className="font-mono text-xs text-muted-foreground">{d.ipAddress}</span>}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">{new Date(d.loginAt).toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,104 +2,38 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react"
|
import { ChevronLeft, ChevronRight, Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
|
||||||
|
|
||||||
import { rolesApi } from "@/lib/api/roles"
|
|
||||||
import { usersApi } from "@/lib/api/users"
|
import { usersApi } from "@/lib/api/users"
|
||||||
import { employeeCrossLinkApi } from "@/lib/api/employees"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { branchesHrmApi, departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters"
|
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { Role } from "@/types/rbac"
|
import { ManagedUser } from "@/types/users"
|
||||||
import { Branch, Department, Designation, EmployeeMatch, EmploymentType, WorkShift } from "@/types/hrm"
|
|
||||||
import { ManagedUser, UserTypeOption } from "@/types/users"
|
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const { isAdmin, branchId: myBranchId, branchName: myBranchName, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
|
||||||
// Same "don't ask if it's already implied" heuristic as the Employees form: an Admin's
|
|
||||||
// currently-selected header branch, or a non-admin's own branch.
|
|
||||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
|
||||||
const lockedBranchName = isAdmin ? activeBranchName : myBranchName
|
|
||||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
|
||||||
|
|
||||||
const [users, setUsers] = useState<ManagedUser[] | null>(null)
|
const [users, setUsers] = useState<ManagedUser[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [roles, setRoles] = useState<Role[]>([])
|
|
||||||
const [userTypes, setUserTypes] = useState<UserTypeOption[]>([])
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(5)
|
||||||
const [open, setOpen] = useState(false)
|
const [search, setSearch] = useState("")
|
||||||
const [username, setUsername] = useState("")
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
const [fullName, setFullName] = useState("")
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
const [email, setEmail] = useState("")
|
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
|
||||||
const [mobileNumber, setMobileNumber] = useState("")
|
|
||||||
const [nic, setNic] = useState("")
|
|
||||||
const [roleId, setRoleId] = useState<string>("")
|
|
||||||
const [userTypeId, setUserTypeId] = useState("")
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
|
|
||||||
// Non-admin roles must be scoped to one branch (see UserManagementService.CreateAsync) —
|
|
||||||
// these fields build the Employee record that carries it.
|
|
||||||
const selectedRole = roles.find((r) => String(r.roleId) === roleId)
|
|
||||||
const requiresBranchScope = !!roleId && selectedRole?.code?.toUpperCase() !== "ADMIN"
|
|
||||||
|
|
||||||
const [employeeCode, setEmployeeCode] = useState("")
|
|
||||||
const [hireDate, setHireDate] = useState(() => new Date().toISOString().slice(0, 10))
|
|
||||||
const [departmentId, setDepartmentId] = useState(0)
|
|
||||||
const [designationId, setDesignationId] = useState(0)
|
|
||||||
const [employmentTypeId, setEmploymentTypeId] = useState(0)
|
|
||||||
const [workShiftId, setWorkShiftId] = useState(0)
|
|
||||||
const [branchId, setBranchId] = useState(0)
|
|
||||||
|
|
||||||
const [departments, setDepartments] = useState<Department[]>([])
|
|
||||||
const [designations, setDesignations] = useState<Designation[]>([])
|
|
||||||
const [employmentTypes, setEmploymentTypes] = useState<EmploymentType[]>([])
|
|
||||||
const [workShifts, setWorkShifts] = useState<WorkShift[]>([])
|
|
||||||
const [branches, setBranches] = useState<Branch[]>([])
|
|
||||||
|
|
||||||
// Advisory cross-link suggestion: does a Staff record already exist with this email?
|
|
||||||
const [staffMatch, setStaffMatch] = useState<EmployeeMatch | null>(null)
|
|
||||||
const [checkingEmail, setCheckingEmail] = useState(false)
|
|
||||||
|
|
||||||
async function checkEmail(value: string) {
|
|
||||||
if (!value.trim()) { setStaffMatch(null); return }
|
|
||||||
setCheckingEmail(true)
|
|
||||||
try {
|
|
||||||
const { match } = await employeeCrossLinkApi.findStaffByEmail(value.trim())
|
|
||||||
setStaffMatch(match)
|
|
||||||
} catch {
|
|
||||||
setStaffMatch(null)
|
|
||||||
} finally {
|
|
||||||
setCheckingEmail(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
usersApi
|
usersApi
|
||||||
.list({ page, pageSize: PAGE_SIZE })
|
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setUsers(res.items)
|
setUsers(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
@@ -107,113 +41,44 @@ export default function UsersPage() {
|
|||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page])
|
useEffect(load, [page, pageSize, debouncedSearch])
|
||||||
|
|
||||||
useEffect(() => {
|
function loadStats() {
|
||||||
rolesApi.list({ pageSize: 200, status: "Active" }).then((res) => setRoles(res.items)).catch(() => setRoles([]))
|
Promise.all([
|
||||||
}, [])
|
usersApi.list({ page: 1, pageSize: 1 }),
|
||||||
|
usersApi.list({ page: 1, pageSize: 1, status: "Active" }),
|
||||||
useEffect(() => {
|
usersApi.list({ page: 1, pageSize: 1, roleCode: "Admin" }),
|
||||||
usersApi
|
])
|
||||||
.userTypes()
|
.then(([totalRes, activeRes, adminsRes]) =>
|
||||||
.then((types) => {
|
setStats({ total: totalRes.pagination.totalItems, active: activeRes.pagination.totalItems, admins: adminsRes.pagination.totalItems })
|
||||||
setUserTypes(types)
|
)
|
||||||
// Only one user type exists today (AuthHex's "Admin"/"dev" seed) — default to it
|
.catch(() => setStats(null))
|
||||||
// so the admin never has to pick a raw GUID; the select still lets them switch
|
|
||||||
// if more types are added later.
|
|
||||||
if (types.length > 0) setUserTypeId((current) => current || types[0].userTypeId)
|
|
||||||
})
|
|
||||||
.catch(() => setUserTypes([]))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([]))
|
|
||||||
designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([]))
|
|
||||||
employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([]))
|
|
||||||
workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([]))
|
|
||||||
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
|
||||||
}, [branchingEnabled])
|
|
||||||
|
|
||||||
// Once a non-admin role is picked, default the branch to whatever's already implied
|
|
||||||
// (an Admin's selected header branch, or a non-admin creator's own branch) instead of
|
|
||||||
// making them pick it again.
|
|
||||||
useEffect(() => {
|
|
||||||
if (requiresBranchScope && lockedBranchId && !branchId) setBranchId(lockedBranchId)
|
|
||||||
}, [requiresBranchScope, lockedBranchId, branchId])
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
setUsername("")
|
|
||||||
setFullName("")
|
|
||||||
setEmail("")
|
|
||||||
setMobileNumber("")
|
|
||||||
setNic("")
|
|
||||||
setRoleId("")
|
|
||||||
setUserTypeId("")
|
|
||||||
setEmployeeCode("")
|
|
||||||
setHireDate(new Date().toISOString().slice(0, 10))
|
|
||||||
setDepartmentId(0)
|
|
||||||
setDesignationId(0)
|
|
||||||
setEmploymentTypeId(0)
|
|
||||||
setWorkShiftId(0)
|
|
||||||
setBranchId(0)
|
|
||||||
setErrors({})
|
|
||||||
setStaffMatch(null)
|
|
||||||
}
|
}
|
||||||
|
useEffect(loadStats, [])
|
||||||
|
|
||||||
async function handleCreate() {
|
useEffect(() => {
|
||||||
const nextErrors: Record<string, string> = {}
|
const t = setTimeout(() => {
|
||||||
if (!username.trim()) nextErrors.username = "Username is required"
|
setPage(1)
|
||||||
if (!fullName.trim()) nextErrors.fullName = "Full name is required"
|
setDebouncedSearch(search.trim())
|
||||||
if (!email.trim()) nextErrors.email = "Email is required"
|
}, 300)
|
||||||
if (!roleId) nextErrors.roleId = "Role is required"
|
return () => clearTimeout(t)
|
||||||
if (!userTypeId.trim()) nextErrors.userTypeId = "User type is required"
|
}, [search])
|
||||||
// A matched existing Staff record already carries a branch — only ask for a fresh
|
|
||||||
// one to be created when there's no such link.
|
|
||||||
if (requiresBranchScope && !staffMatch) {
|
|
||||||
if (!employeeCode.trim()) nextErrors.employeeCode = "Employee code is required"
|
|
||||||
if (!hireDate) nextErrors.hireDate = "Hire date is required"
|
|
||||||
if (!departmentId) nextErrors.departmentId = "Department is required"
|
|
||||||
if (!designationId) nextErrors.designationId = "Designation is required"
|
|
||||||
if (!employmentTypeId) nextErrors.employmentTypeId = "Employment type is required"
|
|
||||||
if (!workShiftId) nextErrors.workShiftId = "Work shift is required"
|
|
||||||
if (branchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
|
|
||||||
}
|
|
||||||
setErrors(nextErrors)
|
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
|
||||||
|
|
||||||
setSubmitting(true)
|
// Toggles the account's standing inside ERPCore (BranchAccessService and the rest of the
|
||||||
|
// app gate on this) — the auth-service credential is untouched either way, and the row is
|
||||||
|
// never removed from the table, just flagged so the user can't log in while Inactive.
|
||||||
|
async function handleToggleStatus(u: ManagedUser) {
|
||||||
|
const nextStatus = u.status === "Active" ? "Inactive" : "Active"
|
||||||
|
setActionPendingId(u.userId)
|
||||||
try {
|
try {
|
||||||
const result = await usersApi.create({
|
await usersApi.updateStatus(u.userId, { status: nextStatus })
|
||||||
username: username.trim(),
|
toast.success(nextStatus === "Active" ? "User activated" : "User deactivated", `${u.username} is now ${nextStatus}.`)
|
||||||
fullName: fullName.trim(),
|
|
||||||
email: email.trim(),
|
|
||||||
mobileNumber: mobileNumber || null,
|
|
||||||
nic: nic || null,
|
|
||||||
roleId: Number(roleId),
|
|
||||||
userTypeId: userTypeId.trim(),
|
|
||||||
linkEmployeeId: staffMatch ? staffMatch.employeeId : null,
|
|
||||||
...(requiresBranchScope && !staffMatch
|
|
||||||
? {
|
|
||||||
employeeCode: employeeCode.trim(),
|
|
||||||
hireDate,
|
|
||||||
departmentId,
|
|
||||||
designationId,
|
|
||||||
employmentTypeId,
|
|
||||||
workShiftId,
|
|
||||||
branchId: branchId || null,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
})
|
|
||||||
toast.success("User created", `Credentials have been emailed to ${result.username}.`)
|
|
||||||
setOpen(false)
|
|
||||||
resetForm()
|
|
||||||
load()
|
load()
|
||||||
|
loadStats()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const fe = fieldErrors(err)
|
toast.error("Could not update user status", errorMessage(err))
|
||||||
if (fe) setErrors(fe)
|
|
||||||
toast.error("Could not create user", errorMessage(err))
|
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setActionPendingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,176 +86,50 @@ export default function UsersPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Users</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
<p className="text-base text-muted-foreground">
|
<h1 className="text-2xl font-bold text-foreground">Users</h1>
|
||||||
Create accounts and assign roles. New users receive their credentials by email.
|
{pagination && (
|
||||||
</p>
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{pagination.totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-base text-muted-foreground">Manage user accounts and their roles.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
<DialogTrigger
|
{stats ? (
|
||||||
render={
|
<>
|
||||||
<Button size="lg">
|
<StatCard label="Total users" value={stats.total} icon={UsersIcon} />
|
||||||
<Plus className="size-5" />
|
<StatCard label="Active" value={stats.active} icon={UserCheck} />
|
||||||
New User
|
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
|
||||||
</Button>
|
<StatCard label="Admins" value={stats.admins} icon={ShieldCheck} />
|
||||||
}
|
</>
|
||||||
/>
|
) : (
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||||
<DialogHeader className="items-center text-center">
|
)}
|
||||||
<DialogTitle>New user</DialogTitle>
|
</div>
|
||||||
<DialogDescription>Created in both the auth service and here; password is emailed.</DialogDescription>
|
|
||||||
</DialogHeader>
|
<div className="relative w-full sm:max-w-xs">
|
||||||
<FieldGroup>
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Field data-invalid={!!errors.username}>
|
<Input
|
||||||
<FieldLabel htmlFor="u-username">Username</FieldLabel>
|
value={search}
|
||||||
<Input id="u-username" value={username} onChange={(e) => setUsername(e.target.value)} aria-invalid={!!errors.username} />
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<FieldError errors={[errors.username ? { message: errors.username } : undefined]} />
|
placeholder="Search users by username or name…"
|
||||||
</Field>
|
className="h-10 pl-9 pr-9"
|
||||||
<Field data-invalid={!!errors.fullName}>
|
aria-label="Search users"
|
||||||
<FieldLabel htmlFor="u-fullname">Full name</FieldLabel>
|
/>
|
||||||
<Input id="u-fullname" value={fullName} onChange={(e) => setFullName(e.target.value)} aria-invalid={!!errors.fullName} />
|
{search && (
|
||||||
<FieldError errors={[errors.fullName ? { message: errors.fullName } : undefined]} />
|
<button
|
||||||
</Field>
|
type="button"
|
||||||
<Field data-invalid={!!errors.email}>
|
onClick={() => setSearch("")}
|
||||||
<FieldLabel htmlFor="u-email">Email</FieldLabel>
|
aria-label="Clear search"
|
||||||
<Input
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
id="u-email"
|
>
|
||||||
type="email"
|
<X className="size-4" />
|
||||||
value={email}
|
</button>
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
)}
|
||||||
onBlur={(e) => checkEmail(e.target.value)}
|
|
||||||
aria-invalid={!!errors.email}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
|
|
||||||
{checkingEmail && <p className="text-sm text-muted-foreground">Checking for an existing staff record…</p>}
|
|
||||||
{staffMatch && (
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm">
|
|
||||||
<Link2 className="size-4 shrink-0 text-primary" />
|
|
||||||
<span>
|
|
||||||
Staff record <strong>{staffMatch.employeeCode}</strong> ({staffMatch.fullName}) matches this email — it will be linked to this user.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="u-mobile">Mobile number (optional)</FieldLabel>
|
|
||||||
<Input id="u-mobile" value={mobileNumber} onChange={(e) => setMobileNumber(e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="u-nic">NIC (optional)</FieldLabel>
|
|
||||||
<Input id="u-nic" value={nic} onChange={(e) => setNic(e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.roleId}>
|
|
||||||
<FieldLabel htmlFor="u-role">Role</FieldLabel>
|
|
||||||
<Select value={roleId || undefined} onValueChange={(v) => setRoleId(v ?? "")}>
|
|
||||||
<SelectTrigger id="u-role">
|
|
||||||
<SelectValue placeholder="Select a role" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{roles.map((r) => (
|
|
||||||
<SelectItem key={r.roleId} value={String(r.roleId)}>
|
|
||||||
{r.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.roleId ? { message: errors.roleId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
{requiresBranchScope && !staffMatch && (
|
|
||||||
<>
|
|
||||||
<div className="rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm text-muted-foreground">
|
|
||||||
This role isn't Admin — it must be scoped to one branch. These details create the linked staff record.
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<Field data-invalid={!!errors.employeeCode}>
|
|
||||||
<FieldLabel htmlFor="u-empcode">Employee code</FieldLabel>
|
|
||||||
<Input id="u-empcode" value={employeeCode} onChange={(e) => setEmployeeCode(e.target.value)} aria-invalid={!!errors.employeeCode} />
|
|
||||||
<FieldError errors={[errors.employeeCode ? { message: errors.employeeCode } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.hireDate}>
|
|
||||||
<FieldLabel htmlFor="u-hire">Hire date</FieldLabel>
|
|
||||||
<Input id="u-hire" type="date" value={hireDate} onChange={(e) => setHireDate(e.target.value)} aria-invalid={!!errors.hireDate} />
|
|
||||||
<FieldError errors={[errors.hireDate ? { message: errors.hireDate } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.departmentId}>
|
|
||||||
<FieldLabel htmlFor="u-dept">Department</FieldLabel>
|
|
||||||
<Select value={departmentId ? String(departmentId) : undefined} onValueChange={(v) => setDepartmentId(v ? Number(v) : 0)}>
|
|
||||||
<SelectTrigger id="u-dept" aria-invalid={!!errors.departmentId}><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.departmentId ? { message: errors.departmentId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.designationId}>
|
|
||||||
<FieldLabel htmlFor="u-desig">Designation</FieldLabel>
|
|
||||||
<Select value={designationId ? String(designationId) : undefined} onValueChange={(v) => setDesignationId(v ? Number(v) : 0)}>
|
|
||||||
<SelectTrigger id="u-desig" aria-invalid={!!errors.designationId}><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.designationId ? { message: errors.designationId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.employmentTypeId}>
|
|
||||||
<FieldLabel htmlFor="u-etype">Employment type</FieldLabel>
|
|
||||||
<Select value={employmentTypeId ? String(employmentTypeId) : undefined} onValueChange={(v) => setEmploymentTypeId(v ? Number(v) : 0)}>
|
|
||||||
<SelectTrigger id="u-etype" aria-invalid={!!errors.employmentTypeId}><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.employmentTypeId ? { message: errors.employmentTypeId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
<Field data-invalid={!!errors.workShiftId}>
|
|
||||||
<FieldLabel htmlFor="u-shift">Work shift</FieldLabel>
|
|
||||||
<Select value={workShiftId ? String(workShiftId) : undefined} onValueChange={(v) => setWorkShiftId(v ? Number(v) : 0)}>
|
|
||||||
<SelectTrigger id="u-shift" aria-invalid={!!errors.workShiftId}><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.workShiftId ? { message: errors.workShiftId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
{branchingEnabled && (
|
|
||||||
<Field data-invalid={!!errors.branchId}>
|
|
||||||
<FieldLabel htmlFor="u-branch">Branch</FieldLabel>
|
|
||||||
{showBranchPicker ? (
|
|
||||||
<Select value={branchId ? String(branchId) : undefined} onValueChange={(v) => setBranchId(v ? Number(v) : 0)}>
|
|
||||||
<SelectTrigger id="u-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<div id="u-branch" className="flex h-9 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
|
|
||||||
{lockedBranchName ?? "No branch assigned — contact an admin"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Field data-invalid={!!errors.userTypeId}>
|
|
||||||
<FieldLabel htmlFor="u-usertype">User type</FieldLabel>
|
|
||||||
<Select value={userTypeId || undefined} onValueChange={(v) => setUserTypeId(v ?? "")}>
|
|
||||||
<SelectTrigger id="u-usertype">
|
|
||||||
<SelectValue placeholder="Select a user type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{userTypes.map((t) => (
|
|
||||||
<SelectItem key={t.userTypeId} value={t.userTypeId}>
|
|
||||||
{t.code ?? t.userTypeId}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.userTypeId ? { message: errors.userTypeId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
|
||||||
{submitting ? "Creating…" : "Create"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -398,14 +137,39 @@ export default function UsersPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && users === null && (
|
{!error && users === null && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
<Skeleton key={i} className="h-14 w-full" />
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||||
|
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="ml-4 h-7 w-16" />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && users !== null && users.length === 0 && (
|
{!error && users !== null && users.length === 0 && debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||||
|
<Search className="size-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No users match "{debouncedSearch}"</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Try a different username or name.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && users !== null && users.length === 0 && !debouncedSearch && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
<UsersIcon className="size-12 text-muted-foreground" />
|
<UsersIcon className="size-12 text-muted-foreground" />
|
||||||
<p className="text-base text-muted-foreground">No users yet.</p>
|
<p className="text-base text-muted-foreground">No users yet.</p>
|
||||||
@@ -413,59 +177,122 @@ export default function UsersPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && users !== null && users.length > 0 && (
|
{!error && users !== null && users.length > 0 && (
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Username</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Display name</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Role</TableHead>
|
<TableHead className="h-11 w-48 px-3 text-sm">Username</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 w-48 px-3 text-sm">Display name</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 w-40 px-3 text-sm">Role</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 w-32 px-3 text-center text-sm">Actions</TableHead>
|
||||||
<TableBody>
|
</TableRow>
|
||||||
{users.map((u) => (
|
</TableHeader>
|
||||||
<TableRow key={u.userId}>
|
<TableBody>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{u.username}</TableCell>
|
{users.map((u) => (
|
||||||
<TableCell className="px-3 py-3.5">{u.displayName}</TableCell>
|
<TableRow key={u.userId} className="group align-top hover:bg-muted/20">
|
||||||
<TableCell className="px-3 py-3.5">{u.roleName ?? "—"}</TableCell>
|
<TableCell className="px-3 py-4">
|
||||||
<TableCell className="px-3 py-3.5">
|
<Link
|
||||||
<Badge
|
href={`/dashboard/settings/users/${u.userId}`}
|
||||||
variant="outline"
|
className="font-semibold leading-tight text-foreground group-hover:underline"
|
||||||
className={cn(
|
>
|
||||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
{u.username}
|
||||||
u.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
</Link>
|
||||||
)}
|
</TableCell>
|
||||||
>
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{u.displayName}</TableCell>
|
||||||
{u.status}
|
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{u.roleName ?? "—"}</TableCell>
|
||||||
</Badge>
|
<TableCell className="px-3 py-4">
|
||||||
</TableCell>
|
<Badge
|
||||||
<TableCell className="px-3 py-3.5">
|
variant="outline"
|
||||||
<Link
|
className={cn(
|
||||||
href={`/dashboard/settings/users/${u.userId}`}
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
u.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
aria-label={`Edit ${u.username}`}
|
)}
|
||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
{u.status}
|
||||||
</Link>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
<TableCell className="px-3 py-4">
|
||||||
))}
|
<div className="flex items-center justify-center gap-1">
|
||||||
</TableBody>
|
<Link
|
||||||
</Table>
|
href={`/dashboard/settings/users/${u.userId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`View ${u.username}`}
|
||||||
|
title={`View ${u.username}`}
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/settings/users/${u.userId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`Edit ${u.username}`}
|
||||||
|
title={`Edit ${u.username}`}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
u.status === "Active"
|
||||||
|
? "text-destructive hover:bg-destructive/10"
|
||||||
|
: "text-success hover:bg-success/10"
|
||||||
|
)}
|
||||||
|
aria-label={u.status === "Active" ? `Deactivate ${u.username}` : `Activate ${u.username}`}
|
||||||
|
title={u.status === "Active" ? `Deactivate ${u.username}` : `Activate ${u.username}`}
|
||||||
|
disabled={actionPendingId === u.userId}
|
||||||
|
onClick={() => handleToggleStatus(u)}
|
||||||
|
>
|
||||||
|
{u.status === "Active" ? <UserX className="size-4" /> : <UserCheck className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-3">
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
<p className="text-sm text-muted-foreground">
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
</p>
|
<span className="font-medium text-foreground">
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> users
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
Page {pagination.page} of {pagination.totalPages}
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -476,6 +303,7 @@ export default function UsersPage() {
|
|||||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ function SidebarContent({
|
|||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={cn(
|
className={cn(
|
||||||
"sidebar-surface flex h-full flex-col rounded-3xl bg-card p-3 shadow-sm ring-1 ring-foreground/10 transition-[width] duration-300 ease-in-out",
|
"sidebar-surface flex h-full flex-col rounded-3xl bg-card p-3 shadow-sm ring-1 ring-foreground/10 transition-[width] duration-300 ease-in-out print:hidden",
|
||||||
!isMobile && (collapsed ? "w-20" : "w-64")
|
!isMobile && (collapsed ? "w-20" : "w-64")
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function Breadcrumbs() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Breadcrumb className="mb-4">
|
<Breadcrumb className="mb-4 print:hidden">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{crumbs.map((crumb, i) => {
|
{crumbs.map((crumb, i) => {
|
||||||
const isLast = i === crumbs.length - 1
|
const isLast = i === crumbs.length - 1
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export function Header() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="mx-3 mt-3 mb-3 flex items-center justify-between gap-2 rounded-3xl bg-card py-4 pr-3 pl-12 shadow-sm ring-1 ring-foreground/10 sm:mx-6 sm:pr-4 sm:pl-14 lg:mx-8 lg:mt-4 lg:mb-4 lg:gap-4 lg:p-4">
|
<header className="print:hidden mx-3 mt-3 mb-3 flex items-center justify-between gap-2 rounded-3xl bg-card py-4 pr-3 pl-12 shadow-sm ring-1 ring-foreground/10 sm:mx-6 sm:pr-4 sm:pl-14 lg:mx-8 lg:mt-4 lg:mb-4 lg:gap-4 lg:p-4">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
||||||
{showBackButton && (
|
{showBackButton && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
// "select all children" convenience toggle — the parent's own visibility is
|
// "select all children" convenience toggle — the parent's own visibility is
|
||||||
// derived from its children on the frontend (see AppSidebar.tsx's filter), so
|
// derived from its children on the frontend (see AppSidebar.tsx's filter), so
|
||||||
// only the children need to carry the actual grant for those groups.
|
// only the children need to carry the actual grant for those groups.
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { NavItem } from "@/types/rbac"
|
import { NavItem } from "@/types/rbac"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -41,42 +44,60 @@ export function RolePermissionTree({ tree, selectedNavItemIds, selectedSubNavIte
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 rounded-lg border p-4">
|
<div className="overflow-hidden rounded-lg border">
|
||||||
{tree.map((item) => {
|
{tree.map((item, i) => {
|
||||||
if (item.children.length === 0) {
|
const hasChildren = item.children.length > 0
|
||||||
return (
|
|
||||||
<label key={item.navItemId} className="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedNavItemIds.has(item.navItemId)}
|
|
||||||
onCheckedChange={() => toggleLeaf(item.navItemId)}
|
|
||||||
/>
|
|
||||||
<Label className="cursor-pointer text-base font-medium">{item.label}</Label>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkedCount = item.children.filter((c) => selectedSubNavItemIds.has(c.subNavItemId)).length
|
const checkedCount = item.children.filter((c) => selectedSubNavItemIds.has(c.subNavItemId)).length
|
||||||
const allChecked = checkedCount === item.children.length
|
const allChecked = hasChildren && checkedCount === item.children.length
|
||||||
|
const someChecked = hasChildren && checkedCount > 0 && !allChecked
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.navItemId} className="flex flex-col gap-1 border-t pt-2 first:border-t-0 first:pt-0">
|
<div
|
||||||
<label className="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50">
|
key={item.navItemId}
|
||||||
<Checkbox checked={allChecked} onCheckedChange={(checked) => toggleAllChildren(item, !!checked)} />
|
className="grid grid-cols-[minmax(11rem,14rem)_1fr] items-start gap-x-4 gap-y-2 border-b bg-white px-4 py-3 last:border-b-0"
|
||||||
<Label className="cursor-pointer text-base font-semibold">
|
>
|
||||||
{item.label} {checkedCount > 0 && !allChecked && <span className="text-sm font-normal text-muted-foreground">({checkedCount} of {item.children.length})</span>}
|
<label className="flex items-center gap-2.5 pt-0.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={hasChildren ? allChecked : selectedNavItemIds.has(item.navItemId)}
|
||||||
|
indeterminate={someChecked}
|
||||||
|
onCheckedChange={
|
||||||
|
hasChildren
|
||||||
|
? (checked) => toggleAllChildren(item, !!checked)
|
||||||
|
: () => toggleLeaf(item.navItemId)
|
||||||
|
}
|
||||||
|
className="size-5 border-2 border-muted-foreground/50 data-checked:border-blue-600! data-checked:bg-blue-600! data-checked:text-white! [&_svg]:size-4"
|
||||||
|
/>
|
||||||
|
<Label className={cn("cursor-pointer text-base", hasChildren ? "font-semibold" : "font-medium")}>
|
||||||
|
{item.label}
|
||||||
</Label>
|
</Label>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-col gap-1 pl-9">
|
|
||||||
{item.children.map((child) => (
|
{hasChildren ? (
|
||||||
<label key={child.subNavItemId} className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<Checkbox
|
{item.children.map((child) => {
|
||||||
checked={selectedSubNavItemIds.has(child.subNavItemId)}
|
const checked = selectedSubNavItemIds.has(child.subNavItemId)
|
||||||
onCheckedChange={() => toggleChild(child.subNavItemId)}
|
return (
|
||||||
/>
|
<button
|
||||||
<Label className="cursor-pointer text-sm">{child.label}</Label>
|
key={child.subNavItemId}
|
||||||
</label>
|
type="button"
|
||||||
))}
|
aria-pressed={checked}
|
||||||
</div>
|
onClick={() => toggleChild(child.subNavItemId)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-sm font-medium transition-colors",
|
||||||
|
checked
|
||||||
|
? "border-transparent bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-500/20 dark:text-blue-300 dark:hover:bg-blue-500/30"
|
||||||
|
: "border-input bg-white text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{checked && <Check className="size-3.5" />}
|
||||||
|
{child.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="pt-1 text-sm text-muted-foreground">No sub-items — grants the whole section.</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
// (Branch, Designation, EmploymentType) — identical shape to each other, so one
|
// (Branch, Designation, EmploymentType) — identical shape to each other, so one
|
||||||
// component parameterized by the resource's api/labels replaces 3 near-duplicate pages.
|
// component parameterized by the resource's api/labels replaces 3 near-duplicate pages.
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Plus } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
|
||||||
|
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ApiResult, EntityStatus, PaginationMeta } from "@/types/common"
|
import { ApiResult, EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
|
|
||||||
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -19,16 +20,22 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||||
|
|
||||||
interface CodeNamed {
|
interface CodeNamed {
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
status: EntityStatus
|
status: EntityStatus
|
||||||
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Api<T extends CodeNamed> {
|
interface Api<T extends CodeNamed> {
|
||||||
list(params: { page: number; pageSize: number }): Promise<{ items: T[]; pagination: PaginationMeta }>
|
list(params: { page: number; pageSize: number; q?: string }): Promise<{ items: T[]; pagination: PaginationMeta }>
|
||||||
|
get(id: number): Promise<ApiResult<T>>
|
||||||
create(request: { code: string; name: string }): Promise<ApiResult<T>>
|
create(request: { code: string; name: string }): Promise<ApiResult<T>>
|
||||||
|
update(id: number, request: { name: string }, ifMatch: string): Promise<ApiResult<T>>
|
||||||
updateStatus(id: number, status: EntityStatus): Promise<void>
|
updateStatus(id: number, status: EntityStatus): Promise<void>
|
||||||
|
remove(id: number): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodeNameMasterPage<T extends CodeNamed>({
|
export function CodeNameMasterPage<T extends CodeNamed>({
|
||||||
@@ -42,11 +49,15 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
|||||||
idOf: (item: T) => number
|
idOf: (item: T) => number
|
||||||
api: Api<T>
|
api: Api<T>
|
||||||
}) {
|
}) {
|
||||||
const PAGE_SIZE = 10
|
const singular = title.replace(/s$/, "")
|
||||||
const [items, setItems] = useState<T[] | null>(null)
|
const [items, setItems] = useState<T[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(5)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
@@ -54,9 +65,16 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const [editItem, setEditItem] = useState<T | null>(null)
|
||||||
|
const [editEtag, setEditEtag] = useState<string | null>(null)
|
||||||
|
const [editName, setEditName] = useState("")
|
||||||
|
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||||
|
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||||
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
api
|
api
|
||||||
.list({ page, pageSize: PAGE_SIZE })
|
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setItems(res.items)
|
setItems(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
@@ -64,7 +82,15 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
|||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page]) // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setPage(1)
|
||||||
|
setDebouncedSearch(search.trim())
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
@@ -76,7 +102,7 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
|||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await api.create({ code: code.trim(), name: name.trim() })
|
await api.create({ code: code.trim(), name: name.trim() })
|
||||||
toast.success(`${title.replace(/s$/, "")} created`)
|
toast.success(`${singular} created`)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setCode("")
|
setCode("")
|
||||||
setName("")
|
setName("")
|
||||||
@@ -89,29 +115,90 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openEdit(item: T) {
|
||||||
|
setEditItem(item)
|
||||||
|
setEditName(item.name)
|
||||||
|
setEditErrors({})
|
||||||
|
setEditEtag(null)
|
||||||
|
setEditLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.get(idOf(item))
|
||||||
|
setEditEtag(res.etag)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not load record", errorMessage(err))
|
||||||
|
setEditItem(null)
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave() {
|
||||||
|
if (!editItem || !editEtag) return
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!editName.trim()) nextErrors.name = "Name is required"
|
||||||
|
setEditErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setEditSubmitting(true)
|
||||||
|
try {
|
||||||
|
await api.update(idOf(editItem), { name: editName.trim() }, editEtag)
|
||||||
|
toast.success(`${singular} updated`)
|
||||||
|
setEditItem(null)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not save", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setEditSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleStatus(item: T) {
|
async function toggleStatus(item: T) {
|
||||||
const next: EntityStatus = item.status === "Active" ? "Inactive" : "Active"
|
const next: EntityStatus = item.status === "Active" ? "Inactive" : "Active"
|
||||||
|
setActionPendingId(idOf(item))
|
||||||
try {
|
try {
|
||||||
await api.updateStatus(idOf(item), next)
|
await api.updateStatus(idOf(item), next)
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not update status", errorMessage(err))
|
toast.error("Could not update status", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: T) {
|
||||||
|
setActionPendingId(idOf(item))
|
||||||
|
try {
|
||||||
|
await api.remove(idOf(item))
|
||||||
|
toast.success(`${singular} deleted`)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not delete", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
||||||
|
{pagination && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{pagination.totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-base text-muted-foreground">{description}</p>
|
<p className="text-base text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New</Button>} />
|
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New {singular}</Button>} />
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>New {title.replace(/s$/, "")}</DialogTitle>
|
<DialogTitle>New {singular}</DialogTitle>
|
||||||
<DialogDescription>Deactivate later — masters are never deleted.</DialogDescription>
|
<DialogDescription>You can deactivate or delete it later.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.code}>
|
<Field data-invalid={!!errors.code}>
|
||||||
@@ -133,58 +220,239 @@ export function CodeNameMasterPage<T extends CodeNamed>({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>Edit {singular}</DialogTitle>
|
||||||
|
<DialogDescription>Code can't be changed after creation.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="m-edit-code">Code</FieldLabel>
|
||||||
|
<Input id="m-edit-code" value={editItem?.code ?? ""} disabled />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!editErrors.name}>
|
||||||
|
<FieldLabel htmlFor="m-edit-name">Name</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="m-edit-name"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
aria-invalid={!!editErrors.name}
|
||||||
|
disabled={editLoading}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
|
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||||
|
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
|
||||||
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={`Search by code or name…`}
|
||||||
|
className="h-10 pl-9 pr-9"
|
||||||
|
aria-label={`Search ${title.toLowerCase()}`}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
|
|
||||||
{!error && items === null && (
|
{!error && items === null && (
|
||||||
<div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length === 0 && (
|
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||||
|
<Search className="size-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No {title.toLowerCase()} match "{debouncedSearch}"</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
<p className="text-base text-muted-foreground">No records yet.</p>
|
<p className="text-base text-muted-foreground">No records yet.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="table-fixed text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 w-1/5 px-4 text-sm">Code</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 w-1/5 px-4 text-sm">Name</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 w-1/5 px-4 text-sm">Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 w-1/5 px-4 text-sm">Created</TableHead>
|
||||||
<TableBody>
|
<TableHead className="h-11 w-1/5 px-4 text-center text-sm">Actions</TableHead>
|
||||||
{items.map((item) => (
|
</TableRow>
|
||||||
<TableRow key={idOf(item)}>
|
</TableHeader>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{item.code}</TableCell>
|
<TableBody>
|
||||||
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
{items.map((item) => (
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableRow key={idOf(item)} className="group align-top hover:bg-muted/20">
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>
|
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{item.code}</TableCell>
|
||||||
{item.status}
|
<TableCell className="px-4 py-4 text-sm text-foreground">{item.name}</TableCell>
|
||||||
</Badge>
|
<TableCell className="px-4 py-4">
|
||||||
</TableCell>
|
<Badge
|
||||||
<TableCell className="px-3 py-3.5">
|
variant="outline"
|
||||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(item)}>
|
className={cn(
|
||||||
{item.status === "Active" ? "Deactivate" : "Activate"}
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
</Button>
|
item.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
</TableCell>
|
)}
|
||||||
</TableRow>
|
>
|
||||||
))}
|
{item.status}
|
||||||
</TableBody>
|
</Badge>
|
||||||
</Table>
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
{new Date(item.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
title={`Edit ${item.name}`}
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
item.status === "Active"
|
||||||
|
? "text-destructive hover:bg-destructive/10"
|
||||||
|
: "text-success hover:bg-success/10"
|
||||||
|
)}
|
||||||
|
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
|
||||||
|
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
|
||||||
|
disabled={actionPendingId === idOf(item)}
|
||||||
|
onClick={() => toggleStatus(item)}
|
||||||
|
>
|
||||||
|
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Delete ${item.name}`}
|
||||||
|
title={`Delete ${item.name}`}
|
||||||
|
disabled={actionPendingId === idOf(item)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title={`Delete ${item.name}?`}
|
||||||
|
description="This permanently removes this record. This cannot be undone. Blocked if it's referenced by other records — deactivate it instead in that case."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => handleDelete(item)}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-3">
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> {title.toLowerCase()}
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
<Button
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
type="button"
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
// Read-only Branch detail screen, linked from the "View" action on BranchMasterPage.
|
||||||
|
// Shared by both entry points (/dashboard/settings/branches/[id] and
|
||||||
|
// /dashboard/hrm/settings/branches/[id]) the same way BranchMasterPage is.
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useParams, usePathname } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { BarChart3, Building2, MapPin, Phone, ReceiptText } from "lucide-react"
|
||||||
|
|
||||||
|
import { employeesApi } from "@/lib/api/employees"
|
||||||
|
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||||
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Branch } from "@/types/hrm"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import PieChart from "@/components/ui/pie-chart"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
// Status colors, not a categorical series — same green/gray the Active/Inactive
|
||||||
|
// badges already use elsewhere on this page, so the chart reads as an extension
|
||||||
|
// of that badge rather than an unrelated palette.
|
||||||
|
const STATUS_COLORS = { active: "#22c55e", inactive: "#9ca3af" }
|
||||||
|
|
||||||
|
// Sales invoice status — same three colors as the invoice status Badge elsewhere in the app.
|
||||||
|
const SALES_STATUS_COLORS = { posted: "#22c55e", draft: "#9ca3af", cancelled: "#ef4444" }
|
||||||
|
|
||||||
|
const CURRENCY_FORMAT = new Intl.NumberFormat("en-LK", { style: "currency", currency: "LKR" })
|
||||||
|
|
||||||
|
export function BranchDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const branchId = Number(params.id)
|
||||||
|
const pathname = usePathname()
|
||||||
|
const listHref = pathname.replace(/\/\d+$/, "")
|
||||||
|
|
||||||
|
const [branch, setBranch] = useState<Branch | null>(null)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const [insights, setInsights] = useState<{ employees: number; activeEmployees: number } | null>(null)
|
||||||
|
const [salesInsights, setSalesInsights] = useState<{ posted: number; draft: number; cancelled: number; revenue: number } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(branchId)) return
|
||||||
|
setLoadError(null)
|
||||||
|
branchesHrmApi
|
||||||
|
.get(branchId)
|
||||||
|
.then((res) => setBranch(res.data))
|
||||||
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
|
}, [branchId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(branchId)) return
|
||||||
|
setInsights(null)
|
||||||
|
Promise.all([
|
||||||
|
employeesApi.list({ branchId, page: 1, pageSize: 1 }),
|
||||||
|
employeesApi.list({ branchId, status: "Active", page: 1, pageSize: 1 }),
|
||||||
|
])
|
||||||
|
.then(([all, active]) =>
|
||||||
|
setInsights({
|
||||||
|
employees: all.pagination.totalItems,
|
||||||
|
activeEmployees: active.pagination.totalItems,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(() => setInsights(null))
|
||||||
|
}, [branchId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(branchId)) return
|
||||||
|
setSalesInsights(null)
|
||||||
|
Promise.all([
|
||||||
|
// Posted revenue is summed from the invoices actually returned, so it's exact up to the
|
||||||
|
// most recent 200 — fine for an at-a-glance figure, not a statutory total.
|
||||||
|
salesApi.listInvoices({ branchId, status: "Posted", page: 1, pageSize: 200 }),
|
||||||
|
salesApi.listInvoices({ branchId, status: "Draft", page: 1, pageSize: 1 }),
|
||||||
|
salesApi.listInvoices({ branchId, status: "Cancelled", page: 1, pageSize: 1 }),
|
||||||
|
])
|
||||||
|
.then(([posted, draft, cancelled]) =>
|
||||||
|
setSalesInsights({
|
||||||
|
posted: posted.pagination.totalItems,
|
||||||
|
draft: draft.pagination.totalItems,
|
||||||
|
cancelled: cancelled.pagination.totalItems,
|
||||||
|
revenue: posted.items.reduce((sum, inv) => sum + inv.totals.grandTotal, 0),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(() => setSalesInsights(null))
|
||||||
|
}, [branchId])
|
||||||
|
|
||||||
|
if (loadError && !branch) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||||
|
<Link href={listHref} className={cn(buttonVariants({ variant: "outline" }))}>
|
||||||
|
Back to branches
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!branch) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Skeleton className="h-10 w-72" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{branch.name}</h1>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||||
|
branch.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{branch.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-sm text-muted-foreground">{branch.code}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Building2 className="size-4.5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Branch details</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Company branch/location used for multi-branch scoping.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Code</span>
|
||||||
|
<span className="font-mono text-sm text-foreground">{branch.code}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Name</span>
|
||||||
|
<span className="text-sm text-foreground">{branch.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<MapPin className="size-3.5" /> Address
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-foreground">{branch.address ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Phone className="size-3.5" /> Phone Number
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-foreground">{branch.phoneNumber ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Created</span>
|
||||||
|
<span className="text-sm text-foreground">{new Date(branch.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
{branch.updatedAt && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Last updated</span>
|
||||||
|
<span className="text-sm text-foreground">{new Date(branch.updatedAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<BarChart3 className="size-4.5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Workforce status</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Active vs. inactive employees at this branch.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
{!insights ? (
|
||||||
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
) : insights.employees === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No employees assigned to this branch yet.</p>
|
||||||
|
) : (
|
||||||
|
<PieChart
|
||||||
|
labels={["Active", "Inactive"]}
|
||||||
|
data={[insights.activeEmployees, insights.employees - insights.activeEmployees]}
|
||||||
|
backgroundColor={[STATUS_COLORS.active, STATUS_COLORS.inactive]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2.5 border-b border-border pb-4">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<ReceiptText className="size-4.5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Sales</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Invoices raised at this branch, by status.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
{!salesInsights ? (
|
||||||
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
) : salesInsights.posted + salesInsights.draft + salesInsights.cancelled === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No sales invoices for this branch yet.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
Posted revenue <span className="font-semibold text-foreground">{CURRENCY_FORMAT.format(salesInsights.revenue)}</span>
|
||||||
|
</p>
|
||||||
|
<PieChart
|
||||||
|
labels={["Posted", "Draft", "Cancelled"]}
|
||||||
|
data={[salesInsights.posted, salesInsights.draft, salesInsights.cancelled]}
|
||||||
|
backgroundColor={[SALES_STATUS_COLORS.posted, SALES_STATUS_COLORS.draft, SALES_STATUS_COLORS.cancelled]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href={listHref} className={cn(buttonVariants({ variant: "outline" }), "ml-auto w-fit")}>
|
||||||
|
Back to branches
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
// entry points (Settings → Branches and HRM → Settings → Branches) since Branch
|
// entry points (Settings → Branches and HRM → Settings → Branches) since Branch
|
||||||
// is a cross-cutting concept now, not HRM-specific.
|
// is a cross-cutting concept now, not HRM-specific.
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Plus } from "lucide-react"
|
import Link from "next/link"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { Building2, ChevronLeft, ChevronRight, CheckCircle2, Eye, Pencil, Plus, Search, Trash2, X, XCircle } from "lucide-react"
|
||||||
|
|
||||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -14,7 +16,8 @@ import { cn } from "@/lib/utils"
|
|||||||
import { Branch } from "@/types/hrm"
|
import { Branch } from "@/types/hrm"
|
||||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
@@ -23,23 +26,31 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
|
||||||
|
|
||||||
export function BranchMasterPage() {
|
export function BranchMasterPage() {
|
||||||
const PAGE_SIZE = 10
|
const pathname = usePathname()
|
||||||
const [items, setItems] = useState<Branch[] | null>(null)
|
const [items, setItems] = useState<Branch[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(5)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<Branch | null>(null)
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [address, setAddress] = useState("")
|
const [address, setAddress] = useState("")
|
||||||
const [phoneNumber, setPhoneNumber] = useState("")
|
const [phoneNumber, setPhoneNumber] = useState("")
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
branchesHrmApi
|
branchesHrmApi
|
||||||
.list({ page, pageSize: PAGE_SIZE })
|
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setItems(res.items)
|
setItems(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
@@ -47,16 +58,39 @@ export function BranchMasterPage() {
|
|||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [page])
|
useEffect(load, [page, pageSize, debouncedSearch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setPage(1)
|
||||||
|
setDebouncedSearch(search.trim())
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
|
setEditing(null)
|
||||||
setName("")
|
setName("")
|
||||||
setAddress("")
|
setAddress("")
|
||||||
setPhoneNumber("")
|
setPhoneNumber("")
|
||||||
setErrors({})
|
setErrors({})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
function openCreateDialog() {
|
||||||
|
resetForm()
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(item: Branch) {
|
||||||
|
setEditing(item)
|
||||||
|
setName(item.name)
|
||||||
|
setAddress(item.address ?? "")
|
||||||
|
setPhoneNumber(item.phoneNumber ?? "")
|
||||||
|
setErrors({})
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
if (!name.trim()) nextErrors.name = "Name is required"
|
if (!name.trim()) nextErrors.name = "Name is required"
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
@@ -64,45 +98,72 @@ export function BranchMasterPage() {
|
|||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await branchesHrmApi.create({
|
const payload = { name: name.trim(), address: address.trim() || null, phoneNumber: phoneNumber.trim() || null }
|
||||||
name: name.trim(),
|
if (editing) {
|
||||||
address: address.trim() || null,
|
// The list response carries no ETag, so re-read to get a fresh If-Match token.
|
||||||
phoneNumber: phoneNumber.trim() || null,
|
const current = await branchesHrmApi.get(editing.branchId)
|
||||||
})
|
await branchesHrmApi.update(editing.branchId, payload, current.etag ?? "")
|
||||||
toast.success("Branch created")
|
toast.success("Branch updated")
|
||||||
|
} else {
|
||||||
|
await branchesHrmApi.create(payload)
|
||||||
|
toast.success("Branch created")
|
||||||
|
}
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not create branch", errorMessage(err))
|
toast.error(editing ? "Could not update branch" : "Could not create branch", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: Branch) {
|
||||||
|
setDeletingId(item.branchId)
|
||||||
|
try {
|
||||||
|
await branchesHrmApi.remove(item.branchId)
|
||||||
|
toast.success("Branch deleted", item.name)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not delete branch", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleStatus(item: Branch) {
|
async function toggleStatus(item: Branch) {
|
||||||
const next: EntityStatus = item.status === "Active" ? "Inactive" : "Active"
|
const next: EntityStatus = item.status === "Active" ? "Inactive" : "Active"
|
||||||
|
setActionPendingId(item.branchId)
|
||||||
try {
|
try {
|
||||||
await branchesHrmApi.updateStatus(item.branchId, next)
|
await branchesHrmApi.updateStatus(item.branchId, next)
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not update status", errorMessage(err))
|
toast.error("Could not update status", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Branches</h1>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Branches</h1>
|
||||||
|
{pagination && (
|
||||||
|
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{pagination.totalItems}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-base text-muted-foreground">Company branches/locations — used for multi-branch scoping.</p>
|
<p className="text-base text-muted-foreground">Company branches/locations — used for multi-branch scoping.</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={open} onOpenChange={(next) => { setOpen(next); if (!next) resetForm() }}>
|
<Dialog open={open} onOpenChange={(next) => { setOpen(next); if (!next) resetForm() }}>
|
||||||
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New</Button>} />
|
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Branch</Button>} />
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>New Branch</DialogTitle>
|
<DialogTitle>{editing ? "Edit Branch" : "New Branch"}</DialogTitle>
|
||||||
<DialogDescription>Code is generated automatically. Deactivate later — branches are never deleted.</DialogDescription>
|
<DialogDescription>{editing ? "Code cannot be changed." : "Code is generated automatically."}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.name}>
|
<Field data-invalid={!!errors.name}>
|
||||||
@@ -121,68 +182,235 @@ export function BranchMasterPage() {
|
|||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<div className="flex justify-center gap-3 pt-2">
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||||
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
|
<Button className="min-w-32" onClick={handleSubmit} disabled={submitting}>
|
||||||
|
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search branches by name…"
|
||||||
|
className="h-10 pl-9 pr-9"
|
||||||
|
aria-label="Search branches"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||||
|
|
||||||
{!error && items === null && (
|
{!error && items === null && (
|
||||||
<div className="flex flex-col gap-3">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||||
|
<Skeleton className="h-3.5 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||||
|
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="ml-4 h-7 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length === 0 && (
|
{!error && items !== null && items.length === 0 && debouncedSearch && (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||||
|
<Search className="size-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No branches match "{debouncedSearch}"</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Try a different name.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && items !== null && items.length === 0 && !debouncedSearch && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
<p className="text-base text-muted-foreground">No branches yet.</p>
|
<Building2 className="size-12 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-medium text-foreground">No branches yet</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Add your first branch to get started.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New Branch
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && items !== null && items.length > 0 && (
|
{!error && items !== null && items.length > 0 && (
|
||||||
<Table className="text-base">
|
<div className="overflow-hidden rounded-xl border">
|
||||||
<TableHeader>
|
<div className="overflow-x-auto">
|
||||||
<TableRow>
|
<Table className="text-base">
|
||||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-12 px-3 text-sm">Address</TableHead>
|
<TableHead className="h-11 w-48 px-4 text-sm">Branch</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Phone Number</TableHead>
|
<TableHead className="h-11 w-32 px-4 text-sm">Code</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-11 px-4 text-sm">Address</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-11 w-40 px-4 text-sm">Phone Number</TableHead>
|
||||||
</TableRow>
|
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="h-11 w-40 px-4 text-center text-sm">Actions</TableHead>
|
||||||
<TableBody>
|
</TableRow>
|
||||||
{items.map((item) => (
|
</TableHeader>
|
||||||
<TableRow key={item.branchId}>
|
<TableBody>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{item.code}</TableCell>
|
{items.map((item) => (
|
||||||
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
<TableRow key={item.branchId} className="align-top hover:bg-muted/20">
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{item.address ?? "—"}</TableCell>
|
<TableCell className="px-4 py-4 font-semibold text-foreground">{item.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{item.phoneNumber ?? "—"}</TableCell>
|
<TableCell className="px-4 py-4">
|
||||||
<TableCell className="px-3 py-3.5">
|
<span className="font-mono text-xs text-muted-foreground">{item.code}</span>
|
||||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}>
|
</TableCell>
|
||||||
{item.status}
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{item.address ?? "—"}</TableCell>
|
||||||
</Badge>
|
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{item.phoneNumber ?? "—"}</TableCell>
|
||||||
</TableCell>
|
<TableCell className="px-4 py-4">
|
||||||
<TableCell className="px-3 py-3.5">
|
<Badge
|
||||||
<Button variant="ghost" size="sm" onClick={() => toggleStatus(item)}>
|
variant="outline"
|
||||||
{item.status === "Active" ? "Deactivate" : "Activate"}
|
className={cn(
|
||||||
</Button>
|
"h-5 w-fit justify-center bg-white px-2 text-xs",
|
||||||
</TableCell>
|
item.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
|
||||||
</TableRow>
|
)}
|
||||||
))}
|
>
|
||||||
</TableBody>
|
{item.status}
|
||||||
</Table>
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Link
|
||||||
|
href={`${pathname}/${item.branchId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`View ${item.name}`}
|
||||||
|
title={`View ${item.name}`}
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
title={`Edit ${item.name}`}
|
||||||
|
onClick={() => openEditDialog(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
item.status === "Active"
|
||||||
|
? "text-destructive hover:bg-destructive/10"
|
||||||
|
: "text-success hover:bg-success/10"
|
||||||
|
)}
|
||||||
|
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
|
||||||
|
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
|
||||||
|
disabled={actionPendingId === item.branchId}
|
||||||
|
onClick={() => toggleStatus(item)}
|
||||||
|
>
|
||||||
|
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10"
|
||||||
|
aria-label={`Delete ${item.name}`}
|
||||||
|
title={`Delete ${item.name}`}
|
||||||
|
disabled={deletingId === item.branchId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title={`Delete ${item.name}?`}
|
||||||
|
description="This permanently removes the branch. Branches referenced by employees, warehouses, or other records cannot be deleted."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => handleDelete(item)}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && pagination.totalItems > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-3">
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium text-foreground">{pagination.totalItems}</span> branches
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
Rows per page
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
|
||||||
|
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
<Button
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
type="button"
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -71,21 +71,21 @@ export function StatCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-3 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
"flex flex-col gap-1.5 rounded-xl bg-card p-3.5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="text-base font-medium text-muted-foreground">{label}</p>
|
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<Icon className="size-5 text-primary" />
|
<Icon className="size-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end justify-between gap-2">
|
<div className="flex items-end justify-between gap-2">
|
||||||
<p className="text-xl font-bold tracking-tight text-foreground">{formatValue(value)}</p>
|
<p className="text-lg font-bold tracking-tight text-foreground">{formatValue(value)}</p>
|
||||||
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Read-only audit trail (ERPCore Controllers/AuditLogsController.cs).
|
||||||
|
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||||
|
import { PagedResponse } from "@/types/common"
|
||||||
|
import { AuditLog } from "@/types/audit"
|
||||||
|
|
||||||
|
export interface ListAuditLogsParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
entityType?: string
|
||||||
|
entityId?: number
|
||||||
|
userId?: number
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditLogsApi = {
|
||||||
|
list(params: ListAuditLogsParams = {}): Promise<PagedResponse<AuditLog>> {
|
||||||
|
return apiRequest<PagedResponse<AuditLog>>(`/audit-logs${buildQuery(params)}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -59,6 +59,10 @@ export const employeesApi = {
|
|||||||
updateStatus(employeeId: number, status: EmployeeStatus): Promise<void> {
|
updateStatus(employeeId: number, status: EmployeeStatus): Promise<void> {
|
||||||
return apiRequest<void>(`/employees/${employeeId}/status`, { method: "PATCH", body: { status } })
|
return apiRequest<void>(`/employees/${employeeId}/status`, { method: "PATCH", body: { status } })
|
||||||
},
|
},
|
||||||
|
/** Permanently deletes the employee and every dependent HR record. Blocked (409) if payroll history exists. */
|
||||||
|
remove(employeeId: number): Promise<void> {
|
||||||
|
return apiRequest<void>(`/employees/${employeeId}`, { method: "DELETE" })
|
||||||
|
},
|
||||||
|
|
||||||
/** Advisory: does a System User already exist with this email? */
|
/** Advisory: does a System User already exist with this email? */
|
||||||
emailLookup(email: string): Promise<{ match: UserMatch | null }> {
|
emailLookup(email: string): Promise<{ match: UserMatch | null }> {
|
||||||
|
|||||||
@@ -28,5 +28,8 @@ export function createMasterApi<T, TCreate, TUpdate>(resource: string) {
|
|||||||
updateStatus(id: number, status: EntityStatus): Promise<void> {
|
updateStatus(id: number, status: EntityStatus): Promise<void> {
|
||||||
return apiRequest<void>(`/${resource}/${id}/status`, { method: "PATCH", body: { status } })
|
return apiRequest<void>(`/${resource}/${id}/status`, { method: "PATCH", body: { status } })
|
||||||
},
|
},
|
||||||
|
remove(id: number): Promise<void> {
|
||||||
|
return apiRequest<void>(`/${resource}/${id}`, { method: "DELETE" })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { LeaveRequest, LeaveRequestStatus } from "@/types/hrm"
|
|||||||
export interface ListLeaveRequestsParams {
|
export interface ListLeaveRequestsParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
|
q?: string
|
||||||
employeeId?: number
|
employeeId?: number
|
||||||
status?: LeaveRequestStatus
|
status?: LeaveRequestStatus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
// User management (ERPCore Controllers/UsersController.cs). Create orchestrates
|
// User management (ERPCore Controllers/UsersController.cs). Create orchestrates
|
||||||
// account creation in both AuthHex and ERPCore's local shadow table server-side.
|
// account creation in both AuthHex and ERPCore's local shadow table server-side.
|
||||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||||
import { PagedResponse } from "@/types/common"
|
import { EntityStatus, PagedResponse } from "@/types/common"
|
||||||
import { CreateUserRequest, ManagedUser, UpdateUserRoleRequest, UserTypeOption } from "@/types/users"
|
import { LoginActivity } from "@/types/audit"
|
||||||
|
import { CreateUserRequest, ManagedUser, UpdateUserRoleRequest, UpdateUserStatusRequest, UserTypeOption } from "@/types/users"
|
||||||
|
|
||||||
export interface ListUsersParams {
|
export interface ListUsersParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
q?: string
|
q?: string
|
||||||
|
status?: EntityStatus
|
||||||
|
roleCode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usersApi = {
|
export const usersApi = {
|
||||||
@@ -27,7 +30,17 @@ export const usersApi = {
|
|||||||
return apiRequest<ManagedUser>(`/users/${userId}/role`, { method: "PUT", body: request })
|
return apiRequest<ManagedUser>(`/users/${userId}/role`, { method: "PUT", body: request })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Revokes/restores standing inside ERPCore only — the auth-service credential itself is untouched. */
|
||||||
|
updateStatus(userId: number, request: UpdateUserStatusRequest): Promise<ManagedUser> {
|
||||||
|
return apiRequest<ManagedUser>(`/users/${userId}/status`, { method: "PATCH", body: request })
|
||||||
|
},
|
||||||
|
|
||||||
userTypes(): Promise<UserTypeOption[]> {
|
userTypes(): Promise<UserTypeOption[]> {
|
||||||
return apiRequest<UserTypeOption[]>("/users/user-types")
|
return apiRequest<UserTypeOption[]>("/users/user-types")
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Device/login history ERPCore captures itself — not a live "currently active sessions" view. */
|
||||||
|
loginDevices(userId: number, params: { page?: number; pageSize?: number } = {}): Promise<PagedResponse<LoginActivity>> {
|
||||||
|
return apiRequest<PagedResponse<LoginActivity>>(`/users/${userId}/login-devices${buildQuery(params)}`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface ListWarehousesParams {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
q?: string
|
q?: string
|
||||||
sort?: string
|
sort?: string
|
||||||
|
branchId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const warehousesApi = {
|
export const warehousesApi = {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Minimal single-page PDF writer — no dependency (jspdf/pdf-lib) needed.
|
||||||
|
// Builds a valid PDF byte-for-byte using the base14 Helvetica font, which every
|
||||||
|
// PDF reader supports without embedding. Good enough for text/table documents
|
||||||
|
// like a payslip breakdown; not a general-purpose PDF library.
|
||||||
|
|
||||||
|
export interface PdfLine {
|
||||||
|
text: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
size?: number
|
||||||
|
bold?: boolean
|
||||||
|
color?: [number, number, number] // 0-1 RGB
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_WIDTH = 612 // US Letter, points
|
||||||
|
const PAGE_HEIGHT = 792
|
||||||
|
|
||||||
|
function escapePdfText(s: string): string {
|
||||||
|
return s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a one-page PDF from absolutely-positioned text lines (origin bottom-left, like PDF space). */
|
||||||
|
export function buildSimplePdf(lines: PdfLine[]): Blob {
|
||||||
|
const content: string[] = ["q"]
|
||||||
|
let currentFont: "F1" | "F2" | null = null
|
||||||
|
let currentSize: number | null = null
|
||||||
|
let currentColor: string | null = null
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const font = line.bold ? "F2" : "F1"
|
||||||
|
const size = line.size ?? 11
|
||||||
|
const [r, g, b] = line.color ?? [0.07, 0.07, 0.07]
|
||||||
|
const colorKey = `${r},${g},${b}`
|
||||||
|
|
||||||
|
if (colorKey !== currentColor) {
|
||||||
|
content.push(`${r.toFixed(3)} ${g.toFixed(3)} ${b.toFixed(3)} rg`)
|
||||||
|
currentColor = colorKey
|
||||||
|
}
|
||||||
|
content.push("BT")
|
||||||
|
if (font !== currentFont || size !== currentSize) {
|
||||||
|
content.push(`/${font} ${size} Tf`)
|
||||||
|
currentFont = font
|
||||||
|
currentSize = size
|
||||||
|
}
|
||||||
|
content.push(`${line.x.toFixed(2)} ${line.y.toFixed(2)} Td`)
|
||||||
|
content.push(`(${escapePdfText(line.text)}) Tj`)
|
||||||
|
content.push("ET")
|
||||||
|
}
|
||||||
|
content.push("Q")
|
||||||
|
const contentStream = content.join("\n")
|
||||||
|
|
||||||
|
const objects: string[] = []
|
||||||
|
objects.push("<< /Type /Catalog /Pages 2 0 R >>")
|
||||||
|
objects.push("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
|
||||||
|
objects.push(
|
||||||
|
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`
|
||||||
|
)
|
||||||
|
objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
|
||||||
|
objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>")
|
||||||
|
objects.push(`<< /Length ${contentStream.length} >>\nstream\n${contentStream}\nendstream`)
|
||||||
|
|
||||||
|
let pdf = "%PDF-1.4\n"
|
||||||
|
const offsets: number[] = [0]
|
||||||
|
objects.forEach((obj, i) => {
|
||||||
|
offsets.push(pdf.length)
|
||||||
|
pdf += `${i + 1} 0 obj\n${obj}\nendobj\n`
|
||||||
|
})
|
||||||
|
|
||||||
|
const xrefStart = pdf.length
|
||||||
|
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
|
||||||
|
for (let i = 1; i <= objects.length; i++) {
|
||||||
|
xref += `${offsets[i].toString().padStart(10, "0")} 00000 n \n`
|
||||||
|
}
|
||||||
|
pdf += xref
|
||||||
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF`
|
||||||
|
|
||||||
|
return new Blob([pdf], { type: "application/pdf" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadPdf(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Audit trail DTOs (mirrors ERPCore's Dtos/Audit/AuditDtos.cs).
|
||||||
|
export type AuditAction = "Create" | "Update" | "Delete" | "Login"
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
auditId: number
|
||||||
|
userId: number
|
||||||
|
entityType: string
|
||||||
|
entityId: number
|
||||||
|
action: AuditAction
|
||||||
|
changeSet: unknown
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A captured past sign-in — device history, not a live "currently active sessions" view. */
|
||||||
|
export interface LoginActivity {
|
||||||
|
loginActivityId: number
|
||||||
|
ipAddress: string | null
|
||||||
|
deviceLabel: string | null
|
||||||
|
loginAt: string
|
||||||
|
}
|
||||||
@@ -44,6 +44,10 @@ export interface UpdateUserRoleRequest {
|
|||||||
roleId: number
|
roleId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserStatusRequest {
|
||||||
|
status: EntityStatus
|
||||||
|
}
|
||||||
|
|
||||||
/** AuthHex UserType lookup, for the create-user form's select (no local shadow — read-only). */
|
/** AuthHex UserType lookup, for the create-user form's select (no local shadow — read-only). */
|
||||||
export interface UserTypeOption {
|
export interface UserTypeOption {
|
||||||
userTypeId: string
|
userTypeId: string
|
||||||
|
|||||||
Reference in New Issue
Block a user