docs(spec): site overhead monthly vs one-off design

This commit is contained in:
DEls 2026-08-28 12:33:39 +02:00
parent 535649bf54
commit 2fac85e79d

View file

@ -0,0 +1,236 @@
# Site overhead — monthly recurring vs one-off
**Date:** 2026-08-28
**Status:** Approved (brainstorming)
**Scope:** Dashboard site detail page. Single-card split for monthly vs one-off overhead entries. Manual add surface for both types.
---
## Problem
The site detail page today has a single "Site P&G overheads" card that lists every `fixed_costs` row and treats it as a **monthly** cost — daily equivalent is always `cost / 21` working days.
In practice, additional overhead often shows up **after** a project has started: things like a one-off compliance certificate, an emergency repair, a penalty, a survey fee. These need to be:
- Logged against the same site (so they show up in the site's "Total spent")
- Distinguished from monthly overheads so the P&G category in the burn bar isn't inflated
- Addable from the dashboard (today, only n8n OCR writes to `fixed_costs`)
There is no field on `fixed_costs`, no UI toggle, and no API route that distinguishes recurring from one-off.
---
## Goals
- Users can add a new overhead entry directly from the site detail page.
- Each entry is classified as either **monthly recurring** or **one-off** at entry time.
- Both types count toward the site's "Total spent" KPI and the sites-list roll-up.
- Existing rows are unchanged (backward-compatible).
- n8n OCR pipelines keep writing to the same collection with no changes.
---
## Non-goals
- Editing or deleting entries from the dashboard (manual entries today; can be added later).
- A new "audit" field tracking `logged_by: ocr | manual` (deferred).
- Renaming the card or moving it elsewhere on the page.
- Bulk import of one-off costs.
- Per-site reporting history or roll-back.
---
## Design
### 1. Architecture & data layer
**Schema** — `scripts/pb-bootstrap-schema.py` (`fixed_costs` collection, around lines 147–159):
```python
{"name": "is_recurring", "type": "bool", "required": False, "options": {"default": True}}
```
- Type `bool`, not required, default `true`.
- Existing rows behave identically without backfill (default true → still monthly).
- PocketBase collection default means n8n OCR writes (which don't set the field) get `true` automatically.
**Type** — `src/lib/pb-types.ts` (`FixedCostRecord`, around lines 53–62):
```ts
is_recurring?: boolean // default true. false = one-off.
```
The `PnGItem` interface in `src/app/dashboard/sites/[id]/site-detail-view.tsx` (lines 31–38) gains one field:
```ts
is_recurring?: boolean
```
**New API route** — `src/app/api/sites/[id]/fixed-costs/route.ts`:
- `POST` only (matches existing site route style).
- Path-scoped to site id; server derives `site` and `project_code` from the path.
- Body schema:
```ts
{
description: string // required, 1–200 chars
cost: number // required, > 0
date_logged: string // required, ISO YYYY-MM-DD
notes?: string // optional, ≤500 chars
is_recurring: boolean // required
}
```
- `201` + new record on success. `400` with `{ error: "validation", field?, message? }` on bad input. `404` if site id doesn't resolve. `500` if PB write throws.
### 2. UI components
**Modified card** — `src/app/dashboard/sites/[id]/site-detail-view.tsx` (currently lines 393–469):
```
┌─ Site P&G overheads ─────────────────────── [+ Add]┐
│ Monthly preliminaries & general running costs. │
├─────────────────────────────────────────────────────┤
│ ▾ Monthly overheads N items │
│ Description │ Notes │ Date │ Monthly │ Daily │ % │
│ … existing rows … │
│ │
│ ▾ One-off costs M items │
│ Description │ Notes │ Date │ Cost │ Daily │ % │
│ … new rows … │
└─────────────────────────────────────────────────────┘
```
- Two stacked `<table>` blocks inside the same `Card`. Each section is collapsible (default open) with a count chip.
- One-off section columns: Description / Notes / Date / **Cost** / Daily (= cost ÷ 21, shown for parity only — never used in math) / % of P&G total.
- Monthly section keeps the exact columns it has today.
- Single `+ Add` button at top-right. Opens a dialog.
**Dialog** — new file `src/components/fixed-cost-dialog.tsx`:
```
┌─ Add overhead cost ────────────────────────<E29480>
│ Type: ( ) Monthly recurring │
│ (•) One-off │
│ │
│ Description [____________________] │
│ Date [YYYY-MM-DD] │
│ Cost [R ______] │
│ Notes [____________________] │
│ │
│ [Cancel] [Save] │
└───────────────────────────────────────────┘
```
- Type radio first — drives the entry's `is_recurring` value on submit.
- `Cost` input is `R-prefixed` (matches existing dashboard currency formatting).
- On submit: `POST /api/sites/<id>/fixed-costs`. Optimistic close + `router.refresh()`.
### 3. Data flow
**Read path** — `src/app/dashboard/sites/[id]/page.tsx` (server component, lines 49–97):
```
fetch fixed_costs WHERE site = <id>
│
▼
partition into:
monthlyItems = rows.filter(r => r.is_recurring !== false)
oneOffItems = rows.filter(r => r.is_recurring === false)
│
▼
fixedSum = Σ monthlyItems.cost (unchanged)
oneOffSum = Σ oneOffItems.cost (NEW)
│
▼
totalSpent = fixedSum + oneOffSum + payrollBurn + materialSum + fuelBurn
```
- `dailyCost` remains `cost / 21` for display, applied identically to both sections so the UI is consistent.
- `% of P&G` in both tables uses `cost / (fixedSum + oneOffSum)` so it sums to 100% across the whole card.
**Sites list roll-up** — `src/app/dashboard/sites/page.tsx` (lines 31–82): same change — include `oneOffSum` in `spent` so site-card "Spent" totals stay consistent with the detail page.
**Write path** (manual, new):
```
[+ Add] → Dialog → POST /api/sites/[id]/fixed-costs
│
▼
pbAdmin().collection("fixed_costs").create({...})
│
▼
revalidatePath(`/dashboard/sites/${id}`)
router.refresh() on client
```
**Write path** (n8n OCR, unchanged): n8n keeps writing to `fixed_costs` directly via `pbAdmin()`. New rows without `is_recurring` default to `true`.
**Empty states:**
- Both sections empty: today's single empty state ("No fixed costs logged for this site yet.") — unchanged.
- Only monthly empty: "No monthly overheads yet."
- Only one-off empty: "No one-off costs logged yet."
### 4. Error handling
| Condition | Response |
|---|---|
| Site id from path doesn't resolve | `404 { error: "site_not_found" }` |
| Body fails validation | `400 { error: "validation", field, message }` |
| `cost <= 0` | `400 { error: "validation", field: "cost", message: "Cost must be greater than zero." }` |
| `description` empty / > 200 chars | `400 { error: "validation", field: "description", message }` |
| `date_logged` not ISO YYYY-MM-DD | `400 { error: "validation", field: "date_logged", message }` |
| PocketBase write throws | `500 { error: "write_failed" }` — log full error server-side |
- Inline field-level error from `400` responses (display under the offending field in the dialog).
- Generic "Couldn't save overhead" banner for `500`.
- Disable Save button while in-flight; show spinner on the Save button only.
- Closing the dialog mid-submit doesn't cancel the request; on success the dialog closes; on failure it stays open with the error.
- Server is source of truth for `site` + `project_code` (derived from path).
- `cost` stored as `number` (PocketBase `number`). No floating-point rounding in the API.
### 5. Testing
**Unit tests** (Vitest/Jest — match repo conventions):
- `src/app/api/sites/[id]/fixed-costs/route.test.ts`:
- ✅ `201` with valid body — record returned
- ✅ `404` when site id missing
- ✅ `400` for each validation failure (description empty, cost 0, cost negative, date wrong format, is_recurring missing)
- ✅ `500` when PocketBase write throws (mock failure)
- `src/lib/__tests__/partition-fixed-costs.test.ts` (pure function extracted from the page server component):
- ✅ Empty list → both arrays empty
- ✅ All `is_recurring: true` (or undefined) → monthly section only
- ✅ All `is_recurring: false` → one-off section only
- ✅ Mixed → correctly partitioned
- ✅ Totals match expected sums
**Manual smoke test:**
1. Open a site detail page.
2. Click `+ Add` → choose "One-off" → fill → save → row appears in "One-off costs" section.
3. Click `+ Add` → choose "Monthly recurring" → fill → save → row appears in "Monthly overheads" section.
4. Reload page — both rows persist.
5. Confirm "Total spent" KPI increased by the sum of both costs.
6. Confirm sites-list card for this site shows the same increased "Spent" total.
7. Confirm `Burn by category` stacked bar still uses `fixedSum` for the P&G segment (unchanged) but the bottom-line "Total spent" is correct (includes one-offs).
---
## Files to create / modify
- **Modify** `scripts/pb-bootstrap-schema.py` — add `is_recurring` field to `fixed_costs`.
- **Modify** `src/lib/pb-types.ts` — add `is_recurring?: boolean` to `FixedCostRecord`.
- **Create** `src/app/api/sites/[id]/fixed-costs/route.ts` — POST endpoint.
- **Create** `src/components/fixed-cost-dialog.tsx` — controlled dialog component.
- **Modify** `src/app/dashboard/sites/[id]/page.tsx` — partition fixed_costs into monthly/one-off, add `oneOffSum`, include in `totalSpent`.
- **Modify** `src/app/dashboard/sites/[id]/site-detail-view.tsx` — split card into two stacked sections, wire `+ Add` button + dialog, update `PnGItem` interface.
- **Modify** `src/app/dashboard/sites/page.tsx` — include `oneOffSum` in sites-list roll-up.
- **Create** `src/app/api/sites/[id]/fixed-costs/route.test.ts` — API route tests.
- **Create** `src/lib/__tests__/partition-fixed-costs.test.ts` — partition helper tests.
---
## Open questions
None — all design decisions resolved during brainstorming.