Quellcode durchsuchen

Rebuild the vehicle page as a call sheet and give every status one colour across the app (#582)

* buttons

* Rebuild the vehicle page around a jobs table and give every status one colour

The vehicle page becomes a call sheet: one rail with the overview (odometer,
last visit, next booking, inspection due, unpaid, lifetime spend), the owner,
the car's details, what needs following up and every note, beside one jobs
table with open work on top and finished work under its year. Each row says
what was done on the job, a booking problem sits on the job's own row, and a
billed job that is not paid says so. Figures in the rail open what they stand
for. Narrower than the two-column layout the jobs sit between the overview
and the rest of the rail.

One add button, top right, adds what the current list shows; the second
button inside the notes, observations and reminders lists is gone.

Work order, quote and inspection statuses are coloured in one table
(src/lib/status-colors.ts) and drawn with one component (StatusBadge) on
every screen: lists, dashboard, customer page, vehicle page, work order
header, tyre jobs, the technician's jobs and the customer portal. A test
fails when a file outside that table colours one of these statuses, and
holds the work board's fills to the same hues.

Days and year groups in the jobs table are the workshop's, on the server and
in the browser, which ends a hydration mismatch on jobs near midnight.

* Keep the unpaid marker and the technician in view, and let a booking warning be hovered

A job with both an unpaid bill and a booking problem said only the problem;
what is owed now shows whatever else the row says. The warning is a link of
its own above the row's link, so its full text shows on hover and a click
still opens the job. Between the stacked row and the full table the
technician stays under the title until the technician column appears.

The marine spec looks for the reading as the new vehicle page writes it,
now that new workshops start on that page.

* Make the vehicle page's add button follow every tab, and say which rows are quotes or inspections

The button added a note, observation or reminder over those lists but still
said "New Work Order" over quotes and inspections. It now adds what the list
in front of you holds on every tab, from one table that maps each tab to
what it adds; a tab without an entry does not compile. The menu beside it
lists the rest in the same order everywhere.

A quote or inspection in the jobs table carries its name beside its icon,
since the icon alone did not say which it was.

* Count vehicle page views by layout, so the classic page can be retired

Both layouts send vehicle_page:page_view with the layout that was drawn and
whether this browser picked it or got it from the workshop's age. A workshop
still on the classic page because nobody there has tried the new one is told
apart from one that went back to it. One event per vehicle opened; moving
between the page's lists does not count again.
Bernt Christian Egeland vor 17 Stunden
Ursprung
Commit
a3bd1dfae6
50 geänderte Dateien mit 2393 neuen und 1361 gelöschten Zeilen
  1. 2 2
      e2e/specs/settings/marine.spec.ts
  2. 16 4
      messages/de/vehicles.json
  3. 16 4
      messages/en/vehicles.json
  4. 16 4
      messages/es/vehicles.json
  5. 16 4
      messages/fr/vehicles.json
  6. 16 4
      messages/it/vehicles.json
  7. 16 4
      messages/lt/vehicles.json
  8. 16 4
      messages/nb/vehicles.json
  9. 16 4
      messages/nl/vehicles.json
  10. 16 4
      messages/pl/vehicles.json
  11. 16 4
      messages/pt-BR/vehicles.json
  12. 16 4
      messages/pt-PT/vehicles.json
  13. 16 4
      messages/ru/vehicles.json
  14. 16 4
      messages/tr/vehicles.json
  15. 181 0
      src/__tests__/app/vehicles/vehicle-detail-new-work-order.test.tsx
  16. 123 45
      src/__tests__/app/vehicles/vehicle-job-warnings-feed.test.tsx
  17. 59 0
      src/__tests__/features/vehicles/vehicle-page-viewed.test.tsx
  18. 120 0
      src/__tests__/lib/status-colors.test.ts
  19. 5 11
      src/app/(authenticated)/(dashboard)/dashboard-client.tsx
  20. 11 23
      src/app/(authenticated)/customers/[id]/customer-detail-client.tsx
  21. 5 15
      src/app/(authenticated)/inspections/inspections-client.tsx
  22. 8 16
      src/app/(authenticated)/quotes/quotes-client.tsx
  23. 14 2
      src/app/(authenticated)/vehicles/[id]/(detail)/page.tsx
  24. 6 11
      src/app/(authenticated)/vehicles/[id]/_classic/service-records-table.tsx
  25. 8 19
      src/app/(authenticated)/vehicles/[id]/_classic/vehicle-detail-classic.tsx
  26. 21 12
      src/app/(authenticated)/vehicles/[id]/findings-table.tsx
  27. 21 107
      src/app/(authenticated)/vehicles/[id]/job-warning.tsx
  28. 20 15
      src/app/(authenticated)/vehicles/[id]/notes-table.tsx
  29. 305 360
      src/app/(authenticated)/vehicles/[id]/vehicle-activity-feed.tsx
  30. 892 526
      src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx
  31. 5 24
      src/app/(authenticated)/work-orders/work-orders-client.tsx
  32. 3 2
      src/app/(public)/portal/[orgId]/dashboard/page.tsx
  33. 5 8
      src/app/(public)/portal/[orgId]/inspections/page.tsx
  34. 5 21
      src/app/(public)/portal/[orgId]/quotes/page.tsx
  35. 59 0
      src/components/status-badge.tsx
  36. 8 27
      src/components/status-lamp.tsx
  37. 3 7
      src/features/quotes/Components/QuotePageClient.tsx
  38. 0 10
      src/features/quotes/Components/quote-page-types.ts
  39. 3 16
      src/features/tire-hotel/Components/TireJobsCard.tsx
  40. 132 0
      src/features/vehicles/Actions/getVehicleGlance.ts
  41. 6 1
      src/features/vehicles/Actions/serviceActions.ts
  42. 3 7
      src/features/vehicles/Components/MyActiveJobs.tsx
  43. 28 0
      src/features/vehicles/Components/VehiclePageLayoutSwitch.tsx
  44. 0 9
      src/features/vehicles/Components/service-detail/types.ts
  45. 4 1
      src/features/vehicles/Components/service-page/modern/ModernHero.tsx
  46. 1 1
      src/features/workboard/utils/job-colors.ts
  47. 6 0
      src/lib/analytics.ts
  48. 99 0
      src/lib/status-colors.ts
  49. 0 7
      src/lib/table-utils.ts
  50. 14 4
      src/lib/vehicle-page-layout.server.ts

+ 2 - 2
e2e/specs/settings/marine.spec.ts

@@ -192,8 +192,8 @@ test.describe('a workshop that picks Marine', () => {
   test('counts a vessel in engine hours, from its page to its invoice', async ({ page }) => {
     vesselUrl = await seededVehicleUrl(page, VESSEL.model)
     await settle(page)
-    await expect(page.getByText('1,234', { exact: true }).first()).toBeVisible()
-    await expect(page.getByText('hrs', { exact: true }).first()).toBeVisible()
+    // The reading and its unit, as the vehicle's overview says them.
+    await expect(page.getByText('1,234 hrs', { exact: true }).first()).toBeVisible()
 
     const jobUrl = await newWorkOrder(page, vesselUrl, `E2E Vessel service ${stamp}`)
     const hours = page.locator('#mileage')

+ 16 - 4
messages/de/vehicles.json

@@ -114,24 +114,36 @@
     "purchasedOn": "Gekauft am",
     "spent": "Ausgegeben",
     "spentOver": "{count, plural, =0 {noch keine Besuche} one {in # Besuch} other {in # Besuchen}}",
-    "openItems": "Offen",
+    "openItems": "Offene Punkte",
+    "glance": "Auf einen Blick",
+    "lastVisit": "Letzter Besuch",
+    "nextBooking": "Nächster Termin",
+    "inspectionDueLabel": "Prüfung fällig",
+    "otherVehicles": "Weitere Fahrzeuge: {count}",
+    "otherVehiclesMarine": "Weitere Boote: {count}",
+    "vehicleFacts": "Details",
     "addOpenItem": "Hinzufügen",
+    "addQuote": "Angebot hinzufügen",
+    "addInspection": "Inspektion hinzufügen",
     "nothingOpen": "Nichts offen",
     "observationItem": "Beobachtung · {severity}",
     "reminderItem": "Erinnerung · {due}",
-    "newMenu": "Neu",
+    "moreToAdd": "Weiteres hinzufügen",
     "closePanel": "Schließen",
     "feed": {
       "all": "Alles",
       "workOrders": "Arbeitsaufträge",
       "search": "Verlauf durchsuchen",
-      "now": "Jetzt",
-      "openJob": "Auftrag öffnen",
+      "open": "Offene Aufträge",
+      "showMoreOpen": "{count} weitere anzeigen",
+      "colJob": "Auftrag und ausgeführte Arbeiten",
+      "partlyPaid": "Teilweise bezahlt",
       "yearSummary": "{count, plural, one {# Besuch} other {# Besuche}} · {total}",
       "showOlder": "{count} ältere anzeigen",
       "empty": "Noch kein Verlauf",
       "noMatches": "Nichts entspricht Ihrer Suche",
       "quote": "Angebot",
+      "inspection": "Inspektion",
       "inspectionOpen": "In Bearbeitung",
       "inspectionResult": "{passed} bestanden",
       "inspectionFailed": "{count} mit Handlungsbedarf"

+ 16 - 4
messages/en/vehicles.json

@@ -114,24 +114,36 @@
     "purchasedOn": "Purchased",
     "spent": "Spent",
     "spentOver": "over {count, plural, =0 {no visits} one {# visit} other {# visits}}",
-    "openItems": "Open",
+    "openItems": "To follow up",
+    "glance": "At a glance",
+    "lastVisit": "Last visit",
+    "nextBooking": "Next booking",
+    "inspectionDueLabel": "Inspection due",
+    "otherVehicles": "Other vehicles: {count}",
+    "otherVehiclesMarine": "Other boats: {count}",
+    "vehicleFacts": "Details",
     "addOpenItem": "Add",
+    "addQuote": "Add Quote",
+    "addInspection": "Add Inspection",
     "nothingOpen": "Nothing open",
     "observationItem": "Observation · {severity}",
     "reminderItem": "Reminder · {due}",
-    "newMenu": "New",
+    "moreToAdd": "More to add",
     "closePanel": "Close",
     "feed": {
       "all": "Everything",
       "workOrders": "Work orders",
       "search": "Search history",
-      "now": "Now",
-      "openJob": "Open job",
+      "open": "Open jobs",
+      "showMoreOpen": "Show {count} more",
+      "colJob": "Job and what was done",
+      "partlyPaid": "Partly paid",
       "yearSummary": "{count, plural, one {# visit} other {# visits}} · {total}",
       "showOlder": "Show {count} older",
       "empty": "No history yet",
       "noMatches": "Nothing matches your search",
       "quote": "Quote",
+      "inspection": "Inspection",
       "inspectionOpen": "In progress",
       "inspectionResult": "{passed} passed",
       "inspectionFailed": "{count} need work"

+ 16 - 4
messages/es/vehicles.json

@@ -114,24 +114,36 @@
     "purchasedOn": "Comprado el",
     "spent": "Gastado",
     "spentOver": "{count, plural, =0 {sin visitas} one {en # visita} other {en # visitas}}",
-    "openItems": "Pendientes",
+    "openItems": "Pendiente de seguimiento",
+    "glance": "De un vistazo",
+    "lastVisit": "Última visita",
+    "nextBooking": "Próxima cita",
+    "inspectionDueLabel": "Inspección pendiente",
+    "otherVehicles": "Otros vehículos: {count}",
+    "otherVehiclesMarine": "Otras embarcaciones: {count}",
+    "vehicleFacts": "Detalles",
     "addOpenItem": "Agregar",
+    "addQuote": "Agregar cotización",
+    "addInspection": "Agregar inspección",
     "nothingOpen": "Nada pendiente",
     "observationItem": "Observación · {severity}",
     "reminderItem": "Recordatorio · {due}",
-    "newMenu": "Nuevo",
+    "moreToAdd": "Añadir más",
     "closePanel": "Cerrar",
     "feed": {
       "all": "Todo",
       "workOrders": "Órdenes de trabajo",
       "search": "Buscar en el historial",
-      "now": "Ahora",
-      "openJob": "Abrir trabajo",
+      "open": "Trabajos abiertos",
+      "showMoreOpen": "Mostrar {count} más",
+      "colJob": "Trabajo y qué se hizo",
+      "partlyPaid": "Pago parcial",
       "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",
+      "inspection": "Inspección",
       "inspectionOpen": "En progreso",
       "inspectionResult": "{passed, plural, one {# apto} other {# aptos}}",
       "inspectionFailed": "{count, plural, one {# requiere atención} other {# requieren atención}}"

+ 16 - 4
messages/fr/vehicles.json

@@ -114,24 +114,36 @@
     "purchasedOn": "Acheté le",
     "spent": "Dépensé",
     "spentOver": "{count, plural, =0 {aucune visite} one {en # visite} other {en # visites}}",
-    "openItems": "À traiter",
+    "openItems": "À suivre",
+    "glance": "En un coup d'œil",
+    "lastVisit": "Dernière visite",
+    "nextBooking": "Prochain rendez-vous",
+    "inspectionDueLabel": "Contrôle à effectuer",
+    "otherVehicles": "Autres véhicules : {count}",
+    "otherVehiclesMarine": "Autres bateaux : {count}",
+    "vehicleFacts": "Détails",
     "addOpenItem": "Ajouter",
+    "addQuote": "Ajouter un devis",
+    "addInspection": "Ajouter une inspection",
     "nothingOpen": "Rien à traiter",
     "observationItem": "Observation · {severity}",
     "reminderItem": "Rappel · {due}",
-    "newMenu": "Nouveau",
+    "moreToAdd": "Ajouter autre chose",
     "closePanel": "Fermer",
     "feed": {
       "all": "Tout",
       "workOrders": "Ordres de travail",
       "search": "Rechercher dans l'historique",
-      "now": "En ce moment",
-      "openJob": "Ouvrir le travail",
+      "open": "Travaux en cours",
+      "showMoreOpen": "Afficher {count} de plus",
+      "colJob": "Intervention et travaux effectués",
+      "partlyPaid": "Partiellement payé",
       "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",
+      "inspection": "Inspection",
       "inspectionOpen": "En cours",
       "inspectionResult": "{passed, plural, one {# conforme} other {# conformes}}",
       "inspectionFailed": "{count} à traiter"

+ 16 - 4
messages/it/vehicles.json

@@ -114,24 +114,36 @@
     "purchasedOn": "Acquistato il",
     "spent": "Speso",
     "spentOver": "{count, plural, =0 {nessuna visita} one {in # visita} other {in # visite}}",
-    "openItems": "In sospeso",
+    "openItems": "Da seguire",
+    "glance": "In breve",
+    "lastVisit": "Ultima visita",
+    "nextBooking": "Prossimo appuntamento",
+    "inspectionDueLabel": "Scadenza revisione",
+    "otherVehicles": "Altri veicoli: {count}",
+    "otherVehiclesMarine": "Altre imbarcazioni: {count}",
+    "vehicleFacts": "Dettagli",
     "addOpenItem": "Aggiungi",
+    "addQuote": "Aggiungi preventivo",
+    "addInspection": "Aggiungi ispezione",
     "nothingOpen": "Niente in sospeso",
     "observationItem": "Osservazione · {severity}",
     "reminderItem": "Promemoria · {due}",
-    "newMenu": "Nuovo",
+    "moreToAdd": "Aggiungi altro",
     "closePanel": "Chiudi",
     "feed": {
       "all": "Tutto",
       "workOrders": "Ordini di lavoro",
       "search": "Cerca nella cronologia",
-      "now": "Adesso",
-      "openJob": "Apri lavoro",
+      "open": "Lavori aperti",
+      "showMoreOpen": "Mostra altri {count}",
+      "colJob": "Lavoro e cosa è stato fatto",
+      "partlyPaid": "Pagato in parte",
       "yearSummary": "{count, plural, one {# visita} other {# visite}} · {total}",
       "showOlder": "Mostra {count} precedenti",
       "empty": "Nessuna cronologia ancora",
       "noMatches": "Nessun risultato per questa ricerca",
       "quote": "Preventivo",
+      "inspection": "Ispezione",
       "inspectionOpen": "In corso",
       "inspectionResult": "{passed, plural, one {# superato} other {# superati}}",
       "inspectionFailed": "{count} da sistemare"

+ 16 - 4
messages/lt/vehicles.json

@@ -114,24 +114,36 @@
     "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",
+    "openItems": "Reikia dėmesio",
+    "glance": "Trumpai",
+    "lastVisit": "Paskutinis apsilankymas",
+    "nextBooking": "Kitas vizitas",
+    "inspectionDueLabel": "Apžiūros terminas",
+    "otherVehicles": "Kitos transporto priemonės: {count}",
+    "otherVehiclesMarine": "Kiti laivai: {count}",
+    "vehicleFacts": "Išsami informacija",
     "addOpenItem": "Pridėti",
+    "addQuote": "Pridėti pasiūlymą",
+    "addInspection": "Pridėti apžiūrą",
     "nothingOpen": "Atvirų nėra",
     "observationItem": "Pastebėjimas · {severity}",
     "reminderItem": "Priminimas · {due}",
-    "newMenu": "Naujas",
+    "moreToAdd": "Pridėti daugiau",
     "closePanel": "Uždaryti",
     "feed": {
       "all": "Viskas",
       "workOrders": "Darbo užsakymai",
       "search": "Ieškoti istorijoje",
-      "now": "Dabar",
-      "openJob": "Atidaryti darbą",
+      "open": "Atviri darbai",
+      "showMoreOpen": "Rodyti dar {count}",
+      "colJob": "Darbas ir kas atlikta",
+      "partlyPaid": "Iš dalies apmokėta",
       "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",
+      "inspection": "Apžiūra",
       "inspectionOpen": "Vykdoma",
       "inspectionResult": "Tinkama: {passed}",
       "inspectionFailed": "Reikia dėmesio: {count}"

+ 16 - 4
messages/nb/vehicles.json

@@ -114,24 +114,36 @@
     "purchasedOn": "Kjøpt",
     "spent": "Brukt",
     "spentOver": "{count, plural, =0 {ingen besøk ennå} one {på # besøk} other {på # besøk}}",
-    "openItems": "Åpent",
+    "openItems": "Til oppfølging",
+    "glance": "Oversikt",
+    "lastVisit": "Siste besøk",
+    "nextBooking": "Neste time",
+    "inspectionDueLabel": "Frist for kontroll",
+    "otherVehicles": "Andre kjøretøy: {count}",
+    "otherVehiclesMarine": "Andre båter: {count}",
+    "vehicleFacts": "Detaljer",
     "addOpenItem": "Legg til",
+    "addQuote": "Legg til tilbud",
+    "addInspection": "Legg til inspeksjon",
     "nothingOpen": "Ingenting åpent",
     "observationItem": "Observasjon · {severity}",
     "reminderItem": "Påminnelse · {due}",
-    "newMenu": "Ny",
+    "moreToAdd": "Legg til mer",
     "closePanel": "Lukk",
     "feed": {
       "all": "Alt",
       "workOrders": "Arbeidsordrer",
       "search": "Søk i historikken",
-      "now": "Nå",
-      "openJob": "Åpne jobben",
+      "open": "Åpne jobber",
+      "showMoreOpen": "Vis {count} til",
+      "colJob": "Jobb og hva som ble gjort",
+      "partlyPaid": "Delvis betalt",
       "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",
+      "inspection": "Inspeksjon",
       "inspectionOpen": "Under arbeid",
       "inspectionResult": "{passed} godkjent",
       "inspectionFailed": "{count} trenger oppfølging"

+ 16 - 4
messages/nl/vehicles.json

@@ -114,24 +114,36 @@
     "purchasedOn": "Gekocht op",
     "spent": "Besteed",
     "spentOver": "{count, plural, =0 {nog geen bezoeken} one {in # bezoek} other {in # bezoeken}}",
-    "openItems": "Open",
+    "openItems": "Op te volgen",
+    "glance": "In één oogopslag",
+    "lastVisit": "Laatste bezoek",
+    "nextBooking": "Volgende afspraak",
+    "inspectionDueLabel": "Keuring vervalt",
+    "otherVehicles": "Andere voertuigen: {count}",
+    "otherVehiclesMarine": "Andere boten: {count}",
+    "vehicleFacts": "Details",
     "addOpenItem": "Toevoegen",
+    "addQuote": "Offerte toevoegen",
+    "addInspection": "Keuring toevoegen",
     "nothingOpen": "Niets open",
     "observationItem": "Observatie · {severity}",
     "reminderItem": "Herinnering · {due}",
-    "newMenu": "Nieuw",
+    "moreToAdd": "Meer toevoegen",
     "closePanel": "Sluiten",
     "feed": {
       "all": "Alles",
       "workOrders": "Werkorders",
       "search": "Geschiedenis doorzoeken",
-      "now": "Nu",
-      "openJob": "Opdracht openen",
+      "open": "Openstaande klussen",
+      "showMoreOpen": "Nog {count} tonen",
+      "colJob": "Klus en wat er is gedaan",
+      "partlyPaid": "Deels betaald",
       "yearSummary": "{count, plural, one {# bezoek} other {# bezoeken}} · {total}",
       "showOlder": "{count} oudere tonen",
       "empty": "Nog geen geschiedenis",
       "noMatches": "Niets gevonden voor uw zoekopdracht",
       "quote": "Offerte",
+      "inspection": "Keuring",
       "inspectionOpen": "Bezig",
       "inspectionResult": "{passed} goedgekeurd",
       "inspectionFailed": "{count, plural, one {# heeft aandacht nodig} other {# hebben aandacht nodig}}"

+ 16 - 4
messages/pl/vehicles.json

@@ -114,24 +114,36 @@
     "purchasedOn": "Zakupiony",
     "spent": "Wydano",
     "spentOver": "{count, plural, =0 {brak wizyt} one {w # wizycie} few {w # wizytach} many {w # wizytach} other {w # wizyty}}",
-    "openItems": "Otwarte",
+    "openItems": "Do dopilnowania",
+    "glance": "W skrócie",
+    "lastVisit": "Ostatnia wizyta",
+    "nextBooking": "Następna wizyta",
+    "inspectionDueLabel": "Termin przeglądu",
+    "otherVehicles": "Inne pojazdy: {count}",
+    "otherVehiclesMarine": "Inne łodzie: {count}",
+    "vehicleFacts": "Szczegóły",
     "addOpenItem": "Dodaj",
+    "addQuote": "Dodaj wycenę",
+    "addInspection": "Dodaj przegląd",
     "nothingOpen": "Brak otwartych",
     "observationItem": "Obserwacja · {severity}",
     "reminderItem": "Przypomnienie · {due}",
-    "newMenu": "Nowe",
+    "moreToAdd": "Dodaj więcej",
     "closePanel": "Zamknij",
     "feed": {
       "all": "Wszystko",
       "workOrders": "Zlecenia",
       "search": "Szukaj w historii",
-      "now": "Teraz",
-      "openJob": "Otwórz zlecenie",
+      "open": "Otwarte zlecenia",
+      "showMoreOpen": "Pokaż jeszcze {count}",
+      "colJob": "Zlecenie i co wykonano",
+      "partlyPaid": "Częściowo opłacone",
       "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",
+      "inspection": "Przegląd",
       "inspectionOpen": "W trakcie",
       "inspectionResult": "Zaliczone: {passed}",
       "inspectionFailed": "{count, plural, one {# wymaga uwagi} few {# wymagają uwagi} many {# wymaga uwagi} other {# wymaga uwagi}}"

+ 16 - 4
messages/pt-BR/vehicles.json

@@ -114,24 +114,36 @@
     "purchasedOn": "Adquirido em",
     "spent": "Gasto",
     "spentOver": "{count, plural, =0 {nenhuma visita} one {em # visita} other {em # visitas}}",
-    "openItems": "Pendentes",
+    "openItems": "Para acompanhar",
+    "glance": "Resumo",
+    "lastVisit": "Última visita",
+    "nextBooking": "Próximo agendamento",
+    "inspectionDueLabel": "Vencimento da inspeção",
+    "otherVehicles": "Outros veículos: {count}",
+    "otherVehiclesMarine": "Outras embarcações: {count}",
+    "vehicleFacts": "Detalhes",
     "addOpenItem": "Adicionar",
+    "addQuote": "Adicionar orçamento",
+    "addInspection": "Adicionar inspeção",
     "nothingOpen": "Nada pendente",
     "observationItem": "Observação · {severity}",
     "reminderItem": "Lembrete · {due}",
-    "newMenu": "Novo",
+    "moreToAdd": "Adicionar mais",
     "closePanel": "Fechar",
     "feed": {
       "all": "Tudo",
       "workOrders": "Ordens de serviço",
       "search": "Buscar no histórico",
-      "now": "Agora",
-      "openJob": "Abrir trabalho",
+      "open": "Serviços em aberto",
+      "showMoreOpen": "Mostrar mais {count}",
+      "colJob": "Serviço e o que foi feito",
+      "partlyPaid": "Pago em parte",
       "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",
+      "inspection": "Inspeção",
       "inspectionOpen": "Em andamento",
       "inspectionResult": "{passed, plural, one {# aprovado} other {# aprovados}}",
       "inspectionFailed": "{count, plural, one {# precisa de atenção} other {# precisam de atenção}}"

+ 16 - 4
messages/pt-PT/vehicles.json

@@ -114,24 +114,36 @@
     "purchasedOn": "Adquirida a",
     "spent": "Gasto",
     "spentOver": "{count, plural, =0 {nenhuma visita} one {em # visita} other {em # visitas}}",
-    "openItems": "Pendentes",
+    "openItems": "A acompanhar",
+    "glance": "Resumo",
+    "lastVisit": "Última visita",
+    "nextBooking": "Próxima marcação",
+    "inspectionDueLabel": "Inspeção até",
+    "otherVehicles": "Outros veículos: {count}",
+    "otherVehiclesMarine": "Outras embarcações: {count}",
+    "vehicleFacts": "Detalhes",
     "addOpenItem": "Adicionar",
+    "addQuote": "Adicionar orçamento",
+    "addInspection": "Adicionar inspeção",
     "nothingOpen": "Nada pendente",
     "observationItem": "Observação · {severity}",
     "reminderItem": "Lembrete · {due}",
-    "newMenu": "Novo",
+    "moreToAdd": "Adicionar mais",
     "closePanel": "Fechar",
     "feed": {
       "all": "Tudo",
       "workOrders": "Ordens de serviço",
       "search": "Pesquisar no histórico",
-      "now": "Agora",
-      "openJob": "Abrir trabalho",
+      "open": "Trabalhos em aberto",
+      "showMoreOpen": "Mostrar mais {count}",
+      "colJob": "Trabalho e o que foi feito",
+      "partlyPaid": "Pago em parte",
       "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",
+      "inspection": "Inspeção",
       "inspectionOpen": "Em curso",
       "inspectionResult": "{passed, plural, one {# aprovado} other {# aprovados}}",
       "inspectionFailed": "{count, plural, one {# precisa de atenção} other {# precisam de atenção}}"

+ 16 - 4
messages/ru/vehicles.json

@@ -114,24 +114,36 @@
     "purchasedOn": "Куплено",
     "spent": "Потрачено",
     "spentOver": "{count, plural, =0 {визитов пока нет} one {за # визит} few {за # визита} many {за # визитов} other {за # визита}}",
-    "openItems": "Открытые",
+    "openItems": "Требует внимания",
+    "glance": "Кратко",
+    "lastVisit": "Последний визит",
+    "nextBooking": "Следующая запись",
+    "inspectionDueLabel": "Срок техосмотра",
+    "otherVehicles": "Другие транспортные средства: {count}",
+    "otherVehiclesMarine": "Другие лодки: {count}",
+    "vehicleFacts": "Сведения",
     "addOpenItem": "Добавить",
+    "addQuote": "Добавить предложение",
+    "addInspection": "Добавить осмотр",
     "nothingOpen": "Открытых нет",
     "observationItem": "Наблюдение · {severity}",
     "reminderItem": "Напоминание · {due}",
-    "newMenu": "Создать",
+    "moreToAdd": "Добавить ещё",
     "closePanel": "Закрыть",
     "feed": {
       "all": "Всё",
       "workOrders": "Заказ-наряды",
       "search": "Поиск по истории",
-      "now": "Сейчас",
-      "openJob": "Открыть заказ-наряд",
+      "open": "Открытые работы",
+      "showMoreOpen": "Показать ещё {count}",
+      "colJob": "Работа и что сделано",
+      "partlyPaid": "Оплачено частично",
       "yearSummary": "{count, plural, one {# визит} few {# визита} many {# визитов} other {# визита}} · {total}",
       "showOlder": "Показать более ранние ({count})",
       "empty": "Истории пока нет",
       "noMatches": "Ничего не найдено по запросу",
       "quote": "Предложение",
+      "inspection": "Осмотр",
       "inspectionOpen": "В процессе",
       "inspectionResult": "Пройдено: {passed}",
       "inspectionFailed": "{count, plural, one {# требует внимания} few {# требуют внимания} many {# требуют внимания} other {# требуют внимания}}"

+ 16 - 4
messages/tr/vehicles.json

@@ -114,24 +114,36 @@
     "purchasedOn": "Alış tarihi",
     "spent": "Harcanan",
     "spentOver": "{count, plural, =0 {henüz ziyaret yok} one {# ziyarette} other {# ziyarette}}",
-    "openItems": "Açık",
+    "openItems": "Takip edilecekler",
+    "glance": "Bir bakışta",
+    "lastVisit": "Son ziyaret",
+    "nextBooking": "Sonraki randevu",
+    "inspectionDueLabel": "Muayene tarihi",
+    "otherVehicles": "Diğer araçlar: {count}",
+    "otherVehiclesMarine": "Diğer tekneler: {count}",
+    "vehicleFacts": "Ayrıntılar",
     "addOpenItem": "Ekle",
+    "addQuote": "Teklif ekle",
+    "addInspection": "Muayene ekle",
     "nothingOpen": "Açık kayıt yok",
     "observationItem": "Gözlem · {severity}",
     "reminderItem": "Hatırlatıcı · {due}",
-    "newMenu": "Yeni",
+    "moreToAdd": "Daha fazla ekle",
     "closePanel": "Kapat",
     "feed": {
       "all": "Tümü",
       "workOrders": "İş emirleri",
       "search": "Geçmişte ara",
-      "now": "Şu an",
-      "openJob": "İşi aç",
+      "open": "Açık işler",
+      "showMoreOpen": "{count} tane daha göster",
+      "colJob": "İş ve yapılanlar",
+      "partlyPaid": "Kısmen ödendi",
       "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",
+      "inspection": "Muayene",
       "inspectionOpen": "Devam ediyor",
       "inspectionResult": "{passed} geçti",
       "inspectionFailed": "{count} ilgi gerektiriyor"

+ 181 - 0
src/__tests__/app/vehicles/vehicle-detail-new-work-order.test.tsx

@@ -0,0 +1,181 @@
+/**
+ * Starting work from the vehicle page: a work order is one click, and the
+ * other things to add (quote, inspection, note, observation, reminder) wait
+ * behind the arrow beside it. Over the notes, observations or reminders the
+ * same button adds one of those, so there is one place to add from and never
+ * a second button under it. An archived vehicle offers neither.
+ */
+import { fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+/** The page's query string: which list is showing. */
+const url = vi.hoisted(() => ({ query: '' }))
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ push: vi.fn(), refresh: vi.fn(), replace: vi.fn() }),
+  useSearchParams: () => new URLSearchParams(url.query),
+  usePathname: () => '/vehicles/v1',
+}))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/features/vehicles/Actions/dismissMaintenance', () => ({
+  dismissMaintenance: vi.fn(),
+  undismissMaintenance: vi.fn(),
+}))
+vi.mock('@/features/vehicles/Actions/serviceActions', () => ({ createServiceRecord: vi.fn() }))
+vi.mock('@/features/vehicles/Actions/findingActions', () => ({ deleteFinding: vi.fn() }))
+vi.mock('@/features/vehicles/Actions/noteActions', () => ({
+  deleteNote: vi.fn(),
+  toggleNotePin: vi.fn(),
+}))
+vi.mock('@/features/vehicles/Actions/reminderActions', () => ({
+  toggleReminder: vi.fn(),
+  deleteReminder: vi.fn(),
+}))
+vi.mock('@/features/vehicles/Actions/unarchiveVehicle', () => ({ unarchiveVehicle: vi.fn() }))
+vi.mock('@/features/vehicles/Actions/deleteVehicle', () => ({ deleteVehicle: vi.fn() }))
+vi.mock('@/features/ai/Actions/aiActions', () => ({
+  aiSummarizeVehicleHistory: vi.fn(),
+  aiGetCommonIssues: vi.fn(),
+  aiClearMessage: vi.fn(),
+}))
+// Screens and dialogs of their own.
+vi.mock('@/features/vehicles/Components/VehicleForm', () => ({ VehicleForm: () => null }))
+vi.mock('@/features/vehicles/Components/NoteForm', () => ({ NoteForm: () => null }))
+vi.mock('@/features/vehicles/Components/ReminderForm', () => ({ ReminderForm: () => null }))
+vi.mock('@/features/vehicles/Components/FindingForm', () => ({ FindingForm: () => null }))
+vi.mock('@/features/vehicles/Components/VehicleSafetyPanel', () => ({
+  VehicleSafetyPanel: () => null,
+}))
+vi.mock('@/features/vehicles/Components/ArchiveVehicleDialog', () => ({
+  ArchiveVehicleDialog: () => null,
+}))
+vi.mock('@/features/tire-hotel/Components/VehicleTireSets', () => ({ VehicleTireSets: () => null }))
+vi.mock('@/features/ai/Components/AskAiSheet', () => ({ AskAiSheet: () => null }))
+vi.mock('@/features/inspections/Components/NewInspectionDialog', () => ({
+  NewInspectionDialog: () => null,
+}))
+vi.mock('@/features/quotes/Components/NewQuoteDialog', () => ({ NewQuoteDialog: () => 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]/findings-table', () => ({ FindingsTable: () => null }))
+
+import { VehicleDetailClient } from '@/app/(authenticated)/vehicles/[id]/vehicle-detail-client'
+
+const VEHICLE = {
+  id: 'v1',
+  make: 'Volvo',
+  model: 'V70',
+  year: 2019,
+  vin: null,
+  licensePlate: 'EL12345',
+  color: null,
+  mileage: 120000,
+  fuelType: null,
+  transmission: null,
+  engineSize: null,
+  engineCode: null,
+  hsn: null,
+  tsn: null,
+  purchaseDate: null,
+  purchasePrice: null,
+  imageUrl: null,
+  isArchived: false,
+  archiveReason: null,
+  customerId: null,
+  customer: null,
+  serviceRecords: [],
+  reminders: [],
+  aiMessages: [],
+  _count: { serviceRecords: 0, notes: 0, reminders: 0, findings: 0 },
+}
+
+const empty = { records: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }
+
+function renderPage(vehicle = VEHICLE) {
+  render(
+    <VehicleDetailClient
+      vehicle={vehicle}
+      paginatedServices={empty}
+      paginatedNotes={empty}
+      paginatedFindings={empty}
+      serviceSearch=""
+      serviceRecordType=""
+      unitSystem="metric"
+      predictionData={null}
+    />
+  )
+}
+
+afterEach(() => {
+  url.query = ''
+})
+
+describe('starting work from the vehicle page', () => {
+  it('opens a new work order for this vehicle in one click', () => {
+    renderPage()
+    expect(screen.getByRole('link', { name: 'New Work Order' })).toHaveAttribute(
+      'href',
+      '/vehicles/v1/service/new'
+    )
+  })
+
+  it('keeps everything else to add behind the arrow beside it', async () => {
+    renderPage()
+    fireEvent.keyDown(screen.getByRole('button', { name: 'More to add' }), { key: 'Enter' })
+
+    const items = await screen.findAllByRole('menuitem')
+    expect(items.map((item) => item.textContent)).toEqual([
+      'Add Quote',
+      'Add Inspection',
+      'Add Note',
+      'Add Observation',
+      'Add Reminder',
+    ])
+  })
+
+  it.each([
+    ['all', 'New Work Order'],
+    ['services', 'New Work Order'],
+    ['quotes', 'Add Quote'],
+    ['inspections', 'Add Inspection'],
+    ['notes', 'Add Note'],
+    ['findings', 'Add Observation'],
+    ['reminders', 'Add Reminder'],
+  ])('offers to add what the %s list holds: %s', (tab, label) => {
+    url.query = tab === 'all' ? '' : `tab=${tab}`
+    renderPage()
+    // The button beside the arrow, whether it is a link or opens a dialog.
+    const group = screen.getByRole('button', { name: 'More to add' }).parentElement as HTMLElement
+    expect(group.firstElementChild).toHaveTextContent(label)
+  })
+
+  it.each([
+    ['quotes', 'Add Quote'],
+    ['inspections', 'Add Inspection'],
+    ['notes', 'Add Note'],
+    ['findings', 'Add Observation'],
+    ['reminders', 'Add Reminder'],
+  ])('adds to the %s list from the same button, and only from it', async (tab, label) => {
+    url.query = `tab=${tab}`
+    renderPage()
+
+    // One button for it, where the work order button is on the other lists.
+    expect(screen.getAllByRole('button', { name: label })).toHaveLength(1)
+    expect(screen.queryByRole('link', { name: 'New Work Order' })).not.toBeInTheDocument()
+
+    // The work order moves behind the arrow, and what the button already
+    // does is not offered there a second time.
+    fireEvent.keyDown(screen.getByRole('button', { name: 'More to add' }), { key: 'Enter' })
+    const items = (await screen.findAllByRole('menuitem')).map((item) => item.textContent)
+    expect(items).toContain('New Work Order')
+    expect(items).not.toContain(label)
+  })
+
+  it('offers neither on an archived vehicle', () => {
+    renderPage({ ...VEHICLE, isArchived: true, archiveReason: null })
+    expect(screen.queryByRole('link', { name: 'New Work Order' })).not.toBeInTheDocument()
+    expect(screen.queryByRole('button', { name: 'More to add' })).not.toBeInTheDocument()
+  })
+})

+ 123 - 45
src/__tests__/app/vehicles/vehicle-job-warnings-feed.test.tsx

@@ -1,7 +1,7 @@
 /**
- * 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.
+ * The vehicle page lists every unfinished job above the finished ones, and a
+ * job whose booking will not work says what is wrong on its own row, in the
+ * work order's words. The row itself is the way into the job to fix it.
  */
 
 import { describe, expect, it } from 'vitest'
@@ -64,7 +64,11 @@ const warning = (over: Partial<VehicleJobWarning>): VehicleJobWarning => ({
   ...over,
 })
 
-function renderFeed(jobWarnings: VehicleJobWarning[], workOrders = [inWorkshop, later, done]) {
+function renderFeed(
+  jobWarnings: VehicleJobWarning[],
+  workOrders = [inWorkshop, later, done],
+  owed: Record<string, 'unpaid' | 'partial'> = {}
+) {
   return render(
     <TooltipProvider>
       <VehicleActivityFeed
@@ -74,19 +78,23 @@ function renderFeed(jobWarnings: VehicleJobWarning[], workOrders = [inWorkshop,
         workOrders={workOrders}
         quotes={[]}
         inspections={[]}
-        notes={[]}
         distUnit="km"
+        readingLabel="Mileage"
         currencyCode="NOK"
         vehicleMileage={0}
         jobWarnings={jobWarnings}
-        onSelectNote={() => undefined}
+        owed={owed}
       />
     </TooltipProvider>
   )
 }
 
-describe('booking warnings in the vehicle feed', () => {
-  it('spells out the problem on the current job, with a way in to fix it', () => {
+/** The table row a job is on, found by the job's title. */
+const rowOf = (title: string) =>
+  screen.getByRole('link', { name: title }).closest('li') as HTMLElement
+
+describe('the jobs table on the vehicle page', () => {
+  it('says what is wrong on the row of the job in the workshop', () => {
     renderFeed([
       warning({
         serviceRecordId: 'wo-now',
@@ -102,44 +110,19 @@ describe('booking warnings in the vehicle feed', () => {
       }),
     ])
 
-    const callout = screen.getByTestId('job-warning-callout')
-    expect(within(callout).getByText('This booking needs attention')).toBeInTheDocument()
+    const row = rowOf('Brake service')
     expect(
-      within(callout).getByText('Ola Nordmann: Away (On leave) · Nov 16 – Nov 17')
+      within(row).getByText('Ola Nordmann: Away (On leave) · Nov 16 – Nov 17')
     ).toBeInTheDocument()
-    expect(within(callout).getByRole('link', { name: /Open the job to fix it/ })).toHaveAttribute(
+    expect(within(row).getByText(/This booking needs attention/)).toBeInTheDocument()
+    expect(within(row).getByRole('link', { name: 'Brake service' })).toHaveAttribute(
       'href',
       '/vehicles/veh-1/service/wo-now'
     )
+    expect(within(rowOf('Tyre change')).queryByTestId('job-warning')).not.toBeInTheDocument()
   })
 
-  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', () => {
+  it('says every problem on a job that is not in the workshop yet', () => {
     renderFeed([
       warning({
         serviceRecordId: 'wo-later',
@@ -157,17 +140,112 @@ describe('booking warnings in the vehicle feed', () => {
       }),
     ])
 
-    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()
+    const said = within(rowOf('Tyre change')).getByTestId('job-warning')
+    expect(said).toHaveTextContent('Volvo XC60 AB12345')
+    expect(said).toHaveTextContent('The workshop is closed then: Staff day')
+    expect(within(rowOf('Brake service')).queryByTestId('job-warning')).not.toBeInTheDocument()
+  })
+
+  it('lists every unfinished job as open, and finished work under its year', () => {
+    renderFeed([])
+
+    const open = screen.getByRole('region', { name: 'Open jobs' })
+    expect(within(open).getByRole('link', { name: 'Brake service' })).toBeInTheDocument()
+    expect(within(open).getByRole('link', { name: 'Tyre change' })).toBeInTheDocument()
+    expect(within(open).queryByRole('link', { name: 'Oil change' })).not.toBeInTheDocument()
+
+    const year = screen.getByRole('region', { name: '2026' })
+    expect(within(year).getByRole('link', { name: 'Oil change' })).toBeInTheDocument()
+  })
+
+  it('says what was done on a job, and that it has not been paid', () => {
+    renderFeed(
+      [],
+      [
+        workOrder({
+          id: 'wo-done',
+          title: 'Service',
+          serviceDate: day(2),
+          totalAmount: 1200,
+          laborItems: [{ description: 'Oil and filter' }],
+          partItems: [{ name: '5W-30 6 l' }],
+        }),
+      ],
+      { 'wo-done': 'unpaid' }
+    )
+
+    const row = rowOf('Service')
+    expect(within(row).getByText(/Oil and filter · 5W-30 6 l/)).toBeInTheDocument()
+    expect(within(row).getByText('Unpaid')).toBeInTheDocument()
+  })
+
+  it('still says a job is unpaid when its booking has a problem too', () => {
+    const billed = workOrder({
+      id: 'wo-billed',
+      title: 'Clutch',
+      status: 'in-progress',
+      startDateTime: day(16),
+      totalAmount: 900,
+      laborItems: [{ description: 'Clutch kit' }],
+    })
+    renderFeed([warning({ serviceRecordId: 'wo-billed', closures: ['Staff day'] })], [billed], {
+      'wo-billed': 'unpaid',
+    })
+
+    const row = rowOf('Clutch')
+    expect(within(row).getByText('Unpaid')).toBeInTheDocument()
+    // The warning opens the job itself, so it can be hovered and clicked.
+    const said = within(row).getByTestId('job-warning')
+    expect(said).toHaveTextContent('The workshop is closed then: Staff day')
+    expect(said).toHaveAttribute('href', '/vehicles/veh-1/service/wo-billed')
+    expect(said).toHaveAttribute('title', expect.stringContaining('Staff day'))
+  })
+
+  it('says in words which rows are a quote or an inspection', () => {
+    render(
+      <TooltipProvider>
+        <VehicleActivityFeed
+          vehicleId="veh-1"
+          filter="all"
+          search=""
+          workOrders={[done]}
+          quotes={[
+            {
+              id: 'q1',
+              quoteNumber: 'QT-1002',
+              title: 'Rear brakes',
+              status: 'converted',
+              totalAmount: 2237,
+              createdAt: day(3),
+            },
+          ]}
+          inspections={[
+            {
+              id: 'i1',
+              status: 'in_progress',
+              createdAt: day(4),
+              completedAt: null,
+              template: { name: 'Vehicle check-in' },
+              items: [],
+            },
+          ]}
+          distUnit="km"
+          readingLabel="Mileage"
+          currencyCode="NOK"
+          vehicleMileage={0}
+        />
+      </TooltipProvider>
+    )
+
+    expect(within(rowOf('Rear brakes')).getByText('Quote')).toBeInTheDocument()
+    expect(within(rowOf('Vehicle check-in')).getByText('Inspection')).toBeInTheDocument()
+    // A work order is the table's default and carries no label.
+    expect(within(rowOf('Oil change')).queryByText('Quote')).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()
   })
 })

+ 59 - 0
src/__tests__/features/vehicles/vehicle-page-viewed.test.tsx

@@ -0,0 +1,59 @@
+/**
+ * Every vehicle page opened is counted with the layout it was drawn in, so
+ * the classic page can be retired once nobody is left on it.
+ */
+import { render } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const track = vi.hoisted(() => vi.fn())
+vi.mock('@/lib/analytics', () => ({ track }))
+vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn() }) }))
+
+import { VehiclePageViewed } from '@/features/vehicles/Components/VehiclePageLayoutSwitch'
+
+beforeEach(() => {
+  track.mockReset()
+})
+
+describe('counting vehicle page views by layout', () => {
+  it('says a workshop is on the classic page because nobody there has tried the new one', () => {
+    render(<VehiclePageViewed vehicleId="v1" layout="classic" chosen={false} />)
+    expect(track).toHaveBeenCalledTimes(1)
+    expect(track).toHaveBeenCalledWith('vehicle_page:page_view', {
+      layout: 'classic',
+      layout_chosen: false,
+    })
+  })
+
+  it('says when the classic page was picked on purpose', () => {
+    render(<VehiclePageViewed vehicleId="v1" layout="classic" chosen />)
+    expect(track).toHaveBeenCalledWith('vehicle_page:page_view', {
+      layout: 'classic',
+      layout_chosen: true,
+    })
+  })
+
+  it('counts one view while the page renders again for another of its lists', () => {
+    const { rerender } = render(<VehiclePageViewed vehicleId="v1" layout="modern" chosen={false} />)
+    rerender(<VehiclePageViewed vehicleId="v1" layout="modern" chosen={false} />)
+    expect(track).toHaveBeenCalledTimes(1)
+  })
+
+  it('counts another vehicle, and the page a switch of layout lands on', () => {
+    const { rerender } = render(
+      <VehiclePageViewed vehicleId="v1" layout="classic" chosen={false} />
+    )
+    rerender(<VehiclePageViewed vehicleId="v2" layout="classic" chosen={false} />)
+    rerender(<VehiclePageViewed vehicleId="v2" layout="modern" chosen />)
+    expect(track.mock.calls.map(([, properties]) => properties)).toEqual([
+      { layout: 'classic', layout_chosen: false },
+      { layout: 'classic', layout_chosen: false },
+      { layout: 'modern', layout_chosen: true },
+    ])
+  })
+
+  it('sends nothing that identifies the vehicle', () => {
+    render(<VehiclePageViewed vehicleId="v-secret" layout="modern" chosen={false} />)
+    expect(JSON.stringify(track.mock.calls)).not.toContain('v-secret')
+  })
+})

+ 120 - 0
src/__tests__/lib/status-colors.test.ts

@@ -0,0 +1,120 @@
+/**
+ * A status has one colour across the app. Work order, quote and inspection
+ * statuses are coloured in src/lib/status-colors.ts and drawn with
+ * StatusBadge; these tests fail when a screen starts keeping a colour table
+ * of its own again, which is how pending came to be yellow in one list,
+ * amber in another and grey on the vehicle page.
+ */
+import { readdirSync, readFileSync, statSync } from 'node:fs'
+import { join, relative } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import {
+  INSPECTION_STATUS_COLORS,
+  QUOTE_STATUS_COLORS,
+  statusBadgeClass,
+  statusColor,
+  WORK_ORDER_STATUS_COLORS,
+} from '@/lib/status-colors'
+import { statusBlockColor } from '@/features/workboard/utils/job-colors'
+
+const SRC = join(process.cwd(), 'src')
+
+/**
+ * Files that colour a status of some other thing, which happens to share a
+ * word with a work order or a quote. Each says what it colours; a new entry
+ * here needs the same.
+ */
+const OTHER_THINGS: Record<string, string> = {
+  'lib/status-colors.ts': 'the one table',
+  'features/workboard/utils/job-colors.ts': 'the work board blocks, held to the same hues below',
+  'features/tire-hotel/Lib/treatments.ts': 'a tyre treatment: pending, done, skipped',
+  'features/status-reports/Components/StatusReportList.tsx':
+    'a status report: draft, published, sent',
+  'app/(authenticated)/vehicles/inspection-reminders/[id]/campaign-detail.tsx':
+    'one send of a reminder campaign: sent, failed, scheduled',
+  'app/(authenticated)/vehicles/[id]/_classic/service-records-table.tsx':
+    'a warranty: active, expiring, expired',
+  'app/(public)/portal/[orgId]/vehicles/[vehicleId]/page.tsx':
+    'a warranty: active, expiring, expired',
+}
+
+const STATUS_WORDS = [
+  ...Object.keys(WORK_ORDER_STATUS_COLORS),
+  ...Object.keys(QUOTE_STATUS_COLORS),
+  ...Object.keys(INSPECTION_STATUS_COLORS),
+  'in_progress',
+  'waiting_parts',
+]
+
+/** `pending: 'bg-yellow-500/10 …'`: a status as a key, a Tailwind colour as its value. */
+const OWN_TABLE = new RegExp(
+  `^\\s*['"]?(${[...new Set(STATUS_WORDS)].join('|')})['"]?:\\s*['"\`][^'"\`]*\\b(bg|text|border)-[a-z]+-\\d`
+)
+
+function sourceFiles(dir: string): string[] {
+  return readdirSync(dir).flatMap((name) => {
+    const path = join(dir, name)
+    if (name === '__tests__' || name === 'generated' || name === 'node_modules') return []
+    if (statSync(path).isDirectory()) return sourceFiles(path)
+    return /\.tsx?$/.test(name) ? [path] : []
+  })
+}
+
+describe('status colours', () => {
+  it('are kept in one table and nowhere else', () => {
+    const offenders = sourceFiles(SRC).flatMap((path) => {
+      const file = relative(SRC, path)
+      if (OTHER_THINGS[file]) return []
+      return readFileSync(path, 'utf8')
+        .split('\n')
+        .flatMap((line, i) => (OWN_TABLE.test(line) ? [`${file}:${i + 1}  ${line.trim()}`] : []))
+    })
+    expect(offenders).toEqual([])
+  })
+
+  it('still names only files that exist', () => {
+    const files = new Set(sourceFiles(SRC).map((path) => relative(SRC, path)))
+    expect(Object.keys(OTHER_THINGS).filter((file) => !files.has(file))).toEqual([])
+  })
+
+  it('give every stored work order status a colour, however it is spelt', () => {
+    for (const status of ['pending', 'scheduled', 'in-progress', 'waiting-parts', 'completed']) {
+      expect(statusBadgeClass('workOrder', status), status).not.toBe('')
+    }
+    // Older rows and the tyre jobs spell these with underscores.
+    expect(statusBadgeClass('workOrder', 'in_progress')).toBe(
+      statusBadgeClass('workOrder', 'in-progress')
+    )
+    expect(statusBadgeClass('workOrder', 'waiting_parts')).toBe(
+      statusBadgeClass('workOrder', 'waiting-parts')
+    )
+    expect(statusBadgeClass('inspection', 'in_progress')).toBe(
+      statusBadgeClass('workOrder', 'in-progress')
+    )
+  })
+
+  it('give every quote status a colour', () => {
+    for (const status of [
+      'draft',
+      'sent',
+      'changes_requested',
+      'accepted',
+      'rejected',
+      'expired',
+      'converted',
+    ]) {
+      expect(statusBadgeClass('quote', status), status).not.toBe('')
+    }
+  })
+
+  it('leave a status nobody has coloured plain, rather than guess', () => {
+    expect(statusBadgeClass('workOrder', 'ready-for-pickup')).toBe('')
+  })
+
+  it('fill the work board blocks with the same hue as the badges', () => {
+    for (const status of Object.keys(WORK_ORDER_STATUS_COLORS)) {
+      const hue = statusColor('workOrder', status)?.hue
+      expect(statusBlockColor(status), status).toContain(`bg-${hue}-`)
+    }
+  })
+})

+ 5 - 11
src/app/(authenticated)/(dashboard)/dashboard-client.tsx

@@ -31,7 +31,7 @@ import { TableCellLink } from '@/components/table-cell-link'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 import { Switch } from '@/components/ui/switch'
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
-import { statusColors } from '@/lib/table-utils'
+import { StatusBadge } from '@/components/status-badge'
 import {
   AlertTriangle,
   ArrowRight,
@@ -1824,12 +1824,9 @@ export function DashboardClient({
                           >
                             <div className="flex items-start justify-between gap-3">
                               <span className="min-w-0 flex-1 truncate font-medium">{s.title}</span>
-                              <Badge
-                                variant="outline"
-                                className={`shrink-0 text-xs ${statusColors[s.status] || ''}`}
-                              >
+                              <StatusBadge kind="workOrder" status={s.status} className="shrink-0">
                                 {serviceLabel.status(s.status)}
-                              </Badge>
+                              </StatusBadge>
                             </div>
                             <p className="truncate text-xs text-muted-foreground">
                               {s.vehicle && (
@@ -1910,12 +1907,9 @@ export function DashboardClient({
                                         {navigatingId === s.id && (
                                           <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
                                         )}
-                                        <Badge
-                                          variant="outline"
-                                          className={`text-xs ${statusColors[s.status] || ''}`}
-                                        >
+                                        <StatusBadge kind="workOrder" status={s.status}>
                                           {serviceLabel.status(s.status)}
-                                        </Badge>
+                                        </StatusBadge>
                                       </span>
                                     </TableCell>
                                     <TableCell className="hidden md:table-cell text-sm text-muted-foreground">

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

@@ -9,7 +9,8 @@ import { useTranslations } from 'next-intl'
 import { Card, CardContent } from '@/components/ui/card'
 import { useFormatDate } from '@/lib/use-format-date'
 import { useFormatCurrency } from '@/components/currency-settings-context'
-import { statusColors } from '@/lib/table-utils'
+import { StatusBadge } from '@/components/status-badge'
+import type { StatusKind } from '@/lib/status-colors'
 import { effectiveInvoiceDate } from '@/lib/invoice-utils'
 import { Button } from '@/components/ui/button'
 import { AskAiSheet } from '@/features/ai/Components/AskAiSheet'
@@ -665,7 +666,7 @@ export function CustomerDetailClient({
         {activeTab === 'invoices' && canReadInvoices && (
           <DocumentList
             rows={invoiceRows}
-            statusStyles={statusColors}
+            statusKind="workOrder"
             statusLabel={serviceLabel.status}
             numberLabel={t('documentTable.invoiceNumber')}
             emptyLabel={t('noInvoices')}
@@ -679,7 +680,7 @@ export function CustomerDetailClient({
         {activeTab === 'quotes' && canReadQuotes && (
           <DocumentList
             rows={quoteRows}
-            statusStyles={quoteStatusColors}
+            statusKind="quote"
             statusLabel={quoteStatusLabel}
             numberLabel={t('documentTable.quoteNumber')}
             emptyLabel={t('noQuotes')}
@@ -755,17 +756,6 @@ export function CustomerDetailClient({
   )
 }
 
-// Quotes use their own status vocabulary, so they cannot share the work-order
-// palette in table-utils.
-const quoteStatusColors: Record<string, string> = {
-  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',
-}
-
 export interface DocumentRow {
   id: string
   href: string
@@ -861,7 +851,7 @@ export function visibleDocuments(
 /// whole org), so search and sort run in memory rather than round-tripping.
 function DocumentList({
   rows,
-  statusStyles,
+  statusKind,
   statusLabel,
   numberLabel,
   emptyLabel,
@@ -871,7 +861,8 @@ function DocumentList({
   onQueryChange,
 }: {
   rows: DocumentRow[]
-  statusStyles: Record<string, string>
+  /** Which statuses the rows carry, so each is drawn in that status's own colour. */
+  statusKind: StatusKind
   /** The status as the reader's language says it: invoices and quotes have their own. */
   statusLabel: (status: string) => string
   numberLabel: string
@@ -1011,9 +1002,9 @@ function DocumentList({
                   <span className="shrink-0 font-semibold">{formatCurrency(row.total)}</span>
                 </div>
                 <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
-                  <Badge variant="outline" className={`text-xs ${statusStyles[row.status] || ''}`}>
+                  <StatusBadge kind={statusKind} status={row.status}>
                     {statusLabel(row.status)}
-                  </Badge>
+                  </StatusBadge>
                   <span className="font-mono">{formatDate(row.date)}</span>
                   <span className="truncate">{displayVehicle(row.vehicle)}</span>
                 </div>
@@ -1063,12 +1054,9 @@ function DocumentList({
                       {displayVehicle(row.vehicle)}
                     </TableCell>
                     <TableCell>
-                      <Badge
-                        variant="outline"
-                        className={`text-xs ${statusStyles[row.status] || ''}`}
-                      >
+                      <StatusBadge kind={statusKind} status={row.status}>
                         {statusLabel(row.status)}
-                      </Badge>
+                      </StatusBadge>
                     </TableCell>
                     <TableCell className="font-mono text-xs">{formatDate(row.date)}</TableCell>
                     <TableCell className="text-right font-semibold">

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

@@ -10,6 +10,7 @@ import { useState, useCallback, useTransition } from 'react'
 import { useRouter, usePathname, useSearchParams } from 'next/navigation'
 import { useFormatDate } from '@/lib/use-format-date'
 import { Badge } from '@/components/ui/badge'
+import { StatusBadge } from '@/components/status-badge'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import {
@@ -90,11 +91,6 @@ const statusTabs = LIST_STATUS_TABS.inspections.map((key) => ({
   labelKey: statusLabelKeys[key],
 }))
 
-const statusColors: Record<string, string> = {
-  in_progress: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
-  completed: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
-}
-
 /**
  * The rail is a breakdown of the grades given, not a completion meter: a full
  * rail with an amber run means every check was graded and some came back as
@@ -338,12 +334,9 @@ export function InspectionsClient({
                   <span className="min-w-0 flex-1 truncate font-medium">
                     {insp.vehicle.year} {insp.vehicle.make} {insp.vehicle.model}
                   </span>
-                  <Badge
-                    variant="outline"
-                    className={`shrink-0 text-xs ${statusColors[insp.status] || ''}`}
-                  >
+                  <StatusBadge kind="inspection" status={insp.status} className="shrink-0">
                     {insp.status === 'in_progress' ? t('statusInProgress') : t('statusCompleted')}
-                  </Badge>
+                  </StatusBadge>
                 </div>
                 <p className="truncate text-xs text-muted-foreground">
                   {insp.vehicle.licensePlate && (
@@ -461,14 +454,11 @@ export function InspectionsClient({
                         />
                       </TableCell>
                       <TableCell>
-                        <Badge
-                          variant="outline"
-                          className={`text-xs ${statusColors[insp.status] || ''}`}
-                        >
+                        <StatusBadge kind="inspection" status={insp.status}>
                           {insp.status === 'in_progress'
                             ? t('statusInProgress')
                             : t('statusCompleted')}
-                        </Badge>
+                        </StatusBadge>
                       </TableCell>
                       <TableCell className="font-mono text-xs">
                         {formatDate(new Date(insp.createdAt))}

+ 8 - 16
src/app/(authenticated)/quotes/quotes-client.tsx

@@ -19,6 +19,7 @@ import {
 } from '@/features/quotes/Lib/quoteStatus'
 import { useFormatDate } from '@/lib/use-format-date'
 import { Badge } from '@/components/ui/badge'
+import { StatusBadge } from '@/components/status-badge'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import {
@@ -93,16 +94,6 @@ const statusTitleKeys: Record<ListStatus<'quotes'>, string> = {
 
 const statusTabs = LIST_STATUS_TABS.quotes.map((key) => ({ key, titleKey: statusTitleKeys[key] }))
 
-const statusColors: Record<string, string> = {
-  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',
-  changes_requested: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
-}
-
 export function QuotesClient({
   data,
   currencyCode = DEFAULT_CURRENCY_CODE,
@@ -308,9 +299,9 @@ export function QuotesClient({
                   </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 ${statusColors[q.status] || ''}`}>
+                  <StatusBadge kind="quote" status={q.status}>
                     {quoteStatusLabel(q.status, tStatus)}
-                  </Badge>
+                  </StatusBadge>
                   {q.quoteNumber && <span className="font-mono">{q.quoteNumber}</span>}
                   <span className="font-mono">{formatDate(new Date(q.createdAt))}</span>
                 </div>
@@ -440,14 +431,15 @@ export function QuotesClient({
                         )}
                       </TableCell>
                       <TableCell>
-                        <Badge
-                          variant="outline"
+                        <StatusBadge
                           // Wraps rather than spilling into the date column
                           // when a language's word is wider than the cell.
-                          className={`max-w-full whitespace-normal text-center text-xs leading-tight ${statusColors[q.status] || ''}`}
+                          kind="quote"
+                          status={q.status}
+                          className="max-w-full whitespace-normal text-center leading-tight"
                         >
                           {quoteStatusLabel(q.status, tStatus)}
-                        </Badge>
+                        </StatusBadge>
                       </TableCell>
                       <TableCell className="font-mono text-xs">
                         {formatDate(new Date(q.createdAt))}

+ 14 - 2
src/app/(authenticated)/vehicles/[id]/(detail)/page.tsx

@@ -11,6 +11,7 @@ import { getTemplates } from '@/features/inspections/Actions/templateActions'
 import { getVehicleQuotes } from '@/features/quotes/Actions/quoteActions'
 import { getVehicleFindings } from '@/features/vehicles/Actions/findingActions'
 import { getVehicleJobWarnings } from '@/features/vehicles/Actions/getVehicleJobWarnings'
+import { getVehicleGlance } from '@/features/vehicles/Actions/getVehicleGlance'
 import { getFeatures } from '@/lib/features'
 import { findSafetyConnection } from '@/features/integrations/Lib/vehicle-safety'
 import { isAiConfigured } from '@/features/integrations/Lib/ai'
@@ -18,7 +19,10 @@ import { getAuthContext } from '@/lib/get-auth-context'
 import { getTireSetsForVehicle } from '@/features/tire-hotel/Actions/tireJobActions'
 import { VehicleDetailClient } from '../vehicle-detail-client'
 import { VehicleDetailClassic } from '../_classic/vehicle-detail-classic'
-import { TryNewVehiclePageBanner } from '@/features/vehicles/Components/VehiclePageLayoutSwitch'
+import {
+  TryNewVehiclePageBanner,
+  VehiclePageViewed,
+} from '@/features/vehicles/Components/VehiclePageLayoutSwitch'
 import { resolveVehiclePageLayout } from '@/lib/vehicle-page-layout.server'
 import { PageHeader } from '@/components/page-header'
 import { redirect } from 'next/navigation'
@@ -66,7 +70,7 @@ export default async function VehicleDetailPage({
   const auth = getAuthContext()
   const layoutChoice = auth.then((ctx) => resolveVehiclePageLayout(ctx?.organizationId))
   const access = await getViewerAccess()
-  const layout = await layoutChoice
+  const { layout, chosen: layoutChosen } = await layoutChoice
   const classic = layout === 'classic'
   const S = PermissionSubject
 
@@ -85,6 +89,7 @@ export default async function VehicleDetailPage({
     authContext,
     jobWarningsResult,
     feedNotesResult,
+    glanceResult,
   ] = await Promise.all([
     getVehicle(id),
     // The new page shows the vehicle's history as one feed, so it asks for all
@@ -113,6 +118,9 @@ export default async function VehicleDetailPage({
     // 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 }),
+    // Last visit, next booking and what is owed, over every work order and
+    // not only the ones a search has left in the list.
+    classic ? null : readIfAllowed(access, S.SERVICES, () => getVehicleGlance(id)),
   ])
 
   if (!result.success || !result.data) {
@@ -248,6 +256,9 @@ export default async function VehicleDetailPage({
   return (
     <>
       <PageHeader />
+      {/* Which layout each visit got, so the classic page can be retired
+          once nobody is left on it. */}
+      <VehiclePageViewed vehicleId={result.data.id} layout={layout} chosen={layoutChosen} />
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
         {classic ? (
           <>
@@ -260,6 +271,7 @@ export default async function VehicleDetailPage({
             jobWarnings={
               jobWarningsResult?.success && jobWarningsResult.data ? jobWarningsResult.data : []
             }
+            glance={glanceResult?.success && glanceResult.data ? glanceResult.data : null}
             feedNotes={
               feedNotesResult?.success && feedNotesResult.data
                 ? feedNotesResult.data.records

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

@@ -34,7 +34,8 @@ import {
   ContextMenuTrigger,
 } from '@/components/ui/context-menu'
 import { DataTablePagination } from '@/components/data-table-pagination'
-import { typeColors, statusColors } from '@/lib/table-utils'
+import { typeColors } from '@/lib/table-utils'
+import { StatusBadge } from '@/components/status-badge'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { getWarrantyStatus, type WarrantyStatus } from '@/lib/warranty'
 import { effectiveInvoiceDate } from '@/lib/invoice-utils'
@@ -297,12 +298,9 @@ export function ServiceRecordsTable({
                   <Badge variant="outline" className={`text-xs ${typeColors[record.type] || ''}`}>
                     {serviceLabel.type(record.type)}
                   </Badge>
-                  <Badge
-                    variant="outline"
-                    className={`text-xs ${statusColors[record.status] || ''}`}
-                  >
+                  <StatusBadge kind="workOrder" status={record.status}>
                     {serviceLabel.status(record.status)}
-                  </Badge>
+                  </StatusBadge>
                   {warranty !== 'none' && <WarrantyBadge status={warranty} t={t} />}
                 </div>
 
@@ -400,12 +398,9 @@ export function ServiceRecordsTable({
                           </Badge>
                         </TableCell>
                         <TableCell>
-                          <Badge
-                            variant="outline"
-                            className={`text-xs ${statusColors[record.status] || ''}`}
-                          >
+                          <StatusBadge kind="workOrder" status={record.status}>
                             {serviceLabel.status(record.status)}
-                          </Badge>
+                          </StatusBadge>
                         </TableCell>
                         <TableCell className="text-right font-mono text-xs">
                           {record.mileage ? record.mileage.toLocaleString() : '-'}

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

@@ -10,6 +10,7 @@ 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 { StatusBadge } from '@/components/status-badge'
 import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
 import {
@@ -239,16 +240,6 @@ interface QuoteRecord {
   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,
@@ -1414,12 +1405,9 @@ export function VehicleDetailClassic({
                       </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] || ''}`}
-                      >
+                      <StatusBadge kind="quote" status={q.status}>
                         {quoteStatusLabel(q.status, tQuoteStatus)}
-                      </Badge>
+                      </StatusBadge>
                       {q.quoteNumber && <span className="font-mono">{q.quoteNumber}</span>}
                       <span className="font-mono">{formatDate(new Date(q.createdAt))}</span>
                     </div>
@@ -1451,12 +1439,13 @@ export function VehicleDetailClassic({
                         </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] || ''}`}
+                          <StatusBadge
+                            kind="quote"
+                            status={q.status}
+                            className="max-w-full whitespace-normal text-center leading-tight"
                           >
                             {quoteStatusLabel(q.status, tQuoteStatus)}
-                          </Badge>
+                          </StatusBadge>
                         </TableCell>
                         <TableCell className="font-mono text-xs text-muted-foreground">
                           {formatDate(new Date(q.createdAt))}

+ 21 - 12
src/app/(authenticated)/vehicles/[id]/findings-table.tsx

@@ -28,6 +28,7 @@ import {
   ContextMenuTrigger,
 } from '@/components/ui/context-menu'
 import { DataTablePagination } from '@/components/data-table-pagination'
+import { cn } from '@/lib/utils'
 import { Loader2, MoreVertical, Pencil, Plus, Trash2, Wrench, ExternalLink } from 'lucide-react'
 import { useTranslations } from 'next-intl'
 
@@ -50,7 +51,8 @@ interface FindingsTableProps {
   page: number
   pageSize: number
   totalPages: number
-  onAddFinding: () => void
+  /** Leave out where the page has its own button for adding an observation. */
+  onAddFinding?: () => void
   onEditFinding: (finding: FindingRow) => void
   onDeleteFinding: (id: string) => void
   onCreateWorkOrder: (findingIds: string[]) => void
@@ -132,7 +134,12 @@ export function FindingsTable({
   return (
     <div className="space-y-4">
       {/* Toolbar */}
-      <div className="flex items-center justify-between gap-2">
+      <div
+        className={cn(
+          'flex items-center justify-between gap-2',
+          !onAddFinding && !isPending && selected.size === 0 && 'hidden'
+        )}
+      >
         <div className="flex items-center gap-2">
           {isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
           {selected.size > 0 && (
@@ -151,16 +158,18 @@ export function FindingsTable({
             </Button>
           )}
         </div>
-        <Button
-          size="sm"
-          onClick={onAddFinding}
-          aria-label={t('addFinding')}
-          title={t('addFinding')}
-          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">{t('addFinding')}</span>
-        </Button>
+        {onAddFinding && (
+          <Button
+            size="sm"
+            onClick={onAddFinding}
+            aria-label={t('addFinding')}
+            title={t('addFinding')}
+            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">{t('addFinding')}</span>
+          </Button>
+        )}
       </div>
 
       {/* Card list (phones + small tablets) */}

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

@@ -2,13 +2,11 @@
 
 import Link from 'next/link'
 import { useMemo } from 'react'
-import { AlertTriangle, ChevronRight } from 'lucide-react'
+import { AlertTriangle } 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
@@ -39,22 +37,6 @@ export function useJobWarningText() {
     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[] = []
@@ -85,101 +67,33 @@ export function useJobWarningText() {
       return out
     }
 
-    return { labels, lines, day, heading: t('heading'), fix: t('fix') }
+    return { lines, heading: t('heading') }
   }, [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.
+ * A job's booking problems on the job's own row, in the work order's own
+ * words: every problem on one amber line, cut short where the row runs out
+ * and whole on hover. Like the row around it, it opens the job to fix it.
  */
-export function JobWarningCallout({ warning, href }: { warning: VehicleJobWarning; href: string }) {
+export function JobWarningLine({ warning, href }: { warning: VehicleJobWarning; href: string }) {
   const text = useJobWarningText()
+  const said = text.lines(warning).join(' · ')
   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"
+    // A link of its own, above the row's stretched link: the pointer reaches
+    // it, so the whole warning shows on hover where the line is cut short,
+    // and a click still opens the job.
+    <Link
+      href={href}
+      data-testid="job-warning"
+      title={`${text.heading}: ${said}`}
+      className="relative z-10 flex min-w-0 items-start gap-1.5 font-medium text-amber-700 @2xl:items-center dark:text-amber-400"
     >
-      <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>
+      <AlertTriangle aria-hidden className="mt-0.5 h-3.5 w-3.5 shrink-0 @2xl:mt-0" />
+      <span className="sr-only">{text.heading}: </span>
+      {/* In full where the row is stacked and has the room; one line, cut
+          short, where it shares the row with the table's other columns. */}
+      <span className="min-w-0 whitespace-normal @2xl:truncate">{said}</span>
+    </Link>
   )
 }

+ 20 - 15
src/app/(authenticated)/vehicles/[id]/notes-table.tsx

@@ -46,7 +46,8 @@ interface NotesTableProps {
   pageSize: number
   totalPages: number
   onSelectNote: (note: NoteRow) => void
-  onAddNote: () => void
+  /** Leave out where the page has its own button for adding a note. */
+  onAddNote?: () => void
   onTogglePin: (id: string) => void
   onDeleteNote: (id: string) => void
 }
@@ -103,21 +104,25 @@ export function NotesTable({
   return (
     <div className="space-y-4">
       {/* Toolbar */}
-      <div className="flex items-center justify-between">
-        {isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
-        <div className="ml-auto">
-          <Button
-            size="sm"
-            onClick={onAddNote}
-            aria-label={t('addNote')}
-            title={t('addNote')}
-            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">{t('addNote')}</span>
-          </Button>
+      {(onAddNote || isPending) && (
+        <div className="flex items-center justify-between">
+          {isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
+          {onAddNote && (
+            <div className="ml-auto">
+              <Button
+                size="sm"
+                onClick={onAddNote}
+                aria-label={t('addNote')}
+                title={t('addNote')}
+                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">{t('addNote')}</span>
+              </Button>
+            </div>
+          )}
         </div>
-      </div>
+      )}
 
       {/* Card list (phones + small tablets) */}
       <div className="space-y-2 md:hidden">

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

@@ -3,26 +3,19 @@
 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 { ClipboardCheck, FileText, History, Paperclip } from 'lucide-react'
 import { Button } from '@/components/ui/button'
-import { StatusLamp, serviceStatusTone, type LampTone } from '@/components/status-lamp'
+import { CustomStatusChip, StatusBadge } from '@/components/status-badge'
 import { useFormatCurrency } from '@/components/currency-settings-context'
-import { useDayFormatter } from '@/features/calendar/Components/useDayFormatter'
+import { useDateSettings } from '@/components/date-settings-context'
+import { safeTimeZone, zonedParts } from '@/lib/timezone'
 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 { cn } from '@/lib/utils'
 import type { VehicleJobWarning } from '@/features/vehicles/Lib/vehicleJobWarnings'
-import { JobWarningCallout, JobWarningChip } from './job-warning'
+import { JobWarningLine } from './job-warning'
 
 export interface FeedWorkOrder {
   id: string
@@ -40,6 +33,9 @@ export interface FeedWorkOrder {
   warrantyExpiresAt: Date | null
   _count: { attachments: number }
   laborItems?: { description: string }[]
+  partItems?: { name: string }[]
+  /** The workshop's own status on the job, shown beside its stage. */
+  customStatus?: { name: string; color: string } | null
 }
 
 export interface FeedQuote {
@@ -60,67 +56,51 @@ export interface FeedInspection {
   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',
+/** Where an open job sorts: in the workshop, then booked, then not yet booked. */
+function openRank(status: string) {
+  if (IN_WORKSHOP.has(status)) return 0
+  if (status === 'scheduled') return 1
+  return 2
 }
 
 /** 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 = 30
+const NOTHING_OWED: Record<string, 'unpaid' | 'partial'> = {}
+const OPEN_AT_FIRST = 6
 
-const SHOWN_AT_FIRST = 20
+/**
+ * What was done on a job, from its first labour and part lines, so the list
+ * answers "when was the oil changed" without anybody opening the job.
+ */
+function workDone(wo: FeedWorkOrder) {
+  const lines = [
+    ...(wo.laborItems ?? []).map((l) => l.description),
+    ...(wo.partItems ?? []).map((p) => p.name),
+  ]
+    .map((line) => line.trim())
+    .filter((line) => line && line !== wo.title)
+  return [...new Set(lines)].join(' · ')
+}
 
 /**
- * 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.
+ * The vehicle's work as one table. First every job that is not finished, the
+ * ones in the workshop on top, each with its status and any booking problem
+ * on its own row. Then what has been done, newest first and grouped by year,
+ * with quotes and inspections in among the work orders. Every row says what
+ * the job was, what was done on it, who did it, the reading and the amount.
  */
 export function VehicleActivityFeed({
   vehicleId,
@@ -129,12 +109,12 @@ export function VehicleActivityFeed({
   workOrders,
   quotes,
   inspections,
-  notes,
   distUnit,
+  readingLabel,
   currencyCode,
   vehicleMileage,
   jobWarnings = [],
-  onSelectNote,
+  owed = NOTHING_OWED,
 }: {
   vehicleId: string
   filter: FeedFilter
@@ -143,45 +123,56 @@ export function VehicleActivityFeed({
   workOrders: FeedWorkOrder[]
   quotes: FeedQuote[]
   inspections: FeedInspection[]
-  notes: FeedNote[]
   distUnit: string
+  /** The heading over the readings: mileage for a car, engine hours for a boat. */
+  readingLabel: string
   currencyCode: string
   vehicleMileage: number
-  /** Booking problems on open jobs, flagged on the job's own line. */
+  /** Booking problems on open jobs, spelled out under the job's own line. */
   jobWarnings?: VehicleJobWarning[]
-  onSelectNote: (note: FeedNote) => void
+  /** Jobs the customer has been billed for and has not paid in full, by id. */
+  owed?: Record<string, 'unpaid' | 'partial'>
 }) {
   const t = useTranslations('vehicles.detail.feed')
   const tQuoteStatus = useTranslations('quotes.statusLabels')
-  const tWarranty = useTranslations('vehicles.services')
+  const tServices = useTranslations('vehicles.services')
+  const tHeader = useTranslations('service.header')
+  const tInspections = useTranslations('inspections.list')
   const serviceLabel = useServiceLabels()
   const formatCurrency = useFormatCurrency()
-  const day = useDayFormatter()
   const numberFormat = useFormatter()
+  // Days and years are the workshop's, on the server and in the browser
+  // alike: a job at half past midnight Oslo time is on that day whichever
+  // machine draws the row, and the two never disagree about it.
+  const timeZone = safeTimeZone(useDateSettings().timezone)
   const [showAll, setShowAll] = useState(false)
+  const [showAllOpen, setShowAllOpen] = 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 { open, events, years } = useMemo(() => {
     const needle = search.trim().toLowerCase()
     const matches = (text: string | null | undefined) =>
       !needle || (text ?? '').toLowerCase().includes(needle)
+    const withJobs = filter === 'all' || filter === 'services'
 
-    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))
+    // In the workshop and booked jobs in the order they happen; jobs with no
+    // slot yet after them, the newest first.
+    const openJobs = workOrders
+      .filter((wo) => wo.status !== 'completed')
+      .sort((a, b) => {
+        const rank = openRank(a.status) - openRank(b.status)
+        if (rank !== 0) return rank
+        const order = +visitDate(a) - +visitDate(b)
+        return openRank(a.status) === 2 ? -order : order
+      })
 
     const list: FeedEvent[] = []
-    if (filter === 'all' || filter === 'services') {
+    if (withJobs) {
       for (const wo of workOrders) {
-        if (!isNow(wo)) list.push({ kind: 'wo', id: wo.id, date: visitDate(wo), wo })
+        if (wo.status === 'completed') list.push({ kind: 'wo', id: wo.id, date: visitDate(wo), wo })
       }
     }
     if (filter === 'all' || filter === 'quotes') {
@@ -201,125 +192,141 @@ export function VehicleActivityFeed({
           })
       }
     }
-    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.
+    // Per year: how many finished 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()
+      if (wo.status !== 'completed') continue
+      const year = zonedParts(visitDate(wo), timeZone).year
       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])
+    return { open: withJobs ? openJobs : [], events: list, years: perYear }
+  }, [filter, search, workOrders, quotes, inspections, timeZone])
 
+  const shownOpen = showAllOpen ? open : open.slice(0, OPEN_AT_FIRST)
   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 year = zonedParts(event.date, timeZone).year
     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 dayMonth = (date: Date) =>
+    numberFormat.dateTime(date, { day: 'numeric', month: 'short', timeZone })
   const reading = (km: number | null) => (km ? `${numberFormat.number(km)} ${distUnit}` : null)
+  const money = (amount: number) => (amount > 0 ? formatCurrency(amount, currencyCode) : null)
 
-  if (now.length === 0 && events.length === 0) {
+  if (open.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" />
+        <History className="h-8 w-8 text-muted-foreground/60" />
         <p className="text-sm text-muted-foreground">{search ? t('noMatches') : t('empty')}</p>
       </div>
     )
   }
 
+  const jobRow = (wo: FeedWorkOrder, isOpen: boolean) => {
+    const href = `/vehicles/${vehicleId}/service/${wo.id}`
+    const summary = wo.laborItems?.[0]?.description
+    const warning = isOpen ? warningFor.get(wo.id) : undefined
+    const warranty = getWarrantyStatus(
+      wo.warrantyExpiresAt,
+      wo.warrantyMileage,
+      wo.mileage,
+      vehicleMileage
+    )
+    const debt = owed[wo.id]
+    return (
+      <Row
+        key={wo.id}
+        date={dayMonth(visitDate(wo))}
+        href={href}
+        title={wo.title}
+        number={wo.invoiceNumber}
+        attachments={wo._count.attachments}
+        detail={
+          <>
+            {debt && (
+              <span className="font-medium text-destructive">
+                {debt === 'partial' ? t('partlyPaid') : tHeader('unpaid')}
+              </span>
+            )}
+            {/* What is owed is said whatever else is: with several bills out,
+                the row is the only place that says which. A booking problem
+                takes the place of what was done, not of the debt. */}
+            {warning ? (
+              <JobWarningLine warning={warning} href={href} />
+            ) : (
+              workDone(wo) || (summary && summary !== wo.title ? summary : null)
+            )}
+          </>
+        }
+        hasDetail={Boolean(warning || debt || workDone(wo))}
+        extra={
+          (warranty === 'active' || warranty === 'expiring') && (
+            <span className="shrink-0 rounded bg-emerald-500/10 px-1.5 py-px text-[11px] font-semibold text-emerald-700 dark:text-emerald-400">
+              {tServices(`warranty.status.${warranty}`)}
+            </span>
+          )
+        }
+        status={
+          <>
+            <StatusBadge kind="workOrder" status={wo.status}>
+              {serviceLabel.status(wo.status)}
+            </StatusBadge>
+            {wo.customStatus && <CustomStatusChip status={wo.customStatus} />}
+          </>
+        }
+        tech={wo.techName}
+        reading={reading(wo.mileage)}
+        amount={money(woTotal(wo))}
+      />
+    )
+  }
+
   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>
+    <div className="@container rounded-xl border bg-card pb-1">
+      <div
+        aria-hidden
+        className={cn(
+          COLUMNS,
+          'hidden border-b px-3 py-2 text-xs font-medium text-muted-foreground sm:px-4 @2xl:grid'
+        )}
+      >
+        <span>{tServices('table.date')}</span>
+        <span>{t('colJob')}</span>
+        <span>{tServices('table.status')}</span>
+        <span className="hidden @4xl:block">{tServices('table.technician')}</span>
+        <span className="truncate text-right">{readingLabel}</span>
+        <span className="text-right">{tServices('table.total')}</span>
+      </div>
+
+      {open.length > 0 && (
+        <section aria-label={t('open')}>
+          <GroupHeading label={t('open')} meta={String(open.length)} />
+          <ol className="divide-y divide-border/70 px-1 sm:px-2">
+            {shownOpen.map((wo) => jobRow(wo, true))}
+          </ol>
+          {!showAllOpen && open.length > OPEN_AT_FIRST && (
+            <div className="border-t border-border/70 px-3 py-1.5 sm:px-4">
+              <Button
+                size="sm"
+                variant="ghost"
+                className="-ml-2 h-7 text-[12.5px] text-muted-foreground"
+                onClick={() => setShowAllOpen(true)}
+              >
+                {t('showMoreOpen', { count: open.length - OPEN_AT_FIRST })}
+              </Button>
+            </div>
+          )}
         </section>
       )}
 
@@ -338,135 +345,60 @@ export function VehicleActivityFeed({
                   : 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">
+            <ol className="divide-y divide-border/70 px-1 sm:px-2">
               {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 === 'wo') return jobRow(event.wo, false)
                 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"
+                      icon={<FileText className="h-3.5 w-3.5 text-amber-700 dark:text-amber-400" />}
+                      kind={t('quote')}
                       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>
+                      status={
+                        <StatusBadge kind="quote" status={q.status}>
+                          {quoteStatusLabel(q.status, tQuoteStatus)}
+                        </StatusBadge>
+                      }
+                      amount={money(q.totalAmount)}
+                    />
                   )
                 }
-                const note = event.note
-                const text = plain(note.content)
+                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={
-                      note.isPinned ? (
-                        <Pin className="h-3.5 w-3.5" />
-                      ) : (
-                        <FileText className="h-3.5 w-3.5" />
-                      )
+                      <ClipboardCheck className="h-3.5 w-3.5 text-teal-700 dark:text-teal-400" />
+                    }
+                    kind={t('inspection')}
+                    href={`/inspections/${insp.id}`}
+                    title={insp.template.name}
+                    detail={
+                      <>
+                        {failed > 0 && (
+                          <span className="font-medium text-red-700 dark:text-red-400">
+                            {t('inspectionFailed', { count: failed })}
+                          </span>
+                        )}
+                        {(done || passed + failed > 0) && t('inspectionResult', { passed })}
+                      </>
                     }
-                    iconClass="bg-card text-muted-foreground"
-                    onClick={() => onSelectNote(note)}
-                    title={note.title}
-                  >
-                    {text && <span className="min-w-0 truncate italic">{text}</span>}
-                  </Row>
+                    hasDetail={done || passed + failed > 0}
+                    status={
+                      <StatusBadge kind="inspection" status={insp.status}>
+                        {done ? tInspections('statusCompleted') : tInspections('statusInProgress')}
+                      </StatusBadge>
+                    }
+                  />
                 )
               })}
             </ol>
@@ -475,7 +407,7 @@ export function VehicleActivityFeed({
       })}
 
       {!showAll && events.length > SHOWN_AT_FIRST && (
-        <div className="pl-[4.5rem] pt-3 sm:pl-[5.5rem]">
+        <div className="px-3 py-3 sm:px-4">
           <Button size="sm" variant="outline" className="h-8" onClick={() => setShowAll(true)}>
             {t('showOlder', { count: events.length - SHOWN_AT_FIRST })}
           </Button>
@@ -485,121 +417,134 @@ export function VehicleActivityFeed({
   )
 }
 
-/** A year (or "Now") over its part of the feed, with a hairline to the edge. */
+/** The table's columns from the width where they fit; narrower, a row stacks instead. */
+const COLUMNS =
+  '@2xl:grid-cols-[3.5rem_minmax(0,1fr)_8rem_6.5rem_6.25rem] @4xl:grid-cols-[3.5rem_minmax(0,1fr)_8.5rem_8.5rem_6.5rem_6.25rem] gap-x-3'
+
+/** "Open jobs" or a year over its rows, staying in view while they scroll. */
 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 className="flex items-baseline gap-2 border-b border-border/70 bg-muted/40 px-3 py-1.5 sm:px-4 xl:sticky xl:top-0 xl:z-20 xl:backdrop-blur">
+      <h2 className="text-[13px] font-semibold">{label}</h2>
+      {meta && <span className="text-xs tabular-nums text-muted-foreground">{meta}</span>}
     </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.
+ * One row of the table: the day, what the job was with what was done under
+ * it, its status, who did it, the reading and the amount, in columns that
+ * line up down the list so each can be read on its own. Where the list is
+ * narrow the status, the technician and the reading drop to a line under
+ * the title instead. The whole row opens the thing it describes.
  */
 function Row({
   date,
-  sub,
   icon,
-  iconClass,
+  kind,
   href,
-  onClick,
   title,
   number,
+  attachments = 0,
+  extra,
+  detail,
+  hasDetail = false,
+  status,
+  tech,
+  reading,
   amount,
-  children,
 }: {
   date: string
-  sub?: string | null
-  icon: React.ReactNode
-  iconClass: string
-  href?: string
-  onClick?: () => void
+  icon?: React.ReactNode
+  /** What the row is when it is not a work order: "Quote", "Inspection". */
+  kind?: string
+  href: string
   title: string
   number?: string | null
-  amount?: string
-  children?: React.ReactNode
+  attachments?: number
+  /** A small marker after the title, such as a warranty still running. */
+  extra?: React.ReactNode
+  /** The line under the title: what was done, or what is wrong. */
+  detail?: React.ReactNode
+  hasDetail?: boolean
+  status?: React.ReactNode
+  tech?: string | null
+  reading?: string | null
+  amount?: string | null
 }) {
-  const stretched =
-    'outline-none after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-ring'
+  // A title made from the job number already says it; no need to print it twice.
+  const showNumber = number && !title.startsWith(number)
+  const hasMeta = Boolean(status || tech || reading)
   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
+    <li className="py-0.5">
+      <div
         className={cn(
-          'relative z-10 mx-auto flex size-6 items-center justify-center rounded-full border sm:size-7',
-          iconClass
+          COLUMNS,
+          'relative grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start rounded-lg px-2 py-1.5 transition-colors hover:bg-muted/50'
         )}
       >
-        {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)}
+        <span className="pt-px text-[12.5px] tabular-nums text-muted-foreground">{date}</span>
+        <div className="min-w-0">
+          <div className="flex min-w-0 items-center gap-2">
+            {/* A row that is not a work order says what it is, in a word
+                beside its icon: an icon alone does not say "quote". */}
+            {icon && (
+              <span className="inline-flex shrink-0 items-center gap-1 rounded border border-border/80 px-1.5 py-px text-[11px] font-medium text-muted-foreground">
+                {icon}
+                {kind}
+              </span>
+            )}
+            <Link
+              href={href}
+              className="shrink truncate text-sm font-medium outline-none after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-ring"
             >
               {title}
-            </button>
+            </Link>
+            {showNumber && (
+              <CopyText
+                value={number}
+                label={number}
+                className="relative z-10 hidden shrink-0 font-mono text-[11.5px] text-muted-foreground sm:inline-flex"
+              />
+            )}
+            {attachments > 0 && (
+              <span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
+                <Paperclip aria-hidden className="h-3 w-3" />
+                {attachments}
+              </span>
+            )}
+            {extra}
+          </div>
+          {hasDetail && (
+            <div className="mt-0.5 flex min-w-0 items-center gap-2 truncate text-[12.5px] text-muted-foreground">
+              {detail}
+            </div>
           )}
-          {number && (
-            <CopyText
-              value={number}
-              label={number}
-              className="hidden shrink-0 font-mono text-[11.5px] text-muted-foreground sm:inline-flex"
-            />
+          {/* Between the stacked row and the full table there is a status
+              column but not yet one for the technician, so the name stays
+              under the title until its column appears. */}
+          {tech && (
+            <div className="mt-0.5 hidden truncate text-[12.5px] text-muted-foreground @2xl:block @4xl:hidden">
+              {tech}
+            </div>
+          )}
+          {hasMeta && (
+            <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-muted-foreground @2xl:hidden">
+              {status}
+              {tech && <span className="truncate">{tech}</span>}
+              {reading && <span className="tabular-nums">{reading}</span>}
+            </div>
           )}
         </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 className="hidden min-w-0 flex-wrap items-center gap-1 @2xl:flex">{status}</div>
+        <div className="hidden min-w-0 truncate pt-px text-[12.5px] text-muted-foreground @4xl:block">
+          {tech}
+        </div>
+        <div className="hidden pt-px text-right text-[12.5px] tabular-nums text-muted-foreground @2xl:block">
+          {reading}
+        </div>
+        <div className="pt-px text-right text-[13px] font-semibold tabular-nums">{amount}</div>
       </div>
-      <div className="pt-0.5 text-right text-[13.5px] font-semibold tabular-nums">{amount}</div>
     </li>
   )
 }

Datei-Diff unterdrückt, da er zu groß ist
+ 892 - 526
src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx


+ 5 - 24
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -32,7 +32,7 @@ import {
 import { DataTablePagination } from '@/components/data-table-pagination'
 import { TableContextMenuHint } from '@/components/table-context-menu-hint'
 import { TableCellLink } from '@/components/table-cell-link'
-import { statusColors } from '@/lib/table-utils'
+import { CustomStatusChip, StatusBadge } from '@/components/status-badge'
 import { isPromiseOverdue } from '@/features/vehicles/Lib/promise'
 import { useListColumns } from '@/hooks/use-list-columns'
 import { WORK_ORDER_COLUMNS, type WorkOrderColumn } from '@/lib/list-columns'
@@ -65,25 +65,9 @@ import { useTranslations } from 'next-intl'
 import { getSmsTemplates } from '@/features/sms/Actions/smsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templates'
-import { statusColorClasses } from '@/features/work-order-statuses/Lib/stages'
-import { cn } from '@/lib/utils'
 import { ListEmpty } from '@/components/list-empty'
 import { useServiceLabels } from '@/features/vehicles/Lib/useServiceLabels'
 
-/** The workshop's own status, in its colour, beside the stage. */
-function CustomStatusChip({ status }: { status: { name: string; color: string } }) {
-  return (
-    <span
-      className={cn(
-        'max-w-40 truncate rounded-full border px-2 py-0.5 text-xs font-medium',
-        statusColorClasses(status.color).chip
-      )}
-    >
-      {status.name}
-    </span>
-  )
-}
-
 interface WorkOrder {
   id: string
   title: string
@@ -463,9 +447,9 @@ export function WorkOrdersClient({
                     <p className="truncate text-xs text-muted-foreground">{rowCustomer.name}</p>
                   )}
                   <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 ${statusColors[r.status] || ''}`}>
+                    <StatusBadge kind="workOrder" status={r.status}>
                       {serviceLabel.status(r.status)}
-                    </Badge>
+                    </StatusBadge>
                     {r.customStatus && <CustomStatusChip status={r.customStatus} />}
                     <span className="font-mono">
                       {formatDate(new Date(r.startDateTime ?? r.serviceDate))}
@@ -668,12 +652,9 @@ export function WorkOrdersClient({
                         </TableCell>
                         <TableCell>
                           <div className="flex flex-wrap items-center gap-1">
-                            <Badge
-                              variant="outline"
-                              className={`text-xs ${statusColors[r.status] || ''}`}
-                            >
+                            <StatusBadge kind="workOrder" status={r.status}>
                               {serviceLabel.status(r.status)}
-                            </Badge>
+                            </StatusBadge>
                             {r.customStatus && <CustomStatusChip status={r.customStatus} />}
                           </div>
                         </TableCell>

+ 3 - 2
src/app/(public)/portal/[orgId]/dashboard/page.tsx

@@ -1,4 +1,5 @@
 import { PortalShell } from '@/features/portal/Components/PortalShell'
+import { StatusBadge } from '@/components/status-badge'
 import { getPortalDashboard, getPortalOpeningHours } from '@/features/portal/Actions/portalActions'
 import { PortalOpeningHours } from '@/features/portal/Components/PortalOpeningHours'
 import { AppCard } from '@/components/app-card'
@@ -205,9 +206,9 @@ export default async function PortalDashboardPage({
                       </p>
                     </div>
                     <div className="flex shrink-0 items-center gap-2">
-                      <Badge variant="outline" className="text-xs">
+                      <StatusBadge kind="quote" status={q.status}>
                         {quoteStatusLabel(q.status, tQuoteStatus)}
-                      </Badge>
+                      </StatusBadge>
                       {q.publicToken && (
                         <Link
                           href={`/share/quote/${orgId}/${q.publicToken}`}

+ 5 - 8
src/app/(public)/portal/[orgId]/inspections/page.tsx

@@ -1,6 +1,6 @@
 import { PortalShell } from '@/features/portal/Components/PortalShell'
+import { StatusBadge } from '@/components/status-badge'
 import { getPortalInspections } from '@/features/portal/Actions/portalActions'
-import { Badge } from '@/components/ui/badge'
 import {
   Table,
   TableBody,
@@ -71,12 +71,9 @@ export default async function PortalInspectionsPage({
                           </span>
                         )}
                       </span>
-                      <Badge
-                        variant={insp.status === 'completed' ? 'default' : 'secondary'}
-                        className="shrink-0"
-                      >
+                      <StatusBadge kind="inspection" status={insp.status} className="shrink-0">
                         {portalStatusLabel('inspection', insp.status, tStatus)}
-                      </Badge>
+                      </StatusBadge>
                     </div>
                     <p className="truncate text-xs text-muted-foreground">{insp.template.name}</p>
                     <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs">
@@ -141,9 +138,9 @@ export default async function PortalInspectionsPage({
                         </TableCell>
                         <TableCell>{insp.template.name}</TableCell>
                         <TableCell>
-                          <Badge variant={insp.status === 'completed' ? 'default' : 'secondary'}>
+                          <StatusBadge kind="inspection" status={insp.status}>
                             {portalStatusLabel('inspection', insp.status, tStatus)}
-                          </Badge>
+                          </StatusBadge>
                         </TableCell>
                         <TableCell>
                           {new Date(insp.createdAt).toLocaleDateString(undefined, { timeZone })}

+ 5 - 21
src/app/(public)/portal/[orgId]/quotes/page.tsx

@@ -1,6 +1,6 @@
 import { PortalShell } from '@/features/portal/Components/PortalShell'
+import { StatusBadge } from '@/components/status-badge'
 import { getPortalQuotes } from '@/features/portal/Actions/portalActions'
-import { Badge } from '@/components/ui/badge'
 import {
   Table,
   TableBody,
@@ -63,17 +63,9 @@ export default async function PortalQuotesPage({ params }: { params: Promise<{ o
                     <span className="shrink-0 font-semibold">{money(q.totalAmount)}</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={
-                        q.status === 'accepted'
-                          ? 'default'
-                          : q.status === 'sent'
-                            ? 'secondary'
-                            : 'outline'
-                      }
-                    >
+                    <StatusBadge kind="quote" status={q.status}>
                       {quoteStatusLabel(q.status, tQuoteStatus)}
-                    </Badge>
+                    </StatusBadge>
                     {q.vehicle && (
                       <span className="truncate">
                         {q.vehicle.make} {q.vehicle.model}
@@ -121,17 +113,9 @@ export default async function PortalQuotesPage({ params }: { params: Promise<{ o
                         {q.vehicle?.make} {q.vehicle?.model}
                       </TableCell>
                       <TableCell>
-                        <Badge
-                          variant={
-                            q.status === 'accepted'
-                              ? 'default'
-                              : q.status === 'sent'
-                                ? 'secondary'
-                                : 'outline'
-                          }
-                        >
+                        <StatusBadge kind="quote" status={q.status}>
                           {quoteStatusLabel(q.status, tQuoteStatus)}
-                        </Badge>
+                        </StatusBadge>
                       </TableCell>
                       <TableCell>{money(q.totalAmount)}</TableCell>
                       <TableCell>

+ 59 - 0
src/components/status-badge.tsx

@@ -0,0 +1,59 @@
+import type { ReactNode } from 'react'
+import { Badge } from '@/components/ui/badge'
+import { statusBadgeClass, type StatusKind } from '@/lib/status-colors'
+import { statusColorClasses } from '@/features/work-order-statuses/Lib/stages'
+import { cn } from '@/lib/utils'
+
+/**
+ * A work order, quote or inspection status as a tinted pill, in the one
+ * colour that status has everywhere (src/lib/status-colors.ts). The label is
+ * the caller's, since each screen already has the translator for it.
+ *
+ * Every screen that shows one of these statuses draws it with this, so a
+ * status cannot be yellow on one page and grey on the next.
+ */
+export function StatusBadge({
+  kind,
+  status,
+  className,
+  children,
+}: {
+  kind: StatusKind
+  status: string
+  className?: string
+  children: ReactNode
+}) {
+  return (
+    <Badge
+      variant="outline"
+      data-status={status}
+      className={cn('text-xs', statusBadgeClass(kind, status), className)}
+    >
+      {children}
+    </Badge>
+  )
+}
+
+/**
+ * One of the workshop's own statuses ("Ready for pickup"), in the colour the
+ * workshop gave it, shown beside the stage's badge.
+ */
+export function CustomStatusChip({
+  status,
+  className,
+}: {
+  status: { name: string; color: string }
+  className?: string
+}) {
+  return (
+    <span
+      className={cn(
+        'max-w-40 truncate rounded-full border px-2 py-0.5 text-xs font-medium',
+        statusColorClasses(status.color).chip,
+        className
+      )}
+    >
+      {status.name}
+    </span>
+  )
+}

+ 8 - 27
src/components/status-lamp.tsx

@@ -15,10 +15,14 @@ const DOT: Record<LampTone, string> = {
 }
 
 /**
- * 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.
+ * A small lit dot and a word, for a condition that is not a document's
+ * status: an inspection coming due, how serious an observation is, a reminder
+ * that is late. Without children it is the dot alone, for places where the
+ * word is already said next to it.
+ *
+ * Work order, quote and inspection statuses are never drawn with this. They
+ * are `StatusBadge` (src/components/status-badge.tsx), in the one colour each
+ * has across the app.
  */
 export function StatusLamp({
   tone,
@@ -41,26 +45,3 @@ export function StatusLamp({
     </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'
-  }
-}

+ 3 - 7
src/features/quotes/Components/QuotePageClient.tsx

@@ -19,7 +19,6 @@ import { useRouter } from 'next/navigation'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { quoteStatusLabel } from '@/features/quotes/Lib/quoteStatus'
-import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
 import { ButtonGroup } from '@/components/ui/button-group'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@@ -48,7 +47,7 @@ import {
 import { formatCurrency, getCurrencySymbol } from '@/lib/format'
 import { ConvertConflictChoices } from './ConvertConflictChoices'
 import type { QuoteAttachment, QuoteRecord, TabType } from './quote-page-types'
-import { statusColors } from './quote-page-types'
+import { StatusBadge } from '@/components/status-badge'
 import { useQuoteFormState, type ConvertTarget } from './useQuoteFormState'
 import { useSaveShortcut } from '@/hooks/use-save-shortcut'
 import {
@@ -355,12 +354,9 @@ export function QuotePageClient({
                   <ArrowLeft className="h-4 w-4 shrink-0" />
                   <div className="min-w-0">
                     <div className="flex items-center gap-2">
-                      <Badge
-                        variant="outline"
-                        className={`shrink-0 text-xs ${statusColors[state.status] || ''}`}
-                      >
+                      <StatusBadge kind="quote" status={state.status} className="shrink-0">
                         {quoteStatusLabel(state.status, tStatus)}
-                      </Badge>
+                      </StatusBadge>
                       <h1 className="truncate text-lg font-semibold leading-tight">
                         {quote.title}
                       </h1>

+ 0 - 10
src/features/quotes/Components/quote-page-types.ts

@@ -94,16 +94,6 @@ export interface QuoteRecord {
   } | null
 }
 
-export const statusColors: Record<string, string> = {
-  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',
-  changes_requested: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
-}
-
 export const emptyPart = (): QuotePartInput => ({
   partNumber: '',
   name: '',

+ 3 - 16
src/features/tire-hotel/Components/TireJobsCard.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { statusBadgeClass } from '@/lib/status-colors'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { AppCard } from '@/components/app-card'
@@ -29,20 +30,6 @@ export type TireJobs = {
   }[]
 }
 
-const QUOTE_TOKENS: Record<string, string> = {
-  draft: 'bg-muted text-muted-foreground border-border',
-  sent: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
-  accepted: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
-  declined: 'bg-red-500/10 text-red-600 border-red-500/20',
-  expired: 'bg-muted text-muted-foreground border-border',
-}
-
-const JOB_TOKENS: Record<string, string> = {
-  pending: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
-  in_progress: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
-  completed: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
-}
-
 /**
  * Selling and fitting a new set, from the set that is already here.
  *
@@ -119,7 +106,7 @@ export function TireJobsCard({
               </span>
               <Badge
                 variant="outline"
-                className={cn('shrink-0 text-[10px]', QUOTE_TOKENS[quote.status] ?? '')}
+                className={cn('shrink-0 text-[10px]', statusBadgeClass('quote', quote.status))}
               >
                 {/* Converted beats the stored status: once it is a job, that
                     is the more useful thing to know about it. */}
@@ -152,7 +139,7 @@ export function TireJobsCard({
               </span>
               <Badge
                 variant="outline"
-                className={cn('shrink-0 text-[10px]', JOB_TOKENS[order.status] ?? '')}
+                className={cn('shrink-0 text-[10px]', statusBadgeClass('workOrder', order.status))}
               >
                 {order.status}
               </Badge>

+ 132 - 0
src/features/vehicles/Actions/getVehicleGlance.ts

@@ -0,0 +1,132 @@
+'use server'
+
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { invoicePaymentStatus } from '@/lib/document-lock'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+
+export interface VehicleGlance {
+  /** When the page was put together: what "days ago" is counted from. */
+  now: string
+  openJobs: number
+  doneJobs: number
+  /** The day of the last finished job, and the job, so the day can open it. */
+  lastVisit: string | null
+  lastVisitJobId: string | null
+  /** The start of the nearest open job with a slot still to come, and the job. */
+  nextBooking: string | null
+  nextBookingJobId: string | null
+  /** What is still owed on work the customer has been billed for. */
+  unpaid: number
+  /** Jobs by id that still have money owed on them, and whether any has come in. */
+  owed: Record<string, 'unpaid' | 'partial'>
+  /** The owner's other vehicles that are not archived. */
+  otherVehicles: number
+}
+
+/**
+ * The figures somebody on the phone with the customer is asked for, worked
+ * out over all of the vehicle's work orders rather than the ones a search
+ * has left on the page: when it was last in, when it is next booked, what
+ * is owed, and what else the owner has here.
+ *
+ * Money counts as owed once the customer holds the bill: a finished job, or
+ * an invoice that has been sent or issued. A job still being written is not
+ * a debt yet, whatever is on it.
+ */
+export async function getVehicleGlance(vehicleId: string) {
+  return withAuth(
+    async ({ organizationId }): Promise<VehicleGlance | null> => {
+      const id = z.string().min(1).parse(vehicleId)
+      const vehicle = await db.vehicle.findFirst({
+        where: { id, organizationId },
+        select: { id: true, customerId: true },
+      })
+      if (!vehicle) return null
+
+      const [jobs, otherVehicles] = await Promise.all([
+        db.serviceRecord.findMany({
+          where: { vehicleId: vehicle.id, vehicle: { organizationId } },
+          select: {
+            id: true,
+            status: true,
+            serviceDate: true,
+            startDateTime: true,
+            totalAmount: true,
+            cost: true,
+            manuallyPaid: true,
+            sentAt: true,
+            issuedAt: true,
+            payments: { select: { amount: true } },
+          },
+        }),
+        vehicle.customerId
+          ? db.vehicle.count({
+              where: {
+                organizationId,
+                customerId: vehicle.customerId,
+                isArchived: false,
+                id: { not: vehicle.id },
+              },
+            })
+          : 0,
+      ])
+
+      const now = new Date()
+      let openJobs = 0
+      let doneJobs = 0
+      let lastVisit: Date | null = null
+      let lastVisitJobId: string | null = null
+      let nextBooking: Date | null = null
+      let nextBookingJobId: string | null = null
+      let unpaid = 0
+      const owed: Record<string, 'unpaid' | 'partial'> = {}
+
+      for (const job of jobs) {
+        const done = job.status === 'completed'
+        if (done) {
+          doneJobs += 1
+          const at = job.startDateTime ?? job.serviceDate
+          if (!lastVisit || at > lastVisit) {
+            lastVisit = at
+            lastVisitJobId = job.id
+          }
+        } else {
+          openJobs += 1
+          if (job.startDateTime && job.startDateTime >= now) {
+            if (!nextBooking || job.startDateTime < nextBooking) {
+              nextBooking = job.startDateTime
+              nextBookingJobId = job.id
+            }
+          }
+        }
+
+        const billed = done || job.sentAt !== null || job.issuedAt !== null
+        const total = job.totalAmount > 0 ? job.totalAmount : job.cost
+        if (!billed || total <= 0) continue
+        const status = invoicePaymentStatus(job)
+        if (status === 'paid') continue
+        const paid = job.payments.reduce((sum, p) => sum + p.amount, 0)
+        unpaid += Math.max(0, total - paid)
+        owed[job.id] = status
+      }
+
+      return {
+        now: now.toISOString(),
+        openJobs,
+        doneJobs,
+        lastVisit: lastVisit ? lastVisit.toISOString() : null,
+        lastVisitJobId,
+        nextBooking: nextBooking ? nextBooking.toISOString() : null,
+        nextBookingJobId,
+        unpaid,
+        owed,
+        otherVehicles,
+      }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }],
+    }
+  )
+}

+ 6 - 1
src/features/vehicles/Actions/serviceActions.ts

@@ -110,7 +110,12 @@ export async function getServiceRecordsPaginated(
           where,
           include: {
             _count: { select: { partItems: true, laborItems: true, attachments: true } },
-            laborItems: { take: 1, select: { description: true } },
+            // The first few lines of each job, so a list can say what was
+            // done without anybody opening the job.
+            laborItems: { take: 4, select: { description: true } },
+            partItems: { take: 4, select: { name: true } },
+            // The workshop's own status, drawn beside the stage in the lists.
+            customStatus: { select: { name: true, color: true } },
           },
           orderBy: [{ startDateTime: { sort: 'desc', nulls: 'last' } }, { serviceDate: 'desc' }],
           skip,

+ 3 - 7
src/features/vehicles/Components/MyActiveJobs.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { statusBadgeClass } from '@/lib/status-colors'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { formatQuantity } from '@/lib/format-quantity'
 import { interactiveRow } from '@/lib/interactive-row'
@@ -65,12 +66,6 @@ const STATUS_ICON: Record<string, typeof Wrench> = {
   'waiting-parts': Pause,
 }
 
-const STATUS_COLOR: Record<string, string> = {
-  'in-progress': 'bg-blue-500/10 text-blue-600',
-  pending: 'bg-amber-500/10 text-amber-600',
-  'waiting-parts': 'bg-orange-500/10 text-orange-600',
-}
-
 export function MyActiveJobs({
   jobs,
   smsEnabled,
@@ -387,7 +382,8 @@ export function MyActiveJobs({
         <div className="@container divide-y" {...tableNav.containerProps}>
           {jobs.map((job) => {
             const StatusIcon = STATUS_ICON[job.status] || Wrench
-            const statusColor = STATUS_COLOR[job.status] || 'bg-muted text-muted-foreground'
+            const statusColor =
+              statusBadgeClass('workOrder', job.status) || 'bg-muted text-muted-foreground'
             const isUploadingPhoto = uploading?.jobId === job.id && uploading.type === 'photo'
             const isUploadingVideo = uploading?.jobId === job.id && uploading.type === 'video'
             const isUploading = uploading?.jobId === job.id

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

@@ -32,6 +32,34 @@ export function useSwitchVehiclePageLayout() {
   }
 }
 
+/**
+ * Counts a vehicle page opened, in the layout it was drawn in. Draws nothing.
+ *
+ * This is what says when the classic page can go: once the views with
+ * `layout: classic` have dried up, nobody is left on it. `layout_chosen`
+ * tells a workshop that went back to classic on purpose from one that is
+ * there only because it predates the new page and never tried it.
+ *
+ * One event per vehicle and layout: moving between the page's lists renders
+ * the page again without counting another visit, and switching layout counts
+ * the view of the page it switched to.
+ */
+export function VehiclePageViewed({
+  vehicleId,
+  layout,
+  chosen,
+}: {
+  vehicleId: string
+  layout: VehiclePageLayout
+  chosen: boolean
+}) {
+  // The vehicle is why the effect runs again, not something to send.
+  useEffect(() => {
+    track('vehicle_page:page_view', { layout, layout_chosen: chosen })
+  }, [vehicleId, layout, chosen])
+  return null
+}
+
 /**
  * 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.

+ 0 - 9
src/features/vehicles/Components/service-detail/types.ts

@@ -1,14 +1,5 @@
 import type { TireSetBannerData } from '@/features/tire-hotel/Components/TireSetBanner'
 
-export const statusColors: Record<string, string> = {
-  pending: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20',
-  'in-progress': 'bg-blue-500/10 text-blue-500 border-blue-500/20',
-  // The status select has offered this since it was added; without an entry
-  // here the badge was the only one on the page with no colour at all.
-  'waiting-parts': 'bg-orange-500/10 text-orange-600 border-orange-500/20',
-  completed: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
-}
-
 /**
  * The translation key for a status. The stored values are hyphenated and the
  * message keys are not, which is why the header badge was printing the raw

+ 4 - 1
src/features/vehicles/Components/service-page/modern/ModernHero.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { statusBadgeClass } from '@/lib/status-colors'
 import { isPromiseOverdue } from '@/features/vehicles/Lib/promise'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
@@ -257,7 +258,9 @@ export function ModernHero({
                 </h1>
                 <div className="flex flex-wrap items-center gap-1.5">
                   <span
-                    className={cn(pill, 'border-primary bg-primary text-primary-foreground')}
+                    // The status's own colour, the one it has in every list.
+                    className={cn(pill, statusBadgeClass('workOrder', status))}
+                    data-status={status}
                     data-testid="service-status"
                   >
                     {statusMessageKeys[status]

+ 1 - 1
src/features/workboard/utils/job-colors.ts

@@ -4,7 +4,7 @@
  * A whole week of work is too much to read job by job, so colour carries the
  * status: the eye should find every job still waiting for parts without
  * reading a single label. The hues match the status badges used in the tables
- * (`src/lib/table-utils.ts`), at a fill strong enough to read as a solid block.
+ * (`src/lib/status-colors.ts`, where a test holds the two to the same hues), at a fill strong enough to read as a solid block.
  */
 
 const STATUS_BLOCK_COLORS: Record<string, string> = {

+ 6 - 0
src/lib/analytics.ts

@@ -21,6 +21,12 @@ import posthog from 'posthog-js'
 export type AnalyticsEvent =
   /** A work order opened. */
   | 'work_order:page_view'
+  /**
+   * A vehicle page opened: `layout` classic or modern, and `layout_chosen`,
+   * whether this browser picked that layout or got it from the workshop's
+   * age. The classic page can be removed when its views stop.
+   */
+  | 'vehicle_page: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

+ 99 - 0
src/lib/status-colors.ts

@@ -0,0 +1,99 @@
+/**
+ * The colour of every work order, quote and inspection status, in one place.
+ *
+ * A status has one colour wherever it is shown: the lists, the dashboard, a
+ * customer's page, a vehicle's page, the work board. Each screen used to keep
+ * a table of its own, and they drifted: pending was yellow in one list, amber
+ * in another and grey on the vehicle page. Screens draw a status with
+ * `StatusBadge` (src/components/status-badge.tsx), which reads these tables,
+ * and a test fails when a file outside this one maps a status to a colour.
+ *
+ * `hue` is the colour's name in the palette; `badge` is the tinted pill the
+ * lists use; the work board fills whole blocks with the same hue
+ * (features/workboard/utils/job-colors.ts).
+ */
+
+interface StatusColor {
+  hue: string
+  /** Border, tint and text for a `Badge variant="outline"`. Written out so Tailwind sees every class. */
+  badge: string
+}
+
+const yellow: StatusColor = {
+  hue: 'yellow',
+  badge: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20',
+}
+const sky: StatusColor = { hue: 'sky', badge: 'bg-sky-500/10 text-sky-600 border-sky-500/20' }
+const blue: StatusColor = { hue: 'blue', badge: 'bg-blue-500/10 text-blue-500 border-blue-500/20' }
+const orange: StatusColor = {
+  hue: 'orange',
+  badge: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
+}
+const emerald: StatusColor = {
+  hue: 'emerald',
+  badge: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
+}
+const gray: StatusColor = { hue: 'gray', badge: 'bg-gray-500/10 text-gray-500 border-gray-500/20' }
+const red: StatusColor = { hue: 'red', badge: 'bg-red-500/10 text-red-500 border-red-500/20' }
+const amber: StatusColor = {
+  hue: 'amber',
+  badge: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
+}
+const purple: StatusColor = {
+  hue: 'purple',
+  badge: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
+}
+
+/**
+ * A work order's stored status. Waiting to start is yellow, booked is sky,
+ * work under way is blue, held for parts is orange, done is green.
+ */
+export const WORK_ORDER_STATUS_COLORS: Record<string, StatusColor> = {
+  pending: yellow,
+  scheduled: sky,
+  'in-progress': blue,
+  'waiting-parts': orange,
+  completed: emerald,
+}
+
+export const QUOTE_STATUS_COLORS: Record<string, StatusColor> = {
+  draft: gray,
+  sent: blue,
+  changes_requested: orange,
+  accepted: emerald,
+  rejected: red,
+  expired: amber,
+  converted: purple,
+}
+
+export const INSPECTION_STATUS_COLORS: Record<string, StatusColor> = {
+  'in-progress': blue,
+  completed: emerald,
+}
+
+export type StatusKind = 'workOrder' | 'quote' | 'inspection'
+
+const TABLES: Record<StatusKind, Record<string, StatusColor>> = {
+  workOrder: WORK_ORDER_STATUS_COLORS,
+  quote: QUOTE_STATUS_COLORS,
+  inspection: INSPECTION_STATUS_COLORS,
+}
+
+/**
+ * The colour for a stored status, or undefined for one nobody has given a
+ * colour: a status the workshop added itself, or one newer than this table,
+ * is drawn plain rather than as a guess.
+ *
+ * Work orders and inspections have been stored both hyphenated and with
+ * underscores ("in-progress", "in_progress"); both find the same colour.
+ * Quote statuses are underscored and are looked up as stored.
+ */
+export function statusColor(kind: StatusKind, status: string): StatusColor | undefined {
+  const table = TABLES[kind]
+  return table[status] ?? table[status.replace(/_/g, '-').toLowerCase()]
+}
+
+/** The badge classes for a status, or an empty string when it has no colour. */
+export function statusBadgeClass(kind: StatusKind, status: string): string {
+  return statusColor(kind, status)?.badge ?? ''
+}

+ 0 - 7
src/lib/table-utils.ts

@@ -5,13 +5,6 @@ export const typeColors: Record<string, string> = {
   inspection: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
 }
 
-export const statusColors: Record<string, string> = {
-  pending: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20',
-  'in-progress': 'bg-blue-500/10 text-blue-500 border-blue-500/20',
-  'waiting-parts': 'bg-orange-500/10 text-orange-500 border-orange-500/20',
-  completed: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
-}
-
 /**
  * Width for a column in a fixed-layout table, sized from what it holds.
  *

+ 14 - 4
src/lib/vehicle-page-layout.server.ts

@@ -8,19 +8,29 @@ import {
   type VehiclePageLayout,
 } from './vehicle-page-layout'
 
-/** The vehicle page this browser should get; see lib/vehicle-page-layout.ts. */
+export interface VehiclePageLayoutChoice {
+  layout: VehiclePageLayout
+  /**
+   * Whether somebody picked it in this browser, as opposed to getting it from
+   * the workshop's age. A workshop still on the classic page because nobody
+   * has tried the new one is a different thing from one that went back.
+   */
+  chosen: boolean
+}
+
+/** The vehicle page this browser should get, and why; see lib/vehicle-page-layout.ts. */
 export async function resolveVehiclePageLayout(
   organizationId: string | null | undefined
-): Promise<VehiclePageLayout> {
+): Promise<VehiclePageLayoutChoice> {
   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
+  if (chosen === 'classic' || chosen === 'modern') return { layout: chosen, chosen: true }
   const workshop = organizationId
     ? await db.organization.findUnique({
         where: { id: organizationId },
         select: { createdAt: true },
       })
     : null
-  return parseVehiclePageLayout(chosen, workshop?.createdAt ?? null)
+  return { layout: parseVehiclePageLayout(chosen, workshop?.createdAt ?? null), chosen: false }
 }

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.