Compare commits

..

44 Commits

Author SHA1 Message Date
Naveen cb6b92d221 Add backup images for login and background removal
- Added loginerps.png.bak (1134 KB) as a backup for the login image.
- Added whithoutbackground.png.bak (3455 KB) as a backup for the image without background.
2026-09-19 00:12:41 +05:30
ImanThiyanga 3f10ae3bca changes fix for production 2026-09-19 00:00:42 +05:30
ImanThiyanga 61fe1a0e0c Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Successful in 31s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 13m12s
2026-09-18 18:23:35 +00:00
ImanThiyanga 941f20d4c5 migrations fix 2026-09-18 23:52:26 +05:30
kalana_Thilakarathna b06d20e649 Merge pull request 'test to yml' (#71) from Dev into production
Reviewed-on: #71
2026-09-18 05:23:55 +00:00
Kalana B. Thilakarathna 445d323bed test to yml
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 14m24s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m59s
2026-09-18 10:52:17 +05:30
kalana_Thilakarathna 9028255800 Merge pull request 'workflowupdate' (#70) from Dev into production
Reviewed-on: #70
2026-09-18 04:51:58 +00:00
Kalana B. Thilakarathna 1b2f244853 workflowupdate
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 11m21s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m6s
2026-09-18 10:21:36 +05:30
kalana_Thilakarathna 475bcfec1a Merge pull request 'pipeline update' (#69) from Dev into production
Reviewed-on: #69
2026-09-18 04:48:49 +00:00
Kalana B. Thilakarathna 85db26ec82 pipeline update
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 14m30s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m58s
2026-09-18 10:18:13 +05:30
kalana_Thilakarathna baee987d26 Merge pull request 'test' (#68) from Dev into production
Reviewed-on: #68
2026-09-17 08:25:31 +00:00
Kalana B. Thilakarathna f45a59f20d test
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 12m49s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m57s
2026-09-17 13:55:06 +05:30
kalana_Thilakarathna 32304cbaff Merge pull request 'user fix at workflow' (#67) from Dev into production
Reviewed-on: #67
2026-09-17 07:35:02 +00:00
Kalana B. Thilakarathna 2b64bb0ebc user fix at workflow
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m17s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
2026-09-17 13:04:15 +05:30
kalana_Thilakarathna ffe5251236 Merge pull request 'workflow fix' (#66) from Dev into production
Reviewed-on: #66
2026-09-17 07:26:14 +00:00
Kalana B. Thilakarathna d04a7fb3bc workflow fix
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Has been cancelled
2026-09-17 12:55:38 +05:30
kalana_Thilakarathna d76595085e Merge pull request 'workflow fix' (#65) from Dev into production
Reviewed-on: #65
2026-09-17 07:08:22 +00:00
Kalana B. Thilakarathna bad33a8492 workflow fix
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m57s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
2026-09-17 12:38:05 +05:30
kalana_Thilakarathna b0216fca30 Merge pull request 'triger erpo core workflow' (#64) from Dev into production
Reviewed-on: #64
2026-09-17 07:07:24 +00:00
Kalana B. Thilakarathna 49c112d188 triger erpo core workflow 2026-09-17 12:37:04 +05:30
kalana_Thilakarathna c836b57d54 Merge pull request 'gitea work flow update' (#63) from Dev into production
Reviewed-on: #63
2026-09-17 07:06:02 +00:00
Kalana B. Thilakarathna 3a583f80e5 gitea work flow update 2026-09-17 12:28:31 +05:30
ImanThiyanga 83e0335504 Merge pull request 'Dev' (#62) from Dev into production
Reviewed-on: #62
2026-09-17 05:58:25 +00:00
ImanThiyanga 513343b62a Merge pull request 'Revert/ci cd merges' (#61) from revert/ci-cd-merges into Dev
Reviewed-on: #61
2026-09-17 05:57:56 +00:00
ImanThiyanga 256e39ccff Revert "Merge pull request 'Dev' (#58) from Dev into production"
This reverts commit 821e96e011, reversing
changes made to 0ff39e9e58.
2026-09-17 11:25:33 +05:30
ImanThiyanga efb37fce7a Revert "Merge pull request 'Dev' (#60) from Dev into production"
This reverts commit 01fc2582b6, reversing
changes made to 821e96e011.
2026-09-17 11:25:33 +05:30
ImanThiyanga 01fc2582b6 Merge pull request 'Dev' (#60) from Dev into production
Reviewed-on: #60
2026-09-16 18:47:15 +00:00
ImanThiyanga 39a2dc60be Merge pull request 'Feat/ci cd implementation' (#59) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 13m56s
CI/CD - ERP Core / detect (pull_request) Failing after 14m48s
CI/CD - ERP Core / scripts (pull_request) Failing after 12m24s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m15s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m7s
CI/CD - ERP Core / backend (pull_request) Failing after 14m59s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
Reviewed-on: #59
2026-09-16 18:30:42 +00:00
ImanThiyanga 3b87a6e421 Merge branch 'Dev' into feat/ci-cd-implementation
CI/CD - ERP Core / detect (pull_request) Waiting to run
CI/CD - ERP Core / backend (pull_request) Blocked by required conditions
CI/CD - ERP Core / frontend (pull_request) Blocked by required conditions
CI/CD - ERP Core / e2e-typecheck (pull_request) Blocked by required conditions
CI/CD - ERP Core / scripts (pull_request) Blocked by required conditions
CI/CD - ERP Core / ci-ok (pull_request) Blocked by required conditions
CI/CD - ERP Core / Build & Deploy (pull_request) Waiting to run
2026-09-16 18:30:22 +00:00
ImanThiyanga 2a7a44cc27 change in to merge
CI/CD - ERP Core / detect (pull_request) Failing after 13m11s
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m17s
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
2026-09-16 23:48:07 +05:30
ImanThiyanga c7cf63e7de initiating connection fixes 2026-09-16 23:47:47 +05:30
ImanThiyanga d1044e4728 final fix 2026-09-16 13:53:57 +05:30
ImanThiyanga 821e96e011 Merge pull request 'Dev' (#58) from Dev into production
Reviewed-on: #58
2026-09-16 06:37:25 +00:00
ImanThiyanga d6f973202d Merge branch 'production' into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 10m38s
CI/CD - ERP Core / detect (pull_request) Failing after 11m30s
CI/CD - ERP Core / scripts (pull_request) Failing after 11m31s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 12m23s
CI/CD - ERP Core / frontend (pull_request) Failing after 13m14s
CI/CD - ERP Core / backend (pull_request) Failing after 14m6s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m59s
2026-09-16 06:36:19 +00:00
ImanThiyanga 1d5a5cf68e Merge pull request 'Feat/ci cd implementation' (#57) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / detect (pull_request) Has been cancelled
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Has been cancelled
Reviewed-on: #57
2026-09-16 06:24:55 +00:00
ImanThiyanga 8e472ac996 initialising for ci-cd pipeline
CI/CD - ERP Core / scripts (pull_request) Failing after 12m21s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m13s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m5s
CI/CD - ERP Core / backend (pull_request) Failing after 14m57s
CI/CD - ERP Core / detect (pull_request) Failing after 13m42s
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m47s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
2026-09-16 11:53:55 +05:30
ImanThiyanga 666979cf22 initiated ci-cd pipeline for the erp core with fronend changes to detect changes on both front and back 2026-09-16 11:53:17 +05:30
ashan_rusiru eb36e0acfa Merge pull request 'Users and employees arrangements' (#56) from users-and-employees-arrangements into Dev
Reviewed-on: #56
2026-09-14 05:23:25 +00:00
ashan_rusiru 0ff39e9e58 Merge pull request 'Users and employees arrangements' (#53) from users-and-employees-arrangements into production
Reviewed-on: #53
2026-09-13 07:52:34 +00:00
ashan_rusiru cbb610fc8b Merge branch 'production' into users-and-employees-arrangements 2026-09-13 07:52:24 +00:00
ashan_rusiru afa9928dcc Merge pull request 'bug fixing' (#55) from bug-fixing into Dev
Reviewed-on: #55
2026-09-13 07:52:06 +00:00
Sasanka 99aa395fb1 updeted 2026-09-13 08:30:18 +05:30
Sasanka cb41d503c4 emloyee and user fixings 2026-09-11 10:32:47 +05:30
ImanThiyanga b4993b202d Merge pull request 'Dev' (#45) from Dev into production
Reviewed-on: #45
2026-08-13 06:52:56 +00:00
173 changed files with 56572 additions and 2702 deletions
Submodule .claude/worktrees/agent-a98805dc618e0f67c added at b4993b202d
+106
View File
@@ -0,0 +1,106 @@
# test
name: CI/CD - ERP Core Monorepo
on:
pull_request:
branches:
- production
types:
- closed
jobs:
backend-build-and-deploy:
name: Build & Deploy ERP Core Backend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Restore backend
working-directory: .gitea/workflows
run: dotnet restore ../../Backend/ERPCore.sln
- name: Publish backend
working-directory: .gitea/workflows
run: |
rm -rf /tmp/erpcore-deploy
mkdir -p /tmp/erpcore-deploy
dotnet publish ../../Backend/ERPCore/ERPCore.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpcore-deploy
- name: Stop backend service
run: systemctl stop erpcore.service
- name: Copy backend to deploy folder
run: |
mkdir -p /home/erp/core/publish
rm -rf /home/erp/core/publish/*
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
- name: Fix backend ownership and permissions
run: |
chown -R erpuser:erpuser /home/erp/core/publish
find /home/erp/core/publish -type d -exec chmod 755 {} +
find /home/erp/core/publish -type f -exec chmod 644 {} +
chmod +x /home/erp/core/publish/ERPCore
- name: Restart backend service
run: systemctl restart erpcore.service
- name: Verify backend
run: |
systemctl is-active erpcore.service && echo "Backend service running" || echo "Backend service failed"
frontend-build-and-deploy:
name: Build & Deploy ERP Core Frontend
runs-on: linux
needs: backend-build-and-deploy
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch Production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Build frontend Docker image
working-directory: .gitea/workflows
run: docker build -t erpcore-frontend:latest ../../Frontend/erp-system
- name: Stop & remove existing frontend container
run: |
docker stop erpcore-frontend 2>/dev/null || true
docker rm erpcore-frontend 2>/dev/null || true
- name: Start frontend container
run: |
docker run -d \
--name erpcore-frontend \
-p 6702:6702 \
--add-host=host.docker.internal:host-gateway \
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
--restart unless-stopped \
--memory="512m" \
--cpus="1.0" \
--cap-drop ALL \
--security-opt no-new-privileges \
erpcore-frontend:latest
- name: Remove old frontend images
run: docker image prune -f
- name: Verify frontend
run: |
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpcore-frontend && echo "Frontend container running" || echo "Frontend container failed"
@@ -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();
}
} }
+14 -2
View File
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms; using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
@@ -15,8 +16,9 @@ public sealed class UomsController : ApiControllerBase
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct) public async Task<ActionResult<PagedResponse<UomDto>>> List(
=> Ok(await _uoms.ListAsync(query, ct)); [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, status, ct));
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
@@ -26,4 +28,14 @@ public sealed class UomsController : ApiControllerBase
var dto = await _uoms.CreateAsync(request, ct); var dto = await _uoms.CreateAsync(request, ct);
return Created($"/api/v1/uoms/{dto.UomId}", dto); return Created($"/api/v1/uoms/{dto.UomId}", dto);
} }
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{uomId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
{
await _uoms.SetStatusAsync(uomId, request.Status, ct);
return NoContent();
}
} }
+21 -3
View File
@@ -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)]
+4 -2
View File
@@ -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; }
}
+6
View File
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities; namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
@@ -10,4 +12,8 @@ public class Uom
{ {
public int UomId { get; set; } public int UomId { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
} }
+4 -1
View File
@@ -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
} }
+4
View File
@@ -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);
+7 -1
View File
@@ -1,11 +1,17 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Uoms; namespace ERPCore.Dtos.Uoms;
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary> /// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public sealed record UomDto(int UomId, string Name); public sealed record UomDto(int UomId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateUomRequest public sealed class CreateUomRequest
{ {
[Required, StringLength(50)] public string Name { get; set; } = string.Empty; [Required, StringLength(50)] public string Name { get; set; } = string.Empty;
} }
public sealed class UpdateUomStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
+5
View File
@@ -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 }
); );
} }
} }
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -13,5 +14,13 @@ public sealed class UomConfiguration : IEntityTypeConfiguration<Uom>
builder.Property(u => u.Name).IsRequired().HasMaxLength(50); builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
builder.HasIndex(u => u.Name).IsUnique(); builder.HasIndex(u => u.Name).IsUnique();
builder.Property(u => u.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(u => u.CreatedAt).IsRequired();
builder.HasIndex(u => u.Status);
} }
} }
@@ -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>();
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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddUomLifecycleColumnsLocal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "uoms",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<string>(
name: "Status",
table: "uoms",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Active");
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAt",
table: "uoms",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_uoms_Status",
table: "uoms",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_uoms_Status",
table: "uoms");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "uoms");
migrationBuilder.DropColumn(
name: "Status",
table: "uoms");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "uoms");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class changeMismatch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -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"
}); });
}); });
@@ -5784,16 +6000,31 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UomId"); b.HasKey("UomId");
b.HasIndex("Name") b.HasIndex("Name")
.IsUnique(); .IsUnique();
b.HasIndex("Status");
b.ToTable("uoms", (string)null); b.ToTable("uoms", (string)null);
}); });
@@ -6618,6 +6849,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")
@@ -6,7 +6,7 @@
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"launchUrl": "swagger", "launchUrl": "swagger",
"applicationUrl": "http://localhost:5224", "applicationUrl": "http://localhost:6701",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
@@ -16,7 +16,7 @@
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"launchUrl": "swagger", "launchUrl": "swagger",
"applicationUrl": "https://localhost:7112;http://localhost:5224", "applicationUrl": "https://localhost:7112;http://localhost:6701",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
+56 -1
View File
@@ -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.Uoms; using ERPCore.Dtos.Uoms;
@@ -6,6 +7,7 @@ namespace ERPCore.Services.Interfaces;
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary> /// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public interface IUomService public interface IUomService
{ {
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default); Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default); Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
Task SetStatusAsync(int uomId, EntityStatus status, 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 &amp; bin master business logic (docs/11-BACKEND-PHASE1.md §2.5).</summary> /// <summary>Warehouse &amp; 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);
} }
+16 -4
View File
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms; using ERPCore.Dtos.Uoms;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
@@ -20,7 +21,7 @@ public sealed class UomService : IUomService
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default) public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{ {
var q = _uoms.Query().AsNoTracking(); var q = _uoms.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q)) if (!string.IsNullOrWhiteSpace(query.Q))
@@ -28,11 +29,12 @@ public sealed class UomService : IUomService
var term = query.Q.Trim(); var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%")); q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
} }
if (status is not null) q = q.Where(u => u.Status == status);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Name) var rows = await q.OrderBy(u => u.Name)
.Skip(query.Skip).Take(query.PageSize) .Skip(query.Skip).Take(query.PageSize)
.Select(u => new UomDto(u.UomId, u.Name)) .Select(u => new UomDto(u.UomId, u.Name, u.Status, u.CreatedAt, u.UpdatedAt))
.ToListAsync(ct); .ToListAsync(ct);
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total); return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
@@ -44,10 +46,20 @@ public sealed class UomService : IUomService
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct)) if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
throw new ConflictException($"A UOM named '{name}' already exists."); throw new ConflictException($"A UOM named '{name}' already exists.");
var uom = new Uom { Name = name }; var uom = new Uom { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
await _uoms.AddAsync(uom, ct); await _uoms.AddAsync(uom, ct);
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
return new UomDto(uom.UomId, uom.Name); return new UomDto(uom.UomId, uom.Name, uom.Status, uom.CreatedAt, uom.UpdatedAt);
}
public async Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default)
{
var uom = await _uoms.GetByIdAsync(uomId, ct)
?? throw new NotFoundException($"UOM {uomId} was not found.");
uom.Status = status;
uom.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
} }
} }
@@ -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);
+2 -1
View File
@@ -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 -1
View File
@@ -6,7 +6,7 @@
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive" "DefaultConnection": "Host=localhost;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "http://localhost:5011" "BaseUrl": "http://localhost:5011"
@@ -1,7 +1,4 @@
{ {
"ConnectionStrings": {
"DefaultConnection": ""
},
"Jwt": { "Jwt": {
"SigningKey": "" "SigningKey": ""
}, },
+3 -2
View File
@@ -6,8 +6,9 @@
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root" "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
}, },
"Urls": "http://0.0.0.0:6701",
"Auth": { "Auth": {
"Issuer": "AuthHex", "Issuer": "AuthHex",
"Audience": "AuthHexClient", "Audience": "AuthHexClient",
@@ -16,7 +17,7 @@
"RequiredRoleCode": "" "RequiredRoleCode": ""
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "http://localhost:5602" "BaseUrl": "http://localhost:6703"
}, },
"FileStorage": { "FileStorage": {
"RootPath": "App_Data/hr-documents", "RootPath": "App_Data/hr-documents",
+18
View File
@@ -0,0 +1,18 @@
node_modules
.next
out
build
coverage
.vercel
.env
.env.*
!.env.local.example
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
Dockerfile
.dockerignore
+1 -1
View File
@@ -4,4 +4,4 @@
# #
# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the # Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the
# proxy will refuse. # proxy will refuse.
BACKEND_ORIGIN=http://localhost:5224 BACKEND_ORIGIN=http://localhost:6701
+32
View File
@@ -0,0 +1,32 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=6702
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.ts ./next.config.ts
EXPOSE 6702
CMD ["npm", "run", "start"]
@@ -161,11 +161,11 @@ export default function BankAccountsPage() {
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell> <TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell> <TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell> <TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground"> <TableCell className="px-3 py-3.5">
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`} {gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell> <TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell> <TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
@@ -148,7 +148,7 @@ export default function ChequeBookDetailPage() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell> <TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell> <TableCell className="px-3 py-3.5">{formatReportDate(p.issueDate)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums"> <TableCell className="px-3 py-3.5 text-right tabular-nums">
{p.amount !== null ? formatAmount(p.amount) : "—"} {p.amount !== null ? formatAmount(p.amount) : "—"}
</TableCell> </TableCell>
@@ -142,15 +142,15 @@ export default function ChequeBooksPage() {
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)} onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
> >
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell> <TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground"> <TableCell className="px-3 py-3.5">
{bank ? bank.accountName : `#${b.bankAccountId}`} {bank ? bank.accountName : `#${b.bankAccountId}`}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell> <TableCell className="px-3 py-3.5">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
{b.startChequeNo} – {b.endChequeNo} {b.startChequeNo} – {b.endChequeNo}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell> <TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell> <TableCell className="px-3 py-3.5">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}> <Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
{b.status} {b.status}
@@ -146,12 +146,12 @@ export default function ReceivedChequesPage() {
> >
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell> <TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell> <TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell> <TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground"> <TableCell className="px-3 py-3.5">
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"} {c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell> <TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell> <TableCell className="px-3 py-3.5">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}> <Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
{c.status} {c.status}
@@ -15,11 +15,12 @@ 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 { PaginationFooter } from "@/components/ui/pagination-footer"
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 = 25
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = { const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
Draft: "bg-muted text-muted-foreground", Draft: "bg-muted text-muted-foreground",
@@ -137,16 +138,7 @@ export default function AttendanceBatchesPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<div className="flex items-center 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-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
<span className="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</Button>
</div>
</div>
)}
</div> </div>
) )
} }
@@ -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>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">{employee.fullName}</h1> <h1 className="text-2xl font-bold text-foreground">{employee.fullName}</h1>
<p className="text-base text-muted-foreground"> <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>
{employee.employeeCode} </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>
<div>
<h2 className="text-base font-semibold text-foreground">Personal details</h2>
<p className="text-sm text-muted-foreground">Contact and identity information.</p>
</div>
</div>
<FieldGroup>
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field><FieldLabel>Full name</FieldLabel><Input disabled={!isEditing} value={employee.fullName} onChange={(e) => setEmployee({ ...employee, fullName: e.target.value })} /></Field>
<Field><FieldLabel>NIC</FieldLabel><Input disabled={!isEditing} value={employee.nic ?? ""} onChange={(e) => setEmployee({ ...employee, nic: e.target.value })} /></Field>
<Field><FieldLabel>Email</FieldLabel><Input disabled={!isEditing} type="email" value={employee.email ?? ""} onChange={(e) => setEmployee({ ...employee, email: e.target.value })} /></Field>
<Field><FieldLabel>Personal mobile</FieldLabel><Input disabled={!isEditing} value={employee.personalMobile ?? ""} onChange={(e) => setEmployee({ ...employee, personalMobile: e.target.value })} /></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">
<Briefcase className="size-4.5 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Employment details</h2>
<p className="text-sm text-muted-foreground">Where and how this employee works.</p>
</div>
</div>
<FieldGroup>
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{branchingEnabled && (
<Field>
<FieldLabel>Branch</FieldLabel>
<Select value={String(employee.branchId)} onValueChange={(v) => setEmployee({ ...employee, branchId: Number(v) })}>
<SelectTrigger disabled={!isEditing || !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>
</Select>
</Field>
)}
<Field> <Field>
<FieldLabel>Department</FieldLabel> <FieldLabel>Department</FieldLabel>
<Select value={String(employee.departmentId)} onValueChange={(v) => setEmployee({ ...employee, departmentId: Number(v) })}> <Select value={String(employee.departmentId)} onValueChange={(v) => setEmployee({ ...employee, departmentId: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger disabled={!isEditing}><SelectValue /></SelectTrigger>
<SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent> <SelectContent>{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}</SelectContent>
</Select> </Select>
</Field> </Field>
<Field> <Field>
<FieldLabel>Designation</FieldLabel> <FieldLabel>Designation</FieldLabel>
<Select value={String(employee.designationId)} onValueChange={(v) => setEmployee({ ...employee, designationId: Number(v) })}> <Select value={String(employee.designationId)} onValueChange={(v) => setEmployee({ ...employee, designationId: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger disabled={!isEditing}><SelectValue /></SelectTrigger>
<SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent> <SelectContent>{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}</SelectContent>
</Select> </Select>
</Field> </Field>
<Field> <Field>
<FieldLabel>Employment type</FieldLabel> <FieldLabel>Employment type</FieldLabel>
<Select value={String(employee.employmentTypeId)} onValueChange={(v) => setEmployee({ ...employee, employmentTypeId: Number(v) })}> <Select value={String(employee.employmentTypeId)} onValueChange={(v) => setEmployee({ ...employee, employmentTypeId: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger disabled={!isEditing}><SelectValue /></SelectTrigger>
<SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent> <SelectContent>{employmentTypes.map((t) => <SelectItem key={t.employmentTypeId} value={String(t.employmentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select> </Select>
</Field> </Field>
<Field> <Field>
<FieldLabel>Work shift</FieldLabel> <FieldLabel>Work shift</FieldLabel>
<Select value={String(employee.workShiftId)} onValueChange={(v) => setEmployee({ ...employee, workShiftId: Number(v) })}> <Select value={String(employee.workShiftId)} onValueChange={(v) => setEmployee({ ...employee, workShiftId: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger disabled={!isEditing}><SelectValue /></SelectTrigger>
<SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent> <SelectContent>{workShifts.map((w) => <SelectItem key={w.workShiftId} value={String(w.workShiftId)}>{w.name}</SelectItem>)}</SelectContent>
</Select> </Select>
</Field> </Field>
{branchingEnabled && (
<Field>
<FieldLabel>Branch</FieldLabel>
<Select value={String(employee.branchId)} onValueChange={(v) => setEmployee({ ...employee, branchId: Number(v) })}>
<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>
</Select>
</Field>
)}
<Field><FieldLabel>EPF number</FieldLabel><Input value={employee.epfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, epfNumber: e.target.value })} /></Field>
<Field><FieldLabel>ETF number</FieldLabel><Input value={employee.etfNumber ?? ""} onChange={(e) => setEmployee({ ...employee, etfNumber: e.target.value })} /></Field>
</div>
<div className="flex justify-end pt-2">
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
</div> </div>
</FieldGroup> </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">
<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">
<Upload className="size-4.5 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Upload a document</h2>
<p className="text-sm text-muted-foreground">NIC, contracts, certificates — PDF, image, or Word file.</p>
</div>
</div>
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:items-end">
<Field className="w-full sm:max-w-64">
<FieldLabel htmlFor="doc-upload-type">Document type</FieldLabel>
<Select value={uploadTypeId} onValueChange={(v) => setUploadTypeId(v ?? "")}> <Select value={uploadTypeId} onValueChange={(v) => setUploadTypeId(v ?? "")}>
<SelectTrigger className="w-56"><SelectValue placeholder="Document type" /></SelectTrigger> <SelectTrigger id="doc-upload-type"><SelectValue placeholder="Select a type" /></SelectTrigger>
<SelectContent>{documentTypes.map((t) => <SelectItem key={t.hrDocumentTypeId} value={String(t.hrDocumentTypeId)}>{t.name}</SelectItem>)}</SelectContent> <SelectContent>{documentTypes.map((t) => <SelectItem key={t.hrDocumentTypeId} value={String(t.hrDocumentTypeId)}>{t.name}</SelectItem>)}</SelectContent>
</Select> </Select>
<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")}> </Field>
<label
className={cn(
"inline-flex h-9 w-fit items-center gap-2 rounded-lg border border-black px-4 text-sm font-medium",
uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60"
)}
title={uploadTypeId ? undefined : "Select a document type first"}
>
<Upload className="size-4" /> <Upload className="size-4" />
Upload Choose file to upload
<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 = "" }} /> <input
type="file"
className="hidden"
disabled={!uploadTypeId}
accept=".pdf,.jpg,.jpeg,.png,.docx"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = "" }}
/>
</label> </label>
</div> </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"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">File</TableHead> <TableHead className="h-11 px-4 text-sm">File</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead> <TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Uploaded</TableHead> <TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Download</TableHead> <TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{documents.map((d) => ( {documents.map((d) => (
<TableRow key={d.employeeDocumentId}> <TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
<TableCell className="px-3 py-3.5">{d.originalFileName}</TableCell> <TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell>
<TableCell className="px-3 py-3.5">{d.hrDocumentTypeName ?? "—"}</TableCell> <TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell> <TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{d.status}</TableCell> <TableCell className="px-4 py-3.5">
<TableCell className="px-3 py-3.5"> <Badge
<a href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)} className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer">Download</a> 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> </TableCell>
</TableRow> </TableRow>
))} ))}
{documents.length === 0 && ( {documents.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-3 py-8 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow> <TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
)} )}
</TableBody> </TableBody>
</Table> </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>
{currentStructure && (
<div className="rounded-xl border p-4 sm:p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">Current breakdown</h3>
<span className="text-xs text-muted-foreground">Effective {new Date(currentStructure.effectiveFrom).toLocaleDateString()}</span>
</div>
<div className="mt-3 flex flex-col gap-1.5 text-sm">
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">Basic salary</span>
<span className="text-right font-medium text-foreground">{money(currentStructure.basicSalary)}</span>
</div>
{earningLines.map((l) => (
<div key={l.salaryComponentId} className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{l.salaryComponentName ?? "Allowance"}</span>
<span className="text-right font-medium text-foreground">+{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">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>
</>
)}
<p className="mt-3 text-xs text-muted-foreground">
OT, Late/No-Pay, loan installments, EPF/ETF, and tax are computed at payroll time and aren&apos;t part of this structure.
</p>
</div>
)}
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Effective from</TableHead> <TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-12 px-3 text-sm">Effective to</TableHead> <TableHead className="h-11 px-4 text-sm">Effective to</TableHead>
<TableHead className="h-12 px-3 text-sm">Basic</TableHead> <TableHead className="h-11 px-4 text-sm">Basic</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{salaryStructures.map((s) => ( {salaryStructures.map((s) => (
<TableRow key={s.employeeSalaryStructureId}> <TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20">
<TableCell className="px-3 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell> <TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell> <TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
<TableCell className="px-3 py-3.5">{money(s.basicSalary)}</TableCell> <TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell>
<TableCell className="px-3 py-3.5">{s.status}</TableCell> <TableCell className="px-4 py-3.5">{s.status}</TableCell>
</TableRow> </TableRow>
))} ))}
{salaryStructures.length === 0 && ( {salaryStructures.length === 0 && (
<TableRow><TableCell colSpan={4} className="px-3 py-8 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow> <TableRow><TableCell colSpan={4} className="px-4 py-10 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
)} )}
</TableBody> </TableBody>
</Table> </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 &amp; Advances</h2> <h2 className="text-lg font-semibold text-foreground">Loans &amp; 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>
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead> <TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Principal</TableHead> <TableHead className="h-11 px-4 text-sm">Principal</TableHead>
<TableHead className="h-12 px-3 text-sm">Outstanding</TableHead> <TableHead className="h-11 px-4 text-sm">Outstanding</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loans.map((l) => ( {loans.map((l) => (
<TableRow key={l.employeeLoanId}> <TableRow key={l.employeeLoanId} className="hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{l.docNo}</TableCell> <TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{l.loanKind}</TableCell> <TableCell className="px-4 py-3.5">{l.loanKind}</TableCell>
<TableCell className="px-3 py-3.5">{money(l.principalAmount)}</TableCell> <TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell>
<TableCell className="px-3 py-3.5">{money(l.outstandingBalance)}</TableCell> <TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell>
<TableCell className="px-3 py-3.5">{l.status}</TableCell> <TableCell className="px-4 py-3.5">{l.status}</TableCell>
</TableRow> </TableRow>
))} ))}
{loans.length === 0 && ( {loans.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-3 py-8 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow> <TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
)} )}
</TableBody> </TableBody>
</Table> </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,89 @@
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 { 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 { PaginationFooter } from "@/components/ui/pagination-footer"
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 { 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([
employeesApi.list({ page: 1, pageSize: 1, branchId }),
employeesApi.list({ page: 1, pageSize: 1, status: "Active", branchId }),
employeesApi.list({ page: 1, pageSize: 1, status: "Terminated", branchId }),
])
.then(([totalRes, activeRes, terminatedRes]) =>
setStats({ total: totalRes.pagination.totalItems, active: activeRes.pagination.totalItems, terminated: terminatedRes.pagination.totalItems })
)
.catch(() => setStats(null))
}
useEffect(loadStats, [activeBranchId, branchingEnabled])
// Permanent delete — blocked by the backend (409) if the employee has payroll history.
async function handleDelete(e: EmployeeListItem) {
setActionPendingId(e.employeeId)
try { try {
const { match } = await employeesApi.emailLookup(email.trim()) await employeesApi.remove(e.employeeId)
setUserMatch(match) toast.success("Employee deleted", `${e.fullName} has been permanently removed.`)
} catch {
setUserMatch(null)
} finally {
setCheckingEmail(false)
}
}
function resetForm() {
setForm(emptyForm)
setUserMatch(null)
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 {
await employeesApi.create({
...form,
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 +92,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>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Employees</h1> <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> </div>
<Field data-invalid={!!errors.fullName}>
<FieldLabel htmlFor="e-name">Full name</FieldLabel> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Input id="e-name" value={form.fullName} onChange={(e) => setForm((f) => ({ ...f, fullName: e.target.value }))} aria-invalid={!!errors.fullName} /> {stats ? (
<FieldError errors={[errors.fullName ? { message: errors.fullName } : undefined]} /> <>
</Field> <StatCard label="Total employees" value={stats.total} icon={UsersIcon} />
<Field> <StatCard label="Active" value={stats.active} icon={UserCheck} />
<FieldLabel htmlFor="e-email">Email (optional)</FieldLabel> <StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
<Input <StatCard label="Terminated" value={stats.terminated} icon={UserMinus} />
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"> Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
{lockedBranchName ?? "No branch assigned — contact an admin"}
</div>
)}
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field>
)} )}
</div> </div>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center"> <div className="relative w-full sm:max-w-xs">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button> <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button> <Input
</div> value={search}
</DialogContent> onChange={(e) => setSearch(e.target.value)}
</Dialog> 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> </div>
<Input placeholder="Search by name or employee code…" value={q} onChange={(e) => { setPage(1); setQ(e.target.value) }} className="max-w-sm" /> {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 && employees === null && ( {!error && employees === 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 && employees !== null && employees.length === 0 && ( {!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 &quot;{debouncedSearch}&quot;</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 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Code</TableHead> <TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead> <TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Department</TableHead> <TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead>
<TableHead className="h-12 px-3 text-sm">Designation</TableHead> <TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead>
<TableHead className="h-12 px-3 text-sm">Login</TableHead> <TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{employees.map((e) => ( {employees.map((e) => (
<TableRow key={e.employeeId}> <TableRow key={e.employeeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{e.employeeCode}</TableCell> <TableCell className="px-3 py-4">
<TableCell className="px-3 py-3.5">{e.fullName}</TableCell> <Link
<TableCell className="px-3 py-3.5">{e.departmentName ?? "—"}</TableCell> href={`/dashboard/hrm/employees/${e.employeeId}`}
<TableCell className="px-3 py-3.5">{e.designationName ?? "—"}</TableCell> 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"
<TableCell className="px-3 py-3.5"> >
{e.fullName}
</Link>
</TableCell>
<TableCell className="px-3 py-4">
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.departmentName ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.designationName ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{new Date(e.hireDate).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</TableCell>
<TableCell className="px-3 py-4">
{e.hasUserLink ? ( {e.hasUserLink ? (
<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> <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-sm text-muted-foreground">No login</span> <span className="text-xs text-muted-foreground">No login</span>
)} )}
</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">
<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>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`}> <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" /> <Pencil className="size-4" />
</Link> </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> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </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,18 @@ 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 { PaginationFooter } from "@/components/ui/pagination-footer"
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 +40,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 +59,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 +109,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 +148,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>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Leave</h1> <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&apos;s OnLeave status and Payroll&apos;s No-Pay calculation.</p> <p className="text-base text-muted-foreground">Approved leave feeds Attendance&apos;s OnLeave status and Payroll&apos;s No-Pay calculation.</p>
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -143,53 +194,228 @@ export default function LeaveRequestsPage() {
</Dialog> </Dialog>
</div> </div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>} <Dialog open={!!rejectTarget} onOpenChange={(v) => !v && setRejectTarget(null)}>
{!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>} <DialogContent className="sm:max-w-sm">
{!error && requests !== null && requests.length === 0 && ( <DialogHeader className="items-center 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 leave requests yet.</p></div> <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 && 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 && (
<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 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Employee</TableHead> <TableHead className="h-11 px-4 text-sm">Employee</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead> <TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Dates</TableHead> <TableHead className="h-11 px-4 text-sm">Dates</TableHead>
<TableHead className="h-12 px-3 text-sm">Days</TableHead> <TableHead className="h-11 px-4 text-sm">Days</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{requests.map((r) => ( {requests.map((r) => (
<TableRow key={r.leaveRequestId}> <TableRow key={r.leaveRequestId} className="hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell> <TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{r.employeeName ?? "—"}</TableCell> <TableCell className="px-4 py-4 text-sm text-foreground">{r.employeeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{r.leaveTypeName ?? "—"}</TableCell> <TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.leaveTypeName ?? "—"}</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-muted-foreground">
<TableCell className="px-3 py-3.5">{r.daysCount}</TableCell> {new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
<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>
<TableCell className="px-3 py-3.5"> <TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.daysCount}</TableCell>
{r.status === "Submitted" && ( <TableCell className="px-4 py-4">
<div className="flex gap-1"> <Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
<Button variant="ghost" size="sm" onClick={() => approve(r.leaveRequestId)}>Approve</Button> {r.status}
<Button variant="ghost" size="sm" onClick={() => reject(r.leaveRequestId)}>Reject</Button> </Badge>
</TableCell>
<TableCell className="px-4 py-4">
{r.status === "Submitted" ? (
<div className="flex items-center justify-center gap-1">
<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> </div>
) : (
<span className="flex justify-center text-xs text-muted-foreground">—</span>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </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,35 +143,89 @@ 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>
<div className="flex items-center gap-2.5">
<h2 className="text-lg font-semibold text-foreground">Payroll lines</h2>
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">{lines.length}</span>
</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); setPage(1) }}
placeholder="Search by name or employee code…"
className="h-10 pl-9 pr-9"
aria-label="Search payroll lines"
/>
{search && (
<button
type="button"
onClick={() => { setSearch(""); setPage(1) }}
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>
{filteredLines.length === 0 ? (
<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" />
<p className="text-base text-muted-foreground">
{search ? <>No lines match &quot;{search}&quot;.</> : "No payroll lines yet."}
</p>
</div>
) : (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Employee</TableHead> <TableHead className="h-11 px-4 text-sm">Employee</TableHead>
<TableHead className="h-12 px-3 text-sm">Basic</TableHead> <TableHead className="h-11 px-4 text-sm">Basic</TableHead>
<TableHead className="h-12 px-3 text-sm">OT</TableHead> <TableHead className="h-11 px-4 text-sm">OT</TableHead>
<TableHead className="h-12 px-3 text-sm">Allowances</TableHead> <TableHead className="h-11 px-4 text-sm">Allowances</TableHead>
<TableHead className="h-12 px-3 text-sm">Deductions</TableHead> <TableHead className="h-11 px-4 text-sm">Deductions</TableHead>
<TableHead className="h-12 px-3 text-sm">Net Salary</TableHead> <TableHead className="h-11 px-4 text-sm">Net Salary</TableHead>
<TableHead className="h-12 px-3 text-sm">Details</TableHead> <TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{lines.map((l) => { {pagedLines.map((l) => {
const totalDeductions = l.grossSalary - l.netSalary const totalDeductions = l.grossSalary - l.netSalary
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId) const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
return ( return (
<TableRow key={l.payrollLineId}> <TableRow key={l.payrollLineId} className="hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{l.employeeName} <span className="text-muted-foreground">({l.employeeCode})</span></TableCell> <TableCell className="px-4 py-3.5 font-medium text-foreground">
<TableCell className="px-3 py-3.5">{money(l.basicSalary)}</TableCell> {l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
<TableCell className="px-3 py-3.5">{money(l.overtimeAmount)}</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{money(l.totalAllowances)}</TableCell> <TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell>
<TableCell className="px-3 py-3.5">{money(totalDeductions)}</TableCell> <TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell>
<TableCell className="px-3 py-3.5 font-semibold">{money(l.netSalary)}</TableCell> <TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell>
<div className="flex items-center gap-3"> <TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell>
<Link href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`} className="text-primary underline underline-offset-2">Breakdown</Link> <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 && ( {payslip && (
<a href={payslipViewUrl(payslip.payslipId)} target="_blank" rel="noreferrer" className={cn("text-primary underline underline-offset-2")}>Payslip</a> <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> </div>
</TableCell> </TableCell>
@@ -166,5 +235,58 @@ export default function PayrollRunDetailPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </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>
) )
} }
@@ -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>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Payroll</h1> <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 && runs.length === 0 && ( {!error && runs === 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 payroll runs 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-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 && (
<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 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Period</TableHead> <TableHead className="h-11 px-4 text-sm">Period</TableHead>
<TableHead className="h-12 px-3 text-sm">Employees</TableHead> <TableHead className="h-11 px-4 text-sm">Employees</TableHead>
<TableHead className="h-12 px-3 text-sm">Gross</TableHead> <TableHead className="h-11 px-4 text-sm">Gross</TableHead>
<TableHead className="h-12 px-3 text-sm">Net</TableHead> <TableHead className="h-11 px-4 text-sm">Net</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{runs.map((r) => ( {runs.map((r) => (
<TableRow key={r.payrollRunId}> <TableRow key={r.payrollRunId} className="hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell> <TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</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.employeeCount}</TableCell> <TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell>
<TableCell className="px-3 py-3.5">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</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">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell> <TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</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">
<TableCell className="px-3 py-3.5"> <Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
<Link href={`/dashboard/hrm/payroll/${r.payrollRunId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label="View"><Eye className="size-4" /></Link> {r.status}
</Badge>
</TableCell>
<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> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </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,17 +9,19 @@ 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"
import { Field, FieldError, 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 { PaginationFooter } from "@/components/ui/pagination-footer"
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 { 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 +30,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 +43,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 +62,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 +105,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 +177,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>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Departments</h1> <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 +238,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&apos;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 &quot;{debouncedSearch}&quot;</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">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Code</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Parent</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((d) => ( {items.map((d) => (
<TableRow key={d.departmentId}> <TableRow key={d.departmentId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{d.code}</TableCell> <TableCell className="px-4 py-4 font-mono text-sm text-foreground">{d.code}</TableCell>
<TableCell className="px-3 py-3.5">{d.name}</TableCell> <TableCell className="px-4 py-4 text-sm text-foreground">{d.name}</TableCell>
<TableCell className="px-3 py-3.5">{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}</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 ?? "—"}
<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> </TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-4 py-4">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(d)}>{d.status === "Active" ? "Deactivate" : "Activate"}</Button> <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-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> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </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,18 +9,20 @@ 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"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, 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 { PaginationFooter } from "@/components/ui/pagination-footer"
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 { 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 +30,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 +44,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 +93,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 +167,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>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Document Types</h1> <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 +221,259 @@ export default function DocumentTypesPage() {
</Dialog> </Dialog>
</div> </div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>} <Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(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>} <DialogContent className="sm:max-w-sm">
{!error && items !== null && items.length === 0 && ( <DialogHeader className="items-center 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 document types yet.</p></div> <DialogTitle>Edit Document Type</DialogTitle>
<DialogDescription>Code can&apos;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 && 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 document types match &quot;{debouncedSearch}&quot;</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">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Code</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Category</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((t) => ( {items.map((t) => (
<TableRow key={t.hrDocumentTypeId}> <TableRow key={t.hrDocumentTypeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{t.code}</TableCell> <TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
<TableCell className="px-3 py-3.5">{t.name}</TableCell> <TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">{t.category}</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">
<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> <Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{t.status}
</Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-4 py-4 text-sm text-muted-foreground">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(t)}>{t.status === "Active" ? "Deactivate" : "Activate"}</Button> {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> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </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,23 +9,29 @@ 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"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, 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 { PaginationFooter } from "@/components/ui/pagination-footer"
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]
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 +43,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 +97,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 +176,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>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Leave Types</h1> <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&apos;s OnLeave classification and Payroll&apos;s No-Pay calc.</p> <p className="text-base text-muted-foreground">Annual, Casual, Medical, Unpaid, etc. — drives Attendance&apos;s OnLeave classification and Payroll&apos;s No-Pay calc.</p>
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -129,48 +229,272 @@ export default function LeaveTypesPage() {
</Dialog> </Dialog>
</div> </div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>} <Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(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>} <DialogContent className="sm:max-w-sm">
{!error && items !== null && items.length === 0 && ( <DialogHeader className="items-center 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 leave types yet.</p></div> <DialogTitle>Edit Leave Type</DialogTitle>
<DialogDescription>Code can&apos;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 && 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-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 &quot;{debouncedSearch}&quot;</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">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Code</TableHead> <TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead> <TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Days/yr</TableHead> <TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead>
<TableHead className="h-12 px-3 text-sm">Paid</TableHead> <TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-11 w-[14%] px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="h-11 w-[14%] px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-[18%] px-4 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((t) => ( {items.map((t) => (
<TableRow key={t.leaveTypeId}> <TableRow key={t.leaveTypeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{t.code}</TableCell> <TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
<TableCell className="px-3 py-3.5">{t.name}</TableCell> <TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">{t.accrualPerYear}</TableCell> <TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.accrualPerYear}</TableCell>
<TableCell className="px-3 py-3.5">{t.isPaid ? "Yes" : "No"}</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">
<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> <Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{t.status}
</Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-4 py-4 text-sm text-muted-foreground">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(t)}>{t.status === "Active" ? "Deactivate" : "Activate"}</Button> {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> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </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,24 +9,30 @@ 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"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, 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 { PaginationFooter } from "@/components/ui/pagination-footer"
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 { 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 +43,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 +91,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 +164,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>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Salary Components</h1> <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 +219,254 @@ export default function SalaryComponentsPage() {
</Dialog> </Dialog>
</div> </div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>} <Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(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>} <DialogContent className="sm:max-w-sm">
{!error && items !== null && items.length === 0 && ( <DialogHeader className="items-center 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 salary components yet.</p></div> <DialogTitle>Edit Salary Component</DialogTitle>
<DialogDescription>Code and type can&apos;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 && 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-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 &quot;{debouncedSearch}&quot;</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">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Code</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((c) => ( {items.map((c) => (
<TableRow key={c.salaryComponentId}> <TableRow key={c.salaryComponentId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{c.code}</TableCell> <TableCell className="px-4 py-4 font-mono text-sm text-foreground">{c.code}</TableCell>
<TableCell className="px-3 py-3.5">{c.name}</TableCell> <TableCell className="px-4 py-4 text-sm text-foreground">{c.name}</TableCell>
<TableCell className="px-3 py-3.5">{c.componentType}</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">
<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> <Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
c.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{c.status}
</Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-4 py-4 text-sm text-muted-foreground">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(c)}>{c.status === "Active" ? "Deactivate" : "Activate"}</Button> {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> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </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">
<div className="flex items-center gap-2.5">
<h2 className="text-lg font-semibold text-foreground">EPF / ETF Rates</h2> <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>
{settings === 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-24" />
</div>
{Array.from({ length: 3 }).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-32" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
)}
{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"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Effective from</TableHead> <TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-12 px-3 text-sm">EPF (Employee)</TableHead> <TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead>
<TableHead className="h-12 px-3 text-sm">EPF (Employer)</TableHead> <TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead>
<TableHead className="h-12 px-3 text-sm">ETF (Employer)</TableHead> <TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead>
<TableHead className="h-12 px-3 text-sm">OT multiplier</TableHead> <TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{settings.map((s) => ( {settings.map((s) => (
<TableRow key={s.payrollStatutorySettingId}> <TableRow key={s.payrollStatutorySettingId} className="hover:bg-muted/20">
<TableCell className="px-3 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}</TableCell> <TableCell className="px-4 py-4 text-sm text-foreground">
<TableCell className="px-3 py-3.5">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell> {new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
<TableCell className="px-3 py-3.5">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell> <TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-3 py-3.5">{s.otMultiplierDefault}x</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> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </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">
<div className="flex items-center gap-2.5">
<h2 className="text-lg font-semibold text-foreground">Tax Slabs</h2> <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>
{slabs === 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-24" />
</div>
{Array.from({ length: 3 }).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-32" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
)}
{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"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Effective from</TableHead> <TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-12 px-3 text-sm">Range</TableHead> <TableHead className="h-11 px-4 text-sm">Range</TableHead>
<TableHead className="h-12 px-3 text-sm">Rate</TableHead> <TableHead className="h-11 px-4 text-sm">Rate</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{slabs.map((s) => ( {slabs.map((s) => (
<TableRow key={s.taxSlabId}> <TableRow key={s.taxSlabId} className="hover:bg-muted/20">
<TableCell className="px-3 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell> <TableCell className="px-4 py-4 text-sm text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</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-3 py-3.5">{(s.rate * 100).toFixed(1)}%</TableCell> <TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.rate * 100).toFixed(1)}%</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </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,24 +9,34 @@ 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"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, 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 { PaginationFooter } from "@/components/ui/pagination-footer"
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 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 +52,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 +77,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 +121,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 +206,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>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Work Shifts</h1> <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 +286,289 @@ export default function WorkShiftsPage() {
</Dialog> </Dialog>
</div> </div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>} <Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(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>} <DialogContent className="sm:max-w-md">
{!error && items !== null && items.length === 0 && ( <DialogHeader className="items-center 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 work shifts yet.</p></div> <DialogTitle>Edit Work Shift</DialogTitle>
<DialogDescription>Code can&apos;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 && 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 work shifts match &quot;{debouncedSearch}&quot;</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">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Code</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Hours</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((w) => ( {items.map((w) => (
<TableRow key={w.workShiftId}> <TableRow key={w.workShiftId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{w.code}</TableCell> <TableCell className="px-4 py-4 font-mono text-sm text-foreground">{w.code}</TableCell>
<TableCell className="px-3 py-3.5">{w.name}</TableCell> <TableCell className="px-4 py-4 text-sm text-foreground">{w.name}</TableCell>
<TableCell className="px-3 py-3.5">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</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">
<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> <Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
w.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{w.status}
</Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-4 py-4 text-sm text-muted-foreground">
<Button variant="ghost" size="sm" onClick={() => toggleStatus(w)}>{w.status === "Active" ? "Deactivate" : "Activate"}</Button> {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> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </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>
)} )}
+5 -5
View File
@@ -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>
@@ -128,7 +128,7 @@ export default function GeneralLedgerReportPage() {
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell> <TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell> <TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell> <TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell> <TableCell className="px-3 py-2.5">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell> <TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell> <TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
+11 -13
View File
@@ -6,7 +6,6 @@ import {
AlertTriangle, AlertTriangle,
BadgeDollarSign, BadgeDollarSign,
Boxes, Boxes,
Clock,
ClipboardList, ClipboardList,
ListChecks, ListChecks,
PackageCheck, PackageCheck,
@@ -128,7 +127,7 @@ export default function DashboardPage() {
</div> </div>
)} )}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6">
{loaded ? ( {loaded ? (
<> <>
<Link href="/dashboard/stock/reorder-alerts"> <Link href="/dashboard/stock/reorder-alerts">
@@ -137,9 +136,6 @@ export default function DashboardPage() {
<Link href="/dashboard/stock/enquiry"> <Link href="/dashboard/stock/enquiry">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} /> <StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
</Link> </Link>
<Link href="/dashboard/procurement/purchase-orders">
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
</Link>
<Link href="/dashboard/receiving/grn"> <Link href="/dashboard/receiving/grn">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} /> <StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
</Link> </Link>
@@ -154,10 +150,11 @@ export default function DashboardPage() {
</Link> </Link>
</> </>
) : ( ) : (
!error && Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />) !error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
)} )}
</div> </div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5"> <div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base"> <h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
@@ -180,7 +177,7 @@ export default function DashboardPage() {
{ {
label: "Stock Value (LKR)", label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total), data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#6366f1", backgroundColor: "#E11D48",
}, },
]} ]}
/> />
@@ -215,6 +212,7 @@ export default function DashboardPage() {
!error && <Skeleton className="h-52 rounded-lg sm:h-64" /> !error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)} )}
</div> </div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5"> <div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
@@ -243,9 +241,9 @@ export default function DashboardPage() {
<TableBody> <TableBody>
{movements.map((entry) => ( {movements.map((entry) => (
<TableRow key={entry.ledgerId}> <TableRow key={entry.ledgerId}>
<TableCell className="font-medium text-foreground">#{entry.itemId}</TableCell> <TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
<TableCell className="text-muted-foreground"> <TableCell>
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`} {warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
@@ -260,11 +258,11 @@ export default function DashboardPage() {
{entry.direction} {entry.direction}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell> <TableCell>{entry.qtyBase}</TableCell>
<TableCell className="text-muted-foreground"> <TableCell>
{entry.sourceDocType} #{entry.sourceDocId} {entry.sourceDocType} #{entry.sourceDocId}
</TableCell> </TableCell>
<TableCell className="text-right text-muted-foreground"> <TableCell className="text-right">
{new Date(entry.createdAt).toLocaleDateString()} {new Date(entry.createdAt).toLocaleDateString()}
</TableCell> </TableCell>
</TableRow> </TableRow>

Some files were not shown because too many files have changed in this diff Show More