WFDASH-WEBAPP/docs/superpowers/specs/2026-11-05-repairs-app-design.md

38 KiB

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:

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?. signed_request is a file field, so it sits at the top level — expand only carries relations. 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.