# 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).