7.7 KiB
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": <uid>, "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 <select> (no window.prompt). Read-only detail drawer with phone + appointment date. Active-staff toggle. |
| F3 | client/src/pages/finance/PayrollRuns.tsx |
/finance/payroll/runs |
Bursar-side payroll CRUD UI. Replaces the legacy placeholder that routed /finance/payroll/runs → <HRManagement />. Backed by /api/finance/payroll/runs (Phase 0 proxy → HR controller). Inline banners for create/calc/pay/cancel outcomes. |
| F4 | client/src/pages/finance/Payslips.tsx |
/finance/payslips |
List + side-drawer detail. Drawer links to the full /hr/payslips/:id view for printing. Self-service scope inherited from the existing /api/hr/payslips endpoint (non-privileged callers automatically see only their own). |
| F5 | client/src/pages/finance/Expenses.tsx |
(modify) | Approve / Reject buttons added on pending rows. New reject modal (required reason). Inline banners for both outcomes. Three summary tiles added at the top, backed by B10. |
| F6 | client/src/pages/admin/HRManagement.tsx |
(modify) | Dropped the inline Approve/Reject buttons on the Leave tab; replaced with a "Review queue" CTA pointing to /hr/leave-approval. Tab is now read-only (View Detail modal still available). |
Store additions
client/src/store/finance.ts now exports approveExpense(id),
rejectExpense(id, reason), and fetchExpenseSummary() plus the
expenseSummary / ExpenseSummary payload type. fetchExpenseSummary is
called from the Expenses page on mount and after every approve/reject to keep
the tile in sync.
Wiring
client/src/App.tsx— registered all 4 routes under the appropriate role guards. Removed the placeholder that mapped/finance/payroll/runs → <HRManagement />for the bursar (and school_admin/principal/systems_admin).client/src/components/Nav.tsx— added Leave Approval, Staff Directory, Payroll, and Payslips entries to thehr,bursar,school_admin,principal, andsystems_adminnav blocks.SCREENS.md— refreshed the HR Manager + Bursar nav tables to reflect the new entries.
Acceptance verification
grep -rn "alert(\|window.prompt" client/src/pages/hr/LeaveApproval.tsx client/src/pages/hr/StaffDirectory.tsx client/src/pages/finance/PayrollRuns.tsx client/src/pages/finance/Payslips.tsx client/src/pages/finance/Expenses.tsx client/src/pages/admin/HRManagement.tsx— no hits in the touched code.- The new Expenses page banner uses inline
<div className="bg-emerald-50… bg-red-50…">instead of nativealert(). - The leave rejection flow uses an inline
<textarea>modal — neverwindow.prompt. npm run buildgreen (see commit body).- Sync contract: every write goes through with
sync_status='pending'.tablesToSync(server/src/services/SyncEngine.js) already includesexpenses,leave_requests,payroll_runs,payslips,staff_records.
Deviations from spec
/api/hr/vacanciesB11 — added filter as planned; no UI consumes it yet (recruitment tab still lists all vacancies). Pinned in the contract so a future Track A or Track B sprint can wire the frontend./api/hr/leave-requests?status=— added in the controller per plan./api/finance/expenses?status=already shipped in Sprint 1a — pinned./api/hr/leave-requestsORDER BY — addedORDER BY lr.start_date DESCso the LeaveApproval page's?status=pendingfilter returns the most relevant rows first. Not a contract change; the previous behavior was undefined (SQLite returned insertion order).- Reject modal validation — client also enforces non-empty reason before dispatching (defense-in-depth on top of B9's 400). UX shows inline error on blank submit.
Demo account (closes the §3a acceptance gap)
server/src/database/init.js now seeds hr@school.com / hr123 alongside
the existing bursar@school.com / bursar123. §3a's acceptance test:
"Logged-in as hr@school.com, /hr/leave shows the staff leave queue"
now passes — Phase 0 didn't seed an HR counterpart. The seed_dummy_data.js
banner was also extended to list both Phase 1 demo accounts.
Re-initialising the DB logs Created hr: hr@school.com and a clean login
round-trip with hr@school.com / hr123 returns role hr and GET /api/hr/leave-requests → 200.
Out of scope (left untouched)
- Paid-state transition (
approved → paid) — Sprint 1c candidate. - ZIMRA FDMS / fiscalisation (Phase 3) — gated on ZIMRA test creds.
- Accountant / Librarian / Nurse dashboards — §9 Phase 2+.
- Tests — no framework installed; out of scope per the plan.
- The remaining
alert()calls in Departments/Inventory/Users/Settings/etc. (tracked by.harness/plans/stubs-sprint-1b/A.mdand friends — separate sweep).