Răsfoiți Sursa

Rebuild the vehicle page, flag booking clashes on it, and redesign the edit vehicle dialog (#580)

* Redesign the vehicle page: photo hero, service timeline and side column

Count lifetime spend over every work order instead of the latest ten.

* Rebuild the vehicle page as a record and activity feed, flag booking clashes, and redesign the edit dialog

The new vehicle page: a fixed side panel (photo, real plate, copyable VIN and
contacts, open items) and one scrolling feed of work orders, quotes,
inspections and notes, with a Now card for jobs in the workshop. Existing
workshops keep the classic page with an invite to try the new one; both can
switch back from the menu.

Open jobs whose booking clashes (technician or helper away, double-booked,
workshop closed) are flagged on the page with the work order's own wording.
The edit vehicle dialog is regrouped into a summary strip and Identification
and Vehicle cards, with in-place mileage editing and phone upload for the
vehicle photo. Skeletons sit only on leaf routes and use a wave shimmer.

* Address review: keep phone photos, give the feed every note, check clashes past one window

- The edit dialog sends the photo only when it was changed there (a new one
  picked or the photo removed), so a photo taken on a phone meanwhile is not
  overwritten; a removed photo now actually saves as none.
- The phone handoff reads the vehicle's photo before showing the code, so a
  photo that lands before the first check is still noticed.
- The new page's feed gets every note (up to 250), not the notes table's page.
- Booking clashes are read in as many windows as the jobs need, and a job
  longer than a window is checked in pieces.
- The fuel and gearbox choices keep their field ids; the marine e2e spec
  drives them as the button rows they now are.
Bernt Christian Egeland 1 zi în urmă
părinte
comite
3db224188e
99 a modificat fișierele cu 9374 adăugiri și 1731 ștergeri
  1. 13 15
      e2e/specs/settings/marine.spec.ts
  2. 15 0
      messages/de/service.json
  3. 69 2
      messages/de/vehicles.json
  4. 15 0
      messages/en/service.json
  5. 69 2
      messages/en/vehicles.json
  6. 15 0
      messages/es/service.json
  7. 69 2
      messages/es/vehicles.json
  8. 15 0
      messages/fr/service.json
  9. 69 2
      messages/fr/vehicles.json
  10. 15 0
      messages/it/service.json
  11. 69 2
      messages/it/vehicles.json
  12. 15 0
      messages/lt/service.json
  13. 69 2
      messages/lt/vehicles.json
  14. 15 0
      messages/nb/service.json
  15. 69 2
      messages/nb/vehicles.json
  16. 15 0
      messages/nl/service.json
  17. 69 2
      messages/nl/vehicles.json
  18. 15 0
      messages/pl/service.json
  19. 69 2
      messages/pl/vehicles.json
  20. 15 0
      messages/pt-BR/service.json
  21. 69 2
      messages/pt-BR/vehicles.json
  22. 15 0
      messages/pt-PT/service.json
  23. 69 2
      messages/pt-PT/vehicles.json
  24. 15 0
      messages/ru/service.json
  25. 69 2
      messages/ru/vehicles.json
  26. 15 0
      messages/tr/service.json
  27. 69 2
      messages/tr/vehicles.json
  28. 131 0
      src/__tests__/api/vehicle-handoff.test.ts
  29. 2 2
      src/__tests__/app/vehicles/vehicle-detail-dropped-connection.test.tsx
  30. 173 0
      src/__tests__/app/vehicles/vehicle-job-warnings-feed.test.tsx
  31. 41 0
      src/__tests__/components/feature-hint-card.test.tsx
  32. 187 0
      src/__tests__/features/vehicles/get-vehicle-job-warnings.test.ts
  33. 228 0
      src/__tests__/features/vehicles/vehicle-form-dialog.test.tsx
  34. 265 0
      src/__tests__/features/vehicles/vehicle-job-warnings.test.ts
  35. 76 0
      src/__tests__/features/vehicles/vehicle-photo-handoff-baseline.test.tsx
  36. 117 0
      src/__tests__/features/vehicles/vehicle-photo-save.test.ts
  37. 54 0
      src/__tests__/lib/vehicle-handoff-token.test.ts
  38. 29 0
      src/__tests__/lib/vehicle-page-layout.test.ts
  39. 2 2
      src/__tests__/lib/viewer-access.test.ts
  40. 9 0
      src/__tests__/multitenancy/vehicle-service-isolation.test.ts
  41. 0 0
      src/app/(authenticated)/(dashboard)/dashboard-client.tsx
  42. 0 0
      src/app/(authenticated)/(dashboard)/loading.tsx
  43. 0 0
      src/app/(authenticated)/(dashboard)/page.tsx
  44. 0 0
      src/app/(authenticated)/billing/(overview)/loading.tsx
  45. 1 1
      src/app/(authenticated)/billing/(overview)/page.tsx
  46. 83 0
      src/app/(authenticated)/billing/recurring/loading.tsx
  47. 0 0
      src/app/(authenticated)/customers/(list)/loading.tsx
  48. 1 1
      src/app/(authenticated)/customers/(list)/page.tsx
  49. 1 0
      src/app/(authenticated)/customers/[id]/customer-detail-client.tsx
  50. 83 0
      src/app/(authenticated)/customers/[id]/loading.tsx
  51. 0 0
      src/app/(authenticated)/inventory/(list)/loading.tsx
  52. 1 1
      src/app/(authenticated)/inventory/(list)/page.tsx
  53. 98 0
      src/app/(authenticated)/inventory/[id]/loading.tsx
  54. 0 0
      src/app/(authenticated)/quotes/(list)/loading.tsx
  55. 1 1
      src/app/(authenticated)/quotes/(list)/page.tsx
  56. 142 0
      src/app/(authenticated)/quotes/[id]/loading.tsx
  57. 0 0
      src/app/(authenticated)/vehicles/(list)/loading.tsx
  58. 1 1
      src/app/(authenticated)/vehicles/(list)/page.tsx
  59. 62 0
      src/app/(authenticated)/vehicles/[id]/(detail)/loading.tsx
  60. 74 26
      src/app/(authenticated)/vehicles/[id]/(detail)/page.tsx
  61. 0 0
      src/app/(authenticated)/vehicles/[id]/_classic/service-records-table.tsx
  62. 1887 0
      src/app/(authenticated)/vehicles/[id]/_classic/vehicle-detail-classic.tsx
  63. 185 0
      src/app/(authenticated)/vehicles/[id]/job-warning.tsx
  64. 0 37
      src/app/(authenticated)/vehicles/[id]/loading.tsx
  65. 85 0
      src/app/(authenticated)/vehicles/[id]/service/[serviceId]/loading.tsx
  66. 2 0
      src/app/(authenticated)/vehicles/[id]/service/new/loading.tsx
  67. 605 0
      src/app/(authenticated)/vehicles/[id]/vehicle-activity-feed.tsx
  68. 1432 1137
      src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx
  69. 37 0
      src/app/(public)/p/[token]/page.tsx
  70. 202 0
      src/app/(public)/p/[token]/vehicle-handoff-client.tsx
  71. 88 0
      src/app/api/public/vehicle-handoff/[token]/route.ts
  72. 42 0
      src/app/globals.css
  73. 128 0
      src/components/copy-value.tsx
  74. 14 1
      src/components/feature-hint/feature-hint.tsx
  75. 107 0
      src/components/plate-chip.tsx
  76. 66 0
      src/components/status-lamp.tsx
  77. 2 7
      src/components/ui/skeleton.tsx
  78. 1 3
      src/features/fault-codes/Components/FaultCodeChip.tsx
  79. 27 4
      src/features/quotes/Components/CustomerCombobox.tsx
  80. 115 0
      src/features/vehicles/Actions/getVehicleJobWarnings.ts
  81. 52 1
      src/features/vehicles/Actions/photoHandoffActions.ts
  82. 17 1
      src/features/vehicles/Actions/vehicleActions.ts
  83. 4 4
      src/features/vehicles/Components/PlateLookupCommand.tsx
  84. 17 4
      src/features/vehicles/Components/ScanDocumentButton.tsx
  85. 606 308
      src/features/vehicles/Components/VehicleForm.tsx
  86. 6 1
      src/features/vehicles/Components/VehicleLookupButton.tsx
  87. 122 0
      src/features/vehicles/Components/VehiclePageLayoutSwitch.tsx
  88. 4 20
      src/features/vehicles/Components/service-edit/ScheduleTimesSection.tsx
  89. 107 26
      src/features/vehicles/Components/service-page/PhotoHandoffButton.tsx
  90. 2 42
      src/features/vehicles/Components/service-page/modern/JobFactsCard.tsx
  91. 155 0
      src/features/vehicles/Lib/vehicleJobWarnings.ts
  92. 18 57
      src/features/workboard/Actions/boardActions/availability.ts
  93. 8 2
      src/features/workboard/Lib/bookings.ts
  94. 94 0
      src/features/workboard/Lib/slotCheck.ts
  95. 31 0
      src/features/workshop-calendar/Lib/format.ts
  96. 8 0
      src/lib/analytics.ts
  97. 66 0
      src/lib/photo-handoff.ts
  98. 26 0
      src/lib/vehicle-page-layout.server.ts
  99. 39 0
      src/lib/vehicle-page-layout.ts

+ 13 - 15
e2e/specs/settings/marine.spec.ts

@@ -123,9 +123,9 @@ function label(dialog: Locator, field: string): Locator {
   return dialog.locator(`label[for="${field}"]`)
   return dialog.locator(`label[for="${field}"]`)
 }
 }
 
 
-/** The select drawn after a label. Its trigger carries no id of its own. */
-function selectAfter(dialog: Locator, field: string): Locator {
-  return dialog.locator(`label[for="${field}"] ~ [role="combobox"]`)
+/** A short list of choices drawn as a row of buttons, found by its label. */
+function choices(dialog: Locator, name: string): Locator {
+  return dialog.getByRole('radiogroup', { name })
 }
 }
 
 
 test.describe('a workshop that picks Marine', () => {
 test.describe('a workshop that picks Marine', () => {
@@ -167,17 +167,17 @@ test.describe('a workshop that picks Marine', () => {
     // A periodic roadworthiness inspection is a road vehicle's.
     // A periodic roadworthiness inspection is a road vehicle's.
     await expect(dialog.locator('#inspectionDueAt')).toHaveCount(0)
     await expect(dialog.locator('#inspectionDueAt')).toHaveCount(0)
 
 
-    // Two-stroke instead of electric and hybrid.
-    await selectAfter(dialog, 'fuelType').click()
-    await expect(page.getByRole('option')).toHaveText(['Gasoline', 'Diesel', 'Two-Stroke', 'Other'])
-    await page.getByRole('option', { name: 'Two-Stroke' }).click()
+    // Two-stroke instead of electric and hybrid, every choice in sight.
+    const fuel = choices(dialog, 'Fuel Type')
+    await expect(fuel.getByRole('radio')).toHaveText(['Gasoline', 'Diesel', 'Two-Stroke', 'Other'])
+    await fuel.getByRole('radio', { name: 'Two-Stroke' }).click()
+    await expect(fuel.getByRole('radio', { name: 'Two-Stroke' })).toBeChecked()
 
 
     // Outboard or inboard, outboard unless told otherwise.
     // Outboard or inboard, outboard unless told otherwise.
-    const engineType = selectAfter(dialog, 'transmission')
-    await expect(engineType).toHaveText('Outboard')
-    await engineType.click()
-    await expect(page.getByRole('option')).toHaveText(['Outboard', 'Inboard'])
-    await page.getByRole('option', { name: 'Inboard' }).click()
+    const engineType = choices(dialog, 'Engine Type')
+    await expect(engineType.getByRole('radio')).toHaveText(['Outboard', 'Inboard'])
+    await expect(engineType.getByRole('radio', { name: 'Outboard' })).toBeChecked()
+    await engineType.getByRole('radio', { name: 'Inboard' }).click()
 
 
     await dialog.locator('#make').fill(VESSEL.make)
     await dialog.locator('#make').fill(VESSEL.make)
     await dialog.locator('#model').fill(VESSEL.model)
     await dialog.locator('#model').fill(VESSEL.model)
@@ -306,15 +306,13 @@ test.describe('a workshop that picks Marine', () => {
     await expect(label(dialog, 'transmission')).toHaveText('Transmission')
     await expect(label(dialog, 'transmission')).toHaveText('Transmission')
     await expect(dialog.locator('#inspectionDueAt')).toBeVisible()
     await expect(dialog.locator('#inspectionDueAt')).toBeVisible()
 
 
-    await selectAfter(dialog, 'fuelType').click()
-    await expect(page.getByRole('option')).toHaveText([
+    await expect(choices(dialog, 'Fuel Type').getByRole('radio')).toHaveText([
       'Gasoline',
       'Gasoline',
       'Diesel',
       'Diesel',
       'Electric',
       'Electric',
       'Hybrid',
       'Hybrid',
       'Other',
       'Other',
     ])
     ])
-    await page.keyboard.press('Escape')
     await dialog.getByRole('button', { name: 'Cancel' }).click()
     await dialog.getByRole('button', { name: 'Cancel' }).click()
 
 
     await expect(sidebar(page).getByRole('link', { name: /^Vehicles/ })).toBeVisible()
     await expect(sidebar(page).getByRole('link', { name: /^Vehicles/ })).toBeVisible()

+ 15 - 0
messages/de/service.json

@@ -830,6 +830,21 @@
         "odometer": "Kilometerstand",
         "odometer": "Kilometerstand",
         "other": "Schaden oder Sonstiges"
         "other": "Schaden oder Sonstiges"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Fahrzeugfoto per Handy aufnehmen",
+      "dialogBody": "Ein Foto des Fahrzeugs mit einem beliebigen Handy aufnehmen oder eines auswählen, das schon darauf ist. Es wird zum Foto oben auf dieser Fahrzeugseite.",
+      "step2": "Nehmen Sie ein Foto des ganzen Fahrzeugs auf oder wählen Sie eines vom Handy aus.",
+      "step3": "Es ersetzt das Foto auf der Fahrzeugseite, sobald es ankommt.",
+      "scope": "Der Link kann nur das Foto dieses Fahrzeugs festlegen und funktioniert nach 30 Minuten nicht mehr.",
+      "waiting": "Warten auf das Foto…",
+      "received": "Foto empfangen",
+      "pageTitle": "Foto des Fahrzeugs",
+      "pageBody": "Nehmen Sie ein Foto des ganzen Fahrzeugs auf. Es ersetzt das Foto auf der Fahrzeugseite.",
+      "choosePhoto": "Foto auswählen",
+      "saved": "Gespeichert. Das Foto ist jetzt auf der Fahrzeugseite. Nehmen Sie ein weiteres auf, um es zu ersetzen.",
+      "invalid": "Scannen Sie den Code auf der Fahrzeugseite erneut.",
+      "notAPhoto": "Hier können nur Fotos verwendet werden."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/de/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Mit dem Register abgeglichen am {date}",
     "inspectionCheckedAt": "Mit dem Register abgeglichen am {date}",
     "notRegistered": "Nicht zugelassen",
     "notRegistered": "Nicht zugelassen",
     "soldReported": "Kunde meldet Verkauf am {date}",
     "soldReported": "Kunde meldet Verkauf am {date}",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Foto hinzufügen",
+    "changePhoto": "Foto ändern",
+    "predictedToday": "Heute ca. {value} {unit}",
+    "viewAll": "Alle anzeigen",
+    "owner": "Halter",
+    "phone": "Telefon",
+    "email": "E-Mail",
+    "tyres": "Reifen",
+    "purchasedOn": "Gekauft am",
+    "spent": "Ausgegeben",
+    "spentOver": "{count, plural, =0 {noch keine Besuche} one {in # Besuch} other {in # Besuchen}}",
+    "openItems": "Offen",
+    "addOpenItem": "Hinzufügen",
+    "nothingOpen": "Nichts offen",
+    "observationItem": "Beobachtung · {severity}",
+    "reminderItem": "Erinnerung · {due}",
+    "newMenu": "Neu",
+    "closePanel": "Schließen",
+    "feed": {
+      "all": "Alles",
+      "workOrders": "Arbeitsaufträge",
+      "search": "Verlauf durchsuchen",
+      "now": "Jetzt",
+      "openJob": "Auftrag öffnen",
+      "yearSummary": "{count, plural, one {# Besuch} other {# Besuche}} · {total}",
+      "showOlder": "{count} ältere anzeigen",
+      "empty": "Noch kein Verlauf",
+      "noMatches": "Nichts entspricht Ihrer Suche",
+      "quote": "Angebot",
+      "inspectionOpen": "In Bearbeitung",
+      "inspectionResult": "{passed} bestanden",
+      "inspectionFailed": "{count} mit Handlungsbedarf"
+    },
+    "layout": {
+      "inviteTitle": "Probieren Sie die neue Fahrzeugseite aus.",
+      "inviteBody": "Alles zum Fahrzeug auf einem Bildschirm und der ganze Verlauf auf einer Zeitachse. Sie können jederzeit zurückwechseln.",
+      "tryIt": "Ausprobieren",
+      "hideInvite": "Ausblenden",
+      "tryNewMenu": "Neues Layout ausprobieren",
+      "useClassicMenu": "Zum klassischen Layout wechseln",
+      "backHintTitle": "Hier geht es zurück",
+      "backHintBody": "Über dieses Menü können Sie jederzeit zum klassischen Layout wechseln."
+    },
+    "exportPreparing": "PDF der Servicehistorie wird erstellt…",
+    "exportDone": "Servicehistorie heruntergeladen",
+    "exportFailed": "PDF konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
+    "jobWarning": {
+      "heading": "Diese Buchung muss geprüft werden",
+      "fix": "Zum Beheben den Auftrag öffnen",
+      "away": "{count, plural, one {{names} ist abwesend} other {{names} sind abwesend}}",
+      "clash": "Doppelt gebucht",
+      "closed": "Werkstatt geschlossen",
+      "onJob": "Arbeitsauftrag {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "Neues Angebot",
     "newQuote": "Neues Angebot",
@@ -378,7 +432,20 @@
     "lookupVin": "FIN nachschlagen",
     "lookupVin": "FIN nachschlagen",
     "lookupEnterVin": "Geben Sie zuerst eine FIN ein",
     "lookupEnterVin": "Geben Sie zuerst eine FIN ein",
     "lookupVinNotFound": "Zu dieser FIN wurde kein Fahrzeug gefunden",
     "lookupVinNotFound": "Zu dieser FIN wurde kein Fahrzeug gefunden",
-    "lookupVinFailed": "FIN konnte nicht nachgeschlagen werden"
+    "lookupVinFailed": "FIN konnte nicht nachgeschlagen werden",
+    "sectionIdentification": "Identifikation",
+    "sectionVehicle": "Fahrzeug",
+    "sectionVehicleMarine": "Boot",
+    "lookupShort": "Abfragen",
+    "decodeShort": "Dekodieren",
+    "vinLength": "{count} von {total} Zeichen",
+    "notSet": "Nicht festgelegt",
+    "fromRegistry": "Aus dem Register",
+    "removePhoto": "Foto entfernen",
+    "deleteVehicle": "Fahrzeug löschen",
+    "deleteVehicleMarine": "Boot löschen",
+    "editMileage": "Kilometerstand ändern",
+    "editMileageMarine": "Betriebsstunden ändern"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Fahrzeug archivieren",
     "title": "Fahrzeug archivieren",

+ 15 - 0
messages/en/service.json

@@ -830,6 +830,21 @@
         "odometer": "Odometer",
         "odometer": "Odometer",
         "other": "Damage or other"
         "other": "Damage or other"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Take the vehicle's photo with a phone",
+      "dialogBody": "Take a photo of the car on any phone, or choose one already on it. It becomes the photo at the top of this vehicle's page.",
+      "step2": "Take a photo of the whole car, or choose one from the phone.",
+      "step3": "It replaces the photo on the vehicle's page as soon as it arrives.",
+      "scope": "The link can only set this vehicle's photo, and stops working after 30 minutes.",
+      "waiting": "Waiting for the photo…",
+      "received": "Photo received",
+      "pageTitle": "Photo of the vehicle",
+      "pageBody": "Take a photo of the whole car. It replaces the photo on the vehicle's page.",
+      "choosePhoto": "Choose a photo",
+      "saved": "Saved. The photo is on the vehicle's page now. Take another to replace it.",
+      "invalid": "Scan the code on the vehicle's page again.",
+      "notAPhoto": "Only photos can be used here."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/en/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Checked against the registry {date}",
     "inspectionCheckedAt": "Checked against the registry {date}",
     "notRegistered": "Not registered",
     "notRegistered": "Not registered",
     "soldReported": "Customer reported sold {date}",
     "soldReported": "Customer reported sold {date}",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Add photo",
+    "changePhoto": "Change photo",
+    "predictedToday": "About {value} {unit} today",
+    "viewAll": "View all",
+    "owner": "Owner",
+    "phone": "Phone",
+    "email": "Email",
+    "tyres": "Tyres",
+    "purchasedOn": "Purchased",
+    "spent": "Spent",
+    "spentOver": "over {count, plural, =0 {no visits} one {# visit} other {# visits}}",
+    "openItems": "Open",
+    "addOpenItem": "Add",
+    "nothingOpen": "Nothing open",
+    "observationItem": "Observation · {severity}",
+    "reminderItem": "Reminder · {due}",
+    "newMenu": "New",
+    "closePanel": "Close",
+    "feed": {
+      "all": "Everything",
+      "workOrders": "Work orders",
+      "search": "Search history",
+      "now": "Now",
+      "openJob": "Open job",
+      "yearSummary": "{count, plural, one {# visit} other {# visits}} · {total}",
+      "showOlder": "Show {count} older",
+      "empty": "No history yet",
+      "noMatches": "Nothing matches your search",
+      "quote": "Quote",
+      "inspectionOpen": "In progress",
+      "inspectionResult": "{passed} passed",
+      "inspectionFailed": "{count} need work"
+    },
+    "layout": {
+      "inviteTitle": "Try the new vehicle page.",
+      "inviteBody": "Everything about the car on one screen, and its whole history as one timeline. You can switch back at any time.",
+      "tryIt": "Try it",
+      "hideInvite": "Hide",
+      "tryNewMenu": "Try the new layout",
+      "useClassicMenu": "Switch to the classic layout",
+      "backHintTitle": "The way back is here",
+      "backHintBody": "Switch to the classic layout from this menu at any time."
+    },
+    "exportPreparing": "Preparing the service history PDF…",
+    "exportDone": "Service history downloaded",
+    "exportFailed": "Couldn't create the PDF. Try again.",
+    "jobWarning": {
+      "heading": "This booking needs attention",
+      "fix": "Open the job to fix it",
+      "away": "{count, plural, one {{names} is away} other {{names} are away}}",
+      "clash": "Double-booked",
+      "closed": "Workshop closed",
+      "onJob": "Work order {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "New Quote",
     "newQuote": "New Quote",
@@ -378,7 +432,20 @@
     "lookupVin": "Look up VIN",
     "lookupVin": "Look up VIN",
     "lookupEnterVin": "Type a VIN first",
     "lookupEnterVin": "Type a VIN first",
     "lookupVinNotFound": "No vehicle found for that VIN",
     "lookupVinNotFound": "No vehicle found for that VIN",
-    "lookupVinFailed": "Could not look up the VIN"
+    "lookupVinFailed": "Could not look up the VIN",
+    "sectionIdentification": "Identification",
+    "sectionVehicle": "Vehicle",
+    "sectionVehicleMarine": "Boat",
+    "lookupShort": "Look up",
+    "decodeShort": "Decode",
+    "vinLength": "{count} of {total} characters",
+    "notSet": "Not set",
+    "fromRegistry": "From the registry",
+    "removePhoto": "Remove photo",
+    "deleteVehicle": "Delete vehicle",
+    "deleteVehicleMarine": "Delete boat",
+    "editMileage": "Change mileage",
+    "editMileageMarine": "Change engine hours"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Archive Vehicle",
     "title": "Archive Vehicle",

+ 15 - 0
messages/es/service.json

@@ -830,6 +830,21 @@
         "odometer": "Cuentakilómetros",
         "odometer": "Cuentakilómetros",
         "other": "Daño u otro"
         "other": "Daño u otro"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Hacer la foto del vehículo con un móvil",
+      "dialogBody": "Haz una foto del coche con cualquier móvil, o elige una que ya esté en él. Será la foto de la parte superior de la página de este vehículo.",
+      "step2": "Haz una foto del coche entero, o elige una del móvil.",
+      "step3": "Sustituye la foto de la página del vehículo en cuanto llega.",
+      "scope": "El enlace solo puede establecer la foto de este vehículo y deja de funcionar a los 30 minutos.",
+      "waiting": "Esperando la foto…",
+      "received": "Foto recibida",
+      "pageTitle": "Foto del vehículo",
+      "pageBody": "Haz una foto del coche entero. Sustituye la foto de la página del vehículo.",
+      "choosePhoto": "Elegir una foto",
+      "saved": "Guardada. La foto ya está en la página del vehículo. Haz otra para sustituirla.",
+      "invalid": "Vuelve a escanear el código de la página del vehículo.",
+      "notAPhoto": "Aquí solo se pueden usar fotos."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/es/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Comprobado en el registro el {date}",
     "inspectionCheckedAt": "Comprobado en el registro el {date}",
     "notRegistered": "Sin matricular",
     "notRegistered": "Sin matricular",
     "soldReported": "El cliente indicó venta el {date}",
     "soldReported": "El cliente indicó venta el {date}",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Añadir foto",
+    "changePhoto": "Cambiar foto",
+    "predictedToday": "Unos {value} {unit} hoy",
+    "viewAll": "Ver todo",
+    "owner": "Propietario",
+    "phone": "Teléfono",
+    "email": "Correo electrónico",
+    "tyres": "Neumáticos",
+    "purchasedOn": "Comprado el",
+    "spent": "Gastado",
+    "spentOver": "{count, plural, =0 {sin visitas} one {en # visita} other {en # visitas}}",
+    "openItems": "Pendientes",
+    "addOpenItem": "Agregar",
+    "nothingOpen": "Nada pendiente",
+    "observationItem": "Observación · {severity}",
+    "reminderItem": "Recordatorio · {due}",
+    "newMenu": "Nuevo",
+    "closePanel": "Cerrar",
+    "feed": {
+      "all": "Todo",
+      "workOrders": "Órdenes de trabajo",
+      "search": "Buscar en el historial",
+      "now": "Ahora",
+      "openJob": "Abrir trabajo",
+      "yearSummary": "{count, plural, one {# visita} other {# visitas}} · {total}",
+      "showOlder": "Mostrar {count} anteriores",
+      "empty": "Aún no hay historial",
+      "noMatches": "Nada coincide con su búsqueda",
+      "quote": "Cotización",
+      "inspectionOpen": "En progreso",
+      "inspectionResult": "{passed, plural, one {# apto} other {# aptos}}",
+      "inspectionFailed": "{count, plural, one {# requiere atención} other {# requieren atención}}"
+    },
+    "layout": {
+      "inviteTitle": "Prueba la nueva página del vehículo.",
+      "inviteBody": "Todo sobre el coche en una sola pantalla, y su historial completo en una línea de tiempo. Puedes volver al anterior cuando quieras.",
+      "tryIt": "Probar",
+      "hideInvite": "Ocultar",
+      "tryNewMenu": "Probar el nuevo diseño",
+      "useClassicMenu": "Cambiar al diseño clásico",
+      "backHintTitle": "Puedes volver desde aquí",
+      "backHintBody": "Cambia al diseño clásico desde este menú cuando quieras."
+    },
+    "exportPreparing": "Preparando el PDF del historial de servicio…",
+    "exportDone": "Historial de servicio descargado",
+    "exportFailed": "No se pudo crear el PDF. Inténtelo de nuevo.",
+    "jobWarning": {
+      "heading": "Esta reserva requiere atención",
+      "fix": "Abrir el trabajo para resolverlo",
+      "away": "{count, plural, one {{names} está ausente} other {{names} están ausentes}}",
+      "clash": "Doble reserva",
+      "closed": "Taller cerrado",
+      "onJob": "Orden de trabajo {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "Nueva cotización",
     "newQuote": "Nueva cotización",
@@ -378,7 +432,20 @@
     "lookupVin": "Buscar VIN",
     "lookupVin": "Buscar VIN",
     "lookupEnterVin": "Escribe primero un VIN",
     "lookupEnterVin": "Escribe primero un VIN",
     "lookupVinNotFound": "No se encontró ningún vehículo con ese VIN",
     "lookupVinNotFound": "No se encontró ningún vehículo con ese VIN",
-    "lookupVinFailed": "No se pudo buscar el VIN"
+    "lookupVinFailed": "No se pudo buscar el VIN",
+    "sectionIdentification": "Identificación",
+    "sectionVehicle": "Vehículo",
+    "sectionVehicleMarine": "Embarcación",
+    "lookupShort": "Buscar",
+    "decodeShort": "Decodificar",
+    "vinLength": "{count} de {total} caracteres",
+    "notSet": "Sin definir",
+    "fromRegistry": "Del registro",
+    "removePhoto": "Quitar foto",
+    "deleteVehicle": "Eliminar vehículo",
+    "deleteVehicleMarine": "Eliminar embarcación",
+    "editMileage": "Cambiar kilometraje",
+    "editMileageMarine": "Cambiar horas de motor"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Archivar vehículo",
     "title": "Archivar vehículo",

+ 15 - 0
messages/fr/service.json

@@ -830,6 +830,21 @@
         "odometer": "Compteur",
         "odometer": "Compteur",
         "other": "Dégât ou autre"
         "other": "Dégât ou autre"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Prendre la photo du véhicule avec un téléphone",
+      "dialogBody": "Prenez une photo du véhicule sur n’importe quel téléphone, ou choisissez-en une déjà présente. Elle devient la photo en haut de la page de ce véhicule.",
+      "step2": "Prenez une photo du véhicule en entier, ou choisissez-en une sur le téléphone.",
+      "step3": "Elle remplace la photo sur la page du véhicule dès son arrivée.",
+      "scope": "Le lien peut uniquement définir la photo de ce véhicule et cesse de fonctionner après 30 minutes.",
+      "waiting": "En attente de la photo…",
+      "received": "Photo reçue",
+      "pageTitle": "Photo du véhicule",
+      "pageBody": "Prenez une photo du véhicule en entier. Elle remplace la photo sur la page du véhicule.",
+      "choosePhoto": "Choisir une photo",
+      "saved": "Enregistrée. La photo est maintenant sur la page du véhicule. Prenez-en une autre pour la remplacer.",
+      "invalid": "Scannez de nouveau le code sur la page du véhicule.",
+      "notAPhoto": "Seules les photos peuvent être utilisées ici."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/fr/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Vérifié auprès du registre le {date}",
     "inspectionCheckedAt": "Vérifié auprès du registre le {date}",
     "notRegistered": "Non immatriculé",
     "notRegistered": "Non immatriculé",
     "soldReported": "Vente signalée par le client le {date}",
     "soldReported": "Vente signalée par le client le {date}",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Ajouter une photo",
+    "changePhoto": "Changer la photo",
+    "predictedToday": "Environ {value} {unit} aujourd'hui",
+    "viewAll": "Tout voir",
+    "owner": "Propriétaire",
+    "phone": "Téléphone",
+    "email": "E-mail",
+    "tyres": "Pneus",
+    "purchasedOn": "Acheté le",
+    "spent": "Dépensé",
+    "spentOver": "{count, plural, =0 {aucune visite} one {en # visite} other {en # visites}}",
+    "openItems": "À traiter",
+    "addOpenItem": "Ajouter",
+    "nothingOpen": "Rien à traiter",
+    "observationItem": "Observation · {severity}",
+    "reminderItem": "Rappel · {due}",
+    "newMenu": "Nouveau",
+    "closePanel": "Fermer",
+    "feed": {
+      "all": "Tout",
+      "workOrders": "Ordres de travail",
+      "search": "Rechercher dans l'historique",
+      "now": "En ce moment",
+      "openJob": "Ouvrir le travail",
+      "yearSummary": "{count, plural, one {# visite} other {# visites}} · {total}",
+      "showOlder": "Afficher les {count} plus anciens",
+      "empty": "Aucun historique pour le moment",
+      "noMatches": "Rien ne correspond à votre recherche",
+      "quote": "Devis",
+      "inspectionOpen": "En cours",
+      "inspectionResult": "{passed, plural, one {# conforme} other {# conformes}}",
+      "inspectionFailed": "{count} à traiter"
+    },
+    "layout": {
+      "inviteTitle": "Essayez la nouvelle page du véhicule.",
+      "inviteBody": "Tout ce qui concerne le véhicule sur un seul écran, et son historique complet sur une seule frise chronologique. Vous pouvez revenir à l'ancienne à tout moment.",
+      "tryIt": "Essayer",
+      "hideInvite": "Masquer",
+      "tryNewMenu": "Essayer la nouvelle présentation",
+      "useClassicMenu": "Passer à la présentation classique",
+      "backHintTitle": "Pour revenir, c'est ici",
+      "backHintBody": "Vous pouvez passer à la présentation classique depuis ce menu à tout moment."
+    },
+    "exportPreparing": "Préparation du PDF de l'historique d'entretien…",
+    "exportDone": "Historique d'entretien téléchargé",
+    "exportFailed": "Impossible de créer le PDF. Veuillez réessayer.",
+    "jobWarning": {
+      "heading": "Cette réservation nécessite votre attention",
+      "fix": "Ouvrez le travail pour corriger le problème",
+      "away": "{count, plural, one {{names} est absent} other {{names} sont absents}}",
+      "clash": "Double réservation",
+      "closed": "Atelier fermé",
+      "onJob": "Ordre de travail {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "Nouveau devis",
     "newQuote": "Nouveau devis",
@@ -378,7 +432,20 @@
     "lookupVin": "Rechercher le NIV",
     "lookupVin": "Rechercher le NIV",
     "lookupEnterVin": "Saisissez d'abord un NIV",
     "lookupEnterVin": "Saisissez d'abord un NIV",
     "lookupVinNotFound": "Aucun véhicule trouvé pour ce NIV",
     "lookupVinNotFound": "Aucun véhicule trouvé pour ce NIV",
-    "lookupVinFailed": "Impossible de rechercher le NIV"
+    "lookupVinFailed": "Impossible de rechercher le NIV",
+    "sectionIdentification": "Identification",
+    "sectionVehicle": "Véhicule",
+    "sectionVehicleMarine": "Bateau",
+    "lookupShort": "Rechercher",
+    "decodeShort": "Décoder",
+    "vinLength": "{count} sur {total} caractères",
+    "notSet": "Non défini",
+    "fromRegistry": "D'après le registre",
+    "removePhoto": "Supprimer la photo",
+    "deleteVehicle": "Supprimer le véhicule",
+    "deleteVehicleMarine": "Supprimer le bateau",
+    "editMileage": "Changer le kilométrage",
+    "editMileageMarine": "Changer les heures moteur"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Archiver le véhicule",
     "title": "Archiver le véhicule",

+ 15 - 0
messages/it/service.json

@@ -830,6 +830,21 @@
         "odometer": "Contachilometri",
         "odometer": "Contachilometri",
         "other": "Danno o altro"
         "other": "Danno o altro"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Scatta la foto del veicolo con un telefono",
+      "dialogBody": "Scatta una foto dell’auto da qualsiasi telefono, o scegline una già presente. Diventa la foto in cima alla pagina di questo veicolo.",
+      "step2": "Scatta una foto dell’auto intera, o scegline una dal telefono.",
+      "step3": "Sostituisce la foto nella pagina del veicolo appena arriva.",
+      "scope": "Il link può solo impostare la foto di questo veicolo e smette di funzionare dopo 30 minuti.",
+      "waiting": "In attesa della foto…",
+      "received": "Foto ricevuta",
+      "pageTitle": "Foto del veicolo",
+      "pageBody": "Scatta una foto dell’auto intera. Sostituisce la foto nella pagina del veicolo.",
+      "choosePhoto": "Scegli una foto",
+      "saved": "Salvata. Ora la foto è nella pagina del veicolo. Scattane un’altra per sostituirla.",
+      "invalid": "Inquadra di nuovo il codice sulla pagina del veicolo.",
+      "notAPhoto": "Qui si possono usare solo foto."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/it/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Verificato nel registro il {date}",
     "inspectionCheckedAt": "Verificato nel registro il {date}",
     "notRegistered": "Non immatricolato",
     "notRegistered": "Non immatricolato",
     "soldReported": "Vendita segnalata dal cliente il {date}",
     "soldReported": "Vendita segnalata dal cliente il {date}",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Aggiungi foto",
+    "changePhoto": "Cambia foto",
+    "predictedToday": "Circa {value} {unit} oggi",
+    "viewAll": "Vedi tutto",
+    "owner": "Proprietario",
+    "phone": "Telefono",
+    "email": "Email",
+    "tyres": "Pneumatici",
+    "purchasedOn": "Acquistato il",
+    "spent": "Speso",
+    "spentOver": "{count, plural, =0 {nessuna visita} one {in # visita} other {in # visite}}",
+    "openItems": "In sospeso",
+    "addOpenItem": "Aggiungi",
+    "nothingOpen": "Niente in sospeso",
+    "observationItem": "Osservazione · {severity}",
+    "reminderItem": "Promemoria · {due}",
+    "newMenu": "Nuovo",
+    "closePanel": "Chiudi",
+    "feed": {
+      "all": "Tutto",
+      "workOrders": "Ordini di lavoro",
+      "search": "Cerca nella cronologia",
+      "now": "Adesso",
+      "openJob": "Apri lavoro",
+      "yearSummary": "{count, plural, one {# visita} other {# visite}} · {total}",
+      "showOlder": "Mostra {count} precedenti",
+      "empty": "Nessuna cronologia ancora",
+      "noMatches": "Nessun risultato per questa ricerca",
+      "quote": "Preventivo",
+      "inspectionOpen": "In corso",
+      "inspectionResult": "{passed, plural, one {# superato} other {# superati}}",
+      "inspectionFailed": "{count} da sistemare"
+    },
+    "layout": {
+      "inviteTitle": "Prova la nuova pagina del veicolo.",
+      "inviteBody": "Tutto sull'auto in una sola schermata e l'intera cronologia su un'unica linea temporale. Puoi tornare indietro in qualsiasi momento.",
+      "tryIt": "Prova",
+      "hideInvite": "Nascondi",
+      "tryNewMenu": "Prova il nuovo layout",
+      "useClassicMenu": "Passa al layout classico",
+      "backHintTitle": "Da qui puoi tornare indietro",
+      "backHintBody": "Passa al layout classico da questo menu in qualsiasi momento."
+    },
+    "exportPreparing": "Preparazione del PDF dello storico assistenza…",
+    "exportDone": "Storico assistenza scaricato",
+    "exportFailed": "Impossibile creare il PDF. Riprova.",
+    "jobWarning": {
+      "heading": "Questa prenotazione richiede attenzione",
+      "fix": "Apri il lavoro per risolvere il problema",
+      "away": "{count, plural, one {{names} è assente} other {{names} sono assenti}}",
+      "clash": "Doppia prenotazione",
+      "closed": "Officina chiusa",
+      "onJob": "Ordine di lavoro {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "Nuovo preventivo",
     "newQuote": "Nuovo preventivo",
@@ -378,7 +432,20 @@
     "lookupVin": "Cerca VIN",
     "lookupVin": "Cerca VIN",
     "lookupEnterVin": "Inserisci prima un VIN",
     "lookupEnterVin": "Inserisci prima un VIN",
     "lookupVinNotFound": "Nessun veicolo trovato per questo VIN",
     "lookupVinNotFound": "Nessun veicolo trovato per questo VIN",
-    "lookupVinFailed": "Impossibile cercare il VIN"
+    "lookupVinFailed": "Impossibile cercare il VIN",
+    "sectionIdentification": "Identificazione",
+    "sectionVehicle": "Veicolo",
+    "sectionVehicleMarine": "Imbarcazione",
+    "lookupShort": "Cerca",
+    "decodeShort": "Decodifica",
+    "vinLength": "{count} di {total} caratteri",
+    "notSet": "Non impostata",
+    "fromRegistry": "Dal registro",
+    "removePhoto": "Rimuovi foto",
+    "deleteVehicle": "Elimina veicolo",
+    "deleteVehicleMarine": "Elimina imbarcazione",
+    "editMileage": "Cambia chilometraggio",
+    "editMileageMarine": "Cambia ore motore"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Archivia Veicolo",
     "title": "Archivia Veicolo",

+ 15 - 0
messages/lt/service.json

@@ -830,6 +830,21 @@
         "odometer": "Odometras",
         "odometer": "Odometras",
         "other": "Pažeidimas ar kita"
         "other": "Pažeidimas ar kita"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Nufotografuoti transporto priemonę telefonu",
+      "dialogBody": "Nufotografuokite automobilį bet kuriame telefone arba pasirinkite jame jau esančią nuotrauką. Ji taps nuotrauka šios transporto priemonės puslapio viršuje.",
+      "step2": "Nufotografuokite visą automobilį arba pasirinkite nuotrauką iš telefono.",
+      "step3": "Ji pakeičia nuotrauką transporto priemonės puslapyje vos gavus.",
+      "scope": "Nuoroda gali tik nustatyti šios transporto priemonės nuotrauką ir nustoja veikti po 30 minučių.",
+      "waiting": "Laukiama nuotraukos…",
+      "received": "Nuotrauka gauta",
+      "pageTitle": "Transporto priemonės nuotrauka",
+      "pageBody": "Nufotografuokite visą automobilį. Ji pakeičia nuotrauką transporto priemonės puslapyje.",
+      "choosePhoto": "Pasirinkti nuotrauką",
+      "saved": "Išsaugota. Nuotrauka jau transporto priemonės puslapyje. Nufotografuokite kitą, kad ją pakeistumėte.",
+      "invalid": "Dar kartą nuskaitykite transporto priemonės puslapio kodą.",
+      "notAPhoto": "Čia galima naudoti tik nuotraukas."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/lt/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Patikrinta registre {date}",
     "inspectionCheckedAt": "Patikrinta registre {date}",
     "notRegistered": "Neregistruota",
     "notRegistered": "Neregistruota",
     "soldReported": "Klientas pranešė pardavęs {date}",
     "soldReported": "Klientas pranešė pardavęs {date}",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Pridėti nuotrauką",
+    "changePhoto": "Pakeisti nuotrauką",
+    "predictedToday": "Šiandien apie {value} {unit}",
+    "viewAll": "Peržiūrėti visus",
+    "owner": "Savininkas",
+    "phone": "Telefonas",
+    "email": "El. paštas",
+    "tyres": "Padangos",
+    "purchasedOn": "Įsigyta",
+    "spent": "Išleista",
+    "spentOver": "{count, plural, =0 {be apsilankymų} one {per # apsilankymą} few {per # apsilankymus} many {per # apsilankymo} other {per # apsilankymų}}",
+    "openItems": "Atviri",
+    "addOpenItem": "Pridėti",
+    "nothingOpen": "Atvirų nėra",
+    "observationItem": "Pastebėjimas · {severity}",
+    "reminderItem": "Priminimas · {due}",
+    "newMenu": "Naujas",
+    "closePanel": "Uždaryti",
+    "feed": {
+      "all": "Viskas",
+      "workOrders": "Darbo užsakymai",
+      "search": "Ieškoti istorijoje",
+      "now": "Dabar",
+      "openJob": "Atidaryti darbą",
+      "yearSummary": "{count, plural, one {# apsilankymas} few {# apsilankymai} many {# apsilankymo} other {# apsilankymų}} · {total}",
+      "showOlder": "Rodyti senesnius ({count})",
+      "empty": "Istorijos dar nėra",
+      "noMatches": "Pagal paiešką nieko nerasta",
+      "quote": "Pasiūlymas",
+      "inspectionOpen": "Vykdoma",
+      "inspectionResult": "Tinkama: {passed}",
+      "inspectionFailed": "Reikia dėmesio: {count}"
+    },
+    "layout": {
+      "inviteTitle": "Išbandykite naują transporto priemonės puslapį.",
+      "inviteBody": "Viskas apie automobilį viename ekrane, o visa jo istorija vienoje laiko juostoje. Grįžti galite bet kada.",
+      "tryIt": "Išbandyti",
+      "hideInvite": "Slėpti",
+      "tryNewMenu": "Išbandyti naują išdėstymą",
+      "useClassicMenu": "Pereiti prie klasikinio išdėstymo",
+      "backHintTitle": "Grįžti galite čia",
+      "backHintBody": "Šiame meniu bet kada galite pereiti prie klasikinio išdėstymo."
+    },
+    "exportPreparing": "Ruošiamas paslaugų istorijos PDF…",
+    "exportDone": "Paslaugų istorija atsisiųsta",
+    "exportFailed": "Nepavyko sukurti PDF. Bandykite dar kartą.",
+    "jobWarning": {
+      "heading": "Šiai rezervacijai reikia dėmesio",
+      "fix": "Atidarykite darbą ir ištaisykite",
+      "away": "{count, plural, one {{names} nedirba} few {{names} nedirba} many {{names} nedirba} other {{names} nedirba}}",
+      "clash": "Dviguba rezervacija",
+      "closed": "Dirbtuvės uždarytos",
+      "onJob": "Darbo užsakymas {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "Naujas pasiūlymas",
     "newQuote": "Naujas pasiūlymas",
@@ -378,7 +432,20 @@
     "lookupVin": "Ieškoti pagal VIN",
     "lookupVin": "Ieškoti pagal VIN",
     "lookupEnterVin": "Pirmiausia įveskite VIN",
     "lookupEnterVin": "Pirmiausia įveskite VIN",
     "lookupVinNotFound": "Pagal šį VIN transporto priemonė nerasta",
     "lookupVinNotFound": "Pagal šį VIN transporto priemonė nerasta",
-    "lookupVinFailed": "Nepavyko rasti VIN"
+    "lookupVinFailed": "Nepavyko rasti VIN",
+    "sectionIdentification": "Identifikacija",
+    "sectionVehicle": "Transporto priemonė",
+    "sectionVehicleMarine": "Valtis",
+    "lookupShort": "Ieškoti",
+    "decodeShort": "Dekoduoti",
+    "vinLength": "{count} iš {total} simbolių",
+    "notSet": "Nenustatyta",
+    "fromRegistry": "Iš registro",
+    "removePhoto": "Pašalinti nuotrauką",
+    "deleteVehicle": "Ištrinti transporto priemonę",
+    "deleteVehicleMarine": "Ištrinti valtį",
+    "editMileage": "Pakeisti ridą",
+    "editMileageMarine": "Pakeisti variklio valandas"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Archyvuoti transporto priemonę",
     "title": "Archyvuoti transporto priemonę",

+ 15 - 0
messages/nb/service.json

@@ -830,6 +830,21 @@
         "odometer": "Kilometerstand",
         "odometer": "Kilometerstand",
         "other": "Skade eller annet"
         "other": "Skade eller annet"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Ta bilde av kjøretøyet med en telefon",
+      "dialogBody": "Ta et bilde av bilen på en hvilken som helst telefon, eller velg et som allerede ligger der. Det blir bildet øverst på kjøretøysiden.",
+      "step2": "Ta et bilde av hele bilen, eller velg et fra telefonen.",
+      "step3": "Det erstatter bildet på kjøretøysiden så snart det kommer inn.",
+      "scope": "Lenken kan bare sette bildet av dette kjøretøyet, og slutter å virke etter 30 minutter.",
+      "waiting": "Venter på bildet…",
+      "received": "Bilde mottatt",
+      "pageTitle": "Bilde av kjøretøyet",
+      "pageBody": "Ta et bilde av hele bilen. Det erstatter bildet på kjøretøysiden.",
+      "choosePhoto": "Velg et bilde",
+      "saved": "Lagret. Bildet ligger nå på kjøretøysiden. Ta et nytt for å erstatte det.",
+      "invalid": "Skann koden på kjøretøysiden på nytt.",
+      "notAPhoto": "Her kan du bare bruke bilder."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/nb/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Sjekket mot registeret {date}",
     "inspectionCheckedAt": "Sjekket mot registeret {date}",
     "notRegistered": "Avregistrert",
     "notRegistered": "Avregistrert",
     "soldReported": "Kunden meldte solgt {date}",
     "soldReported": "Kunden meldte solgt {date}",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Legg til bilde",
+    "changePhoto": "Bytt bilde",
+    "predictedToday": "Ca. {value} {unit} i dag",
+    "viewAll": "Vis alle",
+    "owner": "Eier",
+    "phone": "Telefon",
+    "email": "E-post",
+    "tyres": "Dekk",
+    "purchasedOn": "Kjøpt",
+    "spent": "Brukt",
+    "spentOver": "{count, plural, =0 {ingen besøk ennå} one {på # besøk} other {på # besøk}}",
+    "openItems": "Åpent",
+    "addOpenItem": "Legg til",
+    "nothingOpen": "Ingenting åpent",
+    "observationItem": "Observasjon · {severity}",
+    "reminderItem": "Påminnelse · {due}",
+    "newMenu": "Ny",
+    "closePanel": "Lukk",
+    "feed": {
+      "all": "Alt",
+      "workOrders": "Arbeidsordrer",
+      "search": "Søk i historikken",
+      "now": "Nå",
+      "openJob": "Åpne jobben",
+      "yearSummary": "{count, plural, one {# besøk} other {# besøk}} · {total}",
+      "showOlder": "Vis {count} eldre",
+      "empty": "Ingen historikk ennå",
+      "noMatches": "Ingenting samsvarer med søket ditt",
+      "quote": "Tilbud",
+      "inspectionOpen": "Under arbeid",
+      "inspectionResult": "{passed} godkjent",
+      "inspectionFailed": "{count} trenger oppfølging"
+    },
+    "layout": {
+      "inviteTitle": "Prøv den nye kjøretøysiden.",
+      "inviteBody": "Alt om bilen på én skjerm, og hele historikken på én tidslinje. Du kan bytte tilbake når som helst.",
+      "tryIt": "Prøv den",
+      "hideInvite": "Skjul",
+      "tryNewMenu": "Prøv den nye visningen",
+      "useClassicMenu": "Bytt til den klassiske visningen",
+      "backHintTitle": "Her finner du veien tilbake",
+      "backHintBody": "Bytt til den klassiske visningen fra denne menyen når som helst."
+    },
+    "exportPreparing": "Lager PDF av servicehistorikken…",
+    "exportDone": "Servicehistorikk lastet ned",
+    "exportFailed": "Kunne ikke lage PDF-en. Prøv igjen.",
+    "jobWarning": {
+      "heading": "Denne bookingen må sjekkes",
+      "fix": "Åpne jobben for å fikse det",
+      "away": "{count, plural, one {{names} er borte} other {{names} er borte}}",
+      "clash": "Dobbeltbooket",
+      "closed": "Verkstedet stengt",
+      "onJob": "Arbeidsordre {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "Nytt tilbud",
     "newQuote": "Nytt tilbud",
@@ -378,7 +432,20 @@
     "lookupVin": "Slå opp VIN",
     "lookupVin": "Slå opp VIN",
     "lookupEnterVin": "Skriv inn et VIN først",
     "lookupEnterVin": "Skriv inn et VIN først",
     "lookupVinNotFound": "Fant ingen kjøretøy med det VIN-nummeret",
     "lookupVinNotFound": "Fant ingen kjøretøy med det VIN-nummeret",
-    "lookupVinFailed": "Kunne ikke slå opp VIN-nummeret"
+    "lookupVinFailed": "Kunne ikke slå opp VIN-nummeret",
+    "sectionIdentification": "Identifikasjon",
+    "sectionVehicle": "Kjøretøy",
+    "sectionVehicleMarine": "Båt",
+    "lookupShort": "Slå opp",
+    "decodeShort": "Dekode",
+    "vinLength": "{count} av {total} tegn",
+    "notSet": "Ikke satt",
+    "fromRegistry": "Fra registeret",
+    "removePhoto": "Fjern bilde",
+    "deleteVehicle": "Slett kjøretøy",
+    "deleteVehicleMarine": "Slett båt",
+    "editMileage": "Endre kilometerstand",
+    "editMileageMarine": "Endre motortimer"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Arkiver kjøretøy",
     "title": "Arkiver kjøretøy",

+ 15 - 0
messages/nl/service.json

@@ -830,6 +830,21 @@
         "odometer": "Kilometerstand",
         "odometer": "Kilometerstand",
         "other": "Schade of anders"
         "other": "Schade of anders"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Voertuigfoto maken met een telefoon",
+      "dialogBody": "Maak een foto van de auto op elke telefoon, of kies er een die er al op staat. Die wordt de foto bovenaan de voertuigpagina.",
+      "step2": "Maak een foto van de hele auto, of kies er een van de telefoon.",
+      "step3": "Hij vervangt de foto op de voertuigpagina zodra hij binnenkomt.",
+      "scope": "De link kan alleen de foto van dit voertuig instellen en werkt na 30 minuten niet meer.",
+      "waiting": "Wachten op de foto…",
+      "received": "Foto ontvangen",
+      "pageTitle": "Foto van het voertuig",
+      "pageBody": "Maak een foto van de hele auto. Hij vervangt de foto op de voertuigpagina.",
+      "choosePhoto": "Foto kiezen",
+      "saved": "Opgeslagen. De foto staat nu op de voertuigpagina. Maak een nieuwe om hem te vervangen.",
+      "invalid": "Scan de code op de voertuigpagina opnieuw.",
+      "notAPhoto": "Hier kunnen alleen foto’s worden gebruikt."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/nl/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Gecontroleerd bij het register op {date}",
     "inspectionCheckedAt": "Gecontroleerd bij het register op {date}",
     "notRegistered": "Niet geregistreerd",
     "notRegistered": "Niet geregistreerd",
     "soldReported": "Klant meldde verkocht op {date}",
     "soldReported": "Klant meldde verkocht op {date}",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Foto toevoegen",
+    "changePhoto": "Foto wijzigen",
+    "predictedToday": "Vandaag ongeveer {value} {unit}",
+    "viewAll": "Alles bekijken",
+    "owner": "Eigenaar",
+    "phone": "Telefoon",
+    "email": "E-mail",
+    "tyres": "Banden",
+    "purchasedOn": "Gekocht op",
+    "spent": "Besteed",
+    "spentOver": "{count, plural, =0 {nog geen bezoeken} one {in # bezoek} other {in # bezoeken}}",
+    "openItems": "Open",
+    "addOpenItem": "Toevoegen",
+    "nothingOpen": "Niets open",
+    "observationItem": "Observatie · {severity}",
+    "reminderItem": "Herinnering · {due}",
+    "newMenu": "Nieuw",
+    "closePanel": "Sluiten",
+    "feed": {
+      "all": "Alles",
+      "workOrders": "Werkorders",
+      "search": "Geschiedenis doorzoeken",
+      "now": "Nu",
+      "openJob": "Opdracht openen",
+      "yearSummary": "{count, plural, one {# bezoek} other {# bezoeken}} · {total}",
+      "showOlder": "{count} oudere tonen",
+      "empty": "Nog geen geschiedenis",
+      "noMatches": "Niets gevonden voor uw zoekopdracht",
+      "quote": "Offerte",
+      "inspectionOpen": "Bezig",
+      "inspectionResult": "{passed} goedgekeurd",
+      "inspectionFailed": "{count, plural, one {# heeft aandacht nodig} other {# hebben aandacht nodig}}"
+    },
+    "layout": {
+      "inviteTitle": "Probeer de nieuwe voertuigpagina.",
+      "inviteBody": "Alles over de auto op één scherm, en de hele geschiedenis op één tijdlijn. U kunt altijd terugschakelen.",
+      "tryIt": "Proberen",
+      "hideInvite": "Verbergen",
+      "tryNewMenu": "Nieuwe indeling proberen",
+      "useClassicMenu": "Overschakelen naar de klassieke indeling",
+      "backHintTitle": "Hier vindt u de weg terug",
+      "backHintBody": "Via dit menu kunt u altijd overschakelen naar de klassieke indeling."
+    },
+    "exportPreparing": "PDF van de servicegeschiedenis wordt voorbereid…",
+    "exportDone": "Servicegeschiedenis gedownload",
+    "exportFailed": "Kon de PDF niet maken. Probeer het opnieuw.",
+    "jobWarning": {
+      "heading": "Deze boeking vraagt aandacht",
+      "fix": "Open de opdracht om het op te lossen",
+      "away": "{count, plural, one {{names} is afwezig} other {{names} zijn afwezig}}",
+      "clash": "Dubbel geboekt",
+      "closed": "Werkplaats gesloten",
+      "onJob": "Werkorder {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "Nieuwe offerte",
     "newQuote": "Nieuwe offerte",
@@ -378,7 +432,20 @@
     "lookupVin": "VIN opzoeken",
     "lookupVin": "VIN opzoeken",
     "lookupEnterVin": "Voer eerst een VIN in",
     "lookupEnterVin": "Voer eerst een VIN in",
     "lookupVinNotFound": "Geen voertuig gevonden voor dit VIN",
     "lookupVinNotFound": "Geen voertuig gevonden voor dit VIN",
-    "lookupVinFailed": "VIN kon niet worden opgezocht"
+    "lookupVinFailed": "VIN kon niet worden opgezocht",
+    "sectionIdentification": "Identificatie",
+    "sectionVehicle": "Voertuig",
+    "sectionVehicleMarine": "Boot",
+    "lookupShort": "Opzoeken",
+    "decodeShort": "Decoderen",
+    "vinLength": "{count} van {total} tekens",
+    "notSet": "Niet ingesteld",
+    "fromRegistry": "Uit het register",
+    "removePhoto": "Foto verwijderen",
+    "deleteVehicle": "Voertuig verwijderen",
+    "deleteVehicleMarine": "Boot verwijderen",
+    "editMileage": "Kilometerstand wijzigen",
+    "editMileageMarine": "Motoruren wijzigen"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Voertuig archiveren",
     "title": "Voertuig archiveren",

+ 15 - 0
messages/pl/service.json

@@ -830,6 +830,21 @@
         "odometer": "Licznik",
         "odometer": "Licznik",
         "other": "Uszkodzenie lub inne"
         "other": "Uszkodzenie lub inne"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Zrób zdjęcie pojazdu telefonem",
+      "dialogBody": "Zrób zdjęcie auta na dowolnym telefonie albo wybierz takie, które już na nim jest. Stanie się zdjęciem na górze strony tego pojazdu.",
+      "step2": "Zrób zdjęcie całego auta albo wybierz je z telefonu.",
+      "step3": "Zastępuje zdjęcie na stronie pojazdu, gdy tylko dotrze.",
+      "scope": "Link może tylko ustawić zdjęcie tego pojazdu i przestaje działać po 30 minutach.",
+      "waiting": "Czekam na zdjęcie…",
+      "received": "Odebrano zdjęcie",
+      "pageTitle": "Zdjęcie pojazdu",
+      "pageBody": "Zrób zdjęcie całego auta. Zastąpi ono zdjęcie na stronie pojazdu.",
+      "choosePhoto": "Wybierz zdjęcie",
+      "saved": "Zapisano. Zdjęcie jest już na stronie pojazdu. Zrób kolejne, aby je zastąpić.",
+      "invalid": "Zeskanuj ponownie kod na stronie pojazdu.",
+      "notAPhoto": "Tutaj można używać tylko zdjęć."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/pl/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Sprawdzono w rejestrze {date}",
     "inspectionCheckedAt": "Sprawdzono w rejestrze {date}",
     "notRegistered": "Niezarejestrowany",
     "notRegistered": "Niezarejestrowany",
     "soldReported": "Klient zgłosił sprzedaż {date}",
     "soldReported": "Klient zgłosił sprzedaż {date}",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Dodaj zdjęcie",
+    "changePhoto": "Zmień zdjęcie",
+    "predictedToday": "Dziś ok. {value} {unit}",
+    "viewAll": "Pokaż wszystkie",
+    "owner": "Właściciel",
+    "phone": "Telefon",
+    "email": "E-mail",
+    "tyres": "Opony",
+    "purchasedOn": "Zakupiony",
+    "spent": "Wydano",
+    "spentOver": "{count, plural, =0 {brak wizyt} one {w # wizycie} few {w # wizytach} many {w # wizytach} other {w # wizyty}}",
+    "openItems": "Otwarte",
+    "addOpenItem": "Dodaj",
+    "nothingOpen": "Brak otwartych",
+    "observationItem": "Obserwacja · {severity}",
+    "reminderItem": "Przypomnienie · {due}",
+    "newMenu": "Nowe",
+    "closePanel": "Zamknij",
+    "feed": {
+      "all": "Wszystko",
+      "workOrders": "Zlecenia",
+      "search": "Szukaj w historii",
+      "now": "Teraz",
+      "openJob": "Otwórz zlecenie",
+      "yearSummary": "{count, plural, one {# wizyta} few {# wizyty} many {# wizyt} other {# wizyty}} · {total}",
+      "showOlder": "Pokaż starsze ({count})",
+      "empty": "Brak historii",
+      "noMatches": "Brak wyników wyszukiwania",
+      "quote": "Wycena",
+      "inspectionOpen": "W trakcie",
+      "inspectionResult": "Zaliczone: {passed}",
+      "inspectionFailed": "{count, plural, one {# wymaga uwagi} few {# wymagają uwagi} many {# wymaga uwagi} other {# wymaga uwagi}}"
+    },
+    "layout": {
+      "inviteTitle": "Wypróbuj nową stronę pojazdu.",
+      "inviteBody": "Wszystko o aucie na jednym ekranie, a cała jego historia na jednej osi czasu. Możesz wrócić w każdej chwili.",
+      "tryIt": "Wypróbuj",
+      "hideInvite": "Ukryj",
+      "tryNewMenu": "Wypróbuj nowy układ",
+      "useClassicMenu": "Przełącz na klasyczny układ",
+      "backHintTitle": "Tutaj możesz wrócić",
+      "backHintBody": "Z tego menu możesz w każdej chwili przełączyć na klasyczny układ."
+    },
+    "exportPreparing": "Przygotowywanie PDF historii serwisowej…",
+    "exportDone": "Pobrano historię serwisową",
+    "exportFailed": "Nie udało się utworzyć pliku PDF. Spróbuj ponownie.",
+    "jobWarning": {
+      "heading": "Ta rezerwacja wymaga uwagi",
+      "fix": "Otwórz zlecenie, aby to poprawić",
+      "away": "{count, plural, one {{names}: nieobecność} few {{names}: nieobecni} many {{names}: nieobecni} other {{names}: nieobecni}}",
+      "clash": "Podwójna rezerwacja",
+      "closed": "Warsztat zamknięty",
+      "onJob": "Zlecenie {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "Nowa wycena",
     "newQuote": "Nowa wycena",
@@ -378,7 +432,20 @@
     "lookupVin": "Sprawdź VIN",
     "lookupVin": "Sprawdź VIN",
     "lookupEnterVin": "Najpierw wpisz numer VIN",
     "lookupEnterVin": "Najpierw wpisz numer VIN",
     "lookupVinNotFound": "Nie znaleziono pojazdu o tym numerze VIN",
     "lookupVinNotFound": "Nie znaleziono pojazdu o tym numerze VIN",
-    "lookupVinFailed": "Nie udało się sprawdzić numeru VIN"
+    "lookupVinFailed": "Nie udało się sprawdzić numeru VIN",
+    "sectionIdentification": "Identyfikacja",
+    "sectionVehicle": "Pojazd",
+    "sectionVehicleMarine": "Łódź",
+    "lookupShort": "Sprawdź",
+    "decodeShort": "Dekoduj",
+    "vinLength": "{count} z {total} znaków",
+    "notSet": "Nie ustawiono",
+    "fromRegistry": "Z rejestru",
+    "removePhoto": "Usuń zdjęcie",
+    "deleteVehicle": "Usuń pojazd",
+    "deleteVehicleMarine": "Usuń łódź",
+    "editMileage": "Zmień przebieg",
+    "editMileageMarine": "Zmień godziny pracy silnika"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Archiwizuj pojazd",
     "title": "Archiwizuj pojazd",

+ 15 - 0
messages/pt-BR/service.json

@@ -830,6 +830,21 @@
         "odometer": "Hodômetro",
         "odometer": "Hodômetro",
         "other": "Dano ou outro"
         "other": "Dano ou outro"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Tirar a foto do veículo pelo celular",
+      "dialogBody": "Tire uma foto do carro em qualquer celular, ou escolha uma que já esteja nele. Ela vira a foto no topo da página deste veículo.",
+      "step2": "Tire uma foto do carro inteiro, ou escolha uma do celular.",
+      "step3": "Ela substitui a foto na página do veículo assim que chega.",
+      "scope": "O link só pode definir a foto deste veículo e para de funcionar após 30 minutos.",
+      "waiting": "Aguardando a foto…",
+      "received": "Foto recebida",
+      "pageTitle": "Foto do veículo",
+      "pageBody": "Tire uma foto do carro inteiro. Ela substitui a foto na página do veículo.",
+      "choosePhoto": "Escolher uma foto",
+      "saved": "Salva. A foto já está na página do veículo. Tire outra para substituí-la.",
+      "invalid": "Escaneie de novo o código na página do veículo.",
+      "notAPhoto": "Aqui só é possível usar fotos."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/pt-BR/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Verificado no registro em {date}",
     "inspectionCheckedAt": "Verificado no registro em {date}",
     "notRegistered": "Não registrado",
     "notRegistered": "Não registrado",
     "soldReported": "Cliente informou venda em {date}",
     "soldReported": "Cliente informou venda em {date}",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Adicionar foto",
+    "changePhoto": "Trocar foto",
+    "predictedToday": "Cerca de {value} {unit} hoje",
+    "viewAll": "Ver tudo",
+    "owner": "Proprietário",
+    "phone": "Telefone",
+    "email": "E-mail",
+    "tyres": "Pneus",
+    "purchasedOn": "Adquirido em",
+    "spent": "Gasto",
+    "spentOver": "{count, plural, =0 {nenhuma visita} one {em # visita} other {em # visitas}}",
+    "openItems": "Pendentes",
+    "addOpenItem": "Adicionar",
+    "nothingOpen": "Nada pendente",
+    "observationItem": "Observação · {severity}",
+    "reminderItem": "Lembrete · {due}",
+    "newMenu": "Novo",
+    "closePanel": "Fechar",
+    "feed": {
+      "all": "Tudo",
+      "workOrders": "Ordens de serviço",
+      "search": "Buscar no histórico",
+      "now": "Agora",
+      "openJob": "Abrir trabalho",
+      "yearSummary": "{count, plural, one {# visita} other {# visitas}} · {total}",
+      "showOlder": "Mostrar {count} anteriores",
+      "empty": "Nenhum histórico ainda",
+      "noMatches": "Nada corresponde à sua busca",
+      "quote": "Orçamento",
+      "inspectionOpen": "Em andamento",
+      "inspectionResult": "{passed, plural, one {# aprovado} other {# aprovados}}",
+      "inspectionFailed": "{count, plural, one {# precisa de atenção} other {# precisam de atenção}}"
+    },
+    "layout": {
+      "inviteTitle": "Experimente a nova página do veículo.",
+      "inviteBody": "Tudo sobre o carro em uma só tela, e todo o histórico em uma única linha do tempo. Você pode voltar ao anterior a qualquer momento.",
+      "tryIt": "Experimentar",
+      "hideInvite": "Ocultar",
+      "tryNewMenu": "Experimentar o novo layout",
+      "useClassicMenu": "Mudar para o layout clássico",
+      "backHintTitle": "Para voltar, é por aqui",
+      "backHintBody": "Você pode mudar para o layout clássico neste menu a qualquer momento."
+    },
+    "exportPreparing": "Preparando o PDF do histórico de serviço…",
+    "exportDone": "Histórico de serviço baixado",
+    "exportFailed": "Não foi possível criar o PDF. Tente novamente.",
+    "jobWarning": {
+      "heading": "Este agendamento precisa de atenção",
+      "fix": "Abra o trabalho para corrigir",
+      "away": "{count, plural, one {{names} está ausente} other {{names} estão ausentes}}",
+      "clash": "Agendamento duplo",
+      "closed": "Oficina fechada",
+      "onJob": "Ordem de serviço {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "Novo orçamento",
     "newQuote": "Novo orçamento",
@@ -378,7 +432,20 @@
     "lookupVin": "Consultar chassi",
     "lookupVin": "Consultar chassi",
     "lookupEnterVin": "Digite um chassi primeiro",
     "lookupEnterVin": "Digite um chassi primeiro",
     "lookupVinNotFound": "Nenhum veículo encontrado para esse chassi",
     "lookupVinNotFound": "Nenhum veículo encontrado para esse chassi",
-    "lookupVinFailed": "Não foi possível consultar o chassi"
+    "lookupVinFailed": "Não foi possível consultar o chassi",
+    "sectionIdentification": "Identificação",
+    "sectionVehicle": "Veículo",
+    "sectionVehicleMarine": "Embarcação",
+    "lookupShort": "Consultar",
+    "decodeShort": "Decodificar",
+    "vinLength": "{count} de {total} caracteres",
+    "notSet": "Não definida",
+    "fromRegistry": "Do registro",
+    "removePhoto": "Remover foto",
+    "deleteVehicle": "Excluir veículo",
+    "deleteVehicleMarine": "Excluir embarcação",
+    "editMileage": "Alterar quilometragem",
+    "editMileageMarine": "Alterar horas de motor"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Arquivar Veículo",
     "title": "Arquivar Veículo",

+ 15 - 0
messages/pt-PT/service.json

@@ -830,6 +830,21 @@
         "odometer": "Conta-quilómetros",
         "odometer": "Conta-quilómetros",
         "other": "Dano ou outro"
         "other": "Dano ou outro"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Tirar a fotografia da viatura com um telemóvel",
+      "dialogBody": "Tire uma fotografia da viatura em qualquer telemóvel, ou escolha uma que já lá esteja. Passa a ser a fotografia no topo da página desta viatura.",
+      "step2": "Tire uma fotografia da viatura inteira, ou escolha uma do telemóvel.",
+      "step3": "Substitui a fotografia na página da viatura assim que chega.",
+      "scope": "A ligação só pode definir a fotografia desta viatura e deixa de funcionar ao fim de 30 minutos.",
+      "waiting": "À espera da fotografia…",
+      "received": "Fotografia recebida",
+      "pageTitle": "Fotografia da viatura",
+      "pageBody": "Tire uma fotografia da viatura inteira. Substitui a fotografia na página da viatura.",
+      "choosePhoto": "Escolher uma fotografia",
+      "saved": "Guardada. A fotografia já está na página da viatura. Tire outra para a substituir.",
+      "invalid": "Leia novamente o código na página da viatura.",
+      "notAPhoto": "Aqui só é possível usar fotografias."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/pt-PT/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Verificado no registo automóvel a {date}",
     "inspectionCheckedAt": "Verificado no registo automóvel a {date}",
     "notRegistered": "Não registada",
     "notRegistered": "Não registada",
     "soldReported": "Cliente comunicou a venda a {date}",
     "soldReported": "Cliente comunicou a venda a {date}",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Adicionar fotografia",
+    "changePhoto": "Alterar fotografia",
+    "predictedToday": "Cerca de {value} {unit} hoje",
+    "viewAll": "Ver tudo",
+    "owner": "Proprietário",
+    "phone": "Telefone",
+    "email": "E-mail",
+    "tyres": "Pneus",
+    "purchasedOn": "Adquirida a",
+    "spent": "Gasto",
+    "spentOver": "{count, plural, =0 {nenhuma visita} one {em # visita} other {em # visitas}}",
+    "openItems": "Pendentes",
+    "addOpenItem": "Adicionar",
+    "nothingOpen": "Nada pendente",
+    "observationItem": "Observação · {severity}",
+    "reminderItem": "Lembrete · {due}",
+    "newMenu": "Novo",
+    "closePanel": "Fechar",
+    "feed": {
+      "all": "Tudo",
+      "workOrders": "Ordens de serviço",
+      "search": "Pesquisar no histórico",
+      "now": "Agora",
+      "openJob": "Abrir trabalho",
+      "yearSummary": "{count, plural, one {# visita} other {# visitas}} · {total}",
+      "showOlder": "Mostrar {count} anteriores",
+      "empty": "Ainda não há histórico",
+      "noMatches": "Nada corresponde à sua pesquisa",
+      "quote": "Orçamento",
+      "inspectionOpen": "Em curso",
+      "inspectionResult": "{passed, plural, one {# aprovado} other {# aprovados}}",
+      "inspectionFailed": "{count, plural, one {# precisa de atenção} other {# precisam de atenção}}"
+    },
+    "layout": {
+      "inviteTitle": "Experimente a nova página da viatura.",
+      "inviteBody": "Tudo sobre a viatura num só ecrã, e todo o histórico numa única cronologia. Pode voltar ao anterior a qualquer momento.",
+      "tryIt": "Experimentar",
+      "hideInvite": "Ocultar",
+      "tryNewMenu": "Experimentar o novo layout",
+      "useClassicMenu": "Mudar para o layout clássico",
+      "backHintTitle": "Para voltar, é por aqui",
+      "backHintBody": "Pode mudar para o layout clássico neste menu a qualquer momento."
+    },
+    "exportPreparing": "A preparar o PDF do histórico de serviços…",
+    "exportDone": "Histórico de serviços transferido",
+    "exportFailed": "Não foi possível criar o PDF. Tente novamente.",
+    "jobWarning": {
+      "heading": "Esta marcação precisa de atenção",
+      "fix": "Abra o trabalho para corrigir",
+      "away": "{count, plural, one {{names} está ausente} other {{names} estão ausentes}}",
+      "clash": "Dupla marcação",
+      "closed": "Oficina encerrada",
+      "onJob": "Ordem de serviço {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "Novo orçamento",
     "newQuote": "Novo orçamento",
@@ -378,7 +432,20 @@
     "lookupVin": "Consultar VIN",
     "lookupVin": "Consultar VIN",
     "lookupEnterVin": "Introduza primeiro um VIN",
     "lookupEnterVin": "Introduza primeiro um VIN",
     "lookupVinNotFound": "Nenhuma viatura encontrada para esse VIN",
     "lookupVinNotFound": "Nenhuma viatura encontrada para esse VIN",
-    "lookupVinFailed": "Não foi possível consultar o VIN"
+    "lookupVinFailed": "Não foi possível consultar o VIN",
+    "sectionIdentification": "Identificação",
+    "sectionVehicle": "Viatura",
+    "sectionVehicleMarine": "Embarcação",
+    "lookupShort": "Consultar",
+    "decodeShort": "Descodificar",
+    "vinLength": "{count} de {total} caracteres",
+    "notSet": "Não definida",
+    "fromRegistry": "Do registo automóvel",
+    "removePhoto": "Remover fotografia",
+    "deleteVehicle": "Eliminar viatura",
+    "deleteVehicleMarine": "Eliminar embarcação",
+    "editMileage": "Alterar quilometragem",
+    "editMileageMarine": "Alterar horas de motor"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Arquivar Viatura",
     "title": "Arquivar Viatura",

+ 15 - 0
messages/ru/service.json

@@ -830,6 +830,21 @@
         "odometer": "Одометр",
         "odometer": "Одометр",
         "other": "Повреждение или другое"
         "other": "Повреждение или другое"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Сфотографировать транспортное средство телефоном",
+      "dialogBody": "Сфотографируйте автомобиль любым телефоном или выберите снимок, который уже есть на нём. Он станет фото в верхней части страницы этого транспортного средства.",
+      "step2": "Сфотографируйте автомобиль целиком или выберите снимок на телефоне.",
+      "step3": "Он заменит фото на странице транспортного средства, как только поступит.",
+      "scope": "Ссылка может только установить фото этого транспортного средства и перестаёт работать через 30 минут.",
+      "waiting": "Ожидание фото…",
+      "received": "Фото получено",
+      "pageTitle": "Фото транспортного средства",
+      "pageBody": "Сфотографируйте автомобиль целиком. Снимок заменит фото на странице транспортного средства.",
+      "choosePhoto": "Выбрать фото",
+      "saved": "Сохранено. Фото уже на странице транспортного средства. Сделайте новое, чтобы заменить его.",
+      "invalid": "Отсканируйте код на странице транспортного средства ещё раз.",
+      "notAPhoto": "Здесь можно использовать только фото."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/ru/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Проверено в реестре {date}",
     "inspectionCheckedAt": "Проверено в реестре {date}",
     "notRegistered": "Не зарегистрирован",
     "notRegistered": "Не зарегистрирован",
     "soldReported": "Клиент сообщил о продаже {date}",
     "soldReported": "Клиент сообщил о продаже {date}",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Добавить фото",
+    "changePhoto": "Изменить фото",
+    "predictedToday": "Сегодня около {value} {unit}",
+    "viewAll": "Показать все",
+    "owner": "Владелец",
+    "phone": "Телефон",
+    "email": "Эл. почта",
+    "tyres": "Шины",
+    "purchasedOn": "Куплено",
+    "spent": "Потрачено",
+    "spentOver": "{count, plural, =0 {визитов пока нет} one {за # визит} few {за # визита} many {за # визитов} other {за # визита}}",
+    "openItems": "Открытые",
+    "addOpenItem": "Добавить",
+    "nothingOpen": "Открытых нет",
+    "observationItem": "Наблюдение · {severity}",
+    "reminderItem": "Напоминание · {due}",
+    "newMenu": "Создать",
+    "closePanel": "Закрыть",
+    "feed": {
+      "all": "Всё",
+      "workOrders": "Заказ-наряды",
+      "search": "Поиск по истории",
+      "now": "Сейчас",
+      "openJob": "Открыть заказ-наряд",
+      "yearSummary": "{count, plural, one {# визит} few {# визита} many {# визитов} other {# визита}} · {total}",
+      "showOlder": "Показать более ранние ({count})",
+      "empty": "Истории пока нет",
+      "noMatches": "Ничего не найдено по запросу",
+      "quote": "Предложение",
+      "inspectionOpen": "В процессе",
+      "inspectionResult": "Пройдено: {passed}",
+      "inspectionFailed": "{count, plural, one {# требует внимания} few {# требуют внимания} many {# требуют внимания} other {# требуют внимания}}"
+    },
+    "layout": {
+      "inviteTitle": "Попробуйте новую страницу транспортного средства.",
+      "inviteBody": "Всё об автомобиле на одном экране и вся его история на одной шкале времени. Вернуться можно в любой момент.",
+      "tryIt": "Попробовать",
+      "hideInvite": "Скрыть",
+      "tryNewMenu": "Попробовать новый вид",
+      "useClassicMenu": "Перейти к классическому виду",
+      "backHintTitle": "Вернуться можно здесь",
+      "backHintBody": "Перейти к классическому виду можно в этом меню в любой момент."
+    },
+    "exportPreparing": "Подготовка PDF истории обслуживания…",
+    "exportDone": "История обслуживания скачана",
+    "exportFailed": "Не удалось создать PDF. Попробуйте ещё раз.",
+    "jobWarning": {
+      "heading": "Эта запись требует внимания",
+      "fix": "Откройте заказ-наряд, чтобы исправить",
+      "away": "{count, plural, one {{names} отсутствует} few {{names} отсутствуют} many {{names} отсутствуют} other {{names} отсутствуют}}",
+      "clash": "Двойная запись",
+      "closed": "Мастерская закрыта",
+      "onJob": "Заказ-наряд {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "Новое предложение",
     "newQuote": "Новое предложение",
@@ -378,7 +432,20 @@
     "lookupVin": "Найти по VIN",
     "lookupVin": "Найти по VIN",
     "lookupEnterVin": "Сначала введите VIN",
     "lookupEnterVin": "Сначала введите VIN",
     "lookupVinNotFound": "Автомобиль с таким VIN не найден",
     "lookupVinNotFound": "Автомобиль с таким VIN не найден",
-    "lookupVinFailed": "Не удалось найти VIN"
+    "lookupVinFailed": "Не удалось найти VIN",
+    "sectionIdentification": "Идентификация",
+    "sectionVehicle": "Транспортное средство",
+    "sectionVehicleMarine": "Лодка",
+    "lookupShort": "Найти",
+    "decodeShort": "Расшифровать",
+    "vinLength": "{count} из {total} символов",
+    "notSet": "Не указано",
+    "fromRegistry": "Из реестра",
+    "removePhoto": "Удалить фото",
+    "deleteVehicle": "Удалить транспортное средство",
+    "deleteVehicleMarine": "Удалить лодку",
+    "editMileage": "Изменить пробег",
+    "editMileageMarine": "Изменить моточасы"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Архивировать транспортное средство",
     "title": "Архивировать транспортное средство",

+ 15 - 0
messages/tr/service.json

@@ -830,6 +830,21 @@
         "odometer": "Kilometre sayacı",
         "odometer": "Kilometre sayacı",
         "other": "Hasar veya diğer"
         "other": "Hasar veya diğer"
       }
       }
+    },
+    "vehicle": {
+      "dialogTitle": "Aracın fotoğrafını telefonla çekin",
+      "dialogBody": "Herhangi bir telefonla aracın fotoğrafını çekin veya telefonda zaten olan birini seçin. Bu fotoğraf, bu aracın sayfasının en üstünde görünür.",
+      "step2": "Aracın tamamının fotoğrafını çekin veya telefondan bir fotoğraf seçin.",
+      "step3": "Gelir gelmez araç sayfasındaki fotoğrafın yerini alır.",
+      "scope": "Bağlantı yalnızca bu aracın fotoğrafını ayarlayabilir ve 30 dakika sonra çalışmaz.",
+      "waiting": "Fotoğraf bekleniyor…",
+      "received": "Fotoğraf alındı",
+      "pageTitle": "Aracın fotoğrafı",
+      "pageBody": "Aracın tamamının fotoğrafını çekin. Araç sayfasındaki fotoğrafın yerini alır.",
+      "choosePhoto": "Fotoğraf seç",
+      "saved": "Kaydedildi. Fotoğraf artık araç sayfasında. Değiştirmek için yenisini çekin.",
+      "invalid": "Araç sayfasındaki kodu yeniden tarayın.",
+      "notAPhoto": "Burada yalnızca fotoğraf kullanılabilir."
     }
     }
   },
   },
   "payCode": {
   "payCode": {

+ 69 - 2
messages/tr/vehicles.json

@@ -102,7 +102,61 @@
     "inspectionCheckedAt": "Sicilde {date} tarihinde kontrol edildi",
     "inspectionCheckedAt": "Sicilde {date} tarihinde kontrol edildi",
     "notRegistered": "Kayıtlı değil",
     "notRegistered": "Kayıtlı değil",
     "soldReported": "Müşteri {date} tarihinde satıldığını bildirdi",
     "soldReported": "Müşteri {date} tarihinde satıldığını bildirdi",
-    "typeKey": "HSN/TSN"
+    "typeKey": "HSN/TSN",
+    "addPhoto": "Fotoğraf ekle",
+    "changePhoto": "Fotoğrafı değiştir",
+    "predictedToday": "Bugün yaklaşık {value} {unit}",
+    "viewAll": "Tümünü gör",
+    "owner": "Sahibi",
+    "phone": "Telefon",
+    "email": "E-posta",
+    "tyres": "Lastikler",
+    "purchasedOn": "Alış tarihi",
+    "spent": "Harcanan",
+    "spentOver": "{count, plural, =0 {henüz ziyaret yok} one {# ziyarette} other {# ziyarette}}",
+    "openItems": "Açık",
+    "addOpenItem": "Ekle",
+    "nothingOpen": "Açık kayıt yok",
+    "observationItem": "Gözlem · {severity}",
+    "reminderItem": "Hatırlatıcı · {due}",
+    "newMenu": "Yeni",
+    "closePanel": "Kapat",
+    "feed": {
+      "all": "Tümü",
+      "workOrders": "İş emirleri",
+      "search": "Geçmişte ara",
+      "now": "Şu an",
+      "openJob": "İşi aç",
+      "yearSummary": "{count, plural, one {# ziyaret} other {# ziyaret}} · {total}",
+      "showOlder": "{count} eski kaydı göster",
+      "empty": "Henüz geçmiş yok",
+      "noMatches": "Aramanızla eşleşen sonuç bulunamadı",
+      "quote": "Teklif",
+      "inspectionOpen": "Devam ediyor",
+      "inspectionResult": "{passed} geçti",
+      "inspectionFailed": "{count} ilgi gerektiriyor"
+    },
+    "layout": {
+      "inviteTitle": "Yeni araç sayfasını deneyin.",
+      "inviteBody": "Araçla ilgili her şey tek ekranda, tüm geçmişi de tek bir zaman çizelgesinde. İstediğiniz zaman eskisine dönebilirsiniz.",
+      "tryIt": "Dene",
+      "hideInvite": "Gizle",
+      "tryNewMenu": "Yeni düzeni dene",
+      "useClassicMenu": "Klasik düzene geç",
+      "backHintTitle": "Buradan geri dönebilirsiniz",
+      "backHintBody": "Bu menüden istediğiniz zaman klasik düzene geçebilirsiniz."
+    },
+    "exportPreparing": "Servis geçmişi PDF'i hazırlanıyor…",
+    "exportDone": "Servis geçmişi indirildi",
+    "exportFailed": "PDF oluşturulamadı. Tekrar deneyin.",
+    "jobWarning": {
+      "heading": "Bu randevunun kontrol edilmesi gerekiyor",
+      "fix": "Düzeltmek için işi açın",
+      "away": "{count, plural, one {{names} müsait değil} other {{names} müsait değil}}",
+      "clash": "Çift randevu",
+      "closed": "Atölye kapalı",
+      "onJob": "İş emri {job} · {date}"
+    }
   },
   },
   "quotes": {
   "quotes": {
     "newQuote": "Yeni teklif",
     "newQuote": "Yeni teklif",
@@ -378,7 +432,20 @@
     "lookupVin": "VIN sorgula",
     "lookupVin": "VIN sorgula",
     "lookupEnterVin": "Önce bir VIN yazın",
     "lookupEnterVin": "Önce bir VIN yazın",
     "lookupVinNotFound": "Bu VIN ile araç bulunamadı",
     "lookupVinNotFound": "Bu VIN ile araç bulunamadı",
-    "lookupVinFailed": "VIN sorgulanamadı"
+    "lookupVinFailed": "VIN sorgulanamadı",
+    "sectionIdentification": "Kimlik bilgileri",
+    "sectionVehicle": "Araç",
+    "sectionVehicleMarine": "Tekne",
+    "lookupShort": "Sorgula",
+    "decodeShort": "Çözümle",
+    "vinLength": "{total} karakterden {count} tanesi",
+    "notSet": "Belirlenmedi",
+    "fromRegistry": "Sicilden alındı",
+    "removePhoto": "Fotoğrafı kaldır",
+    "deleteVehicle": "Aracı sil",
+    "deleteVehicleMarine": "Tekneyi sil",
+    "editMileage": "Kilometreyi değiştir",
+    "editMileageMarine": "Motor saatlerini değiştir"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Aracı Arşivle",
     "title": "Aracı Arşivle",

+ 131 - 0
src/__tests__/api/vehicle-handoff.test.ts

@@ -0,0 +1,131 @@
+/**
+ * @vitest-environment node
+ *
+ * A vehicle's "Add from phone": the signed link its QR code carries, and the
+ * public route a phone posts the vehicle's photo to with it. Nobody is signed
+ * in on that route, so what it lets through, and which vehicle it may touch,
+ * are the point.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({
+  db: { vehicle: { findFirst: vi.fn(), updateMany: vi.fn() } },
+}))
+vi.mock('@/lib/backup/freeze', () => ({
+  isRestoring: vi.fn(() => false),
+  RESTORE_IN_PROGRESS_MESSAGE: 'A restore is in progress.',
+}))
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+vi.mock('@/lib/image-upload.server', () => ({ compressPhoto: vi.fn() }))
+vi.mock('@/lib/files/manager', () => ({
+  releaseFiles: vi.fn(async () => undefined),
+  discardUnsavedUpload: vi.fn(async () => undefined),
+}))
+vi.mock('@/lib/upload-root', () => ({ uploadsRoot: () => '/tmp/uploads' }))
+vi.mock('node:fs/promises', () => {
+  const fs = { mkdir: vi.fn(), writeFile: vi.fn() }
+  return { ...fs, default: fs }
+})
+
+import { writeFile } from 'node:fs/promises'
+import { db } from '@/lib/db'
+import { isRestoring } from '@/lib/backup/freeze'
+import { compressPhoto } from '@/lib/image-upload.server'
+import { releaseFiles } from '@/lib/files/manager'
+import { createVehicleHandoffToken } from '@/lib/photo-handoff'
+import { POST } from '@/app/api/public/vehicle-handoff/[token]/route'
+
+const HANDOFF = { organizationId: 'org-1', vehicleId: 'veh-1', userId: 'user-1' }
+const OLD_PHOTO = '/api/protected/files/org-1/vehicles/old.jpg'
+
+function post(token: string, file?: File) {
+  const body = new FormData()
+  if (file) body.append('file', file)
+  return POST(
+    new Request(`http://app.test/api/public/vehicle-handoff/${token}`, { method: 'POST', body }),
+    { params: Promise.resolve({ token }) }
+  )
+}
+
+const photo = () =>
+  new File([new Uint8Array([0xff, 0xd8, 0xff, 0xe0])], 'IMG_0001.HEIC', { type: 'image/heic' })
+
+beforeEach(() => {
+  process.env.BETTER_AUTH_SECRET = 'test-secret-for-vehicle-handoff'
+  vi.mocked(db.vehicle.findFirst)
+    .mockReset()
+    .mockResolvedValue({ id: 'veh-1', imageUrl: OLD_PHOTO } as never)
+  vi.mocked(db.vehicle.updateMany).mockReset().mockResolvedValue({ count: 1 })
+  vi.mocked(isRestoring).mockReset().mockReturnValue(false)
+  vi.mocked(compressPhoto)
+    .mockReset()
+    .mockResolvedValue({ data: Buffer.from([1, 2, 3]), width: 1600, height: 1200 })
+  vi.mocked(releaseFiles).mockClear()
+  vi.mocked(writeFile).mockClear()
+})
+
+describe("a vehicle's phone photo", () => {
+  it('becomes the photo of that vehicle, and the old one is let go', async () => {
+    const { token } = createVehicleHandoffToken(HANDOFF)
+    const res = await post(token, photo())
+
+    expect(res.status).toBe(200)
+    expect(writeFile).toHaveBeenCalledTimes(1)
+    // Scoped to the workshop in the code, not just the vehicle id.
+    expect(db.vehicle.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'veh-1', organizationId: 'org-1' } })
+    )
+    expect(db.vehicle.updateMany).toHaveBeenCalledWith({
+      where: { id: 'veh-1', organizationId: 'org-1' },
+      data: {
+        imageUrl: expect.stringMatching(/^\/api\/protected\/files\/org-1\/vehicles\/.+\.jpg$/),
+      },
+    })
+    expect(releaseFiles).toHaveBeenCalledWith(
+      [OLD_PHOTO],
+      expect.objectContaining({ organizationId: 'org-1' })
+    )
+  })
+
+  it('says an old code has expired and writes nothing', async () => {
+    const now = Date.now()
+    const { token } = createVehicleHandoffToken(HANDOFF, now - 31 * 60 * 1000)
+    const res = await post(token, photo())
+
+    expect(res.status).toBe(410)
+    expect(writeFile).not.toHaveBeenCalled()
+    expect(db.vehicle.updateMany).not.toHaveBeenCalled()
+  })
+
+  it('touches nothing when the vehicle is not in that workshop', async () => {
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(null)
+    const { token } = createVehicleHandoffToken(HANDOFF)
+    const res = await post(token, photo())
+
+    expect(res.status).toBe(404)
+    expect(writeFile).not.toHaveBeenCalled()
+    expect(db.vehicle.updateMany).not.toHaveBeenCalled()
+  })
+
+  it('refuses a file that is not a photo', async () => {
+    const { token } = createVehicleHandoffToken(HANDOFF)
+    const pdf = new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], 'bill.pdf', {
+      type: 'application/pdf',
+    })
+    const res = await post(token, pdf)
+
+    expect(res.status).toBe(400)
+    expect(await res.json()).toEqual({ code: 'type' })
+    expect(writeFile).not.toHaveBeenCalled()
+  })
+
+  it('waits while the workshop is being restored', async () => {
+    vi.mocked(isRestoring).mockReturnValue(true)
+    const { token } = createVehicleHandoffToken(HANDOFF)
+    const res = await post(token, photo())
+
+    expect(res.status).toBe(409)
+    expect(db.vehicle.findFirst).not.toHaveBeenCalled()
+    expect(writeFile).not.toHaveBeenCalled()
+  })
+})

+ 2 - 2
src/__tests__/app/vehicles/vehicle-detail-dropped-connection.test.tsx

@@ -75,8 +75,8 @@ vi.mock('@/features/inspections/Components/NewInspectionDialog', () => ({
   NewInspectionDialog: () => null,
   NewInspectionDialog: () => null,
 }))
 }))
 vi.mock('@/features/quotes/Components/NewQuoteDialog', () => ({ NewQuoteDialog: () => null }))
 vi.mock('@/features/quotes/Components/NewQuoteDialog', () => ({ NewQuoteDialog: () => null }))
-vi.mock('@/app/(authenticated)/vehicles/[id]/service-records-table', () => ({
-  ServiceRecordsTable: () => null,
+vi.mock('@/app/(authenticated)/vehicles/[id]/vehicle-activity-feed', () => ({
+  VehicleActivityFeed: () => null,
 }))
 }))
 vi.mock('@/app/(authenticated)/vehicles/[id]/notes-table', () => ({ NotesTable: () => null }))
 vi.mock('@/app/(authenticated)/vehicles/[id]/notes-table', () => ({ NotesTable: () => null }))
 vi.mock('@/app/(authenticated)/vehicles/[id]/findings-table', () => ({
 vi.mock('@/app/(authenticated)/vehicles/[id]/findings-table', () => ({

+ 173 - 0
src/__tests__/app/vehicles/vehicle-job-warnings-feed.test.tsx

@@ -0,0 +1,173 @@
+/**
+ * A job whose booking will not work is flagged on the vehicle page itself,
+ * on the job's own line, with the way into the job to fix it. The car's
+ * current job spells the problem out; a job further down says it in a word.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { render, screen, within } from '@testing-library/react'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import {
+  type FeedWorkOrder,
+  VehicleActivityFeed,
+} from '@/app/(authenticated)/vehicles/[id]/vehicle-activity-feed'
+import type { VehicleJobWarning } from '@/features/vehicles/Lib/vehicleJobWarnings'
+
+const day = (d: number, h = 9) => new Date(2026, 10, d, h)
+
+function workOrder(over: Partial<FeedWorkOrder>): FeedWorkOrder {
+  return {
+    id: 'wo',
+    title: 'Job',
+    type: 'maintenance',
+    status: 'completed',
+    cost: 0,
+    totalAmount: 0,
+    mileage: null,
+    serviceDate: day(1),
+    startDateTime: null,
+    invoiceNumber: null,
+    techName: null,
+    warrantyMileage: null,
+    warrantyExpiresAt: null,
+    _count: { attachments: 0 },
+    ...over,
+  }
+}
+
+const inWorkshop = workOrder({
+  id: 'wo-now',
+  title: 'Brake service',
+  status: 'in-progress',
+  startDateTime: day(16),
+  invoiceNumber: '2026-1071',
+  techName: 'Ola Nordmann',
+})
+const later = workOrder({
+  id: 'wo-later',
+  title: 'Tyre change',
+  status: 'pending',
+  startDateTime: day(20),
+  invoiceNumber: '2026-1080',
+})
+const done = workOrder({ id: 'wo-done', title: 'Oil change', serviceDate: day(2) })
+
+const warning = (over: Partial<VehicleJobWarning>): VehicleJobWarning => ({
+  serviceRecordId: 'wo',
+  title: 'Job',
+  invoiceNumber: null,
+  start: day(16).toISOString(),
+  closures: [],
+  closedWeekday: false,
+  away: [],
+  clashes: [],
+  ...over,
+})
+
+function renderFeed(jobWarnings: VehicleJobWarning[], workOrders = [inWorkshop, later, done]) {
+  return render(
+    <TooltipProvider>
+      <VehicleActivityFeed
+        vehicleId="veh-1"
+        filter="all"
+        search=""
+        workOrders={workOrders}
+        quotes={[]}
+        inspections={[]}
+        notes={[]}
+        distUnit="km"
+        currencyCode="NOK"
+        vehicleMileage={0}
+        jobWarnings={jobWarnings}
+        onSelectNote={() => undefined}
+      />
+    </TooltipProvider>
+  )
+}
+
+describe('booking warnings in the vehicle feed', () => {
+  it('spells out the problem on the current job, with a way in to fix it', () => {
+    renderFeed([
+      warning({
+        serviceRecordId: 'wo-now',
+        away: [
+          {
+            people: ['Ola Nordmann'],
+            reason: 'holiday',
+            // The whole of Monday and Tuesday.
+            start: new Date(2026, 10, 16).toISOString(),
+            end: new Date(2026, 10, 18).toISOString(),
+          },
+        ],
+      }),
+    ])
+
+    const callout = screen.getByTestId('job-warning-callout')
+    expect(within(callout).getByText('This booking needs attention')).toBeInTheDocument()
+    expect(
+      within(callout).getByText('Ola Nordmann: Away (On leave) · Nov 16 – Nov 17')
+    ).toBeInTheDocument()
+    expect(within(callout).getByRole('link', { name: /Open the job to fix it/ })).toHaveAttribute(
+      'href',
+      '/vehicles/veh-1/service/wo-now'
+    )
+  })
+
+  it('spells it out on a job behind the first one in the workshop too', () => {
+    // Older, so it is the first job in the card; the warned one comes second.
+    const first = workOrder({
+      id: 'wo-first',
+      title: 'Oil service',
+      status: 'in-progress',
+      startDateTime: day(1),
+    })
+    renderFeed(
+      [
+        warning({
+          serviceRecordId: 'wo-now',
+          closures: ['Staff day'],
+        }),
+      ],
+      [first, inWorkshop, done]
+    )
+
+    const callout = screen.getByTestId('job-warning-callout')
+    expect(within(callout).getByText('The workshop is closed then: Staff day')).toBeInTheDocument()
+    expect(within(callout).getByRole('link', { name: /Open the job to fix it/ })).toHaveAttribute(
+      'href',
+      '/vehicles/veh-1/service/wo-now'
+    )
+  })
+
+  it('says it in a word on a job further down, linking to that job', () => {
+    renderFeed([
+      warning({
+        serviceRecordId: 'wo-later',
+        clashes: [
+          {
+            label: 'Volvo XC60 AB12345',
+            start: day(20, 10).toISOString(),
+            end: day(20, 12).toISOString(),
+            onTechnician: false,
+            onBay: true,
+            people: [],
+          },
+        ],
+        closures: ['Staff day'],
+      }),
+    ])
+
+    const chip = screen.getByTestId('job-warning')
+    // The first problem, and how many more there are behind it.
+    expect(chip).toHaveTextContent('Double-booked+1')
+    expect(chip.closest('a')).toHaveAttribute('href', '/vehicles/veh-1/service/wo-later')
+    expect(screen.queryByTestId('job-warning-callout')).not.toBeInTheDocument()
+  })
+
+  it('flags nothing when no job has a problem', () => {
+    renderFeed([])
+    expect(screen.queryByTestId('job-warning')).not.toBeInTheDocument()
+    expect(screen.queryByTestId('job-warning-callout')).not.toBeInTheDocument()
+    expect(screen.getByText('Brake service')).toBeInTheDocument()
+  })
+})

+ 41 - 0
src/__tests__/components/feature-hint-card.test.tsx

@@ -167,3 +167,44 @@ describe('where an announcement is drawn', () => {
     expect(scroll).not.toHaveBeenCalled()
     expect(scroll).not.toHaveBeenCalled()
   })
   })
 })
 })
+
+describe("a hint about one person's own switch", () => {
+  async function renderOwn(open: boolean, onDismiss: () => void) {
+    render(
+      // Nothing pending for the workshop: the caller alone decides.
+      <FeatureHintProvider initialSeen={[]} pending={[]}>
+        <FeatureHint
+          id="vehicle-page.back-to-classic.v1"
+          eligible
+          open={open}
+          onDismiss={onDismiss}
+          title="The way back is here"
+          body="Switch to the classic layout from this menu at any time."
+        >
+          <button type="button">More</button>
+        </FeatureHint>
+      </FeatureHintProvider>
+    )
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 0))
+    })
+  }
+
+  it('shows when the caller says so, and hands "Got it" back to the caller', async () => {
+    const onDismiss = vi.fn()
+    await renderOwn(true, onDismiss)
+
+    expect(screen.getByText('The way back is here')).toBeInTheDocument()
+    act(() => {
+      screen.getByText('Got it').click()
+    })
+    expect(onDismiss).toHaveBeenCalled()
+    // Seen by this person, not marked seen for the whole workshop.
+    expect(dismissFeatureHint).not.toHaveBeenCalled()
+  })
+
+  it('stays away when the caller has nothing to show', async () => {
+    await renderOwn(false, vi.fn())
+    expect(screen.queryByText('The way back is here')).not.toBeInTheDocument()
+  })
+})

+ 187 - 0
src/__tests__/features/vehicles/get-vehicle-job-warnings.test.ts

@@ -0,0 +1,187 @@
+/**
+ * The vehicle page's booking warnings read only this workshop's jobs for this
+ * vehicle, only jobs still to be done, and name technicians from this
+ * workshop alone.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    serviceRecord: { findMany: vi.fn() },
+    technician: { findMany: vi.fn() },
+  },
+}))
+
+vi.mock('@/features/workboard/Lib/bookings', () => ({
+  loadBookingContext: vi.fn(),
+  MAX_WINDOW_DAYS: 400,
+}))
+
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { loadBookingContext } from '@/features/workboard/Lib/bookings'
+import { getVehicleJobWarnings } from '@/features/vehicles/Actions/getVehicleJobWarnings'
+import { absenceBookings } from '@/features/workboard/Lib/availability'
+import { zonedDate } from '@/lib/timezone'
+import { defaultWeek, type WorkshopCalendar } from '@/features/workshop-calendar/Lib/calendar'
+
+const ORG = 'org-a'
+const OSLO = 'Europe/Oslo'
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-a', email: 'a@example.com' },
+  } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never)
+})
+
+describe('getVehicleJobWarnings', () => {
+  it('asks only for this workshop’s open, not yet finished jobs on this vehicle', async () => {
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([])
+    const result = await getVehicleJobWarnings('veh-1')
+
+    expect(result).toEqual({ success: true, data: [] })
+    const where = vi.mocked(db.serviceRecord.findMany).mock.calls[0][0]?.where
+    expect(where).toMatchObject({
+      organizationId: ORG,
+      vehicleId: 'veh-1',
+      status: { notIn: ['completed', 'cancelled'] },
+      startDateTime: { not: null },
+      endDateTime: { gt: expect.any(Date) },
+    })
+    // Nothing booked, so the workshop's calendar is not read at all.
+    expect(loadBookingContext).not.toHaveBeenCalled()
+  })
+
+  it('reports a technician away on a booked job, named from this workshop', async () => {
+    const start = zonedDate(2026, 11, 16, 9, 0, OSLO)
+    const end = zonedDate(2026, 11, 16, 12, 0, OSLO)
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      {
+        id: 'job-1',
+        title: 'Brake service',
+        invoiceNumber: '2026-1071',
+        startDateTime: start,
+        endDateTime: end,
+        technicianId: 'tech-ola',
+        workBayId: null,
+        helpers: [],
+      },
+    ] as never)
+    const calendar: WorkshopCalendar = {
+      timeZone: OSLO,
+      week: defaultWeek('08:00', '16:00'),
+      closures: [],
+      absences: [
+        {
+          id: 'abs-1',
+          technicianId: 'tech-ola',
+          startDate: '2026-11-16',
+          endDate: '2026-11-17',
+          reason: 'holiday',
+        },
+      ],
+    }
+    vi.mocked(loadBookingContext).mockResolvedValue({
+      bookings: absenceBookings(calendar),
+      hours: { start: '08:00', end: '16:00', timeZone: OSLO, calendar },
+      closuresEnabled: true,
+    })
+    vi.mocked(db.technician.findMany).mockResolvedValue([
+      { id: 'tech-ola', name: 'Ola Nordmann' },
+    ] as never)
+
+    const result = await getVehicleJobWarnings('veh-1')
+
+    expect(loadBookingContext).toHaveBeenCalledWith(ORG, start, expect.any(Object))
+    expect(vi.mocked(db.technician.findMany).mock.calls[0][0]?.where).toEqual({
+      organizationId: ORG,
+    })
+    expect(result.success).toBe(true)
+    expect(result.data).toEqual([
+      expect.objectContaining({
+        serviceRecordId: 'job-1',
+        away: [expect.objectContaining({ people: ['Ola Nordmann'], reason: 'holiday' })],
+      }),
+    ])
+  })
+
+  it('reads the workshop once for several jobs, from the first to past the last', async () => {
+    const first = zonedDate(2026, 11, 16, 9, 0, OSLO)
+    const lastEnd = zonedDate(2026, 11, 30, 12, 0, OSLO)
+    const job = (id: string, s: Date, e: Date) => ({
+      id,
+      title: id,
+      invoiceNumber: null,
+      startDateTime: s,
+      endDateTime: e,
+      technicianId: null,
+      workBayId: null,
+      helpers: [],
+    })
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      job('a', first, zonedDate(2026, 11, 16, 12, 0, OSLO)),
+      job('b', zonedDate(2026, 11, 30, 9, 0, OSLO), lastEnd),
+    ] as never)
+    vi.mocked(loadBookingContext).mockResolvedValue({
+      bookings: [],
+      hours: { start: '08:00', end: '16:00', timeZone: OSLO },
+      closuresEnabled: false,
+    })
+    vi.mocked(db.technician.findMany).mockResolvedValue([])
+
+    await getVehicleJobWarnings('veh-1')
+
+    expect(loadBookingContext).toHaveBeenCalledTimes(1)
+    const [, from, opts] = vi.mocked(loadBookingContext).mock.calls[0]
+    expect(from).toEqual(first)
+    const reach = from.getTime() + (opts?.days ?? 0) * 86_400_000
+    expect(reach).toBeGreaterThan(lastEnd.getTime())
+  })
+
+  it('reads each window of jobs far apart on its own, within the loader’s limit', async () => {
+    const job = (id: string, s: Date, e: Date) => ({
+      id,
+      title: id,
+      invoiceNumber: null,
+      startDateTime: s,
+      endDateTime: e,
+      technicianId: null,
+      workBayId: null,
+      helpers: [],
+    })
+    const near = zonedDate(2026, 11, 16, 9, 0, OSLO)
+    const far = zonedDate(2028, 3, 1, 9, 0, OSLO)
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      job('a', near, zonedDate(2026, 11, 16, 12, 0, OSLO)),
+      job('b', far, zonedDate(2028, 3, 1, 12, 0, OSLO)),
+    ] as never)
+    vi.mocked(loadBookingContext).mockResolvedValue({
+      bookings: [],
+      hours: { start: '08:00', end: '16:00', timeZone: OSLO },
+      closuresEnabled: false,
+    })
+    vi.mocked(db.technician.findMany).mockResolvedValue([])
+
+    const result = await getVehicleJobWarnings('veh-1')
+
+    expect(result.success).toBe(true)
+    const calls = vi.mocked(loadBookingContext).mock.calls
+    expect(calls.map(([, from]) => from)).toEqual([near, far])
+    for (const [, , opts] of calls) expect(opts?.days ?? 0).toBeLessThanOrEqual(400)
+  })
+})

+ 228 - 0
src/__tests__/features/vehicles/vehicle-form-dialog.test.tsx

@@ -0,0 +1,228 @@
+/**
+ * The edit vehicle dialog: which vehicle is open, the choices in sight, the
+ * VIN counted as it is typed, delete handed to the page, and a dialog closed
+ * without saving that opens again on what is saved.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ refresh: vi.fn(), push: vi.fn(), replace: vi.fn() }),
+  usePathname: () => '/vehicles/veh-1',
+  useSearchParams: () => new URLSearchParams(),
+}))
+vi.mock('@/components/glass-modal', () => ({ useGlassModal: () => ({ open: vi.fn() }) }))
+vi.mock('@/features/vehicles/Actions/vehicleActions', () => ({
+  createVehicle: vi.fn(),
+  updateVehicle: vi.fn(async () => ({ success: true })),
+}))
+vi.mock('@/features/customers/Actions/customerActions', () => ({
+  createCustomer: vi.fn(),
+  findOwnerCandidates: vi.fn(async () => ({ success: true, data: [] })),
+  getCustomerOption: vi.fn(async () => ({ success: true, data: null })),
+  searchCustomers: vi.fn(async () => ({ success: true, data: [] })),
+}))
+vi.mock('@/features/vehicles/Actions/aiAnalyzeVehicleDocument', () => ({
+  aiAnalyzeVehicleDocument: vi.fn(),
+  isVehicleScanAvailable: vi.fn(async () => ({ success: true, data: false })),
+}))
+vi.mock('@/features/integrations/Actions/vehicleLookupActions', () => ({
+  isVehicleLookupAvailable: vi.fn(async () => ({ success: true, data: true })),
+  lookupVehicle: vi.fn(),
+}))
+vi.mock('@/features/vehicles/Actions/photoHandoffActions', () => ({
+  countPhotosSince: vi.fn(),
+  createPhotoHandoffLink: vi.fn(),
+  createVehiclePhotoHandoffLink: vi.fn(),
+  getVehiclePhotoUrl: vi.fn(),
+}))
+vi.mock('@/features/inspections/Actions/attachmentActions', () => ({
+  countInspectionMedia: vi.fn(),
+  createInspectionHandoffLink: vi.fn(),
+}))
+
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { VehicleForm } from '@/features/vehicles/Components/VehicleForm'
+import { updateVehicle } from '@/features/vehicles/Actions/vehicleActions'
+
+const VEHICLE = {
+  id: 'veh-1',
+  make: 'BMW',
+  model: '5-Serie F11',
+  year: 2011,
+  vin: 'WBAMX11090C883495',
+  licensePlate: 'BS48364',
+  color: null,
+  mileage: 225040,
+  fuelType: 'diesel',
+  transmission: 'automatic',
+  engineSize: null,
+  engineCode: null,
+  imageUrl: null,
+  customerId: null,
+  customer: null,
+  inspectionStatus: null,
+}
+
+function renderForm(props: Partial<React.ComponentProps<typeof VehicleForm>> = {}) {
+  const onOpenChange = vi.fn()
+  const view = render(
+    <TooltipProvider>
+      <VehicleForm
+        open
+        onOpenChange={onOpenChange}
+        vehicle={VEHICLE}
+        distanceUnit="km"
+        {...props}
+      />
+    </TooltipProvider>
+  )
+  return { ...view, onOpenChange }
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+})
+
+describe('edit vehicle dialog', () => {
+  it('names the vehicle open, with its plate and what it is', async () => {
+    renderForm()
+    const dialog = await screen.findByRole('dialog')
+    expect(within(dialog).getByRole('heading', { name: /BMW 5-Serie F11/ })).toBeInTheDocument()
+    expect(within(dialog).getAllByText('BS48364').length).toBeGreaterThan(0)
+    expect(within(dialog).getByText('2011 · Diesel · Automatic')).toBeInTheDocument()
+  })
+
+  it('shows every fuel and gearbox at once, with the saved one chosen', async () => {
+    renderForm()
+    const fuel = await screen.findByRole('radiogroup', { name: 'Fuel Type' })
+    expect(within(fuel).getByRole('radio', { name: 'Diesel' })).toHaveAttribute(
+      'aria-checked',
+      'true'
+    )
+    fireEvent.click(within(fuel).getByRole('radio', { name: 'Electric' }))
+    expect(within(fuel).getByRole('radio', { name: 'Electric' })).toHaveAttribute(
+      'aria-checked',
+      'true'
+    )
+    expect(within(fuel).getByRole('radio', { name: 'Diesel' })).toHaveAttribute(
+      'aria-checked',
+      'false'
+    )
+    const gearbox = screen.getByRole('radiogroup', { name: 'Transmission' })
+    expect(within(gearbox).getByRole('radio', { name: 'Automatic' })).toHaveAttribute(
+      'aria-checked',
+      'true'
+    )
+  })
+
+  it('keeps a fuel the registry wrote that the form does not offer, chosen', async () => {
+    renderForm({ vehicle: { ...VEHICLE, fuelType: 'Hydrogen' } })
+    const fuel = await screen.findByRole('radiogroup', { name: 'Fuel Type' })
+    expect(within(fuel).getByRole('radio', { name: 'Hydrogen' })).toHaveAttribute(
+      'aria-checked',
+      'true'
+    )
+  })
+
+  it('counts the VIN as it is typed', async () => {
+    renderForm()
+    expect(await screen.findByText('17 of 17 characters')).toBeInTheDocument()
+    const vin = screen.getByLabelText('VIN')
+    fireEvent.input(vin, { target: { value: 'WBAMX11090C88349' } })
+    expect(screen.getByText('16 of 17 characters')).toBeInTheDocument()
+  })
+
+  it('shows the mileage with its unit, and follows the field', async () => {
+    renderForm()
+    expect(await screen.findByText('225,040')).toBeInTheDocument()
+    fireEvent.change(screen.getByLabelText('Mileage'), { target: { value: '226100' } })
+    expect(screen.getByText('226,100')).toBeInTheDocument()
+  })
+
+  it('changes the mileage where it is shown at the top, and the field follows', async () => {
+    renderForm()
+    fireEvent.click(await screen.findByRole('button', { name: 'Change mileage: 225,040 km' }))
+    // The reading opens to type in straight away, in place.
+    const editor = document.activeElement as HTMLInputElement
+    expect(editor).toHaveAttribute('aria-label', 'Mileage')
+    expect(editor).not.toHaveAttribute('name')
+    const field = document.querySelector('input[name="mileage"]')
+    fireEvent.change(editor, { target: { value: '231500' } })
+    // Enter keeps the reading without saving the form.
+    fireEvent.keyDown(editor, { key: 'Enter' })
+    expect(screen.getByRole('button', { name: 'Change mileage: 231,500 km' })).toBeInTheDocument()
+    expect(field).toHaveValue(231500)
+    expect(screen.getByRole('dialog')).toBeInTheDocument()
+  })
+
+  it('puts the reading back on Escape, and keeps the dialog open', async () => {
+    const { onOpenChange } = renderForm()
+    fireEvent.click(await screen.findByRole('button', { name: 'Change mileage: 225,040 km' }))
+    const editor = document.activeElement as HTMLInputElement
+    fireEvent.change(editor, { target: { value: '999' } })
+    fireEvent.keyDown(editor, { key: 'Escape' })
+    expect(screen.getByRole('button', { name: 'Change mileage: 225,040 km' })).toBeInTheDocument()
+    expect(screen.getByLabelText('Mileage')).toHaveValue(225040)
+    expect(onOpenChange).not.toHaveBeenCalledWith(false)
+  })
+
+  it('hands delete to the page and closes itself', async () => {
+    const onDelete = vi.fn()
+    renderForm({ onDelete })
+    fireEvent.click(await screen.findByRole('button', { name: /Delete vehicle/ }))
+    expect(onDelete).toHaveBeenCalledTimes(1)
+  })
+
+  it('offers no delete where the page gives none, nor when adding', async () => {
+    renderForm({ onDelete: undefined })
+    await screen.findByRole('dialog')
+    expect(screen.queryByRole('button', { name: /Delete vehicle/ })).not.toBeInTheDocument()
+  })
+
+  it('opens again on what is saved after closing without saving', async () => {
+    const { rerender } = renderForm()
+    const fuel = await screen.findByRole('radiogroup', { name: 'Fuel Type' })
+    fireEvent.click(within(fuel).getByRole('radio', { name: 'Electric' }))
+
+    rerender(
+      <TooltipProvider>
+        <VehicleForm open={false} onOpenChange={vi.fn()} vehicle={VEHICLE} />
+      </TooltipProvider>
+    )
+    await act(async () => undefined)
+    rerender(
+      <TooltipProvider>
+        <VehicleForm open onOpenChange={vi.fn()} vehicle={VEHICLE} />
+      </TooltipProvider>
+    )
+
+    const again = await screen.findByRole('radiogroup', { name: 'Fuel Type' })
+    expect(within(again).getByRole('radio', { name: 'Diesel' })).toHaveAttribute(
+      'aria-checked',
+      'true'
+    )
+  })
+})
+
+describe('saving the photo', () => {
+  const withPhoto = { ...VEHICLE, imageUrl: '/api/protected/files/org-a/vehicles/old.jpg' }
+
+  it('leaves the photo out of a save that did not change it', async () => {
+    // A phone may have replaced it meanwhile; sending the old address back
+    // would undo that.
+    renderForm({ vehicle: withPhoto })
+    fireEvent.click(await screen.findByRole('button', { name: 'Save Changes' }))
+    await waitFor(() => expect(updateVehicle).toHaveBeenCalled())
+    expect(vi.mocked(updateVehicle).mock.calls[0][0]).not.toHaveProperty('imageUrl')
+  })
+
+  it('saves a removed photo as removed', async () => {
+    renderForm({ vehicle: withPhoto })
+    fireEvent.click(await screen.findByRole('button', { name: 'Remove photo' }))
+    fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
+    await waitFor(() => expect(updateVehicle).toHaveBeenCalled())
+    expect(vi.mocked(updateVehicle).mock.calls[0][0]).toMatchObject({ imageUrl: '' })
+  })
+})

+ 265 - 0
src/__tests__/features/vehicles/vehicle-job-warnings.test.ts

@@ -0,0 +1,265 @@
+/**
+ * What the vehicle page flags on a job's booking: the same problems the work
+ * order shows once opened (a technician or helper away, the person or bay
+ * already taken, the workshop closed), and nothing for a job that is fine.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { zonedDate } from '@/lib/timezone'
+import { type Booking, absenceBookings } from '@/features/workboard/Lib/availability'
+import type { BookingContext } from '@/features/workboard/Lib/bookings'
+import { type WorkshopCalendar, defaultWeek } from '@/features/workshop-calendar/Lib/calendar'
+import {
+  type BookedJob,
+  buildVehicleJobWarnings,
+  planWindows,
+  splitSpan,
+} from '@/features/vehicles/Lib/vehicleJobWarnings'
+
+const OSLO = 'Europe/Oslo'
+const oslo = (d: number, h: number, min = 0) => zonedDate(2026, 11, d, h, min, OSLO)
+
+const NAMES = new Map([
+  ['tech-ola', 'Ola Nordmann'],
+  ['tech-kari', 'Kari Hansen'],
+])
+
+function calendar(over: Partial<WorkshopCalendar> = {}): WorkshopCalendar {
+  return {
+    timeZone: OSLO,
+    week: defaultWeek('08:00', '16:00'),
+    closures: [],
+    absences: [],
+    ...over,
+  }
+}
+
+function context(cal: WorkshopCalendar, bookings: Booking[] = []): BookingContext {
+  return {
+    bookings: [...bookings, ...absenceBookings(cal)],
+    hours: { start: '08:00', end: '16:00', timeZone: OSLO, calendar: cal },
+    closuresEnabled: true,
+  }
+}
+
+// Monday 16 November 2026, nine to twelve, Ola on it in bay 1.
+const JOB: BookedJob = {
+  id: 'job-1',
+  title: 'Brake service',
+  invoiceNumber: '2026-1071',
+  start: oslo(16, 9),
+  end: oslo(16, 12),
+  technicianId: 'tech-ola',
+  helperIds: [],
+  workBayId: 'bay-1',
+}
+
+describe('booking warnings on the vehicle page', () => {
+  it('says nothing about a job that is fine', () => {
+    expect(buildVehicleJobWarnings([JOB], context(calendar()), NAMES)).toEqual([])
+  })
+
+  it('names the technician who is away, with why and when', () => {
+    const cal = calendar({
+      absences: [
+        {
+          id: 'abs-1',
+          technicianId: 'tech-ola',
+          startDate: '2026-11-16',
+          endDate: '2026-11-17',
+          reason: 'holiday',
+        },
+      ],
+    })
+    const [warning] = buildVehicleJobWarnings([JOB], context(cal), NAMES)
+    expect(warning).toMatchObject({
+      serviceRecordId: 'job-1',
+      invoiceNumber: '2026-1071',
+      title: 'Brake service',
+      away: [{ people: ['Ola Nordmann'], reason: 'holiday' }],
+      clashes: [],
+      closures: [],
+      closedWeekday: false,
+    })
+    // From the Monday to the midnight after the Tuesday, on the workshop's clock.
+    expect(new Date(warning.away[0].start)).toEqual(oslo(16, 0))
+    expect(new Date(warning.away[0].end)).toEqual(oslo(18, 0))
+  })
+
+  it('names a helper who is away, not only the technician', () => {
+    const cal = calendar({
+      absences: [
+        {
+          id: 'abs-2',
+          technicianId: 'tech-kari',
+          startDate: '2026-11-16',
+          endDate: '2026-11-16',
+          reason: 'sick',
+        },
+      ],
+    })
+    const [warning] = buildVehicleJobWarnings(
+      [{ ...JOB, helperIds: ['tech-kari'] }],
+      context(cal),
+      NAMES
+    )
+    expect(warning.away).toEqual([
+      expect.objectContaining({ people: ['Kari Hansen'], reason: 'sick' }),
+    ])
+  })
+
+  it('flags another job in the same bay, and says it is the bay', () => {
+    const other: Booking = {
+      id: 'job-2',
+      kind: 'serviceRecord',
+      label: 'Volvo XC60 AB12345',
+      start: oslo(16, 10),
+      end: oslo(16, 14),
+      technicianId: 'tech-kari',
+      workBayId: 'bay-1',
+    }
+    const [warning] = buildVehicleJobWarnings([JOB], context(calendar(), [other]), NAMES)
+    expect(warning.clashes).toEqual([
+      {
+        label: 'Volvo XC60 AB12345',
+        start: oslo(16, 10).toISOString(),
+        end: oslo(16, 14).toISOString(),
+        onTechnician: false,
+        onBay: true,
+        people: [],
+      },
+    ])
+  })
+
+  it('names who is double-booked when the clash is on a person', () => {
+    const other: Booking = {
+      id: 'job-3',
+      kind: 'inspection',
+      label: 'Tesla Model 3 EV11111',
+      start: oslo(16, 11),
+      end: oslo(16, 13),
+      technicianId: 'tech-ola',
+      workBayId: 'bay-2',
+    }
+    const [warning] = buildVehicleJobWarnings([JOB], context(calendar(), [other]), NAMES)
+    expect(warning.clashes[0]).toMatchObject({
+      onTechnician: true,
+      onBay: false,
+      people: ['Ola Nordmann'],
+    })
+  })
+
+  it('never flags a job for clashing with itself', () => {
+    const itself: Booking = {
+      id: 'job-1',
+      kind: 'serviceRecord',
+      label: 'Same job',
+      start: JOB.start,
+      end: JOB.end,
+      technicianId: 'tech-ola',
+      workBayId: 'bay-1',
+    }
+    expect(buildVehicleJobWarnings([JOB], context(calendar(), [itself]), NAMES)).toEqual([])
+  })
+
+  it('flags a job booked on a day the workshop is closed, by the closure name', () => {
+    const cal = calendar({
+      closures: [
+        { name: 'Staff day', source: 'closure', startDate: '2026-11-16', endDate: '2026-11-16' },
+      ],
+    })
+    const [warning] = buildVehicleJobWarnings(
+      [{ ...JOB, technicianId: null, workBayId: null }],
+      context(cal),
+      NAMES
+    )
+    expect(warning.closures).toEqual(['Staff day'])
+  })
+
+  it('flags a job on a weekday the workshop does not open', () => {
+    // Sunday 15 November.
+    const sunday = { ...JOB, start: oslo(15, 9), end: oslo(15, 12) }
+    const [warning] = buildVehicleJobWarnings([sunday], context(calendar()), NAMES)
+    expect(warning.closedWeekday).toBe(true)
+  })
+
+  it('keeps the jobs in the order given and leaves out the fine ones', () => {
+    const cal = calendar({
+      absences: [
+        {
+          id: 'abs-3',
+          technicianId: 'tech-ola',
+          startDate: '2026-11-18',
+          endDate: '2026-11-18',
+          reason: 'training',
+        },
+      ],
+    })
+    const fine = JOB
+    const away = { ...JOB, id: 'job-9', start: oslo(18, 9), end: oslo(18, 11) }
+    const result = buildVehicleJobWarnings([fine, away], context(cal), NAMES)
+    expect(result.map((w) => w.serviceRecordId)).toEqual(['job-9'])
+  })
+})
+
+describe('reading bookings in windows', () => {
+  const DAY = 86_400_000
+  const at = (day: number) => new Date(Date.UTC(2027, 0, 1) + day * DAY)
+
+  it('cuts a stretch longer than a window into pieces that each fit', () => {
+    const pieces = splitSpan({ start: at(0), end: at(900) }, 398)
+    expect(pieces.map((p) => [p.start, p.end])).toEqual([
+      [at(0), at(398)],
+      [at(398), at(796)],
+      [at(796), at(900)],
+    ])
+  })
+
+  it('leaves a stretch that fits whole', () => {
+    expect(splitSpan({ start: at(0), end: at(3) }, 398)).toEqual([{ start: at(0), end: at(3) }])
+  })
+
+  it('covers jobs close together with one window and far apart with two', () => {
+    const windows = planWindows(
+      [
+        { start: at(500), end: at(501) },
+        { start: at(0), end: at(1) },
+        { start: at(10), end: at(12) },
+      ],
+      398
+    )
+    expect(windows).toEqual([
+      { start: at(0), end: at(12) },
+      { start: at(500), end: at(501) },
+    ])
+  })
+
+  it('finds a clash past the first window on a job longer than one', () => {
+    // A job booked across 500 days (nobody does, but nothing should hide),
+    // with the technician away near its end.
+    const cal = calendar({
+      absences: [
+        {
+          id: 'abs-far',
+          technicianId: 'tech-ola',
+          startDate: '2028-04-10',
+          endDate: '2028-04-10',
+          reason: 'training',
+        },
+      ],
+    })
+    const long = { ...JOB, start: oslo(16, 9), end: new Date(oslo(16, 9).getTime() + 520 * DAY) }
+    const asked: { start: Date; end: Date }[] = []
+    const [warning] = buildVehicleJobWarnings(
+      [long],
+      (span) => {
+        asked.push(span)
+        return context(cal)
+      },
+      NAMES,
+      398
+    )
+    expect(asked).toHaveLength(2)
+    expect(warning.away).toEqual([expect.objectContaining({ reason: 'training' })])
+  })
+})

+ 76 - 0
src/__tests__/features/vehicles/vehicle-photo-handoff-baseline.test.tsx

@@ -0,0 +1,76 @@
+/**
+ * The vehicle photo from a phone: the photo is read before the code is shown,
+ * so the first one the phone sends is noticed even if it lands before the
+ * dialog's first check, and nothing is reported when nothing changed.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ refresh: vi.fn(), push: vi.fn(), replace: vi.fn() }),
+}))
+vi.mock('@/features/vehicles/Actions/photoHandoffActions', () => ({
+  countPhotosSince: vi.fn(),
+  createPhotoHandoffLink: vi.fn(),
+  createVehiclePhotoHandoffLink: vi.fn(),
+  getVehiclePhotoUrl: vi.fn(),
+}))
+vi.mock('@/features/inspections/Actions/attachmentActions', () => ({
+  countInspectionMedia: vi.fn(),
+  createInspectionHandoffLink: vi.fn(),
+}))
+
+import {
+  createVehiclePhotoHandoffLink,
+  getVehiclePhotoUrl,
+} from '@/features/vehicles/Actions/photoHandoffActions'
+import { PhotoHandoffButton } from '@/features/vehicles/Components/service-page/PhotoHandoffButton'
+
+const OLD = '/api/protected/files/org-a/vehicles/old.jpg'
+const NEW = '/api/protected/files/org-a/vehicles/new.jpg'
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(createVehiclePhotoHandoffLink).mockResolvedValue({
+    success: true,
+    data: { token: 'vh1.token', expiresAt: new Date(Date.now() + 30 * 60_000).toISOString() },
+  } as never)
+})
+
+function open(onVehiclePhoto = vi.fn()) {
+  render(<PhotoHandoffButton vehicleId="veh-1" onVehiclePhoto={onVehiclePhoto} />)
+  fireEvent.click(screen.getByRole('button', { name: 'Add from phone' }))
+  return onVehiclePhoto
+}
+
+describe('vehicle photo from a phone', () => {
+  it('notices a photo that landed before the first check', async () => {
+    // Read once before the code is shown, then the phone has already sent one.
+    vi.mocked(getVehiclePhotoUrl)
+      .mockResolvedValueOnce({ success: true, data: OLD } as never)
+      .mockResolvedValue({ success: true, data: NEW } as never)
+
+    const onVehiclePhoto = open()
+
+    await waitFor(() => expect(onVehiclePhoto).toHaveBeenCalledWith(NEW))
+  })
+
+  it('reports nothing while the photo is still the one it had', async () => {
+    vi.mocked(getVehiclePhotoUrl).mockResolvedValue({ success: true, data: OLD } as never)
+
+    const onVehiclePhoto = open()
+
+    await waitFor(() => expect(getVehiclePhotoUrl).toHaveBeenCalledTimes(2))
+    expect(onVehiclePhoto).not.toHaveBeenCalled()
+  })
+
+  it('shows no code when the photo it would compare with cannot be read', async () => {
+    vi.mocked(getVehiclePhotoUrl).mockResolvedValue({ success: false, error: 'nope' } as never)
+
+    open()
+
+    expect(await screen.findByText('Could not make a code. Try again.')).toBeInTheDocument()
+    expect(screen.queryByText(/vh1\.token/)).not.toBeInTheDocument()
+  })
+})

+ 117 - 0
src/__tests__/features/vehicles/vehicle-photo-save.test.ts

@@ -0,0 +1,117 @@
+/**
+ * Saving a vehicle's photo: a removed photo is saved as none (and its file let
+ * go), and a save that does not name the photo leaves it alone.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+
+vi.mock('@/lib/resolve-upload-path', () => ({
+  resolveUploadPath: vi.fn((url: string) => `/uploads/${url}`),
+}))
+
+// These tests are about workshop scoping, not files: the file manager and the
+// files a delete collects have tests of their own (src/__tests__/lib/files).
+vi.mock('@/lib/files/collect', () => ({
+  serviceRecordFileUrls: vi.fn(async () => []),
+  inspectionFileUrls: vi.fn(async () => []),
+  vehicleFileUrls: vi.fn(async () => []),
+  quoteFileUrls: vi.fn(async () => []),
+  tireSetFileUrls: vi.fn(async () => []),
+  inventoryPartFileUrls: vi.fn(async () => []),
+}))
+vi.mock('@/lib/files/manager', () => ({
+  releaseFiles: vi.fn(async () => ({ removed: [], kept: [], skipped: [] })),
+  parseStoredFileUrl: vi.fn(() => null),
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
+    vehicle: {
+      findFirst: vi.fn(),
+      findMany: vi.fn(),
+      updateMany: vi.fn(),
+      deleteMany: vi.fn(),
+    },
+    quote: { findMany: vi.fn().mockResolvedValue([]) },
+    serviceRecord: {
+      findFirst: vi.fn(),
+      findMany: vi.fn(),
+      delete: vi.fn(),
+    },
+    serviceAttachment: {
+      findFirst: vi.fn(),
+      delete: vi.fn(),
+    },
+    servicePart: { findMany: vi.fn() },
+    inventoryPart: { updateMany: vi.fn() },
+    $transaction: vi.fn(),
+  },
+}))
+
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { updateVehicle } from '@/features/vehicles/Actions/vehicleActions'
+import { releaseFiles } from '@/lib/files/manager'
+
+const mockSession = vi.mocked(getCachedSession)
+const mockMembership = vi.mocked(getCachedMembership)
+const mockUserFindUnique = vi.mocked(db.user.findUnique)
+
+const ORG_A = 'org-a'
+const ORG_B = 'org-b'
+
+function setupOrgAOwner() {
+  mockSession.mockResolvedValue({ user: { id: 'user-a', email: 'a@example.com' } } as any)
+  mockMembership.mockResolvedValue({
+    organizationId: ORG_A,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as any)
+  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
+}
+
+const OLD_PHOTO = `/api/protected/files/${ORG_A}/vehicles/old.jpg`
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
+  setupOrgAOwner()
+  vi.mocked(db.vehicle.findFirst).mockResolvedValue({
+    year: 2011,
+    make: 'BMW',
+    model: '5-Serie',
+    licensePlate: 'BS48364',
+    vin: null,
+    imageUrl: OLD_PHOTO,
+  } as any)
+  vi.mocked(db.vehicle.updateMany).mockResolvedValue({ count: 1 } as any)
+})
+
+describe('updateVehicle and the photo', () => {
+  it('saves a removed photo as no photo and lets the file go', async () => {
+    const result = await updateVehicle({ id: 'veh-a', imageUrl: '' })
+
+    expect(result.success).toBe(true)
+    const data = vi.mocked(db.vehicle.updateMany).mock.calls[0][0].data as Record<string, unknown>
+    expect(data.imageUrl).toBeNull()
+    expect(releaseFiles).toHaveBeenCalledWith([OLD_PHOTO], expect.anything())
+  })
+
+  it('leaves the photo alone when the save does not name it', async () => {
+    await updateVehicle({ id: 'veh-a', make: 'BMW' })
+
+    const data = vi.mocked(db.vehicle.updateMany).mock.calls[0][0].data as Record<string, unknown>
+    expect(data.imageUrl).toBeUndefined()
+    expect(releaseFiles).not.toHaveBeenCalled()
+  })
+})

+ 54 - 0
src/__tests__/lib/vehicle-handoff-token.test.ts

@@ -0,0 +1,54 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import {
+  createPhotoHandoffToken,
+  createVehicleHandoffToken,
+  isVehicleHandoffToken,
+  verifyPhotoHandoffToken,
+  verifyVehicleHandoffToken,
+} from '@/lib/photo-handoff'
+
+const HANDOFF = { organizationId: 'org1', vehicleId: 'veh1', userId: 'user1' }
+
+beforeEach(() => {
+  process.env.BETTER_AUTH_SECRET = 'test-secret-for-vehicle-handoff'
+})
+
+describe("a vehicle's phone code", () => {
+  it('reads back the vehicle it was made for', () => {
+    const { token } = createVehicleHandoffToken(HANDOFF)
+    expect(isVehicleHandoffToken(token)).toBe(true)
+    const check = verifyVehicleHandoffToken(token)
+    expect(check.ok && check.handoff).toMatchObject(HANDOFF)
+  })
+
+  it('stops working after 30 minutes, and says so', () => {
+    const now = Date.now()
+    const { token } = createVehicleHandoffToken(HANDOFF, now)
+    expect(verifyVehicleHandoffToken(token, now + 31 * 60 * 1000)).toEqual({
+      ok: false,
+      reason: 'expired',
+    })
+  })
+
+  it('refuses a code pointed at another vehicle', () => {
+    const { token } = createVehicleHandoffToken(HANDOFF)
+    const [prefix, body, signature] = token.split('.')
+    const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
+    payload.v = 'someone-elses-vehicle'
+    const forged = `${prefix}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.${signature}`
+    expect(verifyVehicleHandoffToken(forged)).toEqual({ ok: false, reason: 'invalid' })
+  })
+
+  it("cannot pass for a work order's code, nor the other way round", () => {
+    const vehicle = createVehicleHandoffToken(HANDOFF).token
+    const job = createPhotoHandoffToken({
+      organizationId: 'org1',
+      serviceRecordId: 'job1',
+      concernId: null,
+      purpose: 'photos',
+      userId: 'user1',
+    }).token
+    expect(verifyPhotoHandoffToken(vehicle).ok).toBe(false)
+    expect(verifyVehicleHandoffToken(job).ok).toBe(false)
+  })
+})

+ 29 - 0
src/__tests__/lib/vehicle-page-layout.test.ts

@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+import { NEW_VEHICLE_PAGE_FROM, parseVehiclePageLayout } from '@/lib/vehicle-page-layout'
+
+const before = new Date(NEW_VEHICLE_PAGE_FROM.getTime() - 86_400_000)
+const after = new Date(NEW_VEHICLE_PAGE_FROM.getTime() + 86_400_000)
+
+describe('which vehicle page a browser gets', () => {
+  it('keeps a workshop that already knew the classic page on it', () => {
+    expect(parseVehiclePageLayout(undefined, before)).toBe('classic')
+  })
+
+  it('starts a workshop made after the new page shipped on the new page', () => {
+    expect(parseVehiclePageLayout(undefined, after)).toBe('modern')
+  })
+
+  it("follows the person's own choice either way", () => {
+    expect(parseVehiclePageLayout('modern', before)).toBe('modern')
+    expect(parseVehiclePageLayout('classic', after)).toBe('classic')
+  })
+
+  it('treats a cookie it does not recognise as no choice', () => {
+    expect(parseVehiclePageLayout('fancy', before)).toBe('classic')
+    expect(parseVehiclePageLayout('fancy', after)).toBe('modern')
+  })
+
+  it('keeps the classic page when the workshop is unknown', () => {
+    expect(parseVehiclePageLayout(undefined, null)).toBe('classic')
+  })
+})

+ 2 - 2
src/__tests__/lib/viewer-access.test.ts

@@ -140,8 +140,8 @@ describe('every page that asks first', () => {
 
 
   it('covers the dashboard and the vehicle page', () => {
   it('covers the dashboard and the vehicle page', () => {
     expect(pages.map((page) => page.path).sort()).toEqual([
     expect(pages.map((page) => page.path).sort()).toEqual([
-      'app/(authenticated)/page.tsx',
-      'app/(authenticated)/vehicles/[id]/page.tsx',
+      'app/(authenticated)/(dashboard)/page.tsx',
+      'app/(authenticated)/vehicles/[id]/(detail)/page.tsx',
     ])
     ])
   })
   })
 
 

+ 9 - 0
src/__tests__/multitenancy/vehicle-service-isolation.test.ts

@@ -49,6 +49,7 @@ vi.mock('@/lib/db', () => ({
     quote: { findMany: vi.fn().mockResolvedValue([]) },
     quote: { findMany: vi.fn().mockResolvedValue([]) },
     serviceRecord: {
     serviceRecord: {
       findFirst: vi.fn(),
       findFirst: vi.fn(),
+      findMany: vi.fn(),
       delete: vi.fn(),
       delete: vi.fn(),
     },
     },
     serviceAttachment: {
     serviceAttachment: {
@@ -139,6 +140,7 @@ describe('getVehicle — cross-org isolation', () => {
   it("returns vehicle data for a vehicle that belongs to the caller's org", async () => {
   it("returns vehicle data for a vehicle that belongs to the caller's org", async () => {
     setupOrgAOwner()
     setupOrgAOwner()
     vi.mocked(db.vehicle.findFirst).mockResolvedValue(ORG_A_VEHICLE as any)
     vi.mocked(db.vehicle.findFirst).mockResolvedValue(ORG_A_VEHICLE as any)
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([])
 
 
     const result = await getVehicle('veh-a')
     const result = await getVehicle('veh-a')
 
 
@@ -149,6 +151,7 @@ describe('getVehicle — cross-org isolation', () => {
   it("db query always scopes vehicles to the caller's organizationId", async () => {
   it("db query always scopes vehicles to the caller's organizationId", async () => {
     setupOrgAOwner()
     setupOrgAOwner()
     vi.mocked(db.vehicle.findFirst).mockResolvedValue(ORG_A_VEHICLE as any)
     vi.mocked(db.vehicle.findFirst).mockResolvedValue(ORG_A_VEHICLE as any)
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([])
 
 
     await getVehicle('veh-a')
     await getVehicle('veh-a')
 
 
@@ -157,6 +160,12 @@ describe('getVehicle — cross-org isolation', () => {
         where: expect.objectContaining({ organizationId: ORG_A }),
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
       })
     )
     )
+    // The lifetime total reads the vehicle's work orders, scoped the same way.
+    expect(vi.mocked(db.serviceRecord.findMany)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: expect.objectContaining({ vehicle: { organizationId: ORG_A } }),
+      })
+    )
   })
   })
 })
 })
 
 

+ 0 - 0
src/app/(authenticated)/dashboard-client.tsx → src/app/(authenticated)/(dashboard)/dashboard-client.tsx


+ 0 - 0
src/app/(authenticated)/loading.tsx → src/app/(authenticated)/(dashboard)/loading.tsx


+ 0 - 0
src/app/(authenticated)/page.tsx → src/app/(authenticated)/(dashboard)/page.tsx


+ 0 - 0
src/app/(authenticated)/billing/loading.tsx → src/app/(authenticated)/billing/(overview)/loading.tsx


+ 1 - 1
src/app/(authenticated)/billing/page.tsx → src/app/(authenticated)/billing/(overview)/page.tsx

@@ -8,7 +8,7 @@ import { PageHeader } from '@/components/page-header'
 import { ListPage } from '@/components/list-page'
 import { ListPage } from '@/components/list-page'
 import { cookies } from 'next/headers'
 import { cookies } from 'next/headers'
 import { COMPLETED_ONLY_COOKIE, parseCompletedOnlyCookie } from '@/lib/completed-only-preference'
 import { COMPLETED_ONLY_COOKIE, parseCompletedOnlyCookie } from '@/lib/completed-only-preference'
-import BillingClient from './billing-client'
+import BillingClient from '../billing-client'
 
 
 export default async function BillingPage({
 export default async function BillingPage({
   searchParams,
   searchParams,

+ 83 - 0
src/app/(authenticated)/billing/recurring/loading.tsx

@@ -0,0 +1,83 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+/**
+ * The recurring invoices page's shape while it loads: the billing switch and
+ * the two actions, then one card down to the window's bottom holding the
+ * invoices as cards on phones and a table from md up.
+ */
+export default function RecurringInvoicesLoading() {
+  return (
+    <div className="flex flex-1 flex-col">
+      <header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 bg-background px-4">
+        <Skeleton className="h-6 w-6" />
+        <Skeleton className="h-4 w-40" />
+      </header>
+      <div className="flex h-[calc(100svh-7.5rem)] min-h-0 flex-col gap-4 p-4 pt-0 md:h-[calc(100svh-4rem)]">
+        <div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
+          <div className="flex items-center gap-3">
+            <Skeleton className="h-8 w-32" />
+            <Skeleton className="h-8 w-36" />
+          </div>
+          <div className="flex items-center gap-2">
+            <Skeleton className="h-9 w-9 md:h-8 md:w-32" />
+            <Skeleton className="h-9 w-9 md:h-8 md:w-36" />
+          </div>
+        </div>
+
+        <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-card py-6">
+          <div className="min-h-0 flex-1 space-y-2 overflow-hidden p-3 md:hidden">
+            {Array.from({ length: 4 }).map((_, i) => (
+              <div key={i} className="space-y-2.5 rounded-lg border bg-card p-3">
+                <div className="flex items-start justify-between gap-3">
+                  <Skeleton className="h-4 w-40" />
+                  <Skeleton className="h-4 w-16" />
+                </div>
+                <Skeleton className="h-3 w-48" />
+                <div className="flex items-center gap-3">
+                  <Skeleton className="h-5 w-14 rounded-full" />
+                  <Skeleton className="h-3 w-14" />
+                  <Skeleton className="h-3 w-20" />
+                </div>
+                <div className="flex justify-end gap-1">
+                  <Skeleton className="h-9 w-9" />
+                  <Skeleton className="h-9 w-9" />
+                </div>
+              </div>
+            ))}
+          </div>
+
+          <div className="hidden min-h-0 flex-1 flex-col md:flex">
+            <div className="flex items-center border-b px-3 py-2">
+              <Skeleton className="h-3 w-56" />
+            </div>
+            <div className="grid h-10 grid-cols-[minmax(0,1.4fr)_minmax(0,1.4fr)_minmax(0,1.2fr)_minmax(0,0.8fr)_minmax(0,0.9fr)_minmax(0,0.8fr)_minmax(0,0.7fr)_minmax(0,0.5fr)_4rem] items-center gap-4 border-b px-2">
+              {['w-12', 'w-14', 'w-16', 'w-16', 'w-16', 'w-14', 'w-12', 'w-10'].map((width, i) => (
+                <Skeleton key={i} className={`h-4 ${width} max-w-full`} />
+              ))}
+              <span />
+            </div>
+            {Array.from({ length: 6 }).map((_, i) => (
+              <div
+                key={i}
+                className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1.4fr)_minmax(0,1.2fr)_minmax(0,0.8fr)_minmax(0,0.9fr)_minmax(0,0.8fr)_minmax(0,0.7fr)_minmax(0,0.5fr)_4rem] items-center gap-4 border-b px-2 py-2"
+              >
+                <Skeleton className="h-4 w-32 max-w-full" />
+                <Skeleton className="h-4 w-36 max-w-full" />
+                <Skeleton className="h-4 w-24 max-w-full" />
+                <Skeleton className="h-4 w-16 max-w-full" />
+                <Skeleton className="h-4 w-20 max-w-full" />
+                <Skeleton className="ml-auto h-4 w-16 max-w-full" />
+                <Skeleton className="h-5 w-14 max-w-full rounded-full" />
+                <Skeleton className="ml-auto h-4 w-6" />
+                <div className="flex justify-end gap-1">
+                  <Skeleton className="h-7 w-7" />
+                  <Skeleton className="h-7 w-7" />
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 0 - 0
src/app/(authenticated)/customers/loading.tsx → src/app/(authenticated)/customers/(list)/loading.tsx


+ 1 - 1
src/app/(authenticated)/customers/page.tsx → src/app/(authenticated)/customers/(list)/page.tsx

@@ -1,7 +1,7 @@
 import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { getTranslations } from 'next-intl/server'
 import { getTranslations } from 'next-intl/server'
 import { getCustomersPaginated } from '@/features/customers/Actions/customerActions'
 import { getCustomersPaginated } from '@/features/customers/Actions/customerActions'
-import { CustomersClient } from './customers-client'
+import { CustomersClient } from '../customers-client'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
 import { ListPage } from '@/components/list-page'
 import { ListPage } from '@/components/list-page'
 import { countUnnumberedCustomers } from '@/features/customers/Actions/customerActions'
 import { countUnnumberedCustomers } from '@/features/customers/Actions/customerActions'

+ 1 - 0
src/app/(authenticated)/customers/[id]/customer-detail-client.tsx

@@ -570,6 +570,7 @@ export function CustomerDetailClient({
               onOpenChange={setShowVehicleForm}
               onOpenChange={setShowVehicleForm}
               defaultCustomerId={customer.id}
               defaultCustomerId={customer.id}
               defaultCustomer={customer}
               defaultCustomer={customer}
+              distanceUnit={readingUnit}
             />
             />
             {customer.vehicles.length === 0 ? (
             {customer.vehicles.length === 0 ? (
               <Card className="border-dashed">
               <Card className="border-dashed">

+ 83 - 0
src/app/(authenticated)/customers/[id]/loading.tsx

@@ -0,0 +1,83 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+/**
+ * The customer page's shape while it loads: the name with its contact row and
+ * edit button, the tab strip, and the vehicles list the page opens on.
+ */
+export default function CustomerDetailLoading() {
+  return (
+    <div className="flex flex-1 flex-col">
+      <header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 bg-background px-4">
+        <Skeleton className="h-6 w-6" />
+        <Skeleton className="h-4 w-48" />
+      </header>
+      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+        <div className="space-y-6">
+          <div>
+            <Skeleton className="mb-4 h-4 w-36" />
+            <div className="flex items-start gap-4">
+              <Skeleton className="h-14 w-14 shrink-0 rounded-2xl" />
+              <div className="min-w-0 flex-1 space-y-3 pt-1">
+                <Skeleton className="h-7 w-56 max-w-full" />
+                <div className="flex flex-wrap gap-x-5 gap-y-2">
+                  <Skeleton className="h-4 w-44" />
+                  <Skeleton className="h-4 w-28" />
+                  <Skeleton className="h-4 w-52" />
+                </div>
+              </div>
+              <Skeleton className="h-9 w-9 shrink-0 md:h-8 md:w-32" />
+            </div>
+          </div>
+
+          <div>
+            <div className="mb-4 flex gap-1 overflow-hidden border-b">
+              {['w-24', 'w-24', 'w-20'].map((width, i) => (
+                <div key={i} className="border-b-2 border-transparent px-4 py-3">
+                  <Skeleton className={`h-4 ${width}`} />
+                </div>
+              ))}
+            </div>
+            <div className="mb-3 flex justify-end">
+              <Skeleton className="h-9 w-9 md:h-8 md:w-28" />
+            </div>
+
+            <div className="space-y-2 md:hidden">
+              {Array.from({ length: 3 }).map((_, i) => (
+                <div key={i} className="space-y-2.5 rounded-lg border bg-card p-3">
+                  <div className="flex items-start justify-between gap-3">
+                    <Skeleton className="h-4 w-40" />
+                    <Skeleton className="h-4 w-16" />
+                  </div>
+                  <div className="flex gap-3">
+                    <Skeleton className="h-3 w-20" />
+                    <Skeleton className="h-3 w-16" />
+                  </div>
+                </div>
+              ))}
+            </div>
+
+            <div className="hidden rounded-lg border md:block">
+              <div className="grid h-10 grid-cols-[120px_minmax(0,1fr)_100px_80px] items-center border-b">
+                <Skeleton className="mx-2 h-4 w-12" />
+                <Skeleton className="mx-2 h-4 w-16" />
+                <Skeleton className="ml-auto mr-2 h-4 w-14" />
+                <Skeleton className="mx-auto h-4 w-14" />
+              </div>
+              {Array.from({ length: 3 }).map((_, i) => (
+                <div
+                  key={i}
+                  className="grid grid-cols-[120px_minmax(0,1fr)_100px_80px] items-center border-b py-2.5 last:border-0"
+                >
+                  <Skeleton className="mx-2 h-4 w-16" />
+                  <Skeleton className="mx-2 h-4 w-48 max-w-[80%]" />
+                  <Skeleton className="ml-auto mr-2 h-4 w-16" />
+                  <Skeleton className="mx-auto h-4 w-6" />
+                </div>
+              ))}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 0 - 0
src/app/(authenticated)/inventory/loading.tsx → src/app/(authenticated)/inventory/(list)/loading.tsx


+ 1 - 1
src/app/(authenticated)/inventory/page.tsx → src/app/(authenticated)/inventory/(list)/page.tsx

@@ -10,7 +10,7 @@ import {
 } from '@/features/inventory/Actions/inventoryActions'
 } from '@/features/inventory/Actions/inventoryActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { InventoryClient } from './inventory-client'
+import { InventoryClient } from '../inventory-client'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
 import { ListPage } from '@/components/list-page'
 import { ListPage } from '@/components/list-page'
 
 

+ 98 - 0
src/app/(authenticated)/inventory/[id]/loading.tsx

@@ -0,0 +1,98 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+/**
+ * The part page's shape while it loads: the name and edit button, the four
+ * stock and price tiles, then the stock history as cards on phones and a
+ * table from md up, with the pager under it.
+ */
+export default function InventoryPartLoading() {
+  return (
+    <div className="flex flex-1 flex-col">
+      <header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 bg-background px-4">
+        <Skeleton className="h-6 w-6" />
+        <Skeleton className="h-4 w-48" />
+      </header>
+      <div className="space-y-6 p-4 pt-0">
+        <div className="flex flex-wrap items-start justify-between gap-3">
+          <div className="space-y-1.5">
+            <Skeleton className="h-7 w-36" />
+            <Skeleton className="h-8 w-64 max-w-full" />
+            <Skeleton className="h-4 w-48" />
+          </div>
+          <Skeleton className="h-9 w-9 shrink-0 md:w-24" />
+        </div>
+
+        <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
+          {['w-16', 'w-14', 'w-16', 'w-16'].map((width, i) => (
+            <div key={i} className="space-y-2 rounded-lg border bg-card px-3 py-2">
+              <Skeleton className={`h-3 ${width}`} />
+              <Skeleton className="h-6 w-20" />
+            </div>
+          ))}
+        </div>
+
+        <div className="space-y-3">
+          <div className="flex flex-wrap items-center justify-between gap-2">
+            <div className="space-y-2">
+              <Skeleton className="h-6 w-40" />
+              <Skeleton className="h-4 w-64 max-w-full" />
+            </div>
+            <Skeleton className="h-9 w-full sm:w-[220px]" />
+          </div>
+
+          <div className="space-y-2 md:hidden">
+            {Array.from({ length: 5 }).map((_, i) => (
+              <div key={i} className="space-y-2.5 rounded-lg border bg-card p-3">
+                <div className="flex items-start justify-between gap-3">
+                  <div className="flex-1 space-y-2">
+                    <Skeleton className="h-4 w-32 max-w-full" />
+                    <Skeleton className="h-3 w-40" />
+                  </div>
+                  <Skeleton className="h-5 w-10 rounded-full" />
+                  <Skeleton className="h-4 w-8" />
+                </div>
+                <div className="flex gap-3">
+                  <Skeleton className="h-3 w-24" />
+                  <Skeleton className="h-3 w-16" />
+                  <Skeleton className="h-3 w-14" />
+                </div>
+              </div>
+            ))}
+          </div>
+
+          <div className="hidden rounded-lg border md:block">
+            <div className="grid h-10 grid-cols-[minmax(0,1.2fr)_minmax(0,0.7fr)_minmax(0,0.7fr)_minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)] items-center gap-4 border-b px-2">
+              {['w-12', 'w-14', 'w-14', 'w-16', 'w-14', 'w-8'].map((width, i) => (
+                <Skeleton key={i} className={`h-4 ${width} max-w-full`} />
+              ))}
+            </div>
+            {Array.from({ length: 8 }).map((_, i) => (
+              <div
+                key={i}
+                className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,0.7fr)_minmax(0,0.7fr)_minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)] items-center gap-4 border-b px-2 py-2.5 last:border-0"
+              >
+                <Skeleton className="h-4 w-28 max-w-full" />
+                <Skeleton className="h-5 w-10 max-w-full rounded-full" />
+                <Skeleton className="h-4 w-8 max-w-full" />
+                <Skeleton className="h-4 w-32 max-w-full" />
+                <Skeleton className="h-4 w-20 max-w-full" />
+                <Skeleton className="h-4 w-16 max-w-full" />
+              </div>
+            ))}
+          </div>
+
+          <div className="flex flex-col items-center justify-between gap-3 sm:flex-row">
+            <Skeleton className="h-8 w-36" />
+            <div className="flex items-center gap-1">
+              <Skeleton className="hidden h-8 w-8 sm:block" />
+              <Skeleton className="h-9 w-9 sm:h-8 sm:w-8" />
+              <Skeleton className="mx-3 h-4 w-16" />
+              <Skeleton className="h-9 w-9 sm:h-8 sm:w-8" />
+              <Skeleton className="hidden h-8 w-8 sm:block" />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 0 - 0
src/app/(authenticated)/quotes/loading.tsx → src/app/(authenticated)/quotes/(list)/loading.tsx


+ 1 - 1
src/app/(authenticated)/quotes/page.tsx → src/app/(authenticated)/quotes/(list)/page.tsx

@@ -4,7 +4,7 @@ import { resolveListStatus } from '@/lib/list-status-preference.server'
 import { getQuotesPaginated } from '@/features/quotes/Actions/quoteActions'
 import { getQuotesPaginated } from '@/features/quotes/Actions/quoteActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { QuotesClient } from './quotes-client'
+import { QuotesClient } from '../quotes-client'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
 import { ListPage } from '@/components/list-page'
 import { ListPage } from '@/components/list-page'
 
 

+ 142 - 0
src/app/(authenticated)/quotes/[id]/loading.tsx

@@ -0,0 +1,142 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+/** One labelled field of the quote's side column: a small label over an input. */
+function FieldSkeleton({ label = 'w-16' }: { label?: string }) {
+  return (
+    <div className="space-y-1.5">
+      <Skeleton className={`h-3 ${label}`} />
+      <Skeleton className="h-9 w-full" />
+    </div>
+  )
+}
+
+/**
+ * A line editor (parts or labor): its title and buttons, then input rows.
+ * `grid` is the row's whole class list, written out so Tailwind sees it.
+ */
+function LinesSkeleton({ grid, cells, title }: { grid: string; cells: number; title: string }) {
+  return (
+    <div className="space-y-2 rounded-lg border p-3">
+      <div className="flex items-center justify-between gap-2">
+        <Skeleton className={`h-4 ${title}`} />
+        <div className="flex gap-2">
+          <Skeleton className="hidden h-8 w-36 sm:block" />
+          <Skeleton className="h-8 w-24" />
+        </div>
+      </div>
+      {Array.from({ length: 2 }).map((_, i) => (
+        <div key={i} className={grid}>
+          {Array.from({ length: cells }).map((_, j) => (
+            <Skeleton key={j} className="h-9" />
+          ))}
+          <Skeleton className="h-9 w-9" />
+        </div>
+      ))}
+    </div>
+  )
+}
+
+/**
+ * The quote's shape while it loads: the title bar with its actions, the tabs,
+ * then the parts, labor and notes beside the customer, details and totals,
+ * stacked into one column below the large breakpoint like the page.
+ */
+export default function QuoteLoading() {
+  return (
+    <div className="flex h-svh flex-col overflow-hidden">
+      <header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 bg-background px-4">
+        <Skeleton className="h-6 w-6" />
+        <Skeleton className="h-4 w-48" />
+      </header>
+      <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
+        <div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-2">
+          <div className="flex min-w-0 items-center gap-3">
+            <Skeleton className="h-4 w-4 shrink-0" />
+            <div className="min-w-0 space-y-1.5">
+              <div className="flex items-center gap-2">
+                <Skeleton className="h-5 w-16 shrink-0 rounded-full" />
+                <Skeleton className="h-5 w-48 max-w-full" />
+              </div>
+              <Skeleton className="h-3 w-40 max-w-full" />
+            </div>
+          </div>
+          <div className="flex shrink-0 items-center gap-2">
+            <Skeleton className="h-8 w-20" />
+            <div className="hidden gap-px sm:flex">
+              {['w-24', 'w-16', 'w-20', 'w-20', 'w-20'].map((width, i) => (
+                <Skeleton key={i} className={`h-8 ${width}`} />
+              ))}
+            </div>
+          </div>
+        </div>
+
+        <div className="flex shrink-0 gap-1 border-b px-4">
+          {['w-20', 'w-20', 'w-24'].map((width, i) => (
+            <div key={i} className="border-b-2 border-transparent px-3 py-2.5">
+              <Skeleton className={`h-4 ${width}`} />
+            </div>
+          ))}
+        </div>
+
+        <div className="min-h-0 flex-1 overflow-hidden p-4">
+          <div className="grid grid-cols-1 items-start gap-3 lg:grid-cols-[minmax(0,3fr)_minmax(0,1fr)] lg:gap-4">
+            <div className="flex min-w-0 flex-col gap-3">
+              <LinesSkeleton
+                title="w-16"
+                cells={7}
+                grid="grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.6fr_0.9fr_0.7fr_0.9fr_0.9fr_auto]"
+              />
+              <LinesSkeleton
+                title="w-20"
+                cells={4}
+                grid="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
+              />
+              <div className="overflow-hidden rounded-lg border">
+                <div className="flex items-center justify-between px-3 py-2">
+                  <Skeleton className="h-4 w-20" />
+                  <Skeleton className="h-7 w-[120px]" />
+                </div>
+                <div className="space-y-2.5 border-t p-3">
+                  <Skeleton className="h-3 w-full" />
+                  <Skeleton className="h-3 w-11/12" />
+                  <Skeleton className="h-3 w-2/3" />
+                </div>
+                <div className="border-t px-3 py-2">
+                  <Skeleton className="h-3 w-56 max-w-full" />
+                </div>
+              </div>
+            </div>
+            <div className="flex min-w-0 flex-col gap-3">
+              <div className="rounded-lg border p-3">
+                <Skeleton className="h-8 w-full" />
+              </div>
+              <div className="space-y-3 rounded-lg border p-3">
+                <FieldSkeleton label="w-16" />
+                <FieldSkeleton label="w-14" />
+              </div>
+              <div className="space-y-3 rounded-lg border p-3">
+                <Skeleton className="h-4 w-20" />
+                <FieldSkeleton label="w-10" />
+                <FieldSkeleton label="w-12" />
+                <FieldSkeleton label="w-20" />
+              </div>
+              <div className="space-y-2.5 rounded-lg border p-3">
+                <Skeleton className="h-4 w-16" />
+                {['w-16', 'w-14', 'w-20', 'w-12'].map((width, i) => (
+                  <div key={i} className="flex items-center justify-between">
+                    <Skeleton className={`h-3 ${width}`} />
+                    <Skeleton className="h-3 w-16" />
+                  </div>
+                ))}
+                <div className="flex items-center justify-between border-t pt-2.5">
+                  <Skeleton className="h-4 w-14" />
+                  <Skeleton className="h-4 w-20" />
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 0 - 0
src/app/(authenticated)/vehicles/loading.tsx → src/app/(authenticated)/vehicles/(list)/loading.tsx


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

@@ -2,7 +2,7 @@ import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { cookies } from 'next/headers'
 import { cookies } from 'next/headers'
 import { getTranslations } from 'next-intl/server'
 import { getTranslations } from 'next-intl/server'
 import { getVehiclesPaginated } from '@/features/vehicles/Actions/vehicleActions'
 import { getVehiclesPaginated } from '@/features/vehicles/Actions/vehicleActions'
-import { VehiclesClient } from './vehicles-client'
+import { VehiclesClient } from '../vehicles-client'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
 import { ListPage } from '@/components/list-page'
 import { ListPage } from '@/components/list-page'
 
 

+ 62 - 0
src/app/(authenticated)/vehicles/[id]/(detail)/loading.tsx

@@ -0,0 +1,62 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+/**
+ * The vehicle page's shape while it loads: the vehicle on the left, the
+ * history on the right, so nothing jumps when the real page arrives.
+ */
+export default function VehicleDetailLoading() {
+  return (
+    <div className="flex flex-1 flex-col">
+      <header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 bg-background px-4">
+        <Skeleton className="h-6 w-6" />
+        <Skeleton className="h-4 w-40" />
+      </header>
+      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+        <div className="grid items-start gap-5 xl:grid-cols-[21.5rem_minmax(0,1fr)]">
+          <div className="space-y-4 rounded-xl border bg-card p-3">
+            <Skeleton className="aspect-[3/2] w-full rounded-lg" />
+            <div className="space-y-2.5 px-1.5">
+              <Skeleton className="h-8 w-28 rounded-md" />
+              <Skeleton className="h-6 w-52" />
+              <Skeleton className="h-4 w-36" />
+            </div>
+            <div className="flex gap-1.5 px-1.5">
+              <Skeleton className="h-8 w-24" />
+              <Skeleton className="h-8 w-24" />
+            </div>
+            <div className="space-y-3 border-t px-1.5 pt-4">
+              {Array.from({ length: 6 }).map((_, i) => (
+                <div key={i} className="flex gap-4">
+                  <Skeleton className="h-4 w-20" />
+                  <Skeleton className="h-4 flex-1" />
+                </div>
+              ))}
+            </div>
+          </div>
+          <div className="space-y-4">
+            <div className="flex items-center gap-1.5">
+              {[16, 28, 24, 18, 20].map((w, i) => (
+                <Skeleton key={i} className="h-8 rounded-full" style={{ width: `${w * 4}px` }} />
+              ))}
+              <Skeleton className="ml-auto h-8 w-20" />
+            </div>
+            <Skeleton className="h-32 w-full rounded-xl" />
+            <div className="space-y-5 pt-2">
+              {Array.from({ length: 7 }).map((_, i) => (
+                <div key={i} className="flex items-start gap-3">
+                  <Skeleton className="h-4 w-14" />
+                  <Skeleton className="h-7 w-7 rounded-full" />
+                  <div className="flex-1 space-y-2">
+                    <Skeleton className="h-4 w-2/5" />
+                    <Skeleton className="h-3 w-1/4" />
+                  </div>
+                  <Skeleton className="h-4 w-16" />
+                </div>
+              ))}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 74 - 26
src/app/(authenticated)/vehicles/[id]/page.tsx → src/app/(authenticated)/vehicles/[id]/(detail)/page.tsx

@@ -10,17 +10,24 @@ import { getVehicleInspections } from '@/features/inspections/Actions/inspection
 import { getTemplates } from '@/features/inspections/Actions/templateActions'
 import { getTemplates } from '@/features/inspections/Actions/templateActions'
 import { getVehicleQuotes } from '@/features/quotes/Actions/quoteActions'
 import { getVehicleQuotes } from '@/features/quotes/Actions/quoteActions'
 import { getVehicleFindings } from '@/features/vehicles/Actions/findingActions'
 import { getVehicleFindings } from '@/features/vehicles/Actions/findingActions'
+import { getVehicleJobWarnings } from '@/features/vehicles/Actions/getVehicleJobWarnings'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
 import { findSafetyConnection } from '@/features/integrations/Lib/vehicle-safety'
 import { findSafetyConnection } from '@/features/integrations/Lib/vehicle-safety'
 import { isAiConfigured } from '@/features/integrations/Lib/ai'
 import { isAiConfigured } from '@/features/integrations/Lib/ai'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getTireSetsForVehicle } from '@/features/tire-hotel/Actions/tireJobActions'
 import { getTireSetsForVehicle } from '@/features/tire-hotel/Actions/tireJobActions'
-import { VehicleDetailClient } from './vehicle-detail-client'
+import { VehicleDetailClient } from '../vehicle-detail-client'
+import { VehicleDetailClassic } from '../_classic/vehicle-detail-classic'
+import { TryNewVehiclePageBanner } from '@/features/vehicles/Components/VehiclePageLayoutSwitch'
+import { resolveVehiclePageLayout } from '@/lib/vehicle-page-layout.server'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
 import { redirect } from 'next/navigation'
 import { redirect } from 'next/navigation'
 import { PermissionSubject } from '@/lib/permissions'
 import { PermissionSubject } from '@/lib/permissions'
 import { getViewerAccess, readIfAllowed } from '@/lib/viewer-access'
 import { getViewerAccess, readIfAllowed } from '@/lib/viewer-access'
 
 
+/** The most notes the new page's feed holds: the same reach as its work orders. */
+const FEED_NOTES = 250
+
 export default async function VehicleDetailPage({
 export default async function VehicleDetailPage({
   params,
   params,
   searchParams,
   searchParams,
@@ -38,9 +45,11 @@ export default async function VehicleDetailPage({
     redirect(`/vehicles/${id}/service/${sp.record}`)
     redirect(`/vehicles/${id}/service/${sp.record}`)
   }
   }
 
 
+  const search = typeof sp.search === 'string' ? sp.search : ''
+  // The classic page pages and filters its service table; the new one shows
+  // the whole history as a feed. See lib/vehicle-page-layout.ts for which.
   const page = Number(sp.page) || 1
   const page = Number(sp.page) || 1
   const pageSize = Number(sp.pageSize) || 10
   const pageSize = Number(sp.pageSize) || 10
-  const search = typeof sp.search === 'string' ? sp.search : ''
   const type = typeof sp.type === 'string' ? sp.type : 'all'
   const type = typeof sp.type === 'string' ? sp.type : 'all'
 
 
   const notesPage = Number(sp.notesPage) || 1
   const notesPage = Number(sp.notesPage) || 1
@@ -52,7 +61,13 @@ export default async function VehicleDetailPage({
   // A role without them is not shown the panels, and is not asked for their
   // A role without them is not shown the panels, and is not asked for their
   // data either: a refused call is a wasted query and a refusal in the audit
   // data either: a refused call is a wasted query and a refusal in the audit
   // log on every vehicle somebody opens (lib/viewer-access).
   // log on every vehicle somebody opens (lib/viewer-access).
+  // Which layout this browser gets decides how the history is read below;
+  // it is worked out while the viewer's access is, not after.
+  const auth = getAuthContext()
+  const layoutChoice = auth.then((ctx) => resolveVehiclePageLayout(ctx?.organizationId))
   const access = await getViewerAccess()
   const access = await getViewerAccess()
+  const layout = await layoutChoice
+  const classic = layout === 'classic'
   const S = PermissionSubject
   const S = PermissionSubject
 
 
   const [
   const [
@@ -66,9 +81,17 @@ export default async function VehicleDetailPage({
     quotesResult,
     quotesResult,
     findingsResult,
     findingsResult,
     tireSetsResult,
     tireSetsResult,
+    predResult,
+    authContext,
+    jobWarningsResult,
+    feedNotesResult,
   ] = await Promise.all([
   ] = await Promise.all([
     getVehicle(id),
     getVehicle(id),
-    getServiceRecordsPaginated(id, { page, pageSize, search, type }),
+    // The new page shows the vehicle's history as one feed, so it asks for all
+    // of it; a car with more work orders than this is shown its latest ones.
+    classic
+      ? getServiceRecordsPaginated(id, { page, pageSize, search, type })
+      : getServiceRecordsPaginated(id, { page: 1, pageSize: 250, search }),
     getNotesPaginated(id, { page: notesPage, pageSize: notesPageSize }),
     getNotesPaginated(id, { page: notesPage, pageSize: notesPageSize }),
     getDisplaySettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.UNIT_SYSTEM]),
     getDisplaySettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.UNIT_SYSTEM]),
     getDisplaySettings([
     getDisplaySettings([
@@ -81,6 +104,15 @@ export default async function VehicleDetailPage({
     getVehicleQuotes(id),
     getVehicleQuotes(id),
     getVehicleFindings(id, { page: findingsPage, pageSize: findingsPageSize }),
     getVehicleFindings(id, { page: findingsPage, pageSize: findingsPageSize }),
     readIfAllowed(access, S.TIRE_HOTEL, () => getTireSetsForVehicle(id)),
     readIfAllowed(access, S.TIRE_HOTEL, () => getTireSetsForVehicle(id)),
+    // Asked alongside the rest rather than after it: neither needs the others.
+    getVehiclePredictedMileage(id),
+    auth,
+    // What is wrong with the bookings of the vehicle's open jobs, flagged in
+    // the new page's feed so nobody has to open each job to find out.
+    classic ? null : readIfAllowed(access, S.WORK_ORDERS, () => getVehicleJobWarnings(id)),
+    // The feed searches and shows every note, newest first, so it is not
+    // handed the notes table's current page; the table keeps its paging.
+    classic ? null : getNotesPaginated(id, { page: 1, pageSize: FEED_NOTES }),
   ])
   ])
 
 
   if (!result.success || !result.data) {
   if (!result.success || !result.data) {
@@ -139,7 +171,6 @@ export default async function VehicleDetailPage({
     confidencePercent: number
     confidencePercent: number
   } | null = null
   } | null = null
 
 
-  const predResult = await getVehiclePredictedMileage(id)
   if (predResult.success && predResult.data) {
   if (predResult.success && predResult.data) {
     const p = predResult.data
     const p = predResult.data
     const mileageSinceLastService = p.predictedMileage - p.lastServiceMileage
     const mileageSinceLastService = p.predictedMileage - p.lastServiceMileage
@@ -171,7 +202,6 @@ export default async function VehicleDetailPage({
   }
   }
 
 
   // Check AI enabled
   // Check AI enabled
-  const authContext = await getAuthContext()
   const orgId = authContext?.organizationId
   const orgId = authContext?.organizationId
   let aiEnabled = false
   let aiEnabled = false
   let safetyAvailable = false
   let safetyAvailable = false
@@ -194,31 +224,49 @@ export default async function VehicleDetailPage({
     aiEnabled = features?.ai === true && aiConfigured
     aiEnabled = features?.ai === true && aiConfigured
   }
   }
 
 
+  // Both pages take the same data; the classic one also reads the type
+  // filter, the new one the booking warnings.
+  const viewProps = {
+    vehicle: result.data,
+    paginatedServices,
+    paginatedNotes,
+    serviceSearch: search,
+    currencyCode,
+    unitSystem,
+    predictionData,
+    inspections: inspectionsResult.success && inspectionsResult.data ? inspectionsResult.data : [],
+    inspectionTemplates:
+      templatesResult.success && templatesResult.data ? templatesResult.data : [],
+    quotes: quotesResult.success && quotesResult.data ? quotesResult.data : [],
+    aiEnabled,
+    safetyAvailable,
+    paginatedFindings,
+    tireSets: tireSetsResult.success && tireSetsResult.data ? tireSetsResult.data : [],
+    serviceRecordType: type,
+  }
+
   return (
   return (
     <>
     <>
       <PageHeader />
       <PageHeader />
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
-        <VehicleDetailClient
-          vehicle={result.data}
-          paginatedServices={paginatedServices}
-          paginatedNotes={paginatedNotes}
-          serviceSearch={search}
-          serviceRecordType={type}
-          currencyCode={currencyCode}
-          unitSystem={unitSystem}
-          predictionData={predictionData}
-          inspections={
-            inspectionsResult.success && inspectionsResult.data ? inspectionsResult.data : []
-          }
-          inspectionTemplates={
-            templatesResult.success && templatesResult.data ? templatesResult.data : []
-          }
-          quotes={quotesResult.success && quotesResult.data ? quotesResult.data : []}
-          aiEnabled={aiEnabled}
-          safetyAvailable={safetyAvailable}
-          paginatedFindings={paginatedFindings}
-          tireSets={tireSetsResult.success && tireSetsResult.data ? tireSetsResult.data : []}
-        />
+        {classic ? (
+          <>
+            <TryNewVehiclePageBanner />
+            <VehicleDetailClassic {...viewProps} />
+          </>
+        ) : (
+          <VehicleDetailClient
+            {...viewProps}
+            jobWarnings={
+              jobWarningsResult?.success && jobWarningsResult.data ? jobWarningsResult.data : []
+            }
+            feedNotes={
+              feedNotesResult?.success && feedNotesResult.data
+                ? feedNotesResult.data.records
+                : paginatedNotes.records
+            }
+          />
+        )}
       </div>
       </div>
     </>
     </>
   )
   )

+ 0 - 0
src/app/(authenticated)/vehicles/[id]/service-records-table.tsx → src/app/(authenticated)/vehicles/[id]/_classic/service-records-table.tsx


+ 1887 - 0
src/app/(authenticated)/vehicles/[id]/_classic/vehicle-detail-classic.tsx

@@ -0,0 +1,1887 @@
+'use client'
+
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
+import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
+import { interactiveRow } from '@/lib/interactive-row'
+import { useState, useTransition, useCallback } from 'react'
+import { useRouter, useSearchParams } from 'next/navigation'
+import Link from 'next/link'
+import { useFormatDate } from '@/lib/use-format-date'
+import { Card, CardContent } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { useGlassModal } from '@/components/glass-modal'
+import { VehicleForm } from '@/features/vehicles/Components/VehicleForm'
+import { NoteForm } from '@/features/vehicles/Components/NoteForm'
+import { ReminderForm } from '@/features/vehicles/Components/ReminderForm'
+import { FindingForm } from '@/features/vehicles/Components/FindingForm'
+import { VehicleSafetyPanel } from '@/features/vehicles/Components/VehicleSafetyPanel'
+import { ServiceRecordsTable } from './service-records-table'
+import { NotesTable } from '../notes-table'
+import { FindingsTable } from '../findings-table'
+import {
+  VehicleTireSets,
+  type VehicleTireSet,
+} from '@/features/tire-hotel/Components/VehicleTireSets'
+import { toast } from 'sonner'
+import { deleteNote, toggleNotePin } from '@/features/vehicles/Actions/noteActions'
+import { toggleReminder, deleteReminder } from '@/features/vehicles/Actions/reminderActions'
+import { deleteFinding } from '@/features/vehicles/Actions/findingActions'
+import { createServiceRecord } from '@/features/vehicles/Actions/serviceActions'
+import { unarchiveVehicle } from '@/features/vehicles/Actions/unarchiveVehicle'
+import { deleteVehicle } from '@/features/vehicles/Actions/deleteVehicle'
+import {
+  dismissMaintenance,
+  undismissMaintenance,
+} from '@/features/vehicles/Actions/dismissMaintenance'
+import { ArchiveVehicleDialog } from '@/features/vehicles/Components/ArchiveVehicleDialog'
+import {
+  aiSummarizeVehicleHistory,
+  aiGetCommonIssues,
+  aiClearMessage,
+} from '@/features/ai/Actions/aiActions'
+import { AI_MESSAGE_TYPES } from '@/features/ai/constants'
+import { AskAiSheet } from '@/features/ai/Components/AskAiSheet'
+import { useFormatCurrency } from '@/components/currency-settings-context'
+import {
+  AlertTriangle,
+  Archive,
+  ArchiveRestore,
+  ArrowLeft,
+  Bell,
+  Car,
+  CheckCircle2,
+  ClipboardCheck,
+  Clock,
+  DollarSign,
+  EyeOff,
+  FileText,
+  Sparkles,
+  Gauge,
+  Loader2,
+  MoreVertical,
+  Pencil,
+  Pin,
+  Plus,
+  StickyNote,
+  Trash2,
+  TrendingUp,
+  Undo2,
+  Wrench,
+  X,
+} from 'lucide-react'
+import { NewInspectionDialog } from '@/features/inspections/Components/NewInspectionDialog'
+import { NewQuoteDialog } from '@/features/quotes/Components/NewQuoteDialog'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { useTranslations } from 'next-intl'
+import { quoteStatusLabel } from '@/features/quotes/Lib/quoteStatus'
+import { useServiceType } from '@/components/service-type-context'
+import { formatTypeKey } from '@/features/vehicles/Lib/typeKey'
+import { useTypeKeyEnabled } from '@/components/type-key-context'
+import { sanitizeHtml } from '@/lib/sanitize-html'
+import { useSwitchVehiclePageLayout } from '@/features/vehicles/Components/VehiclePageLayoutSwitch'
+
+interface PaginatedServices {
+  records: {
+    id: string
+    title: string
+    description: string | null
+    type: string
+    status: string
+    cost: number
+    mileage: number | null
+    serviceDate: Date
+    startDateTime: Date | null
+    invoiceDate: Date | null
+    shopName: string | null
+    techName: string | null
+    totalAmount: number
+    invoiceNumber: string | null
+    warrantyMonths: number | null
+    warrantyMileage: number | null
+    warrantyExpiresAt: Date | null
+    warrantyNotes: string | null
+    _count: { partItems: number; laborItems: number; attachments: number }
+  }[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
+}
+
+interface PaginatedNotes {
+  records: {
+    id: string
+    title: string
+    content: string
+    isPinned: boolean
+    createdAt: Date
+  }[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
+}
+
+interface PaginatedFindings {
+  records: {
+    id: string
+    description: string
+    severity: string
+    status: string
+    notes: string | null
+    imageUrls: string[]
+    createdAt: Date
+    serviceRecord: { id: string; title: string } | null
+    resolvedServiceRecord: { id: string; title: string } | null
+  }[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
+}
+
+interface VehicleDetail {
+  id: string
+  make: string
+  model: string
+  year: number
+  vin: string | null
+  licensePlate: string | null
+  color: string | null
+  mileage: number
+  fuelType: string | null
+  transmission: string | null
+  engineSize: string | null
+  engineCode: string | null
+  hsn: string | null
+  tsn: string | null
+  purchaseDate: Date | null
+  purchasePrice: number | null
+  imageUrl: string | null
+  isArchived: boolean
+  archiveReason: string | null
+  soldReportedAt?: Date | null
+  customerId: string | null
+  customer: {
+    id: string
+    name: string
+    company: string | null
+    email: string | null
+    phone: string | null
+  } | null
+  inspectionStatus?: {
+    dueAt: Date | null
+    lastAt: Date | null
+    source: string
+    checkedAt: Date | null
+    registered: boolean | null
+  } | null
+  serviceRecords: {
+    id: string
+    cost: number
+    totalAmount: number
+  }[]
+  reminders: {
+    id: string
+    title: string
+    description: string | null
+    dueDate: Date | null
+    hasDueTime?: boolean
+    dueMileage: number | null
+    isCompleted: boolean
+    createdAt: Date
+  }[]
+  aiMessages: { type: string; content: string; updatedAt: Date }[]
+  _count: {
+    serviceRecords: number
+    notes: number
+    reminders: number
+    findings: number
+  }
+}
+
+interface QuoteRecord {
+  id: string
+  quoteNumber: string | null
+  title: string
+  status: string
+  totalAmount: number
+  createdAt: Date
+  validUntil: Date | null
+  customer: { id: string; name: string } | null
+}
+
+const quoteStatusColors: Record<string, string> = {
+  changes_requested: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
+  draft: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
+  sent: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
+  accepted: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
+  rejected: 'bg-red-500/10 text-red-500 border-red-500/20',
+  expired: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
+  converted: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
+}
+
+/** Pass/fail/other split of an inspection's items, shared by the card and table views. */
+function InspectionProgress({
+  total,
+  inspected,
+  passCount,
+  failCount,
+}: {
+  total: number
+  inspected: number
+  passCount: number
+  failCount: number
+}) {
+  return (
+    <div className="flex items-center gap-1.5">
+      <div className="flex h-2 w-16 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
+        {total > 0 && (
+          <>
+            <div className="bg-emerald-500" style={{ width: `${(passCount / total) * 100}%` }} />
+            <div className="bg-red-500" style={{ width: `${(failCount / total) * 100}%` }} />
+            <div
+              className="bg-amber-500"
+              style={{ width: `${((inspected - passCount - failCount) / total) * 100}%` }}
+            />
+          </>
+        )}
+      </div>
+      <span className="text-xs text-muted-foreground">
+        {inspected}/{total}
+      </span>
+    </div>
+  )
+}
+
+export function VehicleDetailClassic({
+  vehicle,
+  paginatedServices,
+  paginatedNotes,
+  serviceSearch,
+  serviceRecordType,
+  currencyCode = DEFAULT_CURRENCY_CODE,
+  unitSystem = 'imperial',
+  predictionData,
+  inspections,
+  inspectionTemplates,
+  quotes = [],
+  aiEnabled = false,
+  safetyAvailable = false,
+  paginatedFindings,
+  tireSets = [],
+}: {
+  vehicle: VehicleDetail
+  paginatedServices: PaginatedServices
+  paginatedNotes: PaginatedNotes
+  paginatedFindings: PaginatedFindings
+  /** Empty unless the shop runs a tire hotel and this vehicle has sets in it. */
+  tireSets?: VehicleTireSet[]
+  serviceSearch: string
+  serviceRecordType: string
+  currencyCode?: string
+  unitSystem?: 'metric' | 'imperial'
+  predictionData?: {
+    predictedMileage: number
+    avgPerDay: number
+    lastServiceMileage: number
+    serviceInterval: number
+    mileageSinceLastService: number
+    status: 'overdue' | 'approaching' | 'ok' | null
+    maintenanceDismissed: boolean
+    confidencePercent: number
+  } | null
+  inspections?: {
+    id: string
+    status: string
+    createdAt: Date
+    completedAt: Date | null
+    template: { id: string; name: string }
+    items: { id: string; condition: string }[]
+  }[]
+  inspectionTemplates?: { id: string; name: string; isDefault: boolean }[]
+  quotes?: QuoteRecord[]
+  aiEnabled?: boolean
+  /** A safety authority such as NHTSA is connected and this vehicle has a model to ask about. */
+  safetyAvailable?: boolean
+}) {
+  const formatCurrency = useFormatCurrency()
+  const serviceType = useServiceType()
+  const isMarine = serviceType === 'marine'
+  const distUnit = isMarine ? 'hrs' : unitSystem === 'metric' ? 'km' : 'mi'
+  const router = useRouter()
+  const searchParams = useSearchParams()
+  const { formatDate, formatDateTime } = useFormatDate()
+  const t = useTranslations('vehicles.detail')
+  const typeKeyEnabled = useTypeKeyEnabled()
+  const ti = useTranslations('vehicles.inspections')
+  const tr = useTranslations('vehicles.reminders')
+  const tc = useTranslations('common.buttons')
+
+  const tq = useTranslations('vehicles.quotes')
+  const tQuoteStatus = useTranslations('quotes.statusLabels')
+  const tf = useTranslations('vehicles.findings')
+  const tErr = useTranslations('common.errors')
+  const tLayout = useTranslations('vehicles.detail.layout')
+  const switchLayout = useSwitchVehiclePageLayout()
+  const validTabs = ['services', 'quotes', 'inspections', 'findings', 'notes', 'reminders'] as const
+  const tabParam = searchParams.get('tab')
+  const activeTab = validTabs.includes(tabParam as (typeof validTabs)[number])
+    ? (tabParam as string)
+    : 'services'
+
+  // Where to return after leaving this page (e.g. arrived from a customer
+  // page). Restricted to internal paths; tab changes preserve the param.
+  const rawBack = searchParams.get('back')
+  const backHref =
+    rawBack && rawBack.startsWith('/') && !rawBack.startsWith('//') ? rawBack : '/vehicles'
+
+  const handleTabChange = useCallback(
+    (value: string) => {
+      const params = new URLSearchParams(searchParams.toString())
+      if (value === 'services') {
+        params.delete('tab')
+      } else {
+        params.set('tab', value)
+      }
+      const query = params.toString()
+      router.replace(`/vehicles/${vehicle.id}${query ? `?${query}` : ''}`, { scroll: false })
+    },
+    [searchParams, router, vehicle.id]
+  )
+  const modal = useGlassModal()
+  const quotesNav = useTableKeyboardNav()
+  const inspectionsNav = useTableKeyboardNav()
+  const [showEditForm, setShowEditForm] = useState(false)
+  const [showNoteForm, setShowNoteForm] = useState(false)
+  const [editingNote, setEditingNote] = useState<PaginatedNotes['records'][number] | undefined>()
+  const [showReminderForm, setShowReminderForm] = useState(false)
+  const [editingReminder, setEditingReminder] = useState<
+    VehicleDetail['reminders'][number] | undefined
+  >()
+  const [showFindingForm, setShowFindingForm] = useState(false)
+  const [editingFinding, setEditingFinding] = useState<
+    PaginatedFindings['records'][number] | undefined
+  >()
+  const [reminderFilter, setReminderFilter] = useState<'active' | 'completed' | 'all'>('active')
+  const [showImage, setShowImage] = useState(false)
+  const [selectedNote, setSelectedNote] = useState<PaginatedNotes['records'][number] | null>(null)
+  const [showArchiveDialog, setShowArchiveDialog] = useState(false)
+  const [showNewQuoteDialog, setShowNewQuoteDialog] = useState(false)
+  const [showDeleteDialog, setShowDeleteDialog] = useState(false)
+  const [showNewInspection, setShowNewInspection] = useState(false)
+  const [isDismissPending, startDismissTransition] = useTransition()
+  const [aiSummaryLoading, setAiSummaryLoading] = useState(false)
+  const [commonIssuesLoading, setCommonIssuesLoading] = useState(false)
+  const [activeAiPanel, setActiveAiPanel] = useState<'summary' | 'issues' | null>(null)
+
+  const storedSummary = vehicle.aiMessages.find((m) => m.type === AI_MESSAGE_TYPES.SUMMARY)
+  const storedCommonIssues = vehicle.aiMessages.find(
+    (m) => m.type === AI_MESSAGE_TYPES.COMMON_ISSUES
+  )
+
+  // An action that cannot reach the server throws instead of answering, and a
+  // throw inside a transition replaces the page with the error screen, so it
+  // is caught and reported the way a refusal is.
+  const handleDismissMaintenance = () => {
+    startDismissTransition(async () => {
+      const result = await dismissMaintenance(vehicle.id).catch(() => null)
+      if (!result?.success) toast.error(result?.error || t('maintenanceChangeError'))
+    })
+  }
+
+  const handleUndismissMaintenance = () => {
+    startDismissTransition(async () => {
+      const result = await undismissMaintenance(vehicle.id).catch(() => null)
+      if (!result?.success) toast.error(result?.error || t('maintenanceChangeError'))
+    })
+  }
+
+  const now = new Date()
+  const sevenDaysFromNow = new Date(now)
+  sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7)
+
+  const getUrgency = (r: VehicleDetail['reminders'][number]) => {
+    if (r.isCompleted) return 'completed'
+    if (r.dueDate && new Date(r.dueDate) < now) return 'overdue'
+    if (r.dueDate && new Date(r.dueDate) <= sevenDaysFromNow) return 'due-soon'
+    return 'normal'
+  }
+
+  const overdueCount = vehicle.reminders.filter(
+    (r) => !r.isCompleted && r.dueDate && new Date(r.dueDate) < now
+  ).length
+
+  const filteredReminders = vehicle.reminders.filter((r) => {
+    if (reminderFilter === 'active') return !r.isCompleted
+    if (reminderFilter === 'completed') return r.isCompleted
+    return true
+  })
+
+  const inspectionRows = (inspections ?? []).map((insp) => ({
+    insp,
+    total: insp.items.length,
+    inspected: insp.items.filter((i) => i.condition !== 'not_inspected').length,
+    passCount: insp.items.filter((i) => i.condition === 'pass').length,
+    failCount: insp.items.filter((i) => i.condition === 'fail').length,
+  }))
+
+  const totalServiceCost = vehicle.serviceRecords.reduce(
+    (sum, s) => sum + (s.totalAmount > 0 ? s.totalAmount : s.cost),
+    0
+  )
+
+  const handleDeleteNote = async (id: string) => {
+    const result = await deleteNote(id)
+    if (result.success) {
+      toast.success(t('noteDeleted'))
+      router.refresh()
+    } else {
+      modal.open('error', tErr('error'), result.error || t('noteDeleteError'))
+    }
+  }
+
+  const handleTogglePin = async (id: string) => {
+    const result = await toggleNotePin(id)
+    if (result.success) router.refresh()
+  }
+
+  const handleToggleReminder = async (id: string) => {
+    const result = await toggleReminder(id)
+    if (result.success) router.refresh()
+  }
+
+  const handleDeleteReminder = async (id: string) => {
+    const result = await deleteReminder(id)
+    if (result.success) {
+      toast.success(t('reminderDeleted'))
+      router.refresh()
+    } else {
+      modal.open('error', tErr('error'), result.error || t('reminderDeleteError'))
+    }
+  }
+
+  const handleDeleteFinding = async (id: string) => {
+    const result = await deleteFinding(id)
+    if (result.success) {
+      toast.success(tf('findingDeleted'))
+      router.refresh()
+    } else {
+      modal.open('error', tErr('error'), result.error || tf('deleteError'))
+    }
+  }
+
+  const [creatingWorkOrder, setCreatingWorkOrder] = useState(false)
+  const handleCreateWorkOrderFromFindings = async (findingIds: string[]) => {
+    setCreatingWorkOrder(true)
+    const selectedFindings = paginatedFindings.records.filter((f) => findingIds.includes(f.id))
+    const vehicleName = `${vehicle.year} ${vehicle.make} ${vehicle.model}`
+    // An action that cannot reach the server throws instead of answering, so
+    // it is caught and reported the way a refusal is.
+    const result = await createServiceRecord({
+      vehicleId: vehicle.id,
+      title: `${vehicleName} - ${tf('sectionTitle')}`,
+      type: 'repair',
+      status: 'pending',
+      laborItems: selectedFindings.map((f) => ({
+        description: `${f.description}${f.notes ? ` - ${f.notes}` : ''}`,
+        hours: 0,
+        rate: 0,
+        total: 0,
+        pricingType: 'hourly' as const,
+      })),
+      subtotal: 0,
+      taxRate: 0,
+      taxAmount: 0,
+      totalAmount: 0,
+    }).catch(() => null)
+    if (result?.success && result.data) {
+      const newServiceId = result.data.id
+      // Delete the observations that were added to the work order. The work
+      // order is made either way; one the server kept is still open on the
+      // vehicle, and the person is told rather than left to find it twice.
+      const removed = await Promise.all(findingIds.map((id) => deleteFinding(id).catch(() => null)))
+      if (removed.some((r) => !r?.success)) toast.error(tf('observationsNotRemoved'))
+      router.push(`/vehicles/${vehicle.id}/service/${newServiceId}`)
+    } else {
+      modal.open('error', tErr('error'), result?.error || tf('workOrderFailed'))
+      setCreatingWorkOrder(false)
+    }
+  }
+
+  const handleUnarchive = async () => {
+    const result = await unarchiveVehicle(vehicle.id)
+    if (result.success) {
+      toast.success(t('vehicleUnarchived'))
+      router.refresh()
+    } else {
+      modal.open('error', tErr('error'), result.error || t('unarchiveError'))
+    }
+  }
+
+  const handleAiSummary = async () => {
+    setAiSummaryLoading(true)
+    try {
+      const result = await aiSummarizeVehicleHistory(vehicle.id)
+      if (result.success && result.data) {
+        toast.success(t('aiSummaryGenerated'))
+        router.refresh()
+      } else {
+        toast.error(result.error ?? t('aiSummaryError'))
+      }
+    } catch {
+      toast.error(t('aiSummaryError'))
+    } finally {
+      setAiSummaryLoading(false)
+    }
+  }
+
+  const handleCommonIssues = async () => {
+    setActiveAiPanel('issues')
+    setCommonIssuesLoading(true)
+    try {
+      const result = await aiGetCommonIssues(vehicle.id)
+      if (result.success && result.data) {
+        router.refresh()
+      } else {
+        toast.error(result.error ?? t('aiSummaryError'))
+      }
+    } catch {
+      toast.error(t('aiSummaryError'))
+    } finally {
+      setCommonIssuesLoading(false)
+    }
+  }
+
+  const handleClearAiMessage = async (type: string) => {
+    try {
+      const result = await aiClearMessage(vehicle.id, type)
+      if (result.success) {
+        setActiveAiPanel(null)
+        router.refresh()
+      }
+    } catch {
+      toast.error(t('aiSummaryError'))
+    }
+  }
+
+  const handleDelete = async () => {
+    const result = await deleteVehicle(vehicle.id)
+    if (result.success) {
+      toast.success(t('vehicleDeleted'))
+      router.push(backHref)
+    } else {
+      modal.open('error', tErr('error'), result.error || t('vehicleDeleteError'))
+    }
+  }
+
+  return (
+    <div className="space-y-4">
+      {/* Archived banner */}
+      {vehicle.isArchived && (
+        <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
+          <div className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
+            <Archive className="h-4 w-4 text-amber-600" />
+            <span className="font-medium text-amber-600">{t('vehicleArchived')}</span>
+            {vehicle.archiveReason && (
+              <span className="text-muted-foreground">&mdash; {vehicle.archiveReason}</span>
+            )}
+          </div>
+          <Button size="sm" variant="outline" onClick={handleUnarchive}>
+            <ArchiveRestore className="mr-1 h-3.5 w-3.5" />
+            {t('unarchive')}
+          </Button>
+        </div>
+      )}
+
+      {/* Header */}
+      <div className="space-y-3">
+        <div className="flex items-center justify-between">
+          <Link
+            href={backHref}
+            className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
+          >
+            <ArrowLeft className="h-4 w-4" />
+            {rawBack ? tc('back') : t('backToVehicles')}
+          </Link>
+          <div className="flex items-center gap-2">
+            {!vehicle.isArchived && (
+              <Button
+                size="sm"
+                variant="outline"
+                onClick={() => setShowEditForm(true)}
+                aria-label={t('editVehicle')}
+                title={t('editVehicle')}
+                className="h-9 w-9 p-0 md:h-8 md:w-auto md:px-3"
+              >
+                <Pencil className="h-4 w-4 md:mr-1 md:h-3.5 md:w-3.5" />
+                <span className="hidden md:inline">{t('editVehicle')}</span>
+              </Button>
+            )}
+            <DropdownMenu>
+              <DropdownMenuTrigger asChild>
+                <Button variant="ghost" size="icon" className="h-8 w-8" aria-label={t('openMenu')}>
+                  <MoreVertical className="h-4 w-4" />
+                </Button>
+              </DropdownMenuTrigger>
+              <DropdownMenuContent align="end">
+                <DropdownMenuItem onClick={() => switchLayout('modern', 'menu')}>
+                  <Sparkles className="mr-2 h-4 w-4" />
+                  {tLayout('tryNewMenu')}
+                </DropdownMenuItem>
+                <DropdownMenuSeparator />
+                {vehicle.isArchived ? (
+                  <DropdownMenuItem onClick={handleUnarchive}>
+                    <ArchiveRestore className="mr-2 h-4 w-4" />
+                    {t('unarchive')}
+                  </DropdownMenuItem>
+                ) : (
+                  <DropdownMenuItem onClick={() => setShowArchiveDialog(true)}>
+                    <Archive className="mr-2 h-4 w-4" />
+                    {t('archive')}
+                  </DropdownMenuItem>
+                )}
+                <DropdownMenuSeparator />
+                <DropdownMenuItem
+                  className="text-destructive"
+                  onClick={() => setShowDeleteDialog(true)}
+                >
+                  <Trash2 className="mr-2 h-4 w-4" />
+                  {t('delete')}
+                </DropdownMenuItem>
+              </DropdownMenuContent>
+            </DropdownMenu>
+          </div>
+        </div>
+
+        <div className="flex items-center gap-3">
+          {vehicle.imageUrl ? (
+            <button
+              onClick={() => setShowImage(true)}
+              className="h-11 w-11 overflow-hidden rounded-xl cursor-zoom-in shrink-0"
+            >
+              <img src={vehicle.imageUrl} alt="" className="h-full w-full object-cover" />
+            </button>
+          ) : (
+            <div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10">
+              <Car className="h-5 w-5 text-primary" />
+            </div>
+          )}
+          <div className="min-w-0 flex-1">
+            <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
+              <h1 className="text-lg font-bold leading-tight">
+                {vehicle.year} {vehicle.make} {vehicle.model}
+              </h1>
+              <div className="flex min-w-0 flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
+                {vehicle.licensePlate && <span className="font-mono">{vehicle.licensePlate}</span>}
+                {vehicle.vin && (
+                  <>
+                    {vehicle.licensePlate && <span>&middot;</span>}
+                    <span className="font-mono">{vehicle.vin}</span>
+                  </>
+                )}
+                {vehicle.engineCode && (
+                  <>
+                    {(vehicle.licensePlate || vehicle.vin) && <span>&middot;</span>}
+                    <span className="font-mono">{vehicle.engineCode}</span>
+                  </>
+                )}
+                {typeKeyEnabled && (vehicle.hsn || vehicle.tsn) && (
+                  <>
+                    {(vehicle.licensePlate || vehicle.vin || vehicle.engineCode) && (
+                      <span>&middot;</span>
+                    )}
+                    <span>
+                      {t('typeKey')}{' '}
+                      <span className="font-mono">{formatTypeKey(vehicle.hsn, vehicle.tsn)}</span>
+                    </span>
+                  </>
+                )}
+              </div>
+            </div>
+            <div className="mt-1 flex flex-wrap items-center gap-1.5">
+              {vehicle.fuelType && (
+                <Badge variant="secondary" className="text-xs px-1.5 py-0">
+                  {vehicle.fuelType}
+                </Badge>
+              )}
+              {vehicle.transmission && (
+                <Badge variant="secondary" className="text-xs px-1.5 py-0">
+                  {vehicle.transmission}
+                </Badge>
+              )}
+              {vehicle.engineSize && (
+                <Badge variant="secondary" className="text-xs px-1.5 py-0">
+                  {vehicle.engineSize}
+                </Badge>
+              )}
+              {vehicle.color && (
+                <Badge variant="secondary" className="text-xs px-1.5 py-0">
+                  {vehicle.color}
+                </Badge>
+              )}
+              {vehicle.inspectionStatus?.dueAt && (
+                <Badge
+                  variant="outline"
+                  className={`text-xs px-1.5 py-0 ${
+                    new Date(vehicle.inspectionStatus.dueAt).getTime() < Date.now()
+                      ? 'border-destructive/40 text-destructive'
+                      : new Date(vehicle.inspectionStatus.dueAt).getTime() - Date.now() <
+                          30 * 86_400_000
+                        ? 'border-amber-500/40 text-amber-700 dark:text-amber-400'
+                        : ''
+                  }`}
+                  title={
+                    vehicle.inspectionStatus.checkedAt
+                      ? t('inspectionCheckedAt', {
+                          date: formatDate(vehicle.inspectionStatus.checkedAt),
+                        })
+                      : undefined
+                  }
+                >
+                  {t('inspectionDue', { date: formatDate(vehicle.inspectionStatus.dueAt) })}
+                </Badge>
+              )}
+              {vehicle.soldReportedAt && (
+                <Badge
+                  variant="outline"
+                  className="text-xs px-1.5 py-0 border-amber-500/40 text-amber-700 dark:text-amber-400"
+                >
+                  {t('soldReported', { date: formatDate(vehicle.soldReportedAt) })}
+                </Badge>
+              )}
+              {vehicle.inspectionStatus?.registered === false && (
+                <Badge
+                  variant="outline"
+                  className="text-xs px-1.5 py-0 border-destructive/40 text-destructive"
+                >
+                  {t('notRegistered')}
+                </Badge>
+              )}
+              {vehicle.customer && (
+                <>
+                  <span className="text-muted-foreground/40">|</span>
+                  <Link
+                    href={`/customers/${vehicle.customer.id}`}
+                    className="text-xs font-medium hover:underline"
+                  >
+                    {vehicle.customer.name}
+                  </Link>
+                  {vehicle.customer.company && (
+                    <span className="text-xs text-muted-foreground">
+                      {vehicle.customer.company}
+                    </span>
+                  )}
+                </>
+              )}
+            </div>
+          </div>
+        </div>
+
+        {/* Quick stats */}
+        <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
+          <div className="flex items-center gap-1.5 text-muted-foreground">
+            <Gauge className="h-3.5 w-3.5" />
+            <span className="font-semibold text-foreground">
+              {vehicle.mileage.toLocaleString()}
+            </span>
+            <span className="text-xs">{distUnit}</span>
+          </div>
+          {predictionData && (
+            <div className="flex items-center gap-1.5 text-muted-foreground">
+              <TooltipProvider>
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <div className="flex cursor-help items-center gap-1.5">
+                      <TrendingUp className="h-3.5 w-3.5" />
+                      <span className="text-xs">
+                        {isMarine
+                          ? t('predictedHours')
+                          : unitSystem === 'metric'
+                            ? t('predictedKm')
+                            : t('predictedMileage')}
+                      </span>
+                      <span className="font-semibold text-foreground">
+                        ~{predictionData.predictedMileage.toLocaleString()}
+                      </span>
+                      <span className="text-[10px] text-muted-foreground/70">
+                        ({predictionData.confidencePercent}%)
+                      </span>
+                    </div>
+                  </TooltipTrigger>
+                  <TooltipContent>
+                    <p className="max-w-xs text-xs">{t('predictedTooltip')}</p>
+                  </TooltipContent>
+                </Tooltip>
+              </TooltipProvider>
+              {predictionData.status && predictionData.maintenanceDismissed ? (
+                <>
+                  <Badge variant="outline" className="text-[10px] ml-1 text-muted-foreground">
+                    <EyeOff className="mr-0.5 h-3 w-3" />
+                    {t('dismissed')}
+                  </Badge>
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    className="h-5 px-1.5 text-[10px] text-muted-foreground hover:text-foreground"
+                    disabled={isDismissPending}
+                    onClick={handleUndismissMaintenance}
+                  >
+                    {isDismissPending ? (
+                      <Loader2 className="h-3 w-3 animate-spin" />
+                    ) : (
+                      <>
+                        <Undo2 className="mr-0.5 h-3 w-3" />
+                        {t('undo')}
+                      </>
+                    )}
+                  </Button>
+                </>
+              ) : (
+                <>
+                  {predictionData.status === 'overdue' && (
+                    <>
+                      <Badge variant="destructive" className="text-[10px] ml-1">
+                        {t('serviceOverdue')}
+                      </Badge>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        className="h-5 w-5 text-muted-foreground hover:text-foreground ml-0.5"
+                        disabled={isDismissPending}
+                        onClick={handleDismissMaintenance}
+                        aria-label={t('dismissMaintenance')}
+                      >
+                        {isDismissPending ? (
+                          <Loader2 className="h-3 w-3 animate-spin" />
+                        ) : (
+                          <X className="h-3 w-3" />
+                        )}
+                      </Button>
+                    </>
+                  )}
+                  {predictionData.status === 'approaching' && (
+                    <>
+                      <Badge className="bg-amber-500/15 text-amber-600 border-amber-500/20 text-[10px] ml-1">
+                        {t('serviceSoon')}
+                      </Badge>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        className="h-5 w-5 text-muted-foreground hover:text-foreground ml-0.5"
+                        disabled={isDismissPending}
+                        onClick={handleDismissMaintenance}
+                        aria-label={t('dismissMaintenance')}
+                      >
+                        {isDismissPending ? (
+                          <Loader2 className="h-3 w-3 animate-spin" />
+                        ) : (
+                          <X className="h-3 w-3" />
+                        )}
+                      </Button>
+                    </>
+                  )}
+                </>
+              )}
+            </div>
+          )}
+          <VehicleTireSets sets={tireSets} />
+          <div className="flex items-center gap-1.5 text-muted-foreground">
+            <Wrench className="h-3.5 w-3.5" />
+            <span className="font-semibold text-foreground">{vehicle._count.serviceRecords}</span>
+            <span className="text-xs">{t('services')}</span>
+          </div>
+          <div className="flex items-center gap-1.5 text-muted-foreground">
+            <DollarSign className="h-3.5 w-3.5" />
+            <span className="font-semibold text-foreground">
+              {formatCurrency(totalServiceCost, currencyCode)}
+            </span>
+          </div>
+          {vehicle.purchaseDate && (
+            <div className="flex items-center gap-1.5 text-muted-foreground">
+              <span className="text-xs">
+                {t('purchased', { date: formatDate(new Date(vehicle.purchaseDate)) })}
+              </span>
+            </div>
+          )}
+          {aiEnabled && (
+            <AskAiSheet
+              subject={{ type: 'vehicle', id: vehicle.id }}
+              title={`${vehicle.year} ${vehicle.make} ${vehicle.model}`}
+              className="h-7 text-xs"
+            />
+          )}
+          {aiEnabled && (
+            <div className="flex items-center rounded-md border">
+              {vehicle._count.serviceRecords > 0 && (
+                <Button
+                  variant={activeAiPanel === 'summary' ? 'secondary' : 'ghost'}
+                  size="sm"
+                  className="h-7 gap-1.5 text-xs rounded-r-none border-r"
+                  onClick={() => setActiveAiPanel(activeAiPanel === 'summary' ? null : 'summary')}
+                  disabled={aiSummaryLoading}
+                >
+                  {aiSummaryLoading ? (
+                    <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                  ) : (
+                    <Sparkles className="h-3.5 w-3.5" />
+                  )}
+                  {t('aiSummary')}
+                </Button>
+              )}
+              <Button
+                variant={activeAiPanel === 'issues' ? 'secondary' : 'ghost'}
+                size="sm"
+                className={`h-7 gap-1.5 text-xs ${vehicle._count.serviceRecords > 0 ? 'rounded-l-none' : ''}`}
+                onClick={() => setActiveAiPanel(activeAiPanel === 'issues' ? null : 'issues')}
+                disabled={commonIssuesLoading}
+              >
+                {commonIssuesLoading ? (
+                  <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <AlertTriangle className="h-3.5 w-3.5" />
+                )}
+                {t('commonIssues')}
+              </Button>
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* AI Summary Panel */}
+      {activeAiPanel === 'summary' && aiEnabled && (
+        <Card className="py-0 gap-0 overflow-hidden">
+          <div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2.5">
+            <div className="flex items-center gap-2">
+              <Sparkles className="h-4 w-4 text-purple-500" />
+              <h3 className="text-sm font-semibold">{t('aiSummaryTitle')}</h3>
+              {storedSummary && (
+                <span className="text-[11px] text-muted-foreground">
+                  {t('aiSummaryUpdated', { date: formatDate(new Date(storedSummary.updatedAt)) })}
+                </span>
+              )}
+            </div>
+            <div className="flex items-center gap-1.5">
+              <Button
+                variant="outline"
+                size="sm"
+                className="h-7 gap-1.5 text-xs"
+                onClick={handleAiSummary}
+                disabled={aiSummaryLoading}
+              >
+                {aiSummaryLoading ? (
+                  <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <Sparkles className="h-3.5 w-3.5" />
+                )}
+                {storedSummary ? t('aiSummaryRegenerate') : t('aiSummaryGenerate')}
+              </Button>
+              {storedSummary && (
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
+                  onClick={() => handleClearAiMessage(AI_MESSAGE_TYPES.SUMMARY)}
+                >
+                  <X className="h-3.5 w-3.5" />
+                </Button>
+              )}
+            </div>
+          </div>
+          <div className="px-4 py-3">
+            {aiSummaryLoading && !storedSummary ? (
+              <div className="flex items-center justify-center py-6">
+                <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
+              </div>
+            ) : storedSummary ? (
+              (() => {
+                let summary: {
+                  overview: string
+                  majorWork: { title: string; date: string | null; cost: number }[]
+                  recurringIssues: { title: string; description: string }[]
+                  upcomingMaintenance: { item: string; urgency: string; reason: string }[]
+                } | null = null
+                try {
+                  const raw = storedSummary.content.replace(/^```json?\n?|\n?```$/g, '').trim()
+                  summary = JSON.parse(raw)
+                } catch {
+                  return (
+                    <div className="text-sm text-muted-foreground whitespace-pre-wrap">
+                      {storedSummary.content}
+                    </div>
+                  )
+                }
+                if (!summary) return null
+                const urgencyColor: Record<string, string> = {
+                  high: 'bg-red-500/10 text-red-600 border-red-500/20',
+                  medium: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
+                  low: 'bg-green-500/10 text-green-600 border-green-500/20',
+                }
+                return (
+                  <div className="space-y-4">
+                    <p className="text-sm">{summary.overview}</p>
+
+                    <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                      {summary.majorWork.length > 0 && (
+                        <div>
+                          <h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2 flex items-center gap-1.5">
+                            <Wrench className="h-3.5 w-3.5" />
+                            {t('summaryMajorWork')}
+                          </h4>
+                          <div className="space-y-1.5">
+                            {summary.majorWork.map((w, i) => (
+                              <div key={i} className="text-sm">
+                                <span className="font-medium">{w.title}</span>
+                                {(w.date || w.cost > 0) && (
+                                  <span className="text-xs text-muted-foreground ml-2">
+                                    {[
+                                      w.date,
+                                      w.cost > 0 &&
+                                        new Intl.NumberFormat('en-US', {
+                                          minimumFractionDigits: 0,
+                                        }).format(w.cost),
+                                    ]
+                                      .filter(Boolean)
+                                      .join(' · ')}
+                                  </span>
+                                )}
+                              </div>
+                            ))}
+                          </div>
+                        </div>
+                      )}
+
+                      {summary.upcomingMaintenance.length > 0 && (
+                        <div>
+                          <h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2 flex items-center gap-1.5">
+                            <Clock className="h-3.5 w-3.5" />
+                            {t('summaryUpcoming')}
+                          </h4>
+                          <div className="space-y-2">
+                            {summary.upcomingMaintenance.map((m, i) => (
+                              <div key={i}>
+                                <div className="flex items-center gap-2">
+                                  <span className="text-sm font-medium">{m.item}</span>
+                                  <Badge
+                                    variant="outline"
+                                    className={`text-[10px] px-1.5 py-0 ${urgencyColor[m.urgency] || urgencyColor.medium}`}
+                                  >
+                                    {m.urgency}
+                                  </Badge>
+                                </div>
+                                <p className="text-xs text-muted-foreground mt-0.5">{m.reason}</p>
+                              </div>
+                            ))}
+                          </div>
+                        </div>
+                      )}
+
+                      {summary.recurringIssues.length > 0 && (
+                        <div className="md:col-span-2">
+                          <h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2 flex items-center gap-1.5">
+                            <AlertTriangle className="h-3.5 w-3.5" />
+                            {t('summaryRecurringIssues')}
+                          </h4>
+                          <div className="grid grid-cols-1 md:grid-cols-2 gap-1.5">
+                            {summary.recurringIssues.map((issue, i) => (
+                              <div key={i} className="text-sm">
+                                <span className="font-medium">{issue.title}</span>
+                                <p className="text-xs text-muted-foreground mt-0.5">
+                                  {issue.description}
+                                </p>
+                              </div>
+                            ))}
+                          </div>
+                        </div>
+                      )}
+                    </div>
+                  </div>
+                )
+              })()
+            ) : (
+              <p className="text-sm text-muted-foreground italic">{t('aiSummaryEmpty')}</p>
+            )}
+          </div>
+        </Card>
+      )}
+
+      {/* Common Issues Panel */}
+      {activeAiPanel === 'issues' && aiEnabled && (
+        <Card className="py-0 gap-0 overflow-hidden">
+          <div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2.5">
+            <div className="flex items-center gap-2">
+              <AlertTriangle className="h-4 w-4 text-amber-500" />
+              <h3 className="text-sm font-semibold">
+                {t('commonIssuesTitle', { make: vehicle.make, model: vehicle.model })}
+              </h3>
+              {storedCommonIssues && (
+                <span className="text-[11px] text-muted-foreground">
+                  {t('aiSummaryUpdated', {
+                    date: formatDate(new Date(storedCommonIssues.updatedAt)),
+                  })}
+                </span>
+              )}
+            </div>
+            <div className="flex items-center gap-1.5">
+              <Button
+                variant="outline"
+                size="sm"
+                className="h-7 gap-1.5 text-xs"
+                onClick={handleCommonIssues}
+                disabled={commonIssuesLoading}
+              >
+                {commonIssuesLoading ? (
+                  <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <AlertTriangle className="h-3.5 w-3.5" />
+                )}
+                {storedCommonIssues ? t('aiSummaryRegenerate') : t('aiSummaryGenerate')}
+              </Button>
+              {storedCommonIssues && (
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
+                  onClick={() => handleClearAiMessage(AI_MESSAGE_TYPES.COMMON_ISSUES)}
+                >
+                  <X className="h-3.5 w-3.5" />
+                </Button>
+              )}
+            </div>
+          </div>
+          <div className="divide-y">
+            {commonIssuesLoading && !storedCommonIssues ? (
+              <div className="flex items-center justify-center py-6">
+                <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
+              </div>
+            ) : storedCommonIssues ? (
+              (() => {
+                let issues: {
+                  title: string
+                  description: string
+                  cost: string
+                  risk: string
+                  severity?: number
+                }[] = []
+                try {
+                  const raw = storedCommonIssues.content.replace(/^```json?\n?|\n?```$/g, '').trim()
+                  issues = JSON.parse(raw)
+                } catch {
+                  return (
+                    <div className="px-4 py-3 text-sm text-muted-foreground whitespace-pre-wrap">
+                      {storedCommonIssues.content}
+                    </div>
+                  )
+                }
+                const riskColor: Record<string, string> = {
+                  safety: 'bg-red-500/10 text-red-600 border-red-500/20',
+                  engine: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
+                  transmission: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
+                  electrical: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
+                  other: 'bg-gray-500/10 text-gray-600 border-gray-500/20',
+                }
+                const severityBarColor = (s: number) =>
+                  s >= 4 ? 'bg-red-500' : s >= 3 ? 'bg-amber-500' : 'bg-yellow-400'
+                const severityLabel = (s: number) => {
+                  if (s >= 5) return t('severityVeryCommon')
+                  if (s >= 4) return t('severityCommon')
+                  if (s >= 3) return t('severityModerate')
+                  if (s >= 2) return t('severityUncommon')
+                  return t('severityRare')
+                }
+                return issues.map((issue, i) => {
+                  const sev = Math.max(1, Math.min(5, issue.severity ?? 3))
+                  return (
+                    <div key={i} className="flex gap-3 px-4 py-3">
+                      <div
+                        className="flex flex-col items-center gap-0.5 pt-1 cursor-help"
+                        title={severityLabel(sev)}
+                      >
+                        {Array.from({ length: 5 }).map((_, j) => (
+                          <div
+                            key={j}
+                            className={`w-1.5 h-1.5 rounded-full ${4 - j < sev ? severityBarColor(sev) : 'bg-muted-foreground/20'}`}
+                          />
+                        ))}
+                      </div>
+                      <div className="flex-1 min-w-0">
+                        <div className="flex items-center gap-2 flex-wrap">
+                          <span className="text-sm font-semibold">{issue.title}</span>
+                          <Badge
+                            variant="outline"
+                            className={`text-[10px] px-1.5 py-0 ${riskColor[issue.risk] || riskColor.other}`}
+                          >
+                            {issue.risk}
+                          </Badge>
+                        </div>
+                        <p className="text-sm text-muted-foreground mt-0.5">{issue.description}</p>
+                        <span className="text-xs font-medium text-foreground/70 mt-1 inline-block">
+                          {issue.cost}
+                        </span>
+                      </div>
+                    </div>
+                  )
+                })
+              })()
+            ) : (
+              <div className="px-4 py-3">
+                <p className="text-sm text-muted-foreground italic">{t('commonIssuesEmpty')}</p>
+              </div>
+            )}
+          </div>
+        </Card>
+      )}
+
+      {/* Recalls, owner complaints and crash rating for this model year. */}
+      {safetyAvailable && !isMarine && (
+        <VehicleSafetyPanel
+          vehicleId={vehicle.id}
+          make={vehicle.make}
+          model={vehicle.model}
+          year={vehicle.year}
+          existingFindings={paginatedFindings.records.map((f) => f.description)}
+        />
+      )}
+
+      {/* Tabs */}
+      <Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-4">
+        <TabsList className="grid w-full grid-cols-6">
+          <TabsTrigger value="services" className="gap-1.5">
+            <Wrench className="h-3.5 w-3.5 shrink-0" />
+            <span className="hidden sm:inline">{t('tabs.services')}</span>
+            {vehicle._count.serviceRecords > 0 && (
+              <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1 text-[10px]">
+                {vehicle._count.serviceRecords}
+              </Badge>
+            )}
+          </TabsTrigger>
+          <TabsTrigger value="quotes" className="gap-1.5">
+            <FileText className="h-3.5 w-3.5 shrink-0" />
+            <span className="hidden sm:inline">{t('tabs.quotes')}</span>
+            {quotes.length > 0 && (
+              <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1 text-[10px]">
+                {quotes.length}
+              </Badge>
+            )}
+          </TabsTrigger>
+          <TabsTrigger value="inspections" className="gap-1.5">
+            <ClipboardCheck className="h-3.5 w-3.5 shrink-0" />
+            <span className="hidden sm:inline">{t('tabs.inspections')}</span>
+            {inspections && inspections.length > 0 && (
+              <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1 text-[10px]">
+                {inspections.length}
+              </Badge>
+            )}
+          </TabsTrigger>
+          <TabsTrigger value="findings" className="gap-1.5">
+            <AlertTriangle className="h-3.5 w-3.5 shrink-0" />
+            <span className="hidden sm:inline">{t('tabs.findings')}</span>
+            {vehicle._count.findings > 0 && (
+              <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1 text-[10px]">
+                {vehicle._count.findings}
+              </Badge>
+            )}
+          </TabsTrigger>
+          <TabsTrigger value="notes" className="gap-1.5">
+            <StickyNote className="h-3.5 w-3.5 shrink-0" />
+            <span className="hidden sm:inline">{t('tabs.notes')}</span>
+            {vehicle._count.notes > 0 && (
+              <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1 text-[10px]">
+                {vehicle._count.notes}
+              </Badge>
+            )}
+          </TabsTrigger>
+          <TabsTrigger value="reminders" className="gap-1.5">
+            <Bell className="h-3.5 w-3.5 shrink-0" />
+            <span className="hidden sm:inline">{t('tabs.reminders')}</span>
+            {overdueCount > 0 ? (
+              <Badge variant="destructive" className="ml-1 h-5 min-w-5 px-1 text-[10px]">
+                {overdueCount}
+              </Badge>
+            ) : vehicle._count.reminders > 0 ? (
+              <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1 text-[10px]">
+                {vehicle._count.reminders}
+              </Badge>
+            ) : null}
+          </TabsTrigger>
+        </TabsList>
+
+        {/* Services Tab - React Table with pagination */}
+        <TabsContent value="services" forceMount>
+          <ServiceRecordsTable
+            vehicleId={vehicle.id}
+            records={paginatedServices.records}
+            total={paginatedServices.total}
+            page={paginatedServices.page}
+            pageSize={paginatedServices.pageSize}
+            totalPages={paginatedServices.totalPages}
+            search={serviceSearch}
+            type={serviceRecordType}
+            currencyCode={currencyCode}
+            vehicleMileage={vehicle.mileage}
+          />
+        </TabsContent>
+
+        {/* Quotes Tab */}
+        <TabsContent value="quotes" className="space-y-4">
+          <div className="flex justify-end">
+            <Button
+              size="sm"
+              onClick={() => setShowNewQuoteDialog(true)}
+              aria-label={tq('newQuote')}
+              title={tq('newQuote')}
+              className="h-9 w-9 p-0 md:h-8 md:w-auto md:px-3"
+            >
+              <Plus className="h-4 w-4 md:mr-1 md:h-3.5 md:w-3.5" />
+              <span className="hidden md:inline">{tq('newQuote')}</span>
+            </Button>
+          </div>
+          <NewQuoteDialog
+            open={showNewQuoteDialog}
+            onOpenChange={setShowNewQuoteDialog}
+            defaultVehicle={{
+              id: vehicle.id,
+              make: vehicle.make,
+              model: vehicle.model,
+              year: vehicle.year,
+              licensePlate: vehicle.licensePlate,
+              customerId: vehicle.customerId,
+              customer: vehicle.customer
+                ? { id: vehicle.customer.id, name: vehicle.customer.name }
+                : null,
+            }}
+            defaultCustomer={
+              vehicle.customer
+                ? {
+                    id: vehicle.customer.id,
+                    name: vehicle.customer.name,
+                    company: vehicle.customer.company,
+                  }
+                : null
+            }
+          />
+
+          {quotes.length === 0 ? (
+            <Card className="border-dashed">
+              <CardContent className="flex flex-col items-center py-12">
+                <FileText className="mb-3 h-10 w-10 text-muted-foreground/40" />
+                <p className="text-sm text-muted-foreground">{tq('empty')}</p>
+              </CardContent>
+            </Card>
+          ) : (
+            <>
+              {/* Card list (phones + small tablets) */}
+              <div className="space-y-2 md:hidden">
+                {quotes.map((q) => (
+                  <button
+                    key={q.id}
+                    type="button"
+                    onClick={() => router.push(`/quotes/${q.id}`)}
+                    className="w-full rounded-lg border bg-card p-3 text-left active:bg-muted/50"
+                  >
+                    <div className="flex items-start justify-between gap-3">
+                      <span className="min-w-0 flex-1 truncate font-medium">{q.title}</span>
+                      <span className="shrink-0 font-semibold">
+                        {formatCurrency(q.totalAmount, currencyCode)}
+                      </span>
+                    </div>
+                    <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-muted-foreground">
+                      <Badge
+                        variant="outline"
+                        className={`text-xs ${quoteStatusColors[q.status] || ''}`}
+                      >
+                        {quoteStatusLabel(q.status, tQuoteStatus)}
+                      </Badge>
+                      {q.quoteNumber && <span className="font-mono">{q.quoteNumber}</span>}
+                      <span className="font-mono">{formatDate(new Date(q.createdAt))}</span>
+                    </div>
+                  </button>
+                ))}
+              </div>
+
+              {/* Table (md and up) */}
+              <div className="hidden rounded-lg border md:block" {...quotesNav.containerProps}>
+                <Table>
+                  <TableHeader>
+                    <TableRow>
+                      <TableHead className="w-25">{tq('columnNumber')}</TableHead>
+                      <TableHead>{tq('columnTitle')}</TableHead>
+                      <TableHead className="w-27.5">{tq('columnStatus')}</TableHead>
+                      <TableHead className="w-24">{tq('columnDate')}</TableHead>
+                      <TableHead className="w-22.5 text-right">{tq('columnTotal')}</TableHead>
+                    </TableRow>
+                  </TableHeader>
+                  <TableBody>
+                    {quotes.map((q) => (
+                      <TableRow
+                        key={q.id}
+                        className="cursor-pointer"
+                        {...interactiveRow(() => router.push(`/quotes/${q.id}`))}
+                      >
+                        <TableCell className="font-mono text-xs text-muted-foreground">
+                          {q.quoteNumber || '-'}
+                        </TableCell>
+                        <TableCell className="font-medium">{q.title}</TableCell>
+                        <TableCell>
+                          <Badge
+                            variant="outline"
+                            className={`max-w-full whitespace-normal text-center text-xs leading-tight ${quoteStatusColors[q.status] || ''}`}
+                          >
+                            {quoteStatusLabel(q.status, tQuoteStatus)}
+                          </Badge>
+                        </TableCell>
+                        <TableCell className="font-mono text-xs text-muted-foreground">
+                          {formatDate(new Date(q.createdAt))}
+                        </TableCell>
+                        <TableCell className="text-right font-semibold">
+                          {formatCurrency(q.totalAmount, currencyCode)}
+                        </TableCell>
+                      </TableRow>
+                    ))}
+                  </TableBody>
+                </Table>
+              </div>
+            </>
+          )}
+        </TabsContent>
+
+        {/* Inspections Tab */}
+        <TabsContent value="inspections" className="space-y-4">
+          <div className="flex justify-end">
+            <Button
+              size="sm"
+              onClick={() => setShowNewInspection(true)}
+              aria-label={ti('newInspection')}
+              title={ti('newInspection')}
+              className="h-9 w-9 p-0 md:h-8 md:w-auto md:px-3"
+            >
+              <Plus className="h-4 w-4 md:mr-1 md:h-3.5 md:w-3.5" />
+              <span className="hidden md:inline">{ti('newInspection')}</span>
+            </Button>
+          </div>
+
+          {!inspections || inspections.length === 0 ? (
+            <Card className="border-dashed">
+              <CardContent className="flex flex-col items-center py-12">
+                <ClipboardCheck className="mb-3 h-10 w-10 text-muted-foreground/40" />
+                <p className="text-sm text-muted-foreground">{ti('empty')}</p>
+              </CardContent>
+            </Card>
+          ) : (
+            <>
+              {/* Card list (phones + small tablets) */}
+              <div className="space-y-2 md:hidden">
+                {inspectionRows.map(({ insp, total, inspected, passCount, failCount }) => (
+                  <button
+                    key={insp.id}
+                    type="button"
+                    onClick={() => router.push(`/inspections/${insp.id}`)}
+                    className="w-full rounded-lg border bg-card p-3 text-left active:bg-muted/50"
+                  >
+                    <div className="flex items-start justify-between gap-3">
+                      <span className="min-w-0 flex-1 truncate font-medium">
+                        {insp.template.name}
+                      </span>
+                      <Badge
+                        variant="outline"
+                        className={`shrink-0 text-xs ${insp.status === 'completed' ? 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' : 'bg-blue-500/10 text-blue-500 border-blue-500/20'}`}
+                      >
+                        {insp.status === 'completed' ? ti('completed') : ti('inProgress')}
+                      </Badge>
+                    </div>
+                    <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5">
+                      <InspectionProgress
+                        total={total}
+                        inspected={inspected}
+                        passCount={passCount}
+                        failCount={failCount}
+                      />
+                      <span className="font-mono text-xs text-muted-foreground">
+                        {formatDate(new Date(insp.createdAt))}
+                      </span>
+                    </div>
+                  </button>
+                ))}
+              </div>
+
+              {/* Table (md and up) */}
+              <div className="hidden rounded-lg border md:block" {...inspectionsNav.containerProps}>
+                <Table>
+                  <TableHeader>
+                    <TableRow>
+                      <TableHead>{ti('template')}</TableHead>
+                      <TableHead className="w-32">{ti('progress')}</TableHead>
+                      <TableHead className="w-28">{ti('status')}</TableHead>
+                      <TableHead className="w-24">{ti('date')}</TableHead>
+                    </TableRow>
+                  </TableHeader>
+                  <TableBody>
+                    {inspectionRows.map(({ insp, total, inspected, passCount, failCount }) => (
+                      <TableRow
+                        key={insp.id}
+                        className="cursor-pointer"
+                        {...interactiveRow(() => router.push(`/inspections/${insp.id}`))}
+                      >
+                        <TableCell className="font-medium">{insp.template.name}</TableCell>
+                        <TableCell>
+                          <InspectionProgress
+                            total={total}
+                            inspected={inspected}
+                            passCount={passCount}
+                            failCount={failCount}
+                          />
+                        </TableCell>
+                        <TableCell>
+                          <Badge
+                            variant="outline"
+                            className={`text-xs ${insp.status === 'completed' ? 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' : 'bg-blue-500/10 text-blue-500 border-blue-500/20'}`}
+                          >
+                            {insp.status === 'completed' ? ti('completed') : ti('inProgress')}
+                          </Badge>
+                        </TableCell>
+                        <TableCell className="font-mono text-xs text-muted-foreground">
+                          {formatDate(new Date(insp.createdAt))}
+                        </TableCell>
+                      </TableRow>
+                    ))}
+                  </TableBody>
+                </Table>
+              </div>
+            </>
+          )}
+        </TabsContent>
+
+        {/* Findings Tab */}
+        <TabsContent value="findings" forceMount>
+          <FindingsTable
+            vehicleId={vehicle.id}
+            records={paginatedFindings.records}
+            total={paginatedFindings.total}
+            page={paginatedFindings.page}
+            pageSize={paginatedFindings.pageSize}
+            totalPages={paginatedFindings.totalPages}
+            onAddFinding={() => {
+              setEditingFinding(undefined)
+              setShowFindingForm(true)
+            }}
+            onEditFinding={(f) => {
+              setEditingFinding(f)
+              setShowFindingForm(true)
+            }}
+            onDeleteFinding={handleDeleteFinding}
+            onCreateWorkOrder={handleCreateWorkOrderFromFindings}
+            isCreatingWorkOrder={creatingWorkOrder}
+          />
+        </TabsContent>
+
+        {/* Notes Tab */}
+        <TabsContent value="notes" forceMount>
+          <NotesTable
+            vehicleId={vehicle.id}
+            records={paginatedNotes.records}
+            total={paginatedNotes.total}
+            page={paginatedNotes.page}
+            pageSize={paginatedNotes.pageSize}
+            totalPages={paginatedNotes.totalPages}
+            onSelectNote={setSelectedNote}
+            onAddNote={() => {
+              setEditingNote(undefined)
+              setShowNoteForm(true)
+            }}
+            onTogglePin={handleTogglePin}
+            onDeleteNote={handleDeleteNote}
+          />
+        </TabsContent>
+
+        {/* Reminders Tab */}
+        <TabsContent value="reminders" className="space-y-4">
+          <div className="flex items-center justify-between">
+            <div className="flex gap-1 rounded-lg border p-1">
+              {(['active', 'completed', 'all'] as const).map((f) => (
+                <button
+                  key={f}
+                  onClick={() => setReminderFilter(f)}
+                  className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
+                    reminderFilter === f
+                      ? 'bg-primary text-primary-foreground'
+                      : 'text-muted-foreground hover:text-foreground'
+                  }`}
+                >
+                  {tr(f)}
+                </button>
+              ))}
+            </div>
+            <Button
+              size="sm"
+              onClick={() => {
+                setEditingReminder(undefined)
+                setShowReminderForm(true)
+              }}
+              aria-label={tr('addReminder')}
+              title={tr('addReminder')}
+              className="h-9 w-9 shrink-0 p-0 md:h-8 md:w-auto md:px-3"
+            >
+              <Plus className="h-4 w-4 md:mr-1 md:h-3.5 md:w-3.5" />
+              <span className="hidden md:inline">{tr('addReminder')}</span>
+            </Button>
+          </div>
+
+          {filteredReminders.length === 0 ? (
+            <Card className="border-dashed">
+              <CardContent className="flex flex-col items-center py-12">
+                <Bell className="mb-3 h-10 w-10 text-muted-foreground/40" />
+                <p className="text-sm text-muted-foreground">
+                  {reminderFilter === 'active'
+                    ? tr('emptyActive')
+                    : reminderFilter === 'completed'
+                      ? tr('emptyCompleted')
+                      : tr('empty')}
+                </p>
+              </CardContent>
+            </Card>
+          ) : (
+            <div className="space-y-3">
+              {filteredReminders.map((r) => {
+                const urgency = getUrgency(r)
+                return (
+                  <Card
+                    key={r.id}
+                    className={`${
+                      urgency === 'overdue'
+                        ? 'ring-1 ring-red-500/30'
+                        : urgency === 'due-soon'
+                          ? 'ring-1 ring-amber-500/30'
+                          : ''
+                    }`}
+                  >
+                    <CardContent className="flex items-start justify-between gap-2 p-4">
+                      <div className="flex min-w-0 flex-1 items-start gap-3">
+                        <button
+                          onClick={() => handleToggleReminder(r.id)}
+                          className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${
+                            r.isCompleted
+                              ? 'border-primary bg-primary/10'
+                              : 'border-primary/50 hover:bg-primary/10'
+                          }`}
+                          aria-label={r.title}
+                        >
+                          {r.isCompleted && <CheckCircle2 className="h-5 w-5 text-primary" />}
+                        </button>
+                        <div className="min-w-0">
+                          <div className="flex flex-wrap items-center gap-2">
+                            <p
+                              className={`font-medium ${r.isCompleted ? 'line-through text-muted-foreground' : ''}`}
+                            >
+                              {r.title}
+                            </p>
+                            {urgency === 'overdue' && (
+                              <Badge variant="destructive" className="text-[10px] px-1.5 py-0">
+                                <AlertTriangle className="mr-0.5 h-3 w-3" />
+                                {tr('overdue')}
+                              </Badge>
+                            )}
+                            {urgency === 'due-soon' && (
+                              <Badge className="bg-amber-500/15 text-amber-600 border-amber-500/20 text-[10px] px-1.5 py-0">
+                                <Clock className="mr-0.5 h-3 w-3" />
+                                {tr('dueSoon')}
+                              </Badge>
+                            )}
+                          </div>
+                          <p className="text-xs text-muted-foreground">
+                            {r.dueDate &&
+                              tr('due', {
+                                date: r.hasDueTime
+                                  ? formatDateTime(new Date(r.dueDate))
+                                  : formatDate(new Date(r.dueDate)),
+                              })}
+                            {r.dueMileage &&
+                              `${r.dueDate ? ' · ' : ''}${tr('dueAt', { mileage: r.dueMileage.toLocaleString(), unit: distUnit })}`}
+                          </p>
+                          {r.description && (
+                            <p className="mt-1 text-xs text-muted-foreground">{r.description}</p>
+                          )}
+                        </div>
+                      </div>
+                      <DropdownMenu>
+                        <DropdownMenuTrigger asChild>
+                          <Button
+                            variant="ghost"
+                            size="icon"
+                            className="h-9 w-9 shrink-0 md:h-7 md:w-7"
+                            aria-label={t('openMenu')}
+                          >
+                            <MoreVertical className="h-4 w-4 md:h-3.5 md:w-3.5" />
+                          </Button>
+                        </DropdownMenuTrigger>
+                        <DropdownMenuContent align="end">
+                          <DropdownMenuItem
+                            onClick={() => {
+                              setEditingReminder(r)
+                              setShowReminderForm(true)
+                            }}
+                          >
+                            <Pencil className="mr-2 h-4 w-4" />
+                            {tc('edit')}
+                          </DropdownMenuItem>
+                          <DropdownMenuItem
+                            className="text-destructive"
+                            onClick={() => handleDeleteReminder(r.id)}
+                          >
+                            <Trash2 className="mr-2 h-4 w-4" />
+                            {tc('delete')}
+                          </DropdownMenuItem>
+                        </DropdownMenuContent>
+                      </DropdownMenu>
+                    </CardContent>
+                  </Card>
+                )
+              })}
+            </div>
+          )}
+        </TabsContent>
+      </Tabs>
+
+      <VehicleForm
+        open={showEditForm}
+        onOpenChange={setShowEditForm}
+        vehicle={vehicle}
+        distanceUnit={distUnit}
+        // The page's own confirmation does the deleting.
+        onDelete={() => {
+          setShowEditForm(false)
+          setShowDeleteDialog(true)
+        }}
+      />
+      <NoteForm
+        vehicleId={vehicle.id}
+        open={showNoteForm}
+        onOpenChange={setShowNoteForm}
+        note={editingNote}
+      />
+      <Dialog
+        open={!!selectedNote}
+        onOpenChange={(open) => {
+          if (!open) setSelectedNote(null)
+        }}
+      >
+        <DialogContent className="sm:max-w-3xl" aria-describedby={undefined}>
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              {selectedNote?.isPinned && <Pin className="h-4 w-4 text-primary" />}
+              {selectedNote?.title}
+            </DialogTitle>
+            <p className="text-xs text-muted-foreground">
+              {selectedNote?.createdAt && formatDate(new Date(selectedNote.createdAt))}
+            </p>
+          </DialogHeader>
+          <div
+            className="notes-content max-h-[60vh] overflow-y-auto text-sm break-all"
+            dangerouslySetInnerHTML={{ __html: sanitizeHtml(selectedNote?.content ?? '') }}
+          />
+          <div className="flex justify-end pt-2">
+            <Button
+              size="sm"
+              variant="outline"
+              onClick={() => {
+                if (selectedNote) {
+                  setEditingNote(selectedNote)
+                  setSelectedNote(null)
+                  setShowNoteForm(true)
+                }
+              }}
+            >
+              <Pencil className="mr-1 h-3.5 w-3.5" />
+              {t('edit')}
+            </Button>
+          </div>
+        </DialogContent>
+      </Dialog>
+      <ReminderForm
+        vehicleId={vehicle.id}
+        open={showReminderForm}
+        onOpenChange={setShowReminderForm}
+        reminder={editingReminder}
+      />
+      <FindingForm
+        vehicleId={vehicle.id}
+        open={showFindingForm}
+        onOpenChange={setShowFindingForm}
+        finding={editingFinding}
+      />
+      <NewInspectionDialog
+        open={showNewInspection}
+        onOpenChange={setShowNewInspection}
+        templates={inspectionTemplates || []}
+        preselectedVehicle={vehicle}
+      />
+      <ArchiveVehicleDialog
+        open={showArchiveDialog}
+        onOpenChange={setShowArchiveDialog}
+        vehicleId={vehicle.id}
+        vehicleName={`${vehicle.year} ${vehicle.make} ${vehicle.model}`}
+      />
+      <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
+            <AlertDialogDescription>
+              {t('deleteDescription', { name: `${vehicle.year} ${vehicle.make} ${vehicle.model}` })}
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
+            <AlertDialogAction
+              onClick={handleDelete}
+              className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+            >
+              {tc('delete')}
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+
+      {/* Image lightbox */}
+      {showImage && vehicle.imageUrl && (
+        <div
+          className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
+          onClick={() => setShowImage(false)}
+        >
+          <img
+            src={vehicle.imageUrl}
+            alt={`${vehicle.year} ${vehicle.make} ${vehicle.model}`}
+            className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
+            onClick={(e) => e.stopPropagation()}
+          />
+        </div>
+      )}
+    </div>
+  )
+}

+ 185 - 0
src/app/(authenticated)/vehicles/[id]/job-warning.tsx

@@ -0,0 +1,185 @@
+'use client'
+
+import Link from 'next/link'
+import { useMemo } from 'react'
+import { AlertTriangle, ChevronRight } from 'lucide-react'
+import { useFormatter, useTranslations } from 'next-intl'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { useDateSettings } from '@/components/date-settings-context'
+import { awayEdges } from '@/features/workshop-calendar/Lib/format'
+import type { VehicleJobWarning } from '@/features/vehicles/Lib/vehicleJobWarnings'
+import { cn } from '@/lib/utils'
+
+/**
+ * The words for a job's booking problems. The details are the work order's
+ * own sentences (service.schedule and workshopCalendar.schedule), so what
+ * the vehicle page says is wrong is what the job shows once it is opened.
+ * Times are on the workshop's clock, as the work order shows them.
+ */
+export function useJobWarningText() {
+  const t = useTranslations('vehicles.detail.jobWarning')
+  const tCal = useTranslations('workshopCalendar')
+  const tSchedule = useTranslations('service.schedule')
+  const format = useFormatter()
+  const { timezone } = useDateSettings()
+
+  return useMemo(() => {
+    const timeZone = timezone || undefined
+    const day = (at: Date) => format.dateTime(at, { day: 'numeric', month: 'short', timeZone })
+    const dayAndClock = (at: Date) =>
+      format.dateTime(at, {
+        day: 'numeric',
+        month: 'short',
+        hour: '2-digit',
+        minute: '2-digit',
+        timeZone,
+      })
+    const clock = (at: Date) =>
+      format.dateTime(at, { hour: '2-digit', minute: '2-digit', timeZone })
+    const reason = (stored: string) =>
+      tCal.has(`reasons.${stored}`) ? tCal(`reasons.${stored}`) : stored
+
+    /** Each kind of problem as a few words, the most pressing first. */
+    const labels = (w: VehicleJobWarning): string[] => {
+      const out: string[] = []
+      if (w.away.length > 0) {
+        const people = [...new Set(w.away.flatMap((a) => a.people))]
+        out.push(
+          people.length > 0
+            ? t('away', { count: people.length, names: people.join(', ') })
+            : tCal('schedule.awayHeading')
+        )
+      }
+      if (w.clashes.length > 0) out.push(t('clash'))
+      if (w.closures.length > 0 || w.closedWeekday) out.push(t('closed'))
+      return out
+    }
+
+    /** One sentence per problem, as the work order words it. */
+    const lines = (w: VehicleJobWarning): string[] => {
+      const out: string[] = []
+      for (const a of w.away) {
+        const edges = awayEdges(a.start, a.end, timeZone)
+        const from = edges.startClock ? dayAndClock(edges.start) : day(edges.start)
+        const to = edges.endClock ? dayAndClock(edges.end) : day(edges.end)
+        const who = a.people.length > 0 ? `${a.people.join(', ')}: ` : ''
+        out.push(
+          `${who}${tCal('schedule.awayConflict', { reason: reason(a.reason) })} · ${from === to ? from : `${from} – ${to}`}`
+        )
+      }
+      for (const c of w.clashes) {
+        const held =
+          c.onTechnician && c.onBay
+            ? tSchedule('clashBoth')
+            : c.onTechnician
+              ? c.people.length > 0
+                ? tSchedule('clashPeople', { count: c.people.length, names: c.people.join(', ') })
+                : tSchedule('clashTechnician')
+              : tSchedule('clashBay')
+        out.push(
+          `${c.label || tSchedule('anotherJob')} · ${dayAndClock(new Date(c.start))} – ${clock(new Date(c.end))} · ${held}`
+        )
+      }
+      if (w.closures.length > 0) out.push(tCal('schedule.closed', { names: w.closures.join(', ') }))
+      else if (w.closedWeekday) out.push(tCal('schedule.closedWeekday'))
+      return out
+    }
+
+    return { labels, lines, day, heading: t('heading'), fix: t('fix') }
+  }, [t, tCal, tSchedule, format, timezone])
+}
+
+/**
+ * A job's booking problem, in the line that names the job: the first problem
+ * in a few amber words, and every detail on hover. Given an `href` it is a
+ * link to the job, for rows where the rest of the line is one already; inside
+ * a link it is plain text, the surrounding link doing the opening.
+ */
+export function JobWarningChip({
+  warning,
+  href,
+  className,
+}: {
+  warning: VehicleJobWarning
+  href?: string
+  className?: string
+}) {
+  const text = useJobWarningText()
+  const labels = text.labels(warning)
+  const chip = cn(
+    'inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border border-amber-300 bg-amber-50 px-1.5 py-px text-[11.5px] font-semibold text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300',
+    href && 'relative z-10 transition-colors hover:bg-amber-100 dark:hover:bg-amber-950/70',
+    className
+  )
+  const body = (
+    <>
+      <AlertTriangle aria-hidden className="h-3 w-3 shrink-0" />
+      <span className="truncate">{labels[0]}</span>
+      {labels.length > 1 && <span className="shrink-0 opacity-75">+{labels.length - 1}</span>}
+    </>
+  )
+  return (
+    <Tooltip>
+      <TooltipTrigger asChild>
+        {href ? (
+          <Link href={href} className={chip} data-testid="job-warning">
+            {body}
+          </Link>
+        ) : (
+          <span className={chip} data-testid="job-warning">
+            {body}
+          </span>
+        )}
+      </TooltipTrigger>
+      <TooltipContent side="top" align="start" className="max-w-xs space-y-1 py-2">
+        <p className="font-semibold">{text.heading}</p>
+        <ul className="space-y-0.5">
+          {text.lines(warning).map((line) => (
+            <li key={line} className="leading-snug opacity-90">
+              {line}
+            </li>
+          ))}
+        </ul>
+      </TooltipContent>
+    </Tooltip>
+  )
+}
+
+/**
+ * The same problems spelled out, for the job the car is in for now: what is
+ * wrong, in the work order's own words, and the way into the job to fix it.
+ */
+export function JobWarningCallout({ warning, href }: { warning: VehicleJobWarning; href: string }) {
+  const text = useJobWarningText()
+  return (
+    <div
+      role="alert"
+      data-testid="job-warning-callout"
+      className="flex flex-wrap items-start gap-x-2.5 gap-y-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2.5 dark:border-amber-900/60 dark:bg-amber-950/40"
+    >
+      <AlertTriangle
+        aria-hidden
+        className="mt-px h-4 w-4 shrink-0 text-amber-600 dark:text-amber-500"
+      />
+      <div className="min-w-0 flex-1 basis-48 space-y-1">
+        <p className="text-[13px] font-semibold text-amber-900 dark:text-amber-200">
+          {text.heading}
+        </p>
+        <ul className="space-y-0.5">
+          {text.lines(warning).map((line) => (
+            <li key={line} className="text-xs leading-snug text-amber-800 dark:text-amber-300">
+              {line}
+            </li>
+          ))}
+        </ul>
+      </div>
+      <Link
+        href={href}
+        className="ml-auto inline-flex shrink-0 items-center self-center rounded-md px-1.5 py-1 text-xs font-semibold text-amber-900 underline-offset-2 hover:underline dark:text-amber-200"
+      >
+        {text.fix}
+        <ChevronRight aria-hidden className="ml-0.5 h-3.5 w-3.5" />
+      </Link>
+    </div>
+  )
+}

+ 0 - 37
src/app/(authenticated)/vehicles/[id]/loading.tsx

@@ -1,37 +0,0 @@
-import { Skeleton } from '@/components/ui/skeleton'
-
-export default function VehicleDetailLoading() {
-  return (
-    <div className="flex flex-1 flex-col">
-      <header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 bg-background px-4">
-        <Skeleton className="h-6 w-6" />
-        <Skeleton className="h-4 w-40" />
-      </header>
-      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
-        <div className="grid gap-4 md:grid-cols-2">
-          <Skeleton className="h-48 rounded-xl" />
-          <Skeleton className="h-48 rounded-xl" />
-        </div>
-        <Skeleton className="h-10 w-48" />
-        <div className="rounded-lg border">
-          <div className="border-b p-4">
-            <div className="grid grid-cols-5 gap-4">
-              {Array.from({ length: 5 }).map((_, i) => (
-                <Skeleton key={i} className="h-4" />
-              ))}
-            </div>
-          </div>
-          {Array.from({ length: 5 }).map((_, i) => (
-            <div key={i} className="border-b p-4 last:border-0">
-              <div className="grid grid-cols-5 gap-4">
-                {Array.from({ length: 5 }).map((_, j) => (
-                  <Skeleton key={j} className="h-4" />
-                ))}
-              </div>
-            </div>
-          ))}
-        </div>
-      </div>
-    </div>
-  )
-}

+ 85 - 0
src/app/(authenticated)/vehicles/[id]/service/[serviceId]/loading.tsx

@@ -0,0 +1,85 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+/** A card of the work order page, as outlines: a heading and a few lines. */
+function CardSkeleton({ lines, heading = 'w-32' }: { lines: string[]; heading?: string }) {
+  return (
+    <div className="space-y-3 rounded-xl border bg-card p-4">
+      <Skeleton className={`h-4 ${heading}`} />
+      {lines.map((width, i) => (
+        <Skeleton key={i} className={`h-3 ${width}`} />
+      ))}
+    </div>
+  )
+}
+
+/**
+ * The work order's shape while it loads: the title row, the status steps, the
+ * job column (who and what, the concerns, the work) and the side column, so
+ * opening a job from a vehicle shows a work order arriving, not the vehicle.
+ */
+export default function WorkOrderLoading() {
+  return (
+    <div className="flex flex-1 flex-col">
+      <header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 bg-background px-4">
+        <Skeleton className="h-6 w-6" />
+        <Skeleton className="h-4 w-48" />
+      </header>
+      <div className="mx-auto flex w-full max-w-[1800px] flex-col gap-3 p-4 pt-0">
+        <div className="flex flex-wrap items-center gap-3">
+          <Skeleton className="h-8 w-8 rounded-md" />
+          <Skeleton className="h-7 w-28" />
+          <Skeleton className="h-6 w-20 rounded-full" />
+          <Skeleton className="h-6 w-16 rounded-full" />
+          <div className="ml-auto flex gap-1.5">
+            {Array.from({ length: 5 }).map((_, i) => (
+              <Skeleton key={i} className="h-8 w-8 rounded-md" />
+            ))}
+          </div>
+        </div>
+        <Skeleton className="h-4 w-72" />
+        <div className="flex items-center gap-3 py-1">
+          {Array.from({ length: 4 }).map((_, i) => (
+            <div key={i} className="flex flex-1 items-center gap-2">
+              <Skeleton className="h-5 w-5 rounded-full" />
+              <Skeleton className="h-1.5 flex-1 rounded-full" />
+            </div>
+          ))}
+        </div>
+        <div className="grid grid-cols-1 items-start gap-3 xl:grid-cols-[minmax(0,1fr)_380px]">
+          <div className="flex min-w-0 flex-col gap-3">
+            <div className="grid gap-4 rounded-xl border bg-card p-4 md:grid-cols-2">
+              <div className="space-y-3">
+                <Skeleton className="h-4 w-24" />
+                <Skeleton className="h-5 w-48" />
+                <Skeleton className="h-3 w-40" />
+              </div>
+              <div className="space-y-3">
+                <Skeleton className="h-4 w-24" />
+                <Skeleton className="h-7 w-28 rounded-md" />
+                <Skeleton className="h-3 w-52" />
+              </div>
+            </div>
+            <CardSkeleton heading="w-36" lines={['w-full', 'w-11/12', 'w-2/3']} />
+            <div className="rounded-xl border bg-card">
+              <div className="flex items-center gap-3 border-b p-4">
+                <Skeleton className="h-4 w-24" />
+                <Skeleton className="ml-auto h-8 w-28" />
+              </div>
+              {Array.from({ length: 4 }).map((_, i) => (
+                <div key={i} className="flex items-center gap-4 border-b px-4 py-3 last:border-0">
+                  <Skeleton className="h-4 flex-1" />
+                  <Skeleton className="h-4 w-12" />
+                  <Skeleton className="h-4 w-20" />
+                </div>
+              ))}
+            </div>
+          </div>
+          <div className="flex flex-col gap-3">
+            <CardSkeleton heading="w-40" lines={['w-full', 'w-full', 'w-3/4', 'w-1/2']} />
+            <CardSkeleton heading="w-28" lines={['w-full', 'w-2/3']} />
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 2 - 0
src/app/(authenticated)/vehicles/[id]/service/new/loading.tsx

@@ -0,0 +1,2 @@
+// Opening a new job lands on a work order, so it loads in the work order's shape.
+export { default } from '../[serviceId]/loading'

+ 605 - 0
src/app/(authenticated)/vehicles/[id]/vehicle-activity-feed.tsx

@@ -0,0 +1,605 @@
+'use client'
+
+import Link from 'next/link'
+import { useMemo, useState } from 'react'
+import { useFormatter, useTranslations } from 'next-intl'
+import {
+  ChevronRight,
+  ClipboardCheck,
+  FileText,
+  History,
+  Paperclip,
+  Pin,
+  Wrench,
+} from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { StatusLamp, serviceStatusTone, type LampTone } from '@/components/status-lamp'
+import { useFormatCurrency } from '@/components/currency-settings-context'
+import { useDayFormatter } from '@/features/calendar/Components/useDayFormatter'
+import { useServiceLabels } from '@/features/vehicles/Lib/useServiceLabels'
+import { quoteStatusLabel } from '@/features/quotes/Lib/quoteStatus'
+import { getWarrantyStatus } from '@/lib/warranty'
+import { cn } from '@/lib/utils'
+import { CopyText } from '@/components/copy-value'
+import type { VehicleJobWarning } from '@/features/vehicles/Lib/vehicleJobWarnings'
+import { JobWarningCallout, JobWarningChip } from './job-warning'
+
+export interface FeedWorkOrder {
+  id: string
+  title: string
+  type: string
+  status: string
+  cost: number
+  totalAmount: number
+  mileage: number | null
+  serviceDate: Date
+  startDateTime: Date | null
+  invoiceNumber: string | null
+  techName: string | null
+  warrantyMileage: number | null
+  warrantyExpiresAt: Date | null
+  _count: { attachments: number }
+  laborItems?: { description: string }[]
+}
+
+export interface FeedQuote {
+  id: string
+  quoteNumber: string | null
+  title: string
+  status: string
+  totalAmount: number
+  createdAt: Date
+}
+
+export interface FeedInspection {
+  id: string
+  status: string
+  createdAt: Date
+  completedAt: Date | null
+  template: { name: string }
+  items: { condition: string }[]
+}
+
+export interface FeedNote {
+  id: string
+  title: string
+  content: string
+  isPinned: boolean
+  createdAt: Date
+}
+
+export type FeedFilter = 'all' | 'services' | 'quotes' | 'inspections'
+
+type FeedEvent =
+  | { kind: 'wo'; id: string; date: Date; wo: FeedWorkOrder }
+  | { kind: 'quote'; id: string; date: Date; quote: FeedQuote }
+  | { kind: 'inspection'; id: string; date: Date; inspection: FeedInspection }
+  | { kind: 'note'; id: string; date: Date; note: FeedNote }
+
+/** Statuses that mean the car is with the workshop right now. */
+const IN_WORKSHOP = new Set(['in-progress', 'in_progress', 'waiting-parts', 'waiting_parts'])
+
+/** How far along a job is, as four segments: booked, in, being worked on, done. */
+function stage(status: string) {
+  if (status === 'completed') return 4
+  if (IN_WORKSHOP.has(status)) return 3
+  return 1
+}
+
+const QUOTE_TONE: Record<string, LampTone> = {
+  draft: 'slate',
+  sent: 'amber',
+  changes_requested: 'amber',
+  accepted: 'green',
+  converted: 'violet',
+  rejected: 'red',
+  expired: 'slate',
+}
+
+/** When the car is in: the booked start, else the day the job was made. */
+const visitDate = (wo: FeedWorkOrder) => new Date(wo.startDateTime ?? wo.serviceDate)
+const woTotal = (wo: FeedWorkOrder) => (wo.totalAmount > 0 ? wo.totalAmount : wo.cost)
+/** A job still open with nothing on it yet has no amount to show, rather than a row of zeros. */
+const showsAmount = (wo: FeedWorkOrder) => wo.status === 'completed' || woTotal(wo) > 0
+
+/** A note's text without its markup, for a one-line preview. */
+function plain(html: string) {
+  return html
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/&nbsp;/g, ' ')
+    .replace(/&amp;/g, '&')
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/\s+/g, ' ')
+    .trim()
+}
+
+const SHOWN_AT_FIRST = 20
+
+/**
+ * Everything that has happened to the vehicle, newest first, on one line: work
+ * orders, quotes, inspections and notes, grouped by year. Jobs the car is in
+ * the workshop for, or booked in for, sit above the history in a card of their
+ * own, so the first thing on the page is what is going on now.
+ */
+export function VehicleActivityFeed({
+  vehicleId,
+  filter,
+  search,
+  workOrders,
+  quotes,
+  inspections,
+  notes,
+  distUnit,
+  currencyCode,
+  vehicleMileage,
+  jobWarnings = [],
+  onSelectNote,
+}: {
+  vehicleId: string
+  filter: FeedFilter
+  /** What the history is narrowed to; work orders arrive already searched. */
+  search: string
+  workOrders: FeedWorkOrder[]
+  quotes: FeedQuote[]
+  inspections: FeedInspection[]
+  notes: FeedNote[]
+  distUnit: string
+  currencyCode: string
+  vehicleMileage: number
+  /** Booking problems on open jobs, flagged on the job's own line. */
+  jobWarnings?: VehicleJobWarning[]
+  onSelectNote: (note: FeedNote) => void
+}) {
+  const t = useTranslations('vehicles.detail.feed')
+  const tQuoteStatus = useTranslations('quotes.statusLabels')
+  const tWarranty = useTranslations('vehicles.services')
+  const serviceLabel = useServiceLabels()
+  const formatCurrency = useFormatCurrency()
+  const day = useDayFormatter()
+  const numberFormat = useFormatter()
+  const [showAll, setShowAll] = useState(false)
+  const warningFor = useMemo(
+    () => new Map(jobWarnings.map((w) => [w.serviceRecordId, w])),
+    [jobWarnings]
+  )
+
+  const startOfToday = new Date()
+  startOfToday.setHours(0, 0, 0, 0)
+
+  const { now, events, years } = useMemo(() => {
+    const needle = search.trim().toLowerCase()
+    const matches = (text: string | null | undefined) =>
+      !needle || (text ?? '').toLowerCase().includes(needle)
+
+    const isNow = (wo: FeedWorkOrder) =>
+      IN_WORKSHOP.has(wo.status) || (wo.status === 'scheduled' && visitDate(wo) >= startOfToday)
+    const nowJobs = workOrders
+      .filter(isNow)
+      .sort((a, b) => stage(b.status) - stage(a.status) || +visitDate(a) - +visitDate(b))
+
+    const list: FeedEvent[] = []
+    if (filter === 'all' || filter === 'services') {
+      for (const wo of workOrders) {
+        if (!isNow(wo)) list.push({ kind: 'wo', id: wo.id, date: visitDate(wo), wo })
+      }
+    }
+    if (filter === 'all' || filter === 'quotes') {
+      for (const quote of quotes) {
+        if (matches(quote.title) || matches(quote.quoteNumber))
+          list.push({ kind: 'quote', id: quote.id, date: new Date(quote.createdAt), quote })
+      }
+    }
+    if (filter === 'all' || filter === 'inspections') {
+      for (const inspection of inspections) {
+        if (matches(inspection.template.name))
+          list.push({
+            kind: 'inspection',
+            id: inspection.id,
+            date: new Date(inspection.completedAt ?? inspection.createdAt),
+            inspection,
+          })
+      }
+    }
+    if (filter === 'all') {
+      for (const note of notes) {
+        if (matches(note.title) || matches(plain(note.content)))
+          list.push({ kind: 'note', id: note.id, date: new Date(note.createdAt), note })
+      }
+    }
+    list.sort((a, b) => +b.date - +a.date)
+
+    // Per year: how many visits and what they came to, over the whole year,
+    // not only the part of it on screen.
+    const perYear = new Map<number, { visits: number; total: number }>()
+    for (const wo of workOrders) {
+      const year = visitDate(wo).getFullYear()
+      const entry = perYear.get(year) ?? { visits: 0, total: 0 }
+      entry.visits += 1
+      entry.total += woTotal(wo)
+      perYear.set(year, entry)
+    }
+
+    return {
+      now: filter === 'all' || filter === 'services' ? nowJobs : [],
+      events: list,
+      years: perYear,
+    }
+    // startOfToday changes every render by identity, not by day.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [filter, search, workOrders, quotes, inspections, notes])
+
+  const shown = showAll ? events : events.slice(0, SHOWN_AT_FIRST)
+  const groups: { year: number; items: FeedEvent[] }[] = []
+  for (const event of shown) {
+    const year = event.date.getFullYear()
+    const last = groups[groups.length - 1]
+    if (last?.year === year) last.items.push(event)
+    else groups.push({ year, items: [event] })
+  }
+
+  const dayMonth = (date: Date) => day.dateTime(date, { day: 'numeric', month: 'short' })
+  const reading = (km: number | null) => (km ? `${numberFormat.number(km)} ${distUnit}` : null)
+
+  if (now.length === 0 && events.length === 0) {
+    return (
+      <div className="flex flex-col items-center gap-2 rounded-xl border border-dashed px-6 py-16 text-center">
+        <History className="h-8 w-8 text-muted-foreground/40" />
+        <p className="text-sm text-muted-foreground">{search ? t('noMatches') : t('empty')}</p>
+      </div>
+    )
+  }
+
+  return (
+    <div>
+      {now.length > 0 && (
+        <section aria-label={t('now')} className="mb-2">
+          <GroupHeading label={t('now')} />
+          <div className="rounded-xl border bg-card">
+            {now.map((wo, i) => {
+              const tone = serviceStatusTone(wo.status)
+              const href = `/vehicles/${vehicleId}/service/${wo.id}`
+              const summary = wo.laborItems?.[0]?.description
+              const warning = warningFor.get(wo.id)
+              return i === 0 ? (
+                <div key={wo.id} className="flex flex-col gap-3 p-4">
+                  <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
+                    <StatusLamp tone={tone}>{serviceLabel.status(wo.status)}</StatusLamp>
+                    <StageTrack done={stage(wo.status)} tone={tone} />
+                    <span className="text-[12.5px] text-muted-foreground">
+                      {[wo.techName, dayMonth(visitDate(wo))].filter(Boolean).join(' · ')}
+                    </span>
+                    <Button asChild size="sm" variant="outline" className="ml-auto h-8">
+                      <Link href={href}>
+                        {t('openJob')}
+                        <ChevronRight className="ml-0.5 h-3.5 w-3.5" />
+                      </Link>
+                    </Button>
+                  </div>
+                  <div className="flex min-w-0 items-baseline gap-2.5">
+                    {wo.invoiceNumber && (
+                      <span className="shrink-0 font-mono text-xs text-muted-foreground">
+                        {wo.invoiceNumber}
+                      </span>
+                    )}
+                    <span className="truncate text-[15px] font-semibold">{wo.title}</span>
+                    {showsAmount(wo) && (
+                      <span className="ml-auto shrink-0 font-semibold tabular-nums">
+                        {formatCurrency(woTotal(wo), currencyCode)}
+                      </span>
+                    )}
+                  </div>
+                  {summary && summary !== wo.title && (
+                    <p className="-mt-1.5 truncate text-[13px] text-muted-foreground">{summary}</p>
+                  )}
+                  {warning && <JobWarningCallout warning={warning} href={href} />}
+                </div>
+              ) : (
+                // A job behind the first one is a single line; one whose
+                // booking will not work gets the same callout as the first,
+                // under its line, since that is the job somebody has to open.
+                <div key={wo.id} className="border-t border-dashed">
+                  <Link
+                    href={href}
+                    className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-[13px] transition-colors hover:bg-muted/40"
+                  >
+                    <StatusLamp tone={tone} className="text-[12.5px]">
+                      {serviceLabel.status(wo.status)}
+                    </StatusLamp>
+                    <span className="text-muted-foreground">{dayMonth(visitDate(wo))}</span>
+                    <span className="truncate font-medium">{wo.title}</span>
+                    {wo.techName && <span className="text-muted-foreground">{wo.techName}</span>}
+                    <ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
+                  </Link>
+                  {warning && (
+                    <div className="px-4 pb-3">
+                      <JobWarningCallout warning={warning} href={href} />
+                    </div>
+                  )}
+                </div>
+              )
+            })}
+          </div>
+        </section>
+      )}
+
+      {groups.map((group) => {
+        const totals = years.get(group.year)
+        return (
+          <section key={group.year} aria-label={String(group.year)}>
+            <GroupHeading
+              label={String(group.year)}
+              meta={
+                totals
+                  ? t('yearSummary', {
+                      count: totals.visits,
+                      total: formatCurrency(totals.total, currencyCode),
+                    })
+                  : undefined
+              }
+            />
+            <ol className="relative before:absolute before:inset-y-2 before:left-[calc(3.5rem+0.625rem+0.875rem-0.5px)] sm:before:left-[calc(4.75rem+0.75rem+1.125rem-0.5px)] before:w-px before:bg-border">
+              {group.items.map((event) => {
+                if (event.kind === 'wo') {
+                  const wo = event.wo
+                  const summary = wo.laborItems?.[0]?.description
+                  const warranty = getWarrantyStatus(
+                    wo.warrantyExpiresAt,
+                    wo.warrantyMileage,
+                    wo.mileage,
+                    vehicleMileage
+                  )
+                  return (
+                    <Row
+                      key={event.id}
+                      date={dayMonth(event.date)}
+                      sub={reading(wo.mileage)}
+                      icon={<Wrench className="h-3.5 w-3.5" />}
+                      iconClass="bg-muted text-foreground/70"
+                      href={`/vehicles/${vehicleId}/service/${wo.id}`}
+                      title={wo.title}
+                      number={wo.invoiceNumber}
+                      amount={
+                        showsAmount(wo) ? formatCurrency(woTotal(wo), currencyCode) : undefined
+                      }
+                    >
+                      {wo.status !== 'completed' && (
+                        <StatusLamp tone={serviceStatusTone(wo.status)} className="text-xs">
+                          {serviceLabel.status(wo.status)}
+                        </StatusLamp>
+                      )}
+                      {/* Wrapped, so the dot between details sits outside the
+                          chip's border rather than inside it. */}
+                      {warningFor.has(wo.id) && (
+                        <span>
+                          <JobWarningChip
+                            warning={warningFor.get(wo.id) as VehicleJobWarning}
+                            href={`/vehicles/${vehicleId}/service/${wo.id}`}
+                          />
+                        </span>
+                      )}
+                      {wo.techName && <span>{wo.techName}</span>}
+                      {summary && summary !== wo.title && (
+                        <span className="min-w-0 truncate">{summary}</span>
+                      )}
+                      {wo._count.attachments > 0 && (
+                        <span className="inline-flex items-center gap-1">
+                          <Paperclip className="h-3 w-3" />
+                          {wo._count.attachments}
+                        </span>
+                      )}
+                      {(warranty === 'active' || warranty === 'expiring') && (
+                        <span>
+                          <span className="rounded bg-emerald-500/10 px-1.5 py-px text-[11px] font-semibold text-emerald-700 dark:text-emerald-400">
+                            {tWarranty(`warranty.status.${warranty}`)}
+                          </span>
+                        </span>
+                      )}
+                    </Row>
+                  )
+                }
+                if (event.kind === 'quote') {
+                  const q = event.quote
+                  return (
+                    <Row
+                      key={event.id}
+                      date={dayMonth(event.date)}
+                      icon={<FileText className="h-3.5 w-3.5" />}
+                      iconClass="border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-400"
+                      href={`/quotes/${q.id}`}
+                      title={q.title}
+                      number={q.quoteNumber}
+                      amount={formatCurrency(q.totalAmount, currencyCode)}
+                    >
+                      <StatusLamp tone={QUOTE_TONE[q.status] ?? 'slate'} className="text-xs">
+                        {quoteStatusLabel(q.status, tQuoteStatus)}
+                      </StatusLamp>
+                      <span>{t('quote')}</span>
+                    </Row>
+                  )
+                }
+                if (event.kind === 'inspection') {
+                  const insp = event.inspection
+                  const passed = insp.items.filter((i) => i.condition === 'pass').length
+                  const failed = insp.items.filter((i) => i.condition === 'fail').length
+                  const done = insp.status === 'completed'
+                  return (
+                    <Row
+                      key={event.id}
+                      date={dayMonth(event.date)}
+                      icon={<ClipboardCheck className="h-3.5 w-3.5" />}
+                      iconClass="border-teal-600/25 bg-teal-600/10 text-teal-700 dark:text-teal-400"
+                      href={`/inspections/${insp.id}`}
+                      title={insp.template.name}
+                    >
+                      {!done && (
+                        <StatusLamp tone="blue" className="text-xs">
+                          {t('inspectionOpen')}
+                        </StatusLamp>
+                      )}
+                      {(done || passed + failed > 0) && (
+                        <span>{t('inspectionResult', { passed })}</span>
+                      )}
+                      {failed > 0 && (
+                        <span className="font-medium text-red-700 dark:text-red-400">
+                          {t('inspectionFailed', { count: failed })}
+                        </span>
+                      )}
+                    </Row>
+                  )
+                }
+                const note = event.note
+                const text = plain(note.content)
+                return (
+                  <Row
+                    key={event.id}
+                    date={dayMonth(event.date)}
+                    icon={
+                      note.isPinned ? (
+                        <Pin className="h-3.5 w-3.5" />
+                      ) : (
+                        <FileText className="h-3.5 w-3.5" />
+                      )
+                    }
+                    iconClass="bg-card text-muted-foreground"
+                    onClick={() => onSelectNote(note)}
+                    title={note.title}
+                  >
+                    {text && <span className="min-w-0 truncate italic">{text}</span>}
+                  </Row>
+                )
+              })}
+            </ol>
+          </section>
+        )
+      })}
+
+      {!showAll && events.length > SHOWN_AT_FIRST && (
+        <div className="pl-[4.5rem] pt-3 sm:pl-[5.5rem]">
+          <Button size="sm" variant="outline" className="h-8" onClick={() => setShowAll(true)}>
+            {t('showOlder', { count: events.length - SHOWN_AT_FIRST })}
+          </Button>
+        </div>
+      )}
+    </div>
+  )
+}
+
+/** A year (or "Now") over its part of the feed, with a hairline to the edge. */
+function GroupHeading({ label, meta }: { label: string; meta?: string }) {
+  return (
+    <div className="flex items-baseline gap-2.5 bg-background pb-2 pt-5 first:pt-1 xl:sticky xl:top-0 xl:z-20">
+      <span className="text-[13px] font-semibold">{label}</span>
+      {meta && <span className="text-xs text-muted-foreground">{meta}</span>}
+      <span aria-hidden className="h-px flex-1 -translate-y-1 bg-border" />
+    </div>
+  )
+}
+
+/** Four short segments: how far a job has come. The current one is lit in its status colour. */
+function StageTrack({ done, tone }: { done: number; tone: LampTone }) {
+  const lit: Record<LampTone, string> = {
+    slate: 'bg-slate-500',
+    teal: 'bg-teal-600',
+    blue: 'bg-blue-600',
+    violet: 'bg-violet-600',
+    green: 'bg-emerald-600',
+    amber: 'bg-amber-500',
+    red: 'bg-red-600',
+  }
+  return (
+    <span aria-hidden className="inline-flex gap-[3px]">
+      {[1, 2, 3, 4].map((n) => (
+        <span
+          key={n}
+          className={cn(
+            'h-1 w-[18px] rounded-full',
+            n < done ? 'bg-muted-foreground/60' : n === done ? lit[tone] : 'bg-border'
+          )}
+        />
+      ))}
+    </span>
+  )
+}
+
+/**
+ * One line of the history: the day and reading on the left, a small marker on
+ * the line, the title with one line of detail, the amount on the right. The
+ * whole row opens the thing it describes.
+ */
+function Row({
+  date,
+  sub,
+  icon,
+  iconClass,
+  href,
+  onClick,
+  title,
+  number,
+  amount,
+  children,
+}: {
+  date: string
+  sub?: string | null
+  icon: React.ReactNode
+  iconClass: string
+  href?: string
+  onClick?: () => void
+  title: string
+  number?: string | null
+  amount?: string
+  children?: React.ReactNode
+}) {
+  const stretched =
+    'outline-none after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-ring'
+  return (
+    <li className="relative grid grid-cols-[3.5rem_1.75rem_minmax(0,1fr)_auto] items-start gap-x-2.5 rounded-lg py-2.5 pr-1 transition-colors hover:bg-muted/40 sm:grid-cols-[4.75rem_2.25rem_minmax(0,1fr)_auto] sm:gap-x-3 sm:pr-2">
+      <div className="pt-0.5 text-right">
+        <div className="text-[12.5px] font-semibold leading-tight">{date}</div>
+        {sub && (
+          <div className="mt-0.5 hidden whitespace-nowrap font-mono text-[10.5px] leading-tight text-muted-foreground sm:block">
+            {sub}
+          </div>
+        )}
+      </div>
+      <span
+        className={cn(
+          'relative z-10 mx-auto flex size-6 items-center justify-center rounded-full border sm:size-7',
+          iconClass
+        )}
+      >
+        {icon}
+      </span>
+      <div className="min-w-0">
+        <div className="flex min-w-0 items-baseline gap-2">
+          {href ? (
+            <Link href={href} className={cn('truncate text-sm font-medium', stretched)}>
+              {title}
+            </Link>
+          ) : (
+            <button
+              type="button"
+              onClick={onClick}
+              className={cn('truncate text-left text-sm font-medium', stretched)}
+            >
+              {title}
+            </button>
+          )}
+          {number && (
+            <CopyText
+              value={number}
+              label={number}
+              className="hidden shrink-0 font-mono text-[11.5px] text-muted-foreground sm:inline-flex"
+            />
+          )}
+        </div>
+        {children && (
+          <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1 text-[12.5px] text-muted-foreground sm:[&>*+*]:before:mr-2.5 sm:[&>*+*]:before:inline-block sm:[&>*+*]:before:size-[3px] sm:[&>*+*]:before:-translate-y-0.5 sm:[&>*+*]:before:rounded-full sm:[&>*+*]:before:bg-border sm:[&>*+*]:before:content-['']">
+            {children}
+          </div>
+        )}
+      </div>
+      <div className="pt-0.5 text-right text-[13.5px] font-semibold tabular-nums">{amount}</div>
+    </li>
+  )
+}

Fișier diff suprimat deoarece este prea mare
+ 1432 - 1137
src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx


+ 37 - 0
src/app/(public)/p/[token]/page.tsx

@@ -6,12 +6,15 @@ import {
   isInspectionHandoffToken,
   isInspectionHandoffToken,
   verifyInspectionHandoffToken,
   verifyInspectionHandoffToken,
   verifyPhotoHandoffToken,
   verifyPhotoHandoffToken,
+  isVehicleHandoffToken,
+  verifyVehicleHandoffToken,
 } from '@/lib/photo-handoff'
 } from '@/lib/photo-handoff'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { isDefect } from '@/features/inspections/Lib/conditions'
 import { isDefect } from '@/features/inspections/Lib/conditions'
 import { PhotoHandoffClient } from './photo-handoff-client'
 import { PhotoHandoffClient } from './photo-handoff-client'
 import { InspectionHandoffClient } from './inspection-handoff-client'
 import { InspectionHandoffClient } from './inspection-handoff-client'
 import { FixHandoffClient } from './fix-handoff-client'
 import { FixHandoffClient } from './fix-handoff-client'
+import { VehicleHandoffClient } from './vehicle-handoff-client'
 
 
 export const metadata: Metadata = { robots: { index: false, follow: false } }
 export const metadata: Metadata = { robots: { index: false, follow: false } }
 export const dynamic = 'force-dynamic'
 export const dynamic = 'force-dynamic'
@@ -32,6 +35,7 @@ export default async function PhotoHandoffPage({
 }) {
 }) {
   const { token } = await params
   const { token } = await params
   if (isFixHandoffToken(token)) return <FixHandoffPage token={token} />
   if (isFixHandoffToken(token)) return <FixHandoffPage token={token} />
+  if (isVehicleHandoffToken(token)) return <VehicleHandoffPage token={token} />
   if (isInspectionHandoffToken(token)) {
   if (isInspectionHandoffToken(token)) {
     const { item } = await searchParams
     const { item } = await searchParams
     return (
     return (
@@ -204,3 +208,36 @@ async function FixHandoffPage({ token }: { token: string }) {
     />
     />
   )
   )
 }
 }
+
+/**
+ * The same page for a vehicle's photo: the phone takes or chooses one photo
+ * and it becomes the picture on the vehicle's page. It shows the workshop, the
+ * plate and the make and model, nothing else.
+ */
+async function VehicleHandoffPage({ token }: { token: string }) {
+  const check = verifyVehicleHandoffToken(token)
+  if (!check.ok) {
+    return <VehicleHandoffClient token={token} brand={null} vehicle={null} problem={check.reason} />
+  }
+  const { organizationId, vehicleId } = check.handoff
+  const [brand, vehicle] = await Promise.all([
+    workshopBrand(organizationId),
+    db.vehicle.findFirst({
+      where: { id: vehicleId, organizationId },
+      select: { licensePlate: true, make: true, model: true, year: true },
+    }),
+  ])
+  if (!vehicle) {
+    return <VehicleHandoffClient token={token} brand={brand} vehicle={null} problem="invalid" />
+  }
+  return (
+    <VehicleHandoffClient
+      token={token}
+      brand={brand}
+      vehicle={{
+        plate: vehicle.licensePlate,
+        name: `${vehicle.year} ${vehicle.make} ${vehicle.model}`,
+      }}
+    />
+  )
+}

+ 202 - 0
src/app/(public)/p/[token]/vehicle-handoff-client.tsx

@@ -0,0 +1,202 @@
+'use client'
+
+import { useRef, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Camera, Check, ImagePlus, Loader2, TriangleAlert } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { PlateChip } from '@/components/plate-chip'
+
+function initials(name: string): string {
+  return (
+    name
+      .split(/\s+/)
+      .filter(Boolean)
+      .slice(0, 2)
+      .map((w) => w[0]?.toUpperCase() ?? '')
+      .join('') || '?'
+  )
+}
+
+type State = { preview: string; status: 'uploading' | 'saved' | 'failed' } | null
+
+/**
+ * The phone side of a vehicle's "Add from phone" code: take or choose one
+ * photo of the car, and it becomes the photo on the vehicle's page. A second
+ * photo replaces the first. Shows the workshop and the car, nothing else.
+ */
+export function VehicleHandoffClient({
+  token,
+  brand,
+  vehicle,
+  problem,
+}: {
+  token: string
+  brand: { name: string; logoUrl: string | null } | null
+  vehicle: { plate: string | null; name: string } | null
+  problem?: 'invalid' | 'expired'
+}) {
+  const t = useTranslations('service.photoHandoff')
+  const [logoFailed, setLogoFailed] = useState(false)
+  const [state, setState] = useState<State>(null)
+  const [error, setError] = useState<string | null>(null)
+  const [expired, setExpired] = useState(problem === 'expired')
+  const camera = useRef<HTMLInputElement>(null)
+  const library = useRef<HTMLInputElement>(null)
+  const url = `/api/public/vehicle-handoff/${encodeURIComponent(token)}`
+
+  const send = async (files: FileList | null) => {
+    const file = files?.[0]
+    if (!file) return
+    setError(null)
+    setState({ preview: URL.createObjectURL(file), status: 'uploading' })
+    const body = new FormData()
+    body.append('file', file)
+    const res = await fetch(url, { method: 'POST', body }).catch(() => null)
+    const reply = (await res?.json().catch(() => null)) as { code?: string } | null
+    if (res?.status === 410) setExpired(true)
+    if (res?.ok) {
+      setState((s) => (s ? { ...s, status: 'saved' } : s))
+    } else {
+      setState((s) => (s ? { ...s, status: 'failed' } : s))
+      setError(
+        reply?.code === 'type'
+          ? t('vehicle.notAPhoto')
+          : reply?.code === 'tooLarge'
+            ? t('errors.tooLarge')
+            : t('errors.failed')
+      )
+    }
+    if (camera.current) camera.current.value = ''
+    if (library.current) library.current.value = ''
+  }
+
+  const busy = state?.status === 'uploading'
+  const stop = problem === 'invalid' ? 'invalid' : expired ? 'expired' : null
+
+  return (
+    <div className="min-h-dvh bg-gradient-to-b from-muted/70 to-background">
+      <main className="mx-auto flex min-h-dvh w-full max-w-md flex-col gap-6 px-4 pb-8 pt-8">
+        {brand && (
+          <header className="flex flex-col items-center gap-3 text-center">
+            {brand.logoUrl && !logoFailed ? (
+              <img
+                src={brand.logoUrl}
+                alt={brand.name}
+                onError={() => setLogoFailed(true)}
+                className="max-h-16 max-w-[220px] object-contain"
+              />
+            ) : (
+              <span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-sm">
+                {initials(brand.name)}
+              </span>
+            )}
+            <p className="text-sm font-medium text-muted-foreground">{brand.name}</p>
+          </header>
+        )}
+
+        <section className="rounded-2xl border border-border/70 bg-card p-5 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_16px_40px_-20px_rgb(0_0_0/0.25)]">
+          {stop ? (
+            <div role="alert" className="flex gap-3">
+              <TriangleAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" aria-hidden />
+              <div className="space-y-1">
+                <h1 className="text-base font-semibold">
+                  {stop === 'expired' ? t('expiredTitle') : t('invalidTitle')}
+                </h1>
+                <p className="text-sm text-muted-foreground">
+                  {stop === 'expired' ? t('errors.expired') : t('vehicle.invalid')}
+                </p>
+              </div>
+            </div>
+          ) : (
+            <>
+              <h1 className="text-xl font-semibold tracking-tight">{t('vehicle.pageTitle')}</h1>
+              {vehicle && (
+                <p className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
+                  {vehicle.plate && <PlateChip plate={vehicle.plate} />}
+                  {vehicle.name}
+                </p>
+              )}
+              <p className="mt-3 text-sm text-muted-foreground">{t('vehicle.pageBody')}</p>
+
+              {state && (
+                <div className="relative mt-5 aspect-[4/3] overflow-hidden rounded-xl border bg-muted">
+                  <img src={state.preview} alt="" className="size-full object-cover" />
+                  <span
+                    className={cn(
+                      'absolute bottom-2 right-2 flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-sm',
+                      state.status === 'saved' && 'bg-emerald-600 text-white',
+                      state.status === 'uploading' && 'bg-background/90 text-foreground',
+                      state.status === 'failed' && 'bg-destructive text-white'
+                    )}
+                    aria-live="polite"
+                  >
+                    {state.status === 'saved' ? (
+                      <Check className="size-3.5" aria-hidden />
+                    ) : state.status === 'uploading' ? (
+                      <Loader2 className="size-3.5 animate-spin" aria-hidden />
+                    ) : (
+                      <TriangleAlert className="size-3.5" aria-hidden />
+                    )}
+                    {state.status === 'saved'
+                      ? t('added')
+                      : state.status === 'uploading'
+                        ? t('uploading')
+                        : t('notAdded')}
+                  </span>
+                </div>
+              )}
+
+              <div className="mt-5 grid gap-2">
+                <button
+                  type="button"
+                  disabled={busy}
+                  onClick={() => camera.current?.click()}
+                  className="flex h-14 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-primary text-base font-medium text-primary-foreground shadow-sm transition-opacity active:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
+                >
+                  <Camera className="h-5 w-5" aria-hidden />
+                  {t('takePhoto')}
+                </button>
+                <button
+                  type="button"
+                  disabled={busy}
+                  onClick={() => library.current?.click()}
+                  className="flex h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-xl border bg-background text-base font-medium transition-colors active:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
+                >
+                  <ImagePlus className="h-5 w-5" aria-hidden />
+                  {t('vehicle.choosePhoto')}
+                </button>
+                <input
+                  ref={camera}
+                  type="file"
+                  accept="image/*"
+                  capture="environment"
+                  hidden
+                  onChange={(e) => void send(e.target.files)}
+                />
+                <input
+                  ref={library}
+                  type="file"
+                  accept="image/*"
+                  hidden
+                  onChange={(e) => void send(e.target.files)}
+                />
+              </div>
+
+              {error && (
+                <p role="alert" className="mt-3 text-center text-sm text-destructive">
+                  {error}
+                </p>
+              )}
+              {state?.status === 'saved' && (
+                <p className="mt-4 text-center text-sm text-muted-foreground">
+                  {t('vehicle.saved')}
+                </p>
+              )}
+              <p className="mt-5 text-xs text-muted-foreground">{t('privacy')}</p>
+            </>
+          )}
+        </section>
+      </main>
+    </div>
+  )
+}

+ 88 - 0
src/app/api/public/vehicle-handoff/[token]/route.ts

@@ -0,0 +1,88 @@
+import { NextResponse } from 'next/server'
+import { mkdir, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import crypto from 'node:crypto'
+import { db } from '@/lib/db'
+import { isRestoring, RESTORE_IN_PROGRESS_MESSAGE } from '@/lib/backup/freeze'
+import { compressPhoto } from '@/lib/image-upload.server'
+import { verifyVehicleHandoffToken } from '@/lib/photo-handoff'
+import { rateLimit } from '@/lib/rate-limit'
+import { uploadsRoot } from '@/lib/upload-root'
+import { discardUnsavedUpload, releaseFiles } from '@/lib/files/manager'
+
+const PHOTO_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'])
+/** What a phone camera sends before compression. */
+const MAX_BYTES = 15 * 1024 * 1024
+/** The photo heads the vehicle's page, so it keeps more detail than a job photo. */
+const MAX_SIDE = 1600
+const RATE_PER_MINUTE = 20
+
+function refuse(status: number, code: string) {
+  return NextResponse.json({ code }, { status })
+}
+
+/**
+ * The photo from the phone page a vehicle's "Add from phone" code opens.
+ * Nobody is signed in: the signed code is the permission, and all it can do is
+ * set that one vehicle's photo. The photo is compressed, stored where the
+ * vehicle form puts its photos, and the one it replaces goes to the file
+ * manager, which keeps it if anything else still shows it.
+ */
+export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) {
+  const limited = rateLimit(request, { limit: RATE_PER_MINUTE, windowMs: 60_000, anonymous: true })
+  if (limited) return limited
+
+  const { token } = await params
+  const check = verifyVehicleHandoffToken(token)
+  if (!check.ok) return refuse(check.reason === 'expired' ? 410 : 404, check.reason)
+  const { organizationId, vehicleId } = check.handoff
+  // No session here, so the restore freeze is checked on the route itself.
+  if (isRestoring(organizationId)) {
+    return NextResponse.json(
+      { code: 'restoring', error: RESTORE_IN_PROGRESS_MESSAGE },
+      { status: 409 }
+    )
+  }
+
+  const vehicle = await db.vehicle.findFirst({
+    where: { id: vehicleId, organizationId },
+    select: { id: true, imageUrl: true },
+  })
+  if (!vehicle) return refuse(404, 'invalid')
+
+  const form = await request.formData().catch(() => null)
+  const file = form?.get('file')
+  if (!(file instanceof File)) return refuse(400, 'noFile')
+  if (!PHOTO_TYPES.has(file.type)) return refuse(400, 'type')
+  if (file.size === 0) return refuse(400, 'empty')
+  if (file.size > MAX_BYTES) return refuse(400, 'tooLarge')
+
+  let jpeg: Buffer
+  try {
+    const photo = await compressPhoto(Buffer.from(await file.arrayBuffer()), MAX_SIDE)
+    jpeg = photo.data
+  } catch {
+    return refuse(400, 'type')
+  }
+
+  const filename = `${crypto.randomUUID()}.jpg`
+  const dir = path.join(uploadsRoot(), organizationId, 'vehicles')
+  const stored = path.join(dir, filename)
+  await mkdir(dir, { recursive: true })
+  await writeFile(stored, jpeg)
+  const imageUrl = `/api/protected/files/${organizationId}/vehicles/${filename}`
+
+  const updated = await db.vehicle.updateMany({
+    where: { id: vehicle.id, organizationId },
+    data: { imageUrl },
+  })
+  if (updated.count === 0) {
+    // Deleted while the photo was on its way: nothing shows the new file.
+    await discardUnsavedUpload(organizationId, 'vehicles', filename)
+    return refuse(404, 'invalid')
+  }
+  if (vehicle.imageUrl && vehicle.imageUrl !== imageUrl) {
+    await releaseFiles([vehicle.imageUrl], { organizationId, reason: 'vehicle image replaced' })
+  }
+  return NextResponse.json({ ok: true })
+}

+ 42 - 0
src/app/globals.css

@@ -859,3 +859,45 @@ html body[data-scroll-locked] {
   background: linear-gradient(90deg, transparent, var(--primary), transparent);
   background: linear-gradient(90deg, transparent, var(--primary), transparent);
   animation: search-progress 1.1s ease-in-out infinite;
   animation: search-progress 1.1s ease-in-out infinite;
 }
 }
+
+/* Loading skeletons: a soft wave across the placeholder instead of a blink.
+   Built from currentColor, so it follows the text colour in light, dark and
+   every colour theme without a token of its own. In the components layer, so
+   a page can still restyle one skeleton with utilities. With reduced motion
+   the wave stands still. */
+:root {
+  --skeleton-base: color-mix(in srgb, currentColor 9%, transparent);
+  --skeleton-highlight: color-mix(in srgb, currentColor 20%, transparent);
+  --skeleton-bg: linear-gradient(
+    90deg,
+    var(--skeleton-base) 0%,
+    var(--skeleton-highlight) 50%,
+    var(--skeleton-base) 100%
+  );
+}
+
+@keyframes skeleton-wave {
+  0% {
+    background-position: 200% 0;
+  }
+  100% {
+    background-position: -200% 0;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  @keyframes skeleton-wave {
+    0%,
+    100% {
+      background-position: 50% 0;
+    }
+  }
+}
+
+@layer components {
+  .skeleton {
+    background: var(--skeleton-bg);
+    background-size: 200% 100%;
+    animation: skeleton-wave 1.6s linear infinite;
+  }
+}

+ 128 - 0
src/components/copy-value.tsx

@@ -0,0 +1,128 @@
+'use client'
+
+import { type ReactNode, useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Check, Copy } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+/**
+ * Copies one value, and says so for a moment. Plates, VINs, engine codes and
+ * order numbers get typed into parts catalogues, registries and accounting
+ * systems; one click instead of selecting text saves the trip.
+ */
+export function useCopy(value: string) {
+  const [copied, setCopied] = useState(false)
+  useEffect(() => {
+    if (!copied) return
+    const timer = setTimeout(() => setCopied(false), 1500)
+    return () => clearTimeout(timer)
+  }, [copied])
+  const copy = () => {
+    const done = () => setCopied(true)
+    // The clipboard API exists only on https and localhost. A self-hosted
+    // install reached by its LAN address over http has no such thing, so it
+    // falls back to the older way rather than doing nothing.
+    if (navigator.clipboard && window.isSecureContext) {
+      navigator.clipboard
+        .writeText(value)
+        .then(done)
+        .catch(() => {
+          if (legacyCopy(value)) done()
+        })
+    } else if (legacyCopy(value)) {
+      done()
+    }
+  }
+  return { copied, copy }
+}
+
+/** Copies through a hidden text field, for pages without the clipboard API. */
+function legacyCopy(text: string) {
+  const field = document.createElement('textarea')
+  field.value = text
+  field.setAttribute('readonly', '')
+  field.style.position = 'fixed'
+  field.style.opacity = '0'
+  document.body.appendChild(field)
+  field.select()
+  let ok = false
+  try {
+    ok = document.execCommand('copy')
+  } catch {
+    ok = false
+  }
+  field.remove()
+  return ok
+}
+
+/** A small copy button to sit beside a value. `label` names the value for screen readers. */
+export function CopyValue({
+  value,
+  label,
+  className,
+}: {
+  value: string
+  label: string
+  className?: string
+}) {
+  const t = useTranslations('service.modern.vehicleInfo')
+  const { copied, copy } = useCopy(value)
+  const name = copied ? t('copied') : t('copy', { label })
+  return (
+    <button
+      type="button"
+      onClick={copy}
+      aria-label={name}
+      title={name}
+      className={cn(
+        'inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+        className
+      )}
+    >
+      {copied ? (
+        <Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden="true" />
+      ) : (
+        <Copy className="h-3.5 w-3.5" aria-hidden="true" />
+      )}
+    </button>
+  )
+}
+
+/**
+ * Text that copies itself when clicked, such as an order number in a list
+ * row. It sits above a row's own link, so the click copies rather than opens.
+ */
+export function CopyText({
+  value,
+  label,
+  className,
+  children,
+}: {
+  value: string
+  label: string
+  className?: string
+  children?: ReactNode
+}) {
+  const t = useTranslations('service.modern.vehicleInfo')
+  const { copied, copy } = useCopy(value)
+  const name = copied ? t('copied') : t('copy', { label })
+  return (
+    <button
+      type="button"
+      onClick={(e) => {
+        e.preventDefault()
+        e.stopPropagation()
+        copy()
+      }}
+      aria-label={name}
+      title={name}
+      className={cn(
+        'relative z-10 inline-flex cursor-copy items-center gap-1 rounded px-0.5 -mx-0.5 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+        className
+      )}
+    >
+      {children ?? value}
+      {copied && <Check className="h-3 w-3 text-emerald-600" aria-hidden="true" />}
+    </button>
+  )
+}

+ 14 - 1
src/components/feature-hint/feature-hint.tsx

@@ -38,6 +38,8 @@ export function FeatureHint({
   href,
   href,
   cta,
   cta,
   variant = 'hint',
   variant = 'hint',
+  open: openProp,
+  onDismiss,
   children,
   children,
 }: {
 }: {
   /**
   /**
@@ -77,6 +79,14 @@ export function FeatureHint({
    * being a deliberate keypress rather than a mis-aimed click.
    * being a deliberate keypress rather than a mis-aimed click.
    */
    */
   variant?: 'hint' | 'announcement'
   variant?: 'hint' | 'announcement'
+  /**
+   * For a hint that follows one person's own action rather than something the
+   * workshop switched on: the caller decides when it shows and remembers that
+   * it was seen, and the workshop's list of seen hints is left alone. Give
+   * both, or neither.
+   */
+  open?: boolean
+  onDismiss?: () => void
   /**
   /**
    * What the card points at. Stays fully interactive. Given the open state so
    * What the card points at. Stays fully interactive. Given the open state so
    * it can highlight itself while the card is up.
    * it can highlight itself while the card is up.
@@ -84,7 +94,10 @@ export function FeatureHint({
   children: React.ReactNode | ((open: boolean) => React.ReactNode)
   children: React.ReactNode | ((open: boolean) => React.ReactNode)
 }) {
 }) {
   const t = useTranslations('common')
   const t = useTranslations('common')
-  const { open, dismiss } = useFeatureHint(id, eligible)
+  const controlled = openProp !== undefined && onDismiss !== undefined
+  const shared = useFeatureHint(id, controlled ? false : eligible)
+  const open = controlled ? openProp && eligible : shared.open
+  const dismiss = controlled ? onDismiss : shared.dismiss
   const loud = variant === 'announcement'
   const loud = variant === 'announcement'
 
 
   // Escape closes it wherever focus happens to be. Focus is never moved into
   // Escape closes it wherever focus happens to be. Focus is never moved into

+ 107 - 0
src/components/plate-chip.tsx

@@ -0,0 +1,107 @@
+'use client'
+
+import type { CSSProperties } from 'react'
+import { Check } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { useCopy } from '@/components/copy-value'
+import { cn } from '@/lib/utils'
+
+const SIZE = {
+  sm: { plate: 'h-[22px] rounded-[4px] p-[1.5px]', band: 'w-[6px]', text: 'px-1.5 text-[14px]' },
+  md: { plate: 'h-[34px] rounded-[6px] p-[2px]', band: 'w-[10px]', text: 'px-2.5 text-[21px]' },
+  lg: { plate: 'h-[44px] rounded-[7px] p-[2.5px]', band: 'w-[13px]', text: 'px-3 text-[28px]' },
+} as const
+
+/**
+ * Plate typefaces are condensed and drawn from DIN; Barlow Condensed is
+ * loaded app-wide and comes from the same family of road-sign lettering.
+ */
+const PLATE_FONT: CSSProperties = {
+  fontFamily: 'var(--font-barlow-condensed), var(--font-geist-mono), ui-monospace, monospace',
+}
+
+/**
+ * A registration plate, drawn as one: a white face in a dark rim with a fine
+ * inner line, a band at the left edge and condensed lettering. It stays white
+ * in dark mode and every colour theme on purpose: it is the picture of a
+ * physical plate, not a piece of the interface, which is why its colours are
+ * fixed rather than theme tokens.
+ *
+ * The band carries no flag or country code. Plates differ by country and the
+ * app has no business guessing which one a workshop is in.
+ *
+ * With `copyLabel` a click copies the plate, which is what gets typed into
+ * parts catalogues and registries.
+ */
+export function PlateChip({
+  plate,
+  size = 'sm',
+  copyLabel,
+  className,
+}: {
+  plate: string
+  size?: keyof typeof SIZE
+  /** Names the plate for the copy button; leave out for a plate that only shows. */
+  copyLabel?: string
+  className?: string
+}) {
+  const s = SIZE[size]
+  const frame = cn(
+    'relative inline-flex shrink-0 items-stretch bg-neutral-900 align-middle shadow-[0_1px_2px_rgb(0_0_0/0.18)]',
+    s.plate,
+    className
+  )
+  const face = (copied?: boolean, copiedLabel?: string) => (
+    <span className="flex items-stretch overflow-hidden rounded-[inherit] bg-white ring-1 ring-inset ring-black/15">
+      <span aria-hidden className={cn('shrink-0 bg-[#1f4fae]', s.band)} />
+      <span
+        style={PLATE_FONT}
+        className={cn(
+          'relative flex items-center whitespace-nowrap font-semibold uppercase leading-none tracking-[0.05em] text-neutral-900',
+          s.text
+        )}
+      >
+        <span className={cn(copied && 'invisible')}>{plate}</span>
+        {copied && (
+          <span className="absolute inset-0 flex items-center justify-center gap-1 font-sans text-xs font-medium normal-case tracking-normal text-emerald-700">
+            <Check className="h-3.5 w-3.5" aria-hidden="true" />
+            {copiedLabel}
+          </span>
+        )}
+      </span>
+    </span>
+  )
+
+  if (!copyLabel) return <span className={frame}>{face()}</span>
+  return <CopyablePlate plate={plate} label={copyLabel} frame={frame} face={face} />
+}
+
+function CopyablePlate({
+  plate,
+  label,
+  frame,
+  face,
+}: {
+  plate: string
+  label: string
+  frame: string
+  face: (copied?: boolean, copiedLabel?: string) => React.ReactNode
+}) {
+  const t = useTranslations('service.modern.vehicleInfo')
+  const { copied, copy } = useCopy(plate)
+  const name = copied ? t('copied') : t('copy', { label })
+  return (
+    <button
+      type="button"
+      onClick={copy}
+      aria-label={name}
+      title={name}
+      className={cn(
+        frame,
+        'cursor-copy transition-shadow hover:ring-2 hover:ring-primary hover:ring-offset-2 hover:ring-offset-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
+      )}
+    >
+      {face(copied, t('copied'))}
+    </button>
+  )
+}

+ 66 - 0
src/components/status-lamp.tsx

@@ -0,0 +1,66 @@
+import type { ReactNode } from 'react'
+import { cn } from '@/lib/utils'
+
+export type LampTone = 'slate' | 'teal' | 'blue' | 'violet' | 'green' | 'amber' | 'red'
+
+/** The dot and its halo for each tone. The halo is the same colour, faint. */
+const DOT: Record<LampTone, string> = {
+  slate: 'bg-slate-500 ring-slate-500/20',
+  teal: 'bg-teal-600 ring-teal-600/20',
+  blue: 'bg-blue-600 ring-blue-600/20',
+  violet: 'bg-violet-600 ring-violet-600/20',
+  green: 'bg-emerald-600 ring-emerald-600/20',
+  amber: 'bg-amber-500 ring-amber-500/25',
+  red: 'bg-red-600 ring-red-600/20',
+}
+
+/**
+ * A status as a small lit dot and a word, instead of a tinted pill. Quieter
+ * in a list than a row of coloured badges, and the colour still reads at a
+ * glance. Without children it is the dot alone, for places where the word is
+ * already said next to it.
+ */
+export function StatusLamp({
+  tone,
+  children,
+  className,
+}: {
+  tone: LampTone
+  children?: ReactNode
+  className?: string
+}) {
+  return (
+    <span
+      className={cn(
+        'inline-flex items-center gap-2 whitespace-nowrap text-[13px] font-medium text-foreground/80',
+        className
+      )}
+    >
+      <span aria-hidden className={cn('size-2 shrink-0 rounded-full ring-[3px]', DOT[tone])} />
+      {children}
+    </span>
+  )
+}
+
+/**
+ * The tone for a work order's stored status. Waiting statuses are grey,
+ * booked is teal, work under way is blue, parts on order violet, done green.
+ * A status the workshop added itself, or one this list does not know yet,
+ * is grey rather than a guess.
+ */
+export function serviceStatusTone(status: string): LampTone {
+  switch (status) {
+    case 'scheduled':
+      return 'teal'
+    case 'in-progress':
+    case 'in_progress':
+      return 'blue'
+    case 'waiting-parts':
+    case 'waiting_parts':
+      return 'violet'
+    case 'completed':
+      return 'green'
+    default:
+      return 'slate'
+  }
+}

+ 2 - 7
src/components/ui/skeleton.tsx

@@ -1,13 +1,8 @@
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 
 
+/** A placeholder while content loads; the wave itself is `.skeleton` in globals.css. */
 function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
 function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
-  return (
-    <div
-      data-slot="skeleton"
-      className={cn('bg-accent animate-pulse rounded-md', className)}
-      {...props}
-    />
-  )
+  return <div data-slot="skeleton" className={cn('skeleton rounded-md', className)} {...props} />
 }
 }
 
 
 export { Skeleton }
 export { Skeleton }

+ 1 - 3
src/features/fault-codes/Components/FaultCodeChip.tsx

@@ -93,9 +93,7 @@ export function MeaningText({
 }) {
 }) {
   const t = useTranslations('faultCodes')
   const t = useTranslations('faultCodes')
   if (loading && !view) {
   if (loading && !view) {
-    return (
-      <span className={cn('inline-block h-3 w-40 animate-pulse rounded bg-muted', className)} />
-    )
+    return <span className={cn('skeleton inline-block h-3 w-40 rounded', className)} />
   }
   }
   if (!view || view.state === 'unavailable') {
   if (!view || view.state === 'unavailable') {
     return (
     return (

+ 27 - 4
src/features/quotes/Components/CustomerCombobox.tsx

@@ -37,6 +37,8 @@ interface CustomerComboboxProps {
   footer?: (close: () => void) => ReactNode
   footer?: (close: () => void) => ReactNode
   /** Shown but not changeable here */
   /** Shown but not changeable here */
   disabled?: boolean
   disabled?: boolean
+  /** The chosen customer's initials in a circle before the name, as an owner is shown */
+  showAvatar?: boolean
 }
 }
 
 
 export function CustomerCombobox({
 export function CustomerCombobox({
@@ -49,6 +51,7 @@ export function CustomerCombobox({
   emptyLabel = 'No customers found.',
   emptyLabel = 'No customers found.',
   footer,
   footer,
   disabled = false,
   disabled = false,
+  showAvatar = false,
 }: CustomerComboboxProps) {
 }: CustomerComboboxProps) {
   const [open, setOpen] = useState(false)
   const [open, setOpen] = useState(false)
   const [search, setSearch] = useState('')
   const [search, setSearch] = useState('')
@@ -138,10 +141,20 @@ export function CustomerCombobox({
           disabled={disabled}
           disabled={disabled}
           className="w-full justify-between font-normal"
           className="w-full justify-between font-normal"
         >
         >
-          <span className="truncate">
-            {selected
-              ? `${selected.name}${selected.company ? ` (${selected.company})` : ''}`
-              : placeholder}
+          <span className="flex min-w-0 items-center gap-2">
+            {showAvatar && selected && (
+              <span
+                aria-hidden
+                className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[9.5px] font-semibold text-muted-foreground"
+              >
+                {initialsOf(selected.name)}
+              </span>
+            )}
+            <span className="truncate">
+              {selected
+                ? `${selected.name}${selected.company ? ` (${selected.company})` : ''}`
+                : placeholder}
+            </span>
           </span>
           </span>
           <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
           <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
         </Button>
         </Button>
@@ -200,3 +213,13 @@ export function CustomerCombobox({
     </Popover>
     </Popover>
   )
   )
 }
 }
+
+/** The first letters of the first two names, as the vehicle page shows an owner. */
+function initialsOf(name: string): string {
+  return name
+    .split(/\s+/)
+    .filter(Boolean)
+    .slice(0, 2)
+    .map((part) => part[0]?.toUpperCase())
+    .join('')
+}

+ 115 - 0
src/features/vehicles/Actions/getVehicleJobWarnings.ts

@@ -0,0 +1,115 @@
+'use server'
+
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { loadBookingContext, MAX_WINDOW_DAYS } from '@/features/workboard/Lib/bookings'
+import { ACTIVE_HELPERS } from '@/features/workboard/Lib/jobTeam'
+import {
+  buildVehicleJobWarnings,
+  planWindows,
+  type Span,
+  splitSpan,
+} from '../Lib/vehicleJobWarnings'
+
+/** The most jobs one vehicle is checked for: more than any car is booked in for at once. */
+const MAX_JOBS = 25
+const DAY_MS = 86_400_000
+/**
+ * How many days one read of the bookings covers here: under the loader's
+ * limit, which also reads a day either side.
+ */
+const WINDOW_DAYS = MAX_WINDOW_DAYS - 2
+
+/**
+ * What is wrong with the bookings of a vehicle's open jobs: the technician or
+ * a helper away, the workshop closed, the person or bay already taken. The
+ * same check the work order runs when it opens (workboard/Lib/slotCheck), so
+ * the vehicle page warns about exactly what the job will show once opened.
+ *
+ * Only jobs still to be done and not yet over: a clash in a slot that has
+ * passed is history, not something anybody can fix.
+ */
+export async function getVehicleJobWarnings(vehicleId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const id = z.string().min(1).parse(vehicleId)
+      const now = new Date()
+      const jobs = await db.serviceRecord.findMany({
+        where: {
+          organizationId,
+          vehicleId: id,
+          status: { notIn: ['completed', 'cancelled'] },
+          startDateTime: { not: null },
+          endDateTime: { gt: now },
+        },
+        orderBy: { startDateTime: 'asc' },
+        take: MAX_JOBS,
+        select: {
+          id: true,
+          title: true,
+          invoiceNumber: true,
+          startDateTime: true,
+          endDateTime: true,
+          technicianId: true,
+          workBayId: true,
+          helpers: { ...ACTIVE_HELPERS, select: { technicianId: true } },
+        },
+      })
+      const booked = jobs.flatMap((job) =>
+        job.startDateTime && job.endDateTime && job.startDateTime < job.endDateTime
+          ? [{ ...job, start: job.startDateTime, end: job.endDateTime }]
+          : []
+      )
+      if (booked.length === 0) return []
+
+      // The workshop's bookings, closures and leave are read once per window
+      // of days that covers the jobs, usually one: a read has a limit, and
+      // jobs further apart than that, or one longer, get windows of their own.
+      const pieces = booked.flatMap((job) => splitSpan(job, WINDOW_DAYS))
+      const windows = await Promise.all(
+        planWindows(pieces, WINDOW_DAYS).map(async (window) => ({
+          window,
+          context: await loadBookingContext(organizationId, window.start, {
+            days: Math.ceil((window.end.getTime() - window.start.getTime()) / DAY_MS) + 1,
+          }),
+        }))
+      )
+      const contextFor = (span: Span) => {
+        const found = windows.find(
+          ({ window }) => span.start >= window.start && span.end <= window.end
+        )
+        if (!found) throw new Error('No bookings read for a job on this vehicle')
+        return found.context
+      }
+
+      const technicians = await db.technician.findMany({
+        where: { organizationId },
+        select: { id: true, name: true },
+      })
+
+      return buildVehicleJobWarnings(
+        booked.map((job) => ({
+          id: job.id,
+          title: job.title,
+          invoiceNumber: job.invoiceNumber,
+          start: job.start,
+          end: job.end,
+          technicianId: job.technicianId,
+          helperIds: job.helpers.map((h) => h.technicianId),
+          workBayId: job.workBayId,
+        })),
+        contextFor,
+        new Map(technicians.map((t) => [t.id, t.name])),
+        WINDOW_DAYS
+      )
+    },
+    {
+      // What the work order asks to show the same warnings.
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.WORK_ORDERS },
+      ],
+    }
+  )
+}

+ 52 - 1
src/features/vehicles/Actions/photoHandoffActions.ts

@@ -3,7 +3,11 @@
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
-import { createPhotoHandoffToken, type PhotoHandoffPurpose } from '@/lib/photo-handoff'
+import {
+  createPhotoHandoffToken,
+  createVehicleHandoffToken,
+  type PhotoHandoffPurpose,
+} from '@/lib/photo-handoff'
 
 
 /**
 /**
  * A code for the work order's "Add photos from phone": a signed link that
  * A code for the work order's "Add photos from phone": a signed link that
@@ -79,3 +83,50 @@ export async function countPhotosSince(input: { serviceRecordId: string; since:
     }
     }
   )
   )
 }
 }
+
+/**
+ * A code for a vehicle's "Add from phone": a signed link that lets a phone
+ * that is not signed in set this vehicle's photo, and nothing else, for half
+ * an hour. Only somebody who could change the vehicle can hand it to a phone.
+ */
+export async function createVehiclePhotoHandoffLink(vehicleId: string) {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      const vehicle = await db.vehicle.findFirst({
+        where: { id: vehicleId, organizationId },
+        select: { id: true },
+      })
+      if (!vehicle) throw new Error('Vehicle not found')
+      const { token, expiresAt } = createVehicleHandoffToken({
+        organizationId,
+        vehicleId: vehicle.id,
+        userId,
+      })
+      return { token, expiresAt: expiresAt.toISOString() }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+      ],
+    }
+  )
+}
+
+/**
+ * The vehicle's photo as it is now. The desk's dialog asks every few seconds
+ * and refreshes the page when it changes, the way a job counts its photos.
+ */
+export async function getVehiclePhotoUrl(vehicleId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const vehicle = await db.vehicle.findFirst({
+        where: { id: vehicleId, organizationId },
+        select: { imageUrl: true },
+      })
+      return vehicle?.imageUrl ?? null
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}

+ 17 - 1
src/features/vehicles/Actions/vehicleActions.ts

@@ -97,7 +97,21 @@ export async function getVehicle(vehicleId: string) {
       // Missing or foreign-org vehicle yields null rather than an error: the
       // Missing or foreign-org vehicle yields null rather than an error: the
       // page renders its not-found state, and this also runs during the
       // page renders its not-found state, and this also runs during the
       // post-delete re-render of the vehicle route.
       // post-delete re-render of the vehicle route.
-      return vehicle
+      if (!vehicle) return vehicle
+
+      // What the vehicle has cost over all its work orders. `serviceRecords`
+      // above holds only the ten most recent, and the page used to add those
+      // up, so a car with a longer history showed less than it had spent.
+      const spend = await db.serviceRecord.findMany({
+        where: { vehicleId: vehicle.id, vehicle: { organizationId } },
+        select: { totalAmount: true, cost: true },
+      })
+      const lifetimeSpend = spend.reduce(
+        (sum, r) => sum + (r.totalAmount > 0 ? r.totalAmount : r.cost),
+        0
+      )
+
+      return { ...vehicle, lifetimeSpend }
     },
     },
     {
     {
       requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
       requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
@@ -357,6 +371,8 @@ export async function updateVehicle(input: unknown) {
             await workshopTimeZone(organizationId)
             await workshopTimeZone(organizationId)
           ),
           ),
           customerId: data.customerId !== undefined ? data.customerId || null : undefined,
           customerId: data.customerId !== undefined ? data.customerId || null : undefined,
+          // An emptied photo is no photo, not an empty address.
+          imageUrl: data.imageUrl !== undefined ? data.imageUrl || null : undefined,
         },
         },
       })
       })
       if (updateResult.count === 0) throw new Error('Vehicle not found')
       if (updateResult.count === 0) throw new Error('Vehicle not found')

+ 4 - 4
src/features/vehicles/Components/PlateLookupCommand.tsx

@@ -380,13 +380,13 @@ export function PlateLookupCommand() {
 
 
             {phase === 'loading' && (
             {phase === 'loading' && (
               <div className="space-y-4 px-5 py-6" aria-busy>
               <div className="space-y-4 px-5 py-6" aria-busy>
-                <div className="h-7 w-2/3 animate-pulse rounded bg-muted" />
-                <div className="h-4 w-1/3 animate-pulse rounded bg-muted" />
+                <div className="h-7 w-2/3 skeleton rounded" />
+                <div className="h-4 w-1/3 skeleton rounded" />
                 <div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
                 <div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
                   {Array.from({ length: 6 }, (_, i) => (
                   {Array.from({ length: 6 }, (_, i) => (
                     <div key={i} className="space-y-1.5">
                     <div key={i} className="space-y-1.5">
-                      <div className="h-3 w-16 animate-pulse rounded bg-muted" />
-                      <div className="h-4 w-24 animate-pulse rounded bg-muted" />
+                      <div className="h-3 w-16 skeleton rounded" />
+                      <div className="h-4 w-24 skeleton rounded" />
                     </div>
                     </div>
                   ))}
                   ))}
                 </div>
                 </div>

+ 17 - 4
src/features/vehicles/Components/ScanDocumentButton.tsx

@@ -12,12 +12,15 @@ import {
   type VehicleDocumentScan,
   type VehicleDocumentScan,
 } from '../Actions/aiAnalyzeVehicleDocument'
 } from '../Actions/aiAnalyzeVehicleDocument'
 import { documentToDataUri } from '../Lib/documentImage'
 import { documentToDataUri } from '../Lib/documentImage'
+import { cn } from '@/lib/utils'
 
 
 interface ScanDocumentButtonProps {
 interface ScanDocumentButtonProps {
   /** Called with whatever the papers yielded, for the form to apply. */
   /** Called with whatever the papers yielded, for the form to apply. */
   onScanned: (data: VehicleDocumentScan) => void
   onScanned: (data: VehicleDocumentScan) => void
   /** Hidden when the surrounding form has no room for a second line. */
   /** Hidden when the surrounding form has no room for a second line. */
   showHint?: boolean
   showHint?: boolean
+  /** The hint under the button rather than beside it, for a narrow column. */
+  stacked?: boolean
 }
 }
 
 
 /**
 /**
@@ -27,7 +30,11 @@ interface ScanDocumentButtonProps {
  * vehicle's details and its keeper, so both forms have something to fill from
  * vehicle's details and its keeper, so both forms have something to fill from
  * it and neither should own the scanning.
  * it and neither should own the scanning.
  */
  */
-export function ScanDocumentButton({ onScanned, showHint = true }: ScanDocumentButtonProps) {
+export function ScanDocumentButton({
+  onScanned,
+  showHint = true,
+  stacked = false,
+}: ScanDocumentButtonProps) {
   const t = useTranslations('vehicles.form')
   const t = useTranslations('vehicles.form')
   const [scanning, setScanning] = useState(false)
   const [scanning, setScanning] = useState(false)
   /** null while the availability check is still in flight. */
   /** null while the availability check is still in flight. */
@@ -73,7 +80,11 @@ export function ScanDocumentButton({ onScanned, showHint = true }: ScanDocumentB
   )
   )
 
 
   return (
   return (
-    <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
+    <div
+      className={
+        stacked ? 'flex flex-col gap-2' : 'flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3'
+      }
+    >
       <Tooltip>
       <Tooltip>
         {/* A disabled button swallows pointer events, so the trigger has to be
         {/* A disabled button swallows pointer events, so the trigger has to be
             the wrapper rather than the button itself. */}
             the wrapper rather than the button itself. */}
@@ -82,7 +93,7 @@ export function ScanDocumentButton({ onScanned, showHint = true }: ScanDocumentB
             <Button
             <Button
               type="button"
               type="button"
               variant="outline"
               variant="outline"
-              className="w-full sm:w-auto"
+              className={stacked ? 'w-full' : 'w-full sm:w-auto'}
               onClick={() => inputRef.current?.click()}
               onClick={() => inputRef.current?.click()}
               disabled={scanning || !available}
               disabled={scanning || !available}
             >
             >
@@ -98,7 +109,9 @@ export function ScanDocumentButton({ onScanned, showHint = true }: ScanDocumentB
         {available === false && <TooltipContent>{t('scanUnavailable')}</TooltipContent>}
         {available === false && <TooltipContent>{t('scanUnavailable')}</TooltipContent>}
       </Tooltip>
       </Tooltip>
       {showHint && (
       {showHint && (
-        <p className="text-xs text-muted-foreground sm:flex-1">{t('scanDocumentHint')}</p>
+        <p className={cn('text-xs text-muted-foreground', stacked ? 'leading-snug' : 'sm:flex-1')}>
+          {t('scanDocumentHint')}
+        </p>
       )}
       )}
       <input
       <input
         ref={inputRef}
         ref={inputRef}

Fișier diff suprimat deoarece este prea mare
+ 606 - 308
src/features/vehicles/Components/VehicleForm.tsx


+ 6 - 1
src/features/vehicles/Components/VehicleLookupButton.tsx

@@ -25,6 +25,8 @@ interface VehicleLookupButtonProps {
   onFound: (data: VehicleLookup, asked: string) => void
   onFound: (data: VehicleLookup, asked: string) => void
   /** Set when editing, so the answer is also recorded on the vehicle. */
   /** Set when editing, so the answer is also recorded on the vehicle. */
   vehicleId?: string
   vehicleId?: string
+  /** A short word beside the icon ("Look up", "Decode") where the row has room for it. */
+  text?: string
 }
 }
 
 
 /**
 /**
@@ -42,6 +44,7 @@ export function VehicleLookupButton({
   getValue,
   getValue,
   onFound,
   onFound,
   vehicleId,
   vehicleId,
+  text,
 }: VehicleLookupButtonProps) {
 }: VehicleLookupButtonProps) {
   const t = useTranslations('vehicles.form')
   const t = useTranslations('vehicles.form')
   const [busy, setBusy] = useState(false)
   const [busy, setBusy] = useState(false)
@@ -99,12 +102,14 @@ export function VehicleLookupButton({
           <Button
           <Button
             type="button"
             type="button"
             variant="outline"
             variant="outline"
-            size="icon"
+            size={text ? 'default' : 'icon'}
             aria-label={label}
             aria-label={label}
             onClick={handleClick}
             onClick={handleClick}
             disabled={busy || !available}
             disabled={busy || !available}
+            className={text ? 'gap-1.5 px-3' : undefined}
           >
           >
             {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
             {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
+            {text && <span aria-hidden>{text}</span>}
           </Button>
           </Button>
         </span>
         </span>
       </TooltipTrigger>
       </TooltipTrigger>

+ 122 - 0
src/features/vehicles/Components/VehiclePageLayoutSwitch.tsx

@@ -0,0 +1,122 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { Sparkles, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { track } from '@/lib/analytics'
+import { rememberVehiclePageLayout, type VehiclePageLayout } from '@/lib/vehicle-page-layout'
+
+const DISMISSED_KEY = 'torqvoice:vehiclePageInviteDismissed'
+/** Set when somebody moves to the new page, until they have been shown the way back. */
+const BACK_HINT_KEY = 'torqvoice:vehiclePageBackHint'
+
+/**
+ * Moves this browser to the other vehicle page: the choice goes in a cookie
+ * the server reads, and the page renders again in that layout.
+ */
+export function useSwitchVehiclePageLayout() {
+  const router = useRouter()
+  return (layout: VehiclePageLayout, source: 'invite' | 'menu') => {
+    rememberVehiclePageLayout(layout)
+    // Whoever tries the new page is shown where the way back is, once.
+    try {
+      if (layout === 'modern') localStorage.setItem(BACK_HINT_KEY, 'true')
+      else localStorage.removeItem(BACK_HINT_KEY)
+    } catch {
+      // Private mode: no pointer, and the menu item is still there.
+    }
+    track('vehicle_page:layout_switch', { to_layout: layout, source })
+    router.refresh()
+  }
+}
+
+/**
+ * The invitation on the classic vehicle page. Hiding it is remembered in this
+ * browser; the way in stays in the page's menu for whoever changes their mind.
+ *
+ * Drawn only after mount: whether it was hidden is in localStorage, which the
+ * server cannot see, and a banner that rendered and then vanished on
+ * hydration would push the page down and back.
+ */
+export function TryNewVehiclePageBanner() {
+  const t = useTranslations('vehicles.detail.layout')
+  const switchLayout = useSwitchVehiclePageLayout()
+  const [visible, setVisible] = useState(false)
+
+  useEffect(() => {
+    try {
+      setVisible(localStorage.getItem(DISMISSED_KEY) !== 'true')
+    } catch {
+      setVisible(true)
+    }
+  }, [])
+
+  if (!visible) return null
+
+  const hide = () => {
+    setVisible(false)
+    track('vehicle_page:layout_invite_dismiss')
+    try {
+      localStorage.setItem(DISMISSED_KEY, 'true')
+    } catch {
+      // Private mode: it comes back next visit, which is no harm.
+    }
+  }
+
+  return (
+    <div
+      data-testid="try-new-vehicle-page"
+      className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-primary/25 bg-primary/10 px-4 py-2.5"
+    >
+      <Sparkles className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
+      <p className="min-w-0 flex-1 text-sm">
+        <span className="font-medium">{t('inviteTitle')}</span>{' '}
+        <span className="text-muted-foreground">{t('inviteBody')}</span>
+      </p>
+      <Button
+        type="button"
+        size="sm"
+        className="h-8"
+        onClick={() => switchLayout('modern', 'invite')}
+      >
+        {t('tryIt')}
+      </Button>
+      <button
+        type="button"
+        onClick={hide}
+        aria-label={t('hideInvite')}
+        title={t('hideInvite')}
+        className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground"
+      >
+        <X className="h-4 w-4" />
+      </button>
+    </div>
+  )
+}
+
+/**
+ * Whether to point at the way back to the classic page: only on the first
+ * visit after somebody chose the new one, never for a workshop that started
+ * on it. Read after mount, as the invitation's flag is.
+ */
+export function useVehiclePageBackHint() {
+  const [show, setShow] = useState(false)
+  useEffect(() => {
+    try {
+      setShow(localStorage.getItem(BACK_HINT_KEY) === 'true')
+    } catch {
+      setShow(false)
+    }
+  }, [])
+  const dismiss = () => {
+    setShow(false)
+    try {
+      localStorage.removeItem(BACK_HINT_KEY)
+    } catch {
+      // Nothing to clear.
+    }
+  }
+  return { show, dismiss }
+}

+ 4 - 20
src/features/vehicles/Components/service-edit/ScheduleTimesSection.tsx

@@ -52,6 +52,7 @@ import {
 import { createTechnician } from '@/features/workboard/Actions/technicianActions'
 import { createTechnician } from '@/features/workboard/Actions/technicianActions'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import Link from 'next/link'
 import Link from 'next/link'
+import { awayEdges } from '@/features/workshop-calendar/Lib/format'
 
 
 const HOUR_PRESETS = [1, 2, 4, 5, 7]
 const HOUR_PRESETS = [1, 2, 4, 5, 7]
 
 
@@ -1150,19 +1151,6 @@ export function ScheduleTimesSection({
   )
   )
 }
 }
 
 
-/** Hours and minutes past local midnight of an instant in the workshop's zone. */
-function clockIn(at: Date, timeZone: string | undefined): number {
-  const parts = new Intl.DateTimeFormat('en-GB', {
-    hour: '2-digit',
-    minute: '2-digit',
-    hourCycle: 'h23',
-    timeZone,
-  }).formatToParts(at)
-  const hour = Number(parts.find((p) => p.type === 'hour')?.value ?? 0)
-  const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? 0)
-  return hour * 60 + minute
-}
-
 /**
 /**
  * When a technician is away, as days: "16 Nov – 17 Nov", or with a clock
  * When a technician is away, as days: "16 Nov – 17 Nov", or with a clock
  * only where the time off starts or ends inside a day. The end of a stretch
  * only where the time off starts or ends inside a day. The end of a stretch
@@ -1170,11 +1158,7 @@ function clockIn(at: Date, timeZone: string | undefined): number {
  */
  */
 function awayWhen(startIso: string, endIso: string, timeZone: string | null | undefined): string {
 function awayWhen(startIso: string, endIso: string, timeZone: string | null | undefined): string {
   const zone = timeZone || undefined
   const zone = timeZone || undefined
-  const start = new Date(startIso)
-  let end = new Date(endIso)
-  const startsAtMidnight = clockIn(start, zone) === 0
-  const endsAtMidnight = clockIn(end, zone) === 0
-  if (endsAtMidnight) end = new Date(end.getTime() - 60_000)
+  const { start, end, startClock, endClock } = awayEdges(startIso, endIso, zone)
   const day = (at: Date) =>
   const day = (at: Date) =>
     at.toLocaleDateString(undefined, { day: 'numeric', month: 'short', timeZone: zone })
     at.toLocaleDateString(undefined, { day: 'numeric', month: 'short', timeZone: zone })
   const dayAndClock = (at: Date) =>
   const dayAndClock = (at: Date) =>
@@ -1185,7 +1169,7 @@ function awayWhen(startIso: string, endIso: string, timeZone: string | null | un
       minute: '2-digit',
       minute: '2-digit',
       timeZone: zone,
       timeZone: zone,
     })
     })
-  const from = startsAtMidnight ? day(start) : dayAndClock(start)
-  const to = endsAtMidnight ? day(end) : dayAndClock(end)
+  const from = startClock ? dayAndClock(start) : day(start)
+  const to = endClock ? dayAndClock(end) : day(end)
   return from === to ? from : `${from} – ${to}`
   return from === to ? from : `${from} – ${to}`
 }
 }

+ 107 - 26
src/features/vehicles/Components/service-page/PhotoHandoffButton.tsx

@@ -19,6 +19,8 @@ import { cn } from '@/lib/utils'
 import {
 import {
   countPhotosSince,
   countPhotosSince,
   createPhotoHandoffLink,
   createPhotoHandoffLink,
+  createVehiclePhotoHandoffLink,
+  getVehiclePhotoUrl,
 } from '@/features/vehicles/Actions/photoHandoffActions'
 } from '@/features/vehicles/Actions/photoHandoffActions'
 import {
 import {
   countInspectionMedia,
   countInspectionMedia,
@@ -45,10 +47,14 @@ const CONCERN_SHOWN = 200
  *
  *
  * Given an `inspectionId` instead, it hands over an inspection: one code for
  * Given an `inspectionId` instead, it hands over an inspection: one code for
  * the whole checklist, and the phone picks the check each photo is for.
  * the whole checklist, and the phone picks the check each photo is for.
+ *
+ * Given a `vehicleId`, it hands over the vehicle's own photo: the phone takes
+ * or chooses one, and it replaces the picture on the vehicle's page.
  */
  */
 export function PhotoHandoffButton({
 export function PhotoHandoffButton({
   serviceRecordId = '',
   serviceRecordId = '',
   inspectionId,
   inspectionId,
+  vehicleId,
   inspectionItemId,
   inspectionItemId,
   inspectionItemLabel,
   inspectionItemLabel,
   concernId = null,
   concernId = null,
@@ -59,10 +65,14 @@ export function PhotoHandoffButton({
   disabledReason,
   disabledReason,
   className,
   className,
   compact = false,
   compact = false,
+  iconOnly = false,
+  onVehiclePhoto,
 }: {
 }: {
   serviceRecordId?: string
   serviceRecordId?: string
   /** Hands over this inspection rather than a work order. */
   /** Hands over this inspection rather than a work order. */
   inspectionId?: string
   inspectionId?: string
+  /** Hands over this vehicle's photo rather than a work order. */
+  vehicleId?: string
   /** The check the phone should open on; the code still covers the whole inspection. */
   /** The check the phone should open on; the code still covers the whole inspection. */
   inspectionItemId?: string
   inspectionItemId?: string
   /** That check's name, for the dialog's wording. */
   /** That check's name, for the dialog's wording. */
@@ -83,6 +93,13 @@ export function PhotoHandoffButton({
   className?: string
   className?: string
   /** Icon only below a laptop width, in a bar that has become icons. */
   /** Icon only below a laptop width, in a bar that has become icons. */
   compact?: boolean
   compact?: boolean
+  /** Always just the phone icon, with the name as its tooltip, where room is short. */
+  iconOnly?: boolean
+  /**
+   * Told the vehicle's new photo as the phone sends it, for a form showing the
+   * photo that would otherwise save the old one back over it.
+   */
+  onVehiclePhoto?: (url: string | null) => void
 }) {
 }) {
   const t = useTranslations('service.photoHandoff')
   const t = useTranslations('service.photoHandoff')
   const ti = useTranslations('inspections.phone')
   const ti = useTranslations('inspections.phone')
@@ -97,6 +114,11 @@ export function PhotoHandoffButton({
   // An inspection's count is a total (a check's photos carry no time), so
   // An inspection's count is a total (a check's photos carry no time), so
   // what has arrived is counted from the first answer.
   // what has arrived is counted from the first answer.
   const baselineRef = useRef<number | null>(null)
   const baselineRef = useRef<number | null>(null)
+  // A vehicle's photo is one picture, so what has arrived is a change to it.
+  const photoBaselineRef = useRef<string | null | undefined>(undefined)
+  // Read at the moment a photo lands, so a new callback never restarts the polling.
+  const onVehiclePhotoRef = useRef(onVehiclePhoto)
+  onVehiclePhotoRef.current = onVehiclePhoto
 
 
   // A fresh code every time the dialog opens: the last one may have lapsed.
   // A fresh code every time the dialog opens: the last one may have lapsed.
   useEffect(() => {
   useEffect(() => {
@@ -107,9 +129,22 @@ export function PhotoHandoffButton({
     setReceived(0)
     setReceived(0)
     receivedRef.current = 0
     receivedRef.current = 0
     baselineRef.current = null
     baselineRef.current = null
-    const request = inspectionId
-      ? createInspectionHandoffLink(inspectionId)
-      : createPhotoHandoffLink({ serviceRecordId, concernId, purpose })
+    photoBaselineRef.current = undefined
+    // A vehicle's photo is read before the code is shown, so the first photo
+    // the phone sends is a change from it. Taking the first answer of the
+    // polling as the starting point instead would swallow a photo that
+    // landed before that answer, and the dialog would wait for ever.
+    const request = vehicleId
+      ? Promise.all([createVehiclePhotoHandoffLink(vehicleId), getVehiclePhotoUrl(vehicleId)]).then(
+          ([link, photo]) => {
+            if (!photo.success) return { success: false as const, data: undefined }
+            photoBaselineRef.current = photo.data ?? null
+            return link
+          }
+        )
+      : inspectionId
+        ? createInspectionHandoffLink(inspectionId)
+        : createPhotoHandoffLink({ serviceRecordId, concernId, purpose })
     request
     request
       .then((result) => {
       .then((result) => {
         if (cancelled) return
         if (cancelled) return
@@ -134,11 +169,32 @@ export function PhotoHandoffButton({
     return () => {
     return () => {
       cancelled = true
       cancelled = true
     }
     }
-  }, [open, serviceRecordId, inspectionId, inspectionItemId, concernId, purpose])
+  }, [open, serviceRecordId, inspectionId, vehicleId, inspectionItemId, concernId, purpose])
 
 
   // Photos arriving while the code is up are shown as they land.
   // Photos arriving while the code is up are shown as they land.
   useEffect(() => {
   useEffect(() => {
-    if (!open || !link) return
+    if (!open || !link || !vehicleId) return
+    const check = async () => {
+      const result = await getVehiclePhotoUrl(vehicleId).catch(() => null)
+      if (!result?.success) return
+      const now = result.data ?? null
+      // Read before the code was shown; see where the code is made.
+      if (photoBaselineRef.current === undefined) return
+      if (now !== photoBaselineRef.current) {
+        photoBaselineRef.current = now
+        receivedRef.current += 1
+        setReceived(receivedRef.current)
+        onVehiclePhotoRef.current?.(now)
+        router.refresh()
+      }
+    }
+    void check()
+    const timer = setInterval(check, POLL_MS)
+    return () => clearInterval(timer)
+  }, [open, link, vehicleId, router])
+
+  useEffect(() => {
+    if (!open || !link || vehicleId) return
     const count = async () => {
     const count = async () => {
       if (!inspectionId) return countPhotosSince({ serviceRecordId, since: link.since })
       if (!inspectionId) return countPhotosSince({ serviceRecordId, since: link.since })
       const total = await countInspectionMedia(inspectionId)
       const total = await countInspectionMedia(inspectionId)
@@ -158,7 +214,7 @@ export function PhotoHandoffButton({
       }
       }
     }, POLL_MS)
     }, POLL_MS)
     return () => clearInterval(timer)
     return () => clearInterval(timer)
-  }, [open, link, serviceRecordId, inspectionId, router])
+  }, [open, link, serviceRecordId, inspectionId, vehicleId, router])
 
 
   useEffect(() => {
   useEffect(() => {
     if (!copied) return
     if (!copied) return
@@ -188,11 +244,18 @@ export function PhotoHandoffButton({
         onClick={() => setOpen(true)}
         onClick={() => setOpen(true)}
         data-testid="photo-handoff-open"
         data-testid="photo-handoff-open"
         aria-label={t(purpose === 'dropoff' ? 'dropoff.button' : 'button')}
         aria-label={t(purpose === 'dropoff' ? 'dropoff.button' : 'button')}
-        title={compact ? t(purpose === 'dropoff' ? 'dropoff.button' : 'button') : undefined}
-        className={cn('h-7 gap-1.5 text-xs', compact && 'px-2.5 lg:px-3', className)}
+        title={
+          compact || iconOnly ? t(purpose === 'dropoff' ? 'dropoff.button' : 'button') : undefined
+        }
+        className={cn(
+          'h-7 gap-1.5 text-xs',
+          compact && 'px-2.5 lg:px-3',
+          iconOnly && 'w-7 px-0',
+          className
+        )}
       >
       >
         <Smartphone className="h-3.5 w-3.5" aria-hidden="true" />
         <Smartphone className="h-3.5 w-3.5" aria-hidden="true" />
-        <span className={compact ? 'hidden lg:inline' : undefined}>
+        <span className={iconOnly ? 'sr-only' : compact ? 'hidden lg:inline' : undefined}>
           {t(purpose === 'dropoff' ? 'dropoff.button' : 'button')}
           {t(purpose === 'dropoff' ? 'dropoff.button' : 'button')}
         </span>
         </span>
       </Button>
       </Button>
@@ -211,11 +274,13 @@ export function PhotoHandoffButton({
 
 
   const dropoff = purpose === 'dropoff'
   const dropoff = purpose === 'dropoff'
   const forCheck = !!inspectionId && !!inspectionItemId
   const forCheck = !!inspectionId && !!inspectionItemId
-  const steps = forCheck
-    ? [t('step1'), ti('stepItem2'), ti('step3')]
-    : inspectionId
-      ? [t('step1'), ti('step2'), ti('step3')]
-      : [t('step1'), t(dropoff ? 'dropoff.step2' : 'step2'), t('step3')]
+  const steps = vehicleId
+    ? [t('step1'), t('vehicle.step2'), t('vehicle.step3')]
+    : forCheck
+      ? [t('step1'), ti('stepItem2'), ti('step3')]
+      : inspectionId
+        ? [t('step1'), ti('step2'), ti('step3')]
+        : [t('step1'), t(dropoff ? 'dropoff.step2' : 'step2'), t('step3')]
 
 
   return (
   return (
     <>
     <>
@@ -234,18 +299,22 @@ export function PhotoHandoffButton({
                 </span>
                 </span>
                 <div className="space-y-1.5">
                 <div className="space-y-1.5">
                   <DialogTitle className="text-lg">
                   <DialogTitle className="text-lg">
-                    {forCheck
-                      ? ti('dialogTitleItem', { name: inspectionItemLabel ?? '' })
-                      : inspectionId
-                        ? ti('dialogTitle')
-                        : t(dropoff ? 'dropoff.dialogTitle' : 'dialogTitle')}
+                    {vehicleId
+                      ? t('vehicle.dialogTitle')
+                      : forCheck
+                        ? ti('dialogTitleItem', { name: inspectionItemLabel ?? '' })
+                        : inspectionId
+                          ? ti('dialogTitle')
+                          : t(dropoff ? 'dropoff.dialogTitle' : 'dialogTitle')}
                   </DialogTitle>
                   </DialogTitle>
                   <DialogDescription>
                   <DialogDescription>
-                    {forCheck
-                      ? ti('dialogBodyItem')
-                      : inspectionId
-                        ? ti('dialogBody')
-                        : t(dropoff ? 'dropoff.dialogBody' : 'dialogBody')}
+                    {vehicleId
+                      ? t('vehicle.dialogBody')
+                      : forCheck
+                        ? ti('dialogBodyItem')
+                        : inspectionId
+                          ? ti('dialogBody')
+                          : t(dropoff ? 'dropoff.dialogBody' : 'dialogBody')}
                   </DialogDescription>
                   </DialogDescription>
                 </div>
                 </div>
               </DialogHeader>
               </DialogHeader>
@@ -286,7 +355,13 @@ export function PhotoHandoffButton({
                 <div className="space-y-1 text-xs text-muted-foreground">
                 <div className="space-y-1 text-xs text-muted-foreground">
                   <p className="font-medium text-foreground">{t('noSignIn')}</p>
                   <p className="font-medium text-foreground">{t('noSignIn')}</p>
                   <p>{t('customerToo')}</p>
                   <p>{t('customerToo')}</p>
-                  <p>{inspectionId ? ti('scope') : t(dropoff ? 'dropoff.scope' : 'scope')}</p>
+                  <p>
+                    {vehicleId
+                      ? t('vehicle.scope')
+                      : inspectionId
+                        ? ti('scope')
+                        : t(dropoff ? 'dropoff.scope' : 'scope')}
+                  </p>
                 </div>
                 </div>
               </div>
               </div>
             </div>
             </div>
@@ -360,7 +435,13 @@ export function PhotoHandoffButton({
                     ) : (
                     ) : (
                       <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
                       <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
                     )}
                     )}
-                    {received > 0 ? t('received', { count: received }) : t('waiting')}
+                    {received > 0
+                      ? vehicleId
+                        ? t('vehicle.received')
+                        : t('received', { count: received })
+                      : vehicleId
+                        ? t('vehicle.waiting')
+                        : t('waiting')}
                   </p>
                   </p>
                 </>
                 </>
               )}
               )}

+ 2 - 42
src/features/vehicles/Components/service-page/modern/JobFactsCard.tsx

@@ -3,7 +3,8 @@
 import { useEffect, useState } from 'react'
 import { useEffect, useState } from 'react'
 import Link from 'next/link'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
-import { Check, ChevronDown, Copy } from 'lucide-react'
+import { Check, ChevronDown } from 'lucide-react'
+import { CopyValue, useCopy } from '@/components/copy-value'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { useDateSettings } from '@/components/date-settings-context'
 import { useDateSettings } from '@/components/date-settings-context'
 import { useFormatDate } from '@/lib/use-format-date'
 import { useFormatDate } from '@/lib/use-format-date'
@@ -76,47 +77,6 @@ const TRANSMISSION_LABELS: Record<string, string> = {
   inboard: 'inboard',
   inboard: 'inboard',
 }
 }
 
 
-/**
- * Copies one value, and says so for a moment. The VIN is the reason this
- * exists: it used to take a trip to the vehicle page and back.
- */
-function useCopy(value: string) {
-  const [copied, setCopied] = useState(false)
-  useEffect(() => {
-    if (!copied) return
-    const timer = setTimeout(() => setCopied(false), 1500)
-    return () => clearTimeout(timer)
-  }, [copied])
-  const copy = () => {
-    navigator.clipboard
-      ?.writeText(value)
-      .then(() => setCopied(true))
-      .catch(() => undefined)
-  }
-  return { copied, copy }
-}
-
-function CopyValue({ value, label }: { value: string; label: string }) {
-  const t = useTranslations('service.modern.vehicleInfo')
-  const { copied, copy } = useCopy(value)
-  const name = copied ? t('copied') : t('copy', { label })
-  return (
-    <button
-      type="button"
-      onClick={copy}
-      aria-label={name}
-      title={name}
-      className="inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
-    >
-      {copied ? (
-        <Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden="true" />
-      ) : (
-        <Copy className="h-3.5 w-3.5" aria-hidden="true" />
-      )}
-    </button>
-  )
-}
-
 /**
 /**
  * The number plate, drawn as one, and a click copies it. It used to be a
  * The number plate, drawn as one, and a click copies it. It used to be a
  * second link to the vehicle page, which the name beside it already is; the
  * second link to the vehicle page, which the name beside it already is; the

+ 155 - 0
src/features/vehicles/Lib/vehicleJobWarnings.ts

@@ -0,0 +1,155 @@
+import type { BookingContext } from '@/features/workboard/Lib/bookings'
+import { checkSlotIn } from '@/features/workboard/Lib/slotCheck'
+
+/**
+ * One open job's booking problems, as the vehicle page shows them. Dates are
+ * ISO strings: this crosses from the server page to the browser.
+ */
+export interface VehicleJobWarning {
+  serviceRecordId: string
+  /** Enough of the job to name it where the feed does not show it. */
+  title: string
+  invoiceNumber: string | null
+  start: string
+  /** Names of the workshop's own closures the job falls in. */
+  closures: string[]
+  /** The job is on a weekday the workshop is normally closed. */
+  closedWeekday: boolean
+  /** Somebody on the job is off then. `reason` is the absence's stored reason. */
+  away: { people: string[]; reason: string; start: string; end: string }[]
+  /** Other work holding the same person or bay. */
+  clashes: {
+    label: string
+    start: string
+    end: string
+    onTechnician: boolean
+    onBay: boolean
+    /** Who on the job is double-booked, by name. */
+    people: string[]
+  }[]
+}
+
+export interface BookedJob {
+  id: string
+  title: string
+  invoiceNumber: string | null
+  start: Date
+  end: Date
+  technicianId: string | null
+  helperIds: string[]
+  workBayId: string | null
+}
+
+const DAY_MS = 86_400_000
+
+export interface Span {
+  start: Date
+  end: Date
+}
+
+/**
+ * The bookings a slot is checked against are read for a window of days, and
+ * that window has a limit (MAX_WINDOW_DAYS in workboard/Lib/bookings). A job
+ * longer than `maxDays` is checked in pieces that each fit, so nothing past
+ * the limit goes unseen; any overlap with a piece is an overlap with the job.
+ */
+export function splitSpan(span: Span, maxDays: number): Span[] {
+  const step = maxDays * DAY_MS
+  const pieces: Span[] = []
+  for (let at = span.start.getTime(); at < span.end.getTime(); at += step) {
+    pieces.push({ start: new Date(at), end: new Date(Math.min(at + step, span.end.getTime())) })
+  }
+  return pieces
+}
+
+/**
+ * As few windows as cover every span, each no longer than `maxDays` from its
+ * first day: one read of the workshop's bookings per window rather than per
+ * job. Spans must each fit in `maxDays` already (see splitSpan).
+ */
+export function planWindows(spans: Span[], maxDays: number): Span[] {
+  const sorted = [...spans].sort((a, b) => a.start.getTime() - b.start.getTime())
+  const windows: Span[] = []
+  for (const span of sorted) {
+    const current = windows[windows.length - 1]
+    if (current && span.end.getTime() <= current.start.getTime() + maxDays * DAY_MS) {
+      if (span.end > current.end) current.end = span.end
+    } else {
+      windows.push({ start: span.start, end: span.end })
+    }
+  }
+  return windows
+}
+
+/**
+ * The warnings for each job that has any, in the order given. A job with
+ * nothing wrong is left out, so an empty list means nothing to fix.
+ *
+ * `contextFor` hands the bookings read for the window holding a stretch of
+ * time; a single context does for jobs that all fit one window. `maxDays`
+ * is how long a stretch may be before it is checked in pieces.
+ */
+export function buildVehicleJobWarnings(
+  jobs: BookedJob[],
+  contextFor: BookingContext | ((span: Span) => BookingContext),
+  technicianNames: Map<string, string>,
+  maxDays = Number.POSITIVE_INFINITY
+): VehicleJobWarning[] {
+  const names = (ids: string[]) =>
+    ids.map((id) => technicianNames.get(id)).filter((name): name is string => !!name)
+  const context = (span: Span) => (typeof contextFor === 'function' ? contextFor(span) : contextFor)
+
+  return jobs.flatMap((job) => {
+    const pieces = Number.isFinite(maxDays) ? splitSpan(job, maxDays) : [job]
+    const checks = pieces.map((piece) =>
+      checkSlotIn(context(piece), {
+        start: piece.start,
+        end: piece.end,
+        technicianId: job.technicianId,
+        helperIds: job.helperIds,
+        workBayId: job.workBayId,
+        excludeId: job.id,
+      })
+    )
+    // One clash or closure met by two pieces is still one.
+    const conflicts = [
+      ...new Map(checks.flatMap((c) => c.conflicts).map((c) => [c.id, c])).values(),
+    ]
+    const closures = [...new Set(checks.flatMap((c) => c.closures.map((closure) => closure.name)))]
+    // Whether the day the job starts is a closed weekday, as the work order asks.
+    const closedWeekday = checks[0]?.closedWeekday ?? false
+
+    const away = conflicts
+      .filter((c) => c.kind === 'absence')
+      .map((c) => ({
+        people: names(c.people),
+        reason: c.label,
+        start: c.start.toISOString(),
+        end: c.end.toISOString(),
+      }))
+    const clashes = conflicts
+      .filter((c) => c.kind !== 'absence')
+      .map((c) => ({
+        label: c.label,
+        start: c.start.toISOString(),
+        end: c.end.toISOString(),
+        onTechnician: c.onTechnician,
+        onBay: c.onBay,
+        people: names(c.people),
+      }))
+    if (away.length === 0 && clashes.length === 0 && closures.length === 0 && !closedWeekday)
+      return []
+    return [
+      {
+        serviceRecordId: job.id,
+        title: job.title,
+        invoiceNumber: job.invoiceNumber,
+        start: job.start.toISOString(),
+        closures,
+        closedWeekday,
+        away,
+        clashes,
+      },
+    ]
+  })
+}

+ 18 - 57
src/features/workboard/Actions/boardActions/availability.ts

@@ -2,21 +2,11 @@
 
 
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
-import {
-  findConflicts,
-  nextAvailableSlot,
-  sharedPeople,
-  technicianMinutesInDay,
-} from '../../Lib/availability'
+import { nextAvailableSlot, technicianMinutesInDay } from '../../Lib/availability'
 import { addZonedDays, startOfZonedDay } from '@/lib/timezone'
 import { addZonedDays, startOfZonedDay } from '@/lib/timezone'
 import { loadBookingContext } from '../../Lib/bookings'
 import { loadBookingContext } from '../../Lib/bookings'
-import {
-  absencesOn,
-  awayAllDay,
-  closuresDuring,
-  dayOf,
-  dayState,
-} from '@/features/workshop-calendar/Lib/calendar'
+import { checkSlot } from '../../Lib/slotCheck'
+import { absencesOn, awayAllDay, dayOf } from '@/features/workshop-calendar/Lib/calendar'
 
 
 /**
 /**
  * Is this slot free, and if not, where is the next one.
  * Is this slot free, and if not, where is the next one.
@@ -45,53 +35,24 @@ export async function checkSlotAvailability(input: {
 }) {
 }) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
-      const start = new Date(input.start)
-      const end = new Date(input.end)
-      if (!(start < end)) return { conflicts: [], closures: [], closedWeekday: false }
-
-      const { bookings, hours, closuresEnabled } = await loadBookingContext(organizationId, start)
-      const calendar = hours.calendar
-      // The workshop's own closures matter whoever the job is for.
-      const closures =
-        closuresEnabled && calendar
-          ? closuresDuring(calendar, start, end).map((c) => ({ name: c.name, source: c.source }))
-          : []
-      const closedWeekday =
-        closuresEnabled && calendar
-          ? dayState(calendar, dayOf(start, calendar.timeZone)).closedWeekday
-          : false
-      // Nothing is held, so nothing can be clashed with.
-      if (!input.technicianId && !input.workBayId) return { conflicts: [], closures, closedWeekday }
-
-      const slot = {
-        start,
-        end,
+      // The check itself lives in Lib/slotCheck, shared with the vehicle page's
+      // warnings; this answers the work order with it, as strings.
+      const result = await checkSlot(organizationId, {
+        start: new Date(input.start),
+        end: new Date(input.end),
         technicianId: input.technicianId,
         technicianId: input.technicianId,
-        // Helpers only count beside a technician: without one the job has none.
-        helperIds: input.technicianId ? (input.helperIds ?? []) : [],
+        helperIds: input.helperIds,
         workBayId: input.workBayId,
         workBayId: input.workBayId,
-      }
-      const conflicts = findConflicts(slot, bookings, input.excludeId)
+        excludeId: input.excludeId,
+      })
       return {
       return {
-        conflicts: conflicts.map((c) => {
-          const people = sharedPeople(slot, c)
-          return {
-            id: c.id,
-            kind: c.kind,
-            label: c.label,
-            start: c.start.toISOString(),
-            end: c.end.toISOString(),
-            // Which resource actually clashed, so the warning can say so rather
-            // than leaving somebody to work it out from two times.
-            onTechnician: people.length > 0,
-            onBay: !!input.workBayId && c.workBayId === input.workBayId,
-            // Who is double-booked, as technician ids: with two people on a
-            // job "the technician is busy" no longer says which of them.
-            people,
-          }
-        }),
-        closures,
-        closedWeekday,
+        conflicts: result.conflicts.map((c) => ({
+          ...c,
+          start: c.start.toISOString(),
+          end: c.end.toISOString(),
+        })),
+        closures: result.closures,
+        closedWeekday: result.closedWeekday,
       }
       }
     },
     },
     {
     {

+ 8 - 2
src/features/workboard/Lib/bookings.ts

@@ -19,18 +19,24 @@ import { ACTIVE_HELPERS } from './jobTeam'
 /** How far ahead to read by default. Long enough to answer "next free slot" honestly. */
 /** How far ahead to read by default. Long enough to answer "next free slot" honestly. */
 const WINDOW_DAYS = 45
 const WINDOW_DAYS = 45
 /** The most a caller can ask for: a year and a bit. */
 /** The most a caller can ask for: a year and a bit. */
-const MAX_WINDOW_DAYS = 400
+export const MAX_WINDOW_DAYS = 400
 
 
 /**
 /**
  * `days` is how far past `from` to read. Online booking asks for its whole
  * `days` is how far past `from` to read. Online booking asks for its whole
  * horizon: jobs, closed days and leave past the default window would
  * horizon: jobs, closed days and leave past the default window would
  * otherwise be invisible, and a closed day there offered to a customer.
  * otherwise be invisible, and a closed day there offered to a customer.
  */
  */
+export interface BookingContext {
+  bookings: Booking[]
+  hours: WorkingHours
+  closuresEnabled: boolean
+}
+
 export async function loadBookingContext(
 export async function loadBookingContext(
   organizationId: string,
   organizationId: string,
   from: Date,
   from: Date,
   opts: { days?: number } = {}
   opts: { days?: number } = {}
-): Promise<{ bookings: Booking[]; hours: WorkingHours; closuresEnabled: boolean }> {
+): Promise<BookingContext> {
   const days = Math.min(Math.max(Math.ceil(opts.days ?? WINDOW_DAYS), 1), MAX_WINDOW_DAYS)
   const days = Math.min(Math.max(Math.ceil(opts.days ?? WINDOW_DAYS), 1), MAX_WINDOW_DAYS)
   const windowStart = new Date(from)
   const windowStart = new Date(from)
   windowStart.setDate(windowStart.getDate() - 1)
   windowStart.setDate(windowStart.getDate() - 1)

+ 94 - 0
src/features/workboard/Lib/slotCheck.ts

@@ -0,0 +1,94 @@
+import { findConflicts, sharedPeople } from './availability'
+import { type BookingContext, loadBookingContext } from './bookings'
+import { closuresDuring, dayOf, dayState } from '@/features/workshop-calendar/Lib/calendar'
+
+export interface SlotCheckInput {
+  start: Date
+  end: Date
+  technicianId?: string | null
+  /** Who else is on the job: their day has to be free as well. */
+  helperIds?: string[]
+  workBayId?: string | null
+  /** The job being checked, so it never clashes with itself. */
+  excludeId?: string
+}
+
+export interface SlotCheck {
+  conflicts: {
+    id: string
+    /** `absence` is a technician's time off rather than other work. */
+    kind: 'serviceRecord' | 'inspection' | 'absence'
+    label: string
+    start: Date
+    end: Date
+    /** Which resource clashed, so a warning can say so. */
+    onTechnician: boolean
+    onBay: boolean
+    /** Who on the job is double-booked, as technician ids. */
+    people: string[]
+  }[]
+  /** The workshop's own closures the slot falls in. */
+  closures: { name: string; source: string }[]
+  /** The slot is on a weekday the workshop is normally closed. */
+  closedWeekday: boolean
+}
+
+/**
+ * What a booking collides with: other work holding the same person or bay,
+ * a technician's time off, and any day the workshop is closed. Nothing here
+ * refuses the booking, the desk may agree overtime; it is what the warnings
+ * on a work order, and on its vehicle's page, are made from, so both always
+ * say the same thing.
+ */
+export async function checkSlot(organizationId: string, input: SlotCheckInput): Promise<SlotCheck> {
+  if (!(input.start < input.end)) return { conflicts: [], closures: [], closedWeekday: false }
+  return checkSlotIn(await loadBookingContext(organizationId, input.start), input)
+}
+
+/**
+ * The same check against a context already loaded, so a page asking about
+ * several jobs reads the workshop's bookings once. The context has to cover
+ * the slot: `loadBookingContext` from the slot's start, or from the earliest
+ * of several with `days` reaching past the last.
+ */
+export function checkSlotIn(context: BookingContext, input: SlotCheckInput): SlotCheck {
+  const { start, end } = input
+  if (!(start < end)) return { conflicts: [], closures: [], closedWeekday: false }
+
+  const { bookings, hours, closuresEnabled } = context
+  const calendar = hours.calendar
+  // The workshop's own closures matter whoever the job is for.
+  const closures =
+    closuresEnabled && calendar
+      ? closuresDuring(calendar, start, end).map((c) => ({ name: c.name, source: c.source }))
+      : []
+  const closedWeekday =
+    closuresEnabled && calendar
+      ? dayState(calendar, dayOf(start, calendar.timeZone)).closedWeekday
+      : false
+  // Nothing is held, so nothing can be clashed with.
+  if (!input.technicianId && !input.workBayId) return { conflicts: [], closures, closedWeekday }
+
+  const slot = {
+    start,
+    end,
+    technicianId: input.technicianId,
+    // Helpers only count beside a technician: without one the job has none.
+    helperIds: input.technicianId ? (input.helperIds ?? []) : [],
+    workBayId: input.workBayId,
+  }
+  const conflicts = findConflicts(slot, bookings, input.excludeId).map((c) => {
+    const people = sharedPeople(slot, c)
+    return {
+      id: c.id,
+      kind: c.kind,
+      label: c.label,
+      start: c.start,
+      end: c.end,
+      onTechnician: people.length > 0,
+      onBay: !!input.workBayId && c.workBayId === input.workBayId,
+      people,
+    }
+  })
+  return { conflicts, closures, closedWeekday }
+}

+ 31 - 0
src/features/workshop-calendar/Lib/format.ts

@@ -81,3 +81,34 @@ export function countryName(code: string, locale: string): string {
 export function regionLabel(code: string): string {
 export function regionLabel(code: string): string {
   return code.includes('-') ? code.slice(code.indexOf('-') + 1) : code
   return code.includes('-') ? code.slice(code.indexOf('-') + 1) : code
 }
 }
+
+/** Hours and minutes past local midnight of an instant in the given zone. */
+export function clockIn(at: Date, timeZone: string | undefined): number {
+  const parts = new Intl.DateTimeFormat('en-GB', {
+    hour: '2-digit',
+    minute: '2-digit',
+    hourCycle: 'h23',
+    timeZone,
+  }).formatToParts(at)
+  const hour = Number(parts.find((p) => p.type === 'hour')?.value ?? 0)
+  const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? 0)
+  return hour * 60 + minute
+}
+
+/**
+ * The edges of a stretch of time off, for saying when somebody is away. Time
+ * off is days, so a clock is only worth showing where it starts or ends
+ * inside one; and the end of a stretch is the midnight after its last day,
+ * which has to read as that last day.
+ */
+export function awayEdges(
+  startIso: string,
+  endIso: string,
+  timeZone: string | undefined
+): { start: Date; end: Date; startClock: boolean; endClock: boolean } {
+  const start = new Date(startIso)
+  let end = new Date(endIso)
+  const endsAtMidnight = clockIn(end, timeZone) === 0
+  if (endsAtMidnight) end = new Date(end.getTime() - 60_000)
+  return { start, end, startClock: clockIn(start, timeZone) !== 0, endClock: !endsAtMidnight }
+}

+ 8 - 0
src/lib/analytics.ts

@@ -21,6 +21,14 @@ import posthog from 'posthog-js'
 export type AnalyticsEvent =
 export type AnalyticsEvent =
   /** A work order opened. */
   /** A work order opened. */
   | 'work_order:page_view'
   | 'work_order:page_view'
+  /**
+   * The vehicle page switched between its classic and new layouts: `to_layout`
+   * which one, `source` the invitation or the menu. Switches back to classic
+   * say what the new page still lacks.
+   */
+  | 'vehicle_page:layout_switch'
+  /** The invitation to the new vehicle page closed without trying it. */
+  | 'vehicle_page:layout_invite_dismiss'
   /** An account made with email and password. Google sign-ups show as workshop:create. */
   /** An account made with email and password. Google sign-ups show as workshop:create. */
   | 'account:sign_up'
   | 'account:sign_up'
   /** The onboarding form made the person's first workshop, with or without sample data. */
   /** The onboarding form made the person's first workshop, with or without sample data. */

+ 66 - 0
src/lib/photo-handoff.ts

@@ -286,3 +286,69 @@ export function verifyFixHandoffToken(token: string, now = Date.now()): FixHando
     },
     },
   }
   }
 }
 }
+
+/**
+ * The same kind of code for a vehicle's own photo, the picture at the top of
+ * its page. The phone takes or chooses one photo and it replaces the one on
+ * the vehicle. It can do nothing else: no job, no history, no customer.
+ */
+const VEHICLE_PREFIX = 'vh1'
+
+export interface VehicleHandoff {
+  organizationId: string
+  vehicleId: string
+  /** Who showed the code. */
+  userId: string
+  /** Unix seconds. */
+  expiresAt: number
+}
+
+export function isVehicleHandoffToken(token: string): boolean {
+  return token.startsWith(`${VEHICLE_PREFIX}.`)
+}
+
+export function createVehicleHandoffToken(
+  handoff: Omit<VehicleHandoff, 'expiresAt'>,
+  now = Date.now()
+): { token: string; expiresAt: Date } {
+  const expiresAt = Math.floor(now / 1000) + PHOTO_HANDOFF_TTL_SECONDS
+  const body = Buffer.from(
+    JSON.stringify({
+      o: handoff.organizationId,
+      v: handoff.vehicleId,
+      u: handoff.userId,
+      e: expiresAt,
+    })
+  ).toString('base64url')
+  return {
+    token: `${VEHICLE_PREFIX}.${body}.${sign(body, VEHICLE_PREFIX)}`,
+    expiresAt: new Date(expiresAt * 1000),
+  }
+}
+
+export type VehicleHandoffCheck =
+  | { ok: true; handoff: VehicleHandoff }
+  | { ok: false; reason: 'invalid' | 'expired' }
+
+export function verifyVehicleHandoffToken(token: string, now = Date.now()): VehicleHandoffCheck {
+  const body = signedBody(token, VEHICLE_PREFIX)
+  if (!body) return { ok: false, reason: 'invalid' }
+
+  let payload: { o?: unknown; v?: unknown; u?: unknown; e?: unknown }
+  try {
+    payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
+  } catch {
+    return { ok: false, reason: 'invalid' }
+  }
+  const { o, v, u, e } = payload
+  if (
+    typeof o !== 'string' ||
+    typeof v !== 'string' ||
+    typeof u !== 'string' ||
+    typeof e !== 'number'
+  ) {
+    return { ok: false, reason: 'invalid' }
+  }
+  if (e * 1000 <= now) return { ok: false, reason: 'expired' }
+  return { ok: true, handoff: { organizationId: o, vehicleId: v, userId: u, expiresAt: e } }
+}

+ 26 - 0
src/lib/vehicle-page-layout.server.ts

@@ -0,0 +1,26 @@
+import 'server-only'
+
+import { cookies } from 'next/headers'
+import { db } from '@/lib/db'
+import {
+  VEHICLE_PAGE_LAYOUT_COOKIE,
+  parseVehiclePageLayout,
+  type VehiclePageLayout,
+} from './vehicle-page-layout'
+
+/** The vehicle page this browser should get; see lib/vehicle-page-layout.ts. */
+export async function resolveVehiclePageLayout(
+  organizationId: string | null | undefined
+): Promise<VehiclePageLayout> {
+  const store = await cookies()
+  const chosen = store.get(VEHICLE_PAGE_LAYOUT_COOKIE)?.value
+  // An explicit choice needs no lookup.
+  if (chosen === 'classic' || chosen === 'modern') return chosen
+  const workshop = organizationId
+    ? await db.organization.findUnique({
+        where: { id: organizationId },
+        select: { createdAt: true },
+      })
+    : null
+  return parseVehiclePageLayout(chosen, workshop?.createdAt ?? null)
+}

+ 39 - 0
src/lib/vehicle-page-layout.ts

@@ -0,0 +1,39 @@
+/**
+ * Which vehicle page this browser renders: the new one (the vehicle on the
+ * left, its history as one feed), or the classic one with tabs.
+ *
+ * Workshops that already know the classic page keep it until somebody there
+ * tries the new one; workshops made after the new page shipped start on it.
+ * Either way the choice is the person's: the classic page invites them to
+ * try the new one, and the new page has the way back.
+ *
+ * A cookie, as the work order layout was while it had two: the page is
+ * rendered on the server, and reading the choice there means the first paint
+ * is already the right page rather than the other one flashing past.
+ */
+export type VehiclePageLayout = 'classic' | 'modern'
+
+export const VEHICLE_PAGE_LAYOUT_COOKIE = 'vehiclePageLayout'
+
+/** A year, the same as the other remembered preferences. */
+export const VEHICLE_PAGE_LAYOUT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365
+
+/**
+ * Workshops created from this moment start on the new page. Older ones never
+ * saw it, so they stay on what they know unless they choose otherwise.
+ */
+export const NEW_VEHICLE_PAGE_FROM = new Date('2026-11-14T00:00:00Z')
+
+/** The page a browser gets: its own choice, else what its workshop's age says. */
+export function parseVehiclePageLayout(
+  raw: string | undefined | null,
+  workshopCreatedAt: Date | null
+): VehiclePageLayout {
+  if (raw === 'classic' || raw === 'modern') return raw
+  return workshopCreatedAt && workshopCreatedAt >= NEW_VEHICLE_PAGE_FROM ? 'modern' : 'classic'
+}
+
+/** Client only: remember the choice for the next server render. */
+export function rememberVehiclePageLayout(layout: VehiclePageLayout) {
+  document.cookie = `${VEHICLE_PAGE_LAYOUT_COOKIE}=${layout}; path=/; max-age=${VEHICLE_PAGE_LAYOUT_COOKIE_MAX_AGE}; SameSite=Lax`
+}

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff