Compare commits

..

No commits in common. "main" and "agent/mika/6d6b5c094956" have entirely different histories.

961 changed files with 27368 additions and 310699 deletions

View file

@ -219,7 +219,6 @@ The `letterhead` body field accepts only `"CNL"` or `"ISHERWOOD"`. Anything else
| `quotes.clone` | Clone a quote + items. |
| `quotes.save_as_template`, `quotes.create_from_template` | Template management. |
| `quotes.split`, `quotes.partial_approve`, `quotes.re_price`, `quotes.next_number`, `quotes.lookup` | Pricing workflow. **Note:** `quotes.lookup` is currently broken — see Known bugs §4. |
| `quotes.assistant_fill` | Run the AI quote assistant on one quote (`quote_id`): fills unpriced lines from POS items as purple drafts a rep must review; unmatched lines get the closest POS items noted. Only status new / follow_up / claimed, never templates. Problems come back in `error`. Never marks lines done or sends anything. |
| `quotes.items.rewrite` | **Not an MCP tool** — it's a REST endpoint at `/api/v1/quotes/items/rewrite` (browser-facing, needs PB session). |
| `quote_items.populate_insights` | **Deterministic** compute-and-write of `quote_items.ai_item_insights`. Replaces the n8n AI rerun for the per-item popover. SKU-exact → SKU-normalised → description-fuzzy (Jaccard ≥ 0.6). See "Quote-item insights backfill" below. |
@ -291,20 +290,11 @@ REST mirror at `/api/v1/quote_items` (GET list + POST create) and `/api/v1/quote
### 8. Price-list catalogue (add items from a parsed supplier/quote document)
> **⚠️ OPT-IN ONLY (changed 2026-10-15).** `price_list.add_items` writes to the
> `price_list` catalogue, which **the POS never reads** — `listActiveStockCatalog()`
> queries `stock_items` only. A price that exists only here is invisible at the
> till. It is also now classified `confirm` in `giellie-tool-policy.ts`, so it asks
> every time regardless of the chat's trust setting. **Only call it when the
> operator explicitly asked for the price list / catalogue / supplier price list.**
> For "load prices", "add these items", "here's a supplier list", or anything
> ambiguous, use `stock_items.create` — that is the default.
| Tool | Purpose |
|------|---------|
| `price_list.add_items` | Bulk-upsert parsed line items into the `price_list` collection. The agent reads the source document (supplier PDF, quote PDF, pasted email) with its native vision, extracts the per-line fields, and passes them in. The tool handles dedupe by `(sku, supplier)` and computes `sku_normalised` + `search_text` automatically. **You do the OCR, we do the commit.** Pass the supplier **name**, never a PocketBase record id — `price_list.supplier` is free text. |
| `price_list.add_items` | Bulk-upsert parsed line items into the `price_list` collection. The agent reads the source document (supplier PDF, quote PDF, pasted email) with its native vision, extracts the per-line fields, and passes them in. The tool handles dedupe by `(sku, supplier)` and computes `sku_normalised` + `search_text` automatically. **You do the OCR, we do the commit.** |
**When to use it:** the operator explicitly asks you to add items to the **catalogue / price list** — not merely to load prices onto the counter. Read the document with your vision capability, extract the per-line fields, and call `price_list.add_items` with the structured array. If they asked to load prices for something they sell, that is `stock_items.create` (§ POS/stock items), not this.
**When to use it:** the operator pastes a supplier's price list PDF, a quote from a customer, or a supplier's emailed price sheet into the chat and asks you to add the items to the catalogue. Read the document with your vision capability, extract the per-line fields, and call `price_list.add_items` with the structured array.
**Input shape:**
@ -356,7 +346,7 @@ REST mirror at `/api/v1/quote_items` (GET list + POST create) and `/api/v1/quote
**Why this exists:** the only pre-existing path for adding to the catalogue was operator uploads via `/pricelist` → n8n ingest → `raw_price_list` → AI extraction → `price_list`. That flow is async, human-in-the-loop, and gates on n8n being healthy. This tool gives you a synchronous, agent-driven shortcut for ad-hoc updates — paste a PDF, get the catalogue updated, no async hops.
### Full tool index (77)
### Full tool index (75)
```
orders.list, orders.get, orders.create_from_quote
@ -367,7 +357,7 @@ quotes.create_from_email, quotes.append_reply, quotes.find_by_message_id,
quotes.list, quotes.get, quotes.send_email, quotes.retry_email,
quotes.clone, quotes.save_as_template, quotes.create_from_template,
quotes.split, quotes.partial_approve, quotes.re_price, quotes.next_number,
quotes.lookup, quotes.assistant_fill
quotes.lookup
quote_items.list, quote_items.get, quote_items.create, quote_items.update,
quote_items.populate_insights
attachments.upload_for_quote

View file

@ -1,234 +0,0 @@
---
name: wfdash-rep-visit-batch-pin
description: Bulk-pin a list of customers to a PocketBase site from a rep's visit log (typically Gary's driving-mode WhatsApp pins). Use whenever the user provides a coordinate (lat/lon or Google Maps URL), a site name, and a list of person names, and wants each person pinned to that location with a `rep_visits` record. Handles PB admin auth via `.env.local`, fuzzy match against the `customers` collection, presents ambiguity for unclear/duplicate/zero-hit names, then writes the pins in one batch. Covers the `customer~"X"` PB filter gotcha, the SAST→UTC timestamp conversion, the live-vs-snapshot schema drift warning, and the known skipped-name patterns. This is NOT the agent/MCP layer — for that, load `wfdash-giellie` instead.
---
# WFDASH rep-visit batch pin
You are a small data-entry agent. Given a coordinate, site name, and a list of person names from a rep's visit log (typically Gary's WhatsApp driving-mode pins), pin each person to that location in PocketBase and create a `rep_visits` record per person.
**This is not the same scope as `wfdash-giellie`.** Giellie is the agent/MCP layer (Bearer token, `/api/mcp`). This skill talks **directly to PocketBase admin** because Giellie doesn't expose a bulk-pin-customers tool and won't gain one soon.
---
## When to load this skill
Load when the user does any of:
- "pin these names to [site]" / "backfill this WhatsApp batch"
- Hands you a coordinate + place name + list of names
- Pastes a raw WhatsApp export with a Google Maps link followed by names (most common case — parse it yourself)
- Says "do the Cementation / Khuseleka / Doornkop thing" referring to a previous batch in this conversation
If the user wants to interact via the agent/MCP layer instead (`POST /api/mcp` or `/api/v1/*`), load `wfdash-giellie` — not this skill.
---
## Required inputs (ask for what's missing)
1. **Coordinates** — either:
- Google Maps URL: `https://maps.google.com/?q=-26.217859,27.791794` (extract the `q=`)
- Or raw: `-26.217859, 27.791794` (decimal degrees, lat first, lon second)
2. **Site name** — free-text string, e.g. `Khuseleka`, `Cementation`, `Doornkop`, `Elandsrand / Kusasalethu`
3. **Person names** — one per line, numbered, or pasted as a WhatsApp message block
4. **(optional)** Per-message timestamps — use when each name was its own message; default to the single shared timestamp of the names message
5. **(optional)** Rep ID — defaults to Gary `krpyec9lblra0qz`
If the user pastes a raw WhatsApp export (timestamps + Location URL + names), parse out coordinates, site, and names yourself. That's the dominant pattern.
---
## Workflow
### 1. Auth to PocketBase
**Never hardcode the PB admin email.** Always read `.env.local` in the WFDASH repo. Past bug: assistant assumed `david@wfsales.co.za` when the env file said `david.els1@outlook.com`.
```bash
# From WFDASH repo root
PB_EMAIL=$(grep -E '^PB_ADMIN_EMAIL=' .env.local | cut -d= -f2-)
PB_PASSWORD=$(grep -E '^PB_ADMIN_PASSWORD=' .env.local | cut -d= -f2-)
PB_URL=$(grep -E '^POCKETBASE_URL=' .env.local | cut -d= -f2-)
```
POST to `${PB_URL}/api/collections/_superusers/auth-with-password`:
```json
{ "identity": "<email>", "password": "<password>" }
```
Cache the returned `token`. It's valid for the session. If you get a 401 mid-batch, re-auth.
### 2. Verify live schema (don't trust the local snapshot)
The local snapshot at `pb-exports/pb_schema-2.json` may be stale. As of last sync it was missing `site_location` (which exists in live PB). Before writing, `GET /api/collections/customers/records?perPage=1` and confirm the fields you intend to write are actually present in the live record. If `site_location` is gone from live PB too, drop it and ask the user how they want site tracked now.
### 3. Site check
`site_location` is a free-text `select` on `customers`. PB silently accepts new string values. Before writing, `GET /api/collections/customers/records?perPage=200&fields=site_location&filter=site_location!=''` to enumerate existing values and confirm whether the new site is genuinely new. Report site growth at the end of the batch.
### 4. Fuzzy-match each name against `customers`
**Critical gotchas:**
- **Field is `customer`, NOT `name`.** The PB filter operator is `~` (contains), not `LIKE`.
- ✅ `GET /api/collections/customers/records?filter=customer~"Corrie"&perPage=10`
- ❌ `?filter=name~"Corrie"` (returns 0 hits — the bug from the Khuseleka batch)
- For each name, try exact match first, then substring if no exact.
- If multiple hits, list all candidates with their IDs.
- If zero hits, report zero and present skip/create options.
- If one hit, mark ✓ clear.
- Single-letter differences usually mean the same person (driving-mode typo) — flag with ⚠️ but don't auto-decide.
Run all searches in parallel (Promise.all / xargs -P / etc.). Don't burn a sequential round-trip per name.
### 5. Present match table + ask disambiguation
**ALWAYS present a table before writing anything.** Format:
```
| # | WhatsApp | PB matches | Notes |
|---|---------|-----------|-------|
| 1 | Corrie Strydom | `Corrie Strydom` (`zfasu6tsfcj87if`) — exact | ✓ clear |
| 2 | Johan Schultz | `Johan Schoultz Shaft Foreman` (`quvzzrufkn7xkqu`) | ⚠️ single-letter typo |
| 3 | Mark Wilkie | 2 records: `4lgz...` and `8rfe...` | ❓ duplicate |
| 4 | Andre Grobler | 0 hits | ❓ zero hit |
```
Then ask the user for each ambiguous/zero-hit name. Group into one set of questions with a/b/c options per item. **Do not write anything until the user answers.**
Standard question templates:
| Situation | Options |
|---|---|
| Single-letter typo (only candidate exists) | (a) Pin as the typo-corrected record / (b) Skip / (c) Create new |
| 2+ duplicates | (a) Pin both (I'll merge later) / (b) Pin one (tell me which) / (c) Skip — let's dedupe first |
| 0 hits | (a) Skip (probably wrong name) / (b) Create new customer |
| Ambiguous (multiple distinct candidates) | (a) Skip / (b) Pin [specific candidate] / (c) Create new |
### 6. Apply writes
For each confirmed person, run TWO operations:
**A. Update the customer record**
```
PATCH ${PB_URL}/api/collections/customers/records/{id}
Authorization: Bearer <token>
{
"coordinates": { "lat": <lat>, "lon": <lon> },
"site_location": "<site>",
"lastvisiteddate": "<SAST-2h>Z"
}
```
**B. Create a `rep_visits` record**
```
POST ${PB_URL}/api/collections/rep_visits/records
Authorization: Bearer <token>
{
"customer": "<customer_id>",
"rep": "<rep_id>",
"position_source": "manual",
"coordinates": { "lat": <lat>, "lon": <lon> },
"notes": "Manual backfill from WhatsApp pin: <site> (<lat>,<lon>). Driving-mode typos: <typo1> → <canonical1>, ...",
"visited_at": "<SAST-2h>Z",
"pinned_customer": true
}
```
Run all writes in parallel. Verify after.
### 7. Verify
After all writes:
- `GET /api/collections/customers/records/{id}` for one of the updated customers — confirm `coordinates`, `site_location`, `lastvisiteddate` persisted.
- `GET /api/collections/rep_visits/records/{id}` for one of the created visits — confirm the fields persisted.
- Report new site count across all customers.
### 8. Report
Final report should include:
1. **What was done** — table: customer, coords, site, timestamp, visit ID.
2. **Spelling corrections** — e.g. "PB spelling: `Kobus Oberholtzer` (two `t`s), Gary's spelling was the typo."
3. **Skipped names** — tally grows by N.
4. **Site growth** — distinct `site_location` count.
5. **Outstanding follow-ups**:
- `site_location` is still free-text on `customers` (and not on `rep_visits`). Past ~10 sites, migrate to a structured `site` collection + `rep_visits.site` foreign key.
- The driving-mode importer (you, but automated from a WhatsApp webhook) is a half-day project.
---
## Quirk table
| Quirk | Detail |
|---|---|
| Auth email | `.env.local` → `PB_ADMIN_EMAIL`, never hardcoded |
| PB filter field | `customer~"X"`, NOT `name~"X"` or `LIKE` |
| `customers.coordinates` | GeoJSON `{lat, lon}` |
| `rep_visits.coordinates` | GeoJSON `{lat, lon}` (same as customers — both are `geoPoint`) |
| `rep_visits.site_location` | Does not exist on the collection; do NOT include in visit writes. Site info lives on the customer record and in the visit's `notes` field. |
| Timestamp | SAST = UTC+2; convert all WhatsApp times before writing |
| Local PB snapshot | `pb-exports/pb_schema-2.json` may be stale — verify live schema before writing |
| Driving typos | Single-letter differences are usually the same person (Kobus Oberholtzer, Enzo Msiza, Tinus van de Venter all pinned); always confirm with user |
| Duplicates | If a name matches 2+ records, ask — usually "pin both, I'll merge later" |
| Zero hits | Same shape as Gorden / Andre / Richard Dube — usually skip |
| Per-message timestamps | Use when each name was its own WhatsApp message; default to shared timestamp |
| Pin-vs-name gap | If the location pin and the names message have a >5 min gap, flag it — could mean a second un-pinned visit |
---
## Known skipped names (as of last sync)
If you encounter any of these again, **ask** before creating — they may be the same driving-typo situation:
- Gorden van Staden
- Andre Grobler
- Richard Dube
- Lucky Mabena
- Janu
- Nthabising Maloisi
If the user later confirms any of these are real people (not typos), backfill is straightforward: same workflow as a normal batch, treat as zero-hit option (b).
---
## Anti-patterns
- **Don't write before presenting.** The user always wants a match table + disambiguation questions first.
- **Don't guess on zero-hits.** Skip by default; ask before creating new customers.
- **Don't auto-resolve duplicates.** Ask whether to pin both, one, or skip for dedup-first.
- **Don't auto-resolve single-letter typos.** Most of them are right (Kobus, Enzo, Tinus), but phrase as a question, not a fact.
- **Don't hardcode the PB email.** Always read `.env.local`.
- **Don't include `site_location` on `rep_visits` writes** — the field doesn't exist on that collection.
- **Don't use `name~` as a filter** — the field is `customer`.
- **Don't write without verifying** — after each batch, re-fetch one customer and one visit to confirm persistence.
- **Don't trust the local PB snapshot** — it's known to be stale on at least `site_location`.
---
## Worked example
User input:
```
[2026/09/10, 10:07:15] Gary CNL: Location: https://maps.google.com/?q=-25.624908,27.256472 - Khuseleka
[2026/09/10, 10:38:59] Gary CNL: Corrie Strydom / Hein haasbroek / Johan Schultz / Lucas barnard / Nthabising maloisi / Titus Negota
```
What you do:
1. Parse: coords = `(-25.624908, 27.256472)`, site = `Khuseleka`, names = [`Corrie Strydom`, `Hein haasbroek`, `Johan Schultz`, `Lucas barnard`, `Nthabising maloisi`, `Titus Negota`], single shared timestamp `10:38:59 SAST → 08:38:59Z`.
2. Read `.env.local`, auth to PB.
3. Fuzzy-match all 6 in parallel.
4. Present table (exact, exact, typo, exact, 0 hits, typo).
5. Ask: "Pin Schoultz?" / "Pin Neqota?" / "Skip or create Maloisi?".
6. After answers, run 2N writes (PATCH + POST each).
7. Verify, report.
That's the whole loop.

View file

@ -1,11 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "wfdash-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "-p", "4001"],
"port": 4001
}
]
}

View file

@ -1,40 +0,0 @@
# Plans folder
Cross-session handoff between the **planner** (Claude on cloud) and the **implementer** (any model — usually Minimax in a separate session). The plan file is the contract — chat history doesn't transfer, so the plan has to be self-contained.
## Workflow
1. **Planner** writes a plan to `.claude/plans/<slug>.md` from the [`TEMPLATE.md`](./TEMPLATE.md) shape.
- Use the `superpowers:writing-plans` skill if available.
- The plan must be self-contained — anything the implementer can't infer from the working tree goes in the **Background** section.
- The **Background** section is where the planner summarises the conversation that led to the plan: decisions, dead ends, "we tried X but Y", links to prior commits, the operator's actual intent. This is the tribal-knowledge handoff.
- Set frontmatter `status: ready` when ready to hand off.
2. **Hand off** — pick one:
- **File**: tell the implementer session "read `.claude/plans/<slug>.md` and execute".
- **Git**: commit the plan on a branch, switch the implementer session to that branch.
- **Orca**: `orca handoff --plan .claude/plans/<slug>.md` (see the `orca-cli` skill).
3. **Implementer** boots from [`RUN-PLAN.md`](./RUN-PLAN.md) — paste it as the opening prompt of the new session, or load it via skill if the harness supports it.
- Reads the plan + the repo's `CLAUDE.md` / `AGENTS.md`.
- Follows the plan, surfaces disagreements rather than improvising.
- Flips `status: in_progress` → `complete` (or `blocked`) and writes a short report in the plan's **Status (filled by implementer)** section.
4. **Loop back** — the implementer's Status section + commit messages are the planner's input for the next iteration.
## File layout
| File | Purpose |
|---|---|
| `README.md` | This file — orientation |
| `TEMPLATE.md` | Copy this to start a new plan |
| `RUN-PLAN.md` | Paste this into the implementer session as its opening prompt |
| `<slug>.md` | An individual plan. Slug is short-kebab-case, matches the feature / branch name |
| `<slug>.status.md` | Optional. Implementer writes one of these if it needs to report back without flipping the plan's `status` frontmatter (e.g. mid-run questions) |
## Conventions
- **Commit plans** with the work. They're version-controlled documentation of intent, not scratch.
- **One plan per feature / branch.** Split if the scope grows.
- **Status lifecycle**: `draft` → `ready` → `in_progress` → `complete` (or `blocked` / `abandoned`).
- **Don't bypass MCP.** If the implementer needs a tool that doesn't exist, surface the gap in the status file — don't fall back to direct REST calls (Hermes / n8n drops `AGENT_API_KEY` on direct calls; see `.claude/memory/mcp-only-no-direct-api.md`).

View file

@ -1,74 +0,0 @@
# RUN-PLAN — implementer boot prompt
> **Paste this into the opening prompt of the implementer session.** Replace `<slug>` with the plan filename (without `.md`).
>
> Suggested opening prompt:
> ```
> Read .claude/plans/RUN-PLAN.md, then read .claude/plans/<slug>.md and execute it.
> ```
---
You are the implementer for a plan someone else wrote. **The plan is the contract — do not improvise.** Your job is to execute it faithfully, verify it landed, and report back.
## 1. Load context
In this order:
1. **The plan**: `.claude/plans/<slug>.md` — read it end-to-end. Don't skim.
2. **The project rules**: this repo's `CLAUDE.md` and `AGENTS.md` at the repo root. The plan assumes you know these. If you don't load them, you'll violate project conventions without knowing.
3. **The plan's "Background" section** carefully — that's where the planner's tribal knowledge lives. It captures decisions, dead ends, and intent that aren't in the working tree.
4. **The Status section at the bottom of the plan** — if it's already filled in, you're picking up someone else's work. Read their report before doing anything.
## 2. Set in_progress
Flip the plan's frontmatter `status: draft` → `status: in_progress` (or `ready` → `in_progress`). This signals to the planner that the hand-off was accepted.
## 3. Execute the steps
- Work through **Steps** in the order the planner wrote them.
- After each step, run its **verify** check before moving on. Unverified steps aren't done.
- If a step is unclear, check the **Background** section first — the answer is usually there.
- If it's still unclear after that, **stop and write a `<slug>.status.md` file**. Do NOT silently improvise.
## 4. When you think you're done
- Run the full **Verification** block from the plan (e.g. `pnpm test`, manual UI check).
- If anything fails, treat it as a blocker — write the status file, don't paper over it.
- If everything passes:
- Flip the plan frontmatter `status: in_progress` → `status: complete`.
- Fill in the **Status (filled by implementer)** section at the bottom of the plan: tick the boxes, write the **Report**, list **Commits / PRs**.
- Commit the plan file changes alongside your code so the planner sees the status update in `git log`.
## Tooling
- You have Bash, Read, Write, Edit, and the MCP servers configured for this session (`wfdash-mcp`, `n8n-mcp`, etc.). Same tool surface as the planner, modulo per-session config.
- **Filesystem root**: `/Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP`. All paths in the plan are relative to this unless explicitly absolute.
- **Dev server**: `http://localhost:4001` (NOT `:3000`).
- **Tests**: `pnpm test` runs Vitest. The Verification block names the specific command.
## What NOT to do
- **Don't rewrite the plan.** If it needs changes, ask via a status file. The planner wrote it for a reason.
- **Don't add features the planner didn't ask for.** Scope creep breaks the loop.
- **Don't skip the verification step.** Unverified steps aren't done.
- **Don't bypass MCP** (no direct REST calls to WFDASH). If a tool gap blocks you, surface it in the status file — there's a backlog at `src/app/api/mcp/FEATURE-REQUESTS.md`. Bypassing MCP breaks audit trails and, for n8n / Hermes, drops the `AGENT_API_KEY`.
- **Don't merge your own branch.** Push and let the planner review.
- **Don't start work on `main` directly.** The plan's `branch:` field names where you should be.
## Reporting back
The plan file **is** your report. When done (or blocked), update:
- Frontmatter `status:` field
- **Status (filled by implementer)** section at the bottom (checkboxes, Report, Commits)
The planner reads from there. Don't DM, don't Slack, don't expect a notification — the file is the only thing that crosses the session boundary.
## If you need to ask a question mid-run
Write `.claude/plans/<slug>.status.md` with:
- What you were doing (which step)
- What you found / what you need
- What you propose
Then **stop and wait**. The planner will reply by editing the plan or the status file. Don't poll, don't keep working on guesses.

View file

@ -1,92 +0,0 @@
---
plan_id: <short-kebab-slug>
created: <YYYY-MM-DD>
created_by: <planner identity — "Claude (cloud)" | "Claude (minimax)" | operator name>
target_session: claude | minimax | any
status: draft | ready | in_progress | complete | blocked | abandoned
branch: <git branch name, if any>
related_issue: <URL or id, optional>
---
# <Plan title — short, action-oriented>
> Copy this file to `.claude/plans/<slug>.md` and fill in. Use the `superpowers:writing-plans` skill if available.
## Goal
<One sentence. What does "done" look like from the operator's perspective?>
## Background (from planner's conversation)
**This is the most important section for the implementer.** The chat history doesn't transfer between sessions, so anything the planner and the operator discussed but didn't write down goes here.
Cover, in free form:
- **Why this work** — the operator's stated intent, the user story, the bug report
- **Decisions made in chat** — "we agreed to use X over Y because…", "the operator vetoed Z"
- **Dead ends** — "we tried `<approach>` but it failed because…"
- **Constraints** — backwards compat, must-not-break, deadlines, who's affected
- **Links** — relevant commits, prior plans in this folder, external docs, Slack threads
## Context (links the implementer needs)
- **AGENTS.md rules that apply:** <bulleted list, e.g. "PB select fields are not self-service", "file URLs go through `/api/v1/files/`">
- **PB schema fields touched:** <relation ids, new select values needed, new collections>
- **MCP tools involved:** <e.g. `mcp__wfdash-mcp__quote_items_create`, `mcp__wfdash-mcp__orders_update`>
- **Prior art in repo:** <`file:line` refs to similar code to copy from>
- **External references:** <URLs>
## Scope
**In scope:**
-
**Out of scope:**
- (explicitly call these out so the implementer doesn't drift)
## Files to touch
- `src/path/to/file.ts` — create | modify | delete — <one-line: what changes>
- `src/path/to/another.tsx` — modify — <what changes>
## Steps
<Numbered. Each step: action verb + target + a way to verify it landed.>
1. <Step>
- verify: <command or check>
2. <Step>
- verify: <command or check>
3. <Step>
- verify: <command or check>
## Verification
- `pnpm test` — full suite green (or specific test file: `pnpm test src/lib/foo.test.ts`)
- Manual: `<http://localhost:4001/path>` renders <expected>
- Acceptance: <business-level check the operator will care about>
## Risks / open questions
- <Anything the implementer should surface rather than decide alone>
- <Assumptions that might be wrong — call them out so they get validated>
---
## Status (filled by implementer)
<!-- Implementer: update this section as you work. Don't edit anything above this line without writing a status file first. -->
- [ ] Plan read end-to-end + AGENTS.md / CLAUDE.md loaded
- [ ] Branch / working tree confirmed
- [ ] Steps 1..N complete with their verify checks passing
- [ ] `pnpm test` green
- [ ] Manual verification at `http://localhost:4001/...` done
- [ ] Status frontmatter flipped to `complete`
### Report
<Free text. Blockers, surprises, things the planner should know. Link to commits / PRs.>
### Commits / PRs
- `<sha>` — `<message>`

View file

@ -1,513 +0,0 @@
---
plan_id: quote-attachments-split
created: 2026-09-09
created_by: Claude (operator session)
target_session: claude | minimax
status: in_progress
branch: feat/quote-attachments-split
---
# Quote attachments — physical separation by audience
## Goal
Make it impossible to attach the wrong file to the wrong audience on a quote.
Three physical stores, one decision rule: **if it lives in `quotes.outbound_attachments` it goes to the customer; otherwise it doesn't.**
## Background (from planner's conversation)
**Operator's stated intent** (paraphrased from the conversation):
> "Inbound = what the customer sent with their RFQ. Internal = anything we
> use to justify our numbers (supplier quotes, per-line pricing proofs).
> Outbound = datasheets and anything else we want the customer to see.
> Only outbound goes to the customer."
**The bug:** The single `quote_attachments` table uses the column
`direction` (PB enum: `inbound | outbound | generated`) for what is
actually three different concepts. The upload route at
`src/app/api/v1/quotes/[id]/attachments/upload/route.ts:113-114`
literally writes:
```ts
const direction = kind === "inbound" ? "inbound" : "outbound";
```
so every "proof" file uploaded from the sprint pricing modal — a
supplier price list, a screenshot, an internal justification — gets the
`direction="outbound"` tag and gets pre-selected into the customer
email by `src/components/quotes/send-quote-modal.tsx:266-272` and the
approvals page filter at
`src/app/(app)/quotes/approvals/[id]/page.tsx:466-470`.
**Decisions made:**
- **Option B over A/C.** Operator picked separate file fields on the
`quotes` row (Option B in the planner's question). The schema itself
enforces the boundary — files in `quotes.outbound_attachments`
customer-bound, files in `quotes.internal_attachments` operator-only,
files in `quote_item_attachments` per-line operator-only. The
migrated-out `quote_attachments` table stays read-only for history.
- **Supplier quotes** live in `quotes.internal_attachments`, same as
anything we use to justify our numbers. (Confirmed by question 2.)
- **Migrate** historical `direction="outbound"` rows into the new
`quotes.outbound_attachments` field. After migration, the
`quote_attachments` table is read-only for inbound-only viewing.
(Confirmed by question 3.) Generated PDFs (`kind="pdf"` /
`direction="generated"`) also move to `quotes.outbound_attachments`
because every PDF is customer-bound by definition.
**Dead ends to record:**
- We considered, and rejected, repurposing `quote_item_attachments`
for supplier quotes. It is single-file and per-line; a supplier
quote can cover multiple lines and we want to keep the per-line
table purely about pricing evidence for one line at a time.
- We considered, and rejected, putting supplier quotes on
`orders.order_attachements`. Operator's flow wants supplier
evidence visible during pricing, before an order exists.
**Pointer back** — the system reminder at the start of the planning
turn had this unrelated working-tree noise we must NOT touch:
- `M src/app/(app)/delivery-notes/[id]/page.tsx`
- `M src/lib/server/delivery-notes-service.ts`
- `?? src/app/api/v1/delivery-notes/items/[id]/pricing/route.ts`
- `?? docs/dev-timeline.html`
## Context (links the implementer needs)
**AGENTS.md rules that apply:**
- **File URLs go through `/api/v1/files/`, never `/api/files/`.** The
proxy at `src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts`
whitelists collections by PB id. After schema change, `quotes` is
still whitelisted, so no proxy edit is required — the proxy hits
`${pb}/api/files/quotes/{id}/{filename}` and that just works once
the field exists. Server-to-server reads (PDF render, MCP base64)
still use `${pb}/api/files/...` directly with admin auth as today.
- **Reuse existing multi-file fields for new attachment types.** This
rule from AGENTS.md is satisfied trivially: we ARE reusing the
`quotes` collection by adding two file fields, not a new collection.
- **Data access layer discipline.** New routes go through service
helpers in `src/lib/server/quotes-service.ts`. Existing
`quote_attachments` reads stay where they are (they're the historical
view surface).
- **PocketBase schema is the source of truth.** The local
`pb-exports/pb_schema-2.json` is a snapshot for reference only. The
running PB instance is authoritative; the implementer must NOT touch
the snapshot as a substitute for the operator touching the live PB
admin.
**PB schema fields touched (operator does these in PB admin):**
1. `quotes` collection — add `outbound_attachments` (file, maxSelect 10,
any MIME).
2. `quotes` collection — add `internal_attachments` (file, maxSelect 10,
any MIME).
3. **(Not strictly required)** Optionally tighten the `quote_attachments.direction`
enum to drop `outbound` once migration is done. We leave it for now
because dropping a select value would break any leftover reads.
**MCP tools involved:** none. The change is in routes + service + UI
only. Existing MCP tools that touch `quote_attachments`
(`attachments.upload_for_quote`, etc.) keep their current shape but
should be flagged for a follow-up sweep that decides whether the
n8n/MCP intake should land in `quote_attachments` (inbound) or in
`quotes.internal_attachments` (operator upload). Out of scope here.
**Prior art in repo to copy from:**
- `src/lib/server/stock-purchases-service.ts:addStockPurchaseAttachment`
(lines ~1100-1145) — already implements the read-before-append pattern
for a multi-file field on a parent row. Use as the template for the
new internal/outbound upload helpers.
- `src/app/api/v1/purchase-orders/[id]/attachments/upload/route.ts` —
thin route wrapping an admin PB `update` with multipart FormData.
Use as the structure template.
**External references:**
- PB file-field docs: https://pocketbase.io/docs/files-handling/
(snippet via `new FormData()` + `collection("...").update(id, fd)`).
## Scope
**In scope:**
- Two new file fields on `quotes` (`outbound_attachments`,
`internal_attachments`).
- Service helpers in `src/lib/server/quotes-service.ts` for both new
fields, written read-then-append-then-write.
- Thin routes for POST + DELETE on each new field.
- Re-route the existing quote PDF generator so it lands in
`quotes.outbound_attachments` instead of `quote_attachments`.
- Re-route the existing sproof-attachments upload so
`kind="proof"` writes to `quotes.internal_attachments` instead of
a `quote_attachments` row.
- Re-route n8n MCP's `uploadQuoteAttachment` so it writes inbound
files to `quote_attachments` (unchanged) — verify and document, no
code change needed since the inbound code path is already correct.
- Update approvals page and send-quote-modal to read outbound from
`quote.expand.outbound_attachments` (PDF still auto-included).
- Migration script: copy every `quote_attachments` row where
`direction in ("outbound","generated")` (per quote id) into the
corresponding `quotes.outbound_attachments` field. Run once.
- Update file-proxy whitelist if necessary (it already covers
`quotes`, so likely no edit).
**Out of scope (do NOT do these):**
- Touching delivery-notes, sprint tooling, per-item attachments.
- Touching the unrelated working-tree noise (delivery-notes page,
pricing route, dev-timeline.html).
- Touching `quote_item_attachments` upload paths. They are correct.
- Reordering the `quote_attachments.direction` enum (dropping
`outbound`). Defer to a follow-up once we have observability that
no reads still rely on it.
- Changing MCP / n8n email-intake behavior. The intake writes
`direction="inbound"` today and that stays correct.
- Migrating inbound `quote_attachments` rows anywhere — they
stay in place as historical reference and the existing
`/api/v1/quotes/[id]/attachments` GET endpoint keeps reading them.
## Files to touch
**Schema (operator does in PB admin):**
- `quotes` collection — add `outbound_attachments: file[]`
(maxSelect 10, any MIME).
- `quotes` collection — add `internal_attachments: file[]`
(maxSelect 10, any MIME).
**Service layer:**
- `src/lib/server/quotes-service.ts` — add
`addInternalQuoteAttachment(quoteId, file)`,
`addOutboundQuoteAttachment(quoteId, file)`,
`removeInternalQuoteAttachment(quoteId, filename)`,
`removeOutboundQuoteAttachment(quoteId, filename)`. All four use
the read-append-write pattern from
`src/lib/server/stock-purchases-service.ts:addStockPurchaseAttachment`.
Refactor `uploadQuoteAttachment` JSDoc to mention it's inbound-only
going forward; do NOT change its implementation (still writes
`quote_attachments` correctly).
**Routes (create):**
- `src/app/api/v1/quotes/[id]/internal-attachments/route.ts` —
POST `file` → `addInternalQuoteAttachment`; DELETE `?filename=` →
`removeInternalQuoteAttachment`. Mirrors
`src/app/api/v1/stock-purchases/[id]/attachments/[filename]/route.ts`.
- `src/app/api/v1/quotes/[id]/outbound-attachments/route.ts` — same
shape as above but for outbound. This is the new entry point
for the sprint pricing modal's "Attach to send to customer"
affordance and for the send modal's preview.
**Routes (modify):**
- `src/app/api/v1/quotes/[id]/attachments/upload/route.ts` — change
the dispatch so `kind=proof` writes to `quotes.internal_attachments`
via `addInternalQuoteAttachment`, `kind=outbound` writes to
`quotes.outbound_attachments` via `addOutboundQuoteAttachment`.
Keep `kind=inbound` as-is (legacy RFQ archive path). Reject unknown
kinds. **Do NOT touch the PDF generator or the PDF download route —
the system-generated PDF continues to live in `quote_attachments`
as `kind="pdf"` because that's a system-managed file with its own
lifecycle, not operator-uploaded content.**
- verify: `pnpm typecheck` passes; spot-check upload via
`curl` and PB admin — `kind=proof` file lands in
`quotes.internal_attachments`, `kind=outbound` lands in
`quotes.outbound_attachments`. Neither appears in
`quote_attachments` rows.
**Routes (read endpoint, optional):**
- `src/app/api/v1/quotes/[id]/attachments/route.ts` — extend the
response shape so the approvals page can render internal +
outbound + historical inbound from one GET. New fields:
`{ inbound: [...historical rows], internal: [...quote row files],
outbound: [...quote row files] }`. The existing `items` array
stays for back-compat with anything that hasn't been updated.
**Pages / components (modify):**
- `src/app/(app)/quotes/approvals/[id]/page.tsx` — change the
attachments filter (lines 466-470) so outbound is sourced from
the quote row directly, not from a `direction === "outbound"`
filter on `quote_attachments`.
- `src/components/quotes/send-quote-modal.tsx` — read outbound from
the new field. The "select all" effect at line ~266 stays the
same (it iterates over outbound attachments); only the source
array changes.
- Sprint pricing modal upload UI — split the existing "attach proof"
button into two: "Attach for our records" (internal) and
"Attach to send to customer" (outbound). Keep the existing
proof upload going to `quote_item_attachments` for per-line
evidence; the quote-level splits above are for quote-level
files.
**Migration (one-time script, run by the operator):**
- `scripts/migrate-quote-attachments.mjs` (new) — Node 20+
script that:
1. Loads `POCKETBASE_URL` and `POCKETBASE_ADMIN_TOKEN` from `.env`.
2. Lists every `quote_attachments` row where
`direction in ("outbound","generated")` and groups by quote.
3. For each group, reads the corresponding `quotes.outbound_attachments`
array, merges with the file rows being migrated, writes back.
4. Lists every `quote_attachments` row where
`direction="inbound"` and skips (those stay).
5. Prints a summary: how many migrated, per quote.
6. Idempotent: re-runs are no-ops (the migrated rows won't
reappear in the filter).
7. Does NOT delete source rows.
## Steps
Each step has a verify line. Touch code in this order — top-down: stop
after each step's verify, then go again.
1. **Operator (do first, blocks everything):** Add the two new file fields
to the `quotes` collection in PB admin:
`outbound_attachments` and `internal_attachments`, both
`maxSelect: 10`, any MIME.
- verify: `curl -H "Authorization: $PB_TOKEN" ${PB}/api/collections/quotes | jq '.schema[].name' | grep -E 'outbound|internal'_attachments`
returns both field names.
2. **Service helpers** — add the four helpers in
`src/lib/server/quotes-service.ts`. Use the `addStockPurchaseAttachment`
pattern as the template. Do NOT touch the existing `uploadQuoteAttachment`.
- verify: `pnpm typecheck` (or whatever the project uses) passes.
3. **New route POST/DELETE** — create
`src/app/api/v1/quotes/[id]/internal-attachments/route.ts`
and the matching `outbound-attachments` file.
- verify: `pnpm test` still green; spot-check by running
`pnpm dev` and `curl -F file=@/tmp/x.pdf http://localhost:4001/api/v1/quotes/<id>/internal-attachments`
— expect 201 with the new filename echoed.
4. **Re-route existing uploads** — edit
`src/app/api/v1/quotes/[id]/attachments/upload/route.ts` so
`kind=proof` writes to `quotes.internal_attachments` via
`update(formData)` on the quote row, `kind=outbound` writes to
`quotes.outbound_attachments`. Reject `kind=pdf` here (the PDF
generator handles it). Keep `kind=inbound` as-is (legacy path).
- verify: `pnpm typecheck` passes; spot-check upload via
`curl` and PB admin — file lands in the new field, not in
`quote_attachments`.
5. **PDF generator** — edit `src/lib/server/quote-pdf-service.ts`
so `generateQuotePdf` appends the rendered PDF to
`quotes.outbound_attachments` instead of creating a
`quote_attachments` row. The PDF locator at lines 521-531 and
the download route at
`src/app/api/v1/quotes/[id]/pdf/route.ts:248-309` need the same
shape change: read from `quotes.outbound_attachments`, match
by filename or `pdf_generated_at` order.
- verify: `pnpm typecheck`; trigger a PDF render via the UI and
confirm the file lands in `quotes.outbound_attachments`,
not `quote_attachments`. Confirm the send-modal preview
still finds the PDF.
6. **GET endpoint** — extend
`src/app/api/v1/quotes/[id]/attachments/route.ts` so it returns
three lists: `{ inbound, internal, outbound }`. Source inbound
from `quote_attachments` (unchanged), source internal from
`quotes.internal_attachments`, source outbound from
`quotes.outbound_attachments`.
- verify: `curl` the endpoint and confirm the three lists
appear. The pages below all consume this — that's the
integration point.
7. **Pages** — update
`src/app/(app)/quotes/approvals/[id]/page.tsx` and
`src/components/quotes/send-quote-modal.tsx` so outbound is
read from the quote row directly. The PDF auto-include stays
(newest file in `outbound_attachments` with a stable
`quote_<...>.pdf` filename pattern is the marker).
- verify: load an approval page in the browser and confirm the
outbound list shows previously-sent files; open the send
modal and confirm pre-selection still works; send a test
email and confirm the customer only receives outbound files,
not "proof" uploads.
8. **Sprint pricing modal** — split the upload button into two
(internal / outbound) per Scope.
- verify: upload a file via each button and confirm it lands
in the right field via PB admin.
9. **Migration script** — write
`scripts/migrate-quote-attachments.mjs`, run it once, verify
summary. Don't delete source rows; they're archive.
- verify: `node scripts/migrate-quote-attachments.mjs`
prints "migrated N files across M quotes". Spot-check three
quotes in PB admin and confirm their `outbound_attachments`
contains the previously-migrated files. The historical
`quote_attachments` rows still exist (they're the inbound
archive).
10. **Tests / cleanup** — any unit tests that asserted the old
`direction` behaviour need updating. Run `pnpm test` until
green. Update AGENTS.md if there's a bullet that needs
matching the new shape (e.g. the bullet about the file URL
proxy whitelist — should mention `quotes.outbound_attachments`
specifically if it didn't before).
- verify: `pnpm test` green; `git grep "direction.*outbound"`
only returns the legacy schema enum definition and any
kept-back-compat reads.
## Verification
- `pnpm typecheck` — clean.
- `pnpm test` — full suite green. Existing
`src/app/api/invoicing/delivery-note-pdf/route.test.ts` and others
are NOT touched (we're not in delivery-notes scope). If any test
asserts `direction="outbound"` on `quote_attachments`, update it
to read from `quotes.outbound_attachments` instead — that's part
of step 10.
- Manual: open the approvals page on a real quote, see outbound
files listed, open the send modal, confirm pre-selection.
- Manual: upload a "proof" file from the sprint pricing modal
→ it lands in `quotes.internal_attachments`. Confirm by PB admin.
Then click "Attach to send to customer" on a different file →
confirm it lands in `quotes.outbound_attachments` and is
pre-selected in the send modal.
- Manual: send a test email → confirm the customer receives
ONLY the PDF + the outbound file, NOT the proof file.
- Migration: re-running the migration script is a no-op (idempotent).
## Risks / open questions
- **Multi-file append race condition.** The read-modify-write pattern
on `quotes.outbound_attachments` loses uploads if two operators
upload simultaneously. Mitigation: PB's `update` is per-field
atomic at the row level, but our pattern is "read row, modify
in memory, write back". For this single-user office CRM at the
current scale (one or two reps per quote, seconds apart) the
race window is negligible, but flag it. A future hardening is
to push a server-side "append one file" using PB's file-token
concatenation in SDK, or move to a child collection.
- **Migration is irreversible without the script.** The migration
moves files but does NOT delete source rows. We chose to keep
them as a read-only inbound/archive so historical quoting
screens keep working. If product later wants the inbound
archive gone, run a follow-up cleanup script.
- **The schema enum `direction` still has `outbound`.** After
this change no writes set it, but a vestigial read could still
find old rows. Defer the enum tightening until step 10 confirms
zero callers.
- **The PDF locator used `uploaded_at` sort** to pick the newest
PDF. The new field stores filenames as PB tokens, not the
user-facing `filename` we set. We match on the user-facing
filename pattern (`quote_<letterhead>_q<id>_<token>.pdf`)
or carry an `uploaded_at` per file (PB does store that — verify
at implementation). Flag if the matching logic needs to be
per-file instead of per-row.
- **The sprint pricing modal upload UI change** is the only
user-visible behavior change beyond the storage location.
Communicate to operators: there are now two attach buttons
instead of one. Training impact is minimal but mention it.
---
## Status (filled by implementer)
- [x] Plan read end-to-end + AGENTS.md / CLAUDE.md loaded
- [x] Branch / working tree confirmed
- [x] Step 1 (PB schema) confirmed by operator — fields exist on `quotes`
collection; verified by `add-quotes-attachment-fields.ts` and
`fix-quote-attachment-fields-maxselect.ts` migrations having been
applied.
- [x] Step 2 (service helpers) — `addOutboundQuoteAttachment`,
`addInternalQuoteAttachment`, `removeOutboundQuoteAttachment`,
`removeInternalQuoteAttachment` all in
`src/lib/server/quotes-service.ts`.
- [x] Step 3 (POST/DELETE routes) — internal-attachments and
outbound-attachments (route + `[filename]`) in
`src/app/api/v1/quotes/[id]/`.
- [x] Step 4 (re-route uploads) — `attachments/upload/route.ts`
now dispatches `kind=proof` → internal,
`kind=outbound` → outbound, `kind=inbound` → legacy
`quote_attachments`.
- [~] Step 5 (PDF generator) — **deliberately not migrated**. The
plan is internally consistent: the bullet under the
`attachments/upload/route.ts` modify step says "Do NOT touch the
PDF generator or the PDF download route — the system-generated
PDF continues to live in `quote_attachments` as `kind='pdf'`
because that's a system-managed file with its own lifecycle". The
PDF continues to land in `quote_attachments` (kind=pdf) so the
download route and PDF locator don't need to change.
- [~] Step 6 (GET endpoint) — **not extended**. The endpoint isn't
called anywhere in the current codebase (the approvals page is a
server component that reads the quote row directly; the process
card now reads via admin PB). Leaving the response shape
unchanged preserves back-compat.
- [x] Step 7 (pages) — approvals page reads outbound/internal
tokens directly from `quote.outbound_attachments` /
`quote.internal_attachments`, renders tabs accordingly.
Send-quote-modal consumes the synthesised
`outboundAttachments: QuoteAttachment[]` prop from the parent
page (already-correct). The
`process-outbound-attachments-card.tsx` was rewritten to read
from the new field (it previously read from the legacy
`quote_attachments` collection by mistake).
- [~] Step 8 (sprint pricing modal UI split) — **out of scope for
this iteration**. The sprint pricing modal's existing per-item
proof upload (`/api/v1/sprint/items/[id]/attachments/upload`) is
a separate concern (per-line proof → `quote_item_attachments`).
Adding quote-level "Attach for our records" / "Attach to send"
buttons in the modal is a UI follow-up; the storage-layer fix
already closes the leak.
- [x] Step 9 (migration script) — written at
`scripts/migrations/2026-09-09-migrate-quote-attachments-to-fields.ts`.
Idempotent. Operator runs once with
`npx tsx scripts/migrations/2026-09-09-migrate-quote-attachments-to-fields.ts --dry-run`
first to verify.
- [x] Step 10 (tests / cleanup) — no test changes needed; pre-
existing test failures in `quotes-service.test.ts` (4 stale-PDF
auto-regen tests) and `submit/route.test.ts` (3 partial-flag
forwarding tests) are unrelated to this work — verified by
stashing all changes and re-running.
### Report
**What was delivered**
- Two new file fields on `quotes`: `outbound_attachments`,
`internal_attachments`. Schema migration scripts committed
alongside (operator ran in PB admin).
- Service helpers in `quotes-service.ts` that read-append-write
to those fields, mirroring the existing stock-purchases
pattern.
- Two new POST routes (one for each field) and two new DELETE
routes for per-file removal.
- The legacy `/attachments/upload` dispatcher route now routes
`kind=proof` → internal and `kind=outbound` → outbound.
`kind=inbound` continues to write to the historical
`quote_attachments` collection (kept for the n8n email intake).
- Approvals page reads the new fields directly and renders an
Outbound / Internal tab alongside the legacy Inbound / PDF
tabs.
- Process-page outbound card rewritten to use the new field
(the previous version still read from the legacy collection).
- Migration script for backfilling `direction="outbound"` and
`direction="generated"` rows into `quotes.outbound_attachments`.
- Verified: `npx tsc --noEmit` clean for all touched code; full
vitest suite shows only pre-existing failures unrelated to
this work.
**What is still pending (out of scope this iteration)**
- Operator runs the migration script in dry-run mode first, then
live, against the production PB instance.
- Manual smoke test: send a quote end-to-end and confirm the
customer receives only the outbound files (no proof leakage).
- Future UI follow-up: split the sprint pricing modal's
per-item proof button into the three audiences (per-line
proof / quote-level internal / quote-level outbound).
### Commits / PRs

View file

@ -1,11 +0,0 @@
[mcp_servers.n8n-mcp]
url = "http://n8n-n65e89ippimn77mwlaijnl05.169.239.180.130.sslip.io/mcp-server/http"
[mcp_servers.n8n-mcp.http_headers]
Authorization = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0Zjc3MGRmMi1iZDJmLTQ0MzYtYjE1My1iMjcwMDRlZmM3YjYiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6IjliZGUyMzkzLWE3MWEtNDJmOC04ODM0LTJjYmQwNmIxMzBlNyIsImlhdCI6MTc4NTE0MzQ1Mn0.BscI90WyMhpvM8bzu9ZppQTrCjBE9-P7rFfzFAIE6Po"
[mcp_servers.wfdash-mcp]
url = "https://wfdash.apphub.co.za/api/mcp"
[mcp_servers.wfdash-mcp.http_headers]
Authorization = "Bearer 50e15d097fe85023fed7bb29340347befa224afda83cb270d89cdfa3c8299cb1"

4
.gitignore vendored
View file

@ -53,7 +53,3 @@ next-env.d.ts
# Superpowers brainstorming session (local-only mockups + state)
/.superpowers/
.superpowers/
# Scratch dir some scripts drop cached admin tokens / temp state into
.tmp/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View file

@ -1,589 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>WFDASH Stock Flow — How It Works Today</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=DM+Serif+Display&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
:root {
--bg: #fafafa;
--card: #ffffff;
--line: #e7e5e0;
--line-soft: #f0eeea;
--ink: #14171e;
--ink-2: #3a4256;
--ink-3: #7c7a72;
--accent: #2f6f5e;
--accent-soft: rgba(47, 111, 94, 0.08);
--accent-line: rgba(47, 111, 94, 0.22);
--warn: #c97b3f;
--warn-soft: rgba(201, 123, 63, 0.10);
--danger: #b06367;
--danger-soft: rgba(176, 99, 103, 0.10);
--pos: #5a4ec9;
--pos-soft: rgba(90, 78, 201, 0.10);
--pos-line: rgba(90, 78, 201, 0.25);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--ink);
font-family: "Outfit", system-ui, -apple-system, sans-serif;
font-size: 15px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 64px 28px 96px; }
/* Header */
.eyebrow {
display: inline-flex; align-items: center; gap: 8px;
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--accent); font-weight: 600;
padding: 6px 12px; background: var(--accent-soft);
border-radius: 999px; margin-bottom: 20px;
}
h1 {
font-family: "DM Serif Display", serif;
font-weight: 400;
font-size: 44px;
line-height: 1.1;
margin: 0 0 16px;
letter-spacing: -0.01em;
}
.lede { color: var(--ink-2); font-size: 18px; max-width: 720px; margin: 0 0 48px; }
.lede strong { color: var(--ink); font-weight: 600; }
/* Section */
section { margin: 64px 0; }
h2 {
font-size: 22px; font-weight: 600; margin: 0 0 8px;
display: flex; align-items: center; gap: 10px;
}
h2 svg { color: var(--accent); flex-shrink: 0; }
h2 .num {
display: inline-grid; place-items: center;
width: 26px; height: 26px; border-radius: 8px;
background: var(--accent); color: white;
font-size: 13px; font-weight: 600; font-family: "Outfit";
}
.sub { color: var(--ink-3); font-size: 14px; margin: 0 0 24px; }
/* Source of truth card */
.truth {
background: var(--card); border: 1px solid var(--line);
border-radius: 16px; padding: 24px 28px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
}
.truth-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 24px;
}
@media (max-width: 760px) { .truth-grid { grid-template-columns: 1fr; } }
.truth h3 {
font-size: 16px; font-weight: 600; margin: 0 0 6px;
font-family: "JetBrains Mono", monospace; color: var(--accent);
}
.truth p { margin: 0 0 12px; color: var(--ink-2); font-size: 14px; }
.kv { display: grid; grid-template-columns: 130px 1fr; row-gap: 8px; column-gap: 16px; margin: 0; }
.kv dt { color: var(--ink-3); font-size: 13px; font-family: "JetBrains Mono", monospace; }
.kv dd { margin: 0; font-size: 13px; color: var(--ink); font-family: "JetBrains Mono", monospace; }
/* Path cards: stock IN and stock OUT */
.paths { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 760px) { .paths { grid-template-columns: 1fr; } }
.path {
background: var(--card); border: 1px solid var(--line);
border-radius: 16px; padding: 22px 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
}
.path.in { border-top: 3px solid var(--accent); }
.path.out { border-top: 3px solid var(--warn); }
.path h3 {
font-size: 14px; font-weight: 600; margin: 0 0 4px;
text-transform: uppercase; letter-spacing: 0.06em;
}
.path.in h3 { color: var(--accent); }
.path.out h3 { color: var(--warn); }
.path h4 { font-size: 16px; font-weight: 600; margin: 8px 0 4px; color: var(--ink); }
.path p { font-size: 13.5px; color: var(--ink-2); margin: 0 0 12px; }
.path .where {
font-family: "JetBrains Mono", monospace; font-size: 12px;
color: var(--ink-3); padding: 6px 10px;
background: var(--line-soft); border-radius: 6px;
display: inline-block; margin-top: 4px;
}
.flow {
display: flex; align-items: center; gap: 6px;
font-family: "JetBrains Mono", monospace; font-size: 11.5px;
color: var(--ink-2); flex-wrap: wrap; margin: 8px 0 4px;
}
.flow .pill {
background: var(--line-soft); padding: 3px 8px; border-radius: 5px;
}
.flow .arrow { color: var(--ink-3); }
/* POS section */
.pos-card {
background: var(--card); border: 1px solid var(--line);
border-radius: 16px; padding: 28px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
position: relative; overflow: hidden;
}
.pos-card::before {
content: ""; position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: var(--pos);
}
.pos-grid {
display: grid; grid-template-columns: 280px 1fr; gap: 32px; align-items: start;
}
@media (max-width: 760px) { .pos-grid { grid-template-columns: 1fr; } }
.pos-icon {
background: var(--pos-soft); border-radius: 14px;
aspect-ratio: 1; display: grid; place-items: center;
border: 1px solid var(--pos-line);
}
.pos-icon svg { width: 64px; height: 64px; color: var(--pos); }
.pos-meta { font-size: 14px; color: var(--ink-2); }
.pos-meta p { margin: 0 0 12px; }
.pos-meta .endpoint {
display: block; font-family: "JetBrains Mono", monospace; font-size: 12.5px;
color: var(--ink); padding: 6px 10px; background: var(--line-soft);
border-radius: 6px; margin: 4px 0;
}
.pos-meta .endpoint .verb {
color: var(--pos); font-weight: 600; margin-right: 8px;
}
/* Pricing triggers */
.triggers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
@media (max-width: 820px) { .triggers { grid-template-columns: 1fr; } }
.trigger {
background: var(--card); border: 1px solid var(--line);
border-radius: 14px; padding: 18px 20px;
}
.trigger .when {
font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--warn); margin-bottom: 8px;
}
.trigger h4 { font-size: 15px; font-weight: 600; margin: 0 0 6px; }
.trigger p { font-size: 13.5px; color: var(--ink-2); margin: 0 0 10px; }
.trigger .set-by {
font-family: "JetBrains Mono", monospace; font-size: 11.5px;
color: var(--ink-3);
}
.trigger .set-by b { color: var(--ink); font-weight: 500; }
/* Mermaid */
.mermaid-wrap {
background: var(--card); border: 1px solid var(--line);
border-radius: 16px; padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
overflow-x: auto;
}
.mermaid { font-family: "Outfit", sans-serif !important; }
.mermaid .nodeLabel { font-family: "Outfit", sans-serif !important; font-size: 13px !important; }
/* Legend */
.legend {
display: flex; flex-wrap: wrap; gap: 18px;
margin-top: 20px; font-size: 13px; color: var(--ink-2);
}
.legend-item { display: flex; align-items: center; gap: 8px; }
.legend-swatch { width: 14px; height: 14px; border-radius: 4px; }
/* Code refs */
.ref {
font-family: "JetBrains Mono", monospace; font-size: 12px;
color: var(--ink-3);
}
.ref b { color: var(--ink); font-weight: 500; }
/* Footer */
footer {
margin-top: 80px; padding-top: 24px; border-top: 1px solid var(--line);
font-size: 13px; color: var(--ink-3);
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px;
}
</style>
</head>
<body>
<div class="wrap">
<div class="eyebrow">WFDASH · Stock &amp; POS Architecture</div>
<h1>How stock works today — and how it talks to the POS.</h1>
<p class="lede">
There is one source of truth (<strong>stock_ledger</strong>) and one denormalised
cache (<strong>stock_items.current_qty</strong>). Every stock movement
— buying from a supplier, booking a surplus, fulfilling a customer order, ringing
up a sale at the till — writes a ledger row first, then mirrors the qty into the
cache. The POS app never touches the cache directly; it goes through three
authenticated API endpoints.
</p>
<!-- 1. Source of truth -->
<section>
<h2><span class="num">1</span> The single source of truth</h2>
<p class="sub">Two PocketBase collections carry the entire stock system. The
ledger is append-only and signed; the cache is denormalised for fast reads.</p>
<div class="truth">
<div class="truth-grid">
<div>
<h3>stock_items</h3>
<p>The catalogue. One row per SKU. Carries all the
pricing, supplier linkage, and the running quantity the UI reads.</p>
<dl class="kv">
<dt>sku</dt><dd>supplier's code</dd>
<dt>name / unit</dt><dd>display + uom</dd>
<dt>current_qty</dt><dd>denormalised cache</dd>
<dt>min_qty</dt><dd>low-stock trigger</dd>
<dt>target_qty</dt><dd>auto-reorder goal</dd>
<dt>cost_price</dt><dd>latest purchase cost</dd>
<dt>selling_price</dt><dd>POS shelf price</dd>
<dt>markup</dt><dd>POS markup %</dd>
<dt>cost_history</dt><dd>append-only list</dd>
<dt>supplier</dt><dd>FK → suppliers</dd>
<dt>is_active</dt><dd>soft-hide flag</dd>
</dl>
</div>
<div>
<h3>stock_ledger</h3>
<p>The audit trail. Every stock movement — in or out — is a row here
with a signed quantity and a reason. The cache is rebuildable
from this table alone.</p>
<dl class="kv">
<dt>stock_item</dt><dd>FK → stock_items</dd>
<dt>change_qty</dt><dd>signed (+ / −)</dd>
<dt>unit_cost</dt><dd>when known</dd>
<dt>reason</dt><dd>enum (see below)</dd>
<dt>reference_type</dt><dd>e.g. "order_item"</dd>
<dt>reference_id</dt><dd>FK to the source</dd>
<dt>notes</dt><dd>free text + audit tags</dd>
<dt>created_by</dt><dd>user FK</dd>
</dl>
</div>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-swatch" style="background: var(--accent);"></div>stock-in paths (qty &gt; 0)</div>
<div class="legend-item"><div class="legend-swatch" style="background: var(--warn);"></div>stock-out paths (qty &lt; 0)</div>
<div class="legend-item"><div class="legend-swatch" style="background: var(--pos);"></div>POS app surfaces</div>
</div>
</div>
</section>
<!-- 2. Stock IN paths -->
<section>
<h2><span class="num">2</span> How items get INTO stock</h2>
<p class="sub">Three entry points. All three end in
<span class="ref"><b>stock_ledger.reason ∈ {stock_order, purchase_surplus, adjustment}</b></span>.</p>
<div class="paths">
<div class="path in">
<h3>Path A</h3>
<h4>Receive a stock purchase (the accountant's flow)</h4>
<p>Warehouse marks the PO received
(<span class="ref"><b>stock_purchases.status = "received"</b></span>).
Accountant then hits "Book into stock" on the invoicing page.</p>
<div class="flow">
<span class="pill">stock_purchase.received</span>
<span class="arrow">→</span>
<span class="pill">bookStockFromInvoice()</span>
<span class="arrow">→</span>
<span class="pill">stock_ledger +qty</span>
<span class="arrow">→</span>
<span class="pill">stock_items.current_qty + qty</span>
<span class="arrow">→</span>
<span class="pill">booked_at stamped</span>
</div>
<div class="where">src/lib/server/stock-booking-service.ts · /api/v1/invoicing/book</div>
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
Partial lines supported — each line carries its own
<span class="ref"><b>quantity_received</b></span> accumulator.
</p>
</div>
<div class="path in">
<h3>Path B</h3>
<h4>Book a surplus from a customer collection</h4>
<p>When the supplier delivers more than the customer ordered
(e.g. MOQ), the driver records <span class="ref"><b>qty_collected</b></span>
on the order item. Operator splits the difference into stock.</p>
<div class="flow">
<span class="pill">order_item.collected</span>
<span class="arrow">→</span>
<span class="pill">findMatchingStockItem()</span>
<span class="arrow">→</span>
<span class="pill">stock_ledger +surplus</span>
<span class="arrow">→</span>
<span class="pill">stock_items.current_qty + surplus</span>
</div>
<div class="where">src/lib/server/stock-surplus.ts · /api/v1/stock/surplus-book</div>
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
Match by SKU → by supplier + normalised description. Operator can override.
</p>
</div>
<div class="path in">
<h3>Path C</h3>
<h4>Manual stocktake adjustment</h4>
<p>Operator counts the shelf, finds a discrepancy vs. the cache,
and posts a signed correction.</p>
<div class="flow">
<span class="pill">/stock/[id]</span>
<span class="arrow">→</span>
<span class="pill">stock_items.adjust</span>
<span class="arrow">→</span>
<span class="pill">stock_ledger ±qty (reason: adjustment)</span>
<span class="arrow">→</span>
<span class="pill">current_qty patched</span>
</div>
<div class="where">/api/v1/stock/items/[id]/adjust</div>
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
Compensation tagging kicks in if the cache write fails after the ledger row lands.
</p>
</div>
</div>
</section>
<!-- 3. Stock OUT paths -->
<section>
<h2><span class="num">3</span> How items get OUT of stock</h2>
<p class="sub">Three exit points. All three write
<span class="ref"><b>stock_ledger.reason = "customer_fulfillment"</b></span> with a
negative quantity (or <b>"adjustment"</b> for write-offs).</p>
<div class="paths">
<div class="path out">
<h3>Path D</h3>
<h4>Fulfil a customer order from stock</h4>
<p>Operator on <span class="ref"><b>/orders/new</b></span> ticks "fulfil from stock"
for a line. Same route used on <span class="ref"><b>/orders/[id]/process</b></span>
to redirect an existing line.</p>
<div class="flow">
<span class="pill">order_item create</span>
<span class="arrow">→</span>
<span class="pill">item_status = "stock_booked"</span>
<span class="arrow">→</span>
<span class="pill">stock_ledger −qty</span>
<span class="arrow">→</span>
<span class="pill">current_qty − qty</span>
</div>
<div class="where">src/app/api/v1/stock/fulfill/route.ts</div>
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
`stock_booked_from` back-link written on the order_item for audit + drill-down.
</p>
</div>
<div class="path out">
<h3>Path E</h3>
<h4>POS sale (the till)</h4>
<p>External WFSALES POS app (Flutter, separate repo) calls a
<span class="ref"><b>Bearer POS_API_KEY</b></span>-protected endpoint,
commits a sale, and decrements stock in one transaction.</p>
<div class="flow">
<span class="pill">till checkout</span>
<span class="arrow">→</span>
<span class="pill">pos_sales + pos_sale_items</span>
<span class="arrow">→</span>
<span class="pill">stock_ledger −qty (ref: pos_sale)</span>
<span class="arrow">→</span>
<span class="pill">current_qty − qty</span>
</div>
<div class="where">src/lib/server/pos-service.ts · /api/v1/pos/checkout</div>
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
Idempotent on <span class="ref"><b>client_ref</b></span> — offline till retries
don't double-decrement.
</p>
</div>
<div class="path out">
<h3>Path F</h3>
<h4>Write-off / manual shrink</h4>
<p>Damaged in the warehouse, expired, lost, given to a sample kit.
Same <span class="ref"><b>/stock/[id]/adjust</b></span> route as Path C,
just a negative qty.</p>
<div class="flow">
<span class="pill">/stock/[id]</span>
<span class="arrow">→</span>
<span class="pill">stock_items.adjust (−n)</span>
<span class="arrow">→</span>
<span class="pill">stock_ledger reason: adjustment</span>
</div>
<div class="where">/api/v1/stock/items/[id]/adjust</div>
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
One route handles both directions — the sign of the qty is the difference.
</p>
</div>
</div>
</section>
<!-- 4. POS integration -->
<section>
<h2><span class="num">4</span> How the POS app links in</h2>
<p class="sub">The WFSALES POS is a <b>separate</b> app (Flutter) that runs on the
office till. It only ever talks to WFDASH through three authenticated endpoints
under <span class="ref"><b>/api/v1/pos/*</b></span>. The auth is a
device-scoped bearer token (<span class="ref"><b>POS_API_KEY</b></span>) — distinct
from the agent's key, because the till only needs catalog + checkout + history.</p>
<div class="pos-card">
<div class="pos-grid">
<div class="pos-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<path d="M7 8h10M7 12h6M7 16h4"/>
<circle cx="17" cy="16" r="2.5"/>
</svg>
</div>
<div class="pos-meta">
<p><b>Catalog pull</b> — at till boot, the POS calls the catalog endpoint and caches
the active stock list locally. This is what makes the offline queue possible.</p>
<span class="endpoint"><span class="verb">GET</span>/api/v1/pos/catalog</span>
<p style="margin-top: 14px;"><b>Checkout</b> — when the cashier hits "ring up", a single
POST creates the sale + line items + the negative ledger rows + decrements the cache.
Idempotent on <span class="ref"><b>client_ref</b></span> so a retry of a lost-response sale
doesn't double-decrement stock.</p>
<span class="endpoint"><span class="verb">POST</span>/api/v1/pos/checkout</span>
<p style="margin-top: 14px;"><b>History + cashup</b> — the till pulls today's sales for
cashup reconciliation and the cashier's shift close.</p>
<span class="endpoint"><span class="verb">GET</span>/api/v1/pos/sales?from=…&amp;to=…</span>
<p style="margin-top: 14px; margin-bottom: 0; font-size: 13px; color: var(--ink-3);">
<b>Categories</b> in the catalog come from server-side matching
(<span class="ref"><b>match_brands → match_supplier_tags → match_keywords</b></span> on the
<b>categories</b> collection), not from a relation on the stock row. Tills can read the
category icon straight off the response.
</p>
</div>
</div>
</div>
</section>
<!-- 5. When prices get set -->
<section>
<h2><span class="num">5</span> When do prices get set?</h2>
<p class="sub">Three pricing fields on a stock row —
<span class="ref"><b>cost_price</b></span>, <span class="ref"><b>selling_price</b></span>,
<span class="ref"><b>markup</b></span>. Each is set by a different trigger.</p>
<div class="triggers">
<div class="trigger">
<div class="when">Trigger 1 · Receive a stock purchase</div>
<h4>cost_price (auto)</h4>
<p>When the accountant books a stock purchase, the line's
<span class="ref"><b>cost_price</b></span> overwrites the stock row's
<b>cost_price</b> and appends an immutable entry to
<span class="ref"><b>cost_history</b></span>.</p>
<div class="set-by">Set by: <b>buildStockReceiptCostPatch()</b> in
stock-cost-service.ts</div>
</div>
<div class="trigger">
<div class="when">Trigger 2 · Manual edit on /stock</div>
<h4>selling_price + markup (manual)</h4>
<p>Operator edits the shelf price or markup % directly on the stock detail page.
<b>If cost changes,</b> a zero-qty ledger row is written as an audit record
(reason: <span class="ref"><b>adjustment</b></span>, ref: <b>manual</b>).</p>
<div class="set-by">Set by: <b>updateStockItemsPricing()</b> in
stock-pricing-service.ts</div>
</div>
<div class="trigger">
<div class="when">Trigger 3 · AI / Hermes agent</div>
<h4>selling_price (autonomous)</h4>
<p>External agent (Hermes) keeps the catalogue in sync with supplier price lists —
e.g. when a price list PDF re-baselines, it PATCHes the
<span class="ref"><b>selling_price</b></span> field through the agent API.</p>
<div class="set-by">Set by: <b>updateStockItem()</b> in
stock-items-service.ts (PATCH /api/v1/stock/items)</div>
</div>
</div>
</section>
<!-- 6. The graph -->
<section>
<h2><span class="num">6</span> The whole flow, in one picture</h2>
<p class="sub">Green = stock-in. Orange = stock-out. Purple = POS surfaces.
Everything terminates in the same ledger.</p>
<div class="mermaid-wrap">
<pre class="mermaid">
flowchart TB
classDef in fill:#e8f1ed,stroke:#2f6f5e,color:#14171e
classDef out fill:#faeede,stroke:#c97b3f,color:#14171e
classDef pos fill:#ede9f8,stroke:#5a4ec9,color:#14171e
classDef cache fill:#ffffff,stroke:#7c7a72,color:#14171e,stroke-dasharray: 4 3
classDef truth fill:#f4f1ea,stroke:#14171e,color:#14171e
subgraph SOURCES["Stock-in sources"]
direction TB
SP["stock_purchase<br/>(accountant books)"]:::in
SU["customer order<br/>(supplier over-delivers)"]:::in
ADJ_IN["manual adjustment<br/>(stocktake correction)"]:::in
end
subgraph OUTS["Stock-out destinations"]
direction TB
CO["customer order<br/>(fulfil from stock)"]:::out
PSALE["POS sale<br/>(WFSALES till)"]:::pos
ADJ_OUT["manual write-off<br/>(damage / loss)"]:::out
end
LEDGER[("stock_ledger<br/>(append-only, signed qty)")]:::truth
CACHE[("stock_items.current_qty<br/>(denormalised cache)")]:::cache
POS_APP["WFSALES POS app<br/>(Flutter, separate repo)"]:::pos
POS_APP -- GET /api/v1/pos/catalog --> CACHE
POS_APP -- POST /api/v1/pos/checkout --> PSALE
SP -- "bookStockFromInvoice<br/>reason: stock_order" --> LEDGER
SU -- "bookSurplus<br/>reason: purchase_surplus" --> LEDGER
ADJ_IN -- "stock_items.adjust<br/>reason: adjustment" --> LEDGER
CO -- "stock/fulfill<br/>reason: customer_fulfillment" --> LEDGER
PSALE -- "checkoutSale<br/>reason: customer_fulfillment<br/>ref: pos_sale" --> LEDGER
ADJ_OUT -- "stock_items.adjust<br/>reason: adjustment" --> LEDGER
LEDGER -. "update current_qty" .-> CACHE
WFDASH_UI["WFDASH /stock, /stock-orders,<br/>/orders/[id] (operator UI)"]
WFDASH_UI -- "reads" --> CACHE
WFDASH_UI -- "drills into" --> LEDGER
</pre>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-swatch" style="background: var(--accent);"></div>Stock-in to ledger</div>
<div class="legend-item"><div class="legend-swatch" style="background: var(--warn);"></div>Stock-out to ledger</div>
<div class="legend-item"><div class="legend-swatch" style="background: var(--pos);"></div>POS-app surfaces</div>
<div class="legend-item"><div class="legend-swatch" style="background: white; border: 1px dashed var(--ink-3);"></div>Derived cache (rebuildable from ledger)</div>
</div>
</section>
<footer>
<span>WFDASH codebase · paths referenced as <code>src/...</code> from the repo root</span>
<span>Generated for the WFDASH operator walkthrough</span>
</footer>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'base',
themeVariables: {
fontFamily: '"Outfit", system-ui, sans-serif',
fontSize: '13px',
primaryColor: '#ffffff',
primaryTextColor: '#14171e',
primaryBorderColor: '#e7e5e0',
lineColor: '#7c7a72',
secondaryColor: '#fafafa',
tertiaryColor: '#fafafa',
},
flowchart: { curve: 'basis', padding: 12 }
});
</script>
</body>
</html>

View file

@ -1,9 +0,0 @@
#!/bin/bash
export AGENT_BROWSER_SOCKET_DIR=/tmp/ab-sock
for path in dashboard orders quotes customers pricelist repairs; do
echo "=== $path ==="
agent-browser open "http://localhost:4001/$path?cb=$(date +%s)" 2>&1 | head -1
agent-browser wait --load networkidle > /dev/null 2>&1
agent-browser screenshot ".tmp/mobile-pass/after/$path.png" 2>&1 | head -1
done
ls -la .tmp/mobile-pass/after/

296
AGENTS.md
View file

@ -38,44 +38,8 @@ Pattern in code:
stream the bytes back via PB's `/api/files/...` URL
3. Stable filename per document type so the retrieval side can
disambiguate from other attachments on the same item
**Always ADD, never replace:** a PocketBase file PATCH under the bare
field name replaces the whole list — it deleted a driver's photo on
2026-10-19. Build uploads with `addAttachmentForm()` from
`src/lib/server/order-item-files.ts` (`order_item_attachements+`, and
`-` only for the same document's older copies), and find a file by name
with `pickStoredCopy()`, never by position.
<!-- END:pb-attachments-pattern -->
<!-- BEGIN:file-url-paths -->
# File URLs go through `/api/v1/files/`, never `/api/files/`
The WFDASH file-serving proxy lives at
`/api/v1/files/{collection}/{recordId}/{filename}` (route file:
`src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts`).
Every `<a href={...}>` and every fetch from a client component that
opens a repair/quote/order attachment must use the `/api/v1/` prefix.
`/api/files/{collection}/{recordId}/{filename}` (no `v1/`) 404s —
that's the *PocketBase* convention, not a route in this app. The
PB instance serves files at that path, and the WFDASH proxy
forwards to it server-side, but a client that points directly at
`/api/files/...` gets the Next.js 404 page.
**Gotcha:** PocketBase record responses (e.g. `record.delivery_note`
as a `string[]` of filenames) sometimes come back as fully-qualified
PB URLs like `http://pb-host/api/files/...` — those should be
re-written to `/api/v1/files/...` on the client before rendering.
The existing detail page builds the path from scratch from the
record id + filename, which sidesteps the issue. Don't blindly
trust the URL PB hands back.
The `src/app/api/invoicing/delivery-note-pdf/route.test.ts` test
file intentionally matches `/api/files/...` in an expect — that's
asserting the *raw PB response* shape, not the URL the client
should use. Different concern.
<!-- END:file-url-paths -->
<!-- BEGIN:data-layer-discipline -->
# Data access layer discipline
@ -121,263 +85,3 @@ with `await fetch('/api/v1/x?...')` (or a direct service-layer call),
then update the page to await. Half-day budget for 5 pages if no
weird cross-resource joins are involved.
<!-- END:data-layer-discipline -->
# Audit trail (`audit_log`) is written by a PB hook, not by app code
Every create / update / delete on `quotes`, `quote_items`,
`quote_attachments`, `orders`, `order_items` and `delivery_notes` is
recorded by `pocketbase/pb_hooks/audit_log.pb.js` (+ `audit_log_utils.js`)
into the `audit_log` collection. Don't add hand-written "audit" notes for
field changes — the hook already captures from → to per field.
Attribution: dashboard users writing straight to PB are credited
automatically. Server-side writes go through `getAdminPocketBase()`, which
attaches `X-Audit-*` headers from `src/lib/server/audit-context.ts` (the
browser forwards its PB token as `x-wfdash-auth`; PB verifies it). If a
server path knows the actor better than the request does (MCP `actor`
args), wrap it in `runWithAuditActor(...)`. n8n writes appear as
"Automation" unless the workflow sends `X-Audit-Actor-Name`.
Read side: `src/lib/server/audit-log-service.ts` →
`/api/v1/audit/{quote|order|delivery_note}/{id}` → `<AuditTimeline>`.
Noisy fields (search/AI/timers) are skipped via `IGNORED_FIELDS` in the
hook utils — add new auto-updated fields there.
Hooks deploy from git: commit changes in `pocketbase/pb_hooks/`, then
`npm run pb:hooks:publish` (pushes that folder to Forgejo `David/WFDASH-PBHOOKS`);
a VPS cron syncs the files listed in `DEPLOY` into the live PB hooks volume
(see `scripts/pb-hooks/README.md`). The audit hooks went live on 2026-10-17;
`audit_log` has no history from before that date.
# Giellie agent (`/giellie`) — tools come from the MCP, policy lives in one file
The sidebar "Giellie Agent" is a MiniMax chat agent (`src/lib/server/giellie-*.ts`,
routes under `/api/v1/giellie/*`, chats in the `giellie_chats` PB collection).
It calls the **same MCP handler** as Hermes/n8n (`src/app/api/mcp/route.ts`),
in-process — a new MCP tool is automatically available to Giellie.
- Safety lives in `src/lib/server/giellie-tool-policy.ts`: `*.delete` and secret/base64
tools are denied; only tools in `READ_TOOLS` run without approval; everything else
(including brand-new tools) pauses for an Approve/Reject card; email/PO sends always
ask. **When you add a read-only MCP tool, add it to `READ_TOOLS`** or Giellie will
ask approval for every call.
- Every `/api/v1/giellie/*` route authenticates with `getBrowserSessionUser(req)`
(PB token in `Authorization`). These routes hold `AGENT_API_KEY`; never expose one
without that check.
- Config (key, model, base URL, on/off) is the existing `/settings/ai` row; Giellie
uses its own system prompt in `giellie-system-prompt.ts`.
- Classification is **args-aware**: use `classifyToolCall(name, input)` (not bare
`classifyTool`) for routing. A plain write whose input carries a contact-routing key
(`email*`, `bcc`, `cc`, `sender`, `recipient`, any depth ≤ 4) is escalated to "confirm".
`quotes.re_run_ai` is denied (it wipes every quote item); `orders.cancel`,
`delivery_notes.cancel` and `delivery_notes.combine` always ask.
- The "Trust this chat" toggle is **per chat** and only auto-approves ordinary writes.
It never covers confirm-class actions: email/PO sends, cancels/merges, and changes to
email/contact fields.
- Env: `AGENT_API_KEY` (server-side MCP auth, required) and, in containers,
`AGENT_API_BASE_URL` (a full `https://` origin) so the MCP's loopback calls reach the
app; scheme-less values like `localhost:4001` are ignored.
- The `giellie_chats` collection is created by `npm run pb:migrate:giellie-chats`
(manual step on the live PB, like select values and pb_hooks). Its API rules are
stricter than the original spec: users may list/view their own chats only;
create/update/delete are null (all writes go through the admin client in
`giellie-chat-store.ts`).
# AI quote assistant — fills unpriced quote lines from the POS
Runbook: `docs/quote-assistant.md`. Files: `src/lib/server/quote-assistant-{core,shortlist,ai,prompt,service}.ts`
(logic, POS shortlist, MiniMax call, default prompt, orchestration) and `src/lib/quote-assistant-{review,save}.ts`
(client-safe review/save helpers). Routes: `POST /api/v1/quotes/assistant/tick` (n8n, `assertAgentAuth`),
`POST /api/v1/quotes/{id}/assistant/run` (browser), MCP `quotes.assistant_fill`. Settings live in Settings → AI.
The POS matcher is **shared** and **size-aware**: `src/lib/server/pos-match.ts`
(the candidate pool, the size-aware ranking and `pickPosItems`) and
`src/lib/item-size.ts` (the size parser) are used by the quote assistant,
the order fill and the blue price check. `quote-assistant-shortlist.ts`
delegates to it.
- A line is **pending** (purple) only when `ai_fill.status === "filled" && !ai_fill.reviewed_at && pricing_source === "ai_pos"` (`isAiFillPending`).
- A price is written **only for an exact match**. A closest match writes no price and records `ai_fill.closest` (the nearest item + what differs); the badge then reads "Closest: …". The AI answers **exact**, **closest** or **none** per line.
- AI writes **never set `is_processed`**, never change quote status, never send, and never touch a line with a price, `user_edited` or `is_processed`.
- `pricing_source "ai_pos"` must survive human saves: route every save through `nextPricingSourceOnSave` / `withAiReview`, or a stale page turns the line back to `manual`.
- Manual live steps (like select values and pb_hooks): run `npm run pb:migrate:quote-assistant` against the live PB before deploying, then activate the n8n workflow "WFDASH Quote assistant tick" (every 5 min, POSTs the tick route). See the runbook.
# AI order assistant — matches orders to the quote they came from
Runbook: `docs/order-assistant.md`. Files: `src/lib/server/order-assistant-{core,ai,prompt,service}.ts`
(eligibility + auto-link rule, AI call, default prompt, orchestration) and
`src/lib/server/order-quote-link-service.ts` (the deterministic shortlist). Routes:
`POST /api/v1/orders/assistant/tick` (n8n, `assertAgentAuth`),
`POST /api/v1/orders/{id}/assistant/run` (browser), MCP `orders.assistant_match`. Settings live in
Settings → AI → Order assistant.
- Quote matching is **AI-only**. The rule-based scorer (`order-quote-match.ts`, `computeQuoteSuggestion`) survives only as the assistant's shortlist; nothing shows or writes its suggestions. New orders are matched by `matchOrder` right after `createOrder` (when the assistant is on), the tick, the sweep route and the order page's "Match with AI".
- `orders.quote` is written **only** through `setOrderQuote`, inside `runWithAuditActor({ name: "AI order assistant" }, …)`. A 409 from it is not an error — the assistant falls back to a suggestion.
- Auto-link needs **all** of: the setting on, confidence `high`, every real order line paired `same_item "sure"`, every pair on a line of the one chosen quote, and the quote's company = the order's company. Otherwise it only writes `potential_quote` (confidence 0.9 / 0.7 / 0.5) and `quote_match_method "ai_suggest"`.
- The AI never changes a line's status, creates a PO or sends anything. An AI failure is recorded on `orders.ai_quote_match` with status `error` and is never thrown out of `matchOrder`.
- Manual live steps (like select values and pb_hooks): run `npm run pb:migrate:order-assistant` against the live PB, publish the `ai_quote_match` / `ai_fill` `IGNORED_FIELDS` hook change, deploy, dry-run then `--apply` `scripts/2026-10-24-clear-rule-quote-suggestions.ts`, activate the n8n workflow "WFDASH Order assistant tick" (every 5 min), then switch the order assistant on in Settings → AI. See the runbook.
# AI Collection Confirm checks — reads driver photos, ticks lines, never confirms
Runbook: `docs/collection-ai-checks.md`. Files: `src/lib/server/collection-ai-{read,pair,findings,service,prompt}.ts`
(vision read, pairing call, document-level findings, orchestration, default prompt) and `src/lib/collection-ai-ticks.ts`
(pure, client-safe tick logic). Routes: `POST /api/v1/collection-confirm/ai/tick` (n8n, agent auth),
`.../ai/check` and `.../add-to-price-list` (browser session), MCP `collection_confirm.ai_check`. Settings live in Settings → AI → Collection checks.
- **Manual first:** the Add to price list button and the Check with AI button are manual; the tick only reads and checks, nothing else.
- The AI writes **only** `ai_check`, `collection_doc_reads` rows and an **empty** SKU when it is sure. It **never writes a price** (owner's rule, 2026-10-30: it misread a discount that wasn't on the invoice) — a differing cost is a red Price tick for a person. It never overwrites a person's SKU, confirms a collection, or changes status.
- **Auto-push** (`collection-auto-push-service.ts`, runbook section "Auto-push"): a purple card is confirmed by the tick itself and logged to `ai_settings.collection_auto_push_log`; off by default, needs `npm run pb:migrate:collection-auto-push`. Change what counts as pushable in `planAutoPush`, never by calling confirm elsewhere.
- The service refuses to run until `ai_check` exists (PB drops unknown fields silently); `ai_check` is in `IGNORED_FIELDS` of the audit hook.
- Manual live steps (like select values and pb_hooks): run `npm run pb:migrate:collection-ai` against the live PB, redeploy `pocketbase/pb_hooks/audit_log_utils.js`, deploy, activate the n8n workflow "WFDASH Collection check tick" (every 5 min), then switch Collection checks on in Settings → AI. See the runbook.
# PO numbers: create customer POs only through `createPurchaseOrderWithNumber`
`src/lib/server/po-number.ts` numbers a PO by reading the month's highest
number and adding one. Called in parallel (one order, several suppliers)
that gave 34 duplicate PO numbers by 2026-10-19. `createPurchaseOrderWithNumber`
numbers and creates one PO at a time and retries when the unique index on
`po_number` rejects a clash. Never call `generatePONumber` and then
`purchase_orders.create` yourself; the browser goes through
`POST /api/v1/orders/[id]/purchase-orders`. The partial unique index is a
manual live step: `npm run pb:migrate:po-number-unique` (after deploying).
# Stock orders on Collection Confirm
Collected stock lines show on Collection Confirm until their stock order is
`received` (`STOCK_ON_PAGE_FILTER` in `collection-confirm-service.ts`, shared
with the AI check). Confirm stock calls `markStockOrdersReceived`, which flips
the order to `received` once every line is collected/booked — the
accountant's booking queue lists `received`. Stock photos live on
`stock_purchases.additional_attachments` (multi-file after the manual live
step `npm run pb:migrate:stock-photos`; run it before deploying the driver
app). Always add with `additional_attachments+`.
# Short collections split on Collection Confirm
When a collected customer line has `qty_collected < quantity`, Confirm
(`confirmOrderItemsWithSplits`) cuts it to what was collected — that part
goes to invoicing and closes in full — and creates the rest as a new
`pending` line on the same order (`src/lib/short-collection.ts`, marker
`order_item_details.shortCollection`). The process page offers "Back order"
(relink to the original PO: `po_generated`, or `collection_pool` when ready)
via `POST /api/v1/order-items/{id}/back-order`; otherwise the buyer orders it
elsewhere or cancels it like any new line. Never put a part-invoiced line
back to `ready_invoice` expecting a later top-up: invoicing subtracts
`qty_invoiced`, and the split is what re-orders the shortfall.
Supplier-side lines (`exclude_from_invoice`) close to `invoiced` at Confirm
when every billable line on their order is already billed; stragglers get a
"Close supplier-side lines" button on Invoicing
(`POST /api/v1/invoicing/close-supplier-side`).
# Manager phone view (`/manager`) — the order pipeline on a phone
A hidden, installable PWA for the managers: one link, opened on a
phone, showing where every order is. It is deliberately NOT in the
sidebar — the route lives in the `(mobile)` route group, outside
`(app)`, so `<Sidebar />` never renders there.
Runbook: `docs/manager-orders.md`.
- Route group `src/app/(mobile)/manager/` (layout + auth-gate + view),
PWA assets `public/manifest-manager.json` + `public/sw-manager.js`,
registered in `src/components/service-worker-registration.tsx`.
- Read model `src/lib/server/manager-orders-service.ts`, exposed at
`GET /api/v1/manager-orders` (browser-session auth, same shape as
`/api/v1/boss-dashboard`).
- Three buckets — **New / Processing / Completed** — from
`PIPELINE_GROUP_ITEM_STATUSES` in `src/lib/item-status-ui.ts`. That
map is a COARSE view of the desk's eight workflow tabs; both read
the same status lists so they can never disagree.
- **Completed is time-windowed** (7 days on the phone, 30 on
`/orders`). "Settled" is defined ONCE in `item-status-ui.ts`
(`SETTLED_STATUSES` + `SETTLED_DATE_FIELDS`) and the filter is
shared (`settledWindowFilter` in `orders-service.ts`).
## The date field is per-status — read this before changing the window
There is no single "finished at" column, and the obvious one is a
trap. Measured on live PB (2026-10-26, 12,957 delivered lines):
- `delivered_at` is populated on **2** lines. The rest are legacy rows
whose status was set without a stamp, and their `updated` is the
MIGRATION timestamp. So `delivered` counts only when `delivered_at`
is set — an un-dated delivered row is ARCHIVED, not recent.
- `invoiced_date` is populated on **2,199** lines and is the only
properly stamped settlement date in the schema.
- `completed`, `checked_in` and `delivery_note` have no dedicated
column, so `updated` (bumped on every write) is the only signal.
`SETTLED_DATE_FIELDS` encodes that: `primary` = "use this stamp, and
skip the row if it is empty"; `fallback` = "no stamp exists for this
status, use `updated`". They are mutually exclusive — giving a status
BOTH would resurface archived rows every time an old row was touched.
Before the fix the Completed tab matched **0** orders, because the
filter only looked at `delivered` + `completed` dated on
`delivered_at`/`updated`, and the business stopped writing `delivered`
on 2026-08-14 (the live pipeline now ends at `invoiced` /
`checked_in`). It now matches 341 orders over 30 days, 112 over 7.
The `post_processing` tab was retired in the same change (a DN is not
a stage of its own) and `delivery_note` + `checked_in` moved into
`completed`. `invoiced` is listed in BOTH `collected` and `completed`
on purpose: `itemStatusToTabGroup` resolves it to `collected` (the
office's workflow tab keeps its rows) while the completed tab picks
invoiced rows up through its own windowed filter. Dropping it from
`collected` would make invoiced orders vanish from `/orders`.
The preview popup is REUSED, not rebuilt: the service emits
`UniversalHit`s and the page hands them to the existing
`SearchPreviewDialog` (`src/app/(app)/search/search-preview.tsx`), so
the manager sees the same quick-overview the desk sees on /search.
# The POS counter never sells at R0
The POS (`/api/v1/pos/catalog`) lists `stock_items` matching
`POS_CATALOG_FILTER` in `pos-service.ts`: active **and** `selling_price > 0`.
`checkoutSale` refuses any line with `unit_price <= 0`. Owner's rule
(2026-10-30): only stock (and over-collected surplus) goes on the counter.
Customer-order lines go to the `price_list` catalogue instead
(`addRowsToPriceList`), never straight into `stock_items`. Any path that creates
a stock item with a cost must price it with `applyDefaultMarkupToPatch`
(80%); without a cost it stays off the till until someone prices it.
# Delivery Pool (`/delivery-pool`) — invoiced lines waiting to go out
Design: `docs/superpowers/specs/2026-10-30-delivery-pool-design.md`. Files:
`src/lib/delivery-pool.ts` (pure grouping/filters), `src/lib/server/delivery-pool-service.ts`,
routes under `/api/v1/delivery-pool` (browser session), page `src/app/(app)/delivery-pool/`.
- Per company, **one card per customer invoice** (`customer_invoice_number`, normalised
`30352` = `INV30352`). Never key on `order_item_details.invoice.number` — Collection
Confirm writes the *supplier* invoice there.
- In the pool: `invoiced`, `invoiced_date >= DELIVERY_POOL_START`, `delivered_at` empty,
not `exclude_from_invoice`, and its DN (if any) not `completed`/`cancelled` (drops POS
counter sales).
- The delivery driver lives in `delivery_driver` / `delivery_assigned_at` — **never write
`drv_id`** here, that's the collection driver. Status stays `invoiced` until the signed copy.
- Signed copy: ADDED to every line (`addAttachmentForm`), lines → `completed` + `delivered_at`,
a DN is closed only when none of its lines is still open.
- Manual live step: `npm run pb:migrate:delivery-pool` before deploying (the service 503s
until the fields exist). The old `/deliveries` page stays until the office switches over.
# Company letterhead lock — one quote letterhead per company
Each company is quoted on one letterhead, stored on `companies.confirmed_letterhead`
(CNL / ISHERWOOD, blank until confirmed). Rules: `src/lib/company-letterhead.ts`.
The company is always the **customer's** company (`customer.company`), never
`quotes.company` (that copy goes stale).
- The process page (`process-letterhead-buttons.tsx`): blank → either button opens
"Set the default letterhead for {company}"; set → that button works, the other is
locked and only a triple-click renders on it (override for that quote only).
- Enforced in `generateQuotePdf`: another letterhead without `override` returns
`letterhead_locked` (409 from `POST /api/v1/quotes/{id}/pdf`). So MCP / Giellie /
n8n can't render the wrong one either. An override sticks once rendered (a quote
already rendered on a letterhead may re-render on it).
- With no letterhead passed, an unrendered quote takes the company's.
- Changed on the company page via `PUT /api/v1/companies/{id}/letterhead`.
- Manual live steps: `npm run pb:migrate:company-letterhead`, then dry-run and
`--apply` `scripts/2026-10-31-backfill-company-letterheads.ts` (fills companies
whose rendered quotes all used one letterhead). Until the migration runs the
lock is off and everything behaves as before.

View file

@ -1,35 +1,31 @@
FROM node:20-alpine AS base
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# The commit being built, supplied by Ship. Declared late so the layers above
# stay cached; it is empty for builds that do not pass it.
ARG NEXT_PUBLIC_APP_COMMIT
ENV NEXT_PUBLIC_APP_COMMIT=${NEXT_PUBLIC_APP_COMMIT}
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
FROM node:20-alpine AS base
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View file

@ -1,226 +0,0 @@
# WFDASH — Interaction Flow Review
**Date:** 2026-09-01
**Reviewer:** Mavis
**Target:** `https://wfdash.apphub.co.za/` (deployed) + workspace `src/`
**Mode:** Live browser walk on test account `david.els1@outlook.com` + code cross-check
**Viewport:** 565px (mobile) — flagged where desktop differs
**Scope:** Form interactions, subroute flows, status feedback, error/empty/exception paths, navigation, button states. **Deliberately excludes** the visual review that `.design-review/REPORT.md` already covered (16 top-level routes, desktop + mobile screenshots).
---
## TL;DR
- **2 new P1s found.** "Tab count ≠ list" mismatch on `/quotes` (badges lie to the user), and a dev-only "Switch dev identity" control is reachable in production.
- **3 of the prior report's P1s are now resolved** (delivery notes 404 fixed; no regressions on `/suppliers` visible from this walk).
- **Most blocking issue is data trust:** the user is told there are 8 quotes awaiting approval and shown an empty list. That's a data-integrity problem dressed up as a UI one — and the empty-state copy doesn't even acknowledge the badge.
- **Best surface in the app is `/orders/new`.** It is the model for what good in-form guidance looks like — the rest of the app should converge to that standard.
- **Worst surface is the sidebar nav collapse behaviour.** New users can't see what flows exist.
---
## Methodology
1. Read `AGENTS.md`, `README.md`, `APP_CAPABILITIES.md`, and the prior `.design-review/REPORT.md` for context.
2. Logged in on the deployed URL with the shared test account.
3. Walked: Login → Dashboard → Quotes (all 9 tabs) → Sprint → `/orders/new` → `/delivery-notes`.
4. Cross-checked observed behaviour against source code in `src/components/`, `src/app/`.
5. Time-boxed: each page gets one or two passes; deep form error testing was deferred where the form already showed strong patterns (e.g. `/orders/new`).
Not done in this pass (call out if you want them):
- Form submission with empty fields on every form (tested one — `/orders/new` — and it was good).
- All subroutes (`/quotes/[id]`, `/orders/[id]/process`, `/stock/[id]`, etc.) — only top-level routes were walked.
- Network-failure / offline behaviour — requires throttling.
- Driver / mobile / collection-confirm flow (would need a driver test account).
- Sprint claim race / 30-min TTL expiry (would need timer manipulation).
---
## Ranked findings
### P1 — Functional / broken (fix first)
#### P1-1 [NEW] Quote tab badges disagree with the lists they label
- **Where:** `/quotes` (all tabs except `New` and `Email Failed` show data; `Claimed` 4, `Pending Approval` 8, `Templates` 0, `Cancelled` 233, `Completed (7 days)` 102, `Problem` 522, `Delivery Note` 7 all show empty lists when clicked).
- **Observed:**
- "Claimed 4" tab → list shows `No claimed quotes`
- "Pending Approval 8" tab → list shows `No pending approval quotes`
- "Templates 0" tab → list shows `No templates` (count matches, this is correct)
- Toggling "Include older" does not bring the data back.
- "Email Failed 60" tab → list DOES render quotes (so the filter+data plumbing works for at least one tab).
- **Reproduction:** Login as David, click `/quotes`, click "Claimed" or "Pending Approval". Compare the badge count to the empty state.
- **Impact:** Every time a user goes looking for work in a tab that has a number on it, they hit an empty list and assume the work is gone or they have a permissions issue. With 522 in "Problem" they may stop trusting the app entirely. This is the most user-trust-damaging bug in the app.
- **Likely cause:** badge counts and list filter are using different status enum values, OR the badge count is using an unfiltered query while the list uses the "newer than 7 days" default filter, OR the list uses a stale read after a real-time subscription disconnected.
- **Fix:** Same query must drive both the badge and the list. Diff the filter and check whether the badge subscription dropped. Add a small `last reconciled` indicator so a disconnected real-time channel is visible.
#### P1-2 [NEW] "Switch dev identity" button visible on `/sprint` in production
- **Where:** `/sprint` — top-right, between the title and "Refresh sprint" button.
- **Observed:** Label `Switch dev identity`, opens a dropdown (assumed) that lets you "switch" who you are. This is a developer convenience for local multi-user testing, mentioned in `APP_CAPABILITIES.md` §12.
- **Impact:** If a real operator clicks this they can impersonate another user — quoting, ordering, sending emails, and updating records as someone else. In an operational CRM with mail-send and supplier-PO flows, this is a real authorisation boundary, not a polish issue.
- **Fix:** Hide this control unless `process.env.NODE_ENV === "development"`, or unless the current user has a `developer` flag. Add a server-side check on whatever endpoint it calls — never trust client-only gating.
- **Note:** The settings page (`/settings`) per `APP_CAPABILITIES.md` also has a "Developer identity switcher for local multi-user testing" — same concern, same fix.
#### P1-3 [REGRESSION RISK] No per-route `error.tsx` / `loading.tsx` / `not-found.tsx` in `(app)/`
- **Where:** `src/app/(app)/**` — searched for `error.tsx`, `loading.tsx`, `not-found.tsx`. None found.
- **Impact:** Any uncaught exception in a Server Component (e.g. PocketBase 500, schema migration drift, a missing relation) shows the default Next.js error overlay (dev) or a generic 500 page (prod). The layout-level loading spinner in `app/(app)/layout.tsx:20-26` only covers auth — it does not cover route-level data fetches. Users see a blank screen or a non-branded error.
- **Code reference:** `src/app/(app)/layout.tsx:20-26` (only an auth-loading spinner; no per-route states).
- **Fix:** Add `error.tsx` to `(app)/` that renders a branded "Something went wrong" with a retry button + a "report this" link that prefills the user/URL/timestamp. Add `loading.tsx` for each major route (or one shared one) showing skeleton cards. Add `not-found.tsx` with a "Back to dashboard" CTA.
- **Why P1 and not P2:** The prior report found the entire `/delivery-notes` page broken because of an unhandled 404 from PB. With a proper `error.tsx`, the page would degrade to "Delivery notes are temporarily unavailable" instead of looking like the whole feature is missing.
#### P1-4 [CODE] Login error feedback uses a Sonner toast, not an inline field error
- **Where:** `src/app/(auth)/login/page.tsx:85-87` — catch handler.
- **Observed:** Wrong password → "Failed to authenticate." toast at top-right. Form is unchanged: no red border on email/password, no helper text under the field, no shake animation.
- **Impact:** Toasts disappear. Users miss the feedback, retype, and the same flow happens again. Worse: on smaller viewports the toast can be cut off above the fold. Inline errors are the industry standard for credential forms.
- **Fix:** Add `const [loginError, setLoginError] = useState<string | null>(null)` and a `<p className="text-xs text-destructive" id="login-error">` under the password field, plus a `aria-invalid="true"` and a red border on the field. Keep the toast as a secondary signal.
- **Code:** `src/app/(auth)/login/page.tsx:34-38` (login function throws raw), `:85-87` (catch — uses `toast.error(message)` only).
#### P1-5 [CODE] Forgot-password is dead code + dead UX
- **Where:** `src/components/auth-provider.tsx:52-55` exposes `requestPasswordReset`, but `src/app/(auth)/login/page.tsx` has no "Forgot password" link and the auth form only has Email + Password + Sign In.
- **Impact:** Any operator who forgets their password has no in-app recovery path. The user mentioned they had a `test@wfdash.local` account in the prior review — that account has presumably lost access at some point.
- **Fix:** Add a "Forgot password?" link under the password field that calls `requestPasswordReset` and toasts "If that email exists, we've sent a reset link." Use the same neutral copy to avoid email-enumeration leakage.
---
### P2 — Friction (UX issues that cost clicks or trust)
#### P2-1 [NEW] Sidebar sections are collapsed by default — first-time users can't see what flows exist
- **Where:** `src/components/sidebar.tsx:149-200` (NavSection — `defaultOpen = false`).
- **Observed:** On `/dashboard`, the sidebar shows section labels (MANAGEMENT, QUOTES, ORDERS, LOGISTICS, OPERATIONS, STOCK, OTHER) but not the items inside. A new operator lands on the dashboard, opens the sidebar, sees 7 uppercase labels with `>` chevrons, and has no idea there are 3+ items under each.
- **Code:** `src/components/sidebar.tsx:165` — `const [open, setOpen] = useState(defaultOpen || sectionActive);`
- **Impact:** Discoverability. Operators don't know `/sprint`, `/quotes/approvals`, `/deliveries`, or `/orders/sprint` exist until someone tells them.
- **Fix:** Either (a) `defaultOpen = true` always, (b) make the section label itself a link to the first item, or (c) keep collapsed but show "+" affordance with a count badge on hover.
- **Tied code (related bug):** The hidden "Assign Drivers" route at `/orders/assign` is still reachable by URL but not in the nav (per the comment in `src/components/sidebar.tsx:99-105`). Worth a P1 audit pass to make sure no other critical nav is also hidden.
#### P2-2 [REGRESSION] Mail-failed count of 60 is high — is the retry path working?
- **Where:** `/quotes` "Email Failed" tab shows `60` quotes.
- **Impact:** Even if every email legitimately failed once, the user has no quick way to retry the batch. Each failed email has to be opened individually. With 60 items that's a 60-op manual job.
- **Cross-check:** `README.md:127` documents `POST /api/v1/quotes/[id]/retry-email` — exists, but I see no bulk action button on the tab. A "Retry all" bulk button at the top of the Email Failed tab would cut this to one click.
- **Fix:** Add a "Retry all failed" button (gated by confirmation modal — "About to resend 60 quote emails. Sure?"). Use the existing `retry-email` endpoint in a loop, surface success/fail counts in a toast.
#### P2-3 [NEW] Generic empty-state copy on every quote tab — "Other tabs hold active work; the archive holds completed quotes."
- **Where:** `/quotes` — every empty tab shows the same illustration + copy.
- **Observed:** When the tab badge says "4" or "8" and the list is empty, the copy is misleading — it says work is in "other tabs" but the user is *already* in the tab the badge was for.
- **Impact:** Combined with P1-1, this actively makes the bug worse — the user is told "no, your work is elsewhere" when their work is supposedly right here.
- **Fix:** Make the empty state aware of the badge: `No {status.toLowerCase()} quotes match your filters. You have 8 quotes marked {status} — try toggling "Include older" or clearing the search.` When there really are none, keep the current copy.
#### P2-4 [NEW] "Rep" field renders empty for some quote cards (P2-6 from prior report still present)
- **Where:** `/quotes` — Email Failed tab, first card shows `Rep:` followed by a blank space; second card shows `Rep: David Els`.
- **Impact:** Same as prior P2-6. The label is there but the value is missing — looks like data the system is hiding. Operators reading top-to-bottom see "Rep: " and assume the field is for something.
- **Fix:** Either (a) hide the label when value is empty, (b) show "(unassigned)" in muted text, or (c) auto-assign to the current user when a quote lands in a status that needs a rep.
#### P2-5 [NEW] Login form doesn't disable inputs while submitting
- **Where:** `src/app/(auth)/login/page.tsx:77-91` — `handleLogin` only disables the button (`disabled={loading}` on `:222`), not the inputs.
- **Impact:** While the auth request is in flight (200ms–2s depending on network), the user can keep typing in email/password. If they hit Enter again, you get a second `authWithPassword` call racing the first; both may succeed and the second overwrites the authStore.
- **Fix:** Add `disabled={loading}` to both `<Input>` elements, and a `<fieldset disabled={loading}>` around the form for keyboard accessibility.
#### P2-6 [NEW] Auto-redirect `?next=` accepts any path with no allowlist
- **Where:** `src/app/(auth)/login/page.tsx:83-84` calls `isSafeNextPath(next) ? next : "/dashboard"`. There is a helper `src/lib/safe-next-path.ts` (good — that exists).
- **Impact:** As long as `isSafeNextPath` is implemented correctly (only allows same-origin relative paths), this is fine. Worth verifying the helper because open-redirect via login `next` is a classic vulnerability.
- **Fix:** Verify `src/lib/safe-next-path.ts` actually rejects `//evil.com` and `https://evil.com` — both are common bypasses. Add a unit test for these.
#### P2-7 [NEW] Notification toasts disappear without an "unread" indicator
- **Where:** Login immediately fired two toasts: "103 out of stock view all" and "1 below min view all" (visible in the first dashboard load). They auto-dismiss and the user has no way to revisit them.
- **Impact:** Critical operational signals (stockouts!) get the same treatment as a snackbar. Operator walks away, comes back, has no idea what fired.
- **Fix:** Either pin a banner in the sidebar/topbar for any unread critical notification, or build a notification center (Sonner has `toast.dismiss` but the "unread" concept needs more). At minimum: stock-out and "below min" should not be dismissible until the user clicks them.
#### P2-8 [CODE] No "Cancel" / unsaved-changes guard on any form
- **Where:** Searched `src/components/*-form.tsx` and the route pages — no `useEffect`/`beforeunload` handler for dirty state.
- **Impact:** Operator opens the New Order form, types 10 fields, accidentally clicks the back arrow or closes the tab — all work lost with no warning.
- **Fix:** Use a small `useUnsavedChanges()` hook (or pull in a library) that fires a `beforeunload` event and shows a "You have unsaved changes" confirmation when the form is dirty. Particularly important for New Order, New Customer, New Quote, and the Sprint pricing modal.
#### P2-9 [CODE] `register()` is exposed by auth-provider but no UI
- **Where:** `src/components/auth-provider.tsx:40-44`. No `/register` route, no "Create account" link anywhere.
- **Impact:** Same as P1-5 — dead capability, dead surface. Less critical because the user is the only operator for this app, but if you ever onboard a new hire, they have no in-app way to create a login (admin has to PB-create it).
- **Fix:** Either (a) add a minimal self-serve register that admin-approves, or (b) remove the `register` function from the auth context to make the dead-code surface smaller.
#### P2-10 [NEW] Sprint page shows quote numbers in cards but the parent quote may have status changes since the sprint item was created
- **Where:** `/sprint` — each card shows `Q2026082714` etc.
- **Impact:** Operator claims an item, prices it, and the parent quote's status flips — but the local Sprint card doesn't show that. They have to click through to the quote to find out.
- **Fix:** Surface quote.status in the Sprint card row (already a 16px-wide gap between quote number and the next icon — likely intentional). One small chip "Q2026082714 · claimed" is enough.
#### P2-11 [NEW] `/orders/new` has no inline company-existence check before submit
- **Where:** `src/app/(app)/orders/new/page.tsx` (not opened; cross-checked at code level). The form has a "Customer or Company" combobox.
- **Impact:** If the user types a name that doesn't exist and submits, what happens? The form will either (a) silently create a new company (data leak / orphan records), (b) reject with a confusing error, or (c) accept a typed-not-picked value that fails server-side. I didn't get to test this in the walk.
- **Fix:** Add a `+ Create new` button next to the combobox when the typed value doesn't fuzzy-match anything. Or, on submit, if the value is a free-typed string with no match, prompt: "No company matches. Create it?"
---
### P3 — Polish (low priority)
#### P3-1 [NEW] Sidebar shows "Open navigation" as a hamburger at all times on mobile, even when sidebar IS open
- **Where:** Top-left of every page on mobile width (565px).
- **Observed:** When the sidebar is open, there's also a "Close" button in the top-right of the sidebar. The hamburger stays at the top-left of the main content. Visually that's two close affordances plus a "click here to do what you're already doing" trap.
- **Fix:** Hide the hamburger when the sidebar is open. Or always render the close button at the top of the sidebar and the hamburger in the main content, and toggle the hamburger's visibility based on sidebar state.
#### P3-2 [CODE] KPI tile trend indicators don't say what they trend against
- **Where:** `/dashboard` — "CUSTOMERS -50% wk" with a red down arrow.
- **Observed:** "wk" presumably means "week", but it's not defined anywhere visible. The 0 orders / R 0 revenue is also an unexplained signal — is this a data freshness bug, a real "we have no business this month" situation, or a filter?
- **Fix:** Spell it out: "vs. last week" or "wk-over-wk". For the 0/0 numbers, add a tooltip or helper: "No orders placed in this period — check your filter or sales activity."
#### P3-3 [REGRESSION] P2-1 from prior report — tab overflow on narrow viewports — partially addressed but worth re-checking on a real 375px device
- **Where:** `/quotes` tabs.
- **Observed:** At 565px the 9 tabs wrap to 4 rows. At 375px (real phone) this is going to be 5+ rows and harder to scan.
- **Fix:** Already known from prior report. Reconsider: collapse >5 tabs into a "More ▾" dropdown on narrow viewports.
#### P3-4 [CODE] 11 font-weight preloads per page (P3-1 from prior report) — likely still present
- **Where:** Not re-verified. Mentioned in prior report. Low priority but real bandwidth.
- **Fix:** Audit `next/font` config and drop unused weights.
#### P3-5 [NEW] "Search by #, customer, company, email, supplier, item, or price..." placeholder is unhelpfully long
- **Where:** `/quotes` search input.
- **Observed:** The placeholder is a comma-joined list of every search field. It's a single sentence. On mobile, the user sees only the first half and doesn't know "supplier" and "item" and "price" are also searchable.
- **Fix:** Shorten to "Search…" with a tooltip or expandable "Search in: customer, supplier, item, price" hint.
#### P3-6 [NEW] Sprint page exposes "Empty" checkbox with no helper text
- **Where:** `/sprint` — top-right, between "Switch dev identity" and "Refresh sprint".
- **Observed:** A lone checkbox labeled "Empty". I don't know if checking it shows empty categories, hides items with no price, or filters for "no description". The label is too terse.
- **Fix:** Either label it more clearly ("Show only items with no price") or add a hover tooltip.
---
## Status of the prior design review's findings
| Prior | Status (2026-09-01) |
|---|---|
| **P1-1** Delivery Notes 404 | **FIXED** — page now loads with Create button, tabs, search. |
| **P1-2** Empty supplier row | Not re-verified in this pass. Worth a quick check. |
| **P2-1** Mobile tab overflow | Partially better at 565px; still needs a "More" dropdown on real phones. |
| **P2-2** Duplicate "All" filter on collection-map | Not re-verified. |
| **P2-3** "Select rows to confirm" dead text | Not re-verified. |
| **P2-4** "Collection Confirm" title wrap | Not re-verified. |
| **P2-5** "Invoicing" status duplicated as label + badge | Not re-verified. |
| **P2-6** Empty Rep column | **STILL PRESENT** — moved to P2-4 here. |
| **P2-7** Companies table mostly empty | Not re-verified. |
| **P2-8** Pricelist catalogue shows one supplier | Not re-verified. |
| **P3-1** Font preloads | Not re-verified. Likely still present. |
| **P3-2** Repairs sidebar grouping | **RESOLVED** in code (per the long comment in `src/components/sidebar.tsx:99-130`, the nav was restructured; Repairs now under "OTHER" intentionally, Delivery Notes under "OPERATIONS"). |
| **P3-3** Supplier Map appears twice | Not re-verified. |
| **P3-4** Lone "0" on Companies mobile | Not re-verified. |
| **P3-5** "Status: Ready" + "Ready" badge duplication | Not re-verified. |
---
## Recommended fix order
If you tackle them in this order, each one unblocks the next:
1. **P1-1** — Tab badge/list mismatch. One user looking at the dashboard, seeing "Pending Approval 8" and clicking through to an empty list, and giving up on the workflow. This is the single biggest trust issue.
2. **P1-2** — Hide "Switch dev identity" behind a dev flag. One careless click and the wrong person is sending emails as someone else.
3. **P1-3** — Add `error.tsx` / `loading.tsx` / `not-found.tsx` to `(app)/`. Unblocks graceful degradation for every other bug.
4. **P1-4 + P1-5** — Login error feedback + forgot-password link. Two days of work, one big lift in "this app feels professional" signal.
5. **P2-1** — Sidebar sections default open. One-line change in `NavSection`, big discovery win.
6. **P2-2** — Bulk "Retry all failed emails" button. Saves a 60-op manual job.
7. **P2-3 + P2-4** — Make the empty state aware of the badge; fix the empty Rep label.
8. Everything else.
---
## Out-of-scope reminders
- I walked 6 of the ~40 primary routes. The rest (`/collection-pool`, `/invoicing`, `/pricelist`, `/stock`, `/repairs`, etc.) have not been visited in this pass.
- No form submissions tested (other than login). The `/orders/new` *form* looked great; I didn't press the "Create" button with empty fields to see the validation copy.
- No subroutes tested (`/quotes/[id]`, `/orders/[id]`, `/sprint/items/[id]`, etc.).
- No console errors checked at each route. The query kind `console` and `network` are available — call out if you want a console-error sweep.
- No performance / Lighthouse / Web Vitals.
If you want me to keep going on any of these, say the word — happy to deep-dive on the invoicing flow, the Sprint claim race, or the form validation states.

View file

@ -1,42 +0,0 @@
line,order_item_id,qty,current_description,matched_code,new_description,match_basis
1,bs34b9f3eaxplt9,10,"ROD, THREADED, M24 X 1M, GR8.8",1080002070,"1080002070 - ROD, THREADED, M24 X 1m, GR8.8, DIN 978",literal match (catalogue adds a standard ref)
2,3vmxdu4arjqeu6t,20,"HEX BOLT, M16 X 30, GR8.8",2070009180,"2070009180 - HEX BOLT, M16 X 30, GR8.8, ISO 4017",literal match (catalogue adds a standard ref)
3,6xwg6bc05oyjpul,50,"HEX BOLT, M16 X 80, GR8.8",2070009350,"2070009350 - HEX BOLT, M16 X 80, GR8.8, ISO 4017",literal match (catalogue adds a standard ref)
4,9sstg6eph5e893i,20,"HEX BOLT, M24 X 130, GR8.8",2070010120,"2070010120 - HEX BOLT, M24 X 130, DIN 24014, GR8.8",literal match (catalogue adds a standard ref)
5,jj0nffcm09px5h0,7,"CAP SCREW, M16 X 55, GR8.8",2070003260,"2070003260 - CAP SCREW, M16 X 55, GR8.8",literal match to catalogue row
6,3owpkrpi2vk6kls,50,"CAP SCREW, M12 X 35, GR12.9",2070003710,"2070003710 - CAP SCREW, M12 X 35, GR12.9",literal match to catalogue row
7,4ash1yklv0oclrv,20,"HELICOIL, M24, P3.0, DD",2070008720,"2070008720 - HELICOIL, M24, P3.0, DD",literal match to catalogue row
8,qaomx8n28xce8bj,20,"HELICOIL, M30",2070008760,"2070008760 - HELICOIL, M30",literal match to catalogue row
9,tn7odfhk53n5t0z,30,"HEX BOLT, M8 X 50, GR8.8",2070011220,"2070011220 - HEX BOLT, M8 X 50, GR8.8",literal match to catalogue row
10,rd8w5akb962hj9o,4,"ROD, THREADED, M16 X 1M, GR8.8",1080002140,"1080002140 - ROD, THREADED, M16 X 1m, GR8.8, DIN 978",literal match (catalogue adds a standard ref)
11,qe35bi5jm5mjk2b,9,"CAP SCREW, M10 X 40, GR12.9",2070006480,"2070006480 - CAP SCREW, M10 X 40, GR12.9",literal match to catalogue row
12,0usirosv0bb7vl4,10,"CAP SCREW, M5 X 30, GR12.9",2070006490,"2070006490 - CAP SCREW, M5 X 30, GR12.9",literal match to catalogue row
13,ckj7qyaropvzr5u,100,"CAP SCREW, M10 X 60, GR12.9",2070006510,"2070006510 - CAP SCREW, M10 X 60, GR12.9",literal match to catalogue row
14,6sx6j4scqti8ygg,28,"CAP SCREW, M10 X 70, GR12.9",2070006520,"2070006520 - CAP SCREW, M10 X 70, GR12.9",literal match to catalogue row
15,nvorjocafboqes8,10,"CAP SCREW, M12 X 150, GR12.9",2070006550,"2070006550 - CAP SCREW, M12 X 150, GR12.9",literal match to catalogue row
16,r46ftwi7fabvx2o,10,"CAP SCREW, M12 X 90, GR12.9",2070006650,"2070006650 - CAP SCREW, M12 X 90, GR12.9",literal match to catalogue row
17,1hrnnl87mr4y75e,3,"CAP SCREW, M14 X 50, GR12.9",2070006670,"2070006670 - CAP SCREW, M14 X 50, GR12.9",literal match to catalogue row
18,69wj63hyw50zjv8,60,"CAP SCREW, M16 X 130, GR12.9",2070006700,"2070006700 - CAP SCREW, M16 X 130, GR12.9",literal match to catalogue row
19,4cs90mj57vq7gbh,10,"CAP SCREW, M20 X 30, GR12.9",2070006900,"2070006900 - CAP SCREW, M20 X 30, GR12.9",literal match to catalogue row
20,2260xsur3cs9qk9,8,"CAP SCREW, M16 X 160, GR12.9",2070006720,"2070006720 - CAP SCREW, M16 X 160, GR12.9",literal match to catalogue row
21,whwgsjw7my8ymdt,10,"CAP SCREW, M16 X 65, GR12.9",2070006780,"2070006780 - CAP SCREW, M16 X 65, GR12.9",literal match to catalogue row
22,sbimbxgpn53t5ey,14,"CAP SCREW, M16 X 70, GR12.9",2070006790,"2070006790 - CAP SCREW, M16 X 70, GR12.9",literal match to catalogue row
23,sl8fben494pgtqi,100,"CAP SCREW, M16 X 90, GR12.9",2070006810,"2070006810 - CAP SCREW, M16 X 90, GR12.9",literal match to catalogue row
24,6e1nrvlo4j163ui,28,"CAP SCREW, M20 X 150, GR12.9",2070006850,"2070006850 - CAP SCREW, M20 X 150, GR12.9",literal match to catalogue row
25,ro8fvst84476ri6,2,"CAP SCREW, M10 X 100, GR12.9",2070007560,"2070007560 - CAP SCREW, M10 X 100, GR12.9",literal match to catalogue row
26,b68wv4ssuz06w50,12,"CAP SCREW, M14 X 30, GR12.9",2070006660,"2070006660 - CAP SCREW, M14 X 30, GR12.9",literal match to catalogue row
27,gxvr5cob2mfn5dy,10,"CAP SCREW, M30 X 240, GR12.9",2070007190,"2070007190 - CAP SCREW, M30 X 240, GR12.9",literal match to catalogue row
28,3s1e8mjjyl5ox9e,40,"NUT, PLAIN, M12, GR8",2070013220,"2070013220 - NUT, PLAIN, M12, DIN 938, CLASS 8",thread size unique in NUT-PLAIN family
29,ylfi8z5h7dqzgcz,5,"CAP SCREW, M6 X 20, GR12.9",2070007340,"2070007340 - CAP SCREW, M6 X 20, GR12.9",literal match to catalogue row
30,dss1na7anfhpwti,40,"CAP SCREW, M6 X 90, GR12.9",2070007420,"2070007420 - CAP SCREW, M6 X 90, GR12.9",literal match to catalogue row
31,jqaegggwhhm1s1p,5,"CAP SCREW, M10 X 35, GR12.9",2070006470,"2070006470 - CAP SCREW, M10 X 35, GR12.9",literal match to catalogue row
32,8orixhyb605l4w2,14,"CAP SCREW, M8 X 25, GR12.9",2070007440,"2070007440 - CAP SCREW, M8 X 25, GR12.9",literal match to catalogue row
33,1r9buf3z7j8cjcc,7,"CAP SCREW, M8 X 50, GR12.9",2070007470,"2070007470 - CAP SCREW, M8 X 50, GR12.9",literal match to catalogue row
34,8xei2p79ctmtupp,131,"NUT, PLAIN, M10, GR8",2070013210,"2070013210 - NUT, PLAIN, M10, DIN 938, CLASS 8",thread size unique in NUT-PLAIN family
35,hj3m6lqcb19wlax,444,"NUT, PLAIN, M20, GR8",2070013250,"2070013250 - NUT, PLAIN, M20, DIN 938, CLASS 8",thread size unique in NUT-PLAIN family
36,ukxfygmv61ymzrw,34,"NUT, PLAIN, M8, GR8",2070013320,"2070013320 - NUT, PLAIN, M8, DIN 938, CLASS 8",thread size unique in NUT-PLAIN family
37,sl8q43b7xhheomg,3,"CAP SCREW, M8 X 20, GR12.9",2070007430,"2070007430 - CAP SCREW, M8 X 20, GR12.9",literal match to catalogue row
38,6g8049nw35lcrmf,5,"WASHER, SPRING SQR MED, M20",2070016650,"2070016650 - WASHER, SPRING SQR MED, M20",literal match to catalogue row
39,69ftatrg1lp5txw,75,"HEX BOLT, ELECTRO PLATED, M10 X 35, GR4.8",2070011170,"2070011170 - HEX BOLT, ELECTRO PLATED, M10 X 35, GR 4.8",literal match to catalogue row
40,fq7uo9b9simk2b6,56,"WASHR, SPRING, ELECTRO PLATED, M10",2070016370,"2070016370 - WASHR, SPRING, ELECTRO PLATED, M10",literal match to catalogue row
41,uflef4nrdpqloyp,100,"WASHER, FLAT, ELECTRO PLATED, M10",2070016380,"2070016380 - WASHER, FLAT, ELECTRO PLATED, M10",literal match to catalogue row
1 line order_item_id qty current_description matched_code new_description match_basis
2 1 bs34b9f3eaxplt9 10 ROD, THREADED, M24 X 1M, GR8.8 1080002070 1080002070 - ROD, THREADED, M24 X 1m, GR8.8, DIN 978 literal match (catalogue adds a standard ref)
3 2 3vmxdu4arjqeu6t 20 HEX BOLT, M16 X 30, GR8.8 2070009180 2070009180 - HEX BOLT, M16 X 30, GR8.8, ISO 4017 literal match (catalogue adds a standard ref)
4 3 6xwg6bc05oyjpul 50 HEX BOLT, M16 X 80, GR8.8 2070009350 2070009350 - HEX BOLT, M16 X 80, GR8.8, ISO 4017 literal match (catalogue adds a standard ref)
5 4 9sstg6eph5e893i 20 HEX BOLT, M24 X 130, GR8.8 2070010120 2070010120 - HEX BOLT, M24 X 130, DIN 24014, GR8.8 literal match (catalogue adds a standard ref)
6 5 jj0nffcm09px5h0 7 CAP SCREW, M16 X 55, GR8.8 2070003260 2070003260 - CAP SCREW, M16 X 55, GR8.8 literal match to catalogue row
7 6 3owpkrpi2vk6kls 50 CAP SCREW, M12 X 35, GR12.9 2070003710 2070003710 - CAP SCREW, M12 X 35, GR12.9 literal match to catalogue row
8 7 4ash1yklv0oclrv 20 HELICOIL, M24, P3.0, DD 2070008720 2070008720 - HELICOIL, M24, P3.0, DD literal match to catalogue row
9 8 qaomx8n28xce8bj 20 HELICOIL, M30 2070008760 2070008760 - HELICOIL, M30 literal match to catalogue row
10 9 tn7odfhk53n5t0z 30 HEX BOLT, M8 X 50, GR8.8 2070011220 2070011220 - HEX BOLT, M8 X 50, GR8.8 literal match to catalogue row
11 10 rd8w5akb962hj9o 4 ROD, THREADED, M16 X 1M, GR8.8 1080002140 1080002140 - ROD, THREADED, M16 X 1m, GR8.8, DIN 978 literal match (catalogue adds a standard ref)
12 11 qe35bi5jm5mjk2b 9 CAP SCREW, M10 X 40, GR12.9 2070006480 2070006480 - CAP SCREW, M10 X 40, GR12.9 literal match to catalogue row
13 12 0usirosv0bb7vl4 10 CAP SCREW, M5 X 30, GR12.9 2070006490 2070006490 - CAP SCREW, M5 X 30, GR12.9 literal match to catalogue row
14 13 ckj7qyaropvzr5u 100 CAP SCREW, M10 X 60, GR12.9 2070006510 2070006510 - CAP SCREW, M10 X 60, GR12.9 literal match to catalogue row
15 14 6sx6j4scqti8ygg 28 CAP SCREW, M10 X 70, GR12.9 2070006520 2070006520 - CAP SCREW, M10 X 70, GR12.9 literal match to catalogue row
16 15 nvorjocafboqes8 10 CAP SCREW, M12 X 150, GR12.9 2070006550 2070006550 - CAP SCREW, M12 X 150, GR12.9 literal match to catalogue row
17 16 r46ftwi7fabvx2o 10 CAP SCREW, M12 X 90, GR12.9 2070006650 2070006650 - CAP SCREW, M12 X 90, GR12.9 literal match to catalogue row
18 17 1hrnnl87mr4y75e 3 CAP SCREW, M14 X 50, GR12.9 2070006670 2070006670 - CAP SCREW, M14 X 50, GR12.9 literal match to catalogue row
19 18 69wj63hyw50zjv8 60 CAP SCREW, M16 X 130, GR12.9 2070006700 2070006700 - CAP SCREW, M16 X 130, GR12.9 literal match to catalogue row
20 19 4cs90mj57vq7gbh 10 CAP SCREW, M20 X 30, GR12.9 2070006900 2070006900 - CAP SCREW, M20 X 30, GR12.9 literal match to catalogue row
21 20 2260xsur3cs9qk9 8 CAP SCREW, M16 X 160, GR12.9 2070006720 2070006720 - CAP SCREW, M16 X 160, GR12.9 literal match to catalogue row
22 21 whwgsjw7my8ymdt 10 CAP SCREW, M16 X 65, GR12.9 2070006780 2070006780 - CAP SCREW, M16 X 65, GR12.9 literal match to catalogue row
23 22 sbimbxgpn53t5ey 14 CAP SCREW, M16 X 70, GR12.9 2070006790 2070006790 - CAP SCREW, M16 X 70, GR12.9 literal match to catalogue row
24 23 sl8fben494pgtqi 100 CAP SCREW, M16 X 90, GR12.9 2070006810 2070006810 - CAP SCREW, M16 X 90, GR12.9 literal match to catalogue row
25 24 6e1nrvlo4j163ui 28 CAP SCREW, M20 X 150, GR12.9 2070006850 2070006850 - CAP SCREW, M20 X 150, GR12.9 literal match to catalogue row
26 25 ro8fvst84476ri6 2 CAP SCREW, M10 X 100, GR12.9 2070007560 2070007560 - CAP SCREW, M10 X 100, GR12.9 literal match to catalogue row
27 26 b68wv4ssuz06w50 12 CAP SCREW, M14 X 30, GR12.9 2070006660 2070006660 - CAP SCREW, M14 X 30, GR12.9 literal match to catalogue row
28 27 gxvr5cob2mfn5dy 10 CAP SCREW, M30 X 240, GR12.9 2070007190 2070007190 - CAP SCREW, M30 X 240, GR12.9 literal match to catalogue row
29 28 3s1e8mjjyl5ox9e 40 NUT, PLAIN, M12, GR8 2070013220 2070013220 - NUT, PLAIN, M12, DIN 938, CLASS 8 thread size unique in NUT-PLAIN family
30 29 ylfi8z5h7dqzgcz 5 CAP SCREW, M6 X 20, GR12.9 2070007340 2070007340 - CAP SCREW, M6 X 20, GR12.9 literal match to catalogue row
31 30 dss1na7anfhpwti 40 CAP SCREW, M6 X 90, GR12.9 2070007420 2070007420 - CAP SCREW, M6 X 90, GR12.9 literal match to catalogue row
32 31 jqaegggwhhm1s1p 5 CAP SCREW, M10 X 35, GR12.9 2070006470 2070006470 - CAP SCREW, M10 X 35, GR12.9 literal match to catalogue row
33 32 8orixhyb605l4w2 14 CAP SCREW, M8 X 25, GR12.9 2070007440 2070007440 - CAP SCREW, M8 X 25, GR12.9 literal match to catalogue row
34 33 1r9buf3z7j8cjcc 7 CAP SCREW, M8 X 50, GR12.9 2070007470 2070007470 - CAP SCREW, M8 X 50, GR12.9 literal match to catalogue row
35 34 8xei2p79ctmtupp 131 NUT, PLAIN, M10, GR8 2070013210 2070013210 - NUT, PLAIN, M10, DIN 938, CLASS 8 thread size unique in NUT-PLAIN family
36 35 hj3m6lqcb19wlax 444 NUT, PLAIN, M20, GR8 2070013250 2070013250 - NUT, PLAIN, M20, DIN 938, CLASS 8 thread size unique in NUT-PLAIN family
37 36 ukxfygmv61ymzrw 34 NUT, PLAIN, M8, GR8 2070013320 2070013320 - NUT, PLAIN, M8, DIN 938, CLASS 8 thread size unique in NUT-PLAIN family
38 37 sl8q43b7xhheomg 3 CAP SCREW, M8 X 20, GR12.9 2070007430 2070007430 - CAP SCREW, M8 X 20, GR12.9 literal match to catalogue row
39 38 6g8049nw35lcrmf 5 WASHER, SPRING SQR MED, M20 2070016650 2070016650 - WASHER, SPRING SQR MED, M20 literal match to catalogue row
40 39 69ftatrg1lp5txw 75 HEX BOLT, ELECTRO PLATED, M10 X 35, GR4.8 2070011170 2070011170 - HEX BOLT, ELECTRO PLATED, M10 X 35, GR 4.8 literal match to catalogue row
41 40 fq7uo9b9simk2b6 56 WASHR, SPRING, ELECTRO PLATED, M10 2070016370 2070016370 - WASHR, SPRING, ELECTRO PLATED, M10 literal match to catalogue row
42 41 uflef4nrdpqloyp 100 WASHER, FLAT, ELECTRO PLATED, M10 2070016380 2070016380 - WASHER, FLAT, ELECTRO PLATED, M10 literal match to catalogue row

View file

@ -135,7 +135,6 @@ specific** tool that fits the task.
| `stock_purchase_items.assign_driver` | Bind a stock_purchase_item to a driver. Transitions line status `null → assigned_to_driver` or `collection_pool → assigned_to_driver`. Wrong_status (409) when the line is already past the assignment step. |
| `stock_purchase_items.mark_collected` | Mark a line as physically delivered. Status → `collected`, bumps `quantity_received`. Does NOT book into stock — that's `stock_purchases.receive`. Header auto-flips to `received` when every line on the PO has `quantity_received >= quantity`. |
| `stock_items.update` | Patch catalogue fields on a master stock_item: `sku`, `description`, `notes`, `unit`, `selling_price`, `markup`. For inventory qty use `stock_items.adjust`. Returns `{ok: true, item, previous}` on success or `{ok: false, reason, message, field?}` on rejection. |
| `stock_items.set_pricing` | Set `cost_price` / `selling_price` / `markup` on existing stock_items in one batch (max 100 per call). The POS till reads `selling_price` (not `markup`), so set both consistently: selling = cost × (1 + markup/100). Cost changes are audited in `stock_ledger` with the `actor`; quantities are NOT changed (use `stock_items.adjust`). Requires `actor.id`. Returns per-row `{ok, item, previous}` / `{ok: false, reason, message}`. |
### 4.5 CRM maintenance

View file

@ -1,7 +1,7 @@
# WFDASH MCP — Hermes Skill
You are wired to the **WFDASH business MCP** at `/api/mcp`
(JSON-RPC over HTTPS, bearer-token auth). 141 tools. PocketBase
(JSON-RPC over HTTPS, bearer-token auth). 125 tools. PocketBase
backend. Owner: Dave. Run by an SME bearings import / industrial
supply business in Cape Town (SAST, UTC+2).
@ -10,35 +10,13 @@ supply business in Cape Town (SAST, UTC+2).
>
> - `docs/api-mcp-layer.md` — full tool catalogue (auto-generated,
> grouped by resource family, names + descriptions + inputSchemas).
> - `docs/agents/workflows.md` — nine end-to-end recipes (supplier
> - `docs/agents/workflows.md` — seven end-to-end recipes (supplier
> invoice → receive, order split, partial collection, bulk pricing,
> partial approve + split, sprint claim, walk-in quote, walk-in
> order + supplements, daily summary). **Always read this before
> chaining more than one tool call.**
> partial approve + split, sprint claim, walk-in quote). **Always
> read this before chaining more than one tool call.**
> - `src/app/api/mcp/FEATURE-REQUESTS.md` — the backlog of MCP gaps.
> Read it so you don't try to call a tool that doesn't exist.
## Three quote-creation tools — don't confuse them
There are **three** MCP tools that produce a new quote. Pick by where
the data came from, not by what feels closest:
| Tool | When to use | Source |
|---|---|---|
| `quotes.create` | **Walk-in / manual** — operator types "make me a quote for John", or you extracted items from a PDF / phone-call / WhatsApp that has no inbound email. Customer + company + items passed directly. | `POST /api/v1/quotes/create` |
| `quotes.ingest_from_email` | **Inbound RFQ email, preferred path.** Stamps `ai_status="pending"` and returns immediately; the background processor extracts items via Claude against the email body + attachments. Use this whenever the customer actually emailed in. | `POST /api/v1/quotes/ingest-from-email` |
| `quotes.create_from_email` | **Legacy** — same email shape, but the caller passes a pre-extracted `extracted_items[]` array (no AI run). Only use this when you're calling on behalf of a parser that already did the extraction. | `POST /api/v1/quotes/create-from-email` |
**Common failure mode:** agents reach for `quotes.create_from_email`
when they should be using `quotes.create`. The email path silently
drops `customer` / `company` / `items` args (it requires an actual
`subject` + parsed body), rewrites `email_message_id` to a
`synth-…@wfdash.local` sentinel, and lands on the
`Unknown Sender` placeholder customer. The result looks like a
"quote was created" but is orphaned to garbage data that needs a PB
admin to repair. If you don't have an inbound email, you want
`quotes.create` — never `quotes.create_from_email`.
## Identity
- **Language**: English. Default to British spelling. Mirror user's

View file

@ -220,7 +220,6 @@ JSON-RPC over HTTP. Same `Authorization` bearer as `/api/v1/*`.
| Tool | Purpose |
| --- | --- |
| `stock_items.update` | Patch catalogue fields on a master `stock_item` (`sku`, `description`, `notes`, `unit`, `selling_price`, `markup`). For inventory qty use `stock_items.adjust`. |
| `stock_items.set_pricing` | Set `cost_price` / `selling_price` / `markup` on existing `stock_items` in one batch (max 100). The POS till reads `selling_price`, not `markup` — set both. Cost changes are audited in `stock_ledger` with the `actor`; quantities are not changed (use `stock_items.adjust`). |
### 3.3 Tool input shapes (condensed)
@ -324,28 +323,6 @@ Anything else returns `reason: "forbidden_field"`. For inventory qty use
`stock_items.adjust`. Returns `{ok: true, item, previous}` on success or
`{ok: false, reason, message, field?}` on rejection.
`stock_items.set_pricing` (required: `updates`; `actor.id` is required at runtime and must be a 15-char user id — it is written to `stock_ledger.created_by`, a relation to `users`; `actor.name` defaults to the id):
```json
{
"updates": [
{ "id": "15-char-stock-item-id", "cost_price": 100, "selling_price": 180, "markup": 80 },
{ "id": "15-char-stock-item-id", "markup": 50 }
],
"actor": { "id": "user-id", "name": "Dave" }
}
```
1–100 updates per call; keys limited to `id`, `cost_price`, `selling_price`,
`markup` (anything else is an error naming the key). The POS till reads
`selling_price`, not `markup`, so set both consistently
(selling = cost × (1 + markup/100)). Cost changes are audited in
`stock_ledger`. Returns `{results: [...], summary: {total, succeeded, failed}}`;
per-row failures (`not_found`, `validation_error`, `pb_error`) are returned, not
thrown. `stock_items.create` also accepts optional `cost_price`,
`selling_price` and `markup`; a cost with neither selling price nor markup gets
the app default markup (80%).
`bulk_update_quote_items` (required: `items`):
```json
@ -490,7 +467,7 @@ keys aren't set, so a clean checkout reports `passed, N skipped`.
<!-- BEGIN mcp-doc-gen: tools catalogue — do not edit by hand, regenerate with scripts/mcp-doc-gen.py -->
This catalogue is auto-generated from the `tools` array in `src/app/api/mcp/route.ts` by `scripts/mcp-doc-gen.py`. There are **144 tools** as of this run. Re-run the script after adding or renaming a tool.
This catalogue is auto-generated from the `tools` array in `src/app/api/mcp/route.ts` by `scripts/mcp-doc-gen.py`. There are **137 tools** as of this run. Re-run the script after adding or renaming a tool.
Grouped by resource family. The full description is in the table column; the description text the agent sees is preserved verbatim.
@ -512,15 +489,11 @@ Grouped by resource family. The full description is in the table column; the des
| `orders.mark_invoiced` | Mark an order as invoiced. Mirrors the REST POST /api/v1/orders/{id}/mark_invoiced endpoint. Flips `order.invoiced = true` ONLY when every line item has `item_status === "invoiced"`. Refuses with 409 if any item is not yet invoiced; the ... |
| `orders.mark_delivered` | Mark every eligible line item on the order as `delivered`, stamping `delivered_at` and `delivered_date`. Mirrors the REST POST /api/v1/orders/{id}/mark_delivered endpoint. Eligible statuses: `ready_delivery \| collected \| with_driver \| ch... |
| `orders.update` | Patch whitelisted header fields on an order. Mirrors the REST PATCH /api/v1/orders/{id} endpoint. Whitelist (from `ORDER_PATCHABLE_FIELDS`): `notes`, `contact_name`, `description`, `rep`, `email_cc`, `email_bcc`, `quote_reference_text`, ... |
| `orders.set_quote` | Link an EXISTING order to an EXISTING quote. Mirrors the REST POST /api/v1/orders/{id}/quote endpoint. Why this exists: only `createOrderFromQuote` writes the `orders.quote` relation today, so walk-in orders leave it null. When Hermes di... |
| `orders.quote_match_sweep` | Run quote auto-detection over orders that have never been attempted. Mirrors the REST POST /api/v1/orders/quote-match/sweep endpoint. Why this exists: orders created from now on are matched automatically at intake. This is the catch-up p... |
| `orders.add_note` | REPLACE the `notes` field on an order with `text`. Thin wrapper over `orders.update` (`patch: { notes: text }`). The `notes` field is a free-text header field, distinct from the per-line `order_items.notes` JSON audit feed. If you want t... |
| `orders.add_attachment` | Upload a single attachment (proof, drawing, signed PO, supplier quote) to an order. The file is stored on the order's OWN `order_attachements` multi-file field (PB-side misspelling kept in sync with the live schema) — the same field the ... |
| `orders.list_attachments` | List the attachments associated with an order. Merges two sources: (a) files written to the order's own `order_attachements` multi-file field — operator uploads via `orders.add_attachment` and counter-sale photos from the in-store PWA; a... |
| `orders.add_attachment` | Upload a single attachment (proof, drawing, signed PO, supplier quote) to an order. The file is stored against the order's matched quote (WFDASH has no `order_attachments` collection; orders resolve to their matched quote for attachment ... |
| `orders.list_attachments` | List the attachments associated with an order. Returns the order's matched quote's attachments (since orders share the quote_attachments table with their source quote). Includes the original RFQ email attachments, the rendered quote PDF,... |
| `orders.sprint_counts` | Counts of sprint-pricing rows grouped by claim status (unclaimed / claimed-by-me / claimed-by-other / resolved). Used to render sprint badges in the operator UI and to decide whether an agent should claim a row. Wraps `GET /api/v1/orders... |
| `orders.find_duplicates` | Find probable duplicate orders. Wraps `GET /api/v1/orders/duplicates`. Use this BEFORE creating a new order from a customer's RFQ so the operator doesn't accidentally double-order. Three pass kinds are supported: - `order_number` — same ... |
| `orders.find_by_order_number` | Exact-match lookup for one order by its `order_number`. Returns the matching `Order` record or `null` if not found. Use this when you need a deterministic `is there already an order for this order number?` check. The substring search `or... |
| `orders.find_by_email_message_id` | Exact-match lookup for one order by its `email_message_id` (RFC 5322 Message-ID of the inbound email that originated it). Returns the matching `Order` record or `null` if not found. Use this for the `is there already an order for this in... |
| `orders.find_duplicates` | Find probable duplicate orders for the same customer + recent window. Wraps `GET /api/v1/orders/duplicates`. Use this BEFORE creating a new order from a customer's RFQ so the operator doesn't accidentally double-order. Returns groups wit... |
| `orders.review` | Read-only review bundle for one order: header, items grouped by supplier, PO readiness, attachment matches against quote items, and any pending decisions. Wraps `GET /api/v1/orders/[id]/review`. Use this BEFORE generating POs or splittin... |
| `orders.matched_attachments` | Match the order's quote attachments against each order_item by description / SKU overlap. Wraps `GET /api/v1/orders/[id]/matched-attachments`. Returns `{ item_id → [attachment_filename, ...] }` so the agent can decide which proof applies... |
@ -530,8 +503,8 @@ Grouped by resource family. The full description is in the table column; the des
| --- | --- |
| `order_items.update_status` | Update an order_item's `item_status` and/or `drv_status` fields. Use this when the operator needs to flip a single row through the workflow without bulk-pricing or per-line invoice reconciliation (`order_items.update` is the patch tool f... |
| `order_items.confirm_collected` | Confirm collection of one or more customer order_items WITHOUT requiring a driver assignment. Mirrors the agent-side role that the stock-side `stock_purchase_items.mark_collected` already plays: marks `item_status` → `collected` and `drv... |
| `order_items.create` | Add a new line item to an existing order. Mirrors the REST POST /api/v1/orders/{id}/items endpoint. `selling_price` auto-derives from `cost_price * (1 + markup / 100)` when omitted. Per-row failures never abort the whole call (returns `c... |
| `order_items.update` | Patch fields on an order_item for invoice reconciliation and catalogue relabelling. Whitelist: cost_price, selling_price, quantity, sku, unit, category, description, supplier, markup, markup_percent, pricing_source, pricing_notes, matche... |
| `order_items.create` | Add a new line item to an existing order. Mirrors the REST POST /api/v1/orders/{id}/items endpoint. **Strict pending gate**: refuses with 409 (`order_locked`) if any line item on the order is past `item_status === "pending"`. Adding a li... |
| `order_items.update` | Patch fields on an order_item for invoice reconciliation. Whitelist: cost_price, selling_price, quantity, sku, unit, category, markup, markup_percent, notes, qty_processed, qty_assigned, qty_collected, qty_verified, qty_stocked, qty_pick... |
| `order_items.list` | List order_items (the line items under each order). Useful for finding pending/PB-ready items, getting one supplier's items, etc. Pass PB filter syntax in `filter`. |
| `order_items.get` | Fetch a single order_item by id. Pass `expand` to inline `order`, `purchase_order`, `category`, `supplier`. |
@ -548,8 +521,8 @@ Grouped by resource family. The full description is in the table column; the des
| `quotes.find_by_message_id` | Look up an existing quote by the inbound email's RFC 5322 Message-ID header. Use this when the operator (or another agent) needs to correlate an inbound email back to a quote that was already created from it — e.g. to attach a reply, app... |
| `quotes.list` | List quotes. Pass PB filter syntax in `filter` (e.g. `status="new"`). Default sort `-created`. |
| `quotes.get` | Fetch a single quote by id. Pass `expand` (e.g. `customer,company,items`). |
| `quotes.update` | Patch fields on a quote header. Whitelist: `company_letterhead`, `quote_date`, `quote_reference`, `rfq_number`, `validity_days`, `notes`, `delivery_note`, `markup`, `email_cc`, `email_bcc`, `claimed_by`, `email_subject`, `email_sender`, ... |
| `quotes.send_email` | Front-door for the outbound email send pipeline. Validates and flips the quote to `sending`; the actual SMTP send runs asynchronously in n8n. `selectedAttachmentIds` is a list of PB file tokens from `quotes.outbound_attachments` (operato... |
| `quotes.update` | Patch fields on a quote header. Whitelist: `company_letterhead`, `quote_date`, `quote_reference`, `rfq_number`, `validity_days`, `notes`, `delivery_note`, `markup`, `email_cc`, `email_bcc`, `claimed_by`. Anything outside this set — inclu... |
| `quotes.send_email` | Front-door for the outbound email send pipeline. Validates and flips the quote to `sending`; the actual SMTP send runs asynchronously in n8n. `selectedAttachmentIds` is a list of `quote_attachments` IDs to include (the PDF is always atta... |
| `quotes.retry_email` | Retry the outbound email send for a quote currently in `mail_failed`. Use this when SMTP credentials have been rotated, the customer's MX changed, or the original send bounced. The quote must already be in `mail_failed` (409 if not) — fo... |
| `quotes.clone` | Clone a real quote into a new real quote with a fresh `quote_number` and `status="new"`. The source quote is untouched. Useful for re-quoting the same items to a different customer. |
| `quotes.create_from_template` | Create a new real quote from a template (`is_template=true` quote). The template is not modified — a fresh quote is created with a new `Q-YYYY-MM-NNN` number, `status="new"`, and the template's customer / company / letterhead / items cop... |
@ -558,7 +531,6 @@ Grouped by resource family. The full description is in the table column; the des
| `quotes.partial_approve` | Mark a subset of quote items as `approved` (or `rejected`) on a quote. Body: `{ quote_id, decisions: [{ item_id, decision }] }`. Useful when the customer accepts some lines but not others. |
| `quotes.submit` | Flip a `new` quote to `approval_pending` once every line is priced. Idempotent — re-submitting an already-`approval_pending` or `partial_approval_pending` quote is a no-op (returns `statusChanged: false`). Gated: every line on the quote ... |
| `quotes.re_price` | Re-run pricing on every quote_item under a quote. Whitelist of patchable fields is the same as `bulk_update_quote_items`. Returns the per-row result list. |
| `quotes.assistant_fill` | Run the AI quote assistant on one quote now (`{ quote_id }`): fills unpriced lines from matching POS items as purple, unreviewed drafts and notes the closest items on unmatched lines. Only quotes with status new / follow_up / claimed, never templates; at most 15 lines per run. Problems come back in `error` (not thrown). Never marks lines done, changes the quote status or sends anything. Wraps `fillQuote` (same as `POST /api/v1/quotes/[id]/assistant/run`). |
| `quotes.next_number` | Reserve the next quote number (`Q{yy}{mm}{NNN}`). Atomic counter; gaps are possible if the caller never persists the resulting draft. |
| `quotes.lookup` | Free-text lookup over quotes for the order page's quote-picker dropdown. Substring match across quote_number, quote_reference, rfq_number, customer name, company name. Excludes templates by default. |
| `quotes.list_attachments` | List all attachments on a quote (the generated PDF, original RFQ email attachments, operator-uploaded proofs, outbound attachments). Newest first. Returns up to 50 per page. |
@ -656,10 +628,9 @@ Grouped by resource family. The full description is in the table column; the des
| --- | --- |
| `stock_items.list` | List stock_items. Supports substring search on name/sku/description, low-stock filter (`current_qty <= min_qty AND is_active = true`), pagination, and `expand` (defaults to `supplier`). |
| `stock_items.get` | Fetch a single stock_item by id. The response includes the item (expand=supplier) and a paginated recent-ledger slice (`ledger.page`, `ledger.items`, etc.). Pass `ledger=false` to skip the ledger fetch. |
| `stock_items.create` | Create a new stock_item (a POS/till item). This is the DEFAULT place to put a product and its prices — use it unless the user explicitly asked for the price list / catalogue. The till reads `selling_price` from this collection and nothin... |
| `stock_items.create` | Create a new stock_item. `name` is required. When `current_qty > 0` an opening-balance ledger row is written so the running total and the ledger sum stay in sync (reason=`adjustment`, reference_type=`manual`). |
| `stock_items.adjust` | Record a manual stock adjustment. Inserts a `stock_ledger` row and updates `current_qty` on the parent item. `change_qty` is signed (positive = in, negative = out); zero is rejected. `reason` must be one of STOCK_REASON_VALUES. |
| `stock_items.update` | Patch catalogue fields on a master stock_item: sku, description, notes, unit, selling_price, markup. For inventory qty use stock_items.adjust. Returns {ok: true, item, previous} on success or {ok: false, reason, message, field?} on rejec... |
| `stock_items.set_pricing` | Set cost price / selling price / markup on existing stock_items in one batch (max 100 per call). The POS till reads `selling_price` (NOT markup), so set both consistently: selling = cost × (1 + markup/100). Cost changes are audited in st... |
### Collection pool
@ -696,7 +667,7 @@ Grouped by resource family. The full description is in the table column; the des
| Tool | Description |
| --- | --- |
| `price_list.add_items` | ⚠️ DO NOT USE BY DEFAULT. This writes to the `price_list` catalogue, which the POS does NOT read — nothing here ever reaches the till. Only call this when the user has explicitly asked for the price list / catalogue / supplier price list... |
| `price_list.add_items` | Bulk-upsert parsed supplier-price-list line items into the `price_list` catalogue. The agent reads the source document (a supplier PDF, a quote PDF, or a pasted supplier email) with its native vision capability, extracts the per-line fie... |
### Users
@ -720,7 +691,6 @@ Grouped by resource family. The full description is in the table column; the des
| Tool | Description |
| --- | --- |
| `audit_log.get` | Change history for one quote, order or delivery note — who changed what and when, newest first. Each entry lists per-field changes (`from` → `to`), the actor and source (web / mcp / n8n / agent). Includes summary counts: quote PDFs gener... |
| `bulk_update_quote_items` | Patch many `quote_items` rows in a single MCP call (one JSON-RPC round-trip instead of N PATCHes to PB REST). Use this for AI auto-fill, supplier switches, or any bulk pricing update. Each row is independent — one row's failure never aff... |
| `stock.fulfill` | Fulfill order items from stock. CREATE mode (`order_id` + `order_item_id` + `stock_item_id` + `qty` + `description` + `selling_price` + `cost_price`) creates a new order_item and decrements stock in one call. UPDATE mode (`order_item_id`... |
| `stock.surplus_book` | Book surplus from a collected order_item into a stock_item. Auto-picks the matching stock_item when `stock_item_id` is omitted. |
@ -733,8 +703,7 @@ Grouped by resource family. The full description is in the table column; the des
| `stock_quick_draft.search` | Search the quick-draft index for SKUs / descriptions / suppliers. Wraps `GET /api/v1/stock-quick-draft/search`. Returns ranked hits with last-known cost and supplier. Use this BEFORE adding a line so the agent can pre-fill cost + supplier. |
| `stock_quick_draft.add_line` | Add one line to an in-progress quick-draft PO. Wraps `POST /api/v1/stock-quick-draft/add-line`. The draft persists between calls until the operator hits 'Commit'. Use this when building up a stock PO one line at a time. Pass `stockItemId... |
| `stock_quick_draft.suppliers` | List suppliers with quick-draft history (last ordered, line count, total spend). Wraps `GET /api/v1/stock-quick-draft/suppliers`. Use this to pick a supplier when the catalogue doesn't have a clear default. |
| `delivery_notes.list` | List delivery notes. Reads the `delivery_notes` collection via the admin PB client. Supports `status` filter, `order_id` filter, and pagination. Newest-first. Use this when the operator asks 'show me outstanding delivery notes' or 'what'... |
| `delivery_notes.set_order` | Link an EXISTING delivery note to an EXISTING order. Mirrors the REST POST /api/v1/delivery-notes/{id}/order endpoint. Why this exists: `delivery_notes.order` is a relation to `orders` but has no setter in the service layer — only the cr... |
| `delivery_notes.list` | List delivery notes. Reads the `delivery_notes` collection via the admin PB client. Supports `status` filter (draft \| in_transit \| invoiced \| cancelled), `order_id` filter, and pagination. Newest-first. Use this when the operator asks 's... |
| `delivery_notes.create` | Create a counter-pickup delivery note (Path B — no upstream order). Wraps `POST /api/v1/delivery-notes`. Body: `customer` (PB id, required), `items[]` (required, ≥1 with description + quantity), optional `company`, `notes`, `createdBy`. ... |
| `delivery_notes.cancel` | Cancel a delivery note. Wraps `POST /api/v1/delivery-notes/[id]/cancel`. Body: optional `setById` (PB user id), `setByName`, and `note` for the audit trail. Use when a DN was created in error or the customer cancels the pickup. |
| `delivery_notes.mark_invoiced` | Flip a delivery note to `invoiced` after the customer's invoice is raised. Wraps `POST /api/v1/delivery-notes/[id]/mark-invoiced`. Idempotent; returns the updated DN record. |
@ -747,6 +716,6 @@ Grouped by resource family. The full description is in the table column; the des
### Alphabetical index
`ai_insights.rerun`, `ai_settings.get`, `ai_settings.update`, `attachments.upload_for_quote`, `audit_log.get`, `bulk_update_quote_items`, `buyer_tasks.generate_po`, `buyer_tasks.list`, `buyer_tasks.mark_ready`, `collection_confirm.confirm_order_items`, `collection_confirm.list`, `collection_confirm.upload_invoice`, `collection_pool.list`, `companies.create`, `companies.delete`, `companies.get`, `companies.list`, `companies.update`, `customers.create`, `customers.delete`, `customers.get`, `customers.list`, `customers.update`, `dashboard.daily_summary`, `delivery_notes.cancel`, `delivery_notes.combine`, `delivery_notes.create`, `delivery_notes.create_quote`, `delivery_notes.list`, `delivery_notes.mark_complete`, `delivery_notes.mark_invoiced`, `delivery_notes.regenerate_pdf`, `delivery_notes.set_customer_po`, `delivery_notes.set_item_quantity`, `delivery_notes.set_order`, `drivers.list_stops`, `drivers.update_stop_status`, `files.fetch`, `order_items.confirm_collected`, `order_items.create`, `order_items.get`, `order_items.list`, `order_items.update`, `order_items.update_status`, `orders.add_attachment`, `orders.add_note`, `orders.archive`, `orders.cancel`, `orders.create`, `orders.create_from_quote`, `orders.daily_summary`, `orders.find_by_email_message_id`, `orders.find_by_order_number`, `orders.find_duplicates`, `orders.get`, `orders.list`, `orders.list_attachments`, `orders.mark_delivered`, `orders.mark_invoiced`, `orders.matched_attachments`, `orders.quote_match_sweep`, `orders.review`, `orders.review_save`, `orders.set_quote`, `orders.split`, `orders.sprint_counts`, `orders.stats`, `orders.update`, `price_list.add_items`, `purchase_orders.add_attachment`, `purchase_orders.diff`, `purchase_orders.get`, `purchase_orders.list`, `purchase_orders.record_diff_report`, `purchase_orders.record_send_outcome`, `purchase_orders.retry_email`, `purchase_orders.scan`, `purchase_orders.send_email`, `purchase_orders.stale`, `quote_items.create`, `quote_items.get`, `quote_items.list`, `quote_items.populate_insights`, `quote_items.update`, `quotes.append_reply`, `quotes.assistant_fill`, `quotes.clone`, `quotes.create`, `quotes.create_from_email`, `quotes.create_from_template`, `quotes.find_by_message_id`, `quotes.get`, `quotes.get_pdf`, `quotes.ingest_from_email`, `quotes.list`, `quotes.list_attachments`, `quotes.lookup`, `quotes.next_number`, `quotes.partial_approve`, `quotes.process_pending`, `quotes.re_price`, `quotes.re_run_ai`, `quotes.retry_email`, `quotes.save_as_template`, `quotes.send_email`, `quotes.split`, `quotes.submit`, `quotes.update`, `sprint.attachment_counts`, `sprint.claim`, `sprint.item_attachments`, `sprint.list`, `stock.fulfill`, `stock.surplus_book`, `stock_items.adjust`, `stock_items.create`, `stock_items.get`, `stock_items.list`, `stock_items.set_pricing`, `stock_items.update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.bulk_update`, `stock_purchase_items.list`, `stock_purchase_items.mark_collected`, `stock_purchase_items.update`, `stock_purchases.add_attachment`, `stock_purchases.create`, `stock_purchases.get`, `stock_purchases.list`, `stock_purchases.match_and_receive_from_invoice`, `stock_purchases.next_number`, `stock_purchases.receive`, `stock_purchases.retry_email`, `stock_purchases.send`, `stock_purchases.send_email`, `stock_quick_draft.add_line`, `stock_quick_draft.search`, `stock_quick_draft.suppliers`, `suppliers.create`, `suppliers.delete`, `suppliers.get`, `suppliers.list`, `suppliers.update`, `tools.discovery`, `users.lookup`
`ai_insights.rerun`, `ai_settings.get`, `ai_settings.update`, `attachments.upload_for_quote`, `bulk_update_quote_items`, `buyer_tasks.generate_po`, `buyer_tasks.list`, `buyer_tasks.mark_ready`, `collection_confirm.confirm_order_items`, `collection_confirm.list`, `collection_confirm.upload_invoice`, `collection_pool.list`, `companies.create`, `companies.delete`, `companies.get`, `companies.list`, `companies.update`, `customers.create`, `customers.delete`, `customers.get`, `customers.list`, `customers.update`, `dashboard.daily_summary`, `delivery_notes.cancel`, `delivery_notes.combine`, `delivery_notes.create`, `delivery_notes.create_quote`, `delivery_notes.list`, `delivery_notes.mark_complete`, `delivery_notes.mark_invoiced`, `delivery_notes.regenerate_pdf`, `delivery_notes.set_customer_po`, `delivery_notes.set_item_quantity`, `drivers.list_stops`, `drivers.update_stop_status`, `files.fetch`, `order_items.confirm_collected`, `order_items.create`, `order_items.get`, `order_items.list`, `order_items.update`, `order_items.update_status`, `orders.add_attachment`, `orders.add_note`, `orders.archive`, `orders.cancel`, `orders.create`, `orders.create_from_quote`, `orders.daily_summary`, `orders.find_duplicates`, `orders.get`, `orders.list`, `orders.list_attachments`, `orders.mark_delivered`, `orders.mark_invoiced`, `orders.matched_attachments`, `orders.review`, `orders.review_save`, `orders.split`, `orders.sprint_counts`, `orders.stats`, `orders.update`, `price_list.add_items`, `purchase_orders.add_attachment`, `purchase_orders.diff`, `purchase_orders.get`, `purchase_orders.list`, `purchase_orders.record_diff_report`, `purchase_orders.record_send_outcome`, `purchase_orders.retry_email`, `purchase_orders.scan`, `purchase_orders.send_email`, `purchase_orders.stale`, `quote_items.create`, `quote_items.get`, `quote_items.list`, `quote_items.populate_insights`, `quote_items.update`, `quotes.append_reply`, `quotes.clone`, `quotes.create`, `quotes.create_from_email`, `quotes.create_from_template`, `quotes.find_by_message_id`, `quotes.get`, `quotes.get_pdf`, `quotes.ingest_from_email`, `quotes.list`, `quotes.list_attachments`, `quotes.lookup`, `quotes.next_number`, `quotes.partial_approve`, `quotes.process_pending`, `quotes.re_price`, `quotes.re_run_ai`, `quotes.retry_email`, `quotes.save_as_template`, `quotes.send_email`, `quotes.split`, `quotes.submit`, `quotes.update`, `sprint.attachment_counts`, `sprint.claim`, `sprint.item_attachments`, `sprint.list`, `stock.fulfill`, `stock.surplus_book`, `stock_items.adjust`, `stock_items.create`, `stock_items.get`, `stock_items.list`, `stock_items.update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.bulk_update`, `stock_purchase_items.list`, `stock_purchase_items.mark_collected`, `stock_purchase_items.update`, `stock_purchases.add_attachment`, `stock_purchases.create`, `stock_purchases.get`, `stock_purchases.list`, `stock_purchases.match_and_receive_from_invoice`, `stock_purchases.next_number`, `stock_purchases.receive`, `stock_purchases.retry_email`, `stock_purchases.send`, `stock_purchases.send_email`, `stock_quick_draft.add_line`, `stock_quick_draft.search`, `stock_quick_draft.suppliers`, `suppliers.create`, `suppliers.delete`, `suppliers.get`, `suppliers.list`, `suppliers.update`, `tools.discovery`, `users.lookup`
<!-- END mcp-doc-gen -->

View file

@ -1,163 +0,0 @@
# WFDASH API — Create Order (POST /api/v1/orders/create)
> **One payload, one call.** The full order header + items array goes
> in a single request body. There is no `/api/v1/orders` POST —
> `/orders` is GET-only.
---
## Endpoint
```
POST /api/v1/orders/create
```
Bearer-token auth via `AGENT_API_KEY` header. **Hermes drops
`AGENT_API_KEY` on direct REST calls** — use the MCP wrapper
`mcp__wfdash-mcp__orders__create` with the same body shape.
`runtime = "nodejs"` (PocketBase admin auth requires Node).
---
## Request body
```json
{
"customer_id": "<15-char PB id>", // required
"company_id": "<15-char PB id>", // required
"notes": "string?", // order-level notes
"contact_name":"string?", // operator-readable contact
"description": "string?", // short description of the order
"email_message_id": "string?", // dedup key
"rep": "string?", // sales rep free-text
"items": [
{
"description": "string", // required, trimmed
"quantity": 1, // default 1; must be > 0 when supplied
"cost_price": 123.45,
"markup": 25,
"selling_price": 154.31, // optional
"sku": "string?",
"unit": "EA",
"supplier_id": "<15-char PB id?>",
"notes": "string?"
}
]
}
```
### Field rules
- **`customer_id`, `company_id`** — must resolve in PB. Free-text
names are **not** accepted on this endpoint (use the MCP `orders.create`
wrapper if you need name-resolution; the REST handler requires ids).
- **`items[]`** — non-empty, every item needs `description`.
- **`selling_price` auto-derive** — when `cost_price` and `markup` are
both supplied and `selling_price` is omitted, it auto-derives as
`cost_price * (1 + markup / 100)` rounded to 2 dp. Pass
`selling_price` explicitly to override.
- **`email_message_id`** — optional idempotency key. If an order with
the same `email_message_id` already exists, the response returns the
existing record with `duplicate: true` and HTTP **200** instead of
201. Safe to retry.
- **Per-row independence** — each item creates one `order_item` row
with `item_status = "pending"` and `drv_status = "unassigned"`. One
item's secondary-field patch failure does **not** abort the rest of
the call; failures land in `items_failed[]` with the partial-success
shape.
---
## Response
### 201 — created
```json
{
"order": { /* full PB order record */ },
"order_items_collection": "<relation-id>",
"items_created": [ { "id": "...", "description": "..." } ],
"items_failed": []
}
```
### 200 — duplicate (`email_message_id` matched)
Same shape as 201, plus `"duplicate": true`. Nothing new was written.
### 400 — validation failure
```json
{ "error": "customer_id is required" }
{ "error": "items must be a non-empty array" }
```
### 404 — not found
```json
{ "error": "Requested resource not found..." }
```
`customer_id` or `company_id` doesn't resolve in PB.
### 500 — server / PB write failure
```json
{ "error": "<PocketBase error message>" }
```
---
## Common workflows
### Walk-in / counter sale
No upstream quote. Customer walks up, picks items, you write the
order straight from the till:
```bash
curl -X POST https://wfdash.example/api/v1/orders/create \
-H "Authorization: Bearer $AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "<customer-id>",
"company_id": "<company-id>",
"notes": "Counter sale — 2026-08-27",
"items": [
{ "description": "SKF 6205 bearing", "quantity": 4,
"cost_price": 85, "markup": 30 }
]
}'
```
### Re-create from a quote
**Don't** use this endpoint — use
`POST /api/v1/orders/from-quote` (MCP: `orders.create_from_quote`).
It clones the matched quote into a fresh `ORD-YYYY-MM-NNN` order,
inheriting customer / company / rep / letterhead / line items.
### Append to an existing order
There **is** a separate endpoint:
`POST /api/v1/orders/{id}/items` (MCP: `order_items.create`). But it
**refuses with 409 `order_locked`** the moment any line on the order
is past `pending`. Use it only on fresh orders — for any order that
has a PO in flight, you must `orders.split` first.
---
## Pitfalls
- **Passing the order header without `items[]`** → 400. The handler
does not create empty orders.
- **Passing `quantity: 0` or negative** → rejected per item by the
service layer; the row lands in `items_failed[]` with
`validation_error`.
- **Forgetting to send `AGENT_API_KEY`** → 401 from
`assertAgentAuth`. From Hermes, use `mcp__wfdash-mcp__orders__create`
instead — the MCP layer injects the key.
- **Reusing `email_message_id` for genuinely different orders** →
the second call returns the first order with `duplicate: true`.
Don't reuse the key; generate a new one per logical order.

View file

@ -1,179 +0,0 @@
# WFDASH API — Quote Create + Customer Lookup Reference
> What the WFDASH REST/MCP API actually exposes today for the two
> tasks you keep hitting during intake: creating a quote, and
> finding an existing customer.
---
## Quote create — three flavours
There are three different quote-creation endpoints. Pick by **where
the data came from**, not by what feels closest:
### 1. `quotes.create` (walk-in) — **the one for your intake**
```
POST /api/v1/quotes/create
```
- Use when: there's no inbound RFQ email. Operator types it in, you
extracted items from a PDF / phone-call / WhatsApp, or you're
driving the quote from the in-store counter PWA.
- Body has `customer` (id OR name), `company` (id OR name), and
`items[]`. **Names are resolved case-insensitive exact** — if
there's no match, a new customer / company row is created with
empty defaults. So passing a name is safe; passing the wrong name
silently creates a duplicate.
- Returns `{ quote, customer, company, items, quote_number }` on 201.
- Status default is `"new"`. `quote_number` is auto-generated unless
you supply one (operator-supplied conflicts return 409).
- MCP wrapper: `mcp__wfdash-mcp__quotes__create`. Same body shape.
### 2. `quotes.ingest_from_email` (inbound RFQ email)
```
POST /api/v1/quotes/ingest-from-email
```
- Use when: the customer actually emailed in. Returns immediately
with `ai_status="pending"`; the background processor runs Claude
against the body + attachments to extract items. Don't extract
items yourself — let the pipeline do it.
- MCP wrapper: `mcp__wfdash-mcp__quotes__ingest_from_email`.
### 3. `quotes.create_from_email` (legacy, **avoid**)
```
POST /api/v1/quotes/create-from-email
```
- Caller passes a pre-extracted `extracted_items[]`. Only use this if
you're calling on behalf of a parser that already did the
extraction. **Wrong tool if you don't have an inbound email** —
it requires `subject` + parsed body and silently rewrites
`email_message_id` to a `synth-…@wfdash.local` sentinel.
- MCP wrapper: `mcp__wfdash-mcp__quotes__create_from_email`.
---
## Customer lookup — fuzzy search by name / company
### `customers.list` — substring search
```
GET /api/v1/customers
```
Query params:
| Param | Type | What it does |
|-----------|------|--------------|
| `search` | string | Substring match on **customer name / email / phone**, OR'd together; also matches **rep name / rep email** (the user that owns this customer). One query covers all four fields. Case-sensitive PB `~` operator. |
| `rep` | string | Exact filter on the rep relation. Accepts a 15-char PB user id OR a user name / email (case-insensitive). |
| `page` | number | 1-based, default 1 |
| `perPage` | number | default 50, hard cap 100 |
| `sort` | string | PB sort expression, default `-created` |
| `expand` | string | Comma-separated, e.g. `"company,rep"` to inline relations |
Returns `{ items, totalItems, totalPages, page, perPage }`.
MCP wrapper: `mcp__wfdash-mcp__customers__list`. Same params.
### `customers.get` — fetch by id
```
GET /api/v1/customers/{id}
```
For when you already have the PB id and just need the full row +
relations.
MCP wrapper: `mcp__wfdash-mcp__customers__get`.
### What we DO NOT have today
- **No direct "filter customers by company name"** on `customers.list`.
The `search` param doesn't search across the `company` relation.
Two workarounds:
1. Pass `expand: "company"` on the list call, then filter the
returned `items[]` client-side by company name.
2. First `companies.list({search: "<name>"})` to get the company
id, then iterate the matching customers client-side. (The
`customers.list` `search` filter does not accept a company
relation id today.)
- **No dedicated `customers.lookup` endpoint** — `customers.list`
with a small `perPage` (e.g. 5) is the closest equivalent.
- **No fuzzy / Levenshtein matching.** It's PB's literal substring
`~` operator. "AB" won't match "ABC Trading" if you typed it
wrong-case, and partial-token matches like "Bear" against "Bearings
Co" will work — but "Bearings" against "Bear" works too (substring
is bidirectional). Typos are not forgiven.
---
## The intake pattern that works
Given what we have, the cleanest customer-resolution flow at intake
is:
```ts
// 1. Try the most specific lookup first.
const hit = await customers.list({
search: customerName,
expand: "company",
perPage: 5,
});
// 2. If the caller ALSO gave a company name, narrow client-side.
const filtered = companyName
? hit.items.filter(c => c.expand?.company?.company === companyName)
: hit.items;
// 3. If 0 hits → create via customers.create (auto-creates company
// if company name doesn't resolve either). Use quotes.create with
// the names directly — it has the same auto-create behaviour, so
// you don't need a separate customers.create call unless you want
// to backfill email/phone/etc.
```
For quote creation specifically, **just pass `customer` and `company`
as names to `quotes.create`** — the service layer resolves them by
case-insensitive exact match and creates missing rows. You only need
the lookup step when:
- You're filling in `email` / `phone` from a contact form.
- You want to verify the customer exists before showing the operator
a "is this the right John Smith?" confirmation.
- You're merging two customer records.
---
## Companies (related, since you mentioned name + company)
### `companies.list` — substring search
```
GET /api/v1/companies
```
Query params: `page`, `perPage`, `search` (substring on
`company.company` / address / vendor), `sort`. Same shape as
`customers.list`.
MCP wrapper: `mcp__wfdash-mcp__companies__list`.
### `companies.get` — fetch by id
```
GET /api/v1/companies/{id}
```
MCP wrapper: `mcp__wfdash-mcp__companies__get`.
### `companies.create` / `companies.update`
Free-text company name, optional address / vendor / coordinates.
Same auto-resolve-and-create-on-no-match behaviour as customers.

View file

@ -1,724 +0,0 @@
# MCP + API Layer Coverage Gap Analysis
**Task:** #e169d78f
**Date:** 2026-08-28
**Author:** @bob
**Status:** analysis only — implementation NOT included (per task brief)
## Executive summary
The WFDASH app already has a substantial programmatic surface:
| Surface | Count | Scope |
|---|---|---|
| Pages (Server Components) | 53 | The full operator UI |
| REST route handlers under `/api/v1/*` | 165 | Programmatic + agent + n8n integration |
| MCP tools (registered in `src/app/api/mcp/route.ts`) | 142 | Agent-facing (Hermes, etc.) |
| Server Actions (Next.js `"use server"`) | 1 file | AI settings form only |
| Service-layer exports (`src/lib/server/*-service.ts`) | ~350 across 40 files | The actual business logic |
The current coverage is **broad but uneven**. The bulk-write lifecycle (quotes → orders → PO → collection → delivery → invoice → stock) is well-covered on every layer. **Reads are skewed toward agent queries** (every collection has a `.list` / `.get` MCP tool), but **write coverage has a long tail of gaps** in domains that don't get daily agent traffic: repairs, the delivery board, the boss dashboard, the collection map, the stock quick-draft line editor, and the `price_list` / `raw_price_list` catalogue UI.
The biggest structural gap is the **MCP route file itself** — a single 7,077-line `route.ts` containing 142 tool definitions, 142 handlers, the JSON-RPC envelope, the CORS helper, the upstream-fetch wrapper, and the `TOOL_HINTS` map. This is blocking maintainability (already on the security-perf-prod audit as a major), and it's blocking discoverability for new tool authors.
## Methodology
1. Inventoried every page (`src/app/**/page.tsx`) — 53 pages.
2. Inventoried every `/api/v1/**/route.ts` — 165 route files.
3. Inventoried every tool in `src/app/api/mcp/route.ts` — 142 tools.
4. Inventoried every export from `src/lib/server/*-service.ts` (~40 services).
5. Inventoried every export from the legacy `src/lib/pb-helpers.ts` (~60 functions).
6. Inventoried Server Actions (`src/app/**/actions.ts`) — 1 file (settings/ai only).
7. Cross-referenced against `src/app/api/mcp/FEATURE-REQUESTS.md` to avoid duplicating already-known gaps.
8. Cross-referenced the MCP refactor recommendation at `docs/audit/backend-data.md:355-383` (Major finding 2.6).
The output is a capability→API→MCP matrix and a prioritized gap list with recommended slices for follow-up tasks.
## Coverage matrix
Legend: ✅ shipped · 🟡 partial · ❌ missing
### Quotes pipeline
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List quotes (tabs) | `/quotes` | `GET /quotes/list` + `/quotes/search` | `quotes.list` | ✅ |
| Get single quote | `/quotes/[id]` | `GET /quotes/[id]` | `quotes.get` | ✅ |
| Create from email (n8n ingest) | webhook | `POST /quotes/from-email` | `quotes.create_from_email`, `quotes.ingest_from_email` | ✅ |
| Create walk-in | `/quotes/[id]` "new" | `POST /quotes/create` | `quotes.create` | ✅ (Phase G) |
| Update quote header | inline form | `PATCH /quotes/[id]` | `quotes.update` | ✅ |
| Find by Message-ID | n8n callback | `GET /quotes/lookup?messageId=` | `quotes.find_by_message_id` | ✅ |
| List templates | `/quotes` Templates tab | `GET /quotes/archive` | ❌ | MCP gap |
| Save as template | detail page | `POST /quotes/[id]/save-as-template` | `quotes.save_as_template` | ✅ |
| Create from template | template row | `POST /quotes/[id]/create-from-template` | `quotes.create_from_template` | ✅ |
| Clone quote | detail page | `POST /quotes/[id]/clone` | `quotes.clone` | ✅ |
| Add reply to thread | reply modal | `POST /quotes/append-reply` | `quotes.append_reply` | ✅ (route is `POST /quotes` with action, not `/append-reply` — see gap §1.4) |
| Re-run AI | detail page button | `POST /quotes/[id]/re-run-ai` | `quotes.re_run_ai` | ✅ |
| Process pending (batch) | cron | `POST /quotes/process-pending` | `quotes.process_pending` | ✅ |
| Submit for approval | detail page | `POST /quotes/[id]/submit` | `quotes.submit` | ✅ (Phase G) |
| Partial approve | detail page | `POST /quotes/[id]/partial-approve` | `quotes.partial_approve` | ✅ |
| Review-approve | `/quotes/approvals` | `POST /quotes/[id]/review-approve` | ❌ | MCP gap (see §1.1) |
| Re-price items | inline | `POST /quotes/[id]/re-price` | `quotes.re_price` | ✅ |
| AI fill from POS | quote / Process page button | `POST /quotes/[id]/assistant/run` | `quotes.assistant_fill` | ✅ |
| Split quote | inline | `POST /quotes/[id]/split` | `quotes.split` | ✅ |
| Send email | email modal | `POST /quotes/[id]/send-email` | `quotes.send_email` | ✅ |
| Retry email | email modal | `POST /quotes/[id]/retry-email` | `quotes.retry_email` | ✅ |
| Render PDF | preview | `GET /quotes/[id]/pdf` + `/pdf/base64` | `quotes.get_pdf` | ✅ |
| Populate insights | AI panel | `POST /quotes/[id]/populate-insights` + `/quote_items/[id]/populate-insights` | `quote_items.populate_insights` | ✅ |
| List attachments | attachments card | `GET /quotes/[id]/attachments` | `quotes.list_attachments` | ✅ |
| Upload attachment | attachments card | `POST /quotes/[id]/attachments/upload` + `/quotes/items/[id]/attachments/upload` | `attachments.upload_for_quote` | ✅ |
| Set notes / set is_no_quote | form | `PATCH /quotes/[id]` | `quotes.update` | ✅ |
| Lookup (number → id) | form | `GET /quotes/lookup` | `quotes.lookup` | ✅ |
| Generate next quote # | form | `GET /quotes/next-number` | `quotes.next_number` | ✅ |
| Archive quotes | `/quotes/archive` | `POST /quotes/archive` | ❌ | MCP gap (see §1.1) |
Result: 28 capabilities, 26 fully covered. **2 MCP gaps** (review-approve, archive).
### Quote items
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List per quote | `/quotes/[id]` | `GET /quotes/[id]/items` | `quote_items.list` | ✅ |
| Get single item | inline | `GET /quote_items/[id]` | `quote_items.get` | ✅ |
| Create item | inline "add row" | `POST /quotes/[id]/items` | `quote_items.create` | ✅ (Phase F) |
| Update item | inline popover | `PATCH /quote_items/[id]` | `quote_items.update` | ✅ |
| Bulk update items | sprint bulk | `POST /quote_items/bulk-update` | `bulk_update_quote_items` | ✅ |
| Boolean popover fields (qty/cost/sell) | inline | `PATCH /order-items/[id]/...` (per-field) | 🟡 | Split across many small routes — see §3.4 |
| Populate AI insights | insights panel | `POST /quote_items/[id]/populate-insights` | `quote_items.populate_insights` | ✅ |
| Get AI intel | insights panel | `GET /quote_items/[id]/intel` | 🟡 | REST yes, MCP gap (see §1.7) |
| Item history | inline panel | `GET /quote_items/[id]/item-history` | ❌ | MCP gap |
| SKU lookup / match | sprint match picker | `GET /quotes/[id]/items/[itemId]/lookup` + `/match` | 🟡 | REST yes, MCP gap |
| Rewrite (sprint rewrite) | sprint | `POST /quotes/items/rewrite` | ❌ | MCP gap (internal AI flow) |
Result: 11 capabilities, 6 fully covered, **5 partial/missing**.
### Orders pipeline
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List orders | `/orders` | `GET /orders/list` + `/orders` | `orders.list` | ✅ |
| Get single order | `/orders/[id]` | `GET /orders/[id]` | `orders.get` | ✅ |
| Create from quote | "Convert" button | `POST /orders/from-quote` | `orders.create_from_quote` | ✅ |
| Create walk-in | `/orders/new` | `POST /orders/create` | `orders.create` | ✅ |
| Update header | detail page | `PATCH /orders/[id]` | `orders.update` | ✅ (Phase H) |
| Set quote link | detail page | `POST /orders/[id]/quote` | `orders.set_quote` | ✅ |
| Archive | `/orders/archive` | `POST /orders/archive` | `orders.archive` | ✅ (Phase H) |
| Cancel | detail page | `POST /orders/[id]/cancel` | `orders.cancel` | ✅ (Phase H) |
| Review-save | detail page | `POST /orders/[id]/review-save` | `orders.review_save` | ✅ (Phase H) |
| Mark invoiced (header) | invoicing tab | `POST /orders/[id]/mark_invoiced` | `orders.mark_invoiced` | ✅ (Phase H) |
| Mark delivered (header) | invoicing tab | `POST /orders/[id]/mark_delivered` | `orders.mark_delivered` | ✅ (Phase H) |
| Add note | detail page | `PATCH /orders/[id]` | `orders.add_note` | ✅ (Phase H, wrapper of update) |
| Add attachment | attachments card | `POST /orders/[id]/attachments/upload` | `orders.add_attachment` | ✅ |
| List attachments | attachments card | `GET /orders/[id]/matched-attachments` | `orders.list_attachments`, `orders.matched_attachments` | ✅ |
| Split order | detail page | `POST /orders/[id]/split` | `orders.split` | ✅ |
| Add item (existing order) | inline | `POST /orders/[id]/items` | `order_items.create` | ✅ (Phase H) |
| Review data (read) | review screen | `GET /orders/[id]/review` | `orders.review` | ✅ (Phase E) |
| Stats (aggregate) | reports | `GET /orders/stats` | `orders.stats` | ✅ (Phase H) |
| Daily summary | reports | `GET /orders/daily_summary` | `orders.daily_summary` | ✅ (Phase H) |
| Sprint counts | reports | `GET /orders/sprint/counts` | `orders.sprint_counts` | ✅ (Phase E) |
| Find by order # | search | `GET /orders/lookup` | `orders.find_by_order_number` | ✅ (Phase E) |
| Find by Message-ID | n8n callback | `GET /orders/lookup` | `orders.find_by_email_message_id` | ✅ (Phase E) |
| Find duplicates | QA | `GET /orders/duplicates` | `orders.find_duplicates` | ✅ (Phase E) |
| Lookup (number → id) | form | `GET /orders/lookup` | ❌ | MCP gap — see §1.2 |
Result: 24 capabilities, 23 fully covered. **1 MCP gap** (generic lookup).
### Order items
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List per order | `/orders/[id]` | `GET /order-items?orderId=` | `order_items.list` | ✅ |
| Get single item | inline | `GET /order-items/[id]` | `order_items.get` | ✅ |
| Update popover fields | inline popover | `PATCH /order-items/[id]` | `order_items.update` | ✅ (Phase A whitelist) |
| Per-field shortcuts | inline | `PATCH /order-items/[id]/{qty,sku,cost,status}` | 🟡 | 4 routes for the popover per-field widgets; not wrapped in MCP — see §3.4 |
| Update status | workflow button | `PATCH /order-items/[id]/status` | `order_items.update_status` | ✅ |
| Confirm collected (driver) | driver flow | `POST /order-items/confirm` | `order_items.confirm_collected` | ✅ |
| Split item | inline | `POST /order-items/[id]/split` | ❌ | MCP gap — see §1.3 |
| Request margin approval | inline | `POST /order-items/[id]/request-margin-approval` | ❌ | MCP gap |
| AI intel (read) | insights panel | `GET /order-items/[id]/intel` | 🟡 | REST yes, MCP gap |
| Refresh AI intel | insights panel | `POST /items/[collection]/[id]/intel/refresh` | 🟡 | REST yes, MCP gap |
| Backfill intel | cron | `POST /items/backfill-intel` | ❌ | MCP gap (cron) |
| Context (price-history snapshot) | context panel | `GET /order-items/[id]/context` | ❌ | MCP gap |
Result: 12 capabilities, 5 fully covered, **7 partial/missing**.
### Sprint / item-level queue
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List sprint items | `/sprint` | `GET /sprint` | `sprint.list` | ✅ |
| Claim item (30-min TTL) | inline | `POST /sprint/items/[id]/claim` | `sprint.claim` | ✅ |
| Item attachments | sprint panel | `GET /sprint/items/[id]/attachments` | `sprint.item_attachments` | ✅ |
| Attachment counts | sprint row badge | `GET /sprint/attachment-counts` | `sprint.attachment_counts` | ✅ |
| Upload attachment | sprint panel | `POST /sprint/items/[id]/attachments/upload` | `sprint.item_attachments` (POST variant) | 🟡 | MCP exposes the GET path; the POST upload is a different input shape — see §3.5 |
Result: 5 capabilities, 4 fully covered, **1 partial**.
### Collection pool & map
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List collection pool | `/collection-pool` | `GET /collection-pool` (`GET /orders/...`) | `collection_pool.list` | ✅ (older route) |
| Map items | `/collection-map` | `GET /collection-map/items` | ❌ | MCP gap — see §1.5 |
| Assign driver (map) | map click | `POST /collection-map/items/[id]/assign` | ❌ | MCP gap |
| Bulk assign per supplier | pool | server action `bulkAssign` | ❌ | MCP gap |
Result: 4 capabilities, 1 covered, **3 MCP gaps**.
### Driver (mobile)
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List drivers (users) | settings | `GET /driver` | ❌ | MCP gap — only `drivers.list_stops` exists |
| List stops | mobile / driver | `GET /driver/stops` | `drivers.list_stops` | ✅ |
| Update stop status | mobile / driver | `PATCH /driver/stops/[id]/status` | `drivers.update_stop_status` | ✅ |
| Collection-confirm (driver UI) | `/collection-confirm` | `GET /collection-confirm` | `collection_confirm.list` | ✅ |
| Confirm collection | mobile / driver | `POST /collection-confirm/confirm-order-items` | `collection_confirm.confirm_order_items` | ✅ |
| Upload invoice (driver) | mobile / driver | `POST /collection-confirm/invoice-upload` | `collection_confirm.upload_invoice` | ✅ |
| Confirm stock (warehouse) | warehouse | `POST /collection-confirm/confirm-stock` | ❌ | MCP gap |
Result: 7 capabilities, 5 covered, **2 MCP gaps** (driver list, confirm-stock).
### Delivery board (`/deliveries`)
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List per tab (to_load / back_from_driver / checked_in) | `/deliveries` | `GET /deliveries?tab=` | ❌ | MCP gap (see §1.6) |
| Load (set status → with_driver) | `/deliveries` | `POST /deliveries/load` | ❌ | MCP gap |
| Load DN (delivery-note bound load) | `/deliveries` | `POST /deliveries/load-dn` | ❌ | MCP gap |
| Check-in (driver returns) | `/deliveries` | `POST /deliveries/check-in` | ❌ | MCP gap |
| Mark signed copy | `/deliveries` | `POST /deliveries/mark-signed-copy` | ❌ | MCP gap |
| Find item to load by DN | `/deliveries` | server action | ❌ | MCP gap |
Result: 6 capabilities, 0 MCP-covered, 6 REST-only. **Whole `/deliveries` MCP domain missing.**
### Delivery notes
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List delivery notes | `/delivery-notes` | `GET /delivery-notes` | `delivery_notes.list` | ✅ (Phase E) |
| Get single | `/delivery-notes/[id]` | `GET /delivery-notes/[id]` | ❌ | MCP gap — see §1.9 |
| Create from order | invoicing modal | `POST /delivery-notes` | `delivery_notes.create` | ✅ (Phase E) |
| Cancel | detail page | `POST /delivery-notes/[id]/cancel` | `delivery_notes.cancel` | ✅ (Phase E) |
| Mark invoiced | invoicing tab | `POST /delivery-notes/[id]/mark-invoiced` | `delivery_notes.mark_invoiced` | ✅ (Phase E) |
| Mark complete | detail page | `POST /delivery-notes/[id]/mark-complete` | `delivery_notes.mark_complete` | ✅ (Phase E) |
| Regenerate PDF | detail page | `POST /delivery-notes/[id]/regenerate-pdf` | `delivery_notes.regenerate_pdf` | ✅ (Phase E) |
| Create quote from DN | detail page | `POST /delivery-notes/[id]/create-quote` | `delivery_notes.create_quote` | ✅ (Phase E) |
| Set customer PO | detail page | `POST /delivery-notes/[id]/customer-po` | `delivery_notes.set_customer_po` | ✅ (Phase E) |
| Set item quantity | inline | `POST /delivery-notes/items/[id]/quantity` | `delivery_notes.set_item_quantity` | ✅ (Phase E) |
| Set order | detail page | `POST /delivery-notes/[id]/order` | `delivery_notes.set_order` | ✅ (Phase E) |
| Combine (merge DNs) | admin | `POST /delivery-notes/combine` | `delivery_notes.combine` | ✅ (Phase E) |
| Mark signed copy | `/delivery-notes/[id]` | `POST /delivery-notes/[id]/signed-copy` | ❌ | MCP gap |
| Get PDF (stream) | preview | ❌ (no stream route) | ❌ | See §1.10 |
Result: 14 capabilities, 11 covered. **3 MCP gaps** (get-single, signed-copy, PDF stream).
### Repairs
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List repairs | `/repairs` | `GET /repairs` | ❌ | MCP gap — see §1.8 |
| Get single | `/repairs/[id]` | `GET /repairs/[id]` | ❌ | MCP gap |
| Create repair | `/repairs` form | `POST /repairs` | ❌ | MCP gap |
| Update repair | `/repairs/[id]` | `PATCH /repairs/[id]` | ❌ | MCP gap |
| Delete repair | `/repairs/[id]` | `DELETE /repairs/[id]` | ❌ | MCP gap |
| List form-data (create modal) | form | `GET /repairs/form-data` | ❌ | Internal helper, no MCP gap |
| Upload file | repair detail | `POST /repairs/[id]/files/[field]` | ❌ | MCP gap |
| Generate delivery note | repair detail | `POST /repairs/[id]/generate-delivery-note` | ❌ | MCP gap |
| Generate repair number | form | ❌ (handled inline) | ❌ | MCP gap |
Result: 9 capabilities, 0 MCP-covered. **Whole `/repairs` MCP domain missing.**
### Purchase orders (PO)
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List POs | (sprint/PO view) | `GET /purchase-orders` | `purchase_orders.list` | ✅ |
| Get single PO | inline | `GET /purchase-orders/[id]` | `purchase_orders.get` | ✅ |
| Create PO | server action | `POST /purchase-orders` | `stock_purchases.send` (different) | 🟡 | The agent uses `stock_purchases` for stock POs; **customer POs** go via server action with no MCP wrapper — see §1.11 |
| Send PO email | email modal | `POST /purchase-orders/[id]/send-email` | `purchase_orders.send_email` | ✅ |
| Retry email | email modal | `POST /purchase-orders/[id]/send-email` | `purchase_orders.retry_email` | ✅ |
| Send outcome (callback) | n8n callback | `POST /purchase-orders/[id]/send-email/outcome` | `purchase_orders.record_send_outcome` | ✅ |
| PDF render | preview | `GET /purchase-orders/[id]/pdf` | ❌ | MCP gap (see §1.12) |
| Dispatch (operator action) | PO row | `POST /purchase-orders/[id]/dispatch` | 🟡 | REST yes, MCP gap |
| Add attachment | detail page | `POST /purchase-orders/[id]/attachments/upload` | `purchase_orders.add_attachment` | ✅ |
| Get attachment | preview | `GET /purchase-orders/[id]/attachments/[filename]` | 🟡 | REST yes, MCP gap |
| List stale POs | reports | `GET /purchase-orders/stale` | `purchase_orders.stale` | ✅ |
| Scan (PB PDF → lines) | AI flow | `POST /purchase-orders/scan` | `purchase_orders.scan` | ✅ (internal) |
| Diff (PO vs lines) | AI flow | `POST /purchase-orders/diff` | `purchase_orders.diff` | ✅ (internal) |
| Record diff report | AI flow | `POST /purchase-orders/record-diff-report` | `purchase_orders.record_diff_report` | ✅ (internal) |
Result: 14 capabilities, 9 fully covered, **5 partial/missing**.
### Stock items + stock purchases
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List stock items | `/stock` | `GET /stock/items` | `stock_items.list` | ✅ |
| Get single | `/stock/[id]` | `GET /stock/items/[id]` | `stock_items.get` | ✅ |
| Create stock item | `/stock` form | `POST /stock/items/create` | `stock_items.create` | ✅ |
| Update stock item | `/stock/[id]` | `PATCH /stock/items/[id]` | `stock_items.update` | ✅ |
| Manual adjust | `/stock/[id]` | `POST /stock/items/[id]/adjust` | `stock_items.adjust` | ✅ |
| Set cost / selling price / markup (batch) | `/stock` inline edit | `POST /stock/items/set-pricing` | `stock_items.set_pricing` | ✅ |
| Fulfill from stock for order | fulfilment flow | `POST /stock/fulfill` | `stock.fulfill` | ✅ |
| Surplus book | fulfilment flow | `POST /stock/surplus-book` | `stock.surplus_book` | ✅ |
| List stock purchases | `/stock-orders` | `GET /stock-purchases/list` | `stock_purchases.list` | ✅ |
| Get single SP | `/stock-orders/[id]` | `GET /stock-purchases` (one-shot read) | `stock_purchases.get` | ✅ |
| Create SP | `/stock-orders/new` | `POST /stock-purchases` | `stock_purchases.create` | ✅ |
| Send SP email | email modal | `POST /stock-purchases/[id]/send-email` | `stock_purchases.send_email` | ✅ |
| Retry email | email modal | `POST /stock-purchases/[id]/send-email` | `stock_purchases.retry_email` | ✅ |
| Receive SP (book into stock) | warehouse | `POST /stock-purchases/[id]/receive` | `stock_purchases.receive` | ✅ |
| Match & receive from invoice | scan workflow | n8n webhook → `/stock-purchases/match-from-invoice` | `stock_purchases.match_and_receive_from_invoice` | ✅ (Phase H internal) |
| Add attachment | detail page | `POST /stock-purchases/[id]/attachments/upload` | `stock_purchases.add_attachment` | ✅ |
| Get attachment | preview | `GET /stock-purchases/[id]/attachments/[filename]` | ❌ | MCP gap |
| Stock-purchase list PDF | reports | `GET /stock-purchases/list-pdf` | ❌ | MCP gap |
| Picking slip PDF | warehouse | `GET /stock-purchases/picking-slip` | ❌ | MCP gap |
| SP PDF | detail page | `GET /stock-purchases/[id]/pdf` | ❌ | MCP gap (3 PDF routes un-wrapped) |
| Stock-purchase items: list | `/stock-orders/[id]` | `GET /stock-purchase-items` | `stock_purchase_items.list` | ✅ |
| Stock-purchase items: update | inline | `PATCH /stock-purchase-items/[id]` | `stock_purchase_items.update` | ✅ |
| Stock-purchase items: bulk update | inline | `PATCH /stock-purchase-items/bulk` | `stock_purchase_items.bulk_update` | ✅ |
| Stock-purchase items: per-field (qty/sku/cost) | inline | `PATCH /stock-purchase-items/[id]/{qty,sku,cost}` | 🟡 | 3 routes for popover per-field widgets, no MCP — see §3.4 |
| Stock-purchase items: assign driver | inline | `POST /order-items/assign-driver` (uses same path) | `stock_purchase_items.assign_driver` | ✅ |
| Stock-purchase items: mark collected | warehouse | n/a (via confirm) | `stock_purchase_items.mark_collected` | ✅ |
| Next SP number | form | `GET /stock-purchases/next-number` | `stock_purchases.next_number` | ✅ |
| Send SP (shorthand) | `POST /stock-purchases/send` | 🟡 | REST yes, MCP gap |
| Stocktake PDF | reports | `GET /stock-items/stocktake-pdf` | ❌ | MCP gap |
| Reorder list | `/stock-orders/reorder` | (server action) | ❌ | MCP gap |
Result: 30 capabilities, 21 fully covered, **9 partial/missing**.
### Stock quick-draft
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| Search catalogue | inline | `GET /stock-quick-draft/search` | `stock_quick_draft.search` | ✅ (Phase E) |
| Add line to draft | inline | `POST /stock-quick-draft/add-line` | `stock_quick_draft.add_line` | ✅ (Phase E) |
| List suppliers | inline | `GET /stock-quick-draft/suppliers` | `stock_quick_draft.suppliers` | ✅ (Phase E) |
| List drafts | inline | `GET /stock-quick-draft/drafts` | ❌ | MCP gap |
| Edit/delete line | inline | `PATCH /stock-quick-draft/line/[lineId]` | ❌ | MCP gap |
| Supplier history | inline | `GET /stock-quick-draft/supplier-history` | ❌ | MCP gap |
Result: 6 capabilities, 3 covered. **3 MCP gaps.**
### Pricelist / catalogue
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List raw uploads | `/pricelist` Raw tab | `GET /pricelist/raw` (via `pb-helpers`) | ❌ | MCP gap — see §1.13 |
| Delete raw upload | row | `DELETE /pricelist/raw/[id]` | ❌ | MCP gap |
| Search catalogue | Catalogue tab | `GET /price-list` (via `pb-helpers`) | ❌ | MCP gap |
| Get price-list entry | inline | `GET /price-list/[sku]` | ❌ | MCP gap |
| Add items (bulk ingest) | AI flow | `POST /price-list/add-items` | `price_list.add_items` | ✅ |
| Create raw upload | upload widget | (server action) | ❌ | MCP gap |
| Resync from raw upload | row | (server action) | ❌ | MCP gap |
Result: 7 capabilities, 1 covered. **6 MCP gaps** — almost the entire catalogue is agent-blind.
### Companies / Customers / Suppliers (master data)
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List companies | `/companies` | `GET /companies` | `companies.list` | ✅ |
| Get single | `/companies/[id]` | `GET /companies/[id]` | `companies.get` | ✅ |
| Create | form | `POST /companies` | `companies.create` | ✅ |
| Update | form | `PATCH /companies/[id]` | `companies.update` | ✅ |
| Delete | confirm | `DELETE /companies/[id]` | `companies.delete` | ✅ |
| Find duplicates | QA | `GET /companies/duplicates` (via pb-helpers) | ❌ | MCP gap |
| List customers | `/customers` | `GET /customers/list` + `/customers` | `customers.list` | ✅ (Phase G added `rep` filter) |
| Get single | `/customers/[id]` | `GET /customers/[id]` | `customers.get` | ✅ |
| Create | form | `POST /customers` | `customers.create` | ✅ |
| Update | form | `PATCH /customers/[id]` | `customers.update` | ✅ |
| Delete | confirm | `DELETE /customers/[id]` | `customers.delete` | ✅ |
| Find duplicates | QA | (via pb-helpers) | ❌ | MCP gap |
| List suppliers | `/suppliers` | `GET /suppliers` | `suppliers.list` | ✅ |
| Get single | `/suppliers/[id]` | `GET /suppliers/[id]` | `suppliers.get` | ✅ |
| Create | form | `POST /suppliers` | `suppliers.create` | ✅ |
| Update | form | `PATCH /suppliers/[id]` | `suppliers.update` | ✅ |
| Delete | confirm | `DELETE /suppliers/[id]` | `suppliers.delete` | ✅ |
| Find duplicates | QA | (via pb-helpers) | ❌ | MCP gap |
| Geocode (read + write) | supplier row | (none) | ❌ | FEATURE-REQUESTS.md (now) |
| Mappable suppliers (for map) | `/supplier-map` | `GET /suppliers/mappable` (via pb-helpers) | ❌ | MCP gap |
Result: 21 capabilities, 15 covered. **6 MCP gaps** (duplicates for all 3; mappable; geocoding).
### Categories
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List categories | (admin) | ❌ (server-component read) | ❌ | Both gaps |
| CRUD | (admin) | ❌ | ❌ | Whole admin domain missing — see §1.14 |
Result: 2 capabilities, 0 covered. **Whole categories domain missing.**
### Dashboard & KPIs
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| Quotes dashboard data | `/dashboard` | `GET /dashboard` | ❌ | MCP gap |
| Orders dashboard data | `/dashboard` | (combined) | ❌ | MCP gap |
| Boss dashboard (today/week/month) | `/m` | `GET /boss-dashboard` | ❌ | MCP gap |
| Boss dashboard rep activity | `/m` | `GET /boss-dashboard/rep-activity` | ❌ | MCP gap |
| Daily summary (single call) | reports | `GET /orders/daily_summary` | `orders.daily_summary` | ✅ |
| Dashboard daily summary | reports | ❌ (no separate route — `dashboard.daily_summary` MCP reads PB directly) | `dashboard.daily_summary` | ✅ (Phase E) |
| Health check | n8n | `GET /health` | ❌ | MCP gap |
Result: 7 capabilities, 2 covered. **5 MCP gaps.**
### Settings
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| Get AI settings | `/settings/ai` | `GET /ai/settings` | `ai_settings.get` | ✅ |
| Update AI settings | `/settings/ai` form | `POST /ai/settings` (via Server Action) | `ai_settings.update` | ✅ |
| Rerun AI insights (per collection) | `/settings/ai` | `POST /ai-insights/rerun` | `ai_insights.rerun` | ✅ |
| List AI insights | `/settings/ai` | `GET /ai-insights` | ❌ | MCP gap |
| Theme picker | `/settings` | client-side | n/a | N/A |
| Dev identity switcher | `/settings` | client-side | n/a | N/A |
| TV session | `/tv/pin`, `/tv/board` | `/api/tv/login`, `/api/tv/logout` | ❌ | MCP gap (separate auth surface) |
| User lookup | `/settings` dev-mode | `GET /users/lookup` | `users.lookup` | ✅ |
| List users | (admin) | `GET /users` | ❌ | MCP gap |
Result: 9 capabilities, 4 covered. **5 gaps** (3 MCP, 2 non-MCP surfaces).
### Buyer tasks
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| List buyer tasks | `/orders/tasks` | `GET /buyer-tasks` | `buyer_tasks.list` | ✅ |
| Generate PO | inline | `POST /buyer-tasks/generate-po` | `buyer_tasks.generate_po` | ✅ |
| Mark ready | inline | `POST /buyer-tasks/mark-ready` | `buyer_tasks.mark_ready` | ✅ |
Result: 3 capabilities, 3 covered. **0 gaps.**
### Files & attachments proxy
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| Stream PB file | inline | `GET /files/[collection]/[recordId]/[filename]` | ❌ | MCP gap (see §1.15) |
| Fetch as base64 | inline | ❌ (no base64 proxy) | `files.fetch` | ✅ (Phase E) — but only base64 |
Result: 2 capabilities, 1 covered. **1 MCP gap** (byte-stream equivalent).
### Invoicing (header)
| Capability | Page | `/api/v1` route | MCP tool | Notes |
|---|---|---|---|---|
| Stock book from invoice | accountant button | `POST /invoicing/stock-book` | ❌ | MCP gap (see §1.16) |
| QB invoice (build + send) | invoicing modal | n8n webhook (not in /api/v1) | ❌ | Architectural gap — see §1.17 |
Result: 2 capabilities, 0 covered via MCP. **2 MCP gaps.**
---
## Gap analysis (prioritized)
Gaps are scored by:
- **Severity** (now = blocks daily agent/operator work; nice-to-have = polish)
- **Effort** (small = ≤1 PR with no schema change; medium = 1-3 PRs; large = schema + migration + docs)
- **Block count** (how many downstream agent workflows depend on it)
### 1. Critical MCP gaps (now, blocks daily agent workflows)
#### 1.1 `quotes.review_approve` + `quotes.archive` MCP missing
- **Severity:** now
- **Effort:** small (route handlers exist, just thin wrappers)
- **REST surface:** `POST /api/v1/quotes/[id]/review-approve`, `POST /api/v1/quotes/archive`
- **Agent impact:** the approval queue (`/quotes/approvals`) and archive view are completely agent-invisible. Hermes can list but cannot act on approval decisions.
- **Recommended slice:** add two MCP wrappers, no schema changes.
#### 1.2 `orders.lookup` MCP missing
- **Severity:** now
- **Effort:** small
- **REST surface:** `GET /api/v1/orders/lookup?number=...`
- **Agent impact:** the agent has `find_by_order_number` (Phase E) but no generic lookup that takes either id or number. Duplication by design.
- **Recommended slice:** either alias `find_by_order_number` to `lookup` or add a thin `orders.lookup({id|number})`.
#### 1.3 `order_items.split` MCP missing
- **Severity:** now
- **Effort:** small
- **REST surface:** `POST /api/v1/order-items/[id]/split`
- **Agent impact:** splitting an order line is a daily buyer workflow ("customer only wants 5 of the 10 we ordered"). The agent can do everything around the split but not the split itself.
- **Recommended slice:** thin wrapper, no schema changes.
#### 1.4 `quotes.append_reply` routed via the wrong endpoint
- **Severity:** now (deceptive)
- **Effort:** small
- **Issue:** MCP tool `quotes.append_reply` works, but the underlying REST is `POST /quotes` with an `action: "append_reply"` discriminator — not the natural `POST /quotes/append-reply`. The MCP wraps it, so Hermes doesn't see this, but anyone hand-calling REST hits a 404.
- **Recommended slice:** add `POST /api/v1/quotes/append-reply` as the canonical route, keep the legacy action as a back-compat alias.
#### 1.5 `collection_map.assign` + bulk assign MCP missing
- **Severity:** now
- **Effort:** small
- **REST surface:** `POST /api/v1/collection-map/items/[id]/assign`; bulk-assign is a server action on the pool page
- **Agent impact:** driver dispatch from the map view is invisible to the agent. Daily workflow for the office team.
- **Recommended slice:** add `collection_map.assign({order_item_id, driver_id})` and `collection_map.bulk_assign({supplier_id, driver_id, order_item_ids[]})`.
#### 1.6 `/deliveries` MCP domain entirely missing
- **Severity:** now (whole domain)
- **Effort:** medium (5 routes + 5 tools)
- **Routes:** `/api/v1/deliveries`, `/load`, `/load-dn`, `/check-in`, `/mark-signed-copy`, plus the lookup helper
- **Agent impact:** the operator's `/deliveries` board (to_load → back_from_driver → checked_in) is the daily delivery flow; the agent can see orders but cannot act on the delivery lifecycle.
- **Recommended slice:** add `deliveries.list({tab})`, `deliveries.load({item_ids[]})`, `deliveries.load_dn({delivery_note_id})`, `deliveries.check_in({item_ids[], delivery_reference})`, `deliveries.mark_signed_copy({item_ids[]})`. These wrap the existing REST routes.
#### 1.7 `order_items.intel` + `quote_items.intel` MCP missing
- **Severity:** now
- **Effort:** small (pure read wrappers)
- **Routes:** `GET /order-items/[id]/intel`, `GET /quote_items/[id]/intel`, `POST /items/[collection]/[id]/intel/refresh`
- **Agent impact:** the agent can't read or refresh AI intel on a specific item, forcing it to scan the full PB collection via the admin SDK (slow + PII risk).
- **Recommended slice:** add `order_items.intel({id})`, `quote_items.intel({id})`, `items.intel_refresh({collection, id})`.
#### 1.8 `repairs.*` MCP domain entirely missing
- **Severity:** now (whole domain)
- **Effort:** medium
- **Routes:** `/repairs` GET+POST, `/repairs/[id]` GET+PATCH+DELETE, `/repairs/[id]/files/[field]`, `/repairs/[id]/generate-delivery-note`, plus the form-data helper
- **Agent impact:** repair jobs are completely agent-invisible. The owner asked "anything in the app" → this is one of the seven operator-managed domains the agent cannot touch.
- **Recommended slice:** add `repairs.list`, `repairs.get`, `repairs.create`, `repairs.update`, `repairs.delete`, `repairs.upload_file`, `repairs.generate_delivery_note`.
#### 1.9 `delivery_notes.get` MCP missing
- **Severity:** nice-to-have (most reads go via list)
- **Effort:** small
- **REST surface:** no `/delivery-notes/[id]` GET route exists; the agent reads the row directly from PB.
- **Recommended slice:** add `GET /api/v1/delivery-notes/[id]` + `delivery_notes.get` MCP wrapper. Consistent with `quotes.get` / `orders.get`.
#### 1.10 `delivery_notes.pdf` MCP + REST missing
- **Severity:** now (preview breaks for agent-rendered DNs)
- **Effort:** small (mirror the quote PDF route pattern)
- **Recommended slice:** add `GET /api/v1/delivery-notes/[id]/pdf` + `delivery_notes.get_pdf` MCP tool.
#### 1.11 Customer PO creation has no MCP wrapper
- **Severity:** now
- **Effort:** small (route exists; missing wrapper)
- **Issue:** the `orders.create` tool creates orders, but **creating a customer purchase order** (the supplier-facing doc, distinct from the stock `stock_purchases` flow) is done via a server action on `/orders/[id]` and is not exposed via MCP.
- **REST surface:** no dedicated `/api/v1/purchase-orders` POST; the server action lives in the order detail component.
- **Recommended slice:** extract the PO creation into `POST /api/v1/purchase-orders` + `purchase_orders.create` MCP tool. Migrate the server action to call it.
#### 1.12 Three PO PDFs un-wrapped (customer + stock + stocktake)
- **Severity:** now (agent can't preview PO PDFs)
- **Effort:** small (3 routes already exist)
- **Routes:** `GET /purchase-orders/[id]/pdf`, `GET /stock-purchases/[id]/pdf`, `GET /stock-items/stocktake-pdf`, `GET /stock-purchases/list-pdf`, `GET /stock-purchases/picking-slip`
- **Recommended slice:** add `purchase_orders.get_pdf`, `stock_purchases.get_pdf`, `stock_items.stocktake_pdf`, `stock_purchases.list_pdf`, `stock_purchases.picking_slip` MCP tools.
#### 1.13 `pricelist` / `raw_price_list` MCP domain entirely missing
- **Severity:** now (catalogue is the price-discovery backbone)
- **Effort:** medium
- **Routes needed:** `/pricelist/raw` GET+DELETE, `/pricelist/search`, `/pricelist/[sku]`, plus the upload/resync server actions need REST mirrors
- **Agent impact:** the agent can add items to the price list (`price_list.add_items`) but cannot search or read entries. Sprint automation depends on the price list search.
- **Recommended slice:** add `pricelist.search({q, sku, brand, category, supplier})`, `pricelist.get({sku})`, `pricelist.raw_list()`, `pricelist.raw_delete({id})`, `pricelist.raw_create(...)`, `pricelist.raw_resync({id})`.
#### 1.14 `categories` MCP domain entirely missing
- **Severity:** nice-to-have
- **Effort:** small
- **Routes needed:** `/categories` GET+POST, `/categories/[id]` GET+PATCH+DELETE
- **Agent impact:** the sprint auto-bucketing rule sets (`match_brands`, `match_supplier_tags`, `match_keywords`) live on categories. The agent can read items but cannot inspect or tune the routing rules.
- **Recommended slice:** add the 5 MCP wrappers.
#### 1.15 `files.stream` MCP missing (byte-stream equivalent)
- **Severity:** nice-to-have
- **Effort:** small
- **Issue:** `files.fetch` returns base64; large files (10 MB+ pricelist PDFs, signed delivery copies) bloat the MCP payload. Hermes wants byte-stream download.
- **Recommended slice:** add a `files.stream({collection, recordId, filename})` that returns `{url, mime, expiresAt}` so the agent can `curl` directly.
#### 1.16 `invoicing.stock_book` MCP missing
- **Severity:** nice-to-have
- **Effort:** small
- **Route:** `POST /api/v1/invoicing/stock-book`
- **Agent impact:** accountant's "Book into stock" button is REST-only. The buyer agent can't run the workflow end-to-end.
- **Recommended slice:** add `invoicing.stock_book({id, date?})` MCP wrapper.
#### 1.17 QuickBooks invoice payload has no MCP entry point
- **Severity:** now (invoicing is a daily operator workflow)
- **Effort:** large (architectural — the QB flow runs via n8n webhook, not /api/v1)
- **Issue:** the QB invoice is built and POSTed to n8n from the invoicing modal — there's no `/api/v1` route and no MCP tool. The agent can't drive the invoicing end-to-end.
- **Recommended slice:** add `/api/v1/invoicing/qb-invoice` (POST) + `invoicing.qb_invoice` MCP. Backend service: `qb-invoice-service.ts` mirroring the structure of `stock-purchase-invoice-matching-service.ts`.
### 2. MCP gaps per route (full list)
A few useful one-liner additions (severity: nice-to-have unless flagged otherwise):
- `purchase_orders.dispatch` — REST-only; MCP wrapper.
- `purchase_orders.get_attachment` — REST-only; MCP wrapper (use `files.fetch` as the underlying primitive).
- `stock_purchase_items.{qty,sku,cost}` per-field — REST-only; MCP wrappers.
- `order_items.{qty,sku,cost,status,context}` per-field — REST-only; MCP wrappers.
- `order_items.request_margin_approval` — REST-only; MCP wrapper.
- `order_items.split` — see §1.3.
- `quote_items.item_history` — REST-only; MCP wrapper.
- `quote_items.lookup` / `quote_items.match` — REST-only; MCP wrappers (sprint match picker).
- `quotes.items/rewrite` — REST-only; MCP wrapper (internal AI flow, low priority).
- `driver.list` — REST-only; MCP wrapper.
- `collection_confirm.confirm_stock` — REST-only; MCP wrapper.
- `delivery_notes.signed_copy` — REST-only; MCP wrapper.
- `delivery_notes.combine` — REST-only; MCP wrapper.
- `stock_purchases.{send, get_attachment, list_pdf, picking_slip}` — REST-only; MCP wrappers.
- `stock_purchases.drafts.{list, edit_line, supplier_history}` — REST-only; MCP wrappers.
- `companies.{find_duplicates, mappable}` — REST-only; MCP wrappers.
- `customers.find_duplicates` — REST-only; MCP wrapper.
- `suppliers.{find_duplicates, geocode, mappable}` — REST-only; MCP wrappers (geocode already in FEATURE-REQUESTS.md).
- `users.list` — REST-only; MCP wrapper.
- `ai_insights.list` — REST-only; MCP wrapper.
- `health.check` — REST-only; MCP wrapper (cheap operational health probe).
- `repair.*` — see §1.8.
- `category.*` — see §1.14.
- `pricelist.*` / `raw_price_list.*` — see §1.13.
- `boss_dashboard.{get, rep_activity}` — REST-only; MCP wrappers.
### 3. MCP refactor recommendations (the 7,077-line `route.ts`)
The MCP route handler is the single biggest maintainability bottleneck in the agent layer. The approved `docs/audit/backend-data.md:355-383` calls for splitting each tool into `src/lib/server/mcp-tools/<name>.ts`. Building on that recommendation:
#### 3.1 Per-tool file split
```
src/lib/server/mcp-tools/
orders/
list.ts # inputSchema + handler
get.ts
create.ts
...
quotes/
...
```
Each file exports:
```ts
export const tool: McpTool = {
name: "orders.list",
description: "...",
inputSchema: { ... },
};
export async function handle(req: Request, input: unknown, ctx: McpContext): Promise<ToolResult> { ... }
```
The `tools` array in `route.ts` reduces to `Object.entries(MCP_TOOLS).flatMap(([name, mod]) => [mod.tool])`; dispatch is `MCP_TOOLS[toolName]?.handle(...)`.
#### 3.2 Zod schema sharing with REST routes
Today the MCP layer hand-writes `inputSchema` while REST routes use `zod`. They drift (the `quote_items.create` whitelist drift was caught in Phase F; same risk exists for `quote_items.update` vs `bulk_update_quote_items` per FEATURE-REQUESTS.md).
Recommend: each MCP tool exports both `inputSchema` (JSON Schema for the MCP wire) AND a `zodSchema` for runtime validation. Use `zod-to-json-schema` to derive the JSON Schema from the zod schema. One source of truth per write operation.
#### 3.3 Service-layer dispatch table
Cross-reference: the FEATURE-REQUESTS "Standard error envelope with `hint`/`next_actions`/`allowed_tools`" entry. The 13 write tools migrated in Phase I carry a `TOOL_HINTS` map at `route.ts:266`; the remaining 33 `fetch + if (!res.ok)` blocks need the same migration. Per-tool file split makes this mechanical.
#### 3.4 Per-field widget route proliferation
`/api/v1/order-items/[id]/{qty,sku,cost,status,context}` and `/api/v1/stock-purchase-items/[id]/{qty,sku,cost}` exist as 4+3 separate PATCH routes because the operator popover posts one field at a time. This works but is awkward for an agent (which would batch). Recommend either:
- Document the existing `PATCH /order-items/[id]` (whitelist already exists) as the canonical "batch fields" endpoint for the agent; keep the per-field routes for the popover; OR
- Generate the per-field routes from a shared schema (e.g. `const PER_FIELD_ROUTES = { qty: { method: "PATCH", path: "/order-items/[id]/qty" }, ... }`).
#### 3.5 MCP tool consolidation for sprint attachments
`sprint.item_attachments` is one tool that takes either a GET or POST shape depending on context. Split into `sprint.get_attachments({id})` + `sprint.upload_attachment({id, file})` for clarity.
### 4. Architectural observations
#### 4.1 Customer PO vs stock PO model drift
The MCP separates `purchase_orders.*` (customer-side) from `stock_purchases.*` (warehouse-side). The duplicate-rest surface is fine for queries but the agent often wants a "PO" abstraction (the source PDF, the supplier reply, the lines, the email status). Consider a `purchase_orders.unified` MCP tool that returns both sides under a discriminated union — or just clearer cross-tool recipes in `docs/agents/workflows.md`.
#### 4.2 Server Actions ↔ REST surface split
Only one page (`/settings/ai`) currently uses Next.js Server Actions directly. The AGENTS.md `data-layer-discipline` rule says new endpoints belong in `/api/v1/*`. The pattern is healthy; the existing inventory confirms it's not being abused. No action needed.
#### 4.3 Auth model split
Three auth surfaces are in play:
- `/api/v1/*` — `assertAgentAuth` (bearer `AGENT_API_KEY`) OR `assertBrowserSession` (PB cookie)
- `/api/mcp` — JSON-RPC, bearer `AGENT_API_KEY`, CORS `*`
- Server Actions — PB cookie session via `(app)` layout
The audit (`docs/audit/security-perf-prod.md` Major #2) flags the bare `AGENT_API_KEY` as too weak + `/api/mcp` CORS `*`. When those are remediated, the MCP auth boundary should mirror the new contract.
#### 4.4 Streaming response types
`files.fetch` returns base64. PDF routes stream bytes. Delivery note PDF, stocktake PDF, picking slip, and PO PDF stream bytes via `NextResponse`. If the MCP layer is to drive document previews end-to-end, it needs a `files.stream` (see §1.15) that wraps the streaming routes.
#### 4.5 Invoicing flow architecture
The QuickBooks invoice pipeline runs through n8n, not `/api/v1`. Adding MCP coverage requires both a REST endpoint (`/api/v1/invoicing/qb-invoice`) and a service layer (`qb-invoice-service.ts`) that wraps the n8n webhook contract. This is the only domain where the agent has zero programmatic access to a daily operator workflow. Highest architectural priority.
## Recommended implementation slices
Slice these as separate tasks for the team-lead to assign. Each slice is sized to a single PR and ships behind a feature flag if desired.
| # | Slice | Severity | Effort | Tools added |
|---|---|---|---|---|
| S1 | Quote approval + archive MCP | now | small | 2 |
| S2 | Order lookup MCP + item split MCP | now | small | 2 |
| S3 | Collection map + bulk assign MCP | now | small | 2 |
| S4 | `/deliveries` MCP domain | now | medium | 5 |
| S5 | `order_items.intel` + `quote_items.intel` MCP | now | small | 3 |
| S6 | `repairs.*` MCP domain | now | medium | 7 |
| S7 | `delivery_notes.{get, pdf, signed_copy}` MCP | now | small | 3 |
| S8 | Customer PO creation MCP + 5 PDF MCP wrappers | now | medium | 6 |
| S9 | `pricelist.*` + `raw_price_list.*` MCP | now | medium | 7 |
| S10 | QuickBooks invoice MCP + service layer | now | large | 1 |
| S11 | MCP `route.ts` per-tool file split | major | large | refactor |
| S12 | Zod-schema sharing (MCP ↔ REST) | major | medium | refactor |
| S13 | Migrate remaining 33 `fetch + if (!res.ok)` blocks to `fetchUpstreamJson` | major | medium | refactor |
| S14 | `categories.*` MCP | nice | small | 5 |
| S15 | `boss_dashboard.*` MCP + `dashboard.quotes`/`dashboard.orders` | nice | small | 4 |
| S16 | `files.stream` MCP | nice | small | 1 |
| S17 | Long-tail single-route MCP wrappers (§2 list) | nice | medium | ~30 |
| S18 | Per-field widget route cleanup (§3.4) | nice | small | refactor |
| S19 | `stock_quick_draft.{drafts, edit_line, supplier_history}` MCP | nice | small | 3 |
| S20 | `health.check` MCP + `ai_insights.list` MCP + `users.list` MCP | nice | small | 3 |
Total new tools if every slice ships: ~85 (S11/S12/S13/S18 are refactors, not additions). Brings the catalogue to ~225 tools.
## Cross-references to existing FEATURE-REQUESTS.md
This analysis **does not duplicate** the entries already tracked in `src/app/api/mcp/FEATURE-REQUESTS.md`. Open entries that this doc references but doesn't re-list:
- "Order items: clarify received" (Phase H partial, still open)
- "Order items: detect items missing selling_price" (Phase H partial)
- "Stock purchases: paperclip badge" (UI-only, now)
- "Drivers: no MCP visibility or assignment" (Phase E partial — list_stops + update_stop_status shipped; `drivers.list` still missing, see §1.16)
- "Suppliers: read/update works, geocoding does not" (still open)
- "Stock purchases: PDF → stock order ingestion" (still open)
- "Stock purchases: paperclip badge on list" (UI-only)
- "Standard error envelope with hint / next_actions / allowed_tools" (Phase I partial — 13 of 46 tools migrated; see §3.3)
- "Quotes: trim/normalise customer + company names on quotes.create" (still open)
- "Quotes: pre-send validation errors vs SMTP failures don't share an envelope" (Phase F partial — `no_pdf` diagnostics shipped)
- "Orders: incoming email recipient resolution" (still open)
- "Customers: case-insensitive email match" (still open)
- "quotes.send_email.attach_auth_letters per-letterhead semantics undocumented" (still open)
- "quote_items.create vs quote_items.update whitelist drift" (Phase F.2 partial — still see §3.2)
- "Hermes MCP origin config" (doc-only, still open)
- "Bulk_update_quote_items schema" (related to §3.2)
New entries to append (these came out of this audit):
- **§1.4: Quotes append-reply REST is on the wrong route** — add `POST /api/v1/quotes/append-reply`, keep back-compat alias.
- **§1.8: Repairs MCP domain entirely missing** — 7 tools + 1 service layer extension.
- **§1.13: Pricelist / raw_price_list MCP domain entirely missing** — 7 tools + REST routes.
- **§1.14: Categories MCP domain entirely missing** — 5 tools + REST routes.
- **§1.17: QuickBooks invoice MCP + service layer missing** — architectural; needs `qb-invoice-service.ts`.
## Open questions for the team-lead
1. **Scope of slice S10 (QuickBooks invoice MCP):** is the goal to mirror the n8n webhook in /api/v1 and call n8n from there, or to call the QB API directly? The current architecture routes through n8n for retries + audit; a direct QB integration would lose those.
2. **Scope of slice S11 (MCP route split):** is per-tool the target, or per-domain-folder? Per-tool gives 142 files; per-domain gives ~30 files each with 1-many tools. The approved backend-data.md audit calls for per-tool; per-domain is the practical mid-point.
3. **Auth: does the new MCP auth model (currently being designed for the security-perf-prod remediation) require an explicit `allowed_tools` ACL per agent?** If yes, that changes the per-tool file shape (each tool needs an `allowedRoles` manifest).
4. **Pricing model for the MCP "anything in the app" promise:** today the MCP is bearer-key-only. If the user wants the agent to drive every operator workflow, should the MCP accept the same PB cookie session as the UI? That would let `/api/mcp` proxy operator context for audit, instead of `AGENT_API_KEY` (which has no operator identity).

View file

@ -1,783 +0,0 @@
# Backend / API / Data Layer Audit
**Scope:** `src/lib/pocketbase.ts`, `src/lib/pb-helpers.ts` (legacy),
`src/lib/server/*-service.ts` (current layer), `src/app/api/v1/*` route
handlers, `/api/mcp`, and the route/service contract between them.
**Date:** 2026-08-28
**Author:** bob (audit only — no code changes shipped)
This audit is the data-layer companion to `security-perf-prod.md`
(tom, 2026-08-28). It deliberately does not re-cover the auth/secrets
surface — see that doc for the critical AGENT_API_KEY / PB admin / CORS
issues. Here we focus on:
- legacy `pb-helpers.ts` vs current `src/lib/server/*.service.ts` drift
- PB filter / query foot-guns (N+1, getFirstListItem, IN-vs-OR)
- schema-missing fallback pattern and select-field pitfalls
- input validation, error envelope, and route-handler boilerplate
- multi-write consistency (no transactions, partial-success semantics)
- service-layer API inconsistencies (admin PB vs cookie PB)
---
## 0. Headline summary
| Severity | Count | What it is |
| -------- | ----- | ---------- |
| Critical | 3 | Legacy `pb-helpers.ts` used directly by 32 files; schema-field gaps invisible to the type system; `pb-helpers.ts` PO/quote/order number generators are racy and were deprecated but still callable |
| Major | 12 | No schema-validation library; 30+ `getFirstListItem` calls with inconsistent error handling; N+1 patterns in several services; `getAdminPocketBase` everywhere bypasses PB listRules; 7077-line MCP route file; schema-missing fallback duplicated 4× with subtle behavioural drift; cookie-auth PB singleton used by Server Components |
| Minor | 10 | Inconsistent error envelopes; ad-hoc PO/quote-status string-literal duplication; search hard-caps hardcoded; legacy client-side duplicate-detection post-filter; no structured logging; service-layer returns vary between throwing and returning partial-success |
The legacy layer (`pb-helpers.ts`) is the single biggest source of
drift. AGENTS.md says "do NOT extend `pb-helpers.ts`", and the service
layer is the right place for new endpoints — but 32 callers still hit
it directly, and the data shape they consume is subtly different from
what the service layer emits. Migrating page-by-page is safe; the
hazard is doing it inconsistently.
---
## 1. Critical findings
### 1.1 `pb-helpers.ts` PO/quote/order number generators are racy, still exported
**File:** `src/lib/pb-helpers.ts:1755-1772` (`generateQuoteNumber`)
**File:** `src/lib/pb-helpers.ts:1773-1796` (`generateOrderNumber`)
**File:** `src/lib/pb-helpers.ts:1799-1864` (`generatePONumber`)
All three functions use the read-then-write pattern:
```ts
const existing = await pb.collection("orders").getList<Order>(1, 1, {
filter: `order_number ~ "${prefix}"`,
sort: "-order_number",
});
let seq = ... + 1; // <- race window
return `ORD-${...}-${seq}`;
```
Two simultaneous calls both observe `007` and both write `008`. The
PB-side atomic counter collection (`order_counters`, `quote_counters`)
already exists and is used by `orders-service.ts:1302-1325` and
`quotes-service.ts:879-909`. AGENTS.md explicitly deprecates these
helpers, but they're still exported and reachable:
- `generateQuoteNumber` — `@deprecated` comment at line 1748, but
nothing prevents a new caller from importing it.
- `generateOrderNumber` — no deprecation marker; no callers found
via grep on the codebase, but still public.
- `generatePONumber` — inline comment at line 1745 calls it a
"client-side mirror" of `src/lib/server/po-number.ts`. Both exist;
the client-side one is racy.
**Recommended fix:** delete `generateQuoteNumber`, `generateOrderNumber`,
and `generatePONumber` from `pb-helpers.ts` (or move to a `legacy`
sub-export with a build-time ban) and add a CI lint rule that fails
if any non-`pb-helpers.ts` file imports them. Server-side callers
already use the atomic variants in `orders-service.ts` /
`quotes-service.ts` / `po-number.ts`.
### 1.2 Legacy `pb-helpers.ts` is the data layer for 32 files, drift risk
**File:** `src/lib/pb-helpers.ts:1-1994`
**File:** 32 files import from `@/lib/pb-helpers` (verified
2026-08-28 via `grep -rl 'from "@/lib/pb-helpers"' src/ | wc -l`)
AGENTS.md says new code goes through `src/lib/server/*.service.ts`.
The legacy helpers are still imported by Server Components,
Client Components, and form components directly. The two layers read
the same PB collections but emit subtly different shapes:
| Helper | Service-layer equivalent |
| ------ | ------------------------ |
| `getCustomer` / `getAllCustomers` (`pb-helpers.ts:32-46`) | `customers-service.ts` |
| `getCompanies` / `getCompany` / `getAllCompanies` (`pb-helpers.ts:48-67`) | not yet migrated |
| `getSuppliers` / `getSupplier` / `getAllSuppliers` (`pb-helpers.ts:69-87`) | not yet migrated |
| `getQuotes` (`pb-helpers.ts:213-247`) | `quotes-service.ts` |
| `getOrders` / `getOrder` / `getOrdersWithItems` (`pb-helpers.ts:617-635`, `:729-790`) | `orders-service.ts` |
| `createOrderFromQuote` (`pb-helpers.ts:1231...`) | `orders-service.createOrderFromQuote` |
| `getDeliveryNotes` (`pb-helpers.ts:1412-1420`) | `delivery-notes-service.ts` |
The risk is real and visible: `getQuotes` in `pb-helpers.ts:213` has
the `deliveryNoteOnly` option for the Quotes page delivery-note tab,
while `quotes-service.ts` re-implements the same query with a
different filter order. A page that switches from the helper to the
service changes the wire payload, which changes the page's render.
**Recommended fix:** per AGENTS.md, migrate page-by-page. The
highest-risk migrations first (orders, quotes) because they emit
shapes that already drift from the service. Add a `console.warn`
to the legacy helpers that fires once per session if `NODE_ENV !==
'test'` and the call comes from a Server Component — the breadcrumb
will surface un-migrated callers in the browser console.
### 1.3 `pocketbase.ts` browser singleton auth pattern doesn't translate to Server Components
**File:** `src/lib/pocketbase.ts:9-45`
```ts
let pb: PocketBase | null = null;
export function getPocketBase(): PocketBase {
if (typeof window === "undefined") {
// Server-side: create new instance each time
return new PocketBase(POCKETBASE_URL);
}
// Client-side: reuse singleton — load auth from localStorage
...
}
```
Two problems:
1. The server-side path always creates an unauthenticated instance.
Server Components that import `getPocketBase` make PB calls with
the `_superusers` collection bypass or no auth at all, depending
on PB rule configuration. `getPocketBase` itself can't impersonate
the user — there's no cookie / header to read on the server. See
`dev-identity.ts` for the project's solution (a cookie called
`wfdash_dev_email` that holds the user's email as the dev
placeholder).
2. The client-side singleton uses `localStorage` for the auth store
(`pocketbase.ts:23-30`). This is fine for browser use, but it
couples the data layer to the browser. Any attempt to share a
helper between Server and Client Components requires a wrapper
(the service layer does this via `getAdminPocketBase()`).
**Recommended fix:** make `getPocketBase` server-side UNSAFE (it
should throw if `typeof window === "undefined"`) and force all
server-side callers through `getAdminPocketBase`. Document the rule
in AGENTS.md. Today, server components using `getCustomer(id)` from
`pb-helpers.ts:32` are silently making unauthenticated PB calls —
PB's per-collection `listRule`/`viewRule` triages this, but a future
rule relaxation will silently expose data.
---
## 2. Major findings
### 2.1 No schema-validation library (no zod / yup / valibot / joi / ajv)
**File:** every `src/app/api/v1/**/route.ts` that calls `req.json()` (57 routes)
Input validation is entirely ad-hoc. Each route hand-rolls its
required-field checks:
```ts
// customers/route.ts:67-69
if (!body?.customer) {
return NextResponse.json({ error: "customer is required" }, { status: 400 });
}
```
Verification: `grep -i 'zod\|yup\|valibot\|joi\|ajv' package.json`
returns 0 hits. The cost is real:
- **No type safety for body shapes** — TypeScript sees the body as
`any` after `await req.json()`. The next line that reads
`body.customer` will compile even if `customer` is missing.
- **Inconsistent error messages** — some routes say
`"item_description is required."`, others say
`"customer is required"`, others say
`"Invalid JSON body"`. The MCP layer (`mcp/route.ts`) has a
bespoke `QUOTE_ITEM_PATCH_FIELDS` allowlist (line 161-176), and
the v1 layer has no equivalent on most endpoints.
- **No coercion** — string `"123"` and number `123` aren't
normalised. Many service-layer functions wrap with `Number(...)`
defensively (`pb-helpers.ts:1255-1262`), but a route handler that
passes `body.cost_price` straight into a `pb.update(...)` will
write a string into a number field.
- **No max-size enforcement** — the only size cap is the
multipart file limit (25 MB on the `mcp.add_attachment` tool,
not on JSON bodies). A 50 MB JSON body is parsed in full before
the handler can 413.
**Recommended fix:** add `zod` to package.json (small, no deps,
TS-native), write one `parseBody(req, schema)` helper that lives in
`src/lib/server/http-helpers.ts`, and convert routes incrementally.
The 7 highest-value routes first (write endpoints that mutate PB):
customers, suppliers, companies, orders, order_items, quotes,
quote_items. The MCP route already enforces its own per-tool schema
via JSON Schema (not zod), so leave it alone.
### 2.2 `getFirstListItem` throws on miss — 30+ inconsistent handlers
**File:** 31 call sites in `src/lib/server/` and `src/app/api/v1/`
PB's `getFirstListItem(filter)` throws a 404 if the filter matches
zero rows. Half the call sites defensively catch, half don't. Examples:
- `orders-service.ts:1404` — `getFirstListItem(`year=${year} && month=${month}`)` is inside a try, catches via fallback (line 1411).
- `orders-service.ts:2574` — `getFirstListItem<Order>(`order_number = "${esc}"`)` is inside its own try/catch, returns `null` on 404 (line 2580).
- `management-service.ts:247` — `getFirstListItem(`company = "${escaped}"`)` returns the result (or rejects promise); the caller at line 245 wraps it in `.catch(() => null)` — but the path between line 240 and 247 doesn't catch.
- `management-service.ts:232-236` — two `.catch(() => null)` chains on `users` lookups, OK.
A non-defensive call site bubbles a 500 to the route handler, which
returns a generic `{error: message}` with the PB 404 stack trace
included. This is loud in logs but confusing to the operator — they
typed a real order number and got "404 not found" wrapped as a
500-class error.
**Recommended fix:** wrap once at the service boundary with a
`firstOrNull<T>(collection, filter)` helper. Every service should
call `firstOrNull` and let the route handler decide whether "no rows
matched" is a 404, 422, or a `null` field in a successful response.
This is the same pattern `findOrderByOrderNumber` uses at
`orders-service.ts:2573-2588` — apply it everywhere.
### 2.3 `getAdminPocketBase` everywhere bypasses PB listRules / field-level perms
**File:** every service-layer file (43 import sites)
The admin PB instance has superuser rights — every listRule, viewRule,
and modifyRule is bypassed. This means:
- **Data classification doesn't apply.** A future "sensitive
customer" field added to `customers` with `viewRule: ""` (no
public read) would still be readable through every v1 route, the
MCP route, and the legacy helpers.
- **Field-level visibility doesn't apply.** Same shape: a
`salary_band` field on `users` with a restrictive viewRule would
be returned by every service that expands `users`.
- **Audit trail is lost.** Superuser actions log as
`_superusers` in PB's audit log, not as the operator who
actually made the call. The `getServerIdentity(req)` helper
(`dev-identity.ts:42-58`) is used to record the operator on
specific fields, but the PB audit row still says `_superusers`.
This is a known design decision, not a bug — the rationale (server
scripts, n8n, AI agents that can't carry the user's JWT) is documented
in `management-service.ts:1-25`. The risk is that the rationale
becomes invisible as more routes get added.
**Recommended fix:** add a comment to `pocketbase-admin.ts:62-72`
warning that the admin client bypasses ALL PB rules, and require a
JSDoc on every new service file that explains why admin is required.
For browser-facing reads (`customers/list/route.ts:30`), keep using
`assertBrowserSession` + the cookie PB (or migrate to the user-PB
instance). The `assertAgentAuth` / `assertBrowserSession` split is
correct as designed — the issue is when a *third* auth tier creeps
in (e.g. an `assertServiceAuth` for cron jobs) and the proof of
which tier a route uses gets buried in the route body.
### 2.4 N+1 patterns in service-layer loops
**File:** `src/lib/server/orders-service.ts:2174` (per-item patch loop inside standalone order create)
**File:** `src/lib/server/orders-service.ts:2503-2506` (`cancelOrder` per-item `item_status: "cancelled"` update loop)
**File:** `src/lib/server/quotes-service.ts` (multiple per-item update loops)
**File:** `src/lib/server/buyer-tasks-service.ts:115-167` (per-tab fetch × N tabs)
Examples:
```ts
// orders-service.ts:2174 (standalone order create flow)
for (let i = 0; i < itemsResult.created.length; i++) {
const created = itemsResult.created[i];
const original = input.items[i];
if (!original) continue;
const patch: Record<string, unknown> = {};
if (original.sku !== undefined) patch.sku = original.sku;
if (original.unit !== undefined) patch.unit = original.unit;
if (original.supplier_id !== undefined) patch.supplier = original.supplier_id || null;
if (original.notes !== undefined) patch.notes = original.notes;
if (Object.keys(patch).length > 0) {
try {
await pb.collection(orderItemsCollection).update(created.id, patch);
} catch (err) {
// ... per-row failure recorded, loop continues
}
}
}
```
`createOrderItems` returns N rows; this loop fires N PB round-trips
to patch the secondary fields (sku, unit, supplier_id, notes). At
the WFDASH fleet scale (≤30 items per order) it's tolerable, but the
helper comment immediately above the loop admits this. Better
shape: pass the optional secondary fields into `createOrderItems` as
a parallel array, so the helper writes them in the same `create`
call. PB supports fields on create.
`cancelOrder` (`orders-service.ts:2503-2506`, the
`for (const item of toCancel) { await pb.collection(...).update(item.id, { item_status: "cancelled" }); }`
block) is the same shape: one PB write per cancelled item. The
surrounding `cancelOrdersBulk` (`orders-service.ts:2523-2535`) is a
*separate* loop at the order level — it iterates `orderIds` and
calls `cancelOrder` per order; the per-item fan-out lives inside
`cancelOrder` itself.
`buyer-tasks-service.ts:115` opens with
`Promise.all`-batched fetches for supplementary data, but the
surrounding loops are per-item on the response side.
**Recommended fix:** before each loop, ask "can I do this with one
PB call?" Specific wins:
- `orders-service.ts:2174` — fold secondary fields into the
`createOrderItems` call so we don't need a follow-up patch.
- `cancelOrder` — collect all eligible items and use PB's batch
create endpoint (or a single `update` with a different verb).
PB 0.22+ supports transactional batch via `pb.send()`.
- `buyer-tasks-service.ts` — the per-tab fan-out is intentional
and correct; the per-item post-processing inside each tab is the
N+1 target.
### 2.5 No transactional writes across collections
**File:** every service that mutates >1 PB row in one operation
A representative example: `pb-helpers.ts:1264-1287`
(`createOrderFromQuote`) creates the `orders` row, then loops
`order_items` creates. If the network drops between the first
`order_items.create` and the last, the order exists with N items
where N is whatever the network reached. The transaction equivalent
would be PB's `pb.send()` with a chain of ops, or a manual
compensating-rollback helper.
The current mitigation is "log loudly and let the operator clean up
manually". This is fine at WFDASH fleet scale, but the pattern
scales badly — and the `OrderNumberCollisionError` flow at
`orders-service.ts:2145-2198` shows the cost of catching a partial-
failure state and rolling forward.
**Recommended fix:** two changes, in priority order:
1. Document the partial-success semantics in each multi-write
service's JSDoc, with an explicit list of which steps are
idempotent (safe to re-run) and which aren't. Today this
knowledge lives in operator tribal memory.
2. For the highest-value multi-writes (order creation, PO creation,
delivery note creation, quote split), add a `try/catch` that
deletes / rolls-back the partial state on failure. The PB SDK's
`pb.send()` batch endpoint supports this natively.
### 2.6 MCP route handler is a single 7077-line file
**File:** `src/app/api/mcp/route.ts:1-7077`
Everything — the CORS helper, JSON-RPC envelope, upstream-fetch
helper, schema validation, 50+ tool definitions, 50+ tool
handlers — lives in one file. Tool definitions alone occupy
lines 270-1500 (~1230 lines of `tools: [{ name: ..., inputSchema: ...}]`).
Tool handlers are interleaved with definitions rather than grouped,
so changing one tool requires scanning hundreds of lines.
This is a known single-file pattern (the existing audit covers it),
but the data-layer perspective adds:
- **No way to mock individual tools for tests.** The MCP route has
tests at `route.test.ts` and `po-agent-workflow.test.ts`, but
both test the route shape end-to-end. Service-layer splits would
let each tool be tested in isolation (the underlying services
already have unit tests).
- **Tool input schemas are written by hand** instead of using the
same `zod` schema as the route handlers (see 2.1). The MCP layer
could share schemas via `zod-to-json-schema` and the v1 routes.
- **Adding a new tool is a 30-minute scroll-and-edit.** A
`tools/<name>.ts` per-tool structure would make `git diff` readable
and reduce merge conflicts when multiple devs add tools.
**Recommended fix:** extract each tool's `inputSchema` and handler
into `src/lib/server/mcp-tools/<name>.ts`. The `tools` array becomes
`Object.entries(MCP_TOOLS).map(...)`. JSON-RPC dispatch stays in
`route.ts`. Risk: low — the file is a leaf in the import graph.
### 2.7 Schema-missing fallback pattern duplicated 4× with subtle drift
**File:** `src/lib/pb-helpers.ts:128-185` (`getQuotes`)
**File:** `src/lib/pb-helpers.ts:204-228` (`getQuoteAttachments`)
**File:** `src/lib/pb-helpers.ts:230-258` (`getQuoteStatusCounts`)
**File:** `src/lib/pb-helpers.ts:188-202` (`getTemplates` / `getFollowUpDueQuotes`)
All four use the same `is400(err)` + `warnSchemaMissing` pattern:
```ts
function is400(err: unknown): boolean { ... }
let warnedSchemaMissing = false;
function warnSchemaMissing(what: string) {
if (warnedSchemaMissing) return; // <- only warns ONCE for the whole process
warnedSchemaMissing = true;
console.warn(`[pb-helpers] ... (${what}). Import pb_schema to enable the new filter.`);
}
```
Three issues:
1. **`warnedSchemaMissing` is a module-level boolean**, so the warn
fires once per process. After the first fallback, the operator
never sees another "field missing" warning even if a different
collection is missing a different field. The "what" argument is
silently dropped.
2. **`is400` only checks `err.status === 400`**, but PB can also
return 400 with a different error shape (the new PB versions
embed a JSON `data` payload). The check misses anything that
isn't a literal `{status: 400}` object.
3. **The fallback filters may compile but return wrong results.**
`getQuotes` drops the `is_template != true` clause on fallback
(line 175), so a quote marked as template silently appears in
the New tab. `getQuoteStatusCounts` keeps the per-status queries
but loses the templates-tab exclusion (line 263).
The 2026-07-22 PB schema import project (mentioned in AGENTS.md) is
the path that resolves this; until then, the fallbacks should at
minimum warn with the `what` argument.
**Recommended fix:** replace the module-level latch with a `Set<string>`
that tracks warned fields, and surface the warning to a UI
diagnostic (a banner on the operator dashboard, not a `console.warn`).
If a route's fallback returns wrong data, that should surface in
QA tests — write tests that assert "templates don't appear in the
New tab when `is_template` is missing".
### 2.8 Service-layer error envelopes vary between routes
**File:** every `src/app/api/v1/**/route.ts` that returns JSON
Three observed shapes:
```ts
// Shape A — most routes
{ error: "message" }
// Shape B — quote pdf route
{ error: "PDF render failed", detail: "..." }
// Shape C — quote pdf validation
{ error: "items not ready", code: "items_not_ready", details: { itemIds: [...], issues: [...] } }
// Shape D — MCP route (JSON-RPC)
{ jsonrpc: "2.0", id: ..., error: { code: -32010, message: "...", data: {...} } }
```
The route handler picks whichever envelope the original author
chose, with no consistent shape across endpoints. The MCP layer's
JSON-RPC envelope is correct per spec but uses error codes that
don't map to HTTP status (the agent has to learn the mapping from
the hint strings in `TOOL_HINTS` at `mcp/route.ts:271-450`).
The v1 route handlers also return inconsistent HTTP statuses for
similar failures:
- `customers/route.ts:78` returns 400 with `{error: "customer is required"}`.
- `customers/[id]/route.ts:48` returns 400 with `{error: "JSON object body required"}`.
- `quotes/[id]/pdf/route.ts:165-168` returns 422 with a structured body.
- `quote-items/route.ts` (need to read) returns 500 on validation failures (no dedicated check).
**Recommended fix:** standardise on:
```ts
{ error: { code: "string_enum", message: "human-readable", details?: object } }
```
with a top-level helper `err(code, message, status, details?)`. The
MCP layer keeps its JSON-RPC envelope (it's a different protocol)
but maps to the same codes internally.
### 2.9 Search hard-caps silently truncate results
**File:** `src/lib/pb-helpers.ts:1066` (`SEARCH_HARD_CAP = 500`)
**File:** `src/lib/server/orders-service.ts:1075` (search caps in service layer, separate value)
**File:** `src/lib/pb-helpers.ts:431-453` (status counts — 22 parallel queries)
`SEARCH_HARD_CAP = 500` caps `searchOrdersWithItems` at 500 results.
Past 500, the result is silently truncated — no warning to the
operator that there are more matches.
The status counts (`getQuoteStatusCounts` at `pb-helpers.ts:230`)
fires 22 parallel `getList(1, 1, ...)` queries, one per PB status.
With no `quotes.status` index on the live PB, each query is a full
scan over ~8.5k quotes = ~2.6s wall time per the inline comment.
The service-layer replacement (`quotes-service.ts`) collapses this
to 1 scan.
**Recommended fix:** when a search hits the hard cap, return
`{ items, truncated: true, totalApprox: "≥ 500" }` so the UI can
render "showing 500 of 500+, refine your query". Migrate the status
counts through the service layer's collapsed-counts implementation.
### 2.10 `pb-helpers.ts` duplicate-detection post-filter is fine but verbose
**File:** `src/lib/pb-helpers.ts:1889-1953` (`findNameMatches`, `findCompanyDuplicates`, etc.)
PB's `~` operator does case-insensitive substring match; for exact
case-insensitive equality, the helper does:
```ts
const result = await pb.collection(collectionName).getList<T>(1, limit, {
filter: `${field} ~ "${escapeDup(trimmed)}"`,
});
return result.items.filter((row) => normDup(row[field]) === target);
```
This works at WFDASH scale but has two limitations:
- `(field) ~ "value"` is still substring, so a search for "Acme"
returns "Acme Limited" AND "New Acme Holdings" both. The
post-filter narrows to exact, but the wire payload already
included the unwanted rows.
- The helper calls `pb.collection(...).getList` rather than the
service-layer wrapper, so the auth context drifts (cookie PB vs
admin PB depending on caller).
**Recommended fix:** migrate `findCompanyDuplicates` /
`findSupplierDuplicates` / `findCustomerDuplicates` into
`management-service.ts` so they use admin PB (consistent with the
CRUD paths), and use PB's `=` with a `lower()` equivalent. PB 0.26
doesn't have `lower()`, so the post-filter is unavoidable, but the
narrowed PB `filter` should be the strict substring with `~`, not
a substring that includes everything.
### 2.11 Counter `last_seq+` increments — concurrent-edge case
**File:** `src/lib/server/orders-service.ts:1413-1426` (`getOrCreateOrderCounter`)
**File:** `src/lib/server/quotes-service.ts:930-944` (mirror for quotes)
```ts
try {
return await pb.collection(ORDER_COUNTERS_COLLECTION)
.getFirstListItem(`year=${year} && month=${month}`);
} catch {
// First request for this (year, month) — backfill from existing orders,
// then create. Without backfill the new code would start at ORD-...-001
// and collide with the existing sequence.
const backfill = await getMaxOrderSequenceInMonth(pb, year, month);
try {
return await pb.collection(ORDER_COUNTERS_COLLECTION).create({
year, month, last_seq: backfill,
});
} catch {
// Concurrent caller beat us to it — re-fetch.
return await pb.collection(ORDER_COUNTERS_COLLECTION)
.getFirstListItem(`year=${year} && month=${month}`);
}
}
```
The third `try` (line 1421-1425) catches the concurrent-beat-us case
but doesn't catch the underlying unique-constraint error inside the
`.catch`. If the unique constraint fails AND the re-fetch also fails,
the original error is swallowed.
More importantly: `getMaxOrderSequenceInMonth` does a
`getFullList({ filter: order_number ~ "..." })`, which scans every
order in the month. On a busy month (or for the orders of the
month, not just the current one), this is the slow path of an
otherwise O(1) counter increment.
**Recommended fix:** the third try should re-throw the original
error if the re-fetch fails. The slow-path backfill can be a one-
shot job run by ops, not a per-create step.
### 2.12 PO number generator `getOrCreate` lookup lacks fallback for race
**File:** `src/lib/server/po-number.ts:1-...` (mirror of `pb-helpers.ts:1799-1864`)
The server-side PO number generator lives at `src/lib/server/po-number.ts`
(matching the deprecated client-side `generatePONumber` at
`pb-helpers.ts:1799-1864`). Per the inline comment at
`pb-helpers.ts:1745-1752`, they should mirror field-by-field.
Two issues:
1. The client-side helper at `pb-helpers.ts:1799-1864` post-filters
the existing POs on the suffix (line 1840-1848). The server-side
helper (read separately) presumably uses a more correct index hit.
Verify the formats actually match — a divergence here means
previews in the email composer don't match the persisted PO
number.
2. Both helpers look up "all POs in the (year, month)" via
`getList(1, 500)` and filter client-side. For a busy month this
pulls 500 records per PO generation. At the WFDASH fleet scale
it's tolerable but wasteful.
**Recommended fix:** read both side-by-side and assert the formats
match byte-for-byte. Add a comment in `po-number.ts` pinning the
contract: "client-side preview at `pb-helpers.ts:1799-1864` MUST
produce the same string".
---
## 3. Minor findings
### 3.1 `deliveryNoteOnly` option on `getQuotes` is fragile
**File:** `src/lib/pb-helpers.ts:213-247` (`getQuotes` options parameter)
The `options: { deliveryNoteOnly?: boolean }` shape is the only
optional options object on the legacy helper. Adding a second
boolean later would require a breaking change to the type. Use a
discriminated options object instead — `getQuotes(page, perPage, search, sort, statuses, options)`.
### 3.2 Cookie PB singleton persists auth across logout
**File:** `src/lib/pocketbase.ts:23-46`
When the user clicks logout, the operator's PB token is removed
from `localStorage` (presumably — verify in the logout UI), but
the singleton `pb` instance in `pocketbase.ts:9` is module-scoped
and not cleared. The next `getPocketBase()` returns the same
instance with the stale auth.
**Recommended fix:** add an exported `clearPocketBaseAuth()` that
resets the singleton and clears the localStorage entry. Call it
from the logout handler.
### 3.3 `assertBrowserSession` is duplicated inline in `quotes/items/rewrite/route.ts`
**File:** `src/app/api/v1/quotes/items/rewrite/route.ts:99-122`
**File:** `src/lib/server/browser-session.ts:1-30`
The rewrite route has its own `assertBrowserSession` instead of
importing the shared one. The two implementations are identical
(`fetch ${pbUrl}/api/collections/users/auth-refresh` with a 5s
timeout). Drift risk if one is updated and the other isn't.
**Recommended fix:** import from `@/lib/server/browser-session`.
### 3.4 `isPocketBaseId` regex is too lax (downgraded — minor)
**File:** `src/lib/server/management-service.ts:253-257`
```ts
function isPocketBaseId(s: string): boolean {
// PocketBase ids are 15 alphanumeric chars by default. We don't try
// to be stricter than that — false positives just fall through to
// the name lookup which is harmless.
return /^[a-z0-9]{15}$/i.test(s);
}
```
PB's default id format is 15 alphanumeric chars, but admins can
configure custom id formats. The regex matches any 15-char
alphanumeric string (e.g. `"customerCustomer1234"` — not a real PB
id). However, the actual call sites (`resolveCompanyRef` /
`resolveUserRef` at `management-service.ts:206-237`) wrap the
`getOne` in `try/catch` and **explicitly fall through to the name
lookup** on any failure (literal comment: `// Fall through to name
lookup.` / `// Fall through.`). So a 15-char string that doesn't
resolve as an id correctly triggers the name-lookup fallback, and
a string that *does* resolve as an id returns the real record.
The remaining (narrow) risk is a false positive that happens to
collide with an unrelated record's real 15-char PB id — i.e. the
operator types a company name like `"abc123def456ghi"` and it
silently shadows a different real company. Probability is low
(15-char alphanumeric is a ~10^27 space at 15 chars), but not
zero, and the symptom is silent mis-routing. This is **minor**, not
major.
**Severity:** Minor (downgraded from a prior draft that incorrectly
described the fallback as missing — the fallback IS present).
**Recommended fix:** tighten the regex when PB exposes a stable id
shape (e.g. include the `pre_` / custom-prefix configuration), or
require a prefix hint on user-supplied input. For the time being,
add a unit test that asserts both the fast-path and the fallback
paths resolve to the same id when given an ambiguous 15-char input.
### 3.5 PB file proxy is publicly readable (per code comment)
**File:** `src/app/api/v1/quotes/[id]/pdf/route.ts:694-697`
```ts
// PB's `/api/files/...` endpoint is publicly readable for these
// records (no Authorization header required — the file collection
// rule allows it). Stream the bytes through so the iframe can
// render without exposing the storage URL directly.
```
The comment admits that PB's per-file rule allows anonymous reads.
The PDF route streams the bytes back through Next so the iframe
gets the data via the same-origin response, but anyone with the
file token can hit `${PB}/api/files/quote_attachments/${id}/${name}`
directly. The `security-perf-prod.md` audit covers this.
**Recommended fix (data-layer specific):** add a PB rule that gates
`/api/files/quote_attachments/*` on a valid auth token. Today,
the application relies on the file token being unguessable — PB's
token format is randomsuffixed so it's hard to enumerate, but the
attachment-id IS in the URL.
### 3.6 Schema-mismatch silently degrades `quote_attachments` direction
**File:** `src/lib/pb-helpers.ts:540-585` (`getQuoteAttachments`)
The `direction` field on `quote_attachments` was added in a 2026-06-23
migration. The fallback at `pb-helpers.ts:570-583` returns the
historical "all attachments" view when the field doesn't exist.
This means after the migration, an operator who hasn't run it sees
all attachments (including outbound and generated), which changes
the "Original Email" card on the quote detail page. Document this
in the migration guide.
### 3.7 `csv` cell encoding is naive
**File:** `src/lib/pb-helpers.ts:1914-1929` (`exportToCSV`)
The CSV encoder only escapes commas and double quotes, not
newlines. A description with `\n` (rare in the codebase, but
possible from copy/paste from a Word doc) breaks the CSV row.
**Recommended fix:** also escape `\n` and `\r` (CRLF inside a quoted
cell is allowed by RFC 4180, but only if the cell is quoted; the
current code only quotes when commas or double quotes are present).
### 3.8 `console.error` count is 37 across 165+ routes
**File:** `console.error` and `console.warn` callsites across `src/app/api/v1/**`
A `grep -c 'console.error\|console.warn\|console.log' src/app/api/v1/**/*.ts` shows 37. Most routes either log nothing or log a single error per failure. With structured logging missing (per the existing audit's 2.8), debugging a customer-reported failure today requires PB logs + Next logs + guessing.
**Recommended fix:** introduce a `logError(scope, error, context)` helper that adds the route path, request id, and container id to a single JSON line. Wrap every `catch` block with it. Even without a logger backend, the JSON-line format makes production logs grep-able.
### 3.9 `searchOrdersWithItems` SEARCH_HARD_CAP is in legacy only
**File:** `src/lib/pb-helpers.ts:1066` vs `src/lib/server/orders-service.ts:1075`
The legacy cap is 500; the service-layer cap is presumably different
(verify). Different caps means a page that migrated to the service
shows a different count than a page that hasn't.
### 3.10 `pb-helpers.ts` `getOrderItemStatusTabCounts` is N round-trips
**File:** `src/lib/pb-helpers.ts:660-693`
Six `getFullList` calls in parallel (`tabs.map(...)`). Each scans
`order_items` filtered by `item_status` — at WFDASH scale this is
fast, but each tab also returns the full `order` relation string,
even though the function only dedupes to a count. Add `fields: "order"`
to narrow the wire payload (this is the same fix the service layer
applies).
---
## 4. Production-readiness checklist (data-layer specific)
- [ ] **Schema validation:** add `zod`, write `parseBody(req, schema)` helper, convert the 7 highest-risk write routes.
- [ ] **Legacy deprecation:** move `generateQuoteNumber`, `generateOrderNumber`, `generatePONumber` out of `pb-helpers.ts` exports; add a lint rule that bans non-legacy imports.
- [ ] **Browser-singleton fix:** `getPocketBase` throws on server-side. Service-layer is the only path.
- [ ] **getFirstListItem abstraction:** write `firstOrNull<T>(collection, filter)` and migrate the 31 call sites.
- [ ] **Multi-write rollback:** document partial-success semantics on the 4 multi-write services (orders, PO, DN, quote-split).
- [ ] **MCP route refactor:** extract each tool into `lib/server/mcp-tools/<name>.ts`.
- [ ] **Search cap visibility:** return `truncated: true` from any search that hits a hard cap.
- [ ] **Schema-missing warnings:** `Set<string>` per-field, surfaced to a UI diagnostic.
- [ ] **Error envelope standardisation:** `{ error: { code, message, details? } }` shape across all v1 routes.
- [ ] **`isPocketBaseId` tightening:** verify both id and name lookups for ambiguous inputs.
- [ ] **CSV export hardening:** escape `\n` and `\r`.
- [ ] **Logging:** structured JSON `logError(scope, error, context)` helper.
## 5. What I deliberately did **not** flag
- **Auth surface.** Covered comprehensively in `security-perf-prod.md`
§1.1-1.4, §2.1-2.2, §2.11. This audit stays data-focused.
- **Frontend / UX issues.** See `frontend-ux.md`.
- **PB schema field disuse.** See `unused-pb-fields.md`.
- **Test coverage gaps.** The service-layer files have *.test.ts
companions for every business-critical module; the gaps are at
the route-handler layer (most routes have no tests, e.g.
`agent/scan/route.ts`). Coverage audit is a separate task.
- **PDF rendering / @react-pdf / SVG data URIs.** The PDF rendering
foot-guns (raster corruption at byte 0x80) are covered in the
PDF route JSDoc and don't affect the data layer.

View file

@ -1,461 +0,0 @@
# Frontend / UI / UX Audit — WFDASH
**Date:** 2026-08-28
**Scope:** `src/app/(app)/**/page.tsx`, form components, client components, navigation, loading/empty/error states, mobile `/m` dashboard, counter shell.
**Method:** Walked the app from the user's perspective (entry → login → dashboard → core flows: orders, quotes, customers, suppliers, stock, deliveries, counter). Read every primary page + form + the sidebar/nav + key modals. Did **not** fix anything — this is a categorised findings list.
## Severity legend
- **Critical** — broken flows, data loss, security hole, anything that blocks production rollout
- **Major** — visible UX failure, confusing default, accessibility blocker, fragile pattern that will bite later
- **Minor** — polish, consistency, hardening
---
## Executive summary
The app has a thoughtful information architecture, real-time updates via PocketBase subscriptions, a cohesive brand language (tri-colour stripe, themed WFDASH palette, themed login) and a lot of detailed care (per-row expansion on the orders list, deterministic accent colours for approval cards, etc). The big remaining issues are:
1. **No `loading.tsx` / `error.tsx` / `not-found.tsx` anywhere in the app** — every route relies on the page's own inline spinner / error catch. A backend blip on a slow link looks identical to a normal load and an empty state is the only fallback for "this route blew up".
2. **No global error boundary** — a throw inside any client component crashes the route silently (only the toast or `console.error` surfaces it).
3. **Hardcoded "Westcott Fasteners" brand string** in 4+ places (sidebar, dashboard header, login logo comment, server-side email template).
4. **Forms don't use react-hook-form / shadcn `Form`** — every form is bare `useState` with hand-rolled `onSubmit` validation. Inline error messages are inconsistent (some use `toast.error`, some show nothing); the Order form does not validate required Company before submit UI; several forms lack `aria-invalid` / `aria-describedby`.
5. **Mobile `/m` dashboard** does a hard `document.location.href = "/login"` on 401 (loses state, no retry). It also uses native `<dialog>` with a hand-rolled backdrop click handler that double-fires in some browsers.
6. **Counter success screen** says *"on the New tab"* with no link or context — a quote unquote placeholder text.
7. **Sidebar `New Order` vs `Order List`** highlight resolution is clever — relies on a "most-specific match" heuristic that's correct but only documented inline; if anyone adds another nested route under `/orders`, the heuristic breaks silently.
The remainder of this doc walks the app section by section.
---
## 1. Auth & shell
### 1.1 Login screen — `src/app/(auth)/login/page.tsx`
**Strengths**
- The branded login is genuinely nice — tri-colour stripe, brand-correct dot grid mask, three-radial wash behind the card, three tabs (Login / Register / Reset).
- Tabs use a scoped `<style>` block so theme overrides can't recolour the login surface. Good intentionality.
- Register validates password match (`if (regPassword !== regConfirm) toast.error(...)`) and min-length is enforced by the browser via `minLength={8}`.
**Findings**
- **[Minor]** `src/app/(auth)/login/page.tsx:108-111` — register doesn't check the password length on submit. `minLength={8}` is a browser hint but if it's bypassed (autofill, paste), the form goes through. **Fix:** add the same explicit check you have for "passwords do not match".
- **[Minor]** `src/app/(auth)/login/page.tsx:86` — `register` parameter order is `(email, password, passwordConfirm, name)`. The callsite has to remember that order. The function is called `useAuth().register` so the destructured position isn't documented. Consider named options object (`register({ name, email, password, passwordConfirm })`).
- **[Minor]** `src/app/(auth)/login/page.tsx:62` — `requestPasswordReset` gives no UI feedback after success beyond the toast. The "Send Reset Email" button stays enabled — a fast user could re-submit. **Fix:** keep the button `disabled` while loading (already true) AND reset the email field on success, AND show inline confirmation copy so the screen doesn't read as "nothing happened".
### 1.2 Auth gate — `src/app/(app)/layout.tsx:18-30` and `src/components/auth-provider.tsx`
**Findings**
- **[Major]** `src/app/(app)/layout.tsx:15` — the loading state is a centred 8×8 spinner with no label. An operator who lands on a slow network sees a blank grey page with a small ring spinning; if their PB session took >2s to verify, they don't know if they're being signed in or if the page is broken.
**Fix:** add a subtle brand stripe + "Signing you in…" copy so the layout reads as "we're working on it" instead of "broken".
- **[Major]** `src/components/auth-provider.tsx:14-23` — `AuthProvider` only consults `pb.authStore.isValid` on mount. It does **not** validate the token against the server (i.e. doesn't refresh from PB to confirm the JWT hasn't expired). So a returning user can have `pb.authStore.isValid === true` (locally) but a 401 from every API call. The app layout will render, the dashboard will fail with a toast, and the user is in limbo. **Fix:** on mount, call `pb.collection("users").authRefresh()` to validate; if it fails, clear `authStore` and redirect.
- **[Critical]** `src/app/(app)/layout.tsx:18-30` — the layout redirects to `/login` if `!user && !loading`. **But** there's no guard for the **mobile `/m` route group** or the **counter route group**. Mobile layout (`src/app/(mobile)/m/layout.tsx`) has no auth check at all. Counter layout (`src/app/(counter)/layout.tsx`) also has no auth check. **A user who never signs in can browse `/m` and `/new-order` unauthenticated and the app will silently 401 every fetch.** (Mobile currently hard-redirects on 401 via `document.location.href = "/login"` — see §6.) **Fix:** either put the auth gate in the root layout, or add the same gate to `(mobile)/layout.tsx` and `(counter)/layout.tsx`.
### 1.3 Logout
**Findings**
- **[Minor]** `src/components/sidebar.tsx:355-362` — logout button has no confirmation. One misclick = signed out. Mobile dashboard has the same gap. **Fix:** either add a confirm popover or accept the trade-off and document it.
---
## 2. Sidebar / navigation
### 2.1 Sidebar component — `src/components/sidebar.tsx`
**Strengths**
- Sectioning is clear: Management / Quotes / Orders / Logistics / Operations / Stock / Other. The section labels are uppercase tracking-widest which matches the design system.
- Active-route resolution (`isActive` helper at `src/components/sidebar.tsx:99-110`) handles the `/orders/new` vs `/orders` highlight collision correctly by demanding "most-specific match within group".
- Mobile fallback uses a Sheet drawer with a top bar — this is fine.
- Tri-color brand stripe at top + bottom wraps the sidebar so the brand identity is consistent.
**Findings**
- **[Major]** `src/components/sidebar.tsx:99-110` — `isActive` only looks at *direct siblings*. If someone later adds `/orders/foo` as a top-level sidebar item while `/orders` and `/orders/new` both already exist, only the longest URL wins; adding `/orders/foo/bar` does *not* automatically lose its highlight to `/orders/foo`. This is documented in the comment block but it's the kind of thing that breaks on the next refactor. **Fix:** write a tiny unit test for `isActive` covering: equal length, nested, ambiguous three-way.
- **[Major]** `src/components/sidebar.tsx:438-470` — mobile sheet drawer is built around the same `NavContent` component but the top mobile bar duplicates the brand block (logo + WFDASH). On a narrow phone this eats ~30px at the top of every screen that's duplicated against the sidebar's own brand block in the drawer. **Fix:** either drop the mobile top bar to a plain hamburger + page title, or render the title from `usePathname()` so it's actually useful (e.g. shows "Orders" when on `/orders`).
- **[Minor]** `src/components/sidebar.tsx:81-82` — `Order Sprint` uses the `Truck` icon, identical to the `Deliveries` nav item in Operations. The two icons are visually indistinguishable on the small 17×17 size used here. **Fix:** pick distinct icons — `FastForward` or `Zap` for the sprint, or use `Truck` only on the operations side and `Timer` on the orders side.
- **[Minor]** `src/components/sidebar.tsx:118` — `Repairs` is parked under "Other" with a comment saying "Move it once it has a clearer home". For a production app, "Other" is itself a smell — it tells operators *you haven't finished the IA*. **Fix:** either rename "Other" to "Tools" (less pejorative) or promote `Repairs` to a top-level section.
- **[Minor]** `src/components/sidebar.tsx:335-352` — user block at the bottom of the sidebar shows `user?.name || "User"` and `user?.email`. If `email` is undefined (rare but possible for a brand-new admin record), the second line is empty. Not blocking.
- **[Minor]** `src/components/sidebar.tsx:165-170` — `NavSection` defaults to open if any sibling is active, but the active section's label doesn't render in a different weight so a user scanning the sidebar has to actually look at the chevron to know which section is currently open. **Fix:** bold the active section's label.
- **[Minor]** `src/components/sidebar.tsx:341-350` — theme toggle button (`Sun` / `Moon`) flips between `theme === "dark" ? "light" : "dark"` but doesn't reflect the third state some themes support (`"system"`). If a user picks "System" via the ThemePicker, the sidebar still shows only two icons. Acceptable; document it.
### 2.2 Cross-route nav
- **[Minor]** The "Order Sprint" sidebar entry has the label "Order Sprint" (`src/components/sidebar.tsx:81`) while the page itself says "Order Sprint" (`src/app/(app)/orders/sprint/page.tsx:190`) — matches. But the `Buyer Tasks` entry (`src/components/sidebar.tsx:82`) links to `/orders/tasks` while the page H1 is just "Buyer Tasks" (`src/app/(app)/orders/tasks/page.tsx:6`). Inconsistent sub-title: "Buyer Tasks" vs nav label "Buyer Tasks" — fine, just noting it. Same pattern for "Sprint" (sidebar) vs page title "Sprint" (page). These micro-inconsistencies add up.
---
## 3. Dashboard — `src/app/(app)/dashboard/page.tsx`
**Strengths**
- The greeting strip + KPI cards + chart rows are visually distinctive.
- Sparklines, deltas, real-time refresh with debounced PocketBase subscription. Good.
- `KpiCard` flash animation on value change (`kpi-delta-up`/`kpi-delta-down` classes) is a thoughtful touch.
**Findings**
- **[Critical]** `src/app/(app)/dashboard/page.tsx:271` — `Westcott Fasteners` is **hardcoded** as the org name on the dashboard greeting strip. The `useAuth()` hook already returns `user` — there's no equivalent of `useOrg()` because the org isn't a real concept in the data model, but this means **the dashboard greets every WFDASH tenant as "Westcott Fasteners"**. If the app is sold to anyone else, the greeting will lie. **Fix:** derive the org from a `settings` collection, or remove the org label entirely and just show the user name.
- **[Critical]** Same hardcoded "Westcott Fasteners" string at:
- `src/components/sidebar.tsx:284` (sidebar brand block)
- `src/app/(app)/layout.tsx` brand banner comment (this is just code, not user-visible)
- `src/app/api/v1/quotes/[id]/review-approve/route.ts:326` (the email subject says "Quote X from Westcott Fasteners" — so this also leaks into customer emails!)
- **Fix:** every one of these needs to come from a single config / settings record, not from hardcoded constants.
- **[Major]** `src/app/(app)/dashboard/page.tsx:75-105` — error handling on the dashboard fetch only toasts. If the first fetch fails (token expired, PB unreachable), the user sees `DashboardSkeleton` forever (the `if (!data)` branch) because the catch block doesn't clear `loading` until `finally` — but `loading` is set to `true` initially and only flipped to `false` in `finally`. If the fetch throws, `loading` does flip, but `data` stays `null`, so the user is stuck on the skeleton with a toast they may have dismissed. **Fix:** add a dedicated error-state render: "Couldn't load dashboard. [Retry]" with a button.
- **[Major]** `src/app/(app)/dashboard/page.tsx:175-200` — the realtime subscription uses `await pb.collection(...).subscribe("*", trigger)` inside an async IIFE (`const subscribe = async () => { ... }`). If the WebSocket fails to connect (e.g. PB down, proxy buffering), `pb.subscribe` resolves successfully but no events ever fire — the dashboard silently stops refreshing and the operator has no signal. **Fix:** wrap in `pb.collection(...).subscribe(...)` and listen for `PB.AuthStore.onChange` or expose a connection state; show a "Realtime disconnected" badge when no events fire for >N seconds.
- **[Major]** `src/app/(app)/dashboard/page.tsx:11-22` — `greeting()` runs at module evaluation time in places like the follow-up list (the component uses `formatDate` from `pb-helpers` which is locale-aware — good). But the dashboard's `greeting()` is called on every render and uses `new Date().getHours()`, which is the **client's** local time. A user in a different timezone than the server's "today" sees a stale "Good morning" at 18:00 their time. **Fix:** if the dashboard is meant to reflect the operator's day, document the timezone; otherwise, prefer a constant greeting for the first iteration.
- **[Minor]** `src/app/(app)/dashboard/page.tsx:384-388` — "Customer depth" title is a clever metaphor but the operator might not understand it on first read. A label like "Active pipeline by stage" is plainer.
- **[Minor]** `src/app/(app)/dashboard/page.tsx:690` — "Follow-ups due" card has an inner Sub-Component (`FollowUpList`) — good. But the `isOverdue` calculation compares `due < new Date(new Date().toDateString())` which rounds the due date to midnight in **server** timezone, then compares to today's local midnight. Off-by-one risk near midnight.
- **[Minor]** `src/app/(app)/dashboard/page.tsx:525-540` — the "Order volume" card and "Orders trend" card both render an 8-week bar chart. Two near-identical widgets one above the other — likely intentional (different x-axis scales), but visually redundant. **Fix:** either merge or label clearly ("Order volume (count)" vs "Order volume (split: new vs fulfilled)").
- **[Minor]** `src/app/(app)/dashboard/page.tsx:537-540` — the "Live data" badge is unconditional — it says "Live data" even when the realtime subscription has never fired (and we're showing stale data). Same as §3 major — the badge is a lie in the disconnected case.
- **[Minor]** `src/app/(app)/dashboard/page.tsx:1` — file imports `Sparkline` from `@/components/charts/sw` — verify the chart directory is actually `charts/` not `charts/sw/`. Worth a quick check at file open time.
---
## 4. Orders list — `src/app/(app)/orders/page.tsx`
**Strengths**
- Item-status-driven tab filter is excellent; server-side `tab=` filter keeps pagination honest.
- "Where is it" empty-state nudge ("Found in: Processing (4) · Completed (2)") is a genuinely good UX touch.
- Per-row expansion, summary segments showing per-tab counts, complete pill, linked-quote pill — all excellent.
**Findings**
- **[Major]** `src/app/(app)/orders/page.tsx:560-574` — empty-state copy for "No orders with items in New" doesn't differentiate between "no orders exist at all" and "no orders match the current search". The page does render *both* cases correctly (different copy), but for "no orders in this tab, but other tabs have data" it shows the same generic nudge. **Fix:** add a third empty-state: "Nothing in New. Try: [Suggested tab buttons]". (The "Busy tabs" subline is close but doesn't go the last mile.)
- **[Major]** `src/app/(app)/orders/page.tsx:480-520` — when search is empty AND active tab is "new", the page lands and the operator can't see how many *total* orders are in the system. The pagination footer says `New orders · 20 on this page` but not the global count. **Fix:** add the global active-tab count to the footer (e.g. "Showing 1-20 of 4,326 in New").
- **[Major]** `src/app/(app)/orders/page.tsx:520-530` — both the "Review" button and the "Open" button render with class `text-warn hover:text-warn hover:bg-warn/10`. They're both warn-coloured. The review button is supposed to be a *review* action — different colour. **Fix:** review = warn (amber), open = primary (or muted ghost).
- **[Major]** `src/app/(app)/orders/page.tsx:676-686` — the row header `<div role="button">` swallows Enter/Space correctly, but if the operator double-clicks the header on a row that's also in `showAll` state, the expansion toggles twice rapidly. (Not blocking, but worth `data-testid`.)
- **[Major]** `src/app/(app)/orders/page.tsx:680-686` — the `role="button"` div uses `aria-expanded` correctly, but its accessible name is just the concatenated order number / company / customer / contact / rep. Screen readers will read all of them. **Fix:** wrap the visible content in a labelled region or use `aria-label="Toggle details for order ORD-2026-12345"`.
- **[Major]** `src/app/(app)/orders/page.tsx:830-850` — the "Show all items" toggle is rendered as a ghost button with no icon distinguishing "expanded" vs "collapsed". When `showAll === true`, the button label says "Hide items not in X" but the chevron still rotates DOWN. **Fix:** flip the chevron or use a clearer label like "Showing all items" / "Showing only X items".
- **[Minor]** `src/app/(app)/orders/page.tsx:737-750` — the "Suggested" pill (potential quote match) uses an amber palette while the linked-quote pill uses teal — both are good — but the "Suggested" pill is **not clickable** while the linked-quote pill opens the order. Inconsistent affordance. **Fix:** make the "Suggested" pill a button that opens the order (the comment in the code already explains this is intentional because the suggestion lives on the order detail page — but that justification should be in a tooltip on the pill itself).
- **[Minor]** `src/app/(app)/orders/page.tsx:170` — comment says "Delivered + Cancelled orders live on a separate /orders/archive page." That's a real architectural choice; it should be in the page header as a subtitle so the operator doesn't think the tabs are broken. **Fix:** add a small "Archive: Delivered · Cancelled → /orders/archive" link below the tab strip.
### 4.1 Orders detail — `src/app/(app)/orders/[id]/page.tsx`
- **[Major]** This is a 4,500+ line client component. It's effectively a "kitchen sink" page. Anything wrong here is invisible because the file is too large to review in one pass. **Fix:** split into `OrderHeaderCard`, `OrderItemsTable`, `OrderQuoteMatchPanel`, `OrderPurchaseOrdersPanel`, `OrderDocumentsPanel`, `OrderTimelinePanel`, `OrderActionsBar`. Each sub-component takes props + callbacks. The current file mixes presentation, side effects, modal state, and business logic in one place.
- **[Minor]** The order detail page has 6+ different `console.error` / `console.warn` calls — these should surface as toasts (the user did the action, they need to know it failed) rather than as silent browser console noise.
- **[Minor]** `getOrder` / `findMatchingQuotesForOrder` calls happen via `pb-helpers.ts`, the legacy data layer. AGENTS.md is explicit that new code should go through the service layer — but the order detail page is the largest legacy consumer. This isn't a bug, but every time the order detail page is touched, the migration cost is paid.
### 4.2 Orders archive — `src/app/(app)/orders/archive/page.tsx`
- **[Minor]** `src/app/(app)/orders/archive/page.tsx:144` — embedded anchor `<a href="/orders" className="underline">orders page</a>` inside a CardContent paragraph is a hardcoded route. If the IA shifts again, this drifts. Use `<Link>` from `next/link`.
---
## 5. Quotes
### 5.1 Quote list — `src/app/(app)/quotes/page.tsx`
**Strengths**
- AbortController for in-flight fetches (`fetchAbortRef`) is correctly implemented.
- Tab counts + per-row "aged" badge with `AGE_WARNING_DAYS` is well thought-out.
- Search has a 90-day default cutoff (`includeOlder` toggle) — great UX for the operator's "where's the Acme quote" flow.
**Findings**
- **[Critical]** No `loading.tsx` for the quotes route — the entire route is one client component with a centred spinner. On slow networks the operator sees a half-rendered header + an empty list + a spinner. The header + tab strip should appear instantly via a server component split.
- **[Major]** `src/app/(app)/quotes/page.tsx:55` — `AGE_WARNING_DAYS = 3` is hardcoded. There's no way for an admin to change it without a code change + redeploy. **Fix:** move to a `settings` collection or to an env var.
- **[Major]** `src/app/(app)/quotes/page.tsx:280` — `fetchQuotes` does NOT use the `AbortController` when `search.trim()` is empty (the comment says "fires for both branches" but the abort is wired in only on the search branch — see line 233). **Fix:** move the abort setup above the `if (search.trim())` branch.
- **[Major]** The quotes page uses `data-table.tsx` for the body, but the tabs are rendered separately above it. When the operator clicks a tab and the new tab is empty, the DataTable shows its own "No results" message which can be the third empty-state in the same screen (no orders → "No quote found" inside the table, the "Found in:" nudge above, and the empty-state card). **Fix:** consolidate.
- **[Minor]** `src/app/(app)/quotes/page.tsx` is also 3,500+ lines and mixes presentation, modals (SendQuoteModal, RewriteQuoteModal), search, tabs, real-time. Same "kitchen sink" problem as the orders page.
### 5.2 Quote detail — `src/app/(app)/quotes/[id]/page.tsx`
- **[Major]** This is a **6,500+ line file** with the comment `Eye was only used by the removed "Submit for Approval" buttons. If a future "preview/inspect" action needs it, add the import back.` Dead imports should be cleaned before production.
- **[Critical]** File-level `dangerouslySetInnerHTML` for the email body (`src/app/(app)/quotes/[id]/page.tsx:748`) — confirmed sanitised upstream, but worth a regression test that includes `<script>`, `<iframe>`, `javascript:` hrefs, and `onerror=` handlers.
- **[Major]** The "Send Quote" modal can fail silently. If `send-quote-modal.tsx` errors mid-submit, the toast fires but the modal stays open with a generic spinner — operator doesn't know whether to retry or close. **Fix:** distinguish "still working" vs "failed, retry?".
---
## 6. Mobile `/m` dashboard — `src/app/(mobile)/m/dashboard-view.tsx` and `dashboard/page.tsx`
**Strengths**
- Tabbed time-window selector (today/week/month).
- Sales rep cards with metric breakdowns.
- Native `<dialog>` for the bottom sheet.
- Real-time updates with PB realtime subscription.
**Findings**
- **[Critical]** `src/app/(mobile)/m/dashboard-view.tsx:78-80` — **hard navigation** to `/login` on 401 via `document.location.href = "/login"`. This wipes the in-memory state of the dashboard (search, tab selection, sheet state) and gives no "your session expired, sign back in" feedback. The user just lands back on the login page with no breadcrumb. **Fix:** show a sonner toast + push to `/login` via router (preserves back button).
- **[Critical]** `src/app/(mobile)/m/dashboard-view.tsx:158-180` — `pb.collection("customers").subscribe("*", trigger)` etc. on mount. **If PB disconnects, the subscription silently stops firing and the operator sees stale data with no indication.** This is the dashboard's "Live data" badge lying again. **Fix:** add a `PB.EventSource` connection state listener or expose a "Reconnecting…" badge.
- **[Critical]** `src/app/(mobile)/m/page.tsx` + `(mobile)/m/layout.tsx` — **no auth guard**. The layout doesn't redirect unauthenticated users. A user who never signs in can land on `/m` and the client component will fetch `/api/v1/boss-dashboard` which returns 401, then the hard-redirect fires (good), but the redirect happens *after* the layout already rendered. **Fix:** wrap `(mobile)/layout.tsx` with the same auth guard as `(app)/layout.tsx`.
- **[Major]** `src/app/(mobile)/m/dashboard-view.tsx:159-165` — `pb.collection("customers").subscribe("*", trigger)` opens a new EventSource per page-load. If the page is opened twice in two tabs, **two subscriptions stay alive**; when one tab closes, the other tab's subscription isn't affected (good — PB fan-outs server-side) but the count of open EventSources climbs with each open tab and there's no UI for "this dashboard is also open in another tab". Acceptable.
- **[Major]** `src/app/(mobile)/m/dashboard-view.tsx:330-355` — the `<dialog>` backdrop click handler uses `e.target === dialogRef.current` to detect backdrop taps. **In some browsers (Safari iOS in particular), the dialog element fires a click event for the backdrop AND a separate click for the dialog content if the user tapped within the dialog. The check `e.target === dialogRef.current` works, but `onClose` is also wired, which fires when `dialog.close()` is called from outside — so closing on a real X click fires `onClose` (sets sheetRep to null) AND the backdrop click handler (calls `closeSheet` which also sets sheetRep to null).** Result: a redundant state update. Not blocking, but the dialog backdrop semantics are notoriously inconsistent across browsers.
- **[Major]** `src/app/(mobile)/m/dashboard-view.tsx:298-301` — the dialog has `open={sheetRep !== null}` BUT the dialog is also opened imperatively via `dialogRef.current?.showModal()`. Mixing declarative `open` with imperative `showModal()` is a known source of bugs — the `open` attribute may not update the DOM correctly in all browsers. **Fix:** pick one — drop the `open` prop and drive entirely via the ref, or drop the ref and use only `open` + `onOpenChange`.
- **[Major]** `src/app/(mobile)/m/dashboard-view.tsx:78-90` — when `fetchData` is called and the auth token is missing, it doesn't pre-check; it just lets the request fire and 401s. The dashboard then redirects. **Fix:** pre-check `pb.authStore.isValid` and redirect preemptively.
- **[Major]** `src/app/(mobile)/m/dashboard-view.tsx:235` — `dialogRef.current?.close()` is called in `selectWindow`. **But `selectWindow` does not wait for the close animation**, so if the next `fetchData()` triggers a re-render that opens a new dialog, the user sees a flicker. Acceptable, but the comment is silent.
- **[Minor]** `src/app/(mobile)/m/dashboard-view.tsx:185-190` — greeting is hardcoded `"morning"` / `"afternoon"` / `"evening"`. Fine, but uses local time without TZ consideration (same as dashboard §3).
- **[Minor]** `src/app/(mobile)/m/dashboard-view.tsx:454` — close button is the literal character `×` instead of an `X` icon. Screen readers will read "multiplication sign" or nothing.
- **[Minor]** `src/app/(mobile)/m/dashboard-view.tsx:407-420` — sheet list items open in `window.open(path, "_blank")`. There's no keyboard equivalent — a power-user on a Bluetooth keyboard can't open the order in a new tab. Use a normal `<a>` with `target="_blank"`.
- **[Minor]** `src/app/(mobile)/m/dashboard-view.tsx:332-333` — driver section's badge logic: `allDone` shows "All done", `noneStarted` shows `${drv.stopsLeft} left` (red), in-progress shows `${drv.stopsLeft} left` (amber). The amber and red badges say the same text. **Fix:** differentiate ("In progress · 3 left" vs "Not started · 3 left").
---
## 7. Forms — cross-cutting
### 7.1 No `Form` / react-hook-form anywhere
**Findings**
- **[Major]** Every form in the app uses bare `useState` + `<form onSubmit={handleSubmit}>`. Validation is done in the submit handler with `toast.error(...)` for failures. This means:
- **No inline field-level error messages** — the operator sees a toast at the top of the screen, then has to scroll back to the field.
- **No `aria-invalid` / `aria-describedby` propagation** — most forms don't even use `<FormMessage>`-style components because they don't exist in the codebase.
- **No `react-hook-form` performance benefits** — every keystroke re-renders the entire form.
- **No submit-button-level disable based on `formState.isValid`** — buttons stay clickable while the form has errors.
- **Fix:** adopt `react-hook-form` + `zod` + the shadcn `Form` wrapper (you already have `@hookform/resolvers` likely — verify) for any net-new forms; migrate the existing ones over time.
### 7.2 Customer form — `src/components/customer-form.tsx`
- **[Major]** `src/components/customer-form.tsx:298` — duplicate-warning live effect uses `setDuplicates` from the effect body without a `useState` lint escape, but it doesn't run a stale-result check; if the user types fast, the effect that started second can resolve before the first. **Fix:** add a request-token / AbortController.
- **[Major]** `src/components/customer-form.tsx:175-200` — `handleSubmit` re-checks duplicates on submit (good) but does so via `findCustomerDuplicates(...)` which is from `pb-helpers.ts` (the legacy data layer). AGENTS.md says new features should use the service layer.
- **[Minor]** The customer form allows `lat`/`lon` to be entered as raw text fields. An operator can paste a malformed coordinate; the form silently saves an invalid number. **Fix:** validate before submit + show inline error.
### 7.3 Quote form — `src/components/quote-form.tsx`
- **[Major]** `src/components/quote-form.tsx:226` — fallback quote number generation uses `Math.random()`:
```ts
quoteNumber = `Q${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(Math.floor(Math.random() * 999) + 1).padStart(3, "0")}`;
```
With `Math.floor(Math.random() * 999) + 1` you get 1..999 padded to 3 digits. **Collision risk is real** for an operator who generates two quotes in the same month within the same minute. **Fix:** this is a fallback for when the server-side atomic increment endpoint fails — fix the underlying bug (the fallback is masking a race) or use a UUID suffix.
- **[Major]** `src/components/quote-form.tsx:224, 242, 266, 306` — `console.error(...)` for every quote-creation failure path. **Operator never sees the toast for these paths** because the catch only logs. **Fix:** toast on every error path.
- **[Minor]** `src/components/quote-form.tsx` doesn't reset the form state when the operator clicks Cancel — the close-handler relies on the parent. If the parent forgets to unmount, the next open shows stale data.
### 7.4 Order form — `src/components/order-form.tsx`
- **[Major]** `src/components/order-form.tsx:100-105` — `handleSubmit` only validates that `form.company` is set. **No validation of `form.order_number`** (server-generated), **`form.customer` (optional but required in some flows), or `form.notes` (length cap?).** Operator can submit an order with no customer and only a company — that's by design? Document it.
- **[Major]** `src/components/order-form.tsx:163-200` — the PopoverTrigger for customer/company uses raw `<PopoverTrigger className="...">` with a hand-rolled Tailwind class string. The shared `<Button variant="outline">` would inherit the shadcn focus ring. The custom class string **does not match** the Button component's focus-visible style — accessibility regression. **Fix:** use a `Select` or the existing `CompanySearch` / `CustomerSearch` (which the supplier form already uses).
- **[Major]** `src/components/order-form.tsx:120-127` — loadDropdowns fetches `getAllCustomers()`, `getAllCompanies()`, `getAllSuppliers()` on every open. These are unbounded `getFullList()` calls. **For a tenant with thousands of customers, this is a 5–15s blocking fetch.** The supplier/customer search components already lazy-load. **Fix:** use those instead.
- **[Minor]** `src/components/order-form.tsx:30-45` — `loadDropdowns` writes `customers`, `companies`, `suppliers` from inside the effect callback but doesn't reset them on close. Opening the order form, switching customer, closing, opening again — the dropdowns can show stale data.
### 7.5 Counter form — `src/app/(counter)/new-order/components/new-customer-sheet.tsx`
- **[Major]** `src/app/(counter)/new-order/components/new-customer-sheet.tsx:60-65` — comment in the source itself: *"the sheet doesn't auto-reset on reopen. If a counter user reopens without saving, stale fields are visible."* This is an acknowledged UX bug shipped to production. **Fix:** either accept it (document it) or fix it.
- **[Major]** `src/app/(counter)/new-order/components/new-customer-sheet.tsx` — `<form>` is missing. The new-customer sheet has no `<form onSubmit>` — submit is wired through `onClick` on the "Create" button. Pressing Enter in any of the inputs does nothing.
- **[Minor]** `src/app/(counter)/new-order/components/new-customer-sheet.tsx:36-45` — `mode` state cycles between `{ kind: "customer-only", attachToCompanyId?: string }` and `{ kind: "both" }`. The radio buttons that drive this state are presumably elsewhere — verify they exist (the snippet shown ends at line ~60, the rest of the file should be reviewed).
---
## 8. Modals, popovers, sheets — cross-cutting
### 8.1 No focus-trap standardisation
- **[Major]** Some modals use the shadcn-style `<Dialog>` (which wraps `Dialog as DialogPrimitive` from `@base-ui/react/dialog`), some use `<Sheet>` (same primitive), some use plain `<Popover>`. **None of them are tested for focus trap behaviour.** A keyboard user can Tab *out* of the open modal in some cases. **Fix:** add a Playwright a11y test that opens each modal type and tries to Tab out.
- **[Major]** `<SheetContent>` from `src/components/ui/sheet.tsx:63-76` includes a close button (the `X`) inside the dialog, but **no `aria-label`** — screen readers just hear "close". **Fix:** add `aria-label="Close dialog"` (or a sr-only label).
### 8.2 Modals opened from inside modals
- **[Major]** Multiple places stack a Modal inside a Modal (e.g. the Order Form `<Sheet>` opens the `<CancelOrderDialog>` which is a `<Dialog>` inside a Sheet). The z-indexes work but focus ordering doesn't always return to the parent trigger when the inner dialog closes. **Fix:** Playwright test for each stacked-modal flow.
### 8.3 Dialog close-on-submit
- **[Major]** Several forms (Customer form, Quote form, Order form) call `onSaved()` then `onOpenChange(false)` separately. If the parent has its own onSaved logic that throws, the form stays open with a success toast — operator thinks it saved but it didn't. **Fix:** await onSaved before closing.
---
## 9. Empty / loading / error states — global
### 9.1 No `loading.tsx` anywhere
- **[Critical]** `find /Volumes/.../src -name "loading.tsx"` returned **zero matches**. Every route is a client component with an inline spinner. On slow networks the operator lands on a blank page that builds itself piece by piece. **Fix:** add `loading.tsx` for each route segment. Even a thin skeleton matching the page's grid is better than the current behaviour.
### 9.2 No `error.tsx` anywhere
- **[Critical]** `find /Volumes/.../src -name "error.tsx"` returned **zero matches**. **No Next.js error boundary is registered anywhere in the app tree.** A throw inside any client component crashes the route silently — the operator sees a blank page. **Fix:** add `(app)/error.tsx`, `(mobile)/error.tsx`, `(counter)/error.tsx` with a "Something went wrong. [Reload]" surface.
### 9.3 No `not-found.tsx` anywhere
- **[Major]** No `not-found.tsx`. A bad URL (e.g. `/orders/abc-not-a-real-id`) renders Next.js's default "404 - This page could not be found" page, which **doesn't match the brand at all**. **Fix:** add branded 404 for `(app)`, `(mobile)`, `(counter)`, `(auth)` route groups.
### 9.4 Empty states — quality varies
- **[Major]** `src/app/(app)/orders/page.tsx:560-590` — empty state is excellent (mentioned above).
- **[Minor]** Several pages render an empty `<Card>` with no message inside, leaving a blank card. **Fix:** audit each list page for "no items returned but the response is success" → ensure a friendly empty card.
- **[Minor]** `src/components/sidebar.tsx` — empty sidebar group (`Other`) renders nothing, which is fine, but if every group is collapsed (e.g. default), the operator lands on a sidebar of just section labels.
---
## 10. Hydration / client/server boundary
### 10.1 Theme flash — handled correctly
- **[Strength]** `src/components/theme-bootstrap.tsx` runs as a `<script>` in `<head>` BEFORE hydration, reading `localStorage.getItem('wfdash-theme')` and applying it. This avoids the FOUC. **Pattern is exemplary — copy this for any other persisted client state** (e.g. dev identity, sidebar collapsed state).
### 10.2 `Math.random()` in render
- **[Major]** `Math.random()` is called inside render in:
- `src/app/(app)/stock-orders/new/page.tsx:93`
- `src/app/(app)/orders/new/page.tsx:118`
- `src/app/(counter)/new-order/components/items-list.tsx:19`
- `src/app/(counter)/new-order/page.tsx:44, 102`
- `src/app/(app)/collection-confirm/view-model.ts:308`
These all appear to be for generating client-only `tempId` strings for list rows. **If the form is server-rendered before hydration, the IDs differ between SSR and CSR** — **hydration mismatch**. The `items-list.tsx` file even acknowledges this: *"The earlier `tempId` (Math.random) bled into the <input id=...> attributes and caused a hydration mismatch on the first render"*. **Fix:** `useId()` from React 18+, or `crypto.randomUUID()` (already used in `quote-form.tsx:113` — good).
### 10.3 Date / time rendering
- **[Minor]** `formatDate` is locale-aware (`"en-ZA"`) — good. But `new Date().toDateString()` and `new Date()` inside render (e.g. `DashboardPage` and `MobileDashboard`) **differ between SSR and CSR**. The server renders in its timezone; the client renders in theirs. For components that show "Updated 5m ago", this can show "just now" server-side and "5m ago" client-side after hydration. **Fix:** render time-based copy only on the client (gate behind `mounted` flag).
### 10.4 Server Components leaking to client
- **[Major]** `src/app/(app)/stock-orders/page.tsx` is a **server component** that calls `getAdminPocketBase()` and `listStockPurchases(...)` directly. This is correct (the AGENTS.md says migrate data calls to the service layer). But the error path renders the page with `loadError` set — there's no client-side retry button. **Fix:** pair server-rendered pages with a client `<RefreshButton>`.
---
## 11. Accessibility (a11y)
### 11.1 Good patterns observed
- ✅ `src/components/orders/page.tsx:680-686` — proper `role="button"`, `tabIndex`, keyboard handler, `aria-expanded`.
- ✅ `src/app/(app)/quotes/approvals/page.tsx` — `aria-selected` on tabs.
- ✅ `src/app/(mobile)/m/dashboard-view.tsx:130-150` — `role="tablist"` + `aria-selected` on time-window tabs.
- ✅ `src/components/ui/button.tsx` (assumed from shadcn) — proper focus-visible styles.
- ✅ `aria-label` on icon-only buttons (most places — verify by grep).
### 11.2 Issues
- **[Major]** `src/components/orders/page.tsx:691-697` — the linked-quote pill is a `<button>` rendered inside a `<div role="button">`. **The accessible name of the outer div includes the linked-quote pill text**, so screen readers read the pill twice (once as part of the row, once as its own button). **Fix:** move the pill to the right of the toggle, not inside it.
- **[Major]** `src/components/customer-form.tsx:287-296` — the customer form has no `<label>` elements for its inputs. The `FieldLabel` helper renders a `<Label>` (per Radix's `Label`), but the field below uses `<Input id="...">` and the label `htmlFor` isn't wired up. **Verify:** the `Label` should have `htmlFor={id}` to associate it with the input.
- **[Major]** `src/components/order-form.tsx` — same problem. `<Label>Customer</Label>` is not `htmlFor` the PopoverTrigger.
- **[Major]** `src/components/ui/sidebar.tsx` mobile drawer has `<SheetTitle className="sr-only">Navigation</SheetTitle>` — good. But the desktop `<aside>` has no `<h1>` / landmark. **Fix:** add `aria-label="Primary navigation"` to the desktop aside.
- **[Minor]** Several dialogs use the bare word "Close" as accessible name. The SheetContent's close `<button>` is `<SheetPrimitive.Close>` with no aria-label. **Fix:** add `aria-label="Close"`.
- **[Minor]** `src/app/(mobile)/m/dashboard-view.tsx:454` — close button uses literal `×` instead of an icon — accessible name is empty.
---
## 12. Other gaps
### 12.1 Real-time / SSE / WebSocket disconnect UX
- **[Critical]** No code path in the app surfaces a "Realtime disconnected" badge when PB subscriptions stop firing. The dashboards and orders list all claim to be "live" via badges or copy, but if the WebSocket drops, the operator has no signal. **Fix:** add a single shared realtime-status hook (`useRealtimeStatus`) that watches all PB EventSource connections and renders a "Reconnecting…" pill.
### 12.2 Counter success screen placeholder text
- **[Major]** `src/app/(counter)/new-order/components/success-screen.tsx:22` — *"on the New tab"* — this is **literal placeholder copy**. The success screen tells the operator the order is on "the New tab" of an unspecified list. There's no link to that tab. **Fix:** either remove the line or replace with a real sentence ("Find it in Orders → New").
### 12.3 Counter header
- **[Minor]** `src/app/(counter)/new-order/components/header.tsx` — not reviewed here. Verify it handles the empty-state (operator has placed zero orders) gracefully.
### 12.4 `/webview` page
- **[Minor]** `src/app/(app)/webview/page.tsx:22-30` — the probe uses `method: "HEAD"` with `redirect: "manual"` and a 5s timeout. **Some servers return 405 Method Not Allowed for HEAD.** The probe treats `res.ok || res.status === 302` as "up", which covers most cases, but a server returning 405 is treated as "up" because `res.ok` is false but no other guard rejects it. **Fix:** explicitly treat 405 as "can't tell" rather than up.
- **[Minor]** `src/app/(app)/webview/page.tsx:108-115` — the iframe has `referrerPolicy="strict-origin-when-cross-origin"`. Good. But the `allow="clipboard-read; clipboard-write"` is a permission policy hint — verify the embedded origin actually grants these (otherwise the clipboard buttons in the embedded app silently fail). Document this in the code comment.
### 12.5 Duplicate-orders dialog
- **[Major]** `src/components/duplicate-orders-dialog.tsx:77-90` — duplicate-detection runs on every dialog open. **The search fetches are unbounded — could be very slow on a tenant with thousands of orders.** **Fix:** add pagination + a "show more" affordance.
### 12.6 Order sprint / buyer tasks
- **[Minor]** Not deeply audited — flagged here as needing a separate pass.
### 12.7 Stock page
- **[Major]** `src/app/(app)/stock/page.tsx` — per-row "click to navigate to detail" pattern: the entire `<TableRow>` should be a navigation element. Currently it's a click handler — keyboard users can't activate it without a button. **Fix:** wrap the row in `<Link>` or add `<Button>` per row.
---
## 13. Documentation drift
### 13.1 Hardcoded brand strings
Already covered in §3 (Critical) but the spread is wider than that:
- `src/components/sidebar.tsx:284` — "Westcott Fasteners"
- `src/app/(app)/dashboard/page.tsx:271` — "Westcott Fasteners"
- `src/app/api/v1/quotes/[id]/review-approve/route.ts:326` — "from Westcott Fasteners" (email subject)
- `src/app/(auth)/login/page.tsx:20-30` (comment only) — references `/public/wfdash-logo.png`
**Fix:** introduce a single `appName` + `orgName` from a config collection; thread through every consumer.
### 13.2 Hardcoded SLA thresholds
- `src/app/(app)/quotes/page.tsx:55` — `AGE_WARNING_DAYS = 3`
- `src/lib/server/quotes-service.ts:253` — `COMPLETED_RECENT_WINDOW_DAYS = 7`
**Fix:** both should be settings.
### 13.3 Hardcoded `Math.random()` for IDs
Already covered in §10.2.
---
## 14. Recommended fix priority (rough order)
1. **Production-readiness blockers** (Critical):
- Real-time disconnect UX across all PB subscribers (§12.1)
- Auth gate on mobile + counter layouts (§1.2)
- Hardcoded brand strings (§13.1)
- No `loading.tsx` / `error.tsx` / `not-found.tsx` (§9)
- Mobile dashboard hard-redirect (§6)
- Mobile dashboard `<dialog>` open-state mismatch (§6)
2. **Major UX failures** (Major):
- react-hook-form + zod adoption (§7.1)
- Inline error messages everywhere (§7)
- Order detail / quote detail page splits (kitchen-sink files §4.1, §5.2)
- Stock page keyboard nav (§12.7)
- Mobile sheet dialog state (§6)
3. **Polish / hardening** (Minor):
- Sidebar icon duplication (§2.1)
- "Repairs" section labelling (§2.1)
- Hydration mismatches from `Math.random()` IDs (§10.2)
- Stale counter success-screen copy (§12.2)
---
## Appendix — files touched during the audit
```
src/app/layout.tsx
src/app/page.tsx
src/app/offline.tsx
src/app/(auth)/layout.tsx
src/app/(auth)/login/page.tsx
src/app/(mobile)/m/layout.tsx
src/app/(mobile)/m/page.tsx
src/app/(mobile)/m/dashboard-view.tsx
src/app/(app)/layout.tsx
src/app/(app)/dashboard/page.tsx
src/app/(app)/orders/page.tsx
src/app/(app)/orders/archive/page.tsx
src/app/(app)/orders/[id]/page.tsx
src/app/(app)/quotes/page.tsx
src/app/(app)/quotes/[id]/page.tsx
src/app/(app)/quotes/approvals/page.tsx
src/app/(app)/customers/page.tsx
src/app/(app)/customers/[id]/page.tsx
src/app/(app)/suppliers/page.tsx
src/app/(app)/sprint/page.tsx
src/app/(app)/repairs/page.tsx
src/app/(app)/pricelist/page.tsx
src/app/(app)/invoicing/page.tsx
src/app/(app)/deliveries/page.tsx
src/app/(app)/collection-confirm/page.tsx
src/app/(app)/stock/page.tsx
src/app/(app)/stock-orders/page.tsx
src/app/(app)/settings/page.tsx
src/app/(app)/webview/page.tsx
src/app/(counter)/layout.tsx
src/app/(counter)/page.tsx
src/app/(counter)/new-order/page.tsx
src/app/(counter)/new-order/components/success-screen.tsx
src/app/(counter)/new-order/components/new-customer-sheet.tsx
src/app/(counter)/new-order/components/photo-capture.tsx
src/components/sidebar.tsx
src/components/auth-provider.tsx
src/components/customer-form.tsx
src/components/order-form.tsx
src/components/quote-form.tsx
src/components/duplicate-orders-dialog.tsx
src/components/theme-picker.tsx
src/components/ui/sheet.tsx
src/components/ui/dialog.tsx
src/components/ui/input.tsx
src/components/ui/textarea.tsx
```
Total: ~40 files. The full audit findings map to roughly 30 unique issues. None are show-stoppers for a beta launch; the Critical ones should land before any production rollout.

View file

@ -1,863 +0,0 @@
# Security, Performance & Production-Readiness Audit
**Scope:** WFDASH Next.js app + PocketBase backend.
**Date:** 2026-08-28
**Author:** tom (audit only — no code changes shipped)
This document catalogues the issues that stood out while reading the
codebase end-to-end. It is intentionally a "report", not a fix-it-up
PR. Each finding has a severity (critical / major / minor), a
`file:line` reference where it lives, and a recommended fix.
Conventions used in the file refs:
- `src/...` — relative to repo root.
- Line numbers reference the current state of the file at audit time.
---
## 0. Headline summary
| Severity | Count | What it is |
| -------- | ----- | ---------- |
| Critical | 4 | Hardcoded production-looking secrets, missing security headers, very weak `AGENT_API_KEY` default, plaintext `ai_settings.api_key` storage |
| Major | 11 | Many `/api/v1/*` routes have no auth, no rate limiting, no request-size limits, unauthenticated file proxy, public CORS `*` on MCP, `localStorage` PB token, no observability, dependency risks, console noise leaking PII, no CSP, no backup plan for `ai_settings` |
| Minor | 9 | N+1 patterns, untyped env reads, missing `no-store` on sensitive endpoints, no CI, file type / extension trust, race conditions on shared rows, no HTTP `secure` cookie enforcement in dev, etc. |
The good news: there is no `dangerouslySetInnerHTML` of user-controlled
content without a custom sanitizer (`src/components/quote-email-card.tsx:195-237`),
no `eval`/`new Function` anywhere, the file-proxy has a hard whitelist
of collections, the TV PIN handler uses a constant-time-ish check with
a 250 ms flattening delay (`src/app/api/tv/login/route.ts:34-46`), and
the auth helper re-validates the PB token against PB on every call
instead of trusting the JWT.
The bad news is mostly the auth surface, secret handling, and the
"we trust the dev identity cookie" model. Those are blocking for
production.
---
## 1. Critical findings
### 1.1 Hardcoded production-looking credentials in repo root
**File:** `.env:5` (PB_ADMIN_PASSWORD=<redacted — see .env, ~10-digit numeric>)
**File:** `.env:7` (AGENT_API_KEY=<redacted — see .env, 6-digit numeric>)
`/Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP/.env` is checked into
the working tree on disk and contains what look like real values
for the production deployment (literal values redacted here to avoid
permanently leaking them into git history when this doc is committed —
see the actual `.env` file for the current values):
```
PB_ADMIN_PASSWORD=<redacted — 10-digit numeric string>
AGENT_API_KEY=<redacted — 6-digit numeric string>
```
`.gitignore` correctly excludes `.env*` (`.gitignore:28`), so the file
is **not** in git history — but a `.env.local` and `.env.local.bak`
sitting next to it (`ls -la` output) suggest the same values have been
copied into per-developer files for a while. Whoever runs `git status`
today won't see them, but a copy-paste into a screenshot, a
`tar`/backup, a CI artifact, or a sibling repo will leak them. The
length (6 digits for `AGENT_API_KEY`, 10 numeric for
`PB_ADMIN_PASSWORD`) makes both trivially brute-forceable against a
public deployment.
**Fix:**
1. Rotate both immediately. Treat them as compromised.
2. Move all secrets to a real secret store (Doppler / Vault / Coolify
env / a 1Password CLI hook). Do **not** rely on `.env.local` for
production.
3. Add a `pre-commit` + CI grep that fails on the literal patterns
`PB_ADMIN_PASSWORD=`, `AGENT_API_KEY=`, `ANTHROPIC_API_KEY=`,
`sk-`, `Bearer\s+\w+` in any committed file.
4. Make `AGENT_API_KEY` at least 32 bytes of CSPRNG output
(`openssl rand -hex 32`).
---
### 1.2 `AGENT_API_KEY` is a single bearer shared with the browser bundle
**Files:**
- `src/lib/server/agent-auth.ts:4-19` (the check)
- `src/app/api/mcp/route.ts` (the route)
- `next.config.ts:16` (the rest)
`assertAgentAuth` accepts `Authorization: Bearer <AGENT_API_KEY>` and
that single token is used by:
- The MCP `/api/mcp` route, which calls the REST `/api/v1/*` routes
internally.
- The agent CLIs that drive the same routes from outside.
The MCP route is exposed with `Access-Control-Allow-Origin: *`
(`src/app/api/mcp/route.ts:78-87` — `response.headers.set("Access-Control-Allow-Origin", origin ?? "*")`).
That means **any origin on the public internet** can call
`/api/mcp` with that bearer token, and the operator's n8n workflow
broker the same key. Combined with finding 1.1 (`AGENT_API_KEY` is a
6-digit numeric string — see redacted value),
this is the single most damaging exposure in the system.
**Fix:**
- Restrict `Access-Control-Allow-Origin` to an explicit list (e.g. the
Hermes domain and the agent's own origin). Reject any other origin
with 403.
- Issue **per-integration** keys (n8n key, Hermes key, agent CLI key)
and track them separately. Revoke the shared one.
- Move MCP and `/api/v1/*` agent routes behind a different auth path
(mTLS, signed JWT with short TTL, or at minimum an HMAC request
signature that includes timestamp + nonce).
---
### 1.3 Plaintext `api_key` in `ai_settings` PB row, with no at-rest encryption
**File:** `src/lib/server/ai-settings-service.ts:36-39` (the comment
that explicitly says "plaintext by design")
**File:** `src/lib/server/ai-settings-service.ts:132-133, 235-236`
(where the key is read/written)
`ai_settings.api_key` is stored as a plain string field on the PB
`ai_settings` row. The operator explicitly chose visibility over
encryption-at-rest in August 2026 (per the file's own comment), but:
- The PB admin JWT and the in-app UI both read the row through
admin PB (`getAdminPocketBase`), which means the key is in PB's
on-disk SQLite. Anyone with shell access to the PB container
(`pocketbase/data/data.db`) gets every operator's LLM API key.
- The `/api/v1/ai/settings` route (`src/app/api/v1/ai/settings/route.ts:43-60`)
does NOT include the secret in the response — but it does include
`api_key_set: boolean`, so any bearer holder can probe the row to
confirm whether a key is configured.
- The write path accepts the key in a JSON body over HTTPS only if
the operator has Coolify terminating TLS. In dev (HTTP) it goes
plaintext.
**Fix:**
- Encrypt `api_key` at rest using a key derived from
`process.env.AI_SETTINGS_ENCRYPTION_KEY` (32-byte CSPRNG, rotated
quarterly). Decrypt on read inside the service.
- Move the key to a sidecar (1Password CLI, Vault Agent, Coolify
secret). The PB row should hold a *reference* (vault path /
secret id), not the secret itself.
- Audit PB's `data.db` to confirm no prior keys were committed to
backup snapshots.
---
### 1.4 Zero security headers and no CSP
**File:** `next.config.ts` (no `headers()` block at all)
`next.config.ts:1-46` configures `output: "standalone"`,
`serverExternalPackages`, `allowedDevOrigins` (which includes
`"*"`!), and redirects. It does **not** set any
`Content-Security-Policy`, `X-Frame-Options`,
`Strict-Transport-Security`, `Referrer-Policy`, or
`X-Content-Type-Options`. The only "origin policy" in the file is
`allowedDevOrigins: [..., "*"]`, which is the most permissive
possible value and is shipped in the production build because there
is no env-gated branch.
**Fix:**
- Add a `headers()` block in `next.config.ts`:
```ts
async headers() {
return [{
source: "/:path*",
headers: [
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' <POCKETBASE_URL> <HERMES_URL> <N8N_URL>; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=()" },
],
}];
}
```
Tweak `script-src` to match your actual scripts (you have
`next/script` and `dangerouslySetInnerHTML` in two components, so
`'unsafe-inline'` is currently needed; replace with nonces when
you can).
- Move `allowedDevOrigins` out of the production config. Gate it
on `process.env.NODE_ENV === "development"` and remove the `*`.
- Turn on HSTS at the Coolify/proxy level too (the header is
already standard for Coolify + Traefik).
---
## 2. Major findings
### 2.1 Wide attack surface: 102 of 180 API routes have no auth
**Files:** `src/app/api/v1/orders/[id]/attachments/upload/route.ts`,
`src/app/api/v1/agent/scan/route.ts`, `src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts`,
plus every route in the following directories (sample listing):
- `src/app/api/v1/stock-purchases/**/attachments/**`
- `src/app/api/v1/purchase-orders/**/attachments/**`
- `src/app/api/v1/quotes/**/attachments/**`
- `src/app/api/v1/quotes/[id]/pdf/**` (PDF render endpoints)
- `src/app/api/v1/repairs/**` (entire resource — all 6 routes
unauthenticated)
- `src/app/api/v1/buyer-tasks/**` (PO generation, mark-ready)
- `src/app/api/v1/collection-confirm/**` (confirm-stock, invoice-upload)
- `src/app/api/v1/sprint/items/**/claim` and `attachments/upload`
- `src/app/api/v1/invoicing/**`
- `src/app/api/counter/orders/[id]/attachments/route.ts`
- `src/app/api/invoicing/delivery-note/route.ts`
- `src/app/api/invoicing/customers/route.ts`
- `src/app/api/invoicing/delivery-note-pdf/route.ts`
- `src/app/api/tv/login/route.ts` (PIN-gated but no rate limiting,
see 2.4)
A spot count via `find src/app/api -name "route.ts" -exec grep -L
"assertBrowserSession\|assertAgentAuth" {} \; | wc -l` shows
**102 of 180 routes have no `assertAgentAuth` or `assertBrowserSession`
call**. They rely on the route being called from the in-app UI, which
in turn relies on PocketBase's collection rules being tight.
Concrete consequences:
- `POST /api/v1/orders/[id]/attachments/upload` — anyone with the URL
can append arbitrary files to any order's `order_attachements` field
on PB. PB will accept up to 10 files per order (PB schema is the
only gate).
- `GET /api/v1/agent/scan` — the entire PO triage report (which
orders are stuck, which suppliers owe responses) is world-readable
to anyone who can reach the app.
- `GET /api/v1/files/[collection]/[recordId]/[filename]` — gated by
the `ALLOWED_COLLECTIONS` whitelist, but the whitelist is a
reasonable superset of every collection that holds
operator-uploaded documents. Anyone can read any operator's PDFs
by guessing or scraping the URL shape
(`/api/v1/files/orders/<id>/<pdf>`).
- `POST /api/v1/repairs/[id]/files/[field]` — no auth, no
rate-limit, no size check beyond what PB enforces. The field is
validated against `ALLOWED_FIELDS` but nothing else.
**Fix:**
- Apply a single auth boundary, ideally in middleware. The
current `src/middleware.ts:8-20` is a no-op pass-through. The
comment correctly explains why (the cookie-validating code uses
`node:crypto`, which Edge runtime can't bundle), but the
alternative isn't "do nothing" — it's "validate auth in each
route handler" and that work hasn't been done for these 102
routes.
- Pragmatic stopgap: add a global API middleware (Node runtime)
that:
1. Skips only `/api/v1/health`, `/api/tv/login`,
`/api/counter/orders/[id]/attachments` (PWA kiosk).
2. For everything else under `/api/v1/*` and `/api/mcp`,
require a valid `AGENT_API_KEY` *or* a `assertBrowserSession`
pass.
3. For `/api/invoicing/*`, `/api/counter/*`, `/api/tv/*` —
per-route auth as appropriate.
- Long-term: switch to a per-route `_auth` wrapper that the
middleware can compose.
---
### 2.2 PocketBase auth token kept in `localStorage`
**File:** `src/lib/pocketbase.ts:18-46`
The PB SDK's `authStore` is persisted to `localStorage` under
`pocketbase_auth` with the raw JWT and the user record. Any XSS
on the app would let an attacker read this and impersonate the
operator with full PB privileges (the token is the user's PB JWT,
so it can do whatever the user can do per PB's collection rules).
The app has at least two `dangerouslySetInnerHTML` callsites that
render either operator-controlled data (the email body) or
theme-bootstrap script. The email body sanitizer
(`src/components/quote-email-card.tsx:195-237`) is a
hand-rolled regex sanitizer — it blocks `javascript:`, strips
event handlers, and limits tags. But the comment at line 177
explicitly says "This is NOT a general-purpose sanitizer (use
DOMPurify for that)." That is a future footgun if anyone adds a
new tags-allowing bypass (e.g. `<svg>`-with-`<use>` is currently
allowed because `<svg>` is not in the allow-list, but a small
regression in the regex could let a `<style>` through if the
stripper changes).
**Fix:**
- Move PB tokens out of `localStorage` into an `httpOnly;
SameSite=Lax; Secure` cookie set by a server endpoint. The
browser can keep using PB's SDK by hitting that cookie. The
current `assertBrowserSession` already does server-side
validation — the gap is the storage location.
- Replace the hand-rolled email sanitizer with DOMPurify
(`isomorphic-dompurify` for SSR). Add it to the dep list and
drop ~70 lines of custom regex code.
- Add a strict CSP (see 1.4) so even if XSS happens, it can't
call out.
---
### 2.3 No rate limiting anywhere
**Files:** every `/api/**/route.ts`, especially:
- `src/app/api/tv/login/route.ts` (6-digit PIN brute-force window)
- `src/app/api/v1/quotes/[id]/from-email/route.ts` (PB write loop)
- `src/app/api/mcp/route.ts` (admin PB on every tool)
- `src/app/api/v1/orders/[id]/attachments/upload/route.ts`
(multipart upload)
The TV PIN is 6 digits, so 1 000 000 combinations. The handler has
a 250 ms flattening delay (`src/app/api/tv/login/route.ts:35-38`),
which means ~4000 guesses/min from a single IP. No
`X-Forwarded-For` trust boundary is enforced, so an attacker
spoofing source IPs can multiply that. No per-IP lockout.
`assertAgentAuth` does not rate-limit at all. A leaked
`AGENT_API_KEY` (see 1.1) means a free pass to admin PB.
**Fix:**
- Add a simple token-bucket rate limiter in `src/middleware.ts`
(Edge runtime) keyed on the request IP. ~30 req/min per IP
globally, ~5/min on `/api/tv/login`, ~10/min on agent routes.
- For the TV PIN, lock out an IP for 60 seconds after 5 wrong
attempts in a 5-minute window.
- For the MCP and `/api/v1/*` agent routes, rate-limit per
`Authorization` token (so a leaked key is at least throttled).
- Consider adopting `@upstash/ratelimit` or `next-safe-action`'s
rate-limit helper to avoid hand-rolling token buckets.
---
### 2.4 File upload routes have no per-route size cap and trust PB's defaults
**Files:**
- `src/app/api/v1/orders/[id]/attachments/upload/route.ts:39-50`
(the comment says "default 5 MB but operators usually set 25 MB")
- `src/app/api/v1/collection-confirm/invoice-upload/route.ts`
- `src/app/api/counter/orders/[id]/attachments/route.ts:14-22`
(only enforces a photo cap, but does it via env)
- `src/app/api/v1/repairs/[id]/files/[field]/route.ts`
The order-attachment route says "PB enforces 25 MB" but never
re-validates against the runtime value of PB's schema. An
attacker can POST a 25 MB binary, PB will store it, the proxy
will serve it back, and the operator pays the storage + bandwidth
cost.
More importantly, **MIME type is not validated** at the route.
`file.type` is browser-supplied and trivially spoofable in a
direct `curl`. PB stores whatever the client claims. A PDF-claimed
HTML file uploaded to `order_attachements` is then served back
through `/api/v1/files/orders/<id>/<file>` with `Content-Type:
text/html` (PB echoes the stored MIME) and rendered in the
operator's preview pane. The preview pane uses `<iframe>` /
`<img>` / `<object>` depending on type, so a successful upload
turns into an XSS on the operator's next visit to the order
detail page.
**Fix:**
- In each upload route, after `req.formData()`:
1. Enforce a per-route cap (e.g. 10 MB for orders, 8 MB for
counter photos) using `file.size` BEFORE handing to PB.
2. Validate `file.type` against an allow-list per field
(`order_attachements`: `image/*`, `application/pdf`; counter:
`image/*` only; repairs.photos: `image/*` only;
repairs.quote/PO/DN: `application/pdf` only).
3. Validate the **filename** is not a path traversal attempt
(no `..`, no leading `/`, no null bytes) and has an
appropriate extension.
- Set `Content-Disposition: attachment; filename="..."` in the
proxy (`src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts:152-154`)
for HTML and any other "executable" MIME, **even if the
operator originally uploaded it as PDF**. The proxy's
current `isViewable` heuristic trusts PB's stored content-type,
which the attacker controls.
---
### 2.5 `next.config.ts` ships `allowedDevOrigins: ["*"]`
**File:** `next.config.ts:18-20`
`allowedDevOrigins` is a dev-server feature that lets the HMR
client connect from a non-`localhost` origin. Setting it to `*`
in a `next.config.ts` that's the same file used for `next build`
and `next start` means the production server also allows HMR
from any origin. While the HMR socket is dev-only, having `*` in
the config is misleading and a sign that the dev/prod split is
missing.
**Fix:**
```ts
const isDev = process.env.NODE_ENV !== "production";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["pdf-parse", "pdfjs-dist", "@napi-rs/canvas"],
...(isDev ? { allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.42", "192.168.1.122", "192.168.1.128"] } : {}),
...
};
```
---
### 2.6 `next.config.ts` redirects include the local PIN
**File:** `next.config.ts:30-44`
The `/counter → /new-order` and `/tv → /pin` redirects are fine.
But the underscore-to-hyphen redirects for `/api/v1/order_items`
etc. are a soft commitment: agents (and humans copy-pasting from
PB collection names) reach the right URL, but it means the
server will accept *both* spellings forever. The redirect is
`permanent: true` so it 308s in perpetuity. That's a long-term
naming inconsistency baked into the public API.
**Fix:**
- Keep the redirects, but add an HTTP `Deprecation` or `Sunset`
header on the underscore paths so callers can migrate.
- Or, change PB collection names to use hyphens (PB allows it)
and drop the redirect.
---
### 2.7 CORS on `/api/mcp` is `*` (any origin can use the agent key)
**File:** `src/app/api/mcp/route.ts:78-87`
```ts
function withCors(response: NextResponse, req: NextRequest): NextResponse {
const origin = req.headers.get("origin");
response.headers.set("Access-Control-Allow-Origin", origin ?? "*");
...
}
```
If the request has no `Origin` header (e.g. `curl`, a server-side
agent), the response sets `*`. With an `Origin`, the response
echoes the origin back. That is functionally `Access-Control-Allow-Origin: *`
**and** credentialed CORS at once. The comment on line 68-77 says
"Production should narrow the origin allow-list" — it has not been
narrowed.
**Fix:**
- Replace with an allow-list:
```ts
const ALLOWED_ORIGINS = new Set([
process.env.NEXT_PUBLIC_SITE_URL,
"https://hermes.example.com",
]);
const origin = req.headers.get("origin");
if (origin && ALLOWED_ORIGINS.has(origin)) {
response.headers.set("Access-Control-Allow-Origin", origin);
response.headers.set("Vary", "Origin");
}
// No CORS headers = browser will block; CLI/server callers ignore
```
---
### 2.8 No structured logging / observability
**Files:** 132 `console.*` callsites across `src/`, no logger
abstraction, no Sentry / OpenTelemetry / pino / datadog.
`grep -rn "console\." src/ --include="*.ts" --include="*.tsx" | wc -l` → 132.
The counts include:
- 13 in `src/app/(app)/orders/[id]/process/page.tsx` (operator PII
in the body of the log lines)
- 10 in `src/app/api/v1/quotes/[id]/pdf/route.ts`
- 7 in `src/app/api/v1/quotes/[id]/submit/route.ts`
The messages include `console.log` of error bodies that contain
the customer's email subject, the supplier name, and the parsed
email body (`src/app/(app)/orders/[id]/process/page.tsx:1396-1405`).
That is PII leaking into the server's stdout.
`process.env` is also used directly in many places without a typed
loader (`grep -rn "process.env" src/lib/server/` returns 30+
results), so a typo in an env var name (`ORDER_ITEMS_COLLECTIONS`
vs `ORDER_ITEMS_COLLECTION`) silently falls back to a default and
nobody notices.
**Fix:**
- Add `pino` with a redact list (`req.headers.authorization`,
`pb.authStore.token`, `*.email`, `*.password`). Wire it into
the route-handler error catch in `src/app/api/v1/*`.
- For the Next.js server side, set up `pino-pretty` in dev and
ship JSON to stdout in prod (Coolify picks it up).
- Add Sentry (or self-hosted GlitchTip) with the same redact
config. Wrap the `assertBrowserSession` and `assertAgentAuth`
calls so the path / actor / status code are recorded.
- Centralise env access in `src/lib/server/env.ts` using
`zod` or `t3-env` so typos fail at boot.
- Replace `console.log("[send-po] ...", pdfErr)` and similar
with `logger.error({ err, ...context }, "send-po failed")`.
---
### 2.9 Lucide-react pinned to a very old `1.16.0` fork
**File:** `package.json:73` — `"lucide-react": "^1.16.0"`
**File:** `package-lock.json:8499-8506` — confirmed version
The current published `lucide-react` package is at version
`0.4xx+` (the `0.x` line is the actively-maintained one by the
Lucide maintainers). Version `1.16.0` is from a *separate* fork
(the original Lucide pre-rebrand, ISC-licensed, last published
2018-ish). Peer-dep is `"react": "^16.5.1 || ^17.0.0 || ^18.0.0 ||
^19.0.0"`, so it does work with React 19, but it ships the old
"Lucide" icon set (outlined style) and is no longer receiving
security or icon updates.
This is a maintenance risk, not a known CVE. If a future
advisory lands against the `1.x` line, the project has no
upgrade path short of re-mapping every icon import.
**Fix:**
- Audit the icon list used (`grep -rn "from \"lucide-react\"" src/`).
- Migrate to the `0.x` line (`lucide-react@^0.460.0`) — every
icon name has been preserved, but you'll get active maintenance
+ a much larger icon set.
- If migrating is too expensive today, pin a known-good lockfile
revision and add a Renovate / Dependabot rule to track upstream.
---
### 2.10 PDF rendering libs: `pdfjs-dist` 5.x and `pdf-parse` 2.4.5
**File:** `package.json:78-79`
**File:** `next.config.ts:10-13` (the `serverExternalPackages`
escape hatch)
`pdfjs-dist` 5.x has had multiple CVEs in 2024-2025 (font
parser, JBIG2 decoder, arbitrary code execution on crafted
PDFs). The escape hatch `serverExternalPackages: ["pdf-parse",
"pdfjs-dist", "@napi-rs/canvas"]` is a workaround for webpack's
RSC runtime, not a security control — and the comment
(`next.config.ts:8-13`) admits the underlying lib has ESM/CJS
quirks that suggest an older or untested version may be in use.
`pdf-parse@2.4.5` is *not* the same package as the
`pdf-parse@1.1.1` that npm originally had; it's a separate
maintainer with a recent rewrite, and there's no public
security audit of 2.x yet.
**Fix:**
- Pin both to the latest patch of a known-good line. Add
`npm audit` to CI.
- Prefer `@react-pdf/renderer` (already a dep) for any
*generated* PDFs. Only use `pdfjs-dist` for parsing
untrusted PDFs, and:
1. Strip active content (JS, embedded files) before parse.
2. Run parsing in a worker (already done via Next.js's
node runtime) so a memory-corruption bug stays in
the worker.
3. Cap input size (see 2.4) so an attacker can't
OOM the worker.
---
### 2.11 `dev-identity` cookie is the de-facto auth for many flows
**File:** `src/lib/dev-identity.ts:46-67`
`getServerIdentity` reads identity in this order:
1. `X-User-Email` request header (any caller can set this)
2. `wfdash_dev_email` cookie (the UI switcher)
3. `DEV_USER_EMAIL` env var
4. `DEFAULT_DEV_EMAIL = "david.els1@outlook.com"`
The header is explicitly listed as a per-request override — but
**any HTTP client can set `X-User-Email: <anything>` and be
attributed as that operator**. The function is used by routes
that need an actor name (audit notes, claim owners), so a
malicious caller can spoof audit-trail entries by setting the
header.
The comment block at the top of the file (`dev-identity.ts:1-20`)
calls out a real migration plan: "when real PB auth lands,
replace `getServerIdentity` with `assertBrowserSession`." Until
then, the system is in an explicit "no auth" mode, but the routes
that depend on identity don't all gate on it.
**Fix:**
- Drop the `X-User-Email` header override. Make the cookie the
only override. Validate the cookie against a known set of
dev emails (or against `pb.collection("users").authRefresh`).
- Audit every call site:
```bash
grep -rn "getServerIdentity\|DEV_IDENTITY" src/app/api
```
and ensure each is `assertBrowserSession`-gated before
trusting the identity.
- Set a clear `DEADLINE` (e.g. 30 days) for removing the
`DEFAULT_DEV_EMAIL` fallback so this can't quietly ship
to prod.
---
## 3. Minor findings
### 3.1 CORS preflight sets `Access-Control-Max-Age: 86400`
**File:** `src/app/api/mcp/route.ts:85`
CORS preflight is cached for 24h. If you tighten the CORS
allow-list (see 2.7), browsers will keep allowing disallowed
origins for 24h. Lower to 600s (10 min) during the transition.
### 3.2 No request size cap on JSON endpoints
**File:** all `req.json()` callers in `src/app/api/v1/*/route.ts`
The default Next.js `req.json()` is bounded by the framework
defaults, but no route explicitly sets a max. A 100 MB JSON
body on `POST /api/v1/orders/create` would parse, allocate, and
only then be rejected as "items must be a non-empty array".
**Fix:** Add an explicit content-length check or use `req.json({
limit: "1mb" })` where supported.
### 3.3 `assertBrowserSession` doesn't bind to PB user id
**File:** `src/lib/server/browser-session.ts:30-44`
The function refreshes the JWT but never extracts the user id.
Downstream code that needs to "is this user the one who claimed
this row?" can't tell. Add a return value:
```ts
export async function assertBrowserSession(req: NextRequest): Promise<{ id: string; record: PBRecord }> { ... }
```
and propagate the `id` into `getServerIdentity` so the two
paths converge.
### 3.4 PB admin auth token cached in module-level memory indefinitely
**File:** `src/lib/server/pocketbase-admin.ts:7-15, 78-89`
`cachedAdminAuth` lives in the Node module scope. In a serverless
deploy (Vercel, AWS Lambda) this is fine (per-invocation). In
the standalone Next.js server (Coolify), it lives for the
process lifetime. If an operator's PB password is rotated, every
running instance keeps using the old token until process restart
(5-minute drift, per the `ADMIN_TOKEN_REFRESH_BUFFER_MS = 60_000`
buffer). The `clearAdminPocketBaseAuthCache` export exists but
no caller uses it.
**Fix:** Call `clearAdminPocketBaseAuthCache` from a
`process.on("SIGHUP")` (or a `/api/v1/admin/reload-auth`
debug-only endpoint) so a PB password rotation can take effect
without a process restart.
### 3.5 Many `getFullList` calls — N+1 risk
**File:** `src/lib/server/orders-service.ts:433, 473, 482, 621, 657, 696, 873`
The service uses `pb.collection(...).getFullList(...)` in many
places. PB paginates this internally (default 500/batch), so
"full list" really means "iterate until done". For collections
in the tens of thousands (`order_items` is approaching that),
this is slow under load.
**Fix:** Replace with paginated reads or filter-then-page
where the caller only needs a slice. Add a regression test
that asserts total latency for a 5 000-order workspace.
### 3.6 No CI, no test gating, no pre-commit hooks
**Files:** `package.json:73-90` has no `lint:fix`, no
`format:check`, no `test:ci` script. No `.github/workflows/`,
no `.gitlab-ci.yml`. The Dockerfile builds but doesn't run
tests.
**Fix:**
- Add a GitHub Actions workflow that runs `npm run lint`,
`npm run test:run`, and `npm audit --audit-level=high`
on every PR.
- Add Husky + lint-staged to enforce formatting on commit.
### 3.7 `console.error` messages embed PII (operator names, customer emails)
**File:** `src/app/(app)/orders/[id]/process/page.tsx:1276, 1285, 1396, 1405`
Examples (paraphrased — see actual file for exact strings):
- `console.error("[send-po] PDF fetch threw:", pdfErr)` — `pdfErr`
includes the full response body, which includes the order
number + customer email.
- `console.error("[send-po] PDF generation failed:", errBody)` —
ditto.
- `console.log(...)` in the same file dumps the customer
subject line to stdout.
**Fix:** Adopt a `logger.error({ err, orderId, userId, route }, ...)`
shape (see 2.8) and never log raw request/response bodies.
### 3.8 TV session cookie is not `secure` in dev
**File:** `src/lib/server/tv-session.ts:80-83`
**File:** `src/app/api/tv/login/route.ts:64-66`
The cookie sets `secure: req.nextUrl.protocol === "https:"`. In
dev (`http://localhost`), the cookie goes out as `Secure: false`
which is correct for localhost. But there is no environment
check that prevents `secure: false` from shipping to a
production Coolify deploy that terminates TLS at a proxy and
forwards plain HTTP internally — if `req.nextUrl.protocol` ever
reads `http` in that scenario (it should, since Next sees the
incoming protocol from `X-Forwarded-Proto` only when explicitly
configured), the cookie will be sent over plain HTTP and an
attacker on the LAN can steal it.
**Fix:** Force `secure: process.env.NODE_ENV === "production"`
in the cookie options; trust the env, not the request protocol.
### 3.9 `MCP_ERR_NETWORK` (-32010) and `MCP_ERR_UPSTREAM` (-32011) can leak internal URLs
**File:** `src/lib/server/mcp-upstream-fetch.ts:55-75`
The error envelope includes `data.url` — the full upstream URL
the MCP route tried to call. In a container deploy that URL is
`https://0.0.0.0:3000` (per the `Dockerfile` `HOSTNAME="0.0.0.0"`
and the `upstreamOrigin` fix in the MCP route). The comment
acknowledges the issue but the fix is partial. An agent that
reads this error and uses the URL to call back will hit
`0.0.0.0` from outside the container and fail.
**Fix:** Strip the URL down to origin + path in production.
For local-dev, keep the full URL. Use an env flag.
### 3.10 No CORS on `/api/v1/files/...` proxy
**File:** `src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts`
The file proxy is meant to be loaded by `<iframe src=...>` and
`<a target=_blank href=...>`. Both are same-origin in the
operator flow, so no CORS is needed today. But if a Hermes
frontend tries to embed a PDF preview in an `<iframe>`, the
browser will block it because there's no `Access-Control-Allow-Origin`.
Either add CORS to the proxy for the Hermes origin, or document
that the proxy is same-origin-only.
### 3.11 `tsconfig` paths used in tests require real `process.env` setup
**File:** `vitest.config.ts` (not opened but referenced in
README)
**File:** `src/lib/server/agent-auth.test.ts:6-16` (must set
`AGENT_API_KEY` before import)
Many tests assume `process.env.PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD`
are set. If they're missing at import time, tests fail with
confusing errors. Add a `test-setup.ts` (referenced in
`package.json`) that asserts the env or stubs `getAdminPocketBase`.
### 3.12 `output: "standalone"` includes dev artifacts in the image
**File:** `Dockerfile:1-31`
The Dockerfile uses `node:20-alpine` (3-year-old EOL track —
Node 20 is in maintenance until April 2026, EOL October 2026).
The `builder` stage copies `.` into the image, including the
`docs/`, `scripts/`, `e2e/`, `screenshots`, `.audit-screenshots/`,
and PB exports. The `runner` stage only copies `public/`,
`.next/standalone/`, `.next/static/`, so the extras don't ship —
but the build cache is bigger than it needs to be.
**Fix:**
- Add `.dockerignore` excluding everything except `src/`,
`public/`, `package.json`, `package-lock.json`, `next.config.ts`,
`postcss.config.mjs`, `tsconfig.json`, and `next-env.d.ts`.
- Bump base image to `node:22-alpine` (current LTS as of
2026-08). Pin to a specific minor (e.g. `node:22.11-alpine`)
for reproducibility.
---
## 4. Production readiness checklist (roll-up)
| # | Item | Status | Owner |
|---|------|--------|-------|
| 1 | Rotate `AGENT_API_KEY` to ≥32 bytes CSPRNG | blocking | ops |
| 2 | Rotate `PB_ADMIN_PASSWORD` | blocking | ops |
| 3 | Move secrets out of `.env` files into a real store | blocking | ops |
| 4 | Add CSP, HSTS, X-CTO, X-Frame-Options, Referrer-Policy headers | blocking | dev |
| 5 | Add a global API auth middleware; remove `allowedDevOrigins: "*"` from prod config | blocking | dev |
| 6 | Restrict CORS on `/api/mcp` to an allow-list | blocking | dev |
| 7 | Move PB auth token out of `localStorage` into `httpOnly` cookie | blocker for prod | dev |
| 8 | Replace hand-rolled email HTML sanitizer with DOMPurify | major | dev |
| 9 | Add per-route upload size cap + MIME validation | major | dev |
| 10 | Add rate limiting (TV PIN, agent routes, global) | major | dev |
| 11 | Encrypt `ai_settings.api_key` at rest | major | dev |
| 12 | Add structured logging + Sentry/GlitchTip; redact PII | major | dev |
| 13 | Add CI (lint + test + audit) | major | dev |
| 14 | Replace `getFullList` patterns with paginated reads where feasible | perf | dev |
| 15 | Drop `X-User-Email` header override in `getServerIdentity` | security | dev |
| 16 | Force `Secure` cookie in prod for TV session | minor | dev |
| 17 | Migrate `lucide-react` to the `0.x` maintained line | maintenance | dev |
| 18 | Bump base Docker image to Node 22 LTS | maintenance | ops |
| 19 | Centralise `process.env` access in a typed env module | quality | dev |
| 20 | Add CORS to file proxy for Hermes embed | depends on Hermes scope | dev |
---
## 5. What I deliberately did **not** flag
- **`escapeFilterValue` in `src/app/(app)/invoicing/page.tsx:103`** —
The filter strings are built from server-resolved PB ids and the
comment makes the escaping contract explicit. This is safe.
- **The hand-rolled email sanitizer in `src/components/quote-email-card.tsx:195-237`** —
Has gaps (no DOMPurify) but is well-tested in scope. The
recommendation is to swap it for DOMPurify, not to remove the
current check.
- **`getServerIdentity` cookie path** — The cookie path is `/`,
`SameSite=Lax`, and 1 year max-age. For the dev-identity
temporary stand-in this is acceptable; the real fix is to
replace it (see 2.11).
- **`assertAgentAuth` timing** — String comparison of fixed-length
tokens is not constant-time, but the token is large enough that
timing attacks are impractical. If you rotate to a 32-byte
CSPRNG token, this becomes irrelevant.
- **PDF and image generation libs (`jspdf`, `pdf-lib`,
`@react-pdf/renderer`)** — These are widely used and have no
known critical CVEs at the pinned versions. Re-evaluate at
next major bump.
- **`xlsx` and `mammoth`** — Used for *operator-supplied* file
parsing (price lists, supplier docs). They have a long history
of CVEs around embedded XXE / formula injection. They are
scoped to trusted-operator input today, but if you ever expose
the upload UI to a customer, the same input boundary needs the
same treatment as 2.4.
---
*End of audit. No code was changed during this task — all
recommendations are documented here for the team to triage.*

View file

@ -1,189 +0,0 @@
# Collection Confirm AI checks runbook
Design: `docs/superpowers/specs/2026-10-17-collection-confirm-ai-checks-design.md`.
## What it does
On Collection Confirm, drivers upload photos of the supplier's tax invoice
or delivery note. The AI (the provider set in Settings, AI) reads each
photo, pairs the document lines with our order lines, and shows four ticks
per line (SKU, quantity, description, price), plus a POS column and an
"AI findings" box when something at document level looks wrong.
- It runs automatically (n8n clock, every 5 minutes) on files not yet read,
and on demand with the **Check with AI** button on a card.
- **Check with AI** needs only the global AI switch. The scheduled tick
needs both the global AI switch and "Collection checks enabled".
- A file is read once (by content hash, stored in `collection_doc_reads`).
A failed photo read is retried only by the **Check with AI** button, not
by the tick.
- Lines with no AI check look exactly as before, with no ticks.
- **Add to price list** (was "Add to POS", changed 2026-10-30) is a manual
feature (per line, or "Confirm and add to price list" on the card). A
customer line goes to the `price_list` catalogue at cost + 80%, never to
the POS: only stock goes on the counter, and nothing there may be R0. A
line needs a SKU, a cost and a supplier first; the toast reports lines
added, failed and still needing a SKU / cost / supplier. The buttons are
currently hidden on the card (see `collection-return-to-pool.md`).
What the AI writes: only `ai_check` on the order/stock line, the document
reads and an **empty SKU** when it is sure. It never writes a price. It never overwrites a SKU a person set, never confirms a collection,
and never changes status.
Discounts: the AI reads a line's own "Disc %" column and a discount printed
only in the invoice totals, and the Price tick compares our cost with the
price **after** discount. It **never changes a cost** (changed 2026-10-30):
it once read a 15% discount that wasn't on the invoice (PO202610005TEL) and
cut a cost. A cost that differs from the discounted price is a red Price
tick, which also keeps the card out of auto-push, so a person settles it.
Cost changes the AI made before then still show in purple under the line.
On order lines the SKU fill is audited as an ordinary `sku` change; stock
purchase lines (`stock_purchase_items`) are not covered by the audit hook,
so a SKU fill there is not in the audit log.
## Going live (manual steps, in this order)
1. **Migrate PocketBase.** Run against the live PB:
`npm run pb:migrate:collection-ai`
(creates `collection_doc_reads`, adds `ai_check` to `order_items` and
`stock_purchase_items`, and the `collection_ai_*` fields on
`ai_settings`).
2. **Redeploy the audit hook** `pocketbase/pb_hooks/audit_log_utils.js` to
the PB server's `pb_hooks/`. It now ignores `ai_check`; until then the
only cost is noisy history (a large JSON diff on every re-run).
3. **Deploy** the app. Pushing to main does not auto-deploy; it is a
manual deploy that takes 5 to 10 minutes.
4. **Activate the n8n workflow** (below). Create it inactive, activate it
after steps 1 to 3.
5. **Turn it on** in Settings, AI: the global "AI enabled" switch must be
on, then switch on "Collection checks enabled" in the Collection checks
section and click Save.
The tick does nothing until both switches are on, so steps 4 and 5 are
safe in either order.
## n8n workflow: "WFDASH Collection check tick"
n8n only acts as a clock; the app decides whether anything is due.
| Node | Setting |
|---|---|
| Name | `WFDASH Collection check tick` |
| Trigger | Schedule Trigger, every 5 minutes |
| Action | HTTP Request |
| Method / URL | `POST` `https://wfdash.apphub.co.za/api/v1/collection-confirm/ai/tick` |
| Authentication | The `WFDASH Agent API Key` credential (`Authorization: Bearer <AGENT_API_KEY>`). Do not create a new secret. |
| Body | none |
| Options | Timeout **120000 ms** (120 s); Never Error (neverError) **on** |
| Status | **inactive** until the app is deployed and migrated |
After deploy, Execute workflow once. Expected responses:
`{"ran":false,"reason":"disabled"}` while off, `{"ran":false,"reason":"running"}`
while an earlier run is still going, `{"ran":true,"summary":{...}}` when it
ran. A 401 means the credential is wrong.
Each run is bounded: at most 10 files, and it stops starting new files
after 90 seconds, so it normally answers inside the 120 s timeout;
leftovers carry to the next run. Runs never overlap: a run holds a lease
for up to 10 minutes, and if a run dies without writing its summary the
next tick after that takes over.
The last run (files read, cards checked) is shown in Settings, AI,
Collection checks, and stored in
`ai_settings.collection_ai_last_run_summary`.
Photo on the wrong PO (added 2026-10-19): when the AI reads a PO number
on a photo that isn't the card's PO, and that number is a PO we have, the
card says "Photo may be for <PO>" and, when open, "Invoice <no> is for
<PO>, not this PO" with a **Move photo to <PO>** button. The move is a
person's call — nothing moves on its own. It copies the photo onto the
other PO (a collected line of a customer PO, or the stock purchase's one
photo slot — refused if that slot is taken), removes it from this line,
and deletes the AI's read of it so it is read again where it now belongs
(`POST /api/v1/collection-confirm/move-photo`,
`src/lib/server/collection-photo-move-service.ts`).
Delete photo (added 2026-10-19): next to "Add supplier invoice", **Delete
photo** removes the photo open in the previewer — for a wrong upload or a
duplicate — after an inline "Delete …? Yes, delete" confirm. It takes the
file off every order line (or the stock purchase) carrying it and deletes
the AI's read of it. The PO PDF can't be deleted there. This can't be
undone (`POST /api/v1/collection-confirm/delete-photo`).
Stock orders (changed 2026-10-19): a driver collecting a stock pickup
records the full quantity as received, so collected stock lines used to
skip Collection Confirm and sit "sent" until someone clicked Mark received
(29 stock orders were stuck). Now a collected stock line stays on
Collection Confirm (with its photo and AI check) until its stock order is
marked received; **Confirm stock** marks it received once every line is
collected — that sends it to the accountant's booking queue. Orders with
lines still out with a driver stay open (the toast says how many).
Cancelled stock orders never show. Driver photos for stock pickups are
kept on the stock purchase (`additional_attachments`, multi-file after
`npm run pb:migrate:stock-photos`).
## Auto-push (added 2026-10-28)
With **Auto-push verified cards** on (Settings, AI, Collection checks, off by
default), the tick confirms a card on its own when the page shows it purple
(`cardAiState` "verified": every line checked, no red or amber tick). It runs
at the end of every tick over every card on the page, so a card a person
checked by hand is covered too. It calls the same functions as the manual
buttons (`confirmOrderItemsWithSplits`, `confirmStockRows` +
`markStockOrdersReceived`), as actor "AI collection check".
- Customer lines go to ready for invoicing with the invoice number and date
the AI read. A card covered by two invoices is pushed one invoice at a time.
- Stock lines go to the accountant's stock booking, but only when every line
of the stock order is already collected (no follow-on order is created by
the AI).
- A card is left for a person when: any line has a problem flag, a photo
looks like it belongs to another PO, or a customer line has no invoice
number or no `YYYY-MM-DD` date. Grey ticks do not block it (same rule as
the purple card).
- Every push is appended to `ai_settings.collection_auto_push_log` (kept
three days). The page shows today and yesterday at the top: PO, supplier,
items, value. Endpoint: `GET /api/v1/collection-confirm/auto-push-log`.
- Without the PB fields it never pushes (it could not record the push).
Going live: `npm run pb:migrate:collection-auto-push`, deploy, then switch it on
in Settings, AI and Save.
## Reading the ticks
| Colour | Meaning |
|---|---|
| Green | The document agrees with our line: our SKU equals the document code after normalising (uppercase, letters and digits only; this is also how a SKU the AI filled goes green), quantity equals what was collected and what was ordered, the AI is sure it is the same item, unit price after any discount within 1 cent |
| Amber | Close but worth a look: our SKU differs from the document code; collected quantity matches the document but the ordered quantity differs; the AI thinks it is the same item but is only "likely" sure |
| Red | Mismatch or missing: no SKU on our line, quantity differs from the document, price differs or we have no cost, or the line is not on the document or the AI says it is a different product (all four red) |
| Grey | Can't check, per tick: **SKU** when the document line shows no code; **Qty** when it shows no quantity; **Price** when the document line shows no price (a delivery note that prints prices, like Plumblink's, is compared like an invoice — but the AI only ever corrects a cost from a tax invoice); **all four** when the AI couldn't pair the document (see the AI findings box for the reason; **Check with AI** retries) |
For stock purchase lines the SKU tick and robot use the line's own SKU
(the one a rep or the AI set on the purchase line), not the catalogue SKU.
The AI findings box also lists the AI's **reading notes** — what it noticed on the photo (a delivery note rather than an invoice, the year missing from the date, a rotated or partly covered page).
Hover a tick for the values compared (ours vs the document). A robot icon
marks a SKU the AI filled. Click the document chip on a card to see the
extracted lines next to the photo.
## Errors
- **"Collection checks are not set up yet: run `npm run pb:migrate:collection-ai`
against PocketBase first"** is the guard. The service refuses to run
until `ai_check` exists on the live PB, because PB silently drops
unknown fields and results would vanish. Run step 1 above.
- **"AI is disabled in settings"** from **Check with AI**: switch on the
global AI switch in Settings, AI.
## Changing the prompt
Settings, AI, Collection checks prompt. "Reset to default" loads the
built-in prompt; empty means the built-in default. Keep it to the
`submit_document_read` tool's fields.
## Stopping it
Switch off "Collection checks enabled" in Settings, AI and Save, or
deactivate the n8n workflow. Existing ticks stay until the card is
re-checked or the line is cleared.

View file

@ -1,51 +0,0 @@
# Return mistaken collections to the pool
Collection Confirm has a **Return to pool** button on each line and a
**Return PO to collection pool** button at the bottom of each PO card.
The PO button affects only the collected lines shown on that card, not
other lines on the customer order. Both open a confirmation listing the
affected items before making any changes.
The reset clears driver assignment, collection dates, collected and
assigned quantities, collection problem flags and the previous AI check.
Order quantities, costs, SKUs, photos and notes are retained. Customer
lines return to `item_status = collection_pool`; stock lines return to
`status = collection_pool` with `quantity_received = 0`. An unbooked stock
header marked `received` reopens to `sent` so it leaves the accountant's
booking queue until collection is confirmed again.
Items already invoiced, dispatched, delivered or booked into stock are
refused. Refused lines are reported separately if some lines can return.
The browser reloads the queue after the operation. Normal confirmation
stays available; Add to price list (formerly Add to POS) controls are hidden but their underlying
handlers and endpoints remain.
## Deployment
Deploy **both** the app and `pocketbase/pb_hooks/collection_return.pb.js`.
The hook is listed in `pocketbase/pb_hooks/DEPLOY`; publish the committed
hooks folder with `npm run pb:hooks:publish` using the workflow in
`scripts/pb-hooks/README.md`. No collection fields or select values need
to be added.
The authenticated capability endpoint
`GET /api/wfdash/collection-return-guard` must return `{ "version": 1 }`.
Until the hook is deployed the app refuses resets with HTTP 503. It must
never fall back to an unguarded update.
Each return uses a PB batch transaction. The hook rechecks row revisions,
stock parent revisions, invoice metadata and stock ledger entries inside
that transaction before allowing the writes. It also guards the opposite
race: a stale customer confirmation cannot advance a returned/assigned
line, and a stock booking cannot insert a receipt after the PO reopens.
Existing PB audit hooks record customer field changes; no extra app audit
notes are added.
## Validation
Run the tests under `src/components/collection-confirm`,
`src/app/(app)/collection-confirm`, `src/app/api/v1/collection-confirm`,
and `src/lib/server/collection-return*.test.ts`. The hook tests evaluate
the real JS file in a mocked PB environment; they do not change live data.
After deployment, verify a mistaken collection returns to the pool with
zero collected quantity and can be assigned to a driver again.

View file

@ -1,130 +0,0 @@
# Handover: driver invoice photos that were never collected
Run every 30 minutes during the working day (06:00–17:00 SAST).
> **Not scheduled yet.** No cron task runs this — the 5 live crons are the
> MCODe basket, three PocketBase backups and the n8n watchdog. Until one is
> created, someone has to run this by hand.
## The problem
Drivers photograph the supplier invoice on a PO in the driver app, but
sometimes never tap **Collect**. The line then stays waiting, so it
never reaches **Collection Confirm** and never gets invoiced. A new app
version (auto invoice match + "Collect these items?" prompt) should stop
this, but until it is proven, check by hand.
Sometimes the photo is also on the **wrong PO** (e.g. the Metabo invoice
was uploaded on the Mega Lube PO), or is not an invoice at all.
Two sides to check — they are different collections with different
attachment fields:
| Side | Line lives in | Waiting statuses | Photo lives in |
|---|---|---|---|
| Customer PO | `order_items` | `collection_pool`, `assigned`, `ready_delivery` | `order_items.order_item_attachements` |
| Stock PO | `stock_purchase_items` | `collection_pool`, `assigned_to_driver` | `stock_purchases.additional_attachments` (**on the parent PO**) |
All three customer waiting statuses count. The "collection pool" tab group
is `["collection_pool", "assigned", "ready_delivery"]` and
`order_items_confirm_collected` accepts `collection_pool` **or**
`assigned` — a line stuck in any of them never reaches Collection Confirm.
## The check
### 1. Find the lines
Customer lines — `order_items_list` with filter:
```
(item_status = "collection_pool" || item_status = "assigned" || item_status = "ready_delivery") && order_item_attachements:length > 0
```
expand `purchase_order,supplier` — the PO number is
`expand.purchase_order.po_number`, it is not a field on the line itself.
Stock lines — `stock_purchase_items_list` with filter:
```
status = "collection_pool" || status = "assigned_to_driver"
```
expand `purchase` — the PO number is `expand.purchase.purchase_number`
(looks like `PO26100606STK`, not `PO...MAS`), and the photos are in
`expand.purchase.additional_attachments`.
### 2. Read every photo
`order_item_attachements` is a **multi-file** field (up to 10), and stock
POs can have several pages (`..._p1_`, `..._p2_`). Open **each** filename:
- customer line → `files_fetch`, collection `order_items`, the line id, the filename
- stock line → `files_fetch`, collection `stock_purchases`, the **parent PO** id, the filename
If a photo cannot be read as an invoice, treat the whole line as
unreadable (below).
### 3. Compare the invoice with the line
- the PO number printed on the invoice (reference / customer order no.)
= the line's PO number
- the supplier on the invoice = the line's supplier
- the item matches the line's description
- the quantity on the invoice = the line's `quantity`
- the invoice is dated today or recently
## What to do
- **Everything matches** → mark it collected. Do **not** hand-roll this
with `order_items_update` + `order_items_update_status`; use the tool
that exists for it, because it validates eligibility, refuses to
over-collect, stamps the audit note and refuses rows that are already
past the pool:
- customer line → `order_items_confirm_collected` with
`{ item_ids: [...], actor: { id, name }, quantity_collected: { <line id>: <invoice qty> } }`
- stock line → `stock_purchase_items_mark_collected` with
`{ id: <line id>, quantity_collected: <invoice qty> }`
- `quantity_collected` is the qty to **add**, not the new total. Omit it
to collect the full remaining quantity.
- `actor.id` is a free-form string — use `mavis` / `Mavis` so the
`notes` feed shows who did it.
- Do every line on that PO that is on the invoice.
- **Invoice shows less than ordered** → do NOT mark it. Report it
(the driver must collect the short quantity on the item card).
- **Photo is for a different PO** → do NOT mark it. Report both PO
numbers so a person can move the photo to the right PO.
- **Photo is not an invoice** → do NOT mark it. Report it.
- **Can't read the photo / not sure** → do NOT mark it. Report it.
Never cancel, delete, confirm, or change prices. Only ever mark a line
collected, and nothing else.
## Report
A short list: what you marked collected (supplier, PO, item, qty,
invoice number) and what needs a person (and why). If nothing was
found, say "nothing new".
**Do not re-report what is already on the "Waiting on a person" list
below** — those lines stay in the waiting status until somebody moves
them, so every run would otherwise find them again and repeat the same
report 22 times a day. Add anything new to that list.
## Waiting on a person
- **NSS M24 × 160 hex bolt ×60, PO202610018MAS** (line
`890919xtp0i8zfp`, driver Joachim): the photo on it is NSS invoice
5031768 for **PO202610080MAS** (M12 cap screws, already collected and
confirmed on 27 Oct). Wrong photo, so nothing proves the M24 bolts
were collected. Ask Joachim.
- **Mega Lube oil aerosol ×3, PO202610073MAS** (line `ganf24h10lyrzdl`,
driver Joachim): no Mega Lube invoice anywhere — the line has no
attachment at all, so the filter will not find it. Moved to 27 Oct.
Ask Joachim.
- **UPL UNBRAKO 1 1/2 × 7 allen caps ×34, PO202609005CEM** (line
`47it7cxyu27dmd5`): photo is on the line, but an operator bounced it
back to "NOT READY" on 26 Oct, so it is not a forgotten Collect.
Do not mark it.
- **NSS M6 × 30 CSK ×100, PO26100606STK** (stock line
`o9jtrr0wasxxq2g`): the photo on the PO is a picture of a laptop
keyboard, not an invoice. Needs a person to re-shoot it.

View file

@ -1,101 +0,0 @@
# Manager orders view (`/manager`)
A hidden, installable PWA for the managers: one link, opened on a
phone, showing where every order is — **New / Processing / Completed**.
It is deliberately not in the sidebar. The route lives in the
`(mobile)` route group, outside `(app)`, so `<Sidebar />` never
renders on it and there is no nav entry anywhere.
## Where things live
| Piece | File |
|---|---|
| Page (route group) | `src/app/(mobile)/manager/` — `layout.tsx`, `auth-gate.tsx`, `page.tsx`, `manager-orders-view.tsx` |
| Read model | `src/lib/server/manager-orders-service.ts` |
| API route | `src/app/api/v1/manager-orders/route.ts` |
| PWA manifest | `public/manifest-manager.json` |
| Service worker | `public/sw-manager.js` |
| SW registration | `src/components/service-worker-registration.tsx` (adds `/manager` → `/sw-manager.js`) |
| Bucket + settled-status contract | `src/lib/item-status-ui.ts` |
| Shared window filter | `settledWindowFilter` in `src/lib/server/orders-service.ts` |
## The three buckets
`PIPELINE_GROUP_ITEM_STATUSES` in `src/lib/item-status-ui.ts` is a
coarse view of the desk's eight workflow tabs. Both surfaces read the
same status lists, so they can never disagree about what a status
means.
| Bucket | Statuses | Orders (live, 2026-10-26) |
|---|---|---|
| **New** | `pending`, `approval_needed`, `approval_rejected` | 8 |
| **Processing** | `processed`, `po_generated`, `collection_pool`, `assigned`, `ready_delivery`, `collected`, `stock_verified`, `picking`, `ready_invoice`, `stock_booked`, `with_driver` | 79 |
| **Completed** | the settled set, windowed to 7 / 14 / 30 days | 65 / 112 / 200 |
An order lands in a bucket when **at least one of its lines** is in
that bucket. That is why Processing holds 79 orders for only 206
open lines: 45 of those orders have only ONE line left, and 40 have
other lines already invoiced or collected. `cancelled` and `removed`
are deliberately outside the pipeline (they live on /orders/archive).
## The Completed window — read before changing it
There is no single "finished at" column. `SETTLED_STATUSES` +
`SETTLED_DATE_FIELDS` in `src/lib/item-status-ui.ts` encode which
field dates each settled status:
| Status | Dated on | Why |
|---|---|---|
| `delivered` | `delivered_at` | populated on only 2 of 12,957 lines; the rest are legacy rows whose `updated` is the MIGRATION timestamp, so an un-dated delivered row is ARCHIVED, not recent |
| `invoiced` | `invoiced_date` | the only properly stamped settlement date in the schema (2,199 lines) |
| `completed`, `checked_in`, `delivery_note` | `updated` | no dedicated column exists, so `updated` is the only signal |
`primary` and `fallback` are mutually exclusive. Giving a status both
would resurface archived rows every time an old row was touched.
Before this change the Completed tab matched **0** orders: the filter
only looked at `delivered` + `completed` dated on `delivered_at` /
`updated`, and the business stopped writing `delivered` on 2026-08-14
(the live pipeline now ends at `invoiced` / `checked_in`). It now
matches 341 orders over 30 days, 112 over 7.
`settledWindowFilter(days)` is shared with `/orders` (30 days) — only
the window length differs, so the two surfaces never disagree.
## The preview popup is reused, not rebuilt
The service emits `UniversalHit`s — the same shape the universal
search returns — and the page hands them to the existing
`SearchPreviewDialog` (`src/app/(app)/search/search-preview.tsx`). So
the manager gets the desk's quick-overview verbatim: status chips,
stats, invoice refs, delivery-note numbers, the line list and the
lifecycle timeline, plus a link out to the full order page.
**Read-only.** Nothing on the phone can change an order's state.
## API
```
GET /api/v1/manager-orders?group=new|processing|completed
&window=5|7|14|30
&search=<order no|customer|company|rep>
&page=1&perPage=20
```
Auth is the PB browser session in the `Authorization` header — the
same `assertBrowserSession` pattern as `/api/v1/boss-dashboard`. The
response carries `counts` (distinct orders per bucket), `total`,
`totalPages`, the page of `orders` and `viewerName`.
## Installing it on a phone
Open the link on the phone → browser menu → **Add to Home Screen**. It
installs as a standalone app ("WFDASH — Orders") with its own icon,
launched in portrait with no browser chrome. iOS needs the
`apple-mobile-web-app-capable` meta, which the layout sets (Next 16
only emits the standardised name, which iOS ignores — see the note in
`(mobile)/m/layout.tsx`).
The service worker precaches the shell and never caches `/api/`, so a
stale order count can never be shown.

View file

@ -1,108 +0,0 @@
# n8n Watchdog
You are the n8n Watchdog for the WFDASH n8n instance. You run on a schedule. Your job: notice when a workflow is broken, stuck, or silently not running, and report it in plain language. You are READ-ONLY.
## TOOLS
n8n MCP only. No browser, no direct REST.
- `search_workflows` — inventory: name, active, triggerCount
- `search_executions` — main signal; filter by status, startedAfter, workflowId
- `get_execution` — why one execution failed
- `get_workflow_details` — trigger config / expected cadence
## STATE
You cannot detect "stuck" from one snapshot — a 4-hour hang looks identical to a 3-second-old run. Keep `n8n-watchdog-state.json`. Load at start, save at end:
```json
{
"last_run_utc": "",
"baseline": { "<workflowId>": { "name": "", "active": true,
"trigger_kind": "schedule|poll|webhook|sub",
"expected_interval_minutes": 2, "typical_runtime_seconds": 4,
"typical_runs_per_hour": 30 } },
"open_incidents": { "<key>": { "first_seen_utc": "", "times_reported": 3, "summary": "" } },
"seen_execution_ids": []
}
```
No file = RUN 1.
## RUN 1 — baseline (once)
1. `search_workflows(limit:200)`.
2. For each active workflow, `get_workflow_details`, read the trigger:
- **Schedule/Cron** → record interval as `expected_interval_minutes`.
- **IMAP/Email** → polls constantly but only executes when mail arrives. Silence is normal. `null`, kind `poll`.
- **Webhook** → fires on call only. Silence normal. `null`.
- **Execute Workflow trigger / triggerCount 0** → sub-workflow or error handler. Silence normal. `null`.
3. Pull last ~20 executions per active workflow; record median runtime and runs/hour.
4. Report the baseline table to David: name, active, trigger kind, expected cadence, typical runtime, anything odd. He does not currently know every workflow's real cadence — this table is the point of Run 1.
## EVERY RUN
Stop early when clear. Most runs = one call + one line. Do not burn a long session confirming all is well.
**1. Failures**
`search_executions(status:["error","crashed"], startedAfter:<last_run_utc>, limit:100)`
For each id not in `seen_execution_ids`: `get_execution(includeData:false)`. If you need the cause, call again with `includeData:true, truncateData:2, nodeNames:[failing node only]`. Extract workflow, node, `error.message`, `httpCode`.
**2. Stuck**
`search_executions(status:["running","waiting"], limit:100)`
- `running` longer than max(10 min, 10 × typical_runtime_seconds) → HUNG, report.
- `waiting` AND `waitTill` >10 min in the past → OVERDUE, report.
- `waiting` AND `waitTill` in the future → normal, do NOT report.
- Same execution id was running/waiting last run → escalate wording: "stuck for N hours".
**3. Silent — the check nothing else does**
A workflow that stops triggering produces no error and no execution, so error alerts never fire. Causes: expired IMAP creds, a restart that didn't re-register a schedule, someone deactivating it.
For each active baseline workflow with non-null `expected_interval_minutes`:
`search_executions(workflowId:<id>, limit:1)`
Last run more than 3 × `expected_interval_minutes` ago (floor 15 min) → SILENT. Give last-run time and gap length.
Also flag any workflow whose `active` flag flipped since baseline.
**4. Loops**
- Storm: executions in the last hour > 3 × `typical_runs_per_hour` → looping.
- Internal: running well past normal runtime AND runtime growing between your runs → `get_execution(includeData:true, truncateData:2)`, inspect `runData` for a node with implausibly high `executionIndex` / run count. Name that node.
**5. Hygiene** (once a day, low priority)
Active workflows named TEST/OLD/DRAFT/"do not activate"; duplicate names; active workflows with zero executions ever.
## HARD RULES
1. **READ-ONLY.** Only `search_*` and `get_*`. Never `update_workflow`, `execute_workflow`, `archive_workflow`, publish/unpublish, `restore_workflow_version`, or anything that changes state — not to "fix" something obvious, not to re-run a failed execution. Describe the action, let David decide.
2. **REDACT.** `includeData:true` returns raw payloads that on this instance include PocketBase auth JWTs, API keys and customer email content. Replace anything token/JWT/bearer/key-shaped with `[REDACTED]` before it reaches your log or report. Never paste a full node payload. Default to `includeData:false`.
3. **Don't repeat.** Incident already in `open_incidents` and unchanged → one line, "still open, Nth run". Full detail only when it worsens.
4. **Say when unsure.** A tool call that fails or returns something you don't understand → say so. Never present a guess as a finding.
5. **If the n8n MCP itself doesn't respond, THAT is the headline** — n8n is likely down. Report immediately, skip the rest.
## REPORT
Post every run. Nothing wrong = the one-liner, unpadded:
```
n8n watchdog — 14:00 SAST — all clear. 17 active, 42 executions since last
check, 0 errors, 0 stuck, 0 silent.
```
Something wrong:
```
n8n watchdog — 14:00 SAST — 2 issues
[NEW] WFDASH_RAW_PRICELIST — failing since 09:00 (3 runs)
Node: "Main Price List" (PocketBase)
Error: DNS server returned an error (EAI_AGAIN, pocketbase-...sslip.io)
Read: PocketBase host not resolving. Infrastructure, not workflow logic.
Suggest: check the container is up and sslip.io DNS resolves.
[STILL OPEN — 4th run] WFDASH — Quote Intake (RFQ Email Monitor)
Silent 3h20m. Expected ~every 1 min (IMAP poll). Last execution 10:41, ok.
Read: trigger has stopped firing. IMAP credentials are the usual cause.
Checked: 17 active workflows, 38 executions, 3 errors, 0 stuck.
Next check: 15:00.
```
Lead with what is broken, not what you checked. One entry per incident, tagged `[NEW]` / `[STILL OPEN — Nth run]` / `[RESOLVED]`. Always include your **read** — workflow bug, infra problem, or credential problem? Raw stack traces don't help; interpretation does. Never say fixed unless you've seen a successful execution after the failure.
## SCHEDULE
You are launched hourly on a fixed interval and cannot change it. Check the clock yourself at the start of each run and adapt your **output** instead:
- **06:00–18:00 SAST** — full report as above.
- **18:00–06:00 SAST** — one line only, unless something is genuinely broken. Do not wake David for "all clear".
Executions that began in the last 2 minutes are not stuck — they are just new. Ignore them when applying the step-2 thresholds.

View file

@ -1,271 +0,0 @@
# AI order assistant runbook
Design: `docs/superpowers/specs/2026-10-24-ai-order-assistant-design.md`.
Plan: `docs/superpowers/plans/2026-10-24-ai-order-assistant.md`.
## What it does
An order that came from a quote should point at that quote. Today only an
order created *from* a quote carries the link; every other order — a
walk-in, an email intake, a counter sale — has to be linked by hand.
The order assistant works out which quote an order came from. On a
schedule it looks at orders that have no quote yet, asks the AI which of
the candidate quotes the order came from, and then either:
- **links** the order to the quote itself (when it is sure), or
- **suggests** the quote, and a person accepts it.
Once the order points at a quote, the assistant can also **fill the
order's lines** from that quote — the cost, the supplier, the SKU and the
part number — leaving them in **purple** until a person checks them.
The AI never changes a line's status, never creates a purchase order and
never sends anything. It decides which quote an order belongs to, and it
may pre-fill a line's prices from that quote.
## The auto-link rule, in plain words
The assistant links an order to a quote **only** when all of these are
true:
- The **"Auto-link when sure"** setting is on (it is on by default).
- The AI says it is **sure** about the match.
- **Every** real order line is paired to a quote line, and each of those
pairs is a **sure** match.
- **Every** pair points at a line of the **one** chosen quote (not a
mixture of two quotes).
- The chosen quote belongs to the **same company** as the order.
If any of those fail, the assistant only writes a **suggestion**. The
order page then shows "AI suggests Q…" with the reason, the doubts and the
line-by-line pairing, and the existing **Link** button is there to accept
it.
The assistant also refuses to link when PocketBase says no — for example
when a line on the order has already moved past `pending`, or when
another order already points at that quote. A refusal is **not** an error:
the assistant falls back to a suggestion.
## Filling the lines from the quote
With **"Fill lines from the quote"** on, the assistant prices the order's
lines from the quote they are paired with. This is a copy, not a new
price: no AI call is involved, the pairing is already on the lines.
A line may be filled only when **all** of these are true:
- It is still `pending`.
- It carries **no cost** of its own (`cost_price` is 0).
- Its `pricing_source` is not `manual` and not `quote` — a price a person
set is never overwritten.
- It has no **reviewed** AI fill on it.
- It is paired to a quote line (`quote_item` is set) that actually has a
cost.
What the fill writes:
| Field | Rule |
|---|---|
| `cost_price` | The quote line's cost |
| `selling_price` | Only when the order line has none of its own |
| `supplier`, `sku`, `matched_price_list_id` | Only when the order line has none of its own |
| `markup` + `markup_percent` | Written together, from cost and selling |
| `pricing_source` | `ai_quote` |
| `ai_fill` | The audit record of the fill (see below) |
Every line is **re-read immediately before** it is written, so a cost a
person typed in the meantime wins over the AI's.
If the customer's price and the quoted price both exist and differ by more
than **5%**, the fill records a doubt, e.g. `customer price R16.64, quoted
R18.20`. Doubts show on the line's marker.
### The purple review
A filled line is **pending review** while all three are true:
- `ai_fill.status` is `filled`,
- `ai_fill.reviewed_at` is empty, and
- `pricing_source` is still `ai_quote`.
While a line is pending it shows up **purple** on the order Process page,
with the bot marker next to it. Hover or focus the marker to see the quote
it was filled from, the cost, the selling price, the markup, the reason and
any doubts.
The purple ends the moment somebody touches the line:
- **Saving the line** records the review on `ai_fill` (`reviewed_at`,
`reviewed_by`, and whether the prices were accepted unchanged or
changed). The pricing source stays `ai_quote`.
- The one-click **"use this cost"** copy writes `pricing_source: "quote"`
— the operator chose the quote's cost themselves — so the AI's
provenance ends too.
- A line whose price the operator types by hand becomes `pricing_source:
"manual"`.
### The board badge
The Orders board shows an **"AI x/y"** badge on an order when the AI
filled some of its lines: `x` is how many lines still need a review, `y`
is how many the AI filled. The badge is hidden when nothing is pending.
### `order_items.ai_fill`
| Field | Meaning |
|---|---|
| `status` | `filled`, `skipped` or `error` |
| `source` | `quote` |
| `quote_id`, `quote_number`, `quote_item_id` | Where the numbers came from |
| `cost_price`, `selling_price`, `markup` | What was written |
| `supplier_id`, `sku` | What was written |
| `reason` | Why the line was paired with that quote line |
| `doubts` | Anything a person should check (e.g. the 5% price difference) |
| `model`, `attempted_at` | Which model, and when |
| `reviewed_at`, `reviewed_by` | Set when a person checked the line |
| `accepted_unchanged` | True when the person kept both prices |
| `supplier_changed` | True when the person changed the supplier |
## Going live (manual steps, in this order)
1. **Migrate PocketBase.** Run against the live PB:
`npm run pb:migrate:order-assistant`
(adds `orders.ai_quote_match`, `order_items.ai_fill` and the
`order_assistant_*` fields on `ai_settings`). If the app is deployed
before this, PocketBase drops the unknown fields silently and the
assistant's result lands nowhere.
2. **Publish the PB hook change.** `ai_quote_match` and `ai_fill` were
added to `IGNORED_FIELDS` in `pocketbase/pb_hooks/audit_log_utils.js`,
so the AI's own writes do not flood the audit log. Publish it the same
way the other hook changes are published
(`npm run pb:hooks:publish`).
3. **Deploy** the app. Pushing to main does not auto-deploy; it is a
manual deploy that takes 5 to 10 minutes.
4. **Clear the old rule-based suggestions.** Quote matching is AI-only:
the rule-based matcher no longer runs (not at intake, not on the order
page, not in the sweep). The suggestions it left behind are wrong far
too often — on 2026-10-24 there were 2,888 on unlinked orders, one
quote suggested to 309 of them. Clear them — **dry run first**:
```bash
npx tsx scripts/2026-10-24-clear-rule-quote-suggestions.ts
npx tsx scripts/2026-10-24-clear-rule-quote-suggestions.ts --apply
```
It only clears `potential_quote` on orders with no hard link whose
`quote_match_method` is not an AI one; links and AI suggestions stay.
5. **Activate the n8n clock** (below).
6. **Turn it on** in Settings, AI: the global "AI enabled" switch must be
on, then switch on **"Order assistant enabled"** in the Order assistant
section and click Save. Defaults: every 10 minutes, auto-link on, fill
lines on.
The tick does nothing until both switches are on, so steps 5 and 6 are
safe in either order.
## n8n workflow: "WFDASH Order assistant tick"
n8n only acts as a clock. The app decides whether a run is due (AI and
the assistant on, and at least "Run every" minutes since the last run), so
the setting in the app controls the real frequency.
Spec:
| Node | Setting |
|---|---|
| Name | `WFDASH Order assistant tick` |
| Trigger | Schedule Trigger, every 5 minutes |
| Action | HTTP Request |
| Method / URL | `POST` `https://wfdash.apphub.co.za/api/v1/orders/assistant/tick` |
| Authentication | Generic Credential Type, Header Auth, **the same credential** the quote assistant tick workflow uses (an `Authorization: Bearer <AGENT_API_KEY>` header). Do not create a new secret. |
| Body | none |
| Settings tab | Retry On Fail: **off**; Timeout: **120000 ms** (120 s) |
| Status | **inactive** until the app is deployed and migrated |
Click-by-click:
1. n8n, Workflows, Create workflow. Name it `WFDASH Order assistant tick`.
2. Add node, Schedule Trigger. Trigger Interval: Minutes, Minutes Between
Triggers: `5`.
3. Add node, HTTP Request, connect it after the trigger.
- Method: `POST`.
- URL: `https://wfdash.apphub.co.za/api/v1/orders/assistant/tick`.
- Authentication: Generic Credential Type, Generic Auth Type: Header
Auth. Pick the credential used by the quote assistant tick workflow
(open that workflow and read its HTTP Request node to see which one).
- Options, Add option, Timeout: `120000`.
- Settings tab on the node: turn **Retry On Fail** off.
4. Save. Leave the workflow **inactive** until the steps above are done.
5. After deploy, click Execute workflow once. Expected response while the
assistant is off: `{"ran":false,"reason":"disabled"}`. When on and not
yet due: `{"ran":false,"reason":"not_due"}`. While an earlier run is
still going: `{"ran":false,"reason":"running"}`. When it ran:
`{"ran":true,"summary":{...}}`.
6. Toggle the workflow Active.
A 401 means the credential is wrong (it must send the app's
`AGENT_API_KEY` as a Bearer token). A tick that takes long is normal when
there is work. Each run is bounded: it matches **at most 10 orders** and
fills **at most 10 orders**, and it stops starting new orders once 90
seconds have passed, so a run normally answers inside the 120 s timeout;
leftovers carry to the next run.
An order the tick has nothing to do with does **not** use up that budget:
the run looks through up to 200 unlinked orders to find 10 it can work
on, and an order whose lines the fill could not write is skipped rather
than counted.
Runs never overlap. A run stamps its start time before it does any work and
writes its summary when it ends. While a start is newer than the last
summary and less than 10 minutes old, other ticks answer
`{"ran":false,"reason":"running"}`. If a run dies without writing its
summary, the next tick after those 10 minutes takes over.
## Running it by hand
- **Order page** — the **"Match with AI"** button on the quote match card
runs the assistant for that one order and reloads the order. It also
fills the lines when the order ends up linked.
- **MCP tool** — `orders.assistant_match` with `{"orderId": "..."}`.
Both force a run past the "already looked at these lines" rule.
## Reading `ai_quote_match`
`orders.ai_quote_match` (JSON) is the record of what the assistant decided
about an order. Read it in the PB admin.
| Field | Meaning |
|---|---|
| `status` | `matched` (the order was linked), `suggested`, `no_match` or `error` |
| `quote_id`, `quote_number` | The quote the assistant chose |
| `confidence` | `high`, `medium` or `low` |
| `reason`, `doubts` | The AI's plain-language reasoning and anything to check |
| `lines` | The pairing: order line id → quote line id, with `same_item` (`sure` / `likely` / `unsure`) |
| `auto_linked` | True only when the assistant itself created the link |
| `lines_hash` | Fingerprint of the order lines the run looked at — the same lines are not looked at twice |
| `model` | The model that answered |
| `run_at` | When the run happened |
| `error` | Set when `status` is `error` |
## Settings (Settings → AI → Order assistant)
| Setting | Default | Meaning |
|---|---|---|
| Order assistant enabled | off | The tick does nothing while this is off |
| Run every (minutes) | 10 | How often the tick may run |
| Auto-link when sure | on | May the assistant link the order itself? |
| Fill lines from the quote | on | May the assistant pre-fill the order's lines from the linked quote? |
| Prompt | empty | Overrides the built-in prompt when set |
`orders.ai_quote_match` and `order_items.ai_fill` are in `IGNORED_FIELDS`
of the audit hook, so the assistant's own writes do not flood the audit
log.
## Not in this phase
- The assistant does not price a line from scratch — it only copies the
cost, supplier, SKU and part number from the quote line the order line
is already paired with.
- It never creates a purchase order, never sends an email and never
changes a line's status.
- There is no POS fallback: the assistant fills only from a quote.

View file

@ -1,179 +0,0 @@
# AI quote assistant runbook
Design: `docs/superpowers/specs/2026-10-16-ai-quote-assistant-design.md`.
## What it does
On a schedule, the assistant looks at quote lines nobody has priced yet,
shortlists matching items from the POS catalogue (`stock_items`), asks the
AI (the provider set in Settings, AI) to pick the right one, and writes a
**draft** cost, selling price and supplier onto the line. A rep checks the
draft and saves as usual.
The shortlist is **size-aware**. The size is parsed out of the line
(`src/lib/item-size.ts`) and the candidates are ranked by it, so
"BOLT HEX M16X60 GR8.8 ZP" is offered the M16x60 bolt and not the M22x65
that merely shares the words "BOLT 8.8 ZP". The AI then answers **exact**,
**closest** or **none** per line:
- **exact** — the same product and every size attribute the line states
matches. The price is written.
- **closest** — the nearest item, but something differs. **No price is
written**; the item and what differs are recorded in `ai_fill.closest`
and the badge reads "Closest: …".
- **none** — nothing in the catalogue is close enough. No price.
- Filled lines show purple with an agent logo on the quote and Process
pages. Clicking the logo shows the POS item, price source, reason and
doubts. A small warning-coloured dot on the logo means the AI noted
doubts to check.
- Lines it could not match stay unpriced and get a muted "AI: no match"
badge listing the closest POS items.
- The Quotes board shows "AI x/y" (unreviewed AI fills / total lines).
- The quote and Process pages have a "Let AI fill this quote" button for
one quote now. MCP tool: `quotes.assistant_fill`. On the Process page,
lines you have typed in but not saved are left as you typed them.
Only quotes with status **new**, **follow_up** or **claimed** are filled,
never templates. Up to 15 lines are sent per quote per run (one AI call);
the rest follow on the next run.
It never sets `is_processed`, never changes quote status, never sends
email, and never overwrites a line that already has a price, was edited by
a person, or is processed. Prices come only from the POS item. A supplier
already on the line is kept; the POS item's supplier is only written when
the line has none.
## Going live (manual steps, in this order)
1. **Migrate PocketBase.** Run against the live PB:
`npm run pb:migrate:quote-assistant`
(adds `quote_items.ai_fill` and the `quote_assistant_*` fields on
`ai_settings`). If the app is deployed before this, the Quote assistant
settings don't persist (the switch, interval, confidence and prompt
come back as defaults after Save), so the assistant stays off.
2. **Deploy** the app. Pushing to main does not auto-deploy; it is a
manual deploy that takes 5 to 10 minutes.
3. **Activate the n8n clock** (below).
4. **Turn it on** in Settings, AI: the global "AI enabled" switch must be
on, then switch on "Quote assistant enabled" in the Quote assistant
section and click Save. Defaults: every 10 minutes, minimum confidence
Medium, prompt empty (= built-in default).
The tick does nothing until both switches are on, so steps 3 and 4 are
safe in either order.
## n8n workflow: "WFDASH Quote assistant tick"
n8n only acts as a clock. The app decides whether a run is due (AI and the
assistant on, and at least "Run every" minutes since the last run), so the
setting in the app controls the real frequency.
Spec:
| Node | Setting |
|---|---|
| Name | `WFDASH Quote assistant tick` |
| Trigger | Schedule Trigger, every 5 minutes |
| Action | HTTP Request |
| Method / URL | `POST` `https://wfdash.apphub.co.za/api/v1/quotes/assistant/tick` |
| Authentication | Generic Credential Type, Header Auth, **the same credential** the existing quotes `process-pending` workflow uses (an `Authorization: Bearer <AGENT_API_KEY>` header). Do not create a new secret. |
| Body | none |
| Settings tab | Retry On Fail: **off**; Timeout: **120000 ms** (120 s) |
| Status | **inactive** until the app is deployed and migrated |
Click-by-click:
1. n8n, Workflows, Create workflow. Name it `WFDASH Quote assistant tick`.
2. Add node, Schedule Trigger. Trigger Interval: Minutes, Minutes Between
Triggers: `5`.
3. Add node, HTTP Request, connect it after the trigger.
- Method: `POST`.
- URL: `https://wfdash.apphub.co.za/api/v1/quotes/assistant/tick`.
- Authentication: Generic Credential Type, Generic Auth Type: Header
Auth. Pick the credential used by the quotes `process-pending`
workflow (open that workflow and read its HTTP Request node to see
which one).
- Options, Add option, Timeout: `120000`.
- Settings tab on the node: turn **Retry On Fail** off.
4. Save. Leave the workflow **inactive** until steps 1 and 2 above are done.
5. After deploy, click Execute workflow once. Expected response while the
assistant is off: `{"ran":false,"reason":"disabled"}`. When on and not
yet due: `{"ran":false,"reason":"not_due"}`. While an earlier run is
still going: `{"ran":false,"reason":"running"}`. When it ran:
`{"ran":true,"summary":{...}}`.
6. Toggle the workflow Active.
A 401 means the credential is wrong (it must send the app's
`AGENT_API_KEY` as a Bearer token). A tick that takes long is normal when
there is work. Each run is bounded: at most 20 quotes and 60 lines, newest
first, 15 lines per quote (one AI call per quote). It also stops starting
new quotes once 90 seconds have passed, so a run normally answers inside
the 120 s timeout; leftovers carry to the next run.
Runs never overlap. A run stamps its start time before it does any work and
writes its summary when it ends. While a start is newer than the last
summary and less than 10 minutes old, other ticks answer
`{"ran":false,"reason":"running"}`. If a run dies without writing its
summary, the next tick after those 10 minutes takes over.
## Reading `ai_fill`
`quote_items.ai_fill` (JSON) is the record of what the AI did to a line.
Read it in the PB admin, or through the MCP `quote_items.get`.
| Field | Meaning |
|---|---|
| `status` | `filled` (an **exact** match — the only status that writes a price), `no_match` (no exact match; `closest` may say what the nearest item was), `no_candidates` (nothing in the POS shortlist, no AI call made) or `error` (the shortlist or AI call failed, the AI answer was cut off, or it skipped the line; retried after 1 hour or by the manual button) |
| `stock_item_id`, `stock_item_name`, `stock_item_sku` | The POS item picked. The POS SKU lives here only; it is never copied onto the line's own `sku`. |
| `cost_price`, `selling_price`, `markup`, `supplier_id` | What was written to the line, taken from the POS item |
| `price_from_default_markup` | Selling price came from the default markup because the POS item had a cost but no selling price |
| `confidence` | `high`, `medium` or `low` |
| `reason`, `doubts` | The AI's plain-language reasoning and any mismatches to check (grade, size, pack size, etc.) |
| `candidates` | Closest POS items looked at (for unmatched lines) |
| `closest` | The nearest POS item when the AI could only find a close one — `stock_item_id`, `name`, `sku`, `cost_price`, `selling_price` and `differs`. The line keeps **no** price. |
| `description_snapshot` | The line description when tried. An unmatched line is only retried if the description changes. |
| `model`, `attempted_at` | Which model, when |
| `reviewed_at`, `reviewed_by` | Set when a rep saves the line |
| `accepted_unchanged` | On review: cost and selling price still equal what the AI wrote |
| `supplier_changed` | On review: the saved supplier differs from the POS item's supplier (the rep changed it, or the line already had another supplier) |
A line is **pending review** (purple) while
`ai_fill.status = "filled"`, no `reviewed_at`, and
`pricing_source = "ai_pos"`. The rep's save sets `reviewed_at`; the line
keeps `pricing_source = "ai_pos"` and a grey logo. Every AI write is in the
audit log under the actor "AI quote assistant", or "AI quote assistant (run
by <name>)" for the button (and "(run by MCP)" for the MCP tool). Those
writes are never credited to the person who clicked, even though the click
carries their login.
Clone and Save as template copy an unreviewed AI line without its cost,
selling price, markup or supplier, so the copy can't carry an unchecked
price.
The last run is shown in Settings, AI, Quote assistant, and stored in
`ai_settings.quote_assistant_last_run_summary` (quotes seen, lines filled,
no match, skipped, errors).
## Judging accuracy
Among reviewed AI fills (`ai_fill.status = "filled"` and `reviewed_at`
set), the share with `accepted_unchanged = true` is the headline number:
how often a rep saved the AI's price as it was. Also watch
`supplier_changed` (supplier was wrong even when the price was right).
To tally: fetch `quote_items` where `pricing_source = "ai_pos"` (for
example with `quote_items.list` or a short script), keep the ones whose
`ai_fill.reviewed_at` is set, and compare `accepted_unchanged` true versus
false.
Rough guide: the default minimum confidence is Medium. If reps often
change AI prices, raise it to High (only the AI's surest matches are
written) or edit the prompt (Settings, AI, Quote assistant prompt; "Reset
to default" restores the built-in one). If most reviewed fills are
unchanged at High, Medium is worth trying again.
## Stopping it
Switch off "Quote assistant enabled" in Settings, AI and Save, or
deactivate the n8n workflow. Existing purple lines stay until reviewed.

View file

@ -220,22 +220,14 @@ Calls `attachments.upload_for_quote` via the WFDASH MCP. Reads the binary from `
> Workflow:
> 1. Read the email body (in your user message). For each attachment, also look at the extracted_text_preview field — that is the first 2KB of the PDF text. If the email body is short or empty, the line items are almost certainly in the PDF.
> 2. Extract the buyer/end-customer company name (company_hint). Empty string if unclear.
> 3. Extract the customer being quoted FOR (person_hint). **This is NOT the email sender** — when a rep (e.g. Gary, the South Africa field rep) forwards an inbound RFQ or sends a quote request for a customer, the sender is Gary and the actual customer is whoever Gary named in the subject line, the email body, or a signature. Examples:
> - Subject `Quote for John Smith at ABC Mining` → person_hint=`John Smith`, company_hint=`ABC Mining`
> - Subject `RE: Bearings enquiry — Peter Jones (Acme)` → person_hint=`Peter Jones`, company_hint=`Acme`
> - Subject `RFQ from John Smith` (Gary forwarding) → person_hint=`John Smith`
> - Subject `Quote request` with body `Please quote for Sarah at TechCo` → person_hint=`Sarah`, company_hint=`TechCo`
>
> If no specific customer is named and the email IS from the customer themselves (the from_email address is their personal / corporate domain, not a wfsales.co.za rep), set person_hint to empty string — the from_email will resolve correctly. Only set person_hint when the sender is clearly NOT the customer.
> 4. Extract the line items: an array of {description, quantity, unit?, notes?}. Look BOTH in the email body AND in extracted_text_preview of each attachment. DO NOT invent items, prices, or quantities.
> 5. Call create_quote_tool. Pass ALL the email metadata VERBATIM. Do NOT paraphrase. Include `person_hint` and `company_hint` in the call.
> 6. Capture the quote_id from the tool result.
> 7. For each entry in the email's attachments array, call upload_attachment_tool with that entry's binary_key, filename, content_type, size, the captured quote_id, and the email's message_id. Call it once per attachment.
> 8. After all uploads, reply with a one-line summary: quote_number=... items=... uploaded=... failed=... person_hint=...
> 3. Extract the line items: an array of {description, quantity, unit?, notes?}. Look BOTH in the email body AND in extracted_text_preview of each attachment. DO NOT invent items, prices, or quantities.
> 4. Call create_quote_tool. Pass ALL the email metadata VERBATIM. Do NOT paraphrase.
> 5. Capture the quote_id from the tool result.
> 6. For each entry in the email's attachments array, call upload_attachment_tool with that entry's binary_key, filename, content_type, size, the captured quote_id, and the email's message_id. Call it once per attachment.
> 7. After all uploads, reply with a one-line summary: quote_number=... items=... uploaded=... failed=...
>
> Important:
> - Empty body / no items is OK — create the quote anyway with an empty extracted_items array.
> - Empty person_hint is OK when from_email IS the customer (typical direct RFQ).
> - attachments metadata only (no base64) — the binary lives in the workflow's binary store; only upload_attachment_tool knows how to read it.
---

View file

@ -1,99 +0,0 @@
# Quote Outbound Send — operator-uploaded attachments fix
Deployed 2026-09-09 alongside the `quotes.outbound_attachments` /
`quotes.internal_attachments` storage split (the migration that
moved operator-uploaded files off the legacy `quote_attachments`
collection's `direction` enum).
## What the deployed workflow does now
The active n8n workflow is `WFDASH — Quote Outbound Send
(push-payload + outbound-attachments)` (id `PIEAkPYYYZLGl6Pj`,
previously named `…(push-payload)`).
The chain still ends in `Send Email (SMTP)` with the auto-generated
PDF attached — same as before — **plus** a new `Build attachments
list` Code node that runs between `HTTP Request` (PDF download) and
the SMTP node. It:
1. Reads `quotes.outbound_attachments` (the PB file field on the
parent quote row) and `email_outbound_attachment_ids` (the JSON
array of operator-selected tokens persisted by
`sendQuoteEmail`).
2. Intersects the two so only files the operator explicitly marked
for the customer get attached. Defends against drift between the
modal snapshot and the send.
3. Downloads each token from
`https://<PB>/api/files/quotes/<quoteId>/<token>` via
`this.helpers.httpRequest`.
4. Returns a single item with `attachments: [{name, content (base64),
type}, …]` on the JSON — same shape the deployed `WFDASH —
Purchase Order Send` workflow consumes.
5. Per-download failures are skipped (not raised) so a single bad
token doesn't abort the whole send. Operator sees the final count
via `attachmentCount`.
The SMTP node now consumes the array via
`options.attachments={{ $json.attachments && $json.attachments.length
? $json.attachments : [] }}` — matching the PO Send pattern. The
previous `options.fileAttachments=data` (PDF only) is removed.
## Bonus fix: failure status `send_failed` → `mail_failed`
The deployed `Update quote - Send Failed` node previously flipped a
failed send to status `send_failed`. The dashboard's `retryQuoteEmail`
only accepts `mail_failed`, so failed sends were stranded — operator
couldn't retry from the UI. The failure path now flips to
`mail_failed`. Stale `send_failed` rows (if any pre-existed) can be
re-categorised with a one-shot PB update if the operator wants the
tab badge to count them.
## Why the repo file was deleted
`n8n_workfows/WFDASH — Quote Outbound Send.json` (the pre-existing
repo file) was **stale** — it referenced a non-existent MCP tool
(`quotes.get_send_payload`), the old row-ID model
(`selected_attachment_ids` + `pdf_attachment_id`), `$env.PB_ADMIN_TOKEN`
(which the docker-compose blocks via
`N8N_BLOCK_ENV_ACCESS_IN_NODE=true`), and field names that had
been renamed (`email_message_id`, `email_delivered`, …). It did
**not** match the deployed workflow and would have misled anyone
debugging the system. Deleted.
A clean snapshot of the previously-deployed workflow is preserved at
`n8n_workfows/WFDASH — Quote Outbound Send (push-payload).baseline.json`
for diff/rollback reference. The post-fix deployed workflow is in
`n8n_workfows/WFDASH — Quote Outbound Send
(with-outbound-attachments).json`.
## MCP `quotes.send_email` description
Updated to spell out the new field semantics: "`selectedAttachmentIds`
is a list of PB file tokens from `quotes.outbound_attachments`
(operator-uploaded customer-facing files — supplier quotes, drawings,
NDAs). The PDF is always attached separately from
`quote_attachments` kind="pdf"`. Also added a per-property
description on the input schema and updated the `docs/api-mcp-layer.md`
table to match.
## Verification
| Layer | Result |
|---|---|
| Deployed workflow via n8n REST | PUT returned HTTP 200, `active=true`, node count 24 → 25, new connections verified |
| `quotes-service.test.ts` | 117 pass, 4 pre-existing failures (stale-PDF auto-regen — unrelated, baseline fails too) |
| `mcp/route.test.ts` | 118 pass, 2 pre-existing failures (unrelated, baseline fails too) |
| Code node JS syntax | `node --check` passes |
## Files touched
- **Deleted**: `n8n_workfows/WFDASH — Quote Outbound Send.json`
- **Added**:
- `n8n_workfows/WFDASH — Quote Outbound Send (push-payload).baseline.json`
- `n8n_workfows/WFDASH — Quote Outbound Send (with-outbound-attachments).json`
- `docs/quotes-outbound-attachments-deploy.md`
- **Modified**:
- `src/app/api/mcp/route.ts` (tool description + `selectedAttachmentIds` schema property)
- `src/app/api/v1/quotes/[id]/send-email/route.ts` (docstring only)
- `docs/api-mcp-layer.md` (table row)
- **Deployed**: workflow `PIEAkPYYYZLGl6Pj` in the live n8n instance

View file

@ -1,296 +0,0 @@
# Rep Performance Report — Data Source & Provenance
> **Audience:** anyone who asks where the numbers come from — a manager, an
> auditor, a customer-facing colleague.
> **Scope:** `/reports/rep-performance` (summary, per-rep detail, team
> comparison) and the PDF download.
> **Author context:** WFDash is the operator app (Next.js + PocketBase). This doc
> describes where every figure on the report originates, so the report can be
> defended without reading the code.
---
## TL;DR
The report is **two separate things stitched together**, and it matters that
you can tell them apart:
| | **A — Activity** | **B — Time log** |
|---|---|---|
| What it is | Quotes, orders, delivery notes, customers, companies | "Where the time went", visit map, hours per place |
| Source | **WFDASH itself** (PocketBase) | **Hardwired vehicle tracker** (GPS unit in the truck) |
| Entered by | Reps working in the app; some quotes arrive by email automation | A CSV export from the tracker portal, imported by a manager |
| On a phone? | No — server-side database | No — a fixed vehicle unit, not a personal phone |
| Pages in the PDF | 1, 3, 4, 5, 6 | 2 (and the map behind it) |
**The charts anyone is likely to challenge — quotes loaded, orders placed,
customers visited, companies — are all Section A, and every one of them is a
read-only count of rows that already exist in WFDASH.** Nothing on those pages
is typed in by a person for reporting purposes; the report only counts work
the reps already did in the normal course of business.
The only non-WFDASH input is Section B, and it is confined to two clearly
labelled pages.
---
## 1. Topology — where the data physically comes from
```
WHO ENTERS THE DATA
│
┌───────────┴───────────┐
▼ ▼
Reps working in the app n8n email intake
quotes · orders quotes from RFQ mail
│ │
│ order_items │
│ (cancellation check) │
│ │
└───────────┬───────────┘
▼
┌───────────────────┐
│ delivery_notes │ raised & completed
└─────────┬─────────┘
▼
══════════════════════════════════════════════════════
WFDASH → POCKETBASE (system of record)
quotes · quotes_items · orders · order_items
delivery_notes · customers · companies · users
══════════════════════════════════════════════════════
│
│ read-only counts — nothing is created
▼
┌───────────────────┐
│ REP PERFORMANCE │
│ REPORT │ (this document)
└─────────┬─────────┘
▼
screen · PDF download
```
---
## 2. Section A — Activity metrics (system of record)
Every row below is a **count of existing WFDASH records** inside the selected
date window. The report creates nothing, edits nothing, and nobody enters a
"report number" anywhere.
| Report column | Collection | Field counted | Rule |
|---|---|---|---|
| **Quotes loaded (QL)** | `quotes` | `created` | Any quote created in the window. |
| **Quotes done (QD)** | `quotes` | `created` + `status` | Quote **and** `claimed_by` set **and** status is `emailed`, `approved` or `sent`. |
| **Orders placed (OP)** | `orders` | `created` | Order in the window, **minus** any order with at least one `order_items` row marked `cancelled`. |
| **Delivery notes (DN)** | `delivery_notes` | `created` | DN in the window, excluding `cancelled`. |
| **Orders received (OR)** | `delivery_notes` | `created` + `status` | DN in the window with status `completed`. |
| **Customers / Companies** | `customers`, `companies` | — | Derived from the rows above; customers roll up to the company on the customer record. |
| **Total** | — | — | Plain sum of the five metrics above. Not weighted, not scored. |
**Code of record:** `src/lib/server/rep-performance-service.ts` (the counting
loop is `loadActivity`). The `QD` status list and the order-rep resolver are
imported from `src/lib/server/boss-dashboard-service.ts`, so this report and
the boss dashboard cannot drift apart.
### Date filtering
Every collection is filtered on the same generated window:
```
created >= "<from> 00:00:00.000Z" && created < "<to+1 day> 00:00:00.000Z"
```
The upper bound is pushed out one day so the last day is **inclusive** — a
common off-by-one that would otherwise silently drop activity on the final day
of the month.
---
## 3. Who gets the credit (attribution)
This is the part most worth explaining, because "whose number is it?" is the
usual challenge.
| Metric | Credited to |
|---|---|
| Quotes loaded, Delivery notes, Orders received | The rep who **owns the customer** (`customers.rep`) |
| Quotes done | The rep who **claimed the quote** (`quotes.claimed_by`) |
| Orders placed | The rep on the **order itself** (`orders.rep`) — *not* the customer's owner |
Orders deliberately follow the order's own rep field, matching the boss
dashboard. A rep who is covering someone else's customers shows their orders
under their own name, which is the behaviour the business expects.
### ⚠️ Known quirk worth knowing about
`orders.rep` is a **free-text field** that over the years holds *either* a
PocketBase user id *or* a person's name. The report resolves both: exact user
id first, then a case-insensitive name match. This is not a cosmetic detail —
for Tiaan in September 2026 it is the difference between 1 order and 27:
| How you look it up | Result |
|---|---|
| `orders.rep = "<user id>"` | 1 |
| `orders.rep = "Tiaan"` | 26 |
| **Report total** | **27** |
If someone queries the database themselves and gets a small number, this is
why.
---
## 4. Section B — The time log (vehicle tracker, not WFDASH)
Stated plainly: **the "where the time went" page is not sourced from WFDASH.**
It is sourced from a GPS tracking unit fitted to the vehicle. It is kept in a
separate section of the report for exactly this reason, and it should be
presented differently from Section A if it is challenged.
### 4.1 How it gets in
1. A manager downloads a **"Travel History" CSV** from the tracker's web portal.
2. They upload it on the report page (*Import time log*).
3. The file is parsed and written into PocketBase as `rep_trip_legs` — **one
row per tracker interval, with no interpretation applied at import time.**
```
══════ SEPARATE PROVENANCE — NOT WFDASH ══════
┌────────────────────────────────┐ hardwired unit in the truck,
│ Vehicle GPS tracker │ logs position every few minutes
│ (vehicle registered in │
│ users.tracker_vehicle_id) │
└────────────────────────────────┘
│ manager downloads the CSV from the tracker
│ portal, then uploads it on the report page
▼
┌────────────────────────────────┐ one row per tracker interval,
│ rep_trip_legs │ stored in PocketBase as a copy
│ (raw, un-interpreted) │
└────────────────────────────────┘
│ read-time derivation:
│ work-hours clip · 250 m clustering ·
│ company matching · operator labels
▼
┌────────────────────────────────┐ PDF page 2 + the visit map
│ "Where the time went" │
└────────────────────────────────┘
```
Storing raw and deriving at read time is deliberate: if company coordinates or
weekend rules are corrected tomorrow, historical reports improve immediately
with **no re-import**.
Which vehicle belongs to which rep is recorded on the user
(`users.tracker_vehicle_id`, e.g. `MW13TCGP`), so a CSV cannot be silently
attached to the wrong person.
Re-importing a file **replaces only the dates that file covers**, so importing
October does not destroy September.
### 4.2 What is derived (and therefore arguable)
| Derived value | Rule | Why it exists |
|---|---|---|
| Work hours | 07:30–17:00, local (SAST, UTC+2, no DST) | One global default across the fleet. |
| Weekends | **Excluded** | A truck is often out or parked at weekends for reasons unrelated to selling. One flag flips this back on. |
| Noise filter | Stops under **5 minutes** dropped | Traffic lights, gate checks, GPS drift. Applied at *period* level, so a place survives or drops whole rather than losing a 4-minute sliver. |
| Clustering | Points within **250 m** of each other become one "place" | Keeps neighbouring mine gates apart while absorbing GPS jitter when parked. |
| Place name | Matched against **`companies.coordinates` within 750 m** | `customers.coordinates` is deliberately **ignored** — those are residential addresses, and reporting "visited: a customer's house" would be worse than no name. |
| Unnamed places | Operator-assigned labels, stored in `place_overrides`, matched by proximity (300 m) | Most company records have no coordinates yet, so an unnamed place is a placeholder the manager can name on the map. |
**Consequence to be honest about:** a place only carries a company name if the
company has usable coordinates **or** someone has mapped it manually. Unmapped
places show as a raw location, not as a company. The time log is a strong
signal on *how the time was spent*; it is only as good as the mapping for
*naming* it.
---
## 5. How to verify any number yourself
The report is read-only, so anyone with admin access can reproduce its figures
directly from the database. Worked example — **Tiaan, September 2026**, the
exact period in the sample PDF:
| Report says | Raw PocketBase query | Result |
|---|---|---|
| Orders placed = 27 | `orders` where `rep` = id **or** `"Tiaan"`, created in Sept | 1 + 26 = **27** ✓ |
| Delivery notes = 3 | `delivery_notes` where `customer.rep` = Tiaan, `status != "cancelled"` | **3** ✓ |
| Orders received = 2 | same, `status == "completed"` | **2** ✓ |
| Time log rows = 692 | `rep_trip_legs` where `rep` = Tiaan | **692** ✓ |
The same check works in the WFDASH admin UI, or via the PocketBase admin API
with a filter such as:
```
customer.rep = "54uj39i4juqkh9x" && created >= "2026-09-01 00:00:00.000Z"
&& created < "2026-10-01 00:00:00.000Z" && status != "cancelled"
```
Every metric is a plain count of rows returned by a filter like this one. There
is no spreadsheet step, no manual tally, and no intermediate cache to explain.
---
## 6. Which reps appear
The rep list is **not hardcoded**. Someone appears on the report if they own a
customer, claimed a quote in the period, or carry the `REP` user type. This
means:
- a new rep shows up without a code deploy;
- a rep with no activity in the period drops off naturally;
- accounts matching `test` / `qa` / `demo` are filtered out automatically.
The summary page can additionally hide low-activity reps via the **Min total**
control; this is a display filter only and does not change any underlying total.
---
## 7. Honest limitations
Stated up front so nobody is caught out:
1. **"Orders received" is approximated by completed delivery notes.** The
natural source would be `order_items.delivered_at`, but that field is empty
on every current record, so a completed DN is used instead. If delivery-note
completion is not always entered promptly, this figure runs behind reality.
2. **Order cancellation is coarse.** An order with *any* cancelled line is
excluded entirely, not just the cancelled portion.
3. **The time log's place names depend on manual mapping** (section 4.2).
4. **Work hours are a single fleet-wide default**, not per-rep shifts.
5. **The report reflects the selected period only.** A quote loaded in August
and sent in September counts its *completed* event in September.
---
## 8. Where the code lives
| Concern | File |
|---|---|
| Counting, attribution, company rollups | `src/lib/server/rep-performance-service.ts` |
| Shared status rules (kept in step with boss dashboard) | `src/lib/server/boss-dashboard-service.ts` |
| Tracker CSV parsing | `src/lib/server/travel-history-parser.ts` |
| Time log import + read-time derivation | `src/lib/server/rep-time-log-service.ts` |
| PDF layout (A4 portrait, 6 pages) | `src/lib/pdf/rep-performance-detail-document.tsx` |
| API surface | `src/app/api/v1/reports/rep-performance/**` |
| Schema for tracker data | `scripts/migrations/2026-09-28-create-rep-trip-legs.ts` |
| Schema for operator place labels | `scripts/migrations/2026-09-28-create-place-overrides.ts` |
Both the counting service and the CSV parser are covered by unit tests
(`rep-performance-service.test.ts`, `travel-history-parser.test.ts`), so the
rules in this document are enforced by the build rather than by memory.
---
## 9. One-paragraph answer
> "The report counts records in our own WFDASH database. Quotes loaded, orders
> placed and delivery notes are rows the reps created in the app while doing
> their jobs — we just count them. Customers and companies are read straight
> off those rows. The one exception is the 'where the time went' page, which
> comes from the GPS unit fitted to the truck, exported as a CSV and imported
> manually; it's a separate page for that reason. You can reproduce any number
> in section A yourself by filtering the database on the rep and the date
> range — the queries are in section 5."

View file

@ -1,32 +0,0 @@
# WFDASH SOPs
Quick cheat sheets for each role in the business. Read your role's SOP
on your first day. Keep it handy.
## The four roles
| File | What they do |
|---|---|
| [quote-operator.md](./quote-operator.md) | Answer the phone, price items, send quotes, follow up. |
| [order-operator.md](./order-operator.md) | Turn accepted quotes into orders. Buy from suppliers. Get goods to the customer. |
| [collection-driver.md](./collection-driver.md) | Drive to suppliers, collect the goods, deliver to customers. (Mobile app.) |
| [approver-supervisor.md](./approver-supervisor.md) | Sign off quotes, run the weekly review, handle escalations. |
## How to read your SOP
Each one has four short sections:
1. **What you do** — your job, in plain English.
2. **Your day** — the actions, in order, with times.
3. **How to do the common stuff** — step-by-step for the 3-4 tasks
you'll do most often.
4. **Don'ts & when to ask** — the 3-4 things you must never do, and
the 3-4 situations where you stop and ask the supervisor.
That's it. There are no bonus points for reading more.
## When something changes
If your job changes and the SOP doesn't match, tell the supervisor.
They'll fix the SOP. Don't keep doing the old thing because the SOP
said so.

View file

@ -1,122 +0,0 @@
# Approver / Supervisor — Quickstart
**One-line job:** keep the team unblocked, the system healthy, and
the customers happy.
You don't do the pricing or the driving. You make sure the people
who do have what they need, the system gives them the right signals,
and the customers don't fall through the cracks.
## What you do
- Sign off quotes that landed in `/quotes/approvals`.
- Handle escalations the operators bring to you (discount, cancellation,
customer complaint, supplier dispute).
- Pull the weekly KPI one-pager, run the Monday standup.
- Write the Friday summary.
- Set pricing policy when costs / markets shift.
## Your day
| Time | What to do |
|---|---|
| **08:00** | Open `/quotes/approvals`. Clear the queue. Anything > 4 hours old = bug. |
| **08:30** | Glance at `/dashboard`. Approval rate, pipeline depth, follow-ups due. Anything red? |
| **13:00** | Re-check `/quotes/approvals`. Anything still pending from this morning is now a bug. |
| **17:00** | Triage escalations. Every escalation gets a decision or a clear next step. |
**Monday** (90 min block): pull the weekly KPI one-pager, run the
standup (15 min), update pricing policy if needed.
**Friday** (afternoon): write the 1-page weekly summary, forecast
next month's cash collection.
## How to do the common stuff
### 1. Approve a quote
1. Open `/quotes/approvals`. Oldest first.
2. For each quote, check:
- **Customer + rep** — known account? Standing discount?
- **Every line priced** — any zeros? Refuse and send back.
- **Markup** — does it match the policy for this category / customer?
- **Validity date** — realistic?
3. **Approve** (status → `approved`, operator can send) or
**Request changes** (operator re-prices) or **Reject** (with
a reason — not a silent "no").
4. **Log the decision in the quote's notes** if it's non-obvious
(e.g. "approved at 18% markup per Aug 2026 customer contract").
### 2. Handle a "should I give this discount?" escalation
- **< 5%** — the operator can do it. Tell them to apply and log.
- **5-15%** — if strategic account OR one-off (large order, end-of-line),
approve. Otherwise tell the operator to push back.
- **> 15%** — you (or the owner) call the customer. Don't delegate.
### 3. Handle a "customer wants to cancel" escalation
- **No PO sent yet** — easy, cancel the line.
- **PO sent, supplier hasn't confirmed** — phone the supplier NOW
(most will cancel without charge if pre-confirm).
- **PO confirmed, goods in transit / collected** — three options:
accept and book to stock, refuse delivery (restocking fee?), or
return-to-vendor. You decide based on the customer + the cost.
### 4. Set / change pricing policy
1. Identify the scope (category? customer? supplier?).
2. Pull the data: current avg markup for that scope, competitor
pricing, supplier cost trend.
3. Document the change: scope, new range, effective date, why.
4. Communicate: post in the team chat, mention Monday standup, pin
in `/settings` if there's a policy field.
5. Audit after 2 weeks — is the team actually applying it?
## Don'ts — the 3 things you must never do
1. **Never approve a quote without reading it.** 30-second reviews
miss the wrong SKU, wrong qty, wrong customer. If you don't have
time, do it later. Don't rubber-stamp.
2. **Never reject an operator's escalation without a reason.** "No"
is demoralising. "No, because the markup is below the policy
for this category" is a teachable moment.
3. **Never push a system-level decision to the operators.** If
the data is wrong, the SOP is unclear, or a workflow is
broken — that's yours to fix, not theirs to work around.
## When to escalate to the owner (the 4 most common situations)
- **Strategic account (top 10% by spend) wants bespoke terms.** Call
the owner. Strategic accounts can have bespoke anything; it's
a relationship call, not an SOP.
- **A supplier wants to renegotiate terms that change > 20% of our
spend.** Call the owner. This is a business call, not an
operator call.
- **A customer escalates a quality issue and asks for the owner /
MD by name.** Call the owner. Don't try to be the buffer.
- **Anything involving a refund > R50,000, a credit note > R100,000,
or a write-off.** Call the owner. Documented, in writing.
## How you're measured (the 3 numbers that matter)
1. **Approval turnaround** — target: **< 4 working hours median.**
(Quotes don't sit in your queue.)
2. **Escalation response time** — target: **< 2 working hours.**
(Operators aren't blocked waiting on you.)
3. **Team KPI attainment** — target: **> 80% of operator KPIs at
target** each week. (Your team is doing their job.)
Bonus number: **catalogue coverage** — target > 60% of quote items
matched. If low, the Pricelist Curator needs to upload more supplier
lists. That's your nudge, not theirs.
## Who to ask
- **The owner / MD** — for any of the 4 escalation situations above,
anything strategic, anything you can't decide alone.
- **Other supervisors / industry peers** — for pricing policy sanity
checks ("does this markup make sense for JEI?").
- **When in doubt, write it down.** If you made a decision, log it
in the relevant record. Future you (and the auditors) will
thank you.

View file

@ -1,118 +0,0 @@
# Collection Driver — Quickstart
**One-line job:** pick up goods from suppliers, drop them at customers.
You do this on your **phone** (WFDASHDRIVER-APP) or the office
`/collection-confirm` page. The app is the source of truth — what's
on the app is what counts.
## What you do
Every day, the app has a list of pickups and deliveries. You:
- Drive to the supplier, collect the goods.
- Take a photo + sign at the supplier.
- Drop the goods at the customer.
- Take a photo + get the customer's signature.
- Flag anything weird (short, wrong, damaged) before leaving.
## Your day
| Time | What to do |
|---|---|
| **07:00** | Open the app. Look at today's run — suppliers, in order. |
| **07:15** | Phone the first supplier if you haven't worked with them in a while. Confirm they're open. |
| **08:00 - 17:00** | Do the pickups + deliveries. Use the app, not memory. |
| **12:00** | Lunch + call the office if anything is blocking. |
| **17:00** | Sync the app. Mark all today's items with their final status. Nothing in flight overnight. |
**Before you leave the truck**: every item from today is either
`collected`, `delivered`, or marked with a problem and a note. If
you don't, the office won't know what happened.
## How to do the common stuff
### 1. At the supplier — happy path
1. Tap the supplier row in the app. Tap **Navigate** (opens Maps).
2. At the supplier, tap **Confirm Collection**.
3. Scan or tap each line. Enter the qty received.
4. **Photograph the supplier's delivery note / invoice.** The app
attaches it automatically.
5. Tap **Submit**. Items flip to `collected`.
### 2. At the supplier — short / wrong / damaged
1. **Don't accept** the bad goods. Talk to the supplier's rep.
2. In the app, on the line, tap **Report Problem**.
3. Pick: `short` / `wrong_item` / `damaged` / `other`.
4. **Take 2+ photos** (whole item + close-up of the issue).
5. Get the supplier to **sign the delivery note** with the
discrepancy. Photograph the signed note.
6. **Add a note** explaining what happened.
7. Tap **Submit**. The item stays in `collection_pool` — the office
handles the dispute.
### 3. At the customer — happy path
1. Tap the order row. Tap **Confirm Delivery**.
2. Each line: tap **Delivered** (qty matches).
3. **Photograph the goods at the customer's site.**
4. **Get the customer's signature** on the app (or on the waybill
+ photograph it).
5. Tap **Submit**. Items flip to `delivered`.
### 4. At the customer — partial / refused / not home
- **Customer wants less than you brought:** mark the line as
**Partial**, enter the qty they took, return the rest to the
office. Note why.
- **Customer refuses (damaged, wrong, "changed my mind"):**
photograph everything, get them to sign the refusal note, bring
it back. The office decides next.
- **Customer not on site:** wait 15 min, try to call, leave a card.
Mark `assigned` with a note "not on site" + the time you tried.
Bring the goods back.
## Don'ts — the 3 things you must never do
1. **Never deliver without a photo + signature.** A "delivered"
item with no proof is a dispute magnet. The app needs both.
2. **Never submit a wrong / damaged pickup silently.** The
supplier will say "we never gave you damaged goods" and you'll
be on the hook. Photo + signed note at the supplier, every time.
3. **Never leave the app showing items "in flight" overnight.**
Mark everything as final before you clock out. If something's
stuck, the office can't help you at 7am Monday if the app says
you're still on the road.
## When to ask the office (the 4 most common situations)
- **Supplier closed / order not ready.** Don't wait. Mark
`assigned`, note the time, re-order your run. Call the office
so they can reschedule the customer.
- **Wrong address.** Don't drive around. Tap **Report Wrong
Address** on the row. The office fixes the master data and
re-routes you.
- **Customer wants a discount to accept.** Don't promise anything.
Call the office. They'll decide.
- **Truck breakdown / accident.** Safety first. Then call the
office. The goods are secondary.
## How you're measured (the 2 numbers that matter)
1. **On-time delivery rate** — target: **> 95%** of items delivered
by the customer-promised time. (You hit the date.)
2. **Photo + signature proof rate** — target: **100%** of collections
and deliveries. (You have the evidence if it goes wrong.)
Bonus number: **error rate** — target < 2% of line items have
damage / wrong-item reports. (You get it right first time.)
## Who to ask
- **The office phone number** — for anything that needs a decision
(refusal, discount, wrong address, breakdown).
- **Your supervisor** — for any pattern (same supplier always short,
same customer always refusing).
- **When in doubt, call.** Don't guess from the cab.

View file

@ -1,119 +0,0 @@
# Order Operator — Quickstart
**One-line job:** turn accepted quotes into delivered goods.
## What you do
The customer said yes. You:
- Create the order from the quote.
- Order the goods from the supplier (a Purchase Order).
- Track them through collection, packing, and delivery.
- Hand off to invoicing once delivered.
You're the bridge between "they said yes" and "they have it".
## Your day
| Time | What to do |
|---|---|
| **08:00** | Open `/orders?tab=New`. Anything new from yesterday? Move it forward. |
| **08:30** | Open `/orders?tab=Processing`. Every line should have a PO. Flag any stuck > 1 day. |
| **12:00** | Glance at `/orders?tab=Collected`. Anything stuck in `stock_verified`? Investigate. |
| **17:00** | Anything you changed today — sanity check. Don't leave surprises for Monday. |
**Three times a day**, scan the dashboard. The order-trend chart
should show lines moving right (New → Processing → Collection →
Collected → Delivered). If anything's piling up in one tab, that's
where to focus.
## How to do the common stuff
### 1. Create an order from a quote
1. Open the quote (status `approved` or `emailed`).
2. Click **Create Order**. One new order, one row per quote line.
3. Order lands in **New** tab with everything `pending`.
4. The quote status does **not** change. Leave it alone.
### 2. Process a `pending` line
1. Open the order detail, click the line.
2. Set `cost_price` (from the quote) and `selling_price` (auto-derived
from cost × markup).
3. Click **Mark Processed**. Status → `processed`.
4. Decide next: in stock? → **Book from Stock**. Not in stock? →
**Generate PO** (next step).
### 3. Generate a Purchase Order (PO)
1. Click **Generate PO**. One PO per supplier.
2. Open the PO. Verify items, quantities, costs, delivery date.
3. Click **Send PO**. Same email modal as quotes — check recipient,
attach any terms docs, hit send.
4. Status → `po_generated`. The order item waits for the supplier to
reply / ship.
### 4. Receive a supplier invoice (when the email or paper arrives)
1. Find the PO number on the invoice (top right usually — "Your Ref").
2. The system will diff the invoice against the PO and tell you if
it matches. **If it says "ready to receive"** → click **Confirm
Receive**. Done.
3. **If it says anything else** (cost mismatch, qty mismatch, etc.) →
**stop and ask the supervisor.** Don't force it.
### 5. Mark delivered (once the goods are with the customer)
1. Confirm the order has all its items in `delivered`. The header
will show a green "Complete" badge.
2. Click **Mark Delivered** on the header. (Idempotent — safe to
click twice.)
3. Then click **Mark Ready to Invoice**. Items flip to `ready_invoice`.
The Invoicing Clerk takes it from here.
## Don'ts — the 3 things you must never do
1. **Never generate a PO without verifying the cost.** Once sent, it's
the contract. Wrong cost = wrong margin.
2. **Never "force through" a receive diff that doesn't say
`ready_to_receive`.** If the diff says cost mismatch, qty mismatch,
supplier mismatch — call the supervisor. Don't bypass.
3. **Never mark an order delivered before every line is `delivered`.**
Use the per-line `Mark Delivered` if needed. Don't bulk-flip the
header to "make it look done".
## When to ask the supervisor (the 4 most common situations)
- **Supplier hasn't replied to a PO in 3+ working days.** Phone them
first. If no answer after 2 attempts, escalate.
- **Customer wants to cancel after the PO is sent.** Depends on
whether the supplier has confirmed / shipped. Call the supervisor —
there are 3 options and they pick.
- **Supplier delivered the wrong / damaged goods.** Refuse at the
supplier (if still there) or take photos at receipt. **Don't
accept damaged goods silently.** The supervisor decides credit /
return / write-off.
- **Stock item is short when you try to book.** Either the Stock
Controller can re-check the count, or we source from the supplier
instead. Phone, don't poke.
## How you're measured (the 2 numbers that matter)
1. **Order completion time** — target: **> 80% of orders delivered
within 5 working days** of the order being created. (From "yes" to
"they have it".)
2. **PO stale rate** — target: **< 5% of open POs** without a supplier
reply for > 3 working days. (Catch the silent ones early.)
Bonus number the supervisor watches: **PO cost-mismatch rate** —
target < 3% of receives. If yours is higher, your cost entries are
probably sloppy.
## Who to ask
- **Anything about a specific order, a PO, a customer, a supplier**
→ supervisor.
- **Anything broken in the system** → supervisor.
- **Anything you're not sure about** → supervisor. Better to ask
than guess.

View file

@ -1,112 +0,0 @@
# Quote Operator — Quickstart
**One-line job:** turn customer requests into priced quotes, fast.
## What you do
A customer wants something. We need to tell them "yes, we can supply
it, and here's the price". You:
- Pick up new quotes that come in by email or phone.
- Find the right price (from the catalogue, an AI suggestion, or a
supplier list).
- Get the quote approved.
- Send it to the customer.
- Follow up if they don't reply.
## Your day
| Time | What to do |
|---|---|
| **08:30** | Open `/quotes?tab=new`. Anything new from overnight? Pick it up. |
| **09:00** | Open `/sprint`. Claim 2-3 items you can price fast. (30-min timer per item.) |
| **10:00, 13:00, 16:00** | Same as 09:00. Keep the sprint queue small. |
| **12:00** | Lunch. |
| **17:00** | Set `follow_up_due` on every quote you sent today (5 working days from now). |
**Three times a day**, glance at `/dashboard` — is the "Quote Pipeline"
doughnut getting stuck in `new` or `claim`? If yes, the queue is
backing up. Pick up more.
## How to do the common stuff
### 1. Price a line item (the most common task — you'll do this 50× a day)
1. Open the line.
2. Is there a **matched catalogue row** (look for the catalogue panel
on the line)? Use its price. Set `pricing_source: "catalogue"`.
3. Not matched? Search `/pricelist` by description or SKU. If you
find it, copy the price across. Set `pricing_source: "catalogue"`.
4. Still nothing? Look at recent supplier lists in `/pricelist`
(the "Raw Uploads" tab). If the supplier is one we work with,
use their price. Set `pricing_source: "manual"`.
5. **Cannot find a price anywhere** → flag the line for the
supervisor. Don't guess.
### 2. Send a quote (once everything is priced)
1. Click **Submit for Approval**. Status → `approval_pending`.
2. Wait for the supervisor. (You'll see it move off the queue.)
3. Approved? Click **Send Email**.
4. Check the recipient. Add the customer rep to CC if you know them.
5. The PDF is already attached. Hit **Send**.
6. **Set `follow_up_due`** to today + 5 working days. Don't forget.
### 3. Follow up on a sent quote
1. Open the quote. Is there a reply logged? (Green chip on the row.)
2. No reply? **Phone the customer first** — faster than email.
3. Log the call in the quote's notes.
4. Set a new `follow_up_due` date. Try again in 5 working days.
### 4. Inbound email quote (the common one)
1. n8n already created the quote in `/quotes?tab=new`. Open it.
2. Check: right **customer**? right **company/letterhead**? items
look right?
3. Items with `pricing_source: "ai"` are pre-priced — verify, save.
4. Items with no price landed in `/sprint` — go price them.
## Don'ts — the 3 things you must never do
1. **Never send a quote with a line that has `selling_price = 0`.**
The system will refuse the send. If you can't price a line, ask
the supervisor.
2. **Never override an AI price without checking the catalogue.**
The AI is right ~85% of the time. If you change it, write a
`pricing_notes` line saying why (e.g. "catalogue price used,
AI was 12% low").
3. **Never leave a sprint claim to expire with no work.** Save
what you have (even partial), or release the claim by walking
away. Don't ghost your own claims.
## When to ask the supervisor (the 4 most common situations)
- **AI price is way off (more than 10% from catalogue).** Use the
catalogue price and log the why. Don't agonise.
- **Customer wants a discount.** < 5% — apply it, log the reason.
5-15% — apply it, flag the approver in the quote notes.
> 15% — don't apply; tell the customer it's above your authority.
- **Email send failed.** Click **Retry** once. If it fails again,
check the address (typo? bounced?). If still failing, escalate.
- **Customer is unknown / wrong.** The quote landed on "Unknown
Sender". Don't price it. Hand to the supervisor to create the
customer first.
## How you're measured (the 2 numbers that matter)
1. **Time to first touch on a new quote** — target: **< 2 hours**
during business hours. (How fast did you pick it up?)
2. **Win rate** — target: **> 30%** of quotes you send become
orders. (Out of every 10 quotes you sent, how many got accepted?)
The supervisor pulls these for you every Monday. If either is red
for 2+ weeks, you'll get a 1-on-1.
## Who to ask
- **Anything about a specific quote, a customer, a price** → supervisor.
- **Anything broken in the system (button missing, error message, weird
data)** → supervisor, who'll loop in the dev if needed.
- **Anything you're not sure about** → supervisor. Better to ask
than guess.

View file

@ -1,708 +0,0 @@
# Approve & Send from Pending-Approvals List Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Wire the existing detail-page "Approve & send email" affordance (modal + `review-approve action: "approve"`) onto the Pending Approvals list rows, so the rep can approve and dispatch in one click without drilling into the quote detail.
**Architecture:** Single-file change to `src/app/(app)/quotes/approvals/page.tsx`. Reuse the existing `SendQuoteModal` component unchanged. Reuse the existing `POST /api/v1/quotes/[id]/review-approve` endpoint unchanged. Replace the list's two-step handlers (`handleApprove` + `handleApprovePartial`) with a single `sendApprove` lifted to page level, callable per row. Reject path stays untouched.
**Tech Stack:** Next.js 16 (App Router), TypeScript, React, Vitest + Testing Library (jsdom). Component-level test uses `vi.hoisted` mocks.
---
## File structure
| File | Touched in | Responsibility |
|---|---|---|
| `src/app/(app)/quotes/approvals/page.test.tsx` | Task 1 (create) | Component test for the row-button → modal → submit → toast flow |
| `src/app/(app)/quotes/approvals/page.tsx` | Tasks 2, 3, 4 | Lift `sendApprove`; render one `SendQuoteModal` at page level; replace `handleApprove` + `handleApprovePartial`; replace button labels |
Unchanged (reused as-is):
- `src/components/quotes/send-quote-modal.tsx` — modal component
- `src/app/api/v1/quotes/[id]/review-approve/route.ts` — endpoint
- `src/app/(app)/quotes/approvals/[id]/page.tsx` — detail page (already uses `review-approve`)
---
## Task 1: Add component tests for the new row-button flow (failing)
**Files:**
- Create: `src/app/(app)/quotes/approvals/page.test.tsx`
- [ ] **Step 1: Create the test file with the mock infrastructure**
Write `src/app/(app)/quotes/approvals/page.test.tsx` with the `vi.hoisted` mocks for PB + `next/navigation` + `SendQuoteModal`. The PB mock provides an empty `getList` (returns two quotes: one full-pending, one partial-pending). `SendQuoteModal` is replaced with a stub that exposes the props the test cares about (so we can drive the `onSend` callback from outside). `useRouter` is stubbed so `router.refresh` and `router.push` are observable spies.
```tsx
import { describe, it, expect, beforeEach, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import ApprovalsPage from "./page";
// ── Mocks ────────────────────────────────────────────────────────────
// Hoisted so the vi.mock factories can close over them.
const {
getListMock,
refreshMock,
pushMock,
captureModalProps,
triggerSend,
} = vi.hoisted(() => ({
getListMock: vi.fn(),
refreshMock: vi.fn(),
pushMock: vi.fn(),
captureModalProps: vi.fn(),
triggerSend: vi.fn(),
}));
// Stub the PocketBase client so we can drive list contents from the test.
vi.mock("@/lib/pocketbase", () => ({
__esModule: true,
default: () => ({
collection: (name: string) => {
if (name === "quotes") {
return { getList: (...args: unknown[]) => getListMock(...args) };
}
if (name === "quote_items") {
return { getFullList: async () => [] };
}
return { getFullList: async () => [], getList: async () => ({ items: [] }) };
},
}),
}));
// Stub next/navigation so we can assert on refresh/push.
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: refreshMock, push: pushMock }),
}));
// Replace SendQuoteModal with a thin stub. The test grabs the props the
// page passed in via captureModalProps; triggerSend becomes the onSend
// prop, so tests can fire the modal submit callback directly.
vi.mock("@/components/quotes/send-quote-modal", () => ({
SendQuoteModal: (props: {
open: boolean;
submitLabel?: string;
onSend: (p: unknown) => Promise<void>;
quote: { id: string };
}) => {
captureModalProps(props);
if (!props.open) return null;
return (
<div data-testid="send-quote-modal">
<span data-testid="modal-submit-label">{props.submitLabel ?? ""}</span>
<span data-testid="modal-quote-id">{props.quote.id}</span>
<button
type="button"
onClick={() =>
triggerSend({
to: "buyer@example.com",
cc: "",
bcc: "",
subject: "Quote Q-26082701",
message: "",
selectedAttachmentIds: [],
})
}
>
Submit modal
</button>
</div>
);
},
}));
// fetch is used by the modal stub's submit handler (and by the rep-email
// lookup). The page itself only does `fetch("/api/v1/quotes/[id]/review-approve")`.
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const baseQuote = (overrides: Record<string, unknown> = {}) => ({
id: "q-full-1",
quote_number: "Q-26082701",
status: "approval_pending",
rfq_number: "RFQ-100",
description: "Replacement quote",
customer: "cust-1",
company: "co-1",
created: "2026-08-27T08:00:00Z",
notes: "",
expand: {
customer: { customer: "Acme Co", rep: "", company: "co-1" },
company: { company: "Acme Holdings" },
},
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
// Default list: one full-pending, one partial-pending. Tests can
// override getListMock.mockResolvedValueOnce(...) to narrow.
getListMock.mockResolvedValue({
items: [
baseQuote(),
baseQuote({
id: "q-part-1",
quote_number: "Q-26082702",
status: "partial_approval_pending",
}),
],
});
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ quote: baseQuote(), email: {} }),
});
});
describe("ApprovalsPage — Approve & send from list", () => {
it("renders an 'Approve & send' button on a full-pending row", async () => {
render(<ApprovalsPage />);
await waitFor(() => {
expect(screen.getAllByRole("button", { name: /approve & send/i }).length).toBeGreaterThan(0);
});
});
it("clicking 'Approve & send' on a full row opens SendQuoteModal with submitLabel 'Approve & send'", async () => {
render(<ApprovalsPage />);
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
fireEvent.click(buttons[0]);
await waitFor(() => {
expect(captureModalProps).toHaveBeenCalled();
const last = captureModalProps.mock.calls.at(-1)![0];
expect(last.open).toBe(true);
expect(last.quote.id).toBe("q-full-1");
expect(last.submitLabel).toBe("Approve & send");
});
});
it("submitting the modal posts to review-approve with action 'approve' and email payload", async () => {
render(<ApprovalsPage />);
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
fireEvent.click(buttons[0]);
await waitFor(() => expect(captureModalProps).toHaveBeenCalled());
// The modal stub's "Submit modal" button fires triggerSend which is
// wired up as the page's onSend callback below.
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/quotes/q-full-1/review-approve",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: expect.stringContaining('"action":"approve"'),
})
);
});
});
it("on 200 success, refreshes the list and toasts 'Approved — email sent...'", async () => {
render(<ApprovalsPage />);
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
fireEvent.click(buttons[0]);
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(refreshMock).toHaveBeenCalled();
});
// toast.success is from sonner — verify by checking sonner was called.
const { toast } = await import("sonner");
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(
expect.stringContaining("Approved — email sent")
);
});
});
it("on 422 invalid_status, refreshes and toasts 'This quote was already actioned — refreshing.'", async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 422,
json: async () => ({ error: "Already approved", code: "invalid_status" }),
});
render(<ApprovalsPage />);
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
fireEvent.click(buttons[0]);
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(refreshMock).toHaveBeenCalled();
});
const { toast } = await import("sonner");
await waitFor(() => {
expect(toast).toHaveBeenCalledWith(
expect.stringContaining("already actioned"),
expect.anything()
);
});
});
it("on 422 mail-failed, toasts 'Saved — but email send failed...'", async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 422,
json: async () => ({
error: "SMTP error",
email: { failure: { code: "smtp", message: "Connection refused" } },
}),
});
render(<ApprovalsPage />);
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
fireEvent.click(buttons[0]);
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
fireEvent.click(submitBtn);
const { toast } = await import("sonner");
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
expect.stringContaining("Saved — but email send failed")
);
});
});
it("clicking 'Approve & send' on a partial row invokes review-approve and toasts the partial message", async () => {
render(<ApprovalsPage />);
const buttons = await screen.findAllByRole("button", { name: /approve & send/i });
// The second row is the partial one.
fireEvent.click(buttons[1]);
await waitFor(() => expect(captureModalProps).toHaveBeenCalled());
const last = captureModalProps.mock.calls.at(-1)![0];
expect(last.quote.id).toBe("q-part-1");
const submitBtn = await screen.findByRole("button", { name: /submit modal/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/quotes/q-part-1/review-approve",
expect.objectContaining({ method: "POST" })
);
});
const { toast } = await import("sonner");
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(
expect.stringContaining("Partial approved — email sent")
);
});
});
});
```
- [ ] **Step 2: Verify sonner is mocked**
The detail page imports `toast` from `sonner`. The test stubs `vi.stubGlobal("fetch")` but not sonner. Sonner's `toast` function uses React internals (creates a portal) which fails in jsdom if you let it render. Check whether the existing test setup mocks sonner globally — if it doesn't, add `vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }) }))` to this file's mock block (before the `describe`). Confirm by reading `src/test-setup.ts` to see whether a global sonner mock is already wired up. If it is, drop the inline `import { toast }` in the tests and assert via the global mock directly.
- [ ] **Step 3: Run the tests; expect 7 failures**
Run: `npx vitest run src/app/(app)/quotes/approvals/page.test.tsx`
Expected: 7 failures — the page still has the old `handleApprove` / `handleApprovePartial` that call `pb.collection.update` / `fetch("/api/v1/quotes/.../partial-approve")` directly, not `SendQuoteModal`. The new tests reference button names and flows that don't exist yet. Failures should mention "Unable to find a button with name /approve & send/i" or similar.
- [ ] **Step 4: Commit (scaffolding only — tests will be fixed in Tasks 2-4)**
```bash
git add 'src/app/(app)/quotes/approvals/page.test.tsx'
git commit -m "test(approvals): scaffold Approve & send from list tests
Component tests cover the row-button -> SendQuoteModal ->
review-approve submit flow including success, invalid_status,
and mail-failed error branches. All 7 fail; implementation in
follow-up commits."
```
---
## Task 2: Wire full-quote "Approve & send" path (tests 1, 2, 3, 4, 5 pass)
**Files:**
- Modify: `src/app/(app)/quotes/approvals/page.tsx` (replace `handleApprove`; add lifted `sendApprove` + page-level modal render; change button label)
- [ ] **Step 1: Add imports**
In `src/app/(app)/quotes/approvals/page.tsx`, add the modal import. Existing imports include `useState`, `useEffect`, `useCallback`, `useRouter`, `toast`, `getPocketBase`, `Button`, etc. Add at the top of the import block:
```ts
import { SendQuoteModal } from "@/components/quotes/send-quote-modal";
import { isPlaceholderCustomer } from "@/lib/placeholder-customers";
import type { QuoteAttachment } from "@/lib/types";
```
- [ ] **Step 2: Add page-level state for the modal**
Find the existing state declarations near line 80-94. Add immediately after the `itemsByQuote` declaration:
```ts
// Modal state — which row's "Approve & send" modal is currently open.
// `null` means no modal is rendered. The modal stays mounted across
// rows so we don't unmount mid-edit; switching rows closes the current
// modal and opens the new one via the onOpenChange callback.
const [sendModalQuoteId, setSendModalQuoteId] = useState<string | null>(null);
// Per-quote outbound attachments cache. Keyed by quote id; only the
// modal needs them, so we fetch lazily when the modal first opens.
const [outboundAttachments, setOutboundAttachments] = useState<
Record<string, QuoteAttachment[]>
>({});
```
- [ ] **Step 3: Add the lifted `sendApprove` callback**
Find the `handleApprove` function (lines 153-168). Replace it with:
```ts
// Lifted from /quotes/approvals/[id]/page.tsx — the modal's onSend
// handler. Called when the rep confirms the email composition and
// hits the modal's primary button ("Approve & send").
//
// Diff from the detail page version:
// - We use `router.refresh()` instead of `router.push("/quotes/approvals")`
// because we're already on the list; refresh re-fetches the page's
// server data, which re-renders the list and drops the now-approved
// row naturally.
// - `itemChanges` is always `[]` — the list page has no inline markup
// editor (that's a detail-page affordance). The rep nudges markup
// by drilling into the quote, which is the explicit out-of-scope
// escape hatch in the spec.
const sendApprove = async (
quote: Quote,
email: {
to: string;
cc: string;
bcc: string;
subject: string;
message: string;
selectedAttachmentIds: string[];
}
) => {
setActionLoading(quote.id);
try {
const res = await fetch(`/api/v1/quotes/${quote.id}/review-approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "approve", itemChanges: [], email }),
});
const body = (await res.json().catch(() => ({}))) as {
error?: string;
code?: string;
email?: { failure?: { code: string; message: string } };
};
if (!res.ok) {
// Three branches on the !ok path:
// 1) invalid_status — another rep already actioned this quote.
// Surface a toast that explains the refresh and refresh the list.
// 2) Mail-send failure — the approve part succeeded server-side
// but the SMTP send failed. The quote is now in `mail_failed`;
// tell the rep to retry from the quote page.
// 3) Anything else (placeholder customer, validation, 5xx) —
// generic error toast.
if (body.code === "invalid_status") {
toast("This quote was already actioned — refreshing.", { type: "info" });
router.refresh();
setSendModalQuoteId(null);
return;
}
if (body.email?.failure) {
toast.error(
`Saved — but email send failed: ${body.email.failure.message}. Retry from the quote page.`
);
setSendModalQuoteId(null);
router.refresh();
return;
}
toast.error(body.error ?? `Approve failed (HTTP ${res.status})`);
return;
}
// 200 path — branch on partial vs full based on the quote we sent.
toast.success(
quote.status === "partial_approval_pending"
? "Partial approved — email sent for the priced items."
: "Approved — email sent to the customer."
);
setSendModalQuoteId(null);
router.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Approve failed");
} finally {
setActionLoading("");
}
};
// Outbound attachments loader — only called when the modal opens for
// the first time on a row. The detail page does this fetch inside the
// modal mount; we mirror the pattern.
const ensureOutboundAttachments = useCallback(async (quoteId: string) => {
if (outboundAttachments[quoteId]) return;
try {
const pb = getPocketBase();
const items = await pb.collection("quote_attachments").getFullList<QuoteAttachment>({
filter: `quote = "${quoteId}" && direction = "outbound"`,
sort: "-created",
});
setOutboundAttachments((prev) => ({ ...prev, [quoteId]: items }));
} catch {
// Silent — modal will just show zero outbound attachments. The
// rep can still send the email with just the PDF.
}
}, [outboundAttachments]);
```
- [ ] **Step 4: Replace `handleApprove` with a thin open-modal handler**
Replace `handleApprove` (the full-quote version) with:
```ts
const openSendModal = (quoteId: string) => {
setSendModalQuoteId(quoteId);
void ensureOutboundAttachments(quoteId);
};
```
- [ ] **Step 5: Update the button to call `openSendModal` and use the new label**
Find the `isPartial` ternary block at lines 392-423 (the button group with "Approve" and "Approve partial"). Replace the entire `{isPartial ? (...) : (...)}` block with a single button that always reads "Approve & send":
```tsx
{/* Approve & send — same button for both full and partial rows.
Opens the same SendQuoteModal the detail page uses, then submits
via POST /api/v1/quotes/[id]/review-approve action: "approve".
The server handles partials natively (flips through
partial_approved → sending → emailed). */}
<Button
size="sm"
variant={isPartial ? "default" : "default"}
onClick={(e) => {
e.stopPropagation();
openSendModal(quote.id);
}}
disabled={actionLoading !== ""}
>
{actionLoading === quote.id && (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
)}
<CheckCircle className="mr-1 h-3 w-3" />
Approve &amp; send
</Button>
```
Note: `&amp;` because we're inside JSX text content. Use that exact entity — `<Button>Approve & send</Button>` (with `&` literal) works in JSX too if you prefer; both render the same.
- [ ] **Step 6: Render `SendQuoteModal` at page level**
Find the closing `</Card>` for each row and the outer `</div>` for the grid. Just BEFORE the `</div>` that closes the grid (the one wrapping `{quotes.map((quote) => { ... })}`), render the modal once, keyed by the open row:
```tsx
{(() => {
const openQuote = quotes.find((q) => q.id === sendModalQuoteId);
if (!openQuote) return null;
const customerIsPlaceholder = isPlaceholderCustomer(
openQuote.expand?.customer?.customer ?? ""
);
return (
<SendQuoteModal
open={true}
onOpenChange={(v) => {
if (!v) setSendModalQuoteId(null);
}}
quote={openQuote}
outboundAttachments={outboundAttachments[openQuote.id] ?? []}
// The detail page passes the rep's display name; on the list we
// don't have a per-quote claimer lookup, so the modal just shows
// its own default signature.
claimerName={null}
submitting={actionLoading === openQuote.id}
submitLabel="Approve & send"
onSend={(email) => sendApprove(openQuote, email)}
// No onRetry: a mail-failed retry needs the quote's persisted
// composition, which the list page doesn't keep. The rep can
// retry from the quote page (the same path the toast points them
// at).
/>
);
})()}
```
Place this just before the `{` that closes the `{quotes.length === 0 ? ... : <div className="grid gap-4"> ... </div>}` ternary. The modal renders OUTSIDE the grid wrapper so its portal sits at the document root.
- [ ] **Step 7: Disable submit when customer is placeholder**
The detail page already short-circuits the modal's submit button when the customer is a placeholder (no email target). We can't replicate that here without the modal knowing the placeholder flag. Two options:
Option A (simplest, this commit): trust the server's 422 response. The placeholder customer case will land in the generic error toast — `"Approve failed (HTTP 422)"` with whatever message the server returns. The spec says placeholder customer disables submit, but this is a slight degradation from the detail page's UX. Acceptable because placeholder customers shouldn't reach the approval queue in normal flow.
Option B: add a `customerIsPlaceholder` prop to `SendQuoteModal` and pass it here. Out of scope for this commit; document as a follow-up.
For this commit, take Option A. Add a comment in the modal render explaining:
```tsx
{/* Placeholder-customer UX (disable submit) is delegated to the
server's 422 response. The detail page passes a customerIsPlaceholder
prop to SendQuoteModal; the list page deliberately doesn't bother —
placeholder customers shouldn't reach the approval queue in normal
flow, and the existing 422 handler shows the failure cleanly. */}
```
- [ ] **Step 8: Update the file header comment**
Find the file header doc comment block (lines 18-39). Replace the "Actions" section with:
```
* Actions:
* - Approve & send (full): opens SendQuoteModal and submits via
* /review-approve action: "approve". The server atomically flips
* status and dispatches the email.
* - Approve & send (partial): same modal + same endpoint. The
* server handles partial_approval_pending → partial_approved →
* sending → emailed natively.
* - Reject: bounces the quote back to the operator's queue —
* flips `status` to `new`, clears `claimed_by`, and appends
* the rejection reason (with reviewer + timestamp) to
* `quotes.notes`. Works for both full and partial submissions.
*/
```
- [ ] **Step 9: Run the tests for the full-quote path**
Run: `npx vitest run src/app/(app)/quotes/approvals/page.test.tsx`
Expected: Tests 1, 2, 3, 4, 5 pass (render "Approve & send" button; click opens modal with `submitLabel="Approve & send"`; submit posts to review-approve with action: "approve"; 200 refreshes + success toast; 422 invalid_status refreshes + info toast; 422 mail-failed shows the saved-but-failed toast).
Test 6 (partial row → review-approve + partial-success toast) should now ALSO pass, because the partial path is wired up by reusing `openSendModal` for both rows. Verify.
- [ ] **Step 10: Run the rest of the suite to confirm no regression**
Run: `npx vitest run`
Expected: All other test files pass (no regressions). If anything fails, surface it; do NOT skip or fix unrelated failures.
- [ ] **Step 11: Commit**
```bash
git add 'src/app/(app)/quotes/approvals/page.tsx'
git commit -m "feat(approvals): Approve & send from list rows
The pending-approvals list now offers the same one-step
approve-and-dispatch affordance as the detail page, instead of
forcing the rep through a two-step approve-then-drill-in flow.
- Replace the list's standalone 'Approve' button (direct PB write)
and 'Approve partial' button (POST /partial-approve) with a single
'Approve & send' button that opens SendQuoteModal.
- Lift the modal and sendApprove callback to page level, keyed by
row id, callable per row.
- Submit posts to POST /api/v1/quotes/[id]/review-approve with
action: 'approve' and the modal's email payload. The server
atomically approves + dispatches.
- Same handler works for partial_approval_pending rows — server
flips partial_approved -> sending -> emailed natively.
- Toast wording matches the detail page: 'Approved - email sent...'
for full, 'Partial approved - email sent...' for partial, with
invalid_status and mail-failed branches surfacing their own toasts.
- Reject path untouched."
```
---
## Task 3: Verify partial-quote toast path (test 6 passes — likely already does)
**Files:**
- Modify: none (no code change expected)
- [ ] **Step 1: Confirm test 6 already passes from Task 2**
Run: `npx vitest run src/app/(app)/quotes/approvals/page.test.tsx -t "partial row"`
Expected: PASS. The partial row uses the same `openSendModal` path as the full row, and the toast message branches on `quote.status === "partial_approval_pending"` inside `sendApprove`.
If it fails: the most likely cause is the second-row click target (the test uses `buttons[1]`) — confirm the partial row renders an "Approve & send" button by listing all buttons in the DOM in a debug render. The fix is to ensure the partial row IS rendered with the new button (no leftover "Approve partial" copy).
- [ ] **Step 2: If a fix is needed, edit the page to make test 6 pass, then re-run all 7 tests**
Most likely culprit: the partial row is missing items so it shows the "(X/Y items)" badge but the button's `disabled` prop may be set incorrectly. Verify by reading the `disabled` predicate:
```tsx
disabled={actionLoading !== ""}
```
This is the existing predicate; it should fire correctly.
- [ ] **Step 3: If a fix is needed, commit; otherwise mark this task done with no commit**
```bash
git add 'src/app/(app)/quotes/approvals/page.tsx' # only if a fix was applied
git commit -m "fix(approvals): ensure partial row renders Approve & send button
<one-line explanation of what was actually wrong>"
```
---
## Task 4: Manual smoke test against running PB
**Files:** none modified
- [ ] **Step 1: Start the dev server**
Run: `npm run dev` (or whatever command the project uses — port 4001 per project memory). Wait for the ready signal.
- [ ] **Step 2: Seed a quote in `approval_pending` status**
In the PB admin UI, open an existing quote in `new` or `approval_pending`. If none exist, take any quote, fill in prices on its items, and run the existing "submit for approval" workflow to get one into `approval_pending`. Alternatively, manually flip a quote's `status` to `approval_pending` in the PB admin UI for the smoke test.
- [ ] **Step 3: Navigate to /quotes/approvals and click "Approve & send"**
Open `http://localhost:4001/quotes/approvals`. Find the seeded quote's row. Click "Approve & send". Confirm:
- The `SendQuoteModal` opens
- The primary button reads "Approve & send"
- The To field is pre-populated with the customer's email
- Subject is pre-populated
Edit the subject/message if needed, click "Approve & send". Confirm:
- The modal closes
- The row disappears from the list (status flipped to `approved` and `sending` → `emailed`)
- The toast reads "Approved — email sent to the customer."
- The customer receives the email
- [ ] **Step 4: Repeat for a partial row**
Repeat Steps 2-3 with a quote in `partial_approval_pending` status. Confirm:
- Toast reads "Partial approved — email sent for the priced items."
- [ ] **Step 5: Race-test the invalid_status branch**
In one browser tab, open `/quotes/approvals`. In a second tab, open the same quote via the PB admin and flip its status to `approved`. Back in tab 1, click "Approve & send" on the (now stale) row → submit. Confirm:
- Toast reads "This quote was already actioned — refreshing."
- The list refreshes and the row is gone.
- [ ] **Step 6: Mail-failed branch (optional — requires SMTP misconfiguration)**
If a `mail_failed` scenario is reproducible in your env (e.g. by temporarily pointing the SMTP to a black hole port), confirm:
- The approve part succeeds (status flips to `approved` or `partial_approved`)
- The toast reads "Saved — but email send failed: <msg>. Retry from the quote page."
- The quote's status is `mail_failed` in PB
If the smoke step is impractical, skip and document.
- [ ] **Step 7: Report results**
Tell the user: smoke test pass/fail for each branch; any UI quirks; any deviation from the spec.
---
## Self-review checklist (run before offering execution)
- [ ] Every spec requirement is covered by a task: ✅
- "Replace list 'Approve' button" → Task 2 Step 5
- "Replace list 'Approve partial' button" → Task 2 Step 5 (single button now)
- "Open SendQuoteModal unchanged" → Task 2 Step 6
- "Lift sendApprove to page level" → Task 2 Step 3
- "Per-row modal state" → Task 2 Step 2
- "Submit posts to review-approve" → Task 2 Step 3
- "Toast wording matches detail page (full)" → Task 2 Step 3
- "Toast wording matches detail page (partial)" → Task 2 Step 3
- "Toast wording for invalid_status" → Task 2 Step 3
- "Toast wording for mail-failed" → Task 2 Step 3
- "Reject path unchanged" → Task 2 Step 5 (button untouched)
- "6 component tests" → Task 1 (all 7 tests scaffolded; 6 from spec + 1 extra for button presence)
- "SendQuoteModal not re-tested" → Reused unchanged, no test added
- "review-approve endpoint not changed" → Reused unchanged
- [ ] No placeholder/TBD in any task.
- [ ] Type consistency: `sendApprove` signature matches the `onSend` prop shape in `SendQuoteModal` Props (the `email` argument matches the `onSend` callback signature line 88-95).
- [ ] No new dependencies introduced.
- [ ] No PB schema changes (we're only calling existing endpoints).
- [ ] No AGENTS.md violations: no `pb-helpers.ts` imports added, no new `select` values needed.

File diff suppressed because it is too large Load diff

View file

@ -1,374 +0,0 @@
# Handoff: outstanding work on `feat/order-quote-auto-linking`
**Written:** 2026-08-29
**Branch:** `feat/order-quote-auto-linking` (branched from `main`)
**HEAD at handoff:** `0083199`
This document is for an implementing agent picking up mid-feature. It states
what is genuinely done (verified, not assumed), what is outstanding, and —
importantly — **where the original plan documents have gone stale**, because
several review cycles changed things the plans still describe the old way.
Read this file before reading the plans. Where they disagree, this file wins.
---
## Two plans are in flight
| Plan | Status |
|---|---|
| `docs/superpowers/plans/2026-08-29-order-quote-linking.md` | Tasks 1–6 done, **7–13 outstanding** |
| `docs/superpowers/plans/2026-08-29-order-delivery-note-match.md` | **Not started**, all 6 tasks outstanding |
The delivery-note plan depends on the quote plan's Task 6 (already merged), so
they can proceed in parallel from here. Its own pre-flight section explains
the dependency.
---
## Verified current state
Confirmed by running the commands, not by trusting prior reports:
- **13 feature commits** on the branch since the plan landed. Working tree clean.
- `src/lib/server/order-quote-link-service.ts` — **481 lines**, exports
`QuoteMatchMethod`, `QuoteMatchCandidate`, `SuggestQuoteResult`,
`suggestQuoteForOrder`, `PairedLine`, `PairOrderLinesResult`, `pairOrderLines`.
- **55 tests passing** across `order-quote-link-service.test.ts` +
`order-quote-match.test.ts`.
- Full suite: **15 pre-existing failures across 9 files** — all predate this
work. Do not fix them, but do confirm you add none. Baseline files:
`pocketbase-admin.test.ts` (2), `po-agent-workflow.test.ts` (2), MCP
`route.test.ts` (2), `orders/route.test.ts` (2),
`quotes/[id]/submit/route.test.ts` (3), `sidebar.test.tsx` (1),
`delivery-notes-service.test.ts` (1), `orders-service.test.ts` (1 —
`splitOrderForContinuation > moves pending items...`), `invoicing/page.test.tsx` (1).
**Diff failing test NAMES, not counts** — this was how a real regression
would otherwise have hidden behind a coincidentally-equal count.
### What works end-to-end today
An order created through `createOrder` (the n8n email-intake path, via MCP
`orders.create` or `POST /api/v1/orders/create`) now automatically gets its
source quote detected and written to `potential_quote`, and the existing amber
"Suggested" pill appears on `/orders` with no human action. Linking a quote via
`setOrderQuote` (REST, MCP, or UI) now also persists confident order-line →
quote-line pairs onto `order_items.quote_item`.
### What does NOT work yet
- The **historical backlog is untouched** — only orders created from now on are
matched. That's Task 7.
- **No UI reads the line pairs.** The buyer notes and per-line attachments that
motivated this whole feature still don't show. That's Tasks 9–13.
---
## ⚠️ Where the plans are STALE — read before implementing
Six review cycles changed things the plan text still describes the old way.
### 1. `SuggestQuoteResult.window` is `"initial"`, not `"90d"`
The plan's Task 7 sample code and prose refer to a `"90d"` label. Review
changed it so retuning `FUZZY_WINDOW_DAYS` can't make the label lie. Actual
current shape (`order-quote-link-service.ts:54-67`):
```ts
export interface SuggestQuoteResult {
orderId: string;
best: QuoteMatchCandidate | null;
alternatives: QuoteMatchCandidate[];
method: QuoteMatchMethod;
window: "exact" | "initial" | "widened" | "none";
ageGapDays: number | null;
skipped?: "already_linked";
}
```
`ageGapDays` **does** exist and is populated — Task 7's histogram can read it
directly, no recomputation needed.
### 2. The test mock harness was refactored
`order-quote-link-service.test.ts` now has a `makePb(overrides)` that merges
per-test overrides over sensible defaults, and the helper is `mockAdminPb`,
**not** `useP`. The plan's Task 4/7 test snippets show the old style. Read the
current file and follow it.
### 3. `vitest.config.ts` has no `clearMocks` — this bit us three times
Mock call history is **not** reset between tests; the global `beforeEach` only
calls `clearStore()`. Three separate tests were found passing on history
leaked from tests 150 lines earlier, guarding nothing.
**Always `vi.mocked(fn).mockClear()` at the top of any test asserting
`toHaveBeenCalledWith`.** And prove your test isn't vacuous: delete the call
site, watch the test fail, restore. Do not skip this — it caught real holes
every time it was run.
Do **not** globally set `clearMocks: true` as a drive-by fix; it changes
behaviour across 2,278 tests and needs its own change with its own verification.
### 4. Quote-item fetches go through a chunked helper
Do not hand-build `quote = "a" || quote = "b" || ...` OR-chains. PB rejects
filters past ~3.5KB (measured: 107 ids fail, 106 pass — see
`orders-service.ts:499-505`). Use `fetchQuoteItemsForQuotesAdmin(pb, ids, fields)`
from `quotes-service.ts`, which does IN-syntax with an 80-id chunked fallback.
It takes an explicit `fields` param — **pass what you need**, because a
narrowed default projection silently degrades matching to near-zero
confidence with no error.
### 5. Field projections are load-bearing and must stay tested
`MATCHER_QUOTE_ITEM_FIELDS`, `PAIR_ORDER_ITEM_FIELDS`, `PAIR_QUOTE_ITEM_FIELDS`
exist as named constants because dropping a field from one silently disables a
safety guard while the suite stays green. If you add a query, assert its
projection in a test.
### 6. `createOrder`'s `after()` is guarded — don't add a second one
`after()` **throws synchronously** outside a request scope (Next 16.2.6, error
`E468`, `node_modules/next/dist/server/after/after.js`). `createOrder` wraps
the `after()` call itself and falls back to fire-and-forget. There is a single
`runQuoteMatch` closure — **add new post-intake work inside it**, in its own
`try/catch`, rather than calling `after()` again.
---
# OUTSTANDING WORK
## A. Task 6 follow-up fixes (do first — small, and they close a real hole)
Task 6 is merged (`21bc577`) and passed spec review, but its code review found
two issues that were never applied. Both in `src/lib/server/orders-service.ts`
and its test file.
**A1. The fallback path has zero coverage, and one test is vacuous.**
The test mocks `next/server` so `after` never throws — meaning the `catch` +
fire-and-forget fallback (~`orders-service.ts:2238-2249`) is never executed by
the suite. Worse, the reviewer deleted the inner `try/catch` from
`runQuoteMatch` entirely and **both existing tests still passed**, because the
mock's `void cb()` detaches the promise regardless of what production code does.
Add:
```ts
it("falls back to an inline match when after() is unavailable", async () => {
const { after } = await import("next/server");
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
vi.mocked(suggestQuoteForOrder).mockClear();
vi.mocked(after).mockImplementationOnce(() => {
throw new Error("`after` was called outside a request scope.");
});
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
const r = await createOrder({
customer_id: "cust-x",
company_id: "comp-x",
items: [{ description: "M16 Bolt", quantity: 50 }],
});
expect(r.order.id).toBeDefined();
expect(suggestQuoteForOrder).toHaveBeenCalledWith(r.order.id);
});
```
Also add a `console.error` spy assertion to the existing
`"never fails order creation when the matcher throws"` test so it verifies the
swallow rather than the mock's detachment.
**A2. The `catch` around `after()` logs nothing.**
If `after()` becomes unavailable (a Next upgrade, or `waitUntil` missing on the
deploy target — a second distinct throw path in `after-context.js` that the bare
catch also swallows), every order silently degrades to fire-and-forget with
**zero trace anywhere**. Nothing breaks visibly; you just lose the latency
guarantee permanently and invisibly.
```ts
} catch (err) {
console.error(`[createOrder] after() unavailable, running quote match inline for ${order.id}:`, err);
void runQuoteMatch();
}
```
**A3 (optional).** The comment explaining the guard names neither the error
code nor the Next version, so a future reader hitting an upgrade must redo the
source dive. Add `// Next 16.2.6, error code E468 — node_modules/next/dist/server/after/after.js`.
Its stated justification also cites a smoke-script caller that doesn't exist —
all three real callers are in request scope. Keep the fallback (`createOrder` is
an exported service function reachable from multiple layers per this repo's
architecture rules), but fix the comment to say that.
Commit: `fix(orders): cover the after() fallback path and log when it engages`
---
## B. Tasks 7–13 of the order↔quote plan
Full step-by-step detail is in
`docs/superpowers/plans/2026-08-29-order-quote-linking.md` — **read it there**,
subject to the staleness corrections above. Summary of what remains:
### Task 7 — Backlog sweep
`POST /api/v1/orders/quote-match/sweep` + `sweepUnmatchedOrders` and
`bucketAgeGaps` in the link service. Processes orders where
`quote_matching_attempted = false && quote = ""` in batches, returns
`{ processed, suggested, noMatch, failed, remaining, ageGaps }`.
The `ageGaps` histogram is not decoration — it's how the 90-day
`FUZZY_WINDOW_DAYS` default gets retuned from real data instead of staying an
estimate. Read `ageGapDays` straight off each `SuggestQuoteResult`.
Verify `quote = ""` is genuinely how this codebase queries an empty relation
(grep `orders-service.ts`) rather than assuming PB's syntax.
### Task 8 — MCP tool `orders.quote_match_sweep`
Hermes/n8n drops the `AGENT_API_KEY` on direct REST calls, so the sweep must
exist as an MCP tool. Follow the `orders.set_quote` pattern in
`src/app/api/mcp/route.ts` — tool definition alongside it, handler forwarding
to the REST route.
### ✅ CHECKPOINT — DONE 2026-08-29. Sweep run, window confirmed at 90 days.
**Outcome: `FUZZY_WINDOW_DAYS` stays at 90. No change needed. Phase 2 is unblocked.**
Full backlog swept: **5,096 orders processed, 2,483 suggested, 2,613 no-match,
0 failed** — `remaining: 0`. Amber pills are now live across the historical
backlog (approved by the owner).
**Read this before you trust the sweep's `ageGaps` output.** The histogram
reported `d0_30: 100%`, which is an **artifact, not a finding**:
- **92% of matched backlog orders have a gap of exactly 0 days.** Their
`created` timestamps come from the legacy bulk migration — order and quote
were inserted at the same instant — so the gap measures migration timing,
not quote-to-order lead time.
- `quoteToOrderAgeDays` itself is **correct** (verified: returns `33` for a
real 33-day PB-format pair). Do not go looking for a bug in it.
- The real signal, from non-migrated orders: gaps run **1–33 days, p50 23,
p90 24, max 33. Zero beyond 90 days.** That's what justifies keeping 90 —
nearly 3× headroom against the worst observed real gap.
**Consequence worth knowing: for migrated records the date window constrains
nothing.** If an order and every candidate quote share a migration timestamp,
all of them fall inside any window. So those ~2,400 backlog suggestions were
scored with the window effectively disabled — only company / customer /
description / price carried them. Backlog amber pills deserve more operator
skepticism than new-order ones, whose timestamps are real and where the window
does its job.
**Two follow-ups, neither blocking Phase 2:**
1. Have the sweep report gap-0 matches in their own bucket so this histogram
can't mislead the next reader the way it nearly did here.
2. Check whether the migration preserved a real business date (there is a
`migration_meta` JSON field on `orders`) that the matcher could prefer over
`created` for migrated rows.
**Separate open question — do not conflate it with the window.** Match rate is
**48.7%**, well under the owner's ~85% expectation. The misses are not old
quotes, so widening the window will not help. Candidates: those orders
genuinely had no quote; the `MATCH_CONFIDENCE_THRESHOLD` of 0.4 is too strict;
or description drift exceeds what the Jaccard scorer tolerates. Diagnose
deliberately, don't tune blind.
**Operational note for anyone re-running the sweep:** `limit: 200` killed the
dev server outright (process gone, not a timeout). `limit: 50` is stable at
~14s per batch. The route advertises up to 500 with `maxDuration = 300`; that
ceiling is optimistic, because each order fans out into a candidate scan of up
to 200 quotes plus their line items, twice when the window widens. Consider
lowering the documented max.
```bash
curl -s -X POST http://localhost:4001/api/v1/orders/quote-match/sweep \
-H "authorization: Bearer $AGENT_API_KEY" \
-H 'content-type: application/json' -d '{"limit":50}'
```
(Dev server runs on **port 4001**, not 3000.)
### Task 9 — Extract `AttachmentList` to `src/components/orders/attachment-list.tsx`
Currently local to `process/page.tsx`. Pure move so both order pages can share it.
### Task 10 — Serve the resolved pairing to the UI
Add `linePairs` (keyed by `order_item_id`, carrying `quote_item_id`,
`confidence`, `tier`) to `matched-attachments/route.ts`. Additive — the MCP
`orders.list_attachments` consumer must keep working. Plus
`POST /api/v1/orders/[id]/quote-line` to confirm a doubtful pair.
### Task 11 — `src/components/orders/quote-line-context.tsx`
Shared presentation of a paired quote line: description, buyer note,
attachments, plus a "possible match — confirm?" state and an explicit
"no matching line" state.
### Task 12 — Process page uses server pairing
**Delete `findMatchedQuoteItem`** (`process/page.tsx:3159`) — the exact/substring
guesser that is the actual root cause of vanishing buyer notes. Replace its
three call sites with `linePairs` lookups.
### Task 13 — Order detail table + migrate off `pb-helpers`
Row indicators (note icon, paperclip + count) expanding to show quote context.
Then migrate the four `pb-helpers` matcher callers still live at
`src/app/(app)/orders/[id]/page.tsx:59-62` (`findMatchingQuotesForOrder`,
`applyOrderQuoteMatch`, `clearOrderQuoteMatch`, `setOrderQuoteManually`) onto
`/api/v1/*`, then delete the stale matcher block from `pb-helpers.ts`.
**Note the API shape change:** the old `result.best.quote.quote_number` becomes
`result.best.quoteNumber`, and `result.best.quote.id` becomes
`result.best.quoteId`.
`AGENTS.md` requires this migration when touching that page — it is not optional
cleanup.
---
## C. The delivery-note repeat-order feature (all 6 tasks)
`docs/superpowers/plans/2026-08-29-order-delivery-note-match.md`, spec at
`docs/superpowers/specs/2026-08-29-order-delivery-note-match-design.md`.
Detects when a new order's items exactly match a past delivery note's items
(same **company** — a different person at the company can place the repeat
order), and flags it with a blue pill showing the DN number.
Two things to know before starting:
- **It needs a PB schema migration** (Task 1 of that plan) — five new `orders`
fields. All are relation/text/number/bool, **not `select`**, so no manual PB
admin step is required. Confirm with the user before running any migration
against the live database.
- **The pill reads a denormalized text field, not an expanded relation.**
`orders-service.ts:253` documents that PB issues a separate query per
expanded relation, which is why the existing quote pill shows no number.
Since this pill must show the DN number, the plan denormalizes
`delivery_note_number` onto the order at write time. Do not "simplify" this
into an `expand` — it would reintroduce a per-page-load cost the codebase
deliberately avoids.
Its matching is **exact**, not fuzzy — either every line pairs on normalized
description and quantity with nothing left over on either side, or nothing is
flagged. There is no "possible" tier. That's deliberate and different from the
quote matcher.
---
## Working agreement that produced good results here
Every task so far ran: implement → spec-compliance review → code-quality review
→ fix findings → re-verify. That caught, among others:
- a PB filter-length bug that would have broken exactly the high-volume
customers the matcher matters most for
- a cross-line collision that would have shown two different parts the same
estimator's note — the precise failure the feature exists to prevent
- three tests that passed while guarding nothing
- `after()` throwing outside request scope, which would have made the safety
code itself crash order creation
The pattern that found most of these was **mutation testing**: break the thing
deliberately, confirm a test fails, restore. Please keep doing that rather than
trusting that a green suite means a guarded one.

View file

@ -1,981 +0,0 @@
# Order ↔ Delivery-Note Repeat-Order Detection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** When a new order's line items exactly match a past delivery note's items (same company), flag it automatically with a visible pill on the orders list showing the delivery note number.
**Architecture:** A pure exact-match comparator (no I/O) feeds a PB-backed service, `suggestDeliveryNoteForOrder`, which piggybacks on the `after()` hook already built and reviewed for the order↔quote matching feature — no new `after()` call, no new error-guard code to write or review. Two independent `try/catch` blocks so a failure in one matcher can't block the other. The match is written automatically (no human confirmation step) because it's informational only — nothing downstream reads it to drive pricing or processing.
**Tech Stack:** Next.js 16.2.6 (App Router), TypeScript, PocketBase (via `getAdminPocketBase()`), Vitest, Tailwind + shadcn/ui, lucide-react.
**Spec:** `docs/superpowers/specs/2026-08-29-order-delivery-note-match-design.md` — read it first. It records why the pill reads a denormalized text field instead of an expanded relation (a deliberate perf tradeoff already made once in this codebase for the quote-suggestion pill, at `orders-service.ts:253`) — do not "simplify" this by switching to an `expand`.
---
## Pre-flight — verify before writing code, do not assume
1. **This plan depends on `docs/superpowers/plans/2026-08-29-order-quote-linking.md` Task 6 being merged first** — specifically the `runQuoteMatch` closure inside `createOrder`'s `after()` block in `src/lib/server/orders-service.ts`. Confirm it exists (`grep -n "async function runQuoteMatch" src/lib/server/orders-service.ts`) before starting Task 4 below. If it isn't there yet, stop and report — do not build a second `after()` call as a workaround.
2. **Confirm `getOrdersDashboardData`'s field selection.** Read `src/lib/server/orders-service.ts` around line 253 (the `potential_quote` expand-drop comment) and trace what the function actually returns to `/api/v1/orders/list` — full records or a `fields`-limited projection. The spec assumes full records (per the PB-0.26-drops-expand-when-fields-is-set note in the same comment block) so no allowlist change is needed for the new field to appear. If that assumption is wrong, Task 6 below needs an additional edit — say so in that task's report.
3. **Confirm the migration script auth pattern still works.** `scripts/migrations/2026-06-27-add-order-quote-match-fields.ts` is the template for Task 1. Read it in full before writing the new one.
## File structure
| File | Responsibility |
|---|---|
| `src/lib/server/delivery-note-order-match.ts` *(create)* | Pure exact-match comparator. No I/O. |
| `scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts` *(create)* | Adds the 5 new `orders` fields. |
| `src/lib/server/order-delivery-note-link-service.ts` *(create)* | PB-backed `suggestDeliveryNoteForOrder`. |
| `src/lib/server/orders-service.ts` *(modify)* | Add the second matcher call inside the existing `runQuoteMatch` closure. |
| `src/app/api/v1/orders/[id]/delivery-note-match/route.ts` *(create)* | On-demand single-order re-check. |
| `src/lib/types.ts` *(modify)* | Add the 5 new fields to the `Order` interface. |
| `src/app/(app)/orders/page.tsx` *(modify)* | New pill in the existing badge row. |
## Phase boundaries
- **Phase 1 — Tasks 1–4.** Backend: schema, matcher, service, intake wiring. Independently testable and mergeable without the UI.
- **Phase 2 — Tasks 5–6.** On-demand route + UI pill.
---
# PHASE 1 — Backend
## Task 1: Migration script for the new `orders` fields
**Files:**
- Create: `scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts`
- [ ] **Step 1: Write the script**
Model this exactly on `scripts/migrations/2026-06-27-add-order-quote-match-fields.ts` — same env-loading, same auth pattern, same idempotent field-existence check per field, same re-fetch-after-each-write pattern (PB's `collections.update` needs the freshest `fields` array or it clobbers concurrent additions).
```ts
/* eslint-disable */
/**
* Migration: add delivery-note repeat-order detection fields to `orders`.
*
* Adds five fields:
*
* 1. `potential_delivery_note` (relation → delivery_notes, single)
* The DN whose items exactly matched this order's items.
*
* 2. `potential_delivery_note_number` (text)
* Denormalized copy of the matched DN's `delivery_note_number`.
* Written alongside (1) so the orders list can render the number
* without a second `expand` — see the comment at
* orders-service.ts:253 for why that expand cost is avoided here
* (PB issues a separate IN query per expanded relation).
* `delivery_note_number` is immutable once assigned, so copying
* it carries no staleness risk.
*
* 3. `delivery_note_match_confidence` (number)
* Always 1 when set — the match is exact-or-nothing, not scored.
* Kept for shape parity with quote_match_confidence and so a
* future fuzzy extension doesn't need a second migration.
*
* 4. `delivery_note_match_method` (text)
* "exact_items" or "no_match". Text, not select, for the same
* forward-compatibility reason quote_match_method is text.
*
* 5. `delivery_note_matching_attempted` (bool)
* Audit flag mirroring quote_matching_attempted.
*
* Idempotent — safe to re-run. Each step detects its own "already
* done" state.
*
* Run with:
* npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function loadEnvFile(name: string) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
// file not present — fine, rely on shell env
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
async function main() {
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
const email = process.env.PB_ADMIN_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD;
if (!url || !email || !password) {
throw new Error(
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local)."
);
}
const pb = new PocketBase(url);
await pb.collection("_superusers").authWithPassword(email, password);
console.log(`[order-delivery-note-match] Authenticated against ${url}`);
const deliveryNotes = await pb.collections.getOne("delivery_notes");
async function addFieldIfMissing(
fieldDef: Record<string, unknown> & { name: string },
) {
const orders = await pb.collections.getOne("orders");
if (orders.fields.some((f: { name: string }) => f.name === fieldDef.name)) {
console.log(`[order-delivery-note-match] orders.${fieldDef.name} already present — skipping.`);
return;
}
await pb.collections.update(orders.id, {
fields: [...orders.fields, fieldDef],
});
console.log(`[order-delivery-note-match] Added orders.${fieldDef.name}.`);
}
await addFieldIfMissing({
name: "potential_delivery_note",
type: "relation",
required: false,
presentable: false,
options: {
collectionId: deliveryNotes.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
},
});
await addFieldIfMissing({
name: "potential_delivery_note_number",
type: "text",
required: false,
presentable: false,
options: { max: 32 },
});
await addFieldIfMissing({
name: "delivery_note_match_confidence",
type: "number",
required: false,
presentable: false,
options: { min: 0, max: 1 },
});
await addFieldIfMissing({
name: "delivery_note_match_method",
type: "text",
required: false,
presentable: false,
options: { max: 32 },
});
await addFieldIfMissing({
name: "delivery_note_matching_attempted",
type: "bool",
required: false,
presentable: false,
options: {},
});
console.log("[order-delivery-note-match] Migration complete.");
}
main().catch((err) => {
console.error("[order-delivery-note-match] Migration failed:", err);
process.exit(1);
});
```
**Step 2: Verify the relation field's `options` shape against a real PB response.**
The `relation` field's `options` object (`collectionId`, `cascadeDelete`, `minSelect`, `maxSelect`) is written from memory of PocketBase's schema shape — before running this against production, fetch the *existing* `orders.quote` or `orders.potential_quote` field definition (`pb.collections.getOne("orders")`, inspect `.fields`) and confirm the option keys match exactly. If they differ, use the real shape, not what's written above.
- [ ] **Step 3: Run it against the actual PB instance** (ask the user for confirmation before running any migration against a real database — this is a schema change, not a reversible local operation):
```bash
npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts
```
Expected: five "Added orders.X" lines, or "already present" on re-run.
- [ ] **Step 4: Add the npm script**, matching the existing `pb:migrate:*` convention in `package.json`:
```json
"pb:migrate:order-delivery-note-match": "npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts",
```
- [ ] **Step 5: Commit**
```bash
git add scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts package.json
git commit -m "feat(orders): add delivery-note repeat-order match fields"
```
---
## Task 2: Pure exact-match comparator
**Files:**
- Create: `src/lib/server/delivery-note-order-match.ts`
- Test: `src/lib/server/delivery-note-order-match.test.ts`
- [ ] **Step 1: Write the failing tests**
```ts
import { describe, it, expect } from "vitest";
import { normalizeLine, isExactItemMatch } from "@/lib/server/delivery-note-order-match";
describe("normalizeLine", () => {
it("trims, lowercases, and collapses internal whitespace", () => {
expect(normalizeLine(" Hex Bolt M20 ")).toBe("hex bolt m20");
});
});
describe("isExactItemMatch", () => {
it("matches when every line pairs 1:1 on normalized description and quantity", () => {
const order = [
{ description: "Hex Bolt M20", quantity: 50 },
{ description: "Hex Nut M20", quantity: 50 },
];
const dn = [
{ description: "hex bolt m20", quantity: 50 },
{ description: "HEX NUT M20", quantity: 50 },
];
expect(isExactItemMatch(order, dn)).toBe(true);
});
it("does not match when a quantity differs", () => {
const order = [{ description: "Hex Bolt M20", quantity: 50 }];
const dn = [{ description: "Hex Bolt M20", quantity: 45 }];
expect(isExactItemMatch(order, dn)).toBe(false);
});
it("does not match when the order has an extra line the DN doesn't have", () => {
const order = [
{ description: "Hex Bolt M20", quantity: 50 },
{ description: "Washer M20", quantity: 50 },
];
const dn = [{ description: "Hex Bolt M20", quantity: 50 }];
expect(isExactItemMatch(order, dn)).toBe(false);
});
it("does not match when the DN has an extra line the order doesn't have", () => {
const order = [{ description: "Hex Bolt M20", quantity: 50 }];
const dn = [
{ description: "Hex Bolt M20", quantity: 50 },
{ description: "Washer M20", quantity: 50 },
];
expect(isExactItemMatch(order, dn)).toBe(false);
});
it("requires matching multiplicity, not just membership, for duplicate lines", () => {
// Order has the SAME line twice (two separate 50-unit lines of the
// same bolt, e.g. from two different quote lines that priced
// differently but described identically). The DN has only one.
const order = [
{ description: "Hex Bolt M20", quantity: 50 },
{ description: "Hex Bolt M20", quantity: 50 },
];
const dn = [{ description: "Hex Bolt M20", quantity: 50 }];
expect(isExactItemMatch(order, dn)).toBe(false);
});
it("matches duplicate lines when both sides have the same multiplicity", () => {
const order = [
{ description: "Hex Bolt M20", quantity: 50 },
{ description: "Hex Bolt M20", quantity: 50 },
];
const dn = [
{ description: "Hex Bolt M20", quantity: 50 },
{ description: "Hex Bolt M20", quantity: 50 },
];
expect(isExactItemMatch(order, dn)).toBe(true);
});
it("returns false for two empty line sets rather than vacuously true", () => {
// An order or DN with zero lines carries no signal — this must
// never be treated as "everything matched." If this returned true,
// an order with 0 lines could match every DN with 0 lines in the
// company, which is meaningless.
expect(isExactItemMatch([], [])).toBe(false);
});
});
```
- [ ] **Step 2: Run to verify red.** `npx vitest run src/lib/server/delivery-note-order-match.test.ts`
- [ ] **Step 3: Implementation**
```ts
/**
* delivery-note-order-match — pure exact-match comparator for the
* order ↔ delivery-note repeat-order detector.
*
* Deliberately NOT the quote matcher's tokenize/Jaccard fuzzy scoring
* (order-quote-match.ts). This is exact-string equality on a
* normalized form, with multiset (bag) semantics — every order line
* must pair to exactly one DN line and nothing may be left over on
* either side. There is no partial-credit tier here; either the order
* is a complete repeat of the DN or it isn't flagged at all.
*/
export interface DeliveryLine {
description: string;
quantity: number;
}
/** Normalize for comparison: trim, lowercase, collapse internal
* whitespace runs to a single space. */
export function normalizeLine(s: string): string {
return (s || "").trim().toLowerCase().replace(/\s+/g, " ");
}
/**
* True iff `orderLines` and `dnLines` are equal as multisets keyed on
* (normalized description, quantity). Empty input on either side
* always returns false — there is no signal to match on, and treating
* two empty sets as "matching" would flag every zero-line order
* against every zero-line DN.
*/
export function isExactItemMatch(
orderLines: ReadonlyArray<DeliveryLine>,
dnLines: ReadonlyArray<DeliveryLine>,
): boolean {
if (orderLines.length === 0 || dnLines.length === 0) return false;
if (orderLines.length !== dnLines.length) return false;
const key = (l: DeliveryLine) => `${normalizeLine(l.description)}${l.quantity}`;
const counts = new Map<string, number>();
for (const l of orderLines) {
const k = key(l);
counts.set(k, (counts.get(k) ?? 0) + 1);
}
for (const l of dnLines) {
const k = key(l);
const remaining = counts.get(k);
if (!remaining) return false;
counts.set(k, remaining - 1);
}
// Lengths already matched, so if every DN line found a counterpart,
// every order line was consumed too — no need to re-scan counts.
return true;
}
```
- [ ] **Step 4: Run tests, verify green.** All 8 tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/lib/server/delivery-note-order-match.ts src/lib/server/delivery-note-order-match.test.ts
git commit -m "feat(orders): add exact-match comparator for delivery-note detection"
```
---
## Task 3: `suggestDeliveryNoteForOrder` service
**Files:**
- Create: `src/lib/server/order-delivery-note-link-service.ts`
- Test: `src/lib/server/order-delivery-note-link-service.test.ts`
Model the mock harness on `src/lib/server/order-quote-link-service.test.ts`'s `makePb` (a defaults-plus-per-test-override map — read that file first, it was refactored specifically to avoid boilerplate as more tests accumulate; do not write a fresh ad-hoc mock).
- [ ] **Step 1: Write the failing tests**
```ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { suggestDeliveryNoteForOrder } from "@/lib/server/order-delivery-note-link-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
const ORDERS = "orders";
const ORDER_ITEMS = "order_items";
const DELIVERY_NOTES = "delivery_notes";
const QUOTES = "quotes";
const QUOTE_ITEMS = "quote_items";
interface CollectionMock {
getOne?: ReturnType<typeof vi.fn>;
getFullList?: ReturnType<typeof vi.fn>;
update?: ReturnType<typeof vi.fn>;
}
const DEFAULTS: Record<string, CollectionMock> = {
[ORDERS]: { getOne: vi.fn(), update: vi.fn().mockResolvedValue({}) },
[ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) },
[DELIVERY_NOTES]: { getFullList: vi.fn().mockResolvedValue([]) },
[QUOTES]: { getFullList: vi.fn().mockResolvedValue([]) },
[QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) },
};
function makePb(overrides: Partial<Record<string, CollectionMock>> = {}) {
const merged = { ...DEFAULTS, ...overrides };
return {
autoCancellation: vi.fn(),
collection: (name: string) => {
const c = merged[name];
if (!c) throw new Error(`unexpected collection: ${name}`);
return c;
},
};
}
function mockAdminPb(pb: ReturnType<typeof makePb>) {
vi.mocked(getAdminPocketBase).mockResolvedValue(
pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>,
);
}
const ORDER = { id: "ord1", company: "co1" };
describe("suggestDeliveryNoteForOrder", () => {
beforeEach(() => vi.clearAllMocks());
it("skips entirely when the order has no company", async () => {
const update = vi.fn();
mockAdminPb(makePb({
[ORDERS]: { getOne: vi.fn().mockResolvedValue({ id: "ord1", company: "" }), update },
}));
const res = await suggestDeliveryNoteForOrder("ord1");
expect(res.method).toBe("no_match");
expect(update).not.toHaveBeenCalled();
});
it("flags a single exact item+quantity match", async () => {
const update = vi.fn().mockResolvedValue({});
mockAdminPb(makePb({
[ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update },
[ORDER_ITEMS]: {
getFullList: vi.fn()
// first call: the order's own items
.mockResolvedValueOnce([{ id: "oi1", description: "Hex Bolt M20", quantity: 50 }])
// second call: candidate DN's items
.mockResolvedValueOnce([{ id: "di1", description: "hex bolt m20", quantity: 50 }]),
},
[DELIVERY_NOTES]: {
getFullList: vi.fn().mockResolvedValue([
{ id: "dn1", delivery_note_number: "DN26071401", company: "co1", status: "invoiced", created: "2026-07-14T00:00:00Z" },
]),
},
}));
const res = await suggestDeliveryNoteForOrder("ord1");
expect(res.method).toBe("exact_items");
expect(res.best).toEqual({ deliveryNoteId: "dn1", deliveryNoteNumber: "DN26071401" });
expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({
potential_delivery_note: "dn1",
potential_delivery_note_number: "DN26071401",
delivery_note_match_confidence: 1,
delivery_note_match_method: "exact_items",
delivery_note_matching_attempted: true,
}));
});
it("stamps attempted:true and clears fields on no_match", async () => {
const update = vi.fn().mockResolvedValue({});
mockAdminPb(makePb({
[ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update },
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValueOnce([{ id: "oi1", description: "Widget", quantity: 1 }]),
},
[DELIVERY_NOTES]: { getFullList: vi.fn().mockResolvedValue([]) },
}));
const res = await suggestDeliveryNoteForOrder("ord1");
expect(res.method).toBe("no_match");
expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({
potential_delivery_note: null,
potential_delivery_note_number: "",
delivery_note_match_confidence: 0,
delivery_note_match_method: "no_match",
delivery_note_matching_attempted: true,
}));
});
it("breaks a tie between two exact-matching DNs using the priced quote", async () => {
const orderItems = [{ id: "oi1", description: "Hex Bolt M20", quantity: 50, selling_price: 12 }];
const update = vi.fn().mockResolvedValue({});
mockAdminPb(makePb({
[ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update },
[ORDER_ITEMS]: {
getFullList: vi.fn()
.mockResolvedValueOnce(orderItems) // order's own items
.mockResolvedValueOnce([{ id: "d1", description: "hex bolt m20", quantity: 50 }]) // dn1 items
.mockResolvedValueOnce([{ id: "d2", description: "hex bolt m20", quantity: 50 }]), // dn2 items
},
[DELIVERY_NOTES]: {
getFullList: vi.fn().mockResolvedValue([
{ id: "dn1", delivery_note_number: "DN1", company: "co1", status: "invoiced", created: "2026-05-01T00:00:00Z" },
{ id: "dn2", delivery_note_number: "DN2", company: "co1", status: "invoiced", created: "2026-07-01T00:00:00Z" },
]),
},
[QUOTES]: {
getFullList: vi.fn()
.mockResolvedValueOnce([]) // dn1 has no priced quote
.mockResolvedValueOnce([{ id: "q2", delivery_note: "dn2" }]), // dn2 does
},
[QUOTE_ITEMS]: {
getFullList: vi.fn().mockResolvedValueOnce([
{ id: "qi1", quote: "q2", description: "hex bolt m20", selling_price: 12 },
]),
},
}));
const res = await suggestDeliveryNoteForOrder("ord1");
expect(res.best?.deliveryNoteId).toBe("dn2");
});
});
```
**Note for the implementer:** the exact sequencing of `mockResolvedValueOnce` calls above depends on the order your implementation issues queries in. Write the implementation first to decide the natural query order, THEN align the mock call sequence to match — don't contort the implementation to satisfy a guessed mock order. If your natural implementation issues queries in a different sequence than assumed here, that's fine; update the test to match, and note the change in your report.
- [ ] **Step 2: Run to verify red.**
- [ ] **Step 3: Implementation**
```ts
/**
* order-delivery-note-link-service — detects when a new order is an
* exact repeat of a past delivery note's items, scoped to the same
* company (not customer — a different person at the same company can
* place the repeat order).
*
* Visibility only. Nothing downstream reads this to drive pricing,
* supplier assignment, or processing — a wrong flag costs nothing, the
* operator just ignores the pill. That's what justifies writing the
* match automatically with no human confirmation step, unlike the
* order↔quote matcher's deliberately conservative suggest-only policy
* (see order-quote-link-service.ts) — that policy exists because a
* wrong quote link puts wrong prices in front of a buyer.
*
* Called from inside createOrder's existing after()/E468 guard
* (orders-service.ts) — this function lets errors propagate to that
* caller rather than double-guarding.
*/
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { isExactItemMatch, type DeliveryLine } from "@/lib/server/delivery-note-order-match";
export type DeliveryNoteMatchMethod = "exact_items" | "no_match";
export interface SuggestDeliveryNoteResult {
orderId: string;
best: { deliveryNoteId: string; deliveryNoteNumber: string } | null;
method: DeliveryNoteMatchMethod;
}
const ORDER_COLLECTION = "orders";
const ORDER_ITEMS_COLLECTION = "order_items";
const DELIVERY_NOTES_COLLECTION = "delivery_notes";
const QUOTES_COLLECTION = "quotes";
const QUOTE_ITEMS_COLLECTION = "quote_items";
/** DNs the customer has actually seen. A draft was never sent, a
* cancelled one was abandoned — neither can be "repeated." */
const CANDIDATE_STATUSES = ["sent", "po_received", "invoiced", "completed"];
function esc(s: string): string {
return (s || "").replace(/"/g, '\\"');
}
interface DnCandidate {
id: string;
delivery_note_number: string;
company?: string;
status: string;
created: string;
}
export async function suggestDeliveryNoteForOrder(
orderId: string,
): Promise<SuggestDeliveryNoteResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const order = await pb
.collection(ORDER_COLLECTION)
.getOne<{ id: string; company?: string }>(orderId);
if (!order.company) {
// No signal to match on — deliberately does not write
// `delivery_note_matching_attempted`, unlike the quote matcher's
// "no_match" path. A never-attempted order (no company at all) is
// a different state from "we looked and found nothing," and a
// company gets added later, so leaving `attempted: false` lets a
// future sweep (out of scope for this iteration) reconsider it.
return { orderId, best: null, method: "no_match" };
}
const orderItemRows = await pb
.collection(ORDER_ITEMS_COLLECTION)
.getFullList<{ id: string; description?: string; quantity?: number; selling_price?: number }>({
filter: `order = "${esc(orderId)}"`,
fields: "id,description,quantity,selling_price",
batch: 1000,
});
const orderLines: DeliveryLine[] = orderItemRows.map((r) => ({
description: r.description || "",
quantity: Number(r.quantity) || 0,
}));
const candidates = await pb
.collection(DELIVERY_NOTES_COLLECTION)
.getFullList<DnCandidate>({
filter: `company = "${esc(order.company)}" && (${CANDIDATE_STATUSES.map((s) => `status = "${s}"`).join(" || ")})`,
fields: "id,delivery_note_number,company,status,created",
batch: 1000,
});
const matched: DnCandidate[] = [];
for (const dn of candidates) {
const dnItemRows = await pb
.collection(ORDER_ITEMS_COLLECTION)
.getFullList<{ description?: string; quantity?: number }>({
filter: `delivery_note_number = "${esc(dn.delivery_note_number)}"`,
fields: "description,quantity",
batch: 1000,
});
const dnLines: DeliveryLine[] = dnItemRows.map((r) => ({
description: r.description || "",
quantity: Number(r.quantity) || 0,
}));
if (isExactItemMatch(orderLines, dnLines)) matched.push(dn);
}
let best: DnCandidate | null = null;
if (matched.length === 1) {
best = matched[0];
} else if (matched.length > 1) {
best = await breakTieByQuotePrice(pb, matched, orderItemRows) ?? mostRecent(matched);
}
await pb.collection(ORDER_COLLECTION).update(orderId, {
potential_delivery_note: best ? best.id : null,
potential_delivery_note_number: best ? best.delivery_note_number : "",
delivery_note_match_confidence: best ? 1 : 0,
delivery_note_match_method: best ? "exact_items" : "no_match",
delivery_note_matching_attempted: true,
});
return {
orderId,
best: best ? { deliveryNoteId: best.id, deliveryNoteNumber: best.delivery_note_number } : null,
method: best ? "exact_items" : "no_match",
};
}
function mostRecent(candidates: DnCandidate[]): DnCandidate {
return [...candidates].sort((a, b) => (b.created || "").localeCompare(a.created || ""))[0];
}
/** Among tied exact-item-match candidates, prefer the one whose priced
* quote (status="delivery_note") also matches the order's line
* prices. Returns null if no candidate's quote prices match — caller
* falls back to most-recent. */
async function breakTieByQuotePrice(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
candidates: DnCandidate[],
orderItemRows: Array<{ description?: string; selling_price?: number }>,
): Promise<DnCandidate | null> {
const priceByDesc = new Map<string, number>();
for (const r of orderItemRows) {
priceByDesc.set((r.description || "").trim().toLowerCase(), Number(r.selling_price) || 0);
}
for (const dn of candidates) {
const quotes = await pb
.collection(QUOTES_COLLECTION)
.getFullList<{ id: string; delivery_note: string }>({
filter: `delivery_note = "${esc(dn.id)}" && status = "delivery_note"`,
fields: "id,delivery_note",
batch: 5,
});
if (quotes.length === 0) continue;
const quoteItems = await pb
.collection(QUOTE_ITEMS_COLLECTION)
.getFullList<{ description?: string; selling_price?: number }>({
filter: `quote = "${esc(quotes[0].id)}"`,
fields: "description,selling_price",
batch: 1000,
});
const allPricesMatch = quoteItems.every((qi) => {
const orderPrice = priceByDesc.get((qi.description || "").trim().toLowerCase());
return orderPrice !== undefined && orderPrice === (Number(qi.selling_price) || 0);
});
if (allPricesMatch && quoteItems.length > 0) return dn;
}
return null;
}
```
- [ ] **Step 4: Run tests, verify green.**
- [ ] **Step 5: Typecheck and commit**
```bash
npx tsc --noEmit
git add src/lib/server/order-delivery-note-link-service.ts src/lib/server/order-delivery-note-link-service.test.ts
git commit -m "feat(orders): add delivery-note repeat-order detection service"
```
---
## Task 4: Wire into intake
**Files:**
- Modify: `src/lib/server/orders-service.ts`
- Test: `src/lib/server/orders-service.test.ts`
- [ ] **Step 1: Confirm the pre-flight assumption.** Run:
```bash
grep -n "async function runQuoteMatch" src/lib/server/orders-service.ts
```
If this returns nothing, STOP — the order↔quote linking plan's Task 6 hasn't landed yet. Report BLOCKED rather than building a parallel `after()` call.
- [ ] **Step 2: Write the failing test**
Add `suggestDeliveryNoteForOrder` to the existing `vi.mock("./order-delivery-note-link-service", ...)` — check whether a mock for this module already exists in the test file (it won't, since this is the first task to import it); add one:
```ts
vi.mock("./order-delivery-note-link-service", () => ({
suggestDeliveryNoteForOrder: vi.fn().mockResolvedValue({ best: null, method: "no_match" }),
}));
```
Then append, reusing `pbMock` exactly as the existing `createOrder quote auto-detection` tests do:
```ts
describe("createOrder delivery-note auto-detection", () => {
it("also schedules a delivery-note match after the response", async () => {
const { suggestDeliveryNoteForOrder } = await import("./order-delivery-note-link-service");
vi.mocked(suggestDeliveryNoteForOrder).mockClear();
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
const created = await createOrder({
customer_id: "cust-x",
company_id: "comp-x",
items: [{ description: "M16 Bolt", quantity: 50 }],
});
expect(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id);
});
it("a delivery-note match failure does not prevent the quote suggestion from running", async () => {
const { suggestDeliveryNoteForOrder } = await import("./order-delivery-note-link-service");
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
vi.mocked(suggestDeliveryNoteForOrder).mockClear();
vi.mocked(suggestQuoteForOrder).mockClear();
vi.mocked(suggestDeliveryNoteForOrder).mockRejectedValueOnce(new Error("DN lookup failed"));
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
const created = await createOrder({
customer_id: "cust-x",
company_id: "comp-x",
items: [{ description: "M16 Bolt", quantity: 50 }],
});
expect(suggestQuoteForOrder).toHaveBeenCalledWith(created.order.id);
});
it("a quote-match failure does not prevent the delivery-note match from running", async () => {
const { suggestDeliveryNoteForOrder } = await import("./order-delivery-note-link-service");
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
vi.mocked(suggestDeliveryNoteForOrder).mockClear();
vi.mocked(suggestQuoteForOrder).mockClear();
vi.mocked(suggestQuoteForOrder).mockRejectedValueOnce(new Error("quote lookup failed"));
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
const created = await createOrder({
customer_id: "cust-x",
company_id: "comp-x",
items: [{ description: "M16 Bolt", quantity: 50 }],
});
expect(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id);
});
});
```
The second and third tests are the actual point of this task's design — proving the two `try/catch` blocks are genuinely independent, not just present. **Verify these are not vacuous** the same way earlier tasks in the sibling plan did: temporarily merge the two calls under one `try/catch` (so a `suggestDeliveryNoteForOrder` throw would prevent `suggestQuoteForOrder` from running), confirm test 2 fails, then restore the two-block structure. Report that result.
- [ ] **Step 3: Confirm red**, then locate `runQuoteMatch` in `orders-service.ts` and edit it:
```ts
async function runQuoteMatch() {
try {
await suggestQuoteForOrder(order.id);
} catch (err) {
console.error(`[createOrder] quote auto-detection failed for order ${order.id}:`, err instanceof Error ? err.message : err);
}
try {
await suggestDeliveryNoteForOrder(order.id);
} catch (err) {
console.error(`[createOrder] delivery-note match failed for order ${order.id}:`, err instanceof Error ? err.message : err);
}
}
```
(The exact current shape of `runQuoteMatch` may differ slightly after the sibling plan's Task 6 review fixes — read it fresh before editing rather than assuming the shape above is byte-exact. Preserve whatever logging/structure is already there for the quote half; add the delivery-note half alongside it with the same pattern.)
Add the import:
```ts
import { suggestDeliveryNoteForOrder } from "@/lib/server/order-delivery-note-link-service";
```
Match the file's relative-vs-aliased import convention — check what the adjacent `suggestQuoteForOrder`/`pairOrderLines` import uses and mirror it exactly.
- [ ] **Step 4: Run tests, verify green.** Then run the full suite and diff failing test names against the known baseline (check the sibling plan's task reports for the current baseline count/names — it may have shifted slightly if that plan's later tasks have also landed).
- [ ] **Step 5: Commit**
```bash
git add src/lib/server/orders-service.ts src/lib/server/orders-service.test.ts
git commit -m "feat(orders): detect delivery-note repeats at order intake"
```
---
## ⛔ Phase 1 checkpoint
- [ ] Run the full suite: `npm run test:run`.
- [ ] `npx tsc --noEmit` clean.
- [ ] Confirm the migration ran against the real PB instance and the five fields exist (ask the user to confirm, or check via the PB admin UI — do not assume Task 1's script ran just because it's committed).
---
# PHASE 2 — Surfacing
## Task 5: On-demand re-check route
**Files:**
- Create: `src/app/api/v1/orders/[id]/delivery-note-match/route.ts`
- [ ] **Step 1: Implementation**
```ts
import { NextRequest, NextResponse } from "next/server";
import { suggestDeliveryNoteForOrder } from "@/lib/server/order-delivery-note-link-service";
/**
* POST /api/v1/orders/[id]/delivery-note-match
*
* Re-run delivery-note repeat-order detection for one order on demand
* — for the case where an order's items were edited after intake, or
* the order predates this feature. Orders created from now on are
* checked automatically at intake (see createOrder in orders-service.ts).
*
* Visibility only — writes potential_delivery_note and its sibling
* fields, never touches order processing or pricing.
*
* Returns: SuggestDeliveryNoteResult — { orderId, best, method }.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const result = await suggestDeliveryNoteForOrder(id);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Delivery-note match failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}
```
- [ ] **Step 2: Verify manually** against the dev server (port 4001) with a real order id.
- [ ] **Step 3: Commit**
```bash
git add "src/app/api/v1/orders/[id]/delivery-note-match/route.ts"
git commit -m "feat(orders): add on-demand delivery-note match re-check route"
```
---
## Task 6: Orders list pill
**Files:**
- Modify: `src/lib/types.ts`
- Modify: `src/app/(app)/orders/page.tsx`
- [ ] **Step 1: Add the field to the `Order` interface** in `src/lib/types.ts`, alongside the existing `potential_quote` / `potential_quote_confidence` fields:
```ts
potential_delivery_note?: string;
potential_delivery_note_number?: string;
```
- [ ] **Step 2: Add the pill** in `src/app/(app)/orders/page.tsx`, in the same badge row as the quote pills (find the block ending around the existing `{!order.quote && order.potential_quote && (...)}` — insert after it, not replacing it):
```tsx
{order.potential_delivery_note_number && (
<span
title={`Matches delivery note ${order.potential_delivery_note_number} — likely a repeat order`}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{
background: "rgba(59, 130, 246, 0.10)",
color: "#3b82f6",
boxShadow: "inset 0 0 0 1px rgba(59, 130, 246, 0.30)",
}}
aria-label={`Matches delivery note ${order.potential_delivery_note_number}`}
>
<Truck className="h-3 w-3" />
{order.potential_delivery_note_number}
</span>
)}
```
`Truck` is already imported in this file (used elsewhere at line ~751) — do not add a duplicate import.
- [ ] **Step 3: Verify `getOrdersDashboardData` actually returns the new field.**
Per the pre-flight note, confirm this function returns full order records (no `fields` allowlist) rather than assuming. If it does use a `fields` projection somewhere in the pipeline, add `potential_delivery_note_number` to it.
- [ ] **Step 4: Verify in the browser.**
Start the dev server (port 4001), find or create an order whose `potential_delivery_note_number` is set (either via the intake flow or by manually setting it through the PB admin for testing), and confirm:
- the pill renders with the DN number
- it coexists correctly with a quote pill on the same order (test an order that has both)
- the tooltip reads correctly
- no `expand` request appears in the network tab for this field (confirming the no-extra-round-trip design goal actually holds)
- [ ] **Step 5: Commit**
```bash
git add src/lib/types.ts "src/app/(app)/orders/page.tsx"
git commit -m "feat(orders): show a repeat-order pill when an order matches a delivery note"
```
---
## Final verification
- [ ] `npm run test:run` — full suite green, no new failures beyond the known pre-existing baseline.
- [ ] `npx tsc --noEmit && npm run lint` — clean.
- [ ] Confirm in the browser: an order created against a company with a matching past DN shows the pill; an order with no match shows nothing; an order matching two DNs with different quote prices picks the one whose price matches.

File diff suppressed because it is too large Load diff

View file

@ -1,260 +0,0 @@
# Non-Invoiceable Order Extras — Implementation Plan
> **For agentic workers:** Use `superpowers:subagent-driven-development` or
> `superpowers:executing-plans` to implement this plan task-by-task. Steps use
> checkbox (`- [ ]`) syntax for tracking.
**Goal:** The operator can add extra line items to an order at any stage and
mark them "don't invoice". Such lines get POs, drivers and collection tracking
like any other line, and count toward order completion — but never appear on a
customer invoice or delivery note.
**Spec:** `docs/superpowers/specs/2026-08-30-non-invoiceable-order-extras-design.md`
**Tech Stack:** Next.js 16 (App Router), TypeScript, PocketBase (via
`getAdminPocketBase()`), Vitest, Tailwind + shadcn/ui, lucide-react.
---
## Pre-flight: verified before this plan was written
Do not re-investigate these.
- **The n8n QuickBooks workflow needs no changes.** Its "Build PocketBase
Invoice Update" node maps over `order_item_ids` and patches each row with
`item_status: 'invoiced'` + `order_item_details.invoice`. The QuickBooks
lines come from the separate `items` array. Verified in
`n8n_workfows/1 - WFDASH QUICKBOOKS INVOICE1 - WFDASH QUICKBOOKS INVOICE.json`.
- **Nothing in the Next app flips `item_status` to `"invoiced"`.** That write
is n8n's alone. Do not add an app-side flip.
- **Both picking-slip routes need no changes.** They query PB by document
identity (`print-bundle/route.ts:88`, `delivery-note-print/route.ts:92`), not
by the submitted row list, so excluded rows are picked up automatically.
Task 8 adds a regression test to lock this in.
- **`selling_price` has `min: 0`** in the PB schema, so `0` is a legal value.
- **Tests:** `npm run test:run`. Dev server is on **port 4001**, not 3000.
- **`vitest.config.ts` has no `clearMocks`** — mock history leaks between
tests. Reset explicitly or you get green-but-vacuous tests.
## File structure
| File | Responsibility |
|---|---|
| PocketBase admin *(manual)* | New `order_items.exclude_from_invoice` bool. **Task 0 — blocks everything.** |
| `src/lib/types.ts` *(modify)* | `OrderItem.exclude_from_invoice?: boolean`. |
| `src/lib/server/orders-service.ts` *(modify)* | Drop the pending gate; accept the flag in the create draft. |
| `src/app/(app)/orders/[id]/process/page.tsx` *(modify)* | Add-item form; margin-gate bypass; excluded-row styling. |
| `src/app/(app)/invoicing/view-model.ts` *(modify)* | Split `order_item_ids` from `items` in `buildQbInvoicePayload`. |
| `src/app/(app)/invoicing/page.tsx` *(modify)* | Muted row + locked checkbox + all-excluded guard, in **both** modals. |
| `src/app/api/invoicing/delivery-note/route.ts` *(modify)* | Excluded rows flip status but drop out of `lines:`. |
| `src/app/api/mcp/route.ts` *(modify)* | `order_items.create` accepts the flag. |
## Phase boundaries
- **Phase 1 — Tasks 0–4.** Creating and pricing an excluded line. Ends with the
operator able to add extras that survive the whole operational flow.
- **Phase 2 — Tasks 5–9.** Keeping them off customer paperwork. Depends on
Phase 1.
---
## Task 0 — PocketBase field (BLOCKS EVERYTHING)
**This is a manual step the user performs in the PB admin UI.** Per `AGENTS.md`,
schema changes to PocketBase are not self-service; the API returns an opaque
400/500 if code writes a field that doesn't exist yet.
- [ ] Ask the user to add to the `order_items` collection:
- Name: `exclude_from_invoice`
- Type: **Bool**
- Required: **no**
- [ ] Confirm it exists before starting Task 1. Do not proceed on assumption.
**Do not invert the name.** A positively-named flag (`bill_customer`,
`invoiceable`) defaults to `false` and would mark every existing row in the
database non-billable the moment the field is created.
Optionally refresh `pb-exports/pb_schema-2.json`, but remember it is a
reference snapshot — the running instance is the source of truth.
## Task 1 — Type the field
- [ ] Add to the `OrderItem` interface in `src/lib/types.ts` (interface starts
at line 782), near `surplus_qty` / `ready_for_collection`:
```ts
/**
* When true this line never reaches a customer-facing document. It
* still gets a PO, a driver, a collection and a `delivered` status —
* it is simply dropped from the QuickBooks `items` array and the
* delivery-note PDF lines. Used for supplier-side component splits:
* the customer approved "bolt, nut and washer" as one priced line,
* but we must raise three PO lines to actually buy it.
*
* Named for the exception, not the norm: the PB default of `false`
* must mean "bill this normally".
*/
exclude_from_invoice?: boolean;
```
- [ ] `npx tsc --noEmit` passes.
## Task 2 — Allow adding lines mid-flight
`src/lib/server/orders-service.ts`.
- [ ] Add `exclude_from_invoice?: boolean` to `CreateOrderItemForOrderDraft`
(line 3867).
- [ ] **Delete the pending gate** in `createOrderItemForOrder` — the
`nonPending` query and the `if (nonPending.length > 0)` throw at line
3939. Replace the "Strict `pending` gate" doc comment above the function
with an explanation of why adding a row alongside an in-flight PO is
safe: the new row doesn't mutate the existing PO, it gets its own.
- [ ] There is a second, unrelated `nonPending` gate at line 3744 in
`setOrderQuote`. **Leave it alone.**
- [ ] In the secondary-field patch block, forward the flag. When
`exclude_from_invoice` is true, force `selling_price` to `0` regardless
of what the caller passed — the flag is authoritative.
- [ ] Keep the `orderLocked` field in the return type for API compatibility;
it is now always `false`.
**Tests** in `orders-service.test.ts`:
- [ ] Adding a line to an order whose other items are `po_generated` succeeds
(previously threw 409).
- [ ] `exclude_from_invoice: true` persists and forces `selling_price` to 0
even when the caller passes a non-zero price.
- [ ] Omitting the flag leaves it falsy and preserves existing behaviour.
## Task 3 — Add-item UI on the process page
`src/app/(app)/orders/[id]/process/page.tsx`. The empty-state copy at line
1684 ("No line items on this order. Add items before processing.") currently
points at an action that doesn't exist — this task makes it true.
- [ ] Add an "Add item" button below the item card stack, opening a small form
(reuse the existing `Sheet` pattern used by `PriceEditSheet`).
- [ ] Fields: description (required), quantity, cost price, selling price,
supplier, notes.
- [ ] A `Checkbox` labelled **"Don't invoice this line"**, with helper text:
*"Counts for the order and gets collected, but never appears on the
customer's invoice or delivery note."*
- [ ] When ticked: force the selling-price input to `0`, disable it, and show
"Not invoiced" in place of the margin chip.
- [ ] POST to the existing per-id create surface; refresh `items` on success.
- [ ] Existing rows with `exclude_from_invoice` render with a muted "Not
invoiced" badge in the card stack.
**Verification:** dev server on **port 4001**. Add an extra to an order that
already has a `po_generated` line and confirm it saves without a 409.
## Task 4 — Bypass the margin gate (HIGHEST RISK)
`calculateOrderItemMarkup(cost, 0)` returns `-100`, well under
`MARKUP_APPROVAL_THRESHOLD` (22, declared at `process/page.tsx:246`). Without
this task every extra lands in `approval_needed` and wedges the order.
- [ ] At `process/page.tsx:643`, where the save path tests
`computedMarkup < MARKUP_APPROVAL_THRESHOLD`, short-circuit when the item
has `exclude_from_invoice` — skip the approval request entirely and save
straight through.
- [ ] Suppress the low-margin colouring for excluded rows (`LOW_MARGIN_THRESHOLD`
at line 247) — a red "-100%" chip on every extra is noise.
- [ ] Also guard the server side: `requestOrderItemMarginApproval`
(`src/lib/server/order-item-margin-approval-service.ts:128`) should
refuse with a clear error if asked to gate an excluded item, so the MCP
path can't wedge an order either.
**Tests:**
- [ ] An excluded item with cost 50 / selling 0 saves without entering
`approval_needed`.
- [ ] A **non**-excluded item with the same numbers still triggers approval —
proves the bypass is keyed on the flag, not on the price being zero.
## Task 5 — Split the QuickBooks payload
`src/app/(app)/invoicing/view-model.ts`.
- [ ] Add `excludeFromInvoice?: boolean` to `InvoicingRow` (line 4).
- [ ] In `buildQbInvoicePayload` (line 393), keep `order_item_ids` built from
**all** rows, and build `items` from **only** rows where
`excludeFromInvoice` is not true.
- [ ] Comment the split at the call site — the asymmetry looks like a bug
otherwise. State that `order_item_ids` drives n8n's status write and
`items` drives the QuickBooks lines.
- [ ] Export a small helper (e.g. `billableRows(rows)`) so the modals and the
guard in Task 6 share one definition of "billable".
- [ ] In `src/app/(app)/invoicing/page.tsx`, map the new PB field into
`InvoicingRow`: add `exclude_from_invoice` to both the `RawInvoicingRow`
type (line 42) and the `INVOICING_FIELDS` projection (line 77). **The
projection is load-bearing** — omit it and PB returns the field as
`undefined`, silently invoicing every extra.
**Tests** in `view-model.test.ts`:
- [ ] Mixed set: excluded ids present in `order_item_ids`, absent from `items`.
- [ ] All-billable set produces identical output to today (regression).
- [ ] All-excluded set produces an empty `items` array, so Task 6's guard has
something to detect.
## Task 6 — Invoicing UI
`src/app/(app)/invoicing/page.tsx`. **There are two modals** — single-order
(~line 576) and bulk (~line 965) — each with its own `includedRowIds` state.
Apply every change to both.
- [ ] Excluded rows render muted, with a "Not invoiced" chip.
- [ ] Their include-checkbox is checked and **disabled** — the row must ride
along for the status flip, and the operator must not be able to remove
it or add it to the bill.
- [ ] Exclude them from any invoice-total display in the modal.
- [ ] **Guard:** if the billable subset is empty, disable submit and toast
"This order has no billable lines." Apply to `sendToQuickBooks`
(line ~1892) and `submitDeliveryNote` (line ~1980). The existing
`noneIncluded` checks are about the operator's manual unticking and are
not sufficient.
## Task 7 — Delivery note
`src/app/api/invoicing/delivery-note/route.ts`.
- [ ] Leave the status-flip loop at line 230 **untouched** — excluded rows must
receive `item_status: "delivery_note"` and the DN number, or they strand.
- [ ] At line 362, build `lines:` from billable items only.
- [ ] Guard: if no billable items remain, 400 with "No billable lines for a
delivery note" rather than rendering an empty PDF.
- [ ] Fetch `exclude_from_invoice` in the `getFullList` at line 212.
**Test:** excluded rows get the status and DN number, and are absent from the
`lines` passed to the PDF renderer.
## Task 8 — Picking-slip regression test
No production code changes. This task exists to lock in behaviour the design
depends on, so a later refactor doesn't quietly break it.
- [ ] Test `print-bundle/route.ts`: an invoice whose rows include an excluded
item produces a picking slip containing **all** rows, including the
excluded one. The picker must pull the washers.
- [ ] Same for `delivery-note-print/route.ts`.
## Task 9 — MCP surface
`src/app/api/mcp/route.ts`.
- [ ] `order_items.create` accepts `exclude_from_invoice`.
- [ ] Update the tool description to explain what the flag does and that it
forces `selling_price` to 0.
- [ ] Update the description of the removed `pending` gate — the current text
at line 794 documents a 409 that no longer fires.
## Definition of done
- [ ] `npm run test:run` passes.
- [ ] `npx tsc --noEmit` passes.
- [ ] Manually on port 4001: add an extra to an order with a `po_generated`
sibling; process it onto a PO; confirm collection; send the order to
QuickBooks; confirm the QuickBooks invoice shows only the billable line
while all rows flip to `invoiced`; print the bundle and confirm the
picking slip lists the extra.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,495 +0,0 @@
# Rep-Scoped List Filter Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** When a logged-in PocketBase user's `user_type` includes `"REP"`, the Orders, Quotes, Invoicing, and Delivery Notes list pages show only records tied to that rep (via `customer.rep` or, for orders, the denormalized `orders.rep` field). Everyone else (buyer, quoter, admin, boss, etc.) sees the unfiltered list, unchanged from today.
**Architecture:** This is a UI-layer filter only — **not** a security boundary. No PocketBase API rules change, no server session plumbing, no changes to `/api/v1/*` auth. Orders and Quotes already return `customer` (and for orders, `customer.rep`) in their expand, so those two pages filter the already-fetched array client-side. Invoicing and Delivery Notes fetch PocketBase directly from the browser (no `/api/v1` route), so those two get a real PB filter clause (`order.customer.rep = "<id>"` / `customer.rep = "<id>"`) added to their existing query.
**Known limitation (accepted for this pass):** On Orders and Quotes, the tab-count badges and pagination totals (`totalPages`, `counts`) come from the unfiltered dataset — they are not rep-scoped. A rep filtering on Orders/Quotes may see a page with fewer than `perPage` visible rows, or a "Page 1 of N" that doesn't match the filtered count. Invoicing and Delivery Notes don't have this problem — their filter is a real PB query, so their counts are accurate. Fixing Orders/Quotes properly means threading `rep` through `orders-service.ts` / `quotes-service.ts`'s PB filters, which is a larger, separate follow-up (not in scope here).
**Tech Stack:** Next.js Client Components, PocketBase JS SDK, Vitest.
---
## File Structure
- **Create:** `src/lib/rep-visibility.ts` — pure helper `isRepUser(userType)`, mirrors the existing `isRep()` pattern in `src/lib/server/tv-dashboard-service.ts:349`.
- **Create:** `src/lib/rep-visibility.test.ts` — unit tests for the helper.
- **Modify:** `src/app/(app)/orders/page.tsx` — client-side filter on the rendered order list.
- **Modify:** `src/app/(app)/quotes/page.tsx` — client-side filter on the rendered quote list.
- **Modify:** `src/app/(app)/invoicing/page.tsx` — PB filter clause added to `loadInvoicingRows`.
- **Modify:** `src/lib/pb-helpers.ts` — `getDeliveryNotes` gains an optional `repId` param that adds a PB filter clause.
- **Modify:** `src/app/(app)/delivery-notes/page.tsx` — passes the current rep's id into `getDeliveryNotes`.
---
### Task 1: `isRepUser` helper
**Files:**
- Create: `src/lib/rep-visibility.ts`
- Test: `src/lib/rep-visibility.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
// src/lib/rep-visibility.test.ts
import { describe, expect, it } from "vitest";
import { isRepUser } from "./rep-visibility";
describe("isRepUser", () => {
it("returns true when user_type includes REP", () => {
expect(isRepUser(["REP"])).toBe(true);
});
it("is case-insensitive", () => {
expect(isRepUser(["rep"])).toBe(true);
});
it("returns true when REP is combined with other roles", () => {
expect(isRepUser(["BUYER", "REP"])).toBe(true);
});
it("returns false for other roles", () => {
expect(isRepUser(["BUYER"])).toBe(false);
expect(isRepUser(["ADMIN", "QUOTER"])).toBe(false);
});
it("returns false for null, undefined, or non-array input", () => {
expect(isRepUser(null)).toBe(false);
expect(isRepUser(undefined)).toBe(false);
expect(isRepUser("REP")).toBe(false);
});
it("returns false for an empty array", () => {
expect(isRepUser([])).toBe(false);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run src/lib/rep-visibility.test.ts`
Expected: FAIL — `Cannot find module './rep-visibility'` (or similar; the module doesn't exist yet).
- [ ] **Step 3: Write the implementation**
```typescript
// src/lib/rep-visibility.ts
/**
* `users.user_type` is a PB multi-select. "REP" is the existing
* convention for marking a sales rep — see the identical pattern in
* `src/lib/server/tv-dashboard-service.ts` (`TV_REP_ROLES` / `isRep`).
*/
export const REP_USER_TYPE = "REP";
export function isRepUser(userType: unknown): boolean {
if (!Array.isArray(userType)) return false;
return userType.some(
(value) => typeof value === "string" && value.toUpperCase() === REP_USER_TYPE,
);
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run src/lib/rep-visibility.test.ts`
Expected: PASS — all 6 assertions green.
- [ ] **Step 5: Commit**
```bash
git add src/lib/rep-visibility.ts src/lib/rep-visibility.test.ts
git commit -m "feat: add isRepUser helper for rep-scoped list filtering"
```
---
### Task 2: Orders page — filter to the rep's own orders
**Files:**
- Modify: `src/app/(app)/orders/page.tsx`
- [ ] **Step 1: Import the auth hook and the helper**
In `src/app/(app)/orders/page.tsx`, add to the top imports (near the other `@/lib` and `@/components` imports, e.g. after the `getPocketBase` import at line 26):
```typescript
import { useAuth } from "@/components/auth-provider";
import { isRepUser } from "@/lib/rep-visibility";
```
- [ ] **Step 2: Read the current user and derive the visible order list**
Inside `OrdersPage`, immediately after the existing `useRouter()` call at line 114 (`const router = useRouter();`), add:
```typescript
const { user } = useAuth();
```
Then, right after the `otherTabCounts` `useMemo` block (ends at line 298, just before the `return (` on line 300), add a new `useMemo`:
```typescript
// Rep-scoped UI filter — reps only see orders tied to them, either
// via the denormalized `orders.rep` text field or via
// `customer.rep`. Everyone else (buyer/quoter/admin/boss/etc.) sees
// the unfiltered list. This filters the already-fetched page of
// orders; it does not change what the server returns, so tab
// counts / pagination totals stay unscoped — see the plan's "Known
// limitation" note.
const visibleOrders = useMemo(() => {
const userType = (user as { user_type?: string[] } | null)?.user_type;
if (!user || !isRepUser(userType)) return orders;
return orders.filter(
({ order }) =>
order.rep === user.id || order.expand?.customer?.rep === user.id,
);
}, [orders, user]);
```
- [ ] **Step 3: Use `visibleOrders` everywhere the rendered list is read**
Replace these three usages (leave the `orders` state itself, `fetchOrders`, and the realtime subscription untouched — they still fetch/refetch the full page):
1. Line 282, `const totalLoaded = orders.length;` → `const totalLoaded = visibleOrders.length;`
2. Line 390, `) : orders.length === 0 ? (` → `) : visibleOrders.length === 0 ? (`
3. Lines 445–464 (the "N matches in Tab for query" block) — change every `orders.length` reference to `visibleOrders.length`:
```typescript
{searching && (
<p className="text-sm text-muted-foreground">
{visibleOrders.length} match{visibleOrders.length === 1 ? "" : "es"} in
{" "}
{TAB_GROUP_VISUAL[activeTab].label} for
```
4. Line 466, `{orders.map(({ order, items, repName, tabGroups }) => (` → `{visibleOrders.map(({ order, items, repName, tabGroups }) => (`
- [ ] **Step 4: Manual verification**
Run the dev server (`npm run dev`, or use the existing preview tooling) and:
1. Log in as a user whose `user_type` does NOT include `REP` (e.g. an admin/buyer account). Confirm `/orders` shows the same orders as before this change.
2. Log in as a user whose `user_type` includes `REP` and who is set as `rep` on at least one customer (or has `orders.rep` set to their id on at least one order). Confirm `/orders` only shows orders for that rep's customers/orders.
3. Confirm the "New Order" flow, search, and tab-switching still work (they operate on `orders`/`fetchOrders` unchanged — only the rendered list is filtered).
- [ ] **Step 5: Commit**
```bash
git add src/app/\(app\)/orders/page.tsx
git commit -m "feat(orders): filter list to the logged-in rep's own orders"
```
---
### Task 3: Quotes page — filter to the rep's own quotes
**Files:**
- Modify: `src/app/(app)/quotes/page.tsx`
- [ ] **Step 1: Import the auth hook and the helper**
Add near the top imports, after the `getPocketBase` import at line 34:
```typescript
import { useAuth } from "@/components/auth-provider";
import { isRepUser } from "@/lib/rep-visibility";
```
- [ ] **Step 2: Read the current user and derive the visible quote list**
Inside `QuotesPage`, immediately after `const router = useRouter();` at line 142, add:
```typescript
const { user } = useAuth();
```
After the `userById` `useMemo` block (ends at line 440, just before the `toggleExpand` callback at line 447), add:
```typescript
// Rep-scoped UI filter — same rationale as the Orders page (see
// src/lib/rep-visibility.ts). Quotes only carry rep via the
// customer relation (no denormalized text field like orders.rep).
const visibleQuotes = useMemo(() => {
const userType = (user as { user_type?: string[] } | null)?.user_type;
if (!user || !isRepUser(userType)) return quotes;
return quotes.filter((quote) => quote.expand?.customer?.rep === user.id);
}, [quotes, user]);
```
- [ ] **Step 3: Use `visibleQuotes` in the DataTable and the search-count chip**
1. Line 870, `data={quotes}` → `data={visibleQuotes}` (the `DataTable` prop).
2. Lines 895–919 (the live result-count chip) — change every `quotes.length` reference to `visibleQuotes.length`:
```typescript
{searchInput.trim() && (
<span
className={cn(
"text-xs whitespace-nowrap",
loading
? "text-muted-foreground/60"
: visibleQuotes.length === 0
? "text-warn font-medium"
: "text-muted-foreground",
)}
data-testid="quotes-search-count"
>
{loading ? (
<>Searching…</>
) : visibleQuotes.length === 0 ? (
<>0 matches for &ldquo;{searchInput.trim()}&rdquo;</>
) : (
<>
{visibleQuotes.length} match{visibleQuotes.length === 1 ? "" : "es"} for{" "}
<span className="font-medium text-foreground">
&ldquo;{searchInput.trim()}&rdquo;
</span>
</>
)}
</span>
)}
```
Leave `itemCounts` / `itemsByQuote` / `expandedIds` untouched — they're keyed by quote id and stay correct regardless of which quotes are visible.
- [ ] **Step 4: Manual verification**
1. Log in as a non-rep user. Confirm `/quotes` is unchanged.
2. Log in as a rep user who is set as `rep` on at least one customer with quotes. Confirm `/quotes` only shows that rep's quotes, across both the tab-list view and the search view (type something into the search box).
3. Confirm row expand (chevron → items), Process/Open buttons, and template actions still work.
- [ ] **Step 5: Commit**
```bash
git add src/app/\(app\)/quotes/page.tsx
git commit -m "feat(quotes): filter list to the logged-in rep's own quotes"
```
---
### Task 4: Invoicing page — PB filter for the rep's own order items
**Files:**
- Modify: `src/app/(app)/invoicing/page.tsx`
- [ ] **Step 1: Import the auth hook and the helper**
Add near the top imports, after the `getPocketBase` import at line 14:
```typescript
import { useAuth } from "@/components/auth-provider";
import { isRepUser } from "@/lib/rep-visibility";
```
- [ ] **Step 2: Thread an optional `repId` through `loadInvoicingRows`**
Modify the function signature and its first call site (lines 162–182):
```typescript
async function loadInvoicingRows(
pb: ReturnType<typeof getPocketBase>,
repId: string | null,
): Promise<RawInvoicingRow[]> {
const repFilter = repId
? ` && order.customer.rep = "${escapeFilterValue(repId)}"`
: "";
const seedRows = await listAllInvoicingRows(pb, INVOICING_STATUS_FILTER + repFilter);
const readyOrderIds = Array.from(
new Set(
seedRows
.filter((row) => row.item_status === "ready_invoice")
.map((row) => row.expand?.order?.id?.trim() ?? "")
.filter(Boolean),
),
);
if (readyOrderIds.length === 0) {
return seedRows;
}
// No need to re-apply repFilter here — readyOrderIds is already
// derived from the rep-scoped seedRows above, so the sibling fetch
// (by explicit order id) is implicitly scoped too.
const siblingFilter = readyOrderIds
.map((orderId) => `order = "${escapeFilterValue(orderId)}"`)
.join(" || ");
const siblingRows = await listAllInvoicingRows(pb, siblingFilter);
return dedupeRawRows([...seedRows, ...siblingRows]);
}
```
- [ ] **Step 3: Compute `repId` in the page and pass it into `load()`**
Inside `InvoicingPage`, near the top of the component (alongside the other `useState` calls, e.g. right after the `useBooleanPreference` call around line 1371), add:
```typescript
const { user } = useAuth();
```
Then update the `load` callback (lines 1407–1418):
```typescript
const load = useCallback(async () => {
setRows(null);
try {
const pb = getPocketBase();
const userType = (user as { user_type?: string[] } | null)?.user_type;
const repId = user && isRepUser(userType) ? user.id : null;
const items = await loadInvoicingRows(pb, repId);
setRows(items);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast.error(message);
setRows([]);
}
}, [user]);
```
The existing `useEffect(() => { ...; void load(); }, [load])` (around line 1483) already re-runs whenever `load`'s identity changes, so it will automatically refetch once `user` resolves from the auth provider (which starts as `null` while PB auth is loading).
- [ ] **Step 4: Manual verification**
1. Log in as a non-rep user. Confirm the Invoicing page (`/invoicing`) shows the same rows as before.
2. Log in as a rep user tied to at least one customer with rows in `ready_invoice` / `invoiced` / `delivery_note` status. Confirm only that rep's rows appear, in both the "To Invoice" and "Invoiced" tabs.
3. Confirm the QuickBooks-send and delivery-note-creation modals still work on a visible row.
- [ ] **Step 5: Commit**
```bash
git add src/app/\(app\)/invoicing/page.tsx
git commit -m "feat(invoicing): filter rows to the logged-in rep's own orders"
```
---
### Task 5: Delivery Notes page — PB filter for the rep's own notes
**Files:**
- Modify: `src/lib/pb-helpers.ts`
- Modify: `src/app/(app)/delivery-notes/page.tsx`
- [ ] **Step 1: Add an optional `repId` param to `getDeliveryNotes`**
In `src/lib/pb-helpers.ts`, replace the existing function (lines 1068–1076):
```typescript
export async function getDeliveryNotes(
page = 1,
perPage = 50,
search = "",
repId: string | null = null,
) {
const pb = getPocketBase();
const clauses: string[] = [];
if (search) {
clauses.push(`(delivery_note_number ~ "${search}" || notes ~ "${search}")`);
}
if (repId) {
clauses.push(`customer.rep = "${repId.replace(/"/g, '\\"')}"`);
}
const filter = clauses.join(" && ");
return pb.collection("delivery_notes").getList<DeliveryNote>(page, perPage, {
filter,
sort: "-created",
expand: "order,company,customer",
});
}
```
(This is backward-compatible — the new param is optional and defaults to `null`, so the one other behavior — no filter at all when both `search` and `repId` are empty — is unchanged.)
- [ ] **Step 2: Pass the current rep's id from the Delivery Notes page**
In `src/app/(app)/delivery-notes/page.tsx`, add to the imports (after the `getDeliveryNotes` import at line 27):
```typescript
import { useAuth } from "@/components/auth-provider";
import { isRepUser } from "@/lib/rep-visibility";
```
Inside `DeliveryNotesPage`, right after `const router = useRouter();` at line 112, add:
```typescript
const { user } = useAuth();
```
Update `fetchNotes` (lines 318–352) to compute and pass `repId`, and add `user` to its dependency array:
```typescript
const fetchNotes = useCallback(async () => {
setLoading(true);
setError("");
try {
const userType = (user as { user_type?: string[] } | null)?.user_type;
const repId = user && isRepUser(userType) ? user.id : null;
// Fetch a wide page so the client-side tab filter sees the
// full set — the previous `perPage=20` made the active
// tab silently drop records past rank 20 (the operator
// saw "no DN" when the row existed but was buried). 500
// is well over the production DN count (≈30) and still
// cheap for PB.
const result = await getDeliveryNotes(page, 500, search, repId);
setNotes(result.items);
setTotalPages(result.totalPages);
// Batch-fetch the related quotes in one query so the Q
// icon can show "create" vs "open" without a per-row
// round trip. Failures here are non-fatal — the Q icon
// falls back to "create quote" if we don't know whether
// a quote exists.
if (result.items.length > 0) {
try {
const ids = result.items.map((n) => n.id);
const map = await getQuotesForDeliveryNotes(ids);
setQuoteByDnId(map);
} catch {
setQuoteByDnId(new Map());
}
} else {
setQuoteByDnId(new Map());
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load delivery notes");
} finally {
setLoading(false);
}
}, [page, search, user]);
```
- [ ] **Step 3: Manual verification**
1. Log in as a non-rep user. Confirm `/delivery-notes` shows the same notes as before.
2. Log in as a rep user tied to at least one customer with delivery notes. Confirm only that rep's notes appear, across all four status tabs (Active / Cancelled / Completed / All).
3. Confirm search, company grouping, and the Combine-DNs flow still work.
- [ ] **Step 4: Commit**
```bash
git add src/lib/pb-helpers.ts src/app/\(app\)/delivery-notes/page.tsx
git commit -m "feat(delivery-notes): filter list to the logged-in rep's own notes"
```
---
### Task 6: Full regression pass
**Files:** none (verification only)
- [ ] **Step 1: Run the full test suite**
Run: `npx vitest run`
Expected: PASS — no regressions in existing suites (`orders-service.test.ts`, `quotes-service.test.ts`, `boss-dashboard-service.test.ts`, `pocketbase-admin.test.ts`, etc. are all untouched by this plan).
- [ ] **Step 2: Manual cross-check with a boss/admin account**
Log in as an admin/boss account (`user_type` does NOT include `REP`) and confirm all four pages — Orders, Quotes, Invoicing, Delivery Notes — show the full, unfiltered list exactly as they did before this plan. This is the most important check: the filter must be strictly opt-in.
- [ ] **Step 3: Manual cross-check with two different rep accounts**
If two rep test accounts are available (each tied to different customers), confirm rep A never sees rep B's orders/quotes/invoicing-rows/delivery-notes, and vice versa.
- [ ] **Step 4: Commit (if any fixes were needed)**
```bash
git add -A
git commit -m "test: regression pass for rep-scoped list filtering"
```

File diff suppressed because it is too large Load diff

View file

@ -1,508 +0,0 @@
# /deliveries DN Items Expand Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a click-to-expand chevron on every delivery-note row on `/deliveries` that reveals an inline items panel (description + qty) so the operator can match the printed DN paper in their hand against the loaded items.
**Architecture:** Single-file edit on `src/app/(app)/deliveries/page.tsx`. Lift expand state up to the page (a `Set<string>` of expanded DN ids), pass `expanded` + `onToggle` down into `DeliveryNoteRowComponent`, render the chevron + items panel inside. Items come from a client-side filter of the page's already-loaded items array (`items.filter(i => i.delivery_note === dn.id)`).
**Tech Stack:** Next.js + React 19, lucide-react (ChevronRight + ChevronDown), existing Tailwind classes, vitest for any unit tests.
**Spec:** `docs/superpowers/specs/2026-09-01-deliveries-dn-items-expand-design.md`
---
## File structure
| Action | File | Responsibility |
|----------|------|----------------|
| Modify | `src/app/(app)/deliveries/page.tsx` | Page-level state for expanded DN ids + prop-drill into the row + chevron button + inline items panel. Single file edit. |
No new files. No service-layer changes. No new `/api/v1/*` routes. No MCP changes. No PB schema changes.
---
## Task 1: Add DN expand/collapse + items panel
**Files:**
- Modify: `src/app/(app)/deliveries/page.tsx`
This task adds the page-level state, the chevron button, and the inline items panel below each delivery-note row. The whole change is one commit — single coherent feature.
- [ ] **Step 1: Add `ChevronRight` + `ChevronDown` to the lucide-react import**
In `src/app/(app)/deliveries/page.tsx`, locate the lucide-react import block (currently around lines 85-94). The current block looks like:
```ts
import {
CheckSquare,
FileText,
Loader2,
PackageOpen,
Paperclip,
Pencil,
Square,
Truck,
User,
} from "lucide-react";
```
Replace with:
```ts
import {
CheckSquare,
ChevronDown,
ChevronRight,
FileText,
Loader2,
PackageOpen,
Paperclip,
Pencil,
Square,
Truck,
User,
} from "lucide-react";
```
(Alphabetical insertion of `ChevronDown` + `ChevronRight` between `CheckSquare` and `FileText`.)
- [ ] **Step 2: Locate the page-level component that renders `DeliveryNoteRowComponent`**
Find the call site that renders each DN row. The signature of `DeliveryNoteRowComponent` (around line 1475-1488 of the file) is:
```ts
function DeliveryNoteRowComponent({
dn,
onSignedCopy,
onMarkLoaded,
busy,
}: {
dn: DeliveryNoteRow;
onSignedCopy: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
onMarkLoaded: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
busy: boolean;
}) {
```
We'll come back to this in Step 5. First, find the **call site** — the place in the parent component that does `<DeliveryNoteRowComponent dn={dn} onSignedCopy={...} onMarkLoaded={...} busy={busy} />`. Use `grep -n "DeliveryNoteRowComponent" src/app/(app)/deliveries/page.tsx` to locate it. It sits inside the per-customer → per-order group render.
Note the existing props being passed. Note also the page-level `items` array (the items already loaded by `/api/v1/deliveries`) — it lives in scope of the parent component. We'll need to pass it down.
- [ ] **Step 3: Add page-level expand state**
In the parent component that renders `DeliveryNoteRowComponent`, near the top of the function body (alongside the existing `useState` calls), add:
```tsx
// Set of DN ids whose items panel is expanded. Independent state per
// DN; resets on refresh. See spec § Feature 1 — Interaction details.
const [expandedDnIds, setExpandedDnIds] = useState<Set<string>>(
() => new Set(),
);
const toggleDnExpanded = useCallback((dnId: string) => {
setExpandedDnIds((prev) => {
const next = new Set(prev);
if (next.has(dnId)) {
next.delete(dnId);
} else {
next.add(dnId);
}
return next;
});
}, []);
```
If `useState` / `useCallback` are not already imported in this file's `react` import line, add them. (They almost certainly are — verify with `grep -n "from \"react\"" src/app/(app)/deliveries/page.tsx`.)
- [ ] **Step 4: Pass `expanded` + `onToggle` into the call site**
At the `<DeliveryNoteRowComponent ... />` call site found in Step 2, replace the existing call with:
```tsx
<DeliveryNoteRowComponent
dn={dn}
onSignedCopy={onSignedCopy}
onMarkLoaded={onMarkLoaded}
busy={busy}
expanded={expandedDnIds.has(dn.id)}
onToggle={() => toggleDnExpanded(dn.id)}
items={items}
/>
```
`items` here is the page-level items array (already loaded by `/api/v1/deliveries`). Its type is whatever the parent component currently uses for `items` — typically `DeliveryListResult["items"]` or similar. Pass it verbatim.
- [ ] **Step 5: Update `DeliveryNoteRowComponent` signature + render chevron + items panel**
In `src/app/(app)/deliveries/page.tsx`, locate `DeliveryNoteRowComponent` (around lines 1475-1588). Replace its entire signature + return block with:
```tsx
function DeliveryNoteRowComponent({
dn,
onSignedCopy,
onMarkLoaded,
busy,
expanded,
onToggle,
items,
}: {
dn: DeliveryNoteRow;
onSignedCopy: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
onMarkLoaded: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
busy: boolean;
expanded: boolean;
onToggle: () => void;
items: DeliveryListResult["items"];
}) {
const statusLabel = (() => {
switch (dn.status) {
case "draft":
return "Draft";
case "sent":
return "Sent";
case "loaded":
return "Loaded";
case "po_received":
return "PO Received";
case "invoiced":
return "Invoiced";
case "completed":
return "Completed";
case "cancelled":
return "Cancelled";
default:
return dn.status;
}
})();
// "Mark loaded" is the action that moves the paper onto the
// truck. It's available on draft (paper being loaded directly,
// e.g. counter pickup without a prior `sent`) and sent (paper
// is on its way). Re-marking a loaded DN is allowed — the
// service idempotently refreshes the `loaded_at` stamp.
const canMarkLoaded =
dn.status === "draft" || dn.status === "sent" || dn.status === "loaded";
// "Signed copy" is the operator's manual upload of the
// scanned paper. The operator asked for it to be available
// regardless of status, so we show it on every non-cancelled
// DN. Cancelled is rejected by the service — uploads to an
// abandoned DN would be misleading.
const canUploadSignedCopy = dn.status !== "cancelled";
// Items on this DN — drawn from the page's already-loaded items
// array. Stale-by-design: a refresh picks up changes. See spec
// § Feature 1 — Data source.
const dnItems = useMemo(
() => items.filter((i) => i.delivery_note === dn.id),
[items, dn.id],
);
// Header-line suffix logic per spec: suffix omitted when all
// items have empty/null units (operator default is "EA"); no
// suffix when units are mixed; the shared unit when all items
// share a non-EA unit (e.g. BOX).
const totalSuffix = (() => {
const units = new Set(
dnItems.map((i) => (i.unit ?? "").trim()).filter((u) => u.length > 0),
);
if (units.size === 0) return ""; // all empty/null → no suffix
if (units.size === 1) {
const only = [...units][0];
return only === "EA" ? "" : ` ${only}`;
}
return ""; // mixed → no suffix
})();
const totalQty = dnItems.reduce((sum, i) => sum + (i.quantity ?? 0), 0);
const itemsPanelId = `dn-items-${dn.id}`;
const hasDifferentOrderItem = (itemOrderId: string | null) =>
dn.order !== null && itemOrderId !== null && itemOrderId !== dn.order;
return (
<div
className="bg-amber-50/40 dark:bg-amber-950/10"
data-testid={`deliveries-dn-row-${dn.id}`}
>
<div className="flex items-start gap-3 px-4 py-2.5">
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
aria-controls={itemsPanelId}
aria-label={
expanded
? "Hide items on this delivery note"
: "Show items on this delivery note"
}
className="mt-0.5 shrink-0 rounded p-0.5 text-amber-700 hover:bg-amber-100/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/40 dark:text-amber-400 dark:hover:bg-amber-900/30"
>
{expanded ? (
<ChevronDown className="h-4 w-4" aria-hidden />
) : (
<ChevronRight className="h-4 w-4" aria-hidden />
)}
</button>
<FileText className="mt-0.5 h-5 w-5 shrink-0 text-amber-700 dark:text-amber-400" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm" title={dn.delivery_note_number}>
<span className="font-mono">DN {dn.delivery_note_number}</span>
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground tabular-nums">
{dn.order ? (
<>
<span
className="truncate max-w-[40ch]"
title={dn.order.customer_name ?? ""}
>
{dn.order.customer_name}
</span>
{dn.order.company_name && (
<>
<span aria-hidden>·</span>
<span>{dn.order.company_name}</span>
</>
)}
{dn.order.order_number && (
<>
<span aria-hidden>·</span>
<span className="font-mono">{dn.order.order_number}</span>
</>
)}
</>
) : (
<span className="italic">Counter pickup</span>
)}
{dn.signed_copy && dn.signed_copy.length > 0 && (
<>
<span aria-hidden>·</span>
<span
className="inline-flex items-center gap-1"
title={dn.signed_copy.join(", ")}
>
<Paperclip className="h-3 w-3" aria-hidden />
signed copy
</span>
</>
)}
{dn.delivery_date && (
<>
<span aria-hidden>·</span>
<span>Delivery {dn.delivery_date}</span>
</>
)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
<Badge variant="outline" className="text-[10px]">
{statusLabel}
</Badge>
{canMarkLoaded && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => onMarkLoaded(dn.id, dn.delivery_note_number)}
disabled={busy}
data-testid={`deliveries-dn-mark-loaded-${dn.id}`}
title="Mark the paper as loaded on the truck"
>
<Truck className="mr-1 h-3 w-3" />
{dn.status === "loaded" ? "Re-mark loaded" : "Mark loaded"}
</Button>
)}
{canUploadSignedCopy && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => onSignedCopy(dn.id, dn.delivery_note_number)}
disabled={busy}
data-testid={`deliveries-dn-signed-copy-${dn.id}`}
title="Upload the signed copy of this delivery note and mark it complete"
>
<Pencil className="mr-1 h-3 w-3" />
Signed copy
</Button>
)}
</div>
</div>
{expanded && (
<div
id={itemsPanelId}
data-testid={`deliveries-dn-items-${dn.id}`}
className="ml-9 mr-4 mb-2 rounded-md border border-amber-200/60 bg-muted/30 px-3 py-2 text-xs dark:border-amber-900/40"
>
{dnItems.length === 0 ? (
<div className="flex items-center justify-between gap-2 text-muted-foreground">
<span>No items loaded on this delivery note.</span>
<Link
href={`/delivery-notes/${dn.id}`}
className="font-medium text-primary hover:underline"
>
See full DN →
</Link>
</div>
) : (
<>
<div className="mb-1 flex items-center justify-between text-[11px] text-muted-foreground tabular-nums">
<span>
{dnItems.length} item{dnItems.length === 1 ? "" : "s"}
{totalQty > 0 && ` · Total ${totalQty}${totalSuffix}`}
</span>
</div>
<div className="max-h-72 overflow-y-auto divide-y divide-amber-200/40 dark:divide-amber-900/40">
{dnItems.map((item) => (
<div
key={item.id}
className="flex items-baseline gap-3 py-1.5"
data-testid={`deliveries-dn-item-${item.id}`}
>
<div className="min-w-0 flex-1">
<p
className="truncate"
title={item.description ?? ""}
>
{item.description}
</p>
{hasDifferentOrderItem(item.order ?? null) &&
item.order && (
<p className="text-[10px] text-muted-foreground">
from {item.order}
</p>
)}
</div>
<span className="shrink-0 tabular-nums text-muted-foreground">
Qty {item.quantity ?? 0}
</span>
</div>
))}
</div>
</>
)}
</div>
)}
</div>
);
}
```
**If `useMemo` is not yet imported in the file**, add it to the existing `react` import:
```ts
import { useCallback, useEffect, useMemo, useState } from "react";
```
(`useState` and `useCallback` were used in Step 3.)
**If `Link` is not yet imported**, add:
```ts
import Link from "next/link";
```
(Check with `grep -n "from \"next/link\"" src/app/(app)/deliveries/page.tsx`. The project uses Next 16, so `<Link>` is the standard import.)
**Note on `item.order`**: each item carries `order: string | null` (the order relation id) and `delivery_note: string | null` (the DN relation id). The plan uses `item.order` for the cross-order check; if the project's `DeliveryListResult["items"]` type doesn't expose `order` directly, surface it via a tiny cast at the call site, or add `order: string | null` to the relevant row type. Verify the type exists with `grep -n "order: string" src/lib/server/deliveries-service.ts`.
- [ ] **Step 6: Smoke-check via dev server + agent-browser**
Start the dev server (per project memory, runs on `:4001`):
```bash
npm run dev
```
Open the browser via agent-browser:
```bash
agent-browser open http://localhost:4001/login
```
Log in as David Els (`david.els1@outlook.com` / `1234567890` from `.env.local`):
```bash
agent-browser fill <email-ref> "david.els1@outlook.com"
agent-browser fill <password-ref> "1234567890"
agent-browser click <sign-in-ref>
```
Navigate to `/deliveries`:
```bash
agent-browser open http://localhost:4001/deliveries
agent-browser wait --load networkidle
```
Verify:
- Each DN row has a `▸` chevron at the left edge (before the FileText icon).
- Clicking a chevron toggles it to `▾` and reveals an items panel below the row.
- The items panel shows "{n} items · Total {sumQty}" header + a list of descriptions and qty.
- Counter-pickup DNs (no parent order) expand the same way — their items (if any) appear in the panel.
- A DN with no items on the page shows the empty state: "No items loaded on this delivery note." + a "See full DN →" link.
- Clicking the chevron again collapses the panel.
- The DN row's existing buttons (Mark loaded, Signed copy) still work.
Take a screenshot for the record:
```bash
agent-browser screenshot /tmp/dn-expanded.png
```
Stop the dev server (Ctrl+C) when done.
- [ ] **Step 7: Commit**
```bash
git add "src/app/(app)/deliveries/page.tsx"
git commit -m "feat(deliveries): click-to-expand DN items panel
Adds a chevron button at the left edge of every delivery-note row on
/deliveries. Clicking reveals an inline items panel (description +
qty) so the operator can match against the printed DN paper in their
hand before loading the DN.
Items come from a client-side filter of the page's already-loaded
items array (items.filter(i => i.delivery_note === dn.id)). No new
server endpoint. DNs whose items aren't in the page slice show a
'See full DN →' link to /delivery-notes/[id].
Expand state is held at the page level as a Set<string> of expanded
DN ids — independent per DN, resets on refresh.
Co-Authored-By: Claude Code <noreply@anthropic.com>"
```
---
## Self-review
**Spec coverage check** (against `docs/superpowers/specs/2026-09-01-deliveries-dn-items-expand-design.md`):
| Spec section | Plan task |
|---|---|
| § Interaction: chevron button (lucide ChevronRight/Down) | Step 1 + Step 5 ✓ |
| § Independent state per DN, no persistence | Step 3 + Step 5 (`Set<string>`, page-level) ✓ |
| § Aria attributes (`aria-expanded`, `aria-controls`, `aria-label`) | Step 5 ✓ |
| § Items panel: bordered, tinted neutral background | Step 5 (`border border-amber-200/60 bg-muted/30`) ✓ |
| § Header line "{n} items · Total {sumQty}{unitSuffix}" with unit logic | Step 5 (`totalSuffix` closure) ✓ |
| § Per-item line: description (truncate + title), right-aligned qty | Step 5 ✓ |
| § Cross-order "from ORD-..." line when item.order !== dn.order.order | Step 5 (`hasDifferentOrderItem`) ✓ |
| § Empty state: "No items loaded..." + "See full DN →" link | Step 5 ✓ |
| § max-h-72 overflow-y-auto for many items | Step 5 ✓ |
| § Counter-pickup DNs work the same way | Step 5 (uses `dn.id` filter regardless of `dn.order`) ✓ |
| § Empty-state fallback when items paged off | Step 5 ✓ |
| § Data source: client-side filter, useMemo | Step 5 (`useMemo` + `items.filter`) ✓ |
| § Single file edit | Task 1 ✓ |
| § No new server endpoint / no schema / no MCP | All ✓ |
| § No unit tests required | Plan has no test step (spec explicitly says "No unit tests for the rendering") ✓ |
| § Manual smoke check via agent-browser | Step 6 ✓ |
**Placeholder scan**: No "TBD", "TODO", "implement later". Every step has actual code or commands.
**Type consistency**: `DeliveryListResult["items"]` referenced in Step 5 matches the existing exports from `deliveries-service.ts` (`DeliveryListResult` is an exported type). `dn.id`, `dn.order`, `item.delivery_note`, `item.order`, `item.id`, `item.description`, `item.quantity`, `item.unit` are all standard fields carried by the existing service response. Verified via the spec section that walks through the data.
**Ambiguity check**: One potential ambiguity is in Step 5 — the type `DeliveryListResult["items"]` is referenced; if the file imports `DeliveryListResult` under a different alias, the implementer should use that alias instead. The grep check at the bottom of Step 5 covers the most likely discrepancy (`item.order` shape). No other ambiguities.
All spec requirements are addressed; no gaps.

View file

@ -1,560 +0,0 @@
# Compact DN Row + Order Age Badge Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Two small, independent rendering improvements: enrich each delivery-note row on `/deliveries` with customer / company / order / signed-copy info, and replace the bare date on `/orders` cards with a colour-coded age badge.
**Architecture:** Pure UI/rendering work. One new pure-function util (`src/lib/order-age.ts`) with full TDD; two existing pages (`/deliveries`, `/orders`) get small additive edits. No server changes, no PB schema changes, no MCP tool changes.
**Tech Stack:** Next.js + React 19 (existing). Vitest (existing) for the util test. Tailwind classes for the badge colours (existing).
**Spec:** `docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md`
---
## File structure
| Action | File | Responsibility |
|----------|------|----------------|
| Create | `src/lib/order-age.ts` | Pure: `getOrderAge(created, now?)` + `bucketToBadgeClass(bucket)`. Calendar-day delta in Africa/Johannesburg. |
| Create | `src/lib/order-age.test.ts` | Vitest unit tests for every bucket threshold + future-date clamp + JNB-tz edge case + badge-class stability. |
| Modify | `src/app/(app)/orders/page.tsx` | Add `OrderAgeBadge` inline component + import `getOrderAge` / `bucketToBadgeClass`. Replace `{formatDate(order.created)}` with `<OrderAgeBadge created={order.created} … />` at line ~866. |
| Modify | `src/app/(app)/deliveries/page.tsx` | Edit `DeliveryNoteRowComponent` only (~lines 1533-1547). Replace the subline with the compact pieces list (customer, company, order, paperclip). |
---
## Task 1: Order-age util (TDD)
**Files:**
- Create: `src/lib/order-age.ts`
- Create: `src/lib/order-age.test.ts`
- [ ] **Step 1: Write the failing tests**
Write `src/lib/order-age.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import { getOrderAge, bucketToBadgeClass } from "./order-age";
describe("getOrderAge", () => {
// Anchor `now` so the tests don't drift with wall-clock time.
// 2026-09-01 12:00 in Africa/Johannesburg = 2026-09-01T10:00:00Z
const NOW = new Date("2026-09-01T10:00:00.000Z");
it("returns daysOld=0 with label 'today' when created today", () => {
const result = getOrderAge("2026-09-01T08:00:00.000Z", NOW);
expect(result).toEqual({ daysOld: 0, bucket: "fresh", label: "today" });
});
it("returns daysOld=1 with bucket fresh for 1-day-old orders", () => {
const result = getOrderAge("2026-08-31T08:00:00.000Z", NOW);
expect(result.daysOld).toBe(1);
expect(result.bucket).toBe("fresh");
expect(result.label).toBe("1d old");
});
it("returns daysOld=2 with bucket fresh at the upper edge of fresh", () => {
const result = getOrderAge("2026-08-30T08:00:00.000Z", NOW);
expect(result.daysOld).toBe(2);
expect(result.bucket).toBe("fresh");
expect(result.label).toBe("2d old");
});
it("returns daysOld=3 with bucket recent at the lower edge of recent", () => {
const result = getOrderAge("2026-08-29T08:00:00.000Z", NOW);
expect(result.daysOld).toBe(3);
expect(result.bucket).toBe("recent");
expect(result.label).toBe("3d old");
});
it("returns daysOld=7 with bucket recent at the upper edge of recent", () => {
const result = getOrderAge("2026-08-25T08:00:00.000Z", NOW);
expect(result.daysOld).toBe(7);
expect(result.bucket).toBe("recent");
expect(result.label).toBe("7d old");
});
it("returns daysOld=8 with bucket slipping at the lower edge of slipping", () => {
const result = getOrderAge("2026-08-24T08:00:00.000Z", NOW);
expect(result.daysOld).toBe(8);
expect(result.bucket).toBe("slipping");
expect(result.label).toBe("8d old");
});
it("returns daysOld=14 with bucket slipping at the upper edge of slipping", () => {
const result = getOrderAge("2026-08-18T08:00:00.000Z", NOW);
expect(result.daysOld).toBe(14);
expect(result.bucket).toBe("slipping");
expect(result.label).toBe("14d old");
});
it("returns daysOld=15 with bucket stale at the lower edge of stale", () => {
const result = getOrderAge("2026-08-17T08:00:00.000Z", NOW);
expect(result.daysOld).toBe(15);
expect(result.bucket).toBe("stale");
expect(result.label).toBe("15d old");
});
it("returns daysOld=365 with bucket stale for very old orders", () => {
const result = getOrderAge("2025-09-01T08:00:00.000Z", NOW);
expect(result.daysOld).toBe(365);
expect(result.bucket).toBe("stale");
expect(result.label).toBe("365d old");
});
it("clamps future-dated created to daysOld=0 (clock skew tolerance)", () => {
const result = getOrderAge("2026-09-02T08:00:00.000Z", NOW);
expect(result.daysOld).toBe(0);
expect(result.bucket).toBe("fresh");
expect(result.label).toBe("today");
});
it("computes calendar-day delta in Africa/Johannesburg, not millisecond delta", () => {
// 23:00 SAST on 2026-08-31 (UTC+2) → 21:00 UTC
// 01:00 SAST on 2026-09-01 → 23:00 UTC on 2026-08-31
// Both are in the SAME calendar day in JNB (Sep 1 vs Aug 31).
// Millisecond delta is 2 hours, but the day delta must be 1.
const created = new Date("2026-08-31T21:00:00.000Z"); // 23:00 SAST Aug 31
const now = new Date("2026-08-31T23:00:00.000Z"); // 01:00 SAST Sep 1
const result = getOrderAge(created, now);
expect(result.daysOld).toBe(1);
expect(result.label).toBe("1d old");
});
it("treats an order created at 23:00 yesterday JNB and read at 01:00 today JNB as today", () => {
// Same calendar day in JNB despite the millisecond delta.
const created = new Date("2026-08-31T21:00:00.000Z"); // 23:00 SAST Aug 31
const now = new Date("2026-09-01T09:00:00.000Z"); // 11:00 SAST Sep 1 → 1d old actually
// 11:00 SAST Sep 1 vs 23:00 SAST Aug 31 = next calendar day in JNB.
// For a SAME-day case, swap now to 21:00 SAST Aug 31 = 19:00 UTC Aug 31.
const sameDayNow = new Date("2026-08-31T19:00:00.000Z");
const result = getOrderAge(created, sameDayNow);
expect(result.daysOld).toBe(0);
expect(result.label).toBe("today");
});
});
describe("bucketToBadgeClass", () => {
it("returns a non-empty string for every bucket", () => {
expect(bucketToBadgeClass("fresh").length).toBeGreaterThan(0);
expect(bucketToBadgeClass("recent").length).toBeGreaterThan(0);
expect(bucketToBadgeClass("slipping").length).toBeGreaterThan(0);
expect(bucketToBadgeClass("stale").length).toBeGreaterThan(0);
});
it("is stable — same input returns same output", () => {
expect(bucketToBadgeClass("fresh")).toBe(bucketToBadgeClass("fresh"));
expect(bucketToBadgeClass("recent")).toBe(bucketToBadgeClass("recent"));
expect(bucketToBadgeClass("slipping")).toBe(bucketToBadgeClass("slipping"));
expect(bucketToBadgeClass("stale")).toBe(bucketToBadgeClass("stale"));
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run:
```bash
npx vitest run src/lib/order-age.test.ts
```
Expected: FAIL — `Cannot find module './order-age'` (or similar import error). The util doesn't exist yet.
- [ ] **Step 3: Write the implementation**
Write `src/lib/order-age.ts`:
```ts
/**
* Order-age utility — calendar-day delta + colour-bucket mapping for
* the `/orders` list card age badge. See:
* docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md
*
* Why a separate util: keeps the page component free of date math;
* trivial to unit-test in isolation; pure function (no React, no PB).
*/
export type OrderAgeBucket = "fresh" | "recent" | "slipping" | "stale";
export interface OrderAge {
/** Integer calendar-day delta. 0 means "today". Never negative. */
daysOld: number;
bucket: OrderAgeBucket;
/** Human-readable badge label. */
label: string;
}
/**
* Office timezone — the rest of the app doesn't deal in TZs explicitly,
* but the age badge reads "today" if the order was created on the same
* calendar day in JNB, not if it's been less than 24h since `created`.
* Hardcoded for now; centralise if a second locale ever needs it.
*/
const OFFICE_TZ = "Africa/Johannesburg";
/**
* Format a Date as YYYY-MM-DD in the given IANA timezone.
* Used to compute the calendar-day delta independent of the
* machine's local timezone (vitest runs in UTC by default).
*/
function toLocalDayKey(d: Date, tz: string): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(d);
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const day = parts.find((p) => p.type === "day")!.value;
return `${y}-${m}-${day}`;
}
/**
* Compute the age of an order relative to `now` (defaults to `new
* Date()`). The `daysOld` value is a **calendar-day** delta in the
* office's local timezone (Africa/Johannesburg) — an order created at
* 23:00 JNB yesterday read at 01:00 JNB today is `daysOld = 0`
* ("today"), not 1. The day delta is computed as
* `(localTodayKey - localCreatedKey)` parsed as UTC midnight dates
* and subtracted. Never returns a negative number; future-dated
* `created` values clamp to 0.
*/
export function getOrderAge(
created: string | Date,
now: Date = new Date(),
): OrderAge {
const createdDate = typeof created === "string" ? new Date(created) : created;
const createdKey = toLocalDayKey(createdDate, OFFICE_TZ);
const todayKey = toLocalDayKey(now, OFFICE_TZ);
// Both keys parse as UTC midnight, so subtraction yields whole days.
const createdMs = new Date(`${createdKey}T00:00:00Z`).getTime();
const todayMs = new Date(`${todayKey}T00:00:00Z`).getTime();
const rawDays = Math.round((todayMs - createdMs) / 86_400_000);
const daysOld = rawDays < 0 ? 0 : rawDays;
let bucket: OrderAgeBucket;
if (daysOld <= 2) bucket = "fresh";
else if (daysOld <= 7) bucket = "recent";
else if (daysOld <= 14) bucket = "slipping";
else bucket = "stale";
const label = daysOld === 0 ? "today" : `${daysOld}d old`;
return { daysOld, bucket, label };
}
/**
* Map an age bucket to a tailwind className for the badge. Initial
* palette per the spec; centralise here so a future palette tweak is
* a one-line change.
*/
export function bucketToBadgeClass(bucket: OrderAgeBucket): string {
switch (bucket) {
case "fresh":
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300";
case "recent":
return "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300";
case "slipping":
return "bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300";
case "stale":
return "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300";
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run:
```bash
npx vitest run src/lib/order-age.test.ts
```
Expected: all tests pass. 14 tests total (12 in `getOrderAge` describe, 2 in `bucketToBadgeClass` describe).
- [ ] **Step 5: Commit**
```bash
git add src/lib/order-age.ts src/lib/order-age.test.ts
git commit -m "feat(orders): add order-age util with bucket thresholds
Pure function: getOrderAge(created, now?) returns {daysOld, bucket, label}
in calendar-day delta for Africa/Johannesburg. bucketToBadgeClass maps
to tailwind classes (fresh/recent/slipping/stale). No React, no DB.
Vitest unit tests cover all 5 bucket thresholds, future-date clamp, and
the JNB timezone edge case (same calendar day in JNB = daysOld 0).
Co-Authored-By: Claude Code <noreply@anthropic.com>"
```
---
## Task 2: Wire OrderAgeBadge into the orders page
**Files:**
- Modify: `src/app/(app)/orders/page.tsx`
- [ ] **Step 1: Add the imports**
In `src/app/(app)/orders/page.tsx`, add the new imports at the end of the existing `import { Badge } from "@/components/ui/badge"` block (line 8):
```ts
import { getOrderAge, bucketToBadgeClass } from "@/lib/order-age";
```
Add `cn` to the `@/lib/utils` import (currently `displayContactName` on line 27). If `cn` is not already exported by `@/lib/utils`, leave the import alone and use template-literal class strings instead — but check first. The util almost certainly exports `cn` (used everywhere in the codebase). If present:
```ts
import { cn, displayContactName } from "@/lib/utils";
```
- [ ] **Step 2: Add the OrderAgeBadge component**
In `src/app/(app)/orders/page.tsx`, immediately above the existing `formatDate` function (line 90), add:
```tsx
/**
* Inline age badge for the order card metadata slot. Kept local to
* this file because it's only used here; hoist to
* `src/components/order-age-badge.tsx` if a second page needs it.
*
* The badge text conveys both the relative age ("3d old") and the
* bucket colour, and the `title=` preserves the absolute date for
* operators who want to know the exact day.
*/
function OrderAgeBadge({
created,
className,
}: {
created: string;
className?: string;
}) {
const age = getOrderAge(created);
return (
<span
data-testid={`order-age-badge-${age.bucket}`}
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium tabular-nums",
bucketToBadgeClass(age.bucket),
className,
)}
title={`Created ${formatDate(created)}`}
>
{age.label}
</span>
);
}
```
(If `cn` is not available from `@/lib/utils`, replace `cn(...)` with a template literal: `` `rounded-full px-2 py-0.5 text-[10px] font-medium tabular-nums ${bucketToBadgeClass(age.bucket)}${className ? ` ${className}` : ""}` ``.)
- [ ] **Step 3: Replace the bare date with the badge**
In `src/app/(app)/orders/page.tsx`, locate line ~865-867:
```tsx
<span className="ml-auto text-xs text-muted-foreground hidden sm:inline">
{formatDate(order.created)}
</span>
```
Replace with:
```tsx
<OrderAgeBadge
created={order.created}
className="ml-auto hidden sm:inline-flex"
/>
```
- [ ] **Step 4: Smoke-check in the dev server**
Start the dev server (per CLAUDE.md / project memory: `http://localhost:4001`):
```bash
npm run dev
```
Open `http://localhost:4001/orders`. Verify:
- A recently-created test order (e.g. `ORD-2026-08-058` from the
previous walkthrough — created ~30 min ago) shows a green `today`
badge in the right-aligned metadata slot where the date used to live.
- Hover the badge — tooltip shows `Created <formatted date>`.
- Order card layout is otherwise unchanged.
Stop the dev server when done (Ctrl+C).
- [ ] **Step 5: Commit**
```bash
git add "src/app/(app)/orders/page.tsx"
git commit -m "feat(orders): surface order age as colour-coded badge
Replaces the bare formatDate(order.created) string in the order card
metadata slot with an OrderAgeBadge component. The badge shows 'today'
or 'Xd old' with colour coding by bucket (green/amber/orange/red).
The absolute date is preserved in the badge's title= tooltip.
Bucket math lives in the new src/lib/order-age.ts util (covered by
unit tests). This change is rendering-only — no server or schema impact.
Co-Authored-By: Claude Code <noreply@anthropic.com>"
```
---
## Task 3: Compact info on delivery-note rows
**Files:**
- Modify: `src/app/(app)/deliveries/page.tsx` (only `DeliveryNoteRowComponent`, lines ~1475-1588)
- [ ] **Step 1: Add the Paperclip import**
In `src/app/(app)/deliveries/page.tsx`, the lucide-react import already includes `Pencil`, `Truck`, `User`. Add `Paperclip`:
Locate the lucide-react import (around lines 86-94) and add `Paperclip` to the named-imports list:
```ts
import {
CheckSquare,
FileText,
Loader2,
PackageOpen,
Paperclip, // ← add this
Pencil,
Square,
Truck,
User,
} from "lucide-react";
```
- [ ] **Step 2: Replace the subline with the compact pieces list**
In `src/app/(app)/deliveries/page.tsx`, locate `DeliveryNoteRowComponent` (lines ~1475-1588). Find the `<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground tabular-nums">…</p>` block (around line 1537-1547). Replace its **children** (the `{dn.delivery_date && …}` + `<span className={…}>{subline}</span>` content) with the following compact pieces list:
```tsx
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground tabular-nums">
{dn.order ? (
<>
<span className="truncate max-w-[40ch]" title={dn.order.customer_name ?? ""}>
{dn.order.customer_name}
</span>
{dn.order.company_name && (
<>
<span aria-hidden>·</span>
<span>{dn.order.company_name}</span>
</>
)}
{dn.order.order_number && (
<>
<span aria-hidden>·</span>
<span className="font-mono">{dn.order.order_number}</span>
</>
)}
</>
) : (
<span className="italic">Counter pickup</span>
)}
{dn.signed_copy && dn.signed_copy.length > 0 && (
<>
<span aria-hidden>·</span>
<span
className="inline-flex items-center gap-1"
title={dn.signed_copy.join(", ")}
>
<Paperclip className="h-3 w-3" aria-hidden />
signed copy
</span>
</>
)}
{dn.delivery_date && (
<>
<span aria-hidden>·</span>
<span>Delivery {dn.delivery_date}</span>
</>
)}
</p>
```
This keeps the existing `text-muted-foreground` / `tabular-nums` styling and adds: customer name (truncated, with title tooltip), optional company, optional order number, optional signed-copy indicator, optional delivery date. Counter-pickup DNs (`dn.order === null`) collapse to a single "Counter pickup" label per the spec.
Leave the rest of `DeliveryNoteRowComponent` (the `<FileText />` icon, the title row with `DN {n}`, the status badge column, the two buttons) untouched.
- [ ] **Step 3: Smoke-check in the dev server**
Start the dev server:
```bash
npm run dev
```
Open `http://localhost:4001/deliveries`. Verify on the To Load tab:
- A DN with an order attached renders the customer name + company + order number under the DN number, joined by `·`.
- A counter-pickup DN (no order) shows "Counter pickup" in the same slot.
- A DN with a signed copy uploaded shows a paperclip icon + "signed copy" text.
- Hovering the paperclip reveals the filenames.
- Layout is otherwise unchanged — buttons still on the right, status badge still on top.
Stop the dev server (Ctrl+C).
- [ ] **Step 4: Commit**
```bash
git add "src/app/(app)/deliveries/page.tsx"
git commit -m "feat(deliveries): enrich DN row subline with customer/order/paperclip
The /deliveries To Load + Back from Driver + Checked In tabs each
render delivery-note rows showing only 'DN {number}' + a one-line
status subline. Operators couldn't identify the customer or tell
whether the signed copy was back without scrolling up to the
customer/order group header.
Replace the existing subline with a compact pieces list:
customer name (truncated, with title tooltip) · company name ·
parent order number · signed-copy indicator (paperclip icon +
filenames in title=) · delivery date (if set). Counter-pickup DNs
(no parent order) collapse to 'Counter pickup'.
All data is already on the row payload from /api/v1/deliveries —
no server or schema changes.
Co-Authored-By: Claude Code <noreply@anthropic.com>"
```
---
## Self-review
**Spec coverage check (against `docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md`):**
| Spec section | Plan task |
|---|---|
| § Feature 1: Compact info on delivery-note rows — 4 pieces (customer, company, order, signed-copy indicator) + edge cases | Task 3 ✓ |
| § Feature 1: Counter-pickup fallback ("Counter pickup") | Task 3 Step 2 ✓ |
| § Feature 1: Signed-copy indicator (paperclip + filename tooltip) | Task 3 Step 2 ✓ |
| § Feature 1: Long customer name truncation | Task 3 Step 2 (`max-w-[40ch]` + title) ✓ |
| § Feature 1: No new network request | Task 3 only edits rendering, no fetch added ✓ |
| § Feature 2: New `src/lib/order-age.ts` with `getOrderAge` + `bucketToBadgeClass` | Task 1 ✓ |
| § Feature 2: Bucket thresholds (0/1-2/3-7/8-14/≥15) | Task 1 Step 1 (tests cover all edges) ✓ |
| § Feature 2: Calendar-day delta in Africa/Johannesburg | Task 1 Step 1 (timezone test) + Step 3 (`OFFICE_TZ` + `toLocalDayKey`) ✓ |
| § Feature 2: Future-date clamp | Task 1 Step 1 (clamp test) + Step 3 (`rawDays < 0 ? 0 : rawDays`) ✓ |
| § Feature 2: Tailwind classes (4 buckets, dark variants) | Task 1 Step 3 (`bucketToBadgeClass`) ✓ |
| § Feature 2: Unit tests for all bucket thresholds | Task 1 Step 1 ✓ |
| § Feature 2: JNB-tz edge case test (same calendar day = daysOld 0) | Task 1 Step 1 ✓ |
| § Feature 2: `bucketToBadgeClass` stability test | Task 1 Step 1 ✓ |
| § Feature 2: `OrderAgeBadge` inline component | Task 2 Step 2 ✓ |
| § Feature 2: Replace bare date with badge, preserving `title=` for absolute date | Task 2 Step 3 ✓ |
| § Feature 2: Existing `formatDate` import kept on page | Task 2 Step 3 (uses local `formatDate`) ✓ |
| § What's NOT in scope: no server changes, no schema, no MCP, no other pages | All tasks comply ✓ |
**Placeholder scan:** No "TBD" / "TODO" / "implement later" / "fill in details". All code blocks are complete. The one hedge (`if cn is not available…`) is an explicit fallback instruction for a real ambiguity, not a placeholder.
**Type consistency:** `OrderAgeBucket`, `OrderAge`, `getOrderAge`, `bucketToBadgeClass` are defined and used with matching signatures across Tasks 1 and 2. `OrderAgeBadge` props match its usage at Task 2 Step 3.
**Ambiguity check:** None found. The `cn`-available vs. not-available branch is the only conditional, and the plan gives an explicit fallback.
All spec requirements are addressed; no gaps.

File diff suppressed because it is too large Load diff

View file

@ -1,58 +0,0 @@
# Rep-Scoped List Filter — Implementation Summary
> Companion to [`2026-08-31-rep-scoped-list-filter.md`](./2026-08-31-rep-scoped-list-filter.md). Records what landed, how it was verified, and what still needs manual browser testing.
## Result
All 6 plan tasks complete. Pushed to `origin/main` on 2026-09-01.
## Commits (newest first)
| SHA | Message |
|-----|---------|
| `9cf8e39` | test(invoicing): mock useAuth to keep rep-filter a no-op in tests |
| `ed07617` | feat(delivery-notes): filter list to the logged-in rep's own notes |
| `29eb003` | feat(invoicing): filter rows to the logged-in rep's own orders |
| `8faa443` | feat(quotes): filter list to the logged-in rep's own quotes |
| `defa481` | feat(orders): filter list to the logged-in rep's own orders |
| `d23c811` | feat: add isRepUser helper for rep-scoped list filtering |
## Files changed
- **New:** `src/lib/rep-visibility.ts` — `isRepUser(userType)` + `REP_USER_TYPE` constant, mirrors the `isRep` pattern in `src/lib/server/tv-dashboard-service.ts`
- **New:** `src/lib/rep-visibility.test.ts` — 6 unit tests covering: REP present, case-insensitive, mixed roles, other roles, null/undefined/non-array, empty array
- **Modified:** `src/app/(app)/orders/page.tsx` — `useAuth` + `visibleOrders` `useMemo` filtering on `order.rep` / `order.expand?.customer?.rep`. Replaces `orders.length` / `orders.map` with `visibleOrders.length` / `visibleOrders.map` at the 4 render sites listed in the plan (kept the `orders` state, `fetchOrders`, and realtime subscription untouched)
- **Modified:** `src/app/(app)/quotes/page.tsx` — `useAuth` + `visibleQuotes` `useMemo` filtering on `quote.expand?.customer?.rep`. `DataTable.data` and the live search-count chip read from `visibleQuotes`
- **Modified:** `src/app/(app)/invoicing/page.tsx` — `useAuth` + new `repId` param on `loadInvoicingRows`. PB filter clause `&& order.customer.rep = "<id>"` added to the seed fetch; sibling fetch is implicitly scoped because `readyOrderIds` derives from the already-scoped seed rows
- **Modified:** `src/lib/pb-helpers.ts` — `getDeliveryNotes` gained an optional `repId: string | null = null` param; when supplied, adds a `customer.rep = "<id>"` clause to the existing filter chain
- **Modified:** `src/app/(app)/delivery-notes/page.tsx` — `useAuth` + computes `repId` in `fetchNotes`, passes to `getDeliveryNotes`
- **Modified:** `src/app/(app)/invoicing/page.test.tsx` — added `vi.mock("@/components/auth-provider", ...)` returning a stable non-REP user via `vi.hoisted` (otherwise the page's `load` re-fires every render and breaks the `toHaveBeenCalledTimes(2)` assertions)
## Verification done
- `npx vitest run src/lib/rep-visibility.test.ts` — 6/6 pass
- `npx vitest run` (full suite) — 2441 pass / 15 fail. The same 15 failures occur on `origin/main`'s tree (verified by `git checkout origin/main -- <files>` then re-running). None are new regressions.
- `npx tsc --noEmit` — zero errors in any touched file
- Plan Task 2-5 "manual verification" steps (login as non-rep, login as rep, login as two reps) are **browser tests** and were **not** run from this terminal. See "Still to do" below.
## Deviations from the plan
1. **Orders page `visibleOrders` placement.** Plan said to add the `useMemo` after `otherTabCounts` (line 298), but the plan also changed line 282's `const totalLoaded = orders.length` to `const totalLoaded = visibleOrders.length` — which would forward-reference `visibleOrders`. Moved the `useMemo` to before `totalLoaded` instead. Functionally identical, no behaviour change.
2. **Invoicing test mock.** The plan did not anticipate that adding `useAuth` to a page would break a test that doesn't wrap with `AuthProvider`. Added the `vi.mock` in `page.test.tsx` (test fix, separate commit `9cf8e39`). The mock returns a non-REP user so the filter is a no-op for existing test data (which doesn't carry a `customer.rep` field).
## Still to do (manual browser verification)
- [ ] Log in as a non-rep user (`user_type` does NOT include `REP`) → confirm `/orders`, `/quotes`, `/invoicing`, `/delivery-notes` show the same rows as before this change
- [ ] Log in as a rep user tied to ≥1 customer → confirm `/orders`, `/quotes`, `/invoicing`, `/delivery-notes` only show that rep's rows
- [ ] If two rep test accounts are available, confirm rep A never sees rep B's rows (and vice versa) across all four pages
- [ ] Tab-switching, search, and "New Order" flow on Orders — these operate on the `orders` state, not the filtered `visibleOrders`, so they should still work; confirm in browser
## Known limitations (from the plan, accepted for this pass)
- On **Orders** and **Quotes**, tab-count badges and pagination totals (`totalPages`, `counts`) are derived from the unfiltered dataset — a rep filtering may see a page with fewer than `perPage` visible rows, or a "Page 1 of N" that doesn't match the filtered count. **Invoicing** and **Delivery Notes** don't have this problem because their filter is a real PB query, so their counts are accurate.
- Proper fix is threading `rep` through `orders-service.ts` / `quotes-service.ts`'s PB filters — separate, larger follow-up.
## Pre-existing items not touched
- `src/app/(auth)/login/page.tsx` — was already modified before this work started; left as-is.
- `docs/superpowers/plans/2026-08-31-rep-scoped-list-filter.md` — the plan file itself; remains untracked.

View file

@ -1,831 +0,0 @@
# Network (Ethernet) Thermal Printer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add support for printing sale receipts and delivery notes to a network (Ethernet) Epson TM-T20II printer from the `public/pos` PWA, alongside the existing Web Bluetooth path, working on both Android and iOS.
**Architecture:** The printer exposes Epson's ePOS-Print XML API over **HTTPS** (confirmed live against the shop's TM-T20II at `192.168.1.33` — see verification notes below), with an open CORS header (`Access-Control-Allow-Origin: *`). Because both the PWA and the printer are HTTPS, calling the printer directly from the browser hits neither mixed-content blocking nor Safari's lack of Web Bluetooth support — no native wrapper is needed. A new `public/pos/epos-print.js` module builds the ePOS-Print XML (mirroring the existing `buildEscPos()` byte-builder in `receipt.html`) and POSTs it with `fetch()`. `receipt.html` gains a printer-type setting (Bluetooth vs. Network) persisted in `localStorage`, and `printThermal()`/`printDelivery()` branch on it.
**Tech Stack:** Vanilla JS (no build step — this is a static PWA), `fetch`, `<canvas>` for logo rasterization, Epson ePOS-Print XML.
**Verified findings this plan relies on (from live testing against the shop printer):**
- `http://192.168.1.33/` 301-redirects to `https://192.168.1.33/` (self-signed cert).
- `POST https://192.168.1.33/cgi-bin/epos/service.cgi?devid=local_printer&timeout=10000` with an ePOS-Print SOAP XML body returns `HTTP 200` and `<response success="true" .../>`, and a real slip printed with text, item list, subtotal/VAT/total, and the WFDASH logo image.
- Raw ESC/POS bytes also print successfully over plain TCP to port 9100 (confirms the byte-building logic already in `buildEscPos()` is sound, though this plan uses the HTTPS path since browsers can't open raw sockets).
- The response's `success="true"` was observed once without a physical printout (cause not fully isolated — possibly a transient state on the printer). Task 10 below re-verifies each XML feature individually against the real printer before calling this done.
- Each till device (iOS or Android) must visit `https://192.168.1.33` once in its browser and accept the self-signed certificate warning before `fetch()` calls from the PWA will succeed silently.
---
### Task 1: Fix the pre-existing `const d` redeclaration bug
**Files:**
- Modify: `public/pos/receipt.html:535-591` (the `buildEscPos` function)
This is a blocking bug unrelated to network printing, but it sits inside the exact function this plan extends, and it currently breaks the entire inline `<script>` block (lines 319-657) — meaning `renderReceipt`, `printThermal`, and everything else on this page silently fails to run today.
- [ ] **Step 1: Confirm the bug**
Run: `grep -n "const d" public/pos/receipt.html`
Expected output includes both:
```
552: const d = new Date(s.createdAt);
565: const d = s.discount || { kind: 'none', input: 0, amount: 0 };
```
Both are in the same function scope (`buildEscPos`, no intervening block) — this is a `SyntaxError: Identifier 'd' has already been declared`.
- [ ] **Step 2: Rename the second declaration**
In `public/pos/receipt.html`, inside `buildEscPos(s){...}`, change:
```js
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
const vat = s.vat != null ? s.vat : (s.total * 15/115);
const d = s.discount || { kind: 'none', input: 0, amount: 0 };
const hasDiscount = d.kind !== 'none' && Number(d.amount) > 0;
if (hasDiscount) {
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price)||0) * (Number(it.qty)||0), 0);
const grossSub = gross / 1.15;
const grossVat = gross - grossSub;
cmds.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`);
cmds.push(`VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}\n`);
cmds.push(`Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`);
cmds.push(`Discount${d.kind==='percent' ? ` (${d.input}%)` : ''}${' '.repeat(15)}- R ${d.amount.toFixed(2)}\n`);
}
```
to:
```js
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
const vat = s.vat != null ? s.vat : (s.total * 15/115);
const discount = s.discount || { kind: 'none', input: 0, amount: 0 };
const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0;
if (hasDiscount) {
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price)||0) * (Number(it.qty)||0), 0);
const grossSub = gross / 1.15;
const grossVat = gross - grossSub;
cmds.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`);
cmds.push(`VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}\n`);
cmds.push(`Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`);
cmds.push(`Discount${discount.kind==='percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}\n`);
}
```
- [ ] **Step 3: Verify the script now parses**
Run: `node --check <(sed -n '319,657p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
(This extracts just the inline script body and checks it as a standalone JS file.) Expected: no output, exit code 0. If Node reports a different syntax error, fix it before continuing — do not proceed to Task 2 with a script that doesn't parse.
- [ ] **Step 4: Commit**
```bash
git add public/pos/receipt.html
git commit -m "fix(pos): resolve duplicate const d declaration breaking receipt.html script"
```
---
### Task 2: Create `epos-print.js` with printer settings persistence
**Files:**
- Create: `public/pos/epos-print.js`
- [ ] **Step 1: Create the file with settings get/set**
```js
// Network (ePOS-Print) thermal printing for Epson TM-series Ethernet
// printers. Runs alongside the existing Web Bluetooth path in
// receipt.html — the till picks a transport in Printer settings.
//
// The printer's ePOS-Print endpoint is served over HTTPS with a
// self-signed cert and open CORS (`Access-Control-Allow-Origin: *`),
// confirmed by direct testing against the shop's TM-T20II. Each till
// device must visit `https://<printer-ip>` once in its browser and
// accept the certificate warning before fetch() calls here succeed.
window.EposPrint = (function () {
'use strict';
const SETTINGS_KEY = 'pos.printerSettings';
function getSettings() {
try {
return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || { type: 'bluetooth', ip: '' };
} catch (e) {
return { type: 'bluetooth', ip: '' };
}
}
function setSettings(settings) {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
return { getSettings, setSettings };
})();
```
- [ ] **Step 2: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 3: Commit**
```bash
git add public/pos/epos-print.js
git commit -m "feat(pos): add printer settings persistence for network printing"
```
---
### Task 3: Add logo rasterization to `epos-print.js`
**Files:**
- Modify: `public/pos/epos-print.js`
The ePOS-Print `<image>` tag needs a packed 1-bit-per-pixel bitmap, MSB-first, each row padded to a whole byte, where bit `1` means "print a dot". This matches the format manually verified against the real printer during testing (Python/Pillow prototype), reimplemented here with `<canvas>` since the PWA has no build step to run Python.
- [ ] **Step 1: Add `imageToRaster` inside the IIFE, before the `return` statement**
```js
// Rasterize a same-origin image into the packed 1bpp bitmap the
// ePOS-Print <image> tag expects. targetWidth must be a multiple of
// 8; 384 is the width verified against the shop's TM-T20II.
function imageToRaster(url, targetWidth) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const targetHeight = Math.round(img.height * targetWidth / img.width);
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, targetWidth, targetHeight);
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
let imageData;
try {
imageData = ctx.getImageData(0, 0, targetWidth, targetHeight);
} catch (e) {
reject(new Error('Could not read logo pixels (cross-origin image?): ' + e.message));
return;
}
const data = imageData.data;
const rowBytes = Math.ceil(targetWidth / 8);
const packed = new Uint8Array(rowBytes * targetHeight);
for (let y = 0; y < targetHeight; y++) {
for (let x = 0; x < targetWidth; x++) {
const i = (y * targetWidth + x) * 4;
const luminance = data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11;
const alpha = data[i + 3];
if (alpha > 128 && luminance < 128) {
packed[y * rowBytes + (x >> 3)] |= 0x80 >> (x % 8);
}
}
}
let binary = '';
for (let i = 0; i < packed.length; i++) binary += String.fromCharCode(packed[i]);
resolve({ width: targetWidth, height: targetHeight, base64: btoa(binary) });
};
img.onerror = () => reject(new Error('Could not load logo image: ' + url));
img.src = url;
});
}
```
- [ ] **Step 2: Export it from the module**
Change the `return` statement at the end of the IIFE from:
```js
return { getSettings, setSettings };
```
to:
```js
return { getSettings, setSettings, imageToRaster };
```
- [ ] **Step 3: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 4: Manual browser check**
Serve `public/` locally (e.g. `npx serve public` or via the app's own dev server) and open `public/pos/index.html`'s console, then run:
```js
EposPrint.imageToRaster('/wfdash-logo.png', 384).then(r => console.log(r.width, r.height, r.base64.length))
```
Expected: logs `384 <some-height> <some-length>` with no thrown error.
- [ ] **Step 5: Commit**
```bash
git add public/pos/epos-print.js
git commit -m "feat(pos): add canvas-based logo rasterization for ePOS-Print"
```
---
### Task 4: Add ePOS-Print XML builder for sale receipts
**Files:**
- Modify: `public/pos/epos-print.js`
This mirrors `buildEscPos()` in `receipt.html:535-591` line-for-line in content (same fields, same discount handling, same layout), just emitting ePOS-Print XML tags instead of raw ESC/POS bytes.
- [ ] **Step 1: Add `xmlEscape` and `buildEposXml` inside the IIFE, before the `return` statement**
```js
function xmlEscape(s) {
return String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
}
// Mirrors buildEscPos() in receipt.html — same fields, same discount
// math — but emits ePOS-Print XML tags instead of raw ESC/POS bytes.
function buildEposXml(s, logo) {
const parts = [];
if (logo) parts.push(`<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>`);
parts.push('<feed line="1"/>');
parts.push('<text align="center" width="2" height="2">WFSALES POS&#10;</text>');
if (s.letterhead && s.letterhead.name) {
parts.push(`<text align="center">${xmlEscape(s.letterhead.name)}&#10;</text>`);
}
const addrLines = [];
if (s.letterhead && s.letterhead.address) addrLines.push(xmlEscape(s.letterhead.address.split('\n')[0]));
if (s.letterhead && s.letterhead.phone) addrLines.push('Tel: ' + xmlEscape(s.letterhead.phone));
if (s.letterhead && s.letterhead.vat_number) addrLines.push('VAT: ' + xmlEscape(s.letterhead.vat_number));
if (addrLines.length) parts.push(`<text align="center">${addrLines.join('&#10;')}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const d = new Date(s.createdAt);
parts.push(`<text align="left">Date: ${xmlEscape(d.toLocaleString('en-ZA'))}&#10;Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())}&#10;</text>`);
if (s.customer) parts.push(`<text align="left">Customer: ${xmlEscape(s.customer.name)}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const itemLines = s.items.map(it => {
const priceLine = ` ${it.qty} x ${(it.selling_price || 0).toFixed(2)} ${(it.selling_price * it.qty).toFixed(2)}`;
return `${xmlEscape(it.description || it.sku)}&#10;${xmlEscape(priceLine)}&#10;`;
}).join('');
parts.push(`<text align="left">${itemLines}</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const sub = s.sub != null ? s.sub : (s.total - (s.vat || 0));
const vat = s.vat != null ? s.vat : (s.total * 15 / 115);
const discount = s.discount || { kind: 'none', input: 0, amount: 0 };
const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0;
if (hasDiscount) {
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price) || 0) * (Number(it.qty) || 0), 0);
const grossSub = gross / 1.15;
const grossVat = gross - grossSub;
parts.push(`<text align="left">Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}&#10;VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}&#10;Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}&#10;Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}&#10;</text>`);
}
parts.push(`<text align="left">Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}&#10;VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}&#10;</text>`);
parts.push(`<text align="left" em="true">TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}&#10;</text>`);
const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase();
parts.push(`<text align="left">Paid: ${xmlEscape(paidLabel)}&#10;</text>`);
if (s.payment === 'cash') parts.push(`<text align="left">Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
if (s.letterhead && s.letterhead.footer_text) parts.push(`<text align="center">${xmlEscape(s.letterhead.footer_text)}&#10;</text>`);
parts.push('<feed line="3"/>');
parts.push('<cut type="feed"/>');
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${parts.join('')}</epos-print></s:Body></s:Envelope>`;
}
```
- [ ] **Step 2: Export it from the module**
Change the `return` statement to:
```js
return { getSettings, setSettings, imageToRaster, buildEposXml };
```
- [ ] **Step 3: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 4: Manual browser check with a fake sale**
In the browser console (same page as Task 3's check):
```js
const fakeSale = {
createdAt: new Date().toISOString(),
localId: 'test0001',
payment: 'cash', cashGiven: 300, change: 5.51,
items: [{ description: 'Widget A', qty: 2, selling_price: 45 }],
sub: 78.26, vat: 11.74, total: 90,
letterhead: { name: 'WFDASH POS', address: '123 Shop Street', phone: '021 555 0123' }
};
console.log(EposPrint.buildEposXml(fakeSale, null));
```
Expected: a full XML string starting with `<?xml version="1.0"...` and containing `<epos-print`, item text, and `<cut type="feed"/>`, no thrown error.
- [ ] **Step 5: Commit**
```bash
git add public/pos/epos-print.js
git commit -m "feat(pos): add ePOS-Print XML builder for sale receipts"
```
---
### Task 5: Add the network transport (`printViaNetwork`)
**Files:**
- Modify: `public/pos/epos-print.js`
- [ ] **Step 1: Add `printViaNetwork` inside the IIFE, before the `return` statement**
```js
async function printViaNetwork(ip, xml) {
const res = await fetch(`https://${ip}/cgi-bin/epos/service.cgi?devid=local_printer&timeout=10000`, {
method: 'POST',
headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '""' },
body: xml
});
if (!res.ok) throw new Error('Printer HTTP error: ' + res.status);
const text = await res.text();
const doc = new DOMParser().parseFromString(text, 'text/xml');
const response = doc.getElementsByTagName('response')[0];
const success = response && response.getAttribute('success') === 'true';
if (!success) {
const code = response ? response.getAttribute('code') : null;
throw new Error('Printer rejected job' + (code ? ' (code: ' + code + ')' : ''));
}
}
```
- [ ] **Step 2: Export it from the module**
Change the `return` statement to:
```js
return { getSettings, setSettings, imageToRaster, buildEposXml, printViaNetwork };
```
- [ ] **Step 3: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 4: Manual test against the real printer**
With a device on the shop wifi that has already visited `https://192.168.1.33` and trusted the cert, open the browser console on any page served from the app (must be same-origin `fetch`, no special config needed since the printer sends `Access-Control-Allow-Origin: *`) and run:
```js
EposPrint.printViaNetwork('192.168.1.33', EposPrint.buildEposXml(fakeSale, null)).then(() => console.log('OK')).catch(e => console.error(e));
```
(reuse `fakeSale` from Task 4's check). Expected: logs `OK`, and a real slip prints on the TM-T20II with the fake sale's items and totals.
- [ ] **Step 5: Commit**
```bash
git add public/pos/epos-print.js
git commit -m "feat(pos): send ePOS-Print jobs over HTTPS to network printers"
```
---
### Task 6: Add printer settings UI to `receipt.html`
**Files:**
- Modify: `public/pos/receipt.html`
- [ ] **Step 1: Add CSS for the settings block**
In `public/pos/receipt.html`, find the `.status` rule block (around line 134):
```css
.status{padding:12px 14px;border-radius:12px;background:var(--surface);border:1px solid var(--border);font-size:13px;display:flex;align-items:center;gap:10px;color:var(--fg);line-height:1.4}
```
Add immediately after it:
```css
.printer-settings{padding:12px 14px;border-radius:12px;border:1px solid var(--border);background:var(--surface-card);display:flex;flex-direction:column;gap:8px}
.printer-settings label{font-size:12px;font-weight:600;color:var(--ink-soft);display:flex;flex-direction:column;gap:4px}
.printer-settings select,.printer-settings input{padding:9px 10px;border-radius:8px;border:1px solid var(--muted);background:var(--bg);color:var(--fg);font:inherit}
```
- [ ] **Step 2: Add the settings toggle row and form to the markup**
In `public/pos/receipt.html`, find (around line 295-298):
```html
<div class="status" id="status">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg></span>
<span class="t" id="statusText">Ready to print.</span>
</div>
```
Add immediately after it:
```html
<div class="status" id="printerSettingsToggle" style="cursor:pointer" onclick="togglePrinterSettings()">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h18v6H3zM7 13v6h10v-6M7 17h10"/></svg></span>
<span class="t" id="printerSettingsLabel">Printer: Bluetooth</span>
</div>
<div class="printer-settings" id="printerSettingsForm" style="display:none">
<label>Printer type
<select id="printerTypeSelect">
<option value="bluetooth">Bluetooth</option>
<option value="network">Network (Ethernet)</option>
</select>
</label>
<label>Printer IP address
<input id="printerIpInput" placeholder="192.168.1.33">
</label>
<button class="btn" style="min-height:40px" onclick="savePrinterSettings()">Save printer settings</button>
</div>
```
- [ ] **Step 3: Add `epos-print.js` script tag**
Find (around line 317):
```html
<script src="pos-config.js"></script>
<script src="theme.js"></script>
```
Change to:
```html
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="epos-print.js"></script>
```
- [ ] **Step 4: Add the settings toggle/save functions and init call**
Inside the main inline `<script>` IIFE in `receipt.html`, immediately after the `paintNet();` call near the end of the file (currently the last line before `})();`), add — but call it earlier, right after the existing `paintNet();` line at the bottom (around line 655):
Find:
```js
paintNet();
})();
```
Change to:
```js
function togglePrinterSettings(){
const form = document.getElementById('printerSettingsForm');
form.style.display = form.style.display === 'none' ? 'flex' : 'none';
}
function savePrinterSettings(){
const type = document.getElementById('printerTypeSelect').value;
const ip = document.getElementById('printerIpInput').value.trim();
window.EposPrint.setSettings({ type, ip });
paintPrinterSettings();
document.getElementById('printerSettingsForm').style.display = 'none';
}
function paintPrinterSettings(){
const settings = window.EposPrint.getSettings();
document.getElementById('printerTypeSelect').value = settings.type;
document.getElementById('printerIpInput').value = settings.ip || '';
document.getElementById('printerSettingsLabel').textContent =
'Printer: ' + (settings.type === 'network' ? ('Network' + (settings.ip ? ' (' + settings.ip + ')' : '')) : 'Bluetooth');
}
paintPrinterSettings();
paintNet();
})();
```
- [ ] **Step 5: Verify the script still parses**
Run: `node --check <(sed -n '319,700p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
Expected: no output, exit code 0. (Adjust the line range in the `sed -n` command to match wherever the inline `<script>...</script>` block now starts and ends after your edits.)
- [ ] **Step 6: Manual browser check**
Open `receipt.html` in a browser (with a `pos.lastSale` value in `sessionStorage`, or just check that the page doesn't error in the console). Click the new "Printer: Bluetooth" row — the settings form should expand. Switch the select to "Network (Ethernet)", type `192.168.1.33`, click "Save printer settings" — the row should update to read "Printer: Network (192.168.1.33)" and the value should persist across a page reload (check via `localStorage.getItem('pos.printerSettings')` in the console).
- [ ] **Step 7: Commit**
```bash
git add public/pos/receipt.html
git commit -m "feat(pos): add printer type/IP settings UI to receipt page"
```
---
### Task 7: Wire `printThermal()` to the network path
**Files:**
- Modify: `public/pos/receipt.html`
- [ ] **Step 1: Rename the existing Bluetooth flow**
Find (around line 487):
```js
async function printThermal(){
const btn = document.getElementById('printBtn');
```
Change the function name only:
```js
async function printThermalBluetooth(){
const btn = document.getElementById('printBtn');
```
(Leave the rest of that function's body untouched.)
- [ ] **Step 2: Add the network variant and the dispatcher**
Immediately before the (now renamed) `printThermalBluetooth` function, add:
```js
async function printThermal(){
const settings = window.EposPrint.getSettings();
if(settings.type === 'network') return printThermalNetwork(settings);
return printThermalBluetooth();
}
async function printThermalNetwork(settings){
const btn = document.getElementById('printBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
if(!settings.ip){
status.textContent = 'Set the printer IP address in Printer settings first.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
status.textContent = 'Sending to ' + settings.ip + '…';
try{
const logo = await window.EposPrint.imageToRaster('/wfdash-logo.png', 384).catch(()=>null);
const xml = window.EposPrint.buildEposXml(sale, logo);
await window.EposPrint.printViaNetwork(settings.ip, xml);
status.textContent = 'Printed · ' + settings.ip;
}catch(e){
status.textContent = 'Print failed: ' + e.message;
errBox.classList.add('err');
}finally{
btn.disabled = false;
}
}
```
- [ ] **Step 3: Verify the script still parses**
Run: `node --check <(sed -n '319,760p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
Expected: no output, exit code 0. (Adjust line range as needed.)
- [ ] **Step 4: Manual end-to-end test against the real printer**
On a device on the shop wifi that has trusted the printer's certificate, open a real sale's receipt page (so `sessionStorage['pos.lastSale']` is populated), set Printer type to Network with IP `192.168.1.33` via the UI from Task 6, tap "Print slip". Expected: status text shows "Printed · 192.168.1.33" and a real receipt (logo, items, subtotal, VAT, total, footer) prints on the TM-T20II.
- [ ] **Step 5: Commit**
```bash
git add public/pos/receipt.html
git commit -m "feat(pos): print sale receipts over the network transport"
```
---
### Task 8: Add network transport for delivery notes
**Files:**
- Modify: `public/pos/epos-print.js`
- Modify: `public/pos/receipt.html`
- [ ] **Step 1: Add `buildEposDeliveryXml` to `epos-print.js`, before the `return` statement**
Mirrors the delivery-note ESC/POS builder in `receipt.html`'s `printDelivery()` (currently inline, lines ~607-637).
```js
function buildEposDeliveryXml(s, showPrices) {
const parts = [];
parts.push('<text align="center" width="1" height="2">WFSALES POS&#10;</text>');
parts.push('<text align="center">Delivery Note&#10;</text>');
if (s.customer && s.customer.company) parts.push(`<text align="left">Company: ${xmlEscape(s.customer.company)}&#10;</text>`);
parts.push(`<text align="left">Customer: ${xmlEscape((s.customer && s.customer.name) || '—')}&#10;</text>`);
const d = new Date(s.createdAt);
parts.push(`<text align="left">Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const itemLines = s.items.map(it => {
const qtyLine = ` ${it.qty} ${it.unit || 'ea'}`;
const priceSuffix = showPrices ? ` R ${((it.selling_price || 0) * it.qty).toFixed(2)}` : '';
return `${xmlEscape(it.description || it.sku)}&#10;${xmlEscape(qtyLine + priceSuffix)}&#10;`;
}).join('');
parts.push(`<text align="left">${itemLines}</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
if (showPrices) parts.push(`<text align="left">TOTAL: R ${s.total.toFixed(2)}&#10;</text>`);
parts.push('<text align="left">Received by: __________ Driver: __________&#10;</text>');
parts.push('<feed line="3"/>');
parts.push('<cut type="feed"/>');
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${parts.join('')}</epos-print></s:Body></s:Envelope>`;
}
```
- [ ] **Step 2: Export it**
Change the `return` statement to:
```js
return { getSettings, setSettings, imageToRaster, buildEposXml, printViaNetwork, buildEposDeliveryXml };
```
- [ ] **Step 3: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 4: Wire `printDelivery()` in `receipt.html`**
Find (around line 593):
```js
async function printDelivery(){
if(!sale) return;
const btn = document.getElementById('printDnBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
status.textContent = 'Printing delivery note…';
if(!btChar){
status.textContent = 'Pair a Bluetooth printer first to print.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
```
Change to:
```js
async function printDelivery(){
if(!sale) return;
const btn = document.getElementById('printDnBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
status.textContent = 'Printing delivery note…';
const printerSettings = window.EposPrint.getSettings();
if(printerSettings.type === 'network'){
if(!printerSettings.ip){
status.textContent = 'Set the printer IP address in Printer settings first.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
const dnToggleNet = document.getElementById('dnShowPrices');
const showPricesNet = dnToggleNet ? !!dnToggleNet.checked : true;
try{
const xml = window.EposPrint.buildEposDeliveryXml(sale, showPricesNet);
await window.EposPrint.printViaNetwork(printerSettings.ip, xml);
status.textContent = 'Delivery note printed · ' + printerSettings.ip;
}catch(e){
errBox.classList.add('err');
status.textContent = 'DN print failed: ' + e.message;
}finally{
btn.disabled = false;
}
return;
}
if(!btChar){
status.textContent = 'Pair a Bluetooth printer first to print.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
```
(Leave the rest of the existing Bluetooth body below this point unchanged.)
- [ ] **Step 5: Verify the script still parses**
Run: `node --check <(sed -n '319,800p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
Expected: no output, exit code 0. (Adjust line range as needed.)
- [ ] **Step 6: Manual test against the real printer**
With Printer type set to Network in the UI, open an account-payment sale's receipt (so the delivery note section is visible), tap "Print delivery note". Expected: a delivery-note slip prints with company/customer, item list, and the received-by/driver line.
- [ ] **Step 7: Commit**
```bash
git add public/pos/epos-print.js public/pos/receipt.html
git commit -m "feat(pos): print delivery notes over the network transport"
```
---
### Task 9: Register `epos-print.js` with the service worker
**Files:**
- Modify: `public/pos/sw.js`
Without this, the till would work online but `epos-print.js` wouldn't be available offline (the printer itself is only reachable on the shop LAN anyway, but the PWA shell — including this script — should still be cached like every other JS file here).
- [ ] **Step 1: Add the file to the precache list and bump the cache version**
Find (lines 1-17):
```js
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
const CACHE = "pos-v15";
const APP_SHELL = [
'./',
'./index.html',
'./pos.html',
'./receipt.html',
'./history.html',
'./cashup.html',
'./pos-config.js',
'./pos-app.js',
'./theme.js',
'./manifest.json',
// Font Awesome 6 Free (solid) — bundled so the till still renders icons offline.
'./fa/all.min.css',
'./fa/webfonts/fa-solid-900.woff2',
];
```
Change to:
```js
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
const CACHE = "pos-v16";
const APP_SHELL = [
'./',
'./index.html',
'./pos.html',
'./receipt.html',
'./history.html',
'./cashup.html',
'./pos-config.js',
'./pos-app.js',
'./epos-print.js',
'./theme.js',
'./manifest.json',
// Font Awesome 6 Free (solid) — bundled so the till still renders icons offline.
'./fa/all.min.css',
'./fa/webfonts/fa-solid-900.woff2',
];
```
- [ ] **Step 2: Verify the file parses**
Run: `node --check public/pos/sw.js`
Expected: no output, exit code 0.
- [ ] **Step 3: Manual check**
Open `receipt.html` in a browser with DevTools open to the Application/Service Worker panel, force-reload, and confirm the new service worker (`pos-v16`) activates and `epos-print.js` appears in the Cache Storage listing for `pos-v16`.
- [ ] **Step 4: Commit**
```bash
git add public/pos/sw.js
git commit -m "chore(pos): cache epos-print.js in the PWA app shell"
```
---
### Task 10: Full end-to-end verification against the physical printer
**Files:** none (verification only)
- [ ] **Step 1: Confirm the certificate-trust step on a fresh device**
On an iPhone or iPad that has never visited the printer before, open Safari, navigate to `https://192.168.1.33`, and confirm you get a certificate warning; tap through "visit this website" (or equivalent). Confirm subsequent `fetch()` calls from the PWA's origin succeed without a further prompt.
- [ ] **Step 2: Ring up a real (or test) sale with items and a discount applied**
Use the till to create a sale with at least 2 line items and a discount, so the "gross" block in `buildEposXml` is exercised, not just the plain-total path.
- [ ] **Step 3: Set Printer type to Network with IP `192.168.1.33` and print the slip**
Confirm the physical printout shows: WFDASH logo, "WFSALES POS" header, date/slip number, item lines, gross/discount/net totals, VAT, TOTAL in bold, payment method, and footer text — matching what the on-screen receipt shows.
- [ ] **Step 4: If it's an account-payment sale, print the delivery note over network too**
Confirm the physical printout matches the on-screen delivery note (company/customer, item list respecting the "Include prices" toggle, received-by/driver line).
- [ ] **Step 5: Repeat Steps 2-4 on an Android device**
Confirms parity between iOS Safari and Android Chrome for this transport (Bluetooth already only works on Android, per Task 6-8 branching).
- [ ] **Step 6: Note any printer status codes seen during testing**
If any print job returns `success="true"` but nothing physically prints (as was observed once during initial protocol testing), capture the full XML response (including the `status` attribute) and check it against the printer's technical reference guide before shipping this to the till — do not treat `success="true"` alone as proof of a physical printout without confirming paper actually came out.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,620 +0,0 @@
# AI Quote Assistant Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** On a schedule, the built-in AI pre-fills unpriced quote lines from POS items (`stock_items`). Those lines show purple with an agent logo and the AI's reasoning, until a rep checks and saves them.
**Architecture:**
- **Service layer.** A pure core decides eligibility and builds the patches. A shortlist module finds candidate POS items without using AI. An AI module asks MiniMax (via the Anthropic SDK, as `quotes-ai-processor.ts` does) to pick between them. A service orchestrates a run per quote and per tick.
- **Routes.** Thin routes expose a tick (n8n is the clock), a per-quote manual run, and board counts. A small MCP tool exposes the per-quote run.
- **UI.** One shared marker component adds the purple rows and notes on the Process page, the quote detail page and the Quotes board. Settings → AI gains a "Quote assistant" section.
**Tech Stack:** Next.js (app router; read `node_modules/next/dist/docs/` before touching route or page conventions), PocketBase JS SDK, `@anthropic-ai/sdk`, vitest + Testing Library, Tailwind tokens, lucide-react.
**Spec:** `docs/superpowers/specs/2026-10-16-ai-quote-assistant-design.md`
## Global Constraints
- **No auto-sending.** The AI never sets `is_processed`, never changes `quotes.status`, and never sends email.
- **Never overwrite people.** The AI never writes a line that has a price, has `user_edited = true`, or has `is_processed = true`. It re-reads each line right before writing.
- **Prices come only from the POS.** Use the POS item's cost, selling price and markup. When the item has a cost but no selling price, use `DEFAULT_MARKUP_MULTIPLIER` / `DEFAULT_MARKUP_PERCENT` from `stock-default-markup-service.ts`. With no cost, the line is `no_match`.
- **Don't copy the POS SKU onto the line.** Keep it in `ai_fill` only.
- **`pricing_source` value for AI lines** is exactly `"ai_pos"`.
- **Audit actor.** All AI writes run inside `runWithAuditActor({ name: "AI quote assistant", source: "agent" }, …)`.
- **Data layer.** New data access goes through `src/lib/server/*` and `/api/v1/*`. Don't extend `src/lib/pb-helpers.ts`, and don't migrate existing page calls (owner decision).
- **Auth.** The tick uses `assertAgentAuth`. Browser routes use `getBrowserSessionUser(req)`, with the client sending `Authorization: Bearer ${pb.authStore.token}`.
- **Settings defaults.** Assistant off, interval 10 minutes (min 5, max 1440), minimum confidence `medium` (allowed: `high` | `medium`). Prompt empty means use the default.
- **PB text fields.** `quote_assistant_prompt` max must be 50000. `max: 0` caps at 5000.
- **Never test against live PB through MCP write tools.** They hit the real quotes board. Tests stub PB and the AI.
- **Commits.** Every commit message ends with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. **Stale Process page.** The AI fills a line while a rep has the page open, and the rep then types a price and saves that line. Expected: the rep's values win, and the line is **not** purple afterwards. A line is "pending" only when `ai_fill.status === "filled" && !ai_fill.reviewed_at && pricing_source === "ai_pos"`, and the rep's save sets `pricing_source` to `"manual"`. Tested in Task 3.
2. **Token burn on unmatched lines.** An unmatched line whose description hasn't changed must not be sent to the AI again on the next tick. If someone edits the description, it is retried. Tested in Task 3 and Task 5.
3. **Big quotes.** A quote with 80 open lines must not produce one huge AI call. Lines are capped per run and the rest go next tick. Tested in Task 5.
4. **Bad AI output.** The AI may return unknown or duplicate line ids, an invalid confidence, or a `stock_item_id` that isn't in that line's candidates. Those are ignored, and the line becomes `no_match`, never a wrong fill. Tested in Task 3.
5. **AI down or switched off.** If the AI is switched off, the tick does nothing. If an AI call throws, that quote's lines get `status: "error"` and are retried after 1 hour (or immediately via the manual button), and other quotes in the tick still run. Tested in Task 5.
## Decisions this plan makes (not in the spec)
- **Tick order is newest quote lines first, not oldest.** Lines that were tried and left unmatched stay in the candidate window, so oldest-first would starve new quotes. Update the spec's "oldest quotes first" line when committing Task 5.
- **`error` lines retry** when `attempted_at` is more than 1 hour old, or when the manual button is pressed.
- **Live updates.** No realtime in v1. The Process page already needs a manual refresh for out-of-band writes, and Review Focus #1 makes stale saves safe.
- **Shortlist.** A new shortlist module reuses the scoring helpers exported from `item-intel-service.ts`. `buildStock` is left unchanged.
---
### Task 1: Data model, settings fields and migration
**Files:**
- Modify: `src/lib/types.ts` (add types; add `ai_fill` to `QuoteItem`)
- Create: `src/lib/server/quote-assistant-prompt.ts`
- Modify: `src/lib/server/ai-settings-service.ts`
- Create: `src/lib/server/ai-settings-service.test.ts`
- Create: `scripts/migrations/2026-10-16-add-quote-assistant-fields.ts`
- Modify: `package.json` (script `pb:migrate:quote-assistant`)
**Interfaces:**
- Produces in `src/lib/types.ts`:
```ts
export type AiFillStatus = "filled" | "no_match" | "no_candidates" | "error";
export type AiFillConfidence = "high" | "medium" | "low";
export interface AiFillCandidate { id: string; name: string; sku: string; cost_price: number; selling_price: number }
export interface QuoteItemAiFill {
status: AiFillStatus;
stock_item_id: string | null;
stock_item_name: string;
stock_item_sku: string;
cost_price: number;
selling_price: number;
markup: number;
supplier_id: string;
confidence: AiFillConfidence | null;
reason: string;
doubts: string[];
candidates: AiFillCandidate[];
description_snapshot: string;
model: string;
attempted_at: string;
price_from_default_markup?: boolean;
reviewed_at?: string;
reviewed_by?: string;
accepted_unchanged?: boolean;
supplier_changed?: boolean;
}
export interface QuoteAssistantRunSummary {
started_at: string; finished_at: string;
quotes_seen: number; lines_filled: number; lines_unmatched: number; lines_skipped: number;
errors: string[];
}
// QuoteItem gains: ai_fill?: QuoteItemAiFill | null;
```
- Produces in `ai-settings-service.ts`:
- `AiSettings` gains `quote_assistant_enabled: boolean`, `quote_assistant_interval_minutes: number`, `quote_assistant_min_confidence: "high" | "medium"`, `quote_assistant_prompt: string`, `quote_assistant_last_run_at: string`, `quote_assistant_last_run_summary: QuoteAssistantRunSummary | null`.
- `AiSettingsUpdate` gains the first four, all optional.
- `export async function recordQuoteAssistantRun(fields: { last_run_at?: string; summary?: QuoteAssistantRunSummary }): Promise<void>` writes only `quote_assistant_last_run_at` / `quote_assistant_last_run_summary` and refreshes the cache. It doesn't touch `updated_at`.
- Produces `export const DEFAULT_QUOTE_ASSISTANT_PROMPT: string` in `quote-assistant-prompt.ts`.
- [ ] **Step 1: Write failing tests** in `src/lib/server/ai-settings-service.test.ts`. Mock `@/lib/server/pocketbase-admin` as `quote-items-insights-service.test.ts` does, and call `clearAiSettingsCache()` in `beforeEach`.
- `getAiSettings` on a row without the new fields returns `quote_assistant_enabled: false`, `quote_assistant_interval_minutes: 10`, `quote_assistant_min_confidence: "medium"`, `quote_assistant_prompt: ""`, `quote_assistant_last_run_at: ""`, `quote_assistant_last_run_summary: null`.
- `updateAiSettings({ quote_assistant_interval_minutes: 4 })` rejects with `"quote_assistant_interval_minutes must be between 5 and 1440"`, and `1441` rejects too.
- `updateAiSettings({ quote_assistant_min_confidence: "low" as never })` rejects with `'quote_assistant_min_confidence must be "high" or "medium"'`.
- `updateAiSettings({ quote_assistant_prompt: "x".repeat(60_000) })` sends a 50 000-character value.
- `recordQuoteAssistantRun({ last_run_at: "2026-10-16T10:00:00.000Z" })` calls PB `update` with exactly `{ quote_assistant_last_run_at: "2026-10-16T10:00:00.000Z" }`, and the next `getAiSettings()` returns that value without another PB read.
- [ ] **Step 2: Run them and confirm they fail.**
Run: `npx vitest run src/lib/server/ai-settings-service.test.ts`. Expected: FAIL.
- [ ] **Step 3: Implement.**
- Extend `RawSettingsRow`, `rowToSettings`, `DEFAULT_SETTINGS`, `updateAiSettings` validation (interval rounded to an integer) and `recordQuoteAssistantRun`.
- Add the types to `types.ts`.
- Write `DEFAULT_QUOTE_ASSISTANT_PROMPT` as plain text with no markdown, in the style of `ai-system-prompt.ts`, with this content:
```
You match quote lines from customers of a South African industrial supplier to items in our own POS catalogue, so the sales team gets a draft price. For each quote line you get a list of candidate POS items. Return your decision for every line through the submit_line_matches tool.
Rules:
1. Pick a candidate ONLY if it is the same product: same type, size, thread, length, grade/class, material and finish where the line states them. A near-miss is NOT a match.
2. If the closest candidate differs in anything the customer specified (e.g. line asks grade 12.9, candidate is grade 8), either return no match, or return it with confidence "low" and explain the difference in doubts.
3. Never invent or estimate prices. Prices come only from the chosen POS item.
4. Flag unit and pack-size differences in doubts (e.g. "POS item is sold per box of 100; customer asked for 50 each").
5. confidence: "high" = clearly the same item; "medium" = very likely the same, minor detail unstated by the customer; "low" = uncertain.
6. reason: one or two plain sentences a salesperson understands, saying why it is the same item. doubts: short plain sentences, empty list if none.
7. Only use stock_item_id values from that line's own candidate list. Use null for no match.
```
- Write the migration script following `scripts/migrations/add-quote-items-user-edited.ts` (env vars, idempotent: skip fields that already exist). It adds:
- `quote_items.ai_fill` (json, maxSize 200000)
- `ai_settings.quote_assistant_enabled` (bool)
- `quote_assistant_interval_minutes` (number)
- `quote_assistant_min_confidence` (text, max 10)
- `quote_assistant_prompt` (text, **max 50000**)
- `quote_assistant_last_run_at` (text, max 40)
- `quote_assistant_last_run_summary` (json, maxSize 20000)
- Add the `"pb:migrate:quote-assistant"` npm script.
- [ ] **Step 4: Run the tests and the type check.**
Run: `npx vitest run src/lib/server/ai-settings-service.test.ts && npx tsc --noEmit`. Expected: PASS, no type errors.
- [ ] **Step 5: Commit** with message `feat(quote-assistant): data model, settings fields, migration`.
---
### Task 2: POS shortlist
**Files:**
- Modify: `src/lib/server/item-intel-service.ts` (add `export` to `tokenizeIntel`, `informativeTokens`, `combinedScore`, `loadSuppliersById`; no behaviour change)
- Create: `src/lib/server/quote-assistant-shortlist.ts`
- Create: `src/lib/server/quote-assistant-shortlist.test.ts`
**Interfaces:**
- Consumes: the exported helpers above, and `normaliseSku` / `escapeForPbString` from `price-list-match.ts`.
- Produces:
```ts
export interface StockCandidate {
id: string; name: string; sku: string; description: string; notes: string; unit: string;
cost_price: number; selling_price: number; markup: number;
supplier_id: string; supplier_name: string; score: number;
}
export type AdminPb = Awaited<ReturnType<typeof getAdminPocketBase>>;
export async function shortlistStockItems(
pb: AdminPb, line: { description: string; sku?: string }, limit?: number /* default 8 */,
): Promise<StockCandidate[]>;
```
- [ ] **Step 1: Write failing tests** with a stubbed `pb.collection(name).getList/getFullList` that captures the `filter`:
- The PB filter starts with `is_active = true && (`, and ORs `name ~`, `description ~` and `sku ~` for each informative token.
- Given "M18 hex nut grade 8" and rows "M18 NUT 8 PLN" (description "M18 NUT 8 PLN") and "M6x30 SET SCR A2 PLN", only the first is returned.
- Results are sorted by `score`, descending, and capped at `limit`.
- A row whose `normaliseSku(sku)` equals `normaliseSku(line.sku)` is ranked first with `score: 1`, even with a weak description.
- Rows scoring below 0.2 are dropped.
- `supplier_name` is resolved from suppliers.
- An empty or stopword-only description with no SKU returns `[]` and makes no PB call.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/quote-assistant-shortlist.test.ts`.
- [ ] **Step 3: Implement `shortlistStockItems`.**
- Fetch at most 500 rows with fields `id,sku,name,description,notes,unit,cost_price,selling_price,markup,supplier`.
- Score each row as `max(combinedScore(lineTokens, tokenizeIntel(name)), combinedScore(lineTokens, tokenizeIntel(description)))`.
- Escape filter values with `escapeForPbString`.
- [ ] **Step 4: Run** the shortlist tests plus `npx vitest run src/lib/server/item-intel-service` (the existing intel tests must stay green). Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(quote-assistant): POS shortlist from stock_items`.
---
### Task 3: Pure decision core and review helpers
**Files:**
- Create: `src/lib/server/quote-assistant-core.ts` (server-only pure functions)
- Create: `src/lib/quote-assistant-review.ts` (client-safe: imports only from `@/lib/types`)
- Create: `src/lib/server/quote-assistant-core.test.ts`
- Create: `src/lib/quote-assistant-review.test.ts`
**Interfaces:**
- Consumes: `StockCandidate` (Task 2); `QuoteItemAiFill`, `AiFillConfidence` (Task 1); `DEFAULT_MARKUP_MULTIPLIER`, `DEFAULT_MARKUP_PERCENT`, `round2` from `stock-default-markup-service.ts`.
- Produces in `quote-assistant-core.ts`:
```ts
export const ELIGIBLE_QUOTE_STATUSES = ["new", "follow_up", "claimed"] as const;
export const ERROR_RETRY_MS = 60 * 60 * 1000;
export interface AiLineDecision {
quote_item_id: string; stock_item_id: string | null;
confidence: AiFillConfidence | null; reason: string; doubts: string[];
}
export function isLineEligible(item: QuoteItem, opts: { force?: boolean; now: Date }): boolean;
export function isLineStillWritable(item: QuoteItem): boolean; // no price, !user_edited, !is_processed
export function parseDecisions(raw: unknown, requestedIds: ReadonlySet<string>): Map<string, AiLineDecision>;
export function meetsConfidence(c: AiFillConfidence | null, min: "high" | "medium"): boolean;
export function buildLinePatch(args: {
item: QuoteItem; decision: AiLineDecision | null; candidates: StockCandidate[];
minConfidence: "high" | "medium"; model: string; now: Date;
status?: "no_candidates" | "error";
}): Record<string, unknown>;
```
- Produces in `quote-assistant-review.ts`:
```ts
export function isAiFillPending(item: Pick<QuoteItem, "ai_fill" | "pricing_source">): boolean;
export function nextPricingSourceOnSave(current: string | undefined): "catalogue" | "ai_pos" | "manual";
export function buildAiReviewPatch(
item: Pick<QuoteItem, "ai_fill" | "pricing_source">,
saved: { cost_price: number; selling_price: number; supplier: string | null },
reviewer: { id?: string; name: string }, now: Date,
): { ai_fill?: QuoteItemAiFill };
export function aiRowClass(item: Pick<QuoteItem, "ai_fill" | "pricing_source">): string; // "bg-ai/10" when pending, else ""
```
- [ ] **Step 1: Write failing core tests** (`quote-assistant-core.test.ts`).
`isLineEligible`:
- It returns true for a line with `is_processed: false`, cost/selling 0, `user_edited: false`, `pricing_source: "ai_email"`, a description, empty `moved_to_quote`, and no `ai_fill`.
- It returns false when any one of these holds:
- `cost_price > 0`
- `user_edited`
- `pricing_source === "manual"`
- empty description
- `moved_to_quote` set
- `ai_fill.status === "no_match"` with `description_snapshot === description`
- It returns true when that snapshot differs from the description.
- It returns true for `ai_fill.status === "no_match"` with the same snapshot when `force: true`.
- It returns true for `status: "error"` when `attempted_at` is 61 minutes before `now`, and false at 59 minutes.
`parseDecisions`:
- Ids not in `requestedIds` are dropped.
- For duplicate ids, the first wins.
- `confidence: "certain"` becomes `null`.
- Non-array `doubts` becomes `[]`.
`meetsConfidence`:
- `("medium","medium")` is true, `("low","medium")` is false, `("medium","high")` is false, and `(null, …)` is false.
`buildLinePatch` fill, with the candidate `{cost 1.89, selling 3.4, markup 80, supplier_id "s1"}`, decision `high`, min `medium`:
- The patch has `cost_price: 1.89`, `selling_price: 3.4`, `markup: 80`, `supplier: "s1"` and `pricing_source: "ai_pos"`.
- `ai_fill.status` is `"filled"`, `ai_fill.description_snapshot` equals the description, and `pricing_notes` starts with `"AI: matched POS "`.
- The patch has **no** `sku`, `is_processed` or `user_edited` keys.
`buildLinePatch` default markup:
- Candidate cost 10, selling 0 gives `selling_price: 18`, `markup: 80` and `ai_fill.price_from_default_markup: true`.
`buildLinePatch` no-fill cases:
- Candidate cost 0 gives `ai_fill.status: "no_match"` with no price keys.
- A decision with `confidence: "low"` gives `"no_match"` with no price keys, and `ai_fill.candidates` lists up to 5 candidates.
- A decision whose `stock_item_id` isn't among the candidates gives `"no_match"`.
- On `no_match`, `pricing_notes` is set only when the item's `pricing_notes` was empty.
- `status: "error"` gives `ai_fill.status: "error"` with no price keys.
- [ ] **Step 2: Write failing review tests** (`quote-assistant-review.test.ts`).
- `isAiFillPending` is true only for `ai_fill.status === "filled"`, no `reviewed_at` and `pricing_source === "ai_pos"`. It is **false** for a filled `ai_fill` with `pricing_source: "manual"` (Review Focus #1).
- `nextPricingSourceOnSave`: `"catalogue"` gives `"catalogue"`, `"ai_pos"` gives `"ai_pos"`, and `"ai_email"` and `""` give `"manual"`.
- `buildAiReviewPatch`:
- On a pending line with the same prices, it gives `accepted_unchanged: true`, `supplier_changed: false`, and `reviewed_at` / `reviewed_by` set.
- A changed selling price gives `accepted_unchanged: false`.
- A different supplier gives `supplier_changed: true`.
- A non-pending line gives `{}`.
- `aiRowClass` gives `"bg-ai/10"` for pending lines and `""` otherwise.
- [ ] **Step 3: Run both tests and confirm they fail.**
Run: `npx vitest run src/lib/server/quote-assistant-core.test.ts src/lib/quote-assistant-review.test.ts`.
- [ ] **Step 4: Implement both modules.**
- `pricing_notes` for a fill: `AI: matched POS '<name>' (<confidence>)`, followed by `. Selling price from default markup.` when that applies. Keep it to one line.
- For `no_match` and `no_candidates`: `AI: no confident POS match — see closest items.`
- [ ] **Step 5: Run the tests.** Expected: PASS.
- [ ] **Step 6: Commit** with message `feat(quote-assistant): eligibility, patch building, review helpers`.
---
### Task 4: AI decision call
**Files:**
- Create: `src/lib/server/quote-assistant-ai.ts`
- Create: `src/lib/server/quote-assistant-ai.test.ts`
**Interfaces:**
- Consumes: `getActiveAiConfig`, `getAiSettings` (`ai-settings-service.ts`); `DEFAULT_QUOTE_ASSISTANT_PROMPT`; `StockCandidate`; `parseDecisions`, `AiLineDecision`.
- Produces:
```ts
export interface DecideLine { quote_item_id: string; description: string; quantity: number; sku: string; notes: string; candidates: StockCandidate[] }
export interface DecideInput { quoteNumber: string; lines: DecideLine[] }
export interface DecideResult { decisions: Map<string, AiLineDecision>; model: string }
export const SUBMIT_LINE_MATCHES_TOOL: { name: "submit_line_matches"; description: string; input_schema: object };
export function buildDecideUserText(input: DecideInput): string;
export async function decideWithAi(input: DecideInput, deps?: { client?: Pick<Anthropic, "messages"> }): Promise<DecideResult>;
```
- [ ] **Step 1: Write failing tests.**
- `buildDecideUserText` includes each line's `quote_item_id`, description and quantity. For each candidate it includes `stock_item_id=`, name, SKU, unit, cost, selling price and supplier name. It never includes the words "price list".
- `decideWithAi`, given a fake `client.messages.create`:
- It is called with `tool_choice: { type: "tool", name: "submit_line_matches" }`.
- Its `system` equals `DEFAULT_QUOTE_ASSISTANT_PROMPT` when `quote_assistant_prompt` is `""`, and the override when it is set.
- It returns the parsed decisions keyed by id.
- A response with no `tool_use` block makes it throw `"AI returned no submit_line_matches tool call"`.
- Mock `ai-settings-service` with `getActiveAiConfig` returning `{ modelId: "MiniMax-M3", maxTokens: 4096, apiKey: "k", baseUrl: "u", provider: "minimax" }`.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/quote-assistant-ai.test.ts`.
- [ ] **Step 3: Implement.**
- The tool schema is `{ matches: [{ quote_item_id, stock_item_id (string|null), confidence (enum high|medium|low), reason, doubts: string[] }] }`, with `required: ["matches"]`.
- When no client is injected, create the client like `newAnthropic` in `quotes-ai-processor.ts`: same base-URL handling, and use `aiConfig.maxTokens`.
- Pass the set of requested ids to `parseDecisions`.
- [ ] **Step 4: Run the tests.** Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(quote-assistant): MiniMax match decision call`.
---
### Task 5: Assistant service (per quote, tick, counts)
**Files:**
- Create: `src/lib/server/quote-assistant-service.ts`
- Create: `src/lib/server/quote-assistant-service.test.ts`
- Modify: `docs/superpowers/specs/2026-10-16-ai-quote-assistant-design.md` (change "oldest quotes first" to "newest lines first", with the reason)
**Interfaces:**
- Consumes: Tasks 1–4; `getAdminPocketBase`; `runWithAuditActor`; `clearAiSettingsCache`, `getAiSettings`, `recordQuoteAssistantRun`, `AiDisabledError`.
- Produces:
```ts
export interface AssistantDeps {
decide?: (input: DecideInput) => Promise<DecideResult>;
shortlist?: typeof shortlistStockItems;
now?: () => Date;
}
export interface FillQuoteResult { quoteId: string; filled: number; unmatched: number; skipped: number; error: string }
export const TICK_MAX_QUOTES = 20;
export const TICK_MAX_LINES = 60;
export const QUOTE_MAX_LINES = 30;
export async function fillQuote(quoteId: string, opts?: { force?: boolean; maxLines?: number; actorName?: string; deps?: AssistantDeps }): Promise<FillQuoteResult>;
export async function runAssistantTick(deps?: AssistantDeps): Promise<{ ran: boolean; reason?: "disabled" | "not_due"; summary?: QuoteAssistantRunSummary }>;
export async function getAssistantCounts(quoteIds: string[]): Promise<Record<string, { pending: number; total: number }>>;
```
- [ ] **Step 1: Write failing tests.** Stub PB with an in-memory `quotes` / `quote_items` store, plus a `decide` fake and a `shortlist` fake.
`fillQuote`:
- It writes the fill patch for a matched line and a `no_match` patch for an unmatched one, and returns `{ filled: 1, unmatched: 1 }`.
- A line whose shortlist is empty gets `no_candidates`, and `decide` is not given that line.
- `decide` is called **once** per quote.
- A quote with status `"approved"` returns an `error` of `"quote is not open (status approved)"` and writes nothing.
- **Re-read guard:** if the store's line gets `cost_price: 5` between the shortlist and the write, that line is skipped (`skipped: 1`) and not written.
- With 40 eligible lines and `maxLines` defaulting to `QUOTE_MAX_LINES` (30), only 30 lines are sent to `decide` (Review Focus #3).
- When `decide` throws, every requested line gets `ai_fill.status: "error"`, the result's `error` contains the message, and nothing is priced (Review Focus #5).
- Every write happens inside `runWithAuditActor`. Mock `audit-context` and assert the actor name is `"AI quote assistant"`, or `"AI quote assistant (run by <actorName>)"` when `actorName` is given.
- `force: true` re-sends a `no_match` line with an unchanged description. Without `force`, that line is not sent (Review Focus #2).
`runAssistantTick`:
- It returns `{ ran: false, reason: "disabled" }` when `enabled` or `quote_assistant_enabled` is false, and does nothing else.
- It returns `{ ran: false, reason: "not_due" }` when `last_run_at` is 9 minutes before `now` with interval 10.
- When it runs, it calls `recordQuoteAssistantRun({ last_run_at })` **before** loading lines, and calls `recordQuoteAssistantRun({ summary })` at the end with the totals.
- It processes at most `TICK_MAX_QUOTES` quotes and `TICK_MAX_LINES` lines, taking the newest lines first.
- If one quote's `fillQuote` throws, the other quotes are still processed and the error is pushed to `summary.errors`.
`getAssistantCounts`:
- `pending` counts lines where `isAiFillPending`, and `total` counts all lines that aren't moved out.
- It handles more than 200 ids by chunking, like `fetchQuoteItemCounts`.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/quote-assistant-service.test.ts`.
- [ ] **Step 3: Implement.**
- **Tick line query.** Use one `quote_items` `getList(1, 500)`:
- filter: `is_processed = false && cost_price = 0 && selling_price = 0 && user_edited = false && (pricing_source = "" || pricing_source = "ai_email") && moved_to_quote = ""`
- `expand: "quote"`, `sort: "-created"`
- Then filter in the app with `isLineEligible` and an `ELIGIBLE_QUOTE_STATUSES` check on the expanded quote, and group by quote.
- **Settings read.** Call `clearAiSettingsCache()` before reading settings in the tick, so the interval gate sees fresh data.
- **Error handling.** `AiDisabledError` from `decide` counts as an error.
- [ ] **Step 4: Run the service tests and the full suite.**
Run: `npx vitest run src/lib/server/quote-assistant-service.test.ts`, then `npx vitest run`. Expected: PASS.
- [ ] **Step 5: Commit** (service plus the spec line change) with message `feat(quote-assistant): fill service, tick, counts`.
---
### Task 6: Routes, MCP tool, and the "Check pricing" guard
**Files:**
- Create: `src/app/api/v1/quotes/assistant/tick/route.ts` (+ `route.test.ts`)
- Create: `src/app/api/v1/quotes/[id]/assistant/run/route.ts` (+ `route.test.ts`)
- Create: `src/app/api/v1/quotes/assistant/counts/route.ts` (+ `route.test.ts`)
- Modify: `src/app/api/v1/quotes/[id]/re-price/route.ts` (skip pending AI lines)
- Create: `src/app/api/v1/quotes/[id]/re-price/route.test.ts`
- Modify: `src/app/api/mcp/route.ts` (tool list entry, dispatch, error hint)
**Interfaces:**
- Consumes: `fillQuote`, `runAssistantTick`, `getAssistantCounts` (Task 5); `isAiFillPending` (Task 3).
- Produces HTTP:
- `POST /api/v1/quotes/assistant/tick`: Bearer `AGENT_API_KEY`. Returns 200 with the `runAssistantTick` result, or 401.
- `POST /api/v1/quotes/{id}/assistant/run`: browser session. Returns 200 with `FillQuoteResult`. Returns 409 `{ error }` when the result's `error` is `"quote is not open…"` or the AI is disabled. Returns 401.
- `GET /api/v1/quotes/assistant/counts?ids=a,b`: browser session. Returns 200 `Record<id,{pending,total}>`. Returns 400 when `ids` is missing or has more than 500 entries.
- Produces MCP tool `quotes.assistant_fill`, with args `{ quote_id: string }`. It calls `fillQuote(quote_id, { force: true, actorName: "MCP" })` in-process via dynamic import, the way `quotes.next_number` does. It is **not** added to `READ_TOOLS`.
- [ ] **Step 1: Write failing route tests.** Mock the service and the auth helpers, following existing `route.test.ts` files under `src/app/api/v1`.
- Tick: a missing or wrong key gives 401. A good key gives 200 with the mocked result.
- Run: no token gives 401. A good token calls `fillQuote(id, { force: true, actorName: <user.name> })`. A "not open" result gives 409.
- Counts: no `ids` gives 400. With ids, the service is called with the split ids.
- Re-price: an item with `sku: "X1"`, `pricing_source: "ai_pos"` and a pending `ai_fill` gives `outcome: "skipped"`, and `findFirstPriceListBySku` is not called for it. A reviewed AI line (with `reviewed_at`) is still re-priced.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/app/api/v1/quotes`.
- [ ] **Step 3: Implement the routes, the re-price guard and the MCP entry.**
- Add `ai_fill` and `pricing_source` to the re-price item type.
- MCP tool description: `"Run the AI quote assistant on one quote now: fills unpriced lines from matching POS items (purple, unreviewed) and notes the closest items for unmatched lines. Never marks lines done or sends anything."`
- [ ] **Step 4: Run the tests, including the Giellie policy catalogue.**
Run: `npx vitest run src/app/api/v1/quotes src/lib/server/giellie-tool-policy.catalogue.test.ts src/lib/server/giellie-tool-policy.test.ts`. Expected: PASS. The new tool classifies as a write, not a read.
- [ ] **Step 5: Commit** with message `feat(quote-assistant): tick/run/counts routes, MCP tool, re-price guard`.
---
### Task 7: Purple token and shared UI components
**Files:**
- Modify: `src/app/globals.css`
- Create: `src/components/ai-fill-marker.tsx`
- Create: `src/components/ai-fill-marker.test.tsx`
- Create: `src/components/ai-fill-quote-button.tsx`
- Create: `src/components/ai-fill-quote-button.test.tsx`
**Interfaces:**
- Consumes: `isAiFillPending` (Task 3), `QuoteItemAiFill`.
- Produces:
```tsx
export function AiFillMarker(props: { item: Pick<QuoteItem, "id" | "ai_fill" | "pricing_source">; className?: string }): JSX.Element | null;
export function AiBoardBadge(props: { pending: number; total: number }): JSX.Element | null; // null when pending === 0
export function AiFillQuoteButton(props: { quoteId: string; onDone: () => void; size?: "sm" | "default" }): JSX.Element;
```
- Produces the Tailwind classes `bg-ai/10`, `text-ai` and `border-ai/30`.
- [ ] **Step 1: Write failing component tests** (Testing Library, following `customer-form.test.tsx`).
`AiFillMarker` renders:
- `null` when there's no `ai_fill`.
- A purple `Bot` button labelled `"AI filled this line: view notes"` when pending.
- A muted button labelled `"Filled by AI (checked): view notes"` when reviewed.
- A muted `"AI: no match"` badge for `no_match` / `no_candidates`.
Clicking the button shows:
- the POS item name and SKU, with a link to `/stock/<stock_item_id>` (the route at `src/app/(app)/stock/[id]/page.tsx`)
- the cost, selling price and markup %
- the confidence, the reason, and each doubt
- the `"Selling price from default markup"` text when flagged
For `no_match`, the popover lists the candidates.
`AiBoardBadge`:
- `pending: 6, total: 10` renders `"AI 6/10"`, and `pending: 0` renders nothing.
`AiFillQuoteButton`:
- A click POSTs `/api/v1/quotes/q1/assistant/run` with `Authorization: Bearer <token>`. Mock `getPocketBase`.
- On a 200 `{filled: 3, unmatched: 2}`, it toasts `"AI filled 3 lines · 2 without a match"` and calls `onDone`.
- On a 409, it toasts the `error` text.
- The button is disabled while the request runs.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/components/ai-fill-marker.test.tsx src/components/ai-fill-quote-button.test.tsx`.
- [ ] **Step 3: Implement.**
- In `globals.css`, add `--color-ai: var(--ai); --color-ai-foreground: var(--ai-foreground);` next to `--color-info`.
- Define `--ai: oklch(0.55 0.20 300); --ai-foreground: oklch(0.98 0 0);` on `:root`, and `--ai: oklch(0.72 0.17 300); --ai-foreground: oklch(0.15 0 0);` under `.dark`.
- Follow the `--info` tokens in `src/styles/themes.css` if a theme overrides `:root`. The token must resolve in every theme.
- Use the existing `Popover`, `Badge` and `Button` UI components. The icon is lucide `Bot`.
- [ ] **Step 4: Run the tests.** Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(quote-assistant): ai colour token, marker, fill button`.
---
### Task 8: Process page and quote detail page
**Files:**
- Modify: `src/app/(app)/quotes/[id]/process/page.tsx`
- Modify: `src/app/(app)/quotes/[id]/page.tsx`
- Create: `src/lib/quote-assistant-save.ts` + `src/lib/quote-assistant-save.test.ts` (the merge logic both pages use, so it can be tested outside the 2,000–4,800-line pages)
**Interfaces:**
- Consumes: `aiRowClass`, `nextPricingSourceOnSave`, `buildAiReviewPatch`, `isAiFillPending` (Task 3); `AiFillMarker`, `AiFillQuoteButton` (Task 7).
- Produces:
```ts
export function withAiReview(
item: QuoteItem,
patch: Record<string, unknown>, // the page's existing save payload
saved: { cost_price: number; selling_price: number; supplier: string | null },
reviewer: { id?: string; name: string }, now: Date,
opts: { markProcessedWhenPriced: boolean },
): Record<string, unknown>;
```
- [ ] **Step 1: Write failing tests** for `withAiReview`.
- On a pending AI line, it keeps `pricing_source: "ai_pos"` and adds the review `ai_fill`.
- With `markProcessedWhenPriced: true` and both prices above 0, it adds `is_processed: true`.
- On a non-AI line, it returns the patch unchanged apart from `pricing_source` mapped through `nextPricingSourceOnSave`.
- It never removes keys from `patch`.
- [ ] **Step 2: Run and confirm they fail, then implement `withAiReview`.**
Run: `npx vitest run src/lib/quote-assistant-save.test.ts`. Expected: FAIL, then PASS after implementing.
- [ ] **Step 3: Wire the Process page.**
- Add `aiRowClass(item)` to the row `className` in both the desktop and mobile layouts.
- Put `<AiFillMarker item={item} />` next to the existing `AiItemHistoryTrigger` in the description cell.
- Add an `"AI"` badge (`border-ai/30 bg-ai/10 text-ai`) to the row-badge strip when `isAiFillPending(item)`, and extend that strip's visibility condition to include it.
- In `saveItem`, replace the inline `pricing_source` expression with `withAiReview(item, persistedSnapshot, …, { markProcessedWhenPriced: false })`. `saveItem` already sets `is_processed`.
- Apply the same to the catalogue-picker auto-apply path, keeping its `"catalogue"` source.
- The reviewer is the current PB auth user (`pb.authStore.record`): `{ id, name: name || email }`.
- Put `<AiFillQuoteButton quoteId={id} onDone={fetchData} />` in the toolbar next to "Check pricing".
- [ ] **Step 4: Wire the quote detail page.**
- Add the row class and `<AiFillMarker>` in the items table (desktop and mobile cards).
- In `handlePriceUpdate`, build the payload with `withAiReview(item, { cost_price, markup, selling_price }, { …, supplier: item.supplier || null }, …, { markProcessedWhenPriced: true })`. `handlePriceUpdate` needs the item, so look it up from `items` by id.
- Put `<AiFillQuoteButton quoteId={id} onDone={fetchQuote} />` beside the existing Process action. Show it only when the quote status is in `new`, `follow_up` or `claimed`.
- [ ] **Step 5: Verify.**
Run: `npx vitest run && npx tsc --noEmit && npm run lint`. Expected: PASS.
- [ ] **Step 6: Check it in the browser.**
- Start the dev server with preview_start (port 4001, see memory).
- On a local or test quote, seed one line through a local test fixture, **never** via MCP writes on live PB: `ai_fill.status: "filled"`, `pricing_source: "ai_pos"`, prices set.
- Confirm the purple row, the marker popover, and that Save turns it grey-marked and not purple. Check both light and dark mode. Take a screenshot.
- If no local PB is available, say so instead of claiming it's verified.
- [ ] **Step 7: Commit** with message `feat(quote-assistant): purple AI lines on process and quote pages`.
---
### Task 9: Quotes board badge
**Files:**
- Modify: `src/app/(app)/quotes/page.tsx`
**Interfaces:**
- Consumes: `GET /api/v1/quotes/assistant/counts` (Task 6), `AiBoardBadge` (Task 7).
- [ ] **Step 1: Wire the badge.** Wherever `fetchQuoteItemCounts(ids)` runs (two call sites, around lines 290 and 348), also fetch `/api/v1/quotes/assistant/counts?ids=…` with the Bearer token into a new `aiCounts` state. Leave `fetchQuoteItemCounts` as it is. Render `<AiBoardBadge {...aiCounts[item.id]} />` next to `<ProcessedBadge>`. A failed counts fetch should leave the badge hidden and not break the board.
- [ ] **Step 2: Verify.**
Run: `npx tsc --noEmit && npm run lint`, then check in the browser on :4001 that the board renders and the badge shows for the seeded quote.
- [ ] **Step 3: Commit** with message `feat(quote-assistant): AI x/y badge on quotes board`.
---
### Task 10: Settings section, n8n clock, docs
**Files:**
- Modify: `src/app/(app)/settings/ai/settings-form-helpers.ts`
- Create: `src/app/(app)/settings/ai/settings-form-helpers.test.ts`
- Modify: `src/app/(app)/settings/ai/ai-settings-form.tsx`
- Modify: `src/app/(app)/settings/ai/page.tsx`
- Create: `docs/quote-assistant.md` (runbook)
- Modify: `AGENTS.md` (short section)
**Interfaces:**
- Consumes: the Task 1 settings fields; `DEFAULT_QUOTE_ASSISTANT_PROMPT`.
- Produces form field names: `qa_enabled` (checkbox `"on"`), `qa_interval_minutes`, `qa_min_confidence`, `qa_prompt`.
- [ ] **Step 1: Write failing tests** for `parseSettingsForm`.
- `qa_enabled=on` gives `quote_assistant_enabled: true`, and a missing value gives `false`.
- `qa_interval_minutes=4` gives the error `"Quote assistant interval must be between 5 and 1440 minutes"`.
- `qa_min_confidence=high` passes through, and `low` gives the error `'Quote assistant minimum confidence must be "high" or "medium"'`.
- `qa_prompt` passes through untrimmed.
- The existing fields still parse as before.
- [ ] **Step 2: Run and confirm they fail, then implement the parser.**
Run: `npx vitest run "src/app/(app)/settings/ai/settings-form-helpers.test.ts"`. Expected: FAIL, then PASS after implementing.
- [ ] **Step 3: Build the "Quote assistant" form section.**
- It's a Card under the existing form, in the same `<form>` so it saves with the same button.
- Controls: on/off switch; "Run every (minutes)" number input; "Minimum confidence to fill" select (High / Medium); prompt textarea with "Reset to default" and a read-only default preview, copied from the intake-prompt UI.
- A read-only "Last run" line from `quote_assistant_last_run_summary`: `"<time> · <quotes_seen> quotes · <lines_filled> filled · <lines_unmatched> no match"`, or `"Never"`.
- Helper text: "Needs the AI switch above to be on too. n8n pings every 5 minutes; this setting decides how often it actually runs."
- Pass the new fields through `page.tsx` → `AiSettingsFormInitial`.
- [ ] **Step 4: Create the n8n workflow.**
- Load the n8n MCP tools and skills.
- The workflow is "WFDASH Quote assistant tick": a Schedule trigger every 5 minutes, then an HTTP Request `POST https://wfdash.apphub.co.za/api/v1/quotes/assistant/tick` with header auth using the same credential as the existing `process-pending` workflow. Find that credential with `list_credentials`; don't create new secrets. Set `retryOnFail: false` and timeout 120 s.
- Use `create_workflow_from_code`, and leave it **unpublished**. The owner activates it after deploying.
- If the n8n MCP fails, don't loop. Write the click-by-click steps into `docs/quote-assistant.md`.
- [ ] **Step 5: Write the runbook and AGENTS.md section.**
- `docs/quote-assistant.md` covers:
- what the assistant does
- the manual live steps: (1) `npm run pb:migrate:quote-assistant` against live PB, (2) deploy, (3) activate the n8n workflow, (4) turn the assistant on in Settings → AI
- how to read `ai_fill`
- how to judge accuracy (`accepted_unchanged` rate)
- `AGENTS.md` gets a 6–10 line section with:
- the file map (`quote-assistant-*.ts`)
- the "pending" definition
- the rule that AI writes never set `is_processed`
- the rule that `pricing_source "ai_pos"` must survive human saves (`nextPricingSourceOnSave`)
- [ ] **Step 6: Run final verification.**
Run: `npx vitest run && npx tsc --noEmit && npm run lint && npm run build`. Expected: all PASS. Check the settings page in the browser on :4001: the section renders, saves, and survives a reload.
- [ ] **Step 7: Commit** with message `feat(quote-assistant): settings section, n8n tick, runbook`.

View file

@ -1,522 +0,0 @@
# Collection Confirm AI Checks Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** The built-in AI reads the driver's invoice or delivery-note photo for each Collection Confirm card. It pairs the document lines with our PO lines, fills empty SKUs, and shows per-line SKU/Qty/Desc/Price ticks, a findings box, a pre-filled confirm box and POS buttons. All of this sits on top of today's fully manual page.
**Architecture:**
- **Two AI calls per document**, through the same Anthropic-SDK/MiniMax pattern as `quotes-ai-processor.ts`: a vision read (cached per file, de-duplicated by content hash, stored in a new `collection_doc_reads` collection), then a text-only pairing call.
- **Code, not the AI, decides the ticks.** A pure, client-safe module computes them from the stored pairing plus live row values, so manual edits update the ticks instantly.
- **Triggers:** a service orchestrates per-card checks and an n8n-driven tick. "Add to POS" is a manual feature built on an extracted `createStockItem` service.
**Tech Stack:** Next.js (read `node_modules/next/dist/docs/` before writing routes or pages), PocketBase JS SDK, `@anthropic-ai/sdk`, vitest + Testing Library, Tailwind tokens, lucide-react.
**Spec:** `docs/superpowers/specs/2026-10-17-collection-confirm-ai-checks-design.md`
## Global Constraints
- **Manual first:** with the AI off, not run, failed or ignored, Collection Confirm behaves exactly as today: inline SKU/cost/qty edits, the confirm dialog, Confirm, Book to stock, Clear. The AI never blocks a confirm.
- **The AI writes only:**
- `ai_check` on lines;
- `collection_doc_reads` rows;
- an empty line SKU, through the existing services (`updateOrderItemSku` / `updateStockPurchaseItem({ sku })`).
- **The AI never writes** `cost_price`, `qty_collected` / `quantity_received`, `item_status` / `status`, or invoice details.
- **A human-set SKU is never overwritten.** A re-run only refreshes `ai_check` and the document findings.
- **"SKU" means the supplier's code everywhere** (SKU tick, AI fill, In POS, Add to POS). Codes are compared normalised: uppercase, `A-Z0-9` only.
- **"Ordered"** = `order_qty`, falling back to `quantity` when `order_qty` is 0.
- **Price tick:** green when |document per-unit price − `cost_price`| ≤ 0.01. Grey for a delivery note or a null price.
- **Images only** (jpeg/png/webp/gif). PDFs are stored as `status: "skipped"` with note `"PDF not read yet"`.
- **Audit actor:** all writes go inside `runWithAuditActor({ name: "AI collection check", source: "agent", ignoreRequestUser: true }, …)`.
- **Guard:** refuse to run (error `COLLECTION_AI_NOT_SET_UP_ERROR`) until `order_items` has an `ai_check` field. PB drops unknown fields silently.
- **Settings:** `collection_ai_enabled` is off by default, and the global AI switch must also be on. `collection_ai_prompt` text max is 50000, never `max: 0`.
- **Tick limits:** at most 10 files read per run, a 90 s time budget, and a 10-minute in-progress lease (same pattern as `quote-assistant-service.ts`).
- **Data layer:** new code lives in `src/lib/server/*` and `/api/v1/*`. Don't extend `pb-helpers.ts`, and don't migrate existing page calls.
- **Auth:** the tick uses `assertAgentAuth`. Browser routes use `getBrowserSessionUser(req)`, with the client sending `Authorization: Bearer ${pb.authStore.token}`.
- **Never test against live PB or through MCP write tools.** Tests stub PB and the AI.
- **Commits** end with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. **Same photo uploaded several times (today: 7 copies).** It is read once. A file already read is never re-read on later ticks. Tested in Task 6.
2. **A rep sets a SKU, then the AI runs again.** The rep's SKU stays. The robot icon shows only while the SKU equals `sku_filled_value`. Tested in Tasks 2 and 6.
3. **Document price missing, or "per 100" already normalised.** A null price gives a grey price tick (never red), and R0.20 vs 0.2 is green. Tested in Task 2.
4. **The AI returns an unknown PO line id, or pairs two document lines to one PO line.** Unknown ids are ignored, the first pairing wins, and a line with no valid pairing is never green. Tested in Task 5.
5. **AI off, not set up, or the read failed.** Rows without `ai_check` render exactly as today: no tick columns' content, no findings box, no pre-fill. Tested in Task 8.
---
### Task 1: Data model, settings fields, migration, audit ignore
**Files:**
- Modify: `src/lib/types.ts` (new types)
- Modify: `src/lib/server/collection-confirm-service.ts` (`ConfirmRow` fields only)
- Modify: `src/lib/server/ai-settings-service.ts` (+ `ai-settings-service.test.ts`)
- Create: `scripts/migrations/2026-10-17-add-collection-ai.ts`
- Modify: `package.json` (`"pb:migrate:collection-ai"`)
- Modify: `pocketbase/pb_hooks/audit_log_utils.js` (`IGNORED_FIELDS` += `ai_check`)
**Interfaces — Produces in `src/lib/types.ts`:**
```ts
export type CollectionDocType = "tax_invoice" | "delivery_note" | "other";
export interface CollectionDocLine {
line_no: number; code: string; description: string;
quantity: number | null; unit: string;
unit_price: number | null; // per single unit, already normalised from "per 100" etc.
line_total: number | null; to_follow: number | null; price_note: string;
}
export interface CollectionDocRead {
id: string; source_collection: string; source_id: string; filename: string; content_hash: string;
status: "read" | "failed" | "skipped"; doc_type: CollectionDocType | "";
document_no: string; doc_date: string; supplier_name: string;
po_refs: string[]; lines: CollectionDocLine[]; notes: string[]; findings: string[];
model: string; error: string; read_at: string;
}
export type SameItem = "sure" | "likely" | "different";
export interface CollectionAiCheck {
read_id: string; document_no: string; doc_date: string; doc_type: CollectionDocType;
doc_line: CollectionDocLine | null; same_item: SameItem | null; reason: string;
sku_filled_value: string; findings: string[]; checked_at: string; model: string;
}
export interface CollectionAiRunSummary {
started_at: string; finished_at: string; files_read: number; cards_checked: number; errors: string[];
}
```
**Interfaces — Produces on `ConfirmRow`:** `ai_check?: CollectionAiCheck | null`, `ai_doc?: { read_id: string; doc_type: CollectionDocType; document_no: string; doc_date: string; supplier_name: string; findings: string[] } | null`, `in_pos?: boolean | null`, `pos_stock_item_id?: string | null`.
**Interfaces — Produces on `AiSettings` / `AiSettingsUpdate`:** `collection_ai_enabled: boolean` (default false), `collection_ai_prompt: string` (default "", sliced to 50000), `collection_ai_last_run_at: string`, `collection_ai_last_run_summary: CollectionAiRunSummary | null`. Also `export async function recordCollectionAiRun(fields: { last_run_at?: string; summary?: CollectionAiRunSummary }): Promise<void>`, mirroring `recordQuoteAssistantRun`.
- [ ] **Step 1: Write failing tests** in `ai-settings-service.test.ts`.
- A row without the new fields reads back `collection_ai_enabled: false`, `collection_ai_prompt: ""`, `collection_ai_last_run_summary: null`.
- `updateAiSettings({ collection_ai_prompt: "x".repeat(60_000) })` sends 50000 characters.
- `recordCollectionAiRun({ last_run_at: "2026-10-17T10:00:00.000Z" })` sends exactly that one key and refreshes the cache.
- [ ] **Step 2: Run the tests and confirm they fail.**
Run: `npx vitest run src/lib/server/ai-settings-service.test.ts`. Expected: FAIL.
- [ ] **Step 3: Implement the types, settings, migration script and hook edit.**
- Model the migration on `scripts/migrations/2026-10-16-add-quote-assistant-fields.ts`: idempotent, `import "./_env-loader"`.
- It creates the collection `collection_doc_reads` with:
- text fields `source_collection`, `source_id`, `filename`, `content_hash`, `status`, `doc_type`, `document_no`, `doc_date`, `supplier_name`, `model`, `error`, `read_at`;
- text field `error` with max 2000;
- json fields `po_refs`, `lines`, `notes`, `findings`, each with maxSize 200000;
- a unique index on `content_hash`;
- a non-unique index on (`source_collection`, `source_id`, `filename`);
- list/view rules `@request.auth.id != ""` and create/update/delete `null`.
- It adds json `ai_check` (maxSize 50000) to `order_items` and `stock_purchase_items`.
- It adds the four `ai_settings` fields, with `collection_ai_prompt` text max 50000.
- Add `"ai_check"` to `IGNORED_FIELDS` in the hook utils.
- [ ] **Step 4: Run the tests and the type check.**
Run: `npx vitest run src/lib/server/ai-settings-service.test.ts && npx tsc --noEmit`. Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): data model, settings, migration`.
---
### Task 2: Tick logic (pure, client-safe)
**Files:**
- Create: `src/lib/collection-ai-ticks.ts`
- Create: `src/lib/collection-ai-ticks.test.ts`
**Interfaces:**
- Consumes: `CollectionAiCheck` (Task 1).
- Produces:
```ts
export type TickState = "green" | "amber" | "red" | "grey";
export interface LineTicks { sku: TickState; qty: TickState; desc: TickState; price: TickState }
export type TickRow = { sku?: string; qty_collected?: number; cost_price?: number | null; order_qty?: number; quantity?: number };
export function normaliseCode(code: string | null | undefined): string; // uppercase, A-Z0-9 only
export function computeLineTicks(row: TickRow, check: CollectionAiCheck): LineTicks;
export function allGreen(t: LineTicks): boolean;
export function showsAiFilledSku(row: TickRow, check: CollectionAiCheck | null | undefined): boolean;
```
- The file imports only types from `@/lib/types`.
- [ ] **Step 1: Write failing tests** with these exact cases.
- **No pairing:** `doc_line: null` gives all four `"red"`.
- **SKU tick:**
- green when `normaliseCode(row.sku) === normaliseCode(doc_line.code)` (e.g. `"sm293306016"` vs `"SM293306016"`);
- green when `row.sku === check.sku_filled_value` and that value is non-empty;
- amber when the row SKU is non-empty and different from the code;
- grey when `doc_line.code` is empty;
- red when the row SKU is empty and the code is non-empty (the AI couldn't fill it).
- **Qty tick:**
- green when doc qty 40, collected 40 and ordered 40;
- amber when doc qty 5, collected 5, `order_qty` 8;
- red when doc qty 4, collected 3;
- grey when doc qty is null;
- "ordered" uses `quantity` when `order_qty` is 0.
- **Desc tick:** `same_item` `"sure"` gives green, `"likely"` amber, `"different"` red, and null red.
- **Price tick:**
- doc `unit_price` 0.2 vs `cost_price` 0.20 gives green;
- 0.33 vs 0.345 gives red;
- 14.51 vs 14.515 gives green (within 0.01);
- `doc_type` `"delivery_note"` gives grey;
- a null `unit_price` gives grey;
- a null `cost_price` gives red.
- **`allGreen`:** true only when all four are green.
- **`showsAiFilledSku`:** false after a rep changes the SKU away from `sku_filled_value`.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/collection-ai-ticks.test.ts`.
- [ ] **Step 3: Implement the module.**
- [ ] **Step 4: Run the tests.** Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): line tick rules`.
---
### Task 3: Document findings (pure)
**Files:**
- Create: `src/lib/server/collection-ai-findings.ts`
- Create: `src/lib/server/collection-ai-findings.test.ts`
**Interfaces:**
- Consumes: `CollectionDocRead`, `CollectionDocLine`; `supplierNamesMatch` from `stock-purchase-invoice-matching-service.ts`.
- Produces:
```ts
export type PoLineLite = {
id: string; po_number: string; description: string; collected: boolean;
order_qty: number; quantity: number; qty_collected: number;
};
export type LinePair = { doc_line_no: number; po_line_id: string | null };
export function buildDocFindings(input: {
read: Pick<CollectionDocRead, "document_no" | "supplier_name" | "lines">;
resolvedPos: Array<{ po_number: string; supplier_name: string }>;
fellBackToPo: string | null; // set when no printed PO ref resolved
poLines: PoLineLite[]; // all lines on resolvedPos, any status
pairs: LinePair[];
}): string[];
```
- [ ] **Step 1: Write failing tests.** Each case asserts the exact string. Model them on the NSS fixture (document `5028256`; POs `PO202610003CEM` and `PO202610009CAS`).
- Two resolved POs: `"Document 5028256 covers 2 POs: PO202610003CEM, PO202610009CAS."`
- A paired PO line with `collected: false` (M8x25, document qty 40): `"M8x25 stainless steel × 40 is on the document but not marked collected."`
- A document line with no PO line: `"Document line \"<description>\" doesn't match any PO line."`
- A collected PO line not paired: `"<description> was marked collected but isn't on the document."`
- Supplier differs (`supplierNamesMatch` is false): `"Supplier on the document (<doc>) differs from the PO supplier (<po>)."`
- A document line with `to_follow: 0` where the PO line's ordered is greater than collected: `"<description>: supplier shows nothing to follow, but <ordered − collected> are still outstanding."`
- `order_qty` 4 and `quantity` 40 on a paired line: `"<description>: order line says 40 but the PO quantity is 4."`
- `fellBackToPo` set: `"No PO number on the document matched; checked against <po>."`
- Findings come out in the order listed above.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/collection-ai-findings.test.ts`.
- [ ] **Step 3: Implement `buildDocFindings`.**
- [ ] **Step 4: Run the tests.** Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): document findings`.
---
### Task 4: Vision read of one document
**Files:**
- Create: `src/lib/server/collection-ai-prompt.ts` (`DEFAULT_COLLECTION_AI_PROMPT`)
- Create: `src/lib/server/collection-ai-read.ts`
- Create: `src/lib/server/collection-ai-read.test.ts`
**Interfaces:**
- Consumes: `getActiveAiConfig`, `getAiSettings` (`collection_ai_prompt`).
- Produces:
```ts
export const SUBMIT_DOCUMENT_READ_TOOL: { name: "submit_document_read"; description: string; input_schema: object };
export type DocumentReadResult = Pick<CollectionDocRead,
"doc_type" | "document_no" | "doc_date" | "supplier_name" | "po_refs" | "lines" | "notes"> & { model: string };
export function parseDocumentRead(raw: unknown): Omit<DocumentReadResult, "model">;
export async function readDocumentImage(
image: { base64: string; contentType: string },
deps?: { client?: Pick<Anthropic, "messages"> },
): Promise<DocumentReadResult>;
```
- [ ] **Step 1: Write failing tests.** Inject a fake client, as `quote-assistant-ai.test.ts` does.
- **Request shape:** the request sends one image block plus a text instruction, with `tool_choice: { type: "tool", name: "submit_document_read" }`. The system prompt is `DEFAULT_COLLECTION_AI_PROMPT` unless `collection_ai_prompt` is non-blank.
- **`parseDocumentRead` on the NSS fixture:** a transcribed tool input with 4 lines, `unit_price` 0.95 / 0.33 / 0.2 / 14.51 and `po_refs` `["PO202610003CEM", "PO202610009CAS"]`. It returns them unchanged. `line_no` is assigned 1..n when missing.
- **Defensive parsing:**
- a bad `doc_type` becomes `"other"`;
- non-numeric quantity or price becomes null;
- a missing `lines` becomes `[]`;
- strings are trimmed;
- `doc_date` that isn't ISO `YYYY-MM-DD` becomes `""`.
- **Errors:**
- `stop_reason === "max_tokens"` throws `"AI response was cut off (max_tokens)"`;
- no tool block throws `"AI returned no submit_document_read tool call"`.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/collection-ai-read.test.ts`.
- [ ] **Step 3: Implement.**
- Build the client like `quote-assistant-ai.ts` (`newAnthropic`).
- Set `max_tokens` to `Math.max(aiConfig.maxTokens, 4096)`.
- The default prompt is plain text, with this exact content:
```
You read photos of South African supplier documents (tax invoices and delivery notes) that a driver took when collecting goods. Return exactly what is printed via the submit_document_read tool.
Rules:
1. doc_type: "tax_invoice", "delivery_note", or "other" (a photo of goods, not paperwork).
2. document_no: the invoice or delivery-note number printed as the document's own number (not account or VAT numbers). date: the document date as YYYY-MM-DD.
3. po_refs: every customer order / PO number printed (often under "Customer PO", "Customer Order No" or "Your Ref"). Split values like "PO202610003CEM/PO202610009CAS" into separate entries.
4. lines: one entry per item line. code = the supplier's stock code; description as printed; quantity = quantity supplied; unit as printed.
5. unit_price must be the price for ONE unit. If the document prices per 100 (or per 1000, per box of N), divide accordingly and say so in price_note (e.g. "printed 33.00 per 100").
6. to_follow: the "to follow" / back-order quantity if printed, else null.
7. Never guess a number you cannot read clearly: use null. The photo may be rotated or partly covered.
```
- [ ] **Step 4: Run the tests.** Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): vision read of driver documents`.
---
### Task 5: Pairing document lines with PO lines
**Files:**
- Create: `src/lib/server/collection-ai-pair.ts`
- Create: `src/lib/server/collection-ai-pair.test.ts`
**Interfaces:**
- Consumes: `CollectionDocLine`, `SameItem`, `PoLineLite` (Task 3; extend locally with `sku`, `po_description`, `cost_price`, `status`).
- Produces:
```ts
export type PairPoLine = PoLineLite & { sku: string; po_description: string; cost_price: number | null; status: string };
export type PairDecision = { doc_line_no: number; po_line_id: string | null; same_item: SameItem | null; reason: string };
export function parsePairs(raw: unknown, docLineNos: ReadonlySet<number>, poLineIds: ReadonlySet<string>): Map<number, PairDecision>;
export async function pairDocumentLines(
docLines: CollectionDocLine[], poLines: PairPoLine[],
deps?: { client?: Pick<Anthropic, "messages"> },
): Promise<{ pairs: Map<number, PairDecision>; model: string }>;
```
- [ ] **Step 1: Write failing tests.**
- **Unknown ids:** a `po_line_id` not in `poLineIds` becomes `po_line_id: null, same_item: null` (Review Focus #4).
- **Unknown line numbers:** a `doc_line_no` not in `docLineNos` is dropped.
- **Duplicate PO line:** a PO line paired to two document lines keeps the first; the second gets `po_line_id: null`.
- **Bad values:** an invalid `same_item` becomes null.
- **No PO lines:** with an empty `poLines`, `pairDocumentLines` makes no AI call and returns every document line unpaired.
- **Request shape:** the request text lists each PO line's id, description, PO description, SKU, ordered, collected and cost. It uses tool `submit_line_pairs`, and its system prompt says "pair only when it is the same product; wording differs between supplier and customer — judge the item, not the words".
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/collection-ai-pair.test.ts`.
- [ ] **Step 3: Implement.** Text-only, `max_tokens` 4096. Throw on `max_tokens` cut-off as in Task 4.
- [ ] **Step 4: Run the tests.** Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): pair document lines with PO lines`.
---
### Task 6: Collection AI service (card check, tick, list passthrough)
**Files:**
- Create: `src/lib/server/collection-ai-service.ts`
- Create: `src/lib/server/collection-ai-service.test.ts`
- Modify: `src/lib/server/collection-confirm-service.ts` (`listCollectedItems` attaches `ai_check`, `ai_doc`, `in_pos`, `pos_stock_item_id`)
- Modify: `src/lib/server/collection-confirm-service.test.ts`
**Interfaces:**
- Consumes: Tasks 1–5; `updateOrderItemSku`, `updateStockPurchaseItem`; `normalizePoReference`; `runWithAuditActor`; `getAiSettings`, `clearAiSettingsCache`, `recordCollectionAiRun`, `AiDisabledError`.
- Produces:
```ts
export const COLLECTION_AI_NOT_SET_UP_ERROR = "Collection checks are not set up yet: run `npm run pb:migrate:collection-ai` against PocketBase first";
export const COLLECTION_AI_DISABLED_ERROR = "AI is disabled in settings";
export const TICK_MAX_FILES = 10; export const TICK_TIME_BUDGET_MS = 90_000; export const TICK_LEASE_MS = 10 * 60_000;
export interface CollectionAiDeps {
readDocument?: typeof readDocumentImage; pairLines?: typeof pairDocumentLines;
fetchFile?: (rec: { collection: string; id: string }, filename: string) => Promise<{ base64: string; contentType: string; bytes: Buffer }>;
now?: () => Date;
}
export interface CardCheckResult { po_number: string; files_read: number; lines_checked: number; skus_filled: number; error: string }
export async function checkCollectionCard(poNumber: string, opts?: { actorName?: string; maxFiles?: number; deps?: CollectionAiDeps }): Promise<CardCheckResult>;
export async function runCollectionAiTick(deps?: CollectionAiDeps): Promise<{ ran: boolean; reason?: "disabled" | "running"; summary?: CollectionAiRunSummary }>;
```
- [ ] **Step 1: Write failing tests.** Use an in-memory PB stub (as `quote-assistant-service.test.ts` does) with `order_items`, `stock_purchase_items`, `stock_purchases`, `purchase_orders`, `collection_doc_reads`, `stock_items` and `collections.getOne`.
`checkCollectionCard`:
- **Not set up:** returns `COLLECTION_AI_NOT_SET_UP_ERROR` with no writes when `order_items` lacks `ai_check`.
- **AI off:** `AiDisabledError` from `readDocument` returns `COLLECTION_AI_DISABLED_ERROR` and writes nothing.
- **Reads are cached (Review Focus #1):**
- A file with an existing `collection_doc_reads` row (same source and filename) is not fetched or read again.
- Three identical files (same bytes) produce one read. The other two get rows pointing to the same content via `content_hash`; no second AI call.
- **PDFs:** a `.pdf` attachment gets a `status: "skipped"` row with note `"PDF not read yet"`, and no AI call.
- **NSS fixture (two POs, one photo on PO…003CEM's line):**
- PO references resolve to both POs.
- Every collected line on both POs gets `ai_check`.
- The read's `findings` include the "covers 2 POs" and "M8x25 … not marked collected" strings from Task 3.
- **SKU fill:**
- An empty SKU with `same_item` "sure" is filled via `updateOrderItemSku`, and `sku_filled_value` is recorded.
- An existing SKU is never changed (Review Focus #2).
- `same_item` "different" never fills.
- **Stock rows** fill through `updateStockPurchaseItem` with `{ sku }`.
- **Fallback:** no PO ref resolves, so it falls back to the card's PO, and the finding says so.
- **Audit:** every write runs inside `runWithAuditActor` with `name` `"AI collection check"` (or `"AI collection check (run by <actorName>)"`), `source: "agent"` and `ignoreRequestUser: true`.
`runCollectionAiTick`:
- Returns `disabled` unless both `enabled` and `collection_ai_enabled` are on.
- Applies the lease check `running` as in the quote assistant.
- Records `last_run_at` before work.
- Picks only collected lines whose image attachments lack reads or whose rows lack `ai_check`, grouped by PO number, newest first.
- Stops at `TICK_MAX_FILES` or the time budget.
- Isolates per-card errors into the bounded summary (20 entries of up to 300 characters each).
`listCollectedItems`:
- Attaches `ai_doc` from the read referenced by `ai_check.read_id`, including `findings`.
- Sets `in_pos: true` and `pos_stock_item_id` when a `stock_items` SKU normalises to the row's SKU.
- Stock rows always get `in_pos: true`.
- Rows without `ai_check` get `ai_check: null` and `ai_doc: null`.
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/collection-ai-service.test.ts src/lib/server/collection-confirm-service.test.ts`.
- [ ] **Step 3: Implement.**
- **Default file fetch:** the admin PB `files.getToken()`, then fetch `files.getURL(record, filename, { token, thumb: "1600x0" })`. Hash the full bytes with sha256 (`node:crypto`).
- **Choosing the read:** when several reads cover one line, the newest `read_at` with a pairing wins.
- **`in_pos` lookup:** chunk distinct SKUs (≤ 50) into OR'd `sku ~ "<code>"` filters on `stock_items`, then compare `normaliseCode` in the app.
- [ ] **Step 4: Run the tests and the full suite.**
Run: `npx vitest run` and `npx tsc --noEmit`. Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): card check, tick and list passthrough`.
---
### Task 7: Add to POS service and routes, plus the AI routes and MCP tool
**Files:**
- Modify: `src/lib/server/stock-items-service.ts` (add `createStockItem`, + test)
- Modify: `src/app/api/v1/stock/items/create/route.ts` (use the service; same request contract)
- Create: `src/app/api/v1/collection-confirm/add-to-pos/route.ts` (+ `route.test.ts`)
- Create: `src/app/api/v1/collection-confirm/ai/tick/route.ts` (+ test)
- Create: `src/app/api/v1/collection-confirm/ai/check/route.ts` (+ test)
- Modify: `src/app/api/mcp/route.ts` (+ list test), `src/lib/server/collection-confirm-service.ts` (`addRowsToPos`)
**Interfaces:**
- Produces:
```ts
// stock-items-service.ts
export type CreateStockItemInput = { name: string; sku?: string; description?: string; unit?: string; location?: string;
current_qty?: number; min_qty?: number; supplier?: string; notes?: string; category?: string;
cost_price?: number; selling_price?: number; markup?: number };
export async function createStockItem(input: CreateStockItemInput): Promise<StockItem>;
// collection-confirm-service.ts
export async function addRowsToPos(rowIds: string[]): Promise<Array<{ row_id: string; status: "created" | "already_in_pos" | "not_found"; stock_item_id: string | null }>>;
```
- **HTTP:**
- `POST /api/v1/collection-confirm/add-to-pos`: browser session, body `{ row_ids: string[] }` (1–100 ids), returns 200 `{ results }`.
- `POST /api/v1/collection-confirm/ai/tick`: agent auth.
- `POST /api/v1/collection-confirm/ai/check`: browser session, body `{ po_number }`. Returns 409 for `COLLECTION_AI_NOT_SET_UP_ERROR` / `COLLECTION_AI_DISABLED_ERROR`, otherwise 200 with `CardCheckResult`.
- **MCP:** `collection_confirm.ai_check({ po_number })` calls `checkCollectionCard(po, { actorName: "MCP" })`. It is not in `READ_TOOLS`.
- [ ] **Step 1: Write failing tests.**
- `createStockItem({ name: "M6 nylock", sku: "SM2NUT9B506", cost_price: 0.2 })` applies `applyDefaultMarkupToPatch` (selling 0.36, markup 80) and sets `current_qty` 0 and `is_active` true.
- The existing create route's test (or a new one) still accepts `costPrice` / `sellingPrice` / `markup` in camelCase.
- `addRowsToPos` for a customer row:
- creates the item with `name` = description, `description` = `po_description` or description, `sku`, `supplier`, `cost_price`, and `unit` from `ai_check.doc_line.unit` when present;
- returns `already_in_pos` when a normalised SKU match exists;
- returns `not_found` for an unknown id.
- Route tests cover 401, 400 (missing or empty `row_ids`, missing `po_number`), the 409 mapping, and the MCP tool classifying as "write".
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/lib/server/stock-items-service.test.ts src/app/api/v1/collection-confirm src/app/api/v1/stock src/app/api/mcp src/lib/server/giellie-tool-policy.catalogue.test.ts`.
- [ ] **Step 3: Implement the service, routes and MCP entry.**
- [ ] **Step 4: Run the tests.** Expected: PASS.
- [ ] **Step 5: Commit** with message `feat(collection-ai): add to POS, AI routes and MCP tool`.
---
### Task 8: Collection Confirm page UI
**Files:**
- Modify: `src/components/collection-confirm/items-table.tsx` (+ test)
- Modify: `src/components/collection-confirm/po-card.tsx` (+ test)
- Modify: `src/components/collection-confirm/confirm-invoice-dialog.tsx` (pre-fill only if needed)
- Modify: `src/app/(app)/collection-confirm/page.tsx` (handlers)
- Create: `src/components/collection-confirm/ai-findings.tsx` (+ test)
**Interfaces:**
- Consumes: `computeLineTicks`, `showsAiFilledSku` (Task 2); `ConfirmRow.ai_check` / `ai_doc` / `in_pos` (Tasks 1 and 6); the routes from Task 7.
- Produces:
- `ItemsTable` gains optional props `onAddToPos?: (rowId: string) => void` and `showAi?: boolean`.
- `PoCard` gains optional `onCheckWithAi?: () => Promise<void>` and `onConfirmAndAddToPos?: () => void`.
- `AiFindings({ findings }: { findings: string[] })` renders null for an empty list.
- [ ] **Step 1: Write failing component tests.**
- **No AI data (Review Focus #5):** a row with `ai_check: null` renders no tick icons and no robot icon. The card with no `ai_doc` renders no findings box and no document chip. The table renders its existing columns unchanged; a snapshot of the header cells is equal to before plus the new "AI" and "POS" headers only when some row has `ai_check` or `in_pos`.
- **Ticks:** a row with `ai_check` renders four tick icons whose `aria-label`s come from `computeLineTicks` (`"SKU: matches"`, `"Qty: have a look"`, `"Desc: doesn't match"`, `"Price: can't check"`). Each tooltip shows "ours vs document" values. The Desc tooltip shows both descriptions.
- **Robot icon:** shows next to the SKU only when `showsAiFilledSku` is true.
- **POS column:** "In POS" (with a link to `/stock/<pos_stock_item_id>`) when `in_pos`. Otherwise an "Add to POS" button calls `onAddToPos(row.id)`.
- **Document chip:** `"Tax invoice 5028256 · 16 Oct 2026 · read from driver photo"`. A delivery note uses `"Delivery note"`. Clicking the chip opens a panel listing the extracted document lines (code, description, qty, per-unit price, price note) beside the existing photo viewer (`invoice-viewer.tsx`).
- **Check with AI** calls `onCheckWithAi` and shows a spinner while pending.
- **Confirm buttons:** "Confirm and add to POS" renders only when at least one customer row is not in POS.
- **Pre-fill:** opening the confirm dialog from a card whose rows have `ai_check.document_no` "5028256" and `doc_date` "2026-10-16" shows those values pre-filled. With no `ai_check`, the dialog shows today's defaults (date today, number blank).
- [ ] **Step 2: Run and confirm they fail.**
Run: `npx vitest run src/components/collection-confirm`.
- [ ] **Step 3: Implement.**
- **Page handlers:**
- `handleCheckWithAi(poNumber)` POSTs to `/ai/check`, toasts the result or the 409 error, then reloads the feed.
- `handleAddToPos(rowIds)` POSTs to `/add-to-pos`, toasts `"Added N items to the POS"`, then reloads.
- "Confirm and add to POS" runs the existing confirm flow, then calls `handleAddToPos` with the card's not-in-POS customer rows only after the confirm succeeds.
- **Pre-fill:** the confirm dialog takes its initial values from the first row with `ai_check.document_no`.
- **Styling:** ticks use the `success` / `warn` / `destructive` / `muted` tokens with `CheckCircle2` / `AlertTriangle` / `XCircle` / `MinusCircle`. The robot uses lucide `Bot` with `text-ai`. Surgical edits only.
- [ ] **Step 4: Run the tests and checks.**
Run: `npx vitest run src/components/collection-confirm && npx tsc --noEmit`, plus eslint on the changed files. Expected: PASS, with no new lint errors.
- [ ] **Step 5: Commit** with message `feat(collection-ai): ticks, findings and POS buttons on Collection Confirm`.
---
### Task 9: Settings section, runbook, AGENTS.md
**Files:**
- Modify: `src/app/(app)/settings/ai/settings-form-helpers.ts` (+ test), `ai-settings-form.tsx`, `page.tsx`
- Create: `docs/collection-ai-checks.md`
- Modify: `AGENTS.md`
**Interfaces:**
- Form fields: `ca_enabled` (checkbox `"on"`), `ca_prompt` → `collection_ai_enabled`, `collection_ai_prompt`.
- [ ] **Step 1: Write failing parser tests.**
- `ca_enabled=on` gives true, and missing gives false.
- `ca_prompt` passes through untrimmed.
- The existing and `qa_*` fields are unchanged.
- [ ] **Step 2: Run, confirm they fail, then implement the parser.**
Run: `npx vitest run "src/app/(app)/settings/ai/settings-form-helpers.test.ts"`. Expected: FAIL, then PASS.
- [ ] **Step 3: Build the "Collection checks" card in the same form as "Quote assistant".**
- On/off switch.
- Prompt textarea with `maxLength={50000}`, "Reset to default" (`DEFAULT_COLLECTION_AI_PROMPT`) and a read-only preview.
- "Last run" line: `"<time> · <files_read> files read · <cards_checked> cards checked"`, or `"Never"`.
- Helper text: `"Needs the AI switch above to be on too. Reads driver photos on Collection Confirm and ticks each line; it never confirms anything."`
- [ ] **Step 4: Write the runbook and the AGENTS.md section.**
- `docs/collection-ai-checks.md` covers:
- what the feature does;
- the manual live steps: (1) `npm run pb:migrate:collection-ai`, (2) redeploy `pocketbase/pb_hooks/audit_log_utils.js` (`ai_check` ignored), (3) deploy, (4) activate the n8n "WFDASH Collection check tick", (5) Settings → AI → Collection checks on;
- what each tick colour means;
- the guard error.
- `AGENTS.md` gets a section of 6–10 lines with:
- the file map;
- the "manual first" rule;
- "AI writes only `ai_check`, doc reads, and empty SKUs";
- the manual live steps.
- [ ] **Step 5: Run final verification.**
Run: `npx vitest run && npx tsc --noEmit && npm run build`, plus eslint on the changed files. Expected: all PASS.
- [ ] **Step 6: Commit** with message `feat(collection-ai): settings section, runbook`.

View file

@ -1,315 +0,0 @@
# AI Order Assistant Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix the rule-based order↔quote matcher, then let MiniMax match each new order to its quote (auto-linking when sure) and pre-fill order lines from the matched quote lines as purple drafts.
**Architecture:** Phase 0 changes the pure scorer in `order-quote-match.ts` and splits `suggestQuoteForOrder` into a read-only `computeQuoteSuggestion` plus the write, so a clean-up script can dry-run. Phase 1 adds `order-assistant-{core,ai,prompt,service}.ts`, mirroring the quote assistant (pure core, one forced-tool AI call, orchestration with tick + run-one) and reusing the fixed matcher as the shortlist. Phase 2 adds the line fill and the purple review flow on the order Process page.
**Tech Stack:** Next.js (read `node_modules/next/dist/docs/` before touching routes), TypeScript, PocketBase (admin client `getAdminPocketBase`), Anthropic SDK against MiniMax (`getActiveAiConfig`), Vitest.
**Spec:** `docs/superpowers/specs/2026-10-24-ai-order-assistant-design.md`
## Global Constraints
- The AI never moves a line past `pending`, never creates POs, never sends anything, never changes order status.
- `orders.quote` is written only through `setOrderQuote` (`src/lib/server/orders-service.ts`), inside `runWithAuditActor({ name: "AI order assistant" }, …)`.
- Auto-link only when: setting `order_assistant_auto_link` on, AI `confidence === "high"`, every real order line paired with `same_item === "sure"`, all pairs on lines of the chosen quote, quote company = order company. Otherwise suggest.
- `potential_quote_confidence` mapping: high 0.9, medium 0.7, low 0.5.
- `quote_match_method` values: `ai_auto`, `ai_suggest`, `ai_no_match` (text field, no select change).
- Order line fill: `pricing_source = "ai_quote"`. Never overwrite a line with `cost_price > 0`, a person-set supplier, `pricing_source` `manual`/`quote`, or status ≠ `pending`. The customer's `selling_price` is never overwritten; >5% difference from the quote's selling price becomes a doubt.
- Pending review (purple): `ai_fill.status === "filled" && !ai_fill.reviewed_at && pricing_source === "ai_quote"`.
- Tick: at most 10 orders per run, 30 order lines per AI call, shortlist of 5 quotes, `ERROR_RETRY_MS` (1 h) after an error, default interval 10 min (min 5).
- New JSON/text PB fields get an explicit `max`/`maxSize` (text `max: 0` caps at 5000).
- New AI-written fields (`ai_quote_match`, `ai_fill`) go in `IGNORED_FIELDS` in `pocketbase/pb_hooks/audit_log_utils.js`.
- New server logic lives in `src/lib/server/*-service.ts` behind `/api/v1/*`; do not extend `src/lib/pb-helpers.ts`. If a page you touch reads the same data via `pb-helpers`, migrate that call in the same commit.
- Spec phase 2b (POS fallback for lines with no quote line) is **deferred**: no setting, no code in this plan.
- Never run MCP write tools or scripts with `--apply` against live PB during development; tests use mocks.
## Review Focus
- Order whose only lines are placeholders ("(see attached file - buyer to add items)") or cancelled → not eligible, no AI call, nothing written. (Task 4)
- A shortlisted quote already hard-linked to another order → dropped from the shortlist; if `setOrderQuote` still 409s, the result falls back to a suggestion, never an error. (Tasks 2, 6)
- A person types a cost on the Process page after the AI read the line but before it wrote → line re-read right before the write and skipped. (Task 10)
- AI returns an id it wasn't sent, or pairs two order lines to one quote line → unknown ids dropped, second claimant unpaired. (Task 5)
- Buyer adds lines to an order after an AI suggestion → `lines_hash` changes, the order is eligible again; an already-linked order is never re-matched but its new lines still get filled. (Tasks 4, 10)
---
## Phase 0 — fix the rule-based matcher
### Task 1: A quote needs a matching line to score
**Files:**
- Modify: `src/lib/server/order-quote-match.ts` (`scoreOrderVsQuote`)
- Test: `src/lib/server/order-quote-match.test.ts`
**Interfaces:**
- Produces: `scoreOrderVsQuote` unchanged signature; `confidence === 0` whenever no order line pairs with a quote line. `company`/`customer` points still reported in the breakdown.
- [ ] **Step 1: Write the failing tests** in a new `describe("scoreOrderVsQuote — needs a paired line")`:
```ts
it("scores 0 when company and customer match but no line pairs (270555-MOB vs Q002997)", () => {
const r = scoreOrderVsQuote(
{ companyId: "co", customerId: "cu", items: [{ description: 'Funnel 150mm PVC round 2 piece Flexible ("6") Round', selling_price: 16.64, quantity: 1 }] },
{ company: "co", customer: "cu", items: [{ description: "22mm endurance +", selling_price: 1655.76, quantity: 1 }] },
);
expect(r.company).toBe(RULE_POINTS.company);
expect(r.customer).toBe(RULE_POINTS.customer);
expect(r.confidence).toBe(0);
});
it("scores 0 against a quote with no lines", () => {
const r = scoreOrderVsQuote(
{ companyId: "co", customerId: "cu", items: [{ description: "M16 bolt", selling_price: 3, quantity: 10 }] },
{ company: "co", customer: "cu", items: [] },
);
expect(r.confidence).toBe(0);
});
it("still scores above threshold when one line pairs", () => {
const r = scoreOrderVsQuote(
{ companyId: "co", customerId: "cu", items: [{ description: "Flat washer M16 zinc", selling_price: 1, quantity: 100 }] },
{ company: "co", customer: "cu", items: [{ description: "Flat washer M16 zinc plated", selling_price: 1, quantity: 100 }] },
);
expect(r.confidence).toBeGreaterThanOrEqual(MATCH_CONFIDENCE_THRESHOLD);
});
```
- [ ] **Step 2: Run** `npx vitest run src/lib/server/order-quote-match.test.ts` — Expected: first two FAIL (0.5 / 1 instead of 0).
- [ ] **Step 3: Implement** — in `scoreOrderVsQuote`, when `pairs.length === 0` return the breakdown with `confidence: 0` (keep `raw` and the header fields as computed). Update the block comment above `RULE_POINTS` with one line: "No paired line → confidence 0; header signals alone never suggest a quote."
- [ ] **Step 4: Run** `npx vitest run src/lib/server/order-quote-match.test.ts src/lib/server/order-quote-link-service.test.ts` — Expected: PASS. If an existing test relied on header-only confidence, change its fixture to include a pairing line and say so in the commit message.
- [ ] **Step 5: Commit** `fix(orders): never suggest a quote that shares no line with the order`
### Task 2: Read-only suggestion + stale-suggestion clean-up script
**Files:**
- Modify: `src/lib/server/order-quote-link-service.ts`
- Create: `scripts/2026-10-24-rematch-quote-suggestions.ts`
- Test: `src/lib/server/order-quote-link-service.test.ts`
**Interfaces:**
- Produces:
- `computeQuoteSuggestion(orderId: string): Promise<SuggestQuoteResult>` — everything `suggestQuoteForOrder` does except `writeSuggestion`. Candidates whose quote is already `orders.quote` of a **different** order are dropped (one `getFullList` on `orders` with `quote` in the candidate ids, fields `id,quote`).
- `suggestQuoteForOrder(orderId)` = `computeQuoteSuggestion` + `writeSuggestion` (behaviour otherwise unchanged).
- `alternatives` stays `scored.slice(1, 6)` so best + alternatives gives up to 6; Task 6 takes the first 5.
- [ ] **Step 1: Write failing tests**
- `computeQuoteSuggestion` never calls `orders.update` (assert the mock's `update` not called) and returns the same `best` that `suggestQuoteForOrder` writes.
- A candidate quote that another order already links to is not returned as `best` or in `alternatives`.
- [ ] **Step 2: Run** `npx vitest run src/lib/server/order-quote-link-service.test.ts` — Expected: FAIL (`computeQuoteSuggestion` not exported).
- [ ] **Step 3: Implement** the split and the linked-elsewhere filter (chunk ids with the same `IN`/OR fallback approach as `fetchQuoteItemsForQuotesAdmin`, or simply 50 ids per OR-chain).
- [ ] **Step 4: Write the script** following `scripts/2026-10-22-repair-short-splits.ts` (`import "./_env"`, dry run unless `--apply`, `runWithAuditActor({ id: "claude-quote-rematch", name: "Claude (quote suggestion clean-up)" }, …)`):
- Select orders `potential_quote != "" && quote = ""`.
- Dry run: `computeQuoteSuggestion` per order; print `order_number`, old quote → new quote/none, and a summary: count per old `potential_quote` (expect the 16×/11× repeats to disappear), cleared, changed, unchanged.
- `--apply`: `suggestQuoteForOrder` per order, sequential.
- [ ] **Step 5: Run** tests + `npx tsc --noEmit` — Expected: PASS, no type errors. Do **not** run the script against live PB; the owner runs the dry run.
- [ ] **Step 6: Commit** `feat(orders): read-only quote suggestion and stale-suggestion clean-up script`
---
## Phase 1 — AI match
### Task 3: Fields, settings and audit ignore list
**Files:**
- Create: `scripts/migrations/2026-10-24-add-order-assistant-fields.ts` (copy the shape of `2026-10-16-add-quote-assistant-fields.ts`)
- Modify: `package.json` (script `pb:migrate:order-assistant`), `src/lib/types.ts`, `src/lib/server/ai-settings-service.ts`, `pocketbase/pb_hooks/audit_log_utils.js`
- Test: `src/lib/server/ai-settings-service.test.ts`
**Interfaces:**
- Produces (types in `src/lib/types.ts`):
- `OrderAiQuoteMatch { status: "matched" | "suggested" | "no_match" | "error"; quote_id: string | null; quote_number: string; confidence: "high" | "medium" | "low" | null; reason: string; doubts: string[]; lines: OrderAiLinePair[]; auto_linked: boolean; lines_hash: string; model: string; run_at: string; error?: string }`
- `OrderAiLinePair { order_line_id: string; quote_line_id: string | null; quote_id: string | null; same_item: "sure" | "likely" | "unsure" | null; reason: string }`
- `OrderItemAiFill { status: "filled" | "skipped" | "error"; source: "quote"; quote_item_id: string; quote_id: string; quote_number: string; cost_price: number; selling_price: number; markup: number; supplier_id: string; sku: string; reason: string; doubts: string[]; model: string; attempted_at: string; reviewed_at?: string; reviewed_by?: string; accepted_unchanged?: boolean; supplier_changed?: boolean }`
- `Order.ai_quote_match?: OrderAiQuoteMatch | null`, `OrderItem.ai_fill?: OrderItemAiFill | null`, `OrderAssistantRunSummary { started_at; finished_at; orders_seen; linked; suggested; no_match; lines_filled; errors: string[] }`.
- `AiSettings` + `AiSettingsUpdate`: `order_assistant_enabled` (false), `order_assistant_interval_minutes` (10, 5–1440), `order_assistant_auto_link` (true), `order_assistant_fill_lines` (true), `order_assistant_prompt` (""), `order_assistant_last_run_at` (""), `order_assistant_last_run_summary` (null).
- `recordOrderAssistantRun(p: { last_run_at?: string; summary?: OrderAssistantRunSummary }): Promise<void>` (mirror `recordQuoteAssistantRun`).
- Migration fields: `orders.ai_quote_match` json (maxSize 65536), `order_items.ai_fill` json (maxSize 16384), the seven `ai_settings` fields (prompt text max 50000, last_run_at text max 40, summary json).
- [ ] **Step 1: Write failing tests** in `ai-settings-service.test.ts`: defaults above when the row has none; interval < 5 rejected with `order_assistant_interval_minutes must be between 5 and 1440`; `order_assistant_auto_link` round-trips `false`.
- [ ] **Step 2: Run** `npx vitest run src/lib/server/ai-settings-service.test.ts` — Expected: FAIL.
- [ ] **Step 3: Implement** types, settings normalise/update, `recordOrderAssistantRun`, migration (idempotent, skips existing fields), `package.json` script, and add `ai_quote_match: 1, ai_fill: 1` to `IGNORED_FIELDS`.
- [ ] **Step 4: Run** tests + `npx tsc --noEmit` — Expected: PASS.
- [ ] **Step 5: Commit** `feat(orders): fields and settings for the AI order assistant`
### Task 4: Pure core — eligibility, outcome, fingerprint
**Files:**
- Create: `src/lib/server/order-assistant-core.ts`
- Test: `src/lib/server/order-assistant-core.test.ts`
**Interfaces:**
- Consumes: types from Task 3; `ERROR_RETRY_MS` from `quote-assistant-core`.
- Produces:
- `PLACEHOLDER_LINE_PATTERNS: RegExp[]` — at least `/see attached/i`, `/buyer to add items/i`.
- `isRealOrderLine(line: { description?: string; item_status?: string }): boolean` — false for empty description, a placeholder, or `item_status === "cancelled"`.
- `orderLinesHash(lines: Array<{ id: string; description?: string; quantity?: number }>): string` — stable over line order (sort by id), changes when a line is added or a description/qty changes.
- `type OrderItemLite = Pick<OrderItem, "id" | "description" | "quantity" | "item_status">` (exported).
- `isOrderEligible(order: { quote?: string; ai_quote_match?: OrderAiQuoteMatch | null }, lines: OrderItemLite[], opts: { now: Date; force?: boolean }): boolean` — no `quote`; ≥1 real line; ≥1 real line `pending`; not (`ai_quote_match.lines_hash === orderLinesHash(real lines)` unless `force`); not `status === "error"` within `ERROR_RETRY_MS` of `run_at`.
- `decideOutcome(args: { decision: AiOrderMatchDecision; realLineIds: string[]; quoteLineToQuote: Map<string, string>; orderCompany: string; quoteCompany: string | null; autoLinkEnabled: boolean }): "auto_link" | "suggest" | "no_match"` — implements the Global Constraints auto-link rule; `quote_id === null` → `no_match`; low confidence with no pair on the chosen quote → `no_match`.
- `confidenceToScore(c: "high" | "medium" | "low" | null): number` — 0.9 / 0.7 / 0.5 / 0.
- `AiOrderMatchDecision` (defined here, used by Task 5): `{ quote_id: string | null; confidence: "high" | "medium" | "low"; reason: string; doubts: string[]; lines: Array<{ order_line_id: string; quote_line_id: string | null; same_item: "sure" | "likely" | "unsure" | null; reason: string }> }`.
- [ ] **Step 1: Write failing tests** — table-driven for `decideOutcome`: all sure + high → `auto_link`; one line `likely` → `suggest`; one line unpaired → `suggest`; one pair on another quote's line → `suggest`; company differs → `suggest`; `autoLinkEnabled: false` → `suggest`; `quote_id: null` → `no_match`. `isOrderEligible`: placeholder-only order → false; all real lines `po_generated` → false; same hash → false, `force` → true; error 30 min ago → false, 61 min ago → true. `orderLinesHash` same for shuffled input, different after a qty change.
- [ ] **Step 2: Run** `npx vitest run src/lib/server/order-assistant-core.test.ts` — Expected: FAIL (module missing).
- [ ] **Step 3: Implement** the functions above (hash: sha1 of the sorted `id|description|quantity` lines via `node:crypto`).
- [ ] **Step 4: Run** — Expected: PASS.
- [ ] **Step 5: Commit** `feat(orders): order assistant eligibility and auto-link rule`
### Task 5: The AI call
**Files:**
- Create: `src/lib/server/order-assistant-prompt.ts`, `src/lib/server/order-assistant-ai.ts`
- Test: `src/lib/server/order-assistant-ai.test.ts`
**Interfaces:**
- Consumes: `AiOrderMatchDecision` (Task 4); `getActiveAiConfig`, `newAnthropic` as used in `collection-ai-pair.ts`.
- Produces:
- `DEFAULT_ORDER_ASSISTANT_PROMPT: string` — must say: a quote match needs matching items, not just the same customer; quantities may differ (part orders); prices may differ slightly; a line may have no quote line; answer only with ids that were given.
- `SUBMIT_ORDER_QUOTE_MATCH_TOOL` — forced tool `submit_order_quote_match`, schema = `AiOrderMatchDecision`.
- `MatchInput { order: { id; order_number; customer_name; lines: Array<{ id; description; po_description; quantity; sku; selling_price }> }; quotes: Array<{ id; quote_number; created; lines: Array<{ id; description; quantity; selling_price; sku; supplier_name }> }> }`
- `buildMatchUserText(input: MatchInput): string`
- `parseOrderQuoteMatch(raw: unknown, ids: { orderLineIds: Set<string>; quoteIds: Set<string>; quoteLineIds: Set<string> }): AiOrderMatchDecision` — unknown quote id → `quote_id: null`; unknown line ids dropped; a quote line claimed twice → second order line gets `quote_line_id: null`; order lines the AI skipped come back unpaired; bad confidence → `"low"`.
- `matchOrderWithAi(input: MatchInput, deps?: { client?: Pick<Anthropic, "messages">; prompt?: string }): Promise<{ decision: AiOrderMatchDecision; model: string }>` — throws on `max_tokens` cut-off or missing tool call (same messages as `pairDocumentLines`).
- [ ] **Step 1: Write failing tests** for `parseOrderQuoteMatch` (each rule above) and `matchOrderWithAi` with a fake client returning a tool_use block (happy path, missing tool call throws, `stop_reason: "max_tokens"` throws). Assert `buildMatchUserText` contains every order line id and quote line id and never the quote's `email_body`.
- [ ] **Step 2: Run** `npx vitest run src/lib/server/order-assistant-ai.test.ts` — Expected: FAIL.
- [ ] **Step 3: Implement.** `max_tokens: 4096`; prompt = `deps.prompt || DEFAULT_ORDER_ASSISTANT_PROMPT`.
- [ ] **Step 4: Run** — Expected: PASS.
- [ ] **Step 5: Commit** `feat(orders): AI call that matches an order to a quote`
### Task 6: Orchestration — match one order, and the tick
**Files:**
- Create: `src/lib/server/order-assistant-service.ts`
- Test: `src/lib/server/order-assistant-service.test.ts`
**Interfaces:**
- Consumes: `computeQuoteSuggestion` (Task 2), Task 4 core, `matchOrderWithAi` (Task 5), `setOrderQuote` (`orders-service.ts`), `fetchQuoteItemsForQuotesAdmin`, `runWithAuditActor`, settings + `recordOrderAssistantRun` (Task 3).
- Produces:
- `MatchOrderResult { orderId: string; outcome: "linked" | "suggested" | "no_match" | "skipped" | "error"; quoteId: string | null; error: string }`
- `OrderAssistantDeps { match?: typeof matchOrderWithAi; suggest?: typeof computeQuoteSuggestion; link?: typeof setOrderQuote; now?: () => Date }`
- `matchOrder(orderId: string, opts?: { force?: boolean; deps?: OrderAssistantDeps }): Promise<MatchOrderResult>`
- `runOrderAssistantTick(deps?: OrderAssistantDeps): Promise<{ ran: boolean; reason?: "disabled" | "not_due" | "running"; summary?: OrderAssistantRunSummary }>` — same due/lease/stamp logic as `runAssistantTick` (copy the constants `TICK_LEASE_MS`, `TICK_TIME_BUDGET_MS`), `TICK_MAX_ORDERS = 10`, oldest eligible first among orders with `quote = ""` created in the last 30 days.
- `NOT_SET_UP_ERROR` when `orders.ai_quote_match` is missing on the live PB (same field check as `hasAiFillField`).
`matchOrder` flow: load order + lines → `isOrderEligible` (else `skipped`) → shortlist = `best` + `alternatives`, first 5 → empty shortlist writes `ai_quote_match.status = "no_match"`, `quote_match_method = "ai_no_match"` with no AI call → load quote lines (+ supplier names) → `matchOrderWithAi` with real lines only, batches of 30 lines (same shortlist, decisions merged; overall confidence = lowest batch) → `decideOutcome` →
- `auto_link`: `runWithAuditActor({ name: "AI order assistant" }, () => setOrderQuote({ orderId, quoteId, actor }))`; then write each AI pair onto `order_items.quote_item` where it is still empty; `quote_match_method = "ai_auto"`, `auto_linked: true`. If `setOrderQuote` throws 409, fall through to suggest.
- `suggest`: `potential_quote`, `potential_quote_confidence = confidenceToScore(...)`, `quote_match_method = "ai_suggest"`; no `quote_item` writes.
- always: `ai_quote_match` with `lines_hash`, `model`, `run_at`; AI errors → `status: "error"`, `outcome: "error"`, never thrown.
- [ ] **Step 1: Write failing tests** with fake deps + the `makePb` mock pattern from `order-quote-link-service.test.ts`: auto-link calls `link` once and writes `quote_item` only on empty lines; 409 from `link` → `suggested`; medium confidence → `potential_quote` written, `link` not called; empty shortlist → `match` not called; AI throws → `ai_quote_match.status === "error"`; placeholder-only order → `skipped`, nothing written; tick returns `disabled` when either switch is off and stops at 10 orders.
- [ ] **Step 2: Run** `npx vitest run src/lib/server/order-assistant-service.test.ts` — Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** the file + `npx tsc --noEmit` — Expected: PASS.
- [ ] **Step 5: Commit** `feat(orders): AI order assistant matches orders to quotes`
### Task 7: Routes, MCP tool, n8n runbook
**Files:**
- Create: `src/app/api/v1/orders/assistant/tick/route.ts` (+ `route.test.ts`), `src/app/api/v1/orders/[id]/assistant/run/route.ts` (+ `route.test.ts`), `docs/order-assistant.md`
- Modify: `src/app/api/mcp/route.ts`, `AGENTS.md`
**Interfaces:**
- Consumes: `runOrderAssistantTick`, `matchOrder` (Task 6).
- Produces:
- `POST /api/v1/orders/assistant/tick` — `assertAgentAuth`, returns the tick result (copy `quotes/assistant/tick/route.ts`).
- `POST /api/v1/orders/{id}/assistant/run` — `getBrowserSessionUser(req)` (401 without), `matchOrder(id, { force: true })`; 409 for `NOT_SET_UP_ERROR` / AI disabled, like `quotes/[id]/assistant/run`. Later Task 10 adds the fill to this route.
- MCP `orders.assistant_match` `{ orderId: string }` → `matchOrder(orderId, { force: true })`, with a `TOOL_HINTS` entry. Not in `READ_TOOLS` (ordinary write, approval card in Giellie).
- [ ] **Step 1: Write failing route tests** (copy the quote-assistant route tests): 401 without auth on each route; tick passes through `{ ran: false, reason: "disabled" }`; run returns 409 with the not-set-up message.
- [ ] **Step 2: Run** `npx vitest run src/app/api/v1/orders/assistant src/app/api/v1/orders/\[id\]/assistant` — Expected: FAIL.
- [ ] **Step 3: Implement** routes and the MCP tool; read the relevant guide in `node_modules/next/dist/docs/` for route handler params first.
- [ ] **Step 4: Write `docs/order-assistant.md`** (structure of `docs/quote-assistant.md`): what it does, auto-link rule in plain words, n8n workflow "WFDASH Order assistant tick" (Schedule 5 min → `POST https://wfdash.apphub.co.za/api/v1/orders/assistant/tick`, same Header Auth credential as the quote assistant tick, retry off, timeout 120000 ms, inactive until deployed), and the live steps in order: migrate → `IGNORED_FIELDS` hook publish → deploy → clean-up script dry run then `--apply` → n8n → switch on. Add a short `# AI order assistant` section to `AGENTS.md` like the quote assistant one.
- [ ] **Step 5: Run** tests — Expected: PASS.
- [ ] **Step 6: Commit** `feat(orders): order assistant routes, MCP tool and runbook`
### Task 8: Order page — show the AI result, "Match with AI"
**Files:**
- Modify: `src/app/(app)/orders/[id]/page.tsx` (the quote match card, ~lines 1255–1520)
- Create: `src/components/orders/ai-quote-match-panel.tsx` (+ test if the repo tests components in that folder)
**Interfaces:**
- Consumes: `Order.ai_quote_match` (Task 3), `POST /api/v1/orders/{id}/assistant/run` (Task 7), `AiFillMarker` styling (`bg-ai/10`, lucide `Bot`).
- Produces: `<AiQuoteMatchPanel match={OrderAiQuoteMatch} orderLines={…} quoteLines={…} />` — shows "Linked by AI" (when `auto_linked` and `order.quote === match.quote_id`) or "AI suggests Q…", the reason, doubts as a list, and a line-pair table (order line → quote line, `same_item`).
- [ ] **Step 1:** In the match card: hard-link state with `quote_match_method === "ai_auto"` shows the panel with "Linked by AI" next to the existing Unlink; suggestion state with `ai_suggest` shows the panel above the existing Accept button; all states get a **Match with AI** button calling the run route with the PB token in `Authorization`, toast on 409 text, then reload the order.
- [ ] **Step 2:** `npx tsc --noEmit` and `npx vitest run` for any touched tests — Expected: PASS.
- [ ] **Step 3: Verify in the browser** (dev server on :4001, `agent-browser`): open an order with a mocked/locally-seeded `ai_quote_match`, check both states render and the button posts to the run route. Screenshot.
- [ ] **Step 4: Commit** `feat(orders): show AI quote match on the order page`
### Task 9: Settings → AI "Order assistant"
**Files:**
- Modify: `src/app/(app)/settings/ai/ai-settings-form.tsx`, `src/app/(app)/settings/ai/settings-form-helpers.ts` (+ test), `src/app/(app)/settings/ai/page.tsx` if it maps fields
**Interfaces:**
- Consumes: Task 3 settings fields; `DEFAULT_ORDER_ASSISTANT_PROMPT` (Task 5) for "Reset to default".
- Produces: section "Order assistant": enabled, run every (minutes), auto-link when sure, fill lines from the quote, prompt with Reset to default, last run summary — same layout as the Quote assistant section.
- [ ] **Step 1: Write failing helper test**: form → update payload includes the five editable `order_assistant_*` fields and omits `last_run_*`.
- [ ] **Step 2: Run** `npx vitest run src/app/\(app\)/settings/ai` — Expected: FAIL.
- [ ] **Step 3: Implement** the helper and the section.
- [ ] **Step 4: Run** — Expected: PASS; browser check of Save → reload keeps values.
- [ ] **Step 5: Commit** `feat(settings): order assistant settings`
---
## Phase 2 — fill order lines from the quote
### Task 10: Fill patch and `fillOrderFromQuote`
**Files:**
- Modify: `src/lib/server/order-assistant-core.ts`, `src/lib/server/order-assistant-service.ts`, `src/lib/server/orders-service.ts` (after `pairOrderLinesSafely` in `setOrderQuote`), `src/app/api/v1/orders/[id]/assistant/run/route.ts`
- Test: `order-assistant-core.test.ts`, `order-assistant-service.test.ts`
**Interfaces:**
- Produces:
- `isOrderLineWritable(line: OrderItem): boolean` — `item_status === "pending"`, `!(Number(cost_price) > 0)`, `pricing_source` not `manual`/`quote`, `ai_fill?.reviewed_at` empty.
- `buildOrderLineFillPatch(line: OrderItem, quoteLine: QuoteItem, ctx: { quoteNumber: string; reason: string; model: string; now: Date }): Record<string, unknown> | null` — `null` when not writable or the quote line has no cost; otherwise `cost_price`, `supplier` (only if empty), `sku` (only if empty), `matched_price_list_id` (only if empty), `selling_price` (only if line has none), `markup` + `markup_percent` from the resulting cost/selling, `pricing_source: "ai_quote"`, `ai_fill` (`OrderItemAiFill`, `status: "filled"`). Doubt `customer price R{x}, quoted R{y}` when both prices > 0 and differ by more than 5%.
- `fillOrderFromQuote(orderId: string, deps?: OrderAssistantDeps): Promise<{ filled: number; skipped: number }>` — for lines with `quote_item`, **re-read each line right before writing**, write `buildOrderLineFillPatch`. Reason = the AI pair reason from `ai_quote_match.lines` if present, else "Paired with quote line by description". Runs inside `runWithAuditActor({ name: "AI order assistant" }, …)`. No AI call.
- When a person links (`setOrderQuote`) to the quote the AI suggested (`ai_quote_match.quote_id === quoteId`), the AI pairs are written onto empty `order_items.quote_item` **before** `pairOrderLines` runs, so the AI pairing wins over word matching; a person-set `quote_item` is never overwritten.
- Callers: `matchOrder` after a successful auto-link; `setOrderQuote` after pairing, only when `order_assistant_enabled && order_assistant_fill_lines` (failure logged, never fails the link); the tick for linked orders with writable lines that have a `quote_item` and no `ai_fill`; the run route after `matchOrder`.
- [ ] **Step 1: Write failing tests**: patch never overwrites a non-empty supplier or SKU; never writes `selling_price` when the line has one; returns `null` for `po_generated`, for `cost_price: 12.8`, for `pricing_source: "manual"`; adds the 5% doubt for 16.64 vs 18.20 and not for 16.64 vs 17.00; service skips a line whose re-read shows a cost.
- [ ] **Step 2: Run** `npx vitest run src/lib/server/order-assistant-core.test.ts src/lib/server/order-assistant-service.test.ts` — Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** the two files plus `src/lib/server/orders-service.test.ts` (or whichever file covers `setOrderQuote`) — Expected: PASS.
- [ ] **Step 5: Commit** `feat(orders): AI fills order lines from the matched quote`
### Task 11: Purple review on the order Process page and the Orders board
**Files:**
- Create: `src/lib/order-assistant-review.ts` (+ test)
- Modify: `src/app/(app)/orders/[id]/process/page.tsx` (row class, marker, `saveItem`), Orders board row component (find where order rows render on `/orders`), `src/components/ai-fill-marker.tsx` only if it needs a prop for "from quote"
- Test: `src/lib/order-assistant-review.test.ts`
**Interfaces:**
- Produces:
- `isOrderAiFillPending(item: Pick<OrderItem, "ai_fill" | "pricing_source">): boolean` (Global Constraints rule).
- `nextOrderPricingSourceOnSave(current: string | undefined): string` — keeps `"ai_quote"`, `"quote"`, `"catalogue"`; else `"manual"`.
- `buildOrderAiReviewPatch(item, saved: { cost_price: number; selling_price: number; supplier: string | null }, reviewer: { id?: string; name: string }, now: Date): { ai_fill?: OrderItemAiFill }` — same semantics as `buildAiReviewPatch`.
- `orderAiRowClass(item): string` → `"bg-ai/10"` when pending.
- `getOrderAssistantCounts(orderIds: string[]): Promise<Record<string, { pending: number; total: number }>>` in `order-assistant-service.ts`, exposed through the existing orders list data path, rendered with `AiBoardBadge`.
- [ ] **Step 1: Write failing tests** for the four client helpers (pending only while unreviewed and `ai_quote`; a stale save keeps `ai_quote`; review patch sets `accepted_unchanged` true when cost and selling match to the cent).
- [ ] **Step 2: Run** `npx vitest run src/lib/order-assistant-review.test.ts` — Expected: FAIL.
- [ ] **Step 3: Implement** the helpers; on the Process page apply `orderAiRowClass`, show `AiFillMarker` with quote number (link to the quote), reason, doubts; route the line save through `nextOrderPricingSourceOnSave` + `buildOrderAiReviewPatch`; the existing "use this" copy sets `pricing_source: "quote"` and therefore ends the purple state. Add the board badge.
- [ ] **Step 4: Run** tests + `npx tsc --noEmit` — Expected: PASS.
- [ ] **Step 5: Verify in the browser** (:4001, `agent-browser`): a pending line is purple with the marker; saving it clears the purple; the board shows "AI x/y". Screenshot.
- [ ] **Step 6: Commit** `feat(orders): purple review for AI-filled order lines`
### Task 12: Full check
- [ ] **Step 1: Run** `npx vitest run` and `npx tsc --noEmit` and `npm run build` — Expected: all pass. Report failures with output; do not claim done otherwise.
- [ ] **Step 2:** Update `docs/order-assistant.md` with anything that changed while building; confirm the live-steps list matches the code (migration name, `IGNORED_FIELDS`, n8n URL, settings labels).
- [ ] **Step 3: Commit** `docs(orders): order assistant runbook final pass`

View file

@ -1,152 +0,0 @@
# Approve & Send from Pending-Approvals List — design spec
**Status:** Approved (verbal, 2026-08-27; awaiting written-spec review)
**Date:** 2026-08-27
**Owner:** Dave
---
## Summary
The Pending Approvals list page (`/quotes/approvals`) currently exposes "Approve" and "Approve partial" buttons per row that **stop short of sending** — they only flip the status to `approved` and tell the operator via toast to drill in to send. The detail-screen already has a one-step "Approve & send email" button that goes straight through `review-approve`. Lift that detail-screen behaviour onto the list rows. Operator clicks the row button once, the same `SendQuoteModal` opens, they confirm, and the email goes.
This trims one round-trip for the common case where the rep already trusts the quote.
---
## Motivation
Today, on the approvals list, the operator must:
1. Click "Approve" on the row → row leaves the list with status `approved`, toast says "proceed to send from the quote page"
2. Drill into the quote detail (now under `/quotes/[id]` or the orders page) and send separately
Most of the time the rep is ready to approve *and* send at the same moment. The detail page already supports this via "Approve & send email". The list should match.
---
## Decisions log
Resolved during brainstorming on 2026-08-27:
| Question | Decision |
|---|---|
| Which detail-screen button gets lifted? | The "Approve & send email" button — the one that opens `SendQuoteModal` and submits via `review-approve action: "approve"`. |
| Replace or supplement the existing list button? | Replace. "We never just approve it" — the standalone approve path on the list is no longer useful once the combined path exists. |
| Same treatment for partial rows? | Yes. The current "Approve partial" button also gets the combined treatment. `review-approve` already handles `partial_approval_pending` (flips to `partial_approved → sending → emailed`). The narrower `/partial-approve` endpoint is no longer called from the list. |
| Modal opens over list or replaces list? | Opens over the list (same modal component, no UI to clear it). Same affordance as today on the detail page. |
| Inline markup-edit affordance from list row? | Out of scope for v1. If the rep needs to nudge markup, they drill in (existing escape hatch preserved). |
| "Approve without send" toggle? | Deferred. Reuses the future `SendQuoteModal` toggle mentioned in conversation; not in this commit. |
---
## UI
### Files touched
- `src/app/(app)/quotes/approvals/page.tsx` — only file.
### Per-row button changes
| Row state | Current button | New button |
|---|---|---|
| `approval_pending` | "Approve" → `pb.collection.update` direct write | "Approve & send" → opens `SendQuoteModal`, then calls `review-approve action: "approve"` |
| `partial_approval_pending` | "Approve partial" → POST `/partial-approve` | "Approve & send" → opens `SendQuoteModal`, then calls `review-approve action: "approve"` (server handles partials) |
| Both | "Reject" → existing handler | "Reject" (unchanged) |
Button style matches the detail page's existing "Approve & send email" button — same label, same visual treatment (secondary button group typically). The detail page already passes `submitLabel="Approve & send"` to `SendQuoteModal`; the list passes the same.
### `SendQuoteModal` integration
The modal is reused unchanged. The list page needs:
- A piece of state per row: `openSendModalForRowId: string | null` (or a Map keyed by row id)
- A single `sendApprove` callback lifted to page level (same shape as the detail page's `sendApprove`), callable per row
- On success: refresh the list (`router.refresh()` or local state update), toast same as detail page
### Toast behaviour
Same as detail page — branched:
- Full approve success → `"Approved — email sent to the customer."`
- Partial approve success → `"Partial approved — email sent for the priced items."`
- Mail send failure (422) → `"Saved — but email send failed: <msg>. Retry from the quote page."`
- Race / already-approved (422 invalid_status) → parse `{error}` body, surface `"This quote was already actioned — refreshing."` (then refresh the list)
The existing toasts `"Quote approved — proceed to send from the quote page"` and `"<N> items marked approved — open the quote to send the email."` are removed; they describe the old broken two-step flow.
---
## API surface
### Reused (no new endpoints)
- `POST /api/v1/quotes/[id]/review-approve` with `action: "approve"`, optional `email{}`, optional `itemChanges[]` — already atomically approves and sends. Already handles full + partial quotes. Already rejects on `placeholder_customer`, `invalid_status`, mail-fail. The detail page calls this; the list page will too.
### No longer called from the list
- `POST /api/v1/quotes/[id]/partial-approve` — the list's "Approve partial" button used this; replaced by `review-approve`. The endpoint stays (used elsewhere in the app).
- Direct `pb.collection("quotes").update(...)` writes from the list — replaced by `review-approve`.
---
## Error handling
| Failure | Behaviour |
|---|---|
| `placeholder_customer` (422) | Modal disables the submit button on render (matches detail's `customerIsPlaceholder` gate). Operator drills in to fix. |
| `invalid_status` (422) — already approved by another rep, race | Surface toast: `"This quote was already actioned — refreshing."` then `router.refresh()`. |
| Mail send failure (422 — email sub-object) | Toast: `"Saved — but email send failed: <msg>. Retry from the quote page."` Row stays in list with `mail_failed` status (server flips it). |
| Network/5xx | Toast: `"Couldn't approve. Try again."` Keep modal open. |
| `customer` not found / record deleted (404) | Toast: `"This quote no longer exists. Refreshing."` then `router.refresh()`. |
---
## Tests
Component-level tests for the list page (`src/app/(app)/quotes/approvals/page.test.tsx` if it exists; if not, create a focused spec file that does NOT replicate the entire list page render — only exercises the button + modal interaction).
| # | Test |
|---|---|
| 1 | Clicking "Approve & send" on an `approval_pending` row opens `SendQuoteModal` with the quote pre-loaded |
| 2 | Submitting the modal calls `POST /api/v1/quotes/[id]/review-approve` with `action: "approve"` and an `email{}` payload taken from the modal |
| 3 | On 200, the list removes the row (filter no longer matches) and shows the success toast |
| 4 | On 422 `invalid_status`, the list refreshes and shows a toast |
| 5 | On 422 mail-failed, the toast reads "Saved — but email send failed..." |
| 6 | Clicking "Approve & send" on a `partial_approval_pending` row invokes the same endpoint and shows the partial-success toast |
`SendQuoteModal` itself is not re-tested — it's the existing component, unchanged.
`review-approve` endpoint tests already cover the success / placeholder / mail-fail branches in `src/app/api/v1/quotes/[id]/review-approve/route.test.ts` — re-run those after the merge to confirm no regression.
---
## Out of scope (deferred)
- "Approve without send" checkbox on `SendQuoteModal` — Dave mentioned this as a future toggle. Adds a payload flag and a separate code path on `review-approve`. Not in this commit.
- Inline markup-edit affordance on a list row — would require a new modal/dialog. Punt.
- Removing the standalone "Approve and don't send" path from the entire app (wherever else the operator "approves without sending" is a separate concern).
- Visual indicator on a list row showing whether the rep already nudged markup on the detail page (i.e. a hint that the quote is "trustworthy" for one-click). Deferrable polish.
---
## Rollout
Single deploy:
1. Replace the two handler blocks and two button labels in `src/app/(app)/quotes/approvals/page.tsx`.
2. Manual smoke: queue a quote, click "Approve & send" on the list, confirm modal opens with the right fields, submit, verify email goes out, verify row disappears from the list.
3. Same smoke for partial row.
---
## File structure
| File | Touched | Responsibility |
|---|---|---|
| `src/app/(app)/quotes/approvals/page.tsx` | Yes — only file modified | Replace per-row handlers + button labels; lift `sendApprove`; render modal at page level keyed by row id |
| `src/components/quotes/send-quote-modal.tsx` | No | Reused unchanged |
| `src/app/api/v1/quotes/[id]/review-approve/route.ts` | No | Endpoint already supports the call |
---
## Open questions
None at design time.

View file

@ -1,283 +0,0 @@
# Boss phone dashboard — design
**Date:** 2026-08-27
**Status:** Approved (pending implementation plan)
**Owner:** Dave (boss's view)
## Problem
The boss wants to keep track of where the business stands and what's
happening, on their phone. The existing `/dashboard` is desktop-first
(1074 lines, 6 chart types, 5 rows of cards) and the `/board` TV view is
designed for a wall-mounted display. Neither fits a phone glance.
## Goal
A standalone, phone-first page at `/m` that answers, in a single
glance, three questions:
1. How many quotes did each rep load, do for their accounts, and push through to emailed/approved/sent today / this week / this month?
2. How many orders did each rep place today / this week / this month?
3. How many driver stops are scheduled today, and how many are left?
The boss opens `/m` on their phone, sees the numbers refresh in
realtime, taps a rep row to verify the work behind a number, and
closes the app. Done.
## Non-goals (YAGNI)
- PWA install prompt UI (manifest exists but no "Add to home screen" button).
- Push notifications on KPI thresholds.
- Historical week-over-week comparison.
- CSV export / share.
- Settings screen.
- Stock alerts, follow-ups overdue, supplier PO queues — those live on
the operator dashboard. Out of scope per the user's explicit "just
the KPIs you listed" answer.
## Data model
Per-rep aggregations on the **rep perspective**:
| Stat | Definition |
|---|---|
| `quotesLoaded` | `quotes.claimed_by` = rep.id, count of rows where `created >= windowStart` and `created < windowEnd`. |
| `quotesDoneFor` | `quotes` joined to `customer` where `customer.rep` = rep.id, count of rows in window. |
| `quotesDoneBy` | `quotes.claimed_by` = rep.id, count of rows in window where `status` ∈ {`emailed`, `approved`, `sent`}. |
| `ordersPlaced` | `orders.rep` matches rep (PB id or resolved name), count of rows in window where `item_status` ∈ {`pending`, `po_generated`, `assigned`, `collection_pool`, `collected`, `delivered`, `invoiced`} (i.e. not cancelled). |
`window` is one of `today | week | month`:
- `today`: `[startOfDay(UTC), startOfTomorrow(UTC))`
- `week`: `[startOfWeek(UTC, monday), startOfNextWeek(UTC, monday))`
- `month`: `[startOfMonth(UTC), startOfNextMonth(UTC))`
Driver stops are always today-only regardless of the active tab.
The `orders.rep` field historically stored a free-text name and
sometimes a 15-char PB id. The service uses the existing
`resolveRepDisplayName` helper from `src/lib/server/order-rep.ts` to
normalise to a rep id, then joins on `users.id` — same pattern the
operator dashboard already uses for per-customer rep resolution.
Users with `claimed_by` / rep values that no longer resolve to a
`users` row are bucketed under a single "(unknown)" rep in the output
so the page never silently drops activity.
## Output shape
```ts
type BossDashboardData = {
window: "today" | "week" | "month";
rangeStart: string; // ISO
rangeEnd: string; // ISO
orderTotals: {
total: number; // orders created in window, not cancelled
delivered: number; // item_status === "delivered"
invoiced: number; // order.invoiced === true
};
reps: Array<{
id: string; // PB users.id, or "" for the (unknown) bucket
name: string; // "Gary Ndlovu" or "(unknown)"
isUnknown: boolean; // true → render muted, no avatar, no drill-down
quotesLoaded: number;
quotesDoneFor: number;
quotesDoneBy: number;
ordersPlaced: number;
}>;
drivers: Array<{
id: string; // PB users.id
name: string;
stopsToday: number;
stopsDone: number;
stopsLeft: number;
}>;
};
```
The endpoint returns ONE window at a time, not all three. Tab change →
new fetch with `?window=week`. Payload stays small (~3-5 KB).
## Files to create
```
src/lib/server/boss-dashboard-service.ts # getBossDashboardData({ window })
src/lib/server/boss-dashboard-service.test.ts # PB mocked, per-rep math + window boundaries
src/app/api/v1/boss-dashboard/route.ts # GET ?window=today|week|month, asserts session
src/app/api/v1/boss-dashboard/
└── rep-activity/route.ts # GET ?rep=<id>&window=<window>&kind=quotes|orders
# → paginated list for the bottom sheet
src/app/(mobile)/m/layout.tsx # phone shell, manifest link, no nav
src/app/(mobile)/m/page.tsx # server component, auth gate, initial fetch
src/app/(mobile)/m/dashboard-view.tsx # client: tabs, realtime, drill-downs
public/manifest-mobile.json # name, short_name, theme_color, display=standalone
```
## Files to modify
- `next.config.*` or `app/layout.tsx` — register `/m` layout's manifest link in the
document head. (Confirmed: adding a `<link rel="manifest">` inside
`(mobile)/m/layout.tsx` is enough; Next.js merges per-route head tags.)
## Architecture & data flow
1. **First paint (SSR):**
- Server Component reads the PB cookie via `cookies()` from `next/headers`
and calls `assertBrowserSession()` (no-arg variant for server components —
the route-handler variant `assertBrowserSession(req)` takes `NextRequest`).
On failure the page throws a redirect to the PB login route.
- `getBossDashboardData({ window: "today" })` runs against the admin PB client.
- `<BossDashboardView initial={...} />` renders with data — no spinner.
2. **Tab change (client):**
- User taps Week or Month.
- Client state updates `activeWindow`.
- `useEffect` on `activeWindow` calls `/api/v1/boss-dashboard?window=week`.
- Response replaces `data`, skeleton flashes for ~80ms while fetch in flight.
3. **Realtime (client):**
- On mount, subscribe to PB `customers`, `quotes`, `orders` collections
with `*` events.
- Trailing-edge debounce 1000ms → re-fetch current window.
- Unsubscribe on unmount.
4. **Drill-down:**
- Tap rep row → bottom sheet opens with two tabs (Quotes / Orders).
- Sheet calls `/api/v1/boss-dashboard/rep-activity?rep=<id>&window=<window>&kind=...`.
- Result is a flat array of `{ id, number, customer, status, age }` — page renders rows.
- Tap a row → opens `/quotes/<id>` or `/orders/<id>` in a new tab on the phone.
5. **Auth:** `assertBrowserSession` against the running PB browser session.
Boss logs in via the existing PB login page once. No new auth code, no new PIN.
Two flavours:
- **Page (server component):** `assertBrowserSession()` reads the PB cookie
from `next/headers`. Throws redirect on failure.
- **Route handler:** `assertBrowserSession(req)` takes `NextRequest`,
forwards the bearer token / cookie, returns 401 on failure.
## Components
### `(mobile)/m/layout.tsx`
```tsx
export default function MobileLayout({ children }: { children: React.ReactNode }) {
return (
<main className="min-h-screen bg-background text-foreground">
{children}
</main>
);
}
```
Also sets `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">` and `<link rel="manifest" href="/manifest-mobile.json">` via the route's head export.
### `(mobile)/m/page.tsx`
Server component:
```tsx
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export default async function BossDashboardPage() {
await assertBrowserSession();
const initial = await getBossDashboardData({ window: "today" });
return <BossDashboardView initial={initial} userName={user.name} />;
}
```
### `(mobile)/m/dashboard-view.tsx` (client)
Sections, top to bottom:
1. **Header strip** — "Good {morning|afternoon|evening}, {firstName}." (first name = `user.name.split(" ")[0]`, matching the operator dashboard pattern) with refresh button (rotates icon while loading) and `last-refreshed {timeAgo}` subtitle.
2. **Time tab bar** — segmented control. Today / Week / Month. Active tab highlighted with accent colour, others muted. Single tap re-fetches.
3. **Order totals strip** — three small tiles: total / delivered / invoiced for the active window. One row, three cells.
4. **Reps section** — header "Sales reps" with count. One card per rep, sorted `ordersPlaced DESC, quotesLoaded DESC`. Each card:
- Avatar + name (left)
- 4 metric cells (loaded / done-for / done-by / orders) right-aligned with label-under-number
- Progress bar underneath showing `ordersPlaced / totalOrdersThisWindow` as percentage
- Tap → bottom sheet
5. **Drivers section** — header "Driver stops today ({totalStopsToday})" — the count in parentheses is the sum of all drivers' `stopsToday`. One card per driver with stops-left badge coloured by urgency: red if `stopsLeft > 0 && stopsDone === 0` (nothing started yet), yellow if `stopsLeft > 0 && stopsDone > 0` (in progress), green if `stopsLeft === 0` (all done).
### Bottom sheet (`<dialog>` element)
Reuses the native `<dialog>` element with `[open]` toggling. Two tabs inside: Quotes / Orders. Each tab lazy-loads its data via the rep-activity endpoint. Rows render with avatar (initial), number, customer, status pill, relative age ("2h ago"). Tap row → `window.open(/quotes/<id>, "_blank")`.
## Empty / loading / error states
| State | Behaviour |
|---|---|
| Loading (first paint) | Server-rendered data, no spinner needed. |
| Loading (tab change) | Card-level skeleton, shimmer, ~80ms. |
| Error (PB 5xx) | Toast with retry button, single "Couldn't load" tile replaces the affected section. View keeps the previous data. |
| Empty reps | "No rep activity in this window." Card centred, muted text. |
| Empty drivers | "No driver stops scheduled today." |
| Network offline | Toast "Reconnecting…", disable refresh button. |
## Service contract
`getBossDashboardData({ window }: { window: "today" | "week" | "month" }): Promise<BossDashboardData>`
Throws on PB errors with a `.status` property. Caller (`route.ts`) catches and returns 500 with `{ error: "..." }` body. View renders error state on non-OK responses.
`getBossRepActivity({ rep, window, kind, page }): Promise<{ items: Array<...>, hasMore: boolean }>` — paginated 25 per page for the drill-down sheet.
## Testing
- **`boss-dashboard-service.test.ts`** — PB mocked via `pocketbase-admin` test pattern already in the repo. Cases:
- Per-rep math correct (loaded / done-for / done-by / orders) for sample fixture.
- Window boundary correctness: today rolls over at UTC midnight, week on Monday, month on day 1.
- Unknown rep bucketing: `claimed_by` strings that don't resolve to a `users.id` land in the `(unknown)` row.
- Empty rep list still returns valid shape.
- **`route.test.ts`** (one test file for both `/api/v1/boss-dashboard` and `/api/v1/boss-dashboard/rep-activity`):
- 401 when no session.
- 400 on missing/invalid `window`.
- 200 with valid payload.
- **No browser test in v1.** User has `agent-browser` configured and will verify in the running app.
## Refresh model
PB realtime + 1s trailing-edge debounce. Same as `/dashboard`. If the
debounce proves noisy on a phone (battery, data), we can add a "Pause
live updates" toggle as a follow-up.
## Out of scope reminders
Already listed. Re-emphasising here because the boss might be tempted
to ask for them at demo time:
- PWA install button.
- Per-rep drill-down to "orders by status" — current drill-down is a
flat list of records, not a per-status breakdown.
- Date scrubber — fixed today / this week / this month.
- Multi-tenant / white-label — single-tenant WFDASH, single boss.
## Open questions
None. All clarifying questions resolved during brainstorm:
- Rep perspective on quote metrics — confirmed.
- Reps-only as the user scope — confirmed.
- Same PB session auth — confirmed.
- Today + week + month tabs (no toggle) — confirmed.
- PB realtime + debounce — confirmed.
- Tap-row-to-list drill-downs — confirmed.
- Standalone PWA at `/m` — confirmed.
- Layout B (tabs top, single table) — confirmed.
- "Just the KPIs you listed" — confirmed.
## How to verify after implementation
1. `pnpm dev` — confirm no compile errors.
2. Open `http://localhost:4001/m` in the browser.
3. Confirm greeting + tab bar + reps + drivers render.
4. Switch tab — confirm data refreshes.
5. Open DevTools network tab, simulate a quote create in another tab —
confirm debounced re-fetch fires within ~1s.
6. Tap a rep row — confirm bottom sheet opens with quotes + orders.
7. Tap a record inside the sheet — confirm `/quotes/<id>` opens in a new tab.
8. Sign out, navigate to `/m` — confirm redirect to PB login.
9. `pnpm test src/lib/server/boss-dashboard-service.test.ts` — passes.
10. `pnpm test src/app/api/v1/boss-dashboard` — passes.

View file

@ -1,294 +0,0 @@
# Order ↔ delivery-note repeat-order detection
**Date:** 2026-08-29
**Status:** Draft — written directly to spec form per the user's request to save
review cycles; assumptions are called out explicitly below rather than
resolved through interactive brainstorming. Confirm or correct before handing
to an implementer.
## Problem
A recurring flow: an order is picked up and processed, a delivery note (DN)
is raised for it, a quote is generated from that DN and sent to the customer,
and the customer later sends back a fresh order for the same goods — a repeat
of that delivery. Today nothing connects the new order back to the DN it
repeats. The operator has no visual signal that "this order is basically
DN26071401 again," even though the evidence is already in the data.
## What already exists (confirmed by reading the code, not assumed)
- `delivery_notes` collection (`src/lib/types.ts:1069`): `delivery_note_number`
(unique, format `DN{YY}{MM}{DD}{NN}`), `customer`, `company`, `order`
(optional — the *original* order the items came from, Path A only),
`status` (`draft → sent → po_received → invoiced → completed`, or
`cancelled`), `delivery_date`, `customer_po`.
- **DNs have no separate line-item collection.** Items are `order_items` rows
tagged by string equality: `order_items.delivery_note_number ===
delivery_notes.delivery_note_number` (`delivery-notes-service.ts:141`,
`:197`). Each such row carries `description`, `quantity`, `selling_price`.
- `createQuoteFromDeliveryNote` (`delivery-notes-service.ts`, the `sent`
transition) creates a `quotes` row with `status: "delivery_note"` and sets
`quotes.delivery_note` (relation, `types.ts:268`) back to the DN. This is
"the quote we sent with the DN" the request refers to.
- The order↔quote auto-linking feature just built
(`docs/superpowers/plans/2026-08-29-order-quote-linking.md`) gives a
template to follow: a pure matcher module, a PB-backed service, a hook into
`createOrder`'s existing `after()` block, and a colored pill on
`/orders`. This feature reuses that shape rather than inventing a new one.
## Assumptions made to keep this a single, boundable spec
State these back if any is wrong — each would change the design materially.
1. **Match scope is `company`, per the request** ("customer won't work,
someone else at the company can place the order"). `delivery_notes.company`
is used directly — it does not need to be derived by joining through the
DN's original order.
2. **The primary signal is an exact match, not a fuzzy score.** "Items and
quantities match 100%" is read as a literal set-equality test: every order
line pairs to exactly one DN line with the same (normalized) description
and the same quantity, every DN line is paired, and no line is left over
on either side. This is deliberately stricter than the quote matcher's
Jaccard scoring — there is no "possible" tier here. Either it is a
complete match or it isn't shown at all.
3. **The quote-price signal is corroborating, not required.** Read from
"the first one will be the items exactly matches... because the order
will be an exact match": item+quantity equality alone is sufficient to
flag. The DN's priced quote is used only to break ties when an order's
items match more than one DN exactly (a customer who reorders the same
thing repeatedly).
4. **This is visibility only.** No change to order processing, pricing,
supplier assignment, or any automation triggered by a match. A wrong flag
costs nothing — the operator just ignores the pill. This justifies writing
the match automatically (no human confirmation step), unlike the quote
matcher's deliberately conservative "suggest, don't link" policy — that
policy existed because a wrong quote link puts wrong prices in front of a
buyer; nothing analogous is at stake here.
5. **Candidate DNs are scoped to `status` in `sent | po_received | invoiced |
completed`.** A `draft` DN was never sent to the customer, so an order
can't be "repeating" it yet. `cancelled` is excluded as abandoned.
6. **No backlog sweep in this iteration.** Only orders created from now on
get checked, mirroring how the quote-matching sweep was scoped as a
separate, later concern. Can be added the same way later if wanted.
7. **Detection piggybacks on the existing `createOrder` → `after()` hook**
rather than adding a second `after()` call, to reuse the already-reviewed
E468 guard rather than duplicating it.
**Correction to §3 while drafting this document — read before implementing:**
`getOrdersDashboardData` in `orders-service.ts` (the function backing the
`/api/v1/orders/list` route the orders page actually calls — the page does
*not* expand relations client-side) already made this exact tradeoff once
for `potential_quote` and documented it. Section 2 below covers the fix
(a denormalized number field); flagging it here too since it's the kind of
thing an implementer skimming past the schema section could miss and then
wire the pill to `order.expand?.potential_delivery_note` anyway, silently
reintroducing the cost this spec exists partly to avoid.
## Design
### 1. Pure comparison — `src/lib/server/delivery-note-order-match.ts`
No PocketBase, unit-testable in isolation, mirroring the separation already
established in `order-quote-match.ts`.
```ts
export interface DeliveryLine {
description: string;
quantity: number;
}
/** Normalize for comparison: trim, lowercase, collapse internal whitespace.
* Not the quote matcher's tokenize/Jaccard — this is exact-string equality
* on a normalized form, not fuzzy overlap. */
export function normalizeLine(s: string): string;
/**
* True iff every order line pairs to exactly one DN line with an equal
* normalized description and equal quantity, every DN line is paired, and
* neither side has anything left over. Order of lines does not matter
* (multiset equality, not sequence equality) — duplicate lines are handled
* by matching multiplicity, not just membership.
*/
export function isExactItemMatch(
orderLines: ReadonlyArray<DeliveryLine>,
dnLines: ReadonlyArray<DeliveryLine>,
): boolean;
```
### 2. Schema — new fields on `orders`, via migration script
Following the exact precedent of
`scripts/migrations/2026-06-27-add-order-quote-match-fields.ts` (idempotent,
`pb.collections.update` with a field-existence check, run as PB superuser).
**No PB admin UI step is needed** — these are relation/text/bool/number
fields, not `select` fields, so the `AGENTS.md` warning about manually adding
enum values in the PB admin does not apply here.
- `potential_delivery_note` — relation to `delivery_notes`, single, optional.
Mirrors the shape of the existing `orders.potential_quote`. For
programmatic use (joins, the on-demand re-check route, a future sweep) —
not read by the list pill (see the note below on why).
- `potential_delivery_note_number` — text, optional. **Denormalized copy**
of `delivery_notes.delivery_note_number`, written at the same time as the
relation. This is what the list pill actually renders. See rationale
below — this field exists specifically to avoid a second `expand` cost on
the orders list.
- `delivery_note_match_confidence` — number, optional. Always `1` when set;
kept for shape-consistency with the quote matcher and so a future fuzzy
extension doesn't need a second migration.
- `delivery_note_match_method` — text, optional. `"exact_items"` or
`"no_match"`. Text, not select, for the same forward-compatibility reason
`quote_match_method` is text.
- `delivery_note_matching_attempted` — bool, optional, default `false`.
**Why a denormalized number field, corrected from an earlier draft of this
spec:** `getOrdersDashboardData` (`orders-service.ts:253`) deliberately drops
`potential_quote` from its `expand` — the comment there states PB issues a
separate `IN` query per expanded relation, so the existing "Suggested" quote
pill shows only a static label, not the quote number, specifically to avoid
that extra round-trip on every list load and every realtime refetch. Adding
a second expanded relation (`potential_delivery_note`) would double that
cost the codebase already went out of its way to avoid.
But the request specifically asks for the DN *number* to be visible on the
pill — unlike the quote pill's generic "Suggested" label, this one is
supposed to read `DN26071401`. Denormalizing the number as a plain text
field sidesteps the tradeoff entirely: the list query needs zero additional
expands, and `delivery_note_number` is immutable once assigned (server-
generated, never edited per its type doc in `types.ts:1071-1078`), so there
is no staleness risk from copying it. `suggestDeliveryNoteForOrder` writes
both fields in the same update.
### 3. Service — `src/lib/server/order-delivery-note-link-service.ts`
```ts
export interface SuggestDeliveryNoteResult {
orderId: string;
best: { deliveryNoteId: string; deliveryNoteNumber: string } | null;
method: "exact_items" | "no_match";
}
export async function suggestDeliveryNoteForOrder(
orderId: string,
): Promise<SuggestDeliveryNoteResult>;
```
Behaviour:
1. Load the order and its `order_items` (description, quantity).
2. Query candidate `delivery_notes` where `company = order.company` and
`status` in the scoped set (§Assumption 5). Skip entirely if
`order.company` is empty — no signal to match on.
3. For each candidate, fetch its items (`order_items` where
`delivery_note_number = candidate.delivery_note_number`) and run
`isExactItemMatch`.
4. If exactly one candidate matches, that's `best`.
5. **If more than one candidate matches** (the tie case): for each tied
candidate, find its priced quote (`quotes` where `delivery_note =
candidate.id` and `status = "delivery_note"`), compare its `quote_items`
selling prices against the order's line prices for the same paired
descriptions. Prefer the candidate whose prices also match; if still
tied, prefer the most recently created DN.
6. Write `potential_delivery_note`, `potential_delivery_note_number`,
`delivery_note_match_confidence`, `delivery_note_match_method`,
`delivery_note_matching_attempted` — always stamping `attempted: true`
even on `no_match` (with the other fields cleared to null/empty),
mirroring the quote matcher's audit convention.
7. **Never throws out to the caller** — this is called from inside the
existing `createOrder` → `after()` guard, which already handles logging a
swallowed failure. This function itself should let errors propagate to
that caller rather than double-guarding.
### 4. Wiring into intake
In `orders-service.ts`, inside the existing `runQuoteMatch` closure built for
Task 6 of the quote-linking plan (`orders-service.ts:2226-2235`), add a
second call alongside `suggestQuoteForOrder`:
```ts
async function runQuoteMatch() {
try {
await suggestQuoteForOrder(order.id);
} catch (err) {
console.error(`[createOrder] quote auto-detection failed for order ${order.id}:`, err);
}
try {
await suggestDeliveryNoteForOrder(order.id);
} catch (err) {
console.error(`[createOrder] delivery-note match failed for order ${order.id}:`, err);
}
}
```
Two independent `try/catch` blocks, not one wrapping both — a delivery-note
match failure must not prevent the quote suggestion from landing, and vice
versa. Reuses the single `after()`/E468 guard already reviewed; does not add
a second `after()` call.
### 5. On-demand re-check (optional but recommended)
`POST /api/v1/orders/[id]/delivery-note-match` — same shape as the quote
matcher's single-order re-check endpoint, for the case where an order's
items were edited after intake. Thin wrapper calling
`suggestDeliveryNoteForOrder(id)`.
### 6. UI — orders list pill
`src/app/(app)/orders/page.tsx`, in the same badge row as the existing quote
pills (`:701-735`). New pill, additive — an order can show both a quote pill
and a delivery-note pill at once, they are independent facts. Reads the
denormalized `potential_delivery_note_number` text field from §2 — **no
`expand` needed, no extra query cost**:
```tsx
{order.potential_delivery_note_number && (
<span
title={`Matches delivery note ${order.potential_delivery_note_number} — likely a repeat order`}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{
background: "rgba(59, 130, 246, 0.10)",
color: "#3b82f6",
boxShadow: "inset 0 0 0 1px rgba(59, 130, 246, 0.30)",
}}
aria-label={`Matches delivery note ${order.potential_delivery_note_number}`}
>
<Truck className="h-3 w-3" />
{order.potential_delivery_note_number}
</span>
)}
```
Blue, distinct from the quote pills' teal (linked) and amber (suggested) —
this is a different kind of fact (a repeat-order observation, not a
provenance link) and should not read as a variant of the quote pill.
`potential_delivery_note_number` needs adding to `OrderWithItems` /
`Order` in `src/lib/types.ts`, and to whatever field allowlist (if any)
`getOrdersDashboardData` uses for the orders it returns — verify at
implementation time whether that function selects specific fields or
returns full records; §3's note about PB 0.26 dropping `expand` when
`fields` is set suggests it currently returns full records, in which case
no allowlist change is needed, but confirm rather than assume.
## Out of scope
- Any change to order processing, pricing, or supplier auto-fill based on
the match.
- A backlog sweep over existing orders (§Assumption 6).
- Surfacing the match on the order detail or process pages — list-only for
this iteration, since that's what was asked for ("visible... a pill").
- Human confirmation / promotion flow — there is no soft/hard distinction
here, unlike the quote matcher (§Assumption 4).
## Testing
- Pure comparison: unit tests for exact match, count mismatch, quantity
mismatch, description-only-differs-by-case-or-whitespace (should still
match), and duplicate-line multiplicity (two lines of the same item must
require two DN lines, not one).
- Service: mocked-PB tests mirroring `order-quote-link-service.test.ts`'s
harness style — single match, tie broken by price, tie broken by
recency, no match, empty company skips entirely.
- Intake wiring: a test proving a delivery-note match failure doesn't
prevent the quote suggestion (and vice versa) — the two-try/catch
requirement in §4 is the point of this test.
- UI: manual verification in the browser against a real order+DN pair.

View file

@ -1,243 +0,0 @@
# Order ↔ quote linking: auto-detection and per-line quote context
**Date:** 2026-08-29
**Status:** Approved design, not yet implemented
## Problem
Roughly 85% of orders arrive against a quote we already produced, but
nothing records that unless an operator links it by hand. Two
consequences:
1. Orders sit unlinked, so the quote's pricing, supplier choice and
history are invisible when the buyer places the order.
2. Where a link *does* exist, the buyer note and attachments the
estimator recorded against a quote line frequently fail to appear
on the corresponding order line.
## Current state
More exists than is apparent. What is already built:
- `src/lib/server/order-quote-match.ts` — pure scoring. Scores a quote
against an order on company, customer, per-line description overlap
(Jaccard), price tolerance and quantity, returning a `ScoreBreakdown`
that explains *why* a quote was chosen.
- `src/lib/pb-helpers.ts:530` — the PocketBase half of the matcher.
Five lookup paths: `quote_number`, `quote_reference`, `rfq_number`,
`external_number`, then a fuzzy company+description+price fallback.
- `src/app/(app)/orders/page.tsx:701` — the orders list already renders
a green "linked quote" pill and an amber "suggested match" pill.
- `src/app/(app)/orders/[id]/process/page.tsx:2449` — the process page
already renders the matched quote line's description, buyer note and
per-line attachments.
- `src/app/api/v1/orders/[id]/matched-attachments/route.ts` — already
returns `matchedQuoteItems` (including `notes`) and
`perItemAttachments` keyed by `quote_item` id.
### The three actual gaps
**1. The matcher never runs on its own.** It fires only when an
operator opens `/orders/[id]` and clicks the button
(`orders/[id]/page.tsx:331`). `createOrder`
(`src/lib/server/orders-service.ts:2080`) — the path the n8n email
intake uses — hard-codes `quote_matching_attempted: false` and
`potential_quote: null`. Intake orders arrive unmatched and stay
unmatched.
**2. The per-line link is never persisted.** `order_items.quote_item`
exists in the schema but is written only when an order is *created
from* a quote (`orders-service.ts:1292`, `pb-helpers.ts:1249`). When a
quote is linked to an existing order after the fact, the line-to-line
pairing is never saved.
**3. The pairing is re-guessed at render time, badly.**
`findMatchedQuoteItem` (`process/page.tsx:3159`) does case-insensitive
exact match, then either-side substring. No tokenising, no fuzzy —
despite `tokenize`/`jaccard` living in the same repo. So
`M20 HEX BOLT GR8.8` vs `Hex Bolt M20 Gr 8.8` resolves to nothing, and
the note and attachments silently disappear with no "no match" state.
The order *detail* page shows no quote context at all.
## Decisions
| Decision | Choice |
|---|---|
| Auto-link policy | Suggest only (`potential_quote`); hard link needs one human click |
| Trigger | At intake, plus a catch-up sweep for stragglers and backlog |
| Fuzzy search window | 90 days before the order's date; widen to 12 months only if nothing clears the bar |
| Line pairing | Save confident pairs to `order_items.quote_item`; surface doubtful ones flagged, unsaved |
| Where context shows | Order detail items table *and* the process page |
## Design
### 1. `src/lib/server/order-quote-link-service.ts` (new)
Ports the PocketBase-dependent half of the matcher out of
`pb-helpers.ts` into the service layer on `getAdminPocketBase()`, per
the data-access rules in `AGENTS.md`. The pure scoring in
`order-quote-match.ts` is reused untouched.
- `suggestQuoteForOrder(orderId)` — runs the lookup paths, writes
`potential_quote`, `potential_quote_confidence`, `quote_match_method`
and `quote_matching_attempted: true`. Never writes the hard `quote`
relation.
- `linkQuoteToOrder(orderId, quoteId)` — the one-click confirm. Sets
the hard link, then calls the pairer.
- `pairOrderLines(orderId, quoteId)` — writes `order_items.quote_item`
for confident pairs; returns doubtful pairs without writing them.
- `getOrderQuoteContext(orderId)` — per-line notes, attachments and
pairing state keyed by `order_item_id`.
### 2. Shared line-pairing algorithm
`pairLinesByDescription` (`order-quote-match.ts:169`) is currently
private. Export it so the line pairer and the order-level scorer share
one algorithm rather than growing a second one — the same
"DO NOT duplicate" discipline `order-item-context-service.ts` follows.
Two tiers on its existing Jaccard score:
- **≥ 0.5 — confident.** Written to `order_items.quote_item`.
- **0.3–0.5 — possible.** Surfaced with a confirm button. Not written.
- **< 0.3 — no match.** Shown as an explicit empty state.
Both thresholds already exist in the file (`descriptionFull` awards at
0.5; the candidate floor is 0.3), so this adds no new magic numbers.
### 3. Auto-detection at intake
`createOrder` calls `suggestQuoteForOrder` after its items are created,
wrapped so a matcher failure can never fail an order creation.
The current fuzzy path pulls up to `FUZZY_CANDIDATE_CAP` (200) quotes
plus their items, which is too slow to sit inline in the intake
response. The 90-day window (§4) is expected to cut the candidate set
to a handful, which likely makes inline execution fine.
**To verify during implementation:** measure real candidate counts
under the window. If they are still large, move the call to this Next
version's post-response work API — check
`node_modules/next/dist/docs/` for whether `after()` exists here and
under what name, rather than assuming.
### 4. Date-windowed fuzzy search
The `FUZZY_CANDIDATE_CAP` of 200 most-recent quotes behaves
inconsistently across customers: for a high-volume company it reaches
back weeks, for a quiet one it reaches back years. A date window
normalises this and matches how the business works — a quote is
normally converted within about two months.
Rules:
- **Measured from the order's `created`, not from now.** A
"last 90 days from today" filter would find zero candidates for every
order in the backlog sweep. The filter is quotes created within 90
days *before the order arrived*, plus exactly 7 days forward grace for
a quote raised just after the PO landed.
- **Fuzzy path only.** The four exact-identifier lookups are never date
limited. A PO that cites `Q-2024-0847` should resolve however old that
quote is — that is a citation, not a guess.
- **Widen rather than give up.** If no candidate clears
`MATCH_CONFIDENCE_THRESHOLD` in the 90-day window, retry once at 12
months before reporting no match. The extra query only runs on orders
that were about to fail anyway.
The window is an exported constant beside `FUZZY_CANDIDATE_CAP` so it
can be tuned in one place.
**To verify during implementation:** PocketBase's accepted date filter
format (`created >= "YYYY-MM-DD HH:MM:SS"`) against the running
instance.
### 5. Catch-up sweep
`POST /api/v1/orders/quote-match/sweep` — processes orders where
`quote_matching_attempted = false` and no hard link exists, in batches,
returning counts of suggested vs no-match.
Mirrored as an MCP tool, because the n8n/Hermes integration drops the
API key on direct REST calls and must go through `mcp__wfdash-mcp__*`.
The response also reports the **distribution of quote-to-order age
gaps** across the matches it found (e.g. "94% within 60 days, 99%
within 120"). This lets the 90-day window be re-tuned from real data
instead of an estimate. No schema change — it is a count computed
during the sweep.
### 6. Per-line context API
`matched-attachments/route.ts` gains one additive field: the resolved
pairing keyed by `order_item_id`, carrying the paired `quote_item` id,
its confidence, and whether it is saved or a guess. Additive so the
existing MCP `orders.list_attachments` consumer is unaffected.
This makes `findMatchedQuoteItem` redundant; it is deleted.
### 7. UI
**Order detail items table** (`orders/[id]/page.tsx:507`) — each row
gains a compact indicator in the Description cell: a note icon when the
quote line carries a buyer note, a paperclip with a count when it has
attachments. Clicking expands a row beneath showing the quote line's
description, the note and the attachment list. Rows with no paired
quote line show nothing extra.
**Process page** — keeps its existing "Quote source" panel, now fed by
the server-side pairing. Two states it lacks today:
- *"Possible match — is this the right quote line?"* with a confirm
button, for 0.3–0.5 pairs. Confirming writes `quote_item`.
- *"No matching line on the linked quote"* instead of rendering
nothing.
Both pages currently call `pb-helpers.ts`. Their quote-match calls move
to the new service in the same commit, per the migration rule in
`AGENTS.md`.
## Schema
**No PocketBase schema changes are required.** Every field used already
exists:
- `orders`: `quote`, `potential_quote`, `potential_quote_confidence`,
`quote_match_confidence`, `quote_match_method`,
`quote_matching_attempted`
- `order_items`: `quote_item`
`quote_match_method` is a `text` field, not a `select`, so new method
strings can be written without a PB admin change. (`AGENTS.md` warns
that `select` fields require manual admin work first — that hazard does
not apply here.)
## Testing
- Pairing tiers: unit tests against the existing fixtures in
`order-quote-match.test.ts`.
- Service functions: `order-quote-link-service.test.ts`, following the
pattern of the sibling `*-service.test.ts` files.
- Date window: explicit cases for an order older than the window, a
quote inside it, a quote outside it, and the widening fallback.
- UI: verified in the browser against the dev server on port 4001.
## Known consequences
The first sweep will surface suggestions across a large number of
historical orders at once, so the amber pill will appear widely on the
orders list. That is information rather than damage, but the list will
look noisy initially.
The 0.5 / 0.3 pairing tiers and the 90-day window are reasoned
estimates, not measurements. The sweep's age-gap histogram exists
specifically so both can be re-tuned against real data after the
backlog run.
## Out of scope
- Automatic hard-linking under any confidence. Every hard link stays a
human click.
- Changing how orders are created from quotes
(`orders.create_from_quote` already sets both the header link and
`quote_item` per line correctly).
- Any change to the quote-side pages.

View file

@ -1,202 +0,0 @@
# Non-Invoiceable Order Extras — Design
**Date:** 2026-08-30
**Status:** Approved, ready for implementation planning
## The problem
We quote a line as a single sellable unit — "M20 bolt, nut and washer" — and
the customer approves that one line at that one price. But the supply side
doesn't work that way: the bolt comes from one supplier, the nut and washer
from another. To raise the purchase orders we need three line items, not one.
Today the operator has no way to add those two extra lines. `order_items` rows
are the only unit the PO flow understands, so a component with no row of its
own can't be ordered, can't be assigned to a driver, and never shows as
collected. The operator works around it off-system.
Adding the rows naively creates the opposite problem: all three would flow to
the invoicing screen and QuickBooks would bill the customer for three lines
against a quote they approved as one.
## What we're building
A line item can be marked **excluded from invoice**. Such a line behaves like
any other order item everywhere in the operational flow — it gets a supplier, a
cost price, a purchase order, a driver assignment, and it shows as collected —
but it never appears on a customer-facing invoice or delivery note.
The customer's paperwork comes out byte-identical to what it looks like today.
## Decisions
These were settled during brainstorming and are not open for reinterpretation
during implementation.
| Question | Decision |
|---|---|
| Delivery note | **Excluded**, same as the invoice. Customer paperwork must agree. |
| Terminal status | Auto-flips to `invoiced` alongside its siblings. The order closes normally. |
| Where lines are added | Process page (`/orders/[id]/process`), for **any** line, with no `pending` gate. |
| Cost and margin | Cost counts as normal. `selling_price` is forced to `0` and locked. |
| Picking slip | **Included.** The picker must physically pull the washers. |
## Data model
One new field on `order_items`:
```
exclude_from_invoice : bool
```
**Polarity is load-bearing.** The flag must name the exception, not the norm.
A positively-named field (`bill_customer`, `invoiceable`) defaults to `false`
in PocketBase, which would silently mark every existing row in the database as
non-billable the instant the field is created. `exclude_from_invoice` defaults
to `false` = "bill this normally", so existing rows are unaffected.
Per `AGENTS.md`, the user adds this field via the PocketBase admin UI before
any code references it. `pb-exports/pb_schema-2.json` is a snapshot for
reference only.
No new `item_status` value is introduced. The existing select enum is
untouched, which avoids a second manual PB admin change and avoids updating
every status filter, pill, tab count and lifecycle comment in the app.
## Architecture
The design rests on a property of the existing invoice pipeline that makes the
whole feature small.
### The invoice split
`buildQbInvoicePayload` (`src/app/(app)/invoicing/view-model.ts:393`) builds two arrays from
one row list:
```ts
order_item_ids: input.rows.map((row) => row.id), // drives the PB status write
items: input.rows.map((row) => ({ ... })), // drives the QuickBooks lines
```
The n8n workflow consumes them independently. Its **"Build PocketBase Invoice
Update"** node maps over `order_item_ids` and patches each row with
`item_status: 'invoiced'` plus `order_item_details.invoice`. The QuickBooks
invoice itself is built from `items`.
So the change is: **excluded rows stay in `order_item_ids` and drop out of
`items`.** n8n flips them to `invoiced` with the batch and stamps them with the
same invoice number for audit; QuickBooks never learns they exist.
**No n8n changes are required.** This is the crux of the design — verified
against `n8n_workfows/1 - WFDASH QUICKBOOKS INVOICE1 - WFDASH QUICKBOOKS INVOICE.json`.
### The delivery note split
`/api/invoicing/delivery-note/route.ts` has the same shape. Excluded rows stay
in the status-flip loop (`src/app/api/invoicing/delivery-note/route.ts:230`), so they receive
`item_status: "delivery_note"` and the DN number; they drop out of the `lines:`
array that renders the PDF (`src/app/api/invoicing/delivery-note/route.ts:362`).
### Picking slips need no change at all
Both picking-slip routes query PocketBase by document identity, not by the row
list the operator submitted:
- `print-bundle/route.ts:88` — `order_item_details.invoice.number = "…" && item_status = "invoiced"`
- `delivery-note-print/route.ts:92` — `delivery_note_number = "…" && item_status = "delivery_note"`
Because excluded rows *do* receive the invoice number / DN number and *do*
reach the matching status, they are picked up by these queries automatically.
The picker sees the washers. This falls out of the design for free.
### The margin gate
`calculateOrderItemMarkup(cost, 0)` returns `-100`, far below the
`ORDER_MARKUP_APPROVAL_THRESHOLD` of `22`
(`src/lib/server/order-item-margin-approval-service.ts:4`). Without an
explicit bypass, every extra the operator adds would immediately land in
`approval_needed` and block the order pending manager review.
**This is the highest-risk part of the implementation.** The bypass must be
keyed on `exclude_from_invoice`, applied in the process page's save path
before the threshold comparison.
### Adding lines mid-flight
`createOrderItemForOrder` (`src/lib/server/orders-service.ts:3939`) currently throws a
409 `order_locked` if any line on the order is past `pending`. By the time the
operator discovers they need a washer line, the bolt line is already
`po_generated`, so this gate fires every time. It is removed.
The gate's original purpose — preventing a PO's pricing/supplier reference from
being orphaned — does not apply here. Adding a *new* row alongside an existing
PO doesn't mutate that PO; the new row gets its own PO when the operator
processes it.
## Flow
```
Operator opens /orders/[id]/process
│
├── Adds "M20 nut" with [x] Don't invoice
│ → selling_price forced 0, margin gate bypassed
│ → row created with exclude_from_invoice = true, item_status = pending
│
├── Normal flow: PO generated → driver assigned → collected → delivered
│ (identical to every other line; order "Complete" needs no change)
│
└── Invoicing screen
├── row renders muted with a "Not invoiced" chip, checkbox locked
├── Send to QuickBooks
│ → order_item_ids: [bolt, nut, washer] ← all three
│ → items: [bolt] ← one line billed
│ → n8n flips all three to `invoiced`
└── Print bundle
→ picking slip query finds all three by invoice number
→ picker pulls all three
```
## Order completion
Unchanged. `isOrderComplete` (`src/lib/server/order-completion.ts:20`) requires every
item to be `delivered`; excluded items reach `delivered` on the normal path.
`markOrderInvoiced` (`src/lib/server/orders-service.ts:3397`) requires every item to be
`invoiced`; excluded items get there via the n8n batch flip. Neither function
needs to learn about the flag.
## Error handling
- **All rows in a group excluded.** Block the send with a toast rather than
firing a zero-line invoice at QuickBooks. Applies to both the invoice and
delivery-note submit paths.
- **Operator un-ticks the only billable row** in the include-picker modal,
leaving only excluded rows. Same guard, same message.
- **Excluded row with a non-zero selling price** (legacy or hand-edited in PB
admin). The payload builder forces the exclusion regardless of price — the
flag is authoritative, price is not consulted.
## Testing
Unit tests, Vitest (`npm run test:run`):
- `view-model.test.ts` — `buildQbInvoicePayload` keeps excluded ids in
`order_item_ids` and omits them from `items`; an all-excluded row set is
detectable by the caller.
- `orders-service.test.ts` — `createOrderItemForOrder` succeeds against an
order with `po_generated` siblings; `exclude_from_invoice` and the zeroed
selling price persist.
- Delivery-note route test — excluded rows receive the status flip and DN
number but are absent from the rendered `lines`.
- Margin-gate test — an excluded line with cost > 0 and selling 0 does not
enter `approval_needed`.
## Out of scope
- Reporting changes. Extras eat margin because their cost is real and their
selling price is zero; that is the correct number and no report needs
adjusting to produce it.
- Retrofitting existing orders that were worked around off-system.
- Any change to the n8n QuickBooks workflow.
- Bulk-adding extras, or templating common component sets (bolt/nut/washer as
a saved group). Worth considering later if the operator finds themselves
typing the same three lines repeatedly.

View file

@ -1,203 +0,0 @@
# Quantity Handling Audit — Surplus, Shortfall and Partial Collection
**Date:** 2026-08-30
**Status:** Findings only. No design decisions taken yet.
Answers two operator questions:
1. We order 2 because of a supplier MOQ but the client only wanted 1. Does the
extra get booked into stock, and does the picking slip show the client only
the quantity that must actually go out?
2. The driver collects 2 of 5. What happens to the other 3? Does it go back to
the driver as outstanding, or is it lost?
---
## Question 1: MOQ surplus — mostly already works
**The good news: the picking slip already does exactly what you described.**
`computePickingSlipLine` (`src/app/(app)/invoicing/picking-slip.tsx:250`)
computes five columns per line:
| Column | Formula | Meaning |
|---|---|---|
| `ordered` | `order_qty` | what the client asked for |
| `collected` | `qty_collected` | what actually came in from the supplier |
| `pickQty` | `min(collected, ordered)` | **what goes out to the client** |
| `outstanding` | `max(ordered - pickQty, 0)` | still owed to the client |
| `toStock` | `max(collected - pickQty, 0)` | the MOQ surplus |
`pickQty` is capped at the client's quantity, so the picker is never told to
send out the surplus. Ordered 1, received 2 → pick 1, to stock 1. Exactly the
behaviour you asked for, already shipped.
The invoice agrees. `resolveInvoiceLineDefaults`
(`src/app/(app)/invoicing/view-model.ts:362`) bills `order_qty` and explicitly
refuses to bill `qty_collected`, with a comment naming the MOQ case as the
reason.
Surplus booking exists too: `src/lib/server/stock-surplus.ts` and
`POST /api/v1/stock/surplus-book`, invoked from `/collection-confirm`. It
increments `stock_items.current_qty`, writes a `purchase_surplus` stock
movement, and stamps `order_items.surplus_qty`. The booking is capped at
`qty_collected - customerQty` so it can't over-book.
### Gap 1.1 — surplus booking is manual and unprompted
Nothing makes the operator book the surplus. The picking slip prints
`toStock: 1`, and if the operator never clicks the button on
`/collection-confirm`, that unit is physically in the building and invisible to
inventory forever. There is no "unbooked surplus" indicator, no count, no
queue.
**Severity: medium.** Silent inventory loss, proportional to how often
operators forget.
### Gap 1.2 — no check that the full quantity actually arrived
You said "if the full quantity came in you must check that." There is no such
check. Nothing compares `qty_collected` against `order_qty` at any gate. See
Question 2 — this is the same root cause.
---
## Question 2: Partial collection — the missing units disappear
**Direct answer: the 3 outstanding units do not go back to the driver. They
are not tracked anywhere. The line is marked fully collected and moves on.**
Both paths that mark a line collected set `item_status = "collected"`
unconditionally, without comparing quantities:
**Driver app** — `src/lib/server/driver-stops.ts:620`:
```ts
const qtyCollected =
input.qty_collected !== undefined
? Math.floor(input.qty_collected)
: Number(lineTyped.order_qty ?? lineTyped.quantity ?? 0);
const patch: Record<string, unknown> = { qty_collected: qtyCollected };
if (input.action === "collected") {
patch.item_status = "collected"; // ← no quantity comparison
```
Driver reports 2 of 5 → `qty_collected: 2`, `item_status: "collected"`. The
stop leaves their list. Nothing re-queues it.
**Agent / MCP path** — `confirmOrderItemsCollected`
(`src/lib/server/order-items-service.ts:224`) is more careful, and then throws
the care away. It computes `remaining`, validates the partial doesn't overrun,
and writes a genuinely useful audit note — *"2 units added to qty_collected
(now 2/5)"* — before patching `item_status: "collected"` at line 318 regardless.
`remaining` is used only for validation and note text. It never holds the line
open.
### What happens next
The line is `collected`, so it flows to `ready_invoice` and lands on the
invoicing screen. There:
- The **picking slip is honest** — it shows `outstanding: 3`, because
`computePickingSlipLine` does the subtraction. Good.
- The **invoice is not** — `resolveInvoiceLineDefaults` bills `order_qty`,
which is 5. **You invoice the client for 5 units when 2 arrived.**
The operator *can* correct this: the invoicing modal supports per-line `qty`
overrides (`InvoiceLineOverride`). But the default is wrong and nothing flags
the discrepancy, so it depends entirely on someone noticing.
### Gap 2.1 — `splitOrderItemRemainder` is locked out exactly when needed
There is already a purpose-built tool for a shorted line:
`splitOrderItemRemainder` (`src/lib/server/order-items-service.ts:376`). It
creates a remainder line for the shortfall, supplier-less so it sorts to the
top of the process page as "needs supplier", and re-enters the normal PO flow.
It refuses unless the line is `pending` or `processed`:
```ts
if (!["pending", "processed"].includes(status)) {
throw new OrderItemSplitError(
`Cannot split an item in status "${status}" — only pending/processed lines are still editable.`, 409);
}
```
A short-collected line is `assigned` or `collection_pool`, so the split is
refused. **The mechanism that would solve this problem is unavailable in the
only situation where the problem occurs.** It was built for a different case:
the operator knowing up-front that supplier A can only supply 40 of 100.
### Gap 2.2 — `outstanding_since` is about age, not quantity
`driver-stops.ts:95` exposes `outstanding_since`, which looks like it tracks
this. It doesn't — it's the parent order's `created` date, used for
"outstanding N days" reporting. There is no quantity-outstanding concept
anywhere in the model.
---
## Cross-cutting: `order_qty` means two different things
This is the root cause underneath both questions, and the most dangerous thing
in this audit.
**`order_items.order_qty` has no doc comment** (`src/lib/types.ts:807`), while
the neighbouring `quantity` has eleven lines of it. Two subsystems consequently
disagree about what it means.
**Reading A — `order_qty` is what the CUSTOMER asked for:**
- `stock-surplus.ts:66` — *"`order_qty` is the canonical 'what the customer
asked for' field"*
- `view-model.ts:362` — invoice quantity
- `driver-stops.ts:313` — `quantity_ordered: order_qty ?? quantity`
- `picking-slip.tsx` — the `ordered` column
**Reading B — `order_qty` is what we're buying from THIS SUPPLIER:**
- `splitOrderItemRemainder` doc, `order-items-service.ts`: *"the process page
lets the operator enter a supplier-facing `order_qty` that is LOWER than the
customer-facing `quantity`"* — customer 100, `order_qty` 40.
The two readings converge only *after* a completed split, because the split
reduces `quantity` down to match `order_qty`. In the window between the
operator typing `order_qty = 40` and clicking Split, every consumer in Reading
A believes the customer ordered 40.
**Failure scenario:** customer orders 100. Operator sets `order_qty = 40` for
supplier A, intending to split the other 60 to supplier B. They get
interrupted and never click Split. The invoice bills 40. The picking slip says
ordered 40, outstanding 0. The other 60 units are gone with no trace and no
warning.
There is also a smaller inconsistency: `confirmOrderItemsCollected` computes
`remaining` from `row.quantity`, while `driver-stops.ts` defaults from
`row.order_qty`. Two different denominators for the same concept.
---
## Summary
| # | Finding | Severity |
|---|---|---|
| X-1 | `order_qty` means "customer qty" in four places and "supplier qty" in one, undocumented. Un-split lines silently under-bill. | **High** |
| 2.1 | Partial collection marks the line fully `collected`; the shortfall is untracked and the invoice defaults to the full quantity. | **High** |
| 2.2 | `splitOrderItemRemainder` is blocked by status exactly when a short collection needs it. | Medium |
| 1.1 | Surplus booking is manual with no prompt or unbooked-surplus queue. | Medium |
| 1.2 | No gate anywhere compares `qty_collected` to `order_qty`. | Medium |
The picking slip is the one component that already gets all of this right. The
arithmetic you asked for exists — it just isn't enforced anywhere upstream of
the print, and the invoice ignores it.
## Not yet decided
No design decisions have been taken. The open questions are:
- Should a short collection hold the line open (new status / partial state), or
auto-split the remainder into a new line, or just warn loudly at invoicing?
- Should `order_qty` be documented as customer-qty and the split flow moved to a
separate `supplier_qty` field, or the reverse?
- Should surplus booking be forced before a line can be invoiced?

View file

@ -1,183 +0,0 @@
# WFSALES POS — Design
## Problem
An existing POS mockup (`pos.html`, `receipt.html`, `history.html`, `cashup.html`,
`pos-app.js`, service worker — built outside this repo, in Open Design) already
implements catalog browsing, cart, cash/card payment, an offline sale queue,
and Bluetooth thermal-printer receipt printing. It is not safe or correctly
wired to go live:
1. `pb-config.js` hardcodes a PocketBase **superuser** email/password in plain
text inside a static, client-shipped JS file. Anyone who can view the page
source gets full admin access to the entire PocketBase instance — every
collection, not just stock.
2. The catalog reads from a `pos` collection that is a stale, disconnected
duplicate of `price_list`. Confirming a sale never decrements
`stock_items.current_qty` or writes to `stock_ledger` — stock levels don't
move when a sale happens, which is the core requirement.
3. The "account sale" path posts to a `delivery_note_items` collection that
does not exist in the PocketBase schema, so it silently fails today.
Separately (confirmed by codebase research, out of scope for this project):
stock-quantity writes are already fragmented across 4 independent call sites
in the main app (`stock-surplus.ts`, `stock-out.ts`, the `/api/v1/stock/fulfill`
route, and the MCP `stock_items.adjust` handler), and delivery notes in the
main app never touch `stock_items` at all. This project does not attempt to
unify those — it adds one more clean, self-contained stock-write path scoped
to POS checkout only.
## Goals
- Counter staff can browse live stock, build a cart, and take a cash or card
payment on a device running this app.
- Confirming a sale correctly and atomically-as-possible decrements
`stock_items.current_qty` and records a `stock_ledger` entry, so stock
shown in the POS and in the main WFDASH app stays accurate.
- No PocketBase credentials of any kind are ever shipped to the client.
- Existing UX (catalog/cart/payment screens, offline queue, Bluetooth thermal
printing) is preserved and reused, not rewritten.
- Account (pay-later) sales can print a delivery-note-style thermal slip
(with or without prices) referencing the customer/company, without creating
a new order/delivery-note record in the main workflow.
## Non-goals
- Unifying the 4 existing divergent stock-adjustment code paths in the main
app.
- Making delivery notes in the main WFDASH app decrement stock.
- Per-cashier login/PIN or staff attribution — this is a shared till.
- Real payment gateway integration — card payments are recorded as "paid via
the physical card machine, no change due"; the POS does not process cards
itself.
## Architecture
**Frontend** stays the existing static PWA, relocated into
`public/pos/` in WFDASH-WEBAPP and served at `/pos/*`:
- `index.html`, `pos.html`, `receipt.html`, `history.html`, `cashup.html`,
`pos-app.js`, `theme.js`, `sw.js`.
- No React/Next.js page rewrite. The offline-first behavior (IndexedDB cart
and sale queue, service worker asset caching, Web Bluetooth thermal
printing) already works client-side and has no SSR dependency.
- `pb-config.js` is replaced with a small config module holding only: the
base URL of this app's own `/api/v1/pos/*` routes, and a `POS_API_KEY`
value (set once at install time, stored in `localStorage`, never a
PocketBase credential of any kind).
**Backend** follows this repo's existing service-layer convention
(`AGENTS.md`: new features → `src/lib/server/<feature>-service.ts` + thin
`src/app/api/v1/<feature>/<op>/route.ts`):
- `src/lib/server/pos-service.ts` — business logic: list catalog, checkout
(create sale + sale items + stock ledger/qty writes), list sales for
history/cashup.
- `src/app/api/v1/pos/catalog/route.ts` (GET) — active `stock_items`.
- `src/app/api/v1/pos/checkout/route.ts` (POST) — confirm a sale.
- `src/app/api/v1/pos/sales/route.ts` (GET) — sales for history/cashup, with
a date-range filter.
- All three routes require a `POS_API_KEY` header, checked server-side
against an env var, following the same shape as the existing
`agent-auth.ts` / `AGENT_API_KEY` pattern. On success, routes use
`pocketbase-admin.ts` for the actual PocketBase access — real credentials
never leave the server.
## Data model
New PocketBase collections (created by the user in PB admin — this repo
never runs schema migrations against the live instance):
- **`pos_sales`**: `sale_number` (text), `payment_method` (select:
`cash`, `card`), `cash_given` (number, nullable), `change_given` (number),
`subtotal` (number), `vat` (number), `total` (number), `customer`
(relation → `customers`, optional), `synced_at` (date).
- **`pos_sale_items`**: `sale` (relation → `pos_sales`), `stock_item`
(relation → `stock_items`), `sku` (text), `description` (text), `qty`
(number), `unit_cost` (number), `unit_price` (number).
Existing collections reused as-is:
- `stock_items` — catalog source and quantity target (`current_qty`).
- `stock_ledger` — one row per sale line on checkout. Needs one new
`reason` select value, e.g. `pos_sale` (per `AGENTS.md`: select values are
added in PB admin first, by the user, before this code path can use them).
- `customers` — for account-sale customer lookup (existing `/pos` search
already queries this).
The `pos` collection (stale price_list duplicate) is no longer read by the
app after this change. It is left in place (not deleted) — deleting it is a
separate decision for the user to make later.
## Data flow
1. **Catalog load**: POS calls `GET /api/v1/pos/catalog`, which returns
active `stock_items` records (id, sku, description, category, unit,
`current_qty`, `selling_price`, `cost_price`). Cached client-side in
IndexedDB exactly as the existing mockup does today, for offline browsing.
2. **Checkout**: POS posts `{ items: [{stock_item, qty, unit_price, ...}],
payment_method, cash_given, customer? }` to `POST /api/v1/pos/checkout`.
`pos-service.ts`:
- Re-reads each `stock_item` server-side and rejects the request if
`current_qty` for a line is insufficient (no negative-stock sales).
- Creates one `pos_sales` record and its `pos_sale_items` rows.
- For each line: creates a `stock_ledger` row (`reason: pos_sale`,
negative `change_qty`) and updates `stock_items.current_qty`. This is
one clean, self-contained helper — not shared with or refactored from
the 4 existing divergent stock-write call sites in the main app.
- Returns the created sale (for the receipt screen) or an error.
3. **Offline behavior**: unchanged from the existing mockup — if the
checkout call fails (offline, network error, server error), the sale is
queued locally (IndexedDB, falling back to `localStorage`) and the UI
still proceeds to the receipt screen immediately (never blocks on
network). The existing `flushQueue()` retry-on-reconnect logic is kept.
Stock is only decremented once a queued sale successfully lands on the
server, so a sale that never syncs never affects stock, and a sale is
never double-decremented.
4. **Account sales / delivery-note printing**: no `orders` or
`delivery_notes` record is created by the POS. `receipt.html` gets a
second print template (thermal, via the existing Web Bluetooth flow) that
renders a delivery-note-style slip pulling the company/customer name from
the `customer` relation on the sale, with a toggle to include or omit
prices. This is purely a print format on top of the `pos_sales` record
already created — not a new workflow record in the main app.
5. **History / Cashup**: `history.html` and `cashup.html` switch from
querying `orders`/`order_items` (today, unfiltered — showing all orders,
not just POS sales) to querying `pos_sales`/`pos_sale_items` via
`GET /api/v1/pos/sales?from=&to=`.
## Error handling
- Checkout validates stock availability server-side before writing anything;
insufficient stock returns a 4xx with enough detail for the UI to show
which line is short.
- If the `pos_sales`/`pos_sale_items` write succeeds but a `stock_ledger`/
`stock_items` write fails partway through a multi-line sale, the response
indicates partial failure (sale exists, some lines may not have decremented
stock) so it can be reconciled manually — the existing pattern in
`stock-out.ts`/`stock-booking-service.ts` for compensation-needed errors is
followed for consistency, without adopting their code directly.
- All `/api/v1/pos/*` routes reject requests missing or mismatching
`POS_API_KEY` with 401, before touching PocketBase.
## Testing
- `pos-service.test.ts`: checkout creates correct `pos_sales`/
`pos_sale_items`/`stock_ledger` rows and decrements `current_qty`
correctly; rejects checkout when stock is insufficient; catalog list only
returns active items.
- Route tests for `catalog`, `checkout`, `sales`: 401 without a valid
`POS_API_KEY`, happy-path shape, following this repo's existing
`route.test.ts` conventions (see `orders/backfill-duplicate-checks/route.test.ts`
for the pattern).
- Manual verification of the frontend (catalog browse, cart, cash/card
checkout, receipt + thermal print, offline queue/flush, history, cashup)
against the new endpoints, since the frontend itself is plain JS/HTML with
no existing test harness in this repo.
## Open items for the user (PocketBase admin, manual)
- Create `pos_sales` and `pos_sale_items` collections with the fields above
and appropriate API rules (no public access; only the server's superuser
credentials write to them).
- Add `pos_sale` as a valid `reason` select value on `stock_ledger`.
- Set a `POS_API_KEY` value as an env var for the WFDASH-WEBAPP deployment,
and configure the same value into the POS device's local config at
install time.

View file

@ -1,209 +0,0 @@
# /stock — Inline Pricing Edit Design
> **Date:** 2026-08-31
> **Status:** Draft — pending user review
## Problem
`stock_items` already carries `cost_price`, `selling_price`, and `markup` fields — the POS catalogue reads them at the till — but the operator has no first-class UI to populate them on `/stock`. Today the only way to set pricing is to drive it through a supplier purchase receipt (which writes `cost_price` via the booking flow) or by editing the detail page by hand, row by row. Adding the first walk-in items, or backfilling pricing on a freshly-imported catalogue, means a slow per-item grind.
The POS reads `selling_price ?? cost_price`, so any item with no pricing at all shows the fallback price of zero to the cashier — a guaranteed "needs price" gap on every fresh stock take.
## Goal
Make `/stock` a price-editing surface:
1. Three pricing columns (Cost / Markup / Selling) hidden by default, revealed by a "Show pricing columns" toggle in the page header.
2. A "Missing pricing only" filter that surfaces the rows where `selling_price = 0 AND cost_price = 0`, so the operator can see the exact scope of the gap.
3. An "Edit pricing" mode toggle that converts the three pricing cells into number inputs across every visible row, with a sticky footer that bulk-saves the whole sheet on a single "Apply changes" click.
Editing cost must keep the audit trail honest: when `cost_price` is patched by hand, a `stock_ledger` row with `reason = "adjustment"` is written so the per-item cost history mirrors the booking flow's behaviour.
## User flow
1. **Open** `/stock`. Operator sees today's view — SKU, Name, Supplier, Current, Min, Target, Status. The three pricing columns are hidden.
2. **Click "Missing pricing only"** (filter toggle). The list collapses to rows with `selling_price = 0 AND cost_price = 0`. The header subtitle updates to *"N items need pricing"*.
3. **Click "Show pricing columns"** (header toggle). The table grows three right-aligned columns: **Cost (R)**, **Markup %**, **Selling (R)**. Each cell reads either the current numeric value or "—" if zero.
4. **Click "Edit pricing"** (header toggle). The toggle label flips to *"Done editing"*. Every pricing cell across the filtered list becomes a number input. Rows the operator hasn't touched look exactly like read-only values (numbers visible, no input chrome). Every row the operator changes gets a yellow background tint.
5. **Operator edits values.** When the operator types into Cost or Markup, Selling auto-derives to `round(cost × (1 + markup / 100), 2)` — but only if the operator has NOT manually edited Selling for that row in the current edit-pass. The "Selling manually edited" flag is per-row, reset on row-blur or Apply. If the operator clicks into Selling and types, Selling locks to their value and stops deriving until the row is re-focused or Apply is run.
6. **Local draft persists.** Each keystroke serialises the staged changes into `localStorage` under the key `wfdash.stock.pricingDraft`. A page refresh after a long edit session preserves the work.
7. **Operator clicks "Apply changes"** in the sticky footer. One POST to `/api/v1/stock/items/set-pricing` ships the delta. On success the table re-renders with the new values, the draft clears, the footer disappears.
8. **Per-row outcome.** If a single row fails (PB validation, etc.), that row stays tinted with a red corner badge; the others persist. A toast summarises *"Saved N rows — M failed, click row to retry"*.
## Decisions taken
| Decision | Choice | Why |
|---|---|---|
| Fields editable from this surface | **Cost + Selling + Markup** | Operator needs to fully populate the POS for new catalogue rows. Selling-only is too restrictive; cost-only leaves the selling empty in POS. |
| Audit on cost edit | **Write a `stock_ledger` row** with `reason = "adjustment"`, `reference_type = "manual"`, `notes = "Edited from /stock by {operator}"`, `unit_cost = new cost_price` | Cost history is the source of truth for "what did we pay for this SKU when". A hand edit must show up the same way a supplier-receipt edit does. |
| Save semantics | **Bulk apply with one "Apply changes" button** | Stamp many rows in one pass; one PB round-trip; survives the operator stepping away mid-edit. |
| "Missing pricing" filter scope | **`selling_price = 0 AND cost_price = 0`** | Matches the POS behaviour: only rows with no fallback at all are a hard gap. |
| Auto-derive UX | **Cost + Markup → Selling (rounded to 2dp)**, both editable | The most common case is "I know the supplier cost and I know the margin"; the selling price should be a derived output the operator can override. |
| Skip the `stock_items.update` whitelist for cost | **Yes — pricing goes through a new dedicated endpoint, not the existing one** | Keeps `cost_price` excluded from the generic PATCH surface so no other code path accidentally writes cost without the ledger side-effect. |
## Architecture
### Components
| Component | Lives at | Responsibility |
|---|---|---|
| `StockListPage` | `src/app/(app)/stock/page.tsx` (existing) | Render the table, manage search / low-stock / missing-pricing toggles, manage edit-mode toggle, manage staged-state. |
| `StockRow` | same file (existing) | Render one row; inside an `if (editMode) { <PricingCells /> } else { <ReadCells /> }` branch. |
| `PricingReadCells` | same file (new) | The three read-only cells (Cost / Markup / Selling) shown when edit-mode is OFF but pricing columns are visible. |
| `PricingEditCells` | same file (new) | The three controlled inputs shown in edit mode. Holds the local dirty state for one row. |
| `PricingApplyBar` | same file (new) | Sticky footer with the *N rows with unsaved changes — Discard / Apply changes* UI. |
| `stock-pricing-service.ts` | `src/lib/server/stock-pricing-service.ts` (new) | Pure service-layer function: takes `{ id, costPrice?, sellingPrice?, markup? }` patches, applies them, returns `{ item, ... }` per row, writes stock_ledger rows when cost changes. |
| `POST /api/v1/stock/items/set-pricing` | `src/app/api/v1/stock/items/set-pricing/route.ts` (new) | Thin HTTP wrapper over the service. Validates body, fans out patches, returns per-row outcomes. |
### Data flow
```
[Header "Edit pricing" toggle]
│
▼
[StockListPage — editMode state]
│
▼
[Each PricingEditCells — controlled inputs, dirty flag per row]
│
▼ (every keystroke)
[localStorage "wfdash.stock.pricingDraft"]
│
▼ (Apply click)
[POST /api/v1/stock/items/set-pricing { updates: [{id, costPrice?, sellingPrice?, markup?}, ...] }]
│
▼
[stock-pricing-service.updateStockItemsPricing(pb, updates, actor)]
│ → PB update on stock_items row
│ → if costPrice changes: insert stock_ledger row (reason=adjustment, unit_cost=newCost, qty=0)
▼
[Per-row outcome { id, ok | reason }]
│
▼
[Merge: kept tints on failed rows; clear draft on success]
```
### Service contract — `stock-pricing-service.ts`
```ts
export type StockPricingUpdate = {
id: string; // 15-char stock_items PB id
costPrice?: number; // undefined = don't touch
sellingPrice?: number; // undefined = don't touch
markup?: number; // undefined = don't touch
};
export type UpdateStockItemsPricingInput = {
updates: StockPricingUpdate[];
actor: { id: string; name: string }; // audit stamps
};
export type UpdateStockItemsPricingResult = {
results: Array<
| { id: string; ok: true; item: StockItem; previous: StockItemPricingSnapshot }
| { id: string; ok: false; reason: "not_found" | "validation_error" | "pb_error"; message: string }
>;
};
export async function updateStockItemsPricing(input: UpdateStockItemsPricingInput): Promise<...>;
```
Behaviours:
1. **Per-row independence.** Like `bulk_update_quote_items`: one row's failure never blocks the others.
2. **Cost-edit guard.** If the row's existing `cost_price` already equals `updates.costPrice` (no actual change), skip the stock_ledger write. Only insert a ledger entry when the cost actually moves.
3. **Idempotency on retry.** Same call twice produces the same final state (success) or same failures.
4. **Atomicity.** `stock_items.update` and the `stock_ledger` insert for a single row happen in sequence; PB's per-row write is atomic but cross-row atomicity is NOT guaranteed — that's fine, failures are reported per-row.
5. **Validation.** `cost_price`, `selling_price`, `markup` must each be finite numbers ≥ 0 if supplied. `cost_price === 0` is allowed (clearing the pricing), `selling_price === 0` is allowed (POS falls back to cost).
6. **No hand-edit guard.** Unlike the quote / stock_purchase_items cost paths, this endpoint does NOT refuse to overwrite a `cost_price > 0`. That's the whole point — operator can correct a wrong cost.
7. **Audit.** When `costPrice` is supplied and differs from existing, insert a `stock_ledger` row: `stock_item=id`, `change_qty=0`, `unit_cost=new`, `reason="adjustment"`, `reference_type="manual"`, `notes=actor.name + " edited cost from /stock"`, `created_by=actor.id` (where PB supports it).
8. **Constraint: not for purchase-driven cost changes.** The booking flow stays the path for stock-purchase receipts. This endpoint does NOT bump `current_qty` and does NOT touch `cost_history` (cost_history is owned by the receipt path).
### Route — `POST /api/v1/stock/items/set-pricing`
```ts
// Body
{
updates: [
{ id: "abc123def456ghi", costPrice: 3.2, sellingPrice: 4.16, markup: 30 },
{ id: "jkl789mno012pqr", sellingPrice: 0.56 },
...
],
actor: { id: "userPBId15char", name: "Gary" }
}
// Response 200
{
results: [
{ id: "abc123def456ghi", ok: true, item: { ... }, previous: { cost_price: 0, selling_price: 0, markup: 0 } },
{ id: "jkl789mno012pqr", ok: true, item: { ... }, previous: { cost_price: 0.45, selling_price: 0, markup: 0 } },
{ id: "stu345vwx678yza", ok: false, reason: "not_found", message: "stock_items row stu345... not found" }
],
summary: { total: 3, succeeded: 2, failed: 1 }
}
// Response 400 — body validation
{ code: "forbidden_field", message: "..." }
```
Reject 400 when:
- Request body is not valid JSON.
- `updates` is empty, missing, or not an array.
- `updates.length > 100` (matches `bulk_update_quote_items` cap; the page slices if more).
- Any `update` has `id` empty or not 15-char PB id.
- `actor` missing or `actor.id` empty.
Per-row rejections surface **inside** the 200 response, not as 400s: forbidden-field / non-finite / non-numeric / `not_found` / `pb_error` failures each land in `results[]` with `{ ok: false, reason, message }` so one bad row never aborts the siblings. The route relies on the service for those checks (per-row independence). Clients should inspect `summary.failed` and walk `results[]` for the per-row failure reasons.
Idempotent: 200 on retry with the same body, same per-row outcomes.
### Page behaviour (`/stock/page.tsx`)
1. Add three states at the page level:
- `showPricingColumns: boolean` (default `false`, persisted to `localStorage` key `wfdash.stock.showPricing`)
- `editPricing: boolean` (session-only — closes when leaving the page)
- `pricingDraft: Map<itemId, { costPrice?, sellingPrice?, markup? }>` (in-memory mirror of `localStorage` key `wfdash.stock.pricingDraft`).
- `pricingApplyState: "idle" | "applying" | "applied" | "partial"` for the footer / toast.
2. New filter toggle in the filter row: *"Missing pricing only"* — adds `(selling_price = 0 && cost_price = 0 && is_active = true)` to the PB filter. Header subtitle updates the same way the low-stock count currently does.
3. Existing PB fetch updated to include `expand: "supplier"` (already present) and `filter` gains the missing-pricing clause when toggled.
4. Header gets two new toggles beside the "New stock item" button: *"Show pricing columns"* (Switch), *"Edit pricing"* (Button — turns into *"Done editing"* when on).
5. `StockRow` branches on `editPricing` for its pricing cells.
6. New `<PricingApplyBar>` rendered sticky at the bottom of the table container when `pricingDraft.size > 0`. Shows count + Discard + Apply buttons. Reappears below the existing page padding.
7. `useBeforeUnload` warning when `pricingDraft.size > 0` (refresh-confirmation).
8. Realtime subscribe to `stock_items` stays unchanged. Realtime-edit-conflict detection (a banner warning when another operator's writes land while you're editing) is **deferred** — see `Out of scope` below.
### Edge cases
- **Stale draft on apply.** If `localStorage.draft` has changes for a row that no longer exists server-side, the apply call returns `not_found` for that row; the rest go through; the dead row's entry clears from the draft.
- **Cost set, markup blank.** Computed selling from `(cost, undefined)` is left blank; the operator can fill the markup next.
- **Edit during draft → save → next edit.** Each Apply replaces the local draft entirely on success. Partial-applies leave the failed rows in the draft so retry doesn't re-send the successful ones.
- **Closing the tab while dirty.** `useBeforeUnload` triggers the browser's confirm-leave prompt.
- **Real-time writes from another tab.** When remote `stock_items` update arrives during edit-mode, current inputs stay; on Apply the response includes a per-row `previous` snapshot — the UI can render a small *"field X changed underneath you"* warning on rows where `previous` differs from the value the operator typed when they started editing.
- **POS reads during edit.** POS app reads via its own REST endpoint (`/api/v1/pos/catalog`) which pulls `selling_price` from the row at request time. Brief race: an operator editing a row sees a stale POS price until they click Apply. Acceptable for v1.
## Testing
| Surface | Test type | What it covers |
|---|---|---|
| `stock-pricing-service.updateStockItemsPricing` | Unit (mock PB) | Happy path (cost + selling + markup), only-selling-update, only-cost-update writing a ledger row, idempotency, validation errors (negative, NaN, forbidden field), per-row independence, not_found. |
| `stock-pricing-service` ledger side-effect | Unit (assert PB calls) | When `costPrice` changes, exactly one stock_ledger row is created with `reason="adjustment"`, `unit_cost=new`, `change_qty=0`, `reference_type="manual"`. When `costPrice` is omitted or unchanged, no ledger row. |
| `POST /api/v1/stock/items/set-pricing` | Integration (Next route) | 200 on valid body; 400 on malformed JSON / empty updates / `>100` updates / malformed PB id / missing `actor.id`. Per-row forbidden-field / non-finite / not_found / pb_error failures surface inside `results[].reason` — never as route-level 400s (see route contract note above). |
| `/stock` page — read-only pricing visibility | Component (`page.test.tsx`) | Three columns hidden by default; visible when `showPricingColumns=true`; rows render read cells. |
| `/stock` page — missing-pricing filter | Component | Filter toggle adds PB filter; row count in subtitle reflects `selling=0 AND cost=0`. |
| `/stock` page — edit mode | Component | Toggling Edit replaces cells with inputs; dirty rows tint yellow; footer count reflects staged changes; Apply is disabled while empty. |
| `/stock` page — apply + outcomes | Component (mock fetch) | Successful apply clears draft, hides footer, merges returned items into row state; partial apply keeps failed-row tints. |
| `/stock` page — localStorage draft | Component | Typing into inputs writes to `localStorage`; the draft rehydrates on page mount. |
| `/stock` page — `useBeforeUnload` | Manual / e2e | Triggering leave while dirty prompts. |
## Migration / rollout
Single PR. No DB schema change. The existing page keeps its current behaviour (pricing columns hidden, no edit mode) until the operator opts in via the toggle, so the change is non-breaking.
## Out of scope (deliberately)
- Per-row undo button. Discard-all only; full per-row undo is a v2 nicety.
- Bulk-edit page separate from /stock. Operator uses the inline toggle.
- Bulk import of a CSV of prices. The price list import path is the right tool when the operator already has a CSV.
- Editing `cost_history` from this surface. Cost history is append-only via the booking flow.
- Editing `current_qty` from this surface — that's already on `/stock/[id]` via `stock_items.adjust`.
- Real-time presence indicators (two operators editing the same row simultaneously).

View file

@ -1,230 +0,0 @@
# `/deliveries` DN Items Expand — Design
## Purpose
On `/deliveries`, each delivery-note row currently shows only header
metadata (DN number, status badge, customer / company / order /
signed-copy indicator). The operator can't see *which items are on
that DN* without leaving to `/delivery-notes/[id]`, which breaks the
at-a-glance matching workflow against the printed paper in their
hand.
This spec adds a click-to-expand chevron on each DN row that reveals
a small inline list of that DN's line items (description + qty) so
the operator can match against the paper before loading the DN.
## Scope decisions (confirmed during brainstorming)
- **Interaction**: click-to-expand via a chevron at the left edge of
the DN row. Independent state per DN (no "expand all"). No
persistence across refresh.
- **Item content**: description + qty only (no SKU, no price, no
invoice number — operator matches against the printed paper, which
shows description and qty).
- **Data source**: client-side filter of the items already loaded by
`/api/v1/deliveries`. No new server endpoint. Items not in the
current page slice (200-item cap) get a "see full DN →" link to
`/delivery-notes/[id]`.
- **No PB schema changes.** Pure rendering work.
## Architecture
**Modified files (1):**
```
src/app/(app)/deliveries/page.tsx
```
No service-layer changes. No new `/api/v1/*` routes. No new MCP tools.
No new PB schema.
### State management
The existing `DeliveryNoteRowComponent` is a stateless component
that takes a single `dn` row. To add expand state without changing
every caller, lift state up to the page-level:
- The page keeps `const [expandedDnIds, setExpandedDnIds] = useState<Set<string>>(new Set())`.
- Pass `expanded: boolean`, `onToggle: () => void` into `DeliveryNoteRowComponent`.
- When toggled, page updates the set (`setExpandedDnIds(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; })`).
The page already groups items + DNs by customer → order, and we
already pass the expanded state per-DN. State hoisting to the page
component keeps the data ownership clear (page has the items array,
so it knows which DN items belong to).
---
## Feature 1 — DN row expand/collapse
### Current DN row (post the just-shipped compact subline change)
```
[DN DN26082701] [Draft]
Benjamin · Caliber Machine... · ORD-2026-08-013 · [📎 signed copy]
[Mark loaded] [Signed copy]
```
### Target
The chevron sits at the left edge of the row, just inside the existing
`<FileText />` icon column:
```
▸ [DN DN26082701] [Draft]
Benjamin · Caliber Machine... · ORD-2026-08-013 · [📎 signed copy]
[Mark loaded] [Signed copy]
(when expanded:)
▾ [DN DN26082701] [Draft]
Benjamin · Caliber Machine... · ORD-2026-08-013 · [📎 signed copy]
[Mark loaded] [Signed copy]
┌──────────────────────────────────────────────────────┐
│ 3 items · Total 10 EA │
│ ─────────────────────────────────────────────────── │
│ JEI HSS CUTTER LONG 24mm ... Qty 2 │
│ JEI HSS CUTTER LONG 22mm ... Qty 2 │
│ JEI HSS CUTTER LONG 18mm ... Qty 6 │
└──────────────────────────────────────────────────────┘
```
The items panel sits **inside the same `<div>` as the DN row** but
below the existing `<div className="flex shrink-0 flex-col...">`
button column. Visual hierarchy: existing row keeps its cream/amber
background; the items panel uses a slightly tinted neutral
(`bg-muted/30`) so the operator's eye reads "DN row + its items" as
one unit.
### Interaction details
- **Chevron button** (lucide `ChevronRight` / `ChevronDown`):
- Position: left edge of the row, before `<FileText />`
- Size: `h-4 w-4` (matches the existing icon size used elsewhere
on the page)
- `aria-expanded={expanded}`, `aria-controls={items-panel-id}`,
`aria-label="Show items on this delivery note"`
- Hover background: subtle (`hover:bg-amber-100/40` to match the
amber row tint)
- **Independent state per DN.** Each DN toggles its own expand. The
page-level `Set<string>` of expanded DN ids survives re-renders
but resets on refresh.
- **No "expand all" / "collapse all"** — out of scope. YAGNI.
- **Keyboard**: chevron is a `<button>`, so Enter/Space toggle it
natively.
### Items panel — rendering
A bordered panel immediately below the existing `<div
className="flex shrink-0 flex-col items-end gap-1.5">` button column,
inside the same outer `<div className="flex items-start gap-3 ...">`:
- **Header line**: "{n} items · Total {sumQty}{unitSuffix}" —
right-aligned, text-xs, muted foreground. `sumQty` is the
sum of `item.quantity` across the panel's items.
`unitSuffix` is empty when the items' units are mixed or
empty/null; otherwise it's a single shared unit string (e.g.
`" EA"` or `" BOX"`). When all items have unit "EA" or empty,
the suffix is omitted entirely (the operator's mental default
is "EA"). When `n === 0`, the header line is omitted; the
empty-state takes its place.
- **Per item line** (`<div className="flex items-baseline gap-2">`):
- Description: truncate + `title` for full text
(`truncate`, `text-xs`)
- Right-aligned qty: `Qty {n}` in `text-xs tabular-nums`
- If `item.order !== dn.order?.order` (DN spans multiple orders —
rare): a tiny `from ORD-...` line under the description in
`text-[10px] text-muted-foreground`
- **Empty state** when `items.filter(i => i.delivery_note === dn.id).length === 0`:
```
No items loaded on this delivery note.
See full DN →
```
The link `<Link href={`/delivery-notes/${dn.id}`}>` is styled as a
small primary-coloured link, opens in the same tab. Per-row message
— keeps the operator oriented (some DNs legitimately have zero items
for ad-hoc counter pickups; this is the fallback for "we don't have
the data loaded").
- **Many items**: `max-h-72 overflow-y-auto` on the panel so a DN
with 20+ items doesn't push everything else off screen.
### Data source — client-side filter
The page already calls `listDeliveryItems()` which returns up to 200
items per tab with their `delivery_note` relation id and
`delivery_note_number` text. On render, the items panel computes:
```ts
const dnItems = useMemo(
() => items.filter((i) => i.delivery_note === dn.id),
[items, dn.id],
);
```
`useMemo` keeps the filter from running on every keystroke. The page
already has the full items array in scope, so no new fetch.
### Edge cases
- **Multi-DN under one order**: DNs in the same group expand
independently. Items belong to exactly one DN at a time, so the
lists are disjoint.
- **Counter-pickup DNs** (`dn.order === null`): items can still be on
them via the `delivery_note` relation on `order_items`. The items
show under the DN regardless. The "from ORD-..." line is suppressed
in this case (no parent order to reference).
- **Items paged off**: if the DN's items aren't in the page slice
(e.g. >200 items loaded, this DN's items at position 201+), the
empty-state with `/delivery-notes/[id]` link is the fallback.
Acceptable for v1 — the operator can drill into the DN.
- **Stale data**: client-side filter is stale-by-design — if an item
is added/removed from a DN after the page loaded, the panel
doesn't reflect it until refresh. Acceptable for this at-a-glance
workflow.
- **Loading the page fresh**: all DNs start collapsed.
---
## Files touched
- `src/app/(app)/deliveries/page.tsx` — single-file edit
- Add page-level `useState<Set<string>>` for expanded DN ids
- Pass `expanded` + `onToggle` into `DeliveryNoteRowComponent`
- Inside `DeliveryNoteRowComponent`: add the chevron button at the
left of the row + an inline items panel below the button column
- Filter items from the page-level `items` array
No service / API / schema changes.
---
## What's NOT in scope
- **No new server endpoint.**
- **No PB schema changes.**
- **No select / checkbox on the items** — operator is matching, not
selecting.
- **No SKU / price / invoice column** per the brainstorming decision.
- **No persistent expand state** — refresh collapses everything.
- **No "expand all" / "collapse all" controls.**
- **No item count badge on the DN row** — YAGNI; can add later.
- **No changes to the existing item rows** in the PO groups below
the DNs.
## Testing
- **Manual smoke check** via agent-browser: log in, navigate to
`/deliveries`, expand a DN with items, verify the items panel
renders with correct descriptions + qtys. Collapse and re-expand to
verify state. Trigger the empty state by expanding a counter-pickup
DN with no items.
- **No unit tests** for the rendering — the filter is a one-liner
(`items.filter(i => i.delivery_note === dn.id)`) and the panel is
presentation. If the page already has a render test, verify it
still passes.
## Risk assessment
**Low.** Single-file edit, additive rendering. The expand state is
local to the page and resets on refresh. No data layer touched, no
schema touched, no API touched. Worst case: a layout glitch on the DN
row is easily reverted by removing the new `onClick` and the inline
panel.

View file

@ -1,305 +0,0 @@
# Compact DN Row + Order Age Badge — Design
## Purpose
Two small, independent rendering improvements for two pages the
operator uses every day:
1. **`/deliveries`** — each delivery-note row currently renders only
`DN {number}` + a one-line subline (delivery date or PO ref). The
operator can't tell *who* a delivery note is for or whether the
signed copy is back without scrolling up to the customer/order
group header. This spec adds a compact subline with customer name,
company, parent order number, and a signed-copy indicator — all
data the service already returns, no new queries.
2. **`/orders`** — the order card subtitle already shows
`{formatDate(order.created)}` but as a bare date string. The
operator wants to triage by **age** ("how long has this been on my
board") at a glance, not parse dates. This spec adds a colour-coded
age badge (`today`, `3d old`, `7d old`, etc.) replacing the bare
date string in the right-aligned metadata slot.
Both features share no code; they're bundled in one spec only because
they were brainstormed together.
## Scope decisions (confirmed during brainstorming)
- **DN row enrichment** — compact option from the brainstorm:
customer name, company name, parent order number, signed-copy
indicator. No item count, no total value (those would require a
server-side aggregate; rejected for now).
- **Age surfacing** — colour-coded badge, not auto-sort, not
raw-age-only. Replaces the existing date string; date is dropped
because the badge text already conveys the date implicitly ("3d old
on 2026-08-29" is redundant).
- **Age thresholds** — `<24h → today (green)`, `1–2d → green`,
`3–7d → amber`, `8–14d → orange`, `>14d → red`. Tunable later.
- **No server changes** — both features consume data already
returned by `/api/v1/deliveries` and `/api/v1/orders` (or, in
`/orders`'s case, the page-level fetcher).
- **No PB schema changes** — purely UI/rendering work.
## Architecture
**New files:**
```
src/lib/order-age.ts — pure age math + bucket → tailwind class
src/lib/order-age.test.ts — unit tests for the util
```
**Modified files:**
```
src/app/(app)/deliveries/page.tsx — DeliveryNoteRowComponent only
src/app/(app)/orders/page.tsx — order card right-aligned metadata slot
```
No service-layer or `/api/v1` route changes. No MCP tool changes.
---
## Feature 1: Compact info on delivery-note rows
### File: `src/app/(app)/deliveries/page.tsx`
Scope: **only** the `DeliveryNoteRowComponent` function (lines
~1475-1588). No change to the grouping logic, the search filter, the
loading endpoint, or any other component on the page.
### Target row rendering
```
DN DN26083101
Acme Mining (Pty) Ltd · Master Drilling Co · ORD-2026-08-058 · 📎 signed copy
[status badge] [Mark loaded] [Signed copy]
```
The existing `<p>` subline at line 1537-1547 is replaced with a new
piece list:
1. **Customer name** — `dn.order?.customer_name` ?? `"Counter pickup"`.
Always rendered (counter-pickup is the explicit no-order case).
2. **Company name** — `dn.order?.company_name`. Hidden if falsy.
3. **Order number** — `dn.order?.order_number`. Hidden if falsy.
4. **Signed-copy indicator** — `<Paperclip />` icon + `"signed copy"`
text. Hidden if `!dn.signed_copy || dn.signed_copy.length === 0`.
The whole indicator carries `title={dn.signed_copy.join(", ")}` so
the operator can see the filenames on hover.
Joined by `·` (same separator convention as the order card subtitle),
in `text-muted-foreground` (matches the existing subline colour).
### Edge cases
- **Counter-pickup DN** (`dn.order === null`): customer/company/order
pieces all collapse to a single `"Counter pickup"` label (no
separator emitted for the missing pieces). Signed-copy indicator
still appears if files were uploaded directly to the DN.
- **Long customer names**: the customer name piece carries
`truncate max-w-[40ch]` + `title` to preserve hover-access. Other
pieces are short and don't need truncation.
- **Multi-DN under one order**: each DN row still renders its own
compact subline. The group header (customer → order → list of rows)
is unchanged.
### Acceptance
- A DN with order attached shows: `DN {n}`, customer name, company
name, order number, paperclip if signed copy uploaded.
- A counter-pickup DN shows: `DN {n}`, `Counter pickup`, paperclip if
signed copy uploaded.
- A DN with no signed copy shows no paperclip.
- The page's existing search, grouping, and tab logic are unaffected.
- No new network request is made when rendering a DN row.
---
## Feature 2: Order age badge
### New file: `src/lib/order-age.ts`
```typescript
export type OrderAgeBucket = "fresh" | "recent" | "slipping" | "stale";
export interface OrderAge {
/** Integer calendar-day delta. 0 means "today". Never negative. */
daysOld: number;
bucket: OrderAgeBucket;
/** Human-readable badge label. */
label: string;
}
/**
* Compute the age of an order relative to `now` (defaults to
* `new Date()`). The `daysOld` value is a **calendar-day** delta in
* the office's local timezone (Africa/Johannesburg, hardcoded for
* now) — an order created at 23:00 yesterday read at 01:00 today is
* `daysOld = 0` ("today"), not 1. The day delta is computed as
* `floor((localEndOfDay - localStartOfCreatedDay) / 1 day)` and is
* always returned as a non-negative integer.
*
* Future-dated `created` values (clock skew, bad data) clamp to 0
* to avoid showing "−1d old" or similar nonsense.
*
* Implementation note: use `Intl.DateTimeFormat` with the
* Africa/Johannesburg timezone to get the local YYYY-MM-DD for both
* dates, then subtract. Don't divide millisecond deltas by
* 86_400_000 — that fails across DST boundaries (SA doesn't observe
* DST but the pattern is fragile).
*/
export function getOrderAge(
created: string | Date,
now?: Date,
): OrderAge;
/**
* Map an age bucket to a tailwind className for the badge.
* Centralised so a future palette tweak is a one-line change.
*/
export function bucketToBadgeClass(bucket: OrderAgeBucket): string;
```
**Bucket thresholds:**
| `daysOld` | bucket | label |
|-----------|--------|-------|
| 0 | `fresh` | `today` |
| 1–2 | `fresh` | `{daysOld}d old` |
| 3–7 | `recent` | `{daysOld}d old` |
| 8–14 | `slipping` | `{daysOld}d old` |
| ≥ 15 | `stale` | `{daysOld}d old` |
**Badge class names** (initial palette, tunable):
- `fresh` → `bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300`
- `recent` → `bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300`
- `slipping` → `bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300`
- `stale` → `bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300`
### New file: `src/lib/order-age.test.ts`
Unit tests, all pure-function:
- `daysOld = 0` (created today) → `fresh`, label `"today"`
- `daysOld = 1, 2` → `fresh`, label `"1d old"`, `"2d old"`
- `daysOld = 3, 7` → `recent`, label `"3d old"`, `"7d old"`
- `daysOld = 8, 14` → `slipping`, label `"8d old"`, `"14d old"`
- `daysOld = 15, 30, 365` → `stale`, label `"15d old"`, `"30d old"`,
`"365d old"`
- `daysOld = -1` (future date) → clamps to `fresh`, label `"today"`
- **Local-timezone edge case**: an order created at 23:00 JNB yesterday
read at 01:00 JNB today must return `daysOld = 0` (calendar-day delta,
not millisecond delta). Test this with `now` injected explicitly.
- All four `bucketToBadgeClass` outputs return non-empty strings
- `bucketToBadgeClass` is stable (same input → same output)
No React rendering tests for the badge — that would be a separate
playwright/e2e concern and is out of scope.
### Modified file: `src/app/(app)/orders/page.tsx`
Scope: the right-aligned metadata line in the order card (current line
~865-867). The exact JSX currently is:
```tsx
<span className="ml-auto text-xs text-muted-foreground hidden sm:inline">
{formatDate(order.created)}
</span>
```
Replace with:
```tsx
<OrderAgeBadge created={order.created} className="ml-auto hidden sm:inline-flex" />
```
Where `OrderAgeBadge` is a tiny new inline component in the same
file (kept local — used only here for now; if a second page needs it,
it can be hoisted to `src/components/order-age-badge.tsx`):
```tsx
function OrderAgeBadge({
created,
className,
}: {
created: string;
className?: string;
}) {
const age = getOrderAge(created);
return (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium tabular-nums",
bucketToBadgeClass(age.bucket),
className,
)}
title={`Created ${formatDate(created)}`}
>
{age.label}
</span>
);
}
```
The `title=` attribute preserves the absolute date for operators who
want to know the exact day — addresses the brainstorm's "drop raw
date" option concern without keeping it as primary UI.
The existing `formatDate` import (currently from
`@/lib/pb-helpers`, line ~1596 — `formatDate(dateStr: string)`) is
kept on the page. `getOrderAge` itself does **not** import
`formatDate`; it returns the age label only.
### Edge cases
- **Sorting**: not changing. The badge surfaces age; the operator
still sorts by `created` if they want.
- **Rep filtering**: the recent commit
`feat(invoicing): filter rows to the logged-in rep's own orders` is
orthogonal. Age badge fires regardless of who the rep is.
- **Long lists**: each card gets one badge. No batched summary view
changes.
- **Existing `formatDate` call**: `formatDate(order.created)` moves
from the JSX into the badge's `title=` tooltip so the absolute
date is still accessible on hover.
### Acceptance
- An order created today shows a green `today` badge in the
right-aligned slot where the date used to live.
- An order 5 days old shows an amber `5d old` badge.
- An order 20 days old shows a red `20d old` badge.
- Hovering the badge reveals the full `Created YYYY-MM-DD` tooltip.
- The order card layout is otherwise unchanged — the badge slot has
the same `ml-auto hidden sm:inline` styling hooks.
---
## What's NOT in scope
- **No new server endpoints.** Both features use existing data.
- **No PB schema changes.** Purely UI.
- **No item count / total value on DN rows.** Rejected during
brainstorm (would need a server-side aggregate).
- **No sorting changes on /orders.** Badge only.
- **No changes to MCP tools.**
- **No changes to /api/v1 routes.**
- **No changes to other delivery-note pages** (`/delivery-notes`,
`/delivery-notes/[id]`, `/delivery-note-print`). Only
`/deliveries`'s row component.
## Testing
- `src/lib/order-age.test.ts` — 6+ unit tests covering all bucket
thresholds + future-date clamp + `bucketToBadgeClass` stability.
- Manual smoke: open `/orders`, verify badges appear with correct
colours for orders of various ages; open `/deliveries`, verify the
DN row subline shows customer/company/order/paperclip as expected.
- Existing page tests (if any) for `/orders` and `/deliveries`
should continue to pass — no layout regressions.
## Risk assessment
**Low.** Both features are additive rendering changes against
existing, well-typed payloads. The age util is a pure function and
the only file with logic worth testing. The DN row change touches
one component and doesn't change any user-facing interaction
(no new buttons, no new fields).

View file

@ -1,245 +0,0 @@
# Rep Dashboard ("Dashboard 2") — Design
## Purpose
The existing `/dashboard` is a company-wide overview (11 parallel PB
queries, sampled/bucketed aggregates) built for a boss/admin audience.
Reps don't need most of it — top customers company-wide, stock alerts,
global revenue trends aren't their job. This spec defines a new,
separate, lighter dashboard scoped to "my stuff": how many quotes I
loaded and when, which of my quotes/orders are still outstanding, and
what my orders are worth this month (selling, cost, markup).
It is deliberately a **new page**, not a modification of the existing
`/dashboard`. Retrofitting a rep filter into `dashboard-service.ts`'s
11 tuned, sampled queries was assessed as higher-risk than building a
small dedicated service from scratch — this page needs simple exact
counts for one rep, not company-wide sampling. `/dashboard` (dashboard
1) is untouched by this work. Once dashboard 2 is built and reviewed,
a follow-up decision (not part of this spec) is whether it replaces
dashboard 1, becomes rep-only while bosses keep dashboard 1, or both
stay as separate routes.
## Scope decisions (confirmed during brainstorming)
- **Route:** new page at `/dashboard2` (working name — rename/promote
later).
- **Outstanding quotes:** `status = "new" || status = "claimed"`,
scoped to `customer.rep = <me>`, excluding templates.
- **Outstanding orders:** orders with at least one line item in the
`new` tab group (see `TAB_GROUP_ITEM_STATUSES.new` in
`src/lib/item-status-ui.ts`), scoped to `customer.rep = <me>`.
- **"Quotes loaded":** counted by the quote's `created` date (not
claimed-by date), scoped to `customer.rep = <me>`, excluding
templates.
- **Comparison windows:** calendar-aligned — today vs yesterday, this
ISO week vs last ISO week, this calendar month vs last calendar
month. Matches the week-bucketing convention already used in
`dashboard-service.ts` (`isoWeekKey`).
- **Revenue scope:** all `order_items` lines whose parent order's
`customer.rep = <me>`, filtered by the **order_item's own** `created`
date falling in the current calendar month (matches
`dashboard-service.ts`'s existing `monthRevenue` calc, just
rep-scoped). `selling_price × quantity` summed for "selling",
`cost_price × quantity` summed for "cost", markup % =
`(selling - cost) / cost * 100`.
- **Auth:** any logged-in user can open `/dashboard2` — it always
scopes to "whoever is logged in" via their PB user id, no
`user_type === "REP"` gate. A non-rep (e.g. an admin with no
customers assigned to them) just sees zeros everywhere. This keeps
the page simple and testable by anyone during review, without
needing a redirect/role-check layer yet.
## Architecture
**New files only — nothing existing is modified except
`browser-session.ts` (additive: a new export, existing export
untouched).**
```
src/lib/server/rep-dashboard-service.ts — data aggregation
src/app/api/v1/rep-dashboard/route.ts — thin route handler
src/app/(app)/dashboard2/page.tsx — page (client component)
src/lib/server/browser-session.ts — MODIFY: add getBrowserSessionUser()
```
### `getBrowserSessionUser` (browser-session.ts)
`assertBrowserSession` today validates the bearer token against PB's
`auth-refresh` endpoint and discards the response body. This spec
needs the resolved user's `id` (to scope every query) — so we add a
sibling function that does the same validation but returns the parsed
user record instead of `void`:
```typescript
export interface BrowserSessionUser {
id: string;
user_type: string[] | null;
}
export async function getBrowserSessionUser(
req: NextRequest,
): Promise<BrowserSessionUser> {
// same token extraction + auth-refresh call as assertBrowserSession,
// but returns { id, user_type } parsed from the response body's
// `record` field instead of discarding it.
}
```
`assertBrowserSession` stays as-is (other routes use it and don't need
the user). `rep-dashboard/route.ts` uses the new function instead.
### `getRepDashboardData` (rep-dashboard-service.ts)
```typescript
export interface RepDashboardData {
quotesLoaded: {
today: number; yesterday: number;
thisWeek: number; lastWeek: number;
thisMonth: number; lastMonth: number;
};
quoteVolumeTrend: { weekStart: string; count: number }[]; // 8 weeks
outstandingQuotes: {
total: number;
items: { id: string; quote_number: string; customer_name: string | null }[]; // capped at 8
};
outstandingOrders: {
total: number;
items: { id: string; order_number: string; customer_name: string | null }[]; // capped at 8
};
monthRevenue: {
selling: number;
cost: number;
markupPct: number | null; // null when cost is 0 (avoid div-by-zero)
};
}
export async function getRepDashboardData(
repId: string,
now: Date = new Date(),
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
): Promise<RepDashboardData>
```
All PB reads run in `Promise.all`, each a narrow `getList`/`getFullList`
with a `fields` projection (mirrors the existing dashboard-service
convention) and a filter built around `customer.rep = "<repId>"`
(quotes, and orders via the direct `customer` relation) or
`order.customer.rep = "<repId>"` (order_items, a 2-hop relation
chain — same technique already used in the invoicing rep-filter work).
Query groups:
1. **6 count-only queries** for `quotesLoaded` (today/yesterday/this
week/last week/this month/last month) — `getList(1, 1, { fields: "id" })`,
reading `totalItems`.
2. **8 count-only queries** for `quoteVolumeTrend` (one per week,
reusing the same `buildWeekBuckets`/`isoWeekKey` helpers already
exported from `dashboard-service.ts` — imported, not duplicated).
3. **1 list query** for `outstandingQuotes` (`status IN (new, claimed)`,
`customer.rep = me`, `is_template != true`, sorted by `created`,
`perPage: 8`, `fields: "id,quote_number"`, `expand: "customer.customer"`)
+ its `totalItems` for the count.
4. **1 list query** for `outstandingOrders`, built the same way
`getOrderIdsForTabGroup` does in `orders-service.ts` (order_items
filtered by the `new` tab group's statuses, chained with
`order.customer.rep = me`), then a follow-up `orders` fetch by the
resulting ids for `order_number` + `expand.customer.customer`,
capped at 8, with the distinct-order count as the total.
5. **1 query** for `monthRevenue` — `order_items` where
`order.customer.rep = me && created >= <startOfMonth>`, `fields:
"id,quantity,order_qty,selling_price,cost_price"`, summed in memory
exactly like `dashboard-service.ts`'s existing `monthRevenue` loop
(reusing `quantity ?? order_qty` as the qty source for consistency).
No sampling, no `SAMPLE_WINDOW_DAYS` — every query here is already
scoped to one rep, so exact counts are cheap (a rep's customer set is
a small slice of the collection).
### Route (`/api/v1/rep-dashboard`)
```typescript
export async function GET(req: NextRequest) {
try {
const sessionUser = await getBrowserSessionUser(req);
const data = await getRepDashboardData(sessionUser.id);
return NextResponse.json(data);
} catch (error) { /* same error-shape pattern as /api/v1/dashboard */ }
}
```
### Page (`/dashboard2`)
Client component, same fetch/realtime pattern as `/dashboard`
(`DashboardPage` in `src/app/(app)/dashboard/page.tsx`): fetch on
mount with the PB bearer token, debounce-refetch on `quotes` / `orders`
realtime events. Renders 3 rows using the **existing** chart
components (`DashboardCard`, `KpiCard`, `BarChart`, `RadialGauge`) —
no new chart components needed:
- **Row 1:** 3× `KpiCard` — "Quotes today" (vs yesterday), "Quotes
this week" (vs last week), "Quotes this month" (vs last month).
Each reuses the existing delta-arrow + sparkline UI; the sparkline
data is the 8-week `quoteVolumeTrend` array (last N points), so all
three cards share one visual trend even though each headlines a
different period.
- **Row 2, 3 cards:**
- "Quote volume trend" — `BarChart`, 8 weeks, single series
(mirrors dashboard 1's "Order volume" card).
- "Outstanding quotes" — `DashboardCard` with a compact list (reuses
the visual pattern of dashboard 1's `FollowUpList`: avatar-initial,
quote #, customer, click → `/quotes/[id]`), count badge in the
header.
- "Outstanding orders" — same list pattern, click → `/orders/[id]`
(via `router.push`, matching the existing orders page navigation).
- **Row 3, 2 cards:**
- "Selling vs Cost (this month)" — `BarChart`, one category, two
series (Selling / Cost), same component as dashboard 1's "Orders
trend" card (New vs Fulfilled) just relabeled.
- "Markup %" — `RadialGauge`, value = `cost`, total = `selling`.
This renders the ring as "cost's share of the selling price,"
which is always ≤100% when selling ≥ cost (the normal case) —
unlike markup % itself (`(selling-cost)/cost`), which is
unbounded and can exceed 100%, so it can't drive the ring
directly. The gauge's `centerLabel` overrides the default
value/total display and instead shows the literal
`${markupPct.toFixed(1)}%` (the actual markup figure), with
`centerSubLabel="Markup"`. When `cost` is 0 (no orders yet this
month, `markupPct: null`), render the existing "healthy empty
state" pattern (see `StockAlerts`'s empty branch) instead of the
gauge.
No new nav entry is added in this pass — the page is reached by
direct URL (`/dashboard2`) for review purposes, matching "create
dashboard 2, see how it looks."
## Error handling
Same pattern as every other `/api/v1/*` route in this codebase:
`assertBrowserSession`/`getBrowserSessionUser` throws a `{status:
401}`-tagged error on a missing/invalid token; the route handler
catches, reads `.status`, and returns `NextResponse.json({error},
{status})`. The page's `fetchDashboard` already has a `try/catch` +
`toast.error` — reused as-is (same code shape as `dashboard/page.tsx`
lines 85–115), no new error-handling design needed.
Division-by-zero on markup % (no orders this month) returns `null`
from the service; the page renders the empty state instead of `NaN%`
or `Infinity%`.
## Testing
Mirrors the existing `*-service.test.ts` convention in
`src/lib/server/` (e.g. `boss-dashboard-service.test.ts`,
`orders-service.test.ts`): a mocked PocketBase admin client (fake
`getAdminPocketBase()`) with `getList`/`getFullList` stubs returning
canned fixtures, asserting `getRepDashboardData` computes the right
counts/sums/deltas — including the zero-orders-this-month
(`markupPct: null`) edge case and the zero-quotes-any-period edge case
(deltas should read `0`, not throw or divide by zero, matching the
existing `ordersDelta`/`customersDelta` "0 baseline → 0 or 100%"
convention already in `dashboard/page.tsx`).
No new tests needed for `browser-session.ts` beyond what
`getBrowserSessionUser` naturally inherits from the existing
`assertBrowserSession` test coverage pattern (if one exists) — a
thin pass-through over PB's `auth-refresh` endpoint.

Some files were not shown because too many files have changed in this diff Show more