# HR/Finance Phase 1b — Workflow loops + the §3 pages Sprint 1a didn't ship > Plan: `.harness/plans/hr-finance-fiscalisation.md` §3b > Branch: `feature/hr-finance-phase-1b` (worktree `.worktrees/hr-finance-phase-1b/`) > Owners: backend-expert (B9, B10, B11) → frontend-expert (F1–F6). ## What landed Two pieces: backend closes the approval state machine and adds a `?status=` filter contract; frontend ships the four §3 portal pages Sprint 1a deferred plus the inline approve/reject UI on Expenses. ### Backend — B9, B10, B11 | ID | Endpoint | File | Notes | |---|---|---|---| | B9 | `PUT /api/finance/expenses/:id/approve` | `server/src/controllers/finance.controller.js` | Idempotent state machine. 409 when current status ≠ `pending`. Sets `sync_status='pending'`, records `approved_by`, `approved_at`. | | B9 | `PUT /api/finance/expenses/:id/reject` | same | Body: `{ reason }` (required, ≥1 char). 400 on empty. 409 on non-`pending`. Records `rejection_reason`. | | B10 | `GET /api/finance/expenses/summary` | same | Returns `{ pending_count, pending_amount, approved_this_month:{count,total}, rejected_this_month:{count,total} }`. Powers the new "Approval queue" tiles on `Expenses.tsx` and the Finance dashboard. | | B11 | `?status=` filter on `/api/hr/leave-requests` | `server/src/controllers/hr.controller.js` | Was missing — added. Filter applies on top of the privileged-vs-self scope. | | B11 | `?status=` filter on `/api/hr/vacancies` | same | Was missing — added. | | B11 | `?status=` filter on `/api/finance/expenses` | `server/src/controllers/finance.controller.js` | Already shipped in Sprint 1a; pinned here. | | Schema | `expenses.approved_at`, `expenses.rejection_reason` | `server/src/database/init.js` | Idempotent ALTER for existing DBs (mirrors the Phase 1 pattern). | #### Smoke (curl as `bursar@school.com`) ``` # Approve a pending expense PUT /api/finance/expenses/3/approve → 200 { "id": 3, "status": "approved", "approved_by": , "approved_at": "2026-07-04 21:42:01", ... } # Re-approve the same row PUT /api/finance/expenses/3/approve → 409 { "error": "Cannot approve expense in status 'approved'; only 'pending' is valid.", "current_status": "approved" } # Reject without reason PUT /api/finance/expenses/4/reject → 400 { "error": "reason is required" } # Summary tile GET /api/finance/expenses/summary → 200 { "pending_count": 2, "pending_amount": 540, "approved_this_month": { "count": 1, "total": 125 }, "rejected_this_month": { "count": 0, "total": 0 } } # B11 verifier GET /api/hr/leave-requests?status=pending → 200 [...only pending rows...] ``` ### Frontend — F1, F2, F3, F4, F5, F6 | ID | File | Route | Notes | |---|---|---|---| | F1 | `client/src/pages/hr/LeaveApproval.tsx` | `/hr/leave-approval` | Queue with inline Approve / Reject buttons. Reject opens an inline modal with required reason. Status filter defaults to `pending`. No `alert()`, no `window.prompt`. | | F2 | `client/src/pages/hr/StaffDirectory.tsx` | `/hr/staff` | Read-only listing of `staff_records` joined to department/role/grade. Department `