Просмотр исходного кода

Customer concerns as rows, each answerable by a finding (#270)

* Record what the customer said, apart from what the shop says

A customer hands over the keys and says there is a noise from the back,
and there was nowhere to write that down as what it is. It went into the
description or the internal notes by convention, mixed in with everything
else, and nothing marked it as the customer's own words.

The trade calls these the three Cs: concern, cause, correction. Only the
first belongs to the customer, and only the first is something they can
later dispute, which is why folding it into a field the shop rewrites
loses the one account nobody else can give.

Written at intake, beside the vehicle and the mileage rather than in the
notes section, because that is the shop writing about its own work. Shown
first in the technician app, quoted, and read only there: a technician who
could edit it could make it agree with what they found.

The migration is created and deliberately not applied.

Also added to the backup import. The export sends whole rows but the
import names every column, so a field added to one and not the other is
dropped by every restore without a word. There is a test for that now,
and for the app being unable to write it.

* Move the customer's words to the top of the job, not the sidebar

* Concerns as rows, each answerable by a finding

* Shrink the empty concerns prompt to the size of its own text

* Put the concerns prompt and Store tires on one line

* One row per concern, like every other record on the page

* Call it the customer concern, the term the trade uses
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
b923219ac6
46 измененных файлов с 721 добавлено и 47 удалено
  1. 9 0
      messages/de/service.json
  2. 3 1
      messages/de/vehicles.json
  3. 9 0
      messages/en/service.json
  4. 3 1
      messages/en/vehicles.json
  5. 9 0
      messages/es/service.json
  6. 3 1
      messages/es/vehicles.json
  7. 9 0
      messages/fr/service.json
  8. 3 1
      messages/fr/vehicles.json
  9. 9 0
      messages/it/service.json
  10. 3 1
      messages/it/vehicles.json
  11. 9 0
      messages/lt/service.json
  12. 3 1
      messages/lt/vehicles.json
  13. 9 0
      messages/nb/service.json
  14. 3 1
      messages/nb/vehicles.json
  15. 9 0
      messages/nl/service.json
  16. 3 1
      messages/nl/vehicles.json
  17. 9 0
      messages/pl/service.json
  18. 3 1
      messages/pl/vehicles.json
  19. 9 0
      messages/pt-BR/service.json
  20. 3 1
      messages/pt-BR/vehicles.json
  21. 9 0
      messages/ru/service.json
  22. 3 1
      messages/ru/vehicles.json
  23. 9 0
      messages/tr/service.json
  24. 3 1
      messages/tr/vehicles.json
  25. 29 0
      prisma/migrations/20260829150000_service_concerns/migration.sql
  26. 44 0
      prisma/schema.prisma
  27. 100 0
      src/__tests__/features/service-concerns.test.ts
  28. 2 0
      src/app/api/protected/backup/export/route.ts
  29. 14 0
      src/app/api/protected/backup/import/route.ts
  30. 13 0
      src/app/api/v1/tech/jobs/[id]/route.ts
  31. 24 1
      src/features/vehicles/Actions/findingActions.ts
  32. 57 0
      src/features/vehicles/Actions/serviceActions.ts
  33. 10 7
      src/features/vehicles/Actions/vehicleActions.ts
  34. 30 0
      src/features/vehicles/Components/FindingForm.tsx
  35. 1 0
      src/features/vehicles/Components/service-detail/types.ts
  36. 129 0
      src/features/vehicles/Components/service-edit/ConcernsSection.tsx
  37. 3 0
      src/features/vehicles/Components/service-edit/form-types.ts
  38. 62 26
      src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx
  39. 4 0
      src/features/vehicles/Components/service-page/ObservationsManager.tsx
  40. 6 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  41. 5 0
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  42. 15 1
      src/features/vehicles/Components/service-page/useServiceActions.ts
  43. 4 0
      src/features/vehicles/Components/service-page/useServiceFormState.ts
  44. 8 0
      src/features/vehicles/Schema/findingSchema.ts
  45. 16 0
      src/features/vehicles/Schema/serviceSchema.ts
  46. 1 0
      src/lib/backup/manifest.ts

+ 9 - 0
messages/de/service.json

@@ -385,5 +385,14 @@
       "title": "Standard-Garantieeinstellungen",
       "description": "Standardgarantiebedingungen für neue Serviceeinträge festlegen"
     }
+  },
+  "concerns": {
+    "title": "Kundenanliegen",
+    "addFirst": "Kundenanliegen hinzufügen",
+    "add": "Weiteres hinzufügen",
+    "placeholder": "Beim Bremsen kommt hinten ein Geräusch",
+    "remove": "Dieses Anliegen entfernen",
+    "notAnswered": "Dazu wurde noch nichts festgestellt",
+    "answered": "{count} Feststellung(en) dazu"
   }
 }

+ 3 - 1
messages/de/vehicles.json

@@ -259,7 +259,9 @@
     "previousPage": "Previous page",
     "nextPage": "Next page",
     "lastPage": "Last page",
-    "openMenu": "Open menu"
+    "openMenu": "Open menu",
+    "concernLabel": "Beantwortet welches Anliegen?",
+    "concernNone": "Nichts, wonach der Kunde gefragt hat"
   },
   "observationsPage": {
     "searchPlaceholder": "Beschreibung, Notizen oder Fahrzeug suchen...",

+ 9 - 0
messages/en/service.json

@@ -385,5 +385,14 @@
       "title": "Default Warranty Settings",
       "description": "Set default warranty terms for new service records"
     }
+  },
+  "concerns": {
+    "title": "Customer concerns",
+    "addFirst": "Add a customer concern",
+    "add": "Add another",
+    "placeholder": "There is a noise from the back when I brake",
+    "remove": "Remove this concern",
+    "notAnswered": "Nothing found against this yet",
+    "answered": "{count} finding(s) against this"
   }
 }

+ 3 - 1
messages/en/vehicles.json

@@ -259,7 +259,9 @@
     "previousPage": "Previous page",
     "nextPage": "Next page",
     "lastPage": "Last page",
-    "openMenu": "Open menu"
+    "openMenu": "Open menu",
+    "concernLabel": "Answers which concern?",
+    "concernNone": "Not something the customer asked about"
   },
   "observationsPage": {
     "searchPlaceholder": "Search description, notes, or vehicle...",

+ 9 - 0
messages/es/service.json

@@ -385,5 +385,14 @@
       "title": "Configuración de garantía predeterminada",
       "description": "Establecer términos de garantía predeterminados para nuevos registros de servicio"
     }
+  },
+  "concerns": {
+    "title": "Motivos del cliente",
+    "addFirst": "Añadir un motivo del cliente",
+    "add": "Añadir otro",
+    "placeholder": "Hace un ruido detrás cuando freno",
+    "remove": "Quitar este punto",
+    "notAnswered": "Todavía no se ha encontrado nada",
+    "answered": "{count} hallazgo(s) sobre esto"
   }
 }

+ 3 - 1
messages/es/vehicles.json

@@ -259,7 +259,9 @@
     "previousPage": "Previous page",
     "nextPage": "Next page",
     "lastPage": "Last page",
-    "openMenu": "Open menu"
+    "openMenu": "Open menu",
+    "concernLabel": "¿Responde a qué punto?",
+    "concernNone": "No es algo que preguntara el cliente"
   },
   "observationsPage": {
     "searchPlaceholder": "Buscar descripción, notas o vehículo...",

+ 9 - 0
messages/fr/service.json

@@ -385,5 +385,14 @@
       "title": "Paramètres de garantie par défaut",
       "description": "Définir les conditions de garantie par défaut pour les nouveaux dossiers de service"
     }
+  },
+  "concerns": {
+    "title": "Demandes du client",
+    "addFirst": "Ajouter une demande du client",
+    "add": "En ajouter un autre",
+    "placeholder": "Il y a un bruit à l'arrière quand je freine",
+    "remove": "Retirer ce point",
+    "notAnswered": "Rien trouvé à ce sujet pour l'instant",
+    "answered": "{count} constat(s) à ce sujet"
   }
 }

+ 3 - 1
messages/fr/vehicles.json

@@ -259,7 +259,9 @@
     "previousPage": "Previous page",
     "nextPage": "Next page",
     "lastPage": "Last page",
-    "openMenu": "Open menu"
+    "openMenu": "Open menu",
+    "concernLabel": "Répond à quel point ?",
+    "concernNone": "Rien que le client ait signalé"
   },
   "observationsPage": {
     "searchPlaceholder": "Rechercher une description, des notes ou un véhicule...",

+ 9 - 0
messages/it/service.json

@@ -385,5 +385,14 @@
       "title": "Impostazioni garanzia predefinite",
       "description": "Imposta i termini di garanzia predefiniti per i nuovi interventi"
     }
+  },
+  "concerns": {
+    "title": "Segnalazioni del cliente",
+    "addFirst": "Aggiungi una segnalazione del cliente",
+    "add": "Aggiungine un altro",
+    "placeholder": "C'è un rumore dietro quando freno",
+    "remove": "Rimuovi questo punto",
+    "notAnswered": "Non è ancora stato trovato nulla",
+    "answered": "{count} riscontro/i su questo"
   }
 }

+ 3 - 1
messages/it/vehicles.json

@@ -259,7 +259,9 @@
     "previousPage": "Previous page",
     "nextPage": "Next page",
     "lastPage": "Last page",
-    "openMenu": "Open menu"
+    "openMenu": "Open menu",
+    "concernLabel": "A quale punto risponde?",
+    "concernNone": "Non è qualcosa che ha segnalato il cliente"
   },
   "observationsPage": {
     "searchPlaceholder": "Cerca descrizione, note o veicolo...",

+ 9 - 0
messages/lt/service.json

@@ -385,5 +385,14 @@
       "title": "Numatytieji garantijos nustatymai",
       "description": "Nustatyti numatytas garantijos sąlygas naujiems aptarnavimo įrašams"
     }
+  },
+  "concerns": {
+    "title": "Kliento pranešimai",
+    "addFirst": "Pridėti kliento pranešimą",
+    "add": "Pridėti dar vieną",
+    "placeholder": "Stabdant gale girdisi garsas",
+    "remove": "Pašalinti šį punktą",
+    "notAnswered": "Kol kas nieko nenustatyta",
+    "answered": "Nustatymų: {count}"
   }
 }

+ 3 - 1
messages/lt/vehicles.json

@@ -259,7 +259,9 @@
     "previousPage": "Previous page",
     "nextPage": "Next page",
     "lastPage": "Last page",
-    "openMenu": "Open menu"
+    "openMenu": "Open menu",
+    "concernLabel": "Kurį punktą atsako?",
+    "concernNone": "Klientas apie tai neklausė"
   },
   "observationsPage": {
     "searchPlaceholder": "Ieškoti pagal aprašymą, pastabas arba transporto priemonę...",

+ 9 - 0
messages/nb/service.json

@@ -385,5 +385,14 @@
       "title": "Standard garantiinnstillinger",
       "description": "Angi standard garantivilkår for nye serviceoppføringer"
     }
+  },
+  "concerns": {
+    "title": "Kundens beskrivelse",
+    "addFirst": "Legg til kundens beskrivelse",
+    "add": "Legg til en til",
+    "placeholder": "Det kommer en lyd bakfra når jeg bremser",
+    "remove": "Fjern dette punktet",
+    "notAnswered": "Ingenting funnet på dette ennå",
+    "answered": "{count} funn på dette"
   }
 }

+ 3 - 1
messages/nb/vehicles.json

@@ -259,7 +259,9 @@
     "previousPage": "Previous page",
     "nextPage": "Next page",
     "lastPage": "Last page",
-    "openMenu": "Open menu"
+    "openMenu": "Open menu",
+    "concernLabel": "Svarer på hvilket punkt?",
+    "concernNone": "Ikke noe kunden spurte om"
   },
   "observationsPage": {
     "searchPlaceholder": "Søk i beskrivelse, notater eller kjøretøy...",

+ 9 - 0
messages/nl/service.json

@@ -385,5 +385,14 @@
       "title": "Standaard garantie-instellingen",
       "description": "Stel standaard garantievoorwaarden in voor nieuwe serviceregistraties"
     }
+  },
+  "concerns": {
+    "title": "Klantmeldingen",
+    "addFirst": "Klantmelding toevoegen",
+    "add": "Nog een toevoegen",
+    "placeholder": "Er komt een geluid van achteren als ik rem",
+    "remove": "Dit punt verwijderen",
+    "notAnswered": "Hier is nog niets op gevonden",
+    "answered": "{count} bevinding(en) hierop"
   }
 }

+ 3 - 1
messages/nl/vehicles.json

@@ -259,7 +259,9 @@
     "previousPage": "Previous page",
     "nextPage": "Next page",
     "lastPage": "Last page",
-    "openMenu": "Open menu"
+    "openMenu": "Open menu",
+    "concernLabel": "Beantwoordt welk punt?",
+    "concernNone": "Niet iets waar de klant naar vroeg"
   },
   "observationsPage": {
     "searchPlaceholder": "Zoek in beschrijving, notities of voertuig...",

+ 9 - 0
messages/pl/service.json

@@ -385,5 +385,14 @@
       "title": "Domyślne ustawienia gwarancji",
       "description": "Ustaw domyślne warunki gwarancji dla nowych wpisów serwisowych"
     }
+  },
+  "concerns": {
+    "title": "Zgłoszenia klienta",
+    "addFirst": "Dodaj zgłoszenie klienta",
+    "add": "Dodaj kolejne",
+    "placeholder": "Przy hamowaniu słychać hałas z tyłu",
+    "remove": "Usuń ten punkt",
+    "notAnswered": "Nic jeszcze nie ustalono",
+    "answered": "Ustalenia: {count}"
   }
 }

+ 3 - 1
messages/pl/vehicles.json

@@ -259,7 +259,9 @@
     "previousPage": "Previous page",
     "nextPage": "Next page",
     "lastPage": "Last page",
-    "openMenu": "Open menu"
+    "openMenu": "Open menu",
+    "concernLabel": "Której sprawy dotyczy?",
+    "concernNone": "Klient o to nie pytał"
   },
   "observationsPage": {
     "searchPlaceholder": "Szukaj opisu, notatek lub pojazdu...",

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

@@ -385,5 +385,14 @@
       "title": "Configurações padrão de garantia",
       "description": "Definir termos de garantia padrão para novos registros de serviço"
     }
+  },
+  "concerns": {
+    "title": "Relatos do cliente",
+    "addFirst": "Adicionar um relato do cliente",
+    "add": "Adicionar outro",
+    "placeholder": "Faz um barulho atrás quando eu freio",
+    "remove": "Remover este item",
+    "notAnswered": "Nada encontrado sobre isso ainda",
+    "answered": "{count} constatação(ões) sobre isso"
   }
 }

+ 3 - 1
messages/pt-BR/vehicles.json

@@ -259,7 +259,9 @@
     "previousPage": "Previous page",
     "nextPage": "Next page",
     "lastPage": "Last page",
-    "openMenu": "Open menu"
+    "openMenu": "Open menu",
+    "concernLabel": "Responde a qual ponto?",
+    "concernNone": "Não é algo que o cliente relatou"
   },
   "observationsPage": {
     "searchPlaceholder": "Buscar descrição, notas ou veículo...",

+ 9 - 0
messages/ru/service.json

@@ -385,5 +385,14 @@
       "title": "Настройки гарантии по умолчанию",
       "description": "Установить условия гарантии по умолчанию для новых записей обслуживания"
     }
+  },
+  "concerns": {
+    "title": "Обращения клиента",
+    "addFirst": "Добавить обращение клиента",
+    "add": "Добавить ещё",
+    "placeholder": "При торможении сзади слышен шум",
+    "remove": "Удалить этот пункт",
+    "notAnswered": "По этому пункту пока ничего не найдено",
+    "answered": "Найдено по этому пункту: {count}"
   }
 }

+ 3 - 1
messages/ru/vehicles.json

@@ -259,7 +259,9 @@
     "previousPage": "Previous page",
     "nextPage": "Next page",
     "lastPage": "Last page",
-    "openMenu": "Open menu"
+    "openMenu": "Open menu",
+    "concernLabel": "К какому пункту относится?",
+    "concernNone": "Клиент об этом не спрашивал"
   },
   "observationsPage": {
     "searchPlaceholder": "Поиск по описанию, заметкам или транспорту...",

+ 9 - 0
messages/tr/service.json

@@ -385,5 +385,14 @@
       "title": "Varsayılan garanti ayarları",
       "description": "Yeni servis kayıtları için varsayılan garanti koşullarını ayarlayın"
     }
+  },
+  "concerns": {
+    "title": "Müşteri bildirimleri",
+    "addFirst": "Müşteri bildirimi ekle",
+    "add": "Bir tane daha ekle",
+    "placeholder": "Fren yapınca arkadan ses geliyor",
+    "remove": "Bu maddeyi kaldır",
+    "notAnswered": "Bu konuda henüz bir şey bulunmadı",
+    "answered": "Bu konuda {count} tespit"
   }
 }

+ 3 - 1
messages/tr/vehicles.json

@@ -259,7 +259,9 @@
     "previousPage": "Previous page",
     "nextPage": "Next page",
     "lastPage": "Last page",
-    "openMenu": "Open menu"
+    "openMenu": "Open menu",
+    "concernLabel": "Hangi maddeyi yanıtlıyor?",
+    "concernNone": "Müşterinin sorduğu bir şey değil"
   },
   "observationsPage": {
     "searchPlaceholder": "Açıklama, not veya araç ara...",

+ 29 - 0
prisma/migrations/20260829150000_service_concerns/migration.sql

@@ -0,0 +1,29 @@
+-- Concerns: what the customer asked about, one row each.
+--
+-- Additive only. Every existing job simply has no concerns, which reads
+-- correctly on every screen, so nothing needs backfilling and nothing that
+-- works today stops working.
+
+CREATE TABLE "service_concerns" (
+    "id" TEXT NOT NULL,
+    "description" TEXT NOT NULL,
+    "sortOrder" INTEGER NOT NULL DEFAULT 0,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "serviceRecordId" TEXT NOT NULL,
+
+    CONSTRAINT "service_concerns_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "service_concerns_serviceRecordId_idx" ON "service_concerns"("serviceRecordId");
+
+ALTER TABLE "service_concerns" ADD CONSTRAINT "service_concerns_serviceRecordId_fkey"
+    FOREIGN KEY ("serviceRecordId") REFERENCES "service_records"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- The cause half of the three Cs: a finding may answer a concern.
+ALTER TABLE "vehicle_findings" ADD COLUMN "concernId" TEXT;
+
+CREATE INDEX "vehicle_findings_concernId_idx" ON "vehicle_findings"("concernId");
+
+ALTER TABLE "vehicle_findings" ADD CONSTRAINT "vehicle_findings_concernId_fkey"
+    FOREIGN KEY ("concernId") REFERENCES "service_concerns"("id") ON DELETE SET NULL ON UPDATE CASCADE;

+ 44 - 0
prisma/schema.prisma

@@ -293,6 +293,7 @@ model ServiceRecord {
   stockMovements StockMovement[]
   timeEntries    TimeEntry[]
 
+  concerns           ServiceConcern[]
   discoveredFindings VehicleFinding[] @relation("FindingDiscoveredIn")
   resolvedFindings   VehicleFinding[] @relation("FindingResolvedIn")
 
@@ -372,6 +373,38 @@ model Reminder {
   @@map("reminders")
 }
 
+/// One thing the customer asked about, in their words.
+///
+/// A job is a list of these rather than a single note, because that is how the
+/// work actually divides: "it pulls right when braking" and "the aircon smells"
+/// are two conversations, two diagnoses and two lines on the bill. Storing them
+/// as one blob means the shop can answer one and quietly drop the other, which
+/// is the single most common reason a car comes back.
+///
+/// This is the first of the trade's three Cs. The cause is a VehicleFinding
+/// pointing back here, carrying its own notes and photographs; the correction
+/// is the labour and parts on the job. Together they let the invoice explain
+/// itself: you said this, we found that, we did this about it.
+model ServiceConcern {
+  id          String   @id @default(cuid())
+  /// The customer's own words, kept as they said them.
+  description String
+  /// The order the customer raised them in, which is the order they care about.
+  sortOrder   Int      @default(0)
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+
+  serviceRecordId String
+  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
+
+  /// What the shop found when it went looking. Empty means nobody has answered
+  /// this one yet, which is worth being able to ask about before closing a job.
+  findings VehicleFinding[]
+
+  @@index([serviceRecordId])
+  @@map("service_concerns")
+}
+
 model VehicleFinding {
   id          String   @id @default(cuid())
   description String
@@ -391,8 +424,19 @@ model VehicleFinding {
   resolvedServiceRecordId String?
   resolvedServiceRecord   ServiceRecord? @relation("FindingResolvedIn", fields: [resolvedServiceRecordId], references: [id], onDelete: SetNull)
 
+  /// The customer concern this finding answers, if it answers one.
+  ///
+  /// Nullable because plenty of findings are things nobody asked about: the
+  /// perished bush somebody noticed while the car was on the lift. Those are
+  /// worth just as much, they just are not part of the story the customer
+  /// started. SetNull rather than Cascade because a finding is a fact about
+  /// the vehicle and outlives the concern that prompted the look.
+  concernId String?
+  concern   ServiceConcern? @relation(fields: [concernId], references: [id], onDelete: SetNull)
+
   @@index([vehicleId])
   @@index([serviceRecordId])
+  @@index([concernId])
   @@map("vehicle_findings")
 }
 

+ 100 - 0
src/__tests__/features/service-concerns.test.ts

@@ -0,0 +1,100 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { createServiceSchema, serviceConcernSchema } from '@/features/vehicles/Schema/serviceSchema'
+import { BACKUP_ENTITIES } from '@/lib/backup/manifest'
+
+const read = (p: string) => fs.readFileSync(path.join(process.cwd(), p), 'utf-8')
+
+/**
+ * What the customer asked about, kept apart from what the shop says, and kept
+ * as rows rather than one note.
+ *
+ * The trade calls these the three Cs: concern, cause, correction. Only the
+ * first belongs to the customer, and only the first is something they can
+ * later dispute, which is why the shop cannot rewrite it from the app. Rows
+ * rather than a blob because answering one concern and quietly dropping the
+ * other is the most common reason a car comes back.
+ */
+describe('service concerns', () => {
+  it('accepts a list of concerns, and the list is optional', () => {
+    const base = { title: 'Brakes', vehicleId: 'v1' }
+    expect(createServiceSchema.safeParse(base).success).toBe(true)
+
+    const parsed = createServiceSchema.safeParse({
+      ...base,
+      concerns: [
+        { description: 'Noise from the back when I brake', sortOrder: 0 },
+        { description: 'Aircon smells', sortOrder: 1 },
+      ],
+    })
+    expect(parsed.success).toBe(true)
+    expect(parsed.success ? parsed.data.concerns?.length : 0).toBe(2)
+  })
+
+  it('rejects an empty concern rather than storing a blank row', () => {
+    expect(serviceConcernSchema.safeParse({ description: '' }).success).toBe(false)
+  })
+
+  it('carries an optional id, which is what keeps findings attached', () => {
+    // Parts and labour are deleted and rewritten on every save. Concerns
+    // cannot be, because VehicleFinding.concernId is SetNull: replacing the
+    // list wholesale would cut every diagnosis loose from its question.
+    const parsed = serviceConcernSchema.safeParse({ description: 'Pulls right', id: 'c1' })
+    expect(parsed.success).toBe(true)
+    expect(parsed.success ? parsed.data.id : null).toBe('c1')
+  })
+
+  it('reconciles concerns by id on update instead of replacing them', () => {
+    const actions = read('src/features/vehicles/Actions/serviceActions.ts')
+    // The guard rail for the bug above: if this ever becomes a deleteMany over
+    // the whole job again, findings lose their concern on the next autosave.
+    expect(actions).not.toMatch(/serviceConcern\.deleteMany\(\{\s*where:\s*\{\s*serviceRecordId/)
+    expect(actions).toContain('serviceConcern.updateMany')
+  })
+
+  it('is exported and restored by backups', () => {
+    // A nested entity missing from the manifest is one the restore drops
+    // without a word, which is how clocked hours were lost once already.
+    const entity = BACKUP_ENTITIES.find((e) => e.model === 'ServiceConcern')
+    expect(entity, 'ServiceConcern must be in the backup manifest').toBeDefined()
+    expect(entity?.nestedUnder).toBe('ServiceRecord')
+
+    const exportRoute = read('src/app/api/protected/backup/export/route.ts')
+    // Both paths: vehicle-linked jobs and counter sales.
+    expect(exportRoute.match(/concerns: true/g)?.length).toBe(2)
+
+    const importRoute = read('src/app/api/protected/backup/import/route.ts')
+    expect(importRoute).toContain('serviceConcern.createMany')
+  })
+
+  it('restores concerns before the findings that point at them', () => {
+    const importRoute = read('src/app/api/protected/backup/import/route.ts')
+    const concerns = importRoute.indexOf('serviceConcern.createMany')
+    const findings = importRoute.indexOf('vehicleFinding.createMany')
+    expect(concerns).toBeGreaterThan(-1)
+    if (findings > -1) expect(concerns).toBeLessThan(findings)
+  })
+
+  it('reaches the technician app with the findings against each one', () => {
+    const route = read('src/app/api/v1/tech/jobs/[id]/route.ts')
+    expect(route).toContain('concerns:')
+    expect(route).toMatch(/concerns:[\s\S]{0,400}findings:/)
+  })
+
+  it('is not writable from the technician app', () => {
+    // Read only there on purpose: the value of a concern is that it is the
+    // customer's account and not the shop's, so a technician who could edit it
+    // could make it agree with whatever they found.
+    const techRoutes = [
+      'jobs/[id]/notes',
+      'jobs/[id]/labor',
+      'jobs/[id]/status',
+      'jobs/[id]/findings',
+    ]
+    for (const r of techRoutes) {
+      const src = read(`src/app/api/v1/tech/${r}/route.ts`)
+      expect(src, `${r} must not write concerns`).not.toMatch(/serviceConcern\s*\./)
+    }
+  })
+})

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

@@ -128,6 +128,7 @@ export async function POST(request: NextRequest) {
             serviceRequests: true,
             serviceRecords: {
               include: {
+                concerns: true,
                 partItems: true,
                 laborItems: true,
                 attachments: true,
@@ -164,6 +165,7 @@ export async function POST(request: NextRequest) {
         .findMany({
           where: { organizationId: ctx.organizationId, vehicleId: null },
           include: {
+            concerns: true,
             partItems: true,
             laborItems: true,
             attachments: true,

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

@@ -152,6 +152,20 @@ async function importServiceRecordTree(
     })
   }
 
+  // Concerns. Restored before findings so a finding's concernId still has a
+  // row to point at.
+  const concerns = sr.concerns as Record<string, unknown>[] | undefined
+  if (concerns?.length) {
+    await tx.serviceConcern.createMany({
+      data: concerns.map((c, index) => ({
+        id: c.id as string,
+        description: c.description as string,
+        sortOrder: (c.sortOrder as number) ?? index,
+        serviceRecordId: sr.id as string,
+      })),
+    })
+  }
+
   // Service labor
   const laborItems = sr.laborItems as Record<string, unknown>[] | undefined
   if (laborItems?.length) {

+ 13 - 0
src/app/api/v1/tech/jobs/[id]/route.ts

@@ -27,6 +27,19 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
           id: true,
           title: true,
           description: true,
+          // Read only in the app: the value of these is that they are the
+          // customer's account, not the shop's. Ordered the way the customer
+          // raised them, with whatever the shop has found against each one.
+          concerns: {
+            orderBy: { sortOrder: 'asc' },
+            select: {
+              id: true,
+              description: true,
+              findings: {
+                select: { id: true, description: true, severity: true, status: true },
+              },
+            },
+          },
           status: true,
           diagnosticNotes: true,
           mileage: true,

+ 24 - 1
src/features/vehicles/Actions/findingActions.ts

@@ -173,6 +173,24 @@ export async function getVehicleFindings(
   )
 }
 
+/**
+ * A concern id is only usable if it belongs to this workshop.
+ *
+ * Without this check the id travels straight from the client into a foreign
+ * key, and a finding on one workshop's vehicle could be pointed at another
+ * workshop's concern. Nothing renders it across the boundary, but it is a
+ * cross-organization write, and those do not get to depend on the UI for
+ * their safety.
+ */
+async function assertConcernInOrg(concernId: string | null | undefined, organizationId: string) {
+  if (!concernId) return
+  const concern = await db.serviceConcern.findFirst({
+    where: { id: concernId, serviceRecord: { organizationId } },
+    select: { id: true },
+  })
+  if (!concern) throw new Error('Concern not found')
+}
+
 export async function createFinding(input: unknown) {
   return withAuth(
     async ({ organizationId }) => {
@@ -181,6 +199,7 @@ export async function createFinding(input: unknown) {
         where: { id: data.vehicleId, organizationId },
       })
       if (!vehicle) throw new Error('Vehicle not found')
+      await assertConcernInOrg(data.concernId, organizationId)
 
       const finding = await db.vehicleFinding.create({ data })
       revalidatePath(`/vehicles/${data.vehicleId}`)
@@ -198,7 +217,10 @@ export async function createFinding(input: unknown) {
         action: 'finding.create',
         entity: 'VehicleFinding',
         entityId: result.id,
-        details: { key: 'finding_create', params: { description: result.description, vehicleId: result.vehicleId } },
+        details: {
+          key: 'finding_create',
+          params: { description: result.description, vehicleId: result.vehicleId },
+        },
         metadata: { findingId: result.id, vehicleId: result.vehicleId },
       }),
     }
@@ -213,6 +235,7 @@ export async function updateFinding(input: unknown) {
         where: { id, vehicle: { organizationId } },
       })
       if (!finding) throw new Error('Finding not found')
+      await assertConcernInOrg(data.concernId, organizationId)
 
       const updated = await db.vehicleFinding.update({
         where: { id },

+ 57 - 0
src/features/vehicles/Actions/serviceActions.ts

@@ -67,6 +67,7 @@ export async function getServiceRecordsPaginated(
         where.OR = [
           { title: { contains: params.search, mode: 'insensitive' } },
           { description: { contains: params.search, mode: 'insensitive' } },
+          { concerns: { some: { description: { contains: params.search, mode: 'insensitive' } } } },
           { diagnosticNotes: { contains: params.search, mode: 'insensitive' } },
           { techName: { contains: params.search, mode: 'insensitive' } },
           { shopName: { contains: params.search, mode: 'insensitive' } },
@@ -201,6 +202,7 @@ export async function getServiceRecord(recordId: string) {
       const record = await db.serviceRecord.findFirst({
         where: { id: recordId, organizationId },
         include: {
+          concerns: { orderBy: { sortOrder: 'asc' } },
           partItems: true,
           laborItems: true,
           attachments: true,
@@ -366,6 +368,7 @@ export async function createServiceRecord(input: unknown) {
       }
 
       const {
+        concerns,
         partItems,
         laborItems,
         attachments,
@@ -444,6 +447,16 @@ export async function createServiceRecord(input: unknown) {
           })
         }
 
+        if (concerns && concerns.length > 0) {
+          await tx.serviceConcern.createMany({
+            data: concerns.map((concern, index) => ({
+              description: concern.description,
+              sortOrder: concern.sortOrder ?? index,
+              serviceRecordId: created.id,
+            })),
+          })
+        }
+
         if (laborItems && laborItems.length > 0) {
           await tx.serviceLabor.createMany({
             data: laborItems.map((l) => ({
@@ -536,6 +549,7 @@ export async function updateServiceRecord(input: unknown) {
         id,
         partItems,
         laborItems,
+        concerns,
         attachments,
         customerId: _cid,
         serviceDate: _sd,
@@ -660,6 +674,49 @@ export async function updateServiceRecord(input: unknown) {
           })
         }
 
+        /**
+         * Reconcile concerns by id, rather than replacing them like the rows
+         * above.
+         *
+         * Parts and labour can be deleted and rewritten on every save because
+         * nothing points at them. Findings point at concerns, and the relation
+         * is SetNull, so deleting the whole list on an autosave would silently
+         * cut every diagnosis loose from the question it answered. The rows
+         * that survive an edit have to keep their ids.
+         */
+        if (concerns !== undefined) {
+          const existingConcerns = await tx.serviceConcern.findMany({
+            where: { serviceRecordId: id },
+            select: { id: true },
+          })
+          const keptIds = new Set(
+            concerns.map((concern) => concern.id).filter((cid): cid is string => Boolean(cid))
+          )
+
+          const removedIds = existingConcerns
+            .map((concern) => concern.id)
+            .filter((cid) => !keptIds.has(cid))
+          if (removedIds.length > 0) {
+            await tx.serviceConcern.deleteMany({ where: { id: { in: removedIds } } })
+          }
+
+          for (const [index, concern] of concerns.entries()) {
+            const sortOrder = concern.sortOrder ?? index
+            // An id the client sent that is not on this job is not ours to
+            // trust: fall through to a create rather than updating by id.
+            if (concern.id && keptIds.has(concern.id)) {
+              const { count } = await tx.serviceConcern.updateMany({
+                where: { id: concern.id, serviceRecordId: id },
+                data: { description: concern.description, sortOrder },
+              })
+              if (count > 0) continue
+            }
+            await tx.serviceConcern.create({
+              data: { description: concern.description, sortOrder, serviceRecordId: id },
+            })
+          }
+        }
+
         // Replace labor if provided
         if (laborItems !== undefined) {
           await tx.serviceLabor.deleteMany({ where: { serviceRecordId: id } })

+ 10 - 7
src/features/vehicles/Actions/vehicleActions.ts

@@ -8,7 +8,7 @@ import { createVehicleSchema, updateVehicleSchema } from '../Schema/vehicleSchem
 import { revalidatePath } from 'next/cache'
 import { unlink } from 'fs/promises'
 import { resolveUploadPath } from '@/lib/resolve-upload-path'
-import { auditDetails } from "@/lib/audit";
+import { auditDetails } from '@/lib/audit'
 
 export async function getVehicles() {
   return withAuth(
@@ -195,7 +195,10 @@ export async function createVehicle(input: unknown) {
         action: 'vehicle.create',
         entity: 'Vehicle',
         entityId: result.id,
-        details: { key: 'vehicle_create', params: { year: result.year, make: result.make, model: result.model } },
+        details: {
+          key: 'vehicle_create',
+          params: { year: result.year, make: result.make, model: result.model },
+        },
         metadata: { vehicleId: result.id },
       }),
     }
@@ -263,11 +266,11 @@ export async function updateVehicle(input: unknown) {
         entity: 'Vehicle',
         entityId: result.id,
         details: result.fields.length
-        ? auditDetails("vehicle_update", {
-            name: result.vehicleDisplay,
-            fields: result.fields.join(", "),
-          })
-        : auditDetails("vehicle_updateNoChanges", { name: result.vehicleDisplay }),
+          ? auditDetails('vehicle_update', {
+              name: result.vehicleDisplay,
+              fields: result.fields.join(', '),
+            })
+          : auditDetails('vehicle_updateNoChanges', { name: result.vehicleDisplay }),
         metadata: {
           vehicleId: result.id,
           vehicleDisplay: result.vehicleDisplay,

+ 30 - 0
src/features/vehicles/Components/FindingForm.tsx

@@ -27,6 +27,7 @@ interface FindingData {
   status: string
   notes: string | null
   serviceRecordId?: string | null
+  concernId?: string | null
 }
 
 interface FindingFormProps {
@@ -35,6 +36,8 @@ interface FindingFormProps {
   onOpenChange: (open: boolean) => void
   finding?: FindingData
   serviceRecordId?: string
+  /** The concerns on this job, so a finding can say which one it answers. */
+  concerns?: { id: string; description: string }[]
 }
 
 export function FindingForm({
@@ -43,6 +46,7 @@ export function FindingForm({
   onOpenChange,
   finding,
   serviceRecordId,
+  concerns = [],
 }: FindingFormProps) {
   const router = useRouter()
   const modal = useGlassModal()
@@ -52,6 +56,8 @@ export function FindingForm({
   const [description, setDescription] = useState('')
   const [severity, setSeverity] = useState('needs_work')
   const [notes, setNotes] = useState('')
+  // 'none' rather than '' because Radix treats an empty value as unset.
+  const [concernId, setConcernId] = useState('none')
 
   const isEdit = !!finding
 
@@ -60,10 +66,12 @@ export function FindingForm({
       setDescription(finding.description)
       setSeverity(finding.severity)
       setNotes(finding.notes || '')
+      setConcernId(finding.concernId || 'none')
     } else if (open) {
       setDescription('')
       setSeverity('needs_work')
       setNotes('')
+      setConcernId('none')
     }
   }, [open, finding])
 
@@ -77,6 +85,7 @@ export function FindingForm({
       severity: severity as 'needs_work' | 'monitor' | 'urgent',
       notes: notes || undefined,
       serviceRecordId: serviceRecordId || undefined,
+      concernId: concernId === 'none' ? null : concernId,
     }
 
     const result = isEdit
@@ -127,6 +136,27 @@ export function FindingForm({
             </Select>
           </div>
 
+          {/* Which question this answers. Only on a job that has concerns:
+              on a vehicle-level observation there is nothing to answer. */}
+          {concerns.length > 0 && (
+            <div className="space-y-2">
+              <Label htmlFor="finding-concern">{t('concernLabel')}</Label>
+              <Select value={concernId} onValueChange={setConcernId}>
+                <SelectTrigger id="finding-concern">
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="none">{t('concernNone')}</SelectItem>
+                  {concerns.map((concern) => (
+                    <SelectItem key={concern.id} value={concern.id}>
+                      {concern.description}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+            </div>
+          )}
+
           <div className="space-y-2">
             <Label htmlFor="finding-notes">{t('notesLabel')}</Label>
             <Textarea

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

@@ -37,6 +37,7 @@ export interface ServiceDetail {
   workBayId: string | null
   parts: string | null
   laborHours: number | null
+  concerns: { id: string; description: string; sortOrder: number }[]
   diagnosticNotes: string | null
   invoiceNotes: string | null
   subtotal: number

+ 129 - 0
src/features/vehicles/Components/service-edit/ConcernsSection.tsx

@@ -0,0 +1,129 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { MessageSquareQuote, Plus, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import type { ServiceConcernInput } from '@/features/vehicles/Schema/serviceSchema'
+
+/**
+ * What the customer asked about, at the top of the working column.
+ *
+ * A list rather than one box, because that is how the work divides: "it pulls
+ * right when braking" and "the aircon smells" are two conversations, two
+ * diagnoses and two lines on the bill. The trade calls this the first of the
+ * three Cs, and every serious shop system stores it as rows for the same
+ * reason. Answering one and quietly dropping the other is the most common
+ * reason a car comes back.
+ *
+ * Prominence follows content, but never at the cost of density: this is a page
+ * where every other row is a record, so a concern is one row too. Empty, it is
+ * a single quiet line inviting the first one. An always-empty box at the top of
+ * every job teaches people to look past it, which is the one outcome that kills
+ * the feature.
+ */
+interface ConcernsSectionProps {
+  concerns: ServiceConcernInput[]
+  setConcerns: (concerns: ServiceConcernInput[]) => void
+  onChange: () => void
+  /** Findings already recorded against a concern, keyed by concern id. */
+  answeredCounts?: Record<string, number>
+}
+
+export function ConcernsSection({
+  concerns,
+  setConcerns,
+  onChange,
+  answeredCounts = {},
+}: ConcernsSectionProps) {
+  const t = useTranslations('service.concerns')
+
+  const add = () => {
+    setConcerns([...concerns, { description: '', sortOrder: concerns.length }])
+    onChange()
+  }
+
+  const update = (index: number, description: string) => {
+    setConcerns(concerns.map((c, i) => (i === index ? { ...c, description } : c)))
+    onChange()
+  }
+
+  const remove = (index: number) => {
+    setConcerns(concerns.filter((_, i) => i !== index))
+    onChange()
+  }
+
+  // Empty, this is an offer, not a container. Full width it read as a broken
+  // card sitting above the work; sized to its own text it reads as the small
+  // action it is, and costs one line until somebody actually types something.
+  if (concerns.length === 0) {
+    return (
+      <button
+        type="button"
+        onClick={add}
+        className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground"
+      >
+        <MessageSquareQuote className="h-3.5 w-3.5 shrink-0" />
+        {t('addFirst')}
+      </button>
+    )
+  }
+
+  // One line per concern, the way parts and labour get one line each. This
+  // started as a two-row textarea with the answered state on a line of its own
+  // underneath, which spent four rows of the page on one sentence. On a screen
+  // where every other row is a record, a concern is a record too.
+  return (
+    <div className="space-y-1.5 rounded-lg border border-l-4 border-l-primary/60 px-3 py-2">
+      <div className="flex items-center justify-between gap-2">
+        <div className="flex items-center gap-1.5">
+          <MessageSquareQuote className="h-3.5 w-3.5 text-muted-foreground" />
+          <h3 className="font-medium text-sm">{t('title')}</h3>
+        </div>
+        <Button
+          type="button"
+          variant="ghost"
+          size="icon"
+          onClick={add}
+          aria-label={t('add')}
+          className="h-6 w-6 shrink-0 text-muted-foreground"
+        >
+          <Plus className="h-3.5 w-3.5" />
+        </Button>
+      </div>
+
+      {concerns.map((concern, index) => {
+        const answered = concern.id ? (answeredCounts[concern.id] ?? 0) : 0
+        return (
+          <div key={concern.id ?? `new-${index}`} className="flex items-center gap-2">
+            <Input
+              value={concern.description}
+              placeholder={t('placeholder')}
+              onChange={(e) => update(index, e.target.value)}
+              className="h-8 flex-1 italic"
+              aria-label={t('title')}
+            />
+            {/* Whether anybody has answered this one yet. The point of keeping
+                concerns apart is being able to see the one nobody looked at,
+                so it stays on the row rather than costing a line of its own. */}
+            <span
+              className={`shrink-0 text-xs ${answered > 0 ? 'text-muted-foreground' : 'text-amber-600 dark:text-amber-500'}`}
+            >
+              {answered > 0 ? t('answered', { count: answered }) : t('notAnswered')}
+            </span>
+            <Button
+              type="button"
+              variant="ghost"
+              size="icon"
+              onClick={() => remove(index)}
+              aria-label={t('remove')}
+              className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
+            >
+              <X className="h-3.5 w-3.5" />
+            </Button>
+          </div>
+        )
+      })}
+    </div>
+  )
+}

+ 3 - 0
src/features/vehicles/Components/service-edit/form-types.ts

@@ -1,4 +1,5 @@
 import type {
+  ServiceConcernInput,
   ServicePartInput,
   ServiceLaborInput,
   ServiceAttachmentInput,
@@ -21,6 +22,7 @@ export interface InitialData {
   id: string
   title: string
   description: string
+  /** What the customer said at drop-off, in their words. */
   type: string
   status: string
   mileage: number | null
@@ -33,6 +35,7 @@ export interface InitialData {
   invoiceNumber?: string
   invoiceDate: string
   invoiceDueDate: string
+  concerns: ServiceConcernInput[]
   partItems: ServicePartInput[]
   laborItems: ServiceLaborInput[]
   attachments: (ServiceAttachmentInput & { includeInInvoice?: boolean })[]

+ 62 - 26
src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx

@@ -1,5 +1,6 @@
 import { PartsEditor } from '../service-edit/PartsEditor'
 import { LaborEditor } from '../service-edit/LaborEditor'
+import { ConcernsSection } from '../service-edit/ConcernsSection'
 import { NotesSection } from '../service-edit/NotesSection'
 import { PaymentsSection } from '../service-detail/PaymentsSection'
 import { InvoiceSummary } from '../service-detail/InvoiceSummary'
@@ -39,6 +40,7 @@ interface DetailsLeftColumnProps {
     severity: string
     status: string
     notes: string | null
+    concernId?: string | null
   }[]
   onAddFinding?: () => void
   onEditFinding?: (finding: {
@@ -76,35 +78,69 @@ export function DetailsLeftColumn({
   openObservationsCount = 0,
   onShowExistingObservations,
 }: DetailsLeftColumnProps) {
+  // Which concerns somebody has actually looked at. Counted here rather than
+  // queried, because the findings are already loaded for the section below.
+  const answeredCounts = findings.reduce<Record<string, number>>((counts, finding) => {
+    if (finding.concernId) counts[finding.concernId] = (counts[finding.concernId] ?? 0) + 1
+    return counts
+  }, {})
+
+  const concerns = (
+    <ConcernsSection
+      concerns={formState.concerns}
+      setConcerns={formState.setConcerns}
+      onChange={formState.markDirty}
+      answeredCounts={answeredCounts}
+    />
+  )
+
+  // No set on this job yet. The tires that came off the car are standing in the
+  // corner while the desk writes it up, so the offer to store them belongs here
+  // rather than three screens away.
+  const storeTires =
+    !tireSet && tireHotelEnabled && record.vehicle ? (
+      <StoreTiresButton
+        serviceRecordId={record.id}
+        vehicle={{
+          id: record.vehicle.id,
+          make: record.vehicle.make,
+          model: record.vehicle.model,
+          year: record.vehicle.year,
+          licensePlate: record.vehicle.licensePlate ?? null,
+          customerId: record.customer?.id ?? null,
+        }}
+        imperial={unitSystem === 'imperial'}
+        thresholds={tireThresholds}
+      />
+    ) : null
+
+  // Two short things, one line. Stacked, an empty concerns prompt and a lone
+  // Store tires button read as two abandoned rows above the parts; side by
+  // side they read as the toolbar they actually are. Once somebody types a
+  // concern the block needs the full width, so it gets its own row back.
+  const pairable = formState.concerns.length === 0
+
   return (
     <div className="space-y-3">
-      {/* Above the parts, inside the working column: the tires are the first
-          thing this job needs and the last thing the invoice sidebar cares
-          about, so it belongs here rather than spanning both. */}
-      {tireSet ? (
-        <TireSetBanner set={tireSet} serviceRecordId={record.id} thresholds={tireThresholds} />
+      {/* First thing on the job, above the work itself: why the car is here,
+          in the customer's words. Above the parts, inside the working column:
+          the tires are the first thing this job needs and the last thing the
+          invoice sidebar cares about, so they belong here rather than spanning
+          both columns. */}
+      {pairable ? (
+        <div className="flex items-center justify-between gap-2">
+          {concerns}
+          {storeTires}
+        </div>
       ) : (
-        // No set on this job yet. The tires that came off the car are standing
-        // in the corner while the desk writes it up, so the offer to store
-        // them belongs here rather than three screens away.
-        tireHotelEnabled &&
-        record.vehicle && (
-          <div className="flex justify-end">
-            <StoreTiresButton
-              serviceRecordId={record.id}
-              vehicle={{
-                id: record.vehicle.id,
-                make: record.vehicle.make,
-                model: record.vehicle.model,
-                year: record.vehicle.year,
-                licensePlate: record.vehicle.licensePlate ?? null,
-                customerId: record.customer?.id ?? null,
-              }}
-              imperial={unitSystem === 'imperial'}
-              thresholds={tireThresholds}
-            />
-          </div>
-        )
+        <>
+          {concerns}
+          {storeTires && <div className="flex justify-end">{storeTires}</div>}
+        </>
+      )}
+
+      {tireSet && (
+        <TireSetBanner set={tireSet} serviceRecordId={record.id} thresholds={tireThresholds} />
       )}
 
       <PartsEditor

+ 4 - 0
src/features/vehicles/Components/service-page/ObservationsManager.tsx

@@ -37,6 +37,8 @@ interface ObservationsManagerProps {
   onAddObservations: (selectedIds: string[]) => Promise<void>
   addingObservations: boolean
   onControlsReady: (controls: ObservationsControls) => void
+  /** The concerns on this job, so a finding can say which one it answers. */
+  concerns?: { id: string; description: string }[]
 }
 
 export function ObservationsManager({
@@ -46,6 +48,7 @@ export function ObservationsManager({
   onAddObservations,
   addingObservations,
   onControlsReady,
+  concerns = [],
 }: ObservationsManagerProps) {
   const tf = useTranslations('vehicles.findings')
   const [openFindingForm, setOpenFindingForm] = useState(false)
@@ -90,6 +93,7 @@ export function ObservationsManager({
         open={openFindingForm}
         onOpenChange={setOpenFindingForm}
         finding={editingFinding}
+        concerns={concerns}
       />
 
       <Dialog open={showExistingDialog} onOpenChange={setShowExistingDialog}>

+ 6 - 0
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -396,6 +396,12 @@ export function ServicePageClient({
               openObservations={openObservations}
               onAddObservations={handleAddObservationsToWorkOrder}
               addingObservations={addingObservations}
+              // Saved concerns only: a row still being typed has no id yet, so
+              // there is nothing a finding could point at.
+              concerns={formState.concerns.filter(
+                (c): c is { id: string; description: string; sortOrder: number } =>
+                  Boolean(c.id) && Boolean(c.description.trim())
+              )}
               onControlsReady={(c) => {
                 obsControlsRef.current = c
               }}

+ 5 - 0
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -174,6 +174,11 @@ export async function ServiceRecordPage({
             .toISOString()
             .split('T')[0]
         : '',
+    concerns: record.concerns.map((c) => ({
+      id: c.id,
+      description: c.description,
+      sortOrder: c.sortOrder,
+    })),
     partItems: record.partItems.map((p) => ({
       partNumber: p.partNumber || '',
       name: p.name,

+ 15 - 1
src/features/vehicles/Components/service-page/useServiceActions.ts

@@ -51,6 +51,7 @@ export function useServiceActions({
     selectedVehicleId,
     type,
     status,
+    concerns,
     partItems,
     laborItems,
     subtotal,
@@ -102,7 +103,13 @@ export function useServiceActions({
     // formData.get() returns the first (hidden/stale) one, so read the visible input via offsetParent.
     // For hidden inputs (offsetParent is always null), take the last one in DOM order.
     const getVisible = (name: string) => {
-      const inputs = Array.from(form.querySelectorAll<HTMLInputElement>(`input[name="${name}"]`))
+      // Textareas too. This read only ever looked at inputs, so a multi-line
+      // field would have submitted nothing at all and done it quietly.
+      const inputs = Array.from(
+        form.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(
+          `input[name="${name}"], textarea[name="${name}"]`
+        )
+      )
       const visible = inputs.find((el) => el.offsetParent !== null)
       if (visible) return visible.value
       // All hidden — take last (active layout renders second)
@@ -117,6 +124,9 @@ export function useServiceActions({
       vehicleId: selectedVehicleId,
       title: getVisible('title'),
       description: notesRef.current.description || undefined,
+      // What the customer said at drop-off. Typed at intake beside the vehicle,
+      // not in the notes section, so it is read from the form rather than from
+      // the notes state.
       type,
       status,
       cost: totalAmount,
@@ -131,6 +141,10 @@ export function useServiceActions({
       invoiceNumber: getVisible('invoiceNumber') || undefined,
       invoiceDate: getVisible('invoiceDate') || undefined,
       invoiceDueDate: getVisible('invoiceDueDate') || undefined,
+      // Blank rows are somebody halfway through typing, not a concern.
+      concerns: concerns
+        .filter((c) => c.description.trim())
+        .map((c, index) => ({ ...c, description: c.description.trim(), sortOrder: index })),
       partItems: partItems.filter((p) => p.name),
       laborItems: laborItems.filter((l) => l.description),
       subtotal,

+ 4 - 0
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -5,6 +5,7 @@ import {
   markupFromCostAndPrice,
   priceFromCostAndMarkup,
 } from '@/features/inventory/Lib/partPricing'
+import type { ServiceConcernInput } from '@/features/vehicles/Schema/serviceSchema'
 import type { ServicePartInput, ServiceLaborInput, InitialData } from './service-page-types'
 import type { ServiceDetail } from '../service-detail/types'
 
@@ -28,6 +29,7 @@ export function useServiceFormState({
   const [techName] = useState(initialData.techName || currentUserName)
   const [type, setType] = useState(initialData.type || 'maintenance')
   const [status, setStatus] = useState(initialData.status || 'completed')
+  const [concerns, setConcerns] = useState<ServiceConcernInput[]>(initialData.concerns || [])
   const [partItems, setPartItems] = useState<ServicePartInput[]>(initialData.partItems || [])
   const [laborItems, setLaborItems] = useState<ServiceLaborInput[]>(initialData.laborItems || [])
   const [taxRate, setTaxRate] = useState(initialData.taxRate ?? defaultTaxRate)
@@ -297,6 +299,8 @@ export function useServiceFormState({
     techName,
     type,
     status,
+    concerns,
+    setConcerns,
     partItems,
     laborItems,
     taxRate,

+ 8 - 0
src/features/vehicles/Schema/findingSchema.ts

@@ -6,6 +6,14 @@ export const createFindingSchema = z.object({
   severity: z.enum(['needs_work', 'monitor', 'urgent']).default('needs_work'),
   notes: z.string().optional(),
   serviceRecordId: z.string().optional(),
+  /**
+   * The customer concern this finding answers.
+   *
+   * Nullable rather than absent, because "this answers nothing the customer
+   * asked about" is a real answer: it is the perished bush somebody spotted
+   * while the car was on the lift, and it is worth just as much.
+   */
+  concernId: z.string().nullish(),
 })
 
 export type CreateFindingInput = z.infer<typeof createFindingSchema>

+ 16 - 0
src/features/vehicles/Schema/serviceSchema.ts

@@ -31,6 +31,20 @@ export const serviceAttachmentSchema = z.object({
   includeInInvoice: z.boolean().default(true),
 })
 
+/**
+ * One thing the customer asked about.
+ *
+ * The id is what separates this from the parts and labour arrays beside it.
+ * Those are replaced wholesale on every save, which is fine because nothing
+ * points at them. Findings point at concerns, so a concern that survives an
+ * edit has to keep its id or the link from its diagnosis is quietly cut.
+ */
+export const serviceConcernSchema = z.object({
+  id: z.string().optional(),
+  description: z.string().min(1, 'Concern is required'),
+  sortOrder: z.coerce.number().int().min(0).default(0),
+})
+
 export const createServiceSchema = z.object({
   // null = parts-only / counter sale (no vehicle); customerId is required then
   vehicleId: z.string().nullable(),
@@ -48,6 +62,7 @@ export const createServiceSchema = z.object({
   laborHours: z.coerce.number().optional(),
   diagnosticNotes: z.string().optional(),
   invoiceNotes: z.string().optional(),
+  concerns: z.array(serviceConcernSchema).optional(),
   partItems: z.array(servicePartSchema).optional(),
   laborItems: z.array(serviceLaborSchema).optional(),
   attachments: z.array(serviceAttachmentSchema).optional(),
@@ -72,6 +87,7 @@ export const updateServiceSchema = createServiceSchema.partial().extend({
 })
 
 export type ServiceAttachmentInput = z.infer<typeof serviceAttachmentSchema>
+export type ServiceConcernInput = z.infer<typeof serviceConcernSchema>
 export type ServicePartInput = z.infer<typeof servicePartSchema>
 export type ServiceLaborInput = z.infer<typeof serviceLaborSchema>
 export type CreateServiceInput = z.infer<typeof createServiceSchema>

+ 1 - 0
src/lib/backup/manifest.ts

@@ -77,6 +77,7 @@ export const BACKUP_ENTITIES: readonly BackupEntity[] = [
     restore: 'replace',
     clearOrder: 48,
   },
+  { model: 'ServiceConcern', option: 'vehicles', nestedUnder: 'ServiceRecord', restore: 'replace' },
   { model: 'StatusReport', option: 'vehicles', nestedUnder: 'ServiceRecord', restore: 'replace' },
   { model: 'TimeEntry', option: 'vehicles', nestedUnder: 'ServiceRecord', restore: 'replace' },
   {