docs(spec): repairs app for the phone, and Giellie in repairs mode
This commit is contained in:
parent
9254eecc9a
commit
e56cf01fdf
1 changed files with 811 additions and 0 deletions
811
docs/superpowers/specs/2026-11-05-repairs-app-design.md
Normal file
811
docs/superpowers/specs/2026-11-05-repairs-app-design.md
Normal file
|
|
@ -0,0 +1,811 @@
|
|||
# Repairs app: phone-first capture and tracking, plus Giellie in repairs mode
|
||||
|
||||
Date: 2026-11-05
|
||||
Status: design, approved — pending written review
|
||||
|
||||
## Why
|
||||
|
||||
The person who used to handle repairs is leaving. The job does not go away
|
||||
with him: customers keep calling in with broken machines — a welding
|
||||
machine that will not strike, a compressor that will not build pressure —
|
||||
and someone has to book the machine in, get it to the right supplier, get
|
||||
a quote out, quote the customer, and hand the job over to the buyer.
|
||||
|
||||
Today that job runs on a desktop `DataTable` in `/repairs` with a dialog
|
||||
form. It works for the office. It does not work for someone standing next
|
||||
to a customer's machine with a phone in one hand.
|
||||
|
||||
The operator described the job exactly as it actually runs:
|
||||
|
||||
1. The customer reports a broken machine.
|
||||
2. We collect the machine.
|
||||
3. The repairs person creates the **repair request form**: the machine's
|
||||
serial number, photos of the machine, whose machine it is, what the
|
||||
customer says is wrong, and what the supplier needs to look at.
|
||||
4. The driver takes the form and the machine to the supplier.
|
||||
5. The supplier keeps a copy, **signs one and hands it back**.
|
||||
6. The supplier sends a quote. We upload it against the repair.
|
||||
7. The repair now holds the machine, the photos, the repair request and
|
||||
the supplier quote.
|
||||
8. We do a quote for the customer.
|
||||
9. The customer sends the order. That order **matches the repair**.
|
||||
10. The buyer can see where the machine is, and either sends them the
|
||||
order to go ahead with the repair, or rejects it and collects the
|
||||
machine back unrepaired.
|
||||
|
||||
Two things follow from that description.
|
||||
|
||||
**The capture step is the product.** Steps 3 and 6 are what the person
|
||||
holding the phone actually does. The rest of the chain is office work that
|
||||
already exists and mostly works. "That's why we need to build him
|
||||
something" — the something is a phone app that captures a machine
|
||||
booking-in properly, and then tracks the job from there.
|
||||
|
||||
**The agent cannot help at all yet.** `src/app/api/mcp/route.ts` has
|
||||
**zero** repair tools. `grep repairs` across the 7,462-line MCP route
|
||||
returns nothing. This is a known, logged gap — `FEATURE-REQUESTS.md`
|
||||
line 213, "Repairs: entire MCP domain missing", surfaced 2026-08-28. So
|
||||
"give him full knowledge of repairs via Giellie" is not a re-architecture;
|
||||
it is about eight MCP tools over service functions that already exist.
|
||||
|
||||
### What the code has today, against that flow
|
||||
|
||||
| Step | State |
|
||||
|---|---|
|
||||
| Book in a machine | `repairs` collection, `repairs-service.ts`, six `/api/v1/repairs/*` routes, office page + detail page |
|
||||
| Photos of the machine | `repairs.photos`, multi-file, max 10 |
|
||||
| Serial number, whose machine | `repairs.serial_no`, `repairs.customer`, `repairs.company` |
|
||||
| What the customer says is wrong | `repairs.fault_description` — one field, currently doing both jobs |
|
||||
| What the supplier must look at | **missing** |
|
||||
| Repair request PDF the driver carries | `generateRepairRequest` → `repairs.repair_request` |
|
||||
| The supplier's signed copy coming back | **missing** — we only keep our outgoing PDF |
|
||||
| Supplier quote uploaded | `repairs.quote`, auto-flips status to `quoted` |
|
||||
| Customer quote from the repair | `createCustomerQuoteFromRepair` → `repairs.customer_quote` |
|
||||
| Order from that quote | `orders.create_from_quote` (MCP) |
|
||||
| Order matched to the repair | **half-built and dormant** — `repairs.order` exists and nothing writes it; `orders.is_repair_order` exists and nothing writes or shows it |
|
||||
| Buyer sees where the machine is | **missing** — no linkage to render |
|
||||
| Giellie knows about repairs | **missing** — zero MCP tools |
|
||||
|
||||
### What is wrong with the repair request PDF
|
||||
|
||||
`src/lib/pdf/repair-request-document.tsx` renders a Repair No / Date /
|
||||
Received row, a prominent supplier block, and a four-row item details
|
||||
table: Item Description, Serial Number, **Problem Description**, Status.
|
||||
It then asks the supplier for a numbered list (assessment, parts, labour,
|
||||
timeline, warranty).
|
||||
|
||||
Three things it must do that it does not do:
|
||||
|
||||
- **No customer block.** The supplier cannot see whose machine it is.
|
||||
- **One problem row.** The customer's words and our instruction to the
|
||||
supplier are the same cell.
|
||||
- **No signature block.** Step 5 is the supplier signing a copy and
|
||||
handing it back. There is nowhere to sign.
|
||||
|
||||
And no photos, though the operator lists photos as part of the form.
|
||||
|
||||
### What is wrong with the status labels
|
||||
|
||||
The repair status vocabulary is copied **three** times, with three
|
||||
different sets of words:
|
||||
|
||||
| Status | `repairs/page.tsx` | `repairs/[id]/page.tsx` | `repairs-service.ts` (PDF) |
|
||||
|---|---|---|---|
|
||||
| `new` | New | New | Booked in |
|
||||
| `assessing` | Sent to supplier | Sent to supplier | Sent for Strip & Quote |
|
||||
| `quoted` | Quote received | Quote received | Quote Received |
|
||||
| `approved` | Approved | Approved | Approved |
|
||||
| `in_progress` | In progress | In progress | In Progress |
|
||||
| `completed` | Completed | Completed | Completed |
|
||||
|
||||
A phone app with a status filter row cannot have three vocabularies. This
|
||||
spec consolidates them into one shared module.
|
||||
|
||||
## What success looks like
|
||||
|
||||
- The new person installs one app on his phone and only ever sees
|
||||
repairs. He books a machine in with photos, a serial number, the
|
||||
customer's words and what the supplier needs to look at — in about a
|
||||
minute, one thumb, no training.
|
||||
- He can see the status of every repair without opening anything, and the
|
||||
counts on the filter row add up to the number of repairs.
|
||||
- Every repair carries the full paper trail: photos, the repair request
|
||||
the driver carried, the supplier's signed copy, the supplier quote, the
|
||||
customer quote and the order.
|
||||
- When the customer's order arrives, the buyer opens the order and sees
|
||||
the machine's repair status in place, without asking anyone.
|
||||
- Giellie can drive the whole repair chain by chat, and in repairs mode
|
||||
it will not touch a quote, an order or an email that has nothing to do
|
||||
with repairs.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **A standalone phone app at `/repairs-app`** — own manifest, own
|
||||
service worker, own layout, out of the office sidebar, exactly like
|
||||
`/rep` and `/manager`. A status board, a booking-in form, a repair
|
||||
detail screen, and a Giellie tab.
|
||||
2. **One shared status vocabulary** for every surface — board, detail,
|
||||
PDF, agent.
|
||||
3. **Four new PocketBase fields** to carry what the flow needs and the
|
||||
code cannot express today.
|
||||
4. **The order ↔ repair match**, written in both directions and visible
|
||||
to the buyer.
|
||||
5. **The repair request PDF rebuilt** for the real round trip: customer
|
||||
block, split fault fields, embedded photos, signature block.
|
||||
6. **Eight MCP repair tools**, plus a repairs mode for Giellie that is
|
||||
enforced on the server.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **No offline capture.** The operator confirmed this person always has
|
||||
signal. No outbox, no queue, no local-first conflict handling. The
|
||||
service worker is a shell cache so the app opens instantly and Android
|
||||
offers "Install app"; it does not make the app work without a network.
|
||||
- **No machine location field.** The operator confirmed that "where the
|
||||
machine is" means *the job status*, not a physical location tracker.
|
||||
- **No repairer / assigned-to field.** One operator for now.
|
||||
- **No role gate.** The operator chose open to any signed-in user, the
|
||||
same as `/rep` today. See "Later, not now".
|
||||
- **No new quote or order pipeline.** The customer quote and the order
|
||||
keep running through the existing screens. This spec links them to the
|
||||
repair; it does not redesign them.
|
||||
- **No driver-app work.** See "Later, not now".
|
||||
|
||||
## Anti-goals: it fails if…
|
||||
|
||||
- The person still has to open the office dashboard, scroll a
|
||||
`DataTable` sideways, or learn where a menu is.
|
||||
- The filter counts do not add up to the total, or a status is conveyed by
|
||||
colour alone.
|
||||
- A booking-in can be saved without photos, without a serial, or without
|
||||
the customer — those are the three things the form exists to capture.
|
||||
- The agent can change a repair's status by any route other than the
|
||||
guarded one.
|
||||
- The agent in repairs mode can send a customer email without asking.
|
||||
- Money (cost, selling price, margin) appears on the board or on the
|
||||
booking-in form.
|
||||
|
||||
## How the job actually runs
|
||||
|
||||
This is the reference the rest of the spec is written against.
|
||||
|
||||
```
|
||||
Customer reports a broken machine
|
||||
|
|
||||
v
|
||||
We collect the machine ------------------------------+
|
||||
| |
|
||||
v |
|
||||
Repairs person books it in: |
|
||||
. photos of the machine |
|
||||
. serial number |
|
||||
. whose machine it is (customer) |
|
||||
. what the customer says is wrong |
|
||||
. what the supplier must look at |
|
||||
| |
|
||||
v |
|
||||
Repair request PDF is generated and PRINTED |
|
||||
| |
|
||||
v |
|
||||
Driver carries form + machine to supplier |
|
||||
| |
|
||||
v |
|
||||
Supplier keeps a copy, SIGNS ONE, hands it back <-----+
|
||||
| |
|
||||
+--> signed_request (uploaded) |
|
||||
| |
|
||||
v |
|
||||
Supplier sends quote --> quote (uploaded) --> status = quoted
|
||||
| |
|
||||
v |
|
||||
Customer quote from the repair --> customer_quote |
|
||||
| |
|
||||
v |
|
||||
Customer sends the order --> orders.repair set --------+
|
||||
|
|
||||
v
|
||||
Buyer sees the repair status on the order:
|
||||
approve -> supplier works -> completed
|
||||
reject -> cancelled, machine collected back
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
### 1. PocketBase schema — four new fields
|
||||
|
||||
All four are **new fields on existing collections**. None of them adds a
|
||||
value to an existing `select`, so none of them trips the AGENTS.md
|
||||
"select fields are not self-service" gotcha — the operator creates each
|
||||
field in the PB admin and the code follows.
|
||||
|
||||
| Collection | Field | Type | Why |
|
||||
|---|---|---|---|
|
||||
| `repairs` | `supplier_scope` | text | What the supplier must look at. Separate from the customer's reported fault because two different people say different things on the same form. |
|
||||
| `repairs` | `signed_request` | file, multi, max 10, 8MB | The supplier's signed copy coming back in step 5. |
|
||||
| `orders` | `repair` | relation to `repairs`, single | The order to repair match. Makes the link queryable in both directions without walking order to quote to repair. |
|
||||
| `giellie_chats` | `mode` | text | Chooses the Giellie persona and tool allowlist. Empty = general. |
|
||||
|
||||
**Why `fault_description` is kept and not renamed.** The operator's flow
|
||||
names two things: what the customer says is wrong, and what the supplier
|
||||
must look at. The obvious move is two new fields,
|
||||
`customer_fault` plus `supplier_scope`. But `fault_description` already
|
||||
holds the customer's report for every existing repair, and renaming it
|
||||
means a data migration, a broken `Repair` type, a broken PDF prop and a
|
||||
broken office form for no gain the operator can see — the labels on the
|
||||
form and the PDF carry the meaning, not the column name. So:
|
||||
|
||||
- `fault_description` keeps its name and means **what the customer says
|
||||
is wrong**. Existing rows keep their meaning.
|
||||
- `supplier_scope` is new and means **what the supplier must look at**.
|
||||
- The booking-in form and the PDF label them "Reported fault" and "What
|
||||
the supplier must look at", so the split is visible to every human who
|
||||
touches it.
|
||||
|
||||
Where an existing row's `fault_description` was written as an instruction
|
||||
to the supplier rather than a customer report, the PDF will show it under
|
||||
"Reported fault". That is a historical inaccuracy on old rows only, and
|
||||
it is not worth a migration.
|
||||
|
||||
**`orders.is_repair_order` stays and gets filled in.** It already exists
|
||||
on `orders`, it is already a documented filter on `orders.list` in the
|
||||
MCP, and the n8n AI-insights prompt already reads it. It is simply never
|
||||
set to `true`. Whenever `orders.repair` is set, `is_repair_order` is set
|
||||
to `true` in the same write, so the flag means what it has always claimed
|
||||
to mean.
|
||||
|
||||
### 2. One shared status vocabulary
|
||||
|
||||
New module `src/lib/repair-status.ts`, the single source for every
|
||||
repairs surface:
|
||||
|
||||
```ts
|
||||
export const REPAIR_STATUS = [
|
||||
"new", "assessing", "quoted", "approved", "in_progress", "completed", "cancelled",
|
||||
] as const;
|
||||
|
||||
export type RepairStatus = (typeof REPAIR_STATUS)[number];
|
||||
|
||||
export const REPAIR_STATUS_LABEL: Record<RepairStatus, string>;
|
||||
export const REPAIR_STATUS_VARIANT: Record<RepairStatus, "outline" | "secondary" | "default" | "destructive">;
|
||||
export const REPAIR_TRANSITIONS: Record<RepairStatus, RepairStatus[]>;
|
||||
export type RepairAction =
|
||||
| "generate_repair_request"
|
||||
| "upload_supplier_quote"
|
||||
| "create_customer_quote"
|
||||
| "start_work"
|
||||
| "mark_completed";
|
||||
|
||||
export function nextStepFor(status: RepairStatus): { label: string; action: RepairAction } | null;
|
||||
```
|
||||
|
||||
The labels, chosen to match the operator's own words for the job:
|
||||
|
||||
| Status | Label shown everywhere | What it means on the floor |
|
||||
|---|---|---|
|
||||
| `new` | Booked in | Machine collected; repair request being prepared |
|
||||
| `assessing` | With supplier | Request printed, machine with the supplier, being assessed |
|
||||
| `quoted` | Quote in | Supplier quote received and uploaded |
|
||||
| `approved` | Approved | Customer approved; supplier authorised to work |
|
||||
| `in_progress` | On the bench | Supplier is doing the work |
|
||||
| `completed` | Done | Machine back with the customer |
|
||||
| `cancelled` | Cancelled | Customer rejected; machine collected back unrepaired |
|
||||
|
||||
The transition map moves out of `repairs-service.ts` into this module and
|
||||
the service imports it — the guard stays enforced in one place, and the
|
||||
phone app's "next step" button cannot drift from what the server allows.
|
||||
|
||||
The three label copies in `repairs/page.tsx`, `repairs/[id]/page.tsx`
|
||||
and `repairs-service.ts` are deleted and replaced by imports.
|
||||
|
||||
### 3. The service layer
|
||||
|
||||
`src/lib/server/repairs-service.ts`:
|
||||
|
||||
- `CreateRepairInput` and `UpdateRepairInput` gain `supplier_scope`.
|
||||
`updateRepair` writes it when supplied.
|
||||
- `RepairFileField` gains `"signed_request"`. `uploadRepairFile` already
|
||||
implements the correct append pattern — re-PATCH with the old filenames
|
||||
plus the new one — so the signed copy is added, never substituted. This
|
||||
matters: AGENTS.md records a driver's photo being deleted on 2026-10-19
|
||||
by a bare-field PATCH that replaced the whole list.
|
||||
- `generateRepairRequest` passes the customer and `supplier_scope` to
|
||||
the PDF renderer.
|
||||
- New `linkRepairToOrder(orderId, repairId)` writes the link in both
|
||||
directions in one go: `orders.repair`, `orders.is_repair_order = true`,
|
||||
and `repairs.order`. `repairs.order` already exists on the collection
|
||||
and has never been written; the repair detail screen needs it to show
|
||||
the linked order.
|
||||
- New `getRepairStatusCounts()` returns `{ total, byStatus }` by running
|
||||
one count query per status. PocketBase returns `totalItems` for a
|
||||
filtered list, so each is a single cheap `getList(perPage: 1)`.
|
||||
|
||||
`src/lib/types.ts`: the `Repair` interface gains `supplier_scope?` and
|
||||
`signed_request?`, and `expand` gains `signed_request?: string[]`. The
|
||||
`Order` interface gains `repair?: string` and `expand.repair?: Repair`.
|
||||
|
||||
### 4. The phone app
|
||||
|
||||
Route group `(repairs-app)`, mirroring `(rep)`:
|
||||
|
||||
```
|
||||
src/app/(repairs-app)/repairs-app/layout.tsx metadata, viewport, safe-area
|
||||
src/app/(repairs-app)/repairs-app/page.tsx status board
|
||||
src/app/(repairs-app)/repairs-app/book-in/page.tsx booking-in form
|
||||
src/app/(repairs-app)/repairs-app/[id]/page.tsx repair detail
|
||||
src/app/(repairs-app)/repairs-app/chat/page.tsx Giellie in repairs mode
|
||||
public/manifest-repairs-app.json
|
||||
public/sw-repairs-app.js
|
||||
```
|
||||
|
||||
**Layout.** Same shape as `(rep)/rep/layout.tsx`: its own `metadata` with
|
||||
`manifest: "/manifest-repairs-app.json"`, the
|
||||
`"apple-mobile-web-app-capable": "yes"` override (iOS only honours the
|
||||
apple-prefixed name, and without it the home-screen icon opens as a
|
||||
Safari bookmark rather than a standalone app), `viewportFit: "cover"`,
|
||||
safe-area padding top and bottom, and `MobileAuthGate` from
|
||||
`src/app/(mobile)/m/auth-gate.tsx`.
|
||||
|
||||
**Manifest.** `public/manifest-repairs-app.json`, same shape as
|
||||
`public/manifest-rep.json`: `display: "standalone"`,
|
||||
`orientation: "portrait"`, `start_url: "/repairs-app"`,
|
||||
`scope: "/repairs-app"`, the two Android icons. Display name
|
||||
**"WFDASH — Repairs"**, short name **"Repairs"**.
|
||||
|
||||
**Service worker.** `public/sw-repairs-app.js` — a shell cache, not an
|
||||
offline app. Precaches the shell, serves the cached shell when the
|
||||
network fails, and ignores `/api/` entirely so live data is never served
|
||||
stale. Registered by adding `/repairs-app` to `pickSwPath()` in
|
||||
`src/components/service-worker-registration.tsx`.
|
||||
|
||||
**Bottom tab bar.** Two tabs — **Repairs** and **Ask Giellie** — fixed to
|
||||
the bottom of the viewport, above the safe-area inset. The tab bar is the
|
||||
only navigation in the app. There is no sidebar and no office chrome.
|
||||
|
||||
#### 4a. The status board — `/repairs-app`
|
||||
|
||||
The "easily see the status of each repair" screen.
|
||||
|
||||
A filter row of chips, each with a count, wrapping on a narrow phone:
|
||||
|
||||
```
|
||||
All 12 . Booked in 3 . With supplier 2 . Quote in 4 .
|
||||
Approved 1 . On the bench 1 . Done 1 . Cancelled 0
|
||||
```
|
||||
|
||||
The counts come from `GET /api/v1/repairs/status-counts` and **must
|
||||
reconcile**: the seven status counts add up to `All`. Every status shows
|
||||
all the time, with a zero when it has none, so the row is never a
|
||||
shifting target. The row wraps onto two or three lines on a narrow
|
||||
phone — it is the first thing on the screen and it is allowed to take
|
||||
the space it needs.
|
||||
|
||||
Each repair is a card, minimum 64px tall, tappable:
|
||||
|
||||
```
|
||||
+-------------------------------------+
|
||||
| R-0007 [ On the bench ] | status as TEXT + colour
|
||||
| Hydraulic press, 40T |
|
||||
| Borer Plant . S/N HP-2211 |
|
||||
| 3 photos . Supplier: Bolt & Nut |
|
||||
+-------------------------------------+
|
||||
```
|
||||
|
||||
Rules for this surface, all of them deliberate:
|
||||
|
||||
- **Status is text.** Colour is a second signal, never the only one.
|
||||
- **No money.** No cost, no selling price, no margin. Supplier cost and
|
||||
customer price live on the detail screen and on the documents.
|
||||
- **No hiding.** Every repair is listed. Filters narrow the list, they do
|
||||
not hide repairs behind a count. The "All" number is always the honest
|
||||
total.
|
||||
- **Counts are always visible**, so a tab total never has to be inferred.
|
||||
|
||||
Above the list: a search box (the existing `search` param covers repair
|
||||
number, item, fault, notes and serial). Below the list, pinned above the
|
||||
tab bar: a full-width **"Book in a machine"** button, within thumb reach.
|
||||
|
||||
Data: `GET /api/v1/repairs?page=&perPage=&search=&status=` — the route
|
||||
already exists and already supports all four.
|
||||
|
||||
#### 4b. Book in — `/repairs-app/book-in`
|
||||
|
||||
Seven fields, in this order, each with a real label:
|
||||
|
||||
1. **Whose machine?** — customer search (`CustomerSearch` already
|
||||
exists). Required.
|
||||
2. **Machine** — what it is. Required.
|
||||
3. **Serial number** — required. It is the number the supplier quotes
|
||||
against and the number the customer identifies the machine by.
|
||||
4. **Reported fault** — what the customer says is wrong, in the
|
||||
customer's words. Required.
|
||||
5. **What the supplier must look at** — our instruction to the supplier.
|
||||
6. **Supplier** — optional search (`SupplierSearch` already exists). Can
|
||||
be set later; the repair request cannot be generated without one, and
|
||||
the app says so rather than failing.
|
||||
7. **Photos** — a **Take photo** button using
|
||||
`<input type="file" accept="image/*" capture="environment">` so the
|
||||
camera opens directly, plus a strip of thumbnails. Up to 10, matching
|
||||
the field's `maxSelect`. **At least one photo is required** — the
|
||||
photos are the record of the machine's condition when it was booked
|
||||
in, and a repair without them cannot be defended later.
|
||||
|
||||
**Required:** customer, machine, serial number, reported fault, and at
|
||||
least one photo. **Optional:** what the supplier must look at, and the
|
||||
supplier.
|
||||
|
||||
A pinned **"Book in"** button. On success it navigates straight to the new
|
||||
repair's detail screen and shows the repair number.
|
||||
|
||||
The existing page already enforces the MIME and size rules
|
||||
(JPEG/PNG/WEBP/HEIC, 8MB); the phone form reuses the same constants
|
||||
rather than inventing new ones.
|
||||
|
||||
#### 4c. Repair detail — `/repairs-app/[id]`
|
||||
|
||||
Header: repair number, and the status chip (text + colour).
|
||||
|
||||
**The next-step card**, at the top, holds one full-width 56px button —
|
||||
the single action most likely to be next for the current status:
|
||||
|
||||
| Status | The button says | What it does |
|
||||
|---|---|---|
|
||||
| Booked in | **Send to supplier** | Generates the repair request PDF and flips the status to With supplier |
|
||||
| With supplier | **Upload supplier quote** | Opens the file picker for `quote` |
|
||||
| Quote in | **Make customer quote** | Opens the customer-quote form |
|
||||
| Approved | **Start work** | Flips to On the bench |
|
||||
| On the bench | **Mark completed** | Flips to Done |
|
||||
| Done / Cancelled | *(none)* | Terminal — no button, no dead control |
|
||||
|
||||
The label comes from `nextStepFor(status)` in the shared module, so the
|
||||
button and the server's transition guard can never disagree.
|
||||
|
||||
Below it:
|
||||
|
||||
- **Photos** — thumbnail strip, tap to enlarge, plus a **Take photo**
|
||||
button.
|
||||
- **Documents** — Repair Request (open/download), **Signed copy**,
|
||||
Supplier quote, Supplier PO, Customer quote. Each row shows the file
|
||||
name and a download action. Files are served through
|
||||
`/api/v1/files/{collection}/{recordId}/{filename}` — never
|
||||
`/api/files/`, which 404s in this app.
|
||||
- **Customer quote and order** — a card showing the linked customer quote
|
||||
number and the linked order number, each linking out.
|
||||
- **Notes** — editable, saved on change.
|
||||
- **Status timeline** — read-only, the seven steps with the current one
|
||||
marked.
|
||||
|
||||
### 5. The repair request PDF
|
||||
|
||||
`src/lib/pdf/repair-request-document.tsx` and
|
||||
`renderRepairRequestPdf` in `src/lib/pdf/render.tsx` change shape.
|
||||
|
||||
**New props:** `customerName`, `companyName`, `customerContact`,
|
||||
`supplierScope`, `photos` (array of `{ dataUri, caption? }`),
|
||||
`showSignatureBlock`.
|
||||
|
||||
**What the page gains:**
|
||||
|
||||
- A **customer block** — "Whose machine": customer name, company, contact
|
||||
name and phone if known. Placed under the supplier block, because the
|
||||
supplier reads the recipient first and the owner second.
|
||||
- The item details table's **"Problem Description" row is split** into
|
||||
**"Reported fault"** (customer's words) and **"What the supplier must
|
||||
look at"**.
|
||||
- **Photos**, in a grid under the item details. The operator lists photos
|
||||
as part of the form, and a signed record of the machine's condition is
|
||||
the point of taking them at booking-in.
|
||||
- A **supplier acceptance block** at the bottom: "Received and accepted
|
||||
by", with a name line, a signature line and a date line. This is the
|
||||
copy the supplier signs and hands back in step 5.
|
||||
|
||||
**What stays:** the Repair No / Date / Received row, the prominent
|
||||
supplier block, and the numbered list the supplier is asked to answer
|
||||
(assessment, parts, labour, timeline, warranty).
|
||||
|
||||
**Photo embedding.** `@react-pdf/renderer` renders an `Image` from a
|
||||
data URI. The renderer fetches the photo bytes from PocketBase through
|
||||
the admin client and embeds them as base64 data URIs.
|
||||
|
||||
Caps, so one 10-photo repair cannot produce a 40MB PDF:
|
||||
|
||||
- At most **6 photos** on the PDF, even if more were uploaded. The
|
||||
booking-in keeps all 10; the PDF shows the first 6.
|
||||
- Each is downscaled to **1,200px on the long edge** and re-encoded as
|
||||
JPEG at quality 80 before embedding.
|
||||
- If a photo cannot be fetched or decoded, it is **skipped**, not fatal.
|
||||
A missing photo must never stop the driver from getting the form.
|
||||
- If no photo can be embedded, the photo section is omitted entirely
|
||||
rather than left as an empty frame.
|
||||
|
||||
**The status row** uses the shared `REPAIR_STATUS_LABEL`, so the PDF and
|
||||
the phone app say the same word for the same state.
|
||||
|
||||
### 6. The order to repair match
|
||||
|
||||
**Writing it.** When a customer quote created from a repair becomes an
|
||||
order, the order must know about the repair. The chain is
|
||||
`repairs.customer_quote` to quote to order, and the quote that comes out
|
||||
of a repair carries `customer_quote` set on the repair row. When
|
||||
`orders.create_from_quote` runs against such a quote, the order service
|
||||
resolves the repair by reverse lookup on `repairs.customer_quote` and
|
||||
writes `orders.repair` plus `orders.is_repair_order = true`, and stamps
|
||||
`repairs.order` so the repair can find its order.
|
||||
|
||||
Reverse lookup on `customer_quote` is a single PB filter
|
||||
(`customer_quote = "<quote id>"`). `createCustomerQuoteFromRepair`
|
||||
already guarantees at most one repair per quote — it refuses to overwrite
|
||||
an existing link unless `replace: true` is passed — so the lookup is
|
||||
unambiguous.
|
||||
|
||||
**Reading it.** The order page (`src/app/(app)/orders/[id]/page.tsx`)
|
||||
gains a **Repair card**, shown when `orders.repair` is set:
|
||||
|
||||
- The repair number, linking to `/repairs/[id]`.
|
||||
- The repair's status, using the shared label — so the buyer sees "With
|
||||
supplier", not a raw status string.
|
||||
- Whose machine it is (customer) and the serial number.
|
||||
- A one-line summary of the reported fault.
|
||||
- The repair request and the customer quote as documents.
|
||||
|
||||
This is the operator's step 10: *the buyer can see where the machine is*.
|
||||
The repair status **is** "where the machine is" — the machine is booked
|
||||
in, with the supplier, quoted, approved, on the bench, or done.
|
||||
|
||||
**The approve / reject decision.** The buyer's two paths are the existing
|
||||
order flows: approve the order and the supplier is authorised to work;
|
||||
reject the order and the machine is collected back unrepaired. The spec
|
||||
does not build a new approval surface. What it does is make the repair
|
||||
visible on the order so the decision is informed, and make
|
||||
`repairs.cancelled` the honest terminal state for a rejected job.
|
||||
|
||||
### 7. Giellie in repairs mode
|
||||
|
||||
#### 7a. Eight MCP tools
|
||||
|
||||
Added to `src/app/api/mcp/route.ts`, each wrapping the service layer.
|
||||
The route is 7,462 lines with 148 `name:` entries today; these land in
|
||||
the same style as every other tool there.
|
||||
|
||||
| Tool | Class | Arguments | Wraps |
|
||||
|---|---|---|---|
|
||||
| `repairs.list` | read | `page`, `perPage`, `search`, `status` | `listRepairs` |
|
||||
| `repairs.get` | read | `id` | `getRepair` |
|
||||
| `repairs.create` | write | `customer`, `company`, `order`, `supplier`, `item_description`, `fault_description`, `supplier_scope`, `serial_no`, `notes` | `createRepair` |
|
||||
| `repairs.update` | write | `id`, `patch` (no `status`) | `updateRepair` |
|
||||
| `repairs.advance_status` | write | `id`, `to` | `updateRepair` plus transition guard, returns `nextStepFor(to)` |
|
||||
| `repairs.upload_file` | write | `id`, `field`, `filename`, `content_type`, `base64` | `uploadRepairFile` |
|
||||
| `repairs.generate_repair_request` | write | `id` | `generateRepairRequest` |
|
||||
| `repairs.create_customer_quote` | write | `id`, `items[]`, `quote_notes?`, `validity_days?`, `description?`, `replace?` | `createCustomerQuoteFromRepair` |
|
||||
|
||||
**Status only moves through `repairs.advance_status`.** `repairs.update`
|
||||
rejects `status` in its patch. This keeps one guarded door rather than
|
||||
two, and it means the agent cannot bypass `assertTransition` by writing
|
||||
the field directly.
|
||||
|
||||
**There is no `repairs.delete`.** The tool policy already denies anything
|
||||
ending in `.delete`, and Giellie's persona says it can never delete
|
||||
anything. A repair is cancelled, not removed.
|
||||
|
||||
`repairs.upload_file` accepts `photos`, `quote`, `purchase_order`,
|
||||
`repair_request` and `signed_request`. It appends; it never replaces.
|
||||
|
||||
#### 7b. Policy entries
|
||||
|
||||
In `src/lib/server/giellie-tool-policy.ts`:
|
||||
|
||||
- `repairs.list` and `repairs.get` are added to `READ_TOOLS`.
|
||||
- The six write tools fall through to the default `write` class, which
|
||||
pauses for approval and is auto-approved only when the chat is trusted.
|
||||
- **No repair tool is `confirm` class.** None of them sends an email.
|
||||
`quotes.send_email`, `purchase_orders.send_email` and friends stay
|
||||
`confirm` and always ask, in repairs mode as everywhere else.
|
||||
|
||||
#### 7c. The mode
|
||||
|
||||
`giellie_chats.mode` is a text field on the chat row. Empty means the
|
||||
general office agent.
|
||||
|
||||
**The mode is read from the stored row, never from the request.** This is
|
||||
the whole point: if the client could send `mode=repairs` — or omit it —
|
||||
the scope would be a suggestion rather than a boundary. So:
|
||||
|
||||
1. `createChat(userId, mode)` writes the mode onto the new row.
|
||||
`POST /api/v1/giellie/chats` accepts an optional `mode` of
|
||||
`"repairs"`; anything else is rejected, not silently coerced.
|
||||
2. `giellie-service.ts` `prepare()` loads the chat row and derives the
|
||||
persona and the tool allowlist from its `mode`.
|
||||
3. `listChats` filters by mode, so the repairs tab shows only repairs
|
||||
chats and the office tab shows only general chats. The two lists never
|
||||
mix.
|
||||
|
||||
**The allowlist in repairs mode** is the eight repair tools plus the
|
||||
lookup and creation calls the flow actually needs:
|
||||
|
||||
- Read: `customers.list`, `customers.get`, `suppliers.list`,
|
||||
`suppliers.get`, `quotes.list`, `quotes.get`, `quotes.lookup`,
|
||||
`orders.get`, `orders.list`
|
||||
- Write: the six repair write tools, plus `orders.create_from_quote`
|
||||
|
||||
Everything else — stock, the POS, invoices, sprint, buyer tasks, the
|
||||
other quote tools — is not offered to the repairs agent at all. The
|
||||
allowlist is applied when the tool list is built for the model, so a
|
||||
tool that is not in the list cannot be called, not merely discouraged.
|
||||
|
||||
#### 7d. The repairs persona
|
||||
|
||||
`buildSystemPrompt` in `src/lib/server/giellie-system-prompt.ts` gains
|
||||
a `mode` argument and returns the general prompt or the repairs prompt.
|
||||
The repairs prompt is short, because the person reading it is not
|
||||
technical:
|
||||
|
||||
> You are the repairs assistant for Westcott Fasteners. You help the
|
||||
> person who handles repairs: book machines in, photograph them, get them
|
||||
> to the right supplier, track the job, quote the customer and hand the
|
||||
> work to the buyer.
|
||||
>
|
||||
> - Speak in plain, short South African English. One question at a time.
|
||||
> Never send a form at someone.
|
||||
> - Before any write, say which repair you are changing and name its
|
||||
> repair number, then do it.
|
||||
> - To book a machine in you need: whose machine it is, what it is, what
|
||||
> is wrong, and photos. Ask for what is missing, one thing at a time.
|
||||
> - "What the supplier must look at" is our instruction to the supplier.
|
||||
> It is not the same as what the customer said is wrong. If the person
|
||||
> gives you one, ask whether the other is different.
|
||||
> - When a supplier quote arrives, upload it against the repair. That
|
||||
> moves the repair to "Quote in".
|
||||
> - A supplier may hand back a signed copy of the repair request. Upload
|
||||
> it too.
|
||||
> - To quote the customer, ask for the line items and their prices
|
||||
> before you create anything.
|
||||
> - Never send an email to a customer or a supplier without asking.
|
||||
|
||||
The last line is the general prompt's rule restated for the reader who
|
||||
does not know it. The general prompt already says emails, cancellations
|
||||
and contact-detail changes always ask.
|
||||
|
||||
#### 7e. The chat tab
|
||||
|
||||
`/repairs-app/chat` renders the existing chat components — `ChatList`,
|
||||
`MessageList`, `ApprovalCard`, `Composer`, and the `useGielie` hook
|
||||
— in a phone-sized shell. It is the same code the office uses, with the
|
||||
repairs persona behind it.
|
||||
|
||||
The "Trust this chat" switch is kept: with it on, ordinary changes are
|
||||
auto-approved and the person is not tapping approve on every photo
|
||||
upload. Emails, cancellations and contact-detail changes still always
|
||||
ask, whatever the switch says. The switch label stays as it is today.
|
||||
|
||||
### 8. Files
|
||||
|
||||
**New**
|
||||
|
||||
- `src/app/(repairs-app)/repairs-app/layout.tsx`
|
||||
- `src/app/(repairs-app)/repairs-app/page.tsx`
|
||||
- `src/app/(repairs-app)/repairs-app/book-in/page.tsx`
|
||||
- `src/app/(repairs-app)/repairs-app/[id]/page.tsx`
|
||||
- `src/app/(repairs-app)/repairs-app/chat/page.tsx`
|
||||
- `src/lib/repair-status.ts` — the shared status vocabulary
|
||||
- `src/app/api/v1/repairs/status-counts/route.ts`
|
||||
- `public/manifest-repairs-app.json`
|
||||
- `public/sw-repairs-app.js`
|
||||
|
||||
**Changed**
|
||||
|
||||
- `src/lib/server/repairs-service.ts` — `supplier_scope`, `signed_request`, `linkRepairToOrder`, `getRepairStatusCounts`, shared transition map, customer + scope + photos into the PDF
|
||||
- `src/lib/types.ts` — `Repair`, `Order`
|
||||
- `src/lib/pdf/repair-request-document.tsx` — customer block, split fault rows, photos, signature block
|
||||
- `src/lib/pdf/render.tsx` — `renderRepairRequestPdf` props
|
||||
- `src/app/api/mcp/route.ts` — eight repair tools
|
||||
- `src/lib/server/giellie-tool-policy.ts` — two read tools
|
||||
- `src/lib/server/giellie-system-prompt.ts` — `mode` argument, repairs prompt
|
||||
- `src/lib/server/giellie-service.ts` — `prepare()` derives persona + allowlist from the stored mode
|
||||
- `src/lib/server/giellie-chat-store.ts` — `mode` on the row, `createChat(userId, mode)`, `listChats` filters by mode
|
||||
- `src/app/api/v1/giellie/chats/route.ts` — accepts `mode` on create
|
||||
- `src/components/service-worker-registration.tsx` — `/repairs-app` to `/sw-repairs-app.js`
|
||||
- `src/app/(app)/repairs/page.tsx`, `src/app/(app)/repairs/[id]/page.tsx` — shared labels
|
||||
- `src/app/(app)/orders/[id]/page.tsx` — the repair card
|
||||
|
||||
## Build order
|
||||
|
||||
The phases are sequenced so each one leaves the app in a working state and
|
||||
each one can be verified before the next starts.
|
||||
|
||||
1. **Service layer and schema.** The shared status module, the four PB
|
||||
fields, `supplier_scope`, `signed_request`, `linkRepairToOrder`,
|
||||
`getRepairStatusCounts`, and the eight MCP tools with their policy
|
||||
entries. After this, Giellie can drive repairs by chat from the office
|
||||
desktop, even though the phone app does not exist yet.
|
||||
2. **The phone app.** The route group, manifest, service worker, status
|
||||
board, booking-in form and repair detail. This is what the new person
|
||||
installs, and it is the reason for the whole build.
|
||||
3. **The supplier round trip and the hand-off.** The repair request PDF
|
||||
with the customer block, split fault rows, photos and signature block;
|
||||
the signed copy upload; the order link and the repair card on the order
|
||||
page.
|
||||
4. **Giellie in repairs mode.** The `mode` field, the repairs persona,
|
||||
the server-side allowlist, and the chat tab inside the phone app.
|
||||
|
||||
Phases 1 and 2 do not depend on each other and could be built in the
|
||||
other order, but designing the chat against real tool names produces a
|
||||
better chat, so 1 goes first.
|
||||
|
||||
## Data
|
||||
|
||||
No migration. `fault_description` is untouched and keeps its meaning. The
|
||||
four new fields start empty. Every existing `giellie_chats` row has no
|
||||
`mode`, which means the general agent, which is what all of them are
|
||||
today.
|
||||
|
||||
## Manual steps on the live system
|
||||
|
||||
Four PB admin steps. Do them before the code that writes the field ships
|
||||
— a missing field makes the write fail rather than degrade.
|
||||
|
||||
1. `repairs` add field **`supplier_scope`**, type **text**.
|
||||
2. `repairs` add field **`signed_request`**, type **file**, max select
|
||||
**10**, max size **8MB**, allowed mime types: **application/pdf** plus
|
||||
the same image set as `photos` (JPEG, PNG, WEBP, HEIC). The signed
|
||||
copy is usually a photograph of the paper form, but it may be a PDF
|
||||
the supplier emails back.
|
||||
3. `orders` add field **`repair`**, type **relation**, collection
|
||||
**repairs**, max select **1**.
|
||||
4. `giellie_chats` add field **`mode`**, type **text**.
|
||||
|
||||
None of these adds a value to an existing `select`, so the AGENTS.md
|
||||
select-field gotcha does not apply. If a relation field needs the repairs
|
||||
collection id, the PB admin UI picks it from the dropdown.
|
||||
|
||||
After that: the new person needs a `users` record and the app installed
|
||||
on his phone ("Add to Home Screen" on iOS, "Install app" on Android).
|
||||
|
||||
## Decisions
|
||||
|
||||
Confirmed with the operator on 2026-11-05:
|
||||
|
||||
1. **A separate `/repairs-app` PWA**, not a repairs mode inside the
|
||||
existing app. The office's `/repairs` page keeps its path; the phone
|
||||
app gets its own. It is not in the sidebar, like `/rep`, `/m`,
|
||||
`/manager` and `/counter`.
|
||||
2. **Full lifecycle.** The repairs person can take a job all the way to
|
||||
a customer quote and an order. The hand-off to quotes and orders is
|
||||
part of the job, not a separate team's.
|
||||
3. **Always has signal.** No offline queue. The service worker is a shell
|
||||
cache only.
|
||||
4. **One operator.** The app is built for the single person who handles
|
||||
repairs. No assignment, no multi-tenant filtering.
|
||||
5. **Open to any signed-in user.** Same as `/rep` today. No role gate.
|
||||
6. **Job status is "where the machine is".** No physical location field.
|
||||
7. **Print the repair request PDF** and the driver carries it.
|
||||
8. **No deletes.** Cancelling is how a repair is undone.
|
||||
|
||||
## Left to the builder
|
||||
|
||||
- The exact colour tokens for the status chips, reusing the theme's
|
||||
existing badge variants rather than introducing new ones.
|
||||
- Whether the booking-in form's photos are uploaded on submit as a batch
|
||||
or one at a time as they are taken. One at a time is more robust on a
|
||||
slow connection but means a partial save if the phone drops mid-form.
|
||||
- The wording of the empty states ("No repairs match this filter").
|
||||
- Whether the repair detail screen's photo strip is a horizontal scroll
|
||||
or a 3-column grid, depending on how it reads on a small screen.
|
||||
|
||||
## Later, not now
|
||||
|
||||
These are real and wanted, and deliberately not in this spec.
|
||||
|
||||
- **The driver leg.** The operator's plan: the repair request goes into
|
||||
the **collection pool** and gets assigned to a driver, so the form
|
||||
reaches the driver on his phone instead of on paper. The driver app is
|
||||
a separate frontend outside this repository, so this needs work there
|
||||
and a new endpoint on `/api/v1/driver/*`. The PDF is printed in the
|
||||
meantime.
|
||||
- **A repairer field.** One operator now. If a second person takes over
|
||||
repairs, `repairs.assigned_to` and a "my repairs" filter follow.
|
||||
- **A role gate.** `/repairs-app` is open to any signed-in user today.
|
||||
`user_type` already has a `REP` value that gates rep visibility, so
|
||||
adding a `REPAIRS` value and checking it is a small change when it is
|
||||
wanted.
|
||||
- **Machine location.** The operator was asked whether the buyer needs
|
||||
physical location tracking and said job status is enough. Revisit if
|
||||
machines are ever at more than one place at a time.
|
||||
Loading…
Reference in a new issue