Two things stopped the operator quoting a delivery note. Neither was
friction; both were bugs.
1. "The status is not right"
`createQuoteFromDeliveryNote` opened with `if (dn.status !== "draft")`
and refused everything else. A note the office had already sent, or
that the customer had put a PO against, could not be quoted at all,
and the whole error message was that condition.
Now any status except `cancelled` can be quoted — cancelled is the
one genuinely unquotable state, because the work was abandoned and
pricing it would put dead goods on a customer-facing document.
Critically, the status write is now conditional. It used to force
`sent` on every create, which with a relaxed gate would have dragged
a po_received note BACKWARDS to sent and erased the fact that the
customer had sent their PO. Pricing a note is not a reason to move
it. The detail page's Create quote button was also `draft`-only and
now matches the server.
2. "It needs a supplier and this and that"
The quote was being created fine — the PDF was refusing to render.
The readiness check required four things per line, and the two the
modal already supplied were the only two that were satisfiable:
- `is_processed` was never set on a delivery-note quote, and
NOTHING could set it: the quote_items update whitelists exclude
it, and the only writer was /quotes/[id]/process. So a DN-created
quote could not produce a PDF without leaving the flow entirely.
It now derives from the confirmed pricing — cost AND selling
both present — the same rule the process page uses. A line the
operator left unpriced still blocks, so the check keeps its teeth.
- The supplier block is gone. A counter note has no parent order,
so its lines frequently carry no supplier; demanding one refused
the PDF for a field the counter form never asked for. The
supplier is still printed when present.
Signed copy is never a completion signal. The upload route only ever
wrote `signed_copy` — nothing was writing `completed` — and that is now
pinned by a test asserting create-quote never sets `completed` for any
input status, so the relaxed gate cannot introduce it.
One action set, not two
The list and the detail page each hand-wrote the same action set and
had drifted twice: the list got a pricing-review modal the detail page
never received; the detail page got Mark complete, Cancel, Download /
Regenerate PDF and Upload signed that the list never received. The
status gates drifted with them.
`DeliveryNoteActions` now owns the set, the order and the gating. Both
pages render it — icons on the list, labelled buttons on the detail —
and each supplies its own handlers, so behaviour stays where the state
lives but "which buttons, and when" is answered in one place. An
action with no handler is not rendered, so neither presentation can
invent or hide one.
Tests: 27 new. 19 pin the action set and gating, 8 cover the relaxed
gate / no-backwards / is_processed / no-supplier rules. Verified they
bite by regressing create-quote to draft-only and by restoring the
old supplier block. Full suite 3670 passing, tsc clean, no new lint
warnings.
|
||
|---|---|---|
| .agents/skills | ||
| .audit-screenshots | ||
| .claude | ||
| .codex | ||
| .design-review | ||
| .e2e-tmp | ||
| .freebuff | ||
| .kilo | ||
| .mavis/plans | ||
| .od-skills | ||
| .opencode/openwork/inbox/chat-attachments | ||
| .playwright-mcp | ||
| .remember | ||
| .superpowers/brainstorm/39145-1783449414 | ||
| .tmp | ||
| .vscode | ||
| docs | ||
| e2e | ||
| n8n_workfows | ||
| pb-exports | ||
| plugin-source | ||
| pocketbase/pb_hooks | ||
| public | ||
| reports | ||
| scripts | ||
| src | ||
| --full-page | ||
| .gitignore | ||
| .mcp.example.json | ||
| .tmp-inspect-agent-scan.mjs | ||
| AGENT_BRIEF_AUTOFILL_QUOTES.md | ||
| AGENT_BRIEF_AUTOFILL_QUOTES.v1.long.md | ||
| AGENTS.md | ||
| APP_CAPABILITIES.md | ||
| bulk_modal.png | ||
| CLAUDE.md | ||
| components.json | ||
| deno.lock | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| img1_mid2.png | ||
| img1_skus_v.png | ||
| img2_skus_v.png | ||
| INTERACTION_REVIEW_2026-09-01.md | ||
| invoicing-all.png | ||
| invoicing-invoiced.png | ||
| invoicing-ready.png | ||
| invoicing-search-order.png | ||
| invoicing-search-po.png | ||
| invoicing-search.png | ||
| invoicing_loaded.png | ||
| invoicing_page.png | ||
| modal-current.png | ||
| modal-top-1785968700.png | ||
| modal-top.png | ||
| mr8xsu7z-quotes.pb.js | ||
| next.config.ts | ||
| nixpacks.toml | ||
| nss-hex-bolts-din931-pilo_2t.csv | ||
| nss-hex-bolts-din931-pilot.csv | ||
| nss-hex-nut-din934-grade8-a2.csv | ||
| nss-hex-nut-din934.xlsx | ||
| opencode.jsonc | ||
| p1-classified.png | ||
| p1-mask.png | ||
| package-lock.json | ||
| package.json | ||
| page-overview.png | ||
| pb_quote_attachments.schema.json | ||
| pb_schema (1).json | ||
| playwright.config.ts | ||
| postcss.config.mjs | ||
| README.md | ||
| repairs-list-mobile.jpg | ||
| Screenshot 2026-07-09 at 01.12.32.png | ||
| single_modal.png | ||
| sqlite_mcp_server.db | ||
| stock-flow-overview.html | ||
| stock-orders-after.png | ||
| tsconfig.json | ||
| tv-board-1920-v2.jpeg | ||
| tv-board-1920.jpeg | ||
| tv-board-current.jpeg | ||
| tv-board-v3.jpeg | ||
| tv-board-v4.jpeg | ||
| vitest.config.ts | ||
| wfdash-stock-2026-09-02_4-hex-nuts.xlsx | ||
| WFDASH_NEXTJS.code-workspace | ||
WFDASH — Business CRM
A modern business CRM (customers, companies, suppliers, quotes, orders, driver collection pool, delivery notes, repairs) built as a Next.js 16 / PocketBase re-platform of the legacy Django app.
- Frontend: Next.js 16 (App Router) + React 19 + TypeScript
- UI: shadcn/ui (base-nova style), Tailwind CSS 4, Lucide icons, Sonner toasts
- Maps: Leaflet + react-leaflet (clustering, marker edit, fullscreen, dark tiles)
- Backend: PocketBase (database, auth, file storage, realtime)
- MCP: in-app Model Context Protocol endpoint for n8n / external agents
Note
: This is not the Next.js from older training data. APIs, conventions, and file structure differ. Read the relevant guide in
node_modules/next/dist/docs/before writing code, and watch for deprecation notices.
Prerequisites
- Node.js >= 20.9.0 (enforced by
package.jsonengines) - PocketBase binary (the setup script connects to
http://127.0.0.1:8090)but in my .env.local we have added the cloud url that its connecting to now at the moment
Quick Start
# 1. Install deps
npm install
# 2. Start PocketBase (in another terminal)
./pocketbase serve
# 3. Configure env (see "Environment Variables" below)
cp .env.local.example .env.local # or create manually
# 4. Create collections in PocketBase
npm run setup # base collections (customers, companies, suppliers, quotes, orders, …)
npm run pb:migrate:orders-v2 # new order_items_v2 + collection_pool collections
npm run pb:migrate:order-status-in-stock # switch orders.status to the operational enum + add order_items.in_stock
# 5. Run the app
npm run dev
App runs on http://localhost:3000.
Environment Variables
Create .env.local (gitignored) with:
# Public — exposed to the browser
NEXT_PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090
# Server-only — admin used by setup / migration scripts and agent API
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=your_password
# Agent / MCP API auth (see "API & MCP" below)
AGENT_API_KEY=replace_with_secure_token
# Switch which collection the order-items service writes to
# Set to `order_items_v2` after running the migration
ORDER_ITEMS_COLLECTION=order_items
scripts/setup-pocketbase.tscurrently has the admin email/password hard-coded — edit it before running, or set the matching env vars there.
NPM Scripts
| Script | Purpose |
|---|---|
npm run dev |
Start Next.js dev server (webpack) |
npm run build |
Production build |
npm run start |
Run the production build |
npm run lint |
ESLint over the project |
npm run setup |
Create base PocketBase collections from scripts/setup-pocketbase.ts |
npm run pb:migrate:orders-v2 |
Create order_items_v2 + collection_pool collections |
npm test |
Vitest in watch mode |
npm run test:run |
Vitest single run (CI-friendly) |
npm run test:coverage |
Vitest with V8 coverage |
npm run e2e |
Playwright E2E suite (boots next dev on port 3100) |
npm run e2e:ui |
Playwright UI mode |
npm run e2e:install |
One-time: install Playwright browsers + OS deps |
See Testing below.
Features / Routes
All routes are file-based under src/app/.
Public (auth)
/login— sign in
Protected ((app))
/dashboard— overview stats + recent activity/customers,/customers/[id]— CRUD, search, pagination, CSV export/companies,/companies/[id]— CRUD with detail/suppliers— CRUD with tag support/supplier-map— Leaflet map: clustering, marker editing, fullscreen, dark tiles/quotes— list with claim/process workflow/quotes/[id]— detail/quotes/[id]/process— process view/quotes/approvals— approval queue
/orders,/orders/[id]— list + detail, PO generation by supplier/collection-pool— driver work queue (pool of items to collect/deliver)/delivery-notes— delivery note management/repairs— repair requests
Cross-cutting
- Light/dark theme switcher
- Collapsible sidebar, mobile card views
- Service worker for offline (registered client-side)
API & MCP
All agent endpoints require Authorization: Bearer <AGENT_API_KEY>.
| Method | Path | Notes |
|---|---|---|
GET |
/api/v1/health |
Liveness probe (no auth) |
GET |
/api/v1/orders?page=1&perPage=50&status=new |
List orders (expands company,customer,quote) |
POST |
/api/v1/orders/from-quote |
Body: { "quote_id": "..." } — creates an order + copies quote_items into the configured order_items collection; sets item_status=pending, drv_status=unassigned |
GET |
/api/v1/collection-pool?page=1&perPage=50&status=queued |
List pool records |
PATCH |
/api/v1/order-items/[id]/status |
Body: { item_status, drv_status, notes, problem_notes } (also accepts driver_status for backwards compat) |
POST |
/api/mcp |
JSON-RPC style. Methods: tools/list, tools/call |
MCP tools exposed
orders.listorders.create_from_quotecollection_pool.listorder_items.update_status
See docs/api-mcp-layer.md for full payload details.
PocketBase Collections
Created by the setup / migration scripts and tracked in docs/pocketbase/:
customers— customer database (incl.alternative_email,buyer_email,coordinates)companies— company databasesuppliers— supplier network (tags, coordinates, closing time)quotes,quote_items— quotes with line items and status workfloworders,purchase_orders— orders + per-supplier POsorder_items— legacy order itemsorder_items_v2— replacement schema (newitem_status+drv_statusmodel)collection_pool— driver work queuedelivery_notes,delivery_note_items— delivery recordsrepairs— repair requestsstock_items— minimal stock tracking (folded intoorder_itemsfor now)
Workflow rules and parity targets with the old Django app live in:
- docs/core-recheck-matrix.md — Quotes / Orders / Company / Customer recheck matrix
- docs/django-nextjs-parity-backlog.md — migration status per module
- docs/order-workflow-rules.md — status transitions
- docs/order-items-schema-standard.md — the v2 schema
- docs/orders-first-rollout.md — orders-first rollout
- docs/migration-audit.md — field-by-field audit
- docs/api-mcp-layer.md — API + MCP reference
Project Structure
src/
├── app/
│ ├── (auth)/login/ # Login page
│ ├── (app)/ # Protected app pages
│ │ ├── dashboard/
│ │ ├── customers/ (+[id])
│ │ ├── companies/ (+[id])
│ │ ├── suppliers/
│ │ ├── supplier-map/
│ │ ├── quotes/ # + [id], [id]/process, approvals
│ │ ├── orders/ (+[id])
│ │ ├── collection-pool/
│ │ ├── delivery-notes/
│ │ └── repairs/
│ ├── api/
│ │ ├── mcp/route.ts # MCP endpoint
│ │ └── v1/ # Agent REST endpoints
│ │ ├── health/
│ │ ├── orders/ # + from-quote
│ │ ├── order-items/[id]/status/
│ │ └── collection-pool/
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ ├── ui/ # shadcn/ui (base-nova)
│ ├── sidebar.tsx
│ ├── auth-provider.tsx
│ ├── theme-provider.tsx
│ ├── data-table.tsx
│ ├── line-items-table.tsx
│ ├── customer-form.tsx
│ ├── company-form.tsx
│ ├── supplier-form.tsx
│ ├── quote-form.tsx
│ ├── order-form.tsx
│ ├── delete-dialog.tsx
│ ├── supplier-map.tsx
│ └── service-worker-registration.tsx
└── lib/
├── pocketbase.ts # Client (singleton) + server instances
├── pb-helpers.ts
├── types.ts # TypeScript types for all collections
├── utils.ts
├── quote-status.ts
└── server/
├── agent-auth.ts
├── pocketbase-admin.ts
├── orders-service.ts
└── order-item-status.ts
scripts/
├── setup-pocketbase.ts # base collections
├── import-data.ts
└── migrations/
└── create-order-items-v2-and-collection-pool.ts
e2e/ # Playwright E2E tests (see Testing)
docs/ # parity matrix, rollout plans, MCP, schema standards
vitest.config.ts # Vitest config (jsdom, native tsconfig paths)
playwright.config.ts # Playwright config (boots next dev on :3100)
Dockerfile # multi-stage Node 20 alpine build
nixpacks.toml # sets Nixpacks Node version to 20
Testing
The project ships with two test suites, both wired into npm scripts.
Vitest (unit + API route tests)
- Framework: Vitest + jsdom + Testing Library
- Config: vitest.config.ts — path aliases match
tsconfig.json - Test files live next to the code they cover:
src/**/*.{test,spec}.ts(x)
What gets covered:
| File | What it tests |
|---|---|
src/lib/utils.test.ts |
cn() (clsx + tailwind-merge) |
src/lib/quote-status.test.ts |
Status class lookup + format helper |
src/lib/server/order-item-status.test.ts |
normalizeOrderItemStatusInput (every item/drv status, the legacy driver_status alias, invalid inputs) |
src/lib/server/agent-auth.test.ts |
assertAgentAuth (bearer token validation, missing env, status code tagging) |
src/lib/server/orders-service.test.ts |
createOrderFromQuote (idempotency, no-items error, payload shape, sequence increment, env-driven collection switch) and listCollectionPool (filter + pagination forwarding). PocketBase is mocked. |
src/lib/server/pocketbase-admin.test.ts |
getAdminPocketBase env validation + auth call |
src/app/api/v1/health/route.test.ts |
Health endpoint shape |
src/app/api/v1/orders/route.test.ts |
Auth, status filter, pagination, error mapping |
src/app/api/v1/orders/from-quote/route.test.ts |
Auth, 400 on missing quote_id, 201 vs 200 (idempotent), upstream error |
src/app/api/v1/order-items/[id]/status/route.test.ts |
Auth, empty/invalid input, normalisation integration, env-driven collection |
src/app/api/v1/collection-pool/route.test.ts |
Auth, param forwarding, error mapping |
src/app/api/mcp/route.test.ts |
tools/list, every tools/call tool, missing-id guard, unknown-tool error, method-not-found |
Run:
npm run test:run # one-shot, CI-friendly
npm test # watch mode
npm run test:coverage # with v8 coverage
The MCP
tools/callauth path currently routes auth failures through the JSON-RPC catch (returns 500). The dedicated/api/v1/*endpoints properly return 401. Both are exercised in the tests.
Playwright (E2E)
- Framework: Playwright
- Config: playwright.config.ts — boots
next devon port 3100 by default (override withE2E_BASE_URL). - Tests live in
e2e/*.spec.ts
What's covered:
| File | Specs |
|---|---|
e2e/health.spec.ts |
GET /api/v1/health returns the right JSON shape — runs with no infra |
e2e/agent-api.spec.ts |
Agent auth (401 without bearer, 200 with valid token) — skips unless E2E_AGENT_API_KEY is set |
e2e/auth.spec.ts |
Login form: happy path + wrong-password — skips unless E2E_TEST_EMAIL + E2E_TEST_PASSWORD are set |
The skip behaviour is intentional: with no env vars, the suite reports a clean 1 passed, N skipped so you can run it on a fresh checkout. Once you stand up PocketBase + seed a user, set:
export E2E_BASE_URL=http://localhost:3100
export E2E_POCKETBASE_URL=http://127.0.0.1:8090
export E2E_TEST_EMAIL=ci@example.com
export E2E_TEST_PASSWORD=correct-horse-battery-staple
export E2E_AGENT_API_KEY=$AGENT_API_KEY # must match what next dev sees
npm run e2e
First-time setup: npm run e2e:install (downloads Chromium + OS deps).
Manual smoke-test checklist
For things that aren't (yet) covered by automated tests, walk through this against a running dev server + PocketBase:
- Health —
curl http://localhost:3100/api/v1/health→{ ok: true, service: "wfdash-api" } - Auth —
/login, sign in, get redirected into(app). - Customers —
/customers— CRUD, search, paginate, CSV export. - Companies —
/companies→/companies/[id]— CRUD + relation on linked quotes/orders. - Suppliers —
/suppliers— CRUD with tags; verify on/supplier-map. - Supplier map —
/supplier-map— clustering, dark tiles, fullscreen, marker edit persists coords. - Quotes —
/quotes— create, line items, claim, process, email metadata persists; approvals. - Quote → Order (API) —
POST /api/v1/orders/from-quotewith{ quote_id }; new order + copiedorder_items; quote status unchanged (regression fromcore-recheck-matrix.md). - Orders —
/orders/[id]— per-supplier POs, item + drv status updates. - Order item status (API) —
PATCH /api/v1/order-items/{id}/status; bothdrv_statusanddriver_statusaccepted. - Collection pool —
/collection-pool+GET /api/v1/collection-pool; assignment updates stick. - Delivery notes —
/delivery-notes— basic CRUD; no parity guarantees yet (see parity backlog). - Repairs —
/repairs— placeholder (not migrated, per parity backlog). - MCP —
POST /api/mcpwith{"method":"tools/list"}returns 4 tools;tools/callworks for each. - Build & lint —
npm run lintclean;npm run buildcompletes without type errors.
Deployment
A multi-stage Dockerfile is included (Node 20 alpine, non-root nextjs user, standalone output). A nixpacks.toml pins the Node version for platforms like Railway/Render.
# Docker
docker build -t wfdash .
docker run --env-file .env.local -p 3000:3000 wfdash
# Nixpacks / Railway / Render — just push; nixpacks.toml sets Node 20.