Browse Source

Rebuild inspections (#236)

* demo

* workflow

* demo

* demo

* name

* install

* seed

* seed

* tsx

* seed

* prisma

* demo

* demo features

* posthog

* images

* seed

* pg close

* seed

* demo guard

* reset

* seed

* hardning

* seed

* seed

* restrict workshop deletion

* Rebuild inspections around the EU defect scale

The inspection page, its form, the shared report and the PDF are rebuilt,
and templates become configurable per country and per workshop.

Grading follows Directive 2014/45/EU Art. 7(2): minor, major and dangerous
defects, with the overall result derived per Art. 7(3) so a vehicle with
only minor defects still passes. The stored condition values are unchanged
("attention" is minor, "fail" is major) so no existing row is rewritten;
only "dangerous" is new. Templates that predate this keep the old
pass/attention/fail scale rather than having their history relabelled.

Templates are now real checklists rather than lists of labels: sections and
checks carry regulation references, guidance text and an input type, so a
check can record a measurement in a unit and grade itself against a range.
The builder is rebuilt with drag-and-drop, per-check configuration and a
preset gallery covering the full Annex I checklist, national variants,
workshop, EV and marine checklists.

Certificates carry the Annex IV fields: vehicle category, odometer at time
of test, place of test, inspector, certificate number and next test due.

Accessibility work for the EAA / EN 301 549: the grading control is a real
radiogroup with 44px targets, arrow-key navigation and a visible label and
distinct icon per grade, so a result never depends on colour; the image
viewer is a modal dialog that traps and restores focus; photo removal is
reachable without a pointer; and the amber and emerald text now clears
4.5:1 in both themes.

The public report drops the customer's email and phone at the query, since
anyone holding the link can open it, and carries a notice explaining that
and how to have the link withdrawn.

Migration is additive and idempotent; run it before deploying.

* Keep in-cell links to the width of their text

TableCellLink's block mode stretched the anchor across the whole cell, so
clicking the empty space beside a vehicle name opened the vehicle instead
of the row's own target. The box now hugs its content, capped at the cell
width so a truncating child still has something to truncate against.

Affects every table using the block variant: inspections, observations,
work orders and the two dashboard tables.

* Suggest defect wording instead of retyping it

Recording a defect now offers ready-made phrases under the notes field, so
common failures are picked rather than written out every time.

The phrases are not generic autocomplete. Annex I of Directive 2014/45/EU
already lists the reasons for failure per check and assigns each one a
category, so a suggestion fills in the note and grades the check the way
the Directive grades it. Worn brake linings are a major defect at the wear
indicator and a dangerous one past it, and both phrasings are offered with
their own grade attached. Each chip shows the grade it will apply, so
nothing changes behind the technician's back.

Four sources, ranked: phrases the workshop configured on the check, wording
it has actually used before, the Directive, then general phrasing. A shop
that has settled on its own words sees them first and never scrolls past a
Directive quotation to reach them. Checks with no regulation code fall back
to matching on the check name, so plain workshop checklists get useful
phrasing too.

History is one grouped query for the whole page — an Annex I checklist runs
to ninety-odd checks, and a per-check lookup would mean ninety round-trips
to render the form. The current inspection is excluded so a note just typed
does not reappear as a suggestion beneath the field it was typed into.

Notes are appended rather than replaced, since a check can have more than
one thing wrong with it.

* Fill in the certificate instead of asking for it

Four changes to the certificate details, all aimed at leaving the technician
only the facts they alone know.

Inspector is a dropdown of the workshop's technicians rather than free text,
with "Add technician…" at the foot of the list for someone not on the roster
yet — they are created and selected in one step. The name on the certificate
is snapshotted server-side from the chosen record rather than trusted from
the client, since Annex IV(i) has to name someone who actually works here.
The roster query is scoped to inspection rights: a tester who can open an
inspection has to be able to say who ran it, and it returns nothing but names.

Place of test defaults to the workshop address from settings, with a link to
change it there and a one-tap reset when it has been overridden for off-site
work.

Next test due defaults to two years, the Union baseline under Article 5, with
6 months / 1 / 2 / 3 / 4 years one tap away for the member states that test
more often. Whole-year intervals go through addYears so a test on 29 February
lands on 28 February rather than drifting into March.

A completed inspection can be reopened. The certificate snapshot is kept,
since reopening is usually a correction rather than a fresh test, and the
confirmation says plainly that a live customer link will show the result
changing while an already-downloaded certificate will not.

The card also tracks whether anything actually changed, so Save is disabled
until it has and says so when it has.

* Put photos on the certificate

The PDF never rendered inspection photos at all — the evidence a technician
took the trouble to capture only existed in the app.

@react-pdf/renderer cannot fetch a protected URL, so each photo is read from
disk and embedded. Embedding at full size is not an option either: a full
Annex I sheet can carry a photo per check and phone cameras produce several
megabytes apiece, so each is re-encoded to 900px JPEG, capped at three per
check and forty per document, and anything dropped is stated on the page
rather than left to look complete. Defects are loaded first, since a photo of
a dangerous defect is evidence and a photo of a passing check is a nicety.

Two layout fixes that came with it. The deficiency list is now blocks rather
than table rows: a status badge in a table row stretches to the height of the
row, which turned into a tall pink bar as soon as a photo made the row taller,
and a 35%-wide notes cell is the wrong place to put evidence. Photos sit on
their own full-width line at 158pt rather than 84pt crammed into a cell.

* Number defect grades per country, not globally

I had numbered the EU categories 0-3 as if the numbering were part of the
Directive. It is not: 2014/45/EU names minor, major and dangerous but never
numbers them. The numbering is national, so it now lives in a per-country
scheme and a country with no scheme on record simply shows the wording.

Norway's EU-kontroll, checked against Statens vegvesen and Norwegian workshop
guidance rather than assumed:

  0  ingen mangel          godkjent
  1  mindre mangel         godkjent med merknad, ingen etterkontroll
  2  vesentlig mangel      ikke godkjent, må rettes og etterkontrolleres
  3  farlig mangel         bruksforbud
  4  ikke kontrollert

Only NO is populated. Germany's Hauptuntersuchung categorises defects without
numbering them this way, so guessing a code there would put a wrong number on
a legal document.

Adding the country parameter ahead of the existing optional label argument
meant every call site still typechecked while silently passing a label where
a country code belonged — both strings. All four are fixed and the mapping is
covered by tests.

Also renames the confusing "Certificate number" field to "Report reference"
and says what it is for: your own reference, or the number of the national
control slip if you are an approved test centre issuing one.

* Raise a work order straight from an inspection

Plenty of customers just say "fix it". Forcing a quote in between means
building an estimate nobody asked for and waiting for an approval already
given out loud, so the defects can now become a pending job directly.

Each defect becomes a labour line carrying its check and the note the
technician wrote, which is what the person doing the repair needs to read.
Dangerous defects lead, then major, then minor — the order the work should be
done in. Hours and totals stay at zero: the point is to get the job on the
board immediately and price it as the shop works.

ServiceRecord gains an optional inspection link so the inspection can show
what it raised, the same way it shows a quote. SetNull rather than Cascade:
deleting an inspection must not take a job — and its invoice — with it.

The quote path is untouched and sits beside the new one, since an estimate is
still the right move when the customer has not already said yes.

* Link the inspection page to its documentation

Adds "Read more →" beside the template card, matching the style already used
on the low-stock and payment settings.

* Bring the hand-written migrations in line with the schema

`prisma migrate dev` was asking to create a migration because the database the
migrations produce did not match schema.prisma in two places.

The inspection link on service_records had an index in the SQL but no
@@index in the schema, so Prisma wanted to drop it. The index is right —
every other foreign key on that model is indexed and the inspection page
queries jobs by inspection — so it is now declared.

The four scalar list columns were declared NOT NULL. Prisma maps a String[]
field to a plain nullable array and enforces the rest in the client, so the
constraint left the database a step ahead of the schema. Fixed forward in a
new migration rather than by editing the two files, which have already been
applied and are checksummed.

Verified by replaying every ADD COLUMN and ALTER in these migrations against
the DDL Prisma generates from the schema: 47 columns, all indexes and all
foreign keys now agree.

* Stock the whole checklist library by default

Adding checklists one at a time through a picker is work; deleting the three
you do not run is a glance. A workshop now gets all nine ready-made checklists
and removes what it does not need.

New organizations are stocked on first load. Existing ones — which never hit
the empty-list path — get an "Add all" prompt above the list showing how many
are missing. Installation matches on name, so it adds only what is absent and
running it twice does nothing.

The general multi-point checklist becomes the default rather than a national
statutory test, which a shop may not be approved to run. The browse dialog now
greys out anything already in the list instead of offering a duplicate.

* Deliver the checklist library without asking

Nobody should have to press a button to get the checklists. Every
organization is now topped up on read, so navigating to Inspections shows the
whole library in the template dropdown — existing workshops included, which
never hit the empty-list path the previous change relied on.

Topping up on every load would resurrect a checklist the workshop deliberately
deleted, and make it impossible to get rid of. A per-organization marker
records which presets have been handled — installed, or skipped because a
template of that name already existed — so a deletion is permanent while a
preset added to the library in a later release still arrives on its own.

The banner is now what it actually is: a restore for checklists that were
removed, not an install prompt.

* Fix the blank template editor and number references automatically

Editing a template showed one empty section instead of its contents. The form
reset itself from inside onOpenChange, but Radix only reports its own close
actions through that callback — never the parent setting `open` — so opening
the editor never reset it and it kept the state from its first mount, when no
template was selected. It is now keyed on the template being edited, so React
remounts it against the right one.

References fill themselves in as rows are added: a new section takes the next
number, a new check continues its section's sequence. Both read the highest
existing number rather than the count, so deleting a row does not cause the
next one to reuse its number, and duplicating a check gives the copy its own
reference instead of cloning one.

Only blank references are ever written automatically. On a regulatory template
these are Annex I citations like 1.1.13, and quietly renumbering them
sequentially would break the link to the regulation. Renumbering everything is
a deliberate button, which says plainly that it will replace those citations
when the checklist has any.

* Number the check a new section starts with

Adding a section numbered the section but left the check it comes with blank.
blankSection built that check before the section knew its own number, and the
call site patched the code on afterwards, which the already-built item never
saw. The code is passed in instead, so the section and its first check are
numbered together.

A blank template now also starts at 1 / 1.1, so building a checklist from
scratch never means typing a reference by hand.

* Enforce the mandatory-check flags, and say when work is saved

"Must be completed" and "Photo required when a defect is recorded" rendered a
message and stopped nothing — an inspection could be completed with a major
defect and no photo. Both are promises the report makes to whoever reads it,
so both are now enforced in completeInspection, with the same rule running on
the page so the button and the server cannot disagree. The dialog names what
is outstanding and disables Complete until it is dealt with.

An ungraded mandatory check reports once, as a missing grade, rather than
twice; and a check that passed is never asked for a photo, since the
requirement is evidence of a defect rather than a picture of everything.

The checklist has always saved each check as it was graded, which is only
reassuring if the page says so. The action bar now shows Saving / Saved at a
time / Some changes did not save, and carries a Save button — fields commit on
blur, so a note still being typed has not reached the server, and taking focus
off it is what actually saves.

Photo counts are mirrored on the page alongside grades, so attaching a photo
releases the block immediately rather than after a refresh.

Create work order and Create quote are now always present and disabled until
there is a defect, instead of appearing as one is graded and shifting
everything beside them.
Bernt Christian Egeland 1 month ago
parent
commit
a4ad13567e
100 changed files with 10647 additions and 1662 deletions
  1. 20 2
      Dockerfile
  2. 31 1
      messages/de/pdf.json
  3. 47 1
      messages/de/share.json
  4. 31 1
      messages/en/pdf.json
  5. 47 1
      messages/en/share.json
  6. 31 1
      messages/es/pdf.json
  7. 47 1
      messages/es/share.json
  8. 31 1
      messages/fr/pdf.json
  9. 47 1
      messages/fr/share.json
  10. 31 1
      messages/it/pdf.json
  11. 47 1
      messages/it/share.json
  12. 31 1
      messages/lt/pdf.json
  13. 47 1
      messages/lt/share.json
  14. 31 1
      messages/nb/pdf.json
  15. 47 1
      messages/nb/share.json
  16. 31 1
      messages/nl/pdf.json
  17. 47 1
      messages/nl/share.json
  18. 31 1
      messages/pl/pdf.json
  19. 47 1
      messages/pl/share.json
  20. 31 1
      messages/pt-BR/pdf.json
  21. 47 1
      messages/pt-BR/share.json
  22. 31 1
      messages/ru/pdf.json
  23. 47 1
      messages/ru/share.json
  24. 31 1
      messages/tr/pdf.json
  25. 47 1
      messages/tr/share.json
  26. 2 1
      package.json
  27. 53 0
      prisma/migrations/20260815120000_inspection_eu_compliance/migration.sql
  28. 9 0
      prisma/migrations/20260815140000_inspection_defect_suggestions/migration.sql
  29. 12 0
      prisma/migrations/20260815160000_service_record_inspection_link/migration.sql
  30. 17 0
      prisma/migrations/20260815180000_inspection_array_nullability/migration.sql
  31. 76 9
      prisma/schema.prisma
  32. 1264 0
      prisma/seed_dummy_data.ts
  33. 94 0
      src/__tests__/features/inspections/completion-blockers.test.ts
  34. 146 0
      src/__tests__/features/inspections/defect-catalogue.test.ts
  35. 120 0
      src/__tests__/features/inspections/preset-library.test.ts
  36. 89 0
      src/__tests__/features/inspections/template-numbering.test.ts
  37. 56 0
      src/__tests__/features/inspections/test-intervals.test.ts
  38. 78 37
      src/__tests__/features/public/inspection-view.test.tsx
  39. 2 0
      src/__tests__/multitenancy/inspection-isolation.test.ts
  40. 18 4
      src/app/(authenticated)/inspections/[id]/page.tsx
  41. 5 19
      src/app/(authenticated)/inspections/inspections-client.tsx
  42. 13 8
      src/app/(authenticated)/layout.tsx
  43. 2 0
      src/app/(authenticated)/settings/license/page.tsx
  44. 23 1
      src/app/(authenticated)/settings/templates/template-settings.tsx
  45. 1 1
      src/app/(authenticated)/vehicles/page.tsx
  46. 9 5
      src/app/(public)/auth/sign-in/page.tsx
  47. 58 5
      src/app/(public)/auth/sign-in/sign-in-form.tsx
  48. 4 0
      src/app/(public)/auth/sign-up/page.tsx
  49. 436 352
      src/app/(public)/share/inspection/[orgId]/[token]/inspection-view.tsx
  50. 6 2
      src/app/(public)/share/inspection/[orgId]/[token]/page.tsx
  51. 5 0
      src/app/api/protected/backup/export/route.ts
  52. 5 0
      src/app/api/protected/backup/import-invoice-ninja/route.ts
  53. 5 0
      src/app/api/protected/backup/import-lubelog/route.ts
  54. 5 0
      src/app/api/protected/backup/import/route.ts
  55. 6 1
      src/app/api/protected/inspections/[id]/pdf/route.ts
  56. 5 0
      src/app/api/protected/subscription/billing-portal/route.ts
  57. 5 0
      src/app/api/protected/subscription/checkout/route.ts
  58. 5 0
      src/app/api/protected/subscription/upgrade/route.ts
  59. 21 0
      src/app/api/public/auth/[...all]/route.ts
  60. 10 2
      src/app/api/public/share/inspection/[orgId]/[token]/pdf/route.ts
  61. 4 1
      src/app/layout.tsx
  62. 17 0
      src/components/demo-banner.tsx
  63. 6 6
      src/components/posthog-provider.tsx
  64. 6 2
      src/components/table-cell-link.tsx
  65. 1 0
      src/cronTasks.ts
  66. 2 0
      src/features/admin/Actions/deleteOrganization.ts
  67. 2 0
      src/features/admin/Actions/deleteUser.ts
  68. 2 0
      src/features/admin/Actions/setSystemSettings.ts
  69. 3 0
      src/features/ai/Actions/aiSettingsActions.ts
  70. 5 0
      src/features/email/Actions/emailActions.ts
  71. 3 0
      src/features/email/Actions/emailSettingsActions.ts
  72. 396 5
      src/features/inspections/Actions/inspectionActions.ts
  73. 311 142
      src/features/inspections/Actions/templateActions.ts
  74. 168 0
      src/features/inspections/Components/DefectSuggestions.tsx
  75. 452 0
      src/features/inspections/Components/InspectionCertificateCard.tsx
  76. 668 0
      src/features/inspections/Components/InspectionItemRow.tsx
  77. 403 174
      src/features/inspections/Components/InspectionPDF.tsx
  78. 775 563
      src/features/inspections/Components/InspectionPageClient.tsx
  79. 180 0
      src/features/inspections/Components/MediaLightbox.tsx
  80. 880 176
      src/features/inspections/Components/TemplateForm.tsx
  81. 310 118
      src/features/inspections/Components/TemplateListClient.tsx
  82. 217 0
      src/features/inspections/Components/TemplatePresetPicker.tsx
  83. 56 0
      src/features/inspections/Lib/completion.ts
  84. 417 0
      src/features/inspections/Lib/conditions.ts
  85. 753 0
      src/features/inspections/Lib/defectCatalogue.ts
  86. 99 0
      src/features/inspections/Lib/inspectionPhotos.ts
  87. 774 0
      src/features/inspections/Lib/templatePresets.ts
  88. 42 0
      src/features/inspections/Lib/testIntervals.ts
  89. 21 1
      src/features/inspections/Schema/inspectionSchema.ts
  90. 23 0
      src/features/inspections/Schema/templateSchema.ts
  91. 3 0
      src/features/settings/Actions/accountActions.ts
  92. 2 0
      src/features/settings/Actions/deleteAccount.ts
  93. 2 0
      src/features/settings/Actions/deleteContent.ts
  94. 3 0
      src/features/settings/Actions/settingsActions.ts
  95. 3 1
      src/features/settings/Components/license-settings.tsx
  96. 3 0
      src/features/sms/Actions/smsActions.ts
  97. 3 0
      src/features/sms/Actions/smsSettingsActions.ts
  98. 3 0
      src/features/subscription/Actions/subscriptionActions.ts
  99. 4 0
      src/features/team/Actions/acceptInvitation.ts
  100. 4 0
      src/features/team/Actions/createNewOrganization.ts

+ 20 - 2
Dockerfile

@@ -52,8 +52,26 @@ COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./prisma.config.
 # Copy generated Prisma client
 COPY --from=builder --chown=nextjs:nodejs /app/src/generated ./src/generated
 
-# Install runtime packages for Prisma v7
-RUN npm install prisma@7.6.0 @prisma/client@7.6.0 @prisma/adapter-pg@7.6.0 pg dotenv
+# Remove Next.js standalone's traced stubs for pg/prisma (tracer copies package.json
+# but not all files), then install the full runtime packages fresh.
+RUN rm -rf \
+      /app/node_modules/pg \
+      /app/node_modules/pg-types \
+      /app/node_modules/pg-pool \
+      /app/node_modules/pg-connection-string \
+      /app/node_modules/pg-protocol \
+      /app/node_modules/pg-int8 \
+      /app/node_modules/pg-cloudflare \
+      /app/node_modules/pgpass \
+      /app/node_modules/postgres-array \
+      /app/node_modules/postgres-bytea \
+      /app/node_modules/postgres-date \
+      /app/node_modules/postgres-interval \
+      /app/node_modules/split2 \
+      /app/node_modules/prisma \
+      /app/node_modules/@prisma \
+      /app/node_modules/.prisma \
+    && npm install prisma@7.6.0 @prisma/client@7.6.0 @prisma/adapter-pg@7.6.0 pg dotenv tsx
 
 # The npm install above re-resolves the dependency tree and replaces the
 # standalone build's PATCHED next package with a fresh unpatched copy from the

+ 31 - 1
messages/de/pdf.json

@@ -123,7 +123,37 @@
     "notesColumn": "Hinweise",
     "footerText": "Fahrzeuginspektion — {shopName}",
     "viewPortal": "Ihr Portal ansehen: {url}",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "euPass": "Kein Mangel",
+    "euAttention": "Geringer Mangel",
+    "euFail": "Erheblicher Mangel",
+    "euDangerous": "Gefährlicher Mangel",
+    "basicPass": "Bestanden",
+    "basicAttention": "Achtung",
+    "basicFail": "Durchgefallen",
+    "basicDangerous": "Gefährlich",
+    "resultPass": "Bestanden",
+    "resultPassMinor": "Bestanden mit geringen Mängeln",
+    "resultFail": "Nicht bestanden — erhebliche Mängel",
+    "resultFailDangerous": "Nicht bestanden — gefährliche Mängel",
+    "resultIncomplete": "Nicht abgeschlossen",
+    "testDetails": "Prüfdaten",
+    "testDate": "Prüfdatum",
+    "testLocation": "Prüfort",
+    "inspector": "Prüfer",
+    "certificateNumber": "Bescheinigungsnummer",
+    "vehicleCategory": "Fahrzeugklasse",
+    "nextTestDue": "Nächste Prüfung fällig",
+    "limit": "Grenzwert",
+    "overallResult": "Prüfergebnis",
+    "deficiencies": "Festgestellte Mängel",
+    "reading": "Messwert",
+    "noDeficiencies": "Es wurden keine Mängel erfasst.",
+    "resultDetailPass": "Es wurden keine Mängel festgestellt.",
+    "resultDetailPassMinor": "Das Fahrzeug hat bestanden. Beheben Sie die geringen Mängel ohne unnötige Verzögerung.",
+    "resultDetailFail": "Das Fahrzeug hat nicht bestanden. Die Mängel müssen behoben und das Fahrzeug erneut geprüft werden.",
+    "resultDetailFailDangerous": "Das Fahrzeug darf bis zur Behebung der gefährlichen Mängel nicht im öffentlichen Straßenverkehr genutzt werden.",
+    "resultDetailIncomplete": "Diese Prüfung läuft noch."
   },
   "serviceHistory": {
     "serviceHistoryTitle": "SERVICEHISTORIE",

+ 47 - 1
messages/de/share.json

@@ -149,7 +149,53 @@
     "downloadPdf": "PDF herunterladen",
     "notes": "Hinweise",
     "quoteCancelled": "Angebotsanfrage storniert",
-    "cancelFailed": "Anfrage konnte nicht storniert werden"
+    "cancelFailed": "Anfrage konnte nicht storniert werden",
+    "eu": {
+      "pass": "Kein Mangel",
+      "attention": "Geringer Mangel",
+      "fail": "Erheblicher Mangel",
+      "dangerous": "Gefährlicher Mangel",
+      "not_inspected": "Nicht geprüft"
+    },
+    "basic": {
+      "pass": "Bestanden",
+      "attention": "Achtung",
+      "fail": "Durchgefallen",
+      "dangerous": "Gefährlich",
+      "not_inspected": "Nicht geprüft"
+    },
+    "result": {
+      "pass": "Bestanden",
+      "pass_minor": "Bestanden mit geringen Mängeln",
+      "fail": "Nicht bestanden — erhebliche Mängel",
+      "fail_dangerous": "Nicht bestanden — gefährliche Mängel",
+      "incomplete": "Nicht abgeschlossen"
+    },
+    "resultDetail": {
+      "pass": "Es wurden keine Mängel festgestellt.",
+      "pass_minor": "Das Fahrzeug hat bestanden. Beheben Sie die geringen Mängel ohne unnötige Verzögerung.",
+      "fail": "Das Fahrzeug hat nicht bestanden. Die Mängel müssen behoben und das Fahrzeug erneut geprüft werden.",
+      "fail_dangerous": "Das Fahrzeug darf bis zur Behebung der gefährlichen Mängel nicht im öffentlichen Straßenverkehr genutzt werden.",
+      "incomplete": "Diese Prüfung läuft noch."
+    },
+    "testDetails": "Prüfdaten",
+    "testDate": "Prüfdatum",
+    "certificateNumber": "Bescheinigungsnummer",
+    "vehicleCategory": "Fahrzeugklasse",
+    "inspector": "Prüfer",
+    "testLocation": "Prüfort",
+    "nextTestDue": "Nächste Prüfung fällig",
+    "vesselLabel": "Fahrzeug",
+    "plateLabel": "Kennzeichen",
+    "odometerLabel": "Kilometerstand",
+    "engineHoursLabel": "Betriebsstunden",
+    "summary": "Zusammenfassung",
+    "progressLabel": "{graded} Prüfpunkte bewertet",
+    "limit": "Grenzwert",
+    "viewPhoto": "Foto ansehen",
+    "deficienciesFound": "Festgestellte Mängel ({count})",
+    "allResults": "Alle Ergebnisse",
+    "privacyNotice": "Dieser Bericht wird Ihnen von der Werkstatt bereitgestellt, die die Prüfung durchgeführt hat. Er ist für jeden mit diesem Link abrufbar — bitte leiten Sie ihn nicht weiter. Wenden Sie sich an die Werkstatt, um den Link zurückzuziehen oder Ihre Daten löschen zu lassen."
   },
   "terms": {
     "title": "Verkaufsbedingungen (Salgsvilkår)",

+ 31 - 1
messages/en/pdf.json

@@ -123,7 +123,37 @@
     "notesColumn": "Notes",
     "footerText": "Vehicle Inspection — {shopName}",
     "viewPortal": "View your portal: {url}",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "euPass": "No defect",
+    "euAttention": "Minor defect",
+    "euFail": "Major defect",
+    "euDangerous": "Dangerous defect",
+    "basicPass": "Pass",
+    "basicAttention": "Attention",
+    "basicFail": "Fail",
+    "basicDangerous": "Dangerous",
+    "resultPass": "Pass",
+    "resultPassMinor": "Pass with minor defects",
+    "resultFail": "Fail — major defects",
+    "resultFailDangerous": "Fail — dangerous defects",
+    "resultIncomplete": "Not completed",
+    "testDetails": "Test details",
+    "testDate": "Date of test",
+    "testLocation": "Place of test",
+    "inspector": "Inspector",
+    "certificateNumber": "Certificate number",
+    "vehicleCategory": "Vehicle category",
+    "nextTestDue": "Next test due",
+    "limit": "Limit",
+    "overallResult": "Result of the test",
+    "deficiencies": "Deficiencies found",
+    "reading": "Reading",
+    "noDeficiencies": "No deficiencies were recorded.",
+    "resultDetailPass": "No deficiencies were found.",
+    "resultDetailPassMinor": "The vehicle passes. Repair the minor deficiencies without undue delay.",
+    "resultDetailFail": "The vehicle fails. The deficiencies must be repaired and the vehicle retested.",
+    "resultDetailFailDangerous": "The vehicle must not be used on the public road until the dangerous deficiencies are repaired.",
+    "resultDetailIncomplete": "This inspection is still in progress."
   },
   "serviceHistory": {
     "serviceHistoryTitle": "SERVICE HISTORY",

+ 47 - 1
messages/en/share.json

@@ -149,7 +149,53 @@
     "downloadPdf": "Download PDF",
     "notes": "Notes",
     "quoteCancelled": "Quote request cancelled",
-    "cancelFailed": "Failed to cancel request"
+    "cancelFailed": "Failed to cancel request",
+    "eu": {
+      "pass": "No defect",
+      "attention": "Minor defect",
+      "fail": "Major defect",
+      "dangerous": "Dangerous defect",
+      "not_inspected": "Not inspected"
+    },
+    "basic": {
+      "pass": "Pass",
+      "attention": "Attention",
+      "fail": "Fail",
+      "dangerous": "Dangerous",
+      "not_inspected": "Not inspected"
+    },
+    "result": {
+      "pass": "Pass",
+      "pass_minor": "Pass with minor defects",
+      "fail": "Fail — major defects",
+      "fail_dangerous": "Fail — dangerous defects",
+      "incomplete": "Not completed"
+    },
+    "resultDetail": {
+      "pass": "No deficiencies were found.",
+      "pass_minor": "The vehicle passes. Repair the minor deficiencies without undue delay.",
+      "fail": "The vehicle fails. The deficiencies must be repaired and the vehicle retested.",
+      "fail_dangerous": "The vehicle must not be used on the public road until the dangerous deficiencies are repaired.",
+      "incomplete": "This inspection is still in progress."
+    },
+    "testDetails": "Test details",
+    "testDate": "Date of test",
+    "certificateNumber": "Certificate number",
+    "vehicleCategory": "Vehicle category",
+    "inspector": "Inspector",
+    "testLocation": "Place of test",
+    "nextTestDue": "Next test due",
+    "vesselLabel": "Vessel",
+    "plateLabel": "Registration",
+    "odometerLabel": "Odometer",
+    "engineHoursLabel": "Engine hours",
+    "summary": "Summary",
+    "progressLabel": "{graded} checks graded",
+    "limit": "Limit",
+    "viewPhoto": "View photo",
+    "deficienciesFound": "Deficiencies found ({count})",
+    "allResults": "All results",
+    "privacyNotice": "This report is shared with you by the workshop that carried out the inspection. It is available to anyone holding this link, so please do not forward it. Contact the workshop to have the link withdrawn or your data erased."
   },
   "terms": {
     "title": "Terms of Sale (Salgsvilkår)",

+ 31 - 1
messages/es/pdf.json

@@ -123,7 +123,37 @@
     "notesColumn": "Notas",
     "footerText": "Inspección de vehículo — {shopName}",
     "viewPortal": "Ver su portal: {url}",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "euPass": "Sin deficiencia",
+    "euAttention": "Deficiencia leve",
+    "euFail": "Deficiencia grave",
+    "euDangerous": "Deficiencia peligrosa",
+    "basicPass": "Apto",
+    "basicAttention": "Atención",
+    "basicFail": "No apto",
+    "basicDangerous": "Peligroso",
+    "resultPass": "Favorable",
+    "resultPassMinor": "Favorable con deficiencias leves",
+    "resultFail": "Desfavorable — deficiencias graves",
+    "resultFailDangerous": "Negativo — deficiencias peligrosas",
+    "resultIncomplete": "Sin finalizar",
+    "testDetails": "Datos de la inspección",
+    "testDate": "Fecha de la inspección",
+    "testLocation": "Lugar de la inspección",
+    "inspector": "Inspector",
+    "certificateNumber": "Número de certificado",
+    "vehicleCategory": "Categoría del vehículo",
+    "nextTestDue": "Próxima inspección",
+    "limit": "Límite",
+    "overallResult": "Resultado de la inspección",
+    "deficiencies": "Deficiencias detectadas",
+    "reading": "Lectura",
+    "noDeficiencies": "No se registraron deficiencias.",
+    "resultDetailPass": "No se detectaron deficiencias.",
+    "resultDetailPassMinor": "El vehículo es apto. Repare las deficiencias leves sin demora indebida.",
+    "resultDetailFail": "El vehículo no es apto. Las deficiencias deben repararse y el vehículo volver a inspeccionarse.",
+    "resultDetailFailDangerous": "El vehículo no debe circular por la vía pública hasta que se reparen las deficiencias peligrosas.",
+    "resultDetailIncomplete": "Esta inspección sigue en curso."
   },
   "serviceHistory": {
     "serviceHistoryTitle": "HISTORIAL DE SERVICIO",

+ 47 - 1
messages/es/share.json

@@ -149,7 +149,53 @@
     "downloadPdf": "Descargar PDF",
     "notes": "Notas",
     "quoteCancelled": "Solicitud de presupuesto cancelada",
-    "cancelFailed": "Error al cancelar la solicitud"
+    "cancelFailed": "Error al cancelar la solicitud",
+    "eu": {
+      "pass": "Sin deficiencia",
+      "attention": "Deficiencia leve",
+      "fail": "Deficiencia grave",
+      "dangerous": "Deficiencia peligrosa",
+      "not_inspected": "No inspeccionado"
+    },
+    "basic": {
+      "pass": "Apto",
+      "attention": "Atención",
+      "fail": "No apto",
+      "dangerous": "Peligroso",
+      "not_inspected": "No inspeccionado"
+    },
+    "result": {
+      "pass": "Favorable",
+      "pass_minor": "Favorable con deficiencias leves",
+      "fail": "Desfavorable — deficiencias graves",
+      "fail_dangerous": "Negativo — deficiencias peligrosas",
+      "incomplete": "Sin finalizar"
+    },
+    "resultDetail": {
+      "pass": "No se detectaron deficiencias.",
+      "pass_minor": "El vehículo es apto. Repare las deficiencias leves sin demora indebida.",
+      "fail": "El vehículo no es apto. Las deficiencias deben repararse y el vehículo volver a inspeccionarse.",
+      "fail_dangerous": "El vehículo no debe circular por la vía pública hasta que se reparen las deficiencias peligrosas.",
+      "incomplete": "Esta inspección sigue en curso."
+    },
+    "testDetails": "Datos de la inspección",
+    "testDate": "Fecha de la inspección",
+    "certificateNumber": "Número de certificado",
+    "vehicleCategory": "Categoría del vehículo",
+    "inspector": "Inspector",
+    "testLocation": "Lugar de la inspección",
+    "nextTestDue": "Próxima inspección",
+    "vesselLabel": "Embarcación",
+    "plateLabel": "Matrícula",
+    "odometerLabel": "Cuentakilómetros",
+    "engineHoursLabel": "Horas de motor",
+    "summary": "Resumen",
+    "progressLabel": "{graded} puntos evaluados",
+    "limit": "Límite",
+    "viewPhoto": "Ver foto",
+    "deficienciesFound": "Deficiencias detectadas ({count})",
+    "allResults": "Todos los resultados",
+    "privacyNotice": "Este informe se lo facilita el taller que realizó la inspección. Cualquier persona con este enlace puede consultarlo, así que le rogamos que no lo reenvíe. Contacte con el taller para retirar el enlace o solicitar la supresión de sus datos."
   },
   "terms": {
     "title": "Términos de venta (Salgsvilkår)",

+ 31 - 1
messages/fr/pdf.json

@@ -123,7 +123,37 @@
     "notesColumn": "Notes",
     "footerText": "Inspection du véhicule — {shopName}",
     "viewPortal": "Consultez votre portail : {url}",
-    "tel": "Tél. : {phone}"
+    "tel": "Tél. : {phone}",
+    "euPass": "Aucune défaillance",
+    "euAttention": "Défaillance mineure",
+    "euFail": "Défaillance majeure",
+    "euDangerous": "Défaillance critique",
+    "basicPass": "Conforme",
+    "basicAttention": "Attention",
+    "basicFail": "Non conforme",
+    "basicDangerous": "Dangereux",
+    "resultPass": "Favorable",
+    "resultPassMinor": "Favorable avec défaillances mineures",
+    "resultFail": "Défavorable — défaillances majeures",
+    "resultFailDangerous": "Défavorable — défaillances critiques",
+    "resultIncomplete": "Non terminé",
+    "testDetails": "Détails du contrôle",
+    "testDate": "Date du contrôle",
+    "testLocation": "Lieu du contrôle",
+    "inspector": "Contrôleur",
+    "certificateNumber": "Numéro de certificat",
+    "vehicleCategory": "Catégorie du véhicule",
+    "nextTestDue": "Prochain contrôle",
+    "limit": "Limite",
+    "overallResult": "Résultat du contrôle",
+    "deficiencies": "Défaillances constatées",
+    "reading": "Mesure",
+    "noDeficiencies": "Aucune défaillance n'a été enregistrée.",
+    "resultDetailPass": "Aucune défaillance n'a été constatée.",
+    "resultDetailPassMinor": "Le véhicule est accepté. Réparez les défaillances mineures sans délai excessif.",
+    "resultDetailFail": "Le véhicule est refusé. Les défaillances doivent être réparées et le véhicule doit repasser au contrôle.",
+    "resultDetailFailDangerous": "Le véhicule ne doit pas circuler sur la voie publique tant que les défaillances critiques ne sont pas réparées.",
+    "resultDetailIncomplete": "Ce contrôle est toujours en cours."
   },
   "serviceHistory": {
     "serviceHistoryTitle": "HISTORIQUE D'ENTRETIEN",

+ 47 - 1
messages/fr/share.json

@@ -149,7 +149,53 @@
     "downloadPdf": "Télécharger le PDF",
     "notes": "Notes",
     "quoteCancelled": "Demande de devis annulée",
-    "cancelFailed": "Échec de l'annulation de la demande"
+    "cancelFailed": "Échec de l'annulation de la demande",
+    "eu": {
+      "pass": "Aucune défaillance",
+      "attention": "Défaillance mineure",
+      "fail": "Défaillance majeure",
+      "dangerous": "Défaillance critique",
+      "not_inspected": "Non contrôlé"
+    },
+    "basic": {
+      "pass": "Conforme",
+      "attention": "Attention",
+      "fail": "Non conforme",
+      "dangerous": "Dangereux",
+      "not_inspected": "Non contrôlé"
+    },
+    "result": {
+      "pass": "Favorable",
+      "pass_minor": "Favorable avec défaillances mineures",
+      "fail": "Défavorable — défaillances majeures",
+      "fail_dangerous": "Défavorable — défaillances critiques",
+      "incomplete": "Non terminé"
+    },
+    "resultDetail": {
+      "pass": "Aucune défaillance n'a été constatée.",
+      "pass_minor": "Le véhicule est accepté. Réparez les défaillances mineures sans délai excessif.",
+      "fail": "Le véhicule est refusé. Les défaillances doivent être réparées et le véhicule doit repasser au contrôle.",
+      "fail_dangerous": "Le véhicule ne doit pas circuler sur la voie publique tant que les défaillances critiques ne sont pas réparées.",
+      "incomplete": "Ce contrôle est toujours en cours."
+    },
+    "testDetails": "Détails du contrôle",
+    "testDate": "Date du contrôle",
+    "certificateNumber": "Numéro de certificat",
+    "vehicleCategory": "Catégorie du véhicule",
+    "inspector": "Contrôleur",
+    "testLocation": "Lieu du contrôle",
+    "nextTestDue": "Prochain contrôle",
+    "vesselLabel": "Bateau",
+    "plateLabel": "Immatriculation",
+    "odometerLabel": "Compteur",
+    "engineHoursLabel": "Heures moteur",
+    "summary": "Synthèse",
+    "progressLabel": "{graded} points contrôlés",
+    "limit": "Limite",
+    "viewPhoto": "Voir la photo",
+    "deficienciesFound": "Défaillances constatées ({count})",
+    "allResults": "Tous les résultats",
+    "privacyNotice": "Ce rapport vous est communiqué par l'atelier qui a réalisé le contrôle. Toute personne disposant de ce lien peut le consulter : merci de ne pas le transmettre. Contactez l'atelier pour faire retirer le lien ou effacer vos données."
   },
   "terms": {
     "title": "Conditions de vente (Salgsvilkår)",

+ 31 - 1
messages/it/pdf.json

@@ -123,7 +123,37 @@
     "notesColumn": "Note",
     "footerText": "Ispezione Veicolo — {shopName}",
     "viewPortal": "Visualizza il tuo portale: {url}",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "euPass": "Nessuna carenza",
+    "euAttention": "Carenza lieve",
+    "euFail": "Carenza grave",
+    "euDangerous": "Carenza pericolosa",
+    "basicPass": "Superato",
+    "basicAttention": "Attenzione",
+    "basicFail": "Non superato",
+    "basicDangerous": "Pericoloso",
+    "resultPass": "Esito favorevole",
+    "resultPassMinor": "Favorevole con carenze lievi",
+    "resultFail": "Non favorevole — carenze gravi",
+    "resultFailDangerous": "Non favorevole — carenze pericolose",
+    "resultIncomplete": "Non completato",
+    "testDetails": "Dati del controllo",
+    "testDate": "Data del controllo",
+    "testLocation": "Luogo del controllo",
+    "inspector": "Ispettore",
+    "certificateNumber": "Numero del certificato",
+    "vehicleCategory": "Categoria del veicolo",
+    "nextTestDue": "Prossima revisione",
+    "limit": "Limite",
+    "overallResult": "Esito del controllo",
+    "deficiencies": "Carenze riscontrate",
+    "reading": "Misura",
+    "noDeficiencies": "Non sono state registrate carenze.",
+    "resultDetailPass": "Non sono state riscontrate carenze.",
+    "resultDetailPassMinor": "Il veicolo è idoneo. Riparare le carenze lievi senza indebito ritardo.",
+    "resultDetailFail": "Il veicolo non è idoneo. Le carenze devono essere riparate e il veicolo deve essere ricontrollato.",
+    "resultDetailFailDangerous": "Il veicolo non deve circolare su strada pubblica finché le carenze pericolose non sono riparate.",
+    "resultDetailIncomplete": "Questo controllo è ancora in corso."
   },
   "serviceHistory": {
     "serviceHistoryTitle": "STORICO ASSISTENZA",

+ 47 - 1
messages/it/share.json

@@ -149,7 +149,53 @@
     "downloadPdf": "Scarica PDF",
     "notes": "Note",
     "quoteCancelled": "Richiesta di preventivo annullata",
-    "cancelFailed": "Annullamento della richiesta non riuscito"
+    "cancelFailed": "Annullamento della richiesta non riuscito",
+    "eu": {
+      "pass": "Nessuna carenza",
+      "attention": "Carenza lieve",
+      "fail": "Carenza grave",
+      "dangerous": "Carenza pericolosa",
+      "not_inspected": "Non controllato"
+    },
+    "basic": {
+      "pass": "Superato",
+      "attention": "Attenzione",
+      "fail": "Non superato",
+      "dangerous": "Pericoloso",
+      "not_inspected": "Non controllato"
+    },
+    "result": {
+      "pass": "Esito favorevole",
+      "pass_minor": "Favorevole con carenze lievi",
+      "fail": "Non favorevole — carenze gravi",
+      "fail_dangerous": "Non favorevole — carenze pericolose",
+      "incomplete": "Non completato"
+    },
+    "resultDetail": {
+      "pass": "Non sono state riscontrate carenze.",
+      "pass_minor": "Il veicolo è idoneo. Riparare le carenze lievi senza indebito ritardo.",
+      "fail": "Il veicolo non è idoneo. Le carenze devono essere riparate e il veicolo deve essere ricontrollato.",
+      "fail_dangerous": "Il veicolo non deve circolare su strada pubblica finché le carenze pericolose non sono riparate.",
+      "incomplete": "Questo controllo è ancora in corso."
+    },
+    "testDetails": "Dati del controllo",
+    "testDate": "Data del controllo",
+    "certificateNumber": "Numero del certificato",
+    "vehicleCategory": "Categoria del veicolo",
+    "inspector": "Ispettore",
+    "testLocation": "Luogo del controllo",
+    "nextTestDue": "Prossima revisione",
+    "vesselLabel": "Imbarcazione",
+    "plateLabel": "Targa",
+    "odometerLabel": "Contachilometri",
+    "engineHoursLabel": "Ore motore",
+    "summary": "Riepilogo",
+    "progressLabel": "{graded} controlli valutati",
+    "limit": "Limite",
+    "viewPhoto": "Vedi foto",
+    "deficienciesFound": "Carenze riscontrate ({count})",
+    "allResults": "Tutti i risultati",
+    "privacyNotice": "Questo rapporto le viene fornito dall'officina che ha eseguito il controllo. È consultabile da chiunque disponga di questo link: la preghiamo di non inoltrarlo. Contatti l'officina per revocare il link o cancellare i suoi dati."
   },
   "terms": {
     "title": "Condizioni di Vendita (Salgsvilkår)",

+ 31 - 1
messages/lt/pdf.json

@@ -123,7 +123,37 @@
     "notesColumn": "Pastabos",
     "footerText": "Transporto priemonės apžiūra — {shopName}",
     "viewPortal": "Peržiūrėkite savo portalą: {url}",
-    "tel": "Tel.: {phone}"
+    "tel": "Tel.: {phone}",
+    "euPass": "Trūkumų nėra",
+    "euAttention": "Nedidelis trūkumas",
+    "euFail": "Didelis trūkumas",
+    "euDangerous": "Pavojingas trūkumas",
+    "basicPass": "Tinkama",
+    "basicAttention": "Dėmesio",
+    "basicFail": "Netinkama",
+    "basicDangerous": "Pavojinga",
+    "resultPass": "Teigiamas",
+    "resultPassMinor": "Teigiamas su nedideliais trūkumais",
+    "resultFail": "Neigiamas — dideli trūkumai",
+    "resultFailDangerous": "Neigiamas — pavojingi trūkumai",
+    "resultIncomplete": "Nebaigta",
+    "testDetails": "Apžiūros duomenys",
+    "testDate": "Apžiūros data",
+    "testLocation": "Apžiūros vieta",
+    "inspector": "Kontrolierius",
+    "certificateNumber": "Pažymos numeris",
+    "vehicleCategory": "Transporto priemonės kategorija",
+    "nextTestDue": "Kita apžiūra",
+    "limit": "Ribinė vertė",
+    "overallResult": "Apžiūros rezultatas",
+    "deficiencies": "Nustatyti trūkumai",
+    "reading": "Rodmuo",
+    "noDeficiencies": "Trūkumų neužfiksuota.",
+    "resultDetailPass": "Trūkumų nenustatyta.",
+    "resultDetailPassMinor": "Transporto priemonė tinkama naudoti. Nedelsdami pašalinkite nedidelius trūkumus.",
+    "resultDetailFail": "Transporto priemonė netinkama. Trūkumus reikia pašalinti ir atlikti pakartotinę apžiūrą.",
+    "resultDetailFailDangerous": "Transporto priemone draudžiama važiuoti viešaisiais keliais, kol nebus pašalinti pavojingi trūkumai.",
+    "resultDetailIncomplete": "Ši apžiūra dar vyksta."
   },
   "serviceHistory": {
     "serviceHistoryTitle": "PASLAUGŲ ISTORIJA",

+ 47 - 1
messages/lt/share.json

@@ -149,7 +149,53 @@
     "downloadPdf": "Atsisiųsti PDF",
     "notes": "Pastabos",
     "quoteCancelled": "Pasiūlymo užklausa atšaukta",
-    "cancelFailed": "Nepavyko atšaukti užklausos"
+    "cancelFailed": "Nepavyko atšaukti užklausos",
+    "eu": {
+      "pass": "Trūkumų nėra",
+      "attention": "Nedidelis trūkumas",
+      "fail": "Didelis trūkumas",
+      "dangerous": "Pavojingas trūkumas",
+      "not_inspected": "Netikrinta"
+    },
+    "basic": {
+      "pass": "Tinkama",
+      "attention": "Dėmesio",
+      "fail": "Netinkama",
+      "dangerous": "Pavojinga",
+      "not_inspected": "Netikrinta"
+    },
+    "result": {
+      "pass": "Teigiamas",
+      "pass_minor": "Teigiamas su nedideliais trūkumais",
+      "fail": "Neigiamas — dideli trūkumai",
+      "fail_dangerous": "Neigiamas — pavojingi trūkumai",
+      "incomplete": "Nebaigta"
+    },
+    "resultDetail": {
+      "pass": "Trūkumų nenustatyta.",
+      "pass_minor": "Transporto priemonė tinkama naudoti. Nedelsdami pašalinkite nedidelius trūkumus.",
+      "fail": "Transporto priemonė netinkama. Trūkumus reikia pašalinti ir atlikti pakartotinę apžiūrą.",
+      "fail_dangerous": "Transporto priemone draudžiama važiuoti viešaisiais keliais, kol nebus pašalinti pavojingi trūkumai.",
+      "incomplete": "Ši apžiūra dar vyksta."
+    },
+    "testDetails": "Apžiūros duomenys",
+    "testDate": "Apžiūros data",
+    "certificateNumber": "Pažymos numeris",
+    "vehicleCategory": "Transporto priemonės kategorija",
+    "inspector": "Kontrolierius",
+    "testLocation": "Apžiūros vieta",
+    "nextTestDue": "Kita apžiūra",
+    "vesselLabel": "Laivas",
+    "plateLabel": "Valstybinis numeris",
+    "odometerLabel": "Rida",
+    "engineHoursLabel": "Variklio valandos",
+    "summary": "Santrauka",
+    "progressLabel": "Įvertinta patikrų: {graded}",
+    "limit": "Ribinė vertė",
+    "viewPhoto": "Žiūrėti nuotrauką",
+    "deficienciesFound": "Nustatyti trūkumai ({count})",
+    "allResults": "Visi rezultatai",
+    "privacyNotice": "Šią ataskaitą jums pateikia dirbtuvės, atlikusios apžiūrą. Ją gali peržiūrėti kiekvienas, turintis šią nuorodą, todėl neperduokite jos kitiems. Kreipkitės į dirbtuves, jei norite, kad nuoroda būtų panaikinta arba jūsų duomenys ištrinti."
   },
   "terms": {
     "title": "Pardavimo sąlygos",

+ 31 - 1
messages/nb/pdf.json

@@ -123,7 +123,37 @@
     "notesColumn": "Notater",
     "footerText": "Kjøretøykontroll — {shopName}",
     "viewPortal": "Se portalen din: {url}",
-    "tel": "Tlf: {phone}"
+    "tel": "Tlf: {phone}",
+    "euPass": "Ingen mangel",
+    "euAttention": "Mindre mangel",
+    "euFail": "Vesentlig mangel",
+    "euDangerous": "Farlig mangel",
+    "basicPass": "Godkjent",
+    "basicAttention": "Merknad",
+    "basicFail": "Ikke godkjent",
+    "basicDangerous": "Farlig",
+    "resultPass": "Godkjent",
+    "resultPassMinor": "Godkjent med mindre mangler",
+    "resultFail": "Ikke godkjent — vesentlige mangler",
+    "resultFailDangerous": "Ikke godkjent — farlige mangler",
+    "resultIncomplete": "Ikke fullført",
+    "testDetails": "Kontrollopplysninger",
+    "testDate": "Kontrolldato",
+    "testLocation": "Kontrollsted",
+    "inspector": "Kontrollør",
+    "certificateNumber": "Sertifikatnummer",
+    "vehicleCategory": "Kjøretøygruppe",
+    "nextTestDue": "Neste kontroll",
+    "limit": "Grenseverdi",
+    "overallResult": "Kontrollresultat",
+    "deficiencies": "Mangler funnet",
+    "reading": "Måleverdi",
+    "noDeficiencies": "Ingen mangler ble registrert.",
+    "resultDetailPass": "Det ble ikke funnet mangler.",
+    "resultDetailPassMinor": "Kjøretøyet er godkjent. Utbedre de mindre manglene uten unødig opphold.",
+    "resultDetailFail": "Kjøretøyet er ikke godkjent. Manglene må utbedres og kjøretøyet kontrolleres på nytt.",
+    "resultDetailFailDangerous": "Kjøretøyet må ikke brukes på offentlig vei før de farlige manglene er utbedret.",
+    "resultDetailIncomplete": "Denne kontrollen pågår fortsatt."
   },
   "serviceHistory": {
     "serviceHistoryTitle": "SERVICEHISTORIKK",

+ 47 - 1
messages/nb/share.json

@@ -149,7 +149,53 @@
     "downloadPdf": "Last ned PDF",
     "notes": "Notater",
     "quoteCancelled": "Tilbudsforespørsel avbrutt",
-    "cancelFailed": "Kunne ikke avbryte forespørselen"
+    "cancelFailed": "Kunne ikke avbryte forespørselen",
+    "eu": {
+      "pass": "Ingen mangel",
+      "attention": "Mindre mangel",
+      "fail": "Vesentlig mangel",
+      "dangerous": "Farlig mangel",
+      "not_inspected": "Ikke kontrollert"
+    },
+    "basic": {
+      "pass": "Godkjent",
+      "attention": "Merknad",
+      "fail": "Ikke godkjent",
+      "dangerous": "Farlig",
+      "not_inspected": "Ikke kontrollert"
+    },
+    "result": {
+      "pass": "Godkjent",
+      "pass_minor": "Godkjent med mindre mangler",
+      "fail": "Ikke godkjent — vesentlige mangler",
+      "fail_dangerous": "Ikke godkjent — farlige mangler",
+      "incomplete": "Ikke fullført"
+    },
+    "resultDetail": {
+      "pass": "Det ble ikke funnet mangler.",
+      "pass_minor": "Kjøretøyet er godkjent. Utbedre de mindre manglene uten unødig opphold.",
+      "fail": "Kjøretøyet er ikke godkjent. Manglene må utbedres og kjøretøyet kontrolleres på nytt.",
+      "fail_dangerous": "Kjøretøyet må ikke brukes på offentlig vei før de farlige manglene er utbedret.",
+      "incomplete": "Denne kontrollen pågår fortsatt."
+    },
+    "testDetails": "Kontrollopplysninger",
+    "testDate": "Kontrolldato",
+    "certificateNumber": "Sertifikatnummer",
+    "vehicleCategory": "Kjøretøygruppe",
+    "inspector": "Kontrollør",
+    "testLocation": "Kontrollsted",
+    "nextTestDue": "Neste kontroll",
+    "vesselLabel": "Fartøy",
+    "plateLabel": "Kjennemerke",
+    "odometerLabel": "Kilometerstand",
+    "engineHoursLabel": "Motortimer",
+    "summary": "Sammendrag",
+    "progressLabel": "{graded} punkter vurdert",
+    "limit": "Grenseverdi",
+    "viewPhoto": "Se bilde",
+    "deficienciesFound": "Mangler funnet ({count})",
+    "allResults": "Alle resultater",
+    "privacyNotice": "Denne rapporten deles med deg av verkstedet som utførte kontrollen. Alle som har lenken kan se den, så ikke send den videre. Ta kontakt med verkstedet for å trekke tilbake lenken eller få slettet opplysningene dine."
   },
   "terms": {
     "title": "Salgsbetingelser (Salgsvilkår)",

+ 31 - 1
messages/nl/pdf.json

@@ -123,7 +123,37 @@
     "notesColumn": "Notities",
     "footerText": "Voertuiginspectie — {shopName}",
     "viewPortal": "Bekijk uw portaal: {url}",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "euPass": "Geen gebrek",
+    "euAttention": "Klein gebrek",
+    "euFail": "Groot gebrek",
+    "euDangerous": "Gevaarlijk gebrek",
+    "basicPass": "Goedgekeurd",
+    "basicAttention": "Aandacht",
+    "basicFail": "Afgekeurd",
+    "basicDangerous": "Gevaarlijk",
+    "resultPass": "Goedgekeurd",
+    "resultPassMinor": "Goedgekeurd met kleine gebreken",
+    "resultFail": "Afgekeurd — grote gebreken",
+    "resultFailDangerous": "Afgekeurd — gevaarlijke gebreken",
+    "resultIncomplete": "Niet afgerond",
+    "testDetails": "Keuringsgegevens",
+    "testDate": "Keuringsdatum",
+    "testLocation": "Plaats van keuring",
+    "inspector": "Keurmeester",
+    "certificateNumber": "Certificaatnummer",
+    "vehicleCategory": "Voertuigcategorie",
+    "nextTestDue": "Volgende keuring",
+    "limit": "Grenswaarde",
+    "overallResult": "Keuringsresultaat",
+    "deficiencies": "Vastgestelde gebreken",
+    "reading": "Meetwaarde",
+    "noDeficiencies": "Er zijn geen gebreken vastgelegd.",
+    "resultDetailPass": "Er zijn geen gebreken vastgesteld.",
+    "resultDetailPassMinor": "Het voertuig is goedgekeurd. Herstel de kleine gebreken zonder onnodig uitstel.",
+    "resultDetailFail": "Het voertuig is afgekeurd. De gebreken moeten worden hersteld en het voertuig opnieuw gekeurd.",
+    "resultDetailFailDangerous": "Het voertuig mag niet op de openbare weg worden gebruikt totdat de gevaarlijke gebreken zijn hersteld.",
+    "resultDetailIncomplete": "Deze keuring loopt nog."
   },
   "serviceHistory": {
     "serviceHistoryTitle": "SERVICEGESCHIEDENIS",

+ 47 - 1
messages/nl/share.json

@@ -149,7 +149,53 @@
     "downloadPdf": "PDF downloaden",
     "notes": "Notities",
     "quoteCancelled": "Offerteaanvraag geannuleerd",
-    "cancelFailed": "Annuleren van aanvraag mislukt"
+    "cancelFailed": "Annuleren van aanvraag mislukt",
+    "eu": {
+      "pass": "Geen gebrek",
+      "attention": "Klein gebrek",
+      "fail": "Groot gebrek",
+      "dangerous": "Gevaarlijk gebrek",
+      "not_inspected": "Niet gekeurd"
+    },
+    "basic": {
+      "pass": "Goedgekeurd",
+      "attention": "Aandacht",
+      "fail": "Afgekeurd",
+      "dangerous": "Gevaarlijk",
+      "not_inspected": "Niet gekeurd"
+    },
+    "result": {
+      "pass": "Goedgekeurd",
+      "pass_minor": "Goedgekeurd met kleine gebreken",
+      "fail": "Afgekeurd — grote gebreken",
+      "fail_dangerous": "Afgekeurd — gevaarlijke gebreken",
+      "incomplete": "Niet afgerond"
+    },
+    "resultDetail": {
+      "pass": "Er zijn geen gebreken vastgesteld.",
+      "pass_minor": "Het voertuig is goedgekeurd. Herstel de kleine gebreken zonder onnodig uitstel.",
+      "fail": "Het voertuig is afgekeurd. De gebreken moeten worden hersteld en het voertuig opnieuw gekeurd.",
+      "fail_dangerous": "Het voertuig mag niet op de openbare weg worden gebruikt totdat de gevaarlijke gebreken zijn hersteld.",
+      "incomplete": "Deze keuring loopt nog."
+    },
+    "testDetails": "Keuringsgegevens",
+    "testDate": "Keuringsdatum",
+    "certificateNumber": "Certificaatnummer",
+    "vehicleCategory": "Voertuigcategorie",
+    "inspector": "Keurmeester",
+    "testLocation": "Plaats van keuring",
+    "nextTestDue": "Volgende keuring",
+    "vesselLabel": "Vaartuig",
+    "plateLabel": "Kenteken",
+    "odometerLabel": "Kilometerstand",
+    "engineHoursLabel": "Draaiuren",
+    "summary": "Samenvatting",
+    "progressLabel": "{graded} punten beoordeeld",
+    "limit": "Grenswaarde",
+    "viewPhoto": "Foto bekijken",
+    "deficienciesFound": "Vastgestelde gebreken ({count})",
+    "allResults": "Alle resultaten",
+    "privacyNotice": "Dit rapport wordt u verstrekt door de werkplaats die de keuring heeft uitgevoerd. Iedereen met deze link kan het bekijken; stuur de link daarom niet door. Neem contact op met de werkplaats om de link in te trekken of uw gegevens te laten wissen."
   },
   "terms": {
     "title": "Verkoopvoorwaarden (Salgsvilkår)",

+ 31 - 1
messages/pl/pdf.json

@@ -123,7 +123,37 @@
     "notesColumn": "Uwagi",
     "footerText": "Inspekcja pojazdu — {shopName}",
     "viewPortal": "Zobacz swój portal: {url}",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "euPass": "Brak usterki",
+    "euAttention": "Usterka drobna",
+    "euFail": "Usterka poważna",
+    "euDangerous": "Usterka stwarzająca zagrożenie",
+    "basicPass": "Pozytywny",
+    "basicAttention": "Uwaga",
+    "basicFail": "Negatywny",
+    "basicDangerous": "Niebezpieczne",
+    "resultPass": "Wynik pozytywny",
+    "resultPassMinor": "Pozytywny z usterkami drobnymi",
+    "resultFail": "Negatywny — usterki poważne",
+    "resultFailDangerous": "Negatywny — usterki stwarzające zagrożenie",
+    "resultIncomplete": "Nieukończone",
+    "testDetails": "Dane badania",
+    "testDate": "Data badania",
+    "testLocation": "Miejsce badania",
+    "inspector": "Diagnosta",
+    "certificateNumber": "Numer zaświadczenia",
+    "vehicleCategory": "Kategoria pojazdu",
+    "nextTestDue": "Następne badanie",
+    "limit": "Wartość graniczna",
+    "overallResult": "Wynik badania",
+    "deficiencies": "Stwierdzone usterki",
+    "reading": "Odczyt",
+    "noDeficiencies": "Nie zarejestrowano usterek.",
+    "resultDetailPass": "Nie stwierdzono usterek.",
+    "resultDetailPassMinor": "Pojazd uzyskał wynik pozytywny. Usuń usterki drobne bez zbędnej zwłoki.",
+    "resultDetailFail": "Pojazd uzyskał wynik negatywny. Usterki należy usunąć, a pojazd poddać ponownemu badaniu.",
+    "resultDetailFailDangerous": "Pojazdem nie wolno poruszać się po drogach publicznych do czasu usunięcia usterek stwarzających zagrożenie.",
+    "resultDetailIncomplete": "To badanie jest wciąż w toku."
   },
   "serviceHistory": {
     "serviceHistoryTitle": "HISTORIA SERWISOWA",

+ 47 - 1
messages/pl/share.json

@@ -149,7 +149,53 @@
     "downloadPdf": "Pobierz PDF",
     "notes": "Uwagi",
     "quoteCancelled": "Prośba o wycenę anulowana",
-    "cancelFailed": "Nie udało się anulować prośby"
+    "cancelFailed": "Nie udało się anulować prośby",
+    "eu": {
+      "pass": "Brak usterki",
+      "attention": "Usterka drobna",
+      "fail": "Usterka poważna",
+      "dangerous": "Usterka stwarzająca zagrożenie",
+      "not_inspected": "Nie sprawdzono"
+    },
+    "basic": {
+      "pass": "Pozytywny",
+      "attention": "Uwaga",
+      "fail": "Negatywny",
+      "dangerous": "Niebezpieczne",
+      "not_inspected": "Nie sprawdzono"
+    },
+    "result": {
+      "pass": "Wynik pozytywny",
+      "pass_minor": "Pozytywny z usterkami drobnymi",
+      "fail": "Negatywny — usterki poważne",
+      "fail_dangerous": "Negatywny — usterki stwarzające zagrożenie",
+      "incomplete": "Nieukończone"
+    },
+    "resultDetail": {
+      "pass": "Nie stwierdzono usterek.",
+      "pass_minor": "Pojazd uzyskał wynik pozytywny. Usuń usterki drobne bez zbędnej zwłoki.",
+      "fail": "Pojazd uzyskał wynik negatywny. Usterki należy usunąć, a pojazd poddać ponownemu badaniu.",
+      "fail_dangerous": "Pojazdem nie wolno poruszać się po drogach publicznych do czasu usunięcia usterek stwarzających zagrożenie.",
+      "incomplete": "To badanie jest wciąż w toku."
+    },
+    "testDetails": "Dane badania",
+    "testDate": "Data badania",
+    "certificateNumber": "Numer zaświadczenia",
+    "vehicleCategory": "Kategoria pojazdu",
+    "inspector": "Diagnosta",
+    "testLocation": "Miejsce badania",
+    "nextTestDue": "Następne badanie",
+    "vesselLabel": "Jednostka pływająca",
+    "plateLabel": "Numer rejestracyjny",
+    "odometerLabel": "Przebieg",
+    "engineHoursLabel": "Motogodziny",
+    "summary": "Podsumowanie",
+    "progressLabel": "Oceniono {graded} pozycji",
+    "limit": "Wartość graniczna",
+    "viewPhoto": "Zobacz zdjęcie",
+    "deficienciesFound": "Stwierdzone usterki ({count})",
+    "allResults": "Wszystkie wyniki",
+    "privacyNotice": "Ten raport udostępnia Ci warsztat, który wykonał badanie. Może go zobaczyć każdy, kto ma ten link — prosimy go nie przekazywać. Skontaktuj się z warsztatem, aby wycofać link lub usunąć swoje dane."
   },
   "terms": {
     "title": "Warunki sprzedaży (Salgsvilkår)",

+ 31 - 1
messages/pt-BR/pdf.json

@@ -123,7 +123,37 @@
     "notesColumn": "Observações",
     "footerText": "Inspeção Veicular — {shopName}",
     "viewPortal": "Acesse seu portal: {url}",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "euPass": "Sem deficiência",
+    "euAttention": "Deficiência leve",
+    "euFail": "Deficiência grave",
+    "euDangerous": "Deficiência perigosa",
+    "basicPass": "Aprovado",
+    "basicAttention": "Atenção",
+    "basicFail": "Reprovado",
+    "basicDangerous": "Perigoso",
+    "resultPass": "Aprovado",
+    "resultPassMinor": "Aprovado com deficiências leves",
+    "resultFail": "Reprovado — deficiências graves",
+    "resultFailDangerous": "Reprovado — deficiências perigosas",
+    "resultIncomplete": "Não concluído",
+    "testDetails": "Dados da inspeção",
+    "testDate": "Data da inspeção",
+    "testLocation": "Local da inspeção",
+    "inspector": "Inspetor",
+    "certificateNumber": "Número do certificado",
+    "vehicleCategory": "Categoria do veículo",
+    "nextTestDue": "Próxima inspeção",
+    "limit": "Limite",
+    "overallResult": "Resultado da inspeção",
+    "deficiencies": "Deficiências encontradas",
+    "reading": "Leitura",
+    "noDeficiencies": "Nenhuma deficiência foi registrada.",
+    "resultDetailPass": "Nenhuma deficiência foi encontrada.",
+    "resultDetailPassMinor": "O veículo foi aprovado. Repare as deficiências leves sem demora indevida.",
+    "resultDetailFail": "O veículo foi reprovado. As deficiências devem ser reparadas e o veículo reinspecionado.",
+    "resultDetailFailDangerous": "O veículo não deve circular em via pública até que as deficiências perigosas sejam reparadas.",
+    "resultDetailIncomplete": "Esta inspeção ainda está em andamento."
   },
   "serviceHistory": {
     "serviceHistoryTitle": "HISTÓRICO DE SERVIÇO",

+ 47 - 1
messages/pt-BR/share.json

@@ -149,7 +149,53 @@
     "downloadPdf": "Baixar PDF",
     "notes": "Observações",
     "quoteCancelled": "Solicitação de orçamento cancelada",
-    "cancelFailed": "Falha ao cancelar a solicitação"
+    "cancelFailed": "Falha ao cancelar a solicitação",
+    "eu": {
+      "pass": "Sem deficiência",
+      "attention": "Deficiência leve",
+      "fail": "Deficiência grave",
+      "dangerous": "Deficiência perigosa",
+      "not_inspected": "Não inspecionado"
+    },
+    "basic": {
+      "pass": "Aprovado",
+      "attention": "Atenção",
+      "fail": "Reprovado",
+      "dangerous": "Perigoso",
+      "not_inspected": "Não inspecionado"
+    },
+    "result": {
+      "pass": "Aprovado",
+      "pass_minor": "Aprovado com deficiências leves",
+      "fail": "Reprovado — deficiências graves",
+      "fail_dangerous": "Reprovado — deficiências perigosas",
+      "incomplete": "Não concluído"
+    },
+    "resultDetail": {
+      "pass": "Nenhuma deficiência foi encontrada.",
+      "pass_minor": "O veículo foi aprovado. Repare as deficiências leves sem demora indevida.",
+      "fail": "O veículo foi reprovado. As deficiências devem ser reparadas e o veículo reinspecionado.",
+      "fail_dangerous": "O veículo não deve circular em via pública até que as deficiências perigosas sejam reparadas.",
+      "incomplete": "Esta inspeção ainda está em andamento."
+    },
+    "testDetails": "Dados da inspeção",
+    "testDate": "Data da inspeção",
+    "certificateNumber": "Número do certificado",
+    "vehicleCategory": "Categoria do veículo",
+    "inspector": "Inspetor",
+    "testLocation": "Local da inspeção",
+    "nextTestDue": "Próxima inspeção",
+    "vesselLabel": "Embarcação",
+    "plateLabel": "Placa",
+    "odometerLabel": "Odômetro",
+    "engineHoursLabel": "Horas de motor",
+    "summary": "Resumo",
+    "progressLabel": "{graded} itens avaliados",
+    "limit": "Limite",
+    "viewPhoto": "Ver foto",
+    "deficienciesFound": "Deficiências encontradas ({count})",
+    "allResults": "Todos os resultados",
+    "privacyNotice": "Este relatório é compartilhado com você pela oficina que realizou a inspeção. Qualquer pessoa com este link pode acessá-lo, portanto não o encaminhe. Entre em contato com a oficina para revogar o link ou solicitar a exclusão dos seus dados."
   },
   "terms": {
     "title": "Termos de Venda (Salgsvilkår)",

+ 31 - 1
messages/ru/pdf.json

@@ -123,7 +123,37 @@
     "notesColumn": "Заметки",
     "footerText": "Осмотр транспортного средства — {shopName}",
     "viewPortal": "Ваш портал: {url}",
-    "tel": "Тел: {phone}"
+    "tel": "Тел: {phone}",
+    "euPass": "Неисправностей нет",
+    "euAttention": "Незначительная неисправность",
+    "euFail": "Значительная неисправность",
+    "euDangerous": "Опасная неисправность",
+    "basicPass": "Годен",
+    "basicAttention": "Внимание",
+    "basicFail": "Не годен",
+    "basicDangerous": "Опасно",
+    "resultPass": "Пройдено",
+    "resultPassMinor": "Пройдено с незначительными неисправностями",
+    "resultFail": "Не пройдено — значительные неисправности",
+    "resultFailDangerous": "Не пройдено — опасные неисправности",
+    "resultIncomplete": "Не завершено",
+    "testDetails": "Сведения о проверке",
+    "testDate": "Дата проверки",
+    "testLocation": "Место проверки",
+    "inspector": "Контролёр",
+    "certificateNumber": "Номер свидетельства",
+    "vehicleCategory": "Категория ТС",
+    "nextTestDue": "Следующая проверка",
+    "limit": "Предельное значение",
+    "overallResult": "Результат проверки",
+    "deficiencies": "Выявленные неисправности",
+    "reading": "Показание",
+    "noDeficiencies": "Неисправности не зафиксированы.",
+    "resultDetailPass": "Неисправностей не выявлено.",
+    "resultDetailPassMinor": "Транспортное средство допущено к эксплуатации. Устраните незначительные неисправности без промедления.",
+    "resultDetailFail": "Транспортное средство не допущено. Неисправности необходимо устранить, после чего пройти повторную проверку.",
+    "resultDetailFailDangerous": "Транспортное средство запрещается использовать на дорогах общего пользования до устранения опасных неисправностей.",
+    "resultDetailIncomplete": "Проверка ещё не завершена."
   },
   "serviceHistory": {
     "serviceHistoryTitle": "ИСТОРИЯ ОБСЛУЖИВАНИЯ",

+ 47 - 1
messages/ru/share.json

@@ -149,7 +149,53 @@
     "downloadPdf": "Скачать PDF",
     "notes": "Заметки",
     "quoteCancelled": "Запрос на предложение отменён",
-    "cancelFailed": "Не удалось отменить запрос"
+    "cancelFailed": "Не удалось отменить запрос",
+    "eu": {
+      "pass": "Неисправностей нет",
+      "attention": "Незначительная неисправность",
+      "fail": "Значительная неисправность",
+      "dangerous": "Опасная неисправность",
+      "not_inspected": "Не проверялось"
+    },
+    "basic": {
+      "pass": "Годен",
+      "attention": "Внимание",
+      "fail": "Не годен",
+      "dangerous": "Опасно",
+      "not_inspected": "Не проверялось"
+    },
+    "result": {
+      "pass": "Пройдено",
+      "pass_minor": "Пройдено с незначительными неисправностями",
+      "fail": "Не пройдено — значительные неисправности",
+      "fail_dangerous": "Не пройдено — опасные неисправности",
+      "incomplete": "Не завершено"
+    },
+    "resultDetail": {
+      "pass": "Неисправностей не выявлено.",
+      "pass_minor": "Транспортное средство допущено к эксплуатации. Устраните незначительные неисправности без промедления.",
+      "fail": "Транспортное средство не допущено. Неисправности необходимо устранить, после чего пройти повторную проверку.",
+      "fail_dangerous": "Транспортное средство запрещается использовать на дорогах общего пользования до устранения опасных неисправностей.",
+      "incomplete": "Проверка ещё не завершена."
+    },
+    "testDetails": "Сведения о проверке",
+    "testDate": "Дата проверки",
+    "certificateNumber": "Номер свидетельства",
+    "vehicleCategory": "Категория ТС",
+    "inspector": "Контролёр",
+    "testLocation": "Место проверки",
+    "nextTestDue": "Следующая проверка",
+    "vesselLabel": "Судно",
+    "plateLabel": "Регистрационный знак",
+    "odometerLabel": "Пробег",
+    "engineHoursLabel": "Моточасы",
+    "summary": "Сводка",
+    "progressLabel": "Оценено пунктов: {graded}",
+    "limit": "Предельное значение",
+    "viewPhoto": "Смотреть фото",
+    "deficienciesFound": "Выявленные неисправности ({count})",
+    "allResults": "Все результаты",
+    "privacyNotice": "Этот отчёт предоставлен вам мастерской, проводившей проверку. Он доступен любому, у кого есть эта ссылка, — не пересылайте её. Обратитесь в мастерскую, чтобы отозвать ссылку или удалить ваши данные."
   },
   "terms": {
     "title": "Условия продажи (Salgsvilkar)",

+ 31 - 1
messages/tr/pdf.json

@@ -123,7 +123,37 @@
     "notesColumn": "Notlar",
     "footerText": "Araç Muayenesi — {shopName}",
     "viewPortal": "Portalınızı görüntüleyin: {url}",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "euPass": "Kusur yok",
+    "euAttention": "Hafif kusur",
+    "euFail": "Ağır kusur",
+    "euDangerous": "Tehlikeli kusur",
+    "basicPass": "Geçti",
+    "basicAttention": "Dikkat",
+    "basicFail": "Kaldı",
+    "basicDangerous": "Tehlikeli",
+    "resultPass": "Geçti",
+    "resultPassMinor": "Hafif kusurlarla geçti",
+    "resultFail": "Kaldı — ağır kusurlar",
+    "resultFailDangerous": "Kaldı — tehlikeli kusurlar",
+    "resultIncomplete": "Tamamlanmadı",
+    "testDetails": "Muayene bilgileri",
+    "testDate": "Muayene tarihi",
+    "testLocation": "Muayene yeri",
+    "inspector": "Muayene görevlisi",
+    "certificateNumber": "Belge numarası",
+    "vehicleCategory": "Araç sınıfı",
+    "nextTestDue": "Sonraki muayene",
+    "limit": "Sınır değer",
+    "overallResult": "Muayene sonucu",
+    "deficiencies": "Tespit edilen kusurlar",
+    "reading": "Ölçüm",
+    "noDeficiencies": "Herhangi bir kusur kaydedilmedi.",
+    "resultDetailPass": "Herhangi bir kusur bulunmadı.",
+    "resultDetailPassMinor": "Araç geçti. Hafif kusurları gecikmeden giderin.",
+    "resultDetailFail": "Araç kaldı. Kusurlar giderilmeli ve araç yeniden muayene edilmelidir.",
+    "resultDetailFailDangerous": "Tehlikeli kusurlar giderilene kadar araç karayolunda kullanılmamalıdır.",
+    "resultDetailIncomplete": "Bu muayene hâlâ devam ediyor."
   },
   "serviceHistory": {
     "serviceHistoryTitle": "SERVİS GEÇMİŞİ",

+ 47 - 1
messages/tr/share.json

@@ -149,7 +149,53 @@
     "downloadPdf": "PDF İndir",
     "notes": "Notlar",
     "quoteCancelled": "Teklif talebi iptal edildi",
-    "cancelFailed": "Talep iptal edilemedi"
+    "cancelFailed": "Talep iptal edilemedi",
+    "eu": {
+      "pass": "Kusur yok",
+      "attention": "Hafif kusur",
+      "fail": "Ağır kusur",
+      "dangerous": "Tehlikeli kusur",
+      "not_inspected": "Muayene edilmedi"
+    },
+    "basic": {
+      "pass": "Geçti",
+      "attention": "Dikkat",
+      "fail": "Kaldı",
+      "dangerous": "Tehlikeli",
+      "not_inspected": "Muayene edilmedi"
+    },
+    "result": {
+      "pass": "Geçti",
+      "pass_minor": "Hafif kusurlarla geçti",
+      "fail": "Kaldı — ağır kusurlar",
+      "fail_dangerous": "Kaldı — tehlikeli kusurlar",
+      "incomplete": "Tamamlanmadı"
+    },
+    "resultDetail": {
+      "pass": "Herhangi bir kusur bulunmadı.",
+      "pass_minor": "Araç geçti. Hafif kusurları gecikmeden giderin.",
+      "fail": "Araç kaldı. Kusurlar giderilmeli ve araç yeniden muayene edilmelidir.",
+      "fail_dangerous": "Tehlikeli kusurlar giderilene kadar araç karayolunda kullanılmamalıdır.",
+      "incomplete": "Bu muayene hâlâ devam ediyor."
+    },
+    "testDetails": "Muayene bilgileri",
+    "testDate": "Muayene tarihi",
+    "certificateNumber": "Belge numarası",
+    "vehicleCategory": "Araç sınıfı",
+    "inspector": "Muayene görevlisi",
+    "testLocation": "Muayene yeri",
+    "nextTestDue": "Sonraki muayene",
+    "vesselLabel": "Tekne",
+    "plateLabel": "Plaka",
+    "odometerLabel": "Kilometre",
+    "engineHoursLabel": "Motor saati",
+    "summary": "Özet",
+    "progressLabel": "{graded} kontrol değerlendirildi",
+    "limit": "Sınır değer",
+    "viewPhoto": "Fotoğrafı gör",
+    "deficienciesFound": "Tespit edilen kusurlar ({count})",
+    "allResults": "Tüm sonuçlar",
+    "privacyNotice": "Bu rapor, muayeneyi yapan servis tarafından sizinle paylaşılmıştır. Bu bağlantıya sahip herkes raporu görebilir, lütfen iletmeyin. Bağlantının geri çekilmesi veya verilerinizin silinmesi için servisle iletişime geçin."
   },
   "terms": {
     "title": "Satış Koşulları (Salgsvilkår)",

+ 2 - 1
package.json

@@ -14,7 +14,8 @@
     "check:fix": "biome check . --write",
     "prepare": "next-ws patch --yes",
     "test": "vitest run",
-    "test:coverage": "vitest --coverage"
+    "test:coverage": "vitest --coverage",
+    "seed:demo": "npx tsx prisma/seed_dummy_data.ts"
   },
   "dependencies": {
     "@aws-sdk/client-ses": "^3.1045.0",

+ 53 - 0
prisma/migrations/20260815120000_inspection_eu_compliance/migration.sql

@@ -0,0 +1,53 @@
+-- Inspection overhaul: flexible, country-aware templates and the roadworthiness
+-- certificate fields required by Directive 2014/45/EU Annex IV.
+--
+-- Every column here is nullable or defaulted, so existing rows keep working and
+-- the existing condition values ("pass" / "attention" / "fail" /
+-- "not_inspected") stay valid — the EU scale maps onto them and only adds
+-- "dangerous" on top. Idempotent; safe to re-run.
+
+-- Templates gain a regulatory profile
+ALTER TABLE "inspection_templates" ADD COLUMN IF NOT EXISTS "country" TEXT;
+ALTER TABLE "inspection_templates" ADD COLUMN IF NOT EXISTS "standard" TEXT DEFAULT 'custom';
+ALTER TABLE "inspection_templates" ADD COLUMN IF NOT EXISTS "severityScale" TEXT NOT NULL DEFAULT 'eu';
+
+-- Sections gain a description and a regulation reference (Annex I category)
+ALTER TABLE "inspection_template_sections" ADD COLUMN IF NOT EXISTS "description" TEXT;
+ALTER TABLE "inspection_template_sections" ADD COLUMN IF NOT EXISTS "code" TEXT;
+
+-- Template items become configurable checks rather than plain labels
+ALTER TABLE "inspection_template_items" ADD COLUMN IF NOT EXISTS "description" TEXT;
+ALTER TABLE "inspection_template_items" ADD COLUMN IF NOT EXISTS "code" TEXT;
+ALTER TABLE "inspection_template_items" ADD COLUMN IF NOT EXISTS "inputType" TEXT NOT NULL DEFAULT 'condition';
+ALTER TABLE "inspection_template_items" ADD COLUMN IF NOT EXISTS "unit" TEXT;
+ALTER TABLE "inspection_template_items" ADD COLUMN IF NOT EXISTS "minValue" DOUBLE PRECISION;
+ALTER TABLE "inspection_template_items" ADD COLUMN IF NOT EXISTS "maxValue" DOUBLE PRECISION;
+ALTER TABLE "inspection_template_items" ADD COLUMN IF NOT EXISTS "choices" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
+ALTER TABLE "inspection_template_items" ADD COLUMN IF NOT EXISTS "required" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "inspection_template_items" ADD COLUMN IF NOT EXISTS "photoRequired" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "inspection_template_items" ADD COLUMN IF NOT EXISTS "defaultSeverity" TEXT;
+
+-- Annex IV certificate fields, snapshotted on the inspection itself
+ALTER TABLE "inspections" ADD COLUMN IF NOT EXISTS "vehicleCategory" TEXT;
+ALTER TABLE "inspections" ADD COLUMN IF NOT EXISTS "nextTestDue" TIMESTAMP(3);
+ALTER TABLE "inspections" ADD COLUMN IF NOT EXISTS "certificateNumber" TEXT;
+ALTER TABLE "inspections" ADD COLUMN IF NOT EXISTS "inspectorName" TEXT;
+ALTER TABLE "inspections" ADD COLUMN IF NOT EXISTS "testLocation" TEXT;
+
+-- Filled-in items carry a copy of their check definition plus the recorded value
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "description" TEXT;
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "code" TEXT;
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "sectionCode" TEXT;
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "inputType" TEXT NOT NULL DEFAULT 'condition';
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "unit" TEXT;
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "minValue" DOUBLE PRECISION;
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "maxValue" DOUBLE PRECISION;
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "choices" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "required" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "photoRequired" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "defaultSeverity" TEXT;
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "measuredValue" DOUBLE PRECISION;
+ALTER TABLE "inspection_items" ADD COLUMN IF NOT EXISTS "textValue" TEXT;
+
+-- Templates that predate this migration graded on the old three-step scale
+UPDATE "inspection_templates" SET "severityScale" = 'basic' WHERE "standard" IS NULL OR "standard" = 'custom';

+ 9 - 0
prisma/migrations/20260815140000_inspection_defect_suggestions/migration.sql

@@ -0,0 +1,9 @@
+-- Per-check defect phrases a workshop can standardise on, offered alongside the
+-- built-in catalogue when a technician records a defect. Additive and
+-- idempotent; safe to re-run.
+
+ALTER TABLE "inspection_template_items"
+  ADD COLUMN IF NOT EXISTS "defectSuggestions" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
+
+ALTER TABLE "inspection_items"
+  ADD COLUMN IF NOT EXISTS "defectSuggestions" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];

+ 12 - 0
prisma/migrations/20260815160000_service_record_inspection_link/migration.sql

@@ -0,0 +1,12 @@
+-- Lets a work order be raised straight from an inspection, without a quote in
+-- between, and lets the inspection show what it raised. Additive and
+-- idempotent; safe to re-run.
+
+ALTER TABLE "service_records" ADD COLUMN IF NOT EXISTS "inspectionId" TEXT;
+
+DO $$ BEGIN
+  ALTER TABLE "service_records" ADD CONSTRAINT "service_records_inspectionId_fkey"
+    FOREIGN KEY ("inspectionId") REFERENCES "inspections"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
+CREATE INDEX IF NOT EXISTS "service_records_inspectionId_idx" ON "service_records"("inspectionId");

+ 17 - 0
prisma/migrations/20260815180000_inspection_array_nullability/migration.sql

@@ -0,0 +1,17 @@
+-- Corrects the nullability of the scalar list columns added by
+-- 20260815120000_inspection_eu_compliance and
+-- 20260815140000_inspection_defect_suggestions.
+--
+-- Those migrations declared the arrays NOT NULL. Prisma maps a `String[]` field
+-- to a plain nullable array column and enforces the "never null" part in the
+-- client, so the NOT NULL left the database a step ahead of the schema and
+-- every `prisma migrate dev` wanted to write a migration removing it.
+--
+-- Fixed forward rather than by editing those files: both have already been
+-- applied, and Prisma checksums applied migrations. Idempotent; safe to re-run.
+
+ALTER TABLE "inspection_template_items" ALTER COLUMN "choices" DROP NOT NULL;
+ALTER TABLE "inspection_template_items" ALTER COLUMN "defectSuggestions" DROP NOT NULL;
+
+ALTER TABLE "inspection_items" ALTER COLUMN "choices" DROP NOT NULL;
+ALTER TABLE "inspection_items" ALTER COLUMN "defectSuggestions" DROP NOT NULL;

+ 76 - 9
prisma/schema.prisma

@@ -239,6 +239,12 @@ model ServiceRecord {
   customerId String?
   customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
 
+  /// Set when the job came straight from an inspection, so the inspection can
+  /// show what was raised from it. SetNull: deleting the inspection must not
+  /// take the job — and its invoice — with it.
+  inspectionId String?
+  inspection   Inspection? @relation(fields: [inspectionId], references: [id], onDelete: SetNull)
+
   /// Denormalized from the vehicle historically; now the canonical scope so
   /// records exist without a vehicle. Nullable to mirror Vehicle.organizationId.
   organizationId String?
@@ -262,6 +268,7 @@ model ServiceRecord {
   @@index([technicianId])
   @@index([organizationId])
   @@index([customerId])
+  @@index([inspectionId])
   @@map("service_records")
 }
 
@@ -987,6 +994,14 @@ model InspectionTemplate {
   createdAt   DateTime @default(now())
   updatedAt   DateTime @updatedAt
 
+  // Regulatory profile. `country` is an ISO-3166 alpha-2 code (or null for a
+  // shop-specific checklist), `standard` names the regime the template follows
+  // (e.g. "eu-2014-45"), and `severityScale` picks which defect vocabulary the
+  // technician grades against — see Lib/conditions.ts.
+  country       String?
+  standard      String? @default("custom")
+  severityScale String  @default("eu")
+
   organizationId String
   organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
 
@@ -998,9 +1013,13 @@ model InspectionTemplate {
 }
 
 model InspectionTemplateSection {
-  id        String @id @default(cuid())
-  name      String
-  sortOrder Int    @default(0)
+  id          String  @id @default(cuid())
+  name        String
+  description String?
+  // Regulation reference for the section, e.g. "1" for Braking equipment in
+  // Directive 2014/45/EU Annex I.
+  code        String?
+  sortOrder   Int     @default(0)
 
   templateId String
   template   InspectionTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
@@ -1012,9 +1031,29 @@ model InspectionTemplateSection {
 }
 
 model InspectionTemplateItem {
-  id        String @id @default(cuid())
-  name      String
-  sortOrder Int    @default(0)
+  id          String  @id @default(cuid())
+  name        String
+  description String?
+  // Regulation reference for the check, e.g. "1.1.13".
+  code        String?
+  sortOrder   Int     @default(0)
+
+  // How the technician records the result: "condition" (defect grading only),
+  // "measurement" (numeric value in `unit`, graded against min/max),
+  // "text" (free text) or "choice" (one of `choices`).
+  inputType String   @default("condition")
+  unit      String?
+  minValue  Float?
+  maxValue  Float?
+  choices   String[] @default([])
+
+  required          Boolean  @default(false)
+  photoRequired     Boolean  @default(false)
+  // Defect severity applied when a measurement falls outside min/max.
+  defaultSeverity   String?
+  // Phrases this workshop wants offered when a defect is recorded against the
+  // check, on top of the built-in catalogue. See Lib/defectCatalogue.ts.
+  defectSuggestions String[] @default([])
 
   sectionId String
   section   InspectionTemplateSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
@@ -1045,11 +1084,21 @@ model Inspection {
   technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
   sortOrder    Int         @default(0)
 
+  // Roadworthiness certificate fields required by Directive 2014/45/EU Annex IV.
+  // `inspectorName` and `certificateNumber` are snapshots taken at completion so
+  // the issued certificate stays stable even if staff or settings change later.
+  vehicleCategory   String?
+  nextTestDue       DateTime?
+  certificateNumber String?
+  inspectorName     String?
+  testLocation      String?
+
   organizationId String
 
-  items         InspectionItem[]
-  quoteRequests InspectionQuoteRequest[]
-  quotes        Quote[]
+  items          InspectionItem[]
+  quoteRequests  InspectionQuoteRequest[]
+  quotes         Quote[]
+  serviceRecords ServiceRecord[]
 
   @@index([vehicleId])
   @@index([organizationId])
@@ -1066,6 +1115,24 @@ model InspectionItem {
   notes     String?
   imageUrls String[] @default([])
 
+  // Copied from the template at creation so an inspection stays readable even
+  // after its template is edited or deleted.
+  description       String?
+  code              String?
+  sectionCode       String?
+  inputType         String   @default("condition")
+  unit              String?
+  minValue          Float?
+  maxValue          Float?
+  choices           String[] @default([])
+  required          Boolean  @default(false)
+  photoRequired     Boolean  @default(false)
+  defaultSeverity   String?
+  defectSuggestions String[] @default([])
+
+  measuredValue Float?
+  textValue     String?
+
   inspectionId String
   inspection   Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
 

+ 1264 - 0
prisma/seed_dummy_data.ts

@@ -0,0 +1,1264 @@
+import { PrismaClient } from "../src/generated/prisma/client";
+import { PrismaPg } from "@prisma/adapter-pg";
+import { randomBytes, scryptSync } from "node:crypto";
+import * as fs from "fs";
+import * as path from "path";
+import * as https from "https";
+import * as http from "http";
+
+// Matches better-auth's scrypt params — output is byte-compatible with its hashPassword,
+// so the stored hash is verifiable by better-auth at sign-in time.
+function hashPassword(password: string): string {
+  const N = 16384, r = 16, p = 1, dkLen = 64;
+  const maxmem = 128 * N * r * 2;
+  const salt = randomBytes(16).toString("hex");
+  const key = scryptSync(password.normalize("NFKC"), salt, dkLen, { N, r, p, maxmem });
+  return `${salt}:${key.toString("hex")}`;
+}
+
+const prisma = new PrismaClient({
+  adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
+});
+
+const USER_ID = "IqcAL6GKoedJNO8Xi6UJkCM5YZqgS9uU";
+const ORG_ID = "cmmh0vczm0000oiyan37vuyqf";
+const DEMO_EMAIL = process.env.DEMO_USER_EMAIL || "demo@torqvoice.com";
+const DEMO_PASSWORD = process.env.DEMO_USER_PASSWORD || "demo";
+const DEMO_ORG_NAME = process.env.DEMO_ORG_NAME || "Demo Auto Workshop";
+const DATA_ROOT = process.env.DATA_ROOT || path.join(process.cwd(), "data");
+const UPLOAD_DIR = path.join(DATA_ROOT, "uploads", ORG_ID, "vehicles");
+// Image assets bundled with the repo — preferred over live URL downloads so
+// the seed never fails when an Unsplash photo gets removed.
+const BUNDLED_IMAGE_DIR = path.join(process.cwd(), "prisma", "seed-assets", "vehicles");
+
+// All CDN URLs verified: extracted from Unsplash photo pages via curl, all return HTTP 200
+const vehicleImages: Record<string, string> = {
+  // Cars
+  "toyota-camry.jpg": "https://images.unsplash.com/photo-1621007947382-bb3c3994e3fb?w=800&q=80",
+  "ford-f150-white.jpg": "https://images.unsplash.com/photo-1605152322346-bd2391778772?w=800&q=80",
+  "ford-f150-grey.jpg": "https://images.unsplash.com/photo-1614218110929-caa460524fc1?w=800&q=80",
+  "ford-f150-black.jpg": "https://images.unsplash.com/photo-1691809158613-1e4d51ad086f?w=800&q=80",
+  "bmw-3series.jpg": "https://images.unsplash.com/photo-1629994562870-75d504fc02a1?w=800&q=80",
+  "honda-civic.jpg": "https://images.unsplash.com/photo-1636915873177-a0c1a48d84eb?w=800&q=80",
+  "tesla-model3.jpg": "https://images.unsplash.com/photo-1610470832703-95d40c3fad55?w=800&q=80",
+  "tesla-modely.jpg": "https://images.unsplash.com/photo-1504507926084-34cf0b939964?w=800&q=80",
+  "chevy-silverado.jpg": "https://images.unsplash.com/photo-1601919652576-41bad7c8ccbd?w=800&q=80",
+  "mercedes-sprinter.jpg": "https://images.unsplash.com/photo-1535655685871-dc8158ff167e?w=800&q=80",
+  "audi-a4.jpg": "https://images.unsplash.com/photo-1619732569416-83a8adfd8ac4?w=800&q=80",
+  "jeep-wrangler.jpg": "https://images.unsplash.com/photo-1579097647275-16b002cced9e?w=800&q=80",
+  "jeep-wrangler-orange.jpg": "https://images.unsplash.com/photo-1622364631585-831e11682a67?w=800&q=80",
+  "porsche-911.jpg": "https://images.unsplash.com/photo-1624880056652-c7993c99190f?w=800&q=80",
+  "vw-golf-gti-red.jpg": "https://images.unsplash.com/photo-1560282105-222992ffb774?w=800&q=80",
+  "vw-golf-gti-grey.jpg": "https://images.unsplash.com/photo-1746184842826-73fd144a4587?w=800&q=80",
+  "ram-1500.jpg": "https://images.unsplash.com/photo-1649793395985-967862a3b73f?w=800&q=80",
+  "ram-1500-black.jpg": "https://images.unsplash.com/photo-1678492861610-0bd650a846fe?w=800&q=80",
+  "toyota-tacoma.jpg": "https://images.unsplash.com/photo-1509510779868-337c9f58ad54?w=800&q=80",
+  "subaru-outback.jpg": "https://images.unsplash.com/photo-1681680061152-d3fc0b999a8e?w=800&q=80",
+  "subaru-forester.jpg": "https://images.unsplash.com/photo-1687048988997-ec57f83ea3bd?w=800&q=80",
+  // Trucks
+  "kenworth-t680.jpg": "https://images.unsplash.com/photo-1586191552066-d52dd1e3af86?w=800&q=80",
+  "freightliner-cascadia.jpg": "https://images.unsplash.com/photo-1616432043562-3671ea2e5242?w=800&q=80",
+  "volvo-fh640.jpg": "https://images.unsplash.com/photo-1633966100013-25a16d17b43d?w=800&q=80",
+  "mack-granite.jpg": "https://images.unsplash.com/photo-1746349086423-06ea6b4d73f7?w=800&q=80",
+  "concrete-mixer.jpg": "https://images.unsplash.com/photo-1530139675202-8c52bb810762?w=800&q=80",
+  "dump-truck.jpg": "https://images.unsplash.com/photo-1686945127938-0296f10937ed?w=800&q=80",
+  // Tractors
+  "john-deere-6r.jpg": "https://images.unsplash.com/photo-1604923757148-f6e6957199c3?w=800&q=80",
+  "fendt-942.jpg": "https://images.unsplash.com/photo-1629818572083-3ceb38e34322?w=800&q=80",
+  "massey-ferguson.jpg": "https://images.unsplash.com/photo-1635351461882-9ffff1ce323c?w=800&q=80",
+  "tractor-wheat.jpg": "https://images.unsplash.com/photo-1720273071277-190dd8f0a05a?w=800&q=80",
+  "tractor-field.jpg": "https://images.unsplash.com/photo-1633554587766-ffc8151e31c0?w=800&q=80",
+  "combine-harvester.jpg": "https://images.unsplash.com/photo-1754893889385-6c5a2d675524?w=800&q=80",
+  "combine-jd.jpg": "https://images.unsplash.com/photo-1635174815612-fd9636f70146?w=800&q=80",
+  // Heavy Machinery
+  "cat-d6-bulldozer.jpg": "https://images.unsplash.com/photo-1621922688758-359fc864071e?w=800&q=80",
+  "bulldozer-rocks.jpg": "https://images.unsplash.com/photo-1710669430606-30a8ceeb6e71?w=800&q=80",
+  "komatsu-excavator.jpg": "https://images.unsplash.com/photo-1649807533255-bbc9c9fb7d77?w=800&q=80",
+  "excavator-soil.jpg": "https://images.unsplash.com/photo-1622082679766-c5912d9416eb?w=800&q=80",
+  "volvo-ec220e.jpg": "https://images.unsplash.com/photo-1568678453977-a90de6812e7a?w=800&q=80",
+  "mini-excavator.jpg": "https://images.unsplash.com/photo-1722925837890-0fa0f0c9a356?w=800&q=80",
+  "wheel-loader.jpg": "https://images.unsplash.com/photo-1630288214032-2c4cc2c080ca?w=800&q=80",
+  "bulldozer-road.jpg": "https://images.unsplash.com/photo-1684431438566-6ff4a0df9190?w=800&q=80",
+  "loader-field.jpg": "https://images.unsplash.com/photo-1727863526509-7670e063f308?w=800&q=80",
+  "liebherr-crane.jpg": "https://images.unsplash.com/photo-1685305934074-3a7c37614c29?w=800&q=80",
+  "crane-yellow.jpg": "https://images.unsplash.com/photo-1725882176928-de328f0d485a?w=800&q=80",
+  "forklift.jpg": "https://images.unsplash.com/photo-1740914994657-f1cdffdc418e?w=800&q=80",
+  // Additional vehicles
+  "corvette-c8.jpg": "https://images.unsplash.com/photo-1750050677183-4e68b1d21b65?w=800&q=80",
+  "land-rover-defender.jpg": "https://images.unsplash.com/photo-1741905358583-1999b08d6e2e?w=800&q=80",
+  "dodge-charger.jpg": "https://images.unsplash.com/photo-1674520669939-1a8dbcdc2f6b?w=800&q=80",
+  "subaru-wrx.jpg": "https://images.unsplash.com/photo-1747000586906-c451da789b62?w=800&q=80",
+};
+
+function downloadFile(url: string, dest: string): Promise<void> {
+  return new Promise((resolve, reject) => {
+    const dir = path.dirname(dest);
+    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+    const follow = (u: string, r = 0) => {
+      if (r > 5) return reject(new Error("Too many redirects"));
+      const mod = u.startsWith("https") ? https : http;
+      mod.get(u, { headers: { "User-Agent": "Seed/1.0" } }, (res) => {
+        if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) return follow(res.headers.location, r + 1);
+        if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
+        const f = fs.createWriteStream(dest);
+        res.pipe(f);
+        f.on("finish", () => { f.close(); resolve(); });
+        f.on("error", (e) => { fs.unlinkSync(dest); reject(e); });
+      }).on("error", reject);
+    };
+    follow(url);
+  });
+}
+
+function img(cat: string, file: string) { return `/api/files/${ORG_ID}/${cat}/${file}`; }
+
+async function provisionDemoAccount() {
+  console.log("Provisioning demo user + organization...");
+
+  // Owner user (idempotent — safe to re-run)
+  await prisma.user.upsert({
+    where: { id: USER_ID },
+    create: {
+      id: USER_ID,
+      email: DEMO_EMAIL,
+      name: "Demo Owner",
+      emailVerified: true,
+      termsAcceptedAt: new Date(),
+    },
+    update: { email: DEMO_EMAIL, name: "Demo Owner" },
+  });
+
+  // Credential account — wipe + recreate so the password always matches env
+  const hashedPassword = hashPassword(DEMO_PASSWORD);
+  await prisma.account.deleteMany({ where: { userId: USER_ID, providerId: "credential" } });
+  await prisma.account.create({
+    data: { accountId: USER_ID, providerId: "credential", userId: USER_ID, password: hashedPassword },
+  });
+
+  // Organization
+  await prisma.organization.upsert({
+    where: { id: ORG_ID },
+    create: { id: ORG_ID, name: DEMO_ORG_NAME },
+    update: { name: DEMO_ORG_NAME },
+  });
+
+  // Belt and braces alongside the demoGuard on org creation: purge any
+  // foreign organizations so the reset always converges on a single org.
+  // Org-scoped rows go with them via ON DELETE CASCADE, and stale
+  // active-org cookies fall back to the demo org membership.
+  // inspections.templateId is ON DELETE RESTRICT, so the cascade cannot
+  // resolve templates and inspections in one statement; clear inspections
+  // first. (The only other RESTRICT in the schema, subscriptions.planId,
+  // guards global plans the purge never deletes.)
+  await prisma.inspection.deleteMany({
+    where: { organizationId: { not: ORG_ID } },
+  });
+  const foreignOrgs = await prisma.organization.deleteMany({
+    where: { id: { not: ORG_ID } },
+  });
+  if (foreignOrgs.count > 0) {
+    console.log(`  Removed ${foreignOrgs.count} visitor-created organization(s).`);
+  }
+
+  // Owner membership
+  await prisma.organizationMember.upsert({
+    where: { userId_organizationId: { userId: USER_ID, organizationId: ORG_ID } },
+    create: { userId: USER_ID, organizationId: ORG_ID, role: "owner" },
+    update: { role: "owner" },
+  });
+
+  console.log(`  Owner: ${DEMO_EMAIL} (org: ${DEMO_ORG_NAME})\n`);
+}
+
+/**
+ * Remove files a demo visitor uploaded, while preserving the seed's own cached
+ * vehicle images. `prisma/seed-assets/vehicles/` ships empty, so those images
+ * were fetched from Unsplash once and cached in the data volume — deleting them
+ * would trigger a full re-download on every 3-hourly reset.
+ */
+function cleanupUploads() {
+  const orgRoot = path.join(DATA_ROOT, "uploads", ORG_ID);
+  if (!fs.existsSync(orgRoot)) return;
+
+  const keep = new Set(Object.keys(vehicleImages));
+  let removed = 0;
+
+  const walk = (dir: string) => {
+    for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+      const full = path.join(dir, entry.name);
+      if (entry.isDirectory()) {
+        walk(full);
+        // Drop directories that are now empty (e.g. documents/, diagnostics/).
+        if (fs.readdirSync(full).length === 0) fs.rmdirSync(full);
+        continue;
+      }
+      // Preserve the cached seed images living in uploads/<org>/vehicles/.
+      if (dir === UPLOAD_DIR && keep.has(entry.name)) continue;
+      fs.unlinkSync(full);
+      removed++;
+    }
+  };
+
+  try {
+    walk(orgRoot);
+    console.log(`  Removed ${removed} visitor-uploaded file(s).`);
+  } catch (err) {
+    console.warn("  Upload cleanup failed:", err);
+  }
+}
+
+async function cleanup() {
+  console.log("Cleaning existing data...");
+
+  // Config/state a visitor can change that would otherwise survive every reset:
+  // branding, invoice templates, webhooks pointing at external URLs, schedules,
+  // custom presets. Deleting a Webhook cascades its WebhookDelivery rows.
+  await prisma.webhook.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.reportSchedule.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.teamInvitation.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.laborPreset.deleteMany({ where: { organizationId: ORG_ID } });
+  // Inspection.template is a required relation with no onDelete, so Postgres
+  // restricts the template delete while any inspection still points at it.
+  // Vehicle deletion further down would cascade these away, but that runs too
+  // late — drop them here first. InspectionItem and InspectionQuoteRequest
+  // cascade from Inspection, and Quote.inspectionId is SetNull.
+  await prisma.inspection.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.inspectionTemplate.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.customFieldDefinition.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.subscription.deleteMany({ where: { organizationId: ORG_ID } });
+  // Wipes branding, logo, invoice layout, localization, tax config. The
+  // maintenance.* keys are re-upserted later in the seed.
+  await prisma.appSetting.deleteMany({ where: { organizationId: ORG_ID } });
+
+  // Transient/accumulating records.
+  await prisma.aiChat.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.notification.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.telegramMessage.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.customerMagicLink.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.customerSmsCode.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.auditLog.deleteMany({ where: { organizationId: ORG_ID } });
+
+  // Any team member a visitor added — but never the demo owner, which
+  // provisionDemoAccount() upserts just before this runs.
+  await prisma.organizationMember.deleteMany({
+    where: { organizationId: ORG_ID, userId: { not: USER_ID } },
+  });
+  // Custom roles; OrganizationMember.roleId is onDelete: SetNull, so safe.
+  await prisma.role.deleteMany({ where: { organizationId: ORG_ID } });
+
+  // Core CRUD data. ServiceRequest, CustomerSession and StatusReport cascade
+  // from Vehicle/Customer/ServiceRecord. Inspections are already gone above.
+  await prisma.technician.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.smsMessage.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.quote.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.serviceRecord.deleteMany({ where: { vehicle: { organizationId: ORG_ID } } });
+  await prisma.fuelLog.deleteMany({ where: { vehicle: { organizationId: ORG_ID } } });
+  await prisma.reminder.deleteMany({ where: { vehicle: { organizationId: ORG_ID } } });
+  await prisma.note.deleteMany({ where: { vehicle: { organizationId: ORG_ID } } });
+  await prisma.vehicle.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.customer.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.inventoryPart.deleteMany({ where: { organizationId: ORG_ID } });
+
+  cleanupUploads();
+  console.log("  Done.\n");
+}
+
+async function seed() {
+  console.log("\nStarting seed...\n");
+  await provisionDemoAccount();
+  await cleanup();
+
+  // Populate vehicle images: prefer bundled assets → fall back to cached copy
+  // in data volume → fall back to live download.
+  console.log("Populating vehicle images...");
+  if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
+  for (const [file, url] of Object.entries(vehicleImages)) {
+    const dest = path.join(UPLOAD_DIR, file);
+    const bundled = path.join(BUNDLED_IMAGE_DIR, file);
+    if (fs.existsSync(bundled)) {
+      fs.copyFileSync(bundled, dest);
+      console.log(`  [bundled] ${file}`);
+      continue;
+    }
+    if (fs.existsSync(dest)) { console.log(`  [cached] ${file}`); continue; }
+    try { await downloadFile(url, dest); console.log(`  [downloaded] ${file}`); }
+    catch { console.warn(`  [fail] ${file}`); }
+  }
+
+  // -- Customers (20) --
+  console.log("\nCreating customers...");
+  const customers = await Promise.all([
+    /* 0  */ prisma.customer.create({ data: { name: "Summit Construction Inc.", email: "fleet@summitconstruction.com", phone: "+1 (555) 100-2000", address: "742 Industrial Pkwy, Denver, CO 80216", company: "Summit Construction Inc.", notes: "Major fleet customer. 15 vehicles + heavy machinery. Net-30 terms.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 1  */ prisma.customer.create({ data: { name: "James Mitchell", email: "james.mitchell@gmail.com", phone: "+1 (555) 201-3344", address: "1824 Maple Ave, Austin, TX 78701", notes: "Personal vehicles. Prefers synthetic oil.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 2  */ prisma.customer.create({ data: { name: "Pacific Freight Lines", email: "service@pacificfreight.com", phone: "+1 (555) 302-5566", address: "9100 Logistics Blvd, Portland, OR 97201", company: "Pacific Freight Lines LLC", notes: "Long-haul trucking company. 8 semi-trucks. Priority service.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 3  */ prisma.customer.create({ data: { name: "Sarah Coleman", email: "sarah.coleman@outlook.com", phone: "+1 (555) 403-7788", address: "567 Oak Street, Nashville, TN 37203", notes: "BMW and Porsche owner. Always requests OEM parts.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 4  */ prisma.customer.create({ data: { name: "Metro City Services", email: "fleet@metrocityservices.gov", phone: "+1 (555) 504-9900", address: "100 City Hall Plaza, Boston, MA 02108", company: "Metro City Services", notes: "Municipal fleet contract. Quarterly invoicing. Purchase order required.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 5  */ prisma.customer.create({ data: { name: "Ryan Parker", email: "ryan.parker@icloud.com", phone: "+1 (555) 605-1122", address: "2250 Cedar Lane, Seattle, WA 98101", notes: "Tesla and Subaru owner. Tech-savvy.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 6  */ prisma.customer.create({ data: { name: "Ironclad Equipment Co.", email: "parts@ironcladequip.com", phone: "+1 (555) 706-3344", address: "4500 Heavy Machinery Rd, Houston, TX 77001", company: "Ironclad Equipment Co.", notes: "Heavy equipment dealer. Sends us overflow work. Good referral source.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 7  */ prisma.customer.create({ data: { name: "David Chen", email: "david.chen@gmail.com", phone: "+1 (555) 807-5566", address: "890 Pine Street, San Francisco, CA 94102", notes: "Audi and VW owner. Referred by James Mitchell.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 8  */ prisma.customer.create({ data: { name: "Ridgeline Excavation LLC", email: "ops@ridgelineexcavation.com", phone: "+1 (555) 908-7788", address: "1200 Quarry Rd, Salt Lake City, UT 84101", company: "Ridgeline Excavation LLC", notes: "5 excavators, 2 wheel loaders, 3 dump trucks. Monthly maintenance contract.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 9  */ prisma.customer.create({ data: { name: "Amanda Foster", email: "amanda.foster@hotmail.com", phone: "+1 (555) 109-9900", address: "334 Birch Drive, Charlotte, NC 28202", notes: "Honda Civic owner. Budget-conscious, prefers aftermarket.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 10 */ prisma.customer.create({ data: { name: "Prairie Farms Co-op", email: "maintenance@prairiefarms.com", phone: "+1 (555) 210-1122", address: "Route 66 Farm Road, Omaha, NE 68101", company: "Prairie Farms Co-op", notes: "Agricultural equipment. Tractors, harvesters. Seasonal peaks spring/fall.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 11 */ prisma.customer.create({ data: { name: "Mike Thompson", email: "mike.thompson@yahoo.com", phone: "+1 (555) 311-3344", address: "445 Elm Court, Minneapolis, MN 55401", notes: "Ford F-150 and Jeep owner. Off-road enthusiast.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 12 */ prisma.customer.create({ data: { name: "Apex Contractors Group", email: "service@apexcontractors.com", phone: "+1 (555) 412-5566", address: "2800 Builder's Row, Atlanta, GA 30301", company: "Apex Contractors Group", notes: "Small contractor. 2 excavators, 1 dump truck, 2 vans.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 13 */ prisma.customer.create({ data: { name: "Jessica Rivera", email: "jessica.r@protonmail.com", phone: "+1 (555) 513-7788", address: "1100 Sunset Blvd, Miami, FL 33101", notes: "Tesla Model Y owner. Referred by Ryan Parker.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 14 */ prisma.customer.create({ data: { name: "Titan Building Corp.", email: "projects@titanbuildingcorp.com", phone: "+1 (555) 614-9900", address: "5000 Commerce Dr, Dallas, TX 75201", company: "Titan Building Corp.", notes: "Large construction firm. 20+ pieces of equipment. Net-45 terms.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 15 */ prisma.customer.create({ data: { name: "Kevin O'Brien", email: "kevin.obrien@gmail.com", phone: "+1 (555) 715-1122", address: "678 Market Street, Philadelphia, PA 19101", notes: "Mercedes Sprinter van for his catering business.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 16 */ prisma.customer.create({ data: { name: "Greenfield Waste Management", email: "dispatch@greenfieldwaste.com", phone: "+1 (555) 816-3344", address: "7700 Recycling Way, Phoenix, AZ 85001", company: "Greenfield Waste Management", notes: "Waste management fleet. 12 trucks. Quarterly PM schedule.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 17 */ prisma.customer.create({ data: { name: "Robert Hughes", email: "robert.hughes@outlook.com", phone: "+1 (555) 917-5566", address: "2100 Lakeview Ave, Detroit, MI 48201", notes: "Ram truck and VW Golf owner. Weekend car guy.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 17 */ prisma.customer.create({ data: { name: "Heartland Grain Co.", email: "ops@heartlandgrain.com", phone: "+1 (555) 018-7788", address: "4400 Harvest Rd, Des Moines, IA 50301", company: "Heartland Grain Co.", notes: "Large-scale farming. 6 tractors, 2 combines. Seasonal rush spring and fall.", userId: USER_ID, organizationId: ORG_ID } }),
+    /* 19 */ prisma.customer.create({ data: { name: "Lisa Martinez", email: "lisa.martinez@icloud.com", phone: "+1 (555) 119-9900", address: "425 Ranch Road, San Antonio, TX 78201", notes: "Chevy Silverado and Toyota Tacoma. Ranch use.", userId: USER_ID, organizationId: ORG_ID } }),
+  ]);
+  console.log(`  Created ${customers.length} customers`);
+
+  // -- Vehicles (40) --
+  console.log("\nCreating vehicles...");
+  const vehicles = await Promise.all([
+    // === Cars (0-17) ===
+    /* 0  */ prisma.vehicle.create({ data: { make: "Toyota", model: "Camry XSE", year: 2023, vin: "4T1K61AK5PU083241", licensePlate: "TX-8821", color: "Midnight Black", mileage: 18500, fuelType: "gasoline", transmission: "automatic", engineSize: "2.5L", purchaseDate: new Date("2023-03-15"), purchasePrice: 35500, imageUrl: img("vehicles","toyota-camry.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[1].id } }),
+    /* 1  */ prisma.vehicle.create({ data: { make: "Ford", model: "F-150 Lariat", year: 2022, vin: "1FTFW1E88NFC12345", licensePlate: "CO-4455", color: "Oxford White", mileage: 42300, fuelType: "gasoline", transmission: "automatic", engineSize: "3.5L EcoBoost", purchaseDate: new Date("2022-06-20"), purchasePrice: 58000, imageUrl: img("vehicles","ford-f150-white.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[0].id } }),
+    /* 2  */ prisma.vehicle.create({ data: { make: "Ford", model: "F-150 XLT", year: 2021, vin: "1FTEW1EP5MKD56789", licensePlate: "MN-7766", color: "Carbonized Gray", mileage: 55000, fuelType: "gasoline", transmission: "automatic", engineSize: "2.7L EcoBoost", purchaseDate: new Date("2021-04-10"), purchasePrice: 45000, imageUrl: img("vehicles","ford-f150-grey.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[11].id } }),
+    /* 3  */ prisma.vehicle.create({ data: { make: "Ford", model: "F-150 Platinum", year: 2024, vin: "1FTFW1E55RFA98765", licensePlate: "TX-1199", color: "Agate Black", mileage: 12000, fuelType: "gasoline", transmission: "automatic", engineSize: "3.5L EcoBoost V6", purchaseDate: new Date("2024-02-14"), purchasePrice: 72000, imageUrl: img("vehicles","ford-f150-black.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[4].id } }),
+    /* 4  */ prisma.vehicle.create({ data: { make: "BMW", model: "330i xDrive", year: 2021, vin: "WBA5R1C55M7B98765", licensePlate: "TN-2233", color: "Alpine White", mileage: 55200, fuelType: "gasoline", transmission: "automatic", engineSize: "2.0L Turbo", purchaseDate: new Date("2021-01-10"), purchasePrice: 46000, imageUrl: img("vehicles","bmw-3series.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[3].id } }),
+    /* 5  */ prisma.vehicle.create({ data: { make: "Honda", model: "Civic Sport", year: 2020, vin: "2HGFC2F63LH567890", licensePlate: "NC-6677", color: "Rallye Red", mileage: 67800, fuelType: "gasoline", transmission: "manual", engineSize: "2.0L", purchaseDate: new Date("2020-08-05"), purchasePrice: 26000, imageUrl: img("vehicles","honda-civic.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[9].id } }),
+    /* 6  */ prisma.vehicle.create({ data: { make: "Tesla", model: "Model 3 Long Range", year: 2024, vin: "5YJ3E1EA3RF123456", licensePlate: "WA-9901", color: "Pearl White", mileage: 8200, fuelType: "electric", transmission: "automatic", engineSize: "Dual Motor", purchaseDate: new Date("2024-01-20"), purchasePrice: 47000, imageUrl: img("vehicles","tesla-model3.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[5].id } }),
+    /* 7  */ prisma.vehicle.create({ data: { make: "Tesla", model: "Model Y Long Range", year: 2025, vin: "7SAYGDEE3SF123456", licensePlate: "FL-7720", color: "Quicksilver", mileage: 3200, fuelType: "electric", transmission: "automatic", engineSize: "Dual Motor AWD", purchaseDate: new Date("2025-06-10"), purchasePrice: 52000, imageUrl: img("vehicles","tesla-modely.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[13].id } }),
+    /* 8  */ prisma.vehicle.create({ data: { make: "Chevrolet", model: "Silverado 2500HD", year: 2019, vin: "1GC4YREY5KF234567", licensePlate: "TX-3344", color: "Shadow Gray", mileage: 98400, fuelType: "diesel", transmission: "automatic", engineSize: "6.6L Duramax", purchaseDate: new Date("2019-04-12"), purchasePrice: 52000, imageUrl: img("vehicles","chevy-silverado.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[19].id } }),
+    /* 9  */ prisma.vehicle.create({ data: { make: "Mercedes-Benz", model: "Sprinter 316 CDI", year: 2022, vin: "WDB9066331S789012", licensePlate: "PA-1122", color: "Arctic White", mileage: 35600, fuelType: "diesel", transmission: "automatic", engineSize: "2.1L CDI", purchaseDate: new Date("2022-09-01"), purchasePrice: 55000, imageUrl: img("vehicles","mercedes-sprinter.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[15].id } }),
+    /* 10 */ prisma.vehicle.create({ data: { make: "Audi", model: "A4 Quattro", year: 2023, vin: "WAUDNAF44PN345678", licensePlate: "CA-5566", color: "Navarra Blue", mileage: 22100, fuelType: "gasoline", transmission: "automatic", engineSize: "2.0L TFSI", purchaseDate: new Date("2023-07-14"), purchasePrice: 45000, imageUrl: img("vehicles","audi-a4.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[7].id } }),
+    /* 11 */ prisma.vehicle.create({ data: { make: "Jeep", model: "Wrangler Rubicon", year: 2022, vin: "1C4HJXFN3NW234567", licensePlate: "CO-8800", color: "Black", mileage: 28500, fuelType: "gasoline", transmission: "manual", engineSize: "3.6L Pentastar V6", purchaseDate: new Date("2022-05-18"), purchasePrice: 52000, imageUrl: img("vehicles","jeep-wrangler.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[11].id } }),
+    /* 12 */ prisma.vehicle.create({ data: { make: "Jeep", model: "Wrangler Sport S", year: 2023, vin: "1C4GJXAG4PW345678", licensePlate: "GA-5511", color: "Punk'n Orange", mileage: 15200, fuelType: "gasoline", transmission: "automatic", engineSize: "2.0L Turbo", purchaseDate: new Date("2023-08-22"), purchasePrice: 42000, imageUrl: img("vehicles","jeep-wrangler-orange.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[12].id } }),
+    /* 13 */ prisma.vehicle.create({ data: { make: "Porsche", model: "911 Carrera S", year: 2022, vin: "WP0AB2A94NS234567", licensePlate: "TN-9911", color: "White", mileage: 12800, fuelType: "gasoline", transmission: "automatic", engineSize: "3.0L Twin-Turbo Flat-6", purchaseDate: new Date("2022-11-05"), purchasePrice: 128000, imageUrl: img("vehicles","porsche-911.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[3].id } }),
+    /* 14 */ prisma.vehicle.create({ data: { make: "Volkswagen", model: "Golf GTI", year: 2021, vin: "3VW6T7AU5MM123456", licensePlate: "CA-4422", color: "Tornado Red", mileage: 38500, fuelType: "gasoline", transmission: "manual", engineSize: "2.0L TSI", purchaseDate: new Date("2021-06-15"), purchasePrice: 32000, imageUrl: img("vehicles","vw-golf-gti-red.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[7].id } }),
+    /* 15 */ prisma.vehicle.create({ data: { make: "Ram", model: "1500 Laramie", year: 2023, vin: "1C6SRFJT4PN456789", licensePlate: "MI-3322", color: "Billet Silver", mileage: 19800, fuelType: "gasoline", transmission: "automatic", engineSize: "5.7L HEMI V8", purchaseDate: new Date("2023-03-28"), purchasePrice: 62000, imageUrl: img("vehicles","ram-1500.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[17].id } }),
+    /* 16 */ prisma.vehicle.create({ data: { make: "Toyota", model: "Tacoma TRD Off-Road", year: 2022, vin: "3TMCZ5AN5NM567890", licensePlate: "TX-6644", color: "Super White", mileage: 31200, fuelType: "gasoline", transmission: "automatic", engineSize: "3.5L V6", purchaseDate: new Date("2022-10-01"), purchasePrice: 42000, imageUrl: img("vehicles","toyota-tacoma.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[19].id } }),
+
+    // === Trucks (17-21) ===
+    /* 17 */ prisma.vehicle.create({ data: { make: "Kenworth", model: "T680 Next Gen", year: 2022, vin: "1XKYD49X4NJ456789", licensePlate: "OR-8899", color: "Red", mileage: 185000, fuelType: "diesel", transmission: "automatic", engineSize: "12.9L PACCAR MX-13", purchaseDate: new Date("2022-02-15"), purchasePrice: 175000, imageUrl: img("vehicles","kenworth-t680.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[2].id } }),
+    /* 18 */ prisma.vehicle.create({ data: { make: "Volvo", model: "FH 640", year: 2023, vin: "YV2RT40A5PB567890", licensePlate: "IL-5567", color: "Blue", mileage: 92000, fuelType: "diesel", transmission: "automatic", engineSize: "12.8L D13TC", purchaseDate: new Date("2023-01-10"), purchasePrice: 210000, imageUrl: img("vehicles","volvo-fh640.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[2].id } }),
+    /* 19 */ prisma.vehicle.create({ data: { make: "Mack", model: "Granite GR64F", year: 2020, vin: "1M2AX04C5LM012345", licensePlate: "AZ-9901", color: "Yellow", mileage: 156000, fuelType: "diesel", transmission: "automatic", engineSize: "13.0L MP7", purchaseDate: new Date("2020-11-08"), purchasePrice: 140000, imageUrl: img("vehicles","mack-granite.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[0].id } }),
+    /* 20 */ prisma.vehicle.create({ data: { make: "Peterbilt", model: "567 Mixer", year: 2021, vin: "1NPCD49X1MD678901", licensePlate: "TX-7788", color: "White/Blue", mileage: 68000, fuelType: "diesel", transmission: "automatic", engineSize: "10.8L PACCAR MX-11", purchaseDate: new Date("2021-07-20"), purchasePrice: 165000, imageUrl: img("vehicles","concrete-mixer.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[14].id } }),
+    /* 21 */ prisma.vehicle.create({ data: { make: "Kenworth", model: "T880 Dump Truck", year: 2022, vin: "1NKZX4TX8NJ789012", licensePlate: "CO-5544", color: "Yellow", mileage: 82000, fuelType: "diesel", transmission: "automatic", engineSize: "12.9L PACCAR MX-13", purchaseDate: new Date("2022-04-15"), purchasePrice: 185000, imageUrl: img("vehicles","dump-truck.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[8].id } }),
+
+    // === Tractors (22-28) ===
+    /* 22 */ prisma.vehicle.create({ data: { make: "John Deere", model: "6R 250", year: 2022, vin: "1L06250RCNR012345", licensePlate: "N/A", color: "John Deere Green", mileage: 2800, fuelType: "diesel", transmission: "powershift", engineSize: "6.8L PowerTech PVS", purchaseDate: new Date("2022-03-01"), purchasePrice: 185000, imageUrl: img("vehicles","john-deere-6r.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[10].id } }),
+    /* 23 */ prisma.vehicle.create({ data: { make: "Fendt", model: "942 Vario", year: 2023, vin: "FEN942VARIO3N56789", licensePlate: "N/A", color: "Fendt Green", mileage: 1200, fuelType: "diesel", transmission: "CVT (Vario)", engineSize: "12.4L MAN D2676", purchaseDate: new Date("2023-06-15"), purchasePrice: 420000, imageUrl: img("vehicles","fendt-942.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[18].id } }),
+    /* 24 */ prisma.vehicle.create({ data: { make: "Massey Ferguson", model: "8S.265", year: 2021, vin: "MF8S265A1LM234567", licensePlate: "N/A", color: "Red", mileage: 3600, fuelType: "diesel", transmission: "Dyna-VT CVT", engineSize: "8.4L AGCO Power", purchaseDate: new Date("2021-09-10"), purchasePrice: 245000, imageUrl: img("vehicles","massey-ferguson.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[10].id } }),
+    /* 25 */ prisma.vehicle.create({ data: { make: "John Deere", model: "8R 410", year: 2024, vin: "1RW8410RPEN345678", licensePlate: "N/A", color: "Green/Yellow", mileage: 800, fuelType: "diesel", transmission: "e23 PowerShift", engineSize: "9.0L PowerTech PSS", purchaseDate: new Date("2024-01-20"), purchasePrice: 380000, imageUrl: img("vehicles","tractor-wheat.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[18].id } }),
+    /* 26 */ prisma.vehicle.create({ data: { make: "New Holland", model: "T7.315", year: 2022, vin: "NH7315ACLM456789", licensePlate: "N/A", color: "New Holland Blue", mileage: 2100, fuelType: "diesel", transmission: "Auto Command CVT", engineSize: "6.7L NEF", purchaseDate: new Date("2022-08-05"), purchasePrice: 210000, imageUrl: img("vehicles","tractor-field.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[10].id } }),
+    /* 27 */ prisma.vehicle.create({ data: { make: "John Deere", model: "S780 Combine", year: 2023, vin: "1H0S780SPN567890", licensePlate: "N/A", color: "Green/Yellow", mileage: 600, fuelType: "diesel", transmission: "hydrostatic", engineSize: "13.5L PowerTech PSS", purchaseDate: new Date("2023-05-10"), purchasePrice: 520000, imageUrl: img("vehicles","combine-harvester.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[18].id } }),
+    /* 28 */ prisma.vehicle.create({ data: { make: "John Deere", model: "X9 1100 Combine", year: 2024, vin: "1H0X91100RN678901", licensePlate: "N/A", color: "Green/Yellow", mileage: 350, fuelType: "diesel", transmission: "hydrostatic", engineSize: "13.6L PowerTech", purchaseDate: new Date("2024-03-15"), purchasePrice: 680000, imageUrl: img("vehicles","combine-jd.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[18].id } }),
+
+    // === Heavy Machinery (29-37) ===
+    /* 29 */ prisma.vehicle.create({ data: { make: "Caterpillar", model: "D6 XE Bulldozer", year: 2021, vin: "CAT0D6XE2LM012345", licensePlate: "N/A", color: "CAT Yellow", mileage: 4200, fuelType: "diesel", transmission: "automatic", engineSize: "9.3L C9.3B", purchaseDate: new Date("2021-03-25"), purchasePrice: 320000, imageUrl: img("vehicles","cat-d6-bulldozer.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[0].id } }),
+    /* 30 */ prisma.vehicle.create({ data: { make: "Caterpillar", model: "D8T Bulldozer", year: 2020, vin: "CAT0D8T2KM234567", licensePlate: "N/A", color: "CAT Yellow", mileage: 5800, fuelType: "diesel", transmission: "automatic", engineSize: "15.2L C15", purchaseDate: new Date("2020-06-10"), purchasePrice: 480000, imageUrl: img("vehicles","bulldozer-rocks.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[14].id } }),
+    /* 31 */ prisma.vehicle.create({ data: { make: "Komatsu", model: "PC210LC-11 Excavator", year: 2022, vin: "KMTPC210LCN234567", licensePlate: "N/A", color: "Komatsu Blue/White", mileage: 3100, fuelType: "diesel", transmission: "hydraulic", engineSize: "6.7L SAA6D107E-3", purchaseDate: new Date("2022-07-12"), purchasePrice: 265000, imageUrl: img("vehicles","komatsu-excavator.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[8].id } }),
+    /* 32 */ prisma.vehicle.create({ data: { make: "Komatsu", model: "PC490LC-11 Excavator", year: 2023, vin: "KMTPC490LCN345678", licensePlate: "N/A", color: "Komatsu Yellow", mileage: 1800, fuelType: "diesel", transmission: "hydraulic", engineSize: "15.2L SAA6D170E-7", purchaseDate: new Date("2023-01-20"), purchasePrice: 450000, imageUrl: img("vehicles","excavator-soil.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[8].id } }),
+    /* 33 */ prisma.vehicle.create({ data: { make: "Volvo", model: "EC220E Excavator", year: 2023, vin: "VOLEC220EN3M345678", licensePlate: "N/A", color: "Volvo Yellow", mileage: 1500, fuelType: "diesel", transmission: "hydraulic", engineSize: "5.7L D6J", purchaseDate: new Date("2023-02-20"), purchasePrice: 280000, imageUrl: img("vehicles","volvo-ec220e.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[8].id } }),
+    /* 34 */ prisma.vehicle.create({ data: { make: "Kubota", model: "KX040-4 Mini Excavator", year: 2023, vin: "KUB040KX4N3456789", licensePlate: "N/A", color: "Orange", mileage: 900, fuelType: "diesel", transmission: "hydraulic", engineSize: "1.8L D1803-CR-TE4", purchaseDate: new Date("2023-09-15"), purchasePrice: 62000, imageUrl: img("vehicles","mini-excavator.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[12].id } }),
+    /* 35 */ prisma.vehicle.create({ data: { make: "Case", model: "621G Wheel Loader", year: 2023, vin: "CASE621GN3M456789", licensePlate: "N/A", color: "Power Tan", mileage: 1800, fuelType: "diesel", transmission: "automatic", engineSize: "6.7L FPT", purchaseDate: new Date("2023-04-18"), purchasePrice: 185000, imageUrl: img("vehicles","wheel-loader.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[4].id } }),
+    /* 36 */ prisma.vehicle.create({ data: { make: "Liebherr", model: "LTM 1100-4.2 Crane", year: 2019, vin: "LBH1100LTM9K12345", licensePlate: "GA-2233", color: "Yellow", mileage: 12500, fuelType: "diesel", transmission: "automatic", engineSize: "10.7L D936", purchaseDate: new Date("2019-08-14"), purchasePrice: 850000, imageUrl: img("vehicles","liebherr-crane.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[14].id } }),
+    /* 37 */ prisma.vehicle.create({ data: { make: "Toyota", model: "8FBE18 Forklift", year: 2022, vin: "TYT8FBE18N2M56789", licensePlate: "N/A", color: "Orange/Black", mileage: 4200, fuelType: "electric", transmission: "automatic", engineSize: "48V AC Motor", purchaseDate: new Date("2022-11-01"), purchasePrice: 35000, imageUrl: img("vehicles","forklift.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[6].id } }),
+
+    // === Additional Vehicles (38-41) ===
+    /* 38 */ prisma.vehicle.create({ data: { make: "Chevrolet", model: "Corvette Stingray", year: 2024, vin: "1G1YB2D48R5100234", licensePlate: "TN-0038", color: "Black", mileage: 6200, fuelType: "gasoline", transmission: "automatic", engineSize: "6.2L LT2 V8", purchaseDate: new Date("2024-05-10"), purchasePrice: 68000, imageUrl: img("vehicles","corvette-c8.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[3].id } }),
+    /* 39 */ prisma.vehicle.create({ data: { make: "Land Rover", model: "Defender 110 S", year: 2023, vin: "SALE7EEU5P1234567", licensePlate: "CO-3911", color: "Santorini Black", mileage: 28500, fuelType: "gasoline", transmission: "automatic", engineSize: "3.0L I6 Turbo", purchaseDate: new Date("2023-06-22"), purchasePrice: 62000, imageUrl: img("vehicles","land-rover-defender.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[11].id } }),
+    /* 40 */ prisma.vehicle.create({ data: { make: "Dodge", model: "Charger R/T", year: 2022, vin: "2C3CDXCT5NH234567", licensePlate: "MI-4022", color: "Go Mango Orange", mileage: 34800, fuelType: "gasoline", transmission: "automatic", engineSize: "5.7L HEMI V8", purchaseDate: new Date("2022-08-15"), purchasePrice: 45000, imageUrl: img("vehicles","dodge-charger.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[17].id } }),
+    /* 41 */ prisma.vehicle.create({ data: { make: "Subaru", model: "WRX Premium", year: 2024, vin: "JF1VA1E68R9234567", licensePlate: "CA-4124", color: "WR Blue Pearl", mileage: 12400, fuelType: "gasoline", transmission: "manual", engineSize: "2.4L Turbo Boxer", purchaseDate: new Date("2024-03-01"), purchasePrice: 35000, imageUrl: img("vehicles","subaru-wrx.jpg"), userId: USER_ID, organizationId: ORG_ID, customerId: customers[7].id } }),
+  ]);
+  console.log(`  Created ${vehicles.length} vehicles`);
+
+  // -- Service Records (20) --
+  console.log("\nCreating service records...");
+  const svcData = [
+    { vehicleId: vehicles[0].id, title: "Oil Change & Tire Rotation", description: "Regular 10,000 mi service. Synthetic 0W-20 oil. Rotated tires front to back.", type: "maintenance", status: "completed", serviceDate: new Date("2025-11-15"), mileage: 15000, techName: "Jake Wilson", shopName: "Egeland Auto",
+      partItems: [{ name: "Synthetic Oil 0W-20 (5qt)", partNumber: "TOY-0W20-5Q", quantity: 1, unitPrice: 42, total: 42 }, { name: "Oil Filter", partNumber: "TOY-OF-2023", quantity: 1, unitPrice: 12, total: 12 }],
+      laborItems: [{ description: "Oil change and filter replacement", hours: 0.5, rate: 95, total: 47.50 }, { description: "Tire rotation and pressure check", hours: 0.3, rate: 95, total: 28.50 }],
+      subtotal: 130, taxRate: 8, taxAmount: 10.40, totalAmount: 140.40 },
+    { vehicleId: vehicles[4].id, title: "50,000 Mile Major Service", description: "Major service interval. Replaced brake pads, rotors, spark plugs. Full inspection.", type: "maintenance", status: "completed", serviceDate: new Date("2025-10-20"), mileage: 50000, techName: "Chris Taylor", shopName: "Egeland Auto",
+      partItems: [{ name: "Front Brake Pad Set", partNumber: "BMW-BP-F34", quantity: 1, unitPrice: 185, total: 185 }, { name: "Front Brake Rotors (pair)", partNumber: "BMW-BR-F34", quantity: 1, unitPrice: 320, total: 320 }, { name: "Spark Plugs (set of 4)", partNumber: "BMW-SP-N20", quantity: 1, unitPrice: 68, total: 68 }, { name: "Engine Oil 5W-30 (7qt)", partNumber: "BMW-5W30-7Q", quantity: 1, unitPrice: 89, total: 89 }],
+      laborItems: [{ description: "Front brake pad and rotor replacement", hours: 2.0, rate: 110, total: 220 }, { description: "Spark plug replacement", hours: 1.0, rate: 110, total: 110 }, { description: "Oil change and multi-point inspection", hours: 1.0, rate: 110, total: 110 }],
+      subtotal: 1102, taxRate: 8, taxAmount: 88.16, totalAmount: 1190.16 },
+    { vehicleId: vehicles[6].id, title: "Tire Replacement - All Four", description: "Replaced all four tires. Michelin Pilot Sport 4S. Alignment check passed.", type: "repair", status: "completed", serviceDate: new Date("2026-01-08"), mileage: 7800, techName: "Jake Wilson", shopName: "Egeland Auto",
+      partItems: [{ name: "Michelin Pilot Sport 4S 235/40R19", partNumber: "MICH-PS4S-2354019", quantity: 4, unitPrice: 310, total: 1240 }],
+      laborItems: [{ description: "Mount and balance 4 tires", hours: 1.5, rate: 95, total: 142.50 }, { description: "Wheel alignment check", hours: 0.5, rate: 95, total: 47.50 }],
+      subtotal: 1430, taxRate: 8, taxAmount: 114.40, totalAmount: 1544.40 },
+    { vehicleId: vehicles[13].id, title: "Porsche 911 - Annual Service", description: "Annual service. Oil change, brake fluid flush, cabin filter. Full inspection.", type: "maintenance", status: "completed", serviceDate: new Date("2026-01-15"), mileage: 12500, techName: "Chris Taylor", shopName: "Egeland Auto",
+      partItems: [{ name: "Porsche Approved Oil 0W-40 (9qt)", partNumber: "POR-0W40-9Q", quantity: 1, unitPrice: 165, total: 165 }, { name: "Oil Filter", partNumber: "POR-OF-992", quantity: 1, unitPrice: 28, total: 28 }, { name: "Brake Fluid DOT4 (1L)", partNumber: "POR-BF-DOT4", quantity: 2, unitPrice: 35, total: 70 }],
+      laborItems: [{ description: "Oil and filter change", hours: 1.0, rate: 145, total: 145 }, { description: "Brake fluid flush all 4 corners", hours: 1.5, rate: 145, total: 217.50 }, { description: "Multi-point inspection", hours: 1.0, rate: 145, total: 145 }],
+      subtotal: 770.50, taxRate: 8, taxAmount: 61.64, totalAmount: 832.14 },
+    { vehicleId: vehicles[17].id, title: "150K Mile Engine Service - Kenworth", description: "Major engine service. Replaced fuel filters, air filters, coolant flush. DPF regen.", type: "maintenance", status: "completed", serviceDate: new Date("2025-12-05"), mileage: 180000, techName: "Marcus Reed", shopName: "Egeland Auto",
+      partItems: [{ name: "PACCAR MX-13 Fuel Filter Kit", partNumber: "PAC-FF-MX13", quantity: 1, unitPrice: 125, total: 125 }, { name: "Air Filter Element", partNumber: "PAC-AF-MX13", quantity: 1, unitPrice: 85, total: 85 }, { name: "Coolant (5gal)", partNumber: "PAC-COOL-5G", quantity: 2, unitPrice: 65, total: 130 }, { name: "Engine Oil 15W-40 (10gal)", partNumber: "PAC-15W40-10G", quantity: 1, unitPrice: 280, total: 280 }],
+      laborItems: [{ description: "Engine oil and filter change", hours: 2.0, rate: 125, total: 250 }, { description: "Fuel filter replacement", hours: 1.0, rate: 125, total: 125 }, { description: "Coolant flush and refill", hours: 1.5, rate: 125, total: 187.50 }, { description: "DPF regeneration and diagnostic", hours: 1.5, rate: 125, total: 187.50 }],
+      subtotal: 1370, taxRate: 8, taxAmount: 109.60, totalAmount: 1479.60 },
+    { vehicleId: vehicles[18].id, title: "Volvo FH 640 - DOT Inspection", description: "Full annual inspection per DOT regulations. Brake test, emissions, lights, safety.", type: "inspection", status: "in_progress", serviceDate: new Date("2026-02-18"), mileage: 92000, techName: "Marcus Reed", shopName: "Egeland Auto",
+      partItems: [{ name: "Marker Light Bulbs (pack of 10)", partNumber: "VOL-MLB-10", quantity: 1, unitPrice: 35, total: 35 }, { name: "Wiper Blades (pair)", partNumber: "VOL-WB-FH", quantity: 1, unitPrice: 52, total: 52 }],
+      laborItems: [{ description: "Full DOT inspection and documentation", hours: 4.0, rate: 125, total: 500 }, { description: "Minor repairs and adjustments", hours: 2.0, rate: 125, total: 250 }],
+      subtotal: 837, taxRate: 8, taxAmount: 66.96, totalAmount: 903.96 },
+    { vehicleId: vehicles[22].id, title: "John Deere 6R 250 - 500hr Service", description: "Scheduled 500-hour service. Engine oil, all filters, hydraulic fluid sample, PTO check.", type: "maintenance", status: "completed", serviceDate: new Date("2026-01-20"), mileage: 2500, techName: "Marcus Reed", shopName: "Egeland Auto",
+      partItems: [{ name: "Engine Oil 15W-40 (5gal)", partNumber: "JD-15W40-5G", quantity: 1, unitPrice: 185, total: 185 }, { name: "Oil Filter", partNumber: "JD-OF-6R", quantity: 1, unitPrice: 32, total: 32 }, { name: "Fuel Filter Kit", partNumber: "JD-FFK-6R", quantity: 1, unitPrice: 68, total: 68 }, { name: "Hydraulic Filter", partNumber: "JD-HF-6R", quantity: 1, unitPrice: 55, total: 55 }, { name: "Air Filter Inner+Outer", partNumber: "JD-AF-6R", quantity: 1, unitPrice: 78, total: 78 }],
+      laborItems: [{ description: "Engine oil and all filter replacement", hours: 2.0, rate: 140, total: 280 }, { description: "Hydraulic fluid sampling and analysis", hours: 0.5, rate: 140, total: 70 }, { description: "PTO and 3-point hitch inspection", hours: 1.0, rate: 140, total: 140 }, { description: "Grease all fittings (32 points)", hours: 1.5, rate: 140, total: 210 }],
+      subtotal: 1118, taxRate: 8, taxAmount: 89.44, totalAmount: 1207.44 },
+    { vehicleId: vehicles[23].id, title: "Fendt 942 - CVT Transmission Service", description: "CVT transmission fluid and filter change. Vario calibration and function test.", type: "maintenance", status: "completed", serviceDate: new Date("2025-11-10"), mileage: 1100, techName: "Chris Taylor", shopName: "Egeland Auto",
+      partItems: [{ name: "Vario CVT Fluid (10gal)", partNumber: "FENDT-CVT-10G", quantity: 1, unitPrice: 420, total: 420 }, { name: "CVT Filter Kit", partNumber: "FENDT-CVTF-942", quantity: 1, unitPrice: 165, total: 165 }],
+      laborItems: [{ description: "CVT transmission fluid drain and refill", hours: 3.0, rate: 150, total: 450 }, { description: "CVT filter replacement", hours: 1.5, rate: 150, total: 225 }, { description: "Vario transmission calibration", hours: 2.0, rate: 150, total: 300 }],
+      subtotal: 1560, taxRate: 8, taxAmount: 124.80, totalAmount: 1684.80 },
+    { vehicleId: vehicles[29].id, title: "CAT D6 - Hydraulic System Overhaul", description: "Complete hydraulic system service. Replaced main pump seals, flushed system, new filters.", type: "repair", status: "completed", serviceDate: new Date("2025-09-18"), mileage: 4000, techName: "Marcus Reed", shopName: "Egeland Auto",
+      partItems: [{ name: "Hydraulic Pump Seal Kit", partNumber: "CAT-HPS-D6", quantity: 1, unitPrice: 450, total: 450 }, { name: "Hydraulic Oil (55gal drum)", partNumber: "CAT-HO-55G", quantity: 1, unitPrice: 850, total: 850 }, { name: "Hydraulic Filter Set", partNumber: "CAT-HF-D6", quantity: 2, unitPrice: 120, total: 240 }],
+      laborItems: [{ description: "Hydraulic pump disassembly and seal replacement", hours: 6.0, rate: 140, total: 840 }, { description: "System flush and refill", hours: 3.0, rate: 140, total: 420 }, { description: "Pressure testing and calibration", hours: 2.0, rate: 140, total: 280 }],
+      subtotal: 3080, taxRate: 8, taxAmount: 246.40, totalAmount: 3326.40 },
+    { vehicleId: vehicles[31].id, title: "Komatsu PC210 - Track Chain Replacement", description: "Replaced both track chains and sprockets. Undercarriage inspection completed.", type: "repair", status: "completed", serviceDate: new Date("2026-01-22"), mileage: 3000, techName: "Chris Taylor", shopName: "Egeland Auto",
+      partItems: [{ name: "Track Chain Assembly (left)", partNumber: "KOM-TC-PC210L", quantity: 1, unitPrice: 2800, total: 2800 }, { name: "Track Chain Assembly (right)", partNumber: "KOM-TC-PC210R", quantity: 1, unitPrice: 2800, total: 2800 }, { name: "Track Sprocket (pair)", partNumber: "KOM-TS-PC210", quantity: 1, unitPrice: 1200, total: 1200 }],
+      laborItems: [{ description: "Track chain removal and installation (both sides)", hours: 8.0, rate: 140, total: 1120 }, { description: "Sprocket replacement", hours: 3.0, rate: 140, total: 420 }, { description: "Undercarriage inspection", hours: 2.0, rate: 140, total: 280 }],
+      subtotal: 8620, taxRate: 8, taxAmount: 689.60, totalAmount: 9309.60 },
+    { vehicleId: vehicles[33].id, title: "Volvo EC220E - 1000hr Full Service", description: "All fluids, filters, track tension, swing bearing inspection.", type: "maintenance", status: "in_progress", serviceDate: new Date("2026-02-25"), mileage: 1500, techName: "Chris Taylor", shopName: "Egeland Auto",
+      partItems: [{ name: "Engine Oil 15W-40 (5gal)", partNumber: "VOL-15W40-5G", quantity: 1, unitPrice: 185, total: 185 }, { name: "Hydraulic Filter Set", partNumber: "VOL-HFS-EC220", quantity: 1, unitPrice: 120, total: 120 }, { name: "Fuel Filter Kit", partNumber: "VOL-FFK-EC220", quantity: 1, unitPrice: 58, total: 58 }],
+      laborItems: [{ description: "Engine oil and all filter replacement", hours: 2.5, rate: 140, total: 350 }, { description: "Track tension adjustment", hours: 1.0, rate: 140, total: 140 }, { description: "Full grease service (48 points)", hours: 2.0, rate: 140, total: 280 }],
+      subtotal: 1133, taxRate: 8, taxAmount: 90.64, totalAmount: 1223.64 },
+    { vehicleId: vehicles[11].id, title: "Jeep Wrangler - Lift Kit Install", description: "2.5\" suspension lift, new shocks, extended brake lines. Alignment after.", type: "repair", status: "completed", serviceDate: new Date("2026-02-10"), mileage: 28000, techName: "Jake Wilson", shopName: "Egeland Auto",
+      partItems: [{ name: "Mopar 2.5\" Lift Kit", partNumber: "JEEP-LK-25", quantity: 1, unitPrice: 1200, total: 1200 }, { name: "Bilstein 5100 Shocks (set of 4)", partNumber: "BIL-5100-JL4", quantity: 1, unitPrice: 680, total: 680 }, { name: "Extended Brake Lines", partNumber: "JEEP-EBL-JL", quantity: 1, unitPrice: 120, total: 120 }],
+      laborItems: [{ description: "Suspension lift installation", hours: 6.0, rate: 110, total: 660 }, { description: "Brake line routing and bleed", hours: 1.0, rate: 110, total: 110 }, { description: "4-wheel alignment", hours: 1.5, rate: 110, total: 165 }],
+      subtotal: 2935, taxRate: 8, taxAmount: 234.80, totalAmount: 3169.80 },
+    { vehicleId: vehicles[9].id, title: "Sprinter - Turbocharger Replacement", description: "Turbo shaft play detected. Black smoke under boost. Replaced turbo assembly.", type: "repair", status: "in_progress", serviceDate: new Date("2026-02-19"), mileage: 35400, techName: "Chris Taylor", shopName: "Egeland Auto",
+      partItems: [{ name: "Turbocharger Assembly - OM651", partNumber: "MB-TURBO-OM651", quantity: 1, unitPrice: 1280, total: 1280 }, { name: "Turbo Oil Feed Line", partNumber: "MB-TOFL-OM651", quantity: 1, unitPrice: 68, total: 68 }, { name: "Turbo Gasket Kit", partNumber: "MB-TGK-OM651", quantity: 1, unitPrice: 42, total: 42 }],
+      laborItems: [{ description: "Turbocharger removal", hours: 3.0, rate: 125, total: 375 }, { description: "New turbo installation and oil line", hours: 2.5, rate: 125, total: 312.50 }],
+      subtotal: 2077.50, taxRate: 8, taxAmount: 166.20, totalAmount: 2243.70 },
+    { vehicleId: vehicles[24].id, title: "Massey Ferguson 8S - Hydraulic Pump", description: "Low hydraulic pressure. Replaced main pump and flushed system.", type: "repair", status: "completed", serviceDate: new Date("2026-02-05"), mileage: 3500, techName: "Marcus Reed", shopName: "Egeland Auto",
+      partItems: [{ name: "Main Hydraulic Pump Assembly", partNumber: "MF-HPA-8S", quantity: 1, unitPrice: 1650, total: 1650 }, { name: "Hydraulic Oil (15gal)", partNumber: "HYD-ISO46-15G", quantity: 1, unitPrice: 280, total: 280 }, { name: "Hydraulic Filter Set", partNumber: "MF-HFS-8S", quantity: 1, unitPrice: 95, total: 95 }],
+      laborItems: [{ description: "Hydraulic pump removal", hours: 4.0, rate: 140, total: 560 }, { description: "New pump installation", hours: 3.0, rate: 140, total: 420 }, { description: "System flush and pressure test", hours: 2.0, rate: 140, total: 280 }],
+      subtotal: 3285, taxRate: 8, taxAmount: 262.80, totalAmount: 3547.80 },
+    { vehicleId: vehicles[15].id, title: "Ram 1500 - Brake Service", description: "Front and rear brake pad replacement. Rotor resurface. Fluid flush.", type: "maintenance", status: "completed", serviceDate: new Date("2026-01-28"), mileage: 19500, techName: "Jake Wilson", shopName: "Egeland Auto",
+      partItems: [{ name: "Front Brake Pad Set", partNumber: "RAM-BP-F-DS", quantity: 1, unitPrice: 145, total: 145 }, { name: "Rear Brake Pad Set", partNumber: "RAM-BP-R-DS", quantity: 1, unitPrice: 125, total: 125 }, { name: "Brake Fluid DOT4 (1L)", partNumber: "BF-DOT4-1L", quantity: 2, unitPrice: 18, total: 36 }],
+      laborItems: [{ description: "Front brake pad replacement", hours: 1.5, rate: 110, total: 165 }, { description: "Rear brake pad replacement", hours: 1.0, rate: 110, total: 110 }, { description: "Rotor resurface (all 4)", hours: 1.0, rate: 110, total: 110 }, { description: "Brake fluid flush", hours: 0.5, rate: 110, total: 55 }],
+      subtotal: 746, taxRate: 8, taxAmount: 59.68, totalAmount: 805.68 },
+    { vehicleId: vehicles[14].id, title: "VW Golf GTI - DSG Service + Oil", description: "DSG transmission fluid/filter. Engine oil change. Cabin filter.", type: "maintenance", status: "completed", serviceDate: new Date("2025-12-20"), mileage: 38000, techName: "Jake Wilson", shopName: "Egeland Auto",
+      partItems: [{ name: "DSG Fluid (7qt)", partNumber: "VW-DSG-7Q", quantity: 1, unitPrice: 145, total: 145 }, { name: "DSG Filter", partNumber: "VW-DSGF", quantity: 1, unitPrice: 52, total: 52 }, { name: "Engine Oil 5W-40 (5qt)", partNumber: "VW-5W40-5Q", quantity: 1, unitPrice: 58, total: 58 }],
+      laborItems: [{ description: "DSG fluid and filter change", hours: 1.5, rate: 110, total: 165 }, { description: "Engine oil and filter change", hours: 0.5, rate: 110, total: 55 }],
+      subtotal: 475, taxRate: 8, taxAmount: 38, totalAmount: 513 },
+    { vehicleId: vehicles[19].id, title: "Mack Granite - PTO Pump Replacement", description: "PTO pump failed. No hydraulic pressure for dump body. Replaced and tested.", type: "repair", status: "completed", serviceDate: new Date("2025-12-28"), mileage: 154000, techName: "Marcus Reed", shopName: "Egeland Auto",
+      partItems: [{ name: "PTO Hydraulic Pump", partNumber: "MACK-PTO-GR64", quantity: 1, unitPrice: 1450, total: 1450 }, { name: "PTO Mounting Gasket Kit", partNumber: "MACK-PTO-GK", quantity: 1, unitPrice: 85, total: 85 }, { name: "Hydraulic Oil (10gal)", partNumber: "HYD-ISO46-10G", quantity: 1, unitPrice: 180, total: 180 }],
+      laborItems: [{ description: "PTO pump removal", hours: 3.0, rate: 140, total: 420 }, { description: "New pump installation", hours: 2.5, rate: 140, total: 350 }, { description: "System prime and dump cycle test", hours: 1.5, rate: 140, total: 210 }],
+      subtotal: 2695, taxRate: 8, taxAmount: 215.60, totalAmount: 2910.60 },
+    { vehicleId: vehicles[35].id, title: "Case 621G - Bucket Cylinder Reseal", description: "Bucket cylinder leaking. Resealed both cylinders. Tested under load.", type: "repair", status: "completed", serviceDate: new Date("2025-11-28"), mileage: 1700, techName: "Chris Taylor", shopName: "Egeland Auto",
+      partItems: [{ name: "Bucket Cylinder Seal Kit (pair)", partNumber: "CASE-BCSK-621G", quantity: 1, unitPrice: 380, total: 380 }, { name: "Hydraulic Oil (5gal)", partNumber: "HYD-ISO46-5G", quantity: 2, unitPrice: 95, total: 190 }],
+      laborItems: [{ description: "Cylinder removal and disassembly", hours: 3.0, rate: 140, total: 420 }, { description: "Reseal and reassembly", hours: 2.5, rate: 140, total: 350 }, { description: "System bleed and pressure test", hours: 1.5, rate: 140, total: 210 }],
+      subtotal: 1550, taxRate: 8, taxAmount: 124, totalAmount: 1674 },
+    { vehicleId: vehicles[1].id, title: "F-150 Transmission Fluid Flush", description: "Customer reported rough shifting. Flushed transmission fluid, replaced filter.", type: "repair", status: "completed", serviceDate: new Date("2026-02-01"), mileage: 41500, techName: "Jake Wilson", shopName: "Egeland Auto",
+      partItems: [{ name: "Mercon ULV ATF (12qt)", partNumber: "FORD-ATF-ULV", quantity: 1, unitPrice: 185, total: 185 }, { name: "Transmission Filter Kit", partNumber: "FORD-TFK-10R80", quantity: 1, unitPrice: 68, total: 68 }],
+      laborItems: [{ description: "Transmission fluid flush", hours: 2.0, rate: 110, total: 220 }, { description: "Filter and gasket replacement", hours: 1.5, rate: 110, total: 165 }],
+      subtotal: 638, taxRate: 8, taxAmount: 51.04, totalAmount: 689.04 },
+    { vehicleId: vehicles[38].id, title: "Corvette C8 - First Annual Service", description: "Annual oil change with performance inspection. Checked brake wear, tire depth, and suspension.", type: "maintenance", status: "completed", serviceDate: new Date("2026-01-22"), mileage: 5800, techName: "Chris Taylor", shopName: "Egeland Auto",
+      partItems: [{ name: "Mobil 1 0W-40 Full Synthetic (8qt)", partNumber: "CHV-0W40-8Q", quantity: 1, unitPrice: 95, total: 95 }, { name: "Oil Filter (OEM)", partNumber: "CHV-OF-LT2", quantity: 1, unitPrice: 18, total: 18 }, { name: "Cabin Air Filter", partNumber: "CHV-CAF-C8", quantity: 1, unitPrice: 28, total: 28 }],
+      laborItems: [{ description: "Oil and filter change (dry sump)", hours: 1.0, rate: 130, total: 130 }, { description: "Performance multi-point inspection", hours: 1.0, rate: 130, total: 130 }],
+      subtotal: 401, taxRate: 8, taxAmount: 32.08, totalAmount: 433.08 },
+    { vehicleId: vehicles[39].id, title: "Defender 110 - 30K Service + Off-Road Inspection", description: "30K mile service. Oil change, all filters, diff fluids. Extra inspection for off-road wear.", type: "maintenance", status: "completed", serviceDate: new Date("2025-12-10"), mileage: 27000, techName: "Erik Haugen", shopName: "Egeland Auto",
+      partItems: [{ name: "Engine Oil 0W-20 (7qt)", partNumber: "LR-0W20-7Q", quantity: 1, unitPrice: 78, total: 78 }, { name: "Oil Filter", partNumber: "LR-OF-DEF", quantity: 1, unitPrice: 22, total: 22 }, { name: "Air Filter", partNumber: "LR-AF-DEF", quantity: 1, unitPrice: 45, total: 45 }, { name: "Front Diff Fluid (2qt)", partNumber: "LR-FDF-2Q", quantity: 1, unitPrice: 42, total: 42 }, { name: "Rear Diff Fluid (2.5qt)", partNumber: "LR-RDF-25Q", quantity: 1, unitPrice: 48, total: 48 }],
+      laborItems: [{ description: "Oil and filter change", hours: 0.5, rate: 110, total: 55 }, { description: "Air filter replacement", hours: 0.3, rate: 110, total: 33 }, { description: "Front and rear differential fluid change", hours: 1.5, rate: 110, total: 165 }, { description: "Off-road undercarriage inspection", hours: 1.0, rate: 110, total: 110 }],
+      subtotal: 598, taxRate: 8, taxAmount: 47.84, totalAmount: 645.84 },
+    { vehicleId: vehicles[40].id, title: "Charger R/T - Brake Rotor Replacement", description: "Front rotors warped from aggressive driving. Replace rotors and pads.", type: "repair", status: "completed", serviceDate: new Date("2026-02-05"), mileage: 33500, techName: "Jake Wilson", shopName: "Egeland Auto",
+      partItems: [{ name: "Front Brake Rotors (pair)", partNumber: "DOD-BR-CHG-F", quantity: 1, unitPrice: 280, total: 280 }, { name: "Front Brake Pad Set (performance)", partNumber: "DOD-BP-CHG-F", quantity: 1, unitPrice: 145, total: 145 }],
+      laborItems: [{ description: "Front brake rotor and pad replacement", hours: 2.0, rate: 110, total: 220 }, { description: "Brake system bleed and road test", hours: 0.5, rate: 110, total: 55 }],
+      subtotal: 700, taxRate: 8, taxAmount: 56, totalAmount: 756 },
+    { vehicleId: vehicles[41].id, title: "WRX - Turbo Boost Leak Repair", description: "Customer reported loss of power under boost. Found cracked intercooler pipe.", type: "repair", status: "completed", serviceDate: new Date("2026-01-30"), mileage: 11800, techName: "Chris Taylor", shopName: "Egeland Auto",
+      partItems: [{ name: "Intercooler Pipe + Couplers", partNumber: "SUB-ICP-WRX", quantity: 1, unitPrice: 165, total: 165 }, { name: "T-Bolt Clamps (set of 4)", partNumber: "SUB-TBC-4", quantity: 1, unitPrice: 28, total: 28 }],
+      laborItems: [{ description: "Boost leak diagnosis and smoke test", hours: 1.0, rate: 120, total: 120 }, { description: "Intercooler pipe replacement", hours: 1.5, rate: 120, total: 180 }],
+      subtotal: 493, taxRate: 8, taxAmount: 39.44, totalAmount: 532.44 },
+  ];
+
+  const serviceRecords = [];
+  for (const sr of svcData) {
+    const { partItems, laborItems, ...data } = sr;
+    const record = await prisma.serviceRecord.create({ data: { ...data, cost: data.totalAmount, partItems: { create: partItems }, laborItems: { create: laborItems } } });
+    serviceRecords.push(record);
+  }
+  console.log(`  Created ${serviceRecords.length} service records`);
+
+  // -- Quotes (15) --
+  console.log("\nCreating quotes...");
+  const qData = [
+    { quoteNumber: "Q-2026-001", title: "Full Brake Overhaul - BMW 330i", status: "sent", validUntil: new Date("2026-03-15"), customerId: customers[3].id, vehicleId: vehicles[4].id,
+      partItems: [{ name: "Front Brake Rotor Set (OEM)", partNumber: "BMW-BR-F34-OEM", quantity: 1, unitPrice: 480, total: 480 }, { name: "Rear Brake Rotor Set (OEM)", partNumber: "BMW-BR-R34-OEM", quantity: 1, unitPrice: 390, total: 390 }, { name: "Front Brake Pad Set (OEM)", partNumber: "BMW-BP-F34-OEM", quantity: 1, unitPrice: 220, total: 220 }],
+      laborItems: [{ description: "Front brake rotor and pad replacement", hours: 2.5, rate: 110, total: 275 }, { description: "Rear brake rotor replacement", hours: 2.0, rate: 110, total: 220 }],
+      subtotal: 1585, taxRate: 8, taxAmount: 126.80, totalAmount: 1711.80 },
+    { quoteNumber: "Q-2026-002", title: "200K Engine Service - Kenworth T680", status: "accepted", validUntil: new Date("2026-03-01"), customerId: customers[2].id, vehicleId: vehicles[17].id,
+      partItems: [{ name: "PACCAR MX-13 Overhaul Kit", partNumber: "PAC-OHK-MX13", quantity: 1, unitPrice: 850, total: 850 }, { name: "Injector Set (6)", partNumber: "PAC-INJ-MX13-6", quantity: 1, unitPrice: 1420, total: 1420 }],
+      laborItems: [{ description: "Injector replacement (6 cylinders)", hours: 8.0, rate: 140, total: 1120 }, { description: "Valve adjustment", hours: 4.0, rate: 140, total: 560 }],
+      subtotal: 3950, taxRate: 8, taxAmount: 316, totalAmount: 4266 },
+    { quoteNumber: "Q-2026-003", title: "CAT D6 Undercarriage Rebuild", status: "sent", validUntil: new Date("2026-03-30"), customerId: customers[0].id, vehicleId: vehicles[29].id,
+      partItems: [{ name: "Track Chain Assembly (pair)", partNumber: "CAT-TCA-D6", quantity: 1, unitPrice: 6500, total: 6500 }, { name: "Track Roller Set (14 pcs)", partNumber: "CAT-TR-D6-14", quantity: 1, unitPrice: 4200, total: 4200 }, { name: "Sprocket Set (pair)", partNumber: "CAT-SS-D6", quantity: 1, unitPrice: 1500, total: 1500 }],
+      laborItems: [{ description: "Complete undercarriage disassembly", hours: 12.0, rate: 140, total: 1680 }, { description: "Component installation", hours: 16.0, rate: 140, total: 2240 }],
+      subtotal: 16120, taxRate: 8, taxAmount: 1289.60, totalAmount: 17409.60 },
+    { quoteNumber: "Q-2026-004", title: "John Deere 6R - Front Axle Overhaul", status: "sent", validUntil: new Date("2026-04-15"), customerId: customers[10].id, vehicleId: vehicles[22].id,
+      partItems: [{ name: "Front Axle Bearing Kit", partNumber: "JD-FABK-6R", quantity: 1, unitPrice: 520, total: 520 }, { name: "CV Joint Assembly (pair)", partNumber: "JD-CVJ-6R", quantity: 1, unitPrice: 850, total: 850 }],
+      laborItems: [{ description: "Front axle removal", hours: 5.0, rate: 140, total: 700 }, { description: "Bearing and CV replacement", hours: 4.0, rate: 140, total: 560 }],
+      subtotal: 2630, taxRate: 8, taxAmount: 210.40, totalAmount: 2840.40 },
+    { quoteNumber: "Q-2026-005", title: "Honda Civic - Clutch Replacement", status: "accepted", validUntil: new Date("2026-03-10"), customerId: customers[9].id, vehicleId: vehicles[5].id,
+      partItems: [{ name: "Clutch Kit (disc, plate, bearing)", partNumber: "HON-CLK-FK7", quantity: 1, unitPrice: 380, total: 380 }],
+      laborItems: [{ description: "Transmission removal and clutch replacement", hours: 5.0, rate: 110, total: 550 }, { description: "Reinstallation and test drive", hours: 2.0, rate: 110, total: 220 }],
+      subtotal: 1150, taxRate: 8, taxAmount: 92, totalAmount: 1242 },
+    { quoteNumber: "Q-2026-006", title: "Volvo FH 640 - Air Suspension Repair", status: "accepted", validUntil: new Date("2026-03-10"), customerId: customers[2].id, vehicleId: vehicles[18].id,
+      partItems: [{ name: "Rear Air Spring (pair)", partNumber: "VOL-RAS-FH", quantity: 1, unitPrice: 560, total: 560 }, { name: "Height Sensor", partNumber: "VOL-HS-FH", quantity: 1, unitPrice: 240, total: 240 }],
+      laborItems: [{ description: "Air bag replacement (both sides)", hours: 3.0, rate: 140, total: 420 }, { description: "Sensor replacement and calibration", hours: 1.5, rate: 140, total: 210 }],
+      subtotal: 1430, taxRate: 8, taxAmount: 114.40, totalAmount: 1544.40 },
+    { quoteNumber: "Q-2026-007", title: "Fendt 942 - A/C Compressor Repair", status: "draft", validUntil: new Date("2026-04-10"), customerId: customers[18].id, vehicleId: vehicles[23].id,
+      partItems: [{ name: "A/C Compressor - Fendt 942", partNumber: "FENDT-ACC-942", quantity: 1, unitPrice: 890, total: 890 }, { name: "Condenser Assembly", partNumber: "FENDT-COND-942", quantity: 1, unitPrice: 450, total: 450 }],
+      laborItems: [{ description: "Cab disassembly for A/C access", hours: 3.0, rate: 150, total: 450 }, { description: "Compressor and condenser replacement", hours: 4.0, rate: 150, total: 600 }],
+      subtotal: 2390, taxRate: 8, taxAmount: 191.20, totalAmount: 2581.20 },
+    { quoteNumber: "Q-2026-008", title: "Komatsu PC210 - Boom Cylinder Reseal", status: "sent", validUntil: new Date("2026-03-25"), customerId: customers[8].id, vehicleId: vehicles[31].id,
+      partItems: [{ name: "Boom Cylinder Seal Kit (pair)", partNumber: "KOM-BCSK-PC210", quantity: 1, unitPrice: 520, total: 520 }, { name: "Hydraulic Oil (10gal)", partNumber: "HYD-ISO46-10G", quantity: 1, unitPrice: 180, total: 180 }],
+      laborItems: [{ description: "Boom cylinder removal", hours: 4.0, rate: 140, total: 560 }, { description: "Disassembly and reseal", hours: 3.0, rate: 140, total: 420 }],
+      subtotal: 1680, taxRate: 8, taxAmount: 134.40, totalAmount: 1814.40 },
+    { quoteNumber: "Q-2026-009", title: "Liebherr Crane - Annual Certification", status: "sent", validUntil: new Date("2026-05-01"), customerId: customers[14].id, vehicleId: vehicles[36].id,
+      partItems: [{ name: "Wire Rope Inspection Kit", partNumber: "LBH-WRIK", quantity: 1, unitPrice: 250, total: 250 }, { name: "Hydraulic Hose Set (safety critical)", partNumber: "LBH-HHS-SC", quantity: 1, unitPrice: 850, total: 850 }],
+      laborItems: [{ description: "Full crane inspection per OSHA", hours: 8.0, rate: 160, total: 1280 }, { description: "Hydraulic hose replacement", hours: 4.0, rate: 160, total: 640 }, { description: "Load test and certification", hours: 4.0, rate: 160, total: 640 }],
+      subtotal: 3660, taxRate: 8, taxAmount: 292.80, totalAmount: 3952.80 },
+    { quoteNumber: "Q-2026-010", title: "Porsche 911 - Suspension Refresh", status: "draft", validUntil: new Date("2026-04-01"), customerId: customers[3].id, vehicleId: vehicles[13].id,
+      partItems: [{ name: "Bilstein B8 Dampers (set of 4)", partNumber: "POR-BIL-B8-4", quantity: 1, unitPrice: 1800, total: 1800 }, { name: "H&R Sport Springs", partNumber: "POR-HR-SS-992", quantity: 1, unitPrice: 650, total: 650 }],
+      laborItems: [{ description: "Suspension removal and install", hours: 5.0, rate: 145, total: 725 }, { description: "4-corner alignment", hours: 1.5, rate: 145, total: 217.50 }],
+      subtotal: 3392.50, taxRate: 8, taxAmount: 271.40, totalAmount: 3663.90 },
+    { quoteNumber: "Q-2026-011", title: "Jeep Wrangler - Winch Install", status: "accepted", validUntil: new Date("2026-03-20"), customerId: customers[11].id, vehicleId: vehicles[11].id,
+      partItems: [{ name: "Warn VR EVO 10-S Winch", partNumber: "WARN-VR10S", quantity: 1, unitPrice: 850, total: 850 }, { name: "Winch Mounting Plate", partNumber: "JEEP-WMP-JL", quantity: 1, unitPrice: 220, total: 220 }],
+      laborItems: [{ description: "Winch and plate installation", hours: 3.0, rate: 110, total: 330 }, { description: "Wiring and relay setup", hours: 1.5, rate: 110, total: 165 }],
+      subtotal: 1565, taxRate: 8, taxAmount: 125.20, totalAmount: 1690.20 },
+    { quoteNumber: "Q-2026-012", title: "Fleet Oil Change Package - 10 Trucks", status: "sent", validUntil: new Date("2026-03-15"), customerId: customers[16].id, notes: "Bulk fleet pricing. Drop off in batches of 3-4.",
+      partItems: [{ name: "Synthetic Oil 5W-30 (5qt) x10", partNumber: "OIL-5W30-BULK", quantity: 10, unitPrice: 38, total: 380 }, { name: "Oil Filter Assorted x10", partNumber: "OF-ASSORTED-10", quantity: 10, unitPrice: 9.50, total: 95 }],
+      laborItems: [{ description: "Oil change service x10 vehicles", hours: 5.0, rate: 85, total: 425 }],
+      subtotal: 900, taxRate: 8, taxAmount: 72, totalAmount: 972 },
+    { quoteNumber: "Q-2026-013", title: "S780 Combine - Pre-Season Check", status: "accepted", validUntil: new Date("2026-04-15"), customerId: customers[18].id, vehicleId: vehicles[27].id,
+      partItems: [{ name: "Combine Service Kit (all filters)", partNumber: "JD-CSK-S780", quantity: 1, unitPrice: 420, total: 420 }, { name: "Sickle Blade Set", partNumber: "JD-SBS-S780", quantity: 1, unitPrice: 380, total: 380 }, { name: "Feeder Chain", partNumber: "JD-FC-S780", quantity: 1, unitPrice: 650, total: 650 }],
+      laborItems: [{ description: "Full filter service and oil change", hours: 3.0, rate: 140, total: 420 }, { description: "Sickle replacement and header setup", hours: 4.0, rate: 140, total: 560 }, { description: "Feeder chain replacement", hours: 3.0, rate: 140, total: 420 }],
+      subtotal: 2850, taxRate: 8, taxAmount: 228, totalAmount: 3078 },
+    { quoteNumber: "Q-2026-015", title: "Toyota Tacoma - Suspension Upgrade", status: "sent", validUntil: new Date("2026-04-01"), customerId: customers[19].id, vehicleId: vehicles[16].id,
+      partItems: [{ name: "Bilstein 5100 Front (pair)", partNumber: "BIL-5100-TAC-F", quantity: 1, unitPrice: 340, total: 340 }, { name: "Bilstein 5100 Rear (pair)", partNumber: "BIL-5100-TAC-R", quantity: 1, unitPrice: 280, total: 280 }, { name: "OME Leaf Springs", partNumber: "OME-LS-TAC", quantity: 1, unitPrice: 450, total: 450 }],
+      laborItems: [{ description: "Front strut replacement", hours: 2.5, rate: 110, total: 275 }, { description: "Rear shock and leaf spring install", hours: 3.0, rate: 110, total: 330 }, { description: "Alignment", hours: 1.0, rate: 110, total: 110 }],
+      subtotal: 1785, taxRate: 8, taxAmount: 142.80, totalAmount: 1927.80 },
+  ];
+
+  const quotes = [];
+  for (const q of qData) {
+    const { partItems, laborItems, customerId, vehicleId, ...fields } = q;
+    const quote = await prisma.quote.create({ data: { ...fields, userId: USER_ID, organizationId: ORG_ID, ...(customerId ? { customerId } : {}), ...(vehicleId ? { vehicleId } : {}), partItems: { create: partItems }, laborItems: { create: laborItems } } });
+    quotes.push(quote);
+  }
+  console.log(`  Created ${quotes.length} quotes`);
+
+  // -- Inventory Parts (25) --
+  console.log("\nCreating inventory parts...");
+  const invData = [
+    { name: "Synthetic Engine Oil 5W-30 (5qt)", partNumber: "OIL-5W30-5Q", category: "Fluids & Lubricants", quantity: 24, minQuantity: 10, unitCost: 28, sellPrice: 45, supplier: "AutoZone Commercial", location: "Shelf A-1" },
+    { name: "Synthetic Engine Oil 0W-20 (5qt)", partNumber: "OIL-0W20-5Q", category: "Fluids & Lubricants", quantity: 18, minQuantity: 8, unitCost: 31, sellPrice: 49, supplier: "AutoZone Commercial", location: "Shelf A-1" },
+    { name: "Heavy Duty Engine Oil 15W-40 (5gal)", partNumber: "OIL-15W40-5G", category: "Fluids & Lubricants", quantity: 8, minQuantity: 4, unitCost: 120, sellPrice: 185, supplier: "Shell Fleet", location: "Shelf A-2" },
+    { name: "Hydraulic Oil ISO 46 (55gal)", partNumber: "HYD-ISO46-55G", category: "Fluids & Lubricants", quantity: 3, minQuantity: 2, unitCost: 580, sellPrice: 850, supplier: "Shell Fleet", location: "Floor A-3" },
+    { name: "Brake Pad Set - Universal Light Vehicle", partNumber: "BP-UNI-LV", category: "Brakes", quantity: 14, minQuantity: 6, unitCost: 38, sellPrice: 68, supplier: "NAPA Auto Parts", location: "Shelf B-1" },
+    { name: "Brake Pad Set - Heavy Duty Truck", partNumber: "BP-HD-TRUCK", category: "Brakes", quantity: 6, minQuantity: 4, unitCost: 180, sellPrice: 320, supplier: "FleetPride", location: "Shelf B-2" },
+    { name: "Oil Filter - European Vehicles", partNumber: "OF-EUR-01", category: "Filters", quantity: 30, minQuantity: 10, unitCost: 6.50, sellPrice: 15.50, supplier: "Mann-Filter USA", location: "Shelf C-1" },
+    { name: "Oil Filter - Asian Vehicles", partNumber: "OF-ASN-01", category: "Filters", quantity: 25, minQuantity: 10, unitCost: 4.50, sellPrice: 12, supplier: "Mann-Filter USA", location: "Shelf C-1" },
+    { name: "Hydraulic Filter - CAT/Komatsu", partNumber: "HF-CAT-KOM", category: "Filters", quantity: 5, minQuantity: 3, unitCost: 65, sellPrice: 120, supplier: "Cat Parts Direct", location: "Shelf C-3" },
+    { name: "Spark Plug - Iridium (each)", partNumber: "SP-IRID-01", category: "Ignition", quantity: 40, minQuantity: 16, unitCost: 9.50, sellPrice: 17.50, supplier: "NGK USA", location: "Shelf D-1" },
+    { name: "Car Battery 12V 700CCA", partNumber: "BAT-12V-700CCA", category: "Electrical", quantity: 6, minQuantity: 3, unitCost: 95, sellPrice: 165, supplier: "Interstate Batteries", location: "Floor D-2" },
+    { name: "Truck Battery 12V 1000CCA", partNumber: "BAT-12V-1000CCA", category: "Electrical", quantity: 4, minQuantity: 2, unitCost: 240, sellPrice: 380, supplier: "Interstate Batteries", location: "Floor D-2" },
+    { name: "Coolant Concentrate (1gal)", partNumber: "COOL-CONC-1G", category: "Fluids & Lubricants", quantity: 15, minQuantity: 6, unitCost: 18, sellPrice: 32, supplier: "AutoZone Commercial", location: "Shelf A-2" },
+    { name: "ATF Transmission Fluid (1qt)", partNumber: "ATF-DEXVI-1Q", category: "Fluids & Lubricants", quantity: 20, minQuantity: 8, unitCost: 12, sellPrice: 22, supplier: "AutoZone Commercial", location: "Shelf A-2" },
+    { name: "Serpentine Belt - Universal", partNumber: "SB-UNI-6PK", category: "Engine", quantity: 8, minQuantity: 4, unitCost: 22, sellPrice: 42, supplier: "Gates Corp", location: "Shelf D-3" },
+    { name: "Hydraulic Hose - 1/2\" x 10ft", partNumber: "HH-12-10FT", category: "Hydraulics", quantity: 10, minQuantity: 4, unitCost: 45, sellPrice: 85, supplier: "Parker Hannifin", location: "Rack G-1" },
+    { name: "Track Shoe Bolt Kit (set of 50)", partNumber: "TSB-KIT-50", category: "Undercarriage", quantity: 4, minQuantity: 2, unitCost: 180, sellPrice: 320, supplier: "Cat Parts Direct", location: "Floor G-2" },
+    { name: "R-134a Refrigerant (30oz can)", partNumber: "R134A-30OZ", category: "HVAC", quantity: 8, minQuantity: 4, unitCost: 18, sellPrice: 32, supplier: "AutoZone Commercial", location: "Shelf H-1" },
+    { name: "Fuel Filter - Diesel Universal", partNumber: "FF-DSL-UNI", category: "Filters", quantity: 12, minQuantity: 5, unitCost: 14, sellPrice: 29, supplier: "Mann-Filter USA", location: "Shelf C-2" },
+    { name: "Air Filter - Heavy Equipment", partNumber: "AF-HE-UNI", category: "Filters", quantity: 6, minQuantity: 3, unitCost: 48, sellPrice: 85, supplier: "Cat Parts Direct", location: "Shelf C-3" },
+    { name: "Tractor PTO Shaft Guard", partNumber: "PTO-SG-UNI", category: "Safety", quantity: 3, minQuantity: 2, unitCost: 85, sellPrice: 150, supplier: "Agri Supply Co.", location: "Rack G-3" },
+    { name: "DPF Pressure Sensor", partNumber: "DPF-PS-UNI", category: "Emissions", quantity: 1, minQuantity: 3, unitCost: 65, sellPrice: 120, supplier: "Bosch USA", location: "Shelf D-5" },
+    { name: "Grease Cartridge EP2 (box of 10)", partNumber: "GREASE-EP2-10", category: "Fluids & Lubricants", quantity: 12, minQuantity: 5, unitCost: 28, sellPrice: 48, supplier: "Shell Fleet", location: "Shelf A-3" },
+    { name: "Combine Sickle Section (box of 25)", partNumber: "CSS-UNI-25", category: "Harvest Parts", quantity: 4, minQuantity: 2, unitCost: 65, sellPrice: 110, supplier: "Agri Supply Co.", location: "Rack G-4" },
+    { name: "Wiper Blade 24\" Universal", partNumber: "WB-24-UNI", category: "Body & Exterior", quantity: 12, minQuantity: 6, unitCost: 11, sellPrice: 25, supplier: "Bosch USA", location: "Shelf E-1" },
+  ];
+  const inventoryParts = await Promise.all(invData.map(p => prisma.inventoryPart.create({ data: { ...p, userId: USER_ID, organizationId: ORG_ID } })));
+  console.log(`  Created ${inventoryParts.length} inventory parts`);
+
+  // -- Fuel Logs --
+  console.log("\nCreating fuel logs...");
+  await Promise.all([
+    prisma.fuelLog.create({ data: { vehicleId: vehicles[1].id, date: new Date("2026-01-05"), mileage: 40200, gallons: 26, pricePerGallon: 3.45, totalCost: 89.70, station: "Shell - I-25 & Hampden" } }),
+    prisma.fuelLog.create({ data: { vehicleId: vehicles[1].id, date: new Date("2026-02-02"), mileage: 41500, gallons: 24, pricePerGallon: 3.48, totalCost: 83.52, station: "Costco Gas - Arvada" } }),
+    prisma.fuelLog.create({ data: { vehicleId: vehicles[17].id, date: new Date("2026-01-10"), mileage: 182000, gallons: 120, pricePerGallon: 3.85, totalCost: 462.00, station: "Pilot Travel Center - Portland" } }),
+    prisma.fuelLog.create({ data: { vehicleId: vehicles[8].id, date: new Date("2026-02-10"), mileage: 98000, gallons: 28, pricePerGallon: 3.65, totalCost: 102.20, station: "Buc-ee's - San Antonio" } }),
+    prisma.fuelLog.create({ data: { vehicleId: vehicles[22].id, date: new Date("2026-02-15"), mileage: 2700, gallons: 35, pricePerGallon: 3.78, totalCost: 132.30, station: "Co-op Fuel - Omaha" } }),
+    prisma.fuelLog.create({ data: { vehicleId: vehicles[23].id, date: new Date("2026-01-28"), mileage: 1180, gallons: 80, pricePerGallon: 3.82, totalCost: 305.60, station: "Farm Bureau Fuel - Des Moines" } }),
+    prisma.fuelLog.create({ data: { vehicleId: vehicles[15].id, date: new Date("2026-02-20"), mileage: 19500, gallons: 22, pricePerGallon: 3.55, totalCost: 78.10, station: "Speedway - Detroit" } }),
+    prisma.fuelLog.create({ data: { vehicleId: vehicles[11].id, date: new Date("2026-01-22"), mileage: 28200, gallons: 18, pricePerGallon: 3.62, totalCost: 65.16, station: "Shell - I-70 & Morrison" } }),
+  ]);
+  console.log("  Created 8 fuel logs");
+
+  // -- Reminders --
+  console.log("\nCreating reminders...");
+  await Promise.all([
+    prisma.reminder.create({ data: { vehicleId: vehicles[0].id, title: "Next Oil Change", description: "Due at 25,000 mi or March 2026", dueDate: new Date("2026-03-15"), dueMileage: 25000 } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[4].id, title: "Brake Fluid Flush", description: "BMW recommends every 2 years", dueDate: new Date("2026-06-01") } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[17].id, title: "Annual DOT Inspection", description: "Kenworth T680 - annual inspection", dueDate: new Date("2026-02-28") } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[29].id, title: "Track Tension Check", description: "CAT D6 - check after 100 hours", dueMileage: 4500 } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[22].id, title: "1000hr Service", description: "John Deere 6R 250 - scheduled service", dueMileage: 3500 } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[23].id, title: "Spring Planting Prep", description: "Fendt 942 - full check before spring", dueDate: new Date("2026-04-01") } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[18].id, title: "Volvo FH 640 - Brake Inspection", description: "Check brakes before next long-haul", dueDate: new Date("2026-03-10") } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[13].id, title: "Porsche 911 - Track Day Prep", description: "Brake pads, fluid, tire pressure check before April track day", dueDate: new Date("2026-04-10") } }),
+  ]);
+  console.log("  Created 8 reminders");
+
+  // -- Vehicle Notes --
+  console.log("\nCreating vehicle notes...");
+  await Promise.all([
+    prisma.note.create({ data: { vehicleId: vehicles[4].id, title: "Customer preference", content: "Sarah always requests OEM BMW parts. No aftermarket. She prefers to wait while service is done.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[29].id, title: "Hydraulic leak history", content: "This D6 has had recurring hydraulic leaks on the left blade cylinder. Last repaired Sept 2025. Monitor closely.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[17].id, title: "Fleet note", content: "Pacific Freight unit #3. Driver: Tom Bradley. Call dispatch at (555) 302-5566 for scheduling.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[6].id, title: "Charging note", content: "Customer reports occasional slow charging at home. Suggested checking home charger firmware. Not a vehicle issue.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[36].id, title: "Outrigger issue", content: "Right rear outrigger cylinder slow to extend. May need seal replacement at next service.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[22].id, title: "Warranty info", content: "John Deere 6R 250 still under manufacturer warranty until March 2027. Contact JD dealer for warranty claims.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[23].id, title: "Operator preference", content: "Fendt 942 - Operator prefers front linkage set to position control, not draft. Do not change settings after service.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[18].id, title: "Volvo FH 640 fleet note", content: "Pacific Freight unit #5. Long-haul Portland to Chicago route. D13TC engine with I-Shift dual clutch.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[11].id, title: "Lift kit installed", content: "2.5\" Mopar lift with Bilstein 5100 shocks installed Feb 2026. Customer plans to add 35\" tires next.", isPinned: false } }),
+  ]);
+  console.log("  Created 9 vehicle notes");
+
+  // -- Additional vehicle notes (customer preferences, reminders, history) --
+  console.log("\nCreating additional vehicle notes...");
+  const additionalNotes = await Promise.all([
+    prisma.note.create({ data: { vehicleId: vehicles[0].id, title: "Customer preference", content: "James prefers Mobil 1 full synthetic 0W-20 on every oil change. Do not substitute.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[0].id, title: "Key location", content: "Spare key in lockbox #4 (code 4412). Customer drops off after hours sometimes.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[1].id, title: "Fleet maintenance policy", content: "Summit Construction requires photo documentation of all brake and tire work. Upload to service record.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[1].id, title: "Aftermarket bed liner", content: "Spray-in bed liner installed 2023. Don't pressure wash interior of bed at close range.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[2].id, title: "Off-road use", content: "Mike uses this for hunting trips. Undercarriage gets a lot of mud - always inspect skid plates.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[3].id, title: "Municipal contract vehicle", content: "Metro City Services - invoicing via purchase order only. PO required before work starts.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[5].id, title: "Budget-conscious customer", content: "Amanda prefers aftermarket parts where safe. Always provide OEM vs aftermarket pricing options.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[5].id, title: "Check AC vents", content: "Musty smell reported last visit. Recommended cabin filter + evaporator clean at next service.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[7].id, title: "Referral source", content: "Jessica was referred by Ryan Parker. Offer referral discount on next service.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[8].id, title: "Ranch use", content: "Silverado used for cattle hauling. Check hitch and trailer plug every visit.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[9].id, title: "Commercial use", content: "Kevin uses this daily for catering. Needs quick turnaround - try to get him in/out same day.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[10].id, title: "OEM parts only", content: "David insists on genuine Audi parts. No VAG knockoffs. He'll ask to see part numbers.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[12].id, title: "Soft top care", content: "Customer has both hard and soft top. Store removed top carefully, document any scratches.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[14].id, title: "Manual transmission", content: "David is the primary driver. Wife drives occasionally but doesn't like manual - clutch wear expected.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[15].id, title: "Weekend vehicle", content: "Robert uses this mainly weekends for towing his boat. Look at trailer brakes in spring.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[16].id, title: "TRD package notes", content: "TRD Off-Road suspension - use correct alignment specs (different from base Tacoma).", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[19].id, title: "Dump body maintenance", content: "Body floor develops cracks at 150k+ mi. Inspect weld seams during every service.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[20].id, title: "Concrete mixer drum", content: "Drum drive motor is a known weak point on this unit. Monitor for sluggish rotation.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[24].id, title: "Seasonal use", content: "Massey Ferguson is the backup tractor for Prairie Farms. Heavy use spring/fall only.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[25].id, title: "Warranty active", content: "John Deere 8R 410 still under warranty until Jan 2027. Contact JD dealer for anything covered.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[27].id, title: "Harvest critical", content: "S780 combine is CRITICAL for fall harvest. Any work needs to be done before September.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[30].id, title: "D8T parts lead time", content: "CAT D8T parts often take 2-3 weeks. Order ahead. Titan Building will wait if told up front.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[32].id, title: "Undercarriage wear", content: "Track chains due for replacement around 2000hr. Currently at 1800hr — budget planning.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[35].id, title: "Metro City asset tag", content: "City asset tag #WL-014. Include in all invoices for their records.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[37].id, title: "Indoor use only", content: "Electric forklift used indoors at Ironclad warehouse. Tire compound is non-marking.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[38].id, title: "Track car", content: "Sarah takes this to HPDE track days 3-4 times a year. Always check brake pads and fluid before/after.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[38].id, title: "Dry sump oil system", content: "C8 Corvette has a dry sump system. Takes 8 quarts of 0W-40. Use GM-approved Mobil 1 only.", isPinned: false } }),
+    prisma.note.create({ data: { vehicleId: vehicles[39].id, title: "Heavy off-road use", content: "Mike takes this on trails every weekend. Always inspect skid plates, check for rock damage underneath.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[40].id, title: "Mopar parts preference", content: "Robert wants Mopar or performance-equivalent parts. No economy parts on this car.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[41].id, title: "Modified intake", content: "Customer installed aftermarket cold air intake. Note: may trigger P0171 lean code - not a fault. Recheck with stock intake if persistent.", isPinned: true } }),
+    prisma.note.create({ data: { vehicleId: vehicles[41].id, title: "Catback exhaust", content: "Invidia Q300 catback exhaust installed by customer. Stock exhaust stored at his garage.", isPinned: false } }),
+  ]);
+  console.log(`  Created ${additionalNotes.length} additional vehicle notes`);
+
+  // -- Additional reminders --
+  console.log("\nCreating additional reminders...");
+  const additionalReminders = await Promise.all([
+    prisma.reminder.create({ data: { vehicleId: vehicles[1].id, title: "Timing belt replacement", description: "Interval-based - EcoBoost timing chain inspection due", dueMileage: 60000 } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[2].id, title: "Annual inspection", description: "Minnesota state safety inspection", dueDate: new Date("2026-05-15") } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[5].id, title: "Brake fluid flush", description: "Honda recommends every 3 years", dueMileage: 72000, dueDate: new Date("2026-06-01") } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[8].id, title: "Registration renewal", description: "Texas registration expires June 2026", dueDate: new Date("2026-06-30") } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[9].id, title: "Next oil change", description: "Sprinter service B interval", dueMileage: 40000 } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[10].id, title: "Haldex service", description: "Quattro rear diff fluid change - 40K interval", dueMileage: 40000, isCompleted: true } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[11].id, title: "Front diff fluid", description: "After lift kit, recommend fluid change at 35K", dueMileage: 35000 } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[12].id, title: "Annual inspection", description: "Georgia annual safety inspection", dueDate: new Date("2026-08-22") } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[14].id, title: "DSG service interval", description: "Next DSG fluid/filter at 78K", dueMileage: 78000 } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[17].id, title: "CVT fluid check", description: "Subaru CVT recommended drain/fill", dueMileage: 30000 } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[20].id, title: "Mixer drum inspection", description: "Annual drum wear/bolt check", dueDate: new Date("2026-07-01") } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[25].id, title: "250hr service", description: "John Deere 8R scheduled interval", dueMileage: 1000 } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[28].id, title: "Pre-harvest prep", description: "X9 1100 combine full inspection before harvest", dueDate: new Date("2026-08-15") } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[30].id, title: "Blade edge replacement", description: "Check wear on cutting edge", dueMileage: 6500, isCompleted: true } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[32].id, title: "Undercarriage inspection", description: "2000hr interval for track chain inspection", dueMileage: 2000 } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[36].id, title: "OSHA crane certification", description: "Annual load test and certification due", dueDate: new Date("2026-05-01") } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[38].id, title: "Corvette annual service", description: "GM recommended annual service with performance inspection", dueDate: new Date("2027-01-22") } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[39].id, title: "Transfer case fluid", description: "Land Rover recommends 40K interval for transfer case fluid", dueMileage: 40000 } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[40].id, title: "Transmission fluid change", description: "8-speed auto - ZF recommends fluid change at 50K", dueMileage: 50000 } }),
+    prisma.reminder.create({ data: { vehicleId: vehicles[41].id, title: "Timing belt inspection", description: "FA24 engine - inspect timing chain tensioner at 60K", dueMileage: 60000 } }),
+  ]);
+  console.log(`  Created ${additionalReminders.length} additional reminders`);
+
+  // -- Vehicle Findings (observations from service) --
+  console.log("\nCreating vehicle findings...");
+  const findings = await Promise.all([
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[4].id, description: "Front brake pads at 3mm, recommend replacement within 5,000 miles", severity: "needs_work", status: "open", serviceRecordId: serviceRecords[1].id, notes: "Customer informed during 50K service. Quoted separately.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[5].id, description: "CV boot torn on driver side, grease leaking onto suspension components", severity: "urgent", status: "open", notes: "Needs attention soon - will fail CV joint if not addressed.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[1].id, description: "Slight oil seepage from valve cover gasket, monitor at next service", severity: "monitor", status: "open", serviceRecordId: serviceRecords[18].id, notes: "Not dripping yet - just damp. Re-check in 6 months.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[8].id, description: "Battery testing at 78% capacity, recommend replacement before winter", severity: "monitor", status: "open", notes: "Still starts reliably. Load test shows degraded CCA.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[2].id, description: "Tire tread below 4/32 on front tires, will need replacement soon", severity: "needs_work", status: "open", notes: "Rears still have 6/32. Recommend rotating and planning fronts soon.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[9].id, description: "Coolant appears contaminated with oil traces, consider flush and inspection", severity: "needs_work", status: "open", serviceRecordId: serviceRecords[13].id, notes: "Found during turbo job. May be related to oil cooler seal.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[11].id, description: "Death wobble symptoms reported - track bar bushings worn", severity: "urgent", status: "resolved", notes: "Customer reported highway death wobble. Replaced steering stabilizer and tie rods.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[13].id, description: "Rear tire wear uneven - alignment suspected after track use", severity: "needs_work", status: "open", serviceRecordId: serviceRecords[3].id, notes: "Right rear shows inner edge wear consistent with toe-out.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[17].id, description: "DEF tank heater intermittent - low DEF temperature warning in cold weather", severity: "monitor", status: "open", serviceRecordId: serviceRecords[4].id, notes: "Only acts up below 20F. Monitor and replace if it fails completely.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[18].id, description: "Charge air cooler pipe cracked, boost leak confirmed", severity: "urgent", status: "open", notes: "Part ordered. Causing low-power complaint from driver.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[19].id, description: "Dump body floor showing stress cracks near rear hinge", severity: "needs_work", status: "open", notes: "Will need weld repair within 10,000 miles to avoid structural failure.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[22].id, description: "Front loader pins showing excessive play, bushings worn", severity: "needs_work", status: "open", notes: "Noticeable play when boom is fully extended. Replace at next service.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[23].id, description: "A/C condenser fins damaged, reduced cooling efficiency", severity: "monitor", status: "open", notes: "Fins bent from field debris. Combing/straightening recommended.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[29].id, description: "Left blade cylinder showing slow seepage at rod seal", severity: "monitor", status: "resolved", serviceRecordId: serviceRecords[8].id, resolvedServiceRecordId: serviceRecords[8].id, notes: "Resealed during Sept 2025 hydraulic overhaul.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[31].id, description: "Boom cylinder showing 2 inch drift over 10 minutes under load", severity: "needs_work", status: "open", notes: "Cylinder reseal scheduled in unassigned work.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[32].id, description: "Track shoe bolts loosening, retorque recommended", severity: "monitor", status: "open", notes: "Checked 8 of 42 shoes - 3 required retorque. Full inspection next service.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[36].id, description: "Right rear outrigger extending slower than left", severity: "needs_work", status: "open", notes: "Likely valve or cylinder seal. Scheduled for inspection.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[24].id, description: "Overheating under heavy PTO load - radiator partially blocked with chaff", severity: "needs_work", status: "open", notes: "Cleaned out what we could. Recommend pull radiator for deep clean.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[34].id, description: "Hydraulic hose chafing against frame rail near pivot point", severity: "needs_work", status: "open", notes: "Protective sleeve worn through. Will fail if not rerouted.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[16].id, description: "Leaf spring bushings dry and cracking, suspension noise reported", severity: "needs_work", status: "open", notes: "Customer scheduled for leaf spring replacement.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[38].id, description: "Rear brake pads showing uneven wear from track use, inner pad thinner than outer", severity: "needs_work", status: "open", notes: "Likely caliper slide pins need cleaning and relubing. Recommend high-performance pads for track use.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[39].id, description: "Front left CV boot starting to crack, no grease leaking yet", severity: "monitor", status: "open", notes: "Off-road flex likely contributing. Replace before it tears and contaminates the joint.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[40].id, description: "Rear tires wearing faster on inside edge, alignment needed", severity: "needs_work", status: "open", notes: "Aggressive driving habit. Rear camber may be out of spec after pothole.", imageUrls: [] } }),
+    prisma.vehicleFinding.create({ data: { vehicleId: vehicles[41].id, description: "Exhaust manifold stud #3 showing early surface corrosion", severity: "monitor", status: "open", notes: "Not leaking yet. Anti-seize on next service. Watch for exhaust tick on cold start.", imageUrls: [] } }),
+  ]);
+  console.log(`  Created ${findings.length} vehicle findings`);
+
+  // -- Repeat service history for predicted maintenance --
+  // Pick 6 vehicles, give each 3-4 completed service records with increasing mileage
+  // spanning 2-3 years so the predictedMaintenanceActions has regression data.
+  console.log("\nCreating repeat service history for predicted maintenance...");
+  type HistEntry = { offsetDays: number; mileage: number; title: string; desc: string; type: string; techName: string };
+  const predMaintHistory: { vehicleId: string; entries: HistEntry[] }[] = [
+    {
+      vehicleId: vehicles[1].id, // Ford F-150 Lariat white (Summit Construction, current 42300)
+      entries: [
+        { offsetDays: -820, mileage: 10500, title: "F-150 - 10K Oil Change",       desc: "First service. Synthetic 5W-30, oil filter, tire rotation.",     type: "maintenance", techName: "Jake Wilson" },
+        { offsetDays: -540, mileage: 20200, title: "F-150 - 20K Service",          desc: "Oil change, cabin filter, brake inspection, tire rotation.",     type: "maintenance", techName: "Jake Wilson" },
+        { offsetDays: -280, mileage: 30100, title: "F-150 - 30K Major Service",    desc: "Full fluid service, spark plugs, transfer case fluid.",          type: "maintenance", techName: "Chris Taylor" },
+        { offsetDays: -90,  mileage: 40800, title: "F-150 - 40K Service",          desc: "Oil change, brake fluid flush, differential service.",           type: "maintenance", techName: "Jake Wilson" },
+      ],
+    },
+    {
+      vehicleId: vehicles[4].id, // BMW 330i xDrive (Sarah Coleman, current 55200)
+      entries: [
+        { offsetDays: -900, mileage: 22000, title: "BMW 330i - 22K Oil Service",   desc: "OEM oil, filter, inspection. OEM parts only per customer.",      type: "maintenance", techName: "Chris Taylor" },
+        { offsetDays: -600, mileage: 32500, title: "BMW 330i - 32K Service",       desc: "Oil, cabin filter, brake inspection. All OEM BMW parts.",        type: "maintenance", techName: "Chris Taylor" },
+        { offsetDays: -300, mileage: 43800, title: "BMW 330i - 43K Service",       desc: "Oil change, tire rotation, 4-wheel alignment.",                  type: "maintenance", techName: "Chris Taylor" },
+      ],
+    },
+    {
+      vehicleId: vehicles[5].id, // Honda Civic Sport (Amanda Foster, current 67800)
+      entries: [
+        { offsetDays: -960, mileage: 28500, title: "Civic - 28K Oil Change",       desc: "Synthetic oil, filter, tire rotation.",                          type: "maintenance", techName: "Jake Wilson" },
+        { offsetDays: -680, mileage: 39200, title: "Civic - 39K Service",          desc: "Oil change, cabin filter, engine air filter.",                   type: "maintenance", techName: "Jake Wilson" },
+        { offsetDays: -360, mileage: 52000, title: "Civic - 52K Major Service",    desc: "Oil, coolant flush, transmission fluid, belt inspection.",       type: "maintenance", techName: "Sofia Andersen" },
+        { offsetDays: -110, mileage: 64500, title: "Civic - 64K Service",          desc: "Oil change, brake pad inspection, alignment check.",             type: "maintenance", techName: "Jake Wilson" },
+      ],
+    },
+    {
+      vehicleId: vehicles[8].id, // Silverado 2500HD (Lisa Martinez, current 98400)
+      entries: [
+        { offsetDays: -850, mileage: 62000, title: "Silverado - 62K Oil Service",  desc: "Duramax diesel oil, fuel filters, DEF top-off.",                 type: "maintenance", techName: "Marcus Reed" },
+        { offsetDays: -560, mileage: 75500, title: "Silverado - 75K Service",      desc: "Oil, fuel filters, DEF fluid, tire rotation.",                   type: "maintenance", techName: "Marcus Reed" },
+        { offsetDays: -290, mileage: 87200, title: "Silverado - 87K Major Service", desc: "Oil, coolant flush, transmission service, DPF regen.",          type: "maintenance", techName: "Marcus Reed" },
+        { offsetDays: -60,  mileage: 96100, title: "Silverado - 96K Service",      desc: "Oil change, fuel filter, brake inspection.",                     type: "maintenance", techName: "Jake Wilson" },
+      ],
+    },
+    {
+      vehicleId: vehicles[17].id, // Kenworth T680 (Pacific Freight, current 185000)
+      entries: [
+        { offsetDays: -780, mileage: 92000,  title: "Kenworth - 92K PM",           desc: "Full PM - oil, fuel filters, air filters, inspection.",          type: "maintenance", techName: "Marcus Reed" },
+        { offsetDays: -520, mileage: 118000, title: "Kenworth - 118K PM",          desc: "Engine oil, all filters, DEF, grease service.",                  type: "maintenance", techName: "Marcus Reed" },
+        { offsetDays: -260, mileage: 148000, title: "Kenworth - 148K PM",          desc: "Engine oil, all filters, coolant check, brake adjust.",          type: "maintenance", techName: "Marcus Reed" },
+      ],
+    },
+    {
+      vehicleId: vehicles[16].id, // Tacoma TRD Off-Road (Lisa Martinez, current 31200)
+      entries: [
+        { offsetDays: -790, mileage: 8500,  title: "Tacoma - 8K Oil Change",       desc: "First service. Synthetic oil, filter, tire rotation.",           type: "maintenance", techName: "Jake Wilson" },
+        { offsetDays: -520, mileage: 15800, title: "Tacoma - 15K Service",         desc: "Oil change, cabin filter, brake inspection.",                    type: "maintenance", techName: "Jake Wilson" },
+        { offsetDays: -250, mileage: 24200, title: "Tacoma - 24K Service",         desc: "Oil, tire rotation, differential fluid check.",                  type: "maintenance", techName: "Erik Haugen" },
+      ],
+    },
+  ];
+
+  const today = new Date("2026-04-05");
+  const predMaintRecords = [];
+  for (const hist of predMaintHistory) {
+    for (const entry of hist.entries) {
+      const serviceDate = new Date(today);
+      serviceDate.setDate(serviceDate.getDate() + entry.offsetDays);
+      const rec = await prisma.serviceRecord.create({
+        data: {
+          vehicleId: hist.vehicleId,
+          title: entry.title,
+          description: entry.desc,
+          type: entry.type,
+          status: "completed",
+          serviceDate,
+          mileage: entry.mileage,
+          techName: entry.techName,
+          shopName: "Egeland Auto",
+          subtotal: 180,
+          taxRate: 8,
+          taxAmount: 14.40,
+          totalAmount: 194.40,
+          cost: 194.40,
+          partItems: { create: [{ name: "Synthetic Oil (5qt)", partNumber: "OIL-SYN-5Q", quantity: 1, unitPrice: 45, total: 45 }, { name: "Oil Filter", partNumber: "OF-UNI", quantity: 1, unitPrice: 12, total: 12 }] },
+          laborItems: { create: [{ description: "Service and inspection", hours: 1.2, rate: 102.50, total: 123 }] },
+        },
+      });
+      predMaintRecords.push(rec);
+    }
+  }
+  console.log(`  Created ${predMaintRecords.length} historical service records across ${predMaintHistory.length} vehicles`);
+
+  // Enable predicted maintenance feature setting
+  console.log("\nEnabling predicted maintenance feature...");
+  await Promise.all([
+    prisma.appSetting.upsert({
+      where: { organizationId_key: { organizationId: ORG_ID, key: "maintenance.enabled" } },
+      create: { organizationId: ORG_ID, key: "maintenance.enabled", value: "true", userId: USER_ID },
+      update: { value: "true" },
+    }),
+    prisma.appSetting.upsert({
+      where: { organizationId_key: { organizationId: ORG_ID, key: "maintenance.serviceInterval" } },
+      create: { organizationId: ORG_ID, key: "maintenance.serviceInterval", value: "15000", userId: USER_ID },
+      update: { value: "15000" },
+    }),
+    prisma.appSetting.upsert({
+      where: { organizationId_key: { organizationId: ORG_ID, key: "maintenance.approachingThreshold" } },
+      create: { organizationId: ORG_ID, key: "maintenance.approachingThreshold", value: "1000", userId: USER_ID },
+      update: { value: "1000" },
+    }),
+  ]);
+  console.log("  Enabled predicted maintenance (interval: 15,000 mi, approaching: 1,000 mi)");
+
+  // -- SMS Messages --
+  console.log("\nCreating SMS messages...");
+  const shopNumber = "+15551234567";
+  await Promise.all([
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15551002000", body: "Hi Summit Construction, your Ford F-150 (CO-4455) transmission flush is complete. Total: $689.04. Ready for pickup.", status: "delivered", organizationId: ORG_ID, customerId: customers[0].id } }),
+    prisma.smsMessage.create({ data: { direction: "inbound", fromNumber: "+15551002000", toNumber: shopNumber, body: "Thanks! We'll send someone before 4pm. Can you look at the D6 bulldozer next week? Blade cylinder is leaking again.", status: "received", organizationId: ORG_ID, customerId: customers[0].id, createdAt: new Date(Date.now() - 3500000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15551002000", body: "Absolutely, we can fit the D6 in on Tuesday. We'll order the seal kit ahead of time.", status: "delivered", organizationId: ORG_ID, customerId: customers[0].id, createdAt: new Date(Date.now() - 3400000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15552013344", body: "Hi James, your Toyota Camry is due for its 30K service. We have Thursday or Friday availability.", status: "delivered", organizationId: ORG_ID, customerId: customers[1].id, createdAt: new Date(Date.now() - 86400000) } }),
+    prisma.smsMessage.create({ data: { direction: "inbound", fromNumber: "+15552013344", toNumber: shopNumber, body: "Thursday morning works. Synthetic oil like last time please.", status: "received", organizationId: ORG_ID, customerId: customers[1].id, createdAt: new Date(Date.now() - 82800000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15552013344", body: "Thursday 8am confirmed. Full synthetic 0W-20 as always. See you then!", status: "delivered", organizationId: ORG_ID, customerId: customers[1].id, createdAt: new Date(Date.now() - 82000000) } }),
+    prisma.smsMessage.create({ data: { direction: "inbound", fromNumber: "+15553025566", toNumber: shopNumber, body: "Our Volvo FH 640 (unit #5) is showing a check engine light. Can you get it in ASAP?", status: "received", organizationId: ORG_ID, customerId: customers[2].id, createdAt: new Date(Date.now() - 172800000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15553025566", body: "We can take the Volvo FH 640 first thing tomorrow. Have the driver bring it in by 7am.", status: "delivered", organizationId: ORG_ID, customerId: customers[2].id, createdAt: new Date(Date.now() - 172000000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15553025566", body: "Update on the Volvo FH 640: DOT inspection in progress. Minor issues with marker lights and wipers. Done by 3pm.", status: "delivered", organizationId: ORG_ID, customerId: customers[2].id, createdAt: new Date(Date.now() - 86400000) } }),
+    prisma.smsMessage.create({ data: { direction: "inbound", fromNumber: "+15552101122", toNumber: shopNumber, body: "Our John Deere 6R is running rough and hydraulics feel sluggish. Planting season starts in 3 weeks!", status: "received", organizationId: ORG_ID, customerId: customers[10].id, createdAt: new Date(Date.now() - 432000000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15552101122", body: "Bring the John Deere 6R in this week, we'll bump it to priority. Likely needs 500hr service + hydraulic analysis.", status: "delivered", organizationId: ORG_ID, customerId: customers[10].id, createdAt: new Date(Date.now() - 428400000) } }),
+    prisma.smsMessage.create({ data: { direction: "inbound", fromNumber: "+15552101122", toNumber: shopNumber, body: "Great. The Massey Ferguson 8S has low hydraulic pressure too. Can you look at both?", status: "received", organizationId: ORG_ID, customerId: customers[10].id, createdAt: new Date(Date.now() - 424800000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15552101122", body: "Bring both Wednesday. JD done same day, Massey Thursday. Both ready before planting.", status: "delivered", organizationId: ORG_ID, customerId: customers[10].id, createdAt: new Date(Date.now() - 421200000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15557151122", body: "Kevin, your Sprinter turbo has excessive shaft play causing black smoke. Replacement: $1,280 + labor. Proceed?", status: "delivered", organizationId: ORG_ID, customerId: customers[15].id, createdAt: new Date(Date.now() - 345600000) } }),
+    prisma.smsMessage.create({ data: { direction: "inbound", fromNumber: "+15557151122", toNumber: shopNumber, body: "Ouch. Yeah go ahead, I need the van for catering events next week.", status: "received", organizationId: ORG_ID, customerId: customers[15].id, createdAt: new Date(Date.now() - 342000000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15557151122", body: "Parts arrive Thursday, done Friday afternoon. Total: $2,243.70 including oil change.", status: "delivered", organizationId: ORG_ID, customerId: customers[15].id, createdAt: new Date(Date.now() - 338400000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15559087788", body: "Ridgeline, the Komatsu PC210 track chains are done. Both sides + new sprockets. Total: $9,309.60. Ready for transport.", status: "delivered", organizationId: ORG_ID, customerId: customers[8].id, createdAt: new Date(Date.now() - 604800000) } }),
+    prisma.smsMessage.create({ data: { direction: "inbound", fromNumber: "+15559087788", toNumber: shopNumber, body: "Excellent work. Sending the lowboy tomorrow. Also sending the Volvo EC220E for 1000hr service.", status: "received", organizationId: ORG_ID, customerId: customers[8].id, createdAt: new Date(Date.now() - 601200000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15559087788", body: "We have Volvo EC220E filters in stock. 1-2 day turnaround once it arrives.", status: "delivered", organizationId: ORG_ID, customerId: customers[8].id, createdAt: new Date(Date.now() - 597600000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15554037788", body: "Hi Sarah, your Porsche 911 annual service is complete. Total: $832.14. All OEM parts as always.", status: "delivered", organizationId: ORG_ID, customerId: customers[3].id, createdAt: new Date(Date.now() - 259200000) } }),
+    prisma.smsMessage.create({ data: { direction: "inbound", fromNumber: "+15554037788", toNumber: shopNumber, body: "Perfect, thank you. I also want to get a quote on the BMW brake job with OEM rotors.", status: "received", organizationId: ORG_ID, customerId: customers[3].id, createdAt: new Date(Date.now() - 255600000) } }),
+    prisma.smsMessage.create({ data: { direction: "outbound", fromNumber: shopNumber, toNumber: "+15554037788", body: "Quote sent to your email - $1,711.80 for the full brake overhaul with OEM rotors and pads. Valid until March 15.", status: "delivered", organizationId: ORG_ID, customerId: customers[3].id, createdAt: new Date(Date.now() - 254000000) } }),
+  ]);
+  console.log("  Created 22 SMS messages");
+
+  // -- Technicians & Work Board --
+  console.log("\nCreating technicians...");
+  // Clean up existing workboard data
+  await prisma.technician.deleteMany({ where: { organizationId: ORG_ID } });
+
+  const technicians = await Promise.all([
+    prisma.technician.create({ data: { name: "Jake Wilson", color: "#3b82f6", sortOrder: 0, organizationId: ORG_ID } }),
+    prisma.technician.create({ data: { name: "Chris Taylor", color: "#22c55e", sortOrder: 1, organizationId: ORG_ID } }),
+    prisma.technician.create({ data: { name: "Marcus Reed", color: "#f59e0b", sortOrder: 2, organizationId: ORG_ID } }),
+    prisma.technician.create({ data: { name: "Sofia Andersen", color: "#ec4899", sortOrder: 3, organizationId: ORG_ID } }),
+    prisma.technician.create({ data: { name: "Erik Haugen", color: "#8b5cf6", sortOrder: 4, organizationId: ORG_ID } }),
+    prisma.technician.create({ data: { name: "Lars Johansen", color: "#06b6d4", sortOrder: 5, organizationId: ORG_ID } }),
+    prisma.technician.create({ data: { name: "Nina Berglund", color: "#f97316", sortOrder: 6, organizationId: ORG_ID } }),
+    prisma.technician.create({ data: { name: "Tom Bradley", color: "#14b8a6", sortOrder: 7, organizationId: ORG_ID } }),
+    prisma.technician.create({ data: { name: "Kari Moen", color: "#a855f7", sortOrder: 8, organizationId: ORG_ID } }),
+    prisma.technician.create({ data: { name: "Daniel Eriksen", color: "#ef4444", sortOrder: 9, organizationId: ORG_ID } }),
+    // Demo Owner (index 10) — linked to the demo user so dashboard "My Active Jobs" populates
+    prisma.technician.create({ data: { name: "Demo Owner", color: "#0ea5e9", sortOrder: 10, organizationId: ORG_ID, userId: USER_ID } }),
+  ]);
+  console.log(`  Created ${technicians.length} technicians`);
+
+  // Board assignments - spread across the current week
+  console.log("\nCreating board assignments...");
+  // Anchor to 2026-03-09 (Monday) so the work board day view looks great
+  const monday = new Date("2026-03-09T00:00:00");
+  const day = (offset: number) => {
+    const d = new Date(monday);
+    d.setDate(d.getDate() + offset);
+    return d;
+  };
+  // Helper: create a datetime on a given day offset at a specific hour:minute
+  const dt = (dayOffset: number, hour: number, minute = 0) => {
+    const d = new Date(monday);
+    d.setDate(d.getDate() + dayOffset);
+    d.setHours(hour, minute, 0, 0);
+    return d;
+  };
+
+  // Helper to create service record with parts + labor
+  const sr = (base: Record<string, unknown>, parts: { name: string; partNumber: string; quantity: number; unitPrice: number; total: number }[], labor: { description: string; hours: number; rate: number; total: number }[], notes?: string) => {
+    const subtotal = parts.reduce((s, p) => s + p.total, 0) + labor.reduce((s, l) => s + l.total, 0);
+    const taxAmount = Math.round(subtotal * 0.08 * 100) / 100;
+    return prisma.serviceRecord.create({ data: { ...base, subtotal, taxRate: 8, taxAmount, totalAmount: subtotal + taxAmount, cost: subtotal + taxAmount, diagnosticNotes: notes || null, partItems: { create: parts }, laborItems: { create: labor } } as never });
+  };
+
+  // Create service records for the board (assigned ones)
+  const boardServiceRecords = await Promise.all([
+    // 0: Honda Civic AC
+    sr({ vehicleId: vehicles[5].id, title: "Honda Civic - AC Compressor", description: "AC not blowing cold. Compressor clutch not engaging.", type: "repair", status: "pending", serviceDate: day(0), mileage: 45200, techName: "Jake Wilson", shopName: "Egeland Auto" },
+      [{ name: "AC Compressor Assembly", partNumber: "HON-ACC-10G", quantity: 1, unitPrice: 480, total: 480 }, { name: "Refrigerant R-134a (2 cans)", partNumber: "REF-134A-2", quantity: 1, unitPrice: 45, total: 45 }],
+      [{ description: "AC compressor removal and replacement", hours: 2.5, rate: 110, total: 275 }, { description: "System evacuate, recharge and leak test", hours: 1.0, rate: 110, total: 110 }],
+      "Compressor clutch not engaging. Checked voltage at clutch connector - 12V present. Clutch coil resistance out of spec. Replace compressor assembly."),
+    // 1: Audi A4 timing belt
+    sr({ vehicleId: vehicles[10].id, title: "Audi A4 - Timing Belt Service", description: "Scheduled timing belt and water pump replacement at 60K miles.", type: "maintenance", status: "scheduled", serviceDate: day(0), mileage: 60000, techName: "Chris Taylor", shopName: "Egeland Auto" },
+      [{ name: "Timing Belt Kit w/ Water Pump", partNumber: "AUD-TBK-A4", quantity: 1, unitPrice: 385, total: 385 }, { name: "Thermostat Assembly", partNumber: "AUD-TH-A4", quantity: 1, unitPrice: 65, total: 65 }, { name: "Coolant G13 (5L)", partNumber: "AUD-COOL-G13", quantity: 1, unitPrice: 42, total: 42 }],
+      [{ description: "Timing belt and water pump replacement", hours: 4.5, rate: 120, total: 540 }, { description: "Coolant flush and bleed", hours: 1.0, rate: 120, total: 120 }]),
+    // 2: Tesla Model Y suspension
+    sr({ vehicleId: vehicles[7].id, title: "Tesla Model Y - Suspension Noise", description: "Customer reports clunking from front left over bumps.", type: "repair", status: "pending", serviceDate: day(1), mileage: 32000, techName: "Sofia Andersen", shopName: "Egeland Auto" },
+      [{ name: "Front Lower Control Arm (L)", partNumber: "TES-LCA-MY-L", quantity: 1, unitPrice: 290, total: 290 }],
+      [{ description: "Diagnose suspension noise", hours: 0.5, rate: 110, total: 55 }, { description: "Control arm replacement and alignment", hours: 2.0, rate: 110, total: 220 }],
+      "Play found in front left lower control arm ball joint. No other issues found. Recommend alignment after replacement."),
+    // 3: Silverado 60K
+    sr({ vehicleId: vehicles[8].id, title: "Silverado - 60K Service", description: "60,000 mile major service. Transmission fluid, spark plugs, coolant flush.", type: "maintenance", status: "scheduled", serviceDate: day(1), mileage: 60000, techName: "Jake Wilson", shopName: "Egeland Auto" },
+      [{ name: "ATF Dexron VI (12qt)", partNumber: "GM-ATF-DEX6", quantity: 1, unitPrice: 120, total: 120 }, { name: "Spark Plugs ACDelco (8)", partNumber: "ACD-SP-53", quantity: 8, unitPrice: 12, total: 96 }, { name: "Coolant Dex-Cool (2gal)", partNumber: "GM-COOL-DC", quantity: 1, unitPrice: 38, total: 38 }],
+      [{ description: "Transmission fluid exchange", hours: 1.5, rate: 110, total: 165 }, { description: "Spark plug replacement (V8)", hours: 1.5, rate: 110, total: 165 }, { description: "Coolant flush and refill", hours: 1.0, rate: 110, total: 110 }]),
+    // 4: Tacoma leaf springs
+    sr({ vehicleId: vehicles[16].id, title: "Toyota Tacoma - Leaf Spring Replacement", description: "Rear leaf springs sagging. Replace with upgraded OEM springs.", type: "repair", status: "pending", serviceDate: day(1), mileage: 55000, techName: "Erik Haugen", shopName: "Egeland Auto" },
+      [{ name: "Rear Leaf Spring Pack (pair)", partNumber: "TOY-LS-TAC", quantity: 1, unitPrice: 520, total: 520 }, { name: "U-Bolt Kit", partNumber: "TOY-UB-TAC", quantity: 1, unitPrice: 85, total: 85 }],
+      [{ description: "Leaf spring removal and installation (both sides)", hours: 3.5, rate: 110, total: 385 }]),
+    // 5: Bobcat S650 annual
+    sr({ vehicleId: vehicles[32].id, title: "Bobcat S650 - Annual Service", description: "Full annual service. Engine oil, hydraulic filters, drive belt.", type: "maintenance", status: "scheduled", serviceDate: day(2), mileage: 2200, techName: "Marcus Reed", shopName: "Egeland Auto" },
+      [{ name: "Engine Oil 15W-40 (2gal)", partNumber: "BOB-15W40-2G", quantity: 1, unitPrice: 68, total: 68 }, { name: "Oil Filter", partNumber: "BOB-OF-S650", quantity: 1, unitPrice: 22, total: 22 }, { name: "Hydraulic Filter Set", partNumber: "BOB-HFS-S650", quantity: 1, unitPrice: 95, total: 95 }, { name: "Drive Belt", partNumber: "BOB-DB-S650", quantity: 1, unitPrice: 48, total: 48 }],
+      [{ description: "Engine oil and filter change", hours: 1.0, rate: 130, total: 130 }, { description: "Hydraulic filter replacement", hours: 1.0, rate: 130, total: 130 }, { description: "Drive belt inspection and replacement", hours: 0.5, rate: 130, total: 65 }]),
+    // 6: CAT 745 transmission rebuild
+    sr({ vehicleId: vehicles[34].id, title: "CAT 745 - Transmission Rebuild", description: "Slipping in 3rd gear under load. Full transmission rebuild.", type: "repair", status: "in_progress", serviceDate: day(0), mileage: 8500, techName: "Marcus Reed", shopName: "Egeland Auto" },
+      [{ name: "Transmission Rebuild Kit", partNumber: "CAT-TRK-745", quantity: 1, unitPrice: 4200, total: 4200 }, { name: "Torque Converter", partNumber: "CAT-TC-745", quantity: 1, unitPrice: 2800, total: 2800 }, { name: "Transmission Oil (20gal)", partNumber: "CAT-TO-20G", quantity: 1, unitPrice: 480, total: 480 }],
+      [{ description: "Transmission removal", hours: 8.0, rate: 150, total: 1200 }, { description: "Rebuild and reassembly", hours: 16.0, rate: 150, total: 2400 }, { description: "Installation and calibration", hours: 6.0, rate: 150, total: 900 }],
+      "3rd gear clutch pack worn beyond spec. Torque converter showing excessive slip. Full rebuild recommended over clutch pack only due to hours on unit."),
+    // 7: F-150 exhaust
+    sr({ vehicleId: vehicles[3].id, title: "Ford F-150 - Exhaust Leak Repair", description: "Exhaust manifold gasket leak. Replace gasket and inspect cat.", type: "repair", status: "pending", serviceDate: day(2), mileage: 52000, techName: "Jake Wilson", shopName: "Egeland Auto" },
+      [{ name: "Exhaust Manifold Gasket Set", partNumber: "FORD-EMG-5L", quantity: 1, unitPrice: 85, total: 85 }, { name: "Exhaust Manifold Bolts (set)", partNumber: "FORD-EMB-5L", quantity: 1, unitPrice: 42, total: 42 }],
+      [{ description: "Manifold removal and gasket replacement", hours: 2.5, rate: 110, total: 275 }, { description: "Cat converter inspection", hours: 0.5, rate: 110, total: 55 }]),
+    // 8: Ram transfer case
+    sr({ vehicleId: vehicles[16].id, title: "Ram 1500 - Transfer Case Service", description: "Transfer case fluid change. Customer reports 4WD engagement delay.", type: "maintenance", status: "pending", serviceDate: day(3), mileage: 45000, techName: "Sofia Andersen", shopName: "Egeland Auto" },
+      [{ name: "Transfer Case Fluid (2qt)", partNumber: "RAM-TCF-2Q", quantity: 1, unitPrice: 48, total: 48 }, { name: "Transfer Case Drain Plug Gasket", partNumber: "RAM-DPG-TC", quantity: 1, unitPrice: 8, total: 8 }],
+      [{ description: "Transfer case fluid drain and refill", hours: 1.0, rate: 110, total: 110 }, { description: "4WD engagement test and inspection", hours: 0.5, rate: 110, total: 55 }],
+      "Fluid was dark and slightly burnt. Delay was from low fluid level. Refilled and tested - engages promptly now. Monitor."),
+    // 9: Combine pre-season
+    sr({ vehicleId: vehicles[27].id, title: "Combine Harvester - Pre-Season Prep", description: "Full pre-season inspection. Header, feeder chain, sieve adjustments, all fluids.", type: "maintenance", status: "scheduled", serviceDate: day(4), mileage: 2800, techName: "Marcus Reed", shopName: "Egeland Auto" },
+      [{ name: "Engine Oil 15W-40 (5gal)", partNumber: "CMB-15W40-5G", quantity: 1, unitPrice: 185, total: 185 }, { name: "Hydraulic Filter Set", partNumber: "CMB-HFS", quantity: 1, unitPrice: 110, total: 110 }, { name: "Feeder Chain", partNumber: "CMB-FC-1", quantity: 1, unitPrice: 380, total: 380 }, { name: "Sieve Section (2)", partNumber: "CMB-SS-2", quantity: 2, unitPrice: 145, total: 290 }],
+      [{ description: "Engine oil and filter change", hours: 1.5, rate: 140, total: 210 }, { description: "Feeder chain replacement", hours: 2.5, rate: 140, total: 350 }, { description: "Sieve adjustment and replacement", hours: 2.0, rate: 140, total: 280 }, { description: "Full system inspection and grease", hours: 2.0, rate: 140, total: 280 }]),
+    // 10: Jeep axle seal
+    sr({ vehicleId: vehicles[12].id, title: "Jeep Wrangler - Axle Seal Replacement", description: "Rear axle seal leaking. Replace seals and check diff fluid.", type: "repair", status: "pending", serviceDate: day(4), mileage: 62000, techName: "Chris Taylor", shopName: "Egeland Auto" },
+      [{ name: "Rear Axle Seal Kit", partNumber: "JEEP-ASK-JL", quantity: 1, unitPrice: 65, total: 65 }, { name: "Differential Fluid 75W-140 (2.5qt)", partNumber: "JEEP-DF-75W", quantity: 1, unitPrice: 52, total: 52 }],
+      [{ description: "Axle seal replacement (both sides)", hours: 2.5, rate: 110, total: 275 }, { description: "Differential fluid top-off and inspection", hours: 0.5, rate: 110, total: 55 }]),
+    // 11: Ram oil change (Lars)
+    sr({ vehicleId: vehicles[15].id, title: "Ram 1500 - Oil Change & Inspection", description: "Regular 10K mile service. Check brakes and tires.", type: "maintenance", status: "scheduled", serviceDate: day(0), mileage: 30000, techName: "Lars Johansen", shopName: "Egeland Auto" },
+      [{ name: "Engine Oil 5W-20 (7qt)", partNumber: "RAM-5W20-7Q", quantity: 1, unitPrice: 52, total: 52 }, { name: "Oil Filter", partNumber: "RAM-OF-HEMI", quantity: 1, unitPrice: 14, total: 14 }],
+      [{ description: "Oil and filter change", hours: 0.5, rate: 110, total: 55 }, { description: "Brake and tire inspection", hours: 0.5, rate: 110, total: 55 }]),
+    // 12: F-150 wheel bearing (Nina)
+    sr({ vehicleId: vehicles[2].id, title: "Ford F-150 - Wheel Bearing", description: "Front left wheel bearing noise. Replace bearing assembly.", type: "repair", status: "pending", serviceDate: day(1), mileage: 68000, techName: "Nina Berglund", shopName: "Egeland Auto" },
+      [{ name: "Front Wheel Bearing Hub Assembly", partNumber: "FORD-WBH-F150", quantity: 1, unitPrice: 285, total: 285 }],
+      [{ description: "Wheel bearing hub replacement", hours: 2.0, rate: 110, total: 220 }, { description: "Road test and verify", hours: 0.5, rate: 110, total: 55 }],
+      "Confirmed bearing noise from front left. Bearing has play when checked on lift. Right side OK for now."),
+    // 13: Concrete mixer (Tom)
+    sr({ vehicleId: vehicles[20].id, title: "Concrete Mixer - Drum Motor Service", description: "Hydraulic motor service for drum rotation. Sluggish under load.", type: "repair", status: "in_progress", serviceDate: day(0), mileage: 75000, techName: "Tom Bradley", shopName: "Egeland Auto" },
+      [{ name: "Hydraulic Motor Seal Kit", partNumber: "MIX-HMSK", quantity: 1, unitPrice: 280, total: 280 }, { name: "Hydraulic Oil ISO 46 (10gal)", partNumber: "HYD-ISO46-10G", quantity: 1, unitPrice: 180, total: 180 }, { name: "Hydraulic Hose Assembly", partNumber: "MIX-HHA-12", quantity: 2, unitPrice: 95, total: 190 }],
+      [{ description: "Hydraulic motor removal and reseal", hours: 4.0, rate: 140, total: 560 }, { description: "Hose replacement and system flush", hours: 2.0, rate: 140, total: 280 }, { description: "Load test drum rotation", hours: 1.0, rate: 140, total: 140 }]),
+    // 14: Tractor PTO (Kari)
+    sr({ vehicleId: vehicles[25].id, title: "Tractor - PTO Clutch Adjustment", description: "PTO slipping under heavy load. Adjust clutch pack and inspect.", type: "repair", status: "pending", serviceDate: day(2), mileage: 3200, techName: "Kari Moen", shopName: "Egeland Auto" },
+      [{ name: "PTO Clutch Friction Disc Set", partNumber: "TRC-PTO-FDS", quantity: 1, unitPrice: 165, total: 165 }],
+      [{ description: "PTO clutch pack inspection and adjustment", hours: 2.0, rate: 130, total: 260 }],
+      "Clutch pack within adjustment range. Adjusted gap to spec. If slipping persists, full clutch pack replacement needed."),
+    // 15: Dump truck tailgate (Daniel)
+    sr({ vehicleId: vehicles[21].id, title: "Dump Truck - Tailgate Cylinder", description: "Tailgate cylinder leaking. Reseal and test.", type: "repair", status: "scheduled", serviceDate: day(1), mileage: 120000, techName: "Daniel Eriksen", shopName: "Egeland Auto" },
+      [{ name: "Tailgate Cylinder Seal Kit", partNumber: "DMP-TCSK", quantity: 1, unitPrice: 145, total: 145 }, { name: "Hydraulic Oil ISO 46 (5gal)", partNumber: "HYD-ISO46-5G", quantity: 1, unitPrice: 95, total: 95 }],
+      [{ description: "Cylinder removal and reseal", hours: 3.0, rate: 130, total: 390 }, { description: "Reinstall and cycle test", hours: 1.0, rate: 130, total: 130 }]),
+    // 16: Forklift mast chain (Lars)
+    sr({ vehicleId: vehicles[37].id, title: "Forklift - Mast Chain Replace", description: "Mast chain stretched beyond spec. Replace chains and adjust.", type: "repair", status: "pending", serviceDate: day(3), mileage: 5500, techName: "Lars Johansen", shopName: "Egeland Auto" },
+      [{ name: "Mast Chain (pair)", partNumber: "FRK-MC-PAIR", quantity: 1, unitPrice: 420, total: 420 }, { name: "Chain Anchor Pins", partNumber: "FRK-CAP-4", quantity: 1, unitPrice: 35, total: 35 }],
+      [{ description: "Mast chain removal and replacement", hours: 2.5, rate: 120, total: 300 }, { description: "Chain tension adjustment and mast alignment", hours: 1.0, rate: 120, total: 120 }]),
+    // 17: VW Golf clutch (Nina)
+    sr({ vehicleId: vehicles[14].id, title: "VW Golf GTI - Clutch Replace", description: "Clutch slipping at high RPM. Replace disc, pressure plate, throwout bearing.", type: "repair", status: "scheduled", serviceDate: day(2), mileage: 72000, techName: "Nina Berglund", shopName: "Egeland Auto" },
+      [{ name: "Clutch Kit (disc, plate, bearing)", partNumber: "VW-CK-GTI", quantity: 1, unitPrice: 480, total: 480 }, { name: "Flywheel Bolts (set)", partNumber: "VW-FWB-GTI", quantity: 1, unitPrice: 32, total: 32 }],
+      [{ description: "Transmission removal", hours: 3.5, rate: 120, total: 420 }, { description: "Clutch replacement and installation", hours: 2.5, rate: 120, total: 300 }, { description: "Bleed hydraulic clutch and road test", hours: 1.0, rate: 120, total: 120 }]),
+    // 18: Combine header knives (Tom)
+    sr({ vehicleId: vehicles[28].id, title: "Combine - Header Knife Sharpen", description: "Sharpen and replace worn header knives. 4 sections need new blades.", type: "maintenance", status: "pending", serviceDate: day(4), mileage: 2900, techName: "Tom Bradley", shopName: "Egeland Auto" },
+      [{ name: "Header Knife Sections (pack of 10)", partNumber: "CMB-HKS-10", quantity: 1, unitPrice: 85, total: 85 }, { name: "Knife Rivets (50-pack)", partNumber: "CMB-KR-50", quantity: 1, unitPrice: 22, total: 22 }],
+      [{ description: "Remove header knives", hours: 1.0, rate: 120, total: 120 }, { description: "Sharpen and replace worn sections", hours: 2.0, rate: 120, total: 240 }, { description: "Reinstall and test", hours: 1.0, rate: 120, total: 120 }]),
+    // 19: Kenworth clutch (Kari)
+    sr({ vehicleId: vehicles[17].id, title: "Kenworth T680 - Clutch Adjustment", description: "Driver reports clutch engagement point too high. Adjust and inspect.", type: "repair", status: "pending", serviceDate: day(3), mileage: 185000, techName: "Kari Moen", shopName: "Egeland Auto" },
+      [{ name: "Clutch Adjustment Hardware", partNumber: "KW-CAH-T680", quantity: 1, unitPrice: 45, total: 45 }],
+      [{ description: "Clutch linkage inspection and adjustment", hours: 1.5, rate: 140, total: 210 }, { description: "Road test under load", hours: 0.5, rate: 140, total: 70 }]),
+    // 20: Jeep steering stabilizer (Daniel)
+    sr({ vehicleId: vehicles[11].id, title: "Jeep Wrangler - Steering Stabilizer", description: "Death wobble at highway speed. Replace stabilizer and check tie rods.", type: "repair", status: "scheduled", serviceDate: day(4), mileage: 31000, techName: "Daniel Eriksen", shopName: "Egeland Auto" },
+      [{ name: "Steering Stabilizer", partNumber: "JEEP-SS-JL", quantity: 1, unitPrice: 125, total: 125 }, { name: "Tie Rod End (pair)", partNumber: "JEEP-TRE-JL", quantity: 1, unitPrice: 140, total: 140 }],
+      [{ description: "Steering stabilizer replacement", hours: 0.5, rate: 110, total: 55 }, { description: "Tie rod end inspection and replacement", hours: 1.5, rate: 110, total: 165 }]),
+    // Friday extras (20–29)
+    // 21: Camry cabin filter (Jake)
+    sr({ vehicleId: vehicles[0].id, title: "Toyota Camry - Cabin Filter + Wipers", description: "Replace cabin air filter and wiper blades. Quick service.", type: "maintenance", status: "scheduled", serviceDate: day(4), mileage: 16500, techName: "Jake Wilson", shopName: "Egeland Auto" },
+      [{ name: "Cabin Air Filter", partNumber: "TOY-CAF-CAM", quantity: 1, unitPrice: 22, total: 22 }, { name: "Wiper Blade Set (front)", partNumber: "TOY-WB-CAM", quantity: 1, unitPrice: 38, total: 38 }],
+      [{ description: "Cabin filter and wiper replacement", hours: 0.3, rate: 95, total: 28.50 }]),
+    // 22: Honda brakes (Jake)
+    sr({ vehicleId: vehicles[5].id, title: "Honda Civic - Brake Pad Replace", description: "Front pads worn to 2mm. Replace front brake pads.", type: "repair", status: "pending", serviceDate: day(4), mileage: 46000, techName: "Jake Wilson", shopName: "Egeland Auto" },
+      [{ name: "Front Brake Pad Set", partNumber: "HON-BP-CIV", quantity: 1, unitPrice: 85, total: 85 }, { name: "Brake Cleaner (2 cans)", partNumber: "BC-CLEAN-2", quantity: 1, unitPrice: 12, total: 12 }],
+      [{ description: "Front brake pad replacement", hours: 1.0, rate: 95, total: 95 }, { description: "Brake inspection and road test", hours: 0.3, rate: 95, total: 28.50 }]),
+    // 23: Silverado taillight (Jake)
+    sr({ vehicleId: vehicles[8].id, title: "Silverado - Taillight Assembly", description: "Cracked right taillight from parking lot bump. Replace assembly.", type: "repair", status: "pending", serviceDate: day(4), mileage: 60500, techName: "Jake Wilson", shopName: "Egeland Auto" },
+      [{ name: "Taillight Assembly (right)", partNumber: "GM-TLA-SIL-R", quantity: 1, unitPrice: 125, total: 125 }],
+      [{ description: "Taillight assembly replacement", hours: 0.5, rate: 95, total: 47.50 }]),
+    // 24: Sprinter oil (Chris)
+    sr({ vehicleId: vehicles[9].id, title: "Sprinter - Oil Service + Inspection", description: "Regular oil change with full inspection. Check brake wear sensors.", type: "maintenance", status: "scheduled", serviceDate: day(4), mileage: 37000, techName: "Chris Taylor", shopName: "Egeland Auto" },
+      [{ name: "Engine Oil 5W-30 (8qt)", partNumber: "MB-5W30-8Q", quantity: 1, unitPrice: 78, total: 78 }, { name: "Oil Filter", partNumber: "MB-OF-SPR", quantity: 1, unitPrice: 18, total: 18 }],
+      [{ description: "Oil and filter change", hours: 0.5, rate: 110, total: 55 }, { description: "Multi-point inspection", hours: 1.0, rate: 110, total: 110 }]),
+    // 25: BMW coolant (Chris)
+    sr({ vehicleId: vehicles[4].id, title: "BMW 330i - Coolant Flush", description: "Coolant due. Flush and refill with BMW-approved coolant.", type: "maintenance", status: "scheduled", serviceDate: day(4), mileage: 52000, techName: "Chris Taylor", shopName: "Egeland Auto" },
+      [{ name: "BMW Coolant Concentrate (2L)", partNumber: "BMW-COOL-2L", quantity: 2, unitPrice: 32, total: 64 }, { name: "Thermostat Gasket", partNumber: "BMW-TG-N20", quantity: 1, unitPrice: 12, total: 12 }],
+      [{ description: "Coolant flush and refill", hours: 1.0, rate: 120, total: 120 }, { description: "Bleed cooling system", hours: 0.5, rate: 120, total: 60 }]),
+    // 26: Ram alignment (Sofia)
+    sr({ vehicleId: vehicles[15].id, title: "Ram 1500 - Alignment Check", description: "Pulling to the right after pothole. Full 4-wheel alignment.", type: "maintenance", status: "pending", serviceDate: day(4), mileage: 20000, techName: "Sofia Andersen", shopName: "Egeland Auto" },
+      [],
+      [{ description: "4-wheel alignment", hours: 1.0, rate: 110, total: 110 }]),
+    // 27: Audi spark plugs (Sofia)
+    sr({ vehicleId: vehicles[10].id, title: "Audi A4 - Spark Plug Replace", description: "Misfire on cylinder 2. Replace all 4 spark plugs.", type: "repair", status: "pending", serviceDate: day(4), mileage: 62000, techName: "Sofia Andersen", shopName: "Egeland Auto" },
+      [{ name: "Spark Plugs NGK (set of 4)", partNumber: "AUD-SP-NGK4", quantity: 1, unitPrice: 68, total: 68 }, { name: "Ignition Coil (cyl 2)", partNumber: "AUD-IC-A4", quantity: 1, unitPrice: 52, total: 52 }],
+      [{ description: "Spark plug replacement (4 cyl)", hours: 1.0, rate: 120, total: 120 }, { description: "Diagnose misfire and replace coil", hours: 0.5, rate: 120, total: 60 }],
+      "P0302 - misfire cyl 2. Coil swap test confirmed faulty coil on cyl 2. Replace coil and all 4 plugs."),
+    // 28: Tesla tire rotation (Sofia)
+    sr({ vehicleId: vehicles[7].id, title: "Tesla Model Y - Tire Rotation", description: "Rotate tires front to rear. Check tread depth and tire pressure.", type: "maintenance", status: "scheduled", serviceDate: day(4), mileage: 33000, techName: "Sofia Andersen", shopName: "Egeland Auto" },
+      [],
+      [{ description: "Tire rotation front to rear", hours: 0.5, rate: 95, total: 47.50 }, { description: "Tread depth measurement and pressure set", hours: 0.2, rate: 95, total: 19 }]),
+    // 29: Massey Ferguson radiator (Erik)
+    sr({ vehicleId: vehicles[24].id, title: "Massey Ferguson - Radiator Flush", description: "Overheating under load. Flush radiator and check thermostat.", type: "maintenance", status: "pending", serviceDate: day(4), mileage: 3800, techName: "Erik Haugen", shopName: "Egeland Auto" },
+      [{ name: "Coolant (5gal)", partNumber: "MF-COOL-5G", quantity: 1, unitPrice: 65, total: 65 }, { name: "Thermostat", partNumber: "MF-TH-8S", quantity: 1, unitPrice: 48, total: 48 }],
+      [{ description: "Radiator flush and refill", hours: 1.5, rate: 130, total: 195 }, { description: "Thermostat replacement", hours: 1.0, rate: 130, total: 130 }]),
+    // 30: Mack mirror (Nina)
+    sr({ vehicleId: vehicles[19].id, title: "Mack Granite - Mirror Replacement", description: "Left side mirror housing cracked. Replace complete mirror assembly.", type: "repair", status: "pending", serviceDate: day(4), mileage: 157000, techName: "Nina Berglund", shopName: "Egeland Auto" },
+      [{ name: "Complete Mirror Assembly (left)", partNumber: "MACK-MA-L-GR", quantity: 1, unitPrice: 285, total: 285 }],
+      [{ description: "Mirror assembly replacement", hours: 1.0, rate: 120, total: 120 }]),
+    // 31: Ram battery (Kari)
+    sr({ vehicleId: vehicles[16].id, title: "Ram 1500 - Battery Replace", description: "Slow cranking. Battery tested bad. Replace with OEM spec.", type: "repair", status: "pending", serviceDate: day(4), mileage: 45500, techName: "Kari Moen", shopName: "Egeland Auto" },
+      [{ name: "Battery Group 65 AGM", partNumber: "BAT-G65-AGM", quantity: 1, unitPrice: 185, total: 185 }],
+      [{ description: "Battery replacement and terminal cleaning", hours: 0.5, rate: 95, total: 47.50 }, { description: "Charging system test", hours: 0.2, rate: 95, total: 19 }]),
+    // === Monday extras to fill the timeline ===
+    // 32: Erik - Porsche oil change (Monday)
+    sr({ vehicleId: vehicles[13].id, title: "Porsche 911 - Oil Change", description: "Synthetic oil change with OEM filter. Check brake wear sensors.", type: "maintenance", status: "scheduled", serviceDate: day(0), mileage: 13200, techName: "Erik Haugen", shopName: "Egeland Auto" },
+      [{ name: "Porsche Approved Oil 0W-40 (9qt)", partNumber: "POR-0W40-9Q", quantity: 1, unitPrice: 165, total: 165 }, { name: "Oil Filter", partNumber: "POR-OF-992", quantity: 1, unitPrice: 28, total: 28 }],
+      [{ description: "Oil and filter change", hours: 1.0, rate: 145, total: 145 }, { description: "Multi-point inspection", hours: 0.5, rate: 145, total: 72.50 }]),
+    // 33: Nina - BMW coolant hose (Monday)
+    sr({ vehicleId: vehicles[4].id, title: "BMW 330i - Coolant Hose Replacement", description: "Upper radiator hose cracked. Replace hose and top off coolant.", type: "repair", status: "pending", serviceDate: day(0), mileage: 56000, techName: "Nina Berglund", shopName: "Egeland Auto" },
+      [{ name: "Upper Radiator Hose", partNumber: "BMW-URH-N20", quantity: 1, unitPrice: 85, total: 85 }, { name: "Coolant G13 (1L)", partNumber: "BMW-COOL-1L", quantity: 2, unitPrice: 18, total: 36 }],
+      [{ description: "Radiator hose replacement", hours: 1.5, rate: 120, total: 180 }, { description: "Coolant bleed and pressure test", hours: 0.5, rate: 120, total: 60 }]),
+    // 34: Nina - Jeep brake inspection (Monday)
+    sr({ vehicleId: vehicles[11].id, title: "Jeep Wrangler - Brake Inspection", description: "Customer reports squealing. Inspect pads, rotors, and calipers.", type: "inspection", status: "scheduled", serviceDate: day(0), mileage: 29000, techName: "Nina Berglund", shopName: "Egeland Auto" },
+      [],
+      [{ description: "Full brake system inspection", hours: 1.0, rate: 110, total: 110 }, { description: "Document findings and recommend", hours: 0.5, rate: 110, total: 55 }]),
+    // 35: Kari - Fendt hydraulic filter (Monday)
+    sr({ vehicleId: vehicles[23].id, title: "Fendt 942 - Hydraulic Filter Service", description: "Scheduled hydraulic filter change. Check system pressure.", type: "maintenance", status: "scheduled", serviceDate: day(0), mileage: 1250, techName: "Kari Moen", shopName: "Egeland Auto" },
+      [{ name: "Hydraulic Filter Set", partNumber: "FENDT-HFS-942", quantity: 1, unitPrice: 165, total: 165 }, { name: "Hydraulic Oil Sample Kit", partNumber: "OIL-SAMPLE", quantity: 1, unitPrice: 15, total: 15 }],
+      [{ description: "Hydraulic filter replacement", hours: 1.5, rate: 150, total: 225 }, { description: "Pressure test and oil sample", hours: 1.0, rate: 150, total: 150 }]),
+    // 36: Kari - Excavator grease service (Monday)
+    sr({ vehicleId: vehicles[31].id, title: "Komatsu PC210 - Full Grease Service", description: "Complete grease service all 24 fittings. Check pin play.", type: "maintenance", status: "scheduled", serviceDate: day(0), mileage: 3150, techName: "Kari Moen", shopName: "Egeland Auto" },
+      [{ name: "Grease Cartridges (box of 10)", partNumber: "GRZ-EP2-10", quantity: 1, unitPrice: 42, total: 42 }],
+      [{ description: "Grease all fittings (24 points)", hours: 2.0, rate: 130, total: 260 }, { description: "Pin and bushing play inspection", hours: 0.5, rate: 130, total: 65 }]),
+    // 37: Daniel - Volvo FH brake adjustment (Monday)
+    sr({ vehicleId: vehicles[18].id, title: "Volvo FH 640 - Brake Adjustment", description: "Annual brake adjustment. Check linings and drums.", type: "maintenance", status: "scheduled", serviceDate: day(0), mileage: 93000, techName: "Daniel Eriksen", shopName: "Egeland Auto" },
+      [{ name: "Brake Lining Wear Indicators (set)", partNumber: "VOL-BLWI-FH", quantity: 1, unitPrice: 45, total: 45 }],
+      [{ description: "Brake adjustment all axles", hours: 2.5, rate: 140, total: 350 }, { description: "Lining measurement and documentation", hours: 1.0, rate: 140, total: 140 }]),
+    // 38: Daniel - Dump truck hydraulic hose (Monday)
+    sr({ vehicleId: vehicles[21].id, title: "Dump Truck - Hydraulic Hose Replace", description: "Hose bulging near fitting. Replace before failure.", type: "repair", status: "pending", serviceDate: day(0), mileage: 83000, techName: "Daniel Eriksen", shopName: "Egeland Auto" },
+      [{ name: "Hydraulic Hose Assembly 3/4\"", partNumber: "HYD-HA-34", quantity: 2, unitPrice: 120, total: 240 }, { name: "Hose Fittings (4-pack)", partNumber: "HYD-FIT-4", quantity: 1, unitPrice: 65, total: 65 }],
+      [{ description: "Hose removal and replacement", hours: 2.0, rate: 130, total: 260 }, { description: "System bleed and pressure test", hours: 0.5, rate: 130, total: 65 }]),
+    // 39: Lars - Kenworth air filter (Monday)
+    sr({ vehicleId: vehicles[17].id, title: "Kenworth T680 - Air Filter Service", description: "Replace primary and secondary air filters. Clean intake ducting.", type: "maintenance", status: "scheduled", serviceDate: day(0), mileage: 186500, techName: "Lars Johansen", shopName: "Egeland Auto" },
+      [{ name: "Primary Air Filter", partNumber: "KW-PAF-T680", quantity: 1, unitPrice: 85, total: 85 }, { name: "Secondary Air Filter", partNumber: "KW-SAF-T680", quantity: 1, unitPrice: 55, total: 55 }],
+      [{ description: "Air filter replacement (both)", hours: 0.5, rate: 130, total: 65 }, { description: "Intake system inspection and clean", hours: 0.5, rate: 130, total: 65 }]),
+    // 40: Sofia - Honda Civic tire rotation (Monday)
+    sr({ vehicleId: vehicles[5].id, title: "Honda Civic - Tire Rotation + Check", description: "Rotate tires. Customer also reports slight vibration at highway speed.", type: "maintenance", status: "pending", serviceDate: day(0), mileage: 68500, techName: "Sofia Andersen", shopName: "Egeland Auto" },
+      [],
+      [{ description: "Tire rotation", hours: 0.5, rate: 95, total: 47.50 }, { description: "Wheel balance check (4 wheels)", hours: 0.5, rate: 95, total: 47.50 }, { description: "Road test for vibration", hours: 0.3, rate: 95, total: 28.50 }]),
+  ]);
+
+  // Unassigned jobs (NOT on the board - will show in unassigned panel)
+  console.log("\nCreating unassigned service records...");
+  const unassignedRecords = await Promise.all([
+    sr({ vehicleId: vehicles[0].id, title: "Toyota Camry - Check Engine Light", description: "P0171 - System Too Lean. Diagnose and repair. Customer reports rough idle.", type: "repair", status: "pending", mileage: 16200, shopName: "Egeland Auto" },
+      [{ name: "MAF Sensor", partNumber: "TOY-MAF-CAM", quantity: 1, unitPrice: 145, total: 145 }, { name: "Air Filter", partNumber: "TOY-AF-CAM", quantity: 1, unitPrice: 18, total: 18 }],
+      [{ description: "Diagnostic scan and smoke test", hours: 1.0, rate: 110, total: 110 }, { description: "MAF sensor replacement", hours: 0.5, rate: 110, total: 55 }],
+      "P0171 lean code. Inspected intake for vacuum leaks - none found. MAF sensor reading low. Suspect contaminated MAF."),
+    sr({ vehicleId: vehicles[9].id, title: "Sprinter - Sliding Door Repair", description: "Sliding door sticking halfway. Roller mechanism needs replacement.", type: "repair", status: "pending", mileage: 36500, shopName: "Egeland Auto" },
+      [{ name: "Sliding Door Roller Assembly", partNumber: "MB-SDRA-SPR", quantity: 1, unitPrice: 220, total: 220 }, { name: "Door Track Lubricant", partNumber: "LUB-DTL-1", quantity: 1, unitPrice: 15, total: 15 }],
+      [{ description: "Roller mechanism removal and replacement", hours: 2.0, rate: 110, total: 220 }]),
+    sr({ vehicleId: vehicles[13].id, title: "Porsche 911 - Pre-Track Inspection", description: "Full safety inspection before April track day. Brakes, fluids, suspension, tires.", type: "inspection", status: "pending", mileage: 13000, shopName: "Egeland Auto" },
+      [{ name: "Brake Fluid DOT4 Racing (1L)", partNumber: "POR-BF-R-1L", quantity: 2, unitPrice: 48, total: 96 }],
+      [{ description: "Full multi-point track safety inspection", hours: 2.0, rate: 145, total: 290 }, { description: "Brake fluid flush with racing fluid", hours: 1.5, rate: 145, total: 217.50 }]),
+    sr({ vehicleId: vehicles[19].id, title: "Mack Granite - Dump Body Weld Repair", description: "Cracks in dump body floor. Weld repair and reinforce high-stress areas.", type: "repair", status: "pending", mileage: 156000, shopName: "Egeland Auto" },
+      [{ name: "Steel Plate 3/8\" (2 sheets)", partNumber: "STL-PLT-38", quantity: 2, unitPrice: 120, total: 240 }, { name: "Welding Wire (10lb)", partNumber: "WLD-WIRE-10", quantity: 1, unitPrice: 45, total: 45 }],
+      [{ description: "Grind and prep crack areas", hours: 2.0, rate: 130, total: 260 }, { description: "Weld repair and reinforce", hours: 4.0, rate: 130, total: 520 }]),
+    sr({ vehicleId: vehicles[22].id, title: "John Deere 6R - Front Loader Pins", description: "Excessive play in front loader attachment pins. Replace pins and bushings.", type: "repair", status: "pending", mileage: 2800, shopName: "Egeland Auto" },
+      [{ name: "Loader Pin Kit", partNumber: "JD-LPK-6R", quantity: 1, unitPrice: 185, total: 185 }, { name: "Bronze Bushings (set of 4)", partNumber: "JD-BB-6R-4", quantity: 1, unitPrice: 120, total: 120 }],
+      [{ description: "Pin and bushing removal", hours: 2.0, rate: 140, total: 280 }, { description: "New bushing press-in and pin installation", hours: 2.0, rate: 140, total: 280 }]),
+    sr({ vehicleId: vehicles[31].id, title: "Komatsu PC210 - Boom Cylinder Reseal", description: "Slow drift on boom cylinder. Reseal required.", type: "repair", status: "pending", mileage: 3200, shopName: "Egeland Auto" },
+      [{ name: "Boom Cylinder Seal Kit", partNumber: "KOM-BCSK-PC210", quantity: 1, unitPrice: 320, total: 320 }, { name: "Hydraulic Oil (5gal)", partNumber: "HYD-ISO46-5G", quantity: 1, unitPrice: 95, total: 95 }],
+      [{ description: "Boom cylinder removal", hours: 3.0, rate: 140, total: 420 }, { description: "Reseal and reinstall", hours: 2.5, rate: 140, total: 350 }]),
+    sr({ vehicleId: vehicles[36].id, title: "Liebherr LTM 1100 - Outrigger Service", description: "Right rear outrigger slow to extend. Inspect valve and cylinder seals.", type: "repair", status: "pending", mileage: 4200, shopName: "Egeland Auto" },
+      [{ name: "Outrigger Cylinder Seal Kit", partNumber: "LIE-OCSK-1100", quantity: 1, unitPrice: 450, total: 450 }, { name: "Outrigger Control Valve", partNumber: "LIE-OCV-1100", quantity: 1, unitPrice: 680, total: 680 }],
+      [{ description: "Diagnose outrigger hydraulic circuit", hours: 2.0, rate: 150, total: 300 }, { description: "Cylinder reseal or valve replacement", hours: 4.0, rate: 150, total: 600 }]),
+    sr({ vehicleId: vehicles[6].id, title: "Tesla Model 3 - 12V Battery Replace", description: "Low voltage warning. Replace 12V auxiliary battery.", type: "repair", status: "pending", mileage: 8200, shopName: "Egeland Auto" },
+      [{ name: "12V Lithium Battery (Tesla OEM)", partNumber: "TES-12V-M3", quantity: 1, unitPrice: 110, total: 110 }],
+      [{ description: "12V battery replacement", hours: 0.5, rate: 110, total: 55 }]),
+    sr({ vehicleId: vehicles[23].id, title: "Fendt 942 - GPS Autosteer Calibration", description: "Autosteer drifting right by 6 inches. Recalibrate GPS receiver and steering controller.", type: "maintenance", status: "scheduled", mileage: 1200, shopName: "Egeland Auto" },
+      [],
+      [{ description: "GPS receiver calibration", hours: 1.5, rate: 150, total: 225 }, { description: "Steering controller calibration and field test", hours: 2.0, rate: 150, total: 300 }],
+      "RTK base station signal verified. GPS receiver antenna checked - mounting bracket slightly loose causing drift. Tighten and recalibrate."),
+    sr({ vehicleId: vehicles[33].id, title: "Volvo EC220E - Bucket Teeth Replace", description: "3 worn bucket teeth. Replace full set of 5 for even wear.", type: "maintenance", status: "pending", mileage: 1600, shopName: "Egeland Auto" },
+      [{ name: "Bucket Teeth Set (5)", partNumber: "VOL-BTS-EC220", quantity: 1, unitPrice: 340, total: 340 }, { name: "Retainer Pins (5-pack)", partNumber: "VOL-RP-5", quantity: 1, unitPrice: 45, total: 45 }],
+      [{ description: "Bucket teeth removal and replacement", hours: 1.5, rate: 130, total: 195 }]),
+    sr({ vehicleId: vehicles[18].id, title: "Volvo FH 640 - Turbo Boost Leak", description: "Low power complaint. Suspect charge air cooler pipe leak. Diagnose.", type: "repair", status: "pending", mileage: 93500, shopName: "Egeland Auto" },
+      [{ name: "Charge Air Cooler Pipe + Clamps", partNumber: "VOL-CACP-FH", quantity: 1, unitPrice: 185, total: 185 }],
+      [{ description: "Boost leak diagnosis and smoke test", hours: 1.5, rate: 140, total: 210 }, { description: "Pipe replacement and clamp tightening", hours: 1.0, rate: 140, total: 140 }],
+      "Smoke test confirmed leak at charge air cooler pipe connection. Clamp was loose and pipe boot cracked."),
+    sr({ vehicleId: vehicles[17].id, title: "Kenworth T680 - Sleeper AC Repair", description: "Sleeper AC not cooling. Likely low refrigerant or compressor issue.", type: "repair", status: "waiting-parts", mileage: 186000, shopName: "Egeland Auto" },
+      [{ name: "Sleeper AC Compressor", partNumber: "KW-SAC-T680", quantity: 1, unitPrice: 580, total: 580 }, { name: "Refrigerant R-134a (3 cans)", partNumber: "REF-134A-3", quantity: 1, unitPrice: 65, total: 65 }, { name: "AC Condenser", partNumber: "KW-COND-T680", quantity: 1, unitPrice: 320, total: 320 }],
+      [{ description: "Sleeper AC system diagnosis", hours: 1.5, rate: 140, total: 210 }, { description: "Compressor and condenser replacement", hours: 3.5, rate: 140, total: 490 }, { description: "Evacuate, recharge and leak test", hours: 1.0, rate: 140, total: 140 }],
+      "Compressor seized. Condenser contaminated with metal debris from failed compressor. Both need replacement. Parts on order - ETA Wednesday."),
+    sr({ vehicleId: vehicles[38].id, title: "Corvette C8 - Pre-Track Brake Service", description: "Customer has track day in 2 weeks. Flush brake fluid, inspect pads, check tire wear.", type: "maintenance", status: "pending", mileage: 6200, shopName: "Egeland Auto" },
+      [{ name: "Brake Fluid DOT4 Racing (1L)", partNumber: "BF-DOT4R-1L", quantity: 3, unitPrice: 48, total: 144 }],
+      [{ description: "Brake fluid flush all 4 corners", hours: 1.5, rate: 130, total: 195 }, { description: "Brake pad measurement and inspection", hours: 0.5, rate: 130, total: 65 }, { description: "Tire tread depth and pressure check", hours: 0.3, rate: 130, total: 39 }]),
+    sr({ vehicleId: vehicles[39].id, title: "Defender 110 - Air Suspension Warning", description: "Dashboard warning light for air suspension. Possible compressor or height sensor issue.", type: "repair", status: "pending", mileage: 28500, shopName: "Egeland Auto" },
+      [{ name: "Air Suspension Height Sensor", partNumber: "LR-ASHS-DEF", quantity: 1, unitPrice: 210, total: 210 }],
+      [{ description: "Diagnose air suspension fault codes", hours: 1.0, rate: 120, total: 120 }, { description: "Height sensor replacement and calibration", hours: 1.5, rate: 120, total: 180 }]),
+    sr({ vehicleId: vehicles[40].id, title: "Charger R/T - Rear Alignment + Tire Replace", description: "Inner edge tire wear on rears. Alignment and new rear tires needed.", type: "repair", status: "pending", mileage: 35000, shopName: "Egeland Auto" },
+      [{ name: "Firestone Firehawk Indy 500 275/40R20 (pair)", partNumber: "FS-FH500-27540-2", quantity: 1, unitPrice: 380, total: 380 }],
+      [{ description: "Rear tire mount and balance (2)", hours: 0.5, rate: 95, total: 47.50 }, { description: "4-wheel alignment", hours: 1.0, rate: 110, total: 110 }]),
+    sr({ vehicleId: vehicles[41].id, title: "WRX - Clutch Adjustment + Inspection", description: "Customer reports high clutch engagement point. Inspect clutch wear and adjust.", type: "repair", status: "pending", mileage: 12400, shopName: "Egeland Auto" },
+      [{ name: "Clutch Master Cylinder Fluid", partNumber: "SUB-CMF-DOT4", quantity: 1, unitPrice: 15, total: 15 }],
+      [{ description: "Clutch pedal adjustment and bleed", hours: 0.5, rate: 110, total: 55 }, { description: "Clutch wear inspection via inspection port", hours: 0.5, rate: 110, total: 55 }],
+      "Clutch engagement point higher than normal but disc still within spec. Bled hydraulic line - air found. Pedal feel improved. Monitor."),
+  ]);
+  console.log(`  Created ${unassignedRecords.length} unassigned service records`);
+
+  const jobUpdates = await Promise.all([
+    // ============ MONDAY (day 0) - 2026-03-09 - Primary showcase day ============
+    // Jake Wilson [0]: Honda AC (8:00-11:30) + F-150 trans flush (12:30-15:00)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[0].id }, data: { technicianId: technicians[0].id, sortOrder: 0, startDateTime: dt(0, 8, 0), endDateTime: dt(0, 11, 30) } }),
+    prisma.serviceRecord.update({ where: { id: serviceRecords[18].id }, data: { technicianId: technicians[0].id, sortOrder: 1, startDateTime: dt(0, 12, 30), endDateTime: dt(0, 15, 0) } }),
+    // Chris Taylor [1]: Audi timing belt (7:30-13:00)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[1].id }, data: { technicianId: technicians[1].id, sortOrder: 0, startDateTime: dt(0, 7, 30), endDateTime: dt(0, 13, 0) } }),
+    // Marcus Reed [2]: CAT 745 transmission rebuild (7:00-16:00, multi-day spans Mon-Wed)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[6].id }, data: { technicianId: technicians[2].id, sortOrder: 0, startDateTime: dt(0, 7, 0), endDateTime: dt(2, 16, 0) } }),
+    // Sofia Andersen [3]: Tesla suspension (9:00-11:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[2].id }, data: { technicianId: technicians[3].id, sortOrder: 0, startDateTime: dt(0, 9, 0), endDateTime: dt(0, 11, 30) } }),
+    // Lars Johansen [5]: Ram oil change (8:00-9:00)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[11].id }, data: { technicianId: technicians[5].id, sortOrder: 0, startDateTime: dt(0, 8, 0), endDateTime: dt(0, 9, 0) } }),
+    // Tom Bradley [7]: Concrete mixer drum motor (7:30-14:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[13].id }, data: { technicianId: technicians[7].id, sortOrder: 0, startDateTime: dt(0, 7, 30), endDateTime: dt(0, 14, 30) } }),
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[32].id }, data: { technicianId: technicians[4].id, sortOrder: 1, startDateTime: dt(0, 11, 0), endDateTime: dt(0, 12, 30) } }),
+    // Nina Berglund [6]: BMW coolant hose (7:30-9:30) + Jeep brake inspection (10:00-11:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[33].id }, data: { technicianId: technicians[6].id, sortOrder: 0, startDateTime: dt(0, 7, 30), endDateTime: dt(0, 9, 30) } }),
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[34].id }, data: { technicianId: technicians[6].id, sortOrder: 1, startDateTime: dt(0, 10, 0), endDateTime: dt(0, 11, 30) } }),
+    // Kari Moen [8]: Fendt hydraulic filter (8:00-10:30) + Komatsu grease (11:00-13:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[35].id }, data: { technicianId: technicians[8].id, sortOrder: 0, startDateTime: dt(0, 8, 0), endDateTime: dt(0, 10, 30) } }),
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[36].id }, data: { technicianId: technicians[8].id, sortOrder: 1, startDateTime: dt(0, 11, 0), endDateTime: dt(0, 13, 30) } }),
+    // Daniel Eriksen [9]: Volvo brake adjustment (7:30-11:00) + Dump truck hose (11:30-14:00)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[37].id }, data: { technicianId: technicians[9].id, sortOrder: 0, startDateTime: dt(0, 7, 30), endDateTime: dt(0, 11, 0) } }),
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[38].id }, data: { technicianId: technicians[9].id, sortOrder: 1, startDateTime: dt(0, 11, 30), endDateTime: dt(0, 14, 0) } }),
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[39].id }, data: { technicianId: technicians[5].id, sortOrder: 2, startDateTime: dt(0, 10, 30), endDateTime: dt(0, 11, 30) } }),
+    // Sofia Andersen [3]: (already has Tesla 9-11:30) + Honda tire rotation (12:00-13:15)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[40].id }, data: { technicianId: technicians[3].id, sortOrder: 1, startDateTime: dt(0, 12, 0), endDateTime: dt(0, 13, 15) } }),
+
+    // ============ TUESDAY (day 1) ============
+    // Jake [0]: Silverado 60K (8:00-11:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[3].id }, data: { technicianId: technicians[0].id, sortOrder: 0, startDateTime: dt(1, 8, 0), endDateTime: dt(1, 11, 30) } }),
+    // Chris [1]: Sprinter turbo (7:30-13:00)
+    prisma.serviceRecord.update({ where: { id: serviceRecords[12].id }, data: { technicianId: technicians[1].id, sortOrder: 0, startDateTime: dt(1, 7, 30), endDateTime: dt(1, 13, 0) } }),
+    // Marcus [2]: CAT 745 continued (multi-day job spans from Monday)
+    // Erik [4]: Tacoma leaf springs (8:00-11:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[4].id }, data: { technicianId: technicians[4].id, sortOrder: 0, startDateTime: dt(1, 8, 0), endDateTime: dt(1, 11, 30) } }),
+    // Nina [6]: F-150 wheel bearing (9:00-11:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[12].id }, data: { technicianId: technicians[6].id, sortOrder: 0, startDateTime: dt(1, 9, 0), endDateTime: dt(1, 11, 30) } }),
+    // Daniel [9]: Dump truck tailgate (8:00-12:00)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[15].id }, data: { technicianId: technicians[9].id, sortOrder: 0, startDateTime: dt(1, 8, 0), endDateTime: dt(1, 12, 0) } }),
+
+    // ============ WEDNESDAY (day 2) ============
+    // Jake [0]: F-150 exhaust (8:00-11:00)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[7].id }, data: { technicianId: technicians[0].id, sortOrder: 0, startDateTime: dt(2, 8, 0), endDateTime: dt(2, 11, 0) } }),
+    // Marcus [2]: Bobcat annual (13:00-15:30, after CAT 745 wraps up)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[5].id }, data: { technicianId: technicians[2].id, sortOrder: 1, startDateTime: dt(2, 13, 0), endDateTime: dt(2, 15, 30) } }),
+    // Chris [1]: Volvo EC220E (7:30-13:00)
+    prisma.serviceRecord.update({ where: { id: serviceRecords[10].id }, data: { technicianId: technicians[1].id, sortOrder: 0, startDateTime: dt(2, 7, 30), endDateTime: dt(2, 13, 0) } }),
+    // Kari [8]: Tractor PTO clutch (8:30-10:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[14].id }, data: { technicianId: technicians[8].id, sortOrder: 0, startDateTime: dt(2, 8, 30), endDateTime: dt(2, 10, 30) } }),
+    // Nina [6]: VW Golf clutch (7:30-14:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[17].id }, data: { technicianId: technicians[6].id, sortOrder: 0, startDateTime: dt(2, 7, 30), endDateTime: dt(2, 14, 30) } }),
+
+    // ============ THURSDAY (day 3) ============
+    // Sofia [3]: Ram transfer case (9:00-10:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[8].id }, data: { technicianId: technicians[3].id, sortOrder: 0, startDateTime: dt(3, 9, 0), endDateTime: dt(3, 10, 30) } }),
+    // Jake [0]: Volvo FH DOT inspection (7:30-13:30)
+    prisma.serviceRecord.update({ where: { id: serviceRecords[5].id }, data: { technicianId: technicians[0].id, sortOrder: 0, startDateTime: dt(3, 7, 30), endDateTime: dt(3, 13, 30) } }),
+    // Lars [5]: Forklift mast chain (8:00-11:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[16].id }, data: { technicianId: technicians[5].id, sortOrder: 0, startDateTime: dt(3, 8, 0), endDateTime: dt(3, 11, 30) } }),
+    // Kari [8]: Kenworth clutch adjust (8:00-10:00)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[19].id }, data: { technicianId: technicians[8].id, sortOrder: 0, startDateTime: dt(3, 8, 0), endDateTime: dt(3, 10, 0) } }),
+
+    // ============ FRIDAY (day 4) ============
+    // Jake [0] (3 jobs): Camry cabin filter (8:00-8:30), Honda brakes (8:45-10:00), Silverado taillight (10:15-10:45)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[21].id }, data: { technicianId: technicians[0].id, sortOrder: 0, startDateTime: dt(4, 8, 0), endDateTime: dt(4, 8, 30) } }),
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[22].id }, data: { technicianId: technicians[0].id, sortOrder: 1, startDateTime: dt(4, 8, 45), endDateTime: dt(4, 10, 0) } }),
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[23].id }, data: { technicianId: technicians[0].id, sortOrder: 2, startDateTime: dt(4, 10, 15), endDateTime: dt(4, 10, 45) } }),
+    // Chris [1] (3 jobs): Jeep axle seal (7:30-10:30), Sprinter oil (11:00-12:30), BMW coolant (13:00-14:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[10].id }, data: { technicianId: technicians[1].id, sortOrder: 0, startDateTime: dt(4, 7, 30), endDateTime: dt(4, 10, 30) } }),
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[24].id }, data: { technicianId: technicians[1].id, sortOrder: 1, startDateTime: dt(4, 11, 0), endDateTime: dt(4, 12, 30) } }),
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[25].id }, data: { technicianId: technicians[1].id, sortOrder: 2, startDateTime: dt(4, 13, 0), endDateTime: dt(4, 14, 30) } }),
+    // Marcus [2]: Combine pre-season (7:00-15:00)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[9].id }, data: { technicianId: technicians[2].id, sortOrder: 0, startDateTime: dt(4, 7, 0), endDateTime: dt(4, 15, 0) } }),
+    // Sofia [3] (3 jobs): Ram alignment (8:00-9:00), Audi spark plugs (9:30-11:00), Tesla tire rotation (11:30-12:15)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[26].id }, data: { technicianId: technicians[3].id, sortOrder: 0, startDateTime: dt(4, 8, 0), endDateTime: dt(4, 9, 0) } }),
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[27].id }, data: { technicianId: technicians[3].id, sortOrder: 1, startDateTime: dt(4, 9, 30), endDateTime: dt(4, 11, 0) } }),
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[28].id }, data: { technicianId: technicians[3].id, sortOrder: 2, startDateTime: dt(4, 11, 30), endDateTime: dt(4, 12, 15) } }),
+    // Erik [4]: Massey Ferguson radiator (8:00-10:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[29].id }, data: { technicianId: technicians[4].id, sortOrder: 0, startDateTime: dt(4, 8, 0), endDateTime: dt(4, 10, 30) } }),
+    // Nina [6]: Mack mirror replacement (9:00-10:00)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[30].id }, data: { technicianId: technicians[6].id, sortOrder: 0, startDateTime: dt(4, 9, 0), endDateTime: dt(4, 10, 0) } }),
+    // Tom [7]: Combine header knives (7:30-11:30)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[18].id }, data: { technicianId: technicians[7].id, sortOrder: 0, startDateTime: dt(4, 7, 30), endDateTime: dt(4, 11, 30) } }),
+    // Kari [8]: Ram battery replace (8:00-8:45)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[31].id }, data: { technicianId: technicians[8].id, sortOrder: 0, startDateTime: dt(4, 8, 0), endDateTime: dt(4, 8, 45) } }),
+    // Daniel [9]: Jeep steering stabilizer (8:00-10:00)
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[20].id }, data: { technicianId: technicians[9].id, sortOrder: 0, startDateTime: dt(4, 8, 0), endDateTime: dt(4, 10, 0) } }),
+  ]);
+  console.log(`  Updated ${jobUpdates.length} service records with technician assignments and time slots`);
+
+  // -- Assign the Demo Owner a few active jobs so the dashboard "My Active Jobs" populates --
+  console.log("\nAssigning active jobs to the Demo Owner technician...");
+  const demoOwnerTech = technicians[10]; // Demo Owner
+  const demoOwnerJobs = await Promise.all([
+    // Tesla Model Y suspension (pending) — reassign from Sofia
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[2].id }, data: { technicianId: demoOwnerTech.id } }),
+    // F-150 exhaust leak repair (pending) — reassign from Jake
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[7].id }, data: { technicianId: demoOwnerTech.id } }),
+    // Jeep axle seal replacement (pending) — reassign from Chris
+    prisma.serviceRecord.update({ where: { id: boardServiceRecords[10].id }, data: { technicianId: demoOwnerTech.id } }),
+    // Kenworth T680 Sleeper AC repair (waiting-parts) — previously unassigned
+    prisma.serviceRecord.update({ where: { id: unassignedRecords[11].id }, data: { technicianId: demoOwnerTech.id } }),
+    // Porsche 911 Pre-Track Inspection (pending) — previously unassigned
+    prisma.serviceRecord.update({ where: { id: unassignedRecords[2].id }, data: { technicianId: demoOwnerTech.id } }),
+  ]);
+  console.log(`  Assigned ${demoOwnerJobs.length} active jobs to ${demoOwnerTech.name}`);
+
+  // -- Summary --
+  console.log("\n" + "=".repeat(50));
+  console.log("Seed completed!");
+  console.log("=".repeat(50));
+  console.log(`  Customers:       ${customers.length}`);
+  console.log(`  Vehicles:        ${vehicles.length}`);
+  console.log(`  Service Records: ${serviceRecords.length + boardServiceRecords.length + unassignedRecords.length + predMaintRecords.length}`);
+  console.log(`  Quotes:          ${quotes.length}`);
+  console.log(`  Inventory Parts: ${inventoryParts.length}`);
+  console.log(`  Technicians:     ${technicians.length}`);
+  console.log(`  Board Assignments: (removed - technicianId set directly on service records)`);
+  console.log(`  Unassigned Jobs: ${unassignedRecords.length}`);
+  console.log(`  Notes:           ${9 + additionalNotes.length}`);
+  console.log(`  Reminders:       ${8 + additionalReminders.length}`);
+  console.log(`  Findings:        ${findings.length}`);
+  console.log(`  SMS Messages:    22`);
+  console.log("=".repeat(50));
+}
+
+seed()
+  .then(async () => { await prisma.$disconnect(); process.exit(0); })
+  .catch(async (e) => { console.error("Seed failed:", e); await prisma.$disconnect(); process.exit(1); });

+ 94 - 0
src/__tests__/features/inspections/completion-blockers.test.ts

@@ -0,0 +1,94 @@
+import { describe, it, expect } from "vitest";
+import {
+  describeBlocker,
+  findCompletionBlockers,
+  summariseBlockers,
+  type CompletionCandidate,
+} from "@/features/inspections/Lib/completion";
+
+const check = (over: Partial<CompletionCandidate> = {}): CompletionCandidate => ({
+  id: "i1",
+  name: "Brake linings and pads",
+  code: "1.1.13",
+  condition: "pass",
+  required: false,
+  photoRequired: false,
+  photoCount: 0,
+  ...over,
+});
+
+describe("completion blockers", () => {
+  it("passes a plain inspection with nothing mandatory", () => {
+    expect(findCompletionBlockers([check(), check({ id: "i2", condition: "fail" })])).toEqual([]);
+  });
+
+  it("blocks a defect on a check that requires a photo", () => {
+    const blockers = findCompletionBlockers([
+      check({ photoRequired: true, condition: "fail", photoCount: 0 }),
+    ]);
+    expect(blockers).toHaveLength(1);
+    expect(blockers[0].reason).toBe("missing-photo");
+    expect(blockers[0].label).toBe("1.1.13 Brake linings and pads");
+  });
+
+  it("releases the block once a photo is attached", () => {
+    expect(
+      findCompletionBlockers([check({ photoRequired: true, condition: "fail", photoCount: 1 })])
+    ).toEqual([]);
+  });
+
+  it("blocks every defect grade, not only major", () => {
+    for (const condition of ["attention", "fail", "dangerous"]) {
+      const blockers = findCompletionBlockers([check({ photoRequired: true, condition })]);
+      expect(blockers, condition).toHaveLength(1);
+    }
+  });
+
+  it("does not demand a photo of a check that passed", () => {
+    // The requirement is evidence of a defect, not a photo of every check.
+    expect(
+      findCompletionBlockers([check({ photoRequired: true, condition: "pass" })])
+    ).toEqual([]);
+    expect(
+      findCompletionBlockers([check({ photoRequired: true, condition: "not_inspected" })])
+    ).toEqual([]);
+  });
+
+  it("blocks a mandatory check that was never graded", () => {
+    const blockers = findCompletionBlockers([
+      check({ required: true, condition: "not_inspected" }),
+    ]);
+    expect(blockers[0].reason).toBe("missing-grade");
+  });
+
+  it("reports a mandatory ungraded check once, not twice", () => {
+    // It is both ungraded and photo-less; the grade is the thing to fix first.
+    const blockers = findCompletionBlockers([
+      check({ required: true, photoRequired: true, condition: "not_inspected" }),
+    ]);
+    expect(blockers).toHaveLength(1);
+    expect(blockers[0].reason).toBe("missing-grade");
+  });
+
+  it("falls back to the name when a check has no reference", () => {
+    const blockers = findCompletionBlockers([
+      check({ code: null, required: true, condition: "not_inspected" }),
+    ]);
+    expect(blockers[0].label).toBe("Brake linings and pads");
+  });
+
+  it("names the first few and counts the rest", () => {
+    const many = Array.from({ length: 5 }, (_, i) =>
+      check({ id: `i${i}`, name: `Check ${i}`, code: null, required: true, condition: "not_inspected" })
+    );
+    const summary = summariseBlockers(findCompletionBlockers(many));
+    expect(summary).toContain("5 checks still outstanding");
+    expect(summary).toContain("Check 0 (must be graded)");
+    expect(summary).toContain("and 2 more");
+  });
+
+  it("describes each reason in words a technician can act on", () => {
+    expect(describeBlocker("missing-grade")).toBe("must be graded");
+    expect(describeBlocker("missing-photo")).toBe("needs a photo of the defect");
+  });
+});

+ 146 - 0
src/__tests__/features/inspections/defect-catalogue.test.ts

@@ -0,0 +1,146 @@
+import { describe, it, expect } from "vitest";
+import { rankSuggestions } from "@/features/inspections/Lib/defectCatalogue";
+import {
+  conditionGrade,
+  gradedConditionLabel,
+} from "@/features/inspections/Lib/conditions";
+
+describe("national defect codes", () => {
+  // Verified against Statens vegvesen and Norwegian workshop guidance:
+  // 0 ingen mangel, 1 mindre mangel (godkjent med merknad), 2 vesentlig
+  // mangel (etterkontroll), 3 farlig mangel (bruksforbud), 4 ikke kontrollert.
+  it("numbers the Norwegian EU-kontroll grades", () => {
+    expect(conditionGrade("pass", "eu", "NO")).toBe(0);
+    expect(conditionGrade("attention", "eu", "NO")).toBe(1);
+    expect(conditionGrade("fail", "eu", "NO")).toBe(2);
+    expect(conditionGrade("dangerous", "eu", "NO")).toBe(3);
+    expect(conditionGrade("not_inspected", "eu", "NO")).toBe(4);
+  });
+
+  it("shows no number for a country with no scheme on record", () => {
+    // The Directive names its categories but does not number them, so
+    // inventing a code for an unresearched country would be a wrong code on
+    // a legal document.
+    expect(conditionGrade("fail", "eu", "DE")).toBeNull();
+    expect(conditionGrade("fail", "eu", null)).toBeNull();
+    expect(gradedConditionLabel("fail", "eu", "DE")).toBe("Major defect");
+  });
+
+  it("never numbers the plain three-step scale", () => {
+    expect(conditionGrade("fail", "basic", "NO")).toBeNull();
+  });
+
+  it("puts the code before the wording", () => {
+    expect(gradedConditionLabel("fail", "eu", "NO")).toBe("2 — Major defect");
+    expect(gradedConditionLabel("attention", "eu", "NO", "Mindre mangel")).toBe(
+      "1 — Mindre mangel"
+    );
+  });
+});
+
+
+const BRAKE_PADS = {
+  name: "Brake linings and pads",
+  code: "1.1.13",
+  sectionCode: "1",
+};
+
+describe("rankSuggestions", () => {
+  it("returns the Annex I wording for a check with a known code", () => {
+    const texts = rankSuggestions(BRAKE_PADS).map((s) => s.text);
+    expect(texts).toContain("Linings or pads worn down to the wear indicator");
+    expect(texts).toContain("Lining or pad missing or incorrectly mounted");
+  });
+
+  it("carries the severity the Directive assigns to each reason", () => {
+    const suggestions = rankSuggestions(BRAKE_PADS);
+    const worn = suggestions.find(
+      (s) => s.text === "Linings or pads worn down to the wear indicator"
+    );
+    const metalToMetal = suggestions.find((s) =>
+      s.text.includes("metal to metal contact")
+    );
+    // The same fault is a major defect at the indicator and a dangerous one
+    // past it — the whole point of pairing wording with a grade.
+    expect(worn?.severity).toBe("fail");
+    expect(metalToMetal?.severity).toBe("dangerous");
+  });
+
+  it("falls back to the parent code when a check has no entry of its own", () => {
+    const suggestions = rankSuggestions({
+      name: "Some brake sub-check",
+      code: "1.1.13.4",
+      sectionCode: "1",
+    });
+    expect(suggestions.map((s) => s.text)).toContain(
+      "Linings or pads worn down to the wear indicator"
+    );
+  });
+
+  it("falls back to the section when neither the code nor its parents match", () => {
+    const suggestions = rankSuggestions({
+      name: "Unlisted steering check",
+      code: "2.99",
+      sectionCode: "2",
+    });
+    expect(suggestions.map((s) => s.text)).toContain("Excessive play in the steering");
+  });
+
+  it("matches on the check name when a template carries no codes", () => {
+    const suggestions = rankSuggestions({ name: "Front tyre tread depth" });
+    expect(suggestions.map((s) => s.text)).toContain("Tread below the legal minimum");
+  });
+
+  it("always offers general wording so no check is left without a phrase", () => {
+    const suggestions = rankSuggestions({ name: "Something entirely unusual" });
+    expect(suggestions.map((s) => s.text)).toContain("Damaged");
+    expect(suggestions.length).toBeGreaterThan(0);
+  });
+
+  it("puts the workshop's own wording first, then what it has used before", () => {
+    const suggestions = rankSuggestions(
+      { ...BRAKE_PADS, defectSuggestions: ["Pads at 2mm, replace"] },
+      { history: [{ text: "Rear pads low", severity: "fail" }] }
+    );
+    expect(suggestions[0].text).toBe("Pads at 2mm, replace");
+    expect(suggestions[0].source).toBe("workshop");
+    expect(suggestions[1].text).toBe("Rear pads low");
+    expect(suggestions[1].source).toBe("history");
+  });
+
+  it("floats phrases matching the grade already picked, without hiding the rest", () => {
+    const suggestions = rankSuggestions(BRAKE_PADS, { preferred: "dangerous" });
+    expect(suggestions[0].severity).toBe("dangerous");
+    // Other severities are still reachable — the same fault can be graded
+    // differently depending on how far gone it is.
+    expect(suggestions.some((s) => s.severity === "fail")).toBe(true);
+  });
+
+  it("drops the dangerous category on the plain three-step scale", () => {
+    const suggestions = rankSuggestions(BRAKE_PADS, { scale: "basic" });
+    expect(suggestions.some((s) => s.severity === "dangerous")).toBe(false);
+    expect(suggestions.length).toBeGreaterThan(0);
+  });
+
+  it("does not repeat a phrase that appears in more than one source", () => {
+    const suggestions = rankSuggestions(BRAKE_PADS, {
+      history: [
+        { text: "Lining or pad missing or incorrectly mounted", severity: "fail" },
+      ],
+    });
+    const matches = suggestions.filter(
+      (s) => s.text === "Lining or pad missing or incorrectly mounted"
+    );
+    expect(matches).toHaveLength(1);
+    // The shop's own record of it wins, so its grade is the one that survives.
+    expect(matches[0].source).toBe("history");
+  });
+
+  it("grades a workshop phrase as whatever the technician already picked", () => {
+    const suggestions = rankSuggestions(
+      { ...BRAKE_PADS, defectSuggestions: ["Shop standard wording"] },
+      { preferred: "attention" }
+    );
+    expect(suggestions[0].severity).toBe("attention");
+  });
+});

+ 120 - 0
src/__tests__/features/inspections/preset-library.test.ts

@@ -0,0 +1,120 @@
+import { describe, it, expect } from "vitest";
+import {
+  TEMPLATE_PRESETS,
+  countPresetItems,
+} from "@/features/inspections/Lib/templatePresets";
+
+/**
+ * Mirrors syncPresetLibrary's decision, which is the part that would be
+ * infuriating if wrong: a checklist the workshop deleted must never come back
+ * on its own, while a checklist added to the library later must arrive without
+ * anyone going to fetch it.
+ */
+function pendingPresets(handled: string[], existingNames: string[]) {
+  const handledSet = new Set(handled);
+  const taken = new Set(existingNames.map((n) => n.trim().toLowerCase()));
+  const pending = TEMPLATE_PRESETS.filter((p) => p.id !== "blank" && !handledSet.has(p.id));
+  return {
+    created: pending.filter((p) => !taken.has(p.name.trim().toLowerCase())).map((p) => p.id),
+    marked: pending.map((p) => p.id),
+  };
+}
+
+describe("preset library sync", () => {
+  const libraryIds = TEMPLATE_PRESETS.filter((p) => p.id !== "blank").map((p) => p.id);
+
+  it("installs the whole library for an organization that has never seen it", () => {
+    const { created } = pendingPresets([], []);
+    expect(created).toEqual(libraryIds);
+  });
+
+  it("does nothing once every preset has been handled", () => {
+    const { created, marked } = pendingPresets(libraryIds, []);
+    expect(created).toEqual([]);
+    expect(marked).toEqual([]);
+  });
+
+  it("never brings back a checklist the workshop deleted", () => {
+    // Handled, but no longer in the list — the workshop removed it on purpose.
+    const { created } = pendingPresets(libraryIds, ["Standard multi-point inspection"]);
+    expect(created).toEqual([]);
+  });
+
+  it("delivers a checklist added to the library in a later release", () => {
+    const alreadyShipped = libraryIds.filter((id) => id !== "marine");
+    const { created } = pendingPresets(alreadyShipped, []);
+    expect(created).toEqual(["marine"]);
+  });
+
+  it("does not duplicate a checklist the workshop already built by that name", () => {
+    const { created, marked } = pendingPresets([], ["Marine vessel inspection"]);
+    expect(created).not.toContain("marine");
+    // Still marked handled, so it is not retried on the next page load.
+    expect(marked).toContain("marine");
+  });
+});
+
+
+/**
+ * The whole library is installed for a workshop rather than picked one at a
+ * time, so these guard the shape of what gets written: every preset has to be
+ * uniquely named (installation skips by name), and none may be empty.
+ */
+describe("preset library", () => {
+  const library = TEMPLATE_PRESETS.filter((p) => p.id !== "blank");
+
+  it("names every preset uniquely, since installation matches by name", () => {
+    const names = library.map((p) => p.name.trim().toLowerCase());
+    expect(new Set(names).size).toBe(names.length);
+  });
+
+  it("gives every preset at least one section and one check", () => {
+    for (const preset of library) {
+      expect(preset.sections.length, preset.name).toBeGreaterThan(0);
+      expect(countPresetItems(preset), preset.name).toBeGreaterThan(0);
+      for (const section of preset.sections) {
+        expect(section.items.length, `${preset.name} / ${section.name}`).toBeGreaterThan(0);
+      }
+    }
+  });
+
+  it("keeps the blank starting point out of the library", () => {
+    // "blank" is a way to start building a checklist, not a checklist, so
+    // stocking it for every workshop would just be a row to delete.
+    expect(library.some((p) => p.id === "blank")).toBe(false);
+    expect(TEMPLATE_PRESETS.some((p) => p.id === "blank")).toBe(true);
+  });
+
+  it("ships exactly one general checklist to be the default", () => {
+    // installPresetsForOrg marks standard-multipoint as default when the
+    // organization has none, rather than a national statutory test it may not
+    // be approved to run.
+    expect(library.filter((p) => p.id === "standard-multipoint")).toHaveLength(1);
+  });
+
+  it("gives every measurement check a unit or a bound to grade against", () => {
+    for (const preset of library) {
+      for (const section of preset.sections) {
+        for (const item of section.items) {
+          if (item.inputType !== "measurement") continue;
+          const usable =
+            !!item.unit || item.minValue !== undefined || item.maxValue !== undefined;
+          expect(usable, `${preset.name} / ${item.name}`).toBe(true);
+        }
+      }
+    }
+  });
+
+  it("only grades an out-of-range reading as a real defect category", () => {
+    for (const preset of library) {
+      for (const section of preset.sections) {
+        for (const item of section.items) {
+          if (!item.defaultSeverity) continue;
+          expect(["attention", "fail", "dangerous"], item.name).toContain(
+            item.defaultSeverity
+          );
+        }
+      }
+    }
+  });
+});

+ 89 - 0
src/__tests__/features/inspections/template-numbering.test.ts

@@ -0,0 +1,89 @@
+import { describe, it, expect } from "vitest";
+import {
+  blankSection,
+  nextItemCode,
+  nextSectionCode,
+  renumber,
+  type EditorSection,
+} from "@/features/inspections/Components/TemplateForm";
+
+const item = (code: string) =>
+  ({ key: code || "k", name: "", description: "", code, inputType: "condition" }) as never;
+
+const section = (code: string, itemCodes: string[]): EditorSection =>
+  ({
+    key: `s${code}`,
+    name: "",
+    description: "",
+    code,
+    items: itemCodes.map(item),
+  }) as EditorSection;
+
+describe("new sections", () => {
+  it("numbers the section and the check it starts with", () => {
+    const created = blankSection("3");
+    expect(created.code).toBe("3");
+    // The first check used to come out blank because it was built before the
+    // section knew its own number.
+    expect(created.items).toHaveLength(1);
+    expect(created.items[0].code).toBe("3.1");
+  });
+
+  it("leaves the first check unnumbered when the section is", () => {
+    expect(blankSection().items[0].code).toBe("");
+  });
+
+  it("continues correctly from a section created this way", () => {
+    const created = blankSection("3");
+    expect(nextItemCode(created, 2)).toBe("3.2");
+  });
+});
+
+describe("template reference numbering", () => {
+  it("numbers the first section 1", () => {
+    expect(nextSectionCode([])).toBe("1");
+  });
+
+  it("continues from the highest section number, not the count", () => {
+    // A deleted section must not cause the next one to reuse a number.
+    expect(nextSectionCode([section("1", []), section("4", [])])).toBe("5");
+  });
+
+  it("ignores sections whose reference is not a number", () => {
+    expect(nextSectionCode([section("Brakes", []), section("2", [])])).toBe("3");
+  });
+
+  it("numbers a check under its section", () => {
+    expect(nextItemCode(section("3", ["3.1", "3.2"]), 2)).toBe("3.3");
+  });
+
+  it("falls back to the section position when it has no reference", () => {
+    expect(nextItemCode(section("", []), 0)).toBe("1.1");
+  });
+
+  it("does not renumber from an Annex I citation it cannot own", () => {
+    // 1.1.13 belongs to the Directive; the next added check continues the
+    // section's own sequence rather than trying to extend the citation.
+    expect(nextItemCode(section("1", ["1.1.13", "1.2"]), 0)).toBe("1.3");
+  });
+
+  it("renumbers everything in order when asked", () => {
+    const result = renumber([
+      section("7", ["7.4", "7.9"]),
+      section("2", ["2.1"]),
+    ]);
+    expect(result[0].code).toBe("1");
+    expect(result[0].items.map((i) => i.code)).toEqual(["1.1", "1.2"]);
+    expect(result[1].code).toBe("2");
+    expect(result[1].items.map((i) => i.code)).toEqual(["2.1"]);
+  });
+
+  it("leaves everything else on a row untouched when renumbering", () => {
+    const source = section("5", ["5.1"]);
+    source.name = "Braking equipment";
+    source.items[0].name = "Brake linings and pads";
+    const [result] = renumber([source]);
+    expect(result.name).toBe("Braking equipment");
+    expect(result.items[0].name).toBe("Brake linings and pads");
+  });
+});

+ 56 - 0
src/__tests__/features/inspections/test-intervals.test.ts

@@ -0,0 +1,56 @@
+import { describe, it, expect } from "vitest";
+import {
+  DEFAULT_INTERVAL_MONTHS,
+  TEST_INTERVALS,
+  addInterval,
+  matchInterval,
+  toISODate,
+} from "@/features/inspections/Lib/testIntervals";
+
+describe("test intervals", () => {
+  it("proposes two years, the Union baseline for cars and light goods vehicles", () => {
+    expect(DEFAULT_INTERVAL_MONTHS).toBe(24);
+    expect(addInterval(new Date(2026, 7, 15), DEFAULT_INTERVAL_MONTHS)).toBe("2028-08-15");
+  });
+
+  it("offers the intervals a member state might apply instead", () => {
+    expect(TEST_INTERVALS.map((i) => i.label)).toEqual([
+      "6 months",
+      "1 year",
+      "2 years",
+      "3 years",
+      "4 years",
+    ]);
+  });
+
+  it("adds part-year intervals by month", () => {
+    expect(addInterval(new Date(2026, 7, 15), 6)).toBe("2027-02-15");
+  });
+
+  it("keeps a leap-day test from drifting into March", () => {
+    // Naive month arithmetic would roll 29 February 2028 to 1 March 2030.
+    expect(addInterval(new Date(2028, 1, 29), 24)).toBe("2030-02-28");
+  });
+
+  it("does not shift the date across a timezone boundary", () => {
+    // toISODate reads local components, so a date built at local midnight
+    // survives the round trip rather than slipping to the previous day.
+    expect(toISODate(new Date(2026, 0, 1))).toBe("2026-01-01");
+  });
+
+  it("recognises which interval a stored date came from", () => {
+    const testDate = new Date(2026, 7, 15);
+    expect(matchInterval(testDate, "2027-08-15")?.label).toBe("1 year");
+    expect(matchInterval(testDate, "2028-08-15")?.label).toBe("2 years");
+  });
+
+  it("reports no interval for a date typed by hand", () => {
+    expect(matchInterval(new Date(2026, 7, 15), "2027-03-04")).toBeUndefined();
+  });
+
+  it("treats a missing date as empty rather than as the epoch", () => {
+    expect(toISODate(null)).toBe("");
+    expect(toISODate(undefined)).toBe("");
+    expect(toISODate("not a date")).toBe("");
+  });
+});

+ 78 - 37
src/__tests__/features/public/inspection-view.test.tsx

@@ -1,19 +1,20 @@
 import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, waitFor } from "@testing-library/react";
+import { render, screen, waitFor, within } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
 
-vi.mock("lucide-react", () => ({
-  AlertTriangle: () => <span data-testid="icon-alert" />,
-  Check: () => <span data-testid="icon-check" />,
-  CheckCircle2: () => <span data-testid="icon-check-circle" />,
-  ChevronLeft: () => <span data-testid="icon-left" />,
-  ChevronRight: () => <span data-testid="icon-right" />,
-  ClipboardCheck: () => <span data-testid="icon-clipboard" />,
-  Download: () => <span data-testid="icon-download" />,
-  FileText: () => <span data-testid="icon-file-text" />,
-  Loader2: () => <span data-testid="icon-loader" />,
-  X: () => <span data-testid="icon-x" />,
-}));
+// Stub every icon rather than listing them: the view pulls a different set as
+// the report gains defect categories, and an exhaustive mock only breaks again.
+vi.mock("lucide-react", async (importOriginal) => {
+  const actual = await importOriginal<Record<string, unknown>>();
+  const Icon = () => <span data-testid="icon" />;
+  return new Proxy(
+    { ...actual },
+    {
+      get: (target, prop: string) =>
+        prop === "__esModule" ? true : prop in target ? Icon : undefined,
+    }
+  );
+});
 
 vi.mock("sonner", () => ({
   toast: {
@@ -134,7 +135,7 @@ describe("InspectionView", () => {
     it("renders vehicle information", () => {
       render(<InspectionView {...DEFAULT_PROPS} />);
       expect(screen.getByText("2021 Ford F-150")).toBeInTheDocument();
-      expect(screen.getByText("VIN: FORD123")).toBeInTheDocument();
+      expect(screen.getByText("FORD123")).toBeInTheDocument();
     });
 
     it("renders customer information", () => {
@@ -151,25 +152,42 @@ describe("InspectionView", () => {
   describe("summary bar", () => {
     it("shows total inspected item count", () => {
       render(<InspectionView {...DEFAULT_PROPS} />);
-      // The summary renders: <span class="font-semibold text-gray-900">3</span> inspected
-      // getByText can't match compound nodes, so check the count span directly.
-      const countSpan = document.querySelector("span.font-semibold.text-gray-900");
-      expect(countSpan?.textContent).toBe("3");
+      expect(screen.getByText("inspected").parentElement?.textContent).toContain("3");
     });
 
+    // Counts render as a number next to its EU defect category, in their own
+    // elements, and the same category names also appear on the item badges —
+    // so scope the lookup to the summary region and read the pair together.
+    const countFor = (label: string) =>
+      within(screen.getByRole("region", { name: "Summary" }))
+        .getByText(label)
+        .parentElement?.textContent;
+
     it("shows correct pass count", () => {
       render(<InspectionView {...DEFAULT_PROPS} />);
-      expect(screen.getByText("1 Pass")).toBeInTheDocument();
+      expect(countFor("No defect")).toBe("1No defect");
     });
 
-    it("shows correct fail count", () => {
+    it("shows correct major defect count", () => {
       render(<InspectionView {...DEFAULT_PROPS} />);
-      expect(screen.getByText("1 Fail")).toBeInTheDocument();
+      expect(countFor("Major defect")).toBe("1Major defect");
     });
 
-    it("shows correct attention count", () => {
+    it("shows correct minor defect count", () => {
       render(<InspectionView {...DEFAULT_PROPS} />);
-      expect(screen.getByText("1 Attention")).toBeInTheDocument();
+      expect(countFor("Minor defect")).toBe("1Minor defect");
+    });
+
+    it("shows a dangerous defect count on the EU scale", () => {
+      const props = {
+        ...DEFAULT_PROPS,
+        inspection: {
+          ...BASE_INSPECTION,
+          items: [PASS_ITEM, { ...FAIL_ITEM, condition: "dangerous" }],
+        },
+      };
+      render(<InspectionView {...props} />);
+      expect(countFor("Dangerous defect")).toBe("1Dangerous defect");
     });
 
     it("excludes not_inspected items from the summary", () => {
@@ -184,8 +202,8 @@ describe("InspectionView", () => {
         },
       };
       render(<InspectionView {...props} />);
-      expect(screen.getByText("1 Pass")).toBeInTheDocument();
-      expect(screen.getByText("0 Fail")).toBeInTheDocument();
+      expect(countFor("No defect")).toBe("1No defect");
+      expect(countFor("Major defect")).toBe("0Major defect");
     });
   });
 
@@ -197,24 +215,38 @@ describe("InspectionView", () => {
       expect(screen.getByText("Tires")).toBeInTheDocument();
     });
 
-    it("shows Pass badge on passing item", () => {
+    it("labels a passing item with the EU no-defect grade", () => {
       render(<InspectionView {...DEFAULT_PROPS} />);
-      expect(screen.getByText("Pass")).toBeInTheDocument();
+      expect(screen.getAllByText("No defect").length).toBeGreaterThan(0);
     });
 
-    it("shows Fail badge on failing item", () => {
+    it("labels a failing item as a major defect", () => {
       render(<InspectionView {...DEFAULT_PROPS} />);
-      expect(screen.getByText("Fail")).toBeInTheDocument();
+      expect(screen.getAllByText("Major defect").length).toBeGreaterThan(0);
     });
 
-    it("shows Attention badge on attention item", () => {
+    it("labels an attention item as a minor defect", () => {
       render(<InspectionView {...DEFAULT_PROPS} />);
-      expect(screen.getByText("Attention")).toBeInTheDocument();
+      expect(screen.getAllByText("Minor defect").length).toBeGreaterThan(0);
+    });
+
+    it("uses the plain scale when the template asks for it", () => {
+      const props = {
+        ...DEFAULT_PROPS,
+        inspection: {
+          ...BASE_INSPECTION,
+          template: { name: "Full Inspection", severityScale: "basic" },
+        },
+      };
+      render(<InspectionView {...props} />);
+      expect(screen.getAllByText("Attention").length).toBeGreaterThan(0);
+      expect(screen.queryByText("Minor defect")).not.toBeInTheDocument();
     });
 
     it("renders item notes when present", () => {
       render(<InspectionView {...DEFAULT_PROPS} />);
-      expect(screen.getByText("Worn down to 2mm")).toBeInTheDocument();
+      // Once in the deficiencies summary and once in the full results.
+      expect(screen.getAllByText("Worn down to 2mm").length).toBeGreaterThan(0);
     });
 
     it("does not render not_inspected items", () => {
@@ -388,8 +420,7 @@ describe("InspectionView", () => {
       render(<InspectionView {...props} />);
       const thumbnail = screen.getByAltText("Oil Level 1").closest("button")!;
       await userEvent.click(thumbnail);
-      // Carousel modal appears — only one X icon because PASS uses Check icon
-      expect(screen.getByTestId("icon-x").closest("button")).toBeInTheDocument();
+      expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true");
     });
 
     it("closes carousel when X button is clicked", async () => {
@@ -399,9 +430,19 @@ describe("InspectionView", () => {
       };
       render(<InspectionView {...props} />);
       await userEvent.click(screen.getByAltText("Oil Level 1").closest("button")!);
-      await userEvent.click(screen.getByTestId("icon-x").closest("button")!);
-      // After close, the carousel X button is gone
-      expect(screen.queryByTestId("icon-x")).not.toBeInTheDocument();
+      await userEvent.click(screen.getByRole("button", { name: "Close image viewer" }));
+      expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+    });
+
+    it("closes the viewer on Escape", async () => {
+      const props = {
+        ...DEFAULT_PROPS,
+        inspection: { ...BASE_INSPECTION, items: [IMG_ITEM] },
+      };
+      render(<InspectionView {...props} />);
+      await userEvent.click(screen.getByAltText("Oil Level 1").closest("button")!);
+      await userEvent.keyboard("{Escape}");
+      expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
     });
   });
 });

+ 2 - 0
src/__tests__/multitenancy/inspection-isolation.test.ts

@@ -148,6 +148,8 @@ describe("completeInspection — cross-org isolation", () => {
       id: "insp-a",
       vehicleId: "veh-a",
       organizationId: ORG_A,
+      // Completion checks the items for anything the template made mandatory.
+      items: [],
     } as any);
     vi.mocked(db.inspection.updateMany).mockResolvedValue({ count: 1 } as any);
 

+ 18 - 4
src/app/(authenticated)/inspections/[id]/page.tsx

@@ -1,4 +1,10 @@
-import { getInspection } from "@/features/inspections/Actions/inspectionActions";
+import {
+  getCommonDefectNotes,
+  getInspection,
+  getInspectionTechnicians,
+} from "@/features/inspections/Actions/inspectionActions";
+import { getSettings } from "@/features/settings/Actions/settingsActions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { InspectionPageClient, type InspectionData } from "@/features/inspections/Components/InspectionPageClient";
 import { PageHeader } from "@/components/page-header";
 import { getAuthContext } from "@/lib/get-auth-context";
@@ -21,9 +27,12 @@ export default async function InspectionDetailPage({
     redirect("/inspections");
   }
 
-  const features = authContext?.organizationId
-    ? await getFeatures(authContext.organizationId)
-    : null;
+  const [features, defectHistory, technicians, settings] = await Promise.all([
+    authContext?.organizationId ? getFeatures(authContext.organizationId) : null,
+    getCommonDefectNotes(id),
+    getInspectionTechnicians(),
+    getSettings([SETTING_KEYS.WORKSHOP_ADDRESS]),
+  ]);
 
   return (
     <>
@@ -33,6 +42,11 @@ export default async function InspectionDetailPage({
           inspection={result.data as InspectionData}
           smsEnabled={features?.sms ?? false}
           emailEnabled={features?.smtp ?? false}
+          defectHistory={defectHistory.success ? defectHistory.data : {}}
+          technicians={technicians.success ? technicians.data : []}
+          workshopAddress={
+            (settings.success && settings.data?.[SETTING_KEYS.WORKSHOP_ADDRESS]) || ""
+          }
         />
       </div>
     </>

+ 5 - 19
src/app/(authenticated)/inspections/inspections-client.tsx

@@ -23,6 +23,7 @@ import { TableCellLink } from "@/components/table-cell-link";
 import { ArrowDown, ArrowUp, ArrowUpDown, Car, ExternalLink, Loader2, Plus, Search } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { NewInspectionDialog } from "@/features/inspections/Components/NewInspectionDialog";
+import { CONDITION_TOKENS, countConditions } from "@/features/inspections/Lib/conditions";
 
 interface InspectionRecord {
   id: string;
@@ -61,37 +62,22 @@ const statusColors: Record<string, string> = {
   completed: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
 };
 
-const conditionColors: Record<string, string> = {
-  pass: "bg-emerald-500",
-  fail: "bg-red-500",
-  attention: "bg-amber-500",
-  not_inspected: "bg-gray-300 dark:bg-gray-600",
-};
-
 function InspectionProgress({ items }: { items: { condition: string }[] }) {
   if (items.length === 0) return null;
-  const counts = { pass: 0, fail: 0, attention: 0, not_inspected: 0 };
-  for (const item of items) {
-    const c = item.condition as keyof typeof counts;
-    if (c in counts) counts[c]++;
-  }
+  const counts = countConditions(items);
   return (
     <div className="flex items-center gap-1.5">
       <div className="flex h-2 w-20 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
-        {(["pass", "fail", "attention", "not_inspected"] as const).map((c) => {
+        {(["pass", "attention", "fail", "dangerous"] as const).map((c) => {
           const pct = (counts[c] / items.length) * 100;
           if (pct === 0) return null;
           return (
-            <div
-              key={c}
-              className={conditionColors[c]}
-              style={{ width: `${pct}%` }}
-            />
+            <div key={c} className={CONDITION_TOKENS[c].bar} style={{ width: `${pct}%` }} />
           );
         })}
       </div>
       <span className="text-xs text-muted-foreground">
-        {items.length - counts.not_inspected}/{items.length}
+        {counts.inspected}/{counts.total}
       </span>
     </div>
   );

+ 13 - 8
src/app/(authenticated)/layout.tsx

@@ -21,6 +21,7 @@ import { isSupportEnabled } from "@/lib/support";
 import { ServiceTypeProvider } from "@/components/service-type-context";
 import { LicenseExpiryProvider } from "@/components/license-expiry-context";
 import { db } from "@/lib/db";
+import { isDemoMode } from "@/lib/demo";
 
 export default async function DashboardLayout({
   children,
@@ -49,7 +50,7 @@ export default async function DashboardLayout({
   }
 
   const features = await getFeatures(data.organizationId);
-  const showWhiteLabelCta = !isCloudMode() && !features.brandingRemoved;
+  const showWhiteLabelCta = !isCloudMode() && !features.brandingRemoved && !isDemoMode;
   // Cloud only, and off until a platform admin turns it on. Resolved here so
   // the widget's code never reaches a self-hosted install's page.
   const supportEnabled = await isSupportEnabled();
@@ -116,13 +117,17 @@ export default async function DashboardLayout({
     <ServiceTypeProvider serviceType={data.serviceType}>
     <LicenseExpiryProvider daysUntilExpiry={daysUntilExpiry} dismissed={licenseExpiryDismissed}>
     <WhiteLabelCtaProvider show={showWhiteLabelCta}>
-    <UpdateBanner
-      currentVersion={process.env.APP_VERSION || "development"}
-      lastSeenVersion={data.lastSeenVersion}
-      releaseNotesUrl={
-        process.env.RELEASE_NOTES_URL || "https://github.com/Torqvoice/torqvoice/releases"
-      }
-    />
+    {/* The demo banner already occupies the header, and demo image tags
+        (demo-abc1234) are not versions a visitor should be notified about. */}
+    {!isDemoMode && (
+      <UpdateBanner
+        currentVersion={process.env.APP_VERSION || "development"}
+        lastSeenVersion={data.lastSeenVersion}
+        releaseNotesUrl={
+          process.env.RELEASE_NOTES_URL || "https://github.com/Torqvoice/torqvoice/releases"
+        }
+      />
+    )}
     <SidebarProvider
       defaultOpen={sidebarOpen}
       style={

+ 2 - 0
src/app/(authenticated)/settings/license/page.tsx

@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
 import { isCloudMode } from "@/lib/features";
 import { LicenseSettings } from "@/features/settings/Components/license-settings";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { isDemoMode } from "@/lib/demo";
 
 export default async function LicensePage() {
   if (isCloudMode()) {
@@ -35,6 +36,7 @@ export default async function LicensePage() {
       initialKey={map.get(SETTING_KEYS.LICENSE_KEY) || ""}
       initialValid={map.get(SETTING_KEYS.LICENSE_VALID) === "true"}
       initialCheckedAt={map.get(SETTING_KEYS.LICENSE_CHECKED_AT) || ""}
+      demoMode={isDemoMode}
     />
   );
 }

+ 23 - 1
src/app/(authenticated)/settings/templates/template-settings.tsx

@@ -427,11 +427,30 @@ function SmsTemplateTab({
   );
 }
 
+interface InspectionTemplateItem {
+  id: string;
+  name: string;
+  description?: string | null;
+  code?: string | null;
+  sortOrder: number;
+  inputType?: string | null;
+  unit?: string | null;
+  minValue?: number | null;
+  maxValue?: number | null;
+  choices?: string[];
+  required?: boolean;
+  photoRequired?: boolean;
+  defaultSeverity?: string | null;
+  defectSuggestions?: string[];
+}
+
 interface InspectionTemplateSection {
   id: string;
   name: string;
+  description?: string | null;
+  code?: string | null;
   sortOrder: number;
-  items: { id: string; name: string; sortOrder: number }[];
+  items: InspectionTemplateItem[];
 }
 
 interface InspectionTemplate {
@@ -439,6 +458,9 @@ interface InspectionTemplate {
   name: string;
   description: string | null;
   isDefault: boolean;
+  country?: string | null;
+  standard?: string | null;
+  severityScale?: string | null;
   sections: InspectionTemplateSection[];
 }
 

+ 1 - 1
src/app/(authenticated)/vehicles/page.tsx

@@ -21,7 +21,7 @@ export default async function VehiclesPage({
   const isArchived = params.archived === "true";
   const cookieStore = await cookies();
   const viewCookie = cookieStore.get("torqvoice-vehicles-view")?.value;
-  const initialView = viewCookie === "grid" ? "grid" : viewCookie === "grid6" ? "grid6" : "table";
+  const initialView = viewCookie === "table" ? "table" : viewCookie === "grid6" ? "grid6" : "grid";
   const [result, customersResult] = await Promise.all([
     getVehiclesPaginated({
       page: params.page ? parseInt(params.page) : 1,

+ 9 - 5
src/app/(public)/auth/sign-in/page.tsx

@@ -1,17 +1,21 @@
 import { db } from '@/lib/db'
 import { SignInForm } from './sign-in-form'
+import { isDemoMode } from '@/lib/demo'
 
 export const dynamic = 'force-dynamic'
 
 export default async function SignInPage() {
-  const regSetting = await db.systemSetting.findUnique({
-    where: { key: 'registration.disabled' },
-    select: { value: true },
-  })
+  const regSetting = isDemoMode
+    ? null
+    : await db.systemSetting.findUnique({
+        where: { key: 'registration.disabled' },
+        select: { value: true },
+      })
 
   return (
     <SignInForm
-      registrationDisabled={regSetting?.value === 'true'}
+      registrationDisabled={isDemoMode || regSetting?.value === 'true'}
+      demoMode={isDemoMode}
     />
   )
 }

+ 58 - 5
src/app/(public)/auth/sign-in/sign-in-form.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { Suspense, useRef, useState } from 'react'
+import { Suspense, useEffect, useRef, useState } from 'react'
 import { useRouter, useSearchParams } from 'next/navigation'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
@@ -9,10 +9,13 @@ import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Separator } from '@/components/ui/separator'
-import { Fingerprint, Loader2, XCircle } from 'lucide-react'
+import { Fingerprint, Loader2, PlayCircle, XCircle } from 'lucide-react'
 import { AuthLogo } from '@/components/auth-logo'
 
-function SignInFormInner({ registrationDisabled }: { registrationDisabled: boolean }) {
+const DEMO_EMAIL = 'demo@torqvoice.com'
+const DEMO_PASSWORD = 'demo'
+
+function SignInFormInner({ registrationDisabled, demoMode = false }: { registrationDisabled: boolean; demoMode?: boolean }) {
   const t = useTranslations('auth.signIn')
   const tc = useTranslations('common')
   const [email, setEmail] = useState('')
@@ -52,6 +55,34 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
     }
   }
 
+  const handleDemoSignIn = async () => {
+    setLoading(true)
+    setError('')
+    try {
+      const result = await signIn.email({ email: DEMO_EMAIL, password: DEMO_PASSWORD })
+      if (result.error) {
+        setError(result.error.message || tc('errors.unexpected'))
+        setLoading(false)
+      } else {
+        router.push('/')
+        router.refresh()
+      }
+    } catch {
+      setError(tc('errors.unexpected'))
+      setLoading(false)
+    }
+  }
+
+  // Auto-sign-in when arriving with ?demo=1 (deep-link from marketing site)
+  const autoTriggered = useRef(false)
+  useEffect(() => {
+    if (!demoMode || autoTriggered.current) return
+    if (searchParams.get('demo') !== '1') return
+    autoTriggered.current = true
+    void handleDemoSignIn()
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [demoMode, searchParams])
+
   const handlePasskeySignIn = async () => {
     setPasskeyLoading(true)
     setError('')
@@ -99,6 +130,28 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
         </div>
       )}
 
+      {demoMode && (
+        <div className="mb-6">
+          <Button
+            type="button"
+            className="h-11 w-full"
+            disabled={loading}
+            onClick={handleDemoSignIn}
+          >
+            {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <PlayCircle className="mr-2 h-4 w-4" />}
+            Try the demo
+          </Button>
+          <p className="mt-2 text-center text-xs text-muted-foreground">
+            Or sign in manually with <code className="rounded bg-muted px-1">{DEMO_EMAIL}</code> / <code className="rounded bg-muted px-1">{DEMO_PASSWORD}</code>
+          </p>
+          <div className="relative my-4">
+            <div className="absolute inset-0 flex items-center">
+              <Separator className="w-full" />
+            </div>
+          </div>
+        </div>
+      )}
+
       <form onSubmit={handleSubmit} className="space-y-4">
         <div className="space-y-2">
           <Label htmlFor="email">{tc('form.email')}</Label>
@@ -188,7 +241,7 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
   )
 }
 
-export function SignInForm({ registrationDisabled }: { registrationDisabled: boolean }) {
+export function SignInForm({ registrationDisabled, demoMode = false }: { registrationDisabled: boolean; demoMode?: boolean }) {
   return (
     <div className="grid-bg flex min-h-screen items-center justify-center p-4">
       <div className="absolute inset-0 overflow-hidden">
@@ -196,7 +249,7 @@ export function SignInForm({ registrationDisabled }: { registrationDisabled: boo
         <div className="absolute -bottom-40 -left-40 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
       </div>
       <Suspense>
-        <SignInFormInner registrationDisabled={registrationDisabled} />
+        <SignInFormInner registrationDisabled={registrationDisabled} demoMode={demoMode} />
       </Suspense>
     </div>
   )

+ 4 - 0
src/app/(public)/auth/sign-up/page.tsx

@@ -3,6 +3,7 @@ import { headers } from 'next/headers'
 import { db } from '@/lib/db'
 import { auth } from '@/lib/auth'
 import { SignUpForm } from './sign-up-form'
+import { isDemoMode } from '@/lib/demo'
 
 export const dynamic = 'force-dynamic'
 
@@ -23,6 +24,9 @@ export default async function SignUpPage({
 
   // If there's an invite token, skip the registration-disabled check
   if (!inviteToken) {
+    if (isDemoMode) {
+      redirect('/auth/sign-in')
+    }
     const regSetting = await db.systemSetting.findUnique({
       where: { key: 'registration.disabled' },
       select: { value: true },

+ 436 - 352
src/app/(public)/share/inspection/[orgId]/[token]/inspection-view.tsx

@@ -1,11 +1,37 @@
 "use client";
 
-import { useState, useCallback, useEffect, useRef } from "react";
+import { useMemo, useState } from "react";
 import { useTranslations } from "next-intl";
 import { formatDate as fmtDate, DEFAULT_DATE_FORMAT } from "@/lib/format";
 import { Button } from "@/components/ui/button";
-import { Check, CheckCircle2, Download, FileText, X, AlertTriangle, ClipboardCheck, ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
+import {
+  Check,
+  CheckCircle2,
+  Download,
+  FileText,
+  Loader2,
+  Minus,
+  OctagonAlert,
+  ShieldCheck,
+  TriangleAlert,
+  XCircle,
+} from "lucide-react";
 import { QuoteRequestDialog } from "@/features/inspections/Components/QuoteRequestDialog";
+import {
+  MediaLightbox,
+  type LightboxImage,
+} from "@/features/inspections/Components/MediaLightbox";
+import {
+  CONDITION_TOKENS,
+  TEST_RESULT_TOKENS,
+  countConditions,
+  deriveTestResult,
+  gradedConditionLabel,
+  formatRange,
+  isDefect,
+  type Condition,
+  type SeverityScale,
+} from "@/features/inspections/Lib/conditions";
 import { toast } from "sonner";
 
 interface InspectionItem {
@@ -16,6 +42,15 @@ interface InspectionItem {
   condition: string;
   notes: string | null;
   imageUrls: string[];
+  description?: string | null;
+  code?: string | null;
+  sectionCode?: string | null;
+  inputType?: string | null;
+  unit?: string | null;
+  minValue?: number | null;
+  maxValue?: number | null;
+  measuredValue?: number | null;
+  textValue?: string | null;
 }
 
 interface InspectionRecord {
@@ -25,6 +60,11 @@ interface InspectionRecord {
   notes: string | null;
   completedAt: Date | null;
   createdAt: Date;
+  vehicleCategory?: string | null;
+  nextTestDue?: Date | null;
+  certificateNumber?: string | null;
+  inspectorName?: string | null;
+  testLocation?: string | null;
   vehicle: {
     make: string;
     model: string;
@@ -32,12 +72,22 @@ interface InspectionRecord {
     vin: string | null;
     licensePlate: string | null;
     mileage: number;
-    customer: { name: string; email: string | null; phone: string | null } | null;
+    customer: { name: string } | null;
   };
-  template: { name: string };
+  template: { name: string; severityScale?: string | null; country?: string | null };
   items: InspectionItem[];
 }
 
+const CONDITION_ICONS: Record<Condition, React.ComponentType<{ className?: string }>> = {
+  pass: Check,
+  attention: TriangleAlert,
+  fail: XCircle,
+  dangerous: OctagonAlert,
+  not_inspected: Minus,
+};
+
+const isVideo = (url: string) => /\.(mp4|webm|mov)$/i.test(url);
+
 export function InspectionView({
   inspection,
   workshop,
@@ -67,44 +117,69 @@ export function InspectionView({
   portalUrl?: string;
   serviceType?: "automotive" | "marine";
 }) {
-  const t = useTranslations('share.inspection');
-  const tc = useTranslations('share.common');
+  const t = useTranslations("share.inspection");
+  const tc = useTranslations("share.common");
 
   const fmt = dateFormat || DEFAULT_DATE_FORMAT;
   const tz = timezone || "America/New_York";
-  const formatDate = (d: Date) => fmtDate(new Date(d), fmt, tz);
-
-  const conditionConfig: Record<string, { label: string; color: string; bgColor: string; icon: React.ReactNode }> = {
-    pass: {
-      label: t('pass'),
-      color: "text-emerald-700",
-      bgColor: "bg-emerald-100 border-emerald-300",
-      icon: <Check className="h-4 w-4 text-emerald-600" />,
-    },
-    fail: {
-      label: t('fail'),
-      color: "text-red-700",
-      bgColor: "bg-red-100 border-red-300",
-      icon: <X className="h-4 w-4 text-red-600" />,
-    },
-    attention: {
-      label: t('attention'),
-      color: "text-amber-700",
-      bgColor: "bg-amber-100 border-amber-300",
-      icon: <AlertTriangle className="h-4 w-4 text-amber-600" />,
-    },
-    not_inspected: {
-      label: t('notInspected'),
-      color: "text-gray-500",
-      bgColor: "bg-gray-100 border-gray-300",
-      icon: null,
-    },
-  };
+  const formatDate = (d: Date | string) => fmtDate(new Date(d), fmt, tz);
+
+  const scale: SeverityScale = inspection.template.severityScale === "basic" ? "basic" : "eu";
+  const conditionText = (condition: Condition) =>
+    scale === "basic" ? t(`basic.${condition}`) : t(`eu.${condition}`);
+  // Several member states record defects by grade number rather than by name,
+  // so the number leads and the wording follows.
+  const country = inspection.template.country ?? null;
+  const gradedText = (condition: Condition) =>
+    gradedConditionLabel(condition, scale, country, conditionText(condition));
 
   const [showQuoteDialog, setShowQuoteDialog] = useState(false);
   const [quoteRequested, setQuoteRequested] = useState(hasExistingQuoteRequest);
   const [isCancelling, setIsCancelling] = useState(false);
-  const [carouselIndex, setCarouselIndex] = useState<number | null>(null);
+  const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
+
+  // A check the technician never graded is not a result; showing it would imply
+  // it was looked at and found acceptable.
+  const gradedItems = useMemo(
+    () =>
+      inspection.items
+        .filter((i) => i.condition !== "not_inspected")
+        .sort((a, b) => a.sortOrder - b.sortOrder),
+    [inspection.items]
+  );
+
+  const sections = useMemo(() => {
+    const order: string[] = [];
+    const grouped: Record<string, InspectionItem[]> = {};
+    for (const item of gradedItems) {
+      if (!grouped[item.section]) {
+        grouped[item.section] = [];
+        order.push(item.section);
+      }
+      grouped[item.section].push(item);
+    }
+    return order.map((name) => ({
+      name,
+      code: grouped[name][0]?.sectionCode ?? null,
+      items: grouped[name],
+    }));
+  }, [gradedItems]);
+
+  const images = useMemo<LightboxImage[]>(() => {
+    const list: LightboxImage[] = [];
+    for (const item of gradedItems) {
+      for (const url of item.imageUrls) {
+        if (!isVideo(url)) list.push({ url, caption: item.name });
+      }
+    }
+    return list;
+  }, [gradedItems]);
+
+  const counts = countConditions(gradedItems);
+  const result = deriveTestResult(gradedItems);
+  const resultToken = TEST_RESULT_TOKENS[result];
+  const defects = gradedItems.filter((i) => isDefect(i.condition));
+  const hasDefects = defects.length > 0;
 
   const handleCancelQuoteRequest = async () => {
     setIsCancelling(true);
@@ -116,412 +191,421 @@ export function InspectionView({
       });
       const data = await res.json();
       if (data.success) {
-        toast.success(t('quoteCancelled'));
+        toast.success(t("quoteCancelled"));
         setQuoteRequested(false);
       } else {
-        toast.error(data.error || t('cancelFailed'));
+        toast.error(data.error || t("cancelFailed"));
       }
     } catch {
-      toast.error(t('cancelFailed'));
+      toast.error(t("cancelFailed"));
     } finally {
       setIsCancelling(false);
     }
   };
 
-  // Collect all image URLs across all inspected items for the carousel
-  const allImages: { url: string; itemName: string }[] = [];
-  for (const item of inspection.items) {
-    if (item.condition === "not_inspected") continue;
-    for (const url of item.imageUrls) {
-      if (!/\.(mp4|webm|mov)$/i.test(url)) {
-        allImages.push({ url, itemName: item.name });
-      }
-    }
-  }
-
-  const openCarousel = (globalIndex: number) => setCarouselIndex(globalIndex);
-  const closeCarousel = () => setCarouselIndex(null);
-  const prevImage = useCallback(
-    () => setCarouselIndex((i) => (i !== null && i > 0 ? i - 1 : i)),
-    []
-  );
-  const nextImage = useCallback(
-    () => setCarouselIndex((i) => (i !== null && i < allImages.length - 1 ? i + 1 : i)),
-    [allImages.length]
-  );
-
-  // Keyboard navigation for carousel
-  useEffect(() => {
-    if (carouselIndex === null) return;
-    const handler = (e: KeyboardEvent) => {
-      if (e.key === "Escape") closeCarousel();
-      else if (e.key === "ArrowLeft") prevImage();
-      else if (e.key === "ArrowRight") nextImage();
-    };
-    window.addEventListener("keydown", handler);
-    return () => window.removeEventListener("keydown", handler);
-  }, [carouselIndex, prevImage, nextImage]);
-
-  // Touch swipe for carousel
-  const touchStartX = useRef<number | null>(null);
-  const handleTouchStart = (e: React.TouchEvent) => {
-    touchStartX.current = e.touches[0].clientX;
-  };
-  const handleTouchEnd = (e: React.TouchEvent) => {
-    if (touchStartX.current === null) return;
-    const diff = e.changedTouches[0].clientX - touchStartX.current;
-    if (Math.abs(diff) > 50) {
-      if (diff > 0) prevImage();
-      else nextImage();
-    }
-    touchStartX.current = null;
+  const openImage = (url: string) => {
+    const index = images.findIndex((img) => img.url === url);
+    setLightboxIndex(index >= 0 ? index : null);
   };
 
-  const hasIssues = inspection.items.some(
-    (i) => i.condition === "fail" || i.condition === "attention"
-  );
+  const detailRows: { label: string; value: string }[] = [
+    { label: t("testDate"), value: formatDate(inspection.completedAt ?? inspection.createdAt) },
+    ...(inspection.certificateNumber
+      ? [{ label: t("certificateNumber"), value: inspection.certificateNumber }]
+      : []),
+    ...(inspection.vehicleCategory
+      ? [{ label: t("vehicleCategory"), value: inspection.vehicleCategory }]
+      : []),
+    ...(inspection.inspectorName
+      ? [{ label: t("inspector"), value: inspection.inspectorName }]
+      : []),
+    ...(inspection.testLocation || workshop.address
+      ? [{ label: t("testLocation"), value: inspection.testLocation || workshop.address }]
+      : []),
+    ...(inspection.nextTestDue
+      ? [{ label: t("nextTestDue"), value: formatDate(inspection.nextTestDue) }]
+      : []),
+  ];
 
-  // Only show items that have been inspected (not "not_inspected"), sorted by sortOrder
-  const inspectedItems = inspection.items
-    .filter((i) => i.condition !== "not_inspected")
-    .sort((a, b) => a.sortOrder - b.sortOrder);
-
-  // Group items by section preserving sort order
-  const sectionOrder: string[] = [];
-  const sections: Record<string, InspectionItem[]> = {};
-  for (const item of inspectedItems) {
-    if (!sections[item.section]) {
-      sections[item.section] = [];
-      sectionOrder.push(item.section);
+  const renderValue = (item: InspectionItem) => {
+    if (item.inputType === "measurement" && item.measuredValue !== null && item.measuredValue !== undefined) {
+      const range = formatRange(item);
+      return (
+        <p className="mt-1 text-sm">
+          <span className="font-medium">
+            {item.measuredValue}
+            {item.unit ? ` ${item.unit}` : ""}
+          </span>
+          {range && <span className="text-gray-500"> &middot; {t("limit")}: {range}</span>}
+        </p>
+      );
     }
-    sections[item.section].push(item);
-  }
+    if (item.textValue) {
+      return <p className="mt-1 text-sm">{item.textValue}</p>;
+    }
+    return null;
+  };
 
-  const totalItems = inspectedItems.length;
-  const passCount = inspectedItems.filter((i) => i.condition === "pass").length;
-  const failCount = inspectedItems.filter((i) => i.condition === "fail").length;
-  const attentionCount = inspectedItems.filter((i) => i.condition === "attention").length;
+  const renderMedia = (item: InspectionItem) =>
+    item.imageUrls.length > 0 && (
+      <ul className="mt-2 flex flex-wrap gap-2">
+        {item.imageUrls.map((url, index) => (
+          <li key={url}>
+            {isVideo(url) ? (
+              // eslint-disable-next-line jsx-a11y/media-has-caption
+              <video src={url} controls className="h-44 max-w-sm rounded-lg border" />
+            ) : (
+              <button
+                type="button"
+                onClick={() => openImage(url)}
+                aria-label={`${t("viewPhoto")}: ${item.name} ${index + 1}`}
+                className="block overflow-hidden rounded-lg border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
+              >
+                {/* eslint-disable-next-line @next/next/no-img-element */}
+                <img
+                  src={url}
+                  alt={`${item.name} ${index + 1}`}
+                  className="h-28 w-28 object-cover transition-transform hover:scale-105"
+                />
+              </button>
+            )}
+          </li>
+        ))}
+      </ul>
+    );
 
   return (
     <div className="mx-auto max-w-3xl p-4 sm:p-8">
-      {/* Header with shop branding */}
-      <div className="mb-8 rounded-xl border p-6" style={{ borderTopColor: primaryColor, borderTopWidth: "4px" }}>
+      {/* Workshop header */}
+      <header
+        className="mb-6 rounded-xl border p-6"
+        style={{ borderTopColor: primaryColor, borderTopWidth: "4px" }}
+      >
         <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
           <div>
             {logoUrl && (
-              <img src={logoUrl} alt={workshop.name} className="mb-3 h-12 object-contain" />
-            )}
-            <h2 className="text-xl font-bold">{workshop.name}</h2>
-            {workshop.address && (
-              <p className="text-sm text-gray-500">{workshop.address}</p>
-            )}
-            {workshop.phone && (
-              <p className="text-sm text-gray-500">{workshop.phone}</p>
-            )}
-            {workshop.email && (
-              <p className="text-sm text-gray-500">{workshop.email}</p>
+              // eslint-disable-next-line @next/next/no-img-element
+              <img src={logoUrl} alt="" className="mb-3 h-12 object-contain" />
             )}
+            <p className="text-xl font-bold">{workshop.name}</p>
+            {workshop.address && <p className="text-sm text-gray-500">{workshop.address}</p>}
+            {workshop.phone && <p className="text-sm text-gray-500">{workshop.phone}</p>}
+            {workshop.email && <p className="text-sm text-gray-500">{workshop.email}</p>}
           </div>
           <div className="flex items-center gap-2 sm:text-right">
-            <ClipboardCheck className="hidden h-6 w-6 sm:block" style={{ color: primaryColor }} />
+            <ShieldCheck
+              className="hidden h-6 w-6 sm:block"
+              style={{ color: primaryColor }}
+              aria-hidden="true"
+            />
             <div>
-              <p className="text-lg font-bold">{t('title')}</p>
-              <p className="text-sm text-gray-500">{formatDate(inspection.createdAt)}</p>
+              <h1 className="text-lg font-bold">{t("title")}</h1>
+              <p className="text-sm text-gray-500">{inspection.template.name}</p>
             </div>
           </div>
         </div>
-      </div>
+      </header>
 
-      {/* Vehicle and customer info */}
-      <div className="mb-6 grid grid-cols-2 gap-6 rounded-lg border p-4">
-        <div>
-          <h3 className="mb-2 text-xs font-semibold uppercase text-gray-400">{t('vehicle')}</h3>
-          <p className="font-semibold">
-            {inspection.vehicle.year} {inspection.vehicle.make} {inspection.vehicle.model}
-          </p>
-          {inspection.vehicle.vin && (
-            <p className="text-sm text-gray-500">{serviceType === 'marine' ? `HIN: ${inspection.vehicle.vin}` : t('vin', { vin: inspection.vehicle.vin })}</p>
-          )}
-          {inspection.vehicle.licensePlate && (
-            <p className="text-sm text-gray-500">{serviceType === 'marine' ? `Reg: ${inspection.vehicle.licensePlate}` : t('plate', { plate: inspection.vehicle.licensePlate })}</p>
-          )}
-          {inspection.mileage && (
-            <p className="text-sm text-gray-500">{t(serviceType === 'marine' ? 'mileageMarine' : 'mileage', { mileage: inspection.mileage.toLocaleString() })}</p>
-          )}
-        </div>
-        {inspection.vehicle.customer && (
-          <div>
-            <h3 className="mb-2 text-xs font-semibold uppercase text-gray-400">{t('customer')}</h3>
-            <p className="font-semibold">{inspection.vehicle.customer.name}</p>
-            {inspection.vehicle.customer.email && (
-              <p className="text-sm text-gray-500">{inspection.vehicle.customer.email}</p>
+      {/* Overall result */}
+      <section
+        aria-labelledby="result-heading"
+        className={`mb-6 rounded-xl border-2 p-5 ${resultToken.soft}`}
+      >
+        <h2 id="result-heading" className="text-xl font-bold">
+          {t(`result.${result}`)}
+        </h2>
+        <p className="mt-1 text-sm">{t(`resultDetail.${result}`)}</p>
+      </section>
+
+      {/* Test details */}
+      <section aria-labelledby="details-heading" className="mb-6 rounded-lg border p-4">
+        <h2 id="details-heading" className="mb-3 text-xs font-semibold uppercase text-gray-500">
+          {t("testDetails")}
+        </h2>
+        <div className="grid gap-4 sm:grid-cols-2">
+          <dl className="space-y-1.5">
+            <div>
+              <dt className="text-xs text-gray-500">
+                {serviceType === "marine" ? t("vesselLabel") : t("vehicle")}
+              </dt>
+              <dd className="font-semibold">
+                {inspection.vehicle.year} {inspection.vehicle.make} {inspection.vehicle.model}
+              </dd>
+            </div>
+            {inspection.vehicle.vin && (
+              <div>
+                <dt className="text-xs text-gray-500">
+                  {serviceType === "marine" ? "HIN" : "VIN"}
+                </dt>
+                <dd className="font-mono text-sm break-all">{inspection.vehicle.vin}</dd>
+              </div>
             )}
-            {inspection.vehicle.customer.phone && (
-              <p className="text-sm text-gray-500">{inspection.vehicle.customer.phone}</p>
+            {inspection.vehicle.licensePlate && (
+              <div>
+                <dt className="text-xs text-gray-500">{t("plateLabel")}</dt>
+                <dd className="font-mono text-sm">{inspection.vehicle.licensePlate}</dd>
+              </div>
             )}
-          </div>
-        )}
-      </div>
+            {inspection.mileage !== null && (
+              <div>
+                <dt className="text-xs text-gray-500">
+                  {serviceType === "marine" ? t("engineHoursLabel") : t("odometerLabel")}
+                </dt>
+                <dd className="text-sm">{inspection.mileage.toLocaleString()}</dd>
+              </div>
+            )}
+            {inspection.vehicle.customer && (
+              <div>
+                <dt className="text-xs text-gray-500">{t("customer")}</dt>
+                <dd className="text-sm">{inspection.vehicle.customer.name}</dd>
+              </div>
+            )}
+          </dl>
+          <dl className="space-y-1.5">
+            {detailRows.map((row) => (
+              <div key={row.label}>
+                <dt className="text-xs text-gray-500">{row.label}</dt>
+                <dd className="text-sm">{row.value}</dd>
+              </div>
+            ))}
+          </dl>
+        </div>
+      </section>
 
-      {/* Summary bar */}
-      <div className="mb-6 rounded-lg border p-4">
-        <div className="flex flex-wrap items-center gap-3 sm:gap-4">
-          <div className="text-sm text-gray-500">
-            <span className="font-semibold text-gray-900">{totalItems}</span> {t('inspected')}
-          </div>
-          <div className="flex items-center gap-1.5">
-            <div className="h-3 w-3 rounded-full bg-emerald-500" />
-            <span className="text-sm font-medium">{passCount} {t('pass')}</span>
-          </div>
-          <div className="flex items-center gap-1.5">
-            <div className="h-3 w-3 rounded-full bg-red-500" />
-            <span className="text-sm font-medium">{failCount} {t('fail')}</span>
-          </div>
-          <div className="flex items-center gap-1.5">
-            <div className="h-3 w-3 rounded-full bg-amber-500" />
-            <span className="text-sm font-medium">{attentionCount} {t('attention')}</span>
-          </div>
+      {/* Summary */}
+      <section aria-labelledby="summary-heading" className="mb-6 rounded-lg border p-4">
+        <h2 id="summary-heading" className="sr-only">
+          {t("summary")}
+        </h2>
+        <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
+          <p className="text-sm text-gray-500">
+            <span className="font-semibold text-gray-900 dark:text-gray-100">{counts.inspected}</span>{" "}
+            {t("inspected")}
+          </p>
+          {(["pass", "attention", "fail", "dangerous"] as const)
+            .filter((c) => scale === "eu" || c !== "dangerous")
+            .map((c) => (
+              <p key={c} className="flex items-center gap-1.5 text-sm">
+                <span
+                  className={`h-3 w-3 rounded-full ${CONDITION_TOKENS[c].bar}`}
+                  aria-hidden="true"
+                />
+                <span className="font-medium">{counts[c]}</span>
+                <span className="text-gray-500">{conditionText(c)}</span>
+              </p>
+            ))}
         </div>
-        {/* Progress bar */}
-        <div className="mt-3 flex h-3 w-full overflow-hidden rounded-full bg-gray-200">
-          {totalItems > 0 && (
-            <>
-              <div className="bg-emerald-500" style={{ width: `${(passCount / totalItems) * 100}%` }} />
-              <div className="bg-red-500" style={{ width: `${(failCount / totalItems) * 100}%` }} />
-              <div className="bg-amber-500" style={{ width: `${(attentionCount / totalItems) * 100}%` }} />
-            </>
+        <div
+          className="mt-3 flex h-3 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
+          role="img"
+          aria-label={t("progressLabel", { graded: counts.inspected })}
+        >
+          {(["pass", "attention", "fail", "dangerous"] as const).map((c) =>
+            counts[c] > 0 ? (
+              <div
+                key={c}
+                className={CONDITION_TOKENS[c].bar}
+                style={{ width: `${(counts[c] / Math.max(counts.inspected, 1)) * 100}%` }}
+              />
+            ) : null
           )}
         </div>
-      </div>
+      </section>
 
-      {/* Quote available banner */}
+      {/* Quote available */}
       {quoteShareUrl && (
-        <div className="mb-6 flex items-center gap-3 rounded-lg border border-emerald-200 bg-emerald-50 p-4">
-          <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100">
-            <FileText className="h-4 w-4 text-emerald-600" />
-          </div>
+        <div className="mb-6 flex items-center gap-3 rounded-lg border border-emerald-300 bg-emerald-50 p-4 dark:bg-emerald-950/30">
+          <FileText
+            className="h-5 w-5 shrink-0 text-emerald-700 dark:text-emerald-300"
+            aria-hidden="true"
+          />
           <div className="min-w-0 flex-1">
-            <p className="text-sm font-medium text-emerald-900">
-              {t('quoteAvailable')}
+            <p className="text-sm font-medium text-emerald-900 dark:text-emerald-100">
+              {t("quoteAvailable")}
             </p>
-            <p className="mt-0.5 text-xs text-emerald-700/70">
-              {t('quoteAvailableDescription')}
+            <p className="mt-0.5 text-xs text-emerald-800/80 dark:text-emerald-200/80">
+              {t("quoteAvailableDescription")}
             </p>
           </div>
           <a
             href={quoteShareUrl}
-            className="inline-flex shrink-0 items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors hover:opacity-90"
+            className="inline-flex shrink-0 items-center gap-1.5 rounded-lg px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
             style={{ backgroundColor: primaryColor }}
           >
-            <FileText className="h-4 w-4" />
-            {t('viewQuote')}
+            <FileText className="h-4 w-4" aria-hidden="true" />
+            {t("viewQuote")}
           </a>
         </div>
       )}
 
-      {/* Action buttons */}
+      {/* Actions */}
       <div className="mb-6 flex flex-wrap gap-3">
-        {hasIssues && (
-          quoteRequested ? (
+        {hasDefects &&
+          (quoteRequested ? (
             <Button
               variant="outline"
-              className="gap-2"
+              className="h-11 gap-2"
               onClick={handleCancelQuoteRequest}
               disabled={isCancelling}
             >
               {isCancelling ? (
-                <Loader2 className="h-4 w-4 animate-spin" />
+                <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
               ) : (
-                <CheckCircle2 className="h-4 w-4 text-emerald-500" />
+                <CheckCircle2 className="h-4 w-4 text-emerald-600" aria-hidden="true" />
               )}
-              {t('quoteRequested')}
-              <span className="text-muted-foreground">{t('quoteRequestedCancel')}</span>
+              {t("quoteRequested")}
+              <span className="text-muted-foreground">{t("quoteRequestedCancel")}</span>
             </Button>
           ) : (
             <Button
               onClick={() => setShowQuoteDialog(true)}
               style={{ backgroundColor: primaryColor }}
-              className="gap-2 text-white hover:opacity-90"
+              className="h-11 gap-2 text-white hover:opacity-90"
             >
-              <FileText className="h-4 w-4" />
-              {t('requestQuote')}
+              <FileText className="h-4 w-4" aria-hidden="true" />
+              {t("requestQuote")}
             </Button>
-          )
-        )}
+          ))}
         <Button
           variant="outline"
-          className="gap-2"
-          onClick={() => window.open(`/api/public/share/inspection/${orgId}/${publicToken}/pdf`, "_blank")}
+          className="h-11 gap-2"
+          onClick={() =>
+            window.open(`/api/public/share/inspection/${orgId}/${publicToken}/pdf`, "_blank")
+          }
         >
-          <Download className="h-4 w-4" />
-          {t('downloadPdf')}
+          <Download className="h-4 w-4" aria-hidden="true" />
+          {t("downloadPdf")}
         </Button>
       </div>
 
-      {/* Inspection sections */}
-      <div className="space-y-6">
-        {sectionOrder.map((sectionName) => (
-          <div key={sectionName} className="rounded-lg border overflow-hidden">
-            <div className="border-b px-4 py-3" style={{ backgroundColor: `${primaryColor}10` }}>
-              <h3 className="font-semibold">{sectionName}</h3>
-            </div>
-            <div className="divide-y">
-              {sections[sectionName].map((item) => {
-                const config = conditionConfig[item.condition] || conditionConfig.not_inspected;
+      {/* Deficiencies first — this is what the report is for */}
+      {hasDefects && (
+        <section aria-labelledby="defects-heading" className="mb-6">
+          <h2 id="defects-heading" className="mb-3 text-base font-bold">
+            {t("deficienciesFound", { count: defects.length })}
+          </h2>
+          <ul className="space-y-2">
+            {defects.map((item) => {
+              const token = CONDITION_TOKENS[item.condition as Condition];
+              const Icon = CONDITION_ICONS[item.condition as Condition];
+              return (
+                <li key={item.id} className={`rounded-lg border p-3 ${token.soft}`}>
+                  <div className="flex items-start justify-between gap-3">
+                    <p className="text-sm font-medium">
+                      {item.code && <span className="mr-2 font-mono text-xs">{item.code}</span>}
+                      {item.name}
+                    </p>
+                    <span className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold">
+                      <Icon className="h-3.5 w-3.5" aria-hidden="true" />
+                      {gradedText(item.condition as Condition)}
+                    </span>
+                  </div>
+                  {item.notes && <p className="mt-1 text-sm">{item.notes}</p>}
+                  {renderValue(item)}
+                  {renderMedia(item)}
+                </li>
+              );
+            })}
+          </ul>
+        </section>
+      )}
+
+      {/* Full results */}
+      <section aria-labelledby="all-results-heading" className="space-y-4">
+        <h2 id="all-results-heading" className="text-base font-bold">
+          {t("allResults")}
+        </h2>
+        {sections.map((section) => (
+          <div key={section.name} className="overflow-hidden rounded-lg border">
+            <h3
+              className="border-b px-4 py-3 font-semibold"
+              style={{ backgroundColor: `${primaryColor}12` }}
+            >
+              {section.code && (
+                <span className="mr-2 font-mono text-xs text-gray-500">{section.code}</span>
+              )}
+              {section.name}
+            </h3>
+            <ul className="divide-y">
+              {section.items.map((item) => {
+                const condition = item.condition as Condition;
+                const token = CONDITION_TOKENS[condition];
+                const Icon = CONDITION_ICONS[condition];
                 return (
-                  <div key={item.id} className="px-4 py-3">
-                    <div className="flex items-center justify-between gap-2">
-                      <span className="text-sm font-medium">{item.name}</span>
+                  <li key={item.id} className="px-4 py-3">
+                    <div className="flex items-start justify-between gap-3">
+                      <p className="text-sm font-medium">
+                        {item.code && (
+                          <span className="mr-2 font-mono text-xs text-gray-500">{item.code}</span>
+                        )}
+                        {item.name}
+                      </p>
                       <span
-                        className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium ${config.bgColor} ${config.color}`}
+                        className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium ${token.soft}`}
                       >
-                        {config.icon}
-                        {config.label}
+                        <Icon className="h-3.5 w-3.5" aria-hidden="true" />
+                        {gradedText(condition)}
                       </span>
                     </div>
-                    {item.notes && (
-                      <p className="mt-1 text-sm text-gray-500">{item.notes}</p>
-                    )}
-                    {item.imageUrls && item.imageUrls.length > 0 && (
-                      <div className="mt-2 flex flex-wrap gap-2">
-                        {item.imageUrls.map((url, idx) => {
-                          if (/\.(mp4|webm|mov)$/i.test(url)) {
-                            return (
-                              <video
-                                key={idx}
-                                src={url}
-                                controls
-                                className="h-48 max-w-sm rounded-lg border"
-                              />
-                            );
-                          }
-                          const imageIdx = allImages.findIndex(
-                            (img) => img.url === url && img.itemName === item.name
-                          );
-                          return (
-                            <button
-                              key={idx}
-                              type="button"
-                              onClick={() => openCarousel(imageIdx >= 0 ? imageIdx : 0)}
-                              className="group overflow-hidden rounded-lg border"
-                            >
-                              <img
-                                src={url}
-                                alt={`${item.name} ${idx + 1}`}
-                                className="h-32 rounded-lg object-cover transition-transform group-hover:scale-105"
-                              />
-                            </button>
-                          );
-                        })}
-                      </div>
-                    )}
-                  </div>
+                    {item.notes && <p className="mt-1 text-sm text-gray-500">{item.notes}</p>}
+                    {renderValue(item)}
+                    {renderMedia(item)}
+                  </li>
                 );
               })}
-            </div>
+            </ul>
           </div>
         ))}
-      </div>
+      </section>
 
-      {/* Footer */}
       {inspection.notes && (
-        <div className="mt-6 rounded-lg border p-4">
-          <h3 className="mb-2 text-xs font-semibold uppercase text-gray-400">{t('notes')}</h3>
-          <p className="text-sm text-gray-600 whitespace-pre-wrap">{inspection.notes}</p>
-        </div>
-      )}
-
-      {portalUrl && (
-        <div className="mt-3 border-t pt-3 text-center">
-          <p className="text-xs text-muted-foreground">
-            {tc('portalMessage')}{" "}
-            <a href={portalUrl} className="font-medium text-primary hover:underline">
-              {tc('customerPortal')}
-            </a>
+        <section aria-labelledby="notes-heading" className="mt-6 rounded-lg border p-4">
+          <h2 id="notes-heading" className="mb-2 text-xs font-semibold uppercase text-gray-500">
+            {t("notes")}
+          </h2>
+          <p className="text-sm whitespace-pre-wrap text-gray-600 dark:text-gray-300">
+            {inspection.notes}
           </p>
-        </div>
-      )}
-
-      {showTorqvoiceBranding && (
-        <div className="mt-8 flex items-center justify-center gap-1.5">
-          <span className="text-xs text-gray-400">{tc('poweredBy')}</span>
-          <img src="/torqvoice_app_logo.png" alt="Torqvoice" className="h-4 w-4" />
-          <a
-            href="https://torqvoice.com"
-            target="_blank"
-            rel="noopener noreferrer"
-            className="text-xs font-semibold text-gray-500 hover:text-gray-700"
-          >
-            Torqvoice
-          </a>
-        </div>
+        </section>
       )}
 
-      {/* Image Carousel Modal */}
-      {carouselIndex !== null && allImages[carouselIndex] && (
-        <div
-          className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
-          onTouchStart={handleTouchStart}
-          onTouchEnd={handleTouchEnd}
-        >
-          <button
-            type="button"
-            onClick={closeCarousel}
-            className="absolute top-3 right-3 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white transition-colors hover:bg-black/70 sm:top-4 sm:right-4"
-          >
-            <X className="h-5 w-5" />
-          </button>
-
-          {allImages.length > 1 && (
-            <div className="absolute top-3 left-1/2 z-10 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1 text-sm font-medium text-white sm:top-4">
-              {carouselIndex + 1} / {allImages.length}
-            </div>
-          )}
+      <footer className="mt-8 space-y-3 border-t pt-4">
+        <p className="text-center text-xs text-gray-500">{t("privacyNotice")}</p>
 
-          {carouselIndex > 0 && (
-            <button
-              type="button"
-              onClick={(e) => {
-                e.stopPropagation();
-                prevImage();
-              }}
-              className="absolute left-2 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white transition-colors hover:bg-black/70 sm:left-4 sm:h-12 sm:w-12"
-            >
-              <ChevronLeft className="h-6 w-6" />
-            </button>
-          )}
+        {portalUrl && (
+          <p className="text-center text-xs text-gray-500">
+            {tc("portalMessage")}{" "}
+            <a href={portalUrl} className="text-primary font-medium hover:underline">
+              {tc("customerPortal")}
+            </a>
+          </p>
+        )}
 
-          {carouselIndex < allImages.length - 1 && (
-            <button
-              type="button"
-              onClick={(e) => {
-                e.stopPropagation();
-                nextImage();
-              }}
-              className="absolute right-2 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white transition-colors hover:bg-black/70 sm:right-4 sm:h-12 sm:w-12"
+        {showTorqvoiceBranding && (
+          <p className="flex items-center justify-center gap-1.5">
+            <span className="text-xs text-gray-400">{tc("poweredBy")}</span>
+            {/* eslint-disable-next-line @next/next/no-img-element */}
+            <img src="/torqvoice_app_logo.png" alt="" className="h-4 w-4" />
+            <a
+              href="https://torqvoice.com"
+              target="_blank"
+              rel="noopener noreferrer"
+              className="text-xs font-semibold text-gray-500 hover:text-gray-700"
             >
-              <ChevronRight className="h-6 w-6" />
-            </button>
-          )}
+              Torqvoice
+            </a>
+          </p>
+        )}
+      </footer>
 
-          <div
-            className="flex max-h-[85vh] max-w-[90vw] flex-col items-center"
-            onClick={(e) => e.stopPropagation()}
-          >
-            <img
-              src={allImages[carouselIndex].url}
-              alt={allImages[carouselIndex].itemName}
-              className="max-h-[80vh] max-w-full rounded-lg object-contain"
-              draggable={false}
-            />
-            <p className="mt-2 max-w-md text-center text-sm text-white/80">
-              {allImages[carouselIndex].itemName}
-            </p>
-          </div>
-        </div>
-      )}
+      <MediaLightbox
+        images={images}
+        index={lightboxIndex}
+        onClose={() => setLightboxIndex(null)}
+        onNavigate={setLightboxIndex}
+      />
 
-      {hasIssues && !quoteRequested && (
+      {hasDefects && !quoteRequested && (
         <QuoteRequestDialog
           open={showQuoteDialog}
           onOpenChange={setShowQuoteDialog}

+ 6 - 2
src/app/(public)/share/inspection/[orgId]/[token]/page.tsx

@@ -33,10 +33,14 @@ export default async function PublicInspectionPage({
           vin: true,
           licensePlate: true,
           mileage: true,
-          customer: { select: { name: true, email: true, phone: true } },
+          // Data minimisation (GDPR Art. 5(1)(c)): this page is reachable by
+          // anyone holding the link, so the customer's contact details are
+          // never loaded for it — the name is all the report needs to identify
+          // whose vehicle it is.
+          customer: { select: { name: true } },
         },
       },
-      template: { select: { name: true } },
+      template: { select: { name: true, severityScale: true, country: true } },
       items: { orderBy: { sortOrder: "asc" } },
       quotes: {
         where: { publicToken: { not: null } },

+ 5 - 0
src/app/api/protected/backup/export/route.ts

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
 import { getAuthContext } from "@/lib/get-auth-context";
 import { db } from "@/lib/db";
 import JSZip from "jszip";
+import { isDemoMode } from "@/lib/demo";
 import { readdir, readFile, stat } from "fs/promises";
 import path from "path";
 
@@ -38,6 +39,10 @@ const DEFAULT_OPTIONS: ExportOptions = {
 };
 
 export async function POST(request: NextRequest) {
+  if (isDemoMode) {
+    return NextResponse.json({ error: "This action is disabled on the demo." }, { status: 403 });
+  }
+
   const ctx = await getAuthContext();
 
   if (!ctx) {

+ 5 - 0
src/app/api/protected/backup/import-invoice-ninja/route.ts

@@ -1,6 +1,7 @@
 import { NextRequest, NextResponse } from "next/server";
 import { getAuthContext } from "@/lib/get-auth-context";
 import { db } from "@/lib/db";
+import { isDemoMode } from "@/lib/demo";
 import { resolveInvoicePrefix, toSafeDate } from "@/lib/invoice-utils";
 import { mkdir, writeFile, rm } from "fs/promises";
 import path from "path";
@@ -210,6 +211,10 @@ function getMimeType(filename: string): string {
 // ── API Route ────────────────────────────────────────────────────────────────
 
 export async function POST(request: NextRequest) {
+  if (isDemoMode) {
+    return NextResponse.json({ error: "Data import is disabled on the demo." }, { status: 403 });
+  }
+
   const ctx = await getAuthContext();
   if (!ctx) {
     return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

+ 5 - 0
src/app/api/protected/backup/import-lubelog/route.ts

@@ -1,6 +1,7 @@
 import { NextRequest, NextResponse } from "next/server";
 import { getAuthContext } from "@/lib/get-auth-context";
 import { db } from "@/lib/db";
+import { isDemoMode } from "@/lib/demo";
 import { resolveInvoicePrefix, toSafeDate } from "@/lib/invoice-utils";
 import { mkdir, writeFile, stat, readdir, rm } from "fs/promises";
 import { readFileSync } from "fs";
@@ -199,6 +200,10 @@ async function extractZipToTempDir(zipBuffer: Buffer): Promise<string> {
 // ── API Route ────────────────────────────────────────────────────────────────
 
 export async function POST(request: NextRequest) {
+  if (isDemoMode) {
+    return NextResponse.json({ error: "Data import is disabled on the demo." }, { status: 403 });
+  }
+
   const ctx = await getAuthContext();
   if (!ctx) {
     return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

+ 5 - 0
src/app/api/protected/backup/import/route.ts

@@ -1,6 +1,7 @@
 import { NextRequest, NextResponse } from "next/server";
 import { getAuthContext } from "@/lib/get-auth-context";
 import { db } from "@/lib/db";
+import { isDemoMode } from "@/lib/demo";
 import { toSafeDate } from "@/lib/invoice-utils";
 import { Prisma } from "@/generated/prisma/client";
 import JSZip from "jszip";
@@ -264,6 +265,10 @@ function rewriteFileUrl(
 }
 
 export async function POST(request: NextRequest) {
+  if (isDemoMode) {
+    return NextResponse.json({ error: "Backup import is disabled on the demo." }, { status: 403 });
+  }
+
   const ctx = await getAuthContext();
 
   if (!ctx) {

+ 6 - 1
src/app/api/protected/inspections/[id]/pdf/route.ts

@@ -8,6 +8,7 @@ import { InspectionPDF } from "@/features/inspections/Components/InspectionPDF";
 import React from "react";
 import { readFile } from "fs/promises";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
+import { loadInspectionPhotos } from "@/features/inspections/Lib/inspectionPhotos";
 import { getFeatures } from "@/lib/features";
 import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
 
@@ -48,7 +49,7 @@ export async function GET(
               customer: { select: { name: true, email: true, phone: true } },
             },
           },
-          template: { select: { name: true } },
+          template: { select: { name: true, severityScale: true, country: true } },
           items: { orderBy: { sortOrder: "asc" } },
         },
       }),
@@ -106,6 +107,8 @@ export async function GET(
       headerStyle: settingsMap["invoice.headerStyle"] || "standard",
     };
 
+    const { photos, omitted: photosOmitted } = await loadInspectionPhotos(inspection.items);
+
     const element = React.createElement(InspectionPDF, {
       data: inspection,
       workshop: {
@@ -120,6 +123,8 @@ export async function GET(
       timezone: settingsMap["workshop.timezone"] || undefined,
       template,
       labels,
+      photos,
+      photosOmitted,
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
     }) as any;
     const buffer = await renderToBuffer(element);

+ 5 - 0
src/app/api/protected/subscription/billing-portal/route.ts

@@ -3,9 +3,14 @@ import { auth } from "@/lib/auth";
 import { headers } from "next/headers";
 import { db } from "@/lib/db";
 import { getStripeClient } from "@/lib/stripe-config";
+import { isDemoMode } from "@/lib/demo";
 
 export async function POST() {
   try {
+    if (isDemoMode) {
+      return NextResponse.json({ error: "This action is disabled on the demo." }, { status: 403 });
+    }
+
     const session = await auth.api.getSession({ headers: await headers() });
     if (!session?.user?.id) {
       return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

+ 5 - 0
src/app/api/protected/subscription/checkout/route.ts

@@ -3,9 +3,14 @@ import { auth } from "@/lib/auth";
 import { headers } from "next/headers";
 import { db } from "@/lib/db";
 import { getStripeClient, getStripeConfig } from "@/lib/stripe-config";
+import { isDemoMode } from "@/lib/demo";
 
 export async function POST(request: Request) {
   try {
+    if (isDemoMode) {
+      return NextResponse.json({ error: "This action is disabled on the demo." }, { status: 403 });
+    }
+
     const session = await auth.api.getSession({ headers: await headers() });
     if (!session?.user?.id) {
       return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

+ 5 - 0
src/app/api/protected/subscription/upgrade/route.ts

@@ -3,9 +3,14 @@ import { auth } from "@/lib/auth";
 import { headers } from "next/headers";
 import { db } from "@/lib/db";
 import { getStripeClient, getStripeConfig } from "@/lib/stripe-config";
+import { isDemoMode } from "@/lib/demo";
 
 export async function POST(request: Request) {
   try {
+    if (isDemoMode) {
+      return NextResponse.json({ error: "This action is disabled on the demo." }, { status: 403 });
+    }
+
     const session = await auth.api.getSession({ headers: await headers() });
     if (!session?.user?.id) {
       return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

+ 21 - 0
src/app/api/public/auth/[...all]/route.ts

@@ -1,4 +1,6 @@
+import { NextResponse } from 'next/server'
 import { auth } from '@/lib/auth'
+import { isDemoMode } from '@/lib/demo'
 import { rateLimit } from '@/lib/rate-limit'
 import { toNextJsHandler } from 'better-auth/next-js'
 import { db } from '@/lib/db'
@@ -24,6 +26,17 @@ const authAuditPrefixes = [
   '/api/public/auth/passkey',
 ]
 
+// better-auth's own endpoints bypass server actions, so demoGuard() never sees
+// them. Without this, a demo visitor can change the shared demo user's
+// password or enroll 2FA/passkeys on it, locking the demo for everyone until
+// the next reset.
+const demoBlockedPrefixes = [
+  '/api/public/auth/change-password',
+  '/api/public/auth/set-password',
+  '/api/public/auth/two-factor',
+  '/api/public/auth/passkey',
+]
+
 const defaultConfig = { limit: 30, windowMs: 60_000 }
 
 function getRequestIp(request: Request): string | null {
@@ -39,6 +52,14 @@ function getRequestIp(request: Request): string | null {
 
 async function POST(request: Request) {
   const { pathname } = new URL(request.url)
+
+  if (isDemoMode && demoBlockedPrefixes.some((p) => pathname.startsWith(p))) {
+    return NextResponse.json(
+      { error: 'This action is disabled on the demo. Install Torqvoice on your own server to use it.' },
+      { status: 403 },
+    )
+  }
+
   const config = strictPrefixes.find((p) => pathname.startsWith(p.prefix)) ?? defaultConfig
   const limited = rateLimit(request, config)
   if (limited) return limited

+ 10 - 2
src/app/api/public/share/inspection/[orgId]/[token]/pdf/route.ts

@@ -7,6 +7,7 @@ import { InspectionPDF } from "@/features/inspections/Components/InspectionPDF";
 import React from "react";
 import { readFile } from "fs/promises";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
+import { loadInspectionPhotos } from "@/features/inspections/Lib/inspectionPhotos";
 import { getFeatures } from "@/lib/features";
 import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
 import { resolvePortalOrg } from "@/lib/portal-slug";
@@ -44,10 +45,13 @@ export async function GET(
           select: {
             make: true, model: true, year: true, vin: true,
             licensePlate: true, mileage: true,
-            customer: { select: { name: true, email: true, phone: true } },
+            // Data minimisation (GDPR Art. 5(1)(c)): this certificate is
+            // generated from a public link, so it carries only the name needed
+            // to identify whose vehicle was tested.
+            customer: { select: { name: true } },
           },
         },
-        template: { select: { name: true } },
+        template: { select: { name: true, severityScale: true, country: true } },
         items: { orderBy: { sortOrder: "asc" } },
       },
     });
@@ -121,6 +125,8 @@ export async function GET(
       ? `${appUrl}/portal/${portalSlug || orgId}`
       : undefined;
 
+    const { photos, omitted: photosOmitted } = await loadInspectionPhotos(inspection.items);
+
     const element = React.createElement(InspectionPDF, {
       data: inspection,
       workshop: {
@@ -136,6 +142,8 @@ export async function GET(
       template,
       portalUrl,
       labels,
+      photos,
+      photosOmitted,
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
     }) as any;
     const buffer = await renderToBuffer(element);

+ 4 - 1
src/app/layout.tsx

@@ -10,6 +10,8 @@ import { TooltipProvider } from '@/components/ui/tooltip'
 import { PWAServiceWorker } from '@/components/pwa-service-worker'
 import { PostHogProvider } from '@/components/posthog-provider'
 import { isCloudMode } from '@/lib/features'
+import { isDemoMode } from '@/lib/demo'
+import { DemoBanner } from '@/components/demo-banner'
 import 'react-grid-layout/css/styles.css'
 import 'react-resizable/css/styles.css'
 import './globals.css'
@@ -99,7 +101,7 @@ export default async function RootLayout({
       </head>
       <body className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}>
         <PostHogProvider
-          isCloud={isCloudMode()}
+          enabled={isCloudMode() || isDemoMode}
           posthogKey={process.env.POSTHOG_KEY}
           posthogHost={process.env.POSTHOG_HOST}
         >
@@ -107,6 +109,7 @@ export default async function RootLayout({
             <ThemeProvider defaultTheme="dark">
               <QueryProvider>
                 <TooltipProvider>
+                  <DemoBanner />
                   {children}
                   <GlassModal />
                   <Toaster richColors position="bottom-right" />

+ 17 - 0
src/components/demo-banner.tsx

@@ -0,0 +1,17 @@
+import { isDemoMode } from "@/lib/demo";
+
+export function DemoBanner() {
+  if (!isDemoMode) return null;
+
+  return (
+    <div className="bg-amber-500 text-center text-xs font-medium text-amber-950 py-1.5 px-4">
+      Demo instance — data resets every few hours. Outgoing email, SMS, Telegram and team invites are disabled.{" "}
+      <a
+        href="https://torqvoice.com/docs/installation"
+        className="underline underline-offset-2 hover:text-amber-900"
+      >
+        Install your own →
+      </a>
+    </div>
+  );
+}

+ 6 - 6
src/components/posthog-provider.tsx

@@ -1,4 +1,4 @@
-// PostHog analytics — only active in cloud mode (TORQVOICE_MODE=cloud).
+// PostHog analytics — active in cloud mode and on the public demo instance.
 // Self-hosted instances never load or initialize PostHog.
 'use client'
 
@@ -7,18 +7,18 @@ import { PostHogProvider as PHProvider } from 'posthog-js/react'
 import { useEffect } from 'react'
 
 export function PostHogProvider({
-  isCloud,
+  enabled,
   posthogKey,
   posthogHost,
   children,
 }: {
-  isCloud: boolean
+  enabled: boolean
   posthogKey?: string
   posthogHost?: string
   children: React.ReactNode
 }) {
   useEffect(() => {
-    if (isCloud && posthogKey) {
+    if (enabled && posthogKey) {
       posthog.init(posthogKey, {
         api_host: posthogHost || 'https://us.i.posthog.com',
         person_profiles: 'identified_only',
@@ -26,9 +26,9 @@ export function PostHogProvider({
         capture_pageleave: true,
       })
     }
-  }, [isCloud, posthogKey, posthogHost])
+  }, [enabled, posthogKey, posthogHost])
 
-  if (!isCloud || !posthogKey) {
+  if (!enabled || !posthogKey) {
     return <>{children}</>
   }
 

+ 6 - 2
src/components/table-cell-link.tsx

@@ -11,7 +11,9 @@ import { cn } from "@/lib/utils";
  * open-in-new-tab and keyboard focus.
  *
  * Pass `block` for cells whose content is a stacked block rather than a
- * single run of text.
+ * single run of text. The box still hugs its content: a cell is usually wider
+ * than the text in it, and stretching the anchor across the rest of the cell
+ * would send clicks aimed at the row to this target instead.
  */
 export function TableCellLink({
   href,
@@ -30,7 +32,9 @@ export function TableCellLink({
       onClick={(e) => e.stopPropagation()}
       className={cn(
         "hover:underline underline-offset-2 focus-visible:underline focus-visible:outline-none",
-        block ? "block min-w-0" : "inline",
+        // max-w-full keeps the shrink-to-fit box inside the cell, which is what
+        // lets a `truncate` child still find a width to truncate against.
+        block ? "inline-block w-fit max-w-full min-w-0 align-top" : "inline",
         className
       )}
     >

+ 1 - 0
src/cronTasks.ts

@@ -4,6 +4,7 @@ export { processRecurringInvoices } from './lib/cron/recurring-invoices'
 export { cleanupPortalSessions } from './lib/cron/cleanup-portal-sessions'
 export { cleanupAuditLogs } from './lib/cron/cleanup-audit-logs'
 export { processReportSchedules } from './lib/cron/report-schedules'
+export { startDemoResetCron } from './lib/cron/demo-reset'
 export { processWebhookDeliveries, cleanupWebhookDeliveries } from './lib/cron/webhook-deliveries'
 export { checkLowStock } from './lib/cron/low-stock-alerts'
 export { checkDueReminders } from './lib/cron/reminder-alerts'

+ 2 - 0
src/features/admin/Actions/deleteOrganization.ts

@@ -3,9 +3,11 @@
 import { withSuperAdmin } from "@/lib/with-super-admin";
 import { deleteOrganizationWithData } from "@/lib/delete-user-data";
 import { deleteOrganizationSchema } from "../Schema/adminSchema";
+import { demoGuard } from "@/lib/demo";
 
 export async function deleteOrganization(input: { organizationId: string }) {
   return withSuperAdmin(async () => {
+    demoGuard()
     const { organizationId } = deleteOrganizationSchema.parse(input);
 
     // Also cancels the org's Stripe subscription and removes its uploaded

+ 2 - 0
src/features/admin/Actions/deleteUser.ts

@@ -4,9 +4,11 @@ import { withSuperAdmin } from '@/lib/with-super-admin'
 import { db } from '@/lib/db'
 import { deleteUserOrganizations } from '@/lib/delete-user-data'
 import { deleteUserSchema } from '../Schema/adminSchema'
+import { demoGuard } from '@/lib/demo'
 
 export async function deleteUser(input: { userId: string }) {
   return withSuperAdmin(async (ctx) => {
+    demoGuard()
     const { userId } = deleteUserSchema.parse(input)
 
     if (userId === ctx.userId) {

+ 2 - 0
src/features/admin/Actions/setSystemSettings.ts

@@ -6,9 +6,11 @@ import {
   systemSettingsUpdateSchema,
   ALL_SYSTEM_KEYS,
 } from "../Schema/systemSettingsSchema";
+import { demoGuard } from "@/lib/demo";
 
 export async function setSystemSettings(input: Record<string, string>) {
   return withSuperAdmin(async () => {
+    demoGuard();
     const data = systemSettingsUpdateSchema.parse(input);
 
     // Only allow known keys, filter out license.* (readonly)

+ 3 - 0
src/features/ai/Actions/aiSettingsActions.ts

@@ -5,6 +5,7 @@ import { withAuth } from "@/lib/with-auth";
 import { revalidatePath } from "next/cache";
 import { ALL_AI_KEYS, AI_KEYS, type AiProvider, type AiModel, getModelCost, formatModelLabel } from "../Schema/aiSettingsSchema";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { demoGuard } from "@/lib/demo";
 
 const API_KEY_MASK = "••••••••••••••••";
 
@@ -36,6 +37,7 @@ export async function getAiSettings() {
 export async function setAiSettings(entries: Record<string, string>) {
   return withAuth(
     async ({ userId, organizationId }) => {
+      demoGuard();
       // Filter out masked API key — only update if user provided a new one
       const filtered = Object.entries(entries).filter(
         ([key, value]) => !(key === AI_KEYS.AI_API_KEY && value === API_KEY_MASK),
@@ -118,6 +120,7 @@ function anthropicModelOrder(id: string): number {
 export async function fetchAiModels(provider: AiProvider) {
   return withAuth(
     async ({ organizationId }) => {
+      demoGuard();
       // Read API key from DB — never accept it from the client
       const setting = await db.appSetting.findUnique({
         where: { organizationId_key: { organizationId, key: AI_KEYS.AI_API_KEY } },

+ 5 - 0
src/features/email/Actions/emailActions.ts

@@ -15,6 +15,7 @@ import { getFeatures } from "@/lib/features";
 import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { requireFeature } from "@/lib/features";
+import { demoGuard } from "@/lib/demo";
 
 async function getWorkshopSettings(organizationId: string) {
   const [settings, org] = await Promise.all([
@@ -77,6 +78,7 @@ export async function sendQuoteEmail(input: {
   message?: string;
 }) {
   return withAuth(async ({ organizationId }) => {
+    demoGuard();
     await requireFeature(organizationId, "smtp");
 
     const { quoteId, recipientEmail, message } = input;
@@ -179,6 +181,7 @@ export async function sendNotificationEmail(input: {
   body: string;
 }) {
   return withAuth(async ({ organizationId }) => {
+    demoGuard();
     await requireFeature(organizationId, "smtp");
 
     const settings = await getWorkshopSettings(organizationId);
@@ -214,6 +217,7 @@ export async function sendInvoiceEmail(input: {
   message?: string;
 }) {
   return withAuth(async ({ organizationId }) => {
+    demoGuard();
     await requireFeature(organizationId, "smtp");
 
     const { serviceRecordId, recipientEmail, message } = input;
@@ -317,6 +321,7 @@ export async function sendInspectionEmail(input: {
   message?: string;
 }) {
   return withAuth(async ({ organizationId }) => {
+    demoGuard();
     await requireFeature(organizationId, "smtp");
 
     const { inspectionId, recipientEmail, message } = input;

+ 3 - 0
src/features/email/Actions/emailSettingsActions.ts

@@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache";
 import { ALL_ORG_EMAIL_KEYS } from "../Schema/emailSettingsSchema";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
+import { demoGuard } from "@/lib/demo";
 
 export async function getEmailSettings() {
   return withAuth(
@@ -30,6 +31,7 @@ export async function getEmailSettings() {
 export async function setEmailSettings(entries: Record<string, string>) {
   return withAuth(
     async ({ userId, organizationId }) => {
+      demoGuard();
       await db.$transaction(
         Object.entries(entries).map(([key, value]) =>
           db.appSetting.upsert({
@@ -56,6 +58,7 @@ export async function setEmailSettings(entries: Record<string, string>) {
 export async function testOrgEmailConnection() {
   return withAuth(
     async ({ userId, organizationId }) => {
+      demoGuard();
       const user = await db.user.findUnique({
         where: { id: userId },
         select: { email: true },

+ 396 - 5
src/features/inspections/Actions/inspectionActions.ts

@@ -2,10 +2,16 @@
 
 import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
-import { createInspectionSchema, updateInspectionItemSchema } from "../Schema/inspectionSchema";
+import {
+  createInspectionSchema,
+  updateInspectionDetailsSchema,
+  updateInspectionItemSchema,
+} from "../Schema/inspectionSchema";
 import { revalidatePath } from "next/cache";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { notificationBus } from "@/lib/notification-bus";
+import { isDefect } from "../Lib/conditions";
+import { findCompletionBlockers, summariseBlockers } from "../Lib/completion";
 
 export async function getInspectionsPaginated(params: {
   page?: number;
@@ -99,7 +105,10 @@ export async function getInspection(id: string) {
             customer: { select: { id: true, name: true, email: true, phone: true } },
           },
         },
-        template: { select: { id: true, name: true } },
+        template: {
+          select: { id: true, name: true, severityScale: true, country: true, standard: true },
+        },
+        technician: { select: { id: true, name: true } },
         items: { orderBy: { sortOrder: "asc" } },
         quotes: {
           select: {
@@ -107,6 +116,10 @@ export async function getInspection(id: string) {
             user: { select: { name: true } },
           },
         },
+        serviceRecords: {
+          select: { id: true, title: true, status: true, invoiceNumber: true, createdAt: true },
+          orderBy: { createdAt: "desc" as const },
+        },
         quoteRequests: {
           where: { status: "pending" },
           select: { id: true, message: true, selectedItemIds: true, createdAt: true },
@@ -176,12 +189,26 @@ export async function createInspection(input: unknown) {
       });
 
       // Copy template items into inspection items with globally unique sortOrder
-      // so sections always appear in a stable order when sorted by sortOrder
+      // so sections always appear in a stable order when sorted by sortOrder.
+      // The whole check definition is copied, not just the name, so editing or
+      // deleting the template later cannot change what an issued inspection says.
       const items = template.sections.flatMap((section, sIdx) =>
         section.items.map((item) => ({
           name: item.name,
           section: section.name,
+          sectionCode: section.code,
+          description: item.description,
+          code: item.code,
           sortOrder: sIdx * 1000 + item.sortOrder,
+          inputType: item.inputType,
+          unit: item.unit,
+          minValue: item.minValue,
+          maxValue: item.maxValue,
+          choices: item.choices,
+          required: item.required,
+          photoRequired: item.photoRequired,
+          defaultSeverity: item.defaultSeverity,
+          defectSuggestions: item.defectSuggestions,
           inspectionId: created.id,
         }))
       );
@@ -223,6 +250,8 @@ export async function updateInspectionItem(itemId: string, input: unknown) {
         condition: data.condition,
         notes: data.notes,
         imageUrls: data.imageUrls,
+        measuredValue: data.measuredValue,
+        textValue: data.textValue,
       },
     });
 
@@ -230,16 +259,112 @@ export async function updateInspectionItem(itemId: string, input: unknown) {
   }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.INSPECTIONS }] });
 }
 
-export async function completeInspection(id: string) {
+/**
+ * Saves the certificate fields required by Directive 2014/45/EU Annex IV that
+ * are not derivable from the checks themselves.
+ */
+export async function updateInspectionDetails(id: string, input: unknown) {
   return withAuth(async ({ organizationId }) => {
+    const data = updateInspectionDetailsSchema.parse(input);
+
     const inspection = await db.inspection.findFirst({
       where: { id, organizationId },
+      select: { id: true },
+    });
+    if (!inspection) throw new Error("Inspection not found");
+
+    // The inspector is chosen from the roster, so the name on the certificate
+    // is snapshotted from the technician record rather than trusted from the
+    // client — Annex IV(i) has to name someone who actually works here.
+    let inspectorName: string | null | undefined;
+    if (data.technicianId !== undefined) {
+      if (data.technicianId === null) {
+        inspectorName = null;
+      } else {
+        const technician = await db.technician.findFirst({
+          where: { id: data.technicianId, organizationId },
+          select: { name: true },
+        });
+        if (!technician) throw new Error("Technician not found");
+        inspectorName = technician.name;
+      }
+    }
+
+    const updated = await db.inspection.update({
+      where: { id },
+      data: {
+        mileage: data.mileage,
+        vehicleCategory: data.vehicleCategory,
+        certificateNumber: data.certificateNumber,
+        technicianId: data.technicianId,
+        inspectorName,
+        testLocation: data.testLocation,
+        nextTestDue: data.nextTestDue,
+        notes: data.notes,
+      },
+    });
+
+    revalidatePath(`/inspections/${id}`);
+    return updated;
+  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.INSPECTIONS }] });
+}
+
+export async function completeInspection(id: string) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const inspection = await db.inspection.findFirst({
+      where: { id, organizationId },
+      include: {
+        technician: { select: { name: true } },
+        items: {
+          select: {
+            id: true,
+            name: true,
+            code: true,
+            condition: true,
+            required: true,
+            photoRequired: true,
+            imageUrls: true,
+          },
+          orderBy: { sortOrder: "asc" },
+        },
+      },
     });
     if (!inspection) throw new Error("Inspection not found");
 
+    // Enforced here, not only in the form: a template marking a check
+    // mandatory, or requiring a photo of a defect, is a promise the report
+    // makes to whoever reads it. A client-side warning alone would let a
+    // report be issued that quietly breaks it.
+    const blockers = findCompletionBlockers(
+      inspection.items.map((item) => ({
+        id: item.id,
+        name: item.name,
+        code: item.code,
+        condition: item.condition,
+        required: item.required,
+        photoRequired: item.photoRequired,
+        photoCount: item.imageUrls.length,
+      }))
+    );
+    if (blockers.length > 0) {
+      throw new Error(summariseBlockers(blockers));
+    }
+
+    // Annex IV(i) wants the tester named on the certificate. Snapshot it now so
+    // the record stays accurate if the technician later leaves or is renamed.
+    let inspectorName = inspection.inspectorName ?? inspection.technician?.name ?? null;
+    if (!inspectorName) {
+      const user = await db.user.findUnique({ where: { id: userId }, select: { name: true } });
+      inspectorName = user?.name ?? null;
+    }
+
     await db.inspection.updateMany({
       where: { id, organizationId },
-      data: { status: "completed", completedAt: new Date() },
+      data: {
+        status: "completed",
+        completedAt: new Date(),
+        ...(inspection.inspectorName ? {} : { inspectorName }),
+      },
     });
 
     notificationBus.emit("workboard", {
@@ -265,6 +390,53 @@ export async function completeInspection(id: string) {
   });
 }
 
+/**
+ * Puts a completed inspection back into progress so its checks can be edited.
+ *
+ * The certificate snapshot is left alone: the inspector, certificate number and
+ * next-test date stay as recorded, since reopening is usually a correction
+ * rather than a fresh test. `completedAt` is cleared, so the result reverts to
+ * in-progress until it is completed again.
+ */
+export async function reopenInspection(id: string) {
+  return withAuth(async ({ organizationId }) => {
+    const inspection = await db.inspection.findFirst({
+      where: { id, organizationId },
+      select: { id: true, status: true, vehicleId: true },
+    });
+    if (!inspection) throw new Error("Inspection not found");
+    if (inspection.status !== "completed") {
+      throw new Error("This inspection is already in progress");
+    }
+
+    await db.inspection.updateMany({
+      where: { id, organizationId },
+      data: { status: "in_progress", completedAt: null },
+    });
+
+    notificationBus.emit("workboard", {
+      type: "job_status_changed",
+      organizationId,
+      inspectionId: id,
+      status: "in_progress",
+    });
+
+    revalidatePath("/inspections");
+    revalidatePath(`/inspections/${id}`);
+    revalidatePath(`/vehicles/${inspection.vehicleId}`);
+    return { success: true, inspectionId: id };
+  }, {
+    requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.INSPECTIONS }],
+    audit: ({ result }) => ({
+      action: "inspection.reopen",
+      entity: "Inspection",
+      entityId: result.inspectionId,
+      message: `Reopened inspection ${result.inspectionId}`,
+      metadata: { inspectionId: result.inspectionId },
+    }),
+  });
+}
+
 export async function deleteInspection(id: string) {
   return withAuth(async ({ organizationId }) => {
     const inspection = await db.inspection.findFirst({
@@ -287,3 +459,222 @@ export async function deleteInspection(id: string) {
     }),
   });
 }
+
+/**
+ * The defect notes this organization has written most often against each check
+ * on a given inspection, so a shop's own phrasing is offered back to it.
+ *
+ * Deliberately one grouped query for the whole page rather than one per check:
+ * an Annex I checklist runs to ninety-odd items, and a per-item lookup would
+ * mean ninety round-trips to render a form.
+ */
+export async function getCommonDefectNotes(inspectionId: string) {
+  return withAuth(async ({ organizationId }) => {
+    const items = await db.inspectionItem.findMany({
+      where: { inspectionId, inspection: { organizationId } },
+      select: { name: true },
+      distinct: ["name"],
+    });
+    if (items.length === 0) return {} as Record<string, { text: string; severity: string }[]>;
+
+    const names = items.map((i) => i.name);
+
+    const grouped = await db.inspectionItem.groupBy({
+      by: ["name", "notes", "condition"],
+      where: {
+        inspection: { organizationId },
+        // Exclude this inspection so a note just typed does not immediately
+        // reappear as a suggestion under the field it was typed into.
+        inspectionId: { not: inspectionId },
+        name: { in: names },
+        condition: { in: ["attention", "fail", "dangerous"] },
+        notes: { not: null },
+      },
+      _count: { _all: true },
+      orderBy: { _count: { name: "desc" } },
+      take: 400,
+    });
+
+    const byName: Record<string, { text: string; severity: string; count: number }[]> = {};
+    for (const row of grouped) {
+      const text = row.notes?.trim();
+      if (!text || text.length > 200) continue;
+      const bucket = (byName[row.name] ??= []);
+      // The same wording can have been graded differently on different
+      // vehicles; keep the grade it carried most often.
+      const existing = bucket.find(
+        (b) => b.text.toLowerCase() === text.toLowerCase()
+      );
+      if (existing) {
+        if (row._count._all > existing.count) {
+          existing.severity = row.condition;
+          existing.count = row._count._all;
+        }
+        continue;
+      }
+      bucket.push({ text, severity: row.condition, count: row._count._all });
+    }
+
+    const result: Record<string, { text: string; severity: string }[]> = {};
+    for (const [name, list] of Object.entries(byName)) {
+      result[name] = list
+        .sort((a, b) => b.count - a.count)
+        .slice(0, 4)
+        .map(({ text, severity }) => ({ text, severity }));
+    }
+    return result;
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.INSPECTIONS }] });
+}
+
+/**
+ * The workshop's active technicians, for choosing who carried out a test.
+ *
+ * Scoped to inspection rights rather than work-board rights: a tester who can
+ * open an inspection has to be able to say who ran it, and this returns
+ * nothing but a roster of names.
+ */
+export async function getInspectionTechnicians() {
+  return withAuth(async ({ organizationId }) => {
+    return db.technician.findMany({
+      where: { organizationId, isActive: true },
+      orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
+      select: { id: true, name: true, color: true },
+    });
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.INSPECTIONS }] });
+}
+
+/**
+ * Raises a work order straight from an inspection, skipping the quote.
+ *
+ * Plenty of customers just say "fix it". Forcing a quote in between means
+ * building an estimate nobody asked for and waiting for an approval that has
+ * already been given out loud, so the defects become labour lines on a pending
+ * job directly. Each line carries the check that found it and the note the
+ * technician wrote, which is what the person doing the repair needs to read.
+ *
+ * Dangerous defects lead, then major, then minor — the order the work should
+ * be done in.
+ */
+export async function createWorkOrderFromInspection(id: string) {
+  return withAuth(async ({ organizationId }) => {
+    const inspection = await db.inspection.findFirst({
+      where: { id, organizationId },
+      include: {
+        vehicle: { select: { id: true, make: true, model: true, year: true } },
+        items: { orderBy: { sortOrder: "asc" } },
+        technician: { select: { id: true, name: true } },
+      },
+    });
+    if (!inspection) throw new Error("Inspection not found");
+
+    const severityOrder: Record<string, number> = { dangerous: 0, fail: 1, attention: 2 };
+    const defects = inspection.items
+      .filter((item) => isDefect(item.condition))
+      .sort(
+        (a, b) =>
+          (severityOrder[a.condition] ?? 9) - (severityOrder[b.condition] ?? 9) ||
+          a.sortOrder - b.sortOrder
+      );
+    if (defects.length === 0) {
+      throw new Error("This inspection has no defects to work on");
+    }
+
+    const [settings, org] = await Promise.all([
+      db.appSetting.findMany({
+        where: {
+          organizationId,
+          key: {
+            in: [
+              "workshop.invoicePrefix",
+              "workshop.defaultLaborRate",
+              "workshop.defaultTaxRate",
+              "workshop.taxEnabled",
+              "workshop.taxInclusive",
+            ],
+          },
+        },
+      }),
+      db.organization.findUnique({ where: { id: organizationId }, select: { name: true } }),
+    ]);
+    const settingsMap: Record<string, string> = {};
+    for (const s of settings) settingsMap[s.key] = s.value;
+
+    const rawPrefix = settingsMap["workshop.invoicePrefix"] ?? "{year}-";
+    const now = new Date();
+    const prefix = rawPrefix
+      .replace("{year}", now.getFullYear().toString())
+      .replace("{month}", String(now.getMonth() + 1).padStart(2, "0"));
+
+    const lastRecord = await db.serviceRecord.findFirst({
+      where: { organizationId },
+      orderBy: { createdAt: "desc" },
+      select: { invoiceNumber: true },
+    });
+    let nextNum = 1001;
+    if (lastRecord?.invoiceNumber) {
+      const match = lastRecord.invoiceNumber.match(/(\d+)$/);
+      if (match) nextNum = parseInt(match[1], 10) + 1;
+    }
+
+    const taxEnabled = settingsMap["workshop.taxEnabled"] !== "false";
+    const laborRate = Number(settingsMap["workshop.defaultLaborRate"]) || 0;
+    const vehicleName = `${inspection.vehicle.year} ${inspection.vehicle.make} ${inspection.vehicle.model}`;
+
+    const record = await db.$transaction(async (tx) => {
+      const created = await tx.serviceRecord.create({
+        data: {
+          organizationId,
+          title: `${vehicleName} — inspection repairs`,
+          description: `Raised from the inspection carried out on ${inspection.createdAt.toISOString().slice(0, 10)}.`,
+          type: "repair",
+          status: "pending",
+          vehicleId: inspection.vehicle.id,
+          inspectionId: inspection.id,
+          technicianId: inspection.technicianId,
+          techName: inspection.technician?.name,
+          shopName: org?.name || undefined,
+          invoiceNumber: `${prefix}${nextNum}`,
+          mileage: inspection.mileage,
+          // Hours and totals stay at zero: the point is to get the job on the
+          // board immediately, and the workshop prices it as it works.
+          taxRate: taxEnabled ? Number(settingsMap["workshop.defaultTaxRate"]) || 0 : 0,
+          taxInclusive: settingsMap["workshop.taxInclusive"] === "true",
+          serviceDate: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
+          startDateTime: now,
+        },
+      });
+
+      await tx.serviceLabor.createMany({
+        data: defects.map((item) => ({
+          description: [
+            item.code ? `${item.code} ${item.name}` : item.name,
+            item.notes,
+          ]
+            .filter(Boolean)
+            .join(" — "),
+          hours: 0,
+          rate: laborRate,
+          total: 0,
+          serviceRecordId: created.id,
+        })),
+      });
+
+      return created;
+    });
+
+    revalidatePath("/work-orders");
+    revalidatePath("/work-board");
+    revalidatePath(`/inspections/${id}`);
+    revalidatePath(`/vehicles/${inspection.vehicle.id}`);
+    return { id: record.id, vehicleId: inspection.vehicle.id, defectCount: defects.length };
+  }, {
+    requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.SERVICES }],
+    audit: ({ result }) => ({
+      action: "service.create",
+      entity: "ServiceRecord",
+      entityId: result.id,
+      message: `Created work order ${result.id} from an inspection`,
+      metadata: { serviceRecordId: result.id, vehicleId: result.vehicleId },
+    }),
+  });
+}

+ 311 - 142
src/features/inspections/Actions/templateActions.ts

@@ -5,35 +5,56 @@ import { withAuth } from "@/lib/with-auth";
 import { createTemplateSchema, updateTemplateSchema } from "../Schema/templateSchema";
 import { revalidatePath } from "next/cache";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import type { TemplateSectionInput } from "../Schema/templateSchema";
+import { TEMPLATE_PRESETS, type TemplatePreset } from "../Lib/templatePresets";
+
+/**
+ * Sections and their checks are always rewritten wholesale rather than diffed,
+ * so create and update share one builder. Positional index wins over any
+ * sortOrder the client sent, which keeps the order the user dragged into place.
+ */
+function buildSectionCreates(sections: TemplateSectionInput[]) {
+  return sections.map((section, sIdx) => ({
+    name: section.name,
+    description: section.description || null,
+    code: section.code || null,
+    sortOrder: sIdx,
+    items: {
+      create: section.items.map((item, iIdx) => ({
+        name: item.name,
+        description: item.description || null,
+        code: item.code || null,
+        sortOrder: iIdx,
+        inputType: item.inputType,
+        unit: item.unit || null,
+        minValue: item.minValue ?? null,
+        maxValue: item.maxValue ?? null,
+        choices: item.choices ?? [],
+        required: item.required,
+        photoRequired: item.photoRequired,
+        defaultSeverity: item.defaultSeverity ?? null,
+        defectSuggestions: item.defectSuggestions ?? [],
+      })),
+    },
+  }));
+}
 
 export async function getTemplates() {
-  return withAuth(async ({ organizationId }) => {
-    let templates = await db.inspectionTemplate.findMany({
+  return withAuth(async ({ organizationId, userId }) => {
+    // Runs before the query, so anything new is in the list this request.
+    await syncPresetLibrary(organizationId, userId);
+
+    const templates = await db.inspectionTemplate.findMany({
       where: { organizationId },
       include: {
         sections: {
-          include: { items: true },
+          include: { items: { orderBy: { sortOrder: "asc" } } },
           orderBy: { sortOrder: "asc" },
         },
       },
       orderBy: { createdAt: "desc" },
     });
 
-    // Auto-seed default template for new organizations
-    if (templates.length === 0) {
-      await seedDefaultTemplateForOrg(organizationId);
-      templates = await db.inspectionTemplate.findMany({
-        where: { organizationId },
-        include: {
-          sections: {
-            include: { items: true },
-            orderBy: { sortOrder: "asc" },
-          },
-        },
-        orderBy: { createdAt: "desc" },
-      });
-    }
-
     return templates;
   }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.INSPECTIONS }] });
 }
@@ -72,19 +93,11 @@ export async function createTemplate(input: unknown) {
           name: data.name,
           description: data.description,
           isDefault: data.isDefault,
+          country: data.country ?? null,
+          standard: data.standard ?? "custom",
+          severityScale: data.severityScale,
           organizationId,
-          sections: {
-            create: data.sections.map((section, sIdx) => ({
-              name: section.name,
-              sortOrder: section.sortOrder ?? sIdx,
-              items: {
-                create: section.items.map((item, iIdx) => ({
-                  name: item.name,
-                  sortOrder: item.sortOrder ?? iIdx,
-                })),
-              },
-            })),
-          },
+          sections: { create: buildSectionCreates(data.sections) },
         },
         include: {
           sections: { include: { items: true } },
@@ -137,18 +150,10 @@ export async function updateTemplate(input: unknown) {
           name: data.name,
           description: data.description,
           isDefault: data.isDefault,
-          sections: {
-            create: data.sections.map((section, sIdx) => ({
-              name: section.name,
-              sortOrder: section.sortOrder ?? sIdx,
-              items: {
-                create: section.items.map((item, iIdx) => ({
-                  name: item.name,
-                  sortOrder: item.sortOrder ?? iIdx,
-                })),
-              },
-            })),
-          },
+          country: data.country ?? null,
+          standard: data.standard ?? "custom",
+          severityScale: data.severityScale,
+          sections: { create: buildSectionCreates(data.sections) },
         },
         include: {
           sections: { include: { items: true } },
@@ -202,116 +207,280 @@ export async function deleteTemplate(id: string) {
   });
 }
 
-async function seedDefaultTemplateForOrg(organizationId: string) {
-  const count = await db.inspectionTemplate.count({ where: { organizationId } });
-  if (count > 0) return null;
 
-  const template = await db.inspectionTemplate.create({
-    data: {
-      name: "Standard Multi-Point Inspection",
-      description: "Comprehensive vehicle inspection covering all major systems",
-      isDefault: true,
-      organizationId,
-      sections: {
-        create: [
-          {
-            name: "Exterior",
-            sortOrder: 0,
-            items: {
-              create: [
-                { name: "Body condition", sortOrder: 0 },
-                { name: "Paint", sortOrder: 1 },
-                { name: "Lights", sortOrder: 2 },
-                { name: "Windshield", sortOrder: 3 },
-                { name: "Wipers", sortOrder: 4 },
-                { name: "Tires", sortOrder: 5 },
-                { name: "Wheels", sortOrder: 6 },
-              ],
-            },
-          },
-          {
-            name: "Under Hood",
-            sortOrder: 1,
-            items: {
-              create: [
-                { name: "Engine oil", sortOrder: 0 },
-                { name: "Coolant", sortOrder: 1 },
-                { name: "Brake fluid", sortOrder: 2 },
-                { name: "Power steering", sortOrder: 3 },
-                { name: "Battery", sortOrder: 4 },
-                { name: "Belts", sortOrder: 5 },
-                { name: "Hoses", sortOrder: 6 },
-                { name: "Air filter", sortOrder: 7 },
-              ],
-            },
-          },
-          {
-            name: "Under Vehicle",
-            sortOrder: 2,
-            items: {
-              create: [
-                { name: "Exhaust", sortOrder: 0 },
-                { name: "Suspension", sortOrder: 1 },
-                { name: "CV joints", sortOrder: 2 },
-                { name: "Brake lines", sortOrder: 3 },
-              ],
-            },
-          },
-          {
-            name: "Interior",
-            sortOrder: 3,
-            items: {
-              create: [
-                { name: "Dash lights", sortOrder: 0 },
-                { name: "Horn", sortOrder: 1 },
-                { name: "A/C", sortOrder: 2 },
-                { name: "Heater", sortOrder: 3 },
-                { name: "Seat belts", sortOrder: 4 },
-              ],
-            },
-          },
-          {
-            name: "Brakes",
-            sortOrder: 4,
-            items: {
-              create: [
-                { name: "Front pads", sortOrder: 0 },
-                { name: "Rear pads", sortOrder: 1 },
-                { name: "Rotors", sortOrder: 2 },
-                { name: "Brake lines", sortOrder: 3 },
-              ],
-            },
-          },
-          {
-            name: "Tires",
-            sortOrder: 5,
+/**
+ * Copies a preset from Lib/templatePresets into an editable template owned by
+ * the organization. The copy is a plain template from that point on — nothing
+ * links back to the preset, so the workshop can rename sections, move checks
+ * and change every threshold to match its own country and equipment.
+ */
+export async function createTemplateFromPreset(presetId: string) {
+  return withAuth(async ({ organizationId }) => {
+    const preset = TEMPLATE_PRESETS.find((p) => p.id === presetId);
+    if (!preset) throw new Error("Preset not found");
+
+    const isFirst = (await db.inspectionTemplate.count({ where: { organizationId } })) === 0;
+
+    const template = await db.inspectionTemplate.create({
+      data: presetToCreate(preset, organizationId, isFirst),
+      include: { sections: { include: { items: true } } },
+    });
+
+    revalidatePath("/settings/templates");
+    return template;
+  }, {
+    requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.INSPECTIONS }],
+    audit: ({ result }) => ({
+      action: "inspectionTemplate.create",
+      entity: "InspectionTemplate",
+      entityId: result.id,
+      message: `Created inspection template "${result.name}" from a preset`,
+      metadata: { templateId: result.id, templateName: result.name },
+    }),
+  });
+}
+
+/**
+ * Duplicates an existing template. Useful for keeping one checklist per country
+ * or per vehicle category without rebuilding it from scratch.
+ */
+export async function duplicateTemplate(id: string) {
+  return withAuth(async ({ organizationId }) => {
+    const source = await db.inspectionTemplate.findFirst({
+      where: { id, organizationId },
+      include: {
+        sections: {
+          include: { items: { orderBy: { sortOrder: "asc" } } },
+          orderBy: { sortOrder: "asc" },
+        },
+      },
+    });
+    if (!source) throw new Error("Template not found");
+
+    const template = await db.inspectionTemplate.create({
+      data: {
+        name: `${source.name} (copy)`,
+        description: source.description,
+        isDefault: false,
+        country: source.country,
+        standard: source.standard,
+        severityScale: source.severityScale,
+        organizationId,
+        sections: {
+          create: source.sections.map((section, sIdx) => ({
+            name: section.name,
+            description: section.description,
+            code: section.code,
+            sortOrder: sIdx,
             items: {
-              create: [
-                { name: "Tread depth (LF)", sortOrder: 0 },
-                { name: "Tread depth (RF)", sortOrder: 1 },
-                { name: "Tread depth (LR)", sortOrder: 2 },
-                { name: "Tread depth (RR)", sortOrder: 3 },
-                { name: "Tire pressure", sortOrder: 4 },
-                { name: "Spare tire", sortOrder: 5 },
-              ],
+              create: section.items.map((item, iIdx) => ({
+                name: item.name,
+                description: item.description,
+                code: item.code,
+                sortOrder: iIdx,
+                inputType: item.inputType,
+                unit: item.unit,
+                minValue: item.minValue,
+                maxValue: item.maxValue,
+                choices: item.choices,
+                required: item.required,
+                photoRequired: item.photoRequired,
+                defaultSeverity: item.defaultSeverity,
+                defectSuggestions: item.defectSuggestions,
+              })),
             },
-          },
-        ],
+          })),
+        },
       },
+      include: { sections: { include: { items: true } } },
+    });
+
+    revalidatePath("/settings/templates");
+    return template;
+  }, {
+    requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.INSPECTIONS }],
+    audit: ({ result }) => ({
+      action: "inspectionTemplate.create",
+      entity: "InspectionTemplate",
+      entityId: result.id,
+      message: `Duplicated inspection template into "${result.name}"`,
+      metadata: { templateId: result.id, templateName: result.name },
+    }),
+  });
+}
+
+/** Turns a preset into the nested create Prisma wants. */
+function presetToCreate(preset: TemplatePreset, organizationId: string, isDefault: boolean) {
+  return {
+    name: preset.name,
+    description: preset.description,
+    isDefault,
+    country: preset.country,
+    standard: preset.standard,
+    severityScale: preset.severityScale,
+    organizationId,
+    sections: {
+      create: preset.sections.map((section, sIdx) => ({
+        name: section.name,
+        description: section.description || null,
+        code: section.code || null,
+        sortOrder: sIdx,
+        items: {
+          create: section.items.map((item, iIdx) => ({
+            name: item.name,
+            description: item.description || null,
+            code: item.code || null,
+            sortOrder: iIdx,
+            inputType: item.inputType ?? "condition",
+            unit: item.unit || null,
+            minValue: item.minValue ?? null,
+            maxValue: item.maxValue ?? null,
+            choices: item.choices ?? [],
+            required: item.required ?? false,
+            photoRequired: item.photoRequired ?? false,
+            defaultSeverity: item.defaultSeverity ?? null,
+            defectSuggestions: [],
+          })),
+        },
+      })),
     },
-    include: {
-      sections: { include: { items: true } },
+  };
+}
+
+/** Every preset a workshop would actually run. "blank" is a starting point for
+ *  building one, not a checklist, so it stays out of the library. */
+const LIBRARY_PRESETS = TEMPLATE_PRESETS.filter((p) => p.id !== "blank");
+
+/**
+ * Marks which presets an organization has already been offered.
+ *
+ * Without this, topping the library up on every page load would resurrect a
+ * checklist the workshop deliberately deleted, and it would be impossible to
+ * get rid of. The marker records that a preset has been handled — installed or
+ * consciously skipped — so deleting one is permanent, while a preset added to
+ * the library in a later release still arrives on its own.
+ */
+const PRESETS_INSTALLED_KEY = "inspections.presetsInstalled";
+
+async function readHandledPresets(organizationId: string): Promise<Set<string>> {
+  const row = await db.appSetting.findFirst({
+    where: { organizationId, key: PRESETS_INSTALLED_KEY },
+    select: { value: true },
+  });
+  if (!row?.value) return new Set();
+  try {
+    const parsed = JSON.parse(row.value);
+    return new Set(Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string") : []);
+  } catch {
+    return new Set();
+  }
+}
+
+/**
+ * Brings the organization's list up to the current library.
+ *
+ * Runs on read rather than behind a button, so a workshop that has been using
+ * Torqvoice for a year gets the new checklists without being told to go and
+ * fetch them. It writes nothing once every preset has been handled.
+ */
+async function syncPresetLibrary(organizationId: string, userId: string) {
+  const handled = await readHandledPresets(organizationId);
+  const pending = LIBRARY_PRESETS.filter((p) => !handled.has(p.id));
+  if (pending.length === 0) return 0;
+
+  const existing = await db.inspectionTemplate.findMany({
+    where: { organizationId },
+    select: { name: true, isDefault: true },
+  });
+  const taken = new Set(existing.map((t) => t.name.trim().toLowerCase()));
+  // A workshop that already built its own copy keeps it; the preset is marked
+  // handled so it is never added alongside.
+  const toCreate = pending.filter((p) => !taken.has(p.name.trim().toLowerCase()));
+
+  if (toCreate.length > 0) {
+    // A shop opening the app for the first time should land on the general
+    // checklist, not on a national statutory test it may not be approved to run.
+    const hasDefault = existing.some((t) => t.isDefault);
+    await db.$transaction(
+      toCreate.map((preset) =>
+        db.inspectionTemplate.create({
+          data: presetToCreate(
+            preset,
+            organizationId,
+            !hasDefault && preset.id === "standard-multipoint"
+          ),
+        })
+      )
+    );
+  }
+
+  const next = new Set([...handled, ...pending.map((p) => p.id)]);
+  await db.appSetting.upsert({
+    where: { organizationId_key: { organizationId, key: PRESETS_INSTALLED_KEY } },
+    create: {
+      organizationId,
+      key: PRESETS_INSTALLED_KEY,
+      value: JSON.stringify([...next]),
+      userId,
     },
+    update: { value: JSON.stringify([...next]) },
   });
 
-  return template;
+  return toCreate.length;
+}
+
+/**
+ * Puts back any library checklist the workshop no longer has, ignoring the
+ * handled marker. This is the deliberate "I deleted that and want it back"
+ * path, which is why it is a button rather than something that happens on load.
+ */
+export async function restoreMissingPresets() {
+  return withAuth(async ({ organizationId, userId }) => {
+    const existing = await db.inspectionTemplate.findMany({
+      where: { organizationId },
+      select: { name: true, isDefault: true },
+    });
+    const taken = new Set(existing.map((t) => t.name.trim().toLowerCase()));
+    const missing = LIBRARY_PRESETS.filter((p) => !taken.has(p.name.trim().toLowerCase()));
+    if (missing.length === 0) return { added: 0 };
+
+    const hasDefault = existing.some((t) => t.isDefault);
+    await db.$transaction(
+      missing.map((preset) =>
+        db.inspectionTemplate.create({
+          data: presetToCreate(
+            preset,
+            organizationId,
+            !hasDefault && preset.id === "standard-multipoint"
+          ),
+        })
+      )
+    );
+
+    const handled = await readHandledPresets(organizationId);
+    const next = new Set([...handled, ...LIBRARY_PRESETS.map((p) => p.id)]);
+    await db.appSetting.upsert({
+      where: { organizationId_key: { organizationId, key: PRESETS_INSTALLED_KEY } },
+      create: {
+        organizationId,
+        key: PRESETS_INSTALLED_KEY,
+        value: JSON.stringify([...next]),
+        userId,
+      },
+      update: { value: JSON.stringify([...next]) },
+    });
+
+    revalidatePath("/settings/templates");
+    return { added: missing.length };
+  }, {
+    requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.INSPECTIONS }],
+  });
 }
 
 export async function seedDefaultTemplate() {
-  return withAuth(async ({ organizationId }) => {
-    const result = await seedDefaultTemplateForOrg(organizationId);
-    revalidatePath("/settings/inspections");
-    return result;
+  return withAuth(async ({ organizationId, userId }) => {
+    const added = await syncPresetLibrary(organizationId, userId);
+    revalidatePath("/settings/templates");
+    return { added };
   }, { requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.INSPECTIONS }] });
 }
-

+ 168 - 0
src/features/inspections/Components/DefectSuggestions.tsx

@@ -0,0 +1,168 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from "@/components/ui/command";
+import { Plus } from "lucide-react";
+import {
+  rankSuggestions,
+  type DefectSeverity,
+  type DefectSuggestion,
+  type SuggestionCheck,
+} from "../Lib/defectCatalogue";
+import { CONDITION_TOKENS, conditionLabel, type Condition, type SeverityScale } from "../Lib/conditions";
+
+/** How many phrases sit inline before the rest move into the search popover. */
+const INLINE_LIMIT = 5;
+
+const SOURCE_LABEL: Record<DefectSuggestion["source"], string> = {
+  workshop: "Your workshop",
+  history: "Used before",
+  regulation: "Directive 2014/45/EU",
+  general: "General",
+};
+
+function SeverityDot({ severity }: { severity: DefectSeverity }) {
+  return (
+    <span
+      className={`h-2 w-2 shrink-0 rounded-full ${CONDITION_TOKENS[severity].bar}`}
+      aria-hidden="true"
+    />
+  );
+}
+
+/**
+ * Ready-made defect wording for a check.
+ *
+ * Each phrase shows the grade it will apply, because picking one both writes
+ * the note and sets the grade — the Directive ties the two together, and a
+ * control that silently changed the grade would be a nasty surprise. Showing
+ * the grade up front means the technician chooses it knowingly.
+ */
+export function DefectSuggestions({
+  check,
+  scale,
+  currentCondition,
+  history,
+  disabled,
+  onPick,
+}: {
+  check: SuggestionCheck;
+  scale: SeverityScale;
+  currentCondition: Condition;
+  history?: { text: string; severity: string }[];
+  disabled?: boolean;
+  onPick: (suggestion: DefectSuggestion) => void;
+}) {
+  const [open, setOpen] = useState(false);
+
+  const suggestions = useMemo(
+    () =>
+      rankSuggestions(check, {
+        scale,
+        preferred: currentCondition,
+        history: (history ?? [])
+          .filter((h): h is { text: string; severity: DefectSeverity } =>
+            h.severity === "attention" || h.severity === "fail" || h.severity === "dangerous"
+          )
+          .map((h) => ({ text: h.text, severity: h.severity })),
+      }),
+    [check, scale, currentCondition, history]
+  );
+
+  if (suggestions.length === 0) return null;
+
+  const inline = suggestions.slice(0, INLINE_LIMIT);
+  const hasMore = suggestions.length > inline.length;
+
+  const pick = (suggestion: DefectSuggestion) => {
+    onPick(suggestion);
+    setOpen(false);
+  };
+
+  return (
+    <div className="mt-2">
+      <p className="text-muted-foreground text-xs" id={`${check.name}-suggestions-label`}>
+        Common defects
+      </p>
+      <div
+        role="group"
+        aria-labelledby={`${check.name}-suggestions-label`}
+        className="mt-1 flex flex-wrap gap-1.5"
+      >
+        {inline.map((suggestion) => (
+          <button
+            key={`${suggestion.source}-${suggestion.text}`}
+            type="button"
+            disabled={disabled}
+            onClick={() => pick(suggestion)}
+            aria-label={`Add "${suggestion.text}" and grade as ${conditionLabel(
+              suggestion.severity,
+              scale
+            )}`}
+            title={SOURCE_LABEL[suggestion.source]}
+            className="bg-background hover:bg-muted focus-visible:ring-ring inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
+          >
+            <SeverityDot severity={suggestion.severity} />
+            <span className="truncate">{suggestion.text}</span>
+          </button>
+        ))}
+
+        {hasMore && (
+          <Popover open={open} onOpenChange={setOpen}>
+            <PopoverTrigger asChild>
+              <Button
+                type="button"
+                variant="ghost"
+                size="sm"
+                disabled={disabled}
+                className="h-[30px] rounded-full px-2.5 text-xs"
+              >
+                <Plus className="mr-1 h-3 w-3" aria-hidden="true" />
+                More ({suggestions.length - inline.length})
+              </Button>
+            </PopoverTrigger>
+            <PopoverContent className="w-[min(28rem,90vw)] p-0" align="start">
+              <Command>
+                <CommandInput placeholder="Search defect wording" />
+                <CommandList>
+                  <CommandEmpty>No matching phrase.</CommandEmpty>
+                  {(["workshop", "history", "regulation", "general"] as const).map((source) => {
+                    const group = suggestions.filter((s) => s.source === source);
+                    if (group.length === 0) return null;
+                    return (
+                      <CommandGroup key={source} heading={SOURCE_LABEL[source]}>
+                        {group.map((suggestion) => (
+                          <CommandItem
+                            key={`${source}-${suggestion.text}`}
+                            value={suggestion.text}
+                            onSelect={() => pick(suggestion)}
+                            className="gap-2"
+                          >
+                            <SeverityDot severity={suggestion.severity} />
+                            <span className="flex-1">{suggestion.text}</span>
+                            <span className="text-muted-foreground shrink-0 text-xs">
+                              {conditionLabel(suggestion.severity, scale)}
+                            </span>
+                          </CommandItem>
+                        ))}
+                      </CommandGroup>
+                    );
+                  })}
+                </CommandList>
+              </Command>
+            </PopoverContent>
+          </Popover>
+        )}
+      </div>
+    </div>
+  );
+}

+ 452 - 0
src/features/inspections/Components/InspectionCertificateCard.tsx

@@ -0,0 +1,452 @@
+"use client";
+
+import { useId, useMemo, useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { DateInput } from "@/components/ui/date-input";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectSeparator,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { FileCheck2, Loader2, RotateCcw, UserPlus } from "lucide-react";
+import { toast } from "sonner";
+import { updateInspectionDetails } from "../Actions/inspectionActions";
+import { createTechnician } from "@/features/workboard/Actions/technicianActions";
+import { VEHICLE_CATEGORIES } from "../Lib/conditions";
+import {
+  DEFAULT_INTERVAL_MONTHS,
+  TEST_INTERVALS,
+  addInterval,
+  matchInterval,
+  toISODate,
+} from "../Lib/testIntervals";
+
+export interface TechnicianOption {
+  id: string;
+  name: string;
+  color: string;
+}
+
+/** Sentinel for the "add a technician" row, which is not a selectable value. */
+const ADD_TECHNICIAN = "__add__";
+const NO_TECHNICIAN = "none";
+
+function AddTechnicianDialog({
+  open,
+  onOpenChange,
+  onCreated,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onCreated: (technician: TechnicianOption) => void;
+}) {
+  const [name, setName] = useState("");
+  const [isPending, startTransition] = useTransition();
+  const fieldId = useId();
+
+  const submit = (event: React.FormEvent) => {
+    event.preventDefault();
+    const trimmed = name.trim();
+    if (!trimmed) return;
+    startTransition(async () => {
+      const result = await createTechnician({ name: trimmed, color: "#3b82f6" });
+      if (result.success && result.data) {
+        onCreated({ id: result.data.id, name: result.data.name, color: result.data.color });
+        toast.success(`${result.data.name} added and selected`);
+        setName("");
+        onOpenChange(false);
+      } else {
+        toast.error(result.error || "Could not add the technician");
+      }
+    });
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-sm">
+        <DialogHeader>
+          <DialogTitle>Add technician</DialogTitle>
+          <DialogDescription>
+            They join the workshop roster and can be assigned work as well as inspections.
+          </DialogDescription>
+        </DialogHeader>
+        <form onSubmit={submit} className="space-y-4">
+          <div className="space-y-1.5">
+            <Label htmlFor={`${fieldId}-name`}>Name</Label>
+            {/* eslint-disable-next-line jsx-a11y/no-autofocus */}
+            <Input
+              id={`${fieldId}-name`}
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              placeholder="e.g. Anna Berg"
+              autoFocus
+              required
+            />
+          </div>
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+              Cancel
+            </Button>
+            <Button type="submit" disabled={isPending || !name.trim()}>
+              {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />}
+              Add and select
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+/**
+ * The certificate fields Directive 2014/45/EU Annex IV requires but that cannot
+ * be derived from the checks themselves.
+ *
+ * Everything that can be filled in for the technician already is: the inspector
+ * comes from the workshop roster, the place of test from workshop settings, and
+ * the next test date from the standard interval. What is left is the handful of
+ * facts only the person doing the test knows.
+ */
+export function InspectionCertificateCard({
+  inspection,
+  technicians,
+  workshopAddress,
+  mileageLabel,
+  isCompleted,
+}: {
+  inspection: {
+    id: string;
+    mileage: number | null;
+    vehicleCategory: string | null;
+    certificateNumber: string | null;
+    technicianId: string | null;
+    inspectorName: string | null;
+    testLocation: string | null;
+    nextTestDue: Date | string | null;
+    completedAt: Date | string | null;
+    createdAt: Date | string;
+  };
+  technicians: TechnicianOption[];
+  /** Workshop address from settings, used as the default place of test. */
+  workshopAddress: string;
+  mileageLabel: string;
+  isCompleted: boolean;
+}) {
+  const router = useRouter();
+  const fieldId = useId();
+  const [isPending, startTransition] = useTransition();
+  const [showAddTechnician, setShowAddTechnician] = useState(false);
+  const [roster, setRoster] = useState(technicians);
+
+  const testDate = useMemo(
+    () => new Date(inspection.completedAt ?? inspection.createdAt),
+    [inspection.completedAt, inspection.createdAt]
+  );
+
+  // What is already stored, so the Save button can tell whether anything moved.
+  const persisted = useMemo(
+    () => ({
+      mileage: inspection.mileage === null ? "" : String(inspection.mileage),
+      vehicleCategory: inspection.vehicleCategory ?? NO_TECHNICIAN,
+      certificateNumber: inspection.certificateNumber ?? "",
+      technicianId: inspection.technicianId ?? NO_TECHNICIAN,
+      testLocation: inspection.testLocation ?? "",
+      nextTestDue: toISODate(inspection.nextTestDue),
+    }),
+    [inspection]
+  );
+
+  const [mileage, setMileage] = useState(persisted.mileage);
+  const [vehicleCategory, setVehicleCategory] = useState(persisted.vehicleCategory);
+  const [certificateNumber, setCertificateNumber] = useState(persisted.certificateNumber);
+  const [technicianId, setTechnicianId] = useState(persisted.technicianId);
+  // Blank falls back to the workshop address rather than leaving the certificate
+  // without a place of test; the field stays editable for off-site work.
+  const [testLocation, setTestLocation] = useState(
+    inspection.testLocation ?? workshopAddress ?? ""
+  );
+  const [nextTestDue, setNextTestDue] = useState(
+    toISODate(inspection.nextTestDue) || addInterval(testDate, DEFAULT_INTERVAL_MONTHS)
+  );
+
+  const isDirty =
+    mileage !== persisted.mileage ||
+    vehicleCategory !== persisted.vehicleCategory ||
+    certificateNumber !== persisted.certificateNumber ||
+    technicianId !== persisted.technicianId ||
+    testLocation !== persisted.testLocation ||
+    nextTestDue !== persisted.nextTestDue;
+
+  const activeInterval = matchInterval(testDate, nextTestDue);
+  const locationOverridden = !!workshopAddress && testLocation !== workshopAddress;
+
+  const handleTechnicianChange = (value: string) => {
+    if (value === ADD_TECHNICIAN) {
+      // Let the select finish closing before the dialog opens, otherwise the
+      // select's dismiss layer pulls focus back as the dialog mounts and the
+      // name field never gets it.
+      setTimeout(() => setShowAddTechnician(true), 0);
+      return;
+    }
+    setTechnicianId(value);
+  };
+
+  const handleSave = () => {
+    startTransition(async () => {
+      const result = await updateInspectionDetails(inspection.id, {
+        mileage: mileage === "" ? null : Number(mileage),
+        vehicleCategory: vehicleCategory === NO_TECHNICIAN ? null : vehicleCategory,
+        certificateNumber: certificateNumber || null,
+        technicianId: technicianId === NO_TECHNICIAN ? null : technicianId,
+        testLocation: testLocation || null,
+        nextTestDue: nextTestDue ? new Date(`${nextTestDue}T00:00:00`) : null,
+      });
+      if (result.success) {
+        toast.success("Certificate details saved");
+        router.refresh();
+      } else {
+        toast.error(result.error || "Could not save the certificate details");
+      }
+    });
+  };
+
+  return (
+    <section aria-labelledby={`${fieldId}-heading`} className="bg-card rounded-lg border">
+      <header className="flex items-start gap-2 border-b px-4 py-3">
+        <FileCheck2 className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
+        <div>
+          <h2 id={`${fieldId}-heading`} className="text-sm font-semibold">
+            Certificate details
+          </h2>
+          <p className="text-muted-foreground text-xs">
+            Required on a roadworthiness certificate under Directive 2014/45/EU, Annex IV.
+          </p>
+        </div>
+      </header>
+
+      <div className="grid gap-4 p-4 sm:grid-cols-2">
+        <div className="space-y-1.5">
+          <Label htmlFor={`${fieldId}-inspector`}>Inspector</Label>
+          <Select
+            value={technicianId}
+            onValueChange={handleTechnicianChange}
+            disabled={isCompleted}
+          >
+            <SelectTrigger id={`${fieldId}-inspector`}>
+              <SelectValue placeholder="Not recorded" />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value={NO_TECHNICIAN}>Not recorded</SelectItem>
+              {roster.map((tech) => (
+                <SelectItem key={tech.id} value={tech.id}>
+                  <span className="flex items-center gap-2">
+                    <span
+                      className="h-2.5 w-2.5 shrink-0 rounded-full"
+                      style={{ backgroundColor: tech.color }}
+                      aria-hidden="true"
+                    />
+                    {tech.name}
+                  </span>
+                </SelectItem>
+              ))}
+              {!isCompleted && (
+                <>
+                  <SelectSeparator />
+                  <SelectItem value={ADD_TECHNICIAN}>
+                    <span className="flex items-center gap-2">
+                      <UserPlus className="h-3.5 w-3.5" aria-hidden="true" />
+                      Add technician…
+                    </span>
+                  </SelectItem>
+                </>
+              )}
+            </SelectContent>
+          </Select>
+          {/* An inspection carried out before the roster existed still has a
+              name on it; say so rather than showing an empty field. */}
+          {technicianId === NO_TECHNICIAN && inspection.inspectorName && (
+            <p className="text-muted-foreground text-xs">
+              Recorded as {inspection.inspectorName}
+            </p>
+          )}
+        </div>
+
+        <div className="space-y-1.5">
+          <Label htmlFor={`${fieldId}-category`}>Vehicle category</Label>
+          <Select
+            value={vehicleCategory}
+            onValueChange={setVehicleCategory}
+            disabled={isCompleted}
+          >
+            <SelectTrigger id={`${fieldId}-category`}>
+              <SelectValue placeholder="Not recorded" />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value={NO_TECHNICIAN}>Not recorded</SelectItem>
+              {VEHICLE_CATEGORIES.map((c) => (
+                <SelectItem key={c.value} value={c.value}>
+                  {c.label}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </div>
+
+        <div className="space-y-1.5">
+          <Label htmlFor={`${fieldId}-mileage`}>{mileageLabel} at time of test</Label>
+          <Input
+            id={`${fieldId}-mileage`}
+            inputMode="numeric"
+            value={mileage}
+            onChange={(e) => /^\d*$/.test(e.target.value) && setMileage(e.target.value)}
+            disabled={isCompleted}
+          />
+        </div>
+
+        <div className="space-y-1.5">
+          <Label htmlFor={`${fieldId}-certificate`}>Report reference</Label>
+          <Input
+            id={`${fieldId}-certificate`}
+            value={certificateNumber}
+            onChange={(e) => setCertificateNumber(e.target.value)}
+            placeholder="Optional"
+            disabled={isCompleted}
+          />
+          {!isCompleted && (
+            <p className="text-muted-foreground text-xs">
+              Your own reference for this report. If you are an approved test centre issuing a
+              national control slip, put its number here so the two match. Otherwise leave it
+              empty.
+            </p>
+          )}
+        </div>
+
+        <div className="space-y-1.5 sm:col-span-2">
+          <Label htmlFor={`${fieldId}-location`}>Place of test</Label>
+          <Input
+            id={`${fieldId}-location`}
+            value={testLocation}
+            onChange={(e) => setTestLocation(e.target.value)}
+            placeholder={workshopAddress || "Test centre or workshop address"}
+            disabled={isCompleted}
+          />
+          {!isCompleted && (
+            <p className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
+              {locationOverridden ? (
+                <>
+                  <span>Differs from your workshop address.</span>
+                  <button
+                    type="button"
+                    onClick={() => setTestLocation(workshopAddress)}
+                    className="text-primary inline-flex items-center gap-1 hover:underline"
+                  >
+                    <RotateCcw className="h-3 w-3" aria-hidden="true" />
+                    Use workshop address
+                  </button>
+                </>
+              ) : workshopAddress ? (
+                <>
+                  <span>From your workshop settings.</span>
+                  <Link href="/settings/workshop" className="text-primary hover:underline">
+                    Change
+                  </Link>
+                </>
+              ) : (
+                <>
+                  <span>No workshop address is set.</span>
+                  <Link href="/settings/workshop" className="text-primary hover:underline">
+                    Add one
+                  </Link>
+                </>
+              )}
+            </p>
+          )}
+        </div>
+
+        <div className="space-y-1.5 sm:col-span-2">
+          <Label htmlFor={`${fieldId}-next`}>Next test due</Label>
+          <div className="flex flex-wrap items-center gap-2">
+            <div className="min-w-[12rem] flex-1">
+              <DateInput
+                id={`${fieldId}-next`}
+                value={nextTestDue}
+                onChange={setNextTestDue}
+                placeholder="Not set"
+              />
+            </div>
+            {!isCompleted && (
+              <div
+                role="group"
+                aria-label="Set the next test date from the date of this test"
+                className="flex flex-wrap gap-1"
+              >
+                {TEST_INTERVALS.map((interval) => {
+                  const selected = activeInterval?.months === interval.months;
+                  return (
+                    <button
+                      key={interval.months}
+                      type="button"
+                      aria-pressed={selected}
+                      onClick={() => setNextTestDue(addInterval(testDate, interval.months))}
+                      className={`focus-visible:ring-ring rounded-full border px-2.5 py-1.5 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none ${
+                        selected
+                          ? "bg-primary text-primary-foreground border-primary"
+                          : "bg-background hover:bg-muted"
+                      }`}
+                    >
+                      {interval.label}
+                    </button>
+                  );
+                })}
+              </div>
+            )}
+          </div>
+          {!isCompleted && (
+            <p className="text-muted-foreground text-xs">
+              Counted from the date of this test. Two years is the Union baseline for cars and
+              light goods vehicles; check the interval your country applies.
+            </p>
+          )}
+        </div>
+      </div>
+
+      {!isCompleted && (
+        <div className="flex items-center justify-end gap-3 border-t px-4 py-3">
+          {isDirty && (
+            <p className="text-muted-foreground text-xs" role="status">
+              Unsaved changes
+            </p>
+          )}
+          <Button type="button" size="sm" onClick={handleSave} disabled={isPending || !isDirty}>
+            {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />}
+            Save details
+          </Button>
+        </div>
+      )}
+
+      <AddTechnicianDialog
+        open={showAddTechnician}
+        onOpenChange={setShowAddTechnician}
+        onCreated={(technician) => {
+          setRoster((prev) => [...prev, technician]);
+          setTechnicianId(technician.id);
+        }}
+      />
+    </section>
+  );
+}

+ 668 - 0
src/features/inspections/Components/InspectionItemRow.tsx

@@ -0,0 +1,668 @@
+"use client";
+
+import { useId, useRef, useState, useTransition } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+  Camera,
+  Check,
+  Loader2,
+  Minus,
+  OctagonAlert,
+  TriangleAlert,
+  X,
+  XCircle,
+} from "lucide-react";
+import { toast } from "sonner";
+import { updateInspectionItem } from "../Actions/inspectionActions";
+import { DefectSuggestions } from "./DefectSuggestions";
+import type { DefectSuggestion } from "../Lib/defectCatalogue";
+import {
+  CONDITION_TOKENS,
+  SCALE_STEPS,
+  conditionGrade,
+  gradedConditionLabel,
+  formatRange,
+  gradeMeasurement,
+  isDefect,
+  type Condition,
+  type SeverityScale,
+} from "../Lib/conditions";
+
+export interface InspectionItemData {
+  id: string;
+  name: string;
+  section: string;
+  sortOrder: number;
+  condition: string;
+  notes: string | null;
+  imageUrls: string[];
+  description?: string | null;
+  code?: string | null;
+  sectionCode?: string | null;
+  inputType?: string | null;
+  unit?: string | null;
+  minValue?: number | null;
+  maxValue?: number | null;
+  choices?: string[];
+  required?: boolean;
+  photoRequired?: boolean;
+  defaultSeverity?: string | null;
+  defectSuggestions?: string[] | null;
+  measuredValue?: number | null;
+  textValue?: string | null;
+}
+
+const CONDITION_ICONS: Record<Condition, React.ComponentType<{ className?: string }>> = {
+  pass: Check,
+  attention: TriangleAlert,
+  fail: XCircle,
+  dangerous: OctagonAlert,
+  not_inspected: Minus,
+};
+
+const isVideo = (url: string) => /\.(mp4|webm|mov)$/i.test(url);
+
+/** An empty or unparseable field means "no reading", never NaN. */
+function parseReading(raw: string): number | null {
+  const trimmed = raw.trim();
+  if (trimmed === "") return null;
+  const value = Number(trimmed);
+  return Number.isFinite(value) ? value : null;
+}
+
+/**
+ * Grading control.
+ *
+ * A real radiogroup: arrow keys move between the grades, a roving tabindex
+ * keeps the group to one tab stop, and every option carries its own icon and
+ * visible text so the grade never depends on colour alone (WCAG 2.1 SC 1.4.1).
+ * Targets are 44px tall, which the previous 32px icon-only circles were not.
+ */
+function GradeControl({
+  value,
+  scale,
+  country,
+  labelledBy,
+  disabled,
+  onChange,
+}: {
+  value: Condition;
+  scale: SeverityScale;
+  /** Drives the national defect code shown on each grade, if any. */
+  country: string | null;
+  labelledBy: string;
+  disabled?: boolean;
+  onChange: (next: Condition, options?: { focusNotes?: boolean }) => void;
+}) {
+  const steps: Condition[] = [...SCALE_STEPS[scale], "not_inspected"];
+  const refs = useRef<(HTMLButtonElement | null)[]>([]);
+
+  const focusStep = (index: number) => {
+    const next = (index + steps.length) % steps.length;
+    refs.current[next]?.focus();
+    // Keyboard navigation keeps focus on the group; only a click hands focus
+    // on to the notes field, otherwise arrowing past a defect grade would
+    // throw the user out of the control mid-navigation.
+    onChange(steps[next], { focusNotes: false });
+  };
+
+  const handleKeyDown = (event: React.KeyboardEvent, index: number) => {
+    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
+      event.preventDefault();
+      focusStep(index + 1);
+    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
+      event.preventDefault();
+      focusStep(index - 1);
+    }
+  };
+
+  // Roving tabindex: the checked option owns the tab stop, or the first one
+  // when nothing has been graded yet.
+  const activeIndex = Math.max(steps.indexOf(value), 0);
+
+  return (
+    <div
+      role="radiogroup"
+      aria-labelledby={labelledBy}
+      className="flex flex-wrap gap-1"
+    >
+      {steps.map((step, index) => {
+        const token = CONDITION_TOKENS[step];
+        const Icon = CONDITION_ICONS[step];
+        const checked = value === step;
+        const grade = conditionGrade(step, scale, country);
+        const text = step === "not_inspected" ? "N/A" : token.short;
+        return (
+          <button
+            key={step}
+            ref={(el) => {
+              refs.current[index] = el;
+            }}
+            type="button"
+            role="radio"
+            aria-checked={checked}
+            aria-label={`${gradedConditionLabel(step, scale, country)}. ${token.hint}`}
+            tabIndex={index === activeIndex ? 0 : -1}
+            disabled={disabled}
+            onClick={() => onChange(step)}
+            onKeyDown={(e) => handleKeyDown(e, index)}
+            className={`focus-visible:ring-ring inline-flex h-11 min-w-[4.25rem] items-center justify-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60 ${
+              checked
+                ? token.solid
+                : "bg-background text-muted-foreground hover:bg-muted border-input"
+            }`}
+          >
+            <Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
+            {grade !== null && <span className="font-semibold tabular-nums">{grade}</span>}
+            {text}
+          </button>
+        );
+      })}
+    </div>
+  );
+}
+
+export function InspectionItemRow({
+  item,
+  scale,
+  country = null,
+  isCompleted,
+  history,
+  onOpenImage,
+  onChanged,
+  onSaveState,
+}: {
+  item: InspectionItemData;
+  scale: SeverityScale;
+  country?: string | null;
+  isCompleted: boolean;
+  /** Wording this workshop has used before on a check of this name. */
+  history?: { text: string; severity: string }[];
+  /** Opens the shared lightbox on the given URL. */
+  onOpenImage: (url: string) => void;
+  /** Lets the page recompute the summary without a server round-trip. */
+  onChanged: (itemId: string, change: { condition: Condition; photoCount: number }) => void;
+  /** Reports the autosave lifecycle so the page can show whether work is safe. */
+  onSaveState?: (itemId: string, state: "saving" | "saved" | "error") => void;
+}) {
+  const fieldId = useId();
+  const nameId = `${fieldId}-name`;
+
+  const [condition, setCondition] = useState(item.condition as Condition);
+  const [notes, setNotes] = useState(item.notes ?? "");
+  const [measured, setMeasured] = useState(
+    item.measuredValue === null || item.measuredValue === undefined ? "" : String(item.measuredValue)
+  );
+  const [textValue, setTextValue] = useState(item.textValue ?? "");
+  const [imageUrls, setImageUrls] = useState<string[]>(item.imageUrls ?? []);
+  const [showNotes, setShowNotes] = useState(!!item.notes);
+  const [notesRequired, setNotesRequired] = useState(false);
+  const [showClearDialog, setShowClearDialog] = useState(false);
+  const [isSaving, startSaving] = useTransition();
+  const [isUploading, setIsUploading] = useState(false);
+
+  const fileInputRef = useRef<HTMLInputElement>(null);
+  const notesRef = useRef<HTMLTextAreaElement>(null);
+
+  const inputType = item.inputType ?? "condition";
+  const range = formatRange(item);
+  const token = CONDITION_TOKENS[condition] ?? CONDITION_TOKENS.not_inspected;
+  const needsPhoto = !!item.photoRequired && isDefect(condition) && imageUrls.length === 0;
+
+  const save = (patch: {
+    condition?: Condition;
+    notes?: string;
+    imageUrls?: string[];
+    measuredValue?: number | null;
+    textValue?: string | null;
+  }) => {
+    const next = {
+      condition: patch.condition ?? condition,
+      notes: patch.notes ?? notes,
+      imageUrls: patch.imageUrls ?? imageUrls,
+      measuredValue:
+        patch.measuredValue !== undefined ? patch.measuredValue : parseReading(measured),
+      textValue: patch.textValue !== undefined ? patch.textValue : textValue || null,
+    };
+    onSaveState?.(item.id, "saving");
+    startSaving(async () => {
+      const result = await updateInspectionItem(item.id, {
+        condition: next.condition,
+        notes: next.notes || undefined,
+        imageUrls: next.imageUrls,
+        measuredValue: next.measuredValue,
+        textValue: next.textValue,
+      });
+      if (result.success) {
+        onChanged(item.id, {
+          condition: next.condition,
+          photoCount: next.imageUrls.length,
+        });
+        onSaveState?.(item.id, "saved");
+      } else {
+        onSaveState?.(item.id, "error");
+        toast.error(result.error || "Could not save this check");
+      }
+    });
+  };
+
+  const applyCondition = (next: Condition, { focusNotes = true } = {}) => {
+    if (isCompleted) return;
+
+    // Clearing a graded defect throws away its notes, so confirm first.
+    if (next === "not_inspected" && isDefect(condition) && notes.trim()) {
+      setShowClearDialog(true);
+      return;
+    }
+
+    setCondition(next);
+
+    if (isDefect(next)) {
+      setShowNotes(true);
+      if (notes.trim()) {
+        setNotesRequired(false);
+        save({ condition: next });
+      } else {
+        // Wait for the note before writing: a defect with no explanation is not
+        // a usable record, and the certificate has to say what was wrong.
+        setNotesRequired(true);
+        if (focusNotes) setTimeout(() => notesRef.current?.focus(), 50);
+      }
+      return;
+    }
+
+    setNotesRequired(false);
+    save({ condition: next });
+  };
+
+  const handleNotesBlur = () => {
+    if (isCompleted) return;
+    if (notesRequired) {
+      if (notes.trim()) {
+        setNotesRequired(false);
+        save({ notes });
+      }
+      return;
+    }
+    if (notes === (item.notes ?? "")) return;
+    save({ notes });
+  };
+
+  const handleMeasurementBlur = () => {
+    if (isCompleted) return;
+    const value = parseReading(measured);
+    if (value === null) {
+      save({ measuredValue: null });
+      return;
+    }
+
+    // A reading outside the template's range grades itself, so the technician
+    // records the number once instead of typing it and then also picking a grade.
+    const graded = gradeMeasurement(value, {
+      minValue: item.minValue,
+      maxValue: item.maxValue,
+      defaultSeverity: item.defaultSeverity,
+    });
+    if (graded && graded !== condition) {
+      const severity = graded === "pass" ? "pass" : graded;
+      setCondition(severity);
+      if (isDefect(severity)) setShowNotes(true);
+      save({ measuredValue: value, condition: severity });
+      return;
+    }
+    save({ measuredValue: value });
+  };
+
+  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (!files || files.length === 0 || isCompleted) return;
+
+    setIsUploading(true);
+    const uploaded: string[] = [];
+    try {
+      for (const file of Array.from(files)) {
+        const formData = new FormData();
+        formData.append("file", file);
+        const res = await fetch("/api/protected/upload/service-files", {
+          method: "POST",
+          body: formData,
+        });
+        const data = await res.json();
+        if (data.url) uploaded.push(data.url);
+        else toast.error(data.error || `Could not upload ${file.name}`);
+      }
+      if (uploaded.length > 0) {
+        const next = [...imageUrls, ...uploaded];
+        setImageUrls(next);
+        save({ imageUrls: next });
+      }
+    } catch {
+      toast.error("Upload failed");
+    } finally {
+      setIsUploading(false);
+      if (fileInputRef.current) fileInputRef.current.value = "";
+    }
+  };
+
+  const handleRemoveFile = (index: number) => {
+    if (isCompleted) return;
+    const next = imageUrls.filter((_, i) => i !== index);
+    setImageUrls(next);
+    save({ imageUrls: next });
+  };
+
+  /**
+   * Applies a ready-made phrase. The note is appended rather than replaced so a
+   * check with several faults can carry all of them, and the grade follows the
+   * phrase because the Directive assigns the category, not the technician.
+   */
+  const applySuggestion = (suggestion: DefectSuggestion) => {
+    if (isCompleted) return;
+    const existing = notes.trim();
+    const nextNotes = existing ? `${existing}\n${suggestion.text}` : suggestion.text;
+    setNotes(nextNotes);
+    setNotesRequired(false);
+    setShowNotes(true);
+    if (suggestion.severity !== condition) {
+      setCondition(suggestion.severity);
+      save({ condition: suggestion.severity, notes: nextNotes });
+      return;
+    }
+    save({ notes: nextNotes });
+  };
+
+  const describedBy = [
+    item.description ? `${fieldId}-desc` : null,
+    notesRequired ? `${fieldId}-notes-error` : null,
+    needsPhoto ? `${fieldId}-photo-error` : null,
+  ]
+    .filter(Boolean)
+    .join(" ");
+
+  return (
+    <li
+      className={`rounded-lg border p-3 transition-colors ${
+        isDefect(condition) ? token.soft : "bg-card"
+      }`}
+    >
+      <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
+        <div className="min-w-0 flex-1">
+          <p id={nameId} className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm font-medium">
+            {item.code && (
+              <span className="text-muted-foreground font-mono text-xs">{item.code}</span>
+            )}
+            <span>{item.name}</span>
+            {item.required && (
+              <span className="text-muted-foreground text-xs font-normal">(required)</span>
+            )}
+            {isSaving && (
+              <>
+                <Loader2 className="text-muted-foreground h-3 w-3 animate-spin" aria-hidden="true" />
+                <span className="sr-only">Saving</span>
+              </>
+            )}
+          </p>
+          {item.description && (
+            <p id={`${fieldId}-desc`} className="text-muted-foreground mt-0.5 text-xs">
+              {item.description}
+            </p>
+          )}
+        </div>
+
+        <div className="flex shrink-0 flex-col items-start gap-2 lg:items-end">
+          <GradeControl
+            value={condition}
+            scale={scale}
+            country={country}
+            labelledBy={nameId}
+            disabled={isCompleted}
+            onChange={applyCondition}
+          />
+          <div className="flex items-center gap-1">
+            <Button
+              type="button"
+              variant="ghost"
+              size="sm"
+              className="h-9"
+              aria-expanded={showNotes}
+              onClick={() => setShowNotes((v) => !v)}
+            >
+              {showNotes ? "Hide note" : "Add note"}
+            </Button>
+            <Button
+              type="button"
+              variant="ghost"
+              size="icon"
+              className="h-9 w-9"
+              disabled={isCompleted || isUploading}
+              onClick={() => fileInputRef.current?.click()}
+              aria-label={`Add a photo or video to ${item.name}`}
+            >
+              {isUploading ? (
+                <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
+              ) : (
+                <Camera className="h-4 w-4" aria-hidden="true" />
+              )}
+            </Button>
+            <input
+              ref={fileInputRef}
+              type="file"
+              accept="image/*,video/mp4,video/webm,video/quicktime"
+              multiple
+              className="sr-only"
+              tabIndex={-1}
+              onChange={handleUpload}
+            />
+          </div>
+        </div>
+      </div>
+
+      {/* Recorded value */}
+      {inputType === "measurement" && (
+        <div className="mt-3 flex flex-wrap items-end gap-2">
+          <div className="space-y-1">
+            <label htmlFor={`${fieldId}-value`} className="text-muted-foreground text-xs">
+              Reading{item.unit ? ` (${item.unit})` : ""}
+            </label>
+            <Input
+              id={`${fieldId}-value`}
+              inputMode="decimal"
+              value={measured}
+              onChange={(e) => setMeasured(e.target.value)}
+              onBlur={handleMeasurementBlur}
+              disabled={isCompleted}
+              aria-describedby={describedBy || undefined}
+              className="h-11 w-32"
+            />
+          </div>
+          {range && (
+            <p className="text-muted-foreground pb-3 text-xs">
+              Limit: <span className="font-medium">{range}</span>
+            </p>
+          )}
+        </div>
+      )}
+
+      {inputType === "text" && (
+        <div className="mt-3 space-y-1">
+          <label htmlFor={`${fieldId}-text`} className="text-muted-foreground text-xs">
+            Recorded value
+          </label>
+          <Input
+            id={`${fieldId}-text`}
+            value={textValue}
+            onChange={(e) => setTextValue(e.target.value)}
+            onBlur={() => !isCompleted && textValue !== (item.textValue ?? "") && save({ textValue })}
+            disabled={isCompleted}
+            className="h-11"
+          />
+        </div>
+      )}
+
+      {inputType === "choice" && (item.choices?.length ?? 0) > 0 && (
+        <div className="mt-3 space-y-1">
+          <label htmlFor={`${fieldId}-choice`} className="text-muted-foreground text-xs">
+            Answer
+          </label>
+          <Select
+            value={textValue || undefined}
+            disabled={isCompleted}
+            onValueChange={(v) => {
+              setTextValue(v);
+              save({ textValue: v });
+            }}
+          >
+            <SelectTrigger id={`${fieldId}-choice`} className="h-11 w-full sm:w-64">
+              <SelectValue placeholder="Select an answer" />
+            </SelectTrigger>
+            <SelectContent>
+              {item.choices?.map((choice) => (
+                <SelectItem key={choice} value={choice}>
+                  {choice}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </div>
+      )}
+
+      {showNotes && (
+        <div className="mt-3 space-y-1">
+          <label htmlFor={`${fieldId}-notes`} className="sr-only">
+            Notes for {item.name}
+          </label>
+          <Textarea
+            id={`${fieldId}-notes`}
+            ref={notesRef}
+            value={notes}
+            onChange={(e) => setNotes(e.target.value)}
+            onBlur={handleNotesBlur}
+            disabled={isCompleted}
+            aria-invalid={notesRequired}
+            aria-describedby={describedBy || undefined}
+            placeholder={
+              notesRequired
+                ? "Describe the defect — this appears on the report"
+                : "Add a note (optional)"
+            }
+            className={`min-h-[68px] text-sm ${
+              notesRequired ? "border-destructive focus-visible:ring-destructive" : ""
+            }`}
+          />
+          {notesRequired && (
+            <p id={`${fieldId}-notes-error`} className="text-destructive text-xs">
+              Describe the defect before this grade is saved.
+            </p>
+          )}
+          {!isCompleted && (
+            <DefectSuggestions
+              check={{
+                name: item.name,
+                code: item.code,
+                sectionCode: item.sectionCode,
+                defectSuggestions: item.defectSuggestions,
+              }}
+              scale={scale}
+              currentCondition={condition}
+              history={history}
+              onPick={applySuggestion}
+            />
+          )}
+        </div>
+      )}
+
+      {needsPhoto && (
+        <p id={`${fieldId}-photo-error`} className="text-destructive mt-2 text-xs">
+          A photo of the defect is required here. The inspection cannot be completed until one
+          is attached.
+        </p>
+      )}
+
+      {imageUrls.length > 0 && (
+        <ul className="mt-3 flex flex-wrap gap-2">
+          {imageUrls.map((url, index) => (
+            <li key={url} className="relative">
+              {isVideo(url) ? (
+                // eslint-disable-next-line jsx-a11y/media-has-caption
+                <video src={url} controls className="h-28 max-w-xs rounded-lg border" />
+              ) : (
+                <button
+                  type="button"
+                  onClick={() => onOpenImage(url)}
+                  className="focus-visible:ring-ring block overflow-hidden rounded-lg border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
+                  aria-label={`View photo ${index + 1} of ${item.name} full size`}
+                >
+                  {/* eslint-disable-next-line @next/next/no-img-element */}
+                  <img
+                    src={url}
+                    alt={`${item.name}, photo ${index + 1}`}
+                    className="h-20 w-20 object-cover"
+                  />
+                </button>
+              )}
+              {!isCompleted && (
+                <Button
+                  type="button"
+                  variant="destructive"
+                  size="icon"
+                  className="absolute -top-2 -right-2 h-6 w-6 rounded-full"
+                  onClick={() => handleRemoveFile(index)}
+                  aria-label={`Remove photo ${index + 1} from ${item.name}`}
+                >
+                  <X className="h-3 w-3" aria-hidden="true" />
+                </Button>
+              )}
+            </li>
+          ))}
+        </ul>
+      )}
+
+      <AlertDialog open={showClearDialog} onOpenChange={setShowClearDialog}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>Clear this grade?</AlertDialogTitle>
+            <AlertDialogDescription>
+              The note recorded against &ldquo;{item.name}&rdquo; will be removed and the check
+              goes back to not inspected.
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel>Cancel</AlertDialogCancel>
+            <AlertDialogAction
+              className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+              onClick={() => {
+                setCondition("not_inspected");
+                setNotesRequired(false);
+                setShowNotes(false);
+                setNotes("");
+                setShowClearDialog(false);
+                save({ condition: "not_inspected", notes: "" });
+              }}
+            >
+              Clear
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </li>
+  );
+}

+ 403 - 174
src/features/inspections/Components/InspectionPDF.tsx

@@ -2,6 +2,16 @@ import { Document, Page, Text, View, Image } from '@react-pdf/renderer'
 import { formatDateForPdf, DEFAULT_DATE_FORMAT } from '@/lib/format'
 import { createStyles, gray, getFontBold } from '@/features/vehicles/Components/invoice-pdf/styles'
 import type { TemplateConfig } from '@/features/vehicles/Components/invoice-pdf/types'
+import {
+  CONDITION_TOKENS,
+  TEST_RESULT_TOKENS,
+  gradedConditionLabel,
+  countConditions,
+  deriveTestResult,
+  formatRange,
+  isDefect,
+  type Condition,
+} from '../Lib/conditions'
 
 function fillTemplate(template: string, values: Record<string, string>): string {
   return Object.entries(values).reduce(
@@ -11,19 +21,34 @@ function fillTemplate(template: string, values: Record<string, string>): string
 }
 
 interface InspectionItem {
+  id: string
   name: string
   section: string
   condition: string
   notes: string | null
   sortOrder: number
+  code?: string | null
+  sectionCode?: string | null
+  inputType?: string | null
+  unit?: string | null
+  minValue?: number | null
+  maxValue?: number | null
+  measuredValue?: number | null
+  textValue?: string | null
 }
 
 interface InspectionData {
   id: string
   status: string
   mileage: number | null
+  notes: string | null
   createdAt: Date
   completedAt: Date | null
+  vehicleCategory?: string | null
+  nextTestDue?: Date | null
+  certificateNumber?: string | null
+  inspectorName?: string | null
+  testLocation?: string | null
   vehicle: {
     make: string
     model: string
@@ -37,7 +62,7 @@ interface InspectionData {
       phone?: string | null
     } | null
   }
-  template: { name: string }
+  template: { name: string; severityScale?: string | null; country?: string | null }
   items: InspectionItem[]
 }
 
@@ -48,16 +73,35 @@ interface WorkshopInfo {
   email: string
 }
 
-const conditionColors: Record<string, { bg: string; text: string }> = {
-  pass: { bg: '#dcfce7', text: '#166534' },
-  attention: { bg: '#fef9c3', text: '#854d0e' },
-  fail: { bg: '#fee2e2', text: '#991b1b' },
-}
-
-const conditionDefaultLabels: Record<string, string> = {
-  pass: 'Pass',
-  attention: 'Attention',
-  fail: 'Fail',
+/** English fallbacks for the labels the PDF route passes in from pdf.json. */
+const FALLBACK: Record<string, string> = {
+  euPass: 'No defect',
+  euAttention: 'Minor defect',
+  euFail: 'Major defect',
+  euDangerous: 'Dangerous defect',
+  basicPass: 'Pass',
+  basicAttention: 'Attention',
+  basicFail: 'Fail',
+  basicDangerous: 'Dangerous',
+  resultPass: 'Pass',
+  resultPassMinor: 'Pass with minor defects',
+  resultFail: 'Fail — major defects',
+  resultFailDangerous: 'Fail — dangerous defects',
+  resultIncomplete: 'Not completed',
+  overallResult: 'Result of the test',
+  testDetails: 'Test details',
+  testDate: 'Date of test',
+  testLocation: 'Place of test',
+  inspector: 'Inspector',
+  certificateNumber: 'Certificate number',
+  vehicleCategory: 'Vehicle category',
+  nextTestDue: 'Next test due',
+  deficiencies: 'Deficiencies found',
+  reading: 'Reading',
+  limit: 'Limit',
+  noDeficiencies: 'No deficiencies were recorded.',
+  photos: 'Photos',
+  photosOmitted: '{count} further photo(s) not included to keep this file a sensible size.',
 }
 
 export function InspectionPDF({
@@ -70,6 +114,8 @@ export function InspectionPDF({
   template,
   portalUrl,
   labels = {},
+  photos = {},
+  photosOmitted = 0,
 }: {
   data: InspectionData
   workshop?: WorkshopInfo
@@ -80,6 +126,10 @@ export function InspectionPDF({
   template?: TemplateConfig
   portalUrl?: string
   labels?: Record<string, string>
+  /** Embedded photos keyed by inspection item id; see Lib/inspectionPhotos. */
+  photos?: Record<string, { dataUri: string }[]>
+  /** Photos left out because of the size budget, so the page can say so. */
+  photosOmitted?: number
 }) {
   const primaryColor = template?.primaryColor || '#d97706'
   const fontFamily = template?.fontFamily || 'Helvetica'
@@ -91,17 +141,32 @@ export function InspectionPDF({
 
   const df = dateFormat || DEFAULT_DATE_FORMAT
   const tz = timezone || undefined
-  const createdDate = formatDateForPdf(data.createdAt, df, tz)
+  const testDate = formatDateForPdf(data.completedAt ?? data.createdAt, df, tz)
   const shopName = workshop?.name || 'Torqvoice'
 
-  // Filter out not_inspected items and group by section
-  const inspectedItems = data.items
+  const label = (key: string) => labels[key] || FALLBACK[key] || key
+  const isBasic = data.template.severityScale === 'basic'
+  const conditionText = (condition: Condition) => {
+    const suffix = condition.charAt(0).toUpperCase() + condition.slice(1)
+    return label(`${isBasic ? 'basic' : 'eu'}${suffix}`)
+  }
+  // Several member states record defects by grade number rather than by name,
+  // so the number leads and the wording follows.
+  const gradedText = (condition: Condition) =>
+    gradedConditionLabel(
+      condition,
+      isBasic ? 'basic' : 'eu',
+      data.template.country,
+      conditionText(condition)
+    )
+
+  const gradedItems = data.items
     .filter((i) => i.condition !== 'not_inspected')
     .sort((a, b) => a.sortOrder - b.sortOrder)
 
   const sectionOrder: string[] = []
   const sections: Record<string, InspectionItem[]> = {}
-  for (const item of inspectedItems) {
+  for (const item of gradedItems) {
     if (!sections[item.section]) {
       sections[item.section] = []
       sectionOrder.push(item.section)
@@ -109,11 +174,60 @@ export function InspectionPDF({
     sections[item.section].push(item)
   }
 
-  // Counts
-  const totalInspected = inspectedItems.length
-  const passCount = inspectedItems.filter((i) => i.condition === 'pass').length
-  const attentionCount = inspectedItems.filter((i) => i.condition === 'attention').length
-  const failCount = inspectedItems.filter((i) => i.condition === 'fail').length
+  const counts = countConditions(gradedItems)
+  const result = deriveTestResult(gradedItems)
+  const resultToken = TEST_RESULT_TOKENS[result]
+  const resultLabelKey = {
+    pass: 'resultPass',
+    pass_minor: 'resultPassMinor',
+    fail: 'resultFail',
+    fail_dangerous: 'resultFailDangerous',
+    incomplete: 'resultIncomplete',
+  }[result]
+  const resultDetailKey = {
+    pass: 'resultDetailPass',
+    pass_minor: 'resultDetailPassMinor',
+    fail: 'resultDetailFail',
+    fail_dangerous: 'resultDetailFailDangerous',
+    incomplete: 'resultDetailIncomplete',
+  }[result]
+  const deficiencies = gradedItems.filter((i) => isDefect(i.condition))
+
+  /**
+   * Photos sit on their own full-width line rather than inside a narrow table
+   * cell, so a defect photo is actually large enough to show the defect.
+   */
+  const renderPhotos = (item: InspectionItem, size: 'large' | 'small' = 'large') => {
+    const itemPhotos = photos[item.id]
+    if (!itemPhotos?.length) return null
+    const width = size === 'large' ? 158 : 112
+    return (
+      <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
+        {itemPhotos.map((photo, i) => (
+          <Image
+            key={i}
+            src={photo.dataUri}
+            style={{
+              width,
+              height: Math.round(width * 0.75),
+              borderRadius: 3,
+              objectFit: 'cover',
+            }}
+          />
+        ))}
+      </View>
+    )
+  }
+
+  /** "2.4 mm · Limit: min 3 mm" — the recorded value next to its allowed range. */
+  const valueText = (item: InspectionItem): string | null => {
+    if (item.inputType === 'measurement' && item.measuredValue !== null && item.measuredValue !== undefined) {
+      const range = formatRange(item)
+      const reading = `${item.measuredValue}${item.unit ? ` ${item.unit}` : ''}`
+      return range ? `${reading} · ${label('limit')}: ${range}` : reading
+    }
+    return item.textValue || null
+  }
 
   const renderCompactHeader = () => (
     <View style={{ marginBottom: 20 }}>
@@ -149,7 +263,7 @@ export function InspectionPDF({
           <Text style={{ fontSize: 14, fontFamily: fontBold, color: primaryColor }}>
             {labels.title || 'VEHICLE INSPECTION'}
           </Text>
-          <Text style={{ fontSize: 9, color: gray, marginTop: 2 }}>{createdDate}</Text>
+          <Text style={{ fontSize: 9, color: gray, marginTop: 2 }}>{testDate}</Text>
         </View>
       </View>
       <View
@@ -162,11 +276,11 @@ export function InspectionPDF({
       >
         <View style={{ flexDirection: 'row', gap: 12 }}>
           {workshop?.phone && (
-            <Text style={{ fontSize: 8, color: gray }}>{labels.tel ? fillTemplate(labels.tel, { phone: workshop.phone }) : `Tel: ${workshop.phone}`}</Text>
-          )}
-          {workshop?.email && (
-            <Text style={{ fontSize: 8, color: gray }}>{workshop.email}</Text>
+            <Text style={{ fontSize: 8, color: gray }}>
+              {labels.tel ? fillTemplate(labels.tel, { phone: workshop.phone }) : `Tel: ${workshop.phone}`}
+            </Text>
           )}
+          {workshop?.email && <Text style={{ fontSize: 8, color: gray }}>{workshop.email}</Text>}
         </View>
         {torqvoiceLogoDataUri && (
           <View style={{ flexDirection: 'row', alignItems: 'center', gap: 3 }}>
@@ -181,20 +295,10 @@ export function InspectionPDF({
   const renderModernHeader = () => (
     <View style={{ marginBottom: 24 }}>
       <View
-        style={{
-          backgroundColor: primaryColor,
-          padding: 20,
-          borderRadius: 4,
-          marginHorizontal: -10,
-        }}
+        style={{ backgroundColor: primaryColor, padding: 20, borderRadius: 4, marginHorizontal: -10 }}
       >
         <View
-          style={{
-            flexDirection: 'row',
-            alignItems: 'center',
-            justifyContent: 'center',
-            gap: 12,
-          }}
+          style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12 }}
         >
           {showLogo && logoDataUri && (
             <Image
@@ -204,9 +308,7 @@ export function InspectionPDF({
           )}
           <View style={{ alignItems: 'center' }}>
             {showCompanyName && (
-              <Text style={{ fontSize: 22, fontFamily: fontBold, color: 'white' }}>
-                {shopName}
-              </Text>
+              <Text style={{ fontSize: 22, fontFamily: fontBold, color: 'white' }}>{shopName}</Text>
             )}
             {workshop?.address && (
               <Text style={{ fontSize: 9, color: 'rgba(255,255,255,0.8)', marginTop: 2 }}>
@@ -220,9 +322,7 @@ export function InspectionPDF({
                 </Text>
               )}
               {workshop?.email && (
-                <Text style={{ fontSize: 8, color: 'rgba(255,255,255,0.7)' }}>
-                  {workshop.email}
-                </Text>
+                <Text style={{ fontSize: 8, color: 'rgba(255,255,255,0.7)' }}>{workshop.email}</Text>
               )}
             </View>
           </View>
@@ -256,7 +356,7 @@ export function InspectionPDF({
         <Text style={{ fontSize: 18, fontFamily: fontBold, color: primaryColor }}>
           {labels.title || 'VEHICLE INSPECTION'}
         </Text>
-        <Text style={{ fontSize: 9, color: gray }}>{createdDate}</Text>
+        <Text style={{ fontSize: 9, color: gray }}>{testDate}</Text>
       </View>
     </View>
   )
@@ -265,30 +365,59 @@ export function InspectionPDF({
     <View style={styles.header}>
       <View>
         {showLogo && logoDataUri && (
-          <Image
-            src={logoDataUri}
-            style={{ width: 60, height: 60, marginBottom: 6, borderRadius: 4 }}
-          />
+          <Image src={logoDataUri} style={{ width: 60, height: 60, marginBottom: 6, borderRadius: 4 }} />
         )}
         {showCompanyName && <Text style={styles.brandName}>{shopName}</Text>}
         {workshop?.address && <Text style={styles.brandSub}>{workshop.address}</Text>}
-        {workshop?.phone && <Text style={styles.brandContact}>{labels.tel ? fillTemplate(labels.tel, { phone: workshop.phone }) : `Tel: ${workshop.phone}`}</Text>}
+        {workshop?.phone && (
+          <Text style={styles.brandContact}>
+            {labels.tel ? fillTemplate(labels.tel, { phone: workshop.phone }) : `Tel: ${workshop.phone}`}
+          </Text>
+        )}
         {workshop?.email && <Text style={styles.brandContact}>{workshop.email}</Text>}
       </View>
       <View>
         {torqvoiceLogoDataUri && (
-          <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 3, marginBottom: 6 }}>
+          <View
+            style={{
+              flexDirection: 'row',
+              alignItems: 'center',
+              justifyContent: 'flex-end',
+              gap: 3,
+              marginBottom: 6,
+            }}
+          >
             <Image src={torqvoiceLogoDataUri} style={{ width: 16, height: 16 }} />
             <Text style={{ fontSize: 9, fontFamily: fontBold, color: gray }}>Torqvoice</Text>
           </View>
         )}
-        <Text style={{ ...styles.invoiceTitle, color: primaryColor }}>{labels.title || 'VEHICLE INSPECTION'}</Text>
+        <Text style={{ ...styles.invoiceTitle, color: primaryColor }}>
+          {labels.title || 'VEHICLE INSPECTION'}
+        </Text>
         <Text style={styles.invoiceNumber}>{data.template.name}</Text>
-        <Text style={styles.invoiceNumber}>{createdDate}</Text>
+        <Text style={styles.invoiceNumber}>{testDate}</Text>
       </View>
     </View>
   )
 
+  /** Annex IV rows that only appear once the workshop has filled them in. */
+  const certificateRows: { label: string; value: string }[] = [
+    { label: label('testDate'), value: testDate },
+    ...(data.certificateNumber
+      ? [{ label: label('certificateNumber'), value: data.certificateNumber }]
+      : []),
+    ...(data.vehicleCategory
+      ? [{ label: label('vehicleCategory'), value: data.vehicleCategory }]
+      : []),
+    ...(data.inspectorName ? [{ label: label('inspector'), value: data.inspectorName }] : []),
+    ...(data.testLocation || workshop?.address
+      ? [{ label: label('testLocation'), value: data.testLocation || workshop?.address || '' }]
+      : []),
+    ...(data.nextTestDue
+      ? [{ label: label('nextTestDue'), value: formatDateForPdf(data.nextTestDue, df, tz) }]
+      : []),
+  ]
+
   return (
     <Document>
       <Page size="A4" style={styles.page}>
@@ -298,147 +427,234 @@ export function InspectionPDF({
             ? renderModernHeader()
             : renderStandardHeader()}
 
-        {/* Vehicle & Customer info */}
+        {/* Overall result — Annex IV(g) */}
+        <View
+          style={{
+            backgroundColor: resultToken.pdf.bg,
+            borderRadius: 4,
+            padding: 12,
+            marginBottom: 14,
+          }}
+        >
+          <Text style={{ fontSize: 8, color: resultToken.pdf.text, marginBottom: 2 }}>
+            {label('overallResult')}
+          </Text>
+          <Text style={{ fontSize: 15, fontFamily: fontBold, color: resultToken.pdf.text }}>
+            {label(resultLabelKey)}
+          </Text>
+          <Text style={{ fontSize: 8, color: resultToken.pdf.text, marginTop: 3 }}>
+            {labels[resultDetailKey] || resultToken.detail}
+          </Text>
+        </View>
+
+        {/* Vehicle, customer and test details — Annex IV(a)–(e), (i) */}
         <View style={styles.infoRow}>
-          <View style={styles.infoBox}>
+          <View style={{ ...styles.infoBox, flex: 1 }}>
             <Text style={styles.infoLabel}>{labels.vehicle || 'Vehicle'}</Text>
             <Text style={styles.infoTextBold}>
               {data.vehicle.year} {data.vehicle.make} {data.vehicle.model}
             </Text>
             {data.vehicle.vin && (
-              <Text style={styles.infoTextSmall}>{labels.vin ? fillTemplate(labels.vin, { vin: data.vehicle.vin }) : `VIN: ${data.vehicle.vin}`}</Text>
+              <Text style={styles.infoTextSmall}>
+                {labels.vin ? fillTemplate(labels.vin, { vin: data.vehicle.vin }) : `VIN: ${data.vehicle.vin}`}
+              </Text>
             )}
             {data.vehicle.licensePlate && (
-              <Text style={styles.infoTextSmall}>{labels.plate ? fillTemplate(labels.plate, { plate: data.vehicle.licensePlate }) : `Plate: ${data.vehicle.licensePlate}`}</Text>
+              <Text style={styles.infoTextSmall}>
+                {labels.plate
+                  ? fillTemplate(labels.plate, { plate: data.vehicle.licensePlate })
+                  : `Plate: ${data.vehicle.licensePlate}`}
+              </Text>
             )}
-            {data.mileage && (
-              <Text style={styles.infoTextSmall}>{labels.mileage ? fillTemplate(labels.mileage, { mileage: data.mileage.toLocaleString() }) : `Mileage: ${data.mileage.toLocaleString()}`}</Text>
+            {data.mileage !== null && (
+              <Text style={styles.infoTextSmall}>
+                {labels.mileage
+                  ? fillTemplate(labels.mileage, { mileage: data.mileage.toLocaleString() })
+                  : `Mileage: ${data.mileage.toLocaleString()}`}
+              </Text>
+            )}
+            {data.vehicle.customer && (
+              <Text style={{ ...styles.infoTextSmall, marginTop: 4 }}>
+                {labels.customer || 'Customer'}: {data.vehicle.customer.name}
+              </Text>
             )}
           </View>
-          {data.vehicle.customer && (
-            <View style={styles.infoBox}>
-              <Text style={styles.infoLabel}>{labels.customer || 'Customer'}</Text>
-              <Text style={styles.infoTextBold}>{data.vehicle.customer.name}</Text>
-              {data.vehicle.customer.email && (
-                <Text style={styles.infoTextSmall}>{data.vehicle.customer.email}</Text>
-              )}
-              {data.vehicle.customer.phone && (
-                <Text style={styles.infoTextSmall}>{data.vehicle.customer.phone}</Text>
-              )}
-            </View>
-          )}
-          <View style={styles.infoBox}>
-            <Text style={styles.infoLabel}>{labels.inspectionDetails || 'Inspection Details'}</Text>
-            <Text style={styles.infoTextBold}>{data.template.name}</Text>
-            <Text style={styles.infoTextSmall}>
-              {labels.status
-                ? fillTemplate(labels.status, { status: data.status === 'completed' ? (labels.statusCompleted || 'Completed') : (labels.statusInProgress || 'In Progress') })
-                : `Status: ${data.status === 'completed' ? 'Completed' : 'In Progress'}`}
-            </Text>
+
+          <View style={{ ...styles.infoBox, flex: 1 }}>
+            <Text style={styles.infoLabel}>{label('testDetails')}</Text>
+            {certificateRows.map((row) => (
+              <Text key={row.label} style={styles.infoTextSmall}>
+                {row.label}: {row.value}
+              </Text>
+            ))}
           </View>
         </View>
 
-        {/* Summary bar */}
-        <View
-          style={{
-            flexDirection: 'row',
-            gap: 12,
-            marginBottom: 16,
-            marginTop: 4,
-          }}
-        >
-          <View
-            style={{
-              flex: 1,
-              padding: 8,
-              backgroundColor: '#f3f4f6',
-              borderRadius: 4,
-              alignItems: 'center',
-            }}
-          >
-            <Text style={{ fontSize: 16, fontFamily: fontBold }}>{totalInspected}</Text>
-            <Text style={{ fontSize: 7, color: gray }}>{labels.inspected || 'Inspected'}</Text>
-          </View>
-          <View
-            style={{
-              flex: 1,
-              padding: 8,
-              backgroundColor: '#dcfce7',
-              borderRadius: 4,
-              alignItems: 'center',
-            }}
-          >
-            <Text style={{ fontSize: 16, fontFamily: fontBold, color: '#166534' }}>
-              {passCount}
-            </Text>
-            <Text style={{ fontSize: 7, color: '#166534' }}>{labels.pass || 'Pass'}</Text>
-          </View>
-          <View
-            style={{
-              flex: 1,
-              padding: 8,
-              backgroundColor: '#fef9c3',
-              borderRadius: 4,
-              alignItems: 'center',
-            }}
-          >
-            <Text style={{ fontSize: 16, fontFamily: fontBold, color: '#854d0e' }}>
-              {attentionCount}
-            </Text>
-            <Text style={{ fontSize: 7, color: '#854d0e' }}>{labels.attention || 'Attention'}</Text>
-          </View>
-          <View
-            style={{
-              flex: 1,
-              padding: 8,
-              backgroundColor: '#fee2e2',
-              borderRadius: 4,
-              alignItems: 'center',
-            }}
-          >
-            <Text style={{ fontSize: 16, fontFamily: fontBold, color: '#991b1b' }}>
-              {failCount}
-            </Text>
-            <Text style={{ fontSize: 7, color: '#991b1b' }}>{labels.fail || 'Fail'}</Text>
-          </View>
+        {/* Deficiency counts */}
+        <View style={{ flexDirection: 'row', gap: 10, marginBottom: 16, marginTop: 4 }}>
+          {(
+            [
+              { key: 'inspected' as const, value: counts.inspected, bg: '#f3f4f6', color: '#374151', text: labels.inspected || 'Inspected' },
+              { key: 'pass' as const, value: counts.pass, ...CONDITION_TOKENS.pass.pdf, text: conditionText('pass') },
+              { key: 'attention' as const, value: counts.attention, ...CONDITION_TOKENS.attention.pdf, text: conditionText('attention') },
+              { key: 'fail' as const, value: counts.fail, ...CONDITION_TOKENS.fail.pdf, text: conditionText('fail') },
+              ...(isBasic
+                ? []
+                : [
+                    {
+                      key: 'dangerous' as const,
+                      value: counts.dangerous,
+                      ...CONDITION_TOKENS.dangerous.pdf,
+                      text: conditionText('dangerous'),
+                    },
+                  ]),
+            ] as { key: string; value: number; bg?: string; text: string; color?: string }[]
+          ).map((tile) => (
+            <View
+              key={tile.key}
+              style={{
+                flex: 1,
+                padding: 8,
+                backgroundColor: tile.bg || '#f3f4f6',
+                borderRadius: 4,
+                alignItems: 'center',
+              }}
+            >
+              <Text style={{ fontSize: 15, fontFamily: fontBold, color: tile.color || '#374151' }}>
+                {tile.value}
+              </Text>
+              <Text style={{ fontSize: 6.5, color: tile.color || '#374151', textAlign: 'center' }}>
+                {tile.text}
+              </Text>
+            </View>
+          ))}
+        </View>
+
+        {/* Deficiencies — Annex IV(f) */}
+        <View style={{ marginBottom: 14 }}>
+          <Text style={styles.sectionTitle}>
+            {label('deficiencies')} ({deficiencies.length})
+          </Text>
+          {deficiencies.length === 0 ? (
+            <Text style={{ fontSize: 9, color: gray }}>{label('noDeficiencies')}</Text>
+          ) : (
+            deficiencies.map((item, i) => {
+              const token = CONDITION_TOKENS[item.condition as Condition]
+              const value = valueText(item)
+              return (
+                <View
+                  key={i}
+                  wrap={false}
+                  style={{
+                    borderWidth: 0.5,
+                    borderColor: '#e5e7eb',
+                    borderLeftWidth: 3,
+                    borderLeftColor: token.pdf.text,
+                    borderRadius: 3,
+                    padding: 8,
+                    marginBottom: 6,
+                  }}
+                >
+                  <View
+                    style={{
+                      flexDirection: 'row',
+                      justifyContent: 'space-between',
+                      // Without this the badge stretches to the height of the
+                      // whole block once a photo is present.
+                      alignItems: 'flex-start',
+                      gap: 8,
+                    }}
+                  >
+                    <Text style={{ fontSize: 10, fontFamily: fontBold, flex: 1 }}>
+                      {item.code ? `${item.code}  ` : ''}
+                      {item.name}
+                    </Text>
+                    <View
+                      style={{
+                        backgroundColor: token.pdf.bg,
+                        paddingHorizontal: 6,
+                        paddingVertical: 3,
+                        borderRadius: 3,
+                      }}
+                    >
+                      <Text style={{ fontSize: 7, color: token.pdf.text, fontFamily: fontBold }}>
+                        {gradedText(item.condition as Condition)}
+                      </Text>
+                    </View>
+                  </View>
+                  {item.notes && (
+                    <Text style={{ fontSize: 9, marginTop: 3 }}>{item.notes}</Text>
+                  )}
+                  {value && (
+                    <Text style={{ fontSize: 8, color: gray, marginTop: 2 }}>{value}</Text>
+                  )}
+                  {renderPhotos(item)}
+                </View>
+              )
+            })
+          )}
         </View>
 
-        {/* Sections with items */}
+        {/* Full results by section */}
         {sectionOrder.map((sectionName) => (
           <View key={sectionName} style={{ marginBottom: 12 }} wrap={false}>
-            <Text style={styles.sectionTitle}>{sectionName}</Text>
+            <Text style={styles.sectionTitle}>
+              {sections[sectionName][0]?.sectionCode
+                ? `${sections[sectionName][0].sectionCode}. ${sectionName}`
+                : sectionName}
+            </Text>
             <View style={styles.table}>
               <View style={styles.tableHeader}>
-                <Text style={{ ...styles.tableHeaderCell, width: '45%' }}>{labels.item || 'Item'}</Text>
-                <Text style={{ ...styles.tableHeaderCell, width: '15%' }}>{labels.statusColumn || 'Status'}</Text>
-                <Text style={{ ...styles.tableHeaderCell, width: '40%' }}>{labels.notesColumn || 'Notes'}</Text>
+                <Text style={{ ...styles.tableHeaderCell, width: '10%' }}>#</Text>
+                <Text style={{ ...styles.tableHeaderCell, width: '35%' }}>{labels.item || 'Item'}</Text>
+                <Text style={{ ...styles.tableHeaderCell, width: '20%' }}>
+                  {labels.statusColumn || 'Status'}
+                </Text>
+                <Text style={{ ...styles.tableHeaderCell, width: '35%' }}>
+                  {labels.notesColumn || 'Notes'}
+                </Text>
               </View>
               {sections[sectionName].map((item, i) => {
-                const cond = conditionColors[item.condition]
+                const token = CONDITION_TOKENS[item.condition as Condition]
+                const value = valueText(item)
+                // Defects already carry their photos in the section above;
+                // embedding them twice would double the size of the file.
+                const rowPhotos = isDefect(item.condition) ? null : renderPhotos(item, 'small')
                 return (
-                  <View key={i} style={styles.tableRow}>
-                    <Text style={{ ...styles.tableCell, width: '45%' }}>{item.name}</Text>
-                    <View style={{ width: '15%', flexDirection: 'row' }}>
-                      {cond ? (
-                        <View
-                          style={{
-                            backgroundColor: cond.bg,
-                            paddingHorizontal: 6,
-                            paddingVertical: 2,
-                            borderRadius: 3,
-                          }}
-                        >
-                          <Text style={{ fontSize: 7, color: cond.text, fontFamily: fontBold }}>
-                            {labels[item.condition] || conditionDefaultLabels[item.condition] || item.condition}
-                          </Text>
-                        </View>
-                      ) : (
-                        <Text style={{ fontSize: 9, color: gray }}>—</Text>
-                      )}
+                  <View key={i} style={{ ...styles.tableRow, flexDirection: 'column' }}>
+                    <View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
+                      <Text style={{ ...styles.tableCell, width: '10%', color: gray }}>
+                        {item.code || ''}
+                      </Text>
+                      <Text style={{ ...styles.tableCell, width: '35%' }}>{item.name}</Text>
+                      <View style={{ width: '20%', flexDirection: 'row' }}>
+                        {token ? (
+                          <View
+                            style={{
+                              backgroundColor: token.pdf.bg,
+                              paddingHorizontal: 5,
+                              paddingVertical: 2,
+                              borderRadius: 3,
+                            }}
+                          >
+                            <Text
+                              style={{ fontSize: 6.5, color: token.pdf.text, fontFamily: fontBold }}
+                            >
+                              {gradedText(item.condition as Condition)}
+                            </Text>
+                          </View>
+                        ) : (
+                          <Text style={{ fontSize: 9, color: gray }}>—</Text>
+                        )}
+                      </View>
+                      <View style={{ width: '35%' }}>
+                        {value && <Text style={{ fontSize: 8 }}>{value}</Text>}
+                        {item.notes && (
+                          <Text style={{ ...styles.tableCell, color: gray }}>{item.notes}</Text>
+                        )}
+                      </View>
                     </View>
-                    <Text style={{ ...styles.tableCell, width: '40%', color: gray }}>
-                      {item.notes || ''}
-                    </Text>
+                    {rowPhotos}
                   </View>
                 )
               })}
@@ -446,16 +662,22 @@ export function InspectionPDF({
           </View>
         ))}
 
-        {/* Portal URL */}
+        {photosOmitted > 0 && (
+          <Text style={{ fontSize: 7, color: gray, marginTop: 4 }}>
+            {fillTemplate(label('photosOmitted'), { count: String(photosOmitted) })}
+          </Text>
+        )}
+
         {portalUrl && (
           <View style={{ marginTop: 8 }}>
             <Text style={{ fontSize: 8, color: gray, textAlign: 'center' }}>
-              {labels.viewPortal ? fillTemplate(labels.viewPortal, { url: portalUrl }) : `View your portal: ${portalUrl}`}
+              {labels.viewPortal
+                ? fillTemplate(labels.viewPortal, { url: portalUrl })
+                : `View your portal: ${portalUrl}`}
             </Text>
           </View>
         )}
 
-        {/* Footer */}
         {torqvoiceLogoDataUri ? (
           <View
             style={{
@@ -467,14 +689,21 @@ export function InspectionPDF({
             }}
           >
             <Text style={{ fontSize: 8, color: gray }}>
-              {labels.footerText ? fillTemplate(labels.footerText, { shopName }) : `Vehicle Inspection — ${shopName}`} ·{' '}
+              {labels.footerText
+                ? fillTemplate(labels.footerText, { shopName })
+                : `Vehicle Inspection — ${shopName}`}{' '}
+              ·{' '}
             </Text>
             <Text style={{ fontSize: 7, color: gray }}>{labels.poweredBy || 'Powered by'}</Text>
             <Image src={torqvoiceLogoDataUri} style={{ width: 14, height: 14 }} />
             <Text style={{ fontSize: 7, color: gray, fontFamily: fontBold }}>Torqvoice</Text>
           </View>
         ) : (
-          <Text style={styles.footer}>{labels.footerText ? fillTemplate(labels.footerText, { shopName }) : `Vehicle Inspection — ${shopName}`}</Text>
+          <Text style={styles.footer}>
+            {labels.footerText
+              ? fillTemplate(labels.footerText, { shopName })
+              : `Vehicle Inspection — ${shopName}`}
+          </Text>
         )}
       </Page>
     </Document>

File diff suppressed because it is too large
+ 775 - 563
src/features/inspections/Components/InspectionPageClient.tsx


+ 180 - 0
src/features/inspections/Components/MediaLightbox.tsx

@@ -0,0 +1,180 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+import { ChevronLeft, ChevronRight, X } from "lucide-react";
+
+export interface LightboxImage {
+  url: string;
+  caption: string;
+}
+
+const FOCUSABLE = 'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
+
+/**
+ * Full-screen image viewer.
+ *
+ * Built as a real modal dialog rather than a bare overlay: it takes focus on
+ * open, keeps Tab inside itself while it is open, restores focus to whatever
+ * opened it on close, and closes on Escape (WCAG 2.1 SC 2.1.2 and 2.4.3). The
+ * previous viewer trapped nothing and left keyboard users stranded behind it.
+ */
+export function MediaLightbox({
+  images,
+  index,
+  onClose,
+  onNavigate,
+}: {
+  images: LightboxImage[];
+  /** Index into `images`, or null when the viewer is closed. */
+  index: number | null;
+  onClose: () => void;
+  onNavigate: (index: number) => void;
+}) {
+  const containerRef = useRef<HTMLDivElement>(null);
+  const previouslyFocused = useRef<HTMLElement | null>(null);
+  const touchStartX = useRef<number | null>(null);
+
+  const isOpen = index !== null && !!images[index];
+
+  const goPrev = useCallback(() => {
+    if (index !== null && index > 0) onNavigate(index - 1);
+  }, [index, onNavigate]);
+
+  const goNext = useCallback(() => {
+    if (index !== null && index < images.length - 1) onNavigate(index + 1);
+  }, [index, images.length, onNavigate]);
+
+  // Remember what had focus, move focus into the dialog, and hand it back on close.
+  useEffect(() => {
+    if (!isOpen) return;
+    previouslyFocused.current = document.activeElement as HTMLElement | null;
+    containerRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
+    return () => previouslyFocused.current?.focus?.();
+  }, [isOpen]);
+
+  // The page behind must not scroll while the viewer covers it.
+  useEffect(() => {
+    if (!isOpen) return;
+    const previous = document.body.style.overflow;
+    document.body.style.overflow = "hidden";
+    return () => {
+      document.body.style.overflow = previous;
+    };
+  }, [isOpen]);
+
+  useEffect(() => {
+    if (!isOpen) return;
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (event.key === "Escape") {
+        event.preventDefault();
+        onClose();
+        return;
+      }
+      if (event.key === "ArrowLeft") {
+        goPrev();
+        return;
+      }
+      if (event.key === "ArrowRight") {
+        goNext();
+        return;
+      }
+      if (event.key !== "Tab") return;
+
+      const focusable = Array.from(
+        containerRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []
+      );
+      if (focusable.length === 0) return;
+      const first = focusable[0];
+      const last = focusable[focusable.length - 1];
+      if (event.shiftKey && document.activeElement === first) {
+        event.preventDefault();
+        last.focus();
+      } else if (!event.shiftKey && document.activeElement === last) {
+        event.preventDefault();
+        first.focus();
+      }
+    };
+    window.addEventListener("keydown", onKeyDown);
+    return () => window.removeEventListener("keydown", onKeyDown);
+  }, [isOpen, onClose, goPrev, goNext]);
+
+  if (index === null) return null;
+  const image = images[index];
+  if (!image) return null;
+
+  const handleTouchStart = (e: React.TouchEvent) => {
+    touchStartX.current = e.touches[0].clientX;
+  };
+
+  const handleTouchEnd = (e: React.TouchEvent) => {
+    if (touchStartX.current === null) return;
+    const delta = e.changedTouches[0].clientX - touchStartX.current;
+    if (Math.abs(delta) > 50) {
+      if (delta > 0) goPrev();
+      else goNext();
+    }
+    touchStartX.current = null;
+  };
+
+  return (
+    <div
+      ref={containerRef}
+      role="dialog"
+      aria-modal="true"
+      aria-label={`Image ${index + 1} of ${images.length}: ${image.caption}`}
+      className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
+      onTouchStart={handleTouchStart}
+      onTouchEnd={handleTouchEnd}
+    >
+      <button
+        type="button"
+        onClick={onClose}
+        aria-label="Close image viewer"
+        className="absolute top-3 right-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none sm:top-4 sm:right-4"
+      >
+        <X className="h-5 w-5" aria-hidden="true" />
+      </button>
+
+      {images.length > 1 && (
+        <p className="absolute top-4 left-1/2 z-10 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-sm font-medium text-white">
+          {index + 1} / {images.length}
+        </p>
+      )}
+
+      {index > 0 && (
+        <button
+          type="button"
+          onClick={goPrev}
+          aria-label="Previous image"
+          className="absolute left-2 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none sm:left-4"
+        >
+          <ChevronLeft className="h-6 w-6" aria-hidden="true" />
+        </button>
+      )}
+
+      {index < images.length - 1 && (
+        <button
+          type="button"
+          onClick={goNext}
+          aria-label="Next image"
+          className="absolute right-2 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none sm:right-4"
+        >
+          <ChevronRight className="h-6 w-6" aria-hidden="true" />
+        </button>
+      )}
+
+      <figure className="flex max-h-full max-w-5xl flex-col items-center">
+        {/* eslint-disable-next-line @next/next/no-img-element */}
+        <img
+          src={image.url}
+          alt={image.caption}
+          className="max-h-[80vh] max-w-full rounded-lg object-contain"
+          draggable={false}
+        />
+        <figcaption className="mt-3 max-w-md text-center text-sm text-white/80">
+          {image.caption}
+        </figcaption>
+      </figure>
+    </div>
+  );
+}

+ 880 - 176
src/features/inspections/Components/TemplateForm.tsx

@@ -1,33 +1,646 @@
 "use client";
 
-import { useState, useTransition } from "react";
+import { useId, useState, useTransition } from "react";
 import { useRouter } from "next/navigation";
 import {
-  Sheet, SheetContent, SheetHeader, SheetTitle,
-} from "@/components/ui/sheet";
+  DndContext,
+  KeyboardSensor,
+  PointerSensor,
+  closestCenter,
+  useSensor,
+  useSensors,
+  type DragEndEvent,
+  type Modifier,
+} from "@dnd-kit/core";
+import {
+  SortableContext,
+  arrayMove,
+  sortableKeyboardCoordinates,
+  useSortable,
+  verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
 import { Switch } from "@/components/ui/switch";
-import { Loader2, Plus, Trash2, GripVertical } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import {
+  ChevronDown,
+  Copy,
+  ListOrdered,
+  GripVertical,
+  Loader2,
+  Plus,
+  Settings2,
+  Trash2,
+} from "lucide-react";
 import { toast } from "sonner";
 import { createTemplate, updateTemplate } from "../Actions/templateActions";
+import { COMMON_UNITS, INPUT_TYPES, type InputType, type SeverityScale } from "../Lib/conditions";
+import { TEMPLATE_COUNTRIES } from "../Lib/templatePresets";
+
+/**
+ * Sections and checks are lists, so sideways movement during a drag is only
+ * noise. @dnd-kit/modifiers is not a dependency here and this is the whole of
+ * what it would provide.
+ */
+const restrictToVerticalAxis: Modifier = ({ transform }) => ({ ...transform, x: 0 });
+
+/* -------------------------------------------------------------------------- */
+/* Editor state                                                               */
+/* -------------------------------------------------------------------------- */
 
-interface TemplateSection {
-  id?: string;
+export interface EditorItem {
+  /** Client-only key so drag-and-drop and React keys survive reordering. */
+  key: string;
   name: string;
-  sortOrder: number;
-  items: { id?: string; name: string; sortOrder: number }[];
+  description: string;
+  code: string;
+  inputType: InputType;
+  unit: string;
+  minValue: string;
+  maxValue: string;
+  choices: string;
+  required: boolean;
+  photoRequired: boolean;
+  defaultSeverity: "" | "attention" | "fail" | "dangerous";
+  /** One phrase per line, which is how it is edited and stored. */
+  defectSuggestions: string;
 }
 
-interface TemplateData {
+export interface EditorSection {
+  key: string;
+  name: string;
+  description: string;
+  code: string;
+  items: EditorItem[];
+}
+
+export interface TemplateFormData {
   id: string;
   name: string;
   description: string | null;
   isDefault: boolean;
-  sections: TemplateSection[];
+  country?: string | null;
+  standard?: string | null;
+  severityScale?: string | null;
+  sections: {
+    id?: string;
+    name: string;
+    description?: string | null;
+    code?: string | null;
+    sortOrder: number;
+    items: {
+      id?: string;
+      name: string;
+      description?: string | null;
+      code?: string | null;
+      sortOrder: number;
+      inputType?: string | null;
+      unit?: string | null;
+      minValue?: number | null;
+      maxValue?: number | null;
+      choices?: string[];
+      required?: boolean;
+      photoRequired?: boolean;
+      defaultSeverity?: string | null;
+      defectSuggestions?: string[];
+    }[];
+  }[];
+}
+
+let keyCounter = 0;
+const nextKey = () => `k${++keyCounter}`;
+
+/**
+ * References are filled in as rows are added, so a checklist built from scratch
+ * numbers itself. Only blank codes are ever written: on a regulatory template
+ * these are Annex I references like "1.1.13", and renumbering them sequentially
+ * would destroy the link to the regulation.
+ */
+export function nextSectionCode(sections: EditorSection[]): string {
+  const highest = sections.reduce((max, section) => {
+    const leading = Number.parseInt(section.code.trim(), 10);
+    return Number.isNaN(leading) ? max : Math.max(max, leading);
+  }, 0);
+  return String(highest + 1);
+}
+
+export function nextItemCode(section: EditorSection, sectionIndex: number): string {
+  const prefix = section.code.trim() || String(sectionIndex + 1);
+  const highest = section.items.reduce((max, item) => {
+    const code = item.code.trim();
+    if (!code.startsWith(`${prefix}.`)) return max;
+    const tail = Number.parseInt(code.slice(prefix.length + 1), 10);
+    return Number.isNaN(tail) ? max : Math.max(max, tail);
+  }, 0);
+  return `${prefix}.${highest + 1}`;
+}
+
+/** Renumbers everything sequentially — 1, 1.1, 1.2, 2, 2.1 … */
+export function renumber(sections: EditorSection[]): EditorSection[] {
+  return sections.map((section, sIdx) => {
+    const code = String(sIdx + 1);
+    return {
+      ...section,
+      code,
+      items: section.items.map((item, iIdx) => ({ ...item, code: `${code}.${iIdx + 1}` })),
+    };
+  });
+}
+
+const NUMBER_FIELD = /^-?\d*\.?\d*$/;
+
+function blankItem(name = ""): EditorItem {
+  return {
+    key: nextKey(),
+    name,
+    description: "",
+    code: "",
+    inputType: "condition",
+    unit: "",
+    minValue: "",
+    maxValue: "",
+    choices: "",
+    required: false,
+    photoRequired: false,
+    defaultSeverity: "",
+    defectSuggestions: "",
+  };
+}
+
+export function blankSection(code = ""): EditorSection {
+  return {
+    key: nextKey(),
+    name: "",
+    description: "",
+    code,
+    // A section always starts with one check, and it belongs to the section's
+    // numbering just as much as any check added afterwards.
+    items: [{ ...blankItem(), code: code ? `${code}.1` : "" }],
+  };
+}
+
+function toEditorState(template?: TemplateFormData): EditorSection[] {
+  if (!template) return [blankSection("1")];
+  return template.sections.map((s) => ({
+    key: nextKey(),
+    name: s.name,
+    description: s.description ?? "",
+    code: s.code ?? "",
+    items: s.items.map((i) => ({
+      key: nextKey(),
+      name: i.name,
+      description: i.description ?? "",
+      code: i.code ?? "",
+      inputType: (i.inputType as InputType) || "condition",
+      unit: i.unit ?? "",
+      minValue: i.minValue === null || i.minValue === undefined ? "" : String(i.minValue),
+      maxValue: i.maxValue === null || i.maxValue === undefined ? "" : String(i.maxValue),
+      choices: (i.choices ?? []).join(", "),
+      required: i.required ?? false,
+      photoRequired: i.photoRequired ?? false,
+      defaultSeverity: (i.defaultSeverity as EditorItem["defaultSeverity"]) || "",
+      defectSuggestions: (i.defectSuggestions ?? []).join("\n"),
+    })),
+  }));
+}
+
+/* -------------------------------------------------------------------------- */
+/* Sortable primitives                                                        */
+/* -------------------------------------------------------------------------- */
+
+function useSortableRow(id: string) {
+  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
+  return {
+    setNodeRef,
+    style: { transform: CSS.Transform.toString(transform), transition } as React.CSSProperties,
+    isDragging,
+    handleProps: { ...attributes, ...listeners },
+  };
+}
+
+/** Drag handles are real buttons so the keyboard sensor can pick them up. */
+function DragHandle({
+  label,
+  className = "",
+  ...props
+}: React.ComponentProps<"button"> & { label: string }) {
+  return (
+    <button
+      type="button"
+      aria-label={label}
+      className={`text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex h-8 w-8 shrink-0 cursor-grab items-center justify-center rounded-md focus-visible:ring-2 focus-visible:outline-none active:cursor-grabbing ${className}`}
+      {...props}
+    >
+      <GripVertical className="h-4 w-4" aria-hidden="true" />
+    </button>
+  );
 }
 
+/* -------------------------------------------------------------------------- */
+/* Check row                                                                  */
+/* -------------------------------------------------------------------------- */
+
+function CheckRow({
+  item,
+  sectionIndex,
+  index,
+  canDelete,
+  onChange,
+  onDuplicate,
+  onDelete,
+}: {
+  item: EditorItem;
+  sectionIndex: number;
+  index: number;
+  canDelete: boolean;
+  onChange: (patch: Partial<EditorItem>) => void;
+  onDuplicate: () => void;
+  onDelete: () => void;
+}) {
+  const [expanded, setExpanded] = useState(false);
+  const fieldId = useId();
+  const { setNodeRef, style, isDragging, handleProps } = useSortableRow(item.key);
+  const panelId = `${fieldId}-panel`;
+
+  const isMeasurement = item.inputType === "measurement";
+  const isChoice = item.inputType === "choice";
+  const typeLabel = INPUT_TYPES.find((t) => t.value === item.inputType)?.label ?? "Defect grading";
+
+  return (
+    <li
+      ref={setNodeRef}
+      style={style}
+      className={`bg-background rounded-md border ${isDragging ? "ring-primary/40 shadow-lg ring-2" : ""}`}
+    >
+      <div className="flex items-center gap-1.5 p-1.5">
+        <DragHandle
+          label={`Reorder check ${item.name || index + 1}`}
+          {...handleProps}
+        />
+        <Input
+          value={item.code}
+          onChange={(e) => onChange({ code: e.target.value })}
+          placeholder="Ref."
+          aria-label={`Regulation reference for check ${index + 1}`}
+          className="h-9 w-20 shrink-0 font-mono text-xs"
+        />
+        <Input
+          value={item.name}
+          onChange={(e) => onChange({ name: e.target.value })}
+          placeholder="Check name, e.g. Brake linings and pads"
+          aria-label={`Name of check ${index + 1} in section ${sectionIndex + 1}`}
+          className="h-9"
+          required
+        />
+        <Badge variant="secondary" className="hidden shrink-0 text-[11px] lg:inline-flex">
+          {typeLabel}
+        </Badge>
+        <Button
+          type="button"
+          variant="ghost"
+          size="icon"
+          className="h-9 w-9 shrink-0"
+          aria-expanded={expanded}
+          aria-controls={panelId}
+          onClick={() => setExpanded((v) => !v)}
+        >
+          <ChevronDown
+            className={`h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
+            aria-hidden="true"
+          />
+          <span className="sr-only">
+            {expanded ? "Hide settings for" : "Configure"} {item.name || `check ${index + 1}`}
+          </span>
+        </Button>
+        <Button
+          type="button"
+          variant="ghost"
+          size="icon"
+          className="h-9 w-9 shrink-0"
+          onClick={onDuplicate}
+          aria-label={`Duplicate check ${item.name || index + 1}`}
+        >
+          <Copy className="h-3.5 w-3.5" aria-hidden="true" />
+        </Button>
+        <Button
+          type="button"
+          variant="ghost"
+          size="icon"
+          className="text-muted-foreground hover:text-destructive h-9 w-9 shrink-0"
+          onClick={onDelete}
+          disabled={!canDelete}
+          aria-label={`Remove check ${item.name || index + 1}`}
+        >
+          <Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
+        </Button>
+      </div>
+
+      {expanded && (
+        <div id={panelId} className="bg-muted/30 space-y-4 border-t px-3 py-4">
+          <div className="grid gap-4 sm:grid-cols-2">
+            <div className="space-y-1.5">
+              <Label htmlFor={`${fieldId}-type`}>How is it recorded?</Label>
+              <Select
+                value={item.inputType}
+                onValueChange={(v) => onChange({ inputType: v as InputType })}
+              >
+                <SelectTrigger id={`${fieldId}-type`}>
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  {INPUT_TYPES.map((t) => (
+                    <SelectItem key={t.value} value={t.value}>
+                      {t.label}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+              <p className="text-muted-foreground text-xs">
+                {INPUT_TYPES.find((t) => t.value === item.inputType)?.hint}
+              </p>
+            </div>
+
+            <div className="space-y-1.5">
+              <Label htmlFor={`${fieldId}-desc`}>Guidance for the technician</Label>
+              <Textarea
+                id={`${fieldId}-desc`}
+                value={item.description}
+                onChange={(e) => onChange({ description: e.target.value })}
+                placeholder="Shown under the check name on the inspection form."
+                className="min-h-[64px] text-sm"
+              />
+            </div>
+          </div>
+
+          {isMeasurement && (
+            <fieldset className="space-y-3">
+              <legend className="text-sm font-medium">Measurement</legend>
+              <div className="grid gap-3 sm:grid-cols-4">
+                <div className="space-y-1.5">
+                  <Label htmlFor={`${fieldId}-unit`}>Unit</Label>
+                  <Input
+                    id={`${fieldId}-unit`}
+                    value={item.unit}
+                    onChange={(e) => onChange({ unit: e.target.value })}
+                    list={`${fieldId}-units`}
+                    placeholder="mm"
+                  />
+                  <datalist id={`${fieldId}-units`}>
+                    {COMMON_UNITS.map((u) => (
+                      <option key={u} value={u} />
+                    ))}
+                  </datalist>
+                </div>
+                <div className="space-y-1.5">
+                  <Label htmlFor={`${fieldId}-min`}>Minimum</Label>
+                  <Input
+                    id={`${fieldId}-min`}
+                    inputMode="decimal"
+                    value={item.minValue}
+                    onChange={(e) =>
+                      NUMBER_FIELD.test(e.target.value) && onChange({ minValue: e.target.value })
+                    }
+                    placeholder="1.6"
+                  />
+                </div>
+                <div className="space-y-1.5">
+                  <Label htmlFor={`${fieldId}-max`}>Maximum</Label>
+                  <Input
+                    id={`${fieldId}-max`}
+                    inputMode="decimal"
+                    value={item.maxValue}
+                    onChange={(e) =>
+                      NUMBER_FIELD.test(e.target.value) && onChange({ maxValue: e.target.value })
+                    }
+                    placeholder="8"
+                  />
+                </div>
+                <div className="space-y-1.5">
+                  <Label htmlFor={`${fieldId}-sev`}>Defect if out of range</Label>
+                  <Select
+                    value={item.defaultSeverity || "fail"}
+                    onValueChange={(v) =>
+                      onChange({ defaultSeverity: v as EditorItem["defaultSeverity"] })
+                    }
+                  >
+                    <SelectTrigger id={`${fieldId}-sev`}>
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="attention">Minor defect</SelectItem>
+                      <SelectItem value="fail">Major defect</SelectItem>
+                      <SelectItem value="dangerous">Dangerous defect</SelectItem>
+                    </SelectContent>
+                  </Select>
+                </div>
+              </div>
+              <p className="text-muted-foreground text-xs">
+                Leave a bound empty for a one-sided limit, e.g. a minimum of 1.6 mm with no maximum.
+                Readings outside the range are graded automatically.
+              </p>
+            </fieldset>
+          )}
+
+          {isChoice && (
+            <div className="space-y-1.5">
+              <Label htmlFor={`${fieldId}-choices`}>Answers</Label>
+              <Input
+                id={`${fieldId}-choices`}
+                value={item.choices}
+                onChange={(e) => onChange({ choices: e.target.value })}
+                placeholder="Full, Partial, None"
+              />
+              <p className="text-muted-foreground text-xs">Separate the answers with commas.</p>
+            </div>
+          )}
+
+          <div className="space-y-1.5">
+            <Label htmlFor={`${fieldId}-suggestions`}>Common defect wording</Label>
+            <Textarea
+              id={`${fieldId}-suggestions`}
+              value={item.defectSuggestions}
+              onChange={(e) => onChange({ defectSuggestions: e.target.value })}
+              placeholder={"Worn below minimum\nContaminated with oil\nSeized"}
+              className="min-h-[76px] text-sm"
+            />
+            <p className="text-muted-foreground text-xs">
+              One phrase per line. These are offered first when a technician records a defect
+              here, ahead of the built-in wording, so your shop can settle on how it describes a
+              fault.
+            </p>
+          </div>
+
+          <div className="flex flex-wrap gap-6">
+            <div className="flex items-center gap-2">
+              <Switch
+                id={`${fieldId}-required`}
+                checked={item.required}
+                onCheckedChange={(v) => onChange({ required: v })}
+              />
+              <Label htmlFor={`${fieldId}-required`} className="font-normal">
+                Must be completed
+              </Label>
+            </div>
+            <div className="flex items-center gap-2">
+              <Switch
+                id={`${fieldId}-photo`}
+                checked={item.photoRequired}
+                onCheckedChange={(v) => onChange({ photoRequired: v })}
+              />
+              <Label htmlFor={`${fieldId}-photo`} className="font-normal">
+                Photo required when a defect is recorded
+              </Label>
+            </div>
+          </div>
+        </div>
+      )}
+    </li>
+  );
+}
+
+/* -------------------------------------------------------------------------- */
+/* Section card                                                               */
+/* -------------------------------------------------------------------------- */
+
+function SectionCard({
+  section,
+  index,
+  canDelete,
+  onChange,
+  onDelete,
+  onAddItem,
+  onItemChange,
+  onItemDuplicate,
+  onItemDelete,
+  onItemsReorder,
+}: {
+  section: EditorSection;
+  index: number;
+  canDelete: boolean;
+  onChange: (patch: Partial<EditorSection>) => void;
+  onDelete: () => void;
+  onAddItem: () => void;
+  onItemChange: (itemIndex: number, patch: Partial<EditorItem>) => void;
+  onItemDuplicate: (itemIndex: number) => void;
+  onItemDelete: (itemIndex: number) => void;
+  onItemsReorder: (from: number, to: number) => void;
+}) {
+  const fieldId = useId();
+  const { setNodeRef, style, isDragging, handleProps } = useSortableRow(section.key);
+
+  const sensors = useSensors(
+    useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
+    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
+  );
+
+  const handleItemDragEnd = (event: DragEndEvent) => {
+    const { active, over } = event;
+    if (!over || active.id === over.id) return;
+    const from = section.items.findIndex((i) => i.key === active.id);
+    const to = section.items.findIndex((i) => i.key === over.id);
+    if (from !== -1 && to !== -1) onItemsReorder(from, to);
+  };
+
+  return (
+    <section
+      ref={setNodeRef}
+      style={style}
+      aria-labelledby={`${fieldId}-name`}
+      className={`bg-card rounded-lg border ${isDragging ? "ring-primary/40 shadow-lg ring-2" : ""}`}
+    >
+      <header className="flex items-start gap-1.5 border-b p-3">
+        <DragHandle label={`Reorder section ${section.name || index + 1}`} {...handleProps} />
+        <div className="grid flex-1 gap-2 sm:grid-cols-[5rem_1fr]">
+          <Input
+            value={section.code}
+            onChange={(e) => onChange({ code: e.target.value })}
+            placeholder="Ref."
+            aria-label={`Regulation reference for section ${index + 1}`}
+            className="font-mono text-xs"
+          />
+          <Input
+            id={`${fieldId}-name`}
+            value={section.name}
+            onChange={(e) => onChange({ name: e.target.value })}
+            placeholder="Section name, e.g. Braking equipment"
+            aria-label={`Name of section ${index + 1}`}
+            className="font-medium"
+            required
+          />
+          <Input
+            value={section.description}
+            onChange={(e) => onChange({ description: e.target.value })}
+            placeholder="Optional note shown above the checks in this section"
+            aria-label={`Description of section ${index + 1}`}
+            className="text-sm sm:col-span-2"
+          />
+        </div>
+        <Button
+          type="button"
+          variant="ghost"
+          size="icon"
+          className="text-muted-foreground hover:text-destructive h-9 w-9 shrink-0"
+          onClick={onDelete}
+          disabled={!canDelete}
+          aria-label={`Remove section ${section.name || index + 1}`}
+        >
+          <Trash2 className="h-4 w-4" aria-hidden="true" />
+        </Button>
+      </header>
+
+      <div className="p-3">
+        <DndContext
+          sensors={sensors}
+          collisionDetection={closestCenter}
+          modifiers={[restrictToVerticalAxis]}
+          onDragEnd={handleItemDragEnd}
+        >
+          <SortableContext
+            items={section.items.map((i) => i.key)}
+            strategy={verticalListSortingStrategy}
+          >
+            <ul className="space-y-1.5">
+              {section.items.map((item, itemIndex) => (
+                <CheckRow
+                  key={item.key}
+                  item={item}
+                  sectionIndex={index}
+                  index={itemIndex}
+                  canDelete={section.items.length > 1}
+                  onChange={(patch) => onItemChange(itemIndex, patch)}
+                  onDuplicate={() => onItemDuplicate(itemIndex)}
+                  onDelete={() => onItemDelete(itemIndex)}
+                />
+              ))}
+            </ul>
+          </SortableContext>
+        </DndContext>
+
+        <Button type="button" variant="ghost" size="sm" className="mt-2" onClick={onAddItem}>
+          <Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
+          Add check
+        </Button>
+      </div>
+    </section>
+  );
+}
+
+/* -------------------------------------------------------------------------- */
+/* Builder                                                                    */
+/* -------------------------------------------------------------------------- */
+
 export function TemplateForm({
   open,
   onOpenChange,
@@ -35,105 +648,102 @@ export function TemplateForm({
 }: {
   open: boolean;
   onOpenChange: (open: boolean) => void;
-  template?: TemplateData;
+  template?: TemplateFormData;
 }) {
   const router = useRouter();
   const [isPending, startTransition] = useTransition();
   const isEdit = !!template;
 
-  const [name, setName] = useState(template?.name || "");
-  const [description, setDescription] = useState(template?.description || "");
-  const [isDefault, setIsDefault] = useState(template?.isDefault || false);
-  const [sections, setSections] = useState<TemplateSection[]>(
-    template?.sections.map((s, sIdx) => ({
-      name: s.name,
-      sortOrder: s.sortOrder ?? sIdx,
-      items: s.items.map((i, iIdx) => ({ name: i.name, sortOrder: i.sortOrder ?? iIdx })),
-    })) || [{ name: "", sortOrder: 0, items: [{ name: "", sortOrder: 0 }] }]
+  const [name, setName] = useState(template?.name ?? "");
+  const [description, setDescription] = useState(template?.description ?? "");
+  const [isDefault, setIsDefault] = useState(template?.isDefault ?? false);
+  const [country, setCountry] = useState(template?.country ?? "none");
+  const [severityScale, setSeverityScale] = useState<SeverityScale>(
+    (template?.severityScale as SeverityScale) || "eu"
   );
+  const [sections, setSections] = useState<EditorSection[]>(() => toEditorState(template));
 
-  // Reset form when template changes
-  const resetForm = () => {
-    setName(template?.name || "");
-    setDescription(template?.description || "");
-    setIsDefault(template?.isDefault || false);
-    setSections(
-      template?.sections.map((s, sIdx) => ({
-        name: s.name,
-        sortOrder: s.sortOrder ?? sIdx,
-        items: s.items.map((i, iIdx) => ({ name: i.name, sortOrder: i.sortOrder ?? iIdx })),
-      })) || [{ name: "", sortOrder: 0, items: [{ name: "", sortOrder: 0 }] }]
-    );
-  };
-
-  const handleOpenChange = (open: boolean) => {
-    if (open) resetForm();
-    onOpenChange(open);
-  };
-
-  const addSection = () => {
-    setSections([...sections, { name: "", sortOrder: sections.length, items: [{ name: "", sortOrder: 0 }] }]);
-  };
-
-  const removeSection = (idx: number) => {
-    setSections(sections.filter((_, i) => i !== idx));
-  };
+  const sensors = useSensors(
+    useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
+    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
+  );
 
-  const updateSectionName = (idx: number, val: string) => {
-    const updated = [...sections];
-    updated[idx] = { ...updated[idx], name: val };
-    setSections(updated);
-  };
+  const patchSection = (index: number, patch: Partial<EditorSection>) =>
+    setSections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)));
 
-  const addItem = (sectionIdx: number) => {
-    const updated = [...sections];
-    updated[sectionIdx] = {
-      ...updated[sectionIdx],
-      items: [...updated[sectionIdx].items, { name: "", sortOrder: updated[sectionIdx].items.length }],
-    };
-    setSections(updated);
-  };
+  const patchItem = (sectionIndex: number, itemIndex: number, patch: Partial<EditorItem>) =>
+    setSections((prev) =>
+      prev.map((s, i) =>
+        i === sectionIndex
+          ? { ...s, items: s.items.map((it, j) => (j === itemIndex ? { ...it, ...patch } : it)) }
+          : s
+      )
+    );
 
-  const removeItem = (sectionIdx: number, itemIdx: number) => {
-    const updated = [...sections];
-    updated[sectionIdx] = {
-      ...updated[sectionIdx],
-      items: updated[sectionIdx].items.filter((_, i) => i !== itemIdx),
-    };
-    setSections(updated);
+  const handleSectionDragEnd = (event: DragEndEvent) => {
+    const { active, over } = event;
+    if (!over || active.id === over.id) return;
+    setSections((prev) => {
+      const from = prev.findIndex((s) => s.key === active.id);
+      const to = prev.findIndex((s) => s.key === over.id);
+      return from === -1 || to === -1 ? prev : arrayMove(prev, from, to);
+    });
   };
 
-  const updateItemName = (sectionIdx: number, itemIdx: number, val: string) => {
-    const updated = [...sections];
-    const items = [...updated[sectionIdx].items];
-    items[itemIdx] = { ...items[itemIdx], name: val };
-    updated[sectionIdx] = { ...updated[sectionIdx], items };
-    setSections(updated);
-  };
+  const totalChecks = sections.reduce((sum, s) => sum + s.items.length, 0);
+  // A reference with more than one dot is an Annex I citation, not a position
+  // in the list, so warn before offering to overwrite it.
+  const hasRegulationCodes = sections.some((section) =>
+    section.items.some((item) => (item.code.match(/\./g)?.length ?? 0) >= 2)
+  );
 
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
 
     const payload = {
-      ...(isEdit ? { id: template!.id } : {}),
+      ...(isEdit ? { id: template.id } : {}),
       name,
       description: description || undefined,
       isDefault,
+      country: country === "none" ? null : country,
+      standard: template?.standard ?? "custom",
+      severityScale,
       sections: sections.map((s, sIdx) => ({
         name: s.name,
+        description: s.description || undefined,
+        code: s.code || undefined,
         sortOrder: sIdx,
         items: s.items.map((i, iIdx) => ({
           name: i.name,
+          description: i.description || undefined,
+          code: i.code || undefined,
           sortOrder: iIdx,
+          inputType: i.inputType,
+          unit: i.unit || undefined,
+          // Empty strings mean "no bound", which is not the same as zero.
+          minValue: i.minValue === "" ? null : Number(i.minValue),
+          maxValue: i.maxValue === "" ? null : Number(i.maxValue),
+          choices:
+            i.inputType === "choice"
+              ? i.choices
+                  .split(",")
+                  .map((c) => c.trim())
+                  .filter(Boolean)
+              : [],
+          required: i.required,
+          photoRequired: i.photoRequired,
+          defaultSeverity:
+            i.inputType === "measurement" ? i.defaultSeverity || "fail" : null,
+          defectSuggestions: i.defectSuggestions
+            .split("\n")
+            .map((line) => line.trim())
+            .filter(Boolean),
         })),
       })),
     };
 
     startTransition(async () => {
-      const result = isEdit
-        ? await updateTemplate(payload)
-        : await createTemplate(payload);
-
+      const result = isEdit ? await updateTemplate(payload) : await createTemplate(payload);
       if (result.success) {
         toast.success(isEdit ? "Template updated" : "Template created");
         onOpenChange(false);
@@ -145,119 +755,213 @@ export function TemplateForm({
   };
 
   return (
-    <Sheet open={open} onOpenChange={handleOpenChange}>
-      <SheetContent className="w-full sm:max-w-lg overflow-y-auto p-6">
-        <SheetHeader className="mb-6">
-          <SheetTitle>{isEdit ? "Edit Template" : "New Template"}</SheetTitle>
+    <Sheet open={open} onOpenChange={onOpenChange}>
+      <SheetContent
+        side="right"
+        className="flex w-full flex-col gap-0 p-0 sm:max-w-5xl"
+        aria-describedby="template-builder-description"
+      >
+        <SheetHeader className="border-b px-6 py-4">
+          <SheetTitle>{isEdit ? "Edit template" : "New template"}</SheetTitle>
+          <SheetDescription id="template-builder-description">
+            Build the checklist your workshop actually uses. Drag to reorder, and configure each
+            check to record a grade, a measurement, a written note or a fixed answer.
+          </SheetDescription>
         </SheetHeader>
-        <form onSubmit={handleSubmit} className="space-y-6">
-          <div className="space-y-2">
-            <Label htmlFor="template-name">Name</Label>
-            <Input
-              id="template-name"
-              value={name}
-              onChange={(e) => setName(e.target.value)}
-              placeholder="e.g. Standard Multi-Point Inspection"
-              required
-            />
-          </div>
 
-          <div className="space-y-2">
-            <Label htmlFor="template-desc">Description</Label>
-            <Input
-              id="template-desc"
-              value={description}
-              onChange={(e) => setDescription(e.target.value)}
-              placeholder="Optional description"
-            />
-          </div>
+        <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
+          <div className="grid min-h-0 flex-1 lg:grid-cols-[20rem_1fr]">
+            {/* Settings */}
+            <div className="space-y-5 overflow-y-auto border-b p-6 lg:border-r lg:border-b-0">
+              <div className="space-y-2">
+                <Label htmlFor="template-name">Name</Label>
+                <Input
+                  id="template-name"
+                  value={name}
+                  onChange={(e) => setName(e.target.value)}
+                  placeholder="e.g. Periodic technical inspection"
+                  required
+                />
+              </div>
 
-          <div className="flex items-center gap-2">
-            <Switch id="template-default" checked={isDefault} onCheckedChange={setIsDefault} />
-            <Label htmlFor="template-default">Set as default template</Label>
-          </div>
+              <div className="space-y-2">
+                <Label htmlFor="template-desc">Description</Label>
+                <Textarea
+                  id="template-desc"
+                  value={description}
+                  onChange={(e) => setDescription(e.target.value)}
+                  placeholder="What this checklist is for and when to use it."
+                  className="min-h-[72px]"
+                />
+              </div>
 
-          <div className="space-y-4">
-            <div className="flex items-center justify-between">
-              <Label>Sections</Label>
-              <Button type="button" variant="outline" size="sm" onClick={addSection}>
-                <Plus className="mr-1 h-3.5 w-3.5" />
-                Add Section
-              </Button>
-            </div>
+              <div className="space-y-2">
+                <Label htmlFor="template-country">Country</Label>
+                <Select value={country} onValueChange={setCountry}>
+                  <SelectTrigger id="template-country">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="none">Not country-specific</SelectItem>
+                    {TEMPLATE_COUNTRIES.map((c) => (
+                      <SelectItem key={c.code} value={c.code}>
+                        {c.name}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+                <p className="text-muted-foreground text-xs">
+                  Recorded on the report so a shop working across borders can tell its checklists
+                  apart.
+                </p>
+              </div>
 
-            {sections.map((section, sIdx) => (
-              <div key={sIdx} className="rounded-lg border p-4 space-y-3">
-                <div className="flex items-center gap-2">
-                  <GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
-                  <Input
-                    value={section.name}
-                    onChange={(e) => updateSectionName(sIdx, e.target.value)}
-                    placeholder="Section name (e.g. Exterior)"
-                    className="font-medium"
-                    required
-                  />
-                  {sections.length > 1 && (
-                    <Button
-                      type="button"
-                      variant="ghost"
-                      size="icon"
-                      className="h-8 w-8 shrink-0 text-destructive"
-                      onClick={() => removeSection(sIdx)}
-                      aria-label="Remove section"
-                    >
-                      <Trash2 className="h-3.5 w-3.5" />
-                    </Button>
-                  )}
+              <fieldset className="space-y-2">
+                <legend className="text-sm font-medium">Defect scale</legend>
+                <Select
+                  value={severityScale}
+                  onValueChange={(v) => setSeverityScale(v as SeverityScale)}
+                >
+                  <SelectTrigger aria-label="Defect scale">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="eu">Minor / major / dangerous (EU)</SelectItem>
+                    <SelectItem value="basic">Pass / attention / fail</SelectItem>
+                  </SelectContent>
+                </Select>
+                <p className="text-muted-foreground text-xs">
+                  {severityScale === "eu"
+                    ? "The categories in Article 7(2) of Directive 2014/45/EU. A vehicle with only minor defects still passes."
+                    : "A plain three-step scale for service checklists that are not a statutory test."}
+                </p>
+              </fieldset>
+
+              <div className="flex items-start gap-2 rounded-lg border p-3">
+                <Switch
+                  id="template-default"
+                  checked={isDefault}
+                  onCheckedChange={setIsDefault}
+                  className="mt-0.5"
+                />
+                <div>
+                  <Label htmlFor="template-default">Default template</Label>
+                  <p className="text-muted-foreground text-xs">
+                    Pre-selected when a technician starts a new inspection.
+                  </p>
                 </div>
+              </div>
 
-                <div className="space-y-2 ml-6">
-                  {section.items.map((item, iIdx) => (
-                    <div key={iIdx} className="flex items-center gap-2">
-                      <GripVertical className="h-3.5 w-3.5 text-muted-foreground/50 shrink-0" />
-                      <Input
-                        value={item.name}
-                        onChange={(e) => updateItemName(sIdx, iIdx, e.target.value)}
-                        placeholder="Item name (e.g. Body condition)"
-                        className="text-sm"
-                        required
-                      />
-                      {section.items.length > 1 && (
-                        <Button
-                          type="button"
-                          variant="ghost"
-                          size="icon"
-                          className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
-                          onClick={() => removeItem(sIdx, iIdx)}
-                          aria-label="Remove item"
-                        >
-                          <Trash2 className="h-3 w-3" />
-                        </Button>
-                      )}
-                    </div>
-                  ))}
-                  <Button
-                    type="button"
-                    variant="ghost"
-                    size="sm"
-                    className="text-xs"
-                    onClick={() => addItem(sIdx)}
-                  >
-                    <Plus className="mr-1 h-3 w-3" />
-                    Add Item
-                  </Button>
+              <div className="space-y-3 border-t pt-4">
+                <div className="text-muted-foreground flex items-center gap-2 text-xs">
+                  <Settings2 className="h-3.5 w-3.5" aria-hidden="true" />
+                  {sections.length} section{sections.length === 1 ? "" : "s"} &middot;{" "}
+                  {totalChecks} check{totalChecks === 1 ? "" : "s"}
                 </div>
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="sm"
+                  className="w-full"
+                  onClick={() => setSections((prev) => renumber(prev))}
+                >
+                  <ListOrdered className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
+                  Renumber references
+                </Button>
+                <p className="text-muted-foreground text-xs">
+                  Numbers every section and check in order — 1, 1.1, 1.2, 2. New rows are
+                  numbered as you add them, so you only need this after moving things about.
+                  {hasRegulationCodes && (
+                    <strong className="text-foreground block pt-1">
+                      This checklist uses regulation references such as 1.1.13. Renumbering
+                      replaces them.
+                    </strong>
+                  )}
+                </p>
               </div>
-            ))}
+            </div>
+
+            {/* Checklist */}
+            <div className="bg-muted/20 min-h-0 overflow-y-auto p-6">
+              <DndContext
+                sensors={sensors}
+                collisionDetection={closestCenter}
+                modifiers={[restrictToVerticalAxis]}
+                onDragEnd={handleSectionDragEnd}
+              >
+                <SortableContext
+                  items={sections.map((s) => s.key)}
+                  strategy={verticalListSortingStrategy}
+                >
+                  <div className="space-y-4">
+                    {sections.map((section, index) => (
+                      <SectionCard
+                        key={section.key}
+                        section={section}
+                        index={index}
+                        canDelete={sections.length > 1}
+                        onChange={(patch) => patchSection(index, patch)}
+                        onDelete={() =>
+                          setSections((prev) => prev.filter((_, i) => i !== index))
+                        }
+                        onAddItem={() =>
+                          patchSection(index, {
+                            items: [
+                              ...section.items,
+                              { ...blankItem(), code: nextItemCode(section, index) },
+                            ],
+                          })
+                        }
+                        onItemChange={(itemIndex, patch) => patchItem(index, itemIndex, patch)}
+                        onItemDuplicate={(itemIndex) => {
+                          const source = section.items[itemIndex];
+                          const copy = {
+                            ...source,
+                            key: nextKey(),
+                            code: nextItemCode(section, index),
+                          };
+                          const items = [...section.items];
+                          items.splice(itemIndex + 1, 0, copy);
+                          patchSection(index, { items });
+                        }}
+                        onItemDelete={(itemIndex) =>
+                          patchSection(index, {
+                            items: section.items.filter((_, j) => j !== itemIndex),
+                          })
+                        }
+                        onItemsReorder={(from, to) =>
+                          patchSection(index, { items: arrayMove(section.items, from, to) })
+                        }
+                      />
+                    ))}
+                  </div>
+                </SortableContext>
+              </DndContext>
+
+              <Button
+                type="button"
+                variant="outline"
+                className="mt-4 w-full"
+                onClick={() =>
+                  setSections((prev) => [
+                    ...prev,
+                    blankSection(nextSectionCode(prev)),
+                  ])
+                }
+              >
+                <Plus className="mr-1 h-4 w-4" aria-hidden="true" />
+                Add section
+              </Button>
+            </div>
           </div>
 
-          <div className="flex justify-end gap-2 pt-6">
+          <div className="flex items-center justify-end gap-2 border-t px-6 py-4">
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
               Cancel
             </Button>
             <Button type="submit" disabled={isPending}>
-              {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {isEdit ? "Update" : "Create"} Template
+              {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />}
+              {isEdit ? "Save changes" : "Create template"}
             </Button>
           </div>
         </form>

+ 310 - 118
src/features/inspections/Components/TemplateListClient.tsx

@@ -5,29 +5,69 @@ import { useRouter } from "next/navigation";
 import { Button } from "@/components/ui/button";
 import { Badge } from "@/components/ui/badge";
 import {
-  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
-} from "@/components/ui/table";
-import {
-  DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
 } from "@/components/ui/dropdown-menu";
 import {
-  ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { TableContextMenuHint } from "@/components/table-context-menu-hint";
-import {
-  AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
-  AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
 } from "@/components/ui/alert-dialog";
-import { Loader2, MoreVertical, Pencil, Plus, Trash2 } from "lucide-react";
+import {
+  ClipboardCheck,
+  Copy,
+  Globe2,
+  LayoutTemplate,
+  ListChecks,
+  Loader2,
+  MoreVertical,
+  Pencil,
+  LibraryBig,
+  Plus,
+  Ruler,
+  ShieldCheck,
+  Trash2,
+} from "lucide-react";
 import { toast } from "sonner";
-import { deleteTemplate } from "../Actions/templateActions";
-import { TemplateForm } from "./TemplateForm";
+import {
+  deleteTemplate,
+  duplicateTemplate,
+  restoreMissingPresets,
+} from "../Actions/templateActions";
+import { TemplateForm, type TemplateFormData } from "./TemplateForm";
+import { TemplatePresetPicker } from "./TemplatePresetPicker";
+import { TEMPLATE_COUNTRIES, TEMPLATE_PRESETS } from "../Lib/templatePresets";
 
 interface TemplateSection {
   id: string;
   name: string;
+  description?: string | null;
+  code?: string | null;
   sortOrder: number;
-  items: { id: string; name: string; sortOrder: number }[];
+  items: {
+    id: string;
+    name: string;
+    description?: string | null;
+    code?: string | null;
+    sortOrder: number;
+    inputType?: string | null;
+    unit?: string | null;
+    minValue?: number | null;
+    maxValue?: number | null;
+    choices?: string[];
+    required?: boolean;
+    photoRequired?: boolean;
+    defaultSeverity?: string | null;
+    defectSuggestions?: string[];
+  }[];
 }
 
 interface Template {
@@ -35,14 +75,160 @@ interface Template {
   name: string;
   description: string | null;
   isDefault: boolean;
+  country?: string | null;
+  standard?: string | null;
+  severityScale?: string | null;
   sections: TemplateSection[];
 }
 
+function countryName(code?: string | null) {
+  if (!code) return null;
+  return TEMPLATE_COUNTRIES.find((c) => c.code === code)?.name ?? code;
+}
+
+function TemplateCard({
+  template,
+  onEdit,
+  onDuplicate,
+  onDelete,
+  isDuplicating,
+}: {
+  template: Template;
+  onEdit: () => void;
+  onDuplicate: () => void;
+  onDelete: () => void;
+  isDuplicating: boolean;
+}) {
+  const checkCount = template.sections.reduce((sum, s) => sum + s.items.length, 0);
+  const measurementCount = template.sections.reduce(
+    (sum, s) => sum + s.items.filter((i) => i.inputType === "measurement").length,
+    0
+  );
+  const isEu = template.severityScale === "eu";
+  const country = countryName(template.country);
+
+  return (
+    <article className="bg-card flex flex-col rounded-lg border p-4">
+      <div className="flex items-start justify-between gap-2">
+        <div className="min-w-0">
+          <h3 className="flex items-center gap-2 font-semibold">
+            <span className="truncate">{template.name}</span>
+            {template.isDefault && (
+              <Badge variant="secondary" className="shrink-0 text-[11px]">
+                Default
+              </Badge>
+            )}
+          </h3>
+          {template.description && (
+            <p className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
+              {template.description}
+            </p>
+          )}
+        </div>
+        <DropdownMenu>
+          <DropdownMenuTrigger asChild>
+            <Button
+              variant="ghost"
+              size="icon"
+              className="h-9 w-9 shrink-0"
+              aria-label={`Actions for ${template.name}`}
+            >
+              {isDuplicating ? (
+                <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
+              ) : (
+                <MoreVertical className="h-4 w-4" aria-hidden="true" />
+              )}
+            </Button>
+          </DropdownMenuTrigger>
+          <DropdownMenuContent align="end" className="min-w-44">
+            <DropdownMenuItem onClick={onEdit}>
+              <Pencil className="mr-2 h-4 w-4" aria-hidden="true" />
+              Edit
+            </DropdownMenuItem>
+            <DropdownMenuItem onClick={onDuplicate}>
+              <Copy className="mr-2 h-4 w-4" aria-hidden="true" />
+              Duplicate
+            </DropdownMenuItem>
+            <DropdownMenuSeparator />
+            <DropdownMenuItem className="text-destructive" onClick={onDelete}>
+              <Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
+              Delete
+            </DropdownMenuItem>
+          </DropdownMenuContent>
+        </DropdownMenu>
+      </div>
+
+      <dl className="text-muted-foreground mt-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs">
+        <div className="flex items-center gap-1.5">
+          <LayoutTemplate className="h-3.5 w-3.5" aria-hidden="true" />
+          <dt className="sr-only">Sections</dt>
+          <dd>
+            {template.sections.length} section{template.sections.length === 1 ? "" : "s"}
+          </dd>
+        </div>
+        <div className="flex items-center gap-1.5">
+          <ListChecks className="h-3.5 w-3.5" aria-hidden="true" />
+          <dt className="sr-only">Checks</dt>
+          <dd>
+            {checkCount} check{checkCount === 1 ? "" : "s"}
+          </dd>
+        </div>
+        {measurementCount > 0 && (
+          <div className="flex items-center gap-1.5">
+            <Ruler className="h-3.5 w-3.5" aria-hidden="true" />
+            <dt className="sr-only">Measurements</dt>
+            <dd>{measurementCount} measured</dd>
+          </div>
+        )}
+      </dl>
+
+      <div className="mt-3 flex flex-wrap items-center gap-1.5 border-t pt-3">
+        {country && (
+          <Badge variant="outline" className="gap-1 text-[11px]">
+            <Globe2 className="h-3 w-3" aria-hidden="true" />
+            {country}
+          </Badge>
+        )}
+        <Badge variant="outline" className="gap-1 text-[11px]">
+          <ShieldCheck className="h-3 w-3" aria-hidden="true" />
+          {isEu ? "EU defect scale" : "Pass / attention / fail"}
+        </Badge>
+      </div>
+    </article>
+  );
+}
+
 export function TemplateListClient({ templates }: { templates: Template[] }) {
   const router = useRouter();
   const [showForm, setShowForm] = useState(false);
-  const [editingTemplate, setEditingTemplate] = useState<Template | undefined>();
-  const [deleteId, setDeleteId] = useState<string | null>(null);
+  const [showPresets, setShowPresets] = useState(false);
+  const [editingTemplate, setEditingTemplate] = useState<TemplateFormData | undefined>();
+  const [deleteTarget, setDeleteTarget] = useState<Template | null>(null);
+  const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
+  const [isInstalling, startInstalling] = useTransition();
+
+  // Presets a workshop would actually run; "blank" is a way to start building
+  // one, not a checklist, so it is not part of the library.
+  const installed = new Set(templates.map((t) => t.name.trim().toLowerCase()));
+  const missing = TEMPLATE_PRESETS.filter(
+    (p) => p.id !== "blank" && !installed.has(p.name.trim().toLowerCase())
+  );
+
+  const handleRestore = () => {
+    startInstalling(async () => {
+      const result = await restoreMissingPresets();
+      if (result.success && result.data) {
+        toast.success(
+          result.data.added === 0
+            ? "Every checklist is already here"
+            : `Restored ${result.data.added} checklist${result.data.added === 1 ? "" : "s"}`
+        );
+        router.refresh();
+      } else {
+        toast.error(result.error || "Failed to add the checklists");
+      }
+    });
+  };
   const [isDeleting, startDeleteTransition] = useTransition();
 
   const handleDelete = (id: string) => {
@@ -50,7 +236,7 @@ export function TemplateListClient({ templates }: { templates: Template[] }) {
       const result = await deleteTemplate(id);
       if (result.success) {
         toast.success("Template deleted");
-        setDeleteId(null);
+        setDeleteTarget(null);
         router.refresh();
       } else {
         toast.error(result.error || "Failed to delete template");
@@ -58,8 +244,20 @@ export function TemplateListClient({ templates }: { templates: Template[] }) {
     });
   };
 
+  const handleDuplicate = async (id: string) => {
+    setDuplicatingId(id);
+    const result = await duplicateTemplate(id);
+    setDuplicatingId(null);
+    if (result.success) {
+      toast.success("Template duplicated");
+      router.refresh();
+    } else {
+      toast.error(result.error || "Failed to duplicate template");
+    }
+  };
+
   const handleEdit = (template: Template) => {
-    setEditingTemplate(template);
+    setEditingTemplate(template as TemplateFormData);
     setShowForm(true);
   };
 
@@ -68,109 +266,94 @@ export function TemplateListClient({ templates }: { templates: Template[] }) {
     setShowForm(true);
   };
 
-  const totalItems = (t: Template) => t.sections.reduce((sum, s) => sum + s.items.length, 0);
-
   return (
-    <div className="space-y-4">
-      <div className="flex items-center justify-between">
+    <div className="space-y-5">
+      <div className="flex flex-wrap items-start justify-between gap-3">
         <div>
-          <h2 className="text-lg font-semibold">Inspection Templates</h2>
-          <p className="text-sm text-muted-foreground">
-            Define multi-point inspection checklists for technicians
+          <h2 className="text-lg font-semibold">Inspection templates</h2>
+          <p className="text-muted-foreground text-sm">
+            The checklists your technicians work through. Build one per country, per vehicle type
+            or per service you sell.
           </p>
         </div>
-        <Button size="sm" onClick={handleCreate}>
-          <Plus className="mr-1 h-3.5 w-3.5" />
-          New Template
-        </Button>
+        <div className="flex shrink-0 gap-2">
+          <Button variant="outline" size="sm" onClick={() => setShowPresets(true)}>
+            <ShieldCheck className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
+            Browse checklists
+          </Button>
+          <Button size="sm" onClick={handleCreate}>
+            <Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
+            New template
+          </Button>
+        </div>
       </div>
 
-      <div className="rounded-lg border">
-        <TableContextMenuHint />
-        <Table>
-          <TableHeader>
-            <TableRow>
-              <TableHead>Name</TableHead>
-              <TableHead className="w-28">Sections</TableHead>
-              <TableHead className="w-28">Items</TableHead>
-              <TableHead className="w-28">Default</TableHead>
-              <TableHead className="w-12"></TableHead>
-            </TableRow>
-          </TableHeader>
-          <TableBody>
-            {templates.length === 0 ? (
-              <TableRow>
-                <TableCell colSpan={5} className="h-32 text-center text-muted-foreground">
-                  No templates yet. Create your first inspection template.
-                </TableCell>
-              </TableRow>
-            ) : (
-              templates.map((t) => (
-                <ContextMenu key={t.id} modal={false}>
-                <ContextMenuTrigger asChild>
-                <TableRow>
-                  <TableCell>
-                    <div>
-                      <p className="font-medium">{t.name}</p>
-                      {t.description && (
-                        <p className="text-xs text-muted-foreground truncate max-w-xs">
-                          {t.description}
-                        </p>
-                      )}
-                    </div>
-                  </TableCell>
-                  <TableCell>{t.sections.length}</TableCell>
-                  <TableCell>{totalItems(t)}</TableCell>
-                  <TableCell>
-                    {t.isDefault && (
-                      <Badge variant="secondary" className="text-xs">Default</Badge>
-                    )}
-                  </TableCell>
-                  <TableCell className="px-2">
-                    <DropdownMenu>
-                      <DropdownMenuTrigger asChild>
-                        <Button variant="ghost" size="icon" className="h-7 w-7" aria-label="Open menu">
-                          <MoreVertical className="h-3.5 w-3.5" />
-                        </Button>
-                      </DropdownMenuTrigger>
-                      <DropdownMenuContent align="end">
-                        <DropdownMenuItem onClick={() => handleEdit(t)}>
-                          <Pencil className="mr-2 h-4 w-4" />
-                          Edit
-                        </DropdownMenuItem>
-                        <DropdownMenuItem
-                          className="text-destructive"
-                          onClick={() => setDeleteId(t.id)}
-                        >
-                          <Trash2 className="mr-2 h-4 w-4" />
-                          Delete
-                        </DropdownMenuItem>
-                      </DropdownMenuContent>
-                    </DropdownMenu>
-                  </TableCell>
-                </TableRow>
-                </ContextMenuTrigger>
-                <ContextMenuContent className="min-w-52">
-                  <ContextMenuItem onClick={() => handleEdit(t)}>
-                    <Pencil className="mr-2 h-4 w-4" />
-                    Edit
-                  </ContextMenuItem>
-                  <ContextMenuItem
-                    variant="destructive"
-                    onClick={() => setDeleteId(t.id)}
-                  >
-                    <Trash2 className="mr-2 h-4 w-4" />
-                    Delete
-                  </ContextMenuItem>
-                </ContextMenuContent>
-                </ContextMenu>
-              ))
+      {templates.length > 0 && missing.length > 0 && (
+        <div className="bg-muted/40 flex flex-wrap items-center gap-3 rounded-lg border border-dashed p-3">
+          <LibraryBig className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden="true" />
+          <p className="min-w-0 flex-1 text-sm">
+            {missing.length} ready-made checklist{missing.length === 1 ? " has" : "s have"} been
+            removed from your list. You can put {missing.length === 1 ? "it" : "them"} back.
+          </p>
+          <Button
+            variant="outline"
+            size="sm"
+            className="shrink-0"
+            onClick={handleRestore}
+            disabled={isInstalling}
+          >
+            {isInstalling && (
+              <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" aria-hidden="true" />
             )}
-          </TableBody>
-        </Table>
-      </div>
+            Restore
+          </Button>
+        </div>
+      )}
+
+      {templates.length === 0 ? (
+        <div className="flex flex-col items-center justify-center rounded-lg border border-dashed px-6 py-16 text-center">
+          <ClipboardCheck className="text-muted-foreground/40 h-10 w-10" aria-hidden="true" />
+          <h3 className="mt-4 font-medium">No templates yet</h3>
+          <p className="text-muted-foreground mt-1 max-w-sm text-sm">
+            The ready-made checklists — including the EU periodic technical inspection — have all
+            been removed. Put them back, or build your own from scratch.
+          </p>
+          <div className="mt-5 flex gap-2">
+            <Button onClick={handleRestore} disabled={isInstalling}>
+              {isInstalling ? (
+                <Loader2 className="mr-1 h-4 w-4 animate-spin" aria-hidden="true" />
+              ) : (
+                <LibraryBig className="mr-1 h-4 w-4" aria-hidden="true" />
+              )}
+              Restore checklists
+            </Button>
+            <Button variant="outline" onClick={handleCreate}>
+              <Plus className="mr-1 h-4 w-4" aria-hidden="true" />
+              Build my own
+            </Button>
+          </div>
+        </div>
+      ) : (
+        <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
+          {templates.map((t) => (
+            <TemplateCard
+              key={t.id}
+              template={t}
+              isDuplicating={duplicatingId === t.id}
+              onEdit={() => handleEdit(t)}
+              onDuplicate={() => handleDuplicate(t.id)}
+              onDelete={() => setDeleteTarget(t)}
+            />
+          ))}
+        </div>
+      )}
 
+      {/* Keyed on the template being edited so the editor remounts and reads
+          its state from the right one. Radix only reports its own close
+          actions through onOpenChange, so a form that resets there never sees
+          the parent opening it and would show whatever it mounted with. */}
       <TemplateForm
+        key={editingTemplate?.id ?? "new"}
         open={showForm}
         onOpenChange={(open) => {
           setShowForm(open);
@@ -179,23 +362,32 @@ export function TemplateListClient({ templates }: { templates: Template[] }) {
         template={editingTemplate}
       />
 
-      <AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
+      <TemplatePresetPicker
+        open={showPresets}
+        onOpenChange={setShowPresets}
+        installedNames={templates.map((t) => t.name)}
+      />
+
+      <AlertDialog
+        open={!!deleteTarget}
+        onOpenChange={(open) => !open && setDeleteTarget(null)}
+      >
         <AlertDialogContent>
           <AlertDialogHeader>
-            <AlertDialogTitle>Delete template?</AlertDialogTitle>
+            <AlertDialogTitle>Delete &ldquo;{deleteTarget?.name}&rdquo;?</AlertDialogTitle>
             <AlertDialogDescription>
-              This will permanently delete this inspection template. Existing inspections
-              created from this template will not be affected.
+              This permanently deletes the template. Inspections already carried out with it keep
+              their own copy of the checklist and are not affected.
             </AlertDialogDescription>
           </AlertDialogHeader>
           <AlertDialogFooter>
             <AlertDialogCancel>Cancel</AlertDialogCancel>
             <AlertDialogAction
-              onClick={() => deleteId && handleDelete(deleteId)}
+              onClick={() => deleteTarget && handleDelete(deleteTarget.id)}
               disabled={isDeleting}
               className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
             >
-              {isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />}
               Delete
             </AlertDialogAction>
           </AlertDialogFooter>

+ 217 - 0
src/features/inspections/Components/TemplatePresetPicker.tsx

@@ -0,0 +1,217 @@
+"use client";
+
+import { useMemo, useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { Check, Globe2, Loader2, Search, ShieldCheck, Wrench, Zap } from "lucide-react";
+import { toast } from "sonner";
+import { createTemplateFromPreset } from "../Actions/templateActions";
+import {
+  PRESET_GROUPS,
+  TEMPLATE_PRESETS,
+  countPresetItems,
+  type TemplatePreset,
+} from "../Lib/templatePresets";
+
+const GROUP_ICONS = {
+  regulatory: ShieldCheck,
+  workshop: Wrench,
+  specialist: Zap,
+} as const;
+
+function PresetCard({
+  preset,
+  selected,
+  alreadyAdded,
+  onSelect,
+}: {
+  preset: TemplatePreset;
+  selected: boolean;
+  alreadyAdded: boolean;
+  onSelect: () => void;
+}) {
+  const itemCount = countPresetItems(preset);
+  return (
+    <button
+      type="button"
+      onClick={onSelect}
+      disabled={alreadyAdded}
+      aria-pressed={selected}
+      className={`focus-visible:ring-ring flex h-full flex-col rounded-lg border p-4 text-left transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none ${
+        alreadyAdded
+          ? "cursor-default opacity-55"
+          : selected
+            ? "border-primary bg-primary/5 ring-primary/30 ring-1"
+            : "hover:border-muted-foreground/40 hover:bg-muted/40"
+      }`}
+    >
+      <div className="flex items-start justify-between gap-2">
+        <span className="text-sm font-semibold">{preset.name}</span>
+        {alreadyAdded ? (
+          <span className="text-muted-foreground shrink-0 text-[11px]">In your list</span>
+        ) : (
+          selected && <Check className="text-primary h-4 w-4 shrink-0" aria-hidden="true" />
+        )}
+      </div>
+      <p className="text-muted-foreground mt-1.5 flex-1 text-xs leading-relaxed">
+        {preset.description}
+      </p>
+      <div className="mt-3 flex flex-wrap items-center gap-1.5">
+        {preset.country && (
+          <Badge variant="secondary" className="gap-1 text-[11px]">
+            <Globe2 className="h-3 w-3" aria-hidden="true" />
+            {preset.country}
+          </Badge>
+        )}
+        <Badge variant="outline" className="text-[11px]">
+          {preset.standardLabel}
+        </Badge>
+        <Badge variant="outline" className="text-[11px]">
+          {preset.severityScale === "eu" ? "EU defect scale" : "Pass / attention / fail"}
+        </Badge>
+        <span className="text-muted-foreground ml-auto text-[11px]">
+          {preset.sections.length} sections &middot; {itemCount} checks
+        </span>
+      </div>
+    </button>
+  );
+}
+
+export function TemplatePresetPicker({
+  open,
+  onOpenChange,
+  installedNames = [],
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  /** Names already in the workshop's list, so the same checklist is not offered twice. */
+  installedNames?: string[];
+}) {
+  const router = useRouter();
+  const [query, setQuery] = useState("");
+  const [selectedId, setSelectedId] = useState<string | null>(null);
+  const [isPending, startTransition] = useTransition();
+
+  const installed = useMemo(
+    () => new Set(installedNames.map((name) => name.trim().toLowerCase())),
+    [installedNames]
+  );
+
+  const filtered = useMemo(() => {
+    const q = query.trim().toLowerCase();
+    if (!q) return TEMPLATE_PRESETS;
+    return TEMPLATE_PRESETS.filter((p) =>
+      [p.name, p.description, p.standardLabel, p.country ?? ""]
+        .join(" ")
+        .toLowerCase()
+        .includes(q)
+    );
+  }, [query]);
+
+  const handleUse = () => {
+    if (!selectedId) return;
+    startTransition(async () => {
+      const result = await createTemplateFromPreset(selectedId);
+      if (result.success) {
+        toast.success("Template added. Adjust it to match your workshop.");
+        onOpenChange(false);
+        setSelectedId(null);
+        setQuery("");
+        router.refresh();
+      } else {
+        toast.error(result.error || "Failed to add template");
+      }
+    });
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="flex max-h-[85vh] flex-col gap-0 p-0 sm:max-w-3xl">
+        <DialogHeader className="border-b px-6 py-4">
+          <DialogTitle>Start from a template</DialogTitle>
+          <DialogDescription>
+            Every preset is copied into your workshop as a normal template. Rename sections, move
+            checks and change any limit to match your country and your equipment.
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="border-b px-6 py-3">
+          <div className="relative">
+            <Search
+              className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2"
+              aria-hidden="true"
+            />
+            <Input
+              value={query}
+              onChange={(e) => setQuery(e.target.value)}
+              placeholder="Search by name, country or standard"
+              className="pl-9"
+              aria-label="Search templates"
+            />
+          </div>
+        </div>
+
+        <div className="flex-1 space-y-6 overflow-y-auto px-6 py-5">
+          {PRESET_GROUPS.map((group) => {
+            const presets = filtered.filter((p) => p.group === group.key);
+            if (presets.length === 0) return null;
+            const Icon = GROUP_ICONS[group.key];
+            return (
+              <section key={group.key} aria-labelledby={`preset-group-${group.key}`}>
+                <div className="flex items-center gap-2">
+                  <Icon className="text-muted-foreground h-4 w-4" aria-hidden="true" />
+                  <h3 id={`preset-group-${group.key}`} className="text-sm font-semibold">
+                    {group.label}
+                  </h3>
+                </div>
+                <p className="text-muted-foreground mt-0.5 text-xs">{group.description}</p>
+                <div className="mt-3 grid gap-3 sm:grid-cols-2">
+                  {presets.map((preset) => (
+                    <PresetCard
+                      key={preset.id}
+                      preset={preset}
+                      selected={selectedId === preset.id}
+                      alreadyAdded={installed.has(preset.name.trim().toLowerCase())}
+                      onSelect={() => setSelectedId(preset.id)}
+                    />
+                  ))}
+                </div>
+              </section>
+            );
+          })}
+
+          {filtered.length === 0 && (
+            <p className="text-muted-foreground py-10 text-center text-sm">
+              No template matches &ldquo;{query}&rdquo;.
+            </p>
+          )}
+        </div>
+
+        <div className="flex items-center justify-between gap-3 border-t px-6 py-4">
+          <p className="text-muted-foreground text-xs">
+            Regulatory presets carry the common EU limits. Check them against your national rules
+            before you issue a certificate.
+          </p>
+          <div className="flex shrink-0 gap-2">
+            <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+              Cancel
+            </Button>
+            <Button type="button" onClick={handleUse} disabled={!selectedId || isPending}>
+              {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />}
+              Add template
+            </Button>
+          </div>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 56 - 0
src/features/inspections/Lib/completion.ts

@@ -0,0 +1,56 @@
+import { isDefect } from './conditions'
+
+/**
+ * What still stands between an inspection and being completed.
+ *
+ * A template can mark a check as mandatory, or as needing a photo whenever a
+ * defect is recorded against it. Both are promises the report makes to whoever
+ * reads it — that the check was actually carried out, and that the defect is
+ * evidenced — so they are enforced rather than merely displayed. The same
+ * function runs on the page and in `completeInspection`, so the button and the
+ * server can never disagree about whether a job is finishable.
+ */
+
+export interface CompletionCandidate {
+  id: string
+  name: string
+  code?: string | null
+  condition: string
+  required?: boolean | null
+  photoRequired?: boolean | null
+  photoCount: number
+}
+
+export interface CompletionBlocker {
+  id: string
+  /** "1.1.13 Brake linings and pads" */
+  label: string
+  reason: 'missing-grade' | 'missing-photo'
+}
+
+export function findCompletionBlockers(items: CompletionCandidate[]): CompletionBlocker[] {
+  const blockers: CompletionBlocker[] = []
+  for (const item of items) {
+    const label = item.code ? `${item.code} ${item.name}` : item.name
+    if (item.required && item.condition === 'not_inspected') {
+      blockers.push({ id: item.id, label, reason: 'missing-grade' })
+      continue
+    }
+    if (item.photoRequired && isDefect(item.condition) && item.photoCount === 0) {
+      blockers.push({ id: item.id, label, reason: 'missing-photo' })
+    }
+  }
+  return blockers
+}
+
+export function describeBlocker(reason: CompletionBlocker['reason']): string {
+  return reason === 'missing-grade' ? 'must be graded' : 'needs a photo of the defect'
+}
+
+/** One sentence naming what is outstanding, for the error a server action throws. */
+export function summariseBlockers(blockers: CompletionBlocker[], limit = 3): string {
+  const named = blockers.slice(0, limit).map((b) => `${b.label} (${describeBlocker(b.reason)})`)
+  const rest = blockers.length - named.length
+  const tail = rest > 0 ? `, and ${rest} more` : ''
+  return `${blockers.length} check${blockers.length === 1 ? '' : 's'} still outstanding: ${named.join('; ')}${tail}.`
+}

+ 417 - 0
src/features/inspections/Lib/conditions.ts

@@ -0,0 +1,417 @@
+/**
+ * The defect vocabulary an inspection is graded against.
+ *
+ * Directive 2014/45/EU Article 7(2) puts every deficiency found during a
+ * roadworthiness test into one of three categories:
+ *
+ *   minor      no significant effect on the safety of the vehicle and no other
+ *              non-compliance; the vehicle passes
+ *   major      may prejudice the safety of the vehicle, affect the environment
+ *              or put other road users at risk; the vehicle fails and must be
+ *              repaired and retested
+ *   dangerous  a direct and immediate risk to road safety; the vehicle must not
+ *              be used on the public road
+ *
+ * The stored values keep the names the app has always used, so no existing row
+ * has to be rewritten: `attention` is the minor category and `fail` is the
+ * major one. Only `dangerous` is new. Templates that predate the EU scale keep
+ * grading on the `basic` scale and never offer the dangerous step.
+ */
+export const CONDITIONS = [
+  'pass',
+  'attention',
+  'fail',
+  'dangerous',
+  'not_inspected',
+] as const
+
+export type Condition = (typeof CONDITIONS)[number]
+
+export type SeverityScale = 'eu' | 'basic'
+
+/** Grading steps offered to the technician, worst last. */
+export const SCALE_STEPS: Record<SeverityScale, Condition[]> = {
+  eu: ['pass', 'attention', 'fail', 'dangerous'],
+  basic: ['pass', 'attention', 'fail'],
+}
+
+/** Higher means worse. Used to roll section and inspection results up. */
+const SEVERITY_RANK: Record<Condition, number> = {
+  not_inspected: -1,
+  pass: 0,
+  attention: 1,
+  fail: 2,
+  dangerous: 3,
+}
+
+export function isDefect(condition: string): boolean {
+  return condition === 'attention' || condition === 'fail' || condition === 'dangerous'
+}
+
+/** A defect serious enough that the vehicle fails the test. */
+export function isFailingDefect(condition: string): boolean {
+  return condition === 'fail' || condition === 'dangerous'
+}
+
+export function worstCondition(conditions: string[]): Condition {
+  let worst: Condition = 'not_inspected'
+  for (const c of conditions) {
+    const candidate = (CONDITIONS as readonly string[]).includes(c) ? (c as Condition) : 'not_inspected'
+    if (SEVERITY_RANK[candidate] > SEVERITY_RANK[worst]) worst = candidate
+  }
+  return worst
+}
+
+/**
+ * Presentation tokens for a condition.
+ *
+ * Colour is never the only carrier of meaning (WCAG 2.1 SC 1.4.1): every use
+ * pairs these with the icon and the text label. Foreground/background pairs are
+ * chosen to clear 4.5:1 in both themes — e.g. amber-800 on amber-50 rather than
+ * the amber-600-on-white the page used before, which sat around 3.4:1.
+ */
+export interface ConditionToken {
+  /** i18n key suffix and stable identifier. */
+  key: Condition
+  /** English label for the EU scale. */
+  label: string
+  /** English label for the legacy three-step scale. */
+  basicLabel: string
+  /** Short form for dense UI (segmented control, table cells). */
+  short: string
+  /** What the grade means, shown as help text. */
+  hint: string
+  /** Quiet treatment: tinted surface, used for badges and item rows. */
+  soft: string
+  /** Loud treatment: solid fill, used for the selected segment. */
+  solid: string
+  /** Bare foreground colour for text and icons on the page background. */
+  fg: string
+  /** Plain hex pair for the PDF, which has no Tailwind and no dark mode. */
+  pdf: { bg: string; text: string }
+  /** Fill used in the progress rail. */
+  bar: string
+}
+
+export const CONDITION_TOKENS: Record<Condition, ConditionToken> = {
+  pass: {
+    key: 'pass',
+    label: 'No defect',
+    basicLabel: 'Pass',
+    short: 'OK',
+    hint: 'Meets requirements. Nothing to report.',
+    soft: 'bg-emerald-50 text-emerald-800 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-200 dark:border-emerald-900',
+    solid: 'bg-emerald-700 text-white border-emerald-700 dark:bg-emerald-600 dark:border-emerald-600',
+    fg: 'text-emerald-800 dark:text-emerald-300',
+    pdf: { bg: '#dcfce7', text: '#14532d' },
+    bar: 'bg-emerald-600',
+  },
+  attention: {
+    key: 'attention',
+    label: 'Minor defect',
+    basicLabel: 'Attention',
+    short: 'Minor',
+    hint: 'No significant effect on safety. Vehicle passes; repair when convenient.',
+    soft: 'bg-amber-50 text-amber-900 border-amber-300 dark:bg-amber-950 dark:text-amber-200 dark:border-amber-800',
+    solid: 'bg-amber-700 text-white border-amber-700 dark:bg-amber-600 dark:border-amber-600',
+    fg: 'text-amber-900 dark:text-amber-300',
+    pdf: { bg: '#fef9c3', text: '#713f12' },
+    bar: 'bg-amber-500',
+  },
+  fail: {
+    key: 'fail',
+    label: 'Major defect',
+    basicLabel: 'Fail',
+    short: 'Major',
+    hint: 'May prejudice safety or affect the environment. Vehicle fails; repair and retest.',
+    soft: 'bg-red-50 text-red-800 border-red-200 dark:bg-red-950 dark:text-red-200 dark:border-red-900',
+    solid: 'bg-red-700 text-white border-red-700 dark:bg-red-600 dark:border-red-600',
+    fg: 'text-red-800 dark:text-red-300',
+    pdf: { bg: '#fee2e2', text: '#7f1d1d' },
+    bar: 'bg-red-600',
+  },
+  dangerous: {
+    key: 'dangerous',
+    label: 'Dangerous defect',
+    basicLabel: 'Dangerous',
+    short: 'Danger',
+    hint: 'Direct and immediate risk to road safety. The vehicle must not be driven.',
+    soft: 'bg-red-100 text-red-950 border-red-400 dark:bg-red-900 dark:text-red-50 dark:border-red-700',
+    solid: 'bg-red-900 text-white border-red-900 dark:bg-red-800 dark:border-red-800',
+    fg: 'text-red-900 dark:text-red-200',
+    pdf: { bg: '#fecaca', text: '#450a0a' },
+    bar: 'bg-red-900',
+  },
+  not_inspected: {
+    key: 'not_inspected',
+    label: 'Not inspected',
+    basicLabel: 'Not inspected',
+    short: '—',
+    hint: 'This check has not been carried out.',
+    soft: 'bg-muted text-muted-foreground border-border',
+    solid: 'bg-slate-600 text-white border-slate-600',
+    fg: 'text-muted-foreground',
+    pdf: { bg: '#f3f4f6', text: '#374151' },
+    bar: 'bg-muted-foreground/25',
+  },
+}
+
+export function conditionLabel(condition: string, scale: SeverityScale = 'eu'): string {
+  const token = CONDITION_TOKENS[condition as Condition] ?? CONDITION_TOKENS.not_inspected
+  return scale === 'basic' ? token.basicLabel : token.label
+}
+
+/* -------------------------------------------------------------------------- */
+/* National grade numbering                                                   */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Numeric defect codes, by country.
+ *
+ * Directive 2014/45/EU names its categories but does not number them, so there
+ * is no EU-wide numbering to fall back on. Member states that do number them
+ * are listed here individually, and a country that is not listed simply shows
+ * the wording — which is correct, rather than inventing a scheme.
+ *
+ * Norway's EU-kontroll numbers every check on the kontrollseddel:
+ *   0  ingen mangel — godkjent
+ *   1  mindre mangel — godkjent med merknad, ingen etterkontroll
+ *   2  vesentlig mangel — ikke godkjent, må rettes og etterkontrolleres
+ *   3  farlig mangel — bruksforbud
+ *   4  ikke kontrollert
+ *
+ * Do not add a country here without checking its own regulator: Germany's
+ * Hauptuntersuchung, for one, categorises defects without numbering them this
+ * way, so guessing would put a wrong code on a legal document.
+ */
+export const GRADE_SCHEMES: Record<string, Partial<Record<Condition, number>>> = {
+  NO: { pass: 0, attention: 1, fail: 2, dangerous: 3, not_inspected: 4 },
+}
+
+/** The national defect code, or null where the country does not number them. */
+export function conditionGrade(
+  condition: string,
+  scale: SeverityScale = 'eu',
+  country?: string | null
+): number | null {
+  if (scale === 'basic' || !country) return null
+  const scheme = GRADE_SCHEMES[country]
+  if (!scheme) return null
+  const grade = scheme[condition as Condition]
+  return grade === undefined ? null : grade
+}
+
+/** "2 — Vesentlig mangel", or just the wording where there is no numbering. */
+export function gradedConditionLabel(
+  condition: string,
+  scale: SeverityScale = 'eu',
+  country?: string | null,
+  label?: string
+): string {
+  const text = label ?? conditionLabel(condition, scale)
+  const grade = conditionGrade(condition, scale, country)
+  return grade === null ? text : `${grade} — ${text}`
+}
+
+/* -------------------------------------------------------------------------- */
+/* Overall test result                                                        */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Article 7(3): the outcome of the test follows the worst deficiency found.
+ * A vehicle with only minor deficiencies still passes.
+ */
+export type TestResult = 'pass' | 'pass_minor' | 'fail' | 'fail_dangerous' | 'incomplete'
+
+export interface TestResultToken {
+  key: TestResult
+  label: string
+  /** The consequence for the vehicle owner, in plain language. */
+  detail: string
+  soft: string
+  fg: string
+  pdf: { bg: string; text: string }
+}
+
+export const TEST_RESULT_TOKENS: Record<TestResult, TestResultToken> = {
+  pass: {
+    key: 'pass',
+    label: 'Pass',
+    detail: 'No deficiencies were found.',
+    soft: 'bg-emerald-50 text-emerald-900 border-emerald-300 dark:bg-emerald-950 dark:text-emerald-100 dark:border-emerald-800',
+    fg: 'text-emerald-800 dark:text-emerald-300',
+    pdf: { bg: '#dcfce7', text: '#14532d' },
+  },
+  pass_minor: {
+    key: 'pass_minor',
+    label: 'Pass with minor defects',
+    detail: 'The vehicle passes. Repair the minor deficiencies without undue delay.',
+    soft: 'bg-amber-50 text-amber-900 border-amber-300 dark:bg-amber-950 dark:text-amber-100 dark:border-amber-800',
+    fg: 'text-amber-900 dark:text-amber-300',
+    pdf: { bg: '#fef9c3', text: '#713f12' },
+  },
+  fail: {
+    key: 'fail',
+    label: 'Fail — major defects',
+    detail: 'The vehicle fails. The deficiencies must be repaired and the vehicle retested.',
+    soft: 'bg-red-50 text-red-900 border-red-300 dark:bg-red-950 dark:text-red-100 dark:border-red-800',
+    fg: 'text-red-800 dark:text-red-300',
+    pdf: { bg: '#fee2e2', text: '#7f1d1d' },
+  },
+  fail_dangerous: {
+    key: 'fail_dangerous',
+    label: 'Fail — dangerous defects',
+    detail: 'The vehicle must not be used on the public road until the dangerous deficiencies are repaired.',
+    soft: 'bg-red-100 text-red-950 border-red-500 dark:bg-red-900 dark:text-red-50 dark:border-red-600',
+    fg: 'text-red-900 dark:text-red-200',
+    pdf: { bg: '#fecaca', text: '#450a0a' },
+  },
+  incomplete: {
+    key: 'incomplete',
+    label: 'Not completed',
+    detail: 'The inspection is still in progress.',
+    soft: 'bg-muted text-foreground border-border',
+    fg: 'text-muted-foreground',
+    pdf: { bg: '#f3f4f6', text: '#374151' },
+  },
+}
+
+export interface ConditionCounts {
+  total: number
+  inspected: number
+  pass: number
+  attention: number
+  fail: number
+  dangerous: number
+  notInspected: number
+}
+
+export function countConditions(items: { condition: string }[]): ConditionCounts {
+  const counts: ConditionCounts = {
+    total: items.length,
+    inspected: 0,
+    pass: 0,
+    attention: 0,
+    fail: 0,
+    dangerous: 0,
+    notInspected: 0,
+  }
+  for (const item of items) {
+    switch (item.condition) {
+      case 'pass':
+        counts.pass++
+        counts.inspected++
+        break
+      case 'attention':
+        counts.attention++
+        counts.inspected++
+        break
+      case 'fail':
+        counts.fail++
+        counts.inspected++
+        break
+      case 'dangerous':
+        counts.dangerous++
+        counts.inspected++
+        break
+      default:
+        counts.notInspected++
+    }
+  }
+  return counts
+}
+
+/**
+ * Derives the overall test result. `requireAllInspected` reports `incomplete`
+ * while checks are still outstanding, which is what the in-progress page wants;
+ * the certificate passes `false` because a completed inspection may legitimately
+ * leave non-applicable checks ungraded.
+ */
+export function deriveTestResult(
+  items: { condition: string }[],
+  { requireAllInspected = false }: { requireAllInspected?: boolean } = {}
+): TestResult {
+  const counts = countConditions(items)
+  if (counts.inspected === 0) return 'incomplete'
+  if (requireAllInspected && counts.notInspected > 0) return 'incomplete'
+  if (counts.dangerous > 0) return 'fail_dangerous'
+  if (counts.fail > 0) return 'fail'
+  if (counts.attention > 0) return 'pass_minor'
+  return 'pass'
+}
+
+/* -------------------------------------------------------------------------- */
+/* Measurements                                                               */
+/* -------------------------------------------------------------------------- */
+
+export type InputType = 'condition' | 'measurement' | 'text' | 'choice'
+
+export const INPUT_TYPES: { value: InputType; label: string; hint: string }[] = [
+  { value: 'condition', label: 'Defect grading', hint: 'Technician grades the check against the defect scale.' },
+  { value: 'measurement', label: 'Measurement', hint: 'Records a number in a unit, graded automatically against a range.' },
+  { value: 'text', label: 'Free text', hint: 'Records a written observation, e.g. a tyre brand or a fault code.' },
+  { value: 'choice', label: 'Choice', hint: 'Technician picks one of a fixed list of answers.' },
+]
+
+/** Units offered in the builder. Free text is still allowed. */
+export const COMMON_UNITS = ['mm', 'cm', 'bar', 'psi', '%', 'V', 'A', 'Nm', '°C', 'kg', 'l', 'dB', 'm⁻¹']
+
+/**
+ * Grades a measurement against the check's allowed range. Out-of-range readings
+ * take the check's configured severity so a template can say, for example, that
+ * tread below 1.6 mm is a major defect while a slightly low battery is minor.
+ */
+export function gradeMeasurement(
+  value: number | null | undefined,
+  check: { minValue?: number | null; maxValue?: number | null; defaultSeverity?: string | null }
+): Condition | null {
+  if (value === null || value === undefined || Number.isNaN(value)) return null
+  const { minValue, maxValue } = check
+  if (minValue === null && maxValue === null) return null
+  if (minValue === undefined && maxValue === undefined) return null
+
+  const belowMin = minValue !== null && minValue !== undefined && value < minValue
+  const aboveMax = maxValue !== null && maxValue !== undefined && value > maxValue
+  if (!belowMin && !aboveMax) return 'pass'
+
+  const severity = check.defaultSeverity
+  return severity === 'dangerous' || severity === 'fail' || severity === 'attention' ? severity : 'fail'
+}
+
+/** "1.6–8 mm", "min 1.6 mm", "max 4 bar" — the range shown next to the input. */
+export function formatRange(check: {
+  minValue?: number | null
+  maxValue?: number | null
+  unit?: string | null
+}): string | null {
+  const { minValue, maxValue, unit } = check
+  const suffix = unit ? ` ${unit}` : ''
+  const hasMin = minValue !== null && minValue !== undefined
+  const hasMax = maxValue !== null && maxValue !== undefined
+  if (hasMin && hasMax) return `${minValue}–${maxValue}${suffix}`
+  if (hasMin) return `min ${minValue}${suffix}`
+  if (hasMax) return `max ${maxValue}${suffix}`
+  return null
+}
+
+/* -------------------------------------------------------------------------- */
+/* Vehicle categories (Regulation (EU) 2018/858 / Directive 2014/45/EU Art. 2) */
+/* -------------------------------------------------------------------------- */
+
+export const VEHICLE_CATEGORIES: { value: string; label: string }[] = [
+  { value: 'M1', label: 'M1 — Passenger car (up to 8 seats)' },
+  { value: 'M2', label: 'M2 — Bus or coach, up to 5 t' },
+  { value: 'M3', label: 'M3 — Bus or coach, over 5 t' },
+  { value: 'N1', label: 'N1 — Goods vehicle, up to 3.5 t' },
+  { value: 'N2', label: 'N2 — Goods vehicle, 3.5 t to 12 t' },
+  { value: 'N3', label: 'N3 — Goods vehicle, over 12 t' },
+  { value: 'O1', label: 'O1 — Trailer, up to 0.75 t' },
+  { value: 'O2', label: 'O2 — Trailer, 0.75 t to 3.5 t' },
+  { value: 'O3', label: 'O3 — Trailer, 3.5 t to 10 t' },
+  { value: 'O4', label: 'O4 — Trailer, over 10 t' },
+  { value: 'L3e', label: 'L3e — Motorcycle' },
+  { value: 'L4e', label: 'L4e — Motorcycle with sidecar' },
+  { value: 'L5e', label: 'L5e — Powered tricycle' },
+  { value: 'L7e', label: 'L7e — Heavy quadricycle' },
+  { value: 'T', label: 'T — Wheeled tractor' },
+]

+ 753 - 0
src/features/inspections/Lib/defectCatalogue.ts

@@ -0,0 +1,753 @@
+import type { Condition, SeverityScale } from './conditions'
+
+/**
+ * Ready-made defect descriptions, so a technician picks the wording instead of
+ * retyping it on every inspection.
+ *
+ * The regulatory entries are the "reasons for failure" from Directive
+ * 2014/45/EU Annex I. That table already assigns each reason a category, which
+ * is what makes these more than autocomplete: choosing one fills in the note
+ * *and* grades the check the way the Directive grades it, so the wording and
+ * the severity can never drift apart.
+ *
+ * Nothing here is binding. A workshop can add its own phrases to any check in
+ * the template builder, and whatever it has actually written before is ranked
+ * ahead of these — see `rankSuggestions`.
+ */
+
+export type DefectSeverity = Extract<Condition, 'attention' | 'fail' | 'dangerous'>
+
+export interface DefectSuggestion {
+  text: string
+  severity: DefectSeverity
+  /** Where the phrase came from, shown so the technician can judge it. */
+  source: 'workshop' | 'history' | 'regulation' | 'general'
+}
+
+type CatalogueEntry = [text: string, severity: DefectSeverity]
+
+const entries = (source: DefectSuggestion['source'], list: CatalogueEntry[]): DefectSuggestion[] =>
+  list.map(([text, severity]) => ({ text, severity, source }))
+
+/* -------------------------------------------------------------------------- */
+/* Annex I reasons for failure, by check code                                 */
+/* -------------------------------------------------------------------------- */
+
+const BY_CODE: Record<string, CatalogueEntry[]> = {
+  // 0. Identification
+  '0.1': [
+    ['Registration plate missing', 'fail'],
+    ['Registration plate insecure or likely to fall off', 'fail'],
+    ['Inscription missing, illegible or does not match the vehicle documents', 'fail'],
+  ],
+  '0.2': [
+    ['VIN missing or not to be found', 'fail'],
+    ['VIN incomplete, illegible, obviously falsified or does not match the vehicle documents', 'fail'],
+    ['Vehicle documents illegible or contain a factual inaccuracy', 'attention'],
+  ],
+
+  // 1. Braking equipment
+  '1.1.2': [
+    ['Excessive travel or insufficient reserve travel of the brake control', 'fail'],
+    ['Brake control not releasing correctly', 'attention'],
+    ['Anti-slip provision on the brake pedal missing, loose or worn smooth', 'fail'],
+  ],
+  '1.1.3': [
+    ['Pressure build-up time insufficient for effective braking', 'fail'],
+    ['Insufficient pressure to assist braking at least twice after the warning has operated', 'dangerous'],
+    ['Air or antifreeze leak', 'fail'],
+    ['External damage likely to affect the function of the braking system', 'dangerous'],
+  ],
+  '1.1.4': [
+    ['Gauge or indicator malfunctioning or defective', 'attention'],
+    ['Low pressure not identifiable', 'fail'],
+  ],
+  '1.1.6': [
+    ['Parking brake control not holding, ratchet locking incorrectly', 'fail'],
+    ['Excessive wear at the control pivot or in the ratchet mechanism', 'attention'],
+    ['Excessive movement of the control indicating incorrect adjustment', 'attention'],
+    ['Actuator missing, damaged or inoperative', 'fail'],
+  ],
+  '1.1.10': [
+    ['Servo unit defective or ineffective', 'fail'],
+    ['Servo unit inoperative', 'dangerous'],
+    ['Master cylinder defective but brake still operating', 'fail'],
+    ['Master cylinder leaking', 'fail'],
+    ['Brake fluid reservoir level below the minimum mark', 'attention'],
+    ['Brake fluid reservoir cap missing', 'attention'],
+  ],
+  '1.1.11': [
+    ['Imminent risk of failure or fracture of a rigid pipe', 'dangerous'],
+    ['Pipes or joints leaking', 'dangerous'],
+    ['Pipes damaged or excessively corroded', 'fail'],
+    ['Pipe misplaced with a risk of damage', 'fail'],
+  ],
+  '1.1.12': [
+    ['Imminent risk of failure or fracture of a flexible hose', 'dangerous'],
+    ['Hoses damaged, chafing, twisted or too short', 'attention'],
+    ['Hoses or couplings leaking', 'dangerous'],
+    ['Hoses bulging under pressure', 'fail'],
+    ['Hoses porous or cord damaged', 'dangerous'],
+  ],
+  '1.1.13': [
+    ['Linings or pads worn down to the wear indicator', 'fail'],
+    ['Linings or pads worn beyond the wear indicator, metal to metal contact', 'dangerous'],
+    ['Linings or pads contaminated with oil, grease or other material', 'fail'],
+    ['Linings or pads contaminated, braking effect seriously reduced', 'dangerous'],
+    ['Lining or pad missing or incorrectly mounted', 'dangerous'],
+  ],
+  '1.1.14': [
+    ['Drum or disc excessively worn, scored, cracked, insecure or fractured', 'dangerous'],
+    ['Drum or disc contaminated with oil, grease or other material', 'fail'],
+    ['Drum or disc contaminated, braking effect seriously reduced', 'dangerous'],
+    ['Drum or disc missing', 'dangerous'],
+    ['Back plate insecure', 'fail'],
+  ],
+  '1.1.15': [
+    ['Cable damaged or knotted', 'fail'],
+    ['Component excessively worn or corroded', 'fail'],
+    ['Cable, rod or joint insecure', 'fail'],
+    ['Cable guide defective', 'fail'],
+    ['Restriction of free movement of the braking system', 'fail'],
+  ],
+  '1.1.16': [
+    ['Actuator cracked or damaged', 'fail'],
+    ['Actuator leaking', 'fail'],
+    ['Actuator insecure or inadequately mounted', 'fail'],
+    ['Actuator severely corroded', 'fail'],
+  ],
+  '1.1.17': [
+    ['Linkage defective', 'fail'],
+    ['Linkage incorrectly adjusted', 'fail'],
+    ['Valve seized or inoperative', 'fail'],
+    ['Valve missing where required', 'dangerous'],
+  ],
+  '1.1.21': [
+    ['Other system device damaged, and the braking system operates adversely', 'fail'],
+    ['Air leakage causing a noticeable drop in pressure', 'fail'],
+    ['Any component insecure or inadequately mounted', 'fail'],
+    ['Unsafe modification to a component', 'fail'],
+  ],
+  '1.2.1': [
+    ['Inadequate braking force at one or more wheels', 'fail'],
+    ['No braking force at one or more wheels', 'dangerous'],
+    ['Braking force at any wheel less than 70% of the greatest force on the same axle', 'fail'],
+    ['Excessive fluctuation in braking force through any full wheel revolution', 'fail'],
+    ['Abnormally long delay in the operation of the brakes at any wheel', 'fail'],
+  ],
+  '1.2.2': [
+    ['Braking rate below the minimum required', 'fail'],
+    ['Braking rate less than 50% of the required value', 'dangerous'],
+  ],
+  '1.4.2': [
+    ['Parking brake rate below the minimum required', 'fail'],
+    ['Parking brake rate less than 50% of the required value', 'dangerous'],
+    ['Parking brake does not hold the vehicle on a gradient', 'fail'],
+  ],
+  '1.6': [
+    ['ABS warning device indicates a malfunction', 'fail'],
+    ['ABS warning device inoperative', 'fail'],
+    ['ABS wheel speed sensor missing or damaged', 'fail'],
+    ['ABS wiring damaged', 'fail'],
+    ['ABS system inoperative', 'fail'],
+  ],
+  '1.7': [
+    ['EBS warning device indicates a malfunction', 'fail'],
+    ['EBS warning device inoperative', 'fail'],
+  ],
+  '1.8': [
+    ['Brake fluid contaminated', 'fail'],
+    ['Brake fluid boiling point too low or water content too high', 'fail'],
+    ['Brake fluid at or below the minimum level', 'attention'],
+  ],
+
+  // 2. Steering
+  '2.1': [
+    ['Steering box shaft turning stiffly', 'fail'],
+    ['Steering box shaft excessively worn', 'fail'],
+    ['Excessive movement of the steering box', 'fail'],
+    ['Steering box leaking', 'attention'],
+    ['Steering box dripping oil', 'fail'],
+    ['Steering component insecure, cracked or excessively worn', 'fail'],
+    ['Steering component insecure to the point of affecting safety', 'dangerous'],
+  ],
+  '2.2': [
+    ['Relative movement between the steering wheel and the column, indicating looseness', 'fail'],
+    ['Steering wheel retaining device missing', 'dangerous'],
+    ['Column bearings or couplings excessively worn', 'fail'],
+    ['Excessive vertical or radial movement of the steering wheel hub', 'fail'],
+    ['Handlebars cracked or insecure on the fork stem', 'dangerous'],
+  ],
+  '2.3': [
+    ['Excessive free play at the steering wheel or handlebars', 'fail'],
+    ['Free play so excessive that safe steering is affected', 'dangerous'],
+  ],
+  '2.4': [
+    ['Wheel alignment outside the manufacturer specification', 'attention'],
+    ['Alignment causing abnormal or uneven tyre wear', 'fail'],
+  ],
+  '2.6': [
+    ['Power steering system leaking', 'attention'],
+    ['Insufficient power steering fluid, below the minimum mark', 'attention'],
+    ['Power steering mechanism not functioning', 'fail'],
+    ['Power steering mechanism cracked or insecure', 'fail'],
+    ['Steering wheel or handlebars misaligned or incompatible with the road wheels', 'fail'],
+  ],
+
+  // 3. Visibility
+  '3.1': [
+    ['Obstruction within the driver field of vision that materially affects the forward or side view', 'fail'],
+    ['Obstruction that seriously affects the view through the windscreen', 'dangerous'],
+  ],
+  '3.2': [
+    ['Glass cracked or discoloured', 'attention'],
+    ['Cracked or discoloured glass within the swept area of the windscreen', 'fail'],
+    ['Glass not conforming to the applicable requirements', 'fail'],
+    ['Condition of the glass seriously impairing visibility', 'dangerous'],
+    ['Chip within the driver critical vision area', 'fail'],
+  ],
+  '3.3': [
+    ['Mirror or device missing or not fitted as required', 'fail'],
+    ['Mirror or device defective, loose or insecure', 'attention'],
+    ['Mirror or device with an insufficient field of vision', 'fail'],
+  ],
+  '3.4': [
+    ['Wipers not operating or missing', 'fail'],
+    ['Wiper blade defective', 'attention'],
+    ['Wiper blade missing or obviously ineffective', 'fail'],
+    ['Wiper smearing or juddering across the swept area', 'attention'],
+  ],
+  '3.5': [
+    ['Washers not operating adequately', 'attention'],
+    ['Washers not operating at all', 'fail'],
+    ['Washer fluid reservoir empty', 'attention'],
+  ],
+  '3.6': [['Demisting or defrosting system inoperative or clearly not functioning', 'attention']],
+
+  // 4. Lamps, reflectors and electrical equipment
+  '4.1.1': [
+    ['Light source or lamp defective or missing', 'attention'],
+    ['Two light sources or lamps defective or missing', 'fail'],
+    ['Projection system slightly defective', 'attention'],
+    ['Projection system severely defective or missing', 'fail'],
+    ['Lamp insecurely attached', 'attention'],
+    ['Lens heavily clouded, reducing light output', 'attention'],
+    ['Colour of emitted light not compliant', 'fail'],
+  ],
+  '4.1.2': [['Headlamp aim outside the required limits', 'attention']],
+  '4.2': [
+    ['Light source or lamp defective', 'attention'],
+    ['Lens defective', 'attention'],
+    ['Lamp insecurely attached, with a serious risk of falling off', 'fail'],
+    ['Colour of emitted light not compliant', 'fail'],
+  ],
+  '4.3': [
+    ['Stop lamp not operating', 'fail'],
+    ['One stop lamp of a pair not operating', 'attention'],
+    ['No stop lamp operating at all', 'dangerous'],
+    ['Stop lamp operating permanently', 'fail'],
+  ],
+  '4.4': [
+    ['Indicator or hazard lamp not operating', 'fail'],
+    ['Flashing rate outside the required range', 'attention'],
+    ['Colour of emitted light not compliant', 'fail'],
+    ['Indicator tell-tale inoperative', 'attention'],
+  ],
+  '4.5': [
+    ['Fog lamp not operating', 'attention'],
+    ['Fog lamp incorrectly aimed', 'attention'],
+    ['Rear fog lamp operating permanently', 'attention'],
+  ],
+  '4.6': [
+    ['Reversing lamp not operating', 'attention'],
+    ['Reversing lamp operating permanently or showing to the rear when not in reverse', 'fail'],
+  ],
+  '4.7': [['Registration plate lamp not operating or missing', 'attention']],
+  '4.8': [
+    ['Reflector defective, damaged or missing', 'attention'],
+    ['Conspicuity marking damaged, dirty or partly missing', 'attention'],
+    ['Reflector colour not compliant', 'fail'],
+  ],
+  '4.9': [
+    ['Tell-tale not operating', 'attention'],
+    ['Tell-tale indicating a malfunction of a safety-related system', 'fail'],
+  ],
+  '4.10': [
+    ['Fixed components insecure or damaged', 'attention'],
+    ['Insulation damaged or deteriorated', 'attention'],
+    ['Trailer electrical connection not functioning', 'fail'],
+  ],
+  '4.11': [
+    ['Wiring insecure or inadequately secured', 'attention'],
+    ['Wiring insulation damaged or deteriorated', 'fail'],
+    ['Wiring damaged with a risk of fire or sparking', 'dangerous'],
+    ['Excessively deteriorated wiring in the engine bay or near hot components', 'fail'],
+  ],
+  '4.13': [
+    ['Battery insecure', 'attention'],
+    ['Battery leaking', 'fail'],
+    ['Battery leaking corrosive electrolyte', 'dangerous'],
+    ['Battery switch defective', 'fail'],
+    ['Battery terminals corroded or loose', 'attention'],
+    ['Battery state of charge below the serviceable threshold', 'attention'],
+  ],
+
+  // 5. Axles, wheels, tyres and suspension
+  '5.1.1': [
+    ['Axle cracked or deformed', 'dangerous'],
+    ['Axle insecurely attached to the vehicle', 'fail'],
+    ['Unsafe modification to an axle', 'dangerous'],
+  ],
+  '5.1.3': [
+    ['Wheel bearing with excessive play', 'fail'],
+    ['Wheel bearing too tight or seizing', 'dangerous'],
+    ['Wheel bearing noisy under load', 'fail'],
+  ],
+  '5.2.1': [
+    ['Wheel fracture or defective welding', 'dangerous'],
+    ['Wheel retaining rings not correctly fitted', 'dangerous'],
+    ['Wheel badly distorted or worn', 'fail'],
+    ['Wheel nuts or studs missing or loose', 'dangerous'],
+    ['Wheel size or type not compatible and affecting road safety', 'fail'],
+  ],
+  '5.2.3': [
+    ['Tread depth below the legal minimum', 'fail'],
+    ['Tread wear indicator visible', 'fail'],
+    ['Tyre severely damaged or cut', 'fail'],
+    ['Tyre cords visible or damaged', 'dangerous'],
+    ['Tyres of different size or construction on the same axle', 'fail'],
+    ['Tyre load index or speed rating not compliant with the vehicle', 'fail'],
+    ['Tyre rubbing against another component', 'attention'],
+    ['Tyre seriously rubbing against another component', 'fail'],
+    ['Uneven wear across the tread indicating an alignment or suspension fault', 'attention'],
+    ['Tyre pressure outside the manufacturer specification', 'attention'],
+    ['Tyre perished or showing sidewall cracking with age', 'attention'],
+  ],
+  '5.3.1': [
+    ['Springs insecurely attached to the chassis or axle', 'fail'],
+    ['Spring component damaged or cracked', 'fail'],
+    ['Spring missing or broken', 'dangerous'],
+    ['Stabiliser bar or link worn or insecure', 'fail'],
+  ],
+  '5.3.2': [
+    ['Shock absorbers insecure', 'attention'],
+    ['Shock absorbers insecure with a risk of detachment', 'fail'],
+    ['Shock absorber damaged, showing signs of severe leakage or malfunction', 'fail'],
+    ['Shock absorber missing', 'fail'],
+  ],
+  '5.3.4': [
+    ['Suspension arm, rod or joint excessively worn', 'fail'],
+    ['Joint with excessive play, seriously affecting stability', 'dangerous'],
+    ['Rubber bush perished, split or missing', 'fail'],
+    ['Dust cover split or missing', 'attention'],
+    ['Component insecure or badly corroded', 'fail'],
+  ],
+
+  // 6. Chassis and chassis attachments
+  '6.1.1': [
+    ['Slight damage to a member or crossmember', 'attention'],
+    ['Serious damage to a member or crossmember', 'fail'],
+    ['Insecurity of a member or crossmember', 'fail'],
+    ['Excessive corrosion affecting the rigidity of the assembly', 'fail'],
+    ['Corrosion perforating a structural or load-bearing member', 'dangerous'],
+    ['Unsafe repair or modification to the structure', 'dangerous'],
+  ],
+  '6.1.2': [
+    ['Exhaust system insecure or leaking', 'fail'],
+    ['Fumes entering the cab or passenger compartment', 'fail'],
+    ['Fumes entering the cab in a quantity dangerous to the health of the occupants', 'dangerous'],
+    ['Exhaust silencer defective, missing or bypassed', 'fail'],
+  ],
+  '6.1.3': [
+    ['Fuel tank or pipes insecure', 'fail'],
+    ['Fuel leaking or filler cap missing or ineffective', 'fail'],
+    ['Fuel leaking with a risk of fire', 'dangerous'],
+    ['Fuel pipes chafing or damaged', 'fail'],
+    ['Heat shield missing or ineffective', 'fail'],
+  ],
+  '6.1.6': [['Spare wheel carrier insecure or in an unsatisfactory condition', 'attention']],
+  '6.1.7': [
+    ['Coupling device damaged, defective or cracked', 'fail'],
+    ['Coupling device excessively worn', 'fail'],
+    ['Coupling device defective to the point of risking detachment', 'dangerous'],
+    ['Safety device missing, damaged or not functioning', 'fail'],
+  ],
+  '6.2.1': [
+    ['Panel or component loose or damaged, likely to cause injury', 'fail'],
+    ['Body pillar insecure', 'fail'],
+    ['Ingress of engine or exhaust fumes', 'dangerous'],
+    ['Corrosion in an area that could injure an occupant or another road user', 'fail'],
+    ['Sharp edge or protrusion likely to cause injury', 'fail'],
+  ],
+  '6.2.3': [
+    ['Door or hatch does not open or close properly', 'fail'],
+    ['Door or hatch liable to open unintentionally or fails to stay closed', 'dangerous'],
+    ['Door, hinge, catch or pillar deteriorated', 'attention'],
+  ],
+  '6.2.4': [
+    ['Floor insecure or badly deteriorated', 'fail'],
+    ['Floor insufficiently secure to be safe to stand on', 'dangerous'],
+  ],
+  '6.2.5': [
+    ['Seat with a defective structure', 'fail'],
+    ['Seat insecurely mounted', 'dangerous'],
+    ['Seat adjustment mechanism not functioning correctly', 'fail'],
+  ],
+  '6.2.7': [
+    ['Step or foot rest insecure', 'attention'],
+    ['Step insecure to the point of risking injury', 'fail'],
+  ],
+  '6.2.11': [
+    ['Mudguard missing, loose or badly corroded', 'attention'],
+    ['Insufficient clearance to the wheel or spray suppression ineffective', 'attention'],
+  ],
+
+  // 7. Other equipment
+  '7.1': [
+    ['Belt anchorage badly deteriorated', 'fail'],
+    ['Anchorage deteriorated to the point of affecting stability', 'dangerous'],
+    ['Belt damaged, with a cut or sign of overstretching', 'fail'],
+    ['Belt frayed or dirty but serviceable', 'attention'],
+    ['Belt retractor or buckle not operating correctly', 'fail'],
+    ['Belt missing where one is required', 'fail'],
+    ['Airbag or SRS warning lamp indicates a system failure', 'fail'],
+  ],
+  '7.2': [
+    ['Fire extinguisher missing where required', 'attention'],
+    ['Fire extinguisher out of service date or discharged', 'attention'],
+    ['Fire extinguisher not securely mounted', 'attention'],
+  ],
+  '7.3': [
+    ['Device not functioning to prevent the vehicle being driven away', 'attention'],
+    ['Device defective or locking unintentionally', 'fail'],
+  ],
+  '7.4': [['Warning triangle missing or incomplete', 'attention']],
+  '7.5': [['First aid kit missing, incomplete or out of date', 'attention']],
+  '7.7': [
+    ['Audible warning device not working at all', 'fail'],
+    ['Control insecure or device sounding intermittently', 'attention'],
+    ['Tone not compliant or clearly inadequate', 'attention'],
+  ],
+  '7.8': [
+    ['Speedometer not fitted where required', 'fail'],
+    ['Speedometer not functioning', 'attention'],
+    ['Speedometer not illuminated', 'attention'],
+  ],
+  '7.9': [
+    ['Tachograph missing where required', 'fail'],
+    ['Tachograph not functioning or seals broken', 'fail'],
+    ['Tachograph calibration plate missing, illegible or out of date', 'fail'],
+    ['Obvious evidence of tampering or manipulation', 'fail'],
+  ],
+  '7.10': [
+    ['Speed limitation device missing where required', 'fail'],
+    ['Speed limitation device evidently not functioning', 'fail'],
+    ['Speed limitation device set to the wrong speed or seals missing', 'fail'],
+  ],
+  '7.11': [
+    ['Odometer obviously manipulated to misrepresent the mileage', 'fail'],
+    ['Odometer obviously not functioning', 'attention'],
+  ],
+  '7.12': [
+    ['ESC wheel speed sensor missing or damaged', 'fail'],
+    ['ESC wiring damaged', 'fail'],
+    ['ESC warning device indicates a malfunction', 'fail'],
+    ['ESC system inoperative', 'fail'],
+  ],
+
+  // 8. Nuisance
+  '8.1': [
+    ['Noise level exceeding the permitted level', 'fail'],
+    ['Part of the noise suppression system loose, damaged, missing or obviously modified', 'fail'],
+    ['Part of the noise suppression system likely to increase the noise level', 'dangerous'],
+  ],
+  '8.2.1.2': [
+    ['CO content exceeding the permitted level', 'fail'],
+    ['Lambda outside the range 1 ± 0.03 or not to the manufacturer specification', 'fail'],
+    ['Emission control equipment missing, modified or obviously defective', 'fail'],
+    ['Engine management indicating a serious malfunction', 'fail'],
+  ],
+  '8.2.2.1': [
+    ['Emission control equipment missing or obviously defective', 'fail'],
+    ['Diesel particulate filter obviously modified or removed', 'fail'],
+    ['EGR or SCR system obviously defective or bypassed', 'fail'],
+  ],
+  '8.2.2.2': [
+    ['Smoke opacity exceeding the level recorded on the manufacturer plate', 'fail'],
+    ['Excessive smoke obscuring the view of other road users', 'dangerous'],
+    ['Smoke opacity exceeding the applicable limit', 'fail'],
+  ],
+  '8.5': [
+    ['Excessive fluid leak likely to harm the environment or endanger other road users', 'fail'],
+    ['Constant formation of drops presenting a very serious risk', 'dangerous'],
+    ['Seepage or weeping without dripping', 'attention'],
+  ],
+}
+
+/* -------------------------------------------------------------------------- */
+/* Section-level fallbacks, for codes without an entry of their own           */
+/* -------------------------------------------------------------------------- */
+
+const BY_SECTION: Record<string, CatalogueEntry[]> = {
+  '1': [
+    ['Component excessively worn', 'fail'],
+    ['Component insecure or inadequately mounted', 'fail'],
+    ['Leak from the braking system', 'fail'],
+    ['Braking performance reduced', 'fail'],
+  ],
+  '2': [
+    ['Excessive play in the steering', 'fail'],
+    ['Component worn, insecure or damaged', 'fail'],
+    ['Leak from the steering system', 'attention'],
+  ],
+  '3': [
+    ['Visibility impaired', 'fail'],
+    ['Component defective, missing or ineffective', 'attention'],
+  ],
+  '4': [
+    ['Lamp not operating', 'fail'],
+    ['Lamp defective, damaged or insecure', 'attention'],
+    ['Colour of emitted light not compliant', 'fail'],
+  ],
+  '5': [
+    ['Component excessively worn', 'fail'],
+    ['Component insecure or damaged', 'fail'],
+    ['Excessive play', 'fail'],
+  ],
+  '6': [
+    ['Corrosion affecting the structure', 'fail'],
+    ['Component insecure, loose or damaged', 'fail'],
+    ['Leak from the system', 'fail'],
+  ],
+  '7': [
+    ['Equipment missing where required', 'fail'],
+    ['Equipment defective or not functioning', 'fail'],
+    ['Equipment out of service date', 'attention'],
+  ],
+  '8': [
+    ['Emission or noise level exceeding the permitted limit', 'fail'],
+    ['Control equipment missing, modified or defective', 'fail'],
+    ['Fluid leak', 'attention'],
+  ],
+}
+
+/* -------------------------------------------------------------------------- */
+/* Keyword fallbacks, for checklists that carry no regulation codes           */
+/* -------------------------------------------------------------------------- */
+
+const BY_KEYWORD: { match: RegExp; suggestions: CatalogueEntry[] }[] = [
+  {
+    match: /\b(pad|disc|drum|brake|caliper)\b/i,
+    suggestions: [
+      ['Worn close to the minimum thickness, replacement due soon', 'attention'],
+      ['Worn below the minimum thickness', 'fail'],
+      ['Scored, cracked or corroded', 'fail'],
+      ['Contaminated with oil or grease', 'fail'],
+      ['Seized or binding', 'fail'],
+    ],
+  },
+  {
+    match: /\b(tyre|tire|tread|wheel)\b/i,
+    suggestions: [
+      ['Tread approaching the legal minimum', 'attention'],
+      ['Tread below the legal minimum', 'fail'],
+      ['Uneven or edge wear', 'attention'],
+      ['Sidewall damage, bulge or cut', 'dangerous'],
+      ['Perished with age or cracking', 'attention'],
+      ['Pressure incorrect', 'attention'],
+    ],
+  },
+  {
+    match: /\b(oil|coolant|fluid|leak|grease)\b/i,
+    suggestions: [
+      ['Level low, topped up', 'attention'],
+      ['Seepage, no dripping', 'attention'],
+      ['Active leak, dripping', 'fail'],
+      ['Fluid contaminated or overdue for replacement', 'attention'],
+    ],
+  },
+  {
+    match: /\b(lamp|light|bulb|headlamp|indicator|beam)\b/i,
+    suggestions: [
+      ['Bulb blown', 'fail'],
+      ['Lens cracked, clouded or discoloured', 'attention'],
+      ['Aim incorrect', 'attention'],
+      ['Insecure or water ingress', 'attention'],
+    ],
+  },
+  {
+    match: /\b(belt|hose|pipe)\b/i,
+    suggestions: [
+      ['Perished, cracked or glazed', 'attention'],
+      ['Chafing against an adjacent component', 'fail'],
+      ['Split or leaking', 'fail'],
+      ['Tension incorrect', 'attention'],
+    ],
+  },
+  {
+    match: /\b(battery|charge|voltage|alternator)\b/i,
+    suggestions: [
+      ['State of charge low', 'attention'],
+      ['Terminals corroded or loose', 'attention'],
+      ['Fails a load test, replacement due', 'fail'],
+      ['Casing damaged or leaking', 'fail'],
+    ],
+  },
+  {
+    match: /\b(suspension|shock|damper|spring|bush|joint|bearing)\b/i,
+    suggestions: [
+      ['Excessive play', 'fail'],
+      ['Bush perished or split', 'fail'],
+      ['Leaking or ineffective', 'fail'],
+      ['Corroded or insecure', 'fail'],
+      ['Noisy under load', 'attention'],
+    ],
+  },
+  {
+    match: /\b(exhaust|emission|smoke|silencer|dpf|catalyst)\b/i,
+    suggestions: [
+      ['Blowing at a joint', 'fail'],
+      ['Corroded, repair due soon', 'attention'],
+      ['Mounting perished or insecure', 'attention'],
+      ['Emissions above the limit', 'fail'],
+    ],
+  },
+  {
+    match: /\b(wiper|washer|screen|windscreen|glass|mirror)\b/i,
+    suggestions: [
+      ['Blade smearing or juddering', 'attention'],
+      ['Chip or crack outside the swept area', 'attention'],
+      ['Chip or crack in the driver vision area', 'fail'],
+      ['Washer jet blocked or misaligned', 'attention'],
+    ],
+  },
+  {
+    match: /\b(filter|air|cabin|pollen)\b/i,
+    suggestions: [
+      ['Dirty, replacement recommended', 'attention'],
+      ['Heavily contaminated, replacement due', 'fail'],
+    ],
+  },
+]
+
+/** Always offered, so there is a phrase to hand even for an unusual check. */
+const GENERAL: CatalogueEntry[] = [
+  ['Worn, within limits, monitor at next service', 'attention'],
+  ['Worn beyond the serviceable limit', 'fail'],
+  ['Damaged', 'fail'],
+  ['Corroded', 'attention'],
+  ['Insecure or loose', 'fail'],
+  ['Leaking', 'fail'],
+  ['Missing', 'fail'],
+  ['Not working', 'fail'],
+  ['Excessive play', 'fail'],
+  ['Advisory only, no action needed yet', 'attention'],
+]
+
+/* -------------------------------------------------------------------------- */
+/* Lookup                                                                     */
+/* -------------------------------------------------------------------------- */
+
+export interface SuggestionCheck {
+  name: string
+  code?: string | null
+  sectionCode?: string | null
+  /** Phrases the workshop configured on this check in the template builder. */
+  defectSuggestions?: string[] | null
+}
+
+const normalise = (text: string) => text.trim().toLowerCase().replace(/\s+/g, ' ')
+
+/** Catalogue entries for a check, most specific source first. */
+function catalogueFor(check: SuggestionCheck): DefectSuggestion[] {
+  const found: DefectSuggestion[] = []
+
+  const code = check.code?.trim()
+  if (code) {
+    if (BY_CODE[code]) found.push(...entries('regulation', BY_CODE[code]))
+
+    // "1.1.13" has no entry of its own → try "1.1", then "1". A template can
+    // reference a check at whatever depth suits it and still get useful text.
+    const parts = code.split('.')
+    for (let i = parts.length - 1; i > 0 && found.length === 0; i--) {
+      const parent = parts.slice(0, i).join('.')
+      if (BY_CODE[parent]) found.push(...entries('regulation', BY_CODE[parent]))
+    }
+  }
+
+  const section = (check.sectionCode ?? code ?? '').split('.')[0]
+  if (BY_SECTION[section]) found.push(...entries('regulation', BY_SECTION[section]))
+
+  for (const { match, suggestions } of BY_KEYWORD) {
+    if (match.test(check.name)) found.push(...entries('general', suggestions))
+  }
+
+  found.push(...entries('general', GENERAL))
+  return found
+}
+
+/**
+ * Suggestions for one check, best first.
+ *
+ * Order is deliberate: what the workshop configured, then what it has actually
+ * written before, then the regulation, then general phrasing. A shop that has
+ * settled on its own wording sees that wording at the front, and never has to
+ * scroll past a Directive quotation to find it.
+ *
+ * `preferred` floats phrases matching the grade the technician just picked to
+ * the top without hiding the others, so the list still shows that the same
+ * fault can be minor or dangerous depending on how far gone it is.
+ */
+export function rankSuggestions(
+  check: SuggestionCheck,
+  {
+    scale = 'eu',
+    history = [],
+    preferred,
+  }: {
+    scale?: SeverityScale
+    /** Phrases this organization has used before on this check, most used first. */
+    history?: { text: string; severity: DefectSeverity }[]
+    preferred?: Condition
+  } = {}
+): DefectSuggestion[] {
+  const workshop = (check.defectSuggestions ?? [])
+    .map((text) => text.trim())
+    .filter(Boolean)
+    // A configured phrase has no severity of its own; it takes the grade the
+    // technician already chose, defaulting to a major defect.
+    .map<DefectSuggestion>((text) => ({
+      text,
+      severity: isDefectSeverity(preferred) ? preferred : 'fail',
+      source: 'workshop',
+    }))
+
+  const past = history.map<DefectSuggestion>((h) => ({
+    text: h.text,
+    severity: h.severity,
+    source: 'history',
+  }))
+
+  const all = [...workshop, ...past, ...catalogueFor(check)]
+
+  const seen = new Set<string>()
+  const deduped = all.filter((s) => {
+    const key = normalise(s.text)
+    if (seen.has(key)) return false
+    seen.add(key)
+    return true
+  })
+
+  // The dangerous category only exists on the EU scale.
+  const scoped = scale === 'basic' ? deduped.filter((s) => s.severity !== 'dangerous') : deduped
+
+  if (!isDefectSeverity(preferred)) return scoped
+  return [
+    ...scoped.filter((s) => s.severity === preferred),
+    ...scoped.filter((s) => s.severity !== preferred),
+  ]
+}
+
+export function isDefectSeverity(value: unknown): value is DefectSeverity {
+  return value === 'attention' || value === 'fail' || value === 'dangerous'
+}

+ 99 - 0
src/features/inspections/Lib/inspectionPhotos.ts

@@ -0,0 +1,99 @@
+import "server-only";
+
+import { readFile } from "fs/promises";
+import sharp from "sharp";
+import { resolveUploadPath } from "@/lib/resolve-upload-path";
+import { isDefect } from "./conditions";
+
+/**
+ * Loads inspection photos as data URIs for the PDF certificate.
+ *
+ * @react-pdf/renderer cannot fetch a protected URL, so every photo has to be
+ * embedded. Embedding them at full size is not an option either: a full Annex I
+ * sheet can carry a photo per check, and phone cameras produce several megabytes
+ * apiece, which would make a certificate nobody can email. Each one is therefore
+ * re-encoded down to something that still reads clearly on an A4 page.
+ */
+
+const MAX_WIDTH = 900;
+const JPEG_QUALITY = 72;
+/** Photos embedded per check. */
+const PER_ITEM_LIMIT = 3;
+/** Photos embedded per certificate, across all checks. */
+const DOCUMENT_LIMIT = 40;
+
+const isVideo = (url: string) => /\.(mp4|webm|mov)$/i.test(url);
+
+export interface InspectionPhoto {
+  dataUri: string;
+}
+
+interface PhotoSourceItem {
+  id: string;
+  condition: string;
+  imageUrls: string[];
+  sortOrder: number;
+}
+
+async function loadPhoto(url: string): Promise<InspectionPhoto | null> {
+  try {
+    const buffer = await readFile(resolveUploadPath(url));
+    const resized = await sharp(buffer)
+      .rotate() // honour the EXIF orientation before it is stripped
+      .resize({ width: MAX_WIDTH, withoutEnlargement: true })
+      .jpeg({ quality: JPEG_QUALITY })
+      .toBuffer();
+    return { dataUri: `data:image/jpeg;base64,${resized.toString("base64")}` };
+  } catch {
+    // A missing or unreadable file must not take the whole certificate down.
+    return null;
+  }
+}
+
+/**
+ * Photos keyed by inspection item id.
+ *
+ * When there are more photos than the document budget allows, defects are
+ * loaded first: a photo of a dangerous defect is evidence, whereas a photo of a
+ * check that passed is a nicety. Whatever is dropped is reported in `omitted`
+ * so the certificate can say so rather than quietly appearing complete.
+ */
+export async function loadInspectionPhotos(
+  items: PhotoSourceItem[]
+): Promise<{ photos: Record<string, InspectionPhoto[]>; omitted: number }> {
+  const candidates = items
+    .filter((item) => item.condition !== "not_inspected")
+    .map((item) => ({
+      item,
+      urls: (item.imageUrls ?? []).filter((url) => !isVideo(url)),
+    }))
+    .filter((entry) => entry.urls.length > 0)
+    .sort((a, b) => {
+      const aDefect = isDefect(a.item.condition) ? 0 : 1;
+      const bDefect = isDefect(b.item.condition) ? 0 : 1;
+      return aDefect - bDefect || a.item.sortOrder - b.item.sortOrder;
+    });
+
+  const photos: Record<string, InspectionPhoto[]> = {};
+  let budget = DOCUMENT_LIMIT;
+  let omitted = 0;
+
+  for (const { item, urls } of candidates) {
+    const wanted = urls.slice(0, PER_ITEM_LIMIT);
+    omitted += urls.length - wanted.length;
+
+    const affordable = wanted.slice(0, Math.max(budget, 0));
+    omitted += wanted.length - affordable.length;
+    if (affordable.length === 0) continue;
+
+    const loaded = (await Promise.all(affordable.map(loadPhoto))).filter(
+      (photo): photo is InspectionPhoto => photo !== null
+    );
+    if (loaded.length > 0) {
+      photos[item.id] = loaded;
+      budget -= loaded.length;
+    }
+  }
+
+  return { photos, omitted };
+}

+ 774 - 0
src/features/inspections/Lib/templatePresets.ts

@@ -0,0 +1,774 @@
+import type { Condition, InputType, SeverityScale } from './conditions'
+
+/**
+ * Starting points for the template builder.
+ *
+ * A preset is only ever a starting point: it is copied into an editable
+ * template the moment the user picks it, so a workshop can bend any of these to
+ * its own country, its own equipment and its own house checklist. Nothing here
+ * is enforced at runtime.
+ *
+ * The limit values in the EU presets are the common Union-wide figures from
+ * Directive 2014/45/EU Annex I; several member states test to stricter numbers,
+ * which is exactly why every threshold is an editable field on the check rather
+ * than a constant in the code.
+ */
+
+export interface PresetItem {
+  name: string
+  code?: string
+  description?: string
+  inputType?: InputType
+  unit?: string
+  minValue?: number
+  maxValue?: number
+  choices?: string[]
+  required?: boolean
+  photoRequired?: boolean
+  defaultSeverity?: Extract<Condition, 'attention' | 'fail' | 'dangerous'>
+}
+
+export interface PresetSection {
+  name: string
+  code?: string
+  description?: string
+  items: PresetItem[]
+}
+
+export interface TemplatePreset {
+  id: string
+  name: string
+  description: string
+  /** ISO-3166 alpha-2, or null when the preset is not tied to one country. */
+  country: string | null
+  /** Regime the checklist follows; shown as a badge in the gallery. */
+  standard: string
+  standardLabel: string
+  severityScale: SeverityScale
+  /** Used to group the gallery. */
+  group: 'regulatory' | 'workshop' | 'specialist'
+  sections: PresetSection[]
+}
+
+/* -------------------------------------------------------------------------- */
+/* Directive 2014/45/EU — Annex I categories 0 to 8                           */
+/* -------------------------------------------------------------------------- */
+
+const EU_IDENTIFICATION: PresetSection = {
+  name: 'Identification of the vehicle',
+  code: '0',
+  description: 'Annex I, item 0. Confirms the vehicle presented matches its papers.',
+  items: [
+    { name: 'Registration plates', code: '0.1', required: true },
+    {
+      name: 'Vehicle identification / chassis number',
+      code: '0.2',
+      description: 'VIN present, legible and matching the registration document.',
+      required: true,
+    },
+  ],
+}
+
+const EU_BRAKING: PresetSection = {
+  name: 'Braking equipment',
+  code: '1',
+  description: 'Annex I, item 1. Mechanical condition, then measured performance.',
+  items: [
+    { name: 'Service brake pedal / hand lever pivot', code: '1.1.1' },
+    { name: 'Pedal / lever condition and travel', code: '1.1.2' },
+    { name: 'Vacuum pump or compressor and reservoirs', code: '1.1.3' },
+    { name: 'Low pressure warning gauge or indicator', code: '1.1.4' },
+    { name: 'Parking brake actuator, lever and ratchet', code: '1.1.6' },
+    { name: 'Brake valves', code: '1.1.7' },
+    { name: 'Brake servo unit and master cylinder', code: '1.1.10' },
+    { name: 'Rigid brake pipes', code: '1.1.11', photoRequired: false },
+    { name: 'Flexible brake hoses', code: '1.1.12' },
+    {
+      name: 'Brake linings and pads',
+      code: '1.1.13',
+      description: 'Remaining friction material at the thinnest point.',
+      inputType: 'measurement',
+      unit: 'mm',
+      minValue: 3,
+      defaultSeverity: 'fail',
+    },
+    { name: 'Brake drums and discs', code: '1.1.14' },
+    { name: 'Brake cables, rods, levers and linkages', code: '1.1.15' },
+    { name: 'Brake actuators', code: '1.1.16' },
+    { name: 'Load sensing valve', code: '1.1.17' },
+    { name: 'Complete braking system', code: '1.1.21' },
+    {
+      name: 'Service brake efficiency',
+      code: '1.2.2',
+      description: 'Braking rate as a percentage of the maximum authorised mass. Set the figure your national test uses.',
+      inputType: 'measurement',
+      unit: '%',
+      minValue: 50,
+      required: true,
+      defaultSeverity: 'fail',
+    },
+    {
+      name: 'Service brake imbalance across an axle',
+      code: '1.2.1',
+      description: 'Difference between the braking forces on the two wheels of an axle.',
+      inputType: 'measurement',
+      unit: '%',
+      maxValue: 30,
+      defaultSeverity: 'fail',
+    },
+    {
+      name: 'Parking brake efficiency',
+      code: '1.4.2',
+      inputType: 'measurement',
+      unit: '%',
+      minValue: 16,
+      required: true,
+      defaultSeverity: 'fail',
+    },
+    { name: 'Anti-lock braking system (ABS)', code: '1.6' },
+    { name: 'Electronic braking system (EBS)', code: '1.7' },
+    { name: 'Brake fluid', code: '1.8', description: 'Contamination and boiling point.' },
+  ],
+}
+
+const EU_STEERING: PresetSection = {
+  name: 'Steering',
+  code: '2',
+  description: 'Annex I, item 2.',
+  items: [
+    { name: 'Mechanical condition of the steering', code: '2.1' },
+    { name: 'Steering wheel, column and handlebars', code: '2.2' },
+    {
+      name: 'Steering play',
+      code: '2.3',
+      description: 'Free movement at the rim before the road wheels respond.',
+      inputType: 'measurement',
+      unit: 'mm',
+      maxValue: 120,
+      defaultSeverity: 'fail',
+    },
+    { name: 'Wheel alignment', code: '2.4' },
+    { name: 'Power steering', code: '2.6' },
+  ],
+}
+
+const EU_VISIBILITY: PresetSection = {
+  name: 'Visibility',
+  code: '3',
+  description: 'Annex I, item 3.',
+  items: [
+    { name: 'Field of vision', code: '3.1' },
+    { name: 'Condition of the glass', code: '3.2', description: 'Chips and cracks in the swept area of the windscreen.' },
+    { name: 'Rear-view mirrors or devices', code: '3.3' },
+    { name: 'Windscreen wipers', code: '3.4' },
+    { name: 'Windscreen washers', code: '3.5' },
+    { name: 'Demisting and defrosting system', code: '3.6' },
+  ],
+}
+
+const EU_LIGHTING: PresetSection = {
+  name: 'Lamps, reflectors and electrical equipment',
+  code: '4',
+  description: 'Annex I, item 4.',
+  items: [
+    { name: 'Headlamps — condition and operation', code: '4.1.1' },
+    {
+      name: 'Headlamp aim',
+      code: '4.1.2',
+      description: 'Downward inclination of the dipped beam.',
+      inputType: 'measurement',
+      unit: '%',
+      minValue: -2.5,
+      maxValue: -0.5,
+      defaultSeverity: 'attention',
+    },
+    { name: 'Position, side marker and daytime running lamps', code: '4.2' },
+    { name: 'Stop lamps', code: '4.3', required: true },
+    { name: 'Direction indicator and hazard warning lamps', code: '4.4' },
+    { name: 'Front and rear fog lamps', code: '4.5' },
+    { name: 'Reversing lamps', code: '4.6' },
+    { name: 'Rear registration plate lamp', code: '4.7' },
+    { name: 'Retro reflectors and conspicuity markings', code: '4.8' },
+    { name: 'Tell-tales', code: '4.9' },
+    { name: 'Electrical connections to a trailer', code: '4.10' },
+    { name: 'Electrical wiring', code: '4.11' },
+    { name: 'Battery', code: '4.13' },
+  ],
+}
+
+const EU_RUNNING_GEAR: PresetSection = {
+  name: 'Axles, wheels, tyres and suspension',
+  code: '5',
+  description: 'Annex I, item 5. Tread depth is recorded per wheel.',
+  items: [
+    { name: 'Axles', code: '5.1.1' },
+    { name: 'Stub axles and wheel bearings', code: '5.1.3' },
+    { name: 'Wheels', code: '5.2.1' },
+    { name: 'Tyre condition — sidewalls, load and speed rating', code: '5.2.3' },
+    {
+      name: 'Tread depth — front left',
+      code: '5.2.3',
+      inputType: 'measurement',
+      unit: 'mm',
+      minValue: 1.6,
+      required: true,
+      defaultSeverity: 'fail',
+    },
+    {
+      name: 'Tread depth — front right',
+      code: '5.2.3',
+      inputType: 'measurement',
+      unit: 'mm',
+      minValue: 1.6,
+      required: true,
+      defaultSeverity: 'fail',
+    },
+    {
+      name: 'Tread depth — rear left',
+      code: '5.2.3',
+      inputType: 'measurement',
+      unit: 'mm',
+      minValue: 1.6,
+      required: true,
+      defaultSeverity: 'fail',
+    },
+    {
+      name: 'Tread depth — rear right',
+      code: '5.2.3',
+      inputType: 'measurement',
+      unit: 'mm',
+      minValue: 1.6,
+      required: true,
+      defaultSeverity: 'fail',
+    },
+    { name: 'Springs and stabiliser', code: '5.3.1' },
+    { name: 'Shock absorbers', code: '5.3.2' },
+    { name: 'Suspension arms, rods and joints', code: '5.3.4' },
+  ],
+}
+
+const EU_CHASSIS: PresetSection = {
+  name: 'Chassis and chassis attachments',
+  code: '6',
+  description: 'Annex I, item 6.',
+  items: [
+    { name: 'Chassis or frame and attachments', code: '6.1.1' },
+    { name: 'Exhaust pipes and silencer', code: '6.1.2' },
+    { name: 'Fuel tank and pipes', code: '6.1.3' },
+    { name: 'Spare wheel carrier', code: '6.1.6' },
+    { name: 'Coupling device and towing equipment', code: '6.1.7' },
+    { name: 'Cab and bodywork condition', code: '6.2.1' },
+    { name: 'Doors and door catches', code: '6.2.3' },
+    { name: 'Floor', code: '6.2.4' },
+    { name: 'Driver seat and seats', code: '6.2.5' },
+    { name: 'Steps', code: '6.2.7' },
+    { name: 'Mudguards and spray suppression', code: '6.2.11' },
+  ],
+}
+
+const EU_OTHER_EQUIPMENT: PresetSection = {
+  name: 'Other equipment',
+  code: '7',
+  description: 'Annex I, item 7. Some checks apply only to certain vehicle categories.',
+  items: [
+    { name: 'Safety belts, buckles and restraint systems', code: '7.1', required: true },
+    { name: 'Fire extinguisher', code: '7.2' },
+    { name: 'Locks and anti-theft device', code: '7.3' },
+    { name: 'Warning triangle', code: '7.4' },
+    { name: 'First aid kit', code: '7.5' },
+    { name: 'Audible warning device', code: '7.7' },
+    { name: 'Speedometer', code: '7.8' },
+    { name: 'Tachograph', code: '7.9' },
+    { name: 'Speed limitation device', code: '7.10' },
+    {
+      name: 'Odometer reading',
+      code: '7.11',
+      description: 'Reading at the time of the test. Annex IV requires this on the certificate.',
+      inputType: 'measurement',
+      unit: 'km',
+      required: true,
+    },
+    { name: 'Electronic stability control (ESC)', code: '7.12' },
+  ],
+}
+
+const EU_NUISANCE: PresetSection = {
+  name: 'Nuisance',
+  code: '8',
+  description: 'Annex I, item 8. Emissions and noise. Fill in whichever row matches the fuel.',
+  items: [
+    { name: 'Noise suppression system', code: '8.1' },
+    {
+      name: 'Petrol — CO at idle',
+      code: '8.2.1.2',
+      inputType: 'measurement',
+      unit: '%',
+      maxValue: 0.5,
+      defaultSeverity: 'fail',
+    },
+    {
+      name: 'Petrol — lambda at high idle',
+      code: '8.2.1.2',
+      inputType: 'measurement',
+      minValue: 0.97,
+      maxValue: 1.03,
+      defaultSeverity: 'fail',
+    },
+    {
+      name: 'Diesel — smoke opacity',
+      code: '8.2.2.2',
+      description: 'Absorption coefficient from a free acceleration test.',
+      inputType: 'measurement',
+      unit: 'm⁻¹',
+      maxValue: 1.5,
+      defaultSeverity: 'fail',
+    },
+    { name: 'Exhaust after-treatment system', code: '8.2.2.1', description: 'DPF, EGR and SCR present and unmodified.' },
+    { name: 'Fluid leaks', code: '8.5', defaultSeverity: 'attention' },
+  ],
+}
+
+const EU_SECTIONS: PresetSection[] = [
+  EU_IDENTIFICATION,
+  EU_BRAKING,
+  EU_STEERING,
+  EU_VISIBILITY,
+  EU_LIGHTING,
+  EU_RUNNING_GEAR,
+  EU_CHASSIS,
+  EU_OTHER_EQUIPMENT,
+  EU_NUISANCE,
+]
+
+/** Drops checks that make no sense on a two- or three-wheeler. */
+const MOTORCYCLE_EXCLUDED_CODES = new Set([
+  '1.1.17',
+  '3.1',
+  '3.4',
+  '3.5',
+  '3.6',
+  '5.2.3-rear-extra',
+  '6.2.3',
+  '6.2.4',
+  '6.2.7',
+  '7.1',
+  '7.9',
+  '7.10',
+  '7.12',
+])
+
+const MOTORCYCLE_SECTIONS: PresetSection[] = EU_SECTIONS.map((section) => ({
+  ...section,
+  items: section.items
+    .filter((item) => !(item.code && MOTORCYCLE_EXCLUDED_CODES.has(item.code)))
+    .filter((item) => !item.name.startsWith('Tread depth — rear right'))
+    .filter((item) => !item.name.startsWith('Tread depth — front right'))
+    .map((item) =>
+      item.name === 'Tread depth — front left'
+        ? { ...item, name: 'Tread depth — front tyre', minValue: 1 }
+        : item.name === 'Tread depth — rear left'
+          ? { ...item, name: 'Tread depth — rear tyre', minValue: 1 }
+          : item
+    ),
+})).filter((section) => section.items.length > 0)
+
+/* -------------------------------------------------------------------------- */
+/* Non-regulatory workshop checklists                                         */
+/* -------------------------------------------------------------------------- */
+
+const STANDARD_MULTIPOINT: PresetSection[] = [
+  {
+    name: 'Exterior',
+    items: [
+      { name: 'Body condition' },
+      { name: 'Paint' },
+      { name: 'Lights' },
+      { name: 'Windscreen' },
+      { name: 'Wipers' },
+      { name: 'Mirrors' },
+    ],
+  },
+  {
+    name: 'Under hood',
+    items: [
+      { name: 'Engine oil level and condition' },
+      { name: 'Coolant', inputType: 'measurement', unit: '°C', maxValue: -20, defaultSeverity: 'attention' },
+      { name: 'Brake fluid' },
+      { name: 'Power steering fluid' },
+      { name: 'Battery voltage', inputType: 'measurement', unit: 'V', minValue: 12.4, defaultSeverity: 'attention' },
+      { name: 'Drive belts' },
+      { name: 'Hoses' },
+      { name: 'Air filter' },
+      { name: 'Cabin filter' },
+    ],
+  },
+  {
+    name: 'Under vehicle',
+    items: [
+      { name: 'Exhaust system' },
+      { name: 'Suspension' },
+      { name: 'CV joints and boots' },
+      { name: 'Brake lines' },
+      { name: 'Fluid leaks' },
+    ],
+  },
+  {
+    name: 'Brakes',
+    items: [
+      { name: 'Front pads', inputType: 'measurement', unit: 'mm', minValue: 3, defaultSeverity: 'fail' },
+      { name: 'Rear pads', inputType: 'measurement', unit: 'mm', minValue: 3, defaultSeverity: 'fail' },
+      { name: 'Discs' },
+      { name: 'Parking brake' },
+    ],
+  },
+  {
+    name: 'Tyres',
+    items: [
+      { name: 'Tread depth — front left', inputType: 'measurement', unit: 'mm', minValue: 1.6, defaultSeverity: 'fail' },
+      { name: 'Tread depth — front right', inputType: 'measurement', unit: 'mm', minValue: 1.6, defaultSeverity: 'fail' },
+      { name: 'Tread depth — rear left', inputType: 'measurement', unit: 'mm', minValue: 1.6, defaultSeverity: 'fail' },
+      { name: 'Tread depth — rear right', inputType: 'measurement', unit: 'mm', minValue: 1.6, defaultSeverity: 'fail' },
+      { name: 'Tyre pressure', inputType: 'measurement', unit: 'bar', minValue: 1.8, maxValue: 3.5, defaultSeverity: 'attention' },
+      { name: 'Spare tyre or repair kit' },
+    ],
+  },
+  {
+    name: 'Interior',
+    items: [
+      { name: 'Warning lights' },
+      { name: 'Horn' },
+      { name: 'Air conditioning' },
+      { name: 'Heater' },
+      { name: 'Seat belts' },
+    ],
+  },
+]
+
+const PRE_PURCHASE: PresetSection[] = [
+  {
+    name: 'Documentation and history',
+    items: [
+      { name: 'Registration document matches the vehicle', required: true },
+      { name: 'VIN', inputType: 'text', required: true },
+      { name: 'Service history', inputType: 'choice', choices: ['Full', 'Partial', 'None'] },
+      { name: 'Outstanding recalls' },
+      { name: 'Previous accident damage' },
+    ],
+  },
+  {
+    name: 'Body and paint',
+    items: [
+      { name: 'Panel gaps' },
+      { name: 'Paint thickness — worst panel', inputType: 'measurement', unit: 'µm', minValue: 80, maxValue: 200, defaultSeverity: 'attention' },
+      { name: 'Corrosion', photoRequired: true },
+      { name: 'Glass and lights' },
+    ],
+  },
+  {
+    name: 'Mechanical',
+    items: [
+      { name: 'Cold start' },
+      { name: 'Engine noise' },
+      { name: 'Compression — lowest cylinder', inputType: 'measurement', unit: 'bar', minValue: 10, defaultSeverity: 'fail' },
+      { name: 'Gearbox and clutch' },
+      { name: 'Fault codes on the diagnostic port', inputType: 'text' },
+      { name: 'Road test' },
+    ],
+  },
+  {
+    name: 'Wear items',
+    items: [
+      { name: 'Brake pads and discs' },
+      { name: 'Tyres including date codes' },
+      { name: 'Suspension bushes' },
+      { name: 'Exhaust' },
+    ],
+  },
+]
+
+const EV_HYBRID: PresetSection[] = [
+  {
+    name: 'High-voltage safety',
+    description: 'Carry out with the high-voltage system isolated and the vehicle immobilised.',
+    items: [
+      { name: 'Service disconnect and isolation procedure followed', required: true },
+      { name: 'Orange high-voltage cabling — chafing or damage', photoRequired: true },
+      { name: 'High-voltage connector locks and interlocks' },
+      { name: 'Insulation resistance', inputType: 'measurement', unit: 'MΩ', minValue: 1, defaultSeverity: 'dangerous' },
+      { name: 'Warning labels present and legible' },
+    ],
+  },
+  {
+    name: 'Traction battery',
+    items: [
+      { name: 'State of health', inputType: 'measurement', unit: '%', minValue: 70, defaultSeverity: 'attention' },
+      { name: 'Cell voltage spread', inputType: 'measurement', unit: 'mV', maxValue: 100, defaultSeverity: 'attention' },
+      { name: 'Battery enclosure and underbody protection', photoRequired: true },
+      { name: 'Coolant level and condition' },
+      { name: 'Stored battery fault codes', inputType: 'text' },
+    ],
+  },
+  {
+    name: 'Charging',
+    items: [
+      { name: 'Charge port and flap' },
+      { name: 'AC charge session', inputType: 'measurement', unit: 'kW' },
+      { name: 'DC charge session', inputType: 'measurement', unit: 'kW' },
+      { name: 'Charging cable and plug condition' },
+      { name: 'Residual current device operation' },
+    ],
+  },
+  {
+    name: 'Drivetrain and conventional items',
+    items: [
+      { name: 'Drive motor noise' },
+      { name: 'Reduction gear oil' },
+      { name: 'Regenerative braking operation' },
+      { name: 'Friction brakes — corrosion from low use', description: 'Regenerative braking leaves discs lightly used; check for scoring and rust.' },
+      { name: '12 V auxiliary battery', inputType: 'measurement', unit: 'V', minValue: 12.4, defaultSeverity: 'attention' },
+      { name: 'Acoustic vehicle alerting system (AVAS)', description: 'Mandatory on EU-approved electric vehicles.' },
+    ],
+  },
+]
+
+const MARINE: PresetSection[] = [
+  {
+    name: 'Hull and structure',
+    items: [
+      { name: 'Hull below the waterline', photoRequired: true },
+      { name: 'Keel and skeg' },
+      { name: 'Through-hull fittings and seacocks' },
+      { name: 'Anodes', inputType: 'measurement', unit: '%', minValue: 50, defaultSeverity: 'attention' },
+      { name: 'Rudder and bearings' },
+      { name: 'Deck and fittings' },
+    ],
+  },
+  {
+    name: 'Propulsion',
+    items: [
+      { name: 'Engine hours', inputType: 'measurement', unit: 'h', required: true },
+      { name: 'Engine oil' },
+      { name: 'Gearbox oil' },
+      { name: 'Cooling system and impeller' },
+      { name: 'Fuel system and filters' },
+      { name: 'Propeller and shaft' },
+      { name: 'Stern gland' },
+      { name: 'Exhaust and water lock' },
+    ],
+  },
+  {
+    name: 'Electrical',
+    items: [
+      { name: 'Starter battery', inputType: 'measurement', unit: 'V', minValue: 12.4, defaultSeverity: 'attention' },
+      { name: 'Service battery bank' },
+      { name: 'Shore power and galvanic isolator' },
+      { name: 'Navigation lights' },
+      { name: 'Bilge pumps and float switches', required: true },
+    ],
+  },
+  {
+    name: 'Safety equipment',
+    description: 'Required equipment varies by flag state and by area of operation.',
+    items: [
+      { name: 'Life jackets — quantity and service date', inputType: 'measurement', unit: 'pcs', required: true },
+      { name: 'Life raft service date', inputType: 'text' },
+      { name: 'Fire extinguishers — service date', required: true },
+      { name: 'Flares in date' },
+      { name: 'VHF radio and DSC' },
+      { name: 'EPIRB registration and battery date', inputType: 'text' },
+      { name: 'Gas system and locker' },
+    ],
+  },
+]
+
+/* -------------------------------------------------------------------------- */
+/* The gallery                                                                */
+/* -------------------------------------------------------------------------- */
+
+export const TEMPLATE_PRESETS: TemplatePreset[] = [
+  {
+    id: 'eu-roadworthiness',
+    name: 'EU periodic technical inspection',
+    description:
+      'The full Annex I checklist from Directive 2014/45/EU, graded on the minor / major / dangerous defect scale. Thresholds are pre-filled with the common Union figures — adjust them to your national test.',
+    country: null,
+    standard: 'eu-2014-45',
+    standardLabel: 'Directive 2014/45/EU',
+    severityScale: 'eu',
+    group: 'regulatory',
+    sections: EU_SECTIONS,
+  },
+  {
+    id: 'eu-roadworthiness-motorcycle',
+    name: 'EU inspection — motorcycles (L category)',
+    description:
+      'Annex I reduced to the checks that apply to two- and three-wheelers, with tread limits set for L-category vehicles.',
+    country: null,
+    standard: 'eu-2014-45-l',
+    standardLabel: 'Directive 2014/45/EU, L category',
+    severityScale: 'eu',
+    group: 'regulatory',
+    sections: MOTORCYCLE_SECTIONS,
+  },
+  {
+    id: 'no-eu-kontroll',
+    name: 'Norway — EU-kontroll',
+    description:
+      'Annex I with the Norwegian periodic control in mind. Check the current Statens vegvesen limits before issuing certificates.',
+    country: 'NO',
+    standard: 'eu-2014-45',
+    standardLabel: 'EU-kontroll',
+    severityScale: 'eu',
+    group: 'regulatory',
+    sections: EU_SECTIONS,
+  },
+  {
+    id: 'de-hauptuntersuchung',
+    name: 'Germany — Hauptuntersuchung',
+    description:
+      'Annex I arranged for the German main inspection under §29 StVZO. Confirm the current HU-Richtlinie limits before issuing certificates.',
+    country: 'DE',
+    standard: 'eu-2014-45',
+    standardLabel: 'Hauptuntersuchung (§29 StVZO)',
+    severityScale: 'eu',
+    group: 'regulatory',
+    sections: EU_SECTIONS,
+  },
+  {
+    id: 'nl-apk',
+    name: 'Netherlands — APK',
+    description:
+      'Annex I arranged for the Dutch APK. Confirm the current RDW requirements before issuing certificates.',
+    country: 'NL',
+    standard: 'eu-2014-45',
+    standardLabel: 'APK',
+    severityScale: 'eu',
+    group: 'regulatory',
+    sections: EU_SECTIONS,
+  },
+  {
+    id: 'standard-multipoint',
+    name: 'Standard multi-point inspection',
+    description:
+      'A general workshop health check covering the major systems. Graded pass / attention / fail rather than on the EU defect scale.',
+    country: null,
+    standard: 'custom',
+    standardLabel: 'Workshop checklist',
+    severityScale: 'basic',
+    group: 'workshop',
+    sections: STANDARD_MULTIPOINT,
+  },
+  {
+    id: 'pre-purchase',
+    name: 'Pre-purchase inspection',
+    description:
+      'Buyer-facing report covering documentation, body, mechanicals and wear items, with room for measurements and photos.',
+    country: null,
+    standard: 'custom',
+    standardLabel: 'Workshop checklist',
+    severityScale: 'basic',
+    group: 'workshop',
+    sections: PRE_PURCHASE,
+  },
+  {
+    id: 'ev-hybrid',
+    name: 'Electric and hybrid vehicle check',
+    description:
+      'High-voltage safety, traction battery health and charging, plus the conventional items that behave differently on an electrified drivetrain.',
+    country: null,
+    standard: 'custom',
+    standardLabel: 'Specialist checklist',
+    severityScale: 'eu',
+    group: 'specialist',
+    sections: EV_HYBRID,
+  },
+  {
+    id: 'marine',
+    name: 'Marine vessel inspection',
+    description:
+      'Hull, propulsion, electrical and safety equipment for a boat, with engine hours in place of an odometer reading.',
+    country: null,
+    standard: 'custom',
+    standardLabel: 'Specialist checklist',
+    severityScale: 'basic',
+    group: 'specialist',
+    sections: MARINE,
+  },
+  {
+    id: 'blank',
+    name: 'Blank template',
+    description: 'Start from nothing and build the checklist your workshop actually uses.',
+    country: null,
+    standard: 'custom',
+    standardLabel: 'Empty',
+    severityScale: 'basic',
+    group: 'workshop',
+    sections: [{ name: 'New section', items: [{ name: 'New check' }] }],
+  },
+]
+
+export const PRESET_GROUPS: { key: TemplatePreset['group']; label: string; description: string }[] = [
+  {
+    key: 'regulatory',
+    label: 'Regulatory',
+    description: 'Periodic technical inspection checklists built on Directive 2014/45/EU.',
+  },
+  {
+    key: 'workshop',
+    label: 'Workshop',
+    description: 'General service and sales checklists that are not tied to a regulation.',
+  },
+  {
+    key: 'specialist',
+    label: 'Specialist',
+    description: 'Checklists for drivetrains and vessels that need their own procedure.',
+  },
+]
+
+export function getPreset(id: string): TemplatePreset | undefined {
+  return TEMPLATE_PRESETS.find((p) => p.id === id)
+}
+
+export function countPresetItems(preset: TemplatePreset): number {
+  return preset.sections.reduce((sum, s) => sum + s.items.length, 0)
+}
+
+/**
+ * The countries offered in the template settings. `null` covers a checklist
+ * that is not tied to one country; the list is not exhaustive and the field
+ * accepts anything, so a workshop outside it is not blocked.
+ */
+export const TEMPLATE_COUNTRIES: { code: string; name: string }[] = [
+  { code: 'AT', name: 'Austria' },
+  { code: 'BE', name: 'Belgium' },
+  { code: 'BG', name: 'Bulgaria' },
+  { code: 'HR', name: 'Croatia' },
+  { code: 'CY', name: 'Cyprus' },
+  { code: 'CZ', name: 'Czechia' },
+  { code: 'DK', name: 'Denmark' },
+  { code: 'EE', name: 'Estonia' },
+  { code: 'FI', name: 'Finland' },
+  { code: 'FR', name: 'France' },
+  { code: 'DE', name: 'Germany' },
+  { code: 'GR', name: 'Greece' },
+  { code: 'HU', name: 'Hungary' },
+  { code: 'IS', name: 'Iceland' },
+  { code: 'IE', name: 'Ireland' },
+  { code: 'IT', name: 'Italy' },
+  { code: 'LV', name: 'Latvia' },
+  { code: 'LI', name: 'Liechtenstein' },
+  { code: 'LT', name: 'Lithuania' },
+  { code: 'LU', name: 'Luxembourg' },
+  { code: 'MT', name: 'Malta' },
+  { code: 'NL', name: 'Netherlands' },
+  { code: 'NO', name: 'Norway' },
+  { code: 'PL', name: 'Poland' },
+  { code: 'PT', name: 'Portugal' },
+  { code: 'RO', name: 'Romania' },
+  { code: 'SK', name: 'Slovakia' },
+  { code: 'SI', name: 'Slovenia' },
+  { code: 'ES', name: 'Spain' },
+  { code: 'SE', name: 'Sweden' },
+  { code: 'CH', name: 'Switzerland' },
+  { code: 'GB', name: 'United Kingdom' },
+]

+ 42 - 0
src/features/inspections/Lib/testIntervals.ts

@@ -0,0 +1,42 @@
+import { addMonths, addYears } from 'date-fns'
+
+/**
+ * Intervals to the next roadworthiness test.
+ *
+ * Two years is the Union baseline for M1 and N1 vehicles under Article 5 of
+ * Directive 2014/45/EU, so it is what the certificate proposes. Several member
+ * states test annually and some apply a first-test interval of four years,
+ * which is why the others sit one tap away and the date itself stays editable.
+ */
+export const DEFAULT_INTERVAL_MONTHS = 24
+
+export const TEST_INTERVALS: { months: number; label: string }[] = [
+  { months: 6, label: '6 months' },
+  { months: 12, label: '1 year' },
+  { months: 24, label: '2 years' },
+  { months: 36, label: '3 years' },
+  { months: 48, label: '4 years' },
+]
+
+/** Local-time ISO date (yyyy-mm-dd), which is what the date input speaks. */
+export function toISODate(value: Date | string | null | undefined): string {
+  if (!value) return ''
+  const date = value instanceof Date ? value : new Date(value)
+  if (Number.isNaN(date.getTime())) return ''
+  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
+    date.getDate()
+  ).padStart(2, '0')}`
+}
+
+/**
+ * Adds an interval to the date of the test. Whole years go through addYears so
+ * a test on 29 February lands on 28 February rather than drifting a day.
+ */
+export function addInterval(from: Date, months: number): string {
+  return toISODate(months % 12 === 0 ? addYears(from, months / 12) : addMonths(from, months))
+}
+
+/** The interval a stored date corresponds to, or undefined if it is bespoke. */
+export function matchInterval(from: Date, isoDate: string) {
+  return TEST_INTERVALS.find((interval) => addInterval(from, interval.months) === isoDate)
+}

+ 21 - 1
src/features/inspections/Schema/inspectionSchema.ts

@@ -7,10 +7,30 @@ export const createInspectionSchema = z.object({
 });
 
 export const updateInspectionItemSchema = z.object({
-  condition: z.enum(["pass", "fail", "attention", "not_inspected"]).default("not_inspected"),
+  // "attention" is the EU minor category and "fail" the major one; see
+  // Lib/conditions.ts for why the stored names differ from the EU wording.
+  condition: z
+    .enum(["pass", "fail", "attention", "dangerous", "not_inspected"])
+    .default("not_inspected"),
   notes: z.string().optional(),
   imageUrls: z.array(z.string()).optional(),
+  measuredValue: z.number().nullable().optional(),
+  textValue: z.string().nullable().optional(),
+});
+
+/** Roadworthiness certificate fields — Directive 2014/45/EU Annex IV. */
+export const updateInspectionDetailsSchema = z.object({
+  mileage: z.coerce.number().int().min(0).nullable().optional(),
+  vehicleCategory: z.string().max(10).nullable().optional(),
+  certificateNumber: z.string().max(64).nullable().optional(),
+  // The inspector is picked from the workshop's technicians; the name is
+  // snapshotted from the chosen record rather than sent by the client.
+  technicianId: z.string().nullable().optional(),
+  testLocation: z.string().max(200).nullable().optional(),
+  nextTestDue: z.coerce.date().nullable().optional(),
+  notes: z.string().max(5000).nullable().optional(),
 });
 
 export type CreateInspectionInput = z.infer<typeof createInspectionSchema>;
 export type UpdateInspectionItemInput = z.infer<typeof updateInspectionItemSchema>;
+export type UpdateInspectionDetailsInput = z.infer<typeof updateInspectionDetailsSchema>;

+ 23 - 0
src/features/inspections/Schema/templateSchema.ts

@@ -1,12 +1,31 @@
 import { z } from "zod";
 
+/**
+ * A check on a template. `inputType` decides which of the other fields matter:
+ * `measurement` uses unit/min/max/defaultSeverity, `choice` uses choices, and
+ * `condition`/`text` use none of them. The extra fields are kept regardless so
+ * switching type back and forth in the builder does not lose what was typed.
+ */
 export const templateItemSchema = z.object({
   name: z.string().min(1, "Item name is required"),
+  description: z.string().optional(),
+  code: z.string().optional(),
   sortOrder: z.number().int().min(0).default(0),
+  inputType: z.enum(["condition", "measurement", "text", "choice"]).default("condition"),
+  unit: z.string().optional(),
+  minValue: z.number().nullable().optional(),
+  maxValue: z.number().nullable().optional(),
+  choices: z.array(z.string()).default([]),
+  required: z.boolean().default(false),
+  photoRequired: z.boolean().default(false),
+  defaultSeverity: z.enum(["attention", "fail", "dangerous"]).nullable().optional(),
+  defectSuggestions: z.array(z.string()).default([]),
 });
 
 export const templateSectionSchema = z.object({
   name: z.string().min(1, "Section name is required"),
+  description: z.string().optional(),
+  code: z.string().optional(),
   sortOrder: z.number().int().min(0).default(0),
   items: z.array(templateItemSchema).min(1, "Section must have at least one item"),
 });
@@ -15,6 +34,10 @@ export const createTemplateSchema = z.object({
   name: z.string().min(1, "Template name is required"),
   description: z.string().optional(),
   isDefault: z.boolean().default(false),
+  // ISO-3166 alpha-2, or null when the checklist is not tied to one country.
+  country: z.string().length(2).nullable().optional(),
+  standard: z.string().optional(),
+  severityScale: z.enum(["eu", "basic"]).default("eu"),
   sections: z.array(templateSectionSchema).min(1, "Template must have at least one section"),
 });
 

+ 3 - 0
src/features/settings/Actions/accountActions.ts

@@ -5,6 +5,7 @@ import { withAuth } from "@/lib/with-auth";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { z } from "zod";
 import crypto from "crypto";
+import { demoGuard } from "@/lib/demo";
 
 const updateEmailSchema = z.object({
   email: z.string().email("Invalid email address"),
@@ -12,6 +13,7 @@ const updateEmailSchema = z.object({
 
 export async function updateEmail(data: { email: string }) {
   return withAuth(async ({ userId }) => {
+    demoGuard();
     const parsed = updateEmailSchema.parse(data);
 
     const existing = await db.user.findFirst({
@@ -45,6 +47,7 @@ export async function updateEmail(data: { email: string }) {
  */
 export async function requestEmailChange(data: { email: string }) {
   return withAuth(async ({ userId }) => {
+    demoGuard();
     const parsed = updateEmailSchema.parse(data);
 
     const existing = await db.user.findFirst({

+ 2 - 0
src/features/settings/Actions/deleteAccount.ts

@@ -3,9 +3,11 @@
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { deleteUserOrganizations } from '@/lib/delete-user-data'
+import { demoGuard } from '@/lib/demo'
 
 export async function deleteAccount() {
   return withAuth(async ({ userId }) => {
+    demoGuard()
     // Get user email to clean up invitations
     const user = await db.user.findUnique({
       where: { id: userId },

+ 2 - 0
src/features/settings/Actions/deleteContent.ts

@@ -6,6 +6,7 @@ import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { unlink } from "fs/promises";
 import { revalidatePath } from "next/cache";
 import { z } from "zod";
+import { demoGuard } from "@/lib/demo";
 
 const deleteContentSchema = z.object({
   vehicles: z.boolean().default(false),
@@ -22,6 +23,7 @@ const deleteContentSchema = z.object({
 
 export async function deleteContent(input: unknown) {
   return withAuth(async ({ organizationId }) => {
+    demoGuard()
     if (!organizationId) {
       throw new Error("No organization found");
     }

+ 3 - 0
src/features/settings/Actions/settingsActions.ts

@@ -5,6 +5,7 @@ import { withAuth } from "@/lib/with-auth";
 import { revalidatePath } from "next/cache";
 import type { SettingKey } from "../Schema/settingsSchema";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { demoGuardSettingKey } from "@/lib/demo";
 
 export async function getSetting(key: SettingKey) {
   return withAuth(async ({ userId, organizationId }) => {
@@ -33,6 +34,7 @@ export async function getSettings(keys?: SettingKey[]) {
 
 export async function setSetting(key: SettingKey, value: string) {
   return withAuth(async ({ userId, organizationId }) => {
+    demoGuardSettingKey(key);
     const setting = await db.appSetting.upsert({
       where: { organizationId_key: { organizationId, key } },
       update: { value },
@@ -45,6 +47,7 @@ export async function setSetting(key: SettingKey, value: string) {
 
 export async function setSettings(entries: Record<string, string>) {
   return withAuth(async ({ userId, organizationId }) => {
+    for (const key of Object.keys(entries)) demoGuardSettingKey(key);
     await db.$transaction(
       Object.entries(entries).map(([key, value]) =>
         db.appSetting.upsert({

+ 3 - 1
src/features/settings/Components/license-settings.tsx

@@ -16,10 +16,12 @@ export function LicenseSettings({
   initialKey,
   initialValid,
   initialCheckedAt,
+  demoMode = false,
 }: {
   initialKey: string
   initialValid: boolean
   initialCheckedAt: string
+  demoMode?: boolean
 }) {
   const router = useRouter()
   const t = useTranslations('settings')
@@ -60,7 +62,7 @@ export function LicenseSettings({
               {t('license.description')}
             </CardDescription>
           </div>
-          {!licenseValid && (
+          {!licenseValid && !demoMode && (
             <Button asChild variant="default" size="sm" className="shrink-0">
               <a
                 href={`${process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'}/subscriptions/white-label`}

+ 3 - 0
src/features/sms/Actions/smsActions.ts

@@ -7,6 +7,7 @@ import { requireFeature } from '@/lib/features'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SMS_TEMPLATE_DEFAULTS } from '@/lib/sms-templates'
+import { demoGuard } from '@/lib/demo'
 
 export async function sendSmsToCustomer(input: {
   customerId: string
@@ -16,6 +17,8 @@ export async function sendSmsToCustomer(input: {
 }) {
   return withAuth(
     async ({ organizationId }) => {
+      demoGuard()
+      await requireFeature(organizationId, 'sms')
       await requireFeature(organizationId, 'sms')
 
       const customer = await db.customer.findFirst({

+ 3 - 0
src/features/sms/Actions/smsSettingsActions.ts

@@ -7,6 +7,7 @@ import { revalidatePath } from 'next/cache'
 import { ALL_ORG_SMS_KEYS, ORG_SMS_KEYS } from '../Schema/smsSettingsSchema'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { sendOrgSms } from '@/lib/sms'
+import { demoGuard } from '@/lib/demo'
 
 export async function getSmsSettings() {
   return withAuth(
@@ -29,6 +30,7 @@ export async function getSmsSettings() {
 export async function setSmsSettings(entries: Record<string, string>) {
   return withAuth(
     async ({ userId, organizationId }) => {
+      demoGuard()
       // Auto-generate webhook secret if not already set
       const existing = await db.appSetting.findUnique({
         where: {
@@ -69,6 +71,7 @@ export async function setSmsSettings(entries: Record<string, string>) {
 export async function testSmsSend(testPhone: string) {
   return withAuth(
     async ({ organizationId }) => {
+      demoGuard()
       if (!testPhone?.trim()) {
         throw new Error('Please enter a phone number to send the test SMS to')
       }

+ 3 - 0
src/features/subscription/Actions/subscriptionActions.ts

@@ -3,9 +3,11 @@
 import { withAuth } from "@/lib/with-auth";
 import { db } from "@/lib/db";
 import { getStripeClient } from "@/lib/stripe-config";
+import { demoGuard } from "@/lib/demo";
 
 export async function cancelSubscription() {
   return withAuth(async ({ organizationId }) => {
+    demoGuard();
     const subscription = await db.subscription.findUnique({
       where: { organizationId },
     });
@@ -37,6 +39,7 @@ export async function cancelSubscription() {
 
 export async function resumeSubscription() {
   return withAuth(async ({ organizationId }) => {
+    demoGuard();
     const subscription = await db.subscription.findUnique({
       where: { organizationId },
     });

+ 4 - 0
src/features/team/Actions/acceptInvitation.ts

@@ -4,8 +4,12 @@ import { cookies } from "next/headers";
 import { db } from "@/lib/db";
 import { getCachedSession } from "@/lib/cached-session";
 import { acceptInvitationSchema } from "../Schema/teamSchema";
+import { isDemoMode } from "@/lib/demo";
 
 export async function acceptInvitation(input: unknown) {
+  if (isDemoMode) {
+    return { success: false, error: "This action is disabled on the demo." };
+  }
   try {
     const data = acceptInvitationSchema.parse(input);
 

+ 4 - 0
src/features/team/Actions/createNewOrganization.ts

@@ -4,11 +4,15 @@ import { cookies } from "next/headers";
 import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
 import { isCloudMode, getMaxOrganizations } from "@/lib/features";
+import { demoGuard } from "@/lib/demo";
 import { createOrganizationSchema } from "../Schema/teamSchema";
 import { revalidatePath } from "next/cache";
 
 export async function createNewOrganization(input: unknown) {
   return withAuth(async ({ userId }) => {
+    // A visitor-created org survives the per-org demo reset and hijacks the
+    // shared demo user's active-org cookie for every later visitor.
+    demoGuard();
     const data = createOrganizationSchema.parse(input);
 
     if (isCloudMode()) {

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