geocrop-platform./apps/nextgen/.harness/changelogs/2026-07-04-phase1b.md

7.7 KiB
Raw Blame History

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 (F1F6).

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