Compare commits

..

79 Commits

Author SHA1 Message Date
Sasanka b2646c6cb3 ui and bug fixers 29/09 2026-09-29 14:49:11 +05:30
Sasanka d24072e20a fixing recipts in sales 2026-09-24 17:02:33 +05:30
ashan_rusiru 9185d39c39 Merge pull request 'Dev' (#88) from Dev into production
Reviewed-on: #88
2026-09-24 07:12:59 +00:00
ashan_rusiru a2790c13bb Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m26s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m18s
2026-09-24 07:12:52 +00:00
ashan_rusiru 940583552a Merge pull request 'Ui fixers and responsivenes' (#87) from ui-fixers-and-responsivenes into Dev
Reviewed-on: #87
2026-09-24 07:09:52 +00:00
Sasanka 049c98298a Ui fixings 2026-09-24 12:30:21 +05:30
Sasanka f5e8e0e5b9 responsiveness and ui fixers 2026-09-23 16:55:46 +05:30
ashan_rusiru f35439e364 Merge pull request 'Dev' (#86) from Dev into production
Reviewed-on: #86
2026-09-21 19:03:14 +00:00
ashan_rusiru 4c5e9b347a Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m41s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m5s
2026-09-21 19:03:08 +00:00
Dhananjaya99 fdd104284e feat: add ItemOverviewDto for enriched item data in items list
feat: implement CustomerDetailPage for detailed customer view and editing

feat: create CustomersPage for listing and managing customers with filters

feat: add ColumnsMenu component for customizable table columns visibility

feat: implement CSV export functionality for data download

feat: add customer code generation logic for unique customer identifiers
2026-09-22 00:32:40 +05:30
ImanThiyanga 72819e5f7b production change 2026-09-21 18:02:55 +05:30
kalana_Thilakarathna edd1011330 Merge pull request 'chore: update next.js version to ^16.3.5 in package.json' (#85) from Dev into production
Reviewed-on: #85
2026-09-20 12:41:34 +00:00
Kalana B. Thilakarathna 3ae14fad72 chore: update next.js version to ^16.3.5 in package.json
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m45s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 15m23s
2026-09-20 18:11:04 +05:30
kalana_Thilakarathna 84d3318f76 Merge pull request 'Dev' (#84) from Dev into production
Reviewed-on: #84
2026-09-20 12:27:45 +00:00
kalana_Thilakarathna bfb8e83a82 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 10m33s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m9s
2026-09-20 12:27:31 +00:00
kalana_Thilakarathna 40c1d6da27 Update Frontend/erp-system/Dockerfile 2026-09-20 12:26:57 +00:00
kalana_Thilakarathna 5aa709db17 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#83) from Dev into production
Reviewed-on: #83
2026-09-20 12:03:21 +00:00
kalana_Thilakarathna cf964a19a9 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m37s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m59s
2026-09-20 12:03:07 +00:00
kalana_Thilakarathna a40c6bf598 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#82) from Dev into production
Reviewed-on: #82
2026-09-20 11:57:04 +00:00
kalana_Thilakarathna f951700b58 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m16s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m55s
2026-09-20 11:56:50 +00:00
kalana_Thilakarathna b3939e0685 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#81) from Dev into production
Reviewed-on: #81
2026-09-20 11:50:14 +00:00
kalana_Thilakarathna af6f02023d Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 11m43s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m5s
2026-09-20 11:49:55 +00:00
kalana_Thilakarathna 9d569a1504 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#80) from Dev into production
Reviewed-on: #80
2026-09-20 11:41:23 +00:00
kalana_Thilakarathna 5a44702cf3 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 10m33s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m56s
2026-09-20 11:41:10 +00:00
kalana_Thilakarathna 2a5dee3fef Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#79) from Dev into production
Reviewed-on: #79
2026-09-20 11:34:43 +00:00
kalana_Thilakarathna 2584ab8715 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m8s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m36s
2026-09-20 11:34:29 +00:00
ImanThiyanga 6b9d191bf8 Merge pull request 'fixes on ci-cd' (#78) from Dev into production
Reviewed-on: #78
2026-09-18 19:32:33 +00:00
ImanThiyanga 283fa21e87 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 10m46s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m22s
2026-09-18 19:32:25 +00:00
ImanThiyanga 97d19d83ab fixes on ci-cd 2026-09-19 01:01:30 +05:30
ImanThiyanga 01d5159d27 Merge pull request 'changed front needs' (#77) from Dev into production
Reviewed-on: #77
2026-09-18 19:21:39 +00:00
ImanThiyanga 80899ed88c Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 10m18s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 11m40s
2026-09-18 19:21:32 +00:00
ImanThiyanga cfe2466fec changed front needs 2026-09-19 00:51:11 +05:30
ImanThiyanga 9a124c8c63 Merge pull request 'fixed ci-cd' (#76) from Dev into production
Reviewed-on: #76
2026-09-18 19:05:55 +00:00
ImanThiyanga 42ab7e5964 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 12m25s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m58s
2026-09-18 19:05:48 +00:00
ImanThiyanga e7735135ca fixed ci-cd 2026-09-19 00:35:05 +05:30
ImanThiyanga f61d1f95b8 Merge pull request 'Dev' (#75) from Dev into production
Reviewed-on: #75
2026-09-18 18:54:54 +00:00
ImanThiyanga f632c5fc99 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m26s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 15m0s
2026-09-18 18:54:46 +00:00
ImanThiyanga 70179e1abc Merge pull request 'new updates' (#74) from new-updates into Dev
Reviewed-on: #74
2026-09-18 18:53:13 +00:00
Sasanka 8c3d62223c new updates 2026-09-19 00:22:53 +05:30
ImanThiyanga e1f19fe413 Merge pull request 'Add backup images for login and background removal' (#73) from UI into Dev
Reviewed-on: #73
2026-09-18 18:43:33 +00:00
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 7274b89b28 Merge pull request 'migrations fix' (#72) from Dev into production
Reviewed-on: #72
2026-09-18 18:23:44 +00:00
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 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
219 changed files with 60141 additions and 6656 deletions
+92
View File
@@ -0,0 +1,92 @@
name: CI/CD - ERP Core Monorepo
on:
pull_request:
branches:
- production
types:
- closed
jobs:
build-and-deploy-backend:
name: Build & Deploy 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
run: dotnet restore Backend/ERPCore.sln
- name: Publish
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 service
run: systemctl stop erpcore.service
- name: Copy 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 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 service
run: systemctl restart erpcore.service
- name: Verify
run: |
systemctl is-active erpcore.service && echo "✅ Service running" || echo "❌ Service failed"
build-and-deploy-frontend:
name: Build & Deploy Frontend
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: Build Docker image
run: |
docker build --progress=plain --no-cache \
-t erpcore-frontend:latest \
./Frontend/erp-system
- name: Stop & remove existing container
run: |
docker stop erpcore-frontend 2>/dev/null || true
docker rm erpcore-frontend 2>/dev/null || true
- name: Start 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 images
run: docker image prune -f
- name: Verify
run: |
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpcore-frontend && echo "✅ Container running" || echo "❌ Container failed"
@@ -28,14 +28,20 @@ public sealed class AuthController : ControllerBase
private readonly IAuthAltService _alt; private readonly IAuthAltService _alt;
private readonly IRoleService _roles; private readonly IRoleService _roles;
private readonly IAuditService _audit; private readonly IAuditService _audit;
private readonly IUserManagementService _userManagement;
private readonly ICurrentUser _currentUser;
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit) public AuthController(
IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit,
IUserManagementService userManagement, ICurrentUser currentUser)
{ {
_users = users; _users = users;
_recovery = recovery; _recovery = recovery;
_alt = alt; _alt = alt;
_roles = roles; _roles = roles;
_audit = audit; _audit = audit;
_userManagement = userManagement;
_currentUser = currentUser;
} }
/// <summary> /// <summary>
@@ -139,6 +145,8 @@ public sealed class AuthController : ControllerBase
public async Task<IActionResult> ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct) public async Task<IActionResult> ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct)
{ {
await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), ct); await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), ct);
// AuthHex accepted the new password — release the first-login forced change.
await _userManagement.ClearMustChangePasswordAsync(_currentUser.AuditUserId, ct);
AuthCookieWriter.ClearSession(Response); AuthCookieWriter.ClearSession(Response);
return NoContent(); return NoContent();
} }
@@ -30,6 +30,19 @@ public sealed class ItemsController : ApiControllerBase
CancellationToken ct) CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct)); => Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
[HttpGet("overview")]
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
CancellationToken ct)
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, ct));
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial /// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary> /// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
[HttpGet("scan")] [HttpGet("scan")]
@@ -29,8 +29,10 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] int? customerId, [FromQuery] int? customerId,
[FromQuery] int? warehouseId, [FromQuery] int? warehouseId,
[FromQuery] int? branchId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
CancellationToken ct) CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct)); => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
[HttpGet("{salesInvoiceId:int}")] [HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
+14 -2
View File
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
@@ -18,8 +19,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)]
@@ -29,4 +31,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();
}
} }
@@ -23,6 +23,12 @@ public class BranchConfig
/// single-branch already).</summary> /// single-branch already).</summary>
public bool WarehouseBranchingEnabled { get; set; } = true; public bool WarehouseBranchingEnabled { get; set; } = true;
/// <summary>Independent sub-toggle for Purchase Orders. On: each PO belongs to one branch,
/// non-admins only see their own branch's POs, and line warehouses must be in that branch.
/// Off: POs form one shared pool across branches (kept on the default branch, visible to
/// everyone, any warehouse allowed). Meaningless when <see cref="BranchingEnabled"/> is off.</summary>
public bool PurchaseOrderBranchingEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; } public int? UpdatedBy { 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; }
} }
+8
View File
@@ -31,6 +31,14 @@ public class User
/// </summary> /// </summary>
public string? Email { get; set; } public string? Email { get; set; }
/// <summary>
/// Set when an admin creates the account with an issued (temporary) password
/// (<c>UserManagementService.CreateAsync</c>); surfaced via <c>GET /auth/me</c> so the
/// frontend forces a password change on first login. Cleared by
/// <c>POST /auth/change-password</c>.
/// </summary>
public bool MustChangePassword { get; set; }
/// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary> /// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary>
public int? RoleId { get; set; } public int? RoleId { get; set; }
public Role? Role { get; set; } public Role? Role { get; set; }
@@ -14,6 +14,8 @@ public class Vendor
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string? Terms { get; set; } public string? Terms { get; set; }
public string? TaxReg { get; set; } public string? TaxReg { get; set; }
public string? Address { get; set; }
public string? Phone { get; set; }
public string Currency { get; set; } = "LKR"; public string Currency { get; set; } = "LKR";
public EntityStatus Status { get; set; } = EntityStatus.Active; public EntityStatus Status { get; set; } = EntityStatus.Active;
@@ -4,7 +4,8 @@ namespace ERPCore.Dtos.Config;
/// <summary>Branch configuration resource — the singleton toggle for whether this deployment /// <summary>Branch configuration resource — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.</summary> /// requires a branch on Employee/Warehouse records at all.</summary>
public sealed record BranchConfigDto(bool BranchingEnabled, bool WarehouseBranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy); public sealed record BranchConfigDto(
bool BranchingEnabled, bool WarehouseBranchingEnabled, bool PurchaseOrderBranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
/// <summary> /// <summary>
/// Full replacement of the flag. <c>UpdatedBy</c> is derived from the token, never posted. /// Full replacement of the flag. <c>UpdatedBy</c> is derived from the token, never posted.
@@ -18,4 +19,8 @@ public sealed class UpdateBranchConfigRequest
{ {
[Required] public bool? BranchingEnabled { get; set; } [Required] public bool? BranchingEnabled { get; set; }
[Required] public bool? WarehouseBranchingEnabled { get; set; } [Required] public bool? WarehouseBranchingEnabled { get; set; }
/// <summary>Optional so existing callers that only know the first two flags keep working —
/// omitted means "leave as is".</summary>
public bool? PurchaseOrderBranchingEnabled { get; set; }
} }
@@ -18,6 +18,7 @@ public sealed record DashboardStatsDto(
decimal StockValuationTotal, decimal StockValuationTotal,
IReadOnlyList<WarehouseValuationDto> StockValuationByWarehouse, IReadOnlyList<WarehouseValuationDto> StockValuationByWarehouse,
int PendingApprovalPurchaseOrders, int PendingApprovalPurchaseOrders,
int FullyReceivedPurchaseOrders,
int PendingGrns, int PendingGrns,
int OpenRequisitions, int OpenRequisitions,
int PendingCounts, int PendingCounts,
+1 -1
View File
@@ -22,7 +22,7 @@ public sealed record GrnDto(
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary> /// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto( public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status, int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo); int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
public sealed record CreatedLayerDto( public sealed record CreatedLayerDto(
@@ -0,0 +1,18 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Items;
/// <summary>
/// Item row enriched with cross-module aggregates (current FIFO stock value, low-stock
/// flag, last purchase, trailing-30-day sales) for the Items list page. Deliberately a
/// separate endpoint from <see cref="ItemListItemDto"/>'s <c>GET /items</c> — that list
/// also backs the GRN/PO/Requisition/RFQ item pickers, which don't need these aggregates
/// computed on every keystroke.
/// </summary>
public sealed record ItemOverviewDto(
int ItemId, string Sku, string Name,
int CategoryId, int? SubCategoryId, int? BrandId,
EntityStatus Status,
decimal StockQty, decimal StockValue, bool IsLowStock,
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
decimal SalesQtyLast30Days);
+7 -2
View File
@@ -8,7 +8,12 @@ namespace ERPCore.Dtos.Rbac;
/// branch pickers client-side only (no server-side branch enforcement exists yet, /// branch pickers client-side only (no server-side branch enforcement exists yet,
/// see docs/BRANCH_ISOLATION_PLAN.md). <see cref="BranchingEnabled"/> mirrors the /// see docs/BRANCH_ISOLATION_PLAN.md). <see cref="BranchingEnabled"/> mirrors the
/// singleton <c>BranchConfig</c> toggle — when false the frontend hides branch pickers /// singleton <c>BranchConfig</c> toggle — when false the frontend hides branch pickers
/// entirely rather than merely restricting them by role.</summary> /// entirely rather than merely restricting them by role. <see cref="MustChangePassword"/>
/// is true for an admin-created account that hasn't replaced its issued password yet —
/// the frontend holds the user on the change-password screen until it is false.</summary>
public sealed record MeResponseDto( public sealed record MeResponseDto(
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes, string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled); bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled,
bool MustChangePassword = false,
bool PurchaseOrderBranchingEnabled = true,
int? UserId = null);
+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 -1
View File
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Vendors;
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary> /// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
public sealed record VendorDto( public sealed record VendorDto(
int VendorId, string Code, string Name, string? Terms, string? TaxReg, int VendorId, string Code, string Name, string? Terms, string? TaxReg, string? Address, string? Phone,
string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt); string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateVendorRequest public sealed class CreateVendorRequest
@@ -14,6 +14,8 @@ public sealed class CreateVendorRequest
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(50)] public string? Terms { get; set; } [StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; } [StringLength(50)] public string? TaxReg { get; set; }
[StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR"; [Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
/// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/> /// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/>
/// when branching is enabled; ignored (defaulted) otherwise.</summary> /// when branching is enabled; ignored (defaulted) otherwise.</summary>
@@ -26,6 +28,8 @@ public sealed class UpdateVendorRequest
[Required, StringLength(200)] public string Name { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(50)] public string? Terms { get; set; } [StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; } [StringLength(50)] public string? TaxReg { get; set; }
[StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR"; [Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
public int BranchId { get; set; } public int BranchId { get; set; }
} }
@@ -107,11 +107,14 @@ public sealed class AuthHexClient : IAuthHexClient
// ---- RecoveryManager ------------------------------------------------ // ---- RecoveryManager ------------------------------------------------
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct) // RecoveryManagerService returns its own { success, message, data } object, which the
=> CallAsync<ForgotPasswordResponse>("recovery", "forgotPassword", request, null, ct); // function handler then wraps again as the envelope's `data` — so the payload sits one
// level deeper than for other AuthHex calls (ERP_Auth_Service RecoveryManager.cs).
public async Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
=> Unwrap(await CallAsync<AuthHexEnvelope<ForgotPasswordResponse>>("recovery", "forgotPassword", request, null, ct));
public Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct) public async Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
=> CallAsync<VerifyRecoveryOtpResponse>("recovery", "verifyOTP", request, null, ct); => Unwrap(await CallAsync<AuthHexEnvelope<VerifyRecoveryOtpResponse>>("recovery", "verifyOTP", request, null, ct));
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct) public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct)
=> CallVoidAsync("recovery", "resetPassword", request, null, ct); => CallVoidAsync("recovery", "resetPassword", request, null, ct);
@@ -149,6 +152,14 @@ public sealed class AuthHexClient : IAuthHexClient
// ---- Transport -------------------------------------------------------- // ---- Transport --------------------------------------------------------
/// <summary>Unwraps the inner result object that the recovery functions nest inside the envelope's data.</summary>
private static T Unwrap<T>(AuthHexEnvelope<T>? inner)
{
if (inner is null || !inner.Success || inner.Data is null)
throw new DomainException(ErrorCodes.AuthUpstreamError, inner?.Message ?? "Authentication request failed.", 400);
return inner.Data;
}
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct) private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
=> await CallAsync<JsonElement?>(routeGroup, functionName, payload, bearerToken, ct); => await CallAsync<JsonElement?>(routeGroup, functionName, payload, bearerToken, ct);
@@ -21,6 +21,8 @@ public sealed class BranchConfigConfiguration : IEntityTypeConfiguration<BranchC
builder.Property(c => c.ConfigId).ValueGeneratedNever(); builder.Property(c => c.ConfigId).ValueGeneratedNever();
builder.Property(c => c.BranchingEnabled).IsRequired().HasDefaultValue(true); builder.Property(c => c.BranchingEnabled).IsRequired().HasDefaultValue(true);
// Defaults on so the existing row keeps today's per-branch purchase orders.
builder.Property(c => c.PurchaseOrderBranchingEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag). // PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion(); builder.Property(c => c.RowVersion).IsRowVersion();
@@ -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);
} }
} }
@@ -18,6 +18,8 @@ public sealed class VendorConfiguration : IEntityTypeConfiguration<Vendor>
builder.Property(v => v.Name).IsRequired().HasMaxLength(200); builder.Property(v => v.Name).IsRequired().HasMaxLength(200);
builder.Property(v => v.Terms).HasMaxLength(50); builder.Property(v => v.Terms).HasMaxLength(50);
builder.Property(v => v.TaxReg).HasMaxLength(50); builder.Property(v => v.TaxReg).HasMaxLength(50);
builder.Property(v => v.Address).HasMaxLength(500);
builder.Property(v => v.Phone).HasMaxLength(30);
builder.Property(v => v.Currency).IsRequired().HasMaxLength(3).HasDefaultValue("LKR"); builder.Property(v => v.Currency).IsRequired().HasMaxLength(3).HasDefaultValue("LKR");
builder.Property(v => v.Status) builder.Property(v => v.Status)
@@ -195,7 +195,12 @@ public class ErpDbContext : DbContext
modelBuilder.Entity<Customer>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId); modelBuilder.Entity<Customer>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Requisition>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId); modelBuilder.Entity<Requisition>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Rfq>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId); modelBuilder.Entity<Rfq>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId); // Purchase orders are only scoped while the "divide purchase orders by branch" toggle is
// on — off turns them into one shared pool every branch can see.
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive
|| e.BranchId == _currentBranch.BranchId
|| !Set<Domain.Entities.BranchConfig>().Any(c => c.ConfigId == Domain.Entities.BranchConfig.SingletonId
&& c.BranchingEnabled && c.PurchaseOrderBranchingEnabled));
modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId); modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
// Npgsql requires DateTime values written to `timestamp with time zone` columns // Npgsql requires DateTime values written to `timestamp with time zone` columns
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)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddUserMustChangePassword : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "MustChangePassword",
table: "users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "users",
keyColumn: "UserId",
keyValue: 1,
column: "MustChangePassword",
value: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MustChangePassword",
table: "users");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddVendorAddressPhone : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Address",
table: "vendors",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Phone",
table: "vendors",
type: "character varying(30)",
maxLength: 30,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Address",
table: "vendors");
migrationBuilder.DropColumn(
name: "Phone",
table: "vendors");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddPurchaseOrderBranchingToggle : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "PurchaseOrderBranchingEnabled",
table: "branch_config",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PurchaseOrderBranchingEnabled",
table: "branch_config");
}
}
}
@@ -344,6 +344,11 @@ namespace ERPCore.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<bool>("PurchaseOrderBranchingEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<uint>("RowVersion") b.Property<uint>("RowVersion")
.IsConcurrencyToken() .IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
@@ -6345,16 +6350,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);
}); });
@@ -6379,6 +6399,9 @@ namespace ERPCore.Migrations
.HasMaxLength(320) .HasMaxLength(320)
.HasColumnType("character varying(320)"); .HasColumnType("character varying(320)");
b.Property<bool>("MustChangePassword")
.HasColumnType("boolean");
b.Property<int?>("RoleId") b.Property<int?>("RoleId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -6412,6 +6435,7 @@ namespace ERPCore.Migrations
{ {
UserId = 1, UserId = 1,
DisplayName = "System", DisplayName = "System",
MustChangePassword = false,
Status = "Active", Status = "Active",
Username = "system" Username = "system"
}); });
@@ -6425,6 +6449,10 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("VendorId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("VendorId"));
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("BranchId") b.Property<int>("BranchId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -6448,6 +6476,10 @@ namespace ERPCore.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<uint>("RowVersion") b.Property<uint>("RowVersion")
.IsConcurrencyToken() .IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
@@ -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"
} }
@@ -1,25 +1,54 @@
using ERPCore.Dtos.Auth; using ERPCore.Dtos.Auth;
using ERPCore.Infra.Auth.AuthHex; using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.Extensions.Caching.Memory;
namespace ERPCore.Services.Auth; namespace ERPCore.Services.Auth;
/// <inheritdoc cref="IAuthRecoveryService"/> /// <inheritdoc cref="IAuthRecoveryService"/>
public sealed class AuthRecoveryService : IAuthRecoveryService public sealed class AuthRecoveryService : IAuthRecoveryService
{ {
/// <summary>Longer than any AuthHex recovery OTP lives; only needs to outlast the flow.</summary>
private static readonly TimeSpan RecoveryIdentifierTtl = TimeSpan.FromMinutes(30);
private readonly IAuthHexClient _authHex; private readonly IAuthHexClient _authHex;
private readonly IUserManagementService _userManagement;
private readonly IMemoryCache _cache;
public AuthRecoveryService(IAuthHexClient authHex) => _authHex = authHex; public AuthRecoveryService(IAuthHexClient authHex, IUserManagementService userManagement, IMemoryCache cache)
{
_authHex = authHex;
_userManagement = userManagement;
_cache = cache;
}
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default) public async Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
=> _authHex.ForgotPasswordAsync(request, ct); {
var response = await _authHex.ForgotPasswordAsync(request, ct);
// The reset call only carries the reference number — remember who it was for, so a
// successful reset can also release a pending first-login forced password change.
if (!string.IsNullOrEmpty(response.ReferenceNumber))
_cache.Set(CacheKey(response.ReferenceNumber), request.Identifier.Trim(), RecoveryIdentifierTtl);
return response;
}
public Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default) public Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default)
=> _authHex.VerifyRecoveryOtpAsync(request, ct); => _authHex.VerifyRecoveryOtpAsync(request, ct);
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default) public async Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
=> _authHex.ResetPasswordAsync(request, ct); {
await _authHex.ResetPasswordAsync(request, ct);
var key = CacheKey(request.ReferenceNumber);
if (_cache.TryGetValue(key, out string? identifier) && !string.IsNullOrEmpty(identifier))
{
_cache.Remove(key);
await _userManagement.ClearMustChangePasswordByIdentifierAsync(identifier, ct);
}
}
public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default) public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default)
=> _authHex.ResetPasswordWithTokenAsync(request, ct); => _authHex.ResetPasswordWithTokenAsync(request, ct);
private static string CacheKey(string referenceNumber) => $"auth-recovery:{referenceNumber}";
} }
@@ -54,6 +54,8 @@ public sealed class BranchConfigService : IBranchConfigService
config.BranchingEnabled = request.BranchingEnabled!.Value; config.BranchingEnabled = request.BranchingEnabled!.Value;
config.WarehouseBranchingEnabled = request.WarehouseBranchingEnabled!.Value; config.WarehouseBranchingEnabled = request.WarehouseBranchingEnabled!.Value;
if (request.PurchaseOrderBranchingEnabled is bool poBranching)
config.PurchaseOrderBranchingEnabled = poBranching;
config.UpdatedAt = DateTime.UtcNow; config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId; config.UpdatedBy = _currentUser.AuditUserId;
@@ -110,6 +112,25 @@ public sealed class BranchConfigService : IBranchConfigService
return await ResolveDefaultBranchIdAsync(ct); return await ResolveDefaultBranchIdAsync(ct);
} }
public async Task<bool> IsPurchaseOrderBranchingEnabledAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == BranchConfig.SingletonId, ct);
return (config?.BranchingEnabled ?? true) && (config?.PurchaseOrderBranchingEnabled ?? true);
}
public async Task<int> ResolvePurchaseOrderBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
{
if (await IsPurchaseOrderBranchingEnabledAsync(ct))
{
if (requestedBranchId <= 0)
throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
return requestedBranchId;
}
return await ResolveDefaultBranchIdAsync(ct);
}
// Branching (or warehouse branching specifically) is off — ignore whatever was posted and // Branching (or warehouse branching specifically) is off — ignore whatever was posted and
// use the deployment's one default branch, creating it on first use (same placeholder // use the deployment's one default branch, creating it on first use (same placeholder
// MakeEmployeeBranchRequired/AddWarehouseBranch backfill onto for pre-existing rows). // MakeEmployeeBranchRequired/AddWarehouseBranch backfill onto for pre-existing rows).
@@ -125,5 +146,6 @@ public sealed class BranchConfigService : IBranchConfigService
return branch.BranchId; return branch.BranchId;
} }
private static BranchConfigDto Map(BranchConfig c) => new(c.BranchingEnabled, c.WarehouseBranchingEnabled, c.UpdatedAt, c.UpdatedBy); private static BranchConfigDto Map(BranchConfig c) => new(
c.BranchingEnabled, c.WarehouseBranchingEnabled, c.PurchaseOrderBranchingEnabled, c.UpdatedAt, c.UpdatedBy);
} }
+3 -1
View File
@@ -84,6 +84,8 @@ public sealed class DashboardService : IDashboardService
// not yet added) — these three stay global regardless of the selected branch. // not yet added) — these three stay global regardless of the selected branch.
var pendingApprovalPOs = await _pos.Query().AsNoTracking() var pendingApprovalPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct); .CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
var fullyReceivedPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.FullyReceived, ct);
var openRequisitions = await _requisitions.Query().AsNoTracking() var openRequisitions = await _requisitions.Query().AsNoTracking()
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct); .CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct); var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
@@ -105,7 +107,7 @@ public sealed class DashboardService : IDashboardService
return new DashboardStatsDto( return new DashboardStatsDto(
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse, lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs, pendingApprovalPOs, fullyReceivedPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
pendingLeaveRequests, overdueSalesInvoices); pendingLeaveRequests, overdueSalesInvoices);
} }
} }
+1 -1
View File
@@ -100,7 +100,7 @@ public sealed class GrnService : IGrnService
.Skip(query.Skip).Take(query.PageSize) .Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto( .Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status, g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.Lines.Sum(l => l.LineTotal),
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null)) g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct); .ToListAsync(ct);
@@ -29,6 +29,14 @@ public interface IBranchConfigService
/// <see cref="IsWarehouseBranchingEnabledAsync"/> instead of the global flag.</summary> /// <see cref="IsWarehouseBranchingEnabledAsync"/> instead of the global flag.</summary>
Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default); Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
/// <summary>True when Purchase Orders are divided by branch. Never true when
/// <see cref="IsBranchingEnabledAsync"/> is false.</summary>
Task<bool> IsPurchaseOrderBranchingEnabledAsync(CancellationToken ct = default);
/// <summary>Same as <see cref="ResolveBranchIdAsync"/> but gated on
/// <see cref="IsPurchaseOrderBranchingEnabledAsync"/> — off puts every PO on the default branch.</summary>
Task<int> ResolvePurchaseOrderBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
/// <summary>The deployment's single default branch ("BR-000"/"Main Branch"), created on /// <summary>The deployment's single default branch ("BR-000"/"Main Branch"), created on
/// first use if it doesn't exist yet. Used as a fallback branch for document types that /// first use if it doesn't exist yet. Used as a fallback branch for document types that
/// don't yet carry an explicit branch (docs/BRANCH_ISOLATION_PLAN.md).</summary> /// don't yet carry an explicit branch (docs/BRANCH_ISOLATION_PLAN.md).</summary>
@@ -15,6 +15,12 @@ public interface IItemService
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId, PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, CancellationToken ct = default); TrackingMode? trackingMode, CancellationToken ct = default);
/// <summary>Same filters as <see cref="ListAsync"/>, enriched with stock value, low-stock,
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
CancellationToken ct = default);
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default); Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default); Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default);
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService public interface ISalesInvoiceService
{ {
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default); Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default); Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default); Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, 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);
} }
@@ -27,6 +27,12 @@ public interface IUserManagementService
/// </summary> /// </summary>
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default); Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
/// <summary>Clears the first-login forced password change once the user has set their own password.</summary>
Task ClearMustChangePasswordAsync(int userId, CancellationToken ct = default);
/// <summary>Same, for a forgot-password reset — resolves the user by email or username.</summary>
Task ClearMustChangePasswordByIdentifierAsync(string identifier, 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);
+80
View File
@@ -1,4 +1,5 @@
using ERPCore.Common.Http; using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config; using ERPCore.Dtos.Config;
@@ -35,6 +36,7 @@ public sealed class ItemService : IItemService
private readonly IRepository<Warehouse> _warehouses; private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers; private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<StockLedger> _stockLedger; private readonly IRepository<StockLedger> _stockLedger;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<Batch> _batches; private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials; private readonly IRepository<Serial> _serials;
private readonly IProductConfigService _config; private readonly IProductConfigService _config;
@@ -52,6 +54,7 @@ public sealed class ItemService : IItemService
IRepository<Warehouse> warehouses, IRepository<Warehouse> warehouses,
IRepository<StockLayer> stockLayers, IRepository<StockLayer> stockLayers,
IRepository<StockLedger> stockLedger, IRepository<StockLedger> stockLedger,
IRepository<GrnLine> grnLines,
IRepository<Batch> batches, IRepository<Batch> batches,
IRepository<Serial> serials, IRepository<Serial> serials,
IProductConfigService config, IProductConfigService config,
@@ -68,6 +71,7 @@ public sealed class ItemService : IItemService
_warehouses = warehouses; _warehouses = warehouses;
_stockLayers = stockLayers; _stockLayers = stockLayers;
_stockLedger = stockLedger; _stockLedger = stockLedger;
_grnLines = grnLines;
_batches = batches; _batches = batches;
_serials = serials; _serials = serials;
_config = config; _config = config;
@@ -108,6 +112,82 @@ public sealed class ItemService : IItemService
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total); return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
} }
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
CancellationToken ct = default)
{
var q = _items.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(i => EF.Functions.ILike(i.Sku, $"%{term}%") || EF.Functions.ILike(i.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(i => i.Status == status);
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
var total = await q.CountAsync(ct);
var page = await q.OrderBy(i => i.Sku)
.Skip(query.Skip).Take(query.PageSize)
.Select(i => new { i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status, i.ReorderSettings })
.ToListAsync(ct);
var itemIds = page.Select(i => i.ItemId).ToList();
// Current FIFO stock value/qty — every open layer, every warehouse.
var stockByItem = await _stockLayers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.ToDictionaryAsync(x => x.ItemId, ct);
// On-hand per (item, warehouse), to compare against each item's reorder settings.
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
.GroupBy(l => new { l.ItemId, l.WarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
.ToListAsync(ct))
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
// Last confirmed purchase (GRN) per item, with its vendor — pulled in bulk and
// reduced in memory rather than a "first per group" query EF can't always translate.
var grnCandidates = await _grnLines.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.Grn!.PostedAt != null)
.Select(l => new { l.ItemId, PostedAt = l.Grn!.PostedAt!.Value, l.Grn.VendorId, VendorName = l.Grn.Vendor!.Name })
.ToListAsync(ct);
var lastPurchaseByItem = grnCandidates
.GroupBy(x => x.ItemId)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.PostedAt).First());
// Qty sold in the trailing 30 days, across every sale channel (invoice/slip/bundle),
// read off the stock ledger — the one place every sale channel posts uniformly.
var cutoff = DateTime.UtcNow.AddDays(-30);
var salesTypes = new[] { DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale };
var salesByItem = await _stockLedger.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && l.Direction == Direction.Out
&& l.CreatedAt >= cutoff && salesTypes.Contains(l.SourceDocType))
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyBase) })
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
var rows = page.Select(i =>
{
var stock = stockByItem.GetValueOrDefault(i.ItemId);
var isLowStock = i.ReorderSettings.Any(rs =>
onHandByItemWarehouse.GetValueOrDefault((rs.ItemId, rs.WarehouseId)) <= rs.ReorderPoint);
var lastPurchase = lastPurchaseByItem.GetValueOrDefault(i.ItemId);
return new ItemOverviewDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status,
stock?.Qty ?? 0m, stock?.Value ?? 0m, isLowStock,
lastPurchase?.PostedAt, lastPurchase?.VendorId, lastPurchase?.VendorName,
salesByItem.GetValueOrDefault(i.ItemId));
}).ToList();
return PagedResponse<ItemOverviewDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default) public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
{ {
var item = await _items.Query().AsNoTracking() var item = await _items.Query().AsNoTracking()
@@ -88,7 +88,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
public async Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default) public async Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default)
{ {
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct); await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); var branchId = await _branchConfig.ResolvePurchaseOrderBranchIdAsync(request.BranchId, ct);
await EnsureLinesInBranchAsync(branchId, request.Lines.Select(l => l.WarehouseId), ct); await EnsureLinesInBranchAsync(branchId, request.Lines.Select(l => l.WarehouseId), ct);
var actor = _currentUser.AuditUserId; var actor = _currentUser.AuditUserId;
@@ -305,9 +305,16 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
} }
/// <summary>A PO is constrained to one branch (docs/BRANCH_ISOLATION_PLAN.md — Open /// <summary>A PO is constrained to one branch (docs/BRANCH_ISOLATION_PLAN.md — Open
/// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.</summary> /// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.
/// Skipped unless both purchase orders AND warehouses are divided by branch: with PO
/// branching off the PO sits on the default branch, and with warehouse branching off every
/// warehouse is parked on the default branch too (shared by all branches) — either way a
/// branch match is meaningless and would reject every line.</summary>
private async Task EnsureLinesInBranchAsync(int branchId, IEnumerable<int> warehouseIds, CancellationToken ct) private async Task EnsureLinesInBranchAsync(int branchId, IEnumerable<int> warehouseIds, CancellationToken ct)
{ {
if (!await _branchConfig.IsPurchaseOrderBranchingEnabledAsync(ct)) return;
if (!await _branchConfig.IsWarehouseBranchingEnabledAsync(ct)) return;
var ids = warehouseIds.Distinct().ToList(); var ids = warehouseIds.Distinct().ToList();
var mismatched = await _warehouses.Query().AsNoTracking() var mismatched = await _warehouses.Query().AsNoTracking()
.Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId) .Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId)
+19 -1
View File
@@ -22,6 +22,7 @@ public sealed class RoleService : IRoleService
private readonly IRepository<RolePermission> _rolePermissions; private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions; private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees; private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex; private readonly IAuthHexClient _authHex;
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
@@ -29,12 +30,13 @@ public sealed class RoleService : IRoleService
public RoleService( public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions, IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
IRepository<Employee> employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow) IRepository<Employee> employees, IRepository<User> users, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
{ {
_roles = roles; _roles = roles;
_rolePermissions = rolePermissions; _rolePermissions = rolePermissions;
_permissions = permissions; _permissions = permissions;
_employees = employees; _employees = employees;
_users = users;
_currentUser = currentUser; _currentUser = currentUser;
_authHex = authHex; _authHex = authHex;
_branchConfig = branchConfig; _branchConfig = branchConfig;
@@ -202,6 +204,22 @@ public sealed class RoleService : IRoleService
} }
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default) public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default)
{
var me = await BuildMeAsync(roleCode, ct) with
{
PurchaseOrderBranchingEnabled = await _branchConfig.IsPurchaseOrderBranchingEnabledAsync(ct)
};
if (!_currentUser.IsAuthenticated) return me;
var mustChangePassword = await _users.Query().AsNoTracking()
.Where(u => u.UserId == _currentUser.AuditUserId)
.Select(u => u.MustChangePassword)
.FirstOrDefaultAsync(ct);
// Local user id lets the frontend spot records the caller created (e.g. self-approval of a PO).
return me with { MustChangePassword = mustChangePassword, UserId = _currentUser.AuditUserId };
}
private async Task<MeResponseDto> BuildMeAsync(string? roleCode, CancellationToken ct)
{ {
// The caller's branch, via their optional Employee link — independent of role, // The caller's branch, via their optional Employee link — independent of role,
// since even an Admin's own "home" branch may be worth surfacing later. Null // since even an Admin's own "home" branch may be worth surfacing later. Null
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default) public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
{ {
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines); IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q)) if (!string.IsNullOrWhiteSpace(query.Q))
@@ -64,6 +64,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId); if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
// Warehouses are shared across branches — the invoice carries its own owning BranchId. // Warehouses are shared across branches — the invoice carries its own owning BranchId.
if (branchId is not null) q = q.Where(x => x.BranchId == branchId); if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService.
if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
+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);
} }
} }
@@ -124,7 +124,9 @@ public sealed class UserManagementService : IUserManagementService
DisplayName = request.FullName.Trim(), DisplayName = request.FullName.Trim(),
Email = request.Email.Trim(), Email = request.Email.Trim(),
RoleId = role.RoleId, RoleId = role.RoleId,
Status = EntityStatus.Active Status = EntityStatus.Active,
// The admin chose (or AuthHex generated) this password — the owner must replace it on first login.
MustChangePassword = true
}; };
await _users.AddAsync(user, ct); await _users.AddAsync(user, ct);
@@ -183,6 +185,27 @@ public sealed class UserManagementService : IUserManagementService
return Map(user); return Map(user);
} }
public async Task ClearMustChangePasswordAsync(int userId, CancellationToken ct = default)
{
var user = await _users.GetByIdAsync(userId, ct);
if (user is null || !user.MustChangePassword) return;
user.MustChangePassword = false;
await _uow.SaveChangesAsync(ct);
}
public async Task ClearMustChangePasswordByIdentifierAsync(string identifier, CancellationToken ct = default)
{
var term = identifier.Trim();
var user = await _users.Query()
.FirstOrDefaultAsync(u => u.MustChangePassword
&& ((u.Email != null && EF.Functions.ILike(u.Email, term)) || u.Username == term), ct);
if (user is null) return;
user.MustChangePassword = false;
await _uow.SaveChangesAsync(ct);
}
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);
+7 -1
View File
@@ -62,6 +62,8 @@ public sealed class VendorService : IVendorService
Name = request.Name.Trim(), Name = request.Name.Trim(),
Terms = request.Terms, Terms = request.Terms,
TaxReg = request.TaxReg, TaxReg = request.TaxReg,
Address = Normalize(request.Address),
Phone = Normalize(request.Phone),
Currency = request.Currency.Trim().ToUpperInvariant(), Currency = request.Currency.Trim().ToUpperInvariant(),
Status = EntityStatus.Active, Status = EntityStatus.Active,
BranchId = branchId, BranchId = branchId,
@@ -94,6 +96,8 @@ public sealed class VendorService : IVendorService
vendor.Name = request.Name.Trim(); vendor.Name = request.Name.Trim();
vendor.Terms = request.Terms; vendor.Terms = request.Terms;
vendor.TaxReg = request.TaxReg; vendor.TaxReg = request.TaxReg;
vendor.Address = Normalize(request.Address);
vendor.Phone = Normalize(request.Phone);
vendor.Currency = request.Currency.Trim().ToUpperInvariant(); vendor.Currency = request.Currency.Trim().ToUpperInvariant();
vendor.BranchId = branchId; vendor.BranchId = branchId;
vendor.UpdatedAt = DateTime.UtcNow; vendor.UpdatedAt = DateTime.UtcNow;
@@ -120,6 +124,8 @@ public sealed class VendorService : IVendorService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static VendorDto Map(Vendor v) => new( private static VendorDto Map(Vendor v) => new(
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt); v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Address, v.Phone, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
} }
+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 -4
View File
@@ -1,11 +1,8 @@
{ {
"ConnectionStrings": {
"DefaultConnection": ""
},
"Jwt": { "Jwt": {
"SigningKey": "" "SigningKey": ""
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "" "BaseUrl": "http://localhost:6703"
} }
} }
+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
+33
View File
@@ -0,0 +1,33 @@
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 ./
COPY --from=deps /app/node_modules ./node_modules
RUN npm prune --production && 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"]
@@ -0,0 +1,181 @@
"use client"
// Forced first-login password change: admin-created accounts land here (via the login page
// and AuthProvider's guard) while GET /auth/me reports mustChangePassword. The server clears
// the flag and ends the session on success, so the user signs in again with the new password.
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Check, Eye, EyeOff } from "lucide-react"
import { authApi } from "@/lib/api/auth"
import { clearStoredUser } from "@/lib/auth-session"
import { errorMessage } from "@/lib/error-map"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
{met ? <Check className="size-4" /> : <span>○</span>}
</div>
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
</div>
)
}
function PasswordInput({
id,
label,
value,
onChange,
placeholder,
autoComplete,
invalid,
}: {
id: string
label: string
value: string
onChange: (value: string) => void
placeholder: string
autoComplete: string
invalid?: boolean
}) {
const [show, setShow] = useState(false)
return (
<div>
<label htmlFor={id} className="block text-sm font-semibold text-foreground">{label}</label>
<div className="relative mt-2">
<Input
id={id}
type={show ? "text" : "password"}
autoComplete={autoComplete}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-11 rounded-lg px-4 pr-11 text-base"
placeholder={placeholder}
aria-invalid={invalid}
/>
<button
type="button"
onClick={() => setShow((s) => !s)}
aria-label={show ? "Hide password" : "Show password"}
className="absolute inset-y-0 right-3 flex items-center text-muted-foreground transition-colors hover:text-foreground"
>
{show ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
</div>
)
}
export default function ChangePasswordPage() {
const router = useRouter()
const [current, setCurrent] = useState("")
const [password, setPassword] = useState("")
const [confirm, setConfirm] = useState("")
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const meetsLength = password.length >= 8 && password.length <= 20
const hasUpper = /[A-Z]/.test(password)
const hasNumber = /[0-9]/.test(password)
const hasSpecial = /[^A-Za-z0-9]/.test(password)
const differsFromCurrent = password.length > 0 && password !== current
const passwordsMatch = password === confirm && password.length > 0
const allValid = current.length > 0 && meetsLength && hasUpper && hasNumber && hasSpecial && differsFromCurrent && passwordsMatch
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
if (!allValid) return
setSubmitError(null)
setSubmitting(true)
try {
await authApi.changePassword(current, password)
clearStoredUser()
router.replace("/login?passwordChanged=1")
} catch (err) {
setSubmitError(errorMessage(err))
setSubmitting(false)
}
}
async function signOut() {
await authApi.logout().catch(() => {})
clearStoredUser()
router.replace("/login")
}
return (
<AuthShell>
<div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">First sign in</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Set your password</h1>
<p className="mt-3 text-base text-muted-foreground">
Your account was created with a temporary password. Choose a new one to continue.
</p>
</div>
<form noValidate className="mt-10 space-y-6" onSubmit={onSubmit}>
{submitError && (
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{submitError}
</div>
)}
<PasswordInput
id="current"
label="Temporary Password"
value={current}
onChange={setCurrent}
placeholder="Enter the password you signed in with"
autoComplete="current-password"
/>
<PasswordInput
id="password"
label="New Password"
value={password}
onChange={setPassword}
placeholder="Enter your new password"
autoComplete="new-password"
/>
<div>
<PasswordInput
id="confirm"
label="Confirm Password"
value={confirm}
onChange={setConfirm}
placeholder="Confirm your new password"
autoComplete="new-password"
invalid={!passwordsMatch && confirm.length > 0}
/>
{!passwordsMatch && confirm.length > 0 && (
<p className="mt-2 text-sm font-medium text-destructive">Passwords don&apos;t match.</p>
)}
</div>
<div className="space-y-3 rounded-lg bg-primary/5 p-4">
<p className="text-sm font-semibold text-foreground">Password requirements:</p>
<PasswordRequirement met={meetsLength} label="Between 8 and 20 characters" />
<PasswordRequirement met={hasUpper} label="At least 1 uppercase letter" />
<PasswordRequirement met={hasNumber} label="At least 1 number" />
<PasswordRequirement met={hasSpecial} label="At least 1 special character" />
<PasswordRequirement met={differsFromCurrent} label="Different from the temporary password" />
</div>
<Button type="submit" disabled={!allValid || submitting} className="mt-8 h-12 w-full rounded-full text-base font-semibold">
{submitting ? "Saving…" : "Change password"}
</Button>
<div className="text-center">
<button type="button" onClick={signOut} className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
Sign out
</button>
</div>
</form>
</AuthShell>
)
}
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Pencil, Plus, Search, Wallet } from "lucide-react" import { ArrowLeft, Banknote, Pencil, Plus, Search, Wallet } from "lucide-react"
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger" import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -12,23 +12,44 @@ import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/g
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { FilterBar } from "@/components/ui/filter-bar"
import { Field, FieldLabel } from "@/components/ui/field"
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
type AccountTypeFilter = CashBankAccountType | "Both" type AccountTypeFilter = CashBankAccountType | "Both"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size,
// same convention as the other list pages even though this one pages client-side.
const MOBILE_PAGE_SIZE_STEP = 10
export default function BankAccountsPage() { export default function BankAccountsPage() {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null) const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glAccounts, setGlAccounts] = useState<GlAccount[] | null>(null) const [glAccounts, setGlAccounts] = useState<GlAccount[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both") const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
return () => clearTimeout(t)
}, [search])
useEffect(() => setPage(1), [debouncedSearch, accountType])
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table // GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below. // before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
// There's no server pagination on this endpoint (it returns the full union in one array), so
// page/pageSize below are applied client-side over whatever this call returns.
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()]) Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
@@ -40,6 +61,7 @@ export default function BankAccountsPage() {
.catch((err) => { .catch((err) => {
if (!cancelled) setError(errorMessage(err)) if (!cancelled) setError(errorMessage(err))
}) })
.finally(() => setShowMoreLoading(false))
return () => { return () => {
cancelled = true cancelled = true
} }
@@ -49,7 +71,7 @@ export default function BankAccountsPage() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!accounts) return null if (!accounts) return null
const q = search.trim().toLowerCase() const q = debouncedSearch.toLowerCase()
if (!q) return accounts if (!q) return accounts
return accounts.filter((a) => { return accounts.filter((a) => {
const gl = glAccountsById.get(a.glAccountId) const gl = glAccountsById.get(a.glAccountId)
@@ -62,116 +84,201 @@ export default function BankAccountsPage() {
(gl?.accountCode ?? "").toLowerCase().includes(q) (gl?.accountCode ?? "").toLowerCase().includes(q)
) )
}) })
}, [accounts, search, glAccountsById]) }, [accounts, debouncedSearch, glAccountsById])
const stats = useMemo(() => {
if (!accounts) return null
return {
total: accounts.length,
bank: accounts.filter((a) => a.accountType === CashBankAccountType.Bank).length,
cash: accounts.filter((a) => a.accountType === CashBankAccountType.Cash).length,
}
}, [accounts])
const totalItems = filtered?.length ?? 0
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
const pageItems = filtered?.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize) ?? null
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
setShowMoreLoading(false)
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}> <Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" /> <ArrowLeft className="size-5" />
</Link> </Link>
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1> <div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
{accounts && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{accounts.length}
</span>
)}
</div>
<p className="text-base text-muted-foreground">Cash and Bank accounts linked to a GL account, for reconciliation.</p> <p className="text-base text-muted-foreground">Cash and Bank accounts linked to a GL account, for reconciliation.</p>
</div> </div>
</div> </div>
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Account New Account
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-wrap gap-2">
<div className="relative flex-1"> {stats ? (
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" /> <>
<Input <StatCard
value={search} label="Total Accounts"
onChange={(e) => setSearch(e.target.value)} value={stats.total}
placeholder="Search name, bank/type, account no. or currency…" icon={Wallet}
className="h-14 w-full pl-11 text-base" active={accountType === "Both"}
aria-label="Search cash/bank accounts" onClick={() => setAccountType("Both")}
/> />
</div> <StatCard
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}> label="Bank Accounts"
<SelectTrigger className="h-14! w-full text-base sm:w-48"> value={stats.bank}
<SelectValue /> icon={Banknote}
</SelectTrigger> active={accountType === CashBankAccountType.Bank}
<SelectContent> onClick={() => setAccountType((t) => (t === CashBankAccountType.Bank ? "Both" : CashBankAccountType.Bank))}
<SelectItem value="Both" label="All types" className="text-base">All types</SelectItem> />
<SelectItem value={CashBankAccountType.Bank} label="Bank" className="text-base">Bank</SelectItem> <StatCard
<SelectItem value={CashBankAccountType.Cash} label="Cash" className="text-base">Cash</SelectItem> label="Cash Accounts"
</SelectContent> value={stats.cash}
</Select> icon={Wallet}
active={accountType === CashBankAccountType.Cash}
onClick={() => setAccountType((t) => (t === CashBankAccountType.Cash ? "Both" : CashBankAccountType.Cash))}
/>
</>
) : (
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
)}
</div> </div>
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search name, bank/type, account no. or currency…"
advancedActive={accountType !== "Both"}
onClearAdvanced={() => setAccountType("Both")}
advanced={
<Field>
<FieldLabel htmlFor="bank-filter-type">Type</FieldLabel>
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
<SelectTrigger id="bank-filter-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Both">All types</SelectItem>
<SelectItem value={CashBankAccountType.Bank}>Bank</SelectItem>
<SelectItem value={CashBankAccountType.Cash}>Cash</SelectItem>
</SelectContent>
</Select>
</Field>
}
/>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && filtered === null && ( {!error && pageItems === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
{Array.from({ length: 4 }).map((_, i) => ( <div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton key={i} className="h-14 w-full" /> <Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
{!error && filtered !== null && filtered.length === 0 && ( {!error && pageItems !== null && pageItems.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-3 rounded-xl border border-dashed py-16 text-center">
<Wallet className="size-12 text-muted-foreground" /> <Search className="size-10 text-muted-foreground" />
<p className="text-base text-muted-foreground"> <div>
{search ? "No accounts match your search." : "No cash/bank accounts yet."} <p className="text-base font-medium text-foreground">No accounts match &quot;{debouncedSearch}&quot;</p>
</p> <p className="text-sm text-muted-foreground">Try a different name, bank or account number.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div> </div>
)} )}
{!error && filtered !== null && filtered.length > 0 && ( {!error && pageItems !== null && pageItems.length === 0 && !debouncedSearch && (
<Table className="text-base"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<TableHeader> <Wallet className="size-12 text-muted-foreground" />
<TableRow> <div>
<TableHead className="h-12 px-3 text-sm">Type</TableHead> <p className="text-base font-medium text-foreground">No cash/bank accounts yet</p>
<TableHead className="h-12 px-3 text-sm">Account name</TableHead> <p className="text-sm text-muted-foreground">Add your first cash or bank account to get started.</p>
<TableHead className="h-12 px-3 text-sm">Bank / Cash type</TableHead> </div>
<TableHead className="h-12 px-3 text-sm">Account no.</TableHead> <Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants())}>
<TableHead className="h-12 px-3 text-sm">GL account</TableHead> <Plus className="size-4" />
<TableHead className="h-12 px-3 text-sm">Currency</TableHead> New Account
<TableHead className="h-12 px-3 text-sm">Created</TableHead> </Link>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> </div>
</TableRow> )}
</TableHeader>
<TableBody> {!error && pageItems !== null && pageItems.length > 0 && (
{filtered.map((a) => { <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{pageItems.map((a) => {
const gl = glAccountsById.get(a.glAccountId) const gl = glAccountsById.get(a.glAccountId)
return ( return (
<TableRow key={`${a.accountType}-${a.accountId}`}> <div key={`${a.accountType}-${a.accountId}`} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-3 py-3.5"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{a.accountName}</p>
<span className="text-xs text-muted-foreground">{a.accountNumber ?? "—"}</span>
</div>
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"h-6 w-16 justify-center border-transparent text-sm", "h-6 w-16 shrink-0 justify-center border-transparent text-sm",
a.accountType === CashBankAccountType.Cash a.accountType === CashBankAccountType.Cash ? "bg-success/10 text-success" : "bg-primary/10 text-primary"
? "bg-success/10 text-success"
: "bg-primary/10 text-primary"
)} )}
> >
{a.accountType} {a.accountType}
</Badge> </Badge>
</TableCell> </div>
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell> <div>
<TableCell className="px-3 py-3.5 text-muted-foreground"> <p className="text-muted-foreground">Bank / Cash type</p>
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`} <p className="font-medium text-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</p>
</TableCell> </div>
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell> <div>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell> <p className="text-muted-foreground">Currency</p>
<TableCell className="px-3 py-3.5"> <p className="font-medium text-foreground">{a.currencyCode}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">GL account</p>
<p className="font-medium text-foreground">{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{formatReportDate(a.createdAt)}</p>
</div>
</div>
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />}
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
}
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</TooltipTrigger> </TooltipTrigger>
@@ -180,12 +287,94 @@ export default function BankAccountsPage() {
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts. {a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TableCell> </div>
</TableRow> </div>
) )
})} })}
</TableBody>
</Table> {totalItems > pageItems.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Type</TableHead>
<TableHead className="h-11 px-3 text-sm">Account name</TableHead>
<TableHead className="h-11 px-3 text-sm">Bank / Cash type</TableHead>
<TableHead className="h-11 px-3 text-sm">Account no.</TableHead>
<TableHead className="h-11 px-3 text-sm">GL account</TableHead>
<TableHead className="h-11 px-3 text-sm">Currency</TableHead>
<TableHead className="h-11 px-3 text-sm">Created</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((a) => {
const gl = glAccountsById.get(a.glAccountId)
return (
<TableRow key={`${a.accountType}-${a.accountId}`} className="align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center border-transparent text-sm",
a.accountType === CashBankAccountType.Cash ? "bg-success/10 text-success" : "bg-primary/10 text-primary"
)}
>
{a.accountType}
</Badge>
</TableCell>
<TableCell className="px-3 py-4 font-semibold text-foreground">{a.accountName}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.accountNumber ?? "—"}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-4">
<div className="flex items-center justify-center">
<Tooltip>
<TooltipTrigger
render={
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
}
>
<Pencil className="size-4" />
</TooltipTrigger>
<TooltipContent>
Editing isn&apos;t available yet — the General Ledger service has no update endpoint for
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
{totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems, totalPages }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)} )}
</div> </div>
) )
@@ -18,6 +18,7 @@ import { buttonVariants } from "@/components/ui/button"
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 { ChequePageDialog } from "@/components/accounts/ChequePageDialog" import { ChequePageDialog } from "@/components/accounts/ChequePageDialog"
import { useAuth } from "@/components/auth/AuthProvider"
const STATUS_BADGE: Record<ChequePageIssueStatus, string> = { const STATUS_BADGE: Record<ChequePageIssueStatus, string> = {
[ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground", [ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground",
@@ -38,6 +39,7 @@ export default function ChequeBookDetailPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null) const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const { branchingEnabled } = useAuth()
useEffect(() => { useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([])) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
@@ -99,10 +101,12 @@ export default function ChequeBookDetailPage() {
<p className="text-sm text-muted-foreground">Bank account</p> <p className="text-sm text-muted-foreground">Bank account</p>
<p className="text-base font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}</p> <p className="text-base font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}</p>
</div> </div>
<div> {branchingEnabled && (
<p className="text-sm text-muted-foreground">Branch</p> <div>
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p> <p className="text-sm text-muted-foreground">Branch</p>
</div> <p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
</div>
)}
<div> <div>
<p className="text-sm text-muted-foreground">Range</p> <p className="text-sm text-muted-foreground">Range</p>
<p className="text-base font-medium"> <p className="text-base font-medium">
@@ -148,7 +152,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>
@@ -57,7 +57,10 @@ export default function NewChequeBookPage() {
// specific branch (or any non-admin) gets it locked here too — only "All Branches" (or the // specific branch (or any non-admin) gets it locked here too — only "All Branches" (or the
// Branch Configuration toggle being off) leaves it as a free pick, since GL's own record // Branch Configuration toggle being off) leaves it as a free pick, since GL's own record
// still needs *some* branch id regardless of our toggle. // still needs *some* branch id regardless of our toggle.
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : null // With branching off the field is hidden entirely, but GL still needs a branch id — use the
// deployment's default branch (BR-000, the one the backend files everything under then).
const defaultBranchId = (branches.find((b) => b.code === "BR-000") ?? branches[0])?.branchId ?? null
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : defaultBranchId
const showBranchPicker = !lockedBranchId const showBranchPicker = !lockedBranchId
useEffect(() => { useEffect(() => {
@@ -137,6 +140,7 @@ export default function NewChequeBookPage() {
<FieldError errors={[errors.bankAccountId ? { message: errors.bankAccountId } : undefined]} /> <FieldError errors={[errors.bankAccountId ? { message: errors.bankAccountId } : undefined]} />
</Field> </Field>
{branchingEnabled && (
<Field data-invalid={!!errors.branchId}> <Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="cb-branch">Branch</FieldLabel> <FieldLabel htmlFor="cb-branch">Branch</FieldLabel>
{showBranchPicker ? ( {showBranchPicker ? (
@@ -163,6 +167,7 @@ export default function NewChequeBookPage() {
)} )}
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} /> <FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field> </Field>
)}
<Field data-invalid={!!errors.chequeBookNo}> <Field data-invalid={!!errors.chequeBookNo}>
<FieldLabel htmlFor="cb-no">Cheque book number</FieldLabel> <FieldLabel htmlFor="cb-no">Cheque book number</FieldLabel>
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ArrowLeft, BookText, Plus } from "lucide-react" import { ArrowLeft, BookCheck, BookText, BookX, CheckCircle2, Eye, Plus, Search } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger" import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -15,152 +15,425 @@ import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
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"
type StatusFilter = ChequeBookStatus | "All" type StatusFilter = ChequeBookStatus | "All"
interface StatusCounts {
total: number
active: number
completed: number
cancelled: number
}
const STATUS_BADGE: Record<ChequeBookStatus, string> = { const STATUS_BADGE: Record<ChequeBookStatus, string> = {
[ChequeBookStatus.Active]: "bg-success/10 text-success", [ChequeBookStatus.Active]: "bg-success/10 text-success",
[ChequeBookStatus.Completed]: "bg-primary/10 text-primary", [ChequeBookStatus.Completed]: "bg-primary/10 text-primary",
[ChequeBookStatus.Cancelled]: "bg-destructive/10 text-destructive", [ChequeBookStatus.Cancelled]: "bg-destructive/10 text-destructive",
} }
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function ChequeBooksPage() { export default function ChequeBooksPage() {
const router = useRouter() const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const [books, setBooks] = useState<ChequeBook[] | null>(null) const [books, setBooks] = useState<ChequeBook[] | null>(null)
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null) const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const [totalItems, setTotalItems] = useState(0)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([])) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, []) }, [])
useEffect(() => setPage(1), [status, activeBranchId])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
Promise.all([ Promise.all([
chequeBooksApi.list({ chequeBooksApi.list({ page, pageSize, status: status === "All" ? undefined : status, branchId }),
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
}),
bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank), bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank),
]) ])
.then(([result, banks]) => { .then(([result, banks]) => {
if (cancelled) return if (cancelled) return
setBooks(result.items) setBooks(result.items)
setTotalItems(result.totalCount)
setBankAccounts(banks) setBankAccounts(banks)
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) setError(errorMessage(err)) if (!cancelled) setError(errorMessage(err))
}) })
.finally(() => {
if (!cancelled) setShowMoreLoading(false)
})
return () => { return () => {
cancelled = true cancelled = true
} }
// bankAccounts intentionally excluded — fetched once, reused across status re-fetches. // bankAccounts intentionally excluded — fetched once, reused across status re-fetches.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, activeBranchId, branchingEnabled]) }, [page, pageSize, status, activeBranchId, branchingEnabled])
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
// the main list. Each call falls back to 0 on its own so one bad request doesn't blank
// out the other tiles.
useEffect(() => {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
const countFor = (s?: ChequeBookStatus) =>
chequeBooksApi
.list({ page: 1, pageSize: 1, status: s, branchId })
.then((res) => res.totalCount)
.catch(() => 0)
Promise.all([
countFor(undefined),
countFor(ChequeBookStatus.Active),
countFor(ChequeBookStatus.Completed),
countFor(ChequeBookStatus.Cancelled),
]).then(([total, active, completed, cancelled]) => setStatusCounts({ total, active, completed, cancelled }))
}, [activeBranchId, branchingEnabled])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
return () => clearTimeout(t)
}, [search])
const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts]) const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches]) const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
// The API has no `q` param, so search narrows whatever page is currently loaded — same
// pattern as the Sales Invoices list.
const visibleBooks = useMemo(() => {
if (!books) return null
const q = debouncedSearch.toLowerCase()
if (!q) return books
return books.filter((b) => {
const bank = bankAccountsById.get(b.bankAccountId)
const branch = branchesById.get(b.branchId)
return (
b.chequeBookNo.toLowerCase().includes(q) ||
(bank?.accountName ?? "").toLowerCase().includes(q) ||
(branch?.name ?? "").toLowerCase().includes(q)
)
})
}, [books, debouncedSearch, bankAccountsById, branchesById])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const hasFilters = debouncedSearch.length > 0 || status !== "All"
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}> <Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" /> <ArrowLeft className="size-5" />
</Link> </Link>
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1> <div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
{statusCounts && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{statusCounts.total}
</span>
)}
</div>
<p className="text-base text-muted-foreground">Cheque books issued from this company&apos;s own supply.</p> <p className="text-base text-muted-foreground">Cheque books issued from this company&apos;s own supply.</p>
</div> </div>
</div> </div>
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Cheque Book New Cheque Book
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-wrap gap-2">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}> {statusCounts ? (
<SelectTrigger className="h-12! w-full text-base sm:w-48"> <>
<SelectValue /> <StatCard
</SelectTrigger> label="Total Cheque Books"
<SelectContent> value={statusCounts.total}
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem> icon={BookText}
<SelectItem value={ChequeBookStatus.Active} label="Active" className="text-base">Active</SelectItem> active={status === "All"}
<SelectItem value={ChequeBookStatus.Completed} label="Completed" className="text-base">Completed</SelectItem> onClick={() => setStatus("All")}
<SelectItem value={ChequeBookStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem> />
</SelectContent> <StatCard
</Select> label="Active"
value={statusCounts.active}
icon={CheckCircle2}
active={status === ChequeBookStatus.Active}
onClick={() => setStatus((s) => (s === ChequeBookStatus.Active ? "All" : ChequeBookStatus.Active))}
/>
<StatCard
label="Completed"
value={statusCounts.completed}
icon={BookCheck}
active={status === ChequeBookStatus.Completed}
onClick={() => setStatus((s) => (s === ChequeBookStatus.Completed ? "All" : ChequeBookStatus.Completed))}
/>
<StatCard
label="Cancelled"
value={statusCounts.cancelled}
icon={BookX}
active={status === ChequeBookStatus.Cancelled}
onClick={() => setStatus((s) => (s === ChequeBookStatus.Cancelled ? "All" : ChequeBookStatus.Cancelled))}
/>
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
)}
</div> </div>
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search cheque book no., bank or branch…"
advancedActive={status !== "All"}
onClearAdvanced={() => setStatus("All")}
advanced={
<Field>
<FieldLabel htmlFor="chq-filter-status">Status</FieldLabel>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger id="chq-filter-status"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All statuses</SelectItem>
<SelectItem value={ChequeBookStatus.Active}>Active</SelectItem>
<SelectItem value={ChequeBookStatus.Completed}>Completed</SelectItem>
<SelectItem value={ChequeBookStatus.Cancelled}>Cancelled</SelectItem>
</SelectContent>
</Select>
</Field>
}
/>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && books === null && ( {!error && visibleBooks === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
{Array.from({ length: 4 }).map((_, i) => ( <div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton key={i} className="h-14 w-full" /> <Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
{!error && books !== null && books.length === 0 && ( {!error && visibleBooks !== null && visibleBooks.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-3 rounded-xl border border-dashed py-16 text-center">
<BookText className="size-12 text-muted-foreground" /> <Search className="size-10 text-muted-foreground" />
<p className="text-base text-muted-foreground">No cheque books yet.</p> <div>
<p className="text-base font-medium text-foreground">No cheque books match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different cheque book no., bank or branch.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div> </div>
)} )}
{!error && books !== null && books.length > 0 && ( {!error && visibleBooks !== null && visibleBooks.length === 0 && !debouncedSearch && (
<Table className="text-base"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<TableHeader> <BookText className="size-12 text-muted-foreground" />
<TableRow> <div>
<TableHead className="h-12 px-3 text-sm">Cheque book no.</TableHead> <p className="text-base font-medium text-foreground">{hasFilters ? "No cheque books match your filters." : "No cheque books yet."}</p>
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead> {!hasFilters && <p className="text-sm text-muted-foreground">Add your first cheque book to get started.</p>}
<TableHead className="h-12 px-3 text-sm">Branch</TableHead> </div>
<TableHead className="h-12 px-3 text-sm">Range</TableHead> {!hasFilters && (
<TableHead className="h-12 px-3 text-sm">Leaves</TableHead> <Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants())}>
<TableHead className="h-12 px-3 text-sm">Received</TableHead> <Plus className="size-4" />
<TableHead className="h-12 px-3 text-sm">Status</TableHead> New Cheque Book
</TableRow> </Link>
</TableHeader> )}
<TableBody> </div>
{books.map((b) => { )}
{!error && visibleBooks !== null && visibleBooks.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{visibleBooks.map((b) => {
const bank = bankAccountsById.get(b.bankAccountId) const bank = bankAccountsById.get(b.bankAccountId)
return ( return (
<TableRow <div
key={b.chequeBookNo} key={b.chequeBookNo}
className="cursor-pointer" role="link"
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)} tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
> >
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5 text-muted-foreground"> <p className="truncate font-semibold text-foreground">{b.chequeBookNo}</p>
{bank ? bank.accountName : `#${b.bankAccountId}`} <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
</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">
{b.startChequeNo} – {b.endChequeNo}
</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">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
{b.status} {b.status}
</Badge> </Badge>
</TableCell> </div>
</TableRow>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Bank account</p>
<p className="font-medium text-foreground">{bank ? bank.accountName : `#${b.bankAccountId}`}</p>
</div>
{branchingEnabled && (
<div>
<p className="text-muted-foreground">Branch</p>
<p className="font-medium text-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Range</p>
<p className="font-medium text-foreground">{b.startChequeNo} – {b.endChequeNo}</p>
</div>
<div>
<p className="text-muted-foreground">Leaves</p>
<p className="font-medium text-foreground">{b.totalLeaves}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Received</p>
<p className="font-medium text-foreground">{formatReportDate(b.receivedDate)}</p>
</div>
</div>
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${b.chequeBookNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</div>
) )
})} })}
</TableBody>
</Table> {totalItems > visibleBooks.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Cheque book no.</TableHead>
<TableHead className="h-11 px-3 text-sm">Bank account</TableHead>
{branchingEnabled && <TableHead className="h-11 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-11 px-3 text-sm">Range</TableHead>
<TableHead className="h-11 px-3 text-sm">Leaves</TableHead>
<TableHead className="h-11 px-3 text-sm">Received</TableHead>
<TableHead className="h-11 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleBooks.map((b) => {
const bank = bankAccountsById.get(b.bankAccountId)
return (
<TableRow
key={b.chequeBookNo}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
}}
>
<TableCell className="px-3 py-4" data-no-row-nav>
<Link
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
className="font-semibold text-foreground hover:underline"
>
{b.chequeBookNo}
</Link>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{bank ? bank.accountName : `#${b.bankAccountId}`}</TableCell>
{branchingEnabled && (
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
)}
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{b.startChequeNo} – {b.endChequeNo}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{b.totalLeaves}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent text-xs", STATUS_BADGE[b.status])}>
{b.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${b.chequeBookNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
{totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)) }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)} )}
</div> </div>
) )
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Inbox, Plus } from "lucide-react" import { ArrowLeft, Banknote, CheckCircle2, Eye, Inbox, Plus, Search, Undo2, Wallet } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger" import { receivedChequesApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -14,14 +14,27 @@ import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
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 { ReceivedChequeDialog } from "@/components/accounts/ReceivedChequeDialog" import { ReceivedChequeDialog } from "@/components/accounts/ReceivedChequeDialog"
type StatusFilter = ReceivedChequeStatus | "All" type StatusFilter = ReceivedChequeStatus | "All"
interface StatusCounts {
total: number
received: number
deposited: number
cleared: number
returned: number
cancelled: number
}
const STATUS_BADGE: Record<ReceivedChequeStatus, string> = { const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
[ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground", [ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground",
[ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary", [ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary",
@@ -30,12 +43,27 @@ const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
[ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive", [ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive",
} }
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function ReceivedChequesPage() { export default function ReceivedChequesPage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null) const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null)
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const [totalItems, setTotalItems] = useState(0)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [selected, setSelected] = useState<ReceivedCheque | null>(null) const [selected, setSelected] = useState<ReceivedCheque | null>(null)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
@@ -43,124 +71,385 @@ export default function ReceivedChequesPage() {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([])) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, []) }, [])
useEffect(() => setPage(1), [status, activeBranchId])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
receivedChequesApi receivedChequesApi
.list({ .list({
page,
pageSize,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
}) })
.then((res) => { .then((res) => {
if (!cancelled) setCheques(res.items) if (cancelled) return
setCheques(res.items)
setTotalItems(res.totalCount)
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) setError(errorMessage(err)) if (!cancelled) setError(errorMessage(err))
}) })
.finally(() => {
if (!cancelled) setShowMoreLoading(false)
})
return () => { return () => {
cancelled = true cancelled = true
} }
}, [status, activeBranchId, branchingEnabled]) }, [page, pageSize, status, activeBranchId, branchingEnabled])
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
// the main list. Each call falls back to 0 on its own so one bad request doesn't blank
// out the other tiles.
useEffect(() => {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
const countFor = (s?: ReceivedChequeStatus) =>
receivedChequesApi
.list({ page: 1, pageSize: 1, status: s, branchId })
.then((res) => res.totalCount)
.catch(() => 0)
Promise.all([
countFor(undefined),
countFor(ReceivedChequeStatus.Received),
countFor(ReceivedChequeStatus.Deposited),
countFor(ReceivedChequeStatus.Cleared),
countFor(ReceivedChequeStatus.Returned),
countFor(ReceivedChequeStatus.Cancelled),
]).then(([total, received, deposited, cleared, returned, cancelled]) =>
setStatusCounts({ total, received, deposited, cleared, returned, cancelled })
)
}, [activeBranchId, branchingEnabled])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
return () => clearTimeout(t)
}, [search])
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches]) const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
// The API has no `q` param, so search narrows whatever page is currently loaded — same
// pattern as the Sales Invoices list.
const visibleCheques = useMemo(() => {
if (!cheques) return null
const q = debouncedSearch.toLowerCase()
if (!q) return cheques
return cheques.filter((c) => {
const branch = c.branchId ? branchesById.get(c.branchId) : null
return (
c.chequeNo.toLowerCase().includes(q) ||
c.receivedFromName.toLowerCase().includes(q) ||
(branch?.name ?? "").toLowerCase().includes(q)
)
})
}, [cheques, debouncedSearch, branchesById])
function handleUpdated(updated: ReceivedCheque) { function handleUpdated(updated: ReceivedCheque) {
setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev)) setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev))
setSelected(updated) setSelected(updated)
} }
function openDialog(c: ReceivedCheque) {
setSelected(c)
setDialogOpen(true)
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const hasFilters = debouncedSearch.length > 0 || status !== "All"
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}> <Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" /> <ArrowLeft className="size-5" />
</Link> </Link>
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1> <div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
{statusCounts && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{statusCounts.total}
</span>
)}
</div>
<p className="text-base text-muted-foreground">Cheques received from customers, suppliers, or others.</p> <p className="text-base text-muted-foreground">Cheques received from customers, suppliers, or others.</p>
</div> </div>
</div> </div>
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Received Cheque New Received Cheque
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-wrap gap-2">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}> {statusCounts ? (
<SelectTrigger className="h-12! w-full text-base sm:w-48"> <>
<SelectValue /> <StatCard
</SelectTrigger> label="Total Received"
<SelectContent> value={statusCounts.total}
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem> icon={Inbox}
<SelectItem value={ReceivedChequeStatus.Received} label="Received" className="text-base">Received</SelectItem> active={status === "All"}
<SelectItem value={ReceivedChequeStatus.Deposited} label="Deposited" className="text-base">Deposited</SelectItem> onClick={() => setStatus("All")}
<SelectItem value={ReceivedChequeStatus.Cleared} label="Cleared" className="text-base">Cleared</SelectItem> />
<SelectItem value={ReceivedChequeStatus.Returned} label="Returned" className="text-base">Returned</SelectItem> <StatCard
<SelectItem value={ReceivedChequeStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem> label="Received"
</SelectContent> value={statusCounts.received}
</Select> icon={Wallet}
active={status === ReceivedChequeStatus.Received}
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Received ? "All" : ReceivedChequeStatus.Received))}
/>
<StatCard
label="Deposited"
value={statusCounts.deposited}
icon={Banknote}
active={status === ReceivedChequeStatus.Deposited}
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Deposited ? "All" : ReceivedChequeStatus.Deposited))}
/>
<StatCard
label="Cleared"
value={statusCounts.cleared}
icon={CheckCircle2}
active={status === ReceivedChequeStatus.Cleared}
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Cleared ? "All" : ReceivedChequeStatus.Cleared))}
/>
<StatCard
label="Returned"
value={statusCounts.returned}
icon={Undo2}
active={status === ReceivedChequeStatus.Returned}
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Returned ? "All" : ReceivedChequeStatus.Returned))}
/>
</>
) : (
Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
)}
</div> </div>
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder={branchingEnabled ? "Search cheque no., received from or branch…" : "Search cheque no. or received from…"}
advancedActive={status !== "All"}
onClearAdvanced={() => setStatus("All")}
advanced={
<Field>
<FieldLabel htmlFor="rc-filter-status">Status</FieldLabel>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger id="rc-filter-status"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All statuses</SelectItem>
<SelectItem value={ReceivedChequeStatus.Received}>Received</SelectItem>
<SelectItem value={ReceivedChequeStatus.Deposited}>Deposited</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cleared}>Cleared</SelectItem>
<SelectItem value={ReceivedChequeStatus.Returned}>Returned</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cancelled}>Cancelled</SelectItem>
</SelectContent>
</Select>
</Field>
}
/>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && cheques === null && ( {!error && visibleCheques === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
{Array.from({ length: 4 }).map((_, i) => ( <div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton key={i} className="h-14 w-full" /> <Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
{!error && cheques !== null && cheques.length === 0 && ( {!error && visibleCheques !== null && visibleCheques.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-3 rounded-xl border border-dashed py-16 text-center">
<Inbox className="size-12 text-muted-foreground" /> <Search className="size-10 text-muted-foreground" />
<p className="text-base text-muted-foreground">No received cheques yet.</p> <div>
<p className="text-base font-medium text-foreground">No cheques match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different cheque no., name or branch.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div> </div>
)} )}
{!error && cheques !== null && cheques.length > 0 && ( {!error && visibleCheques !== null && visibleCheques.length === 0 && !debouncedSearch && (
<Table className="text-base"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<TableHeader> <Inbox className="size-12 text-muted-foreground" />
<TableRow> <div>
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead> <p className="text-base font-medium text-foreground">{hasFilters ? "No received cheques match your filters." : "No received cheques yet."}</p>
<TableHead className="h-12 px-3 text-sm">Received from</TableHead> {!hasFilters && <p className="text-sm text-muted-foreground">Record your first received cheque to get started.</p>}
<TableHead className="h-12 px-3 text-sm">Type</TableHead> </div>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead> {!hasFilters && (
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead> <Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants())}>
<TableHead className="h-12 px-3 text-sm">Received</TableHead> <Plus className="size-4" />
<TableHead className="h-12 px-3 text-sm">Status</TableHead> New Received Cheque
</TableRow> </Link>
</TableHeader> )}
<TableBody> </div>
{cheques.map((c) => ( )}
<TableRow
{!error && visibleCheques !== null && visibleCheques.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{visibleCheques.map((c) => (
<div
key={c.receivedChequeId} key={c.receivedChequeId}
className="cursor-pointer" role="link"
onClick={() => { tabIndex={0}
setSelected(c) onClick={(e) => {
setDialogOpen(true) if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
openDialog(c)
}} }}
onKeyDown={(e) => {
if (e.key === "Enter") openDialog(c)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
> >
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell> <p className="truncate font-semibold text-foreground">{c.chequeNo}</p>
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell> <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
</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">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
{c.status} {c.status}
</Badge> </Badge>
</TableCell> </div>
</TableRow>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Received from</p>
<p className="font-medium text-foreground">{c.receivedFromName}</p>
</div>
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-medium text-foreground">{c.receivedFromType}</p>
</div>
{branchingEnabled && (
<div>
<p className="text-muted-foreground">Branch</p>
<p className="font-medium text-foreground">{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Amount</p>
<p className="font-medium text-foreground tabular-nums">{formatAmount(c.amount)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Received</p>
<p className="font-medium text-foreground">{formatReportDate(c.receivedDate)}</p>
</div>
</div>
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
<Button
variant="ghost"
size="icon-sm"
aria-label={`View cheque ${c.chequeNo}`}
title="View"
onClick={() => openDialog(c)}
>
<Eye className="size-4" />
</Button>
</div>
</div>
))} ))}
</TableBody>
</Table> {totalItems > visibleCheques.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Cheque no.</TableHead>
<TableHead className="h-11 px-3 text-sm">Received from</TableHead>
<TableHead className="h-11 px-3 text-sm">Type</TableHead>
{branchingEnabled && <TableHead className="h-11 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-11 px-3 text-sm text-right">Amount</TableHead>
<TableHead className="h-11 px-3 text-sm">Received</TableHead>
<TableHead className="h-11 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleCheques.map((c) => (
<TableRow
key={c.receivedChequeId}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
openDialog(c)
}}
>
<TableCell className="px-3 py-4 font-semibold text-foreground">{c.chequeNo}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{c.receivedFromType}</TableCell>
{branchingEnabled && (
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
</TableCell>
)}
<TableCell className="px-3 py-4 text-right text-sm tabular-nums text-muted-foreground">{formatAmount(c.amount)}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent text-xs", STATUS_BADGE[c.status])}>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center">
<Button
variant="ghost"
size="icon-sm"
aria-label={`View cheque ${c.chequeNo}`}
title="View"
onClick={() => openDialog(c)}
>
<Eye className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{totalItems > 0 && (
<div className="hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)) }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)} )}
<ReceivedChequeDialog cheque={selected} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handleUpdated} /> <ReceivedChequeDialog cheque={selected} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handleUpdated} />
@@ -0,0 +1,555 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Pencil, Receipt, Save, Users } from "lucide-react"
import { customersApi } from "@/lib/api/customers"
import { salesApi } from "@/lib/api/sales"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Customer, CustomerType } from "@/types/customers"
import { SalesInvoiceSummary } from "@/types/sales"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import BarChart from "@/components/ui/bar-chart"
const INVOICE_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** Small tinted stat chip for the profile header — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Users; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div>
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
)
}
export default function CustomerDetailPage() {
const params = useParams<{ id: string }>()
const customerId = Number(params.id)
const [customer, setCustomer] = useState<Customer | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [customerCode, setCustomerCode] = useState("")
const [customerType, setCustomerType] = useState<CustomerType>("B2C")
const [name, setName] = useState("")
const [displayName, setDisplayName] = useState("")
const [phone, setPhone] = useState("")
const [email, setEmail] = useState("")
const [addressLine1, setAddressLine1] = useState("")
const [addressLine2, setAddressLine2] = useState("")
const [city, setCity] = useState("")
const [country, setCountry] = useState("")
const [taxRegistrationNo, setTaxRegistrationNo] = useState("")
const [creditLimit, setCreditLimit] = useState("0")
const [creditDays, setCreditDays] = useState("0")
const [errors, setErrors] = useState<Record<string, string>>({})
const [conflict, setConflict] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false)
const [editOpen, setEditOpen] = useState(false)
// Profile activity: this customer's recent sales invoices ("selling").
const [invoices, setInvoices] = useState<SalesInvoiceSummary[] | null>(null)
function applyCustomer(data: Customer) {
setCustomer(data)
setCustomerCode(data.customerCode)
setCustomerType(data.customerType)
setName(data.name)
setDisplayName(data.displayName ?? "")
setPhone(data.phone ?? "")
setEmail(data.email ?? "")
setAddressLine1(data.addressLine1 ?? "")
setAddressLine2(data.addressLine2 ?? "")
setCity(data.city ?? "")
setCountry(data.country ?? "")
setTaxRegistrationNo(data.taxRegistrationNo ?? "")
setCreditLimit(String(data.creditLimit))
setCreditDays(String(data.creditDays))
}
function load() {
setLoadError(null)
customersApi
.get(customerId)
.then(({ data, etag: tag }) => {
applyCustomer(data)
setEtag(tag)
setConflict(false)
})
.catch((err) => setLoadError(errorMessage(err)))
}
useEffect(() => {
if (Number.isFinite(customerId)) load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [customerId])
/** Opens the edit dialog fresh from the last-loaded customer, discarding any unsaved
* edits left over from a previous cancelled session. */
function openEdit() {
if (customer) applyCustomer(customer)
setErrors({})
setSaveError(null)
setEditOpen(true)
}
function closeEdit() {
if (customer) applyCustomer(customer)
setEditOpen(false)
}
useEffect(() => {
if (!Number.isFinite(customerId)) return
salesApi.listInvoices({ customerId, pageSize: 100 }).then((res) => setInvoices(res.items)).catch(() => setInvoices([]))
}, [customerId])
const totalSold = useMemo(() => (invoices ?? []).reduce((sum, i) => sum + i.totals.grandTotal, 0), [invoices])
const outstandingBalance = useMemo(() => (invoices ?? []).reduce((sum, i) => sum + i.totals.balanceAmount, 0), [invoices])
const recentInvoices = useMemo(
() => (invoices ?? []).slice().sort((a, b) => new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime()),
[invoices]
)
const [invoicePage, setInvoicePage] = useState(1)
const [invoicePageSize, setInvoicePageSize] = useState(INVOICE_PAGE_SIZE_OPTIONS[0])
const [mobileInvoiceCount, setMobileInvoiceCount] = useState(INVOICE_PAGE_SIZE_OPTIONS[0])
const invoicePagination = {
page: invoicePage,
pageSize: invoicePageSize,
totalItems: recentInvoices.length,
totalPages: Math.max(1, Math.ceil(recentInvoices.length / invoicePageSize)),
}
const pagedInvoices = recentInvoices.slice((invoicePage - 1) * invoicePageSize, invoicePage * invoicePageSize)
const monthlySales = useMemo(() => {
const byMonth = new Map<string, number>()
for (const inv of invoices ?? []) {
const key = inv.invoiceDate.slice(0, 7)
byMonth.set(key, (byMonth.get(key) ?? 0) + inv.totals.grandTotal)
}
return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-5)
}, [invoices])
// Below `sm`, skip the fixed-slot axis padding so a handful of bars stretch to fill the
// narrow card instead of leaving blank trailing slots — there's less width to spare there.
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const mql = window.matchMedia("(max-width: 639px)")
setIsMobile(mql.matches)
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}, [])
async function handleSave() {
if (!customer || !etag) return
setSaveError(null)
const nextErrors: Record<string, string> = {}
if (!customerCode.trim()) nextErrors.customerCode = "Customer code is required"
if (!name.trim()) nextErrors.name = "Customer name is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSaving(true)
try {
const result = await customersApi.update(
customerId,
{
customerCode,
customerType,
name,
displayName: displayName || null,
phone: phone || null,
email: email || null,
addressLine1: addressLine1 || null,
addressLine2: addressLine2 || null,
city: city || null,
country: country || null,
taxRegistrationNo: taxRegistrationNo || null,
creditLimit: Number(creditLimit) || 0,
creditDays: Number(creditDays) || 0,
defaultWarehouseId: customer.defaultWarehouseId,
branchId: customer.branchId,
},
etag
)
applyCustomer(result.data)
setEtag(result.etag)
setEditOpen(false)
toast.success("Customer saved", `${result.data.customerCode} — ${result.data.name}`)
} catch (err) {
const code = (err as { code?: string })?.code
if (code === "CONCURRENCY_CONFLICT") {
setConflict(true)
setSaveError(errorMessage(err))
setSaving(false)
setEditOpen(false)
return
}
const fe = fieldErrors(err)
if (fe?.customerCode) setErrors({ customerCode: fe.customerCode })
setSaveError(errorMessage(err))
toast.error("Could not save customer", errorMessage(err))
} finally {
setSaving(false)
}
}
async function handleToggleStatus() {
if (!customer) return
const next = customer.status === "Active" ? "Inactive" : "Active"
setTogglingStatus(true)
try {
await customersApi.updateStatus(customerId, next)
toast.success(next === "Active" ? "Customer activated" : "Customer deactivated")
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setTogglingStatus(false)
}
}
if (loadError && !customer) {
return (
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
<Link href="/dashboard/customers" className={cn(buttonVariants({ variant: "outline" }))}>
Back to customers
</Link>
</div>
)
}
if (!customer) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
return (
<div className="flex flex-col gap-6">
<Link
href="/dashboard/customers"
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" />
Back to Customers
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<Users className="size-6 text-primary" />
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{customer.customerCode}</h1>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
customer.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{customer.status}
</Badge>
</div>
<p className="text-base text-muted-foreground">{customer.displayName ?? customer.name}</p>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button size="lg" variant="outline" className="w-full sm:w-auto" onClick={openEdit}>
<Pencil className="size-4" />
Edit
</Button>
<Button
size="lg"
variant={customer.status === "Active" ? "destructive" : "success"}
className="w-full sm:w-auto"
onClick={handleToggleStatus}
disabled={togglingStatus}
>
{togglingStatus ? "Updating…" : customer.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatChip icon={BadgeDollarSign} label="Total sold" value={invoices === null ? "…" : `LKR ${totalSold.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatChip icon={Receipt} label="Invoices" value={invoices === null ? "…" : String(invoices.length)} />
<StatChip
icon={AlertTriangle}
label="Outstanding balance"
value={invoices === null ? "…" : `LKR ${outstandingBalance.toLocaleString(undefined, { maximumFractionDigits: 0 })}`}
/>
</div>
{conflict && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" />
<div className="flex flex-col gap-2">
<p>{saveError ?? "This customer was changed by someone else."} Reload before retrying.</p>
<Button size="sm" variant="outline" onClick={load}>
Reload
</Button>
</div>
</div>
)}
{saveError && !conflict && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Monthly sales</h2>
{invoices === null ? (
<Skeleton className="h-85 w-full" />
) : monthlySales.length > 0 ? (
<BarChart
// Padded to a fixed 5-slot axis on larger screens: with fewer than 5 months of
// activity, Chart.js would otherwise center the lone category across the full
// width. Trailing blank slots keep the axis/gridlines spanning the full card
// while the real bars sit at the start. Skipped on mobile — there the bars
// should stretch to fill the narrower card instead.
labels={[
...monthlySales.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlySales.length)).fill("")),
]}
datasets={[
{
label: "Sold (LKR)",
data: [
...monthlySales.map(([, v]) => v),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlySales.length)).fill(null)),
],
backgroundColor: "rgba(239,68,68,0.6)",
},
]}
barPercentage={0.6}
categoryPercentage={0.6}
height={340}
/>
) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No sales recorded yet.</p>
)}
</div>
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Recent sales invoices</h2>
{invoices === 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: 4 }).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" />
</div>
))}
</div>
) : recentInvoices.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No sales to this customer yet.</p>
) : (
<>
{/* Mobile: stacked cards, "Show more" instead of pagination. */}
<div className="flex flex-col gap-2 md:hidden">
{recentInvoices.slice(0, mobileInvoiceCount).map((inv) => (
<Link
key={inv.salesInvoiceId}
href={`/dashboard/sales/invoices/${inv.salesInvoiceId}`}
className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm hover:bg-muted/20"
>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-foreground">{inv.invoiceNo}</span>
<span className="shrink-0 text-xs text-muted-foreground">{new Date(inv.invoiceDate).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-muted px-2 text-xs text-muted-foreground">
{inv.status}
</Badge>
<span className="font-medium text-foreground">LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</Link>
))}
{mobileInvoiceCount < recentInvoices.length && (
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={() => setMobileInvoiceCount((c) => c + INVOICE_PAGE_SIZE_OPTIONS[0])}
>
Show more
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Date</TableHead>
<TableHead>Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedInvoices.map((inv) => (
<TableRow key={inv.salesInvoiceId} className="align-top hover:bg-muted/20">
<TableCell className="text-sm text-muted-foreground">{new Date(inv.invoiceDate).toLocaleDateString()}</TableCell>
<TableCell>
<Link href={`/dashboard/sales/invoices/${inv.salesInvoiceId}`} className="font-semibold text-foreground hover:underline">
{inv.invoiceNo}
</Link>
</TableCell>
<TableCell>
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-muted px-2.5 text-sm text-muted-foreground">
{inv.status}
</Badge>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">
LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={invoicePagination}
onPageChange={setInvoicePage}
pageSizeOptions={INVOICE_PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setInvoicePage(1); setInvoicePageSize(size) }}
/>
</div>
</>
)}
</div>
</div>
<Dialog open={editOpen} onOpenChange={(open) => (open ? openEdit() : closeEdit())}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Edit {customer.customerCode}</DialogTitle>
<DialogDescription>Update this customer&apos;s details.</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Code</Label>
<Input value={customerCode} onChange={(e) => setCustomerCode(e.target.value)} aria-invalid={!!errors.customerCode} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.customerCode ? { message: errors.customerCode } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Type</Label>
<Select<CustomerType> value={customerType} onValueChange={(v) => v && setCustomerType(v)} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="B2C" className="text-base">B2C — individual</SelectItem>
<SelectItem value="B2B" className="text-base">B2B — business</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Display name (optional)</Label>
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Phone</Label>
<Input value={phone} onChange={(e) => setPhone(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Email</Label>
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Address line 1</Label>
<Input value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Address line 2</Label>
<Input value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">City</Label>
<Input value={city} onChange={(e) => setCity(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Country</Label>
<Input value={country} onChange={(e) => setCountry(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax registration</Label>
<Input value={taxRegistrationNo} onChange={(e) => setTaxRegistrationNo(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Credit limit (LKR)</Label>
<Input type="number" min="0" step="0.01" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Credit days</Label>
<Input type="number" min="0" step="1" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
</div>
<DialogFooter>
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
Cancel
</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,548 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Eye, Pencil, Plus, Search, Trash2, Users } from "lucide-react"
import { customersApi } from "@/lib/api/customers"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { generateCustomerCode } from "@/lib/customer-code"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Customer, CustomerType } from "@/types/customers"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
type StatusFilter = EntityStatus | "All"
type TypeFilter = CustomerType | "All"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function CustomersPage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const [customers, setCustomers] = useState<Customer[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [customerType, setCustomerType] = useState<TypeFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [newCustomerType, setNewCustomerType] = useState<CustomerType>("B2C")
const [phone, setPhone] = useState("")
const [email, setEmail] = useState("")
const [creditLimit, setCreditLimit] = useState("")
const [creditDays, setCreditDays] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Every other customer's code, to de-dupe against when the name edit regenerates this
// customer's own code (mirrors the vendor create dialog).
const [allCustomerCodes, setAllCustomerCodes] = useState<string[]>([])
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const generatedCode = name.trim() ? generateCustomerCode(name, allCustomerCodes) : ""
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, customerType])
function load() {
setError(null)
customersApi
.list({
q: query || undefined,
status: status === "All" ? undefined : status,
customerType: customerType === "All" ? undefined : customerType,
page,
pageSize,
})
.then((res) => {
setCustomers(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [query, status, customerType, page, pageSize])
useEffect(() => {
customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
}, [])
// Admin-only branch picker — non-admins never see this control, their own employee
// branch is used directly (mirrors the New GRN / New Sale pattern).
useEffect(() => {
if (branchingEnabled && isAdmin) {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}
}, [branchingEnabled, isAdmin])
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function resetForm() {
setName("")
setNewCustomerType("B2C")
setPhone("")
setEmail("")
setCreditLimit("")
setCreditDays("")
setBranchFilterId(null)
setErrors({})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Customer name is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await customersApi.create({
customerCode: generatedCode,
customerType: newCustomerType,
name,
phone: phone || null,
email: email || null,
creditLimit: Number(creditLimit) || 0,
creditDays: Number(creditDays) || 0,
branchId: effectiveBranchId ?? 0,
})
toast.success("Customer created", `${result.data.customerCode} — ${result.data.name}`)
setOpen(false)
resetForm()
load()
setAllCustomerCodes((codes) => [...codes, result.data.customerCode])
} catch (err) {
toast.error("Could not create customer", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** FR-MD-08: masters are deactivated, never hard-deleted — "Delete" in the UI maps to
* PATCH status → Inactive, same mechanism as the profile page's toggle. */
async function handleDeactivate(customer: Customer) {
setActionPendingId(customer.customerId)
try {
await customersApi.updateStatus(customer.customerId, "Inactive")
toast.success("Customer deleted", `${customer.customerCode} has been deactivated.`)
load()
} catch (err) {
toast.error("Could not delete customer", errorMessage(err))
} finally {
setActionPendingId(null)
}
}
const hasFilters = query.length > 0 || status !== "All" || customerType !== "All"
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Customers</h1>
<p className="text-base text-muted-foreground">Customer master data used by sales invoices, slips and bundles.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="lg" onClick={resetForm}>
<Plus className="size-5" />
New Customer
</Button>
}
/>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New customer</DialogTitle>
<DialogDescription>Its code is generated from the name — everything else can be filled in later from its profile.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="c-name">Name</FieldLabel>
<Input id="c-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Nimal Perera" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="c-code">Code (auto-generated)</FieldLabel>
<Input id="c-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
<Field>
<FieldLabel htmlFor="c-type">Customer type</FieldLabel>
<Select<CustomerType> value={newCustomerType} onValueChange={(v) => v && setNewCustomerType(v)}>
<SelectTrigger id="c-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="B2C">B2C — individual</SelectItem>
<SelectItem value="B2B">B2B — business</SelectItem>
</SelectContent>
</Select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel>
<Input id="c-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
</Field>
<Field>
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
<Input id="c-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
<Input id="c-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="c-credit-days">Credit days</FieldLabel>
<Input id="c-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
</Field>
</div>
{showBranchPicker && (
<Field data-invalid={!!errors.branch}>
<FieldLabel htmlFor="c-branch">Branch</FieldLabel>
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger id="c-branch" className="w-full" aria-invalid={!!errors.branch}>
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.branch ? { message: errors.branch } : undefined]} />
</Field>
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search code or name…"
className="h-14 w-full pl-11 text-base"
aria-label="Search customers"
/>
</div>
<Select<TypeFilter> value={customerType} onValueChange={(v) => setCustomerType(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All types</SelectItem>
<SelectItem value="B2C" className="text-base">B2C</SelectItem>
<SelectItem value="B2B" className="text-base">B2B</SelectItem>
</SelectContent>
</Select>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</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 && customers === 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: 4 }).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 && customers !== null && customers.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Users className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No customers match your search/filter." : "No customers yet."}
</p>
</div>
)}
{!error && customers !== null && customers.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{customers.map((c) => (
<div
key={c.customerId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/customers/${c.customerId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/customers/${c.customerId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{c.customerCode}</p>
<p className="text-sm text-muted-foreground">{c.displayName ?? c.name}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 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>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Type</p>
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center text-xs">{c.customerType}</Badge>
</div>
<div>
<p className="text-muted-foreground">Phone</p>
<p className="font-medium text-foreground">{c.phone ?? "—"}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Credit limit</p>
<p className="font-medium text-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/customers/${c.customerId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${c.customerCode}`}
title="Edit"
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.customerCode}`}
disabled={c.status === "Inactive" || actionPendingId === c.customerId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.customerCode}?`}
description={`This deactivates ${c.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its profile.`}
confirmLabel="Delete"
onConfirm={() => handleDeactivate(c)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && customers.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-32">Code</TableHead>
<TableHead className="w-48">Name</TableHead>
<TableHead className="w-28">Type</TableHead>
<TableHead className="w-36">Phone</TableHead>
<TableHead className="w-36">Credit limit</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-24 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customers.map((c) => (
<TableRow
key={c.customerId}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/customers/${c.customerId}`)
}}
>
<TableCell>
<Link href={`/dashboard/customers/${c.customerId}`} className="font-semibold text-foreground hover:underline">
{c.customerCode}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{c.displayName ?? c.name}</TableCell>
<TableCell>
<Badge variant="outline">{c.customerType}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{c.phone ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{c.status}
</Badge>
</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/customers/${c.customerId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${c.customerCode}`}
title="View"
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/customers/${c.customerId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${c.customerCode}`}
title="Edit"
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.customerCode}`}
disabled={c.status === "Inactive" || actionPendingId === c.customerId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.customerCode}?`}
description={`This deactivates ${c.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its profile.`}
confirmLabel="Delete"
onConfirm={() => handleDeactivate(c)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)}
</div>
)
}
+163 -105
View File
@@ -4,9 +4,8 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { import {
CheckCircle2, CheckCircle2,
ChevronLeft,
ChevronRight,
Clock, Clock,
Eye,
HelpCircle, HelpCircle,
Inbox, Inbox,
Plus, Plus,
@@ -21,7 +20,7 @@ import { SupportTicketSummary } from "@/types/support"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
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 { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -33,12 +32,16 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card" 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_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size
// client-side, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const statusStyles: Record<string, string> = { const statusStyles: Record<string, string> = {
Open: "border-transparent bg-primary/10 text-primary", Open: "border-transparent bg-primary/10 text-primary",
@@ -61,6 +64,7 @@ export default function HelpPage() {
const [pageSize, setPageSize] = useState(5) const [pageSize, setPageSize] = useState(5)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<SupportTicketSummary["status"] | null>(null)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [subject, setSubject] = useState("") const [subject, setSubject] = useState("")
@@ -85,6 +89,12 @@ export default function HelpPage() {
return () => clearTimeout(t) return () => clearTimeout(t)
}, [search]) }, [search])
useEffect(() => setPage(1), [statusFilter])
function toggleStatusFilter(status: SupportTicketSummary["status"]) {
setStatusFilter((s) => (s === status ? null : status))
}
const stats = useMemo(() => { const stats = useMemo(() => {
if (!tickets) return null if (!tickets) return null
return { return {
@@ -98,11 +108,12 @@ export default function HelpPage() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!tickets) return [] if (!tickets) return []
const term = debouncedSearch.toLowerCase() const term = debouncedSearch.toLowerCase()
if (!term) return tickets
return tickets.filter( return tickets.filter(
(t) => t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term) (t) =>
(!statusFilter || t.status === statusFilter) &&
(!term || t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term))
) )
}, [tickets, debouncedSearch]) }, [tickets, debouncedSearch, statusFilter])
const totalItems = filtered.length const totalItems = filtered.length
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)) const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
@@ -205,16 +216,40 @@ export default function HelpPage() {
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="flex flex-wrap gap-2">
{stats ? ( {stats ? (
<> <>
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} /> <StatCard
<StatCard label="Open" value={stats.open} icon={Inbox} /> label="Total tickets"
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} /> value={stats.total}
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} /> icon={HelpCircle}
active={statusFilter === null}
onClick={() => setStatusFilter(null)}
/>
<StatCard
label="Open"
value={stats.open}
icon={Inbox}
active={statusFilter === "Open"}
onClick={() => toggleStatusFilter("Open")}
/>
<StatCard
label="In Progress"
value={stats.inProgress}
icon={Clock}
active={statusFilter === "InProgress"}
onClick={() => toggleStatusFilter("InProgress")}
/>
<StatCard
label="Resolved"
value={stats.resolved}
icon={CheckCircle2}
active={statusFilter === "Resolved"}
onClick={() => toggleStatusFilter("Resolved")}
/>
</> </>
) : ( ) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />) Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
)} )}
</div> </div>
@@ -262,15 +297,17 @@ export default function HelpPage() {
</div> </div>
)} )}
{!error && tickets !== null && pageItems.length === 0 && debouncedSearch && ( {!error && tickets !== null && pageItems.length === 0 && tickets.length > 0 && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center"> <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" /> <Search className="size-10 text-muted-foreground" />
<div> <div>
<p className="text-base font-medium text-foreground">No tickets match &quot;{debouncedSearch}&quot;</p> <p className="text-base font-medium text-foreground">
<p className="text-sm text-muted-foreground">Try a different subject or name.</p> {debouncedSearch ? <>No tickets match &quot;{debouncedSearch}&quot;</> : `No ${statusLabels[statusFilter ?? ""] ?? ""} tickets`}
</p>
<p className="text-sm text-muted-foreground">Try a different subject/name, or clear the status filter.</p>
</div> </div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}> <Button variant="outline" size="sm" onClick={() => { setSearch(""); setStatusFilter(null) }}>
Clear search Clear filters
</Button> </Button>
</div> </div>
)} )}
@@ -288,98 +325,119 @@ export default function HelpPage() {
)} )}
{!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && ( {!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {pageItems.map((t) => (
<TableRow className="hover:bg-transparent"> <div key={t.ticketId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 px-3 text-sm">Subject</TableHead> <div className="flex items-start justify-between gap-2">
{isAdmin && <TableHead className="h-11 w-40 px-3 text-sm">Submitted By</TableHead>} <p className="min-w-0 truncate font-semibold text-foreground">{t.subject}</p>
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead> <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center bg-white px-2.5 text-sm", statusStyles[t.status])}>
<TableHead className="h-11 w-24 px-3 text-sm">Messages</TableHead> {statusLabels[t.status]}
<TableHead className="h-11 w-40 px-3 text-sm">Last Updated</TableHead> </Badge>
</TableRow> </div>
</TableHeader> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<TableBody> {isAdmin && (
{pageItems.map((t) => ( <div>
<TableRow key={t.ticketId} className="group align-top hover:bg-muted/20"> <p className="text-muted-foreground">Submitted By</p>
<TableCell className="px-3 py-4"> <p className="font-medium text-foreground">{t.createdByName}</p>
<Link </div>
href={`/dashboard/help/tickets/${t.ticketId}`} )}
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" <div>
> <p className="text-muted-foreground">Messages</p>
{t.subject} <p className="font-medium text-foreground">{t.messageCount}</p>
</Link> </div>
</TableCell> <div>
{isAdmin && <TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.createdByName}</TableCell>} <p className="text-muted-foreground">Last Updated</p>
<TableCell className="px-3 py-4"> <p className="font-medium text-foreground">
<Badge variant="outline" className={cn("h-5 w-fit justify-center bg-white px-2 text-xs", statusStyles[t.status])}> {new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
{statusLabels[t.status]} </p>
</Badge> </div>
</TableCell> </div>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.messageCount}</TableCell> <div className="flex items-center justify-end border-t pt-3">
<TableCell className="px-3 py-4 text-sm text-muted-foreground"> <Link href={`/dashboard/help/tickets/${t.ticketId}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, { <Eye className="size-4" />
year: "numeric", View
month: "short", </Link>
day: "numeric", </div>
})} </div>
</TableCell> ))}
</TableRow>
))} {totalItems > pageItems.length && (
</TableBody> <Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
</Table> Show more
</Button>
)}
</div> </div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Subject</TableHead>
{isAdmin && <TableHead className="h-11 w-40 px-3 text-sm">Submitted By</TableHead>}
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-sm">Messages</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Last Updated</TableHead>
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((t) => (
<TableRow key={t.ticketId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<Link
href={`/dashboard/help/tickets/${t.ticketId}`}
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"
>
{t.subject}
</Link>
</TableCell>
{isAdmin && <TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.createdByName}</TableCell>}
<TableCell className="px-3 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center bg-white px-2 text-xs", statusStyles[t.status])}>
{statusLabels[t.status]}
</Badge>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.messageCount}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</TableCell>
<TableCell className="px-3 py-4">
<div className="flex items-center justify-center">
<Link
href={`/dashboard/help/tickets/${t.ticketId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ticket ${t.subject}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{totalItems > 0 && ( {totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={{ page, pageSize, totalItems, totalPages }}
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground">{Math.min(page * pageSize, totalItems)}</span>{" "} pageSizeOptions={PAGE_SIZE_OPTIONS}
of <span className="font-medium text-foreground">{totalItems}</span> tickets onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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={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 {page} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -123,7 +123,7 @@ export default function SupportTicketDetailPage() {
{isAdmin ? ( {isAdmin ? (
<Select value={ticket.status} onValueChange={(v) => handleStatusChange(v as TicketStatus)} disabled={updatingStatus}> <Select value={ticket.status} onValueChange={(v) => handleStatusChange(v as TicketStatus)} disabled={updatingStatus}>
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger> <SelectTrigger className="w-full sm:w-44"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Open">Open</SelectItem> <SelectItem value="Open">Open</SelectItem>
<SelectItem value="InProgress">In Progress</SelectItem> <SelectItem value="InProgress">In Progress</SelectItem>
@@ -141,9 +141,9 @@ export default function SupportTicketDetailPage() {
{ticket.messages.map((m) => ( {ticket.messages.map((m) => (
<div <div
key={m.messageId} key={m.messageId}
className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-8 bg-primary/5" : "mr-8 bg-card"}`} className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-4 bg-primary/5 sm:ml-8" : "mr-4 bg-card sm:mr-8"}`}
> >
<div className="mb-1 flex items-center justify-between gap-2"> <div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
{m.senderName} {m.isFromAdmin && <span className="text-primary">(Support)</span>} {m.senderName} {m.isFromAdmin && <span className="text-primary">(Support)</span>}
</span> </span>
@@ -162,7 +162,7 @@ export default function SupportTicketDetailPage() {
placeholder={isAdmin ? "Reply to this ticket…" : "Add more details…"} placeholder={isAdmin ? "Reply to this ticket…" : "Add more details…"}
rows={3} rows={3}
/> />
<Button className="w-fit" onClick={handleReply} disabled={sending || !reply.trim()}> <Button className="w-full sm:w-fit" onClick={handleReply} disabled={sending || !reply.trim()}>
{sending ? "Sending…" : "Send"} {sending ? "Sending…" : "Send"}
</Button> </Button>
</div> </div>
@@ -117,7 +117,7 @@ export default function AttendanceBatchDetailPage() {
<h1 className="text-2xl font-bold text-foreground">{batch.docNo}</h1> <h1 className="text-2xl font-bold text-foreground">{batch.docNo}</h1>
<p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p> <p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Badge variant="outline" className="h-7 px-3 text-sm">{batch.status}</Badge> <Badge variant="outline" className="h-7 px-3 text-sm">{batch.status}</Badge>
{batch.status === "Draft" && <Button onClick={validate} disabled={busy || unresolved.length > 0}>Validate</Button>} {batch.status === "Draft" && <Button onClick={validate} disabled={busy || unresolved.length > 0}>Validate</Button>}
{batch.status === "Validated" && <Button onClick={confirm} disabled={busy}>Confirm</Button>} {batch.status === "Validated" && <Button onClick={confirm} disabled={busy}>Confirm</Button>}
@@ -148,47 +148,95 @@ export default function AttendanceBatchDetailPage() {
</div> </div>
)} )}
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {records.map((r) => (
<TableHead className="h-12 px-3 text-sm">Employee</TableHead> <div key={r.attendanceRecordId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-12 px-3 text-sm">Date</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-12 px-3 text-sm">In / Out</TableHead> <div className="min-w-0">
<TableHead className="h-12 px-3 text-sm">Working Hours</TableHead> <p className="truncate font-semibold text-foreground">{r.employeeName ?? r.employeeCode ?? "Unknown"}</p>
<TableHead className="h-12 px-3 text-sm">Late</TableHead> <p className="text-sm text-muted-foreground">{new Date(r.attendanceDate).toLocaleDateString()}</p>
<TableHead className="h-12 px-3 text-sm">OT</TableHead> </div>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
<TableHead className="h-12 px-3 text-sm">Row</TableHead> </div>
{!locked && <TableHead className="h-12 px-3 text-sm">Resolve</TableHead>} <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
</TableRow> <div>
</TableHeader> <p className="text-muted-foreground">In / Out</p>
<TableBody> <p className="font-medium text-foreground">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</p>
{records.map((r) => ( </div>
<TableRow key={r.attendanceRecordId}> <div>
<TableCell className="px-3 py-3.5">{r.employeeName ?? r.employeeCode ?? "Unknown"}</TableCell> <p className="text-muted-foreground">Working Hours</p>
<TableCell className="px-3 py-3.5">{new Date(r.attendanceDate).toLocaleDateString()}</TableCell> <p className="font-medium text-foreground">{minutesLabel(r.workingMinutes)}</p>
<TableCell className="px-3 py-3.5">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</TableCell> </div>
<TableCell className="px-3 py-3.5">{minutesLabel(r.workingMinutes)}</TableCell> <div>
<TableCell className="px-3 py-3.5">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</TableCell> <p className="text-muted-foreground">Late</p>
<TableCell className="px-3 py-3.5">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</TableCell> <p className="font-medium text-foreground">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</p>
<TableCell className="px-3 py-3.5">{r.attendanceStatus}</TableCell> </div>
<TableCell className="px-3 py-3.5"> <div>
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge> <p className="text-muted-foreground">OT</p>
</TableCell> <p className="font-medium text-foreground">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</p>
{!locked && ( </div>
<TableCell className="px-3 py-3.5"> <div className="col-span-2">
{r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && ( <p className="text-muted-foreground">Row status</p>
<div className="flex gap-1"> <p className="font-medium text-foreground">{r.attendanceStatus}</p>
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button> </div>
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button> </div>
</div> {!locked && r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
<div className="flex gap-2 border-t pt-3">
<Button variant="outline" size="sm" className="flex-1" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
</div>
)}
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Employee</TableHead>
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="h-12 px-3 text-sm">In / Out</TableHead>
<TableHead className="h-12 px-3 text-sm">Working Hours</TableHead>
<TableHead className="h-12 px-3 text-sm">Late</TableHead>
<TableHead className="h-12 px-3 text-sm">OT</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Row</TableHead>
{!locked && <TableHead className="h-12 px-3 text-sm">Resolve</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.attendanceRecordId}>
<TableCell className="px-3 py-3.5">{r.employeeName ?? r.employeeCode ?? "Unknown"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.attendanceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{minutesLabel(r.workingMinutes)}</TableCell>
<TableCell className="px-3 py-3.5">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</TableCell>
<TableCell className="px-3 py-3.5">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</TableCell>
<TableCell className="px-3 py-3.5">{r.attendanceStatus}</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", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
</TableCell>
{!locked && (
<TableCell className="px-3 py-3.5">
{r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
<Button variant="ghost" size="sm" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
</div>
)}
</TableCell>
)} )}
</TableCell> </TableRow>
)} ))}
</TableRow> </TableBody>
))} </Table>
</TableBody> </div>
</Table> </div>
</div> </div>
) )
} }
@@ -1,8 +1,8 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import { useRouter } from "next/navigation"
import { Download, Eye, Plus, Upload } from "lucide-react" import { Download, Plus, Upload } from "lucide-react"
import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance" import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -15,11 +15,15 @@ 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
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 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",
@@ -29,10 +33,13 @@ const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
} }
export default function AttendanceBatchesPage() { export default function AttendanceBatchesPage() {
const router = useRouter()
const [batches, setBatches] = useState<AttendanceUploadBatch[] | null>(null) const [batches, setBatches] = useState<AttendanceUploadBatch[] | 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(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [periodStart, setPeriodStart] = useState("") const [periodStart, setPeriodStart] = useState("")
@@ -42,11 +49,17 @@ export default function AttendanceBatchesPage() {
function load() { function load() {
attendanceApi attendanceApi
.list({ page, pageSize: PAGE_SIZE }) .list({ page, pageSize })
.then((res) => { setBatches(res.items); setPagination(res.pagination) }) .then((res) => { setBatches(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [page])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize])
async function handleUpload() { async function handleUpload() {
if (!file) { toast.error("Choose an Excel or CSV file first"); return } if (!file) { toast.error("Choose an Excel or CSV file first"); return }
@@ -74,12 +87,12 @@ export default function AttendanceBatchesPage() {
<h1 className="text-2xl font-bold text-foreground">Attendance</h1> <h1 className="text-2xl font-bold text-foreground">Attendance</h1>
<p className="text-base text-muted-foreground">Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll&apos;s source of truth.</p> <p className="text-base text-muted-foreground">Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll&apos;s source of truth.</p>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-col gap-2 sm:flex-row">
<a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }))}> <a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
<Download className="size-4" />Download template <Download className="size-4" />Download template
</a> </a>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />Upload</Button>} /> <DialogTrigger render={<Button size="lg" className="w-full sm:w-auto"><Plus className="size-5" />Upload</Button>} />
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>Upload Attendance</DialogTitle> <DialogTitle>Upload Attendance</DialogTitle>
@@ -110,41 +123,83 @@ export default function AttendanceBatchesPage() {
<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 attendance batches yet.</p></div> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No attendance batches yet.</p></div>
)} )}
{!error && batches !== null && batches.length > 0 && ( {!error && batches !== null && batches.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
<TableHead className="h-12 px-3 text-sm">Rows</TableHead>
<TableHead className="h-12 px-3 text-sm">Duplicates/Errors</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{batches.map((b) => ( {batches.map((b) => (
<TableRow key={b.attendanceUploadBatchId}> <div
<TableCell className="px-3 py-3.5 font-medium">{b.docNo}</TableCell> key={b.attendanceUploadBatchId}
<TableCell className="px-3 py-3.5">{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}</TableCell> role="link"
<TableCell className="px-3 py-3.5">{b.rowCountTotal}</TableCell> tabIndex={0}
<TableCell className="px-3 py-3.5">{b.rowCountDuplicate} / {b.rowCountError}</TableCell> onClick={() => router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`)}
<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[b.status])}>{b.status}</Badge></TableCell> onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`) }}
<TableCell className="px-3 py-3.5"> className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
<Link href={`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label="View"><Eye className="size-4" /></Link> >
</TableCell> <div className="flex items-start justify-between gap-2">
</TableRow> <div className="min-w-0">
<p className="truncate font-semibold text-foreground">{b.docNo}</p>
<p className="text-sm text-muted-foreground">
{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}
</p>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Rows</p>
<p className="font-medium text-foreground">{b.rowCountTotal}</p>
</div>
<div>
<p className="text-muted-foreground">Duplicates/Errors</p>
<p className="font-medium text-foreground">{b.rowCountDuplicate} / {b.rowCountError}</p>
</div>
</div>
</div>
))} ))}
</TableBody>
</Table> {pagination && batches.length < pagination.totalItems && (
)} <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{pagination && pagination.totalPages > 1 && ( {showMoreLoading ? "Loading…" : "Show more"}
<div className="flex items-center justify-between"> </Button>
<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>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
<TableHead className="h-12 px-3 text-sm">Rows</TableHead>
<TableHead className="h-12 px-3 text-sm">Duplicates/Errors</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{batches.map((b) => (
<TableRow
key={b.attendanceUploadBatchId}
className="cursor-pointer hover:bg-muted/40"
onClick={() => router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`)}
>
<TableCell className="px-3 py-3.5 font-medium">{b.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountTotal}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountDuplicate} / {b.rowCountError}</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[b.status])}>{b.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
{pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
</div> </div>
)} )}
</div> </div>
@@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import { useParams, useSearchParams } from "next/navigation" import { useParams, useSearchParams } from "next/navigation"
import { Briefcase, Download, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react" import { Briefcase, Download, Link2, Plus, ShieldCheck, Upload, User, X } from "lucide-react"
@@ -44,6 +44,14 @@ function money(n: number): string {
} }
export default function EmployeeDetailPage() { export default function EmployeeDetailPage() {
return (
<Suspense>
<EmployeeDetailPageContent />
</Suspense>
)
}
function EmployeeDetailPageContent() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const employeeId = Number(params.id) const employeeId = Number(params.id)
@@ -327,7 +335,7 @@ export default function EmployeeDetailPage() {
<button <button
key={t} key={t}
onClick={() => setTab(t)} onClick={() => setTab(t)}
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")} className={cn("shrink-0 whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition-colors sm:flex-1", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
> >
{t} {t}
</button> </button>
@@ -428,12 +436,12 @@ export default function EmployeeDetailPage() {
</section> </section>
{isEditing && ( {isEditing && (
<div className="flex justify-end gap-2"> <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleCancelEdit} disabled={saving}> <Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={saving}>
<X className="size-4" /> <X className="size-4" />
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button> <Button className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
</div> </div>
)} )}
</div> </div>
@@ -452,12 +460,12 @@ export default function EmployeeDetailPage() {
</section> </section>
{isEditing && ( {isEditing && (
<div className="flex justify-end gap-2"> <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleCancelEdit} disabled={savingBank}> <Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={savingBank}>
<X className="size-4" /> <X className="size-4" />
Cancel Cancel
</Button> </Button>
<Button onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button> <Button className="w-full sm:w-auto" onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button>
</div> </div>
)} )}
</div> </div>
@@ -485,7 +493,7 @@ export default function EmployeeDetailPage() {
</Field> </Field>
<label <label
className={cn( className={cn(
"inline-flex h-9 w-fit items-center gap-2 rounded-lg border border-black px-4 text-sm font-medium", "inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-black px-4 text-sm font-medium sm:w-fit",
uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60" uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60"
)} )}
title={uploadTypeId ? undefined : "Select a document type first"} title={uploadTypeId ? undefined : "Select a document type first"}
@@ -502,65 +510,105 @@ export default function EmployeeDetailPage() {
</label> </label>
</div> </div>
</section> </section>
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10"> {documents.length === 0 ? (
<div className="overflow-x-auto"> <div className="rounded-2xl bg-card p-10 text-center text-muted-foreground shadow-sm ring-1 ring-foreground/10">No documents uploaded yet.</div>
<Table className="text-base"> ) : (
<TableHeader> <>
<TableRow className="hover:bg-transparent"> {/* Mobile: stacked cards. */}
<TableHead className="h-11 px-4 text-sm">File</TableHead> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.map((d) => ( {documents.map((d) => (
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20"> <div key={d.employeeDocumentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell> <div className="min-w-0">
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell> <p className="truncate font-semibold text-foreground">{d.originalFileName}</p>
<TableCell className="px-4 py-3.5"> <p className="text-sm text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</p>
</div>
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs", "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground" d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)} )}
> >
{d.status} {d.status}
</Badge> </Badge>
</TableCell> </div>
<TableCell className="px-4 py-3.5"> <div className="flex items-center justify-between border-t pt-3 text-sm">
<div className="flex justify-center"> <span className="text-muted-foreground">Uploaded {new Date(d.uploadedAt).toLocaleDateString()}</span>
<a <a
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)} href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
aria-label={`Download ${d.originalFileName}`} aria-label={`Download ${d.originalFileName}`}
title={`Download ${d.originalFileName}`} title={`Download ${d.originalFileName}`}
> >
<Download className="size-4" /> <Download className="size-4" />
</a> </a>
</div> </div>
</TableCell> </div>
</TableRow>
))} ))}
{documents.length === 0 && ( </div>
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
)} {/* Desktop: table. */}
</TableBody> <div className="hidden overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 md:block">
</Table> <div className="overflow-x-auto">
</div> <Table className="text-base">
</div> <TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">File</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Uploaded</TableHead>
<TableHead className="h-11 w-28 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.map((d) => (
<TableRow key={d.employeeDocumentId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium text-foreground">{d.originalFileName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(d.uploadedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
d.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{d.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex justify-center">
<a
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
target="_blank"
rel="noreferrer"
aria-label={`Download ${d.originalFileName}`}
title={`Download ${d.originalFileName}`}
>
<Download className="size-4" />
</a>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
</div> </div>
)} )}
{tab === "Salary & Loans" && ( {tab === "Salary & Loans" && (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10"> <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 flex-wrap items-center justify-between gap-3">
<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() }}>
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Structure</Button>} /> <DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Structure</Button>} />
@@ -644,37 +692,63 @@ export default function EmployeeDetailPage() {
</div> </div>
)} )}
<div className="overflow-hidden rounded-xl border"> {salaryStructures.length === 0 ? (
<div className="overflow-x-auto"> <div className="rounded-xl border p-10 text-center text-muted-foreground">No salary structure set yet.</div>
<Table className="text-base"> ) : (
<TableHeader> <>
<TableRow className="hover:bg-transparent"> {/* Mobile: stacked cards. */}
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-11 px-4 text-sm">Effective to</TableHead>
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salaryStructures.map((s) => ( {salaryStructures.map((s) => (
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20"> <div key={s.employeeSalaryStructureId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell> <div className="flex items-center justify-between">
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell> <span className="font-medium text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</span>
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell> <span className="text-muted-foreground">{s.status}</span>
<TableCell className="px-4 py-3.5">{s.status}</TableCell> </div>
</TableRow> <div className="grid grid-cols-2 gap-2 border-t pt-2">
<div>
<p className="text-muted-foreground">Effective to</p>
<p className="font-medium text-foreground">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</p>
</div>
<div>
<p className="text-muted-foreground">Basic</p>
<p className="font-medium text-foreground">{money(s.basicSalary)}</p>
</div>
</div>
</div>
))} ))}
{salaryStructures.length === 0 && ( </div>
<TableRow><TableCell colSpan={4} className="px-4 py-10 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
)} {/* Desktop: table. */}
</TableBody> <div className="hidden overflow-hidden rounded-xl border md:block">
</Table> <div className="overflow-x-auto">
</div> <Table className="text-base">
</div> <TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">Effective to</TableHead>
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salaryStructures.map((s) => (
<TableRow key={s.employeeSalaryStructureId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</TableCell>
<TableCell className="px-4 py-3.5">{money(s.basicSalary)}</TableCell>
<TableCell className="px-4 py-3.5">{s.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
</section> </section>
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10"> <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 flex-wrap items-center justify-between gap-3">
<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}>
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Loan</Button>} /> <DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Loan</Button>} />
@@ -710,35 +784,61 @@ export default function EmployeeDetailPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
<div className="overflow-hidden rounded-xl border"> {loans.length === 0 ? (
<div className="overflow-x-auto"> <div className="rounded-xl border p-10 text-center text-muted-foreground">No loans or advances on file.</div>
<Table className="text-base"> ) : (
<TableHeader> <>
<TableRow className="hover:bg-transparent"> {/* Mobile: stacked cards. */}
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Principal</TableHead>
<TableHead className="h-11 px-4 text-sm">Outstanding</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loans.map((l) => ( {loans.map((l) => (
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20"> <div key={l.employeeLoanId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell> <div className="flex items-center justify-between">
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell> <span className="font-medium text-foreground">{l.docNo}</span>
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell> <span className="text-muted-foreground">{l.loanKind} · {l.status}</span>
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell> </div>
<TableCell className="px-4 py-3.5">{l.status}</TableCell> <div className="grid grid-cols-2 gap-2 border-t pt-2">
</TableRow> <div>
<p className="text-muted-foreground">Principal</p>
<p className="font-medium text-foreground">{money(l.principalAmount)}</p>
</div>
<div>
<p className="text-muted-foreground">Outstanding</p>
<p className="font-medium text-foreground">{money(l.outstandingBalance)}</p>
</div>
</div>
</div>
))} ))}
{loans.length === 0 && ( </div>
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
)} {/* Desktop: table. */}
</TableBody> <div className="hidden overflow-hidden rounded-xl border md:block">
</Table> <div className="overflow-x-auto">
</div> <Table className="text-base">
</div> <TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Principal</TableHead>
<TableHead className="h-11 px-4 text-sm">Outstanding</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loans.map((l) => (
<TableRow key={l.employeeLoanId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium">{l.docNo}</TableCell>
<TableCell className="px-4 py-3.5">{l.loanKind}</TableCell>
<TableCell className="px-4 py-3.5">{money(l.principalAmount)}</TableCell>
<TableCell className="px-4 py-3.5">{money(l.outstandingBalance)}</TableCell>
<TableCell className="px-4 py-3.5">{l.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
</section> </section>
</div> </div>
)} )}
@@ -339,11 +339,11 @@ export default function NewEmployeePage() {
</section> </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"> <div className="sticky bottom-0 -mx-4 flex flex-col-reverse gap-3 border-t bg-background/95 px-4 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:-mx-6 sm:flex-row sm:justify-end sm:px-6">
<Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }))}> <Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button onClick={handleCreate} disabled={submitting} className="min-w-40"> <Button onClick={handleCreate} disabled={submitting} className="w-full sm:w-auto sm:min-w-40">
<Save className="size-4" /> <Save className="size-4" />
{submitting ? "Creating…" : "Create employee"} {submitting ? "Creating…" : "Create employee"}
</Button> </Button>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react" import { useRouter } from "next/navigation"
import { Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
import { employeesApi } from "@/lib/api/employees" import { employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters" import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters"
@@ -15,8 +16,10 @@ import { useAuth } from "@/components/auth/AuthProvider"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, 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 { FilterBar } from "@/components/ui/filter-bar" import { FilterBar } from "@/components/ui/filter-bar"
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 { StatCard } from "@/components/ui/stat-card"
@@ -26,8 +29,12 @@ import { toast } from "@/components/ui/toast"
const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned", "Terminated", "Retired"] const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned", "Terminated", "Retired"]
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function EmployeesPage() { export default function EmployeesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null) const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
@@ -39,6 +46,7 @@ export default function EmployeesPage() {
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [userAccountChoiceFor, setUserAccountChoiceFor] = useState<EmployeeListItem | null>(null) const [userAccountChoiceFor, setUserAccountChoiceFor] = useState<EmployeeListItem | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null) const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null)
@@ -62,6 +70,12 @@ export default function EmployeesPage() {
}) })
.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)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled, statusFilter, departmentId, designationId]) useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled, statusFilter, departmentId, designationId])
useEffect(() => setPage(1), [statusFilter, departmentId, designationId]) useEffect(() => setPage(1), [statusFilter, departmentId, designationId])
@@ -153,13 +167,31 @@ export default function EmployeesPage() {
</Link> </Link>
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="flex flex-wrap gap-2">
{stats ? ( {stats ? (
<> <>
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} /> <StatCard
<StatCard label="Active" value={stats.active} icon={UserCheck} /> label="Total employees"
value={stats.total}
icon={UsersIcon}
active={statusFilter === undefined}
onClick={() => setStatusFilter(undefined)}
/>
<StatCard
label="Active"
value={stats.active}
icon={UserCheck}
active={statusFilter === "Active"}
onClick={() => setStatusFilter((s) => (s === "Active" ? undefined : "Active"))}
/>
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} /> <StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} /> <StatCard
label="Terminated"
value={stats.terminated}
icon={UserMinus}
active={statusFilter === "Terminated"}
onClick={() => setStatusFilter((s) => (s === "Terminated" ? undefined : "Terminated"))}
/>
</> </>
) : ( ) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />) Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -260,159 +292,240 @@ export default function EmployeesPage() {
)} )}
{!error && employees !== null && employees.length > 0 && ( {!error && employees !== null && employees.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {employees.map((e) => (
<TableRow className="hover:bg-transparent"> <div
<TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead> key={e.employeeId}
<TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead> role="link"
<TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead> tabIndex={0}
<TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead> onClick={(ev) => {
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead> if ((ev.target as HTMLElement).closest("[data-no-row-nav]")) return
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead> router.push(`/dashboard/hrm/employees/${e.employeeId}`)
<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> onKeyDown={(ev) => {
</TableRow> if (ev.key === "Enter") router.push(`/dashboard/hrm/employees/${e.employeeId}`)
</TableHeader> }}
<TableBody> className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
{employees.map((e) => ( >
<TableRow key={e.employeeId} className="group align-top hover:bg-muted/20"> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-4"> <div className="min-w-0">
<Link <p className="truncate font-semibold text-foreground">{e.fullName}</p>
href={`/dashboard/hrm/employees/${e.employeeId}`} <span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
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" </div>
> <Badge
{e.fullName} variant="outline"
</Link> className={cn(
</TableCell> "h-6 w-fit shrink-0 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>
</div>
<TableCell className="px-3 py-4"> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span> <div>
</TableCell> <p className="text-muted-foreground">Department</p>
<p className="font-medium text-foreground">{e.departmentName ?? "—"}</p>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.departmentName ?? "—"}</TableCell> </div>
<div>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{e.designationName ?? "—"}</TableCell> <p className="text-muted-foreground">Designation</p>
<p className="font-medium text-foreground">{e.designationName ?? "—"}</p>
<TableCell className="px-3 py-4 text-sm text-muted-foreground"> </div>
<div>
<p className="text-muted-foreground">Hire Date</p>
<p className="font-medium text-foreground">
{new Date(e.hireDate).toLocaleDateString(undefined, { {new Date(e.hireDate).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
})} })}
</TableCell> </p>
</div>
<div>
<p className="text-muted-foreground">Login</p>
{e.hasUserLink ? (
<Badge variant="outline" className="mt-0.5 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>
) : (
<p className="font-medium text-muted-foreground">No login</p>
)}
</div>
</div>
<TableCell className="px-3 py-4"> <div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
{e.hasUserLink ? ( <Link
<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> href={`/dashboard/hrm/employees/${e.employeeId}`}
) : ( className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
<span className="text-xs text-muted-foreground">No login</span> aria-label={`View ${e.fullName}`}
)} title={`View ${e.fullName}`}
</TableCell> >
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${e.fullName}`}
title={`Edit ${e.fullName}`}
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${e.fullName}`}
title={`Delete ${e.fullName}`}
disabled={actionPendingId === e.employeeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${e.fullName}?`}
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
confirmLabel="Delete"
onConfirm={() => handleDeleteRequest(e)}
/>
</AlertDialog>
</div>
</div>
))}
<TableCell className="px-3 py-4"> {pagination && employees.length < pagination.totalItems && (
<Badge <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
variant="outline" {showMoreLoading ? "Loading…" : "Show more"}
className={cn( </Button>
"h-5 w-fit justify-center bg-white px-2 text-xs", )}
e.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{e.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4">
<div className="flex items-center justify-center gap-1">
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
<Eye className="size-4" />
</Link>
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${e.fullName}`}
title={`Delete ${e.fullName}`}
disabled={actionPendingId === e.employeeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${e.fullName}?`}
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
confirmLabel="Delete"
onConfirm={() => handleDeleteRequest(e)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-48 px-3 text-sm">Employee</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Code</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Department</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Designation</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-32 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employees.map((e) => (
<TableRow
key={e.employeeId}
className="group align-top hover:bg-muted/20 cursor-pointer"
onClick={(ev) => {
if ((ev.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/hrm/employees/${e.employeeId}`)
}}
>
<TableCell className="px-3 py-4" data-no-row-nav>
<Link
href={`/dashboard/hrm/employees/${e.employeeId}`}
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"
>
{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 ? (
<Badge variant="outline" className="h-5 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2 text-xs text-primary"><Link2 className="size-3.5" />Linked</Badge>
) : (
<span className="text-xs text-muted-foreground">No login</span>
)}
</TableCell>
<TableCell className="px-3 py-4">
<Badge
variant="outline"
className={cn(
"h-5 w-fit justify-center bg-white px-2 text-xs",
e.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground"
)}
>
{e.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
<Eye className="size-4" />
</Link>
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${e.fullName}`}
title={`Delete ${e.fullName}`}
disabled={actionPendingId === e.employeeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${e.fullName}?`}
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
confirmLabel="Delete"
onConfirm={() => handleDeleteRequest(e)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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">
<Button
type="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>
)} )}
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Plus, Search, X, XCircle } from "lucide-react" import { CheckCircle2, Clock, PenLine, 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"
@@ -10,7 +10,6 @@ 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 { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@/types/hrm" import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@/types/hrm"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -18,6 +17,7 @@ import { Badge } from "@/components/ui/badge"
import { KpiRow } from "@/components/ui/kpi-row" import { KpiRow } from "@/components/ui/kpi-row"
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"
@@ -25,6 +25,9 @@ import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const STATUSES: LeaveRequestStatus[] = ["Draft", "Submitted", "Approved", "Rejected", "Cancelled"] const STATUSES: LeaveRequestStatus[] = ["Draft", "Submitted", "Approved", "Rejected", "Cancelled"]
const ALL_STATUS = "__all__" const ALL_STATUS = "__all__"
@@ -37,8 +40,8 @@ const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
} }
export default function LeaveRequestsPage() { export default function LeaveRequestsPage() {
const stats = useDashboardStats()
const [requests, setRequests] = useState<LeaveRequest[] | null>(null) const [requests, setRequests] = useState<LeaveRequest[] | null>(null)
const [statusCounts, setStatusCounts] = useState<Record<LeaveRequestStatus, number> | 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)
@@ -47,6 +50,7 @@ export default function LeaveRequestsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch
@@ -76,10 +80,32 @@ export default function LeaveRequestsPage() {
}) })
.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)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
// Counts for the stat cards — one cheap pageSize:1 call per status. Each call falls back
// to 0 on its own so one bad request doesn't blank out the other tiles.
function loadCounts() {
Promise.all(
STATUSES.map((s) =>
leaveRequestsApi
.list({ page: 1, pageSize: 1, status: s })
.then((res) => res.pagination.totalItems)
.catch(() => 0)
)
).then((values) => {
setStatusCounts(Object.fromEntries(STATUSES.map((s, i) => [s, values[i]])) as Record<LeaveRequestStatus, number>)
})
}
useEffect(loadCounts, [])
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
setPage(1) setPage(1)
@@ -102,7 +128,7 @@ export default function LeaveRequestsPage() {
toast.success("Leave request submitted") toast.success("Leave request submitted")
setOpen(false) setOpen(false)
setEmployeeId(""); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setReason("") setEmployeeId(""); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setReason("")
load() load(); loadCounts()
} catch (err) { } catch (err) {
toast.error("Could not submit leave request", errorMessage(err)) toast.error("Could not submit leave request", errorMessage(err))
} finally { } finally {
@@ -115,7 +141,7 @@ export default function LeaveRequestsPage() {
try { try {
await leaveRequestsApi.approve(id) await leaveRequestsApi.approve(id)
toast.success("Leave approved") toast.success("Leave approved")
load() load(); loadCounts()
} catch (err) { } catch (err) {
toast.error("Could not approve", errorMessage(err)) toast.error("Could not approve", errorMessage(err))
} finally { } finally {
@@ -138,7 +164,7 @@ export default function LeaveRequestsPage() {
await leaveRequestsApi.reject(rejectTarget.leaveRequestId, rejectReason.trim()) await leaveRequestsApi.reject(rejectTarget.leaveRequestId, rejectReason.trim())
toast.success("Leave rejected") toast.success("Leave rejected")
setRejectTarget(null) setRejectTarget(null)
load() load(); loadCounts()
} catch (err) { } catch (err) {
toast.error("Could not reject", errorMessage(err)) toast.error("Could not reject", errorMessage(err))
} finally { } finally {
@@ -227,7 +253,49 @@ export default function LeaveRequestsPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<KpiRow tiles={stats ? [{ label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock }] : null} /> <KpiRow
tiles={
statusCounts
? [
{
label: "Draft",
value: statusCounts.Draft,
icon: PenLine,
active: statusFilter === "Draft",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Draft" ? ALL_STATUS : "Draft")) },
},
{
label: "Pending Leave",
value: statusCounts.Submitted,
icon: Clock,
active: statusFilter === "Submitted",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Submitted" ? ALL_STATUS : "Submitted")) },
},
{
label: "Approved",
value: statusCounts.Approved,
icon: CheckCircle2,
active: statusFilter === "Approved",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Approved" ? ALL_STATUS : "Approved")) },
},
{
label: "Rejected",
value: statusCounts.Rejected,
icon: XCircle,
active: statusFilter === "Rejected",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Rejected" ? ALL_STATUS : "Rejected")) },
},
{
label: "Cancelled",
value: statusCounts.Cancelled,
icon: X,
active: statusFilter === "Cancelled",
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Cancelled" ? ALL_STATUS : "Cancelled")) },
},
]
: null
}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4"> <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"> <div className="relative w-full sm:max-w-xs">
@@ -303,124 +371,155 @@ export default function LeaveRequestsPage() {
)} )}
{!error && requests !== null && requests.length > 0 && ( {!error && requests !== null && requests.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {requests.map((r) => (
<TableRow className="hover:bg-transparent"> <div key={r.leaveRequestId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-11 px-4 text-sm">Employee</TableHead> <div className="min-w-0">
<TableHead className="h-11 px-4 text-sm">Type</TableHead> <p className="truncate font-semibold text-foreground">{r.employeeName ?? "—"}</p>
<TableHead className="h-11 px-4 text-sm">Dates</TableHead> <span className="font-mono text-xs text-muted-foreground">{r.docNo}</span>
<TableHead className="h-11 px-4 text-sm">Days</TableHead> </div>
<TableHead className="h-11 px-4 text-sm">Status</TableHead> <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead> {r.status}
</TableRow> </Badge>
</TableHeader> </div>
<TableBody>
{requests.map((r) => ( <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<TableRow key={r.leaveRequestId} className="hover:bg-muted/20"> <div>
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell> <p className="text-muted-foreground">Type</p>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.employeeName ?? "—"}</TableCell> <p className="font-medium text-foreground">{r.leaveTypeName ?? "—"}</p>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.leaveTypeName ?? "—"}</TableCell> </div>
<TableCell className="px-4 py-4 text-sm text-muted-foreground"> <div>
<p className="text-muted-foreground">Days</p>
<p className="font-medium text-foreground">{r.daysCount}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Dates</p>
<p className="font-medium text-foreground">
{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()} {new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
</TableCell> </p>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.daysCount}</TableCell> </div>
<TableCell className="px-4 py-4"> </div>
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
{r.status} {r.status === "Submitted" && (
</Badge> <div className="flex items-center justify-end gap-1 border-t pt-3">
</TableCell> <Button
<TableCell className="px-4 py-4"> type="button"
{r.status === "Submitted" ? ( variant="ghost"
<div className="flex items-center justify-center gap-1"> size="icon-sm"
<Button className="text-success hover:bg-success/10"
type="button" aria-label={`Approve ${r.docNo}`}
variant="ghost" title={`Approve ${r.docNo}`}
size="icon-sm" disabled={actionPendingId === r.leaveRequestId}
className="text-success hover:bg-success/10" onClick={() => approve(r.leaveRequestId)}
aria-label={`Approve ${r.docNo}`} >
title={`Approve ${r.docNo}`} <CheckCircle2 className="size-4" />
disabled={actionPendingId === r.leaveRequestId} </Button>
onClick={() => approve(r.leaveRequestId)} <Button
> type="button"
<CheckCircle2 className="size-4" /> variant="ghost"
</Button> size="icon-sm"
<Button className="text-destructive hover:bg-destructive/10"
type="button" aria-label={`Reject ${r.docNo}`}
variant="ghost" title={`Reject ${r.docNo}`}
size="icon-sm" disabled={actionPendingId === r.leaveRequestId}
className="text-destructive hover:bg-destructive/10" onClick={() => openReject(r)}
aria-label={`Reject ${r.docNo}`} >
title={`Reject ${r.docNo}`} <XCircle className="size-4" />
disabled={actionPendingId === r.leaveRequestId} </Button>
onClick={() => openReject(r)} </div>
> )}
<XCircle className="size-4" /> </div>
</Button> ))}
</div>
) : ( {pagination && requests.length < pagination.totalItems && (
<span className="flex justify-center text-xs text-muted-foreground">—</span> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
)} {showMoreLoading ? "Loading…" : "Show more"}
</TableCell> </Button>
</TableRow> )}
))}
</TableBody>
</Table>
</div> </div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 px-4 text-sm">Dates</TableHead>
<TableHead className="h-11 px-4 text-sm">Days</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((r) => (
<TableRow key={r.leaveRequestId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.employeeName ?? "—"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.leaveTypeName ?? "—"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.daysCount}</TableCell>
<TableCell className="px-4 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
{r.status}
</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>
) : (
<span className="flex justify-center text-xs text-muted-foreground">—</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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">
<Button
type="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> </div>
@@ -110,12 +110,12 @@ export default function PayrollLineDetailPage() {
</div> </div>
<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"> <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">
<div className="flex items-start justify-between gap-4 border-b border-border pb-5"> <div className="flex flex-col gap-4 border-b border-border pb-5 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1> <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> <p className="mt-1 text-base text-muted-foreground">{line.employeeName} <span className="font-mono">({line.employeeCode})</span></p>
</div> </div>
<div className="text-right"> <div className="sm:text-right">
<p className="text-sm text-muted-foreground">Period</p> <p className="text-sm text-muted-foreground">Period</p>
<p className="font-semibold text-foreground">{period}</p> <p className="font-semibold text-foreground">{period}</p>
<p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p> <p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p>
@@ -1,9 +1,8 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import { useParams, useRouter } from "next/navigation"
import { useParams } from "next/navigation" import { BadgeDollarSign, FileText, Receipt, Search, Users, Wallet, X } from "lucide-react"
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"
@@ -15,18 +14,38 @@ 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 { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
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_OPTIONS = [5, 10, 15, 20] const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size
// client-side, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
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 })
} }
/** Small tinted stat chip — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Wallet; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div>
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
)
}
export default function PayrollRunDetailPage() { export default function PayrollRunDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter()
const runId = Number(params.id) const runId = Number(params.id)
const [run, setRun] = useState<PayrollRun | null>(null) const [run, setRun] = useState<PayrollRun | null>(null)
@@ -136,11 +155,11 @@ export default function PayrollRunDetailPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-[repeat(2,minmax(0,16rem))] lg:grid-cols-[repeat(4,minmax(0,16rem))]">
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Gross</p><p className="text-xl font-bold">{money(run.totalGross)}</p></div> <StatChip icon={Wallet} label="Gross" value={money(run.totalGross)} />
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Net</p><p className="text-xl font-bold">{money(run.totalNet)}</p></div> <StatChip icon={BadgeDollarSign} label="Net" value={money(run.totalNet)} />
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Employees</p><p className="text-xl font-bold">{run.employeeCount}</p></div> <StatChip icon={Users} label="Employees" value={String(run.employeeCount)} />
<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> <StatChip icon={Receipt} label="Deductions" value={money(run.totalGross - run.totalNet)} />
</div> </div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
@@ -177,114 +196,146 @@ export default function PayrollRunDetailPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {pagedLines.map((l) => {
<TableRow className="hover:bg-transparent"> const totalDeductions = l.grossSalary - l.netSalary
<TableHead className="h-11 px-4 text-sm">Employee</TableHead> const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
<TableHead className="h-11 px-4 text-sm">Basic</TableHead> return (
<TableHead className="h-11 px-4 text-sm">OT</TableHead> <div
<TableHead className="h-11 px-4 text-sm">Allowances</TableHead> key={l.payrollLineId}
<TableHead className="h-11 px-4 text-sm">Deductions</TableHead> role="link"
<TableHead className="h-11 px-4 text-sm">Net Salary</TableHead> tabIndex={0}
<TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead> onClick={(e) => {
</TableRow> if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
</TableHeader> router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`)
<TableBody> }}
{pagedLines.map((l) => { onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`) }}
const totalDeductions = l.grossSalary - l.netSalary className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId) >
return ( <div className="flex items-start justify-between gap-2">
<TableRow key={l.payrollLineId} className="hover:bg-muted/20"> <div className="min-w-0">
<TableCell className="px-4 py-3.5 font-medium text-foreground"> <p className="truncate font-semibold text-foreground">{l.employeeName}</p>
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span> <span className="font-mono text-xs text-muted-foreground">{l.employeeCode}</span>
</TableCell> </div>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell> {payslip && (
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell> <a
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell> href={payslipViewUrl(payslip.payslipId)}
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell> target="_blank"
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell> rel="noreferrer"
<TableCell className="px-4 py-3.5"> data-no-row-nav
<div className="flex items-center justify-center gap-1"> className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "shrink-0")}
<Link aria-label={`View payslip for ${l.employeeName}`}
href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`} title="View payslip"
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} >
aria-label={`View breakdown for ${l.employeeName}`} <FileText className="size-4" />
title="View breakdown" </a>
> )}
<Eye className="size-4" /> </div>
</Link> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
{payslip && ( <div>
<a <p className="text-muted-foreground">Basic</p>
href={payslipViewUrl(payslip.payslipId)} <p className="font-medium text-foreground">{money(l.basicSalary)}</p>
target="_blank" </div>
rel="noreferrer" <div>
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} <p className="text-muted-foreground">OT</p>
aria-label={`View payslip for ${l.employeeName}`} <p className="font-medium text-foreground">{money(l.overtimeAmount)}</p>
title="View payslip" </div>
> <div>
<FileText className="size-4" /> <p className="text-muted-foreground">Allowances</p>
</a> <p className="font-medium text-foreground">{money(l.totalAllowances)}</p>
)} </div>
</div> <div>
</TableCell> <p className="text-muted-foreground">Deductions</p>
</TableRow> <p className="font-medium text-foreground">{money(totalDeductions)}</p>
) </div>
})} <div className="col-span-2">
</TableBody> <p className="text-muted-foreground">Net Salary</p>
</Table> <p className="font-semibold text-foreground">{money(l.netSalary)}</p>
</div>
</div>
</div>
)
})}
{filteredLines.length > pagedLines.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
Show more
</Button>
)}
</div> </div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Employee</TableHead>
<TableHead className="h-11 px-4 text-sm">Basic</TableHead>
<TableHead className="h-11 px-4 text-sm">OT</TableHead>
<TableHead className="h-11 px-4 text-sm">Allowances</TableHead>
<TableHead className="h-11 px-4 text-sm">Deductions</TableHead>
<TableHead className="h-11 px-4 text-sm">Net Salary</TableHead>
<TableHead className="h-11 w-28 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedLines.map((l) => {
const totalDeductions = l.grossSalary - l.netSalary
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
return (
<TableRow
key={l.payrollLineId}
className="cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`)
}}
>
<TableCell className="px-4 py-3.5 font-medium text-foreground">
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.basicSalary)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.overtimeAmount)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell>
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell>
<TableCell className="px-4 py-3.5" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
{payslip && (
<a
href={payslipViewUrl(payslip.payslipId)}
target="_blank"
rel="noreferrer"
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View payslip for ${l.employeeName}`}
title="View payslip"
>
<FileText className="size-4" />
</a>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{filteredLines.length > 0 && ( {filteredLines.length > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={{ page: currentPage, pageSize, totalItems: filteredLines.length, totalPages }}
Showing <span className="font-medium text-foreground">{(currentPage - 1) * pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground">{Math.min(currentPage * pageSize, filteredLines.length)}</span>{" "} pageSizeOptions={PAGE_SIZE_OPTIONS}
of <span className="font-medium text-foreground">{filteredLines.length}</span> lines onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</p> />
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Plus } from "lucide-react" import { useRouter } from "next/navigation"
import { 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"
@@ -16,11 +17,15 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
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_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const STATUSES: PayrollRunStatus[] = ["Draft", "Approved", "Locked"] const STATUSES: PayrollRunStatus[] = ["Draft", "Approved", "Locked"]
const ALL_STATUS = "__all__" const ALL_STATUS = "__all__"
@@ -31,12 +36,14 @@ const STATUS_STYLE: Record<PayrollRunStatus, string> = {
} }
export default function PayrollRunsPage() { export default function PayrollRunsPage() {
const router = useRouter()
const [runs, setRuns] = useState<PayrollRun[] | null>(null) const [runs, setRuns] = useState<PayrollRun[] | 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 [pageSize, setPageSize] = useState(5)
const [statusFilter, setStatusFilter] = useState(ALL_STATUS) const [statusFilter, setStatusFilter] = useState(ALL_STATUS)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const now = new Date() const now = new Date()
@@ -49,6 +56,12 @@ export default function PayrollRunsPage() {
.list({ page, pageSize, status: statusFilter === ALL_STATUS ? undefined : (statusFilter as PayrollRunStatus) }) .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)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -141,102 +154,112 @@ export default function PayrollRunsPage() {
)} )}
{!error && runs !== null && runs.length > 0 && ( {!error && runs !== null && runs.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {runs.map((r) => (
<TableRow className="hover:bg-transparent"> <div
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead> key={r.payrollRunId}
<TableHead className="h-11 px-4 text-sm">Period</TableHead> role="link"
<TableHead className="h-11 px-4 text-sm">Employees</TableHead> tabIndex={0}
<TableHead className="h-11 px-4 text-sm">Gross</TableHead> onClick={() => router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`)}
<TableHead className="h-11 px-4 text-sm">Net</TableHead> onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`) }}
<TableHead className="h-11 px-4 text-sm">Status</TableHead> className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead> >
</TableRow> <div className="flex items-start justify-between gap-2">
</TableHeader> <div className="min-w-0">
<TableBody> <p className="truncate font-semibold text-foreground">{r.docNo}</p>
{runs.map((r) => ( <p className="text-sm text-muted-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</p>
<TableRow key={r.payrollRunId} className="hover:bg-muted/20"> </div>
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell> <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell> {r.status}
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell> </Badge>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell> </div>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</TableCell> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<TableCell className="px-4 py-4"> <div>
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}> <p className="text-muted-foreground">Employees</p>
{r.status} <p className="font-medium text-foreground">{r.employeeCount}</p>
</Badge> </div>
</TableCell> <div>
<TableCell className="px-4 py-4"> <p className="text-muted-foreground">Gross</p>
<div className="flex items-center justify-center"> <p className="font-medium text-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
<Link </div>
href={`/dashboard/hrm/payroll/${r.payrollRunId}`} <div>
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} <p className="text-muted-foreground">Net</p>
aria-label={`View ${r.docNo}`} <p className="font-medium text-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
title={`View ${r.docNo}`} </div>
> </div>
<Eye className="size-4" /> </div>
</Link> ))}
</div>
</TableCell> {pagination && runs.length < pagination.totalItems && (
</TableRow> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
))} {showMoreLoading ? "Loading…" : "Show more"}
</TableBody> </Button>
</Table> )}
</div> </div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Doc No</TableHead>
<TableHead className="h-11 px-4 text-sm">Period</TableHead>
<TableHead className="h-11 px-4 text-sm">Employees</TableHead>
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((r) => (
<TableRow
key={r.payrollRunId}
className="cursor-pointer hover:bg-muted/20"
onClick={() => router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`)}
>
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.totalGross.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-4 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent px-2 text-xs", STATUS_STYLE[r.status])}>
{r.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/hrm/payroll/${r.payrollRunId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label={`View payroll run ${r.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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">
<Button
type="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> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, 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"
@@ -15,24 +15,31 @@ 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"
import { useAuth } from "@/components/auth/AuthProvider"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const NONE = "__none__" const NONE = "__none__"
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function DepartmentsPage() { export default function DepartmentsPage() {
const [items, setItems] = useState<Department[] | null>(null) const [items, setItems] = useState<Department[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const { branchingEnabled } = useAuth()
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 [pageSize, setPageSize] = useState(5)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -59,6 +66,12 @@ export default function DepartmentsPage() {
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -216,18 +229,20 @@ export default function DepartmentsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
<Field> {branchingEnabled && (
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel> <Field>
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}> <FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
<SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger> <Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
<SelectContent> <SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectItem value={NONE}>None</SelectItem> <SelectContent>
{branches.map((b) => ( <SelectItem value={NONE}>None</SelectItem>
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem> {branches.map((b) => (
))} <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</Field> </Select>
</Field>
)}
</FieldGroup> </FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
@@ -271,22 +286,24 @@ export default function DepartmentsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
<Field> {branchingEnabled && (
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel> <Field>
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}> <FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
<SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger> <Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
<SelectContent> <SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger>
<SelectItem value={NONE}>None</SelectItem> <SelectContent>
{branches.map((b) => ( <SelectItem value={NONE}>None</SelectItem>
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem> {branches.map((b) => (
))} <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</Field> </Select>
</Field>
)}
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -355,151 +372,209 @@ export default function DepartmentsPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="table-fixed text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {items.map((d) => (
<TableRow className="hover:bg-transparent"> <div key={d.departmentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead> <div className="min-w-0">
<TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead> <p className="truncate font-semibold text-foreground">{d.name}</p>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead> <span className="font-mono text-xs text-muted-foreground">{d.code}</span>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead> </div>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead> <Badge
</TableRow> variant="outline"
</TableHeader> className={cn(
<TableBody> "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
{items.map((d) => ( d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
<TableRow key={d.departmentId} className="group align-top hover:bg-muted/20"> )}
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{d.code}</TableCell> >
<TableCell className="px-4 py-4 text-sm text-foreground">{d.name}</TableCell> {d.status}
<TableCell className="px-4 py-4 text-sm text-muted-foreground"> </Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Parent</p>
<p className="font-medium text-foreground">
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"} {items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
</TableCell> </p>
<TableCell className="px-4 py-4"> </div>
<Badge <div>
variant="outline" <p className="text-muted-foreground">Created</p>
className={cn( <p className="font-medium text-foreground">
"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" })} {new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell> </p>
<TableCell className="px-4 py-4"> </div>
<div className="flex items-center justify-center gap-1"> </div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<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 <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`Edit ${d.name}`} className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
title={`Edit ${d.name}`} aria-label={`Delete ${d.name}`}
onClick={() => openEdit(d)} title={`Delete ${d.name}`}
>
<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} disabled={actionPendingId === d.departmentId}
onClick={() => toggleStatus(d)} />
> }
{d.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} >
</Button> <Trash2 className="size-4" />
<AlertDialog> </AlertDialogTrigger>
<AlertDialogTrigger <AlertDialogContent
render={ variant="destructive"
<Button title={`Delete ${d.name}?`}
variant="ghost" 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."
size="icon-sm" confirmLabel="Delete"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" onConfirm={() => handleDelete(d)}
aria-label={`Delete ${d.name}`} />
title={`Delete ${d.name}`} </AlertDialog>
disabled={actionPendingId === d.departmentId} </div>
/> </div>
} ))}
>
<Trash2 className="size-4" /> {pagination && items.length < pagination.totalItems && (
</AlertDialogTrigger> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
<AlertDialogContent {showMoreLoading ? "Loading…" : "Show more"}
variant="destructive" </Button>
title={`Delete ${d.name}?`} )}
description="This permanently removes this department. This cannot be undone. Blocked if it has sub-departments or employees assigned — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(d)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Parent</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</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>
</TableHeader>
<TableBody>
{items.map((d) => (
<TableRow key={d.departmentId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{d.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{d.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
</TableCell>
<TableCell className="px-4 py-4">
<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>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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">
<Button
type="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> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, 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"
@@ -16,6 +16,7 @@ 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"
@@ -23,6 +24,9 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] 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"]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function DocumentTypesPage() { export default function DocumentTypesPage() {
const [items, setItems] = useState<HrDocumentType[] | null>(null) const [items, setItems] = useState<HrDocumentType[] | null>(null)
@@ -33,6 +37,7 @@ export default function DocumentTypesPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -58,6 +63,12 @@ export default function DocumentTypesPage() {
.list({ page, pageSize, q: debouncedSearch || undefined }) .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)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -260,9 +271,9 @@ export default function DocumentTypesPage() {
<FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel> <FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -331,149 +342,205 @@ export default function DocumentTypesPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="table-fixed text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {items.map((t) => (
<TableRow className="hover:bg-transparent"> <div key={t.hrDocumentTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead> <div className="min-w-0">
<TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead> <p className="truncate font-semibold text-foreground">{t.name}</p>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead> <span className="font-mono text-xs text-muted-foreground">{t.code}</span>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead> </div>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead> <Badge
</TableRow> variant="outline"
</TableHeader> className={cn(
<TableBody> "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
{items.map((t) => ( t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
<TableRow key={t.hrDocumentTypeId} className="group align-top hover:bg-muted/20"> )}
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell> >
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell> {t.status}
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.category}</TableCell> </Badge>
<TableCell className="px-4 py-4"> </div>
<Badge
variant="outline" <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
className={cn( <div>
"h-5 w-fit justify-center bg-white px-2 text-xs", <p className="text-muted-foreground">Category</p>
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground" <p className="font-medium text-foreground">{t.category}</p>
)} </div>
> <div>
{t.status} <p className="text-muted-foreground">Created</p>
</Badge> <p className="font-medium text-foreground">
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} {new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell> </p>
<TableCell className="px-4 py-4"> </div>
<div className="flex items-center justify-center gap-1"> </div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<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 <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`Edit ${t.name}`} className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
title={`Edit ${t.name}`} aria-label={`Delete ${t.name}`}
onClick={() => openEdit(t)} title={`Delete ${t.name}`}
>
<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} disabled={actionPendingId === t.hrDocumentTypeId}
onClick={() => toggleStatus(t)} />
> }
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} >
</Button> <Trash2 className="size-4" />
<AlertDialog> </AlertDialogTrigger>
<AlertDialogTrigger <AlertDialogContent
render={ variant="destructive"
<Button title={`Delete ${t.name}?`}
variant="ghost" 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."
size="icon-sm" confirmLabel="Delete"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" onConfirm={() => handleDelete(t)}
aria-label={`Delete ${t.name}`} />
title={`Delete ${t.name}`} </AlertDialog>
disabled={actionPendingId === t.hrDocumentTypeId} </div>
/> </div>
} ))}
>
<Trash2 className="size-4" /> {pagination && items.length < pagination.totalItems && (
</AlertDialogTrigger> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
<AlertDialogContent {showMoreLoading ? "Loading…" : "Show more"}
variant="destructive" </Button>
title={`Delete ${t.name}?`} )}
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Category</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</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>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.hrDocumentTypeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.category}</TableCell>
<TableCell className="px-4 py-4">
<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 className="px-4 py-4 text-sm text-muted-foreground">
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${t.name}`}
title={`Edit ${t.name}`}
onClick={() => openEdit(t)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
t.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
disabled={actionPendingId === t.hrDocumentTypeId}
onClick={() => toggleStatus(t)}
>
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${t.name}`}
title={`Delete ${t.name}`}
disabled={actionPendingId === t.hrDocumentTypeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${t.name}?`}
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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">
<Button
type="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> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, 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"
@@ -16,11 +16,15 @@ 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_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function LeaveTypesPage() { export default function LeaveTypesPage() {
const [items, setItems] = useState<LeaveType[] | null>(null) const [items, setItems] = useState<LeaveType[] | null>(null)
@@ -31,6 +35,7 @@ export default function LeaveTypesPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -59,6 +64,12 @@ export default function LeaveTypesPage() {
.list({ page, pageSize, q: debouncedSearch || undefined }) .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)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -278,9 +289,9 @@ export default function LeaveTypesPage() {
<FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel> <FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -350,151 +361,211 @@ export default function LeaveTypesPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="table-fixed text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {items.map((t) => (
<TableRow className="hover:bg-transparent"> <div key={t.leaveTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead> <div className="min-w-0">
<TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead> <p className="truncate font-semibold text-foreground">{t.name}</p>
<TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead> <span className="font-mono text-xs text-muted-foreground">{t.code}</span>
<TableHead className="h-11 w-[14%] px-4 text-sm">Status</TableHead> </div>
<TableHead className="h-11 w-[14%] px-4 text-sm">Created</TableHead> <Badge
<TableHead className="h-11 w-[18%] px-4 text-center text-sm">Actions</TableHead> variant="outline"
</TableRow> className={cn(
</TableHeader> "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
<TableBody> t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
{items.map((t) => ( )}
<TableRow key={t.leaveTypeId} className="group align-top hover:bg-muted/20"> >
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell> {t.status}
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell> </Badge>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.accrualPerYear}</TableCell> </div>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.isPaid ? "Yes" : "No"}</TableCell>
<TableCell className="px-4 py-4"> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<Badge <div>
variant="outline" <p className="text-muted-foreground">Days/yr</p>
className={cn( <p className="font-medium text-foreground">{t.accrualPerYear}</p>
"h-5 w-fit justify-center bg-white px-2 text-xs", </div>
t.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground" <div>
)} <p className="text-muted-foreground">Paid</p>
> <p className="font-medium text-foreground">{t.isPaid ? "Yes" : "No"}</p>
{t.status} </div>
</Badge> <div>
</TableCell> <p className="text-muted-foreground">Created</p>
<TableCell className="px-4 py-4 text-sm text-muted-foreground"> <p className="font-medium text-foreground">
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} {new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell> </p>
<TableCell className="px-4 py-4"> </div>
<div className="flex items-center justify-center gap-1"> </div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<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 <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`Edit ${t.name}`} className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
title={`Edit ${t.name}`} aria-label={`Delete ${t.name}`}
onClick={() => openEdit(t)} title={`Delete ${t.name}`}
>
<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} disabled={actionPendingId === t.leaveTypeId}
onClick={() => toggleStatus(t)} />
> }
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} >
</Button> <Trash2 className="size-4" />
<AlertDialog> </AlertDialogTrigger>
<AlertDialogTrigger <AlertDialogContent
render={ variant="destructive"
<Button title={`Delete ${t.name}?`}
variant="ghost" 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."
size="icon-sm" confirmLabel="Delete"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" onConfirm={() => handleDelete(t)}
aria-label={`Delete ${t.name}`} />
title={`Delete ${t.name}`} </AlertDialog>
disabled={actionPendingId === t.leaveTypeId} </div>
/> </div>
} ))}
>
<Trash2 className="size-4" /> {pagination && items.length < pagination.totalItems && (
</AlertDialogTrigger> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
<AlertDialogContent {showMoreLoading ? "Loading…" : "Show more"}
variant="destructive" </Button>
title={`Delete ${t.name}?`} )}
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-[14%] px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-[18%] px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-[12%] px-4 text-sm">Days/yr</TableHead>
<TableHead className="h-11 w-[10%] px-4 text-sm">Paid</TableHead>
<TableHead className="h-11 w-[14%] px-4 text-sm">Status</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>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.leaveTypeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{t.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{t.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.accrualPerYear}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{t.isPaid ? "Yes" : "No"}</TableCell>
<TableCell className="px-4 py-4">
<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 className="px-4 py-4 text-sm text-muted-foreground">
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${t.name}`}
title={`Edit ${t.name}`}
onClick={() => openEdit(t)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
t.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
disabled={actionPendingId === t.leaveTypeId}
onClick={() => toggleStatus(t)}
>
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${t.name}`}
title={`Delete ${t.name}`}
disabled={actionPendingId === t.leaveTypeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${t.name}?`}
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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">
<Button
type="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> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, 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"
@@ -16,12 +16,16 @@ 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_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function SalaryComponentsPage() { export default function SalaryComponentsPage() {
const [items, setItems] = useState<SalaryComponent[] | null>(null) const [items, setItems] = useState<SalaryComponent[] | null>(null)
@@ -32,6 +36,7 @@ export default function SalaryComponentsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -56,6 +61,12 @@ export default function SalaryComponentsPage() {
.list({ page, pageSize, q: debouncedSearch || undefined }) .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)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -253,9 +264,9 @@ export default function SalaryComponentsPage() {
<FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel> <FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -324,149 +335,205 @@ export default function SalaryComponentsPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="table-fixed text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {items.map((c) => (
<TableRow className="hover:bg-transparent"> <div key={c.salaryComponentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead> <div className="min-w-0">
<TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead> <p className="truncate font-semibold text-foreground">{c.name}</p>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead> <span className="font-mono text-xs text-muted-foreground">{c.code}</span>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead> </div>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead> <Badge
</TableRow> variant="outline"
</TableHeader> className={cn(
<TableBody> "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
{items.map((c) => ( c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
<TableRow key={c.salaryComponentId} className="group align-top hover:bg-muted/20"> )}
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{c.code}</TableCell> >
<TableCell className="px-4 py-4 text-sm text-foreground">{c.name}</TableCell> {c.status}
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{c.componentType}</TableCell> </Badge>
<TableCell className="px-4 py-4"> </div>
<Badge
variant="outline" <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
className={cn( <div>
"h-5 w-fit justify-center bg-white px-2 text-xs", <p className="text-muted-foreground">Type</p>
c.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground" <p className="font-medium text-foreground">{c.componentType}</p>
)} </div>
> <div>
{c.status} <p className="text-muted-foreground">Created</p>
</Badge> <p className="font-medium text-foreground">
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} {new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell> </p>
<TableCell className="px-4 py-4"> </div>
<div className="flex items-center justify-center gap-1"> </div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<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 <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`Edit ${c.name}`} className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
title={`Edit ${c.name}`} aria-label={`Delete ${c.name}`}
onClick={() => openEdit(c)} title={`Delete ${c.name}`}
>
<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} disabled={actionPendingId === c.salaryComponentId}
onClick={() => toggleStatus(c)} />
> }
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} >
</Button> <Trash2 className="size-4" />
<AlertDialog> </AlertDialogTrigger>
<AlertDialogTrigger <AlertDialogContent
render={ variant="destructive"
<Button title={`Delete ${c.name}?`}
variant="ghost" 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."
size="icon-sm" confirmLabel="Delete"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" onConfirm={() => handleDelete(c)}
aria-label={`Delete ${c.name}`} />
title={`Delete ${c.name}`} </AlertDialog>
disabled={actionPendingId === c.salaryComponentId} </div>
/> </div>
} ))}
>
<Trash2 className="size-4" /> {pagination && items.length < pagination.totalItems && (
</AlertDialogTrigger> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
<AlertDialogContent {showMoreLoading ? "Loading…" : "Show more"}
variant="destructive" </Button>
title={`Delete ${c.name}?`} )}
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Type</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</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>
</TableHeader>
<TableBody>
{items.map((c) => (
<TableRow key={c.salaryComponentId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{c.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{c.name}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{c.componentType}</TableCell>
<TableCell className="px-4 py-4">
<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 className="px-4 py-4 text-sm text-muted-foreground">
{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
title={`Edit ${c.name}`}
onClick={() => openEdit(c)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
c.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
disabled={actionPendingId === c.salaryComponentId}
onClick={() => toggleStatus(c)}
>
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${c.name}`}
title={`Delete ${c.name}`}
disabled={actionPendingId === c.salaryComponentId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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">
<Button
type="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> </div>
@@ -141,34 +141,66 @@ export default function StatutorySettingsPage() {
)} )}
{settings !== null && settings.length > 0 && ( {settings !== null && settings.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {settings.map((s) => (
<TableRow className="hover:bg-transparent"> <div key={s.payrollStatutorySettingId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead> <p className="font-semibold text-foreground">
<TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead> {new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
<TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead> </p>
<TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead> <div>
</TableRow> <p className="text-muted-foreground">EPF (Employee)</p>
</TableHeader> <p className="font-medium text-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</p>
<TableBody> </div>
{settings.map((s) => ( <div>
<TableRow key={s.payrollStatutorySettingId} className="hover:bg-muted/20"> <p className="text-muted-foreground">EPF (Employer)</p>
<TableCell className="px-4 py-4 text-sm text-foreground"> <p className="font-medium text-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</p>
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"} </div>
</TableCell> <div>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell> <p className="text-muted-foreground">ETF (Employer)</p>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell> <p className="font-medium text-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</p>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell> </div>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.otMultiplierDefault}x</TableCell> <div>
</TableRow> <p className="text-muted-foreground">OT multiplier</p>
))} <p className="font-medium text-foreground">{s.otMultiplierDefault}x</p>
</TableBody> </div>
</Table> </div>
</div>
))}
</div> </div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">EPF (Employee)</TableHead>
<TableHead className="h-11 px-4 text-sm">EPF (Employer)</TableHead>
<TableHead className="h-11 px-4 text-sm">ETF (Employer)</TableHead>
<TableHead className="h-11 px-4 text-sm">OT multiplier</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{settings.map((s) => (
<TableRow key={s.payrollStatutorySettingId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 text-sm text-foreground">
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.otMultiplierDefault}x</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
</section> </section>
@@ -225,28 +257,46 @@ export default function StatutorySettingsPage() {
)} )}
{slabs !== null && slabs.length > 0 && ( {slabs !== null && slabs.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {slabs.map((s) => (
<TableRow className="hover:bg-transparent"> <div key={s.taxSlabId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead> <div className="flex items-center justify-between gap-2">
<TableHead className="h-11 px-4 text-sm">Range</TableHead> <p className="font-semibold text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</p>
<TableHead className="h-11 px-4 text-sm">Rate</TableHead> <p className="font-medium text-foreground">{(s.rate * 100).toFixed(1)}%</p>
</TableRow> </div>
</TableHeader> <p className="border-t pt-3 text-sm text-muted-foreground">
<TableBody> {s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}
{slabs.map((s) => ( </p>
<TableRow key={s.taxSlabId} className="hover:bg-muted/20"> </div>
<TableCell className="px-4 py-4 text-sm text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell> ))}
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.rate * 100).toFixed(1)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Effective from</TableHead>
<TableHead className="h-11 px-4 text-sm">Range</TableHead>
<TableHead className="h-11 px-4 text-sm">Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{slabs.map((s) => (
<TableRow key={s.taxSlabId} className="hover:bg-muted/20">
<TableCell className="px-4 py-4 text-sm text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{(s.rate * 100).toFixed(1)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
</section> </section>
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, 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"
@@ -16,12 +16,16 @@ 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_OPTIONS = [5, 10, 25, 50] 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"]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
function maskToDays(mask: number): boolean[] { function maskToDays(mask: number): boolean[] {
return DAYS.map((_, i) => (mask & (1 << i)) !== 0) return DAYS.map((_, i) => (mask & (1 << i)) !== 0)
@@ -36,6 +40,7 @@ export default function WorkShiftsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -74,6 +79,12 @@ export default function WorkShiftsPage() {
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -217,7 +228,7 @@ export default function WorkShiftsPage() {
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Shift</Button>} /> <DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Shift</Button>} />
<DialogContent className="sm:max-w-md"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>New Work Shift</DialogTitle> <DialogTitle>New Work Shift</DialogTitle>
<DialogDescription>Standard hours, grace period, and working days for this shift.</DialogDescription> <DialogDescription>Standard hours, grace period, and working days for this shift.</DialogDescription>
@@ -286,7 +297,7 @@ export default function WorkShiftsPage() {
</div> </div>
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}> <Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
<DialogContent className="sm:max-w-md"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>Edit Work Shift</DialogTitle> <DialogTitle>Edit Work Shift</DialogTitle>
<DialogDescription>Code can&apos;t be changed after creation.</DialogDescription> <DialogDescription>Code can&apos;t be changed after creation.</DialogDescription>
@@ -355,9 +366,9 @@ export default function WorkShiftsPage() {
</div> </div>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -426,149 +437,205 @@ export default function WorkShiftsPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<Table className="table-fixed text-base"> <div className="flex flex-col gap-3 md:hidden">
<TableHeader> {items.map((w) => (
<TableRow className="hover:bg-transparent"> <div key={w.workShiftId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead> <div className="flex items-start justify-between gap-2">
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead> <div className="min-w-0">
<TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead> <p className="truncate font-semibold text-foreground">{w.name}</p>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</TableHead> <span className="font-mono text-xs text-muted-foreground">{w.code}</span>
<TableHead className="h-11 w-1/6 px-4 text-sm">Created</TableHead> </div>
<TableHead className="h-11 w-1/6 px-4 text-center text-sm">Actions</TableHead> <Badge
</TableRow> variant="outline"
</TableHeader> className={cn(
<TableBody> "h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
{items.map((w) => ( w.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
<TableRow key={w.workShiftId} className="group align-top hover:bg-muted/20"> )}
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{w.code}</TableCell> >
<TableCell className="px-4 py-4 text-sm text-foreground">{w.name}</TableCell> {w.status}
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</TableCell> </Badge>
<TableCell className="px-4 py-4"> </div>
<Badge
variant="outline" <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
className={cn( <div>
"h-5 w-fit justify-center bg-white px-2 text-xs", <p className="text-muted-foreground">Hours</p>
w.status === "Active" ? "border-success/40 text-success" : "border-input text-muted-foreground" <p className="font-medium text-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</p>
)} </div>
> <div>
{w.status} <p className="text-muted-foreground">Created</p>
</Badge> <p className="font-medium text-foreground">
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">
{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} {new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell> </p>
<TableCell className="px-4 py-4"> </div>
<div className="flex items-center justify-center gap-1"> </div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<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 <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`Edit ${w.name}`} className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
title={`Edit ${w.name}`} aria-label={`Delete ${w.name}`}
onClick={() => openEdit(w)} title={`Delete ${w.name}`}
>
<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} disabled={actionPendingId === w.workShiftId}
onClick={() => toggleStatus(w)} />
> }
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} >
</Button> <Trash2 className="size-4" />
<AlertDialog> </AlertDialogTrigger>
<AlertDialogTrigger <AlertDialogContent
render={ variant="destructive"
<Button title={`Delete ${w.name}?`}
variant="ghost" 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."
size="icon-sm" confirmLabel="Delete"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" onConfirm={() => handleDelete(w)}
aria-label={`Delete ${w.name}`} />
title={`Delete ${w.name}`} </AlertDialog>
disabled={actionPendingId === w.workShiftId} </div>
/> </div>
} ))}
>
<Trash2 className="size-4" /> {pagination && items.length < pagination.totalItems && (
</AlertDialogTrigger> <Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
<AlertDialogContent {showMoreLoading ? "Loading…" : "Show more"}
variant="destructive" </Button>
title={`Delete ${w.name}?`} )}
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(w)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 w-1/6 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Name</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Hours</TableHead>
<TableHead className="h-11 w-1/6 px-4 text-sm">Status</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>
</TableHeader>
<TableBody>
{items.map((w) => (
<TableRow key={w.workShiftId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{w.code}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{w.name}</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-4 py-4">
<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 className="px-4 py-4 text-sm text-muted-foreground">
{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${w.name}`}
title={`Edit ${w.name}`}
onClick={() => openEdit(w)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
w.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
disabled={actionPendingId === w.workShiftId}
onClick={() => toggleStatus(w)}
>
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${w.name}`}
title={`Delete ${w.name}`}
disabled={actionPendingId === w.workShiftId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${w.name}?`}
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(w)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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">
<Button
type="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> </div>
@@ -112,7 +112,7 @@ export default function BalanceSheetPage() {
)} )}
{!error && report !== null && !isEmpty && ( {!error && report !== null && !isEmpty && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<ReportHeader title="Statement of Financial Position" subtitle={`As at ${formatReportDate(asOfDate)}`} /> <ReportHeader title="Statement of Financial Position" subtitle={`As at ${formatReportDate(asOfDate)}`} />
<div className="mt-4"> <div className="mt-4">
@@ -103,39 +103,39 @@ export default function GeneralLedgerReportPage() {
)} )}
{!error && rows !== null && rows.length > 0 && ( {!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<ReportHeader title="General Ledger" subtitle={`${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} /> <ReportHeader title="General Ledger" subtitle={`${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} />
<Table className="mt-4 text-base"> <div className="mt-4 overflow-x-auto">
<TableHeader> <Table className="text-base">
<TableRow> <TableHeader>
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead> <TableRow>
<TableHead className="h-11 px-3 text-sm">Account Name</TableHead> <TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
<TableHead className="h-11 px-3 text-sm">Date</TableHead> <TableHead className="h-11 px-3 text-sm">Account Name</TableHead>
<TableHead className="h-11 px-3 text-sm">Description</TableHead> <TableHead className="h-11 px-3 text-sm">Date</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead> <TableHead className="h-11 px-3 text-sm">Description</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead> <TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every {/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line — leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
lands on adjacent rows instead of being scattered under separate account blocks. */} lands on adjacent rows instead of being scattered under separate account blocks. */}
{[...rows] {[...rows]
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo)) .sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
.map((row, i) => ( .map((row, i) => (
<TableRow key={i}> <TableRow key={i}>
<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>
</TableRow>
</TableRow> ))}
))} </TableBody>
</TableBody> </Table>
</Table> </div>
</div> </div>
)} )}
</div> </div>
@@ -127,7 +127,7 @@ export default function ProfitAndLossPage() {
)} )}
{!error && report !== null && !isEmpty && ( {!error && report !== null && !isEmpty && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<ReportHeader <ReportHeader
title="Statement of Profit or Loss" title="Statement of Profit or Loss"
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
@@ -230,7 +230,7 @@ export default function TaxReportPage() {
)} )}
{!error && report !== null && ( {!error && report !== null && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
{/* Own header, not the shared ReportHeader — GL's own PDF gives this report a fuller {/* Own header, not the shared ReportHeader — GL's own PDF gives this report a fuller
identity block (company name/address/TIN/BRN) that today lives only in GL's own identity block (company name/address/TIN/BRN) that today lives only in GL's own
appsettings.json, with no endpoint exposing it. Deliberately not fabricated here — appsettings.json, with no endpoint exposing it. Deliberately not fabricated here —
@@ -97,36 +97,38 @@ export default function TrialBalancePage() {
)} )}
{!error && rows !== null && rows.length > 0 && ( {!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<ReportHeader title="Trial Balance" subtitle={`As at ${formatReportDate(asOfDate)}`} /> <ReportHeader title="Trial Balance" subtitle={`As at ${formatReportDate(asOfDate)}`} />
<Table className="mt-4 text-base"> <div className="mt-4 overflow-x-auto">
<TableHeader> <Table className="text-base">
<TableRow> <TableHeader>
<TableHead className="h-11 px-3 text-sm">Account</TableHead> <TableRow>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead> <TableHead className="h-11 px-3 text-sm">Account</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead> <TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
</TableRow> <TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</TableHeader>
<TableBody>
{rows.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5">
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
<span>{row.accountName}</span>
</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.credit, true)}</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
<TableFooter className="border-t-2 border-foreground/70 bg-transparent"> {rows.map((row, i) => (
<TableRow className="hover:bg-transparent"> <TableRow key={i}>
<TableCell className="px-3 py-3 font-bold">Total</TableCell> <TableCell className="px-3 py-2.5">
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalDebit)}</TableCell> <span className="text-muted-foreground">{row.accountCode}</span>{" "}
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalCredit)}</TableCell> <span>{row.accountName}</span>
</TableRow> </TableCell>
</TableFooter> <TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
</Table> <TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.credit, true)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter className="border-t-2 border-foreground/70 bg-transparent">
<TableRow className="hover:bg-transparent">
<TableCell className="px-3 py-3 font-bold">Total</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalDebit)}</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalCredit)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div> </div>
)} )}
</div> </div>
+156 -93
View File
@@ -4,9 +4,10 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { import {
AlertTriangle, AlertTriangle,
ArrowDownLeft,
ArrowUpRight,
BadgeDollarSign, BadgeDollarSign,
Boxes, Boxes,
Clock,
ClipboardList, ClipboardList,
ListChecks, ListChecks,
PackageCheck, PackageCheck,
@@ -128,92 +129,91 @@ 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="flex flex-wrap gap-2">
{loaded ? ( {loaded ? (
<> <>
<Link href="/dashboard/stock/reorder-alerts"> <Link href="/dashboard/stock/reorder-alerts">
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} /> <StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} size="sm" />
</Link> </Link>
<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} size="sm" />
</Link>
<Link href="/dashboard/procurement/purchase-orders">
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
</Link> </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} size="sm" />
</Link> </Link>
<Link href="/dashboard/procurement/requisitions"> <Link href="/dashboard/procurement/requisitions">
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} /> <StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} size="sm" />
</Link> </Link>
<Link href="/dashboard/stock/counts"> <Link href="/dashboard/stock/counts">
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} /> <StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} size="sm" />
</Link> </Link>
<Link href="/dashboard/procurement/rfqs"> <Link href="/dashboard/procurement/rfqs">
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} /> <StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} size="sm" />
</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-20 w-52 rounded-2xl" />)
)} )}
</div> </div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base"> <div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<BadgeDollarSign className="size-4 shrink-0 text-primary" /> <h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Valuation by Warehouse <BadgeDollarSign className="size-4 shrink-0 text-primary" />
</h2> Stock Valuation by Warehouse
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}> </h2>
View details <Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
</Link> View details
</Link>
</div>
{loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="h-64 sm:h-72">
<BarChart
labels={stats.stockValuationByWarehouse.map(
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
)}
datasets={[
{
label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#E11D48",
},
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
)
) : (
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
)}
</div> </div>
{loaded ? ( <div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
(stats.stockValuationByWarehouse ?? []).length > 0 ? ( <div className="mb-4 flex items-center gap-2">
<div className="h-52 sm:h-64"> <ScrollText className="size-4 shrink-0 text-primary" />
<BarChart <h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
labels={stats.stockValuationByWarehouse.map( Stock Movement Trend (last {TREND_DAYS} days)
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}` </h2>
)} </div>
{loaded ? (
<div className="h-64 sm:h-72">
<LineChart
labels={movementTrend.labels}
datasets={[ datasets={[
{ { label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
label: "Stock Value (LKR)", { label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#6366f1",
},
]} ]}
/> />
</div> </div>
) : ( ) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p> !error && <Skeleton className="h-64 rounded-lg sm:h-72" />
) )}
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days)
</h2>
</div> </div>
{loaded ? (
<div className="h-52 sm:h-64">
<LineChart
labels={movementTrend.labels}
datasets={[
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]}
/>
</div>
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</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">
@@ -229,48 +229,111 @@ export default function DashboardPage() {
{loaded ? ( {loaded ? (
movements.length > 0 ? ( movements.length > 0 ? (
<Table> <>
<TableHeader> {/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead>Item</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Source</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{movements.map((entry) => ( {movements.map((entry) => (
<TableRow key={entry.ledgerId}> <div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<TableCell className="font-medium text-foreground">#{entry.itemId}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="text-muted-foreground"> <div className="flex min-w-0 items-center gap-3">
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`} <div
</TableCell> className={cn(
<TableCell> "flex size-9 shrink-0 items-center justify-center rounded-lg",
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
)}
>
{entry.direction === "In" ? (
<ArrowDownLeft className="size-4 text-success" />
) : (
<ArrowUpRight className="size-4 text-destructive" />
)}
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
<p className="text-sm text-muted-foreground">
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</p>
</div>
</div>
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"h-6 w-16 justify-center text-xs", "h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
entry.direction === "In" entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)} )}
> >
{entry.direction} {entry.direction}
</Badge> </Badge>
</TableCell> </div>
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell>
<TableCell className="text-muted-foreground"> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
{entry.sourceDocType} #{entry.sourceDocId} <div>
</TableCell> <p className="text-muted-foreground">Qty</p>
<TableCell className="text-right text-muted-foreground"> <p className="font-medium text-foreground">{entry.qtyBase}</p>
{new Date(entry.createdAt).toLocaleDateString()} </div>
</TableCell> <div>
</TableRow> <p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Source</p>
<p className="font-medium text-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</p>
</div>
</div>
</div>
))} ))}
</TableBody> </div>
</Table>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="min-w-160">
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Source</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{movements.map((entry) => (
<TableRow key={entry.ledgerId}>
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
<TableCell>
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center text-xs",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</TableCell>
<TableCell>{entry.qtyBase}</TableCell>
<TableCell>
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right">
{new Date(entry.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
) : ( ) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p> <p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
) )
@@ -126,7 +126,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
return ( return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6"> <div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<div className="flex items-center justify-between gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}> <Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
@@ -136,7 +136,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
<p className="text-base text-muted-foreground">{invoice.docNo}</p> <p className="text-base text-muted-foreground">{invoice.docNo}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{invoice.status !== "Draft" && ( {invoice.status !== "Draft" && (
<Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<BookOpen className="size-5" /> <BookOpen className="size-5" />
@@ -157,7 +157,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>} {error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>}
<section className="rounded-2xl border bg-card p-6 shadow-sm"> <section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="border-b pb-4"> <div className="border-b pb-4">
<div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}> <div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
<div> <div>
@@ -224,7 +224,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
{line.grnDocNo} {line.grnDocNo}
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="px-4 py-3 text-muted-foreground">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell> <TableCell className="px-4 py-3">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell> <TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell> <TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell>
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell> <TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell>
@@ -286,7 +286,7 @@ function NewPurchaseInvoiceContent() {
/> />
</TableCell> </TableCell>
<TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell> <TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell>
<TableCell className="px-3 py-3 text-muted-foreground">{new Date(g.postedAt).toLocaleDateString()}</TableCell> <TableCell className="px-3 py-3">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell> <TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell> <TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell> <TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell>
@@ -314,11 +314,11 @@ function NewPurchaseInvoiceContent() {
</div> </div>
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={submit} disabled={saving}> <Button size="lg" type="button" onClick={submit} disabled={saving} className="w-full sm:w-auto">
{saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"} {saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"}
</Button> </Button>
</div> </div>
@@ -2,28 +2,33 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react" import { useRouter } from "next/navigation"
import { CheckCircle2, CircleDollarSign, Filter, Plus, Printer, ReceiptText, TrendingUp } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices" import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { dashboardApi } from "@/lib/api/dashboard"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
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 { 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"
import BarChart from "@/components/ui/bar-chart"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog" import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges" import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { Branch } from "@/types/hrm" import { Branch } from "@/types/hrm"
import { MonthlyPurchasesVsSales } from "@/types/dashboard"
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices" import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type StatusFilter = PurchaseInvoiceStatus | "All" type StatusFilter = PurchaseInvoiceStatus | "All"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
const tabs: { value: StatusFilter; label: string }[] = [ const tabs: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All" }, { value: "All", label: "All" },
{ value: "Draft", label: "Draft" }, { value: "Draft", label: "Draft" },
@@ -32,6 +37,7 @@ const tabs: { value: StatusFilter; label: string }[] = [
] ]
export default function PurchaseInvoicesPage() { export default function PurchaseInvoicesPage() {
const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth() const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null) const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
@@ -44,6 +50,15 @@ export default function PurchaseInvoicesPage() {
const [confirmingId, setConfirmingId] = useState<number | null>(null) const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null) const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false) const [payDialogOpen, setPayDialogOpen] = useState(false)
const [monthlyPurchases, setMonthlyPurchases] = useState<MonthlyPurchasesVsSales[] | null>(null)
useEffect(() => {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
dashboardApi
.stats({ branchId })
.then((res) => setMonthlyPurchases(res.monthlyPurchasesVsSales))
.catch(() => setMonthlyPurchases([]))
}, [activeBranchId, branchingEnabled])
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -139,6 +154,30 @@ export default function PurchaseInvoicesPage() {
</div> </div>
</div> </div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<TrendingUp className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">Monthly Purchases</h2>
</div>
{monthlyPurchases === null ? (
<Skeleton className="h-64 rounded-lg sm:h-72" />
) : monthlyPurchases.length > 0 ? (
<div className="h-64 sm:h-72">
<BarChart
labels={monthlyPurchases.map((m) =>
new Date(`${m.month}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })
)}
datasets={[
{ label: "Purchases", data: monthlyPurchases.map((m) => m.purchases), backgroundColor: "#E11D48" },
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No posted invoices yet.</p>
)}
</div>
<div className="rounded-2xl border bg-card shadow-sm"> <div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center"> <div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -189,7 +228,99 @@ export default function PurchaseInvoicesPage() {
{!error && rows !== null && visibleRows.length > 0 && ( {!error && rows !== null && visibleRows.length > 0 && (
<> <>
<div className="overflow-x-auto"> <div className="flex flex-col gap-3 p-4 md:hidden">
{visibleRows.map((row) => (
<div
key={row.purchaseInvoiceId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
data-no-row-nav
className="font-semibold text-foreground hover:underline"
>
{row.docNo}
</Link>
<p className="truncate text-sm text-muted-foreground">{row.vendorSnapshotName}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<PurchaseInvoiceStatusBadge status={row.status} />
{row.status !== "Draft" && (
<PurchaseInvoicePaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Vendor invoice no.</p>
<p className="font-medium text-foreground">{row.vendorInvoiceNo}</p>
</div>
<div>
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</p>
</div>
<div>
<p className="text-muted-foreground">GRNs</p>
<p className="font-medium text-foreground tabular-nums">{row.grnCount}</p>
</div>
<div>
<p className="text-muted-foreground">Total</p>
<p className="font-semibold text-foreground tabular-nums">{row.totals.grandTotal.toFixed(2)}</p>
</div>
{isAdmin && branchingEnabled && (
<div>
<p className="text-muted-foreground">Branch</p>
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(row.branchId)}</Badge>
</div>
)}
</div>
{(row.status === "Draft" || (row.status !== "Cancelled" && row.totals.balanceAmount > 0)) && (
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
{row.status === "Draft" && (
<Button
type="button"
variant="outline"
size="sm"
disabled={confirmingId === row.purchaseInvoiceId}
onClick={() => handleConfirm(row)}
>
<CheckCircle2 className="size-4" />
{confirmingId === row.purchaseInvoiceId ? "Confirming…" : "Confirm"}
</Button>
)}
{row.totals.balanceAmount > 0 && (
<Button
type="button"
size="sm"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
>
<CircleDollarSign className="size-4" />
Pay
</Button>
)}
</div>
)}
</div>
))}
</div>
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -208,7 +339,14 @@ export default function PurchaseInvoicesPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{visibleRows.map((row) => ( {visibleRows.map((row) => (
<TableRow key={row.purchaseInvoiceId} className="hover:bg-muted/40"> <TableRow
key={row.purchaseInvoiceId}
className="cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
}}
>
<TableCell className="px-4 py-3.5 font-medium"> <TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline"> <Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline">
{row.docNo} {row.docNo}
@@ -216,10 +354,10 @@ export default function PurchaseInvoicesPage() {
</TableCell> </TableCell>
<TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell> <TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell>
<TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell> <TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell> <TableCell className="px-4 py-3.5">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell> <TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell> <TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">{row.totals.vatTotal.toFixed(2)}</TableCell> <TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.vatTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell> <TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
{isAdmin && branchingEnabled && ( {isAdmin && branchingEnabled && (
<TableCell className="px-4 py-3.5"> <TableCell className="px-4 py-3.5">
@@ -234,7 +372,7 @@ export default function PurchaseInvoicesPage() {
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-4 py-3.5 text-right"> <TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{row.status === "Draft" && ( {row.status === "Draft" && (
<Button <Button
@@ -261,14 +399,6 @@ export default function PurchaseInvoicesPage() {
Pay Pay
</Button> </Button>
)} )}
<Link
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Show invoice ${row.docNo}`}
title="Show invoice"
>
<Eye className="size-4" />
</Link>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -294,22 +424,9 @@ export default function PurchaseInvoicesPage() {
</div> </div>
</div> </div>
{pagination && pagination.totalPages > 1 && ( {pagination && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between"> <div className="border-t px-4 py-4">
<p className="text-sm text-muted-foreground"> <PaginationFooter pagination={pagination} onPageChange={setPage} />
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))}>
<ChevronLeft />
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
<ChevronRight />
</Button>
</div>
</div> </div>
)} )}
</> </>
@@ -29,6 +29,7 @@ import {
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { Field, FieldError, 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 { Combobox } from "@/components/ui/combobox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -55,7 +56,7 @@ export default function PurchaseOrderDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter() const router = useRouter()
const poId = Number(params.id) const poId = Number(params.id)
const { isAdmin } = useAuth() const { isAdmin, userId } = useAuth()
const [po, setPo] = useState<PurchaseOrder | null>(null) const [po, setPo] = useState<PurchaseOrder | null>(null)
const [etag, setEtag] = useState<string | null>(null) const [etag, setEtag] = useState<string | null>(null)
@@ -121,6 +122,9 @@ export default function PurchaseOrderDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [poId]) }, [poId])
// Searchable item picker options — the label is what the combobox filters on.
const itemOptions = items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` }))
function itemFor(itemId: number | null) { function itemFor(itemId: number | null) {
return items.find((i) => i.itemId === itemId) ?? null return items.find((i) => i.itemId === itemId) ?? null
} }
@@ -174,7 +178,7 @@ export default function PurchaseOrderDetailPage() {
setSaving(true) setSaving(true)
try { try {
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines }, etag) const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines, branchId: po.branchId }, etag)
setPo(result.data) setPo(result.data)
setEtag(result.etag) setEtag(result.etag)
setLines(toDraftLines(result.data)) setLines(toDraftLines(result.data))
@@ -307,6 +311,19 @@ export default function PurchaseOrderDetailPage() {
// a Draft is deleted instead, and closed/cancelled POs are terminal. // a Draft is deleted instead, and closed/cancelled POs are terminal.
const cancellable = po.status === "Approved" || po.status === "PartiallyReceived" const cancellable = po.status === "Approved" || po.status === "PartiallyReceived"
// Mirrors PurchaseOrderService.LoadForApprovalDecisionAsync: only an Admin who didn't
// create the PO may approve/reject it. Explain why instead of silently hiding the buttons.
const isOwnPo = userId !== null && po.createdBy === userId
const approvalBlockedReason =
po.status !== "PendingApproval"
? null
: !isAdmin
? "This purchase order is awaiting approval. Only an Admin can approve or reject it."
: isOwnPo
? "You created this purchase order, so you can't approve or reject it yourself. Another Admin must review it."
: null
const canDecide = po.status === "PendingApproval" && approvalBlockedReason === null
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">
@@ -322,16 +339,16 @@ export default function PurchaseOrderDetailPage() {
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
{po.status === "Draft" && ( {po.status === "Draft" && (
<> <>
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}> <Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Send className="size-5" /> <Send className="size-5" />
{submitting ? "Submitting…" : "Submit for approval"} {submitting ? "Submitting…" : "Submit for approval"}
</Button> </Button>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger <AlertDialogTrigger
render={<Button variant="destructive" size="lg" disabled={deleting || submitting} />} render={<Button variant="destructive" size="lg" className="w-full sm:w-auto" disabled={deleting || submitting} />}
> >
<Trash2 className="size-5" /> <Trash2 className="size-5" />
{deleting ? "Deleting…" : "Delete draft"} {deleting ? "Deleting…" : "Delete draft"}
@@ -346,13 +363,13 @@ export default function PurchaseOrderDetailPage() {
</AlertDialog> </AlertDialog>
</> </>
)} )}
{po.status === "PendingApproval" && isAdmin && ( {canDecide && (
<> <>
<Button variant="success" size="lg" onClick={handleApprove} disabled={approving || rejecting}> <Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
<Check className="size-5" /> <Check className="size-5" />
{approving ? "Approving…" : "Approve"} {approving ? "Approving…" : "Approve"}
</Button> </Button>
<Button variant="destructive" size="lg" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}> <Button variant="destructive" size="lg" className="w-full sm:w-auto" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}>
<X className="size-5" /> <X className="size-5" />
Reject Reject
</Button> </Button>
@@ -362,6 +379,7 @@ export default function PurchaseOrderDetailPage() {
<Button <Button
variant="destructive" variant="destructive"
size="lg" size="lg"
className="w-full sm:w-auto"
onClick={() => setShowCancelForm(true)} onClick={() => setShowCancelForm(true)}
disabled={hasReceipts} disabled={hasReceipts}
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined} title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
@@ -373,6 +391,13 @@ export default function PurchaseOrderDetailPage() {
</div> </div>
</div> </div>
{approvalBlockedReason && (
<div role="status" className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
<p>{approvalBlockedReason}</p>
</div>
)}
{showCancelForm && ( {showCancelForm && (
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-5"> <div className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-5">
<p className="text-base font-semibold text-destructive">Cancel {po.docNo}</p> <p className="text-base font-semibold text-destructive">Cancel {po.docNo}</p>
@@ -380,13 +405,13 @@ export default function PurchaseOrderDetailPage() {
value={cancelReason} value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)} onChange={(e) => setCancelReason(e.target.value)}
placeholder="Reason (e.g. Duplicate order)" placeholder="Reason (e.g. Duplicate order)"
className="h-11 max-w-md text-base" className="h-11 w-full text-base sm:max-w-md"
/> />
<div className="flex gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row">
<Button variant="outline" onClick={() => setShowCancelForm(false)} disabled={cancelling}> <Button variant="outline" className="w-full sm:w-auto" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
Back Back
</Button> </Button>
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}> <Button variant="destructive" className="w-full sm:w-auto" onClick={handleCancel} disabled={cancelling}>
{cancelling ? "Cancelling…" : "Confirm cancellation"} {cancelling ? "Cancelling…" : "Confirm cancellation"}
</Button> </Button>
</div> </div>
@@ -437,7 +462,7 @@ export default function PurchaseOrderDetailPage() {
)} )}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
{editable && ( {editable && (
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}> <Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
@@ -447,11 +472,150 @@ export default function PurchaseOrderDetailPage() {
)} )}
</div> </div>
<div className="overflow-x-auto"> {/* Mobile: stacked cards — read-only summary or editable fields depending on `editable`. */}
<div className="flex flex-col gap-3 md:hidden">
{lines.map((line) => {
const errors = lineErrors[line.key] ?? {}
const item = itemFor(line.itemId)
if (!editable) {
return (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">UOM</p>
<p className="font-medium text-foreground">{baseUomLabel(items, uoms, line.itemId)}</p>
</div>
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Qty / Received</p>
<p className="font-medium text-foreground">{line.qty} / {line.qtyReceived}</p>
</div>
<div>
<p className="text-muted-foreground">Unit price / Tax</p>
<p className="font-medium text-foreground">{Number(line.unitPrice).toFixed(2)} / {(Number(line.tax) * 100).toFixed(0)}%</p>
</div>
</div>
</div>
)
}
return (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start gap-2">
<div className="flex-1">
<FieldLabel className="text-sm">Item</FieldLabel>
<Combobox<number>
items={itemOptions}
value={line.itemId}
onValueChange={(v) => updateLine(line.key, { itemId: v })}
placeholder="Search item by SKU or name…"
emptyText="No items match."
className="h-11 text-base"
openDownward
aria-invalid={!!errors.itemId}
/>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-6 shrink-0"
onClick={() => removeLine(line.key)}
aria-label="Remove line"
disabled={line.qtyReceived > 0}
title={line.qtyReceived > 0 ? "Cannot remove a line that already has receipts" : undefined}
>
<Trash2 className="size-5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<FieldLabel className="text-sm">UOM</FieldLabel>
<div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</div>
</div>
<div>
<FieldLabel className="text-sm">Warehouse</FieldLabel>
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
<SelectValue placeholder="Warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<FieldLabel className="text-sm">Qty (received {line.qtyReceived})</FieldLabel>
<Input
type="number"
min={line.qtyReceived}
step="any"
inputMode="decimal"
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</div>
<div>
<FieldLabel className="text-sm">Unit price</FieldLabel>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={line.unitPrice}
aria-invalid={!!errors.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</div>
</div>
<div>
<FieldLabel className="text-sm">Tax</FieldLabel>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={line.tax}
aria-invalid={!!errors.tax}
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
className="h-11 w-full text-base sm:w-32"
/>
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead> <TableHead className="h-12 w-80 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead> <TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Warehouse</TableHead> <TableHead className="h-12 w-36 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead> <TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
@@ -481,18 +645,17 @@ export default function PurchaseOrderDetailPage() {
return ( return (
<TableRow key={line.key}> <TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top"> <TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> <Combobox<number>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}> items={itemOptions}
<SelectValue placeholder="Select item" /> value={line.itemId}
</SelectTrigger> onValueChange={(v) => updateLine(line.key, { itemId: v })}
<SelectContent> placeholder="Search item by SKU or name…"
{items.map((i) => ( emptyText="No items match."
<SelectItem key={i.itemId} value={i.itemId} className="text-base"> className="h-11 text-base"
{i.sku} — {i.name} popupClassName="min-w-96"
</SelectItem> openDownward
))} aria-invalid={!!errors.itemId}
</SelectContent> />
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} /> <FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3 align-top"> <TableCell className="px-3 py-3 align-top">
@@ -574,11 +737,11 @@ export default function PurchaseOrderDetailPage() {
</div> </div>
{editable && ( {editable && (
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/procurement/purchase-orders")}> <Button variant="outline" size="lg" className="w-full sm:w-auto" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
Back Back
</Button> </Button>
<Button size="lg" onClick={handleSave} disabled={saving}> <Button size="lg" className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>
<Save className="size-5" /> <Save className="size-5" />
{saving ? "Saving…" : "Save changes"} {saving ? "Saving…" : "Save changes"}
</Button> </Button>
@@ -2,7 +2,8 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { Check, CheckCircle2, Clock, Eye, ShieldAlert, X } from "lucide-react" import { useRouter } from "next/navigation"
import { Check, CheckCircle2, Clock, ShieldAlert, X } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders" import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
@@ -34,6 +35,7 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
* still shows the queue to non-admins (read-only) so anyone can see what's waiting. * still shows the queue to non-admins (read-only) so anyone can see what's waiting.
*/ */
export default function PurchaseOrderApprovalsPage() { export default function PurchaseOrderApprovalsPage() {
const router = useRouter()
const { isAdmin } = useAuth() const { isAdmin } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null) const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -156,7 +158,14 @@ export default function PurchaseOrderApprovalsPage() {
{pos.map((po) => { {pos.map((po) => {
const vendor = vendorsById.get(po.vendorId) const vendor = vendorsById.get(po.vendorId)
return ( return (
<TableRow key={po.poId} className="align-top"> <TableRow
key={po.poId}
className="align-top cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline"> <Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo} {po.docNo}
@@ -173,7 +182,7 @@ export default function PurchaseOrderApprovalsPage() {
{new Date(po.createdAt).toLocaleString()} {new Date(po.createdAt).toLocaleString()}
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
type="button" type="button"
@@ -195,14 +204,6 @@ export default function PurchaseOrderApprovalsPage() {
<X className="size-4" /> <X className="size-4" />
Reject Reject
</Button> </Button>
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label="View"
title="View"
>
<Eye className="size-4" />
</Link>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -10,6 +10,7 @@ import { requisitionsApi } from "@/lib/api/requisitions"
import { rfqsApi } from "@/lib/api/rfqs" import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms" import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label" import { baseUomLabel } from "@/lib/uom-label"
@@ -19,7 +20,9 @@ import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code" import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement" import { CreatePoLineInput } from "@/types/procurement"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data" import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -33,6 +36,7 @@ import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Combobox } from "@/components/ui/combobox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -66,11 +70,16 @@ function NewPurchaseOrderContent() {
const requisitionId = Number(searchParams.get("requisitionId")) || null const requisitionId = Number(searchParams.get("requisitionId")) || null
const rfqId = Number(searchParams.get("rfqId")) || null const rfqId = Number(searchParams.get("rfqId")) || null
const rfqVendorId = Number(searchParams.get("vendorId")) || null const rfqVendorId = Number(searchParams.get("vendorId")) || null
// The PO's branch follows the purchase-order sub-toggle — off means no branch is asked for
// and the server files the PO on the default branch.
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, purchaseOrderBranchingEnabled: branchingEnabled, warehouseBranchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null) const [uoms, setUoms] = useState<Uom[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [vendors, setVendors] = useState<Vendor[] | null>(null) const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId) const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -86,6 +95,8 @@ function NewPurchaseOrderContent() {
const [vName, setVName] = useState("") const [vName, setVName] = useState("")
const [vTerms, setVTerms] = useState("") const [vTerms, setVTerms] = useState("")
const [vTaxReg, setVTaxReg] = useState("") const [vTaxReg, setVTaxReg] = useState("")
const [vAddress, setVAddress] = useState("")
const [vPhone, setVPhone] = useState("")
const [vCurrency, setVCurrency] = useState("LKR") const [vCurrency, setVCurrency] = useState("LKR")
const [vErrors, setVErrors] = useState<Record<string, string>>({}) const [vErrors, setVErrors] = useState<Record<string, string>>({})
const [vSubmitting, setVSubmitting] = useState(false) const [vSubmitting, setVSubmitting] = useState(false)
@@ -125,6 +136,8 @@ function NewPurchaseOrderContent() {
setVName("") setVName("")
setVTerms("") setVTerms("")
setVTaxReg("") setVTaxReg("")
setVAddress("")
setVPhone("")
setVCurrency("LKR") setVCurrency("LKR")
setVErrors({}) setVErrors({})
} }
@@ -143,6 +156,8 @@ function NewPurchaseOrderContent() {
name: vName, name: vName,
terms: vTerms || null, terms: vTerms || null,
taxReg: vTaxReg || null, taxReg: vTaxReg || null,
address: vAddress || null,
phone: vPhone || null,
currency: vCurrency, currency: vCurrency,
}) })
await loadVendors() await loadVendors()
@@ -211,6 +226,29 @@ function NewPurchaseOrderContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [requisitionId, rfqId, rfqVendorId]) }, [requisitionId, rfqId, rfqVendorId])
// Admin-only branch picker — non-admins never see this control, their own employee
// branch is used directly (mirrors the New GRN / New Sale pattern).
useEffect(() => {
if (branchingEnabled && isAdmin) {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}
}, [branchingEnabled, isAdmin])
// An Admin on a specific header branch is pinned to it here too (shown, not editable) —
// the dropdown only appears when the header itself is on "All Branches", so there's no
// branch to pin to and the Admin must pick one explicitly for this PO.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
// Line warehouses must sit in the PO's branch only when warehouses are divided by branch
// too (mirrors PurchaseOrderService.EnsureLinesInBranchAsync); otherwise every warehouse
// is shared and all of them are offered.
const lineWarehouses =
branchingEnabled && warehouseBranchingEnabled && effectiveBranchId
? (warehouses ?? []).filter((w) => w.branchId === effectiveBranchId)
: (warehouses ?? [])
function updateLine(key: string, patch: Partial<DraftLine>) { function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
} }
@@ -219,6 +257,9 @@ function NewPurchaseOrderContent() {
setLines((prev) => prev.filter((l) => l.key !== key)) setLines((prev) => prev.filter((l) => l.key !== key))
} }
// Searchable item picker options — the label is what the combobox filters on.
const itemOptions = (items ?? []).map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` }))
function itemFor(itemId: number | null) { function itemFor(itemId: number | null) {
return items?.find((i) => i.itemId === itemId) ?? null return items?.find((i) => i.itemId === itemId) ?? null
} }
@@ -231,6 +272,10 @@ function NewPurchaseOrderContent() {
setHeaderError("Select a vendor.") setHeaderError("Select a vendor.")
return return
} }
if (branchingEnabled && !effectiveBranchId) {
setHeaderError("Select a branch for this purchase order.")
return
}
if (lines.length === 0) { if (lines.length === 0) {
setSubmitError("Add at least one line.") setSubmitError("Add at least one line.")
return return
@@ -268,6 +313,7 @@ function NewPurchaseOrderContent() {
requisitionId: requisitionId ?? (rfqId ? undefined : null), requisitionId: requisitionId ?? (rfqId ? undefined : null),
lines: payloadLines, lines: payloadLines,
saveAsDraft, saveAsDraft,
branchId: effectiveBranchId ?? 0,
}) })
toast.success( toast.success(
"Purchase order created", "Purchase order created",
@@ -351,6 +397,14 @@ function NewPurchaseOrderContent() {
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel> <FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" /> <Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field> </Field>
<Field>
<FieldLabel htmlFor="po-v-address">Address (optional)</FieldLabel>
<Input id="po-v-address" value={vAddress} onChange={(e) => setVAddress(e.target.value)} placeholder="No. 12, Main Street, Colombo 03" maxLength={500} />
</Field>
<Field>
<FieldLabel htmlFor="po-v-phone">Phone (optional)</FieldLabel>
<Input id="po-v-phone" type="tel" value={vPhone} onChange={(e) => setVPhone(e.target.value)} placeholder="+94 77 123 4567" maxLength={30} />
</Field>
<Field data-invalid={!!vErrors.currency}> <Field data-invalid={!!vErrors.currency}>
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel> <FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} /> <Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
@@ -369,6 +423,30 @@ function NewPurchaseOrderContent() {
</Dialog> </Dialog>
</div> </div>
</div> </div>
{branchingEnabled && isAdmin && (
<div className="flex flex-col gap-2">
<Label className="text-base">Branch</Label>
{showBranchPicker ? (
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="All branches" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{activeBranchName ?? `Branch #${lockedBranchId}`}
</div>
)}
</div>
)}
{requisitionId && ( {requisitionId && (
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div> <div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
)} )}
@@ -380,9 +458,9 @@ function NewPurchaseOrderContent() {
)} )}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Link <Link
href="/dashboard/products/new" href="/dashboard/products/new"
target="_blank" target="_blank"
@@ -401,61 +479,64 @@ function NewPurchaseOrderContent() {
</div> </div>
{lines.length > 0 && ( {lines.length > 0 && (
<div className="overflow-x-auto"> <>
<Table className="table-fixed text-base"> {/* Mobile: stacked editable cards, one per line. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {lines.map((line) => {
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead> const errors = lineErrors[line.key] ?? {}
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead> const item = itemFor(line.itemId)
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead> return (
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead> <div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead> <div className="flex items-start gap-2">
<TableHead className="h-12 w-10 px-3" /> <div className="flex-1">
</TableRow> <Label className="text-sm">Item</Label>
</TableHeader>
<TableBody>
{lines.map((line) => {
const errors = lineErrors[line.key] ?? {}
const item = itemFor(line.itemId)
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{requisitionId || rfqId ? ( {requisitionId || rfqId ? (
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div> <div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
</div>
) : ( ) : (
<> <>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> <Combobox<number>
<SelectTrigger items={itemOptions}
className="h-11! w-full text-base" value={line.itemId}
aria-invalid={!!errors.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}
title={item ? `${item.sku} — ${item.name}` : undefined} placeholder="Search item by SKU or name…"
> emptyText="No items match."
<SelectValue placeholder="Select item" /> className="h-11 text-base"
</SelectTrigger> openDownward
<SelectContent> aria-invalid={!!errors.itemId}
{(items ?? []).map((i) => ( />
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} /> <FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</> </>
)} )}
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> <Button
type="button"
variant="ghost"
size="icon"
className="mt-6 shrink-0"
onClick={() => removeLine(line.key)}
aria-label="Remove line"
>
<Trash2 className="size-5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">UOM</Label>
<div className="flex h-11 items-center text-base text-muted-foreground"> <div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)} {baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div> </div>
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> <div>
<Label className="text-sm">Warehouse</Label>
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> <Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}> <SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
<SelectValue placeholder="Warehouse" /> <SelectValue placeholder="Warehouse" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(warehouses ?? []).map((w) => ( {lineWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base"> <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} {w.code}
</SelectItem> </SelectItem>
@@ -463,42 +544,139 @@ function NewPurchaseOrderContent() {
</SelectContent> </SelectContent>
</Select> </Select>
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} /> <FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> </div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Qty</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
step="any" step="any"
inputMode="decimal"
value={line.qty} value={line.qty}
aria-invalid={!!errors.qty} aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })} onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base" className="h-11 text-base"
/> />
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} /> <FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> <div>
<Label className="text-sm">Unit price</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
step="any" step="any"
inputMode="decimal"
value={line.unitPrice} value={line.unitPrice}
aria-invalid={!!errors.unitPrice} aria-invalid={!!errors.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })} onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
className="h-11 text-base" className="h-11 text-base"
/> />
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} /> <FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> </div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line"> </div>
<Trash2 className="size-5" /> )
</Button> })}
</TableCell> </div>
</TableRow>
) {/* Desktop: table. */}
})} <div className="hidden overflow-x-auto md:block">
</TableBody> <Table className="table-fixed text-base">
</Table> <TableHeader>
</div> <TableRow>
<TableHead className="h-12 w-80 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const errors = lineErrors[line.key] ?? {}
const item = itemFor(line.itemId)
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{requisitionId || rfqId ? (
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
) : (
<>
<Combobox<number>
items={itemOptions}
value={line.itemId}
onValueChange={(v) => updateLine(line.key, { itemId: v })}
placeholder="Search item by SKU or name…"
emptyText="No items match."
className="h-11 text-base"
popupClassName="min-w-96"
openDownward
aria-invalid={!!errors.itemId}
/>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
<SelectValue placeholder="Warehouse" />
</SelectTrigger>
<SelectContent>
{lineWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.unitPrice}
aria-invalid={!!errors.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</>
)} )}
</div> </div>
@@ -506,14 +684,14 @@ function NewPurchaseOrderContent() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" variant="outline" onClick={() => handleSubmit(true)} disabled={submitting}> <Button size="lg" type="button" variant="outline" className="w-full sm:w-auto" onClick={() => handleSubmit(true)} disabled={submitting}>
{submitting ? "Saving…" : "Save as draft"} {submitting ? "Saving…" : "Save as draft"}
</Button> </Button>
<Button size="lg" type="button" onClick={() => handleSubmit(false)} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? "Creating…" : "Create & submit"} {submitting ? "Creating…" : "Create & submit"}
</Button> </Button>
</div> </div>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react" import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, Eye, PackageCheck, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
@@ -19,6 +20,7 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
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 { PaginationFooter } from "@/components/ui/pagination-footer"
import { KpiRow } from "@/components/ui/kpi-row" import { KpiRow } from "@/components/ui/kpi-row"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -28,11 +30,17 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = PurchaseOrderStatus | "All" type StatusFilter = PurchaseOrderStatus | "All"
const PAGE_SIZE = 10 const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function PurchaseOrdersListPage() { export default function PurchaseOrdersListPage() {
const router = useRouter()
const stats = useDashboardStats() const stats = useDashboardStats()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth() // Every branch picker/column/filter on this screen follows the purchase-order sub-toggle,
// not the global one — off means POs are one shared pool across branches.
const { isAdmin, activeBranchId, purchaseOrderBranchingEnabled: branchingEnabled } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null) const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([]) const [vendors, setVendors] = useState<Vendor[]>([])
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
@@ -43,8 +51,10 @@ export default function PurchaseOrdersListPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [deletingId, setDeletingId] = useState<number | null>(null) const [deletingId, setDeletingId] = useState<number | null>(null)
const [submittingId, setSubmittingId] = useState<number | null>(null) const [submittingId, setSubmittingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -56,15 +66,21 @@ export default function PurchaseOrdersListPage() {
function load() { function load() {
setError(null) setError(null)
purchaseOrdersApi purchaseOrdersApi
.list({ page, pageSize: PAGE_SIZE, q: query || undefined, status: status === "All" ? undefined : status }) .list({ page, pageSize, q: query || undefined, status: status === "All" ? undefined : status })
.then((res) => { .then((res) => {
setPos(res.items) setPos(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [page, query, status]) function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, query, status])
useEffect(() => { useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {}) vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
}, []) }, [])
@@ -76,8 +92,12 @@ export default function PurchaseOrdersListPage() {
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([])) if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [isAdmin, branchingEnabled]) }, [isAdmin, branchingEnabled])
function vendorOf(vendorId: number) {
return vendors.find((v) => v.vendorId === vendorId)
}
function vendorCode(vendorId: number) { function vendorCode(vendorId: number) {
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}` return vendorOf(vendorId)?.code ?? `#${vendorId}`
} }
function branchName(branchId: number) { function branchName(branchId: number) {
@@ -118,22 +138,53 @@ export default function PurchaseOrdersListPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1> <div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</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">Draft, freely editable; submitting sends it for approval before it&apos;s issued (FR-PROC-03..05).</p> <p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it&apos;s issued (FR-PROC-03..05).</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Link href="/dashboard/procurement/purchase-orders/approvals" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link
href="/dashboard/procurement/purchase-orders/approvals"
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
>
<Clock className="size-5" /> <Clock className="size-5" />
Approvals queue Approvals queue
</Link> </Link>
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New PO New PO
</Link> </Link>
</div> </div>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} /> <KpiRow
tiles={
stats
? [
{
label: "Pending Approval",
value: stats.pendingApprovalPurchaseOrders,
icon: Clock,
active: status === "PendingApproval",
onClick: () => setStatus((s) => (s === "PendingApproval" ? "All" : "PendingApproval")),
},
{
label: "Fully Received",
value: stats.fullyReceivedPurchaseOrders,
icon: PackageCheck,
active: status === "FullyReceived",
onClick: () => setStatus((s) => (s === "FullyReceived" ? "All" : "FullyReceived")),
},
]
: null
}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input <Input
@@ -170,9 +221,21 @@ export default function PurchaseOrdersListPage() {
)} )}
{!error && pos === null && ( {!error && pos === null && (
<div className="flex flex-col gap-3"> <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) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -186,45 +249,201 @@ export default function PurchaseOrdersListPage() {
{!error && visiblePos !== null && visiblePos.length > 0 && ( {!error && visiblePos !== null && visiblePos.length > 0 && (
<> <>
<Table className="text-base"> {/* Mobile: stacked cards. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> {visiblePos.map((po) => {
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> const editable = isPoEditable(po.status)
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead> return (
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <div
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead> key={po.poId}
{isAdmin && branchingEnabled && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>} role="link"
<TableHead className="h-12 px-3 text-sm">Created</TableHead> tabIndex={0}
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead> onClick={(e) => {
</TableRow> if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
</TableHeader> router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{po.docNo}</p>
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
{vendorOf(po.vendorId)?.name && (
<p className="truncate text-sm text-foreground">{vendorOf(po.vendorId)?.name}</p>
)}
</div>
<PoStatusBadge status={po.status} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Grand total</p>
<p className="font-medium text-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(po.createdAt).toLocaleDateString()}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Details</p>
{vendorOf(po.vendorId)?.address || vendorOf(po.vendorId)?.phone ? (
<>
{vendorOf(po.vendorId)?.address && <p className="font-medium text-foreground">{vendorOf(po.vendorId)?.address}</p>}
{vendorOf(po.vendorId)?.phone && <p className="font-medium text-foreground">{vendorOf(po.vendorId)?.phone}</p>}
</>
) : (
<p className="font-medium text-foreground">—</p>
)}
</div>
{isAdmin && branchingEnabled && (
<div className="col-span-2">
<p className="text-muted-foreground">Branch</p>
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
{branchName(po.branchId)}
</Badge>
</div>
)}
</div>
{editable && (
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="Edit draft"
title="Edit draft"
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Submit for approval"
title="Submit for approval"
disabled={submittingId === po.poId || deletingId === po.poId}
className="text-success hover:bg-success/10 hover:text-success"
/>
}
>
<Send className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="success"
title={`Submit ${po.docNo} for approval?`}
description="It will be locked for editing while it awaits an Admin's approval."
confirmLabel="Submit"
onConfirm={() => handleSubmit(po)}
/>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Delete draft"
title="Delete draft"
disabled={deletingId === po.poId || submittingId === po.poId}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete draft ${po.docNo}?`}
description="This cannot be undone."
confirmLabel="Delete"
onConfirm={() => handleDelete(po)}
/>
</AlertDialog>
</div>
)}
</div>
)
})}
{pagination && pos !== null && pos.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-48">Doc No</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Details</TableHead>
<TableHead>Status</TableHead>
<TableHead>Grand total</TableHead>
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
<TableHead>Created</TableHead>
<TableHead className="w-44 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody> <TableBody>
{visiblePos.map((po) => { {visiblePos.map((po) => {
const editable = isPoEditable(po.status) const editable = isPoEditable(po.status)
return ( return (
<TableRow key={po.poId}> <TableRow
<TableCell className="px-3 py-3.5"> key={po.poId}
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline"> className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
>
<TableCell>
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-semibold text-foreground hover:underline">
{po.docNo} {po.docNo}
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell> <TableCell>
<TableCell className="px-3 py-3.5"> <p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
{vendorOf(po.vendorId)?.name && (
<p className="text-sm font-medium text-foreground">{vendorOf(po.vendorId)?.name}</p>
)}
</TableCell>
<TableCell className="max-w-64 whitespace-normal text-sm text-muted-foreground">
{vendorOf(po.vendorId)?.address || vendorOf(po.vendorId)?.phone ? (
<>
{vendorOf(po.vendorId)?.address && <p>{vendorOf(po.vendorId)?.address}</p>}
{vendorOf(po.vendorId)?.phone && <p className="whitespace-nowrap">{vendorOf(po.vendorId)?.phone}</p>}
</>
) : (
"—"
)}
</TableCell>
<TableCell>
<PoStatusBadge status={po.status} /> <PoStatusBadge status={po.status} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell> <TableCell className="text-sm text-muted-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
{isAdmin && branchingEnabled && ( {isAdmin && branchingEnabled && (
<TableCell className="px-3 py-3.5"> <TableCell>
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge> <Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge>
</TableCell> </TableCell>
)} )}
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell data-no-row-nav>
<div className="flex items-center gap-1"> <div className="flex items-center justify-center gap-1">
<Link <Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`} href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="View" aria-label="View purchase order"
title="View" title="View"
> >
<Eye className="size-4" /> <Eye className="size-4" />
@@ -296,30 +515,17 @@ export default function PurchaseOrdersListPage() {
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && pagination.totalPages > 1 && ( {pagination && (
<div className="flex items-center justify-between"> <div className="hidden md:block">
<p className="text-sm text-muted-foreground"> <PaginationFooter
Showing {(pagination.page - 1) * pagination.pageSize + 1}– pagination={pagination}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} onPageChange={setPage}
</p> pageSizeOptions={PAGE_SIZE_OPTIONS}
<div className="flex items-center gap-2"> onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}> />
<ChevronLeft />
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
<ChevronRight />
</Button>
</div>
</div> </div>
)} )}
</> </>
@@ -210,49 +210,99 @@ function NewPurchaseReturnContent() {
{selectedGrn && ( {selectedGrn && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Lines received on {selectedGrn.docNo}</h2> <h2 className="text-base font-semibold text-foreground">Lines received on {selectedGrn.docNo}</h2>
<Table className="text-base">
<TableHeader> {/* Mobile: stacked cards. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 w-10 px-3" /> {selectedGrn.lines.map((line) => {
<TableHead className="h-12 px-3 text-sm">Item</TableHead> const item = itemFor(line.itemId)
<TableHead className="h-12 px-3 text-sm">Received qty</TableHead> const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead> const errors = lineErrors[line.grnLineId] ?? {}
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead> return (
</TableRow> <div key={line.grnLineId} className="flex flex-col gap-3 rounded-xl border p-4">
</TableHeader> <label className="flex items-start gap-3">
<TableBody> <Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} className="mt-0.5 shrink-0" />
{selectedGrn.lines.map((line) => { <span className="min-w-0 truncate text-base font-medium text-foreground">
const item = itemFor(line.itemId) {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" } </span>
const errors = lineErrors[line.grnLineId] ?? {} </label>
return (
<TableRow key={line.grnLineId}> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<TableCell className="px-3 py-3.5"> <div>
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} /> <p className="text-muted-foreground">Received qty</p>
</TableCell> <p className="font-medium text-foreground">{line.qty}</p>
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell> </div>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell> <div>
<TableCell className="px-3 py-3.5"> <p className="text-muted-foreground">Hold status</p>
<HoldStatusBadge status={line.holdStatus} /> <HoldStatusBadge status={line.holdStatus} />
</TableCell> </div>
<TableCell className="px-3 py-3 align-top"> </div>
<Input
type="number" <div>
min="0" <Label className="text-sm">Return qty</Label>
step="any" <Input
value={state.qty} type="number"
disabled={!state.selected} min="0"
aria-invalid={!!errors.qty} step="any"
onChange={(e) => setQty(line.grnLineId, e.target.value)} inputMode="decimal"
className="h-11 text-base" value={state.qty}
/> disabled={!state.selected}
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} /> aria-invalid={!!errors.qty}
</TableCell> onChange={(e) => setQty(line.grnLineId, e.target.value)}
</TableRow> className="h-11 text-base"
) />
})} <FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableBody> </div>
</Table> </div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3" />
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Received qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedGrn.lines.map((line) => {
const item = itemFor(line.itemId)
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.grnLineId] ?? {}
return (
<TableRow key={line.grnLineId}>
<TableCell className="px-3 py-3.5">
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} />
</TableCell>
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">
<HoldStatusBadge status={line.holdStatus} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={state.qty}
disabled={!state.selected}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(line.grnLineId, e.target.value)}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div> </div>
)} )}
@@ -260,11 +310,11 @@ function NewPurchaseReturnContent() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Return"} {submitting ? "Posting…" : "Post Return"}
</Button> </Button>
</div> </div>
@@ -9,31 +9,70 @@ import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes" import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { PaginationMeta } from "@/types/common"
import { PurchaseReturnSummary } from "@/types/procurement" import { PurchaseReturnSummary } from "@/types/procurement"
import { Vendor, Warehouse } from "@/types/master-data" import { Vendor, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock" import { ReasonCode } from "@/types/stock"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function PurchaseReturnsListPage() { export default function PurchaseReturnsListPage() {
const [returns, setReturns] = useState<PurchaseReturnSummary[] | null>(null) const [returns, setReturns] = useState<PurchaseReturnSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([]) const [vendors, setVendors] = useState<Vendor[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([]) const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([purchaseReturnsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")]) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
.then(([r, v, w, rc]) => { return () => clearTimeout(timeout)
setReturns(r.items) }, [searchInput])
useEffect(() => setPage(1), [query])
function load() {
setError(null)
purchaseReturnsApi
.list({ page, pageSize, q: query || undefined })
.then((res) => {
setReturns(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, query])
useEffect(() => {
Promise.all([vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
.then(([v, w, rc]) => {
setVendors(v.items) setVendors(v.items)
setWarehouses(w.items) setWarehouses(w.items)
setReasonCodes(rc.items) setReasonCodes(rc.items)
}) })
.catch((err) => setError(errorMessage(err))) .catch(() => {})
}, []) }, [])
function vendorCode(id: number) { function vendorCode(id: number) {
@@ -46,6 +85,8 @@ export default function PurchaseReturnsListPage() {
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}` return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
} }
const hasFilters = query.length > 0
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">
@@ -53,20 +94,42 @@ export default function PurchaseReturnsListPage() {
<h1 className="text-2xl font-bold text-foreground">Purchase Returns</h1> <h1 className="text-2xl font-bold text-foreground">Purchase Returns</h1>
<p className="text-base text-muted-foreground">Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).</p> <p className="text-base text-muted-foreground">Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).</p>
</div> </div>
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Return New Return
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no., vendor…"
className="h-12 w-full flex-1 basis-0 text-base"
aria-label="Search purchase returns"
/>
</div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && returns === null && ( {!error && returns === null && (
<div className="flex flex-col gap-3"> <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: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -74,45 +137,107 @@ export default function PurchaseReturnsListPage() {
{!error && returns !== null && returns.length === 0 && ( {!error && returns !== null && returns.length === 0 && (
<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">
<PackageX className="size-12 text-muted-foreground" /> <PackageX className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No purchase returns yet.</p> <p className="text-base text-muted-foreground">{hasFilters ? "No purchase returns match your search." : "No purchase returns yet."}</p>
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> {!hasFilters && (
<Plus className="size-5" /> <Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
New Return <Plus className="size-5" />
</Link> New Return
</Link>
)}
</div> </div>
)} )}
{!error && returns !== null && returns.length > 0 && ( {!error && returns !== null && returns.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{returns.map((r) => ( {returns.map((r) => (
<TableRow key={r.returnId}> <div key={r.returnId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5">{vendorCode(r.vendorId)}</TableCell> <div className="min-w-0">
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell> <p className="truncate font-semibold text-foreground">{r.docNo}</p>
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell> <p className="text-sm text-muted-foreground">{vendorCode(r.vendorId)}</p>
<TableCell className="px-3 py-3.5"> </div>
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success"> <Badge variant="outline" className="h-6 w-fit shrink-0 justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status} {r.status}
</Badge> </Badge>
</TableCell> </div>
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
</TableRow> <div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{warehouseCode(r.warehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">Reason</p>
<p className="font-medium text-foreground">{reasonLabel(r.reasonCodeId)}</p>
</div>
<div>
<p className="text-muted-foreground">GL Journal</p>
<p className="font-medium text-foreground">{r.glJournalNo ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(r.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
))} ))}
</TableBody>
</Table> {pagination && returns !== null && returns.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-48">Doc No</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Status</TableHead>
<TableHead>GL Journal</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{returns.map((r) => (
<TableRow key={r.returnId} className="align-top hover:bg-muted/20">
<TableCell className="font-semibold text-foreground">{r.docNo}</TableCell>
<TableCell className="text-sm text-muted-foreground">{vendorCode(r.vendorId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{warehouseCode(r.warehouseId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{reasonLabel(r.reasonCodeId)}</TableCell>
<TableCell>
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)} )}
</div> </div>
) )
@@ -7,15 +7,19 @@ import { Plus, Trash2 } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions" import { requisitionsApi } from "@/lib/api/requisitions"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateRequisitionLine } from "@/lib/validations/procurement" import { validateRequisitionLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CreateReqLineInput } from "@/types/procurement" import { CreateReqLineInput } from "@/types/procurement"
import { ItemListItem } from "@/types/master-data" import { ItemListItem } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { FieldError } from "@/components/ui/field" import { FieldError } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@@ -40,6 +44,7 @@ function emptyLine(): DraftLine {
export default function NewRequisitionPage() { export default function NewRequisitionPage() {
const router = useRouter() const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -47,11 +52,25 @@ export default function NewRequisitionPage() {
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({}) const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err))) itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err)))
}, []) }, [])
// Admin-only branch picker — non-admins never see this control, their own employee
// branch is used directly (mirrors the New GRN / New Sale pattern).
useEffect(() => {
if (branchingEnabled && isAdmin) {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}
}, [branchingEnabled, isAdmin])
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function updateLine(key: string, patch: Partial<DraftLine>) { function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
} }
@@ -67,6 +86,10 @@ export default function NewRequisitionPage() {
setSubmitError("Add at least one line.") setSubmitError("Add at least one line.")
return return
} }
if (branchingEnabled && !effectiveBranchId) {
setSubmitError("Select a branch for this requisition.")
return
}
const nextLineErrors: Record<string, Record<string, string>> = {} const nextLineErrors: Record<string, Record<string, string>> = {}
for (const line of lines) { for (const line of lines) {
@@ -87,7 +110,7 @@ export default function NewRequisitionPage() {
setSubmitting(true) setSubmitting(true)
try { try {
const requisition = await requisitionsApi.create({ lines: payloadLines }) const requisition = await requisitionsApi.create({ lines: payloadLines, branchId: effectiveBranchId ?? 0 })
toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`) toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`)
router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`) router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`)
} catch (err) { } catch (err) {
@@ -117,6 +140,30 @@ export default function NewRequisitionPage() {
{!loading && ( {!loading && (
<> <>
{branchingEnabled && isAdmin && (
<div className="flex flex-col gap-2 sm:max-w-xs">
<Label className="text-base">Branch</Label>
{showBranchPicker ? (
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="All branches" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{activeBranchName ?? `Branch #${lockedBranchId}`}
</div>
)}
</div>
)}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
@@ -14,8 +14,9 @@ import { cn } from "@/lib/utils"
import { useDashboardStats } from "@/hooks/use-dashboard-stats" import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { KpiRow } from "@/components/ui/kpi-row" import { KpiRow } from "@/components/ui/kpi-row"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -23,7 +24,7 @@ import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = RequisitionStatus | "All" type StatusFilter = RequisitionStatus | "All"
const PAGE_SIZE = 10 const PAGE_SIZE = 25
export default function RequisitionsListPage() { export default function RequisitionsListPage() {
const stats = useDashboardStats() const stats = useDashboardStats()
@@ -77,7 +78,21 @@ export default function RequisitionsListPage() {
</Link> </Link>
</div> </div>
<KpiRow tiles={stats ? [{ label: "Open Requisitions", value: stats.openRequisitions, icon: Send }] : null} /> <KpiRow
tiles={
stats
? [
{
label: "Open Requisitions",
value: stats.openRequisitions,
icon: Send,
active: status === "Submitted",
onClick: () => setStatus((s) => (s === "Submitted" ? "All" : "Submitted")),
},
]
: null
}
/>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}> <Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
@@ -152,29 +167,7 @@ export default function RequisitionsListPage() {
</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>
@@ -9,6 +9,7 @@ import { productionRunsApi } from "@/lib/api/production-runs"
import { productionTemplatesApi } from "@/lib/api/production-templates" import { productionTemplatesApi } from "@/lib/api/production-templates"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { PaginationMeta } from "@/types/common"
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production" import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
@@ -16,6 +17,7 @@ import { Badge } from "@/components/ui/badge"
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 { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
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, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -34,6 +36,10 @@ function formatCost(value: number) {
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 }) return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
} }
// The produced list comes back whole (no server paging), so pages are sliced client-side from
// the already-loaded lines. The summary totals still cover the whole filtered list.
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
export default function ProducedItemsPage() { export default function ProducedItemsPage() {
const router = useRouter() const router = useRouter()
@@ -49,6 +55,8 @@ export default function ProducedItemsPage() {
const [templateId, setTemplateId] = useState<number | null>(null) const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null) const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const load = useCallback(() => { const load = useCallback(() => {
setLoadError(null) setLoadError(null)
@@ -60,7 +68,11 @@ export default function ProducedItemsPage() {
warehouseId: warehouseId ?? undefined, warehouseId: warehouseId ?? undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
}) })
.then(setData) .then((res) => {
setData(res)
// A new filter is a new list — start back at the first page.
setPage(1)
})
.catch((err) => { .catch((err) => {
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } }) setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
setLoadError(errorMessage(err)) setLoadError(errorMessage(err))
@@ -80,26 +92,37 @@ export default function ProducedItemsPage() {
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All" const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
const totalItems = data?.items.length ?? 0
const pagination: PaginationMeta = {
page,
pageSize,
totalItems,
totalPages: Math.max(1, Math.ceil(totalItems / pageSize)),
}
const pageItems = data?.items.slice((page - 1) * pageSize, page * pageSize) ?? []
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Produced Items</h1>
<p className="text-base text-muted-foreground"> <p className="text-sm text-muted-foreground sm:text-base">
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13). Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
</p> </p>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5"> {/* Two-up on phones (date range side by side, then template/warehouse), status full width —
<div className="flex flex-col gap-1.5"> five stacked labelled controls would push the results well below the fold. */}
<Label className="text-base">From</Label> <div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" /> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-sm sm:text-base">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 min-w-0 text-base" />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-base">To</Label> <Label className="text-sm sm:text-base">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" /> <Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 min-w-0 text-base" />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-base">Template</Label> <Label className="text-sm sm:text-base">Template</Label>
<Select<number | null> value={templateId} onValueChange={setTemplateId}> <Select<number | null> value={templateId} onValueChange={setTemplateId}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All templates" /> <SelectValue placeholder="All templates" />
@@ -111,8 +134,8 @@ export default function ProducedItemsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-base">Warehouse</Label> <Label className="text-sm sm:text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}> <Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All warehouses" /> <SelectValue placeholder="All warehouses" />
@@ -124,8 +147,8 @@ export default function ProducedItemsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="col-span-2 flex min-w-0 flex-col gap-1.5 lg:col-span-1">
<Label className="text-base">Status</Label> <Label className="text-sm sm:text-base">Status</Label>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}> <Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue /> <SelectValue />
@@ -175,67 +198,148 @@ export default function ProducedItemsPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto rounded-xl border"> <>
<Table className="text-base"> {/* Mobile: totals first (the figure people come here for), then one card per line. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> <div className="rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <p className="text-sm font-semibold text-foreground">
<TableHead className="h-12 px-3 text-sm">Status</TableHead> Total · {data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"}
<TableHead className="h-12 px-3 text-sm">Template</TableHead> </p>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead> <p className="mt-1 text-xs text-muted-foreground">Cost of production</p>
<TableHead className="h-12 px-3 text-sm">Item</TableHead> <p className="text-xl font-bold text-foreground tabular-nums">{formatCost(data.summary.totalCost)}</p>
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead> <div className="mt-3 grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead> <div>
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead> <p className="text-muted-foreground">Produced</p>
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead> <p className="font-semibold text-foreground tabular-nums">{data.summary.totalProducedQty.toLocaleString()}</p>
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead> </div>
<TableHead className="h-12 px-3 text-sm">Completed</TableHead> <div>
</TableRow> <p className="text-muted-foreground">Scrapped</p>
</TableHeader> <p className="font-semibold text-foreground tabular-nums">{data.summary.totalScrappedQty.toLocaleString()}</p>
<TableBody> </div>
{data.items.map((line) => ( <div>
<TableRow <p className="text-muted-foreground">Good</p>
key={line.runId} <p className="font-semibold text-foreground tabular-nums">{data.summary.totalGoodQty.toLocaleString()}</p>
className="cursor-pointer" </div>
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)} </div>
> </div>
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
<TableCell className="px-3 py-3.5"> {pageItems.map((line) => (
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}> <button
{line.status === "InProgress" ? "In Progress" : line.status} key={line.runId}
</Badge> type="button"
</TableCell> onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell> className="flex flex-col gap-3 rounded-xl border bg-card p-4 text-left shadow-sm active:bg-muted/50"
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell> >
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell> <div className="flex w-full items-start justify-between gap-2">
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell> <div className="min-w-0">
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell> <p className="truncate font-semibold text-foreground">{line.docNo}</p>
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell> <p className="truncate text-sm text-muted-foreground">{line.itemName}</p>
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell> </div>
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell> <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
<TableCell className="px-3 py-3.5 text-muted-foreground"> {line.status === "InProgress" ? "In Progress" : line.status}
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"} </Badge>
</TableCell> </div>
<div className="grid w-full grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Produced</p>
<p className="font-medium text-foreground tabular-nums">{line.producedQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Scrapped</p>
<p className="font-medium text-foreground tabular-nums">{line.scrappedQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Good</p>
<p className="font-medium text-foreground tabular-nums">{line.goodQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Unit cost</p>
<p className="font-medium text-foreground tabular-nums">{formatCost(line.unitCost)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Cost of production</p>
<p className="font-semibold text-foreground tabular-nums">{formatCost(line.totalCost)}</p>
</div>
</div>
<p className="w-full truncate border-t pt-3 text-xs text-muted-foreground">
{line.templateName} · {line.warehouseName}
{line.completedAt && ` · Completed ${new Date(line.completedAt).toLocaleDateString()}`}
</p>
</button>
))}
</div>
<div className="hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
<TableFooter> {pageItems.map((line) => (
<TableRow> <TableRow
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}> key={line.runId}
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"}) className="cursor-pointer"
</TableCell> onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell> >
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell> <TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell> <TableCell className="px-3 py-3.5">
<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", runStatusBadgeClass(line.status))}>
<TableCell className="px-3 py-3.5 text-right font-semibold"> {line.status === "InProgress" ? "In Progress" : line.status}
Cost of Production: {formatCost(data.summary.totalCost)} </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5" /> <TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
</TableRow> <TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
</TableFooter> <TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
</Table> <TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
</div> <TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
<TableCell className="px-3 py-3.5">
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5" />
<TableCell className="px-3 py-3.5 text-right font-semibold">
Cost of Production: {formatCost(data.summary.totalCost)}
</TableCell>
<TableCell className="px-3 py-3.5" />
</TableRow>
</TableFooter>
</Table>
</div>
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</>
)} )}
</div> </div>
) )
@@ -140,7 +140,7 @@ export function RunActions({
return ( return (
<> <>
<div className="flex flex-wrap gap-2"> <div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}> <Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}>
<Undo2 className="size-4" /> <Undo2 className="size-4" />
Return leftover Return leftover
@@ -19,6 +19,7 @@ import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock" import { ReasonCode } from "@/types/stock"
import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors" import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors"
import { import {
RunStageCardBody,
RunHeaderNodeComponent, RunHeaderNodeComponent,
RunStageNodeComponent, RunStageNodeComponent,
type RunHeaderData, type RunHeaderData,
@@ -50,7 +51,75 @@ const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeCom
*/ */
const HEADER_GAP_X = 240 const HEADER_GAP_X = 240
function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } { /** Card data per stage, keyed by runStageId — shared by the canvas nodes and the phone list. */
function buildStageData(run: ProductionRunGraph): Map<number, RunStageData> {
// The stage the operator is expected to act on: the furthest-along actionable one, so a run
// mid-flight highlights the stage in progress rather than the first thing still Waiting.
const actionable = ["InProgress", "Done", "Ready"] as const
const activeId =
run.status === "InProgress"
? actionable.reduce<number | null>(
(found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
null
)
: null
return new Map(
run.stages.map((stage) => {
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
return [
stage.runStageId,
{
name: stage.name,
roleLabel: stage.roleLabel,
state: stage.status,
isTerminal: stage.isTerminal,
isEntry: stage.isEntry,
estimatedMinutes: stage.estimatedMinutes,
actualMinutes: stage.actualMinutes,
actualStartAt: stage.actualStartAt,
intake:
upstream.length === 0
? null
: {
delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
},
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
isActive: stage.runStageId === activeId,
} satisfies RunStageData,
]
})
)
}
/**
* Stages in flow order for the phone list: a topological sort, breaking ties by canvas position
* (left to right, then top to bottom) so parallel branches read the way they were drawn.
*/
function flowOrder(run: ProductionRunGraph): RunStage[] {
const byPosition = (a: RunStage, b: RunStage) => a.posX - b.posX || a.posY - b.posY
const inDegree = new Map(run.stages.map((s) => [s.runStageId, 0]))
for (const e of run.edges) inDegree.set(e.childRunStageId, (inDegree.get(e.childRunStageId) ?? 0) + 1)
const ready = run.stages.filter((s) => inDegree.get(s.runStageId) === 0)
const ordered: RunStage[] = []
while (ready.length > 0) {
ready.sort(byPosition)
const stage = ready.shift()!
ordered.push(stage)
for (const e of run.edges.filter((e) => e.parentRunStageId === stage.runStageId)) {
const next = (inDegree.get(e.childRunStageId) ?? 0) - 1
inDegree.set(e.childRunStageId, next)
if (next === 0) ready.push(run.stages.find((s) => s.runStageId === e.childRunStageId)!)
}
}
// The server guarantees a DAG, but never drop a stage from view if that ever fails to hold.
const seen = new Set(ordered.map((s) => s.runStageId))
return [...ordered, ...run.stages.filter((s) => !seen.has(s.runStageId)).sort(byPosition)]
}
function buildFlow(run: ProductionRunGraph, stageData: Map<number, RunStageData>): { nodes: Node[]; edges: Edge[] } {
const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0 const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0
const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0 const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0
@@ -64,42 +133,12 @@ function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
}, },
] ]
// The stage the operator is expected to act on: the furthest-along actionable one, so a run
// mid-flight highlights the stage in progress rather than the first thing still Waiting.
const actionable = ["InProgress", "Done", "Ready"] as const
const activeId =
run.status === "InProgress"
? actionable.reduce<number | null>(
(found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
null
)
: null
for (const stage of run.stages) { for (const stage of run.stages) {
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
nodes.push({ nodes.push({
id: String(stage.runStageId), id: String(stage.runStageId),
type: "runStage", type: "runStage",
position: { x: stage.posX, y: stage.posY }, position: { x: stage.posX, y: stage.posY },
data: { data: stageData.get(stage.runStageId)!,
name: stage.name,
roleLabel: stage.roleLabel,
state: stage.status,
isTerminal: stage.isTerminal,
isEntry: stage.isEntry,
estimatedMinutes: stage.estimatedMinutes,
actualMinutes: stage.actualMinutes,
actualStartAt: stage.actualStartAt,
intake:
upstream.length === 0
? null
: {
delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
},
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
isActive: stage.runStageId === activeId,
} satisfies RunStageData,
draggable: false, draggable: false,
}) })
} }
@@ -173,10 +212,12 @@ export default function ProductionRunDetailPage() {
}) })
}, []) }, [])
const stageData = useMemo(() => (run ? buildStageData(run) : new Map<number, RunStageData>()), [run])
const { nodes, edges } = useMemo( const { nodes, edges } = useMemo(
() => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }), () => (run ? buildFlow(run, stageData) : { nodes: [] as Node[], edges: [] as Edge[] }),
[run] [run, stageData]
) )
const orderedStages = useMemo(() => (run ? flowOrder(run) : []), [run])
const stageSummary: StageSummary = useMemo(() => { const stageSummary: StageSummary = useMemo(() => {
const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 } const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 }
@@ -213,9 +254,9 @@ export default function ProductionRunDetailPage() {
if (!run) { if (!run) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Skeleton className="h-10 w-64" /> <Skeleton className="h-10 w-full max-w-64" />
<Skeleton className="h-40 w-full rounded-2xl" /> <Skeleton className="h-40 w-full rounded-2xl" />
<Skeleton className="h-[45vh] w-full rounded-2xl" /> <Skeleton className="h-64 w-full rounded-2xl md:h-[45vh]" />
</div> </div>
) )
} }
@@ -292,9 +333,38 @@ export default function ProductionRunDetailPage() {
<StageStatusLegend /> <StageStatusLegend />
</div> </div>
<p className="text-sm text-muted-foreground">Click a stage to open it.</p> <p className="text-sm text-muted-foreground">
<span className="md:hidden">Tap a stage to open it.</span>
<span className="hidden md:inline">Click a stage to open it.</span>
</p>
<div className="h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10"> {/* Phones get the stages as a list in flow order — the operator on the floor needs to
reach "their" stage in one tap, not pan and pinch around a canvas. */}
<ol className="flex flex-col gap-3 md:hidden">
{orderedStages.map((stage, i) => {
const data = stageData.get(stage.runStageId)!
return (
<li key={stage.runStageId} className="flex gap-3">
<span className="mt-3 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground tabular-nums">
{i + 1}
</span>
<button
type="button"
onClick={() => setSelectedStageId(stage.runStageId)}
className={cn(
"min-w-0 flex-1 rounded-2xl bg-card p-3 text-left shadow-sm ring-2 transition-colors active:bg-muted/50",
selectedStageId === stage.runStageId ? "ring-primary" : "ring-foreground/10"
)}
style={data.isActive && selectedStageId !== stage.runStageId ? { boxShadow: `0 0 0 3px ${STAGE_STATUS_COLOR[data.state]}` } : undefined}
>
<RunStageCardBody data={data} />
</button>
</li>
)
})}
</ol>
<div className="hidden h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
{mounted ? ( {mounted ? (
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}

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