129 lines
7.7 KiB
Markdown
129 lines
7.7 KiB
Markdown
# 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 the `hr`, `bursar`, `school_admin`,
|
||
`principal`, and `systems_admin` nav 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 native `alert()`.
|
||
- The leave rejection flow uses an inline `<textarea>` modal — never
|
||
`window.prompt`.
|
||
- `npm run build` green (see commit body).
|
||
- Sync contract: every write goes through with `sync_status='pending'`.
|
||
`tablesToSync` (`server/src/services/SyncEngine.js`) already includes
|
||
`expenses`, `leave_requests`, `payroll_runs`, `payslips`, `staff_records`.
|
||
|
||
## Deviations from spec
|
||
|
||
1. **`/api/hr/vacancies` B11** — 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.
|
||
2. **`/api/hr/leave-requests?status=`** — added in the controller per plan.
|
||
`/api/finance/expenses?status=` already shipped in Sprint 1a — pinned.
|
||
3. **`/api/hr/leave-requests` ORDER BY** — added `ORDER BY lr.start_date DESC`
|
||
so the LeaveApproval page's `?status=pending` filter returns the most
|
||
relevant rows first. Not a contract change; the previous behavior was
|
||
undefined (SQLite returned insertion order).
|
||
4. **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.md` and friends — separate
|
||
sweep). |