Two things on the collection pages. Descriptions were clamped to two lines (`line-clamp-2`) on the Collection Confirm items table and the Collection Pool card, so a long description lost everything after the second line - exactly the part that distinguishes one line from another (size, spec, colour). Both now wrap to full height, with `break-words` so a long unbroken part number cannot widen the table. And a POS item's price was never a decision. `stock_items.selling_price` is what the till charges, and it could move on its own: a bulk import wrote a spreadsheet's selling price straight over the catalogue, and the /stock pricing grid recomputed the selling price whenever its cost cell was edited. Nothing asked. Now the operator decides, per item, when they confirm a PO card. A price confirmation dialog opens BEFORE the invoice/stock confirm - once per card, and only when confirming would move a till price. Every row shows the old price and the new price; the default is Keep price, so moving a till price is always deliberate. Where the line cost and the catalogue cost differ, both are shown and the operator picks which one prices the till. The price is derived, never accepted from the client: the route reads the item's own markup and computes it, so the number the operator saw is the number that lands. Update writes cost AND price together (a price with a stale cost behind it would mis-price the next markup run), plus a zero-quantity cost_history entry and a stock_ledger adjustment row. The markup field is never written - the operator's rate is theirs. A failed price write does not block the confirm: goods stuck unconfirmed is a bigger problem than a till price that did not move. Also fixes a live bug found on the way: `listCollectedItems` expanded `purchase.stock_item`, but `stock_purchases` has no `stock_item` relation - it lives on `stock_purchase_items` - so `expand.stock_item` came back undefined on every row and the POS pricing was never available. Expanding `stock_item` directly takes the stock leg from 0/27 rows carrying POS pricing to 27/27 (verified on live PB). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
535 lines
33 KiB
Markdown
535 lines
33 KiB
Markdown
<!-- BEGIN:nextjs-agent-rules -->
|
|
# This is NOT the Next.js you know
|
|
|
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
|
<!-- END:nextjs-agent-rules -->
|
|
|
|
<!-- BEGIN:pb-select-field-gotcha -->
|
|
# PocketBase select fields are not self-service
|
|
|
|
The `order_items.item_status` field is a `select` with a fixed list of
|
|
values. If you want to set a new value (e.g. `delivery_note` for the
|
|
new delivery-note flow), you must add it to the schema in the PB admin
|
|
**first** — the API will 500 with "Failed to update record." otherwise.
|
|
The local `pb-exports/pb_schema-2.json` is a snapshot for reference
|
|
only; the source of truth is the running PB instance.
|
|
|
|
If a new code path sets a select value that doesn't exist yet, ask the
|
|
user to add it via the PB admin before debugging the API. They do this
|
|
manually (it's a UI-only change in the PB admin).
|
|
<!-- END:pb-select-field-gotcha -->
|
|
|
|
<!-- BEGIN:pb-attachments-pattern -->
|
|
# Reuse existing multi-file fields for new attachment types
|
|
|
|
The `order_items` collection has a multi-file field `order_item_attachements`
|
|
(`maxSelect: 10`, accepts any MIME). When adding a new document type
|
|
to attach to an order item (e.g. delivery note PDF), upload the file
|
|
to this field rather than creating a new attachment collection. The
|
|
operator queries by some key field on the item (e.g.
|
|
`delivery_note_number`) to find the items, then reads the attachments
|
|
off each item.
|
|
|
|
Pattern in code:
|
|
1. POST endpoint: generate the PDF, upload via multipart/form-data
|
|
using the `order_item_attachements` field on each item in the group
|
|
2. GET endpoint: query `order_items` by the key field, find a file
|
|
whose name matches the expected pattern (e.g. `DN26071401.pdf`),
|
|
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
|
|
|
|
The app has two coexisting data layers:
|
|
|
|
- **Legacy**: `src/lib/pb-helpers.ts` — used directly by Server Components
|
|
under `src/app/(app)/**/page.tsx` and form components.
|
|
- **Current**: `src/lib/server/*-service.ts` — exposed via `/api/v1/*` route
|
|
handlers and `/api/mcp`, used by the agent API and external integrations.
|
|
|
|
Both layers read the same PocketBase collections. Pick the right one
|
|
depending on what you're doing.
|
|
|
|
## Rules
|
|
|
|
**For new endpoints / features**: put business logic in
|
|
`src/lib/server/<feature>-service.ts` and expose it via a thin
|
|
`src/app/api/v1/<feature>/<op>/route.ts`. Do NOT extend `pb-helpers.ts`.
|
|
|
|
**For new pages / form components**: call `/api/v1/*` (or have the
|
|
Server Component call the corresponding `lib/server/*-service.ts`
|
|
function directly). Do NOT import from `pb-helpers.ts`.
|
|
|
|
**When touching a page that already uses `pb-helpers.ts`**: migrate its
|
|
data calls to the service layer in the same commit. One resource at a
|
|
time, no big-bang rewrites. Lowest-risk order: customers → orders →
|
|
quotes → suppliers → companies → everything else.
|
|
|
|
## How to spot you're reaching for the legacy layer
|
|
|
|
`pb-helpers.ts` is the file in question. Grep for it:
|
|
|
|
```bash
|
|
grep -rn 'from "@/lib/pb-helpers"' src/ --include="*.ts" --include="*.tsx"
|
|
```
|
|
|
|
Each hit is one caller to migrate when you're in the area.
|
|
|
|
## Migration cost indicator
|
|
|
|
A single page rewrite is typically: replace `getX(...)` import + call
|
|
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.
|
|
|
|
Talking to the live PB directly: authenticate as superuser with
|
|
`PB_ADMIN_EMAIL`/`PB_ADMIN_PASSWORD` via
|
|
`POST /api/collections/_superusers/auth-with-password` — that is what the
|
|
server does too (`getAdminPocketBase()`), and the `PB_ADMIN_TOKEN` in `.env`
|
|
is not always current (a 401 means re-auth, not a wrong URL). Records live
|
|
under `/api/collections/<name>/records`; the path without `/records` returns
|
|
the collection definition, not the rows.
|
|
|
|
# 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.
|
|
|
|
# Repairs (`/repairs`) — the machine-repair job, and the phone app planned for it
|
|
|
|
The job runs: customer reports a broken machine → we collect it → the
|
|
repairs person books it in (photos, serial number, whose machine it is,
|
|
what the customer says is wrong, what the supplier must look at) → the
|
|
driver carries the printed request to the supplier → the supplier keeps a
|
|
copy, signs one and hands it back → supplier quote uploaded → customer
|
|
quote → customer order → the buyer approves the order or rejects it and
|
|
collects the machine back unrepaired.
|
|
|
|
**That flow is not in the code.** It came from the operator on 2026-11-05.
|
|
Design: `docs/superpowers/specs/2026-11-05-repairs-app-design.md`. The
|
|
planned phone app lives at `/repairs-app`, not built yet.
|
|
|
|
- Status vocabulary `new → assessing → quoted → approved → in_progress →
|
|
completed` (+ `cancelled`) lives in **`src/lib/repair-status.ts`** —
|
|
one shared vocabulary holding the labels, the badge variants, the
|
|
allowed transitions and the transition guard. The service imports the
|
|
guard from there; the two office pages import the labels from there.
|
|
There is exactly ONE `assertRepairTransition` in `src/` — do not add
|
|
a second. The repair request PDF still carries its own older label map
|
|
on purpose; Phase 3 rebuilds that document.
|
|
- **`repairs.order` and `orders.is_repair_order` are still never
|
|
written in practice.** `linkRepairToOrder()` in the service writes
|
|
both, but it has no caller until Phase 3 wires it into order creation.
|
|
- A Repair Request is a **paper document for the supplier**, not a
|
|
delivery note. Repairs do not flow through the customer-DN pipeline.
|
|
- `createCustomerQuoteFromRepair` refuses to overwrite an existing
|
|
`customer_quote` link unless `replace: true` is passed.
|
|
- **The browser token is NOT on the auth context.** `useAuth()` exposes
|
|
`user`, `loading` and the auth actions, and **no `token` field** —
|
|
`useAuth().token` is `undefined`, so a fetch built from it sends NO
|
|
session and every route answers 401 silently. The token lives in the
|
|
PocketBase client auth store: `getPocketBase().authStore.token` (see
|
|
`delivery-pool/page.tsx` and `search/page.tsx`). Any route that
|
|
calls `assertBrowserSession(req)` needs it in the `Authorization`
|
|
header — the `x-wfdash-auth` header the global fetch wrapper adds is
|
|
audit attribution only, and no route treats it as auth.
|
|
- **MERGE GATE — four PocketBase fields must exist before this code
|
|
runs:** `repairs.supplier_scope`, `repairs.signed_request`,
|
|
`orders.repair`, `giellie_chats.mode`. **PB drops unknown fields
|
|
SILENTLY**, so a missing field is NOT an error: the create returns 200
|
|
and the supplier scope is gone, the signed copy is accepted and not
|
|
stored, and the agent is told the upload worked. Add the fields FIRST,
|
|
then verify by round-tripping a save — do not wait for an error, there
|
|
will not be one. The phone booking-in form is the first surface that
|
|
WRITES `supplier_scope`, so it is where the loss would show first.
|
|
- **The repairs REST family requires a browser session** (closed
|
|
2026-11-06). Every handler under `/api/v1/repairs/` checks
|
|
`assertBrowserSession` first and answers **401** without one — they
|
|
read and write through the PocketBase superuser client, so the check is
|
|
the only thing standing between an anonymous caller and a customer's
|
|
address, serial number and fault description. The `repairs` slice of
|
|
the `/api/v1/files/` proxy is gated the same way (photos, the Repair
|
|
Request PDF, the supplier quote PDF **with the pricing**); every other
|
|
collection's files are unaffected. That slice also accepts bearer
|
|
`AGENT_API_KEY`, so the MCP `files.fetch` tool keeps working. The
|
|
`repairs.*` MCP tools are gated by `assertAgentAuth` and call the
|
|
service layer directly, so they are unaffected.
|
|
- **The repairs phone app is at `/repairs-app`** (Phase 2 landed
|
|
2026-11-06). A standalone installable PWA — own manifest
|
|
(`public/manifest-repairs-app.json`), own service worker
|
|
(`public/sw-repairs-app.js`), its own route group
|
|
`src/app/(repairs-app)/repairs-app/`, `MobileAuthGate`, a fixed
|
|
bottom tab bar, and **no** office chrome. It is deliberately NOT in
|
|
the sidebar, like `/m`, `/counter`, `/manager` and `/rep`. Three
|
|
screens: the status board (filter chips whose counts reconcile, status
|
|
shown as text, no money), the booking-in form (camera → photos,
|
|
serial, customer, reported fault, supplier scope), and the repair
|
|
detail with ONE next step from `nextStepFor()`. **No money on any
|
|
phone screen** — the "Make customer quote" step deliberately reads
|
|
"Ask the office to quote" and names the repair and the customer so it
|
|
can be done over the phone, while the shared vocabulary still says
|
|
"Make customer quote" for the MCP and the office.
|
|
- **`/offline` is a real route now.** It was `src/app/offline.tsx`, a
|
|
page component at the wrong path, so `/offline` 404'd — and because
|
|
`Cache.addAll` is atomic, that made **all six** service workers in
|
|
this repo fail to install (browser-proven: installing → redundant). It
|
|
moved to `src/app/offline/page.tsx` on 2026-11-06 and all six now
|
|
install. If you add a seventh worker, remember that every worker here
|
|
registers at scope `/` and its `activate` must not delete the other
|
|
apps' caches — the repairs worker only deletes `wfdash-repairs-app-*`.
|
|
|
|
# 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.
|
|
|
|
# What's New (sidebar popup) — release numbers and commits, never dates
|
|
|
|
The sidebar's "What's New" popup lists releases from `src/lib/release-notes.ts` — a
|
|
hand-written array, newest first. There is no changelog table and no fetch: the notes are
|
|
typed in when a release goes out. The "New" badge lights up when the top entry's release
|
|
number differs from `wfdash-whats-new-seen` in the operator's localStorage.
|
|
|
|
- A release is identified by **its number** and **the short SHA of the commit it went out
|
|
with** (`commit`). The SHA is plain text — never a link; the Forgejo instance is on an
|
|
internal host.
|
|
- **No dates on screen.** The `date` field in the data file is an authoring reference only
|
|
(which window of commits a release covers) and is never rendered. The date formatter was
|
|
deleted, so there is no code path that can turn a date into text, and
|
|
`src/components/whats-new-dialog.test.tsx` fails the build if a date comes back.
|
|
- To publish a release: merge, take `git log --oneline -1`, add an entry at the top with the
|
|
next number (the next one after this change is 6) and that SHA. The number is the "seen"
|
|
marker — bumping it re-notifies everyone.
|
|
|
|
# POS price confirmation (Collection Confirm) — the till price is a decision
|
|
|
|
A POS item's price (`stock_items.selling_price`) is what the till charges. It used to
|
|
move on its own: a bulk import wrote a spreadsheet's selling price straight over the
|
|
catalogue, and the /stock pricing grid silently recomputed the selling price whenever its
|
|
cost cell was edited. Nothing asked.
|
|
|
|
Now the operator decides, per item, when they confirm a PO card. Files:
|
|
`src/lib/pos-price.ts` (pure price maths), `src/lib/server/collection-pos-price-service.ts`
|
|
(the write), `src/components/collection-confirm/pos-price-dialog.tsx` (the dialog), route
|
|
`POST /api/v1/collection-confirm/pos-price`.
|
|
|
|
- **The dialog opens BEFORE the invoice/stock confirm**, once per PO card, and only when
|
|
confirming would move a till price. A PO card with nothing to decide goes straight to
|
|
the confirm dialog.
|
|
- **Every row shows the old price AND the new price.** The default is "Keep price" —
|
|
moving a till price is always deliberate.
|
|
- **The price is derived, never accepted from the client.** The route reads the item's own
|
|
markup and computes the price; the request body carries the COST only.
|
|
- **Update writes cost AND price together**, plus a zero-quantity `cost_history` entry
|
|
and a `stock_ledger` row with reason `adjustment`. Writing the price alone would leave
|
|
the cost stale and the next markup run would compute from the wrong base. The `markup`
|
|
field is NEVER written — the operator's rate is theirs.
|
|
- **Markup rule**: the item's `markup` field if set, else the margin implied by its
|
|
current price and cost, else the 80% default. `src/lib/pos-price.ts` is the only place
|
|
that rule lives, so the number the operator sees is the number that lands.
|
|
- **A failed price write does NOT block the confirm.** Goods stuck unconfirmed is a bigger
|
|
problem than a till price that did not move; the failure is reported by name.
|
|
- `src/lib/pos-price.ts` cannot import the server markup constant (it pulls in the PB admin
|
|
client), so `DEFAULT_MARKUP_PERCENT` is duplicated there. `pos-price.test.ts` fails if
|
|
the two ever disagree.
|
|
|
|
## The stock_item expand bug — `fields` silently empties `expand`
|
|
|
|
`listCollectedItems` expanded `purchase.stock_item` for the stock leg. `stock_purchases` has
|
|
no `stock_item` relation (only `supplier` and `booked_by`) — the relation lives on
|
|
`stock_purchase_items` — so `expand.stock_item` came back undefined on every row and the POS
|
|
pricing was never available. Fixed on 2026-11-09 by expanding `stock_item` directly.
|
|
|
|
**Do not add a `fields` option to that query.** PocketBase applies `fields` to expanded
|
|
relations too, so naming only the parent columns empties `expand` again and the POS pricing
|
|
disappears — the same bug, one option away.
|