Browse Source

Fill the vehicle form from a photo of the registration papers (#262)

* Fill the vehicle form from a photo of the registration papers

Scan a registration document and the make, model, year, VIN, plate, colour,
fuel type and engine size fill themselves. The keeper's name is matched
against existing customers, offering the close ones to pick from or a
prefilled new customer, so the vehicle lands on the right one.

Provider errors now read as one short line instead of the gateway's HTML
error page, and the vehicle dialog is two columns so it stops scrolling.

* Offer the vehicle after scanning papers in the customer dialog

The add customer dialog can scan the same registration document to fill the
name and address. When the papers also describe a complete vehicle, saving
the customer asks whether to add it and attaches it to them.

The scan button moved into a shared component so both dialogs behave alike.

* Lay the customer dialog out in two columns

Adding the scan button made it tall enough to scroll. Contact details sit on
the left, billing and notes on the right, in a dialog widened to match the
vehicle one.

* Group the dialog fields so the two columns mean something

Notes is free text and reads badly at half width, so it spans the full dialog
below both groups. Company moves next to the tax fields where it belongs, and
each column now says what it holds.

* Put the scan action on its own row above both columns

Contact and Billing now start at the same height, with the scan button and its
hint on the row above them. The button keeps its natural width instead of
stretching across the dialog.

* Offer the scanned vehicle as a checkbox instead of a second dialog

Scanning papers that describe a vehicle now shows "Add vehicle 1999 Harley-
Davidson FLST-C" with a checkbox under the scan button. Saving the customer
creates the vehicle in the same step, so nothing pops up afterwards.

* Create the scanned keeper as a customer unless told otherwise

The vehicle dialog used to need a separate click on "Add as customer", so
saving straight away silently dropped the owner the scan had just read. It is
a ticked checkbox now, and saving the vehicle creates the customer with it.
Bernt Christian Egeland 1 month ago
parent
commit
0939c9fa0d

+ 4 - 1
messages/de/customers.json

@@ -68,7 +68,10 @@
     "customerCreated": "Kunde angelegt",
     "saveError": "Kunde konnte nicht gespeichert werden",
     "customerNumber": "Kundennr.",
-    "customerNumberAuto": "Auto"
+    "customerNumberAuto": "Auto",
+    "sectionContact": "Kontakt",
+    "sectionBilling": "Rechnung",
+    "addScannedVehicle": "Fahrzeug {vehicle} anlegen"
   },
   "serviceRequests": {
     "statusNew": "Neu",

+ 16 - 1
messages/de/vehicles.json

@@ -321,7 +321,22 @@
     "licensePlateMarine": "Registrierungsnummer",
     "vehicleUpdated": "Fahrzeug aktualisiert",
     "vehicleAdded": "Fahrzeug hinzugefügt",
-    "saveError": "Fehler beim Speichern des Fahrzeugs"
+    "saveError": "Fehler beim Speichern des Fahrzeugs",
+    "scanDocument": "Fahrzeugschein scannen",
+    "scanDocumentHint": "Fahrzeugpapiere abfotografieren, die Felder werden automatisch ausgefüllt.",
+    "scanningDocument": "Dokument wird gelesen...",
+    "scanSuccess": "Felder aus dem Dokument übernommen",
+    "scanFailed": "Dokument konnte nicht gelesen werden",
+    "ownerMatchTitle": "Fahrzeug zuordnen",
+    "ownerMatchDescription": "Im Dokument steht {name}. Wählen Sie einen dieser Kunden oder legen Sie einen neuen an.",
+    "ownerMatchScore": "{percent}% Übereinstimmung",
+    "ownerMatchCreateNew": "Neuen Kunden anlegen",
+    "ownerMatchChoose": "Kunde auswählen",
+    "scanUnavailable": "KI in den Einstellungen aktivieren",
+    "sectionAssignment": "Foto und Halter",
+    "sectionDetails": "Details",
+    "addScannedOwner": "Kunde {name} anlegen",
+    "ownerSaveFailed": "Kunde konnte nicht angelegt werden"
   },
   "archiveDialog": {
     "title": "Fahrzeug archivieren",

+ 4 - 1
messages/en/customers.json

@@ -68,7 +68,10 @@
     "customerCreated": "Customer created",
     "saveError": "Failed to save customer",
     "customerNumber": "Customer no.",
-    "customerNumberAuto": "Auto"
+    "customerNumberAuto": "Auto",
+    "sectionContact": "Contact",
+    "sectionBilling": "Billing",
+    "addScannedVehicle": "Add vehicle {vehicle}"
   },
   "serviceRequests": {
     "statusNew": "New",

+ 16 - 1
messages/en/vehicles.json

@@ -321,7 +321,22 @@
     "licensePlateMarine": "Registration Number",
     "vehicleUpdated": "Vehicle updated",
     "vehicleAdded": "Vehicle added",
-    "saveError": "Failed to save vehicle"
+    "saveError": "Failed to save vehicle",
+    "scanDocument": "Scan registration document",
+    "scanDocumentHint": "Photograph the vehicle papers and the fields fill in automatically.",
+    "scanningDocument": "Reading document...",
+    "scanSuccess": "Fields filled from the document",
+    "scanFailed": "Could not read the document",
+    "ownerMatchTitle": "Assign this vehicle",
+    "ownerMatchDescription": "The document lists {name}. Use one of these customers or create a new one.",
+    "ownerMatchScore": "{percent}% match",
+    "ownerMatchCreateNew": "Create new customer",
+    "ownerMatchChoose": "Choose customer",
+    "scanUnavailable": "Enable AI in settings",
+    "sectionAssignment": "Photo and owner",
+    "sectionDetails": "Details",
+    "addScannedOwner": "Add customer {name}",
+    "ownerSaveFailed": "Could not create the customer"
   },
   "archiveDialog": {
     "title": "Archive Vehicle",

+ 4 - 1
messages/es/customers.json

@@ -68,7 +68,10 @@
     "customerCreated": "Cliente creado",
     "saveError": "Error al guardar el cliente",
     "customerNumber": "N.º de cliente",
-    "customerNumberAuto": "Auto"
+    "customerNumberAuto": "Auto",
+    "sectionContact": "Contacto",
+    "sectionBilling": "Facturación",
+    "addScannedVehicle": "Añadir el vehículo {vehicle}"
   },
   "serviceRequests": {
     "statusNew": "Nuevo",

+ 16 - 1
messages/es/vehicles.json

@@ -321,7 +321,22 @@
     "licensePlateMarine": "Número de Registro",
     "vehicleUpdated": "Vehículo actualizado",
     "vehicleAdded": "Vehículo agregado",
-    "saveError": "Error al guardar vehículo"
+    "saveError": "Error al guardar vehículo",
+    "scanDocument": "Escanear permiso de circulación",
+    "scanDocumentHint": "Fotografía la documentación del vehículo y los campos se rellenan solos.",
+    "scanningDocument": "Leyendo el documento...",
+    "scanSuccess": "Campos rellenados desde el documento",
+    "scanFailed": "No se pudo leer el documento",
+    "ownerMatchTitle": "Asignar este vehículo",
+    "ownerMatchDescription": "El documento indica {name}. Elige uno de estos clientes o crea uno nuevo.",
+    "ownerMatchScore": "{percent}% de coincidencia",
+    "ownerMatchCreateNew": "Crear cliente nuevo",
+    "ownerMatchChoose": "Elegir cliente",
+    "scanUnavailable": "Activa la IA en los ajustes",
+    "sectionAssignment": "Foto y titular",
+    "sectionDetails": "Detalles",
+    "addScannedOwner": "Añadir el cliente {name}",
+    "ownerSaveFailed": "No se pudo crear el cliente"
   },
   "archiveDialog": {
     "title": "Archivar vehículo",

+ 4 - 1
messages/fr/customers.json

@@ -68,7 +68,10 @@
     "customerCreated": "Client créé",
     "saveError": "Échec de l'enregistrement du client",
     "customerNumber": "N° client",
-    "customerNumberAuto": "Auto"
+    "customerNumberAuto": "Auto",
+    "sectionContact": "Contact",
+    "sectionBilling": "Facturation",
+    "addScannedVehicle": "Ajouter le véhicule {vehicle}"
   },
   "serviceRequests": {
     "statusNew": "Nouveau",

+ 16 - 1
messages/fr/vehicles.json

@@ -321,7 +321,22 @@
     "licensePlateMarine": "Numéro d'Immatriculation",
     "vehicleUpdated": "Véhicule mis à jour",
     "vehicleAdded": "Véhicule ajouté",
-    "saveError": "Échec de l'enregistrement du véhicule"
+    "saveError": "Échec de l'enregistrement du véhicule",
+    "scanDocument": "Scanner la carte grise",
+    "scanDocumentHint": "Photographiez les papiers du véhicule, les champs se remplissent automatiquement.",
+    "scanningDocument": "Lecture du document...",
+    "scanSuccess": "Champs remplis à partir du document",
+    "scanFailed": "Impossible de lire le document",
+    "ownerMatchTitle": "Attribuer ce véhicule",
+    "ownerMatchDescription": "Le document indique {name}. Choisissez un de ces clients ou créez-en un nouveau.",
+    "ownerMatchScore": "{percent}% de correspondance",
+    "ownerMatchCreateNew": "Créer un nouveau client",
+    "ownerMatchChoose": "Choisir un client",
+    "scanUnavailable": "Activez l'IA dans les paramètres",
+    "sectionAssignment": "Photo et titulaire",
+    "sectionDetails": "Détails",
+    "addScannedOwner": "Ajouter le client {name}",
+    "ownerSaveFailed": "Impossible de créer le client"
   },
   "archiveDialog": {
     "title": "Archiver le véhicule",

+ 4 - 1
messages/it/customers.json

@@ -68,7 +68,10 @@
     "customerCreated": "Cliente creato",
     "saveError": "Salvataggio del cliente non riuscito",
     "customerNumber": "N. cliente",
-    "customerNumberAuto": "Auto"
+    "customerNumberAuto": "Auto",
+    "sectionContact": "Contatto",
+    "sectionBilling": "Fatturazione",
+    "addScannedVehicle": "Aggiungi il veicolo {vehicle}"
   },
   "serviceRequests": {
     "statusNew": "Nuova",

+ 16 - 1
messages/it/vehicles.json

@@ -321,7 +321,22 @@
     "licensePlateMarine": "Numero di Registrazione",
     "vehicleUpdated": "Veicolo aggiornato",
     "vehicleAdded": "Veicolo aggiunto",
-    "saveError": "Impossibile salvare il veicolo"
+    "saveError": "Impossibile salvare il veicolo",
+    "scanDocument": "Scansiona il libretto di circolazione",
+    "scanDocumentHint": "Fotografa i documenti del veicolo e i campi si compilano da soli.",
+    "scanningDocument": "Lettura del documento...",
+    "scanSuccess": "Campi compilati dal documento",
+    "scanFailed": "Impossibile leggere il documento",
+    "ownerMatchTitle": "Assegna questo veicolo",
+    "ownerMatchDescription": "Il documento riporta {name}. Scegli uno di questi clienti o creane uno nuovo.",
+    "ownerMatchScore": "{percent}% di corrispondenza",
+    "ownerMatchCreateNew": "Crea nuovo cliente",
+    "ownerMatchChoose": "Scegli cliente",
+    "scanUnavailable": "Attiva l'IA nelle impostazioni",
+    "sectionAssignment": "Foto e intestatario",
+    "sectionDetails": "Dettagli",
+    "addScannedOwner": "Aggiungi il cliente {name}",
+    "ownerSaveFailed": "Impossibile creare il cliente"
   },
   "archiveDialog": {
     "title": "Archivia Veicolo",

+ 4 - 1
messages/lt/customers.json

@@ -68,7 +68,10 @@
     "customerCreated": "Klientas sukurtas",
     "saveError": "Nepavyko išsaugoti kliento",
     "customerNumber": "Kliento nr.",
-    "customerNumberAuto": "Autom."
+    "customerNumberAuto": "Autom.",
+    "sectionContact": "Kontaktai",
+    "sectionBilling": "Atsiskaitymas",
+    "addScannedVehicle": "Pridėti transporto priemonę {vehicle}"
   },
   "serviceRequests": {
     "statusNew": "Nauja",

+ 16 - 1
messages/lt/vehicles.json

@@ -321,7 +321,22 @@
     "licensePlateMarine": "Registracijos numeris",
     "vehicleUpdated": "Transporto priemonė atnaujinta",
     "vehicleAdded": "Transporto priemonė pridėta",
-    "saveError": "Nepavyko išsaugoti transporto priemonės"
+    "saveError": "Nepavyko išsaugoti transporto priemonės",
+    "scanDocument": "Skenuoti registracijos liudijimą",
+    "scanDocumentHint": "Nufotografuokite transporto priemonės dokumentus ir laukai bus užpildyti automatiškai.",
+    "scanningDocument": "Skaitomas dokumentas...",
+    "scanSuccess": "Laukai užpildyti iš dokumento",
+    "scanFailed": "Nepavyko perskaityti dokumento",
+    "ownerMatchTitle": "Priskirti transporto priemonę",
+    "ownerMatchDescription": "Dokumente nurodyta {name}. Pasirinkite vieną iš šių klientų arba sukurkite naują.",
+    "ownerMatchScore": "{percent}% atitikmuo",
+    "ownerMatchCreateNew": "Sukurti naują klientą",
+    "ownerMatchChoose": "Pasirinkti klientą",
+    "scanUnavailable": "Įjunkite DI nustatymuose",
+    "sectionAssignment": "Nuotrauka ir savininkas",
+    "sectionDetails": "Duomenys",
+    "addScannedOwner": "Pridėti klientą {name}",
+    "ownerSaveFailed": "Nepavyko sukurti kliento"
   },
   "archiveDialog": {
     "title": "Archyvuoti transporto priemonę",

+ 4 - 1
messages/nb/customers.json

@@ -68,7 +68,10 @@
     "customerCreated": "Kunde opprettet",
     "saveError": "Kunne ikke lagre kunde",
     "customerNumber": "Kundenr.",
-    "customerNumberAuto": "Auto"
+    "customerNumberAuto": "Auto",
+    "sectionContact": "Kontakt",
+    "sectionBilling": "Fakturering",
+    "addScannedVehicle": "Legg til kjøretøyet {vehicle}"
   },
   "serviceRequests": {
     "statusNew": "Ny",

+ 16 - 1
messages/nb/vehicles.json

@@ -321,7 +321,22 @@
     "licensePlateMarine": "Registreringsnummer",
     "vehicleUpdated": "Kjøretøy oppdatert",
     "vehicleAdded": "Kjøretøy lagt til",
-    "saveError": "Kunne ikke lagre kjøretøy"
+    "saveError": "Kunne ikke lagre kjøretøy",
+    "scanDocument": "Skann vognkortet",
+    "scanDocumentHint": "Ta bilde av vognkortet, så fylles feltene ut automatisk.",
+    "scanningDocument": "Leser dokumentet...",
+    "scanSuccess": "Feltene er fylt ut fra dokumentet",
+    "scanFailed": "Kunne ikke lese dokumentet",
+    "ownerMatchTitle": "Tilordne kjøretøyet",
+    "ownerMatchDescription": "Dokumentet oppgir {name}. Velg en av disse kundene, eller opprett en ny.",
+    "ownerMatchScore": "{percent}% treff",
+    "ownerMatchCreateNew": "Opprett ny kunde",
+    "ownerMatchChoose": "Velg kunde",
+    "scanUnavailable": "Aktiver KI i innstillingene",
+    "sectionAssignment": "Bilde og eier",
+    "sectionDetails": "Detaljer",
+    "addScannedOwner": "Legg til kunden {name}",
+    "ownerSaveFailed": "Kunne ikke opprette kunden"
   },
   "archiveDialog": {
     "title": "Arkiver kjøretøy",

+ 4 - 1
messages/nl/customers.json

@@ -68,7 +68,10 @@
     "customerCreated": "Klant aangemaakt",
     "saveError": "Opslaan van klant mislukt",
     "customerNumber": "Klantnr.",
-    "customerNumberAuto": "Auto"
+    "customerNumberAuto": "Auto",
+    "sectionContact": "Contact",
+    "sectionBilling": "Facturatie",
+    "addScannedVehicle": "Voertuig {vehicle} toevoegen"
   },
   "serviceRequests": {
     "statusNew": "Nieuw",

+ 16 - 1
messages/nl/vehicles.json

@@ -321,7 +321,22 @@
     "licensePlateMarine": "Registratienummer",
     "vehicleUpdated": "Voertuig bijgewerkt",
     "vehicleAdded": "Voertuig toegevoegd",
-    "saveError": "Opslaan van voertuig mislukt"
+    "saveError": "Opslaan van voertuig mislukt",
+    "scanDocument": "Kentekenbewijs scannen",
+    "scanDocumentHint": "Fotografeer de voertuigpapieren, de velden worden automatisch ingevuld.",
+    "scanningDocument": "Document wordt gelezen...",
+    "scanSuccess": "Velden ingevuld vanaf het document",
+    "scanFailed": "Kon het document niet lezen",
+    "ownerMatchTitle": "Voertuig toewijzen",
+    "ownerMatchDescription": "Op het document staat {name}. Kies een van deze klanten of maak een nieuwe aan.",
+    "ownerMatchScore": "{percent}% overeenkomst",
+    "ownerMatchCreateNew": "Nieuwe klant aanmaken",
+    "ownerMatchChoose": "Klant kiezen",
+    "scanUnavailable": "Schakel AI in bij instellingen",
+    "sectionAssignment": "Foto en eigenaar",
+    "sectionDetails": "Details",
+    "addScannedOwner": "Klant {name} toevoegen",
+    "ownerSaveFailed": "Kon de klant niet aanmaken"
   },
   "archiveDialog": {
     "title": "Voertuig archiveren",

+ 4 - 1
messages/pl/customers.json

@@ -68,7 +68,10 @@
     "customerCreated": "Klient utworzony",
     "saveError": "Nie udało się zapisać klienta",
     "customerNumber": "Nr klienta",
-    "customerNumberAuto": "Auto"
+    "customerNumberAuto": "Auto",
+    "sectionContact": "Kontakt",
+    "sectionBilling": "Rozliczenia",
+    "addScannedVehicle": "Dodaj pojazd {vehicle}"
   },
   "serviceRequests": {
     "statusNew": "Nowe",

+ 16 - 1
messages/pl/vehicles.json

@@ -321,7 +321,22 @@
     "licensePlateMarine": "Numer Rejestracyjny",
     "vehicleUpdated": "Pojazd zaktualizowany",
     "vehicleAdded": "Pojazd dodany",
-    "saveError": "Nie udało się zapisać pojazdu"
+    "saveError": "Nie udało się zapisać pojazdu",
+    "scanDocument": "Skanuj dowód rejestracyjny",
+    "scanDocumentHint": "Zrób zdjęcie dokumentów pojazdu, a pola wypełnią się automatycznie.",
+    "scanningDocument": "Odczytywanie dokumentu...",
+    "scanSuccess": "Pola wypełnione z dokumentu",
+    "scanFailed": "Nie udało się odczytać dokumentu",
+    "ownerMatchTitle": "Przypisz pojazd",
+    "ownerMatchDescription": "W dokumencie widnieje {name}. Wybierz jednego z tych klientów lub utwórz nowego.",
+    "ownerMatchScore": "{percent}% zgodności",
+    "ownerMatchCreateNew": "Utwórz nowego klienta",
+    "ownerMatchChoose": "Wybierz klienta",
+    "scanUnavailable": "Włącz AI w ustawieniach",
+    "sectionAssignment": "Zdjęcie i właściciel",
+    "sectionDetails": "Szczegóły",
+    "addScannedOwner": "Dodaj klienta {name}",
+    "ownerSaveFailed": "Nie udało się utworzyć klienta"
   },
   "archiveDialog": {
     "title": "Archiwizuj pojazd",

+ 4 - 1
messages/pt-BR/customers.json

@@ -68,7 +68,10 @@
     "customerCreated": "Cliente cadastrado",
     "saveError": "Falha ao salvar cliente",
     "customerNumber": "Nº do cliente",
-    "customerNumberAuto": "Auto"
+    "customerNumberAuto": "Auto",
+    "sectionContact": "Contato",
+    "sectionBilling": "Faturamento",
+    "addScannedVehicle": "Adicionar o veículo {vehicle}"
   },
   "serviceRequests": {
     "statusNew": "Novo",

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

@@ -321,7 +321,22 @@
     "licensePlateMarine": "Número de Registro",
     "vehicleUpdated": "Veículo atualizado",
     "vehicleAdded": "Veículo adicionado",
-    "saveError": "Falha ao salvar veículo"
+    "saveError": "Falha ao salvar veículo",
+    "scanDocument": "Escanear documento do veículo",
+    "scanDocumentHint": "Fotografe o documento do veículo e os campos são preenchidos automaticamente.",
+    "scanningDocument": "Lendo o documento...",
+    "scanSuccess": "Campos preenchidos a partir do documento",
+    "scanFailed": "Não foi possível ler o documento",
+    "ownerMatchTitle": "Atribuir este veículo",
+    "ownerMatchDescription": "O documento indica {name}. Escolha um destes clientes ou crie um novo.",
+    "ownerMatchScore": "{percent}% de correspondência",
+    "ownerMatchCreateNew": "Criar novo cliente",
+    "ownerMatchChoose": "Escolher cliente",
+    "scanUnavailable": "Ative a IA nas configurações",
+    "sectionAssignment": "Foto e proprietário",
+    "sectionDetails": "Detalhes",
+    "addScannedOwner": "Adicionar o cliente {name}",
+    "ownerSaveFailed": "Não foi possível criar o cliente"
   },
   "archiveDialog": {
     "title": "Arquivar Veículo",

+ 4 - 1
messages/ru/customers.json

@@ -68,7 +68,10 @@
     "customerCreated": "Клиент создан",
     "saveError": "Не удалось сохранить клиента",
     "customerNumber": "№ клиента",
-    "customerNumberAuto": "Авто"
+    "customerNumberAuto": "Авто",
+    "sectionContact": "Контакты",
+    "sectionBilling": "Выставление счетов",
+    "addScannedVehicle": "Добавить автомобиль {vehicle}"
   },
   "serviceRequests": {
     "statusNew": "Новая",

+ 16 - 1
messages/ru/vehicles.json

@@ -321,7 +321,22 @@
     "licensePlateMarine": "Регистрационный Номер",
     "vehicleUpdated": "Транспортное средство обновлено",
     "vehicleAdded": "Транспортное средство добавлено",
-    "saveError": "Не удалось сохранить транспортное средство"
+    "saveError": "Не удалось сохранить транспортное средство",
+    "scanDocument": "Сканировать документы на автомобиль",
+    "scanDocumentHint": "Сфотографируйте документы, и поля заполнятся автоматически.",
+    "scanningDocument": "Чтение документа...",
+    "scanSuccess": "Поля заполнены из документа",
+    "scanFailed": "Не удалось прочитать документ",
+    "ownerMatchTitle": "Привязать автомобиль",
+    "ownerMatchDescription": "В документе указан {name}. Выберите одного из этих клиентов или создайте нового.",
+    "ownerMatchScore": "совпадение {percent}%",
+    "ownerMatchCreateNew": "Создать нового клиента",
+    "ownerMatchChoose": "Выбрать клиента",
+    "scanUnavailable": "Включите ИИ в настройках",
+    "sectionAssignment": "Фото и владелец",
+    "sectionDetails": "Характеристики",
+    "addScannedOwner": "Добавить клиента {name}",
+    "ownerSaveFailed": "Не удалось создать клиента"
   },
   "archiveDialog": {
     "title": "Архивировать транспортное средство",

+ 4 - 1
messages/tr/customers.json

@@ -68,7 +68,10 @@
     "customerCreated": "Müşteri oluşturuldu",
     "saveError": "Müşteri kaydedilemedi",
     "customerNumber": "Müşteri no",
-    "customerNumberAuto": "Oto"
+    "customerNumberAuto": "Oto",
+    "sectionContact": "İletişim",
+    "sectionBilling": "Faturalama",
+    "addScannedVehicle": "{vehicle} aracını ekle"
   },
   "serviceRequests": {
     "statusNew": "Yeni",

+ 16 - 1
messages/tr/vehicles.json

@@ -321,7 +321,22 @@
     "licensePlateMarine": "Tescil Numarası",
     "vehicleUpdated": "Araç güncellendi",
     "vehicleAdded": "Araç eklendi",
-    "saveError": "Araç kaydedilemedi"
+    "saveError": "Araç kaydedilemedi",
+    "scanDocument": "Ruhsatı tara",
+    "scanDocumentHint": "Araç belgelerini fotoğraflayın, alanlar otomatik doldurulsun.",
+    "scanningDocument": "Belge okunuyor...",
+    "scanSuccess": "Alanlar belgeden dolduruldu",
+    "scanFailed": "Belge okunamadı",
+    "ownerMatchTitle": "Bu aracı ata",
+    "ownerMatchDescription": "Belgede {name} yazıyor. Bu müşterilerden birini seçin veya yeni bir müşteri oluşturun.",
+    "ownerMatchScore": "%{percent} eşleşme",
+    "ownerMatchCreateNew": "Yeni müşteri oluştur",
+    "ownerMatchChoose": "Müşteri seç",
+    "scanUnavailable": "Ayarlardan yapay zekayı etkinleştirin",
+    "sectionAssignment": "Fotoğraf ve sahibi",
+    "sectionDetails": "Ayrıntılar",
+    "addScannedOwner": "{name} müşterisini ekle",
+    "ownerSaveFailed": "Müşteri oluşturulamadı"
   },
   "archiveDialog": {
     "title": "Aracı Arşivle",

+ 55 - 0
src/__tests__/lib/ai-error.test.ts

@@ -0,0 +1,55 @@
+/**
+ * Tests for the message a failed AI call shows the user.
+ *
+ * These land in a toast, so the size and shape of the string is the whole
+ * point: a wrong API key used to surface as the provider gateway's HTML error
+ * page, rendered as markup in the corner of the screen.
+ */
+
+import { describe, it, expect } from 'vitest'
+
+import OpenAI from 'openai'
+import { describeAiError } from '@/lib/ai-error'
+
+function apiError(status: number, message: string) {
+  return new OpenAI.APIError(status, undefined, message, undefined)
+}
+
+describe('describeAiError', () => {
+  it('names the API key on a rejected credential', () => {
+    const message = describeAiError(apiError(401, 'Incorrect API key provided'))
+    expect(message).toContain('API key')
+    expect(message).toContain('Settings')
+  })
+
+  it('does not leak an HTML error page into the toast', () => {
+    const html =
+      '<!DOCTYPE html><html><head><title>401 Unauthorized</title></head><body><div class="err"><h1>Unauthorized</h1><p>invalid key</p></div></body></html>'
+    const message = describeAiError(new Error(html))
+    expect(message).not.toContain('<')
+    expect(message).not.toContain('DOCTYPE')
+    expect(message.length).toBeLessThan(120)
+  })
+
+  it('keeps an HTML page out even when the status is one it recognises', () => {
+    const message = describeAiError(apiError(500, '<html><body>Bad gateway</body></html>'))
+    expect(message).not.toContain('<')
+  })
+
+  it('caps a long provider message', () => {
+    const message = describeAiError(new Error('x'.repeat(5000)))
+    expect(message.length).toBeLessThanOrEqual(203)
+  })
+
+  it('passes a short, useful provider message through', () => {
+    expect(describeAiError(new Error('Model is overloaded'))).toBe('Model is overloaded')
+  })
+
+  it('has something to say about a rate limit', () => {
+    expect(describeAiError(apiError(429, 'slow down'))).toContain('rate limit')
+  })
+
+  it('falls back when there is no message at all', () => {
+    expect(describeAiError(new Error(''))).toBe('The AI provider returned an unexpected response.')
+  })
+})

+ 61 - 0
src/__tests__/lib/name-similarity.test.ts

@@ -0,0 +1,61 @@
+/**
+ * Tests for the fuzzy name match behind the registration document scan.
+ *
+ * The threshold the vehicle form uses is 0.9, so what matters here is which
+ * side of it a given pair of names lands on. Score too generously and the
+ * workshop is asked to pick between two brothers; too harshly and a customer
+ * they already have is quietly duplicated.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { nameSimilarity, normalizeName } from '@/lib/name-similarity'
+
+const THRESHOLD = 0.9
+
+describe('normalizeName', () => {
+  it('puts a comma-separated name in the same shape as a spaced one', () => {
+    expect(normalizeName('Lücking, Manuel')).toBe(normalizeName('Manuel Lücking'))
+  })
+
+  it('strips accents so an OCR pass that drops them still lines up', () => {
+    expect(normalizeName('Lücking')).toBe('lucking')
+  })
+})
+
+describe('nameSimilarity', () => {
+  it('matches a name written surname-first', () => {
+    expect(nameSimilarity('Lücking, Manuel', 'Manuel Lücking')).toBe(1)
+  })
+
+  it('matches through a lost umlaut', () => {
+    expect(nameSimilarity('Manuel Lucking', 'Manuel Lücking')).toBeGreaterThanOrEqual(THRESHOLD)
+  })
+
+  it('matches a company through its legal form', () => {
+    expect(nameSimilarity('Autohaus Meyer', 'Autohaus Meyer GmbH')).toBeGreaterThanOrEqual(
+      THRESHOLD
+    )
+  })
+
+  it('matches through an added middle name', () => {
+    expect(nameSimilarity('Manuel Lücking', 'Manuel Josef Lücking')).toBeGreaterThanOrEqual(
+      THRESHOLD
+    )
+  })
+
+  it('keeps two relatives at one address apart', () => {
+    expect(nameSimilarity('Manuel Lücking', 'Michael Lücking')).toBeLessThan(THRESHOLD)
+  })
+
+  it('does not match on a shared surname alone', () => {
+    expect(nameSimilarity('Lücking', 'Manuel Lücking')).toBeLessThan(THRESHOLD)
+  })
+
+  it('scores unrelated names low', () => {
+    expect(nameSimilarity('Manuel Lücking', 'Petra Schneider')).toBeLessThan(0.4)
+  })
+
+  it('treats an empty name as no match', () => {
+    expect(nameSimilarity('', 'Manuel Lücking')).toBe(0)
+  })
+})

+ 243 - 143
src/features/customers/Components/CustomerForm.tsx

@@ -1,212 +1,312 @@
-"use client";
-
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Switch } from "@/components/ui/switch";
-import { Textarea } from "@/components/ui/textarea";
+'use client'
+
+import { useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Textarea } from '@/components/ui/textarea'
 import {
   Dialog,
   DialogContent,
   DialogDescription,
   DialogHeader,
   DialogTitle,
-} from "@/components/ui/dialog";
-import { DocsLink } from "@/components/docs-link";
-import { useGlassModal } from "@/components/glass-modal";
-import { toast } from "sonner";
-import { createCustomer, updateCustomer } from "../Actions/customerActions";
-import { Loader2 } from "lucide-react";
+} from '@/components/ui/dialog'
+import { DocsLink } from '@/components/docs-link'
+import { useGlassModal } from '@/components/glass-modal'
+import { toast } from 'sonner'
+import { createCustomer, updateCustomer } from '../Actions/customerActions'
+import { createVehicle } from '@/features/vehicles/Actions/vehicleActions'
+import { ScanDocumentButton } from '@/features/vehicles/Components/ScanDocumentButton'
+import type { VehicleDocumentScan } from '@/features/vehicles/Actions/aiAnalyzeVehicleDocument'
+import { Loader2 } from 'lucide-react'
 
 interface CustomerFormProps {
-  open: boolean;
-  onOpenChange: (open: boolean) => void;
+  open: boolean
+  onOpenChange: (open: boolean) => void
   customer?: {
-    id: string;
-    customerNumber?: string | null;
-    name: string;
-    email?: string | null;
-    phone?: string | null;
-    address?: string | null;
-    company?: string | null;
-    taxId?: string | null;
-    taxExempt?: boolean;
-    notes?: string | null;
-  };
-  onCreated?: (customer: { id: string; name: string; company: string | null }) => void;
+    id: string
+    customerNumber?: string | null
+    name: string
+    email?: string | null
+    phone?: string | null
+    address?: string | null
+    company?: string | null
+    taxId?: string | null
+    taxExempt?: boolean
+    notes?: string | null
+  }
+  /**
+   * Prefills a new customer, e.g. with the keeper read off a scanned
+   * registration document. Ignored when editing an existing customer.
+   */
+  defaults?: { name?: string; address?: string }
+  onCreated?: (customer: { id: string; name: string; company: string | null }) => void
 }
 
-export function CustomerForm({ open, onOpenChange, customer, onCreated }: CustomerFormProps) {
-  const t = useTranslations("customers.form");
-  const tc = useTranslations("common");
-  const router = useRouter();
-  const modal = useGlassModal();
-  const [loading, setLoading] = useState(false);
-  const [taxExempt, setTaxExempt] = useState(customer?.taxExempt ?? false);
+export function CustomerForm({
+  open,
+  onOpenChange,
+  customer,
+  defaults,
+  onCreated,
+}: CustomerFormProps) {
+  const t = useTranslations('customers.form')
+  const tc = useTranslations('common')
+  const router = useRouter()
+  const modal = useGlassModal()
+  const tv = useTranslations('vehicles.form')
+  const [loading, setLoading] = useState(false)
+  const [taxExempt, setTaxExempt] = useState(customer?.taxExempt ?? false)
+  const formRef = useRef<HTMLFormElement>(null)
+  /** Vehicle details from a scanned document, offered once the customer exists. */
+  const [scannedVehicle, setScannedVehicle] = useState<VehicleDocumentScan | null>(null)
+  const [addVehicle, setAddVehicle] = useState(true)
+
+  const applyScan = (data: VehicleDocumentScan) => {
+    const form = formRef.current
+    if (form && data.owner) {
+      const setIfEmpty = (name: string, value: string | undefined) => {
+        if (!value) return
+        const input = form.elements.namedItem(name) as HTMLInputElement | null
+        if (input && !input.value) input.value = value
+      }
+      setIfEmpty('name', data.owner.name)
+      setIfEmpty('address', data.owner.address)
+    }
+
+    // The papers describe a vehicle too, but only a complete one can be saved:
+    // make, model and year are all required.
+    const complete = Boolean(data.make && data.model && data.year)
+    setScannedVehicle(complete ? data : null)
+    setAddVehicle(true)
+  }
+
+  const vehicleLabel = (data: VehicleDocumentScan) =>
+    [data.year, data.make, data.model].filter(Boolean).join(' ') +
+    (data.licensePlate ? ` (${data.licensePlate})` : '')
 
   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
-    setLoading(true);
+    e.preventDefault()
+    setLoading(true)
 
-    const formData = new FormData(e.currentTarget);
+    const formData = new FormData(e.currentTarget)
     const data = {
-      name: formData.get("name") as string,
-      customerNumber: (formData.get("customerNumber") as string) || undefined,
-      email: (formData.get("email") as string) || undefined,
-      phone: (formData.get("phone") as string) || undefined,
-      address: (formData.get("address") as string) || undefined,
-      company: (formData.get("company") as string) || undefined,
-      taxId: (formData.get("taxId") as string) || undefined,
+      name: formData.get('name') as string,
+      customerNumber: (formData.get('customerNumber') as string) || undefined,
+      email: (formData.get('email') as string) || undefined,
+      phone: (formData.get('phone') as string) || undefined,
+      address: (formData.get('address') as string) || undefined,
+      company: (formData.get('company') as string) || undefined,
+      taxId: (formData.get('taxId') as string) || undefined,
       taxExempt,
-      notes: (formData.get("notes") as string) || undefined,
-    };
+      notes: (formData.get('notes') as string) || undefined,
+    }
 
     const result = customer
       ? await updateCustomer({ ...data, id: customer.id })
-      : await createCustomer(data);
+      : await createCustomer(data)
 
     if (result.success) {
-      toast.success(customer ? t("customerUpdated") : t("customerCreated"));
-      onOpenChange(false);
-      if (!customer && result.data && onCreated) {
-        const created = result.data as { id: string; name: string; company: string | null };
-        onCreated({ id: created.id, name: created.name, company: created.company ?? null });
+      toast.success(customer ? t('customerUpdated') : t('customerCreated'))
+      const created = result.data as
+        | { id: string; name: string; company: string | null }
+        | undefined
+      const customerId = customer?.id ?? created?.id
+
+      // The scanned papers describe a vehicle too, and the customer it belongs
+      // to only exists now.
+      if (scannedVehicle && addVehicle && customerId) {
+        const vehicleResult = await createVehicle({
+          make: scannedVehicle.make as string,
+          model: scannedVehicle.model as string,
+          year: scannedVehicle.year as number,
+          vin: scannedVehicle.vin,
+          licensePlate: scannedVehicle.licensePlate,
+          color: scannedVehicle.color,
+          fuelType: scannedVehicle.fuelType,
+          engineSize: scannedVehicle.engineSize,
+          mileage: 0,
+          customerId,
+        })
+        if (vehicleResult.success) {
+          toast.success(tv('vehicleAdded'))
+        } else {
+          toast.error(vehicleResult.error || tv('saveError'))
+        }
       }
-      router.refresh();
+
+      setScannedVehicle(null)
+      onOpenChange(false)
+      if (!customer && created && onCreated) {
+        onCreated({ id: created.id, name: created.name, company: created.company ?? null })
+      }
+      router.refresh()
     } else {
-      modal.open("error", tc("errors.error"), result.error || t("saveError"));
+      modal.open('error', tc('errors.error'), result.error || t('saveError'))
     }
 
-    setLoading(false);
-  };
+    setLoading(false)
+  }
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
+      <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
         <DialogHeader>
-          <DialogTitle>
-            {customer ? t("editTitle") : t("addTitle")}
-          </DialogTitle>
+          <DialogTitle>{customer ? t('editTitle') : t('addTitle')}</DialogTitle>
           <DocsLink href="/docs/features/customers" variant="hint" className="self-start" />
           <DialogDescription className="sr-only">
-            {customer ? t("editTitle") : t("addTitle")}
+            {customer ? t('editTitle') : t('addTitle')}
           </DialogDescription>
         </DialogHeader>
 
-        <form onSubmit={handleSubmit} className="space-y-4">
-          <div className="grid grid-cols-[1fr_120px] gap-4">
-            <div className="space-y-2">
-              <Label htmlFor="name">{t("nameRequired")}</Label>
-              <Input
-                id="name"
-                name="name"
-                placeholder={t("namePlaceholder")}
-                defaultValue={customer?.name}
-                required
-              />
+        <form
+          key={customer?.id ?? `${defaults?.name ?? ''}|${defaults?.address ?? ''}`}
+          ref={formRef}
+          onSubmit={handleSubmit}
+          className="grid gap-x-6 gap-y-4 md:grid-cols-2"
+        >
+          {/* The keeper on a registration document is a customer waiting to be typed in */}
+          <div className="space-y-3 md:col-span-2">
+            <ScanDocumentButton onScanned={applyScan} />
+
+            {scannedVehicle && (
+              <label className="flex cursor-pointer items-center gap-3 rounded-lg border border-dashed border-border bg-muted/30 p-3">
+                <Checkbox
+                  checked={addVehicle}
+                  onCheckedChange={(next) => setAddVehicle(next === true)}
+                />
+                <span className="text-sm">
+                  {t('addScannedVehicle', { vehicle: vehicleLabel(scannedVehicle) })}
+                </span>
+              </label>
+            )}
+          </div>
+
+          {/* Left: who the customer is and how to reach them */}
+          <div className="space-y-4">
+            <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+              {t('sectionContact')}
+            </p>
+
+            <div className="grid grid-cols-[1fr_120px] gap-4">
+              <div className="space-y-2">
+                <Label htmlFor="name">{t('nameRequired')}</Label>
+                <Input
+                  id="name"
+                  name="name"
+                  placeholder={t('namePlaceholder')}
+                  defaultValue={customer?.name ?? defaults?.name ?? ''}
+                  required
+                />
+              </div>
+              <div className="space-y-2">
+                <Label htmlFor="customerNumber">{t('customerNumber')}</Label>
+                <Input
+                  id="customerNumber"
+                  name="customerNumber"
+                  placeholder={t('customerNumberAuto')}
+                  defaultValue={customer?.customerNumber ?? ''}
+                  maxLength={20}
+                />
+              </div>
+            </div>
+
+            <div className="grid grid-cols-2 gap-4">
+              <div className="space-y-2">
+                <Label htmlFor="email">{tc('form.email')}</Label>
+                <Input
+                  id="email"
+                  name="email"
+                  type="email"
+                  placeholder={t('emailPlaceholder')}
+                  defaultValue={customer?.email ?? ''}
+                />
+              </div>
+              <div className="space-y-2">
+                <Label htmlFor="phone">{tc('form.phone')}</Label>
+                <Input
+                  id="phone"
+                  name="phone"
+                  placeholder={t('phonePlaceholder')}
+                  defaultValue={customer?.phone ?? ''}
+                />
+              </div>
             </div>
+
             <div className="space-y-2">
-              <Label htmlFor="customerNumber">{t("customerNumber")}</Label>
+              <Label htmlFor="address">{tc('form.address')}</Label>
               <Input
-                id="customerNumber"
-                name="customerNumber"
-                placeholder={t("customerNumberAuto")}
-                defaultValue={customer?.customerNumber ?? ""}
-                maxLength={20}
+                id="address"
+                name="address"
+                placeholder={t('addressPlaceholder')}
+                defaultValue={customer?.address ?? defaults?.address ?? ''}
               />
             </div>
           </div>
 
-          <div className="grid grid-cols-2 gap-4">
+          {/* Right: who they are on an invoice */}
+          <div className="space-y-4">
+            <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+              {t('sectionBilling')}
+            </p>
+
             <div className="space-y-2">
-              <Label htmlFor="email">{tc("form.email")}</Label>
+              <Label htmlFor="company">{tc('form.company')}</Label>
               <Input
-                id="email"
-                name="email"
-                type="email"
-                placeholder={t("emailPlaceholder")}
-                defaultValue={customer?.email ?? ""}
+                id="company"
+                name="company"
+                placeholder={t('companyPlaceholder')}
+                defaultValue={customer?.company ?? ''}
               />
             </div>
+
             <div className="space-y-2">
-              <Label htmlFor="phone">{tc("form.phone")}</Label>
+              <Label htmlFor="taxId">{t('taxId')}</Label>
               <Input
-                id="phone"
-                name="phone"
-                placeholder={t("phonePlaceholder")}
-                defaultValue={customer?.phone ?? ""}
+                id="taxId"
+                name="taxId"
+                placeholder={t('taxIdPlaceholder')}
+                defaultValue={customer?.taxId ?? ''}
               />
+              <p className="text-xs text-muted-foreground">{t('taxIdHint')}</p>
             </div>
-          </div>
-
-          <div className="space-y-2">
-            <Label htmlFor="company">{tc("form.company")}</Label>
-            <Input
-              id="company"
-              name="company"
-              placeholder={t("companyPlaceholder")}
-              defaultValue={customer?.company ?? ""}
-            />
-          </div>
-
-          <div className="space-y-2">
-            <Label htmlFor="address">{tc("form.address")}</Label>
-            <Input
-              id="address"
-              name="address"
-              placeholder={t("addressPlaceholder")}
-              defaultValue={customer?.address ?? ""}
-            />
-          </div>
-
-          <div className="space-y-2">
-            <Label htmlFor="taxId">{t("taxId")}</Label>
-            <Input
-              id="taxId"
-              name="taxId"
-              placeholder={t("taxIdPlaceholder")}
-              defaultValue={customer?.taxId ?? ""}
-            />
-            <p className="text-xs text-muted-foreground">{t("taxIdHint")}</p>
-          </div>
 
-          <div className="flex items-center justify-between gap-4 rounded-lg border p-3">
-            <div className="space-y-0.5">
-              <Label>{t("taxExempt")}</Label>
-              <p className="text-xs text-muted-foreground">{t("taxExemptHint")}</p>
+            <div className="flex items-center justify-between gap-4 rounded-lg border p-3">
+              <div className="space-y-0.5">
+                <Label>{t('taxExempt')}</Label>
+                <p className="text-xs text-muted-foreground">{t('taxExemptHint')}</p>
+              </div>
+              <Switch checked={taxExempt} onCheckedChange={setTaxExempt} />
             </div>
-            <Switch checked={taxExempt} onCheckedChange={setTaxExempt} />
           </div>
 
-          <div className="space-y-2">
-            <Label htmlFor="notes">{tc("form.notes")}</Label>
+          <div className="space-y-2 md:col-span-2">
+            <Label htmlFor="notes">{tc('form.notes')}</Label>
             <Textarea
               id="notes"
               name="notes"
-              placeholder={t("notesPlaceholder")}
+              placeholder={t('notesPlaceholder')}
               rows={3}
-              defaultValue={customer?.notes ?? ""}
+              defaultValue={customer?.notes ?? ''}
             />
           </div>
 
-          <div className="flex justify-end gap-3 pt-4">
-            <Button
-              type="button"
-              variant="outline"
-              onClick={() => onOpenChange(false)}
-            >
-              {tc("buttons.cancel")}
+          <div className="flex justify-end gap-3 border-t pt-4 md:col-span-2">
+            <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+              {tc('buttons.cancel')}
             </Button>
             <Button type="submit" disabled={loading}>
               {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {customer ? tc("buttons.saveChanges") : t("addTitle")}
+              {customer ? tc('buttons.saveChanges') : t('addTitle')}
             </Button>
           </div>
         </form>
       </DialogContent>
     </Dialog>
-  );
+  )
 }

+ 161 - 0
src/features/vehicles/Actions/aiAnalyzeVehicleDocument.ts

@@ -0,0 +1,161 @@
+'use server'
+
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { db } from '@/lib/db'
+import { AI_KEYS } from '@/features/ai/Schema/aiSettingsSchema'
+import { visionCompletion } from '@/lib/ai'
+import { getLocale } from 'next-intl/server'
+import { localeNames, type Locale } from '@/i18n/config'
+
+/**
+ * What a registration document can give us. Everything is optional: papers
+ * differ by country, and a phone photo of a folded one rarely yields every
+ * field.
+ */
+export interface VehicleDocumentScan {
+  make?: string
+  model?: string
+  /** Year of first registration, the closest thing the papers state to a model year. */
+  year?: number
+  vin?: string
+  licensePlate?: string
+  color?: string
+  /** One of the form's fuel options, already normalised. */
+  fuelType?: string
+  engineSize?: string
+  /** Registered keeper, for attaching the vehicle to a customer. */
+  owner?: {
+    name?: string
+    address?: string
+  }
+}
+
+/**
+ * Whether this organization can scan at all. The vision call needs an AI
+ * provider and key configured, and every caller of the form would otherwise
+ * have to thread that down from its own server page.
+ */
+export async function isVehicleScanAvailable() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const settings = await db.appSetting.findMany({
+        where: {
+          organizationId,
+          key: { in: [AI_KEYS.AI_ENABLED, AI_KEYS.AI_API_KEY, AI_KEYS.AI_MODEL] },
+        },
+      })
+      const map = new Map(settings.map((s) => [s.key, s.value]))
+      return (
+        map.get(AI_KEYS.AI_ENABLED) === 'true' &&
+        Boolean(map.get(AI_KEYS.AI_API_KEY)) &&
+        Boolean(map.get(AI_KEYS.AI_MODEL))
+      )
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}
+
+const FUEL_TYPES = ['gasoline', 'diesel', 'electric', 'hybrid', 'two-stroke', 'other']
+
+export async function aiAnalyzeVehicleDocument(imageDataUris: string | string[]) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const locale = (await getLocale()) as Locale
+      const langName = localeNames[locale] || 'English'
+
+      const uris = Array.isArray(imageDataUris) ? imageDataUris : [imageDataUris]
+      const imageCount = uris.length
+
+      const systemPrompt = `You are an expert at reading vehicle registration documents from any country: the EU registration certificate (Zulassungsbescheinigung Teil I, carte grise, libretto di circolazione, kentekenbewijs, vognkort), a US title or registration card, a UK V5C, and similar papers.
+
+${imageCount > 1 ? `The ${imageCount} images show the same document, or its front and back. Combine information from all of them.` : ''}
+
+EU certificates label their fields with harmonised codes. Read them as:
+- A = registration number (license plate)
+- B = date of first registration
+- D.1 = make, D.2 = type/variant/version, D.3 = commercial description (model)
+- E = vehicle identification number (VIN)
+- P.1 = engine displacement, P.3 = fuel type
+- R = colour
+- C.1.1 = surname or business name of the keeper, C.1.2 = first name, C.1.3 = address
+
+On documents without these codes, read the equivalent labelled fields.
+
+Return ONLY valid JSON, no markdown fence, no commentary. Omit any field you cannot read with confidence, and never guess a VIN or plate from a partially legible one:
+{
+  "make": "manufacturer, e.g. Volkswagen",
+  "model": "model name only, with the manufacturer name removed if it repeats, e.g. Golf",
+  "year": 1999,
+  "vin": "chassis number exactly as printed, uppercase",
+  "licensePlate": "registration number exactly as printed",
+  "color": "colour${locale !== 'en' ? ` (translated into ${langName})` : ''}",
+  "fuelType": "one of: ${FUEL_TYPES.join(', ')}",
+  "engineSize": "displacement with its unit as printed, e.g. 1338 cc",
+  "owner": { "name": "given name followed by surname, or the business name", "address": "street, postal code and city on one line" }
+}
+
+"year" is the year from the date of first registration (field B), as a number.
+"fuelType" must be one of the listed values, translated from whatever the document says (Benzin/Essence/Bensin means gasoline, two-stroke only for outboard marine engines). Use "other" for anything that does not fit.
+Leave "make", "model", "vin", "licensePlate" and the owner details in their original form. Do not translate them.`
+
+      const userText =
+        imageCount > 1
+          ? `Read these ${imageCount} images of a vehicle registration document and extract the vehicle and keeper details.`
+          : 'Read this vehicle registration document and extract the vehicle and keeper details.'
+
+      const raw = await visionCompletion(organizationId, systemPrompt, userText, uris)
+
+      const cleaned = raw.replace(/^```json?\n?|\n?```$/g, '').trim()
+
+      // A model that answers in prose ("I cannot read this image") would
+      // otherwise surface as a parser error the workshop cannot act on.
+      let parsed: VehicleDocumentScan
+      try {
+        parsed = JSON.parse(cleaned) as VehicleDocumentScan
+      } catch {
+        console.error('[aiAnalyzeVehicleDocument] unparseable response:', cleaned.slice(0, 500))
+        throw new Error('Could not read the document. Try a sharper, straight-on photo.')
+      }
+
+      // The model returns free-form JSON, so sanity check anything the form
+      // would otherwise take at face value.
+      const year =
+        typeof parsed.year === 'number' &&
+        parsed.year >= 1885 &&
+        parsed.year <= new Date().getFullYear() + 1
+          ? parsed.year
+          : undefined
+
+      const fuelType =
+        typeof parsed.fuelType === 'string' && FUEL_TYPES.includes(parsed.fuelType)
+          ? parsed.fuelType
+          : undefined
+
+      const owner =
+        parsed.owner?.name || parsed.owner?.address
+          ? { name: parsed.owner?.name, address: parsed.owner?.address }
+          : undefined
+
+      const result: VehicleDocumentScan = {
+        make: parsed.make,
+        model: parsed.model,
+        year,
+        vin: parsed.vin?.toUpperCase().replace(/\s+/g, ''),
+        licensePlate: parsed.licensePlate,
+        color: parsed.color,
+        fuelType,
+        engineSize: parsed.engineSize,
+        owner,
+      }
+      return result
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.VEHICLES },
+      ],
+    }
+  )
+}

+ 112 - 0
src/features/vehicles/Components/ScanDocumentButton.tsx

@@ -0,0 +1,112 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { Loader2, ScanLine } from 'lucide-react'
+import {
+  aiAnalyzeVehicleDocument,
+  isVehicleScanAvailable,
+  type VehicleDocumentScan,
+} from '../Actions/aiAnalyzeVehicleDocument'
+import { documentToDataUri } from '../Lib/documentImage'
+
+interface ScanDocumentButtonProps {
+  /** Called with whatever the papers yielded, for the form to apply. */
+  onScanned: (data: VehicleDocumentScan) => void
+  /** Hidden when the surrounding form has no room for a second line. */
+  showHint?: boolean
+}
+
+/**
+ * Reads a registration document and hands the result to the form around it.
+ *
+ * Shared by the vehicle and customer dialogs: the same photo carries the
+ * vehicle's details and its keeper, so both forms have something to fill from
+ * it and neither should own the scanning.
+ */
+export function ScanDocumentButton({ onScanned, showHint = true }: ScanDocumentButtonProps) {
+  const t = useTranslations('vehicles.form')
+  const [scanning, setScanning] = useState(false)
+  /** null while the availability check is still in flight. */
+  const [available, setAvailable] = useState<boolean | null>(null)
+  const inputRef = useRef<HTMLInputElement>(null)
+
+  // Scanning needs an AI provider configured for the organization. Checked
+  // here rather than passed in, so no call site has to thread it down.
+  useEffect(() => {
+    let active = true
+    isVehicleScanAvailable().then((result) => {
+      if (active) setAvailable(result.success && result.data === true)
+    })
+    return () => {
+      active = false
+    }
+  }, [])
+
+  const handleSelect = useCallback(
+    async (e: React.ChangeEvent<HTMLInputElement>) => {
+      const file = e.target.files?.[0]
+      e.target.value = ''
+      if (!file) return
+
+      setScanning(true)
+      const toastId = toast.loading(t('scanningDocument'))
+      try {
+        const dataUri = await documentToDataUri(file)
+        const result = await aiAnalyzeVehicleDocument(dataUri)
+        if (!result.success || !result.data) {
+          toast.error(result.error || t('scanFailed'), { id: toastId })
+          return
+        }
+        onScanned(result.data)
+        toast.success(t('scanSuccess'), { id: toastId })
+      } catch {
+        toast.error(t('scanFailed'), { id: toastId })
+      } finally {
+        setScanning(false)
+      }
+    },
+    [onScanned, t]
+  )
+
+  return (
+    <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
+      <Tooltip>
+        {/* A disabled button swallows pointer events, so the trigger has to be
+            the wrapper rather than the button itself. */}
+        <TooltipTrigger asChild>
+          <span className="block">
+            <Button
+              type="button"
+              variant="outline"
+              className="w-full sm:w-auto"
+              onClick={() => inputRef.current?.click()}
+              disabled={scanning || !available}
+            >
+              {scanning ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <ScanLine className="mr-2 h-4 w-4" />
+              )}
+              {t('scanDocument')}
+            </Button>
+          </span>
+        </TooltipTrigger>
+        {available === false && <TooltipContent>{t('scanUnavailable')}</TooltipContent>}
+      </Tooltip>
+      {showHint && (
+        <p className="text-xs text-muted-foreground sm:flex-1">{t('scanDocumentHint')}</p>
+      )}
+      <input
+        ref={inputRef}
+        type="file"
+        accept="image/jpeg,image/png,image/webp,image/avif"
+        onChange={handleSelect}
+        className="hidden"
+      />
+    </div>
+  )
+}

+ 431 - 223
src/features/vehicles/Components/VehicleForm.tsx

@@ -15,6 +15,7 @@ import {
   SelectValue,
 } from '@/components/ui/select'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Checkbox } from '@/components/ui/checkbox'
 import {
   Command,
   CommandEmpty,
@@ -34,13 +35,28 @@ import { DocsLink } from '@/components/docs-link'
 import { toast } from 'sonner'
 import { useGlassModal } from '@/components/glass-modal'
 import { createVehicle, updateVehicle } from '../Actions/vehicleActions'
+import type { VehicleDocumentScan } from '../Actions/aiAnalyzeVehicleDocument'
+import { ScanDocumentButton } from './ScanDocumentButton'
+import { nameSimilarity } from '@/lib/name-similarity'
 import { Camera, Check, ChevronsUpDown, Loader2, Plus, X } from 'lucide-react'
 import { compressImage } from '@/lib/compress-image'
 import { CustomerForm } from '@/features/customers/Components/CustomerForm'
+import { createCustomer } from '@/features/customers/Actions/customerActions'
 import { useTranslations } from 'next-intl'
 import { useServiceType } from '@/components/service-type-context'
 import type { CreateVehicleInput } from '../Schema/vehicleSchema'
 
+/**
+ * How alike two names must read before the scanned keeper is offered as an
+ * existing customer rather than a new one.
+ */
+const OWNER_MATCH_THRESHOLD = 0.9
+
+interface OwnerMatch {
+  customer: { id: string; name: string; company: string | null }
+  score: number
+}
+
 interface VehicleFormProps {
   open: boolean
   onOpenChange: (open: boolean) => void
@@ -87,14 +103,26 @@ export function VehicleForm({
   const [customerOpen, setCustomerOpen] = useState(false)
   const [showCustomerForm, setShowCustomerForm] = useState(false)
   const [localCustomers, setLocalCustomers] = useState(customers || [])
+  const [fuelType, setFuelType] = useState(vehicle?.fuelType ?? 'gasoline')
+  /** Keeper read off a scanned document, until it is tied to a customer. */
+  const [scannedOwner, setScannedOwner] = useState<{ name?: string; address?: string } | null>(null)
+  const [ownerMatches, setOwnerMatches] = useState<OwnerMatch[]>([])
+  /** Whether saving should also create the keeper as a customer. */
+  const [addOwner, setAddOwner] = useState(true)
+  const [showOwnerMatch, setShowOwnerMatch] = useState(false)
   const fileRef = useRef<HTMLInputElement>(null)
+  const formRef = useRef<HTMLFormElement>(null)
 
   // Sync state when vehicle prop changes (e.g. opening edit for a different vehicle)
   useEffect(() => {
     setSelectedCustomerId(vehicle?.customerId || defaultCustomerId || 'none')
     setPreview(vehicle?.imageUrl ?? null)
     setImageFile(null)
-  }, [vehicle?.id, vehicle?.customerId, vehicle?.imageUrl, defaultCustomerId])
+    setFuelType(vehicle?.fuelType ?? 'gasoline')
+    setScannedOwner(null)
+    setOwnerMatches([])
+    setAddOwner(true)
+  }, [vehicle?.id, vehicle?.customerId, vehicle?.imageUrl, vehicle?.fuelType, defaultCustomerId])
 
   const selectedCustomerLabel = useMemo(() => {
     if (!selectedCustomerId || selectedCustomerId === 'none') return t('noCustomer')
@@ -142,6 +170,55 @@ export function VehicleForm({
     return url
   }
 
+  const applyScan = (data: VehicleDocumentScan) => {
+    const form = formRef.current
+    if (!form) return
+
+    const setIfEmpty = (name: string, value: string | undefined) => {
+      if (!value) return
+      const input = form.elements.namedItem(name) as HTMLInputElement | null
+      if (input && !input.value) input.value = value
+    }
+
+    setIfEmpty('make', data.make)
+    setIfEmpty('model', data.model)
+    setIfEmpty('year', data.year ? String(data.year) : undefined)
+    setIfEmpty('vin', data.vin)
+    setIfEmpty('licensePlate', data.licensePlate)
+    setIfEmpty('color', data.color)
+    setIfEmpty('engineSize', data.engineSize)
+
+    // A select has no empty state, so only fill it while it still holds the
+    // value the form opened with.
+    if (data.fuelType && fuelType === (vehicle?.fuelType ?? 'gasoline')) {
+      setFuelType(data.fuelType)
+    }
+
+    if (!data.owner?.name || selectedCustomerId !== 'none') return
+    setScannedOwner(data.owner)
+    setAddOwner(true)
+
+    // Near-identical names are still a judgement call: two brothers at one
+    // address differ by a first name. Offer the close ones and let the user say.
+    const ownerName = data.owner.name
+    const candidates = localCustomers
+      .map((c) => ({
+        customer: c,
+        // Papers naming a business may match the company field rather than the
+        // contact saved as the customer's name.
+        score: Math.max(
+          nameSimilarity(ownerName, c.name),
+          c.company ? nameSimilarity(ownerName, c.company) : 0
+        ),
+      }))
+      .filter((m) => m.score >= OWNER_MATCH_THRESHOLD)
+      .sort((a, b) => b.score - a.score)
+      .slice(0, 5)
+
+    setOwnerMatches(candidates)
+    if (candidates.length > 0) setShowOwnerMatch(true)
+  }
+
   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault()
     const formData = new FormData(e.currentTarget)
@@ -161,6 +238,21 @@ export function VehicleForm({
         return
       }
 
+      // The keeper read off the papers is nobody until a customer row exists,
+      // and losing them to an unnoticed checkbox is worse than an extra record.
+      let customerId = selectedCustomerId === 'none' ? undefined : selectedCustomerId || undefined
+      if (!customerId && addOwner && scannedOwner?.name) {
+        const created = await createCustomer({
+          name: scannedOwner.name,
+          address: scannedOwner.address,
+        })
+        if (created.success && created.data) {
+          customerId = (created.data as { id: string }).id
+        } else {
+          toast.error(created.error || t('ownerSaveFailed'))
+        }
+      }
+
       const data: CreateVehicleInput & { imageUrl?: string } = {
         make: formData.get('make') as string,
         model: formData.get('model') as string,
@@ -169,11 +261,11 @@ export function VehicleForm({
         licensePlate: (formData.get('licensePlate') as string) || undefined,
         color: (formData.get('color') as string) || undefined,
         mileage: Number(formData.get('mileage')) || 0,
-        fuelType: (formData.get('fuelType') as string) || undefined,
+        fuelType: fuelType || undefined,
         transmission: (formData.get('transmission') as string) || undefined,
         engineSize: (formData.get('engineSize') as string) || undefined,
         engineCode: (formData.get('engineCode') as string) || undefined,
-        customerId: selectedCustomerId === 'none' ? undefined : selectedCustomerId || undefined,
+        customerId,
       }
 
       const payload = imageUrl ? { ...data, imageUrl } : data
@@ -198,7 +290,7 @@ export function VehicleForm({
   return (
     <>
       <Dialog open={open} onOpenChange={onOpenChange}>
-        <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
+        <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
           <DialogHeader>
             <DialogTitle>{vehicle ? t('editTitle') : t('addTitle')}</DialogTitle>
             <DocsLink href="/docs/features/vehicles" variant="hint" className="self-start" />
@@ -207,223 +299,282 @@ export function VehicleForm({
             </DialogDescription>
           </DialogHeader>
 
-          <form onSubmit={handleSubmit} className="space-y-4">
-            {/* Image upload */}
-            <div className="space-y-2">
-              <Label>{t('photo')}</Label>
-              <div
-                {...interactiveRow(() => fileRef.current?.click())}
-                className="group relative flex h-44 cursor-pointer items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-border bg-muted/30 transition-colors hover:border-primary/50 hover:bg-muted/50"
-              >
-                {preview ? (
-                  <>
-                    <Image
-                      src={preview}
-                      alt="Vehicle preview"
-                      fill
-                      unoptimized
-                      className="object-cover"
-                    />
-                    <div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" />
-                    <button
-                      type="button"
-                      onClick={(e) => {
-                        e.stopPropagation()
-                        clearImage()
-                      }}
-                      className="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity group-hover:opacity-100"
-                    >
-                      <X className="h-4 w-4" />
-                    </button>
-                  </>
-                ) : (
-                  <div className="flex flex-col items-center gap-2 text-muted-foreground">
-                    <Camera className="h-8 w-8" />
-                    <span className="text-sm">{t('clickToUpload')}</span>
-                    <span className="text-xs">{t('imageFormats')}</span>
-                  </div>
-                )}
-              </div>
-              <input
-                ref={fileRef}
-                type="file"
-                accept="image/jpeg,image/png,image/webp,image/avif"
-                onChange={handleImageSelect}
-                className="hidden"
-              />
+          <form
+            ref={formRef}
+            onSubmit={handleSubmit}
+            className="grid gap-x-6 gap-y-4 md:grid-cols-2"
+          >
+            {/* Scan the registration document and fill the form from it */}
+            <div className="md:col-span-2">
+              <ScanDocumentButton onScanned={applyScan} />
             </div>
 
-            {/* Customer selector */}
-            <div className="space-y-2">
-              <Label>{t('customer')}</Label>
-              <Popover open={customerOpen} onOpenChange={setCustomerOpen} modal={true}>
-                <PopoverTrigger asChild>
-                  <Button
-                    variant="outline"
-                    role="combobox"
-                    aria-expanded={customerOpen}
-                    className="w-full justify-between font-normal"
-                  >
-                    <span className="truncate">{selectedCustomerLabel}</span>
-                    <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
-                  </Button>
-                </PopoverTrigger>
-                <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
-                  <Command>
-                    <CommandInput placeholder={t('searchCustomers')} />
-                    <CommandList className="max-h-60 overflow-y-auto">
-                      <CommandEmpty>{t('noCustomerFound')}</CommandEmpty>
-                      <CommandGroup>
-                        <CommandItem
-                          value="no-customer"
-                          onSelect={() => {
-                            setSelectedCustomerId('none')
-                            setCustomerOpen(false)
-                          }}
-                        >
-                          <Check
-                            className={`mr-2 h-4 w-4 ${selectedCustomerId === 'none' ? 'opacity-100' : 'opacity-0'}`}
-                          />
-                          {t('noCustomer')}
-                        </CommandItem>
-                        {localCustomers.map((c) => {
-                          const label = c.name + (c.company ? ` (${c.company})` : '')
-                          return (
-                            <CommandItem
-                              key={c.id}
-                              value={label}
-                              onSelect={() => {
-                                setSelectedCustomerId(c.id)
-                                setCustomerOpen(false)
-                              }}
-                            >
-                              <Check
-                                className={`mr-2 h-4 w-4 ${selectedCustomerId === c.id ? 'opacity-100' : 'opacity-0'}`}
-                              />
-                              {label}
-                            </CommandItem>
-                          )
-                        })}
-                      </CommandGroup>
-                    </CommandList>
-                    <div className="border-t p-1">
+            {/* Left: what the vehicle belongs to and where its data comes from */}
+            <div className="space-y-4">
+              <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+                {t('sectionAssignment')}
+              </p>
+
+              {/* Image upload */}
+              <div className="space-y-2">
+                <Label>{t('photo')}</Label>
+                <div
+                  {...interactiveRow(() => fileRef.current?.click())}
+                  className="group relative flex h-44 cursor-pointer items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-border bg-muted/30 transition-colors hover:border-primary/50 hover:bg-muted/50"
+                >
+                  {preview ? (
+                    <>
+                      <Image
+                        src={preview}
+                        alt="Vehicle preview"
+                        fill
+                        unoptimized
+                        className="object-cover"
+                      />
+                      <div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" />
                       <button
                         type="button"
-                        className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
-                        onClick={() => {
-                          setCustomerOpen(false)
-                          setShowCustomerForm(true)
+                        onClick={(e) => {
+                          e.stopPropagation()
+                          clearImage()
                         }}
+                        className="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity group-hover:opacity-100"
                       >
-                        <Plus className="h-4 w-4" />
-                        {t('addCustomer')}
+                        <X className="h-4 w-4" />
                       </button>
+                    </>
+                  ) : (
+                    <div className="flex flex-col items-center gap-2 text-muted-foreground">
+                      <Camera className="h-8 w-8" />
+                      <span className="text-sm">{t('clickToUpload')}</span>
+                      <span className="text-xs">{t('imageFormats')}</span>
                     </div>
-                  </Command>
-                </PopoverContent>
-              </Popover>
-            </div>
-
-            <div className="grid grid-cols-2 gap-4">
-              <div className="space-y-2">
-                <Label htmlFor="make">{isMarine ? t('makeMarine') : t('make')}</Label>
-                <Input
-                  id="make"
-                  name="make"
-                  placeholder={isMarine ? 'Boston Whaler' : 'Toyota'}
-                  defaultValue={vehicle?.make}
-                  required
+                  )}
+                </div>
+                <input
+                  ref={fileRef}
+                  type="file"
+                  accept="image/jpeg,image/png,image/webp,image/avif"
+                  onChange={handleImageSelect}
+                  className="hidden"
                 />
               </div>
-              <div className="space-y-2">
-                <Label htmlFor="model">{t('model')}</Label>
-                <Input
-                  id="model"
-                  name="model"
-                  placeholder={isMarine ? 'Montauk 170' : 'Camry'}
-                  defaultValue={vehicle?.model}
-                  required
-                />
-              </div>
-            </div>
 
-            <div className="grid grid-cols-2 gap-4">
+              {/* Customer selector */}
               <div className="space-y-2">
-                <Label htmlFor="year">{t('year')}</Label>
-                <Input
-                  id="year"
-                  name="year"
-                  type="number"
-                  placeholder="2024"
-                  defaultValue={vehicle?.year}
-                  required
-                />
-              </div>
-              <div className="space-y-2">
-                <Label htmlFor="mileage">{isMarine ? t('mileageMarine') : t('mileage')}</Label>
-                <Input
-                  id="mileage"
-                  name="mileage"
-                  type="number"
-                  placeholder="0"
-                  defaultValue={vehicle?.mileage}
-                />
+                <Label>{t('customer')}</Label>
+                <Popover open={customerOpen} onOpenChange={setCustomerOpen} modal={true}>
+                  <PopoverTrigger asChild>
+                    <Button
+                      variant="outline"
+                      role="combobox"
+                      aria-expanded={customerOpen}
+                      className="w-full justify-between font-normal"
+                    >
+                      <span className="truncate">{selectedCustomerLabel}</span>
+                      <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+                    </Button>
+                  </PopoverTrigger>
+                  <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+                    <Command>
+                      <CommandInput placeholder={t('searchCustomers')} />
+                      <CommandList className="max-h-60 overflow-y-auto">
+                        <CommandEmpty>{t('noCustomerFound')}</CommandEmpty>
+                        <CommandGroup>
+                          <CommandItem
+                            value="no-customer"
+                            onSelect={() => {
+                              setSelectedCustomerId('none')
+                              setCustomerOpen(false)
+                            }}
+                          >
+                            <Check
+                              className={`mr-2 h-4 w-4 ${selectedCustomerId === 'none' ? 'opacity-100' : 'opacity-0'}`}
+                            />
+                            {t('noCustomer')}
+                          </CommandItem>
+                          {localCustomers.map((c) => {
+                            const label = c.name + (c.company ? ` (${c.company})` : '')
+                            return (
+                              <CommandItem
+                                key={c.id}
+                                value={label}
+                                onSelect={() => {
+                                  setSelectedCustomerId(c.id)
+                                  setCustomerOpen(false)
+                                }}
+                              >
+                                <Check
+                                  className={`mr-2 h-4 w-4 ${selectedCustomerId === c.id ? 'opacity-100' : 'opacity-0'}`}
+                                />
+                                {label}
+                              </CommandItem>
+                            )
+                          })}
+                        </CommandGroup>
+                      </CommandList>
+                      <div className="border-t p-1">
+                        <button
+                          type="button"
+                          className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
+                          onClick={() => {
+                            setCustomerOpen(false)
+                            setShowCustomerForm(true)
+                          }}
+                        >
+                          <Plus className="h-4 w-4" />
+                          {t('addCustomer')}
+                        </button>
+                      </div>
+                    </Command>
+                  </PopoverContent>
+                </Popover>
+
+                {/* Keeper read off the papers who matches no customer yet */}
+                {scannedOwner?.name && selectedCustomerId === 'none' && (
+                  <div className="space-y-2 rounded-lg border border-dashed border-border bg-muted/30 p-3">
+                    <label className="flex cursor-pointer items-start gap-3">
+                      <Checkbox
+                        className="mt-0.5"
+                        checked={addOwner}
+                        onCheckedChange={(next) => setAddOwner(next === true)}
+                      />
+                      <span className="min-w-0">
+                        <span className="block text-sm">
+                          {t('addScannedOwner', { name: scannedOwner.name })}
+                        </span>
+                        {scannedOwner.address && (
+                          <span className="block truncate text-xs text-muted-foreground">
+                            {scannedOwner.address}
+                          </span>
+                        )}
+                      </span>
+                    </label>
+
+                    {ownerMatches.length > 0 && (
+                      <Button
+                        type="button"
+                        size="sm"
+                        variant="outline"
+                        className="w-full"
+                        onClick={() => setShowOwnerMatch(true)}
+                      >
+                        {t('ownerMatchChoose')}
+                      </Button>
+                    )}
+                  </div>
+                )}
               </div>
             </div>
 
-            <div className="grid grid-cols-2 gap-4">
-              <div className="space-y-2">
-                <Label htmlFor="vin">{isMarine ? t('vinMarine') : t('vin')}</Label>
-                <Input
-                  id="vin"
-                  name="vin"
-                  placeholder="1HGCM82633A004352"
-                  defaultValue={vehicle?.vin ?? ''}
-                />
+            {/* Right: the vehicle's own details */}
+            <div className="space-y-4">
+              <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+                {t('sectionDetails')}
+              </p>
+
+              <div className="grid grid-cols-2 gap-4">
+                <div className="space-y-2">
+                  <Label htmlFor="make">{isMarine ? t('makeMarine') : t('make')}</Label>
+                  <Input
+                    id="make"
+                    name="make"
+                    placeholder={isMarine ? 'Boston Whaler' : 'Toyota'}
+                    defaultValue={vehicle?.make}
+                    required
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="model">{t('model')}</Label>
+                  <Input
+                    id="model"
+                    name="model"
+                    placeholder={isMarine ? 'Montauk 170' : 'Camry'}
+                    defaultValue={vehicle?.model}
+                    required
+                  />
+                </div>
               </div>
-              <div className="space-y-2">
-                <Label htmlFor="licensePlate">
-                  {isMarine ? t('licensePlateMarine') : t('licensePlate')}
-                </Label>
-                <Input
-                  id="licensePlate"
-                  name="licensePlate"
-                  placeholder="ABC-1234"
-                  defaultValue={vehicle?.licensePlate ?? ''}
-                />
+
+              <div className="grid grid-cols-2 gap-4">
+                <div className="space-y-2">
+                  <Label htmlFor="year">{t('year')}</Label>
+                  <Input
+                    id="year"
+                    name="year"
+                    type="number"
+                    placeholder="2024"
+                    defaultValue={vehicle?.year}
+                    required
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="mileage">{isMarine ? t('mileageMarine') : t('mileage')}</Label>
+                  <Input
+                    id="mileage"
+                    name="mileage"
+                    type="number"
+                    placeholder="0"
+                    defaultValue={vehicle?.mileage}
+                  />
+                </div>
               </div>
-            </div>
 
-            <div className="grid grid-cols-3 gap-4">
-              <div className="space-y-2">
-                <Label htmlFor="color">{t('color')}</Label>
-                <Input
-                  id="color"
-                  name="color"
-                  placeholder="Silver"
-                  defaultValue={vehicle?.color ?? ''}
-                />
+              <div className="grid grid-cols-2 gap-4">
+                <div className="space-y-2">
+                  <Label htmlFor="vin">{isMarine ? t('vinMarine') : t('vin')}</Label>
+                  <Input
+                    id="vin"
+                    name="vin"
+                    placeholder="1HGCM82633A004352"
+                    defaultValue={vehicle?.vin ?? ''}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="licensePlate">
+                    {isMarine ? t('licensePlateMarine') : t('licensePlate')}
+                  </Label>
+                  <Input
+                    id="licensePlate"
+                    name="licensePlate"
+                    placeholder="ABC-1234"
+                    defaultValue={vehicle?.licensePlate ?? ''}
+                  />
+                </div>
               </div>
-              <div className="space-y-2">
-                <Label htmlFor="fuelType">{t('fuelType')}</Label>
-                <Select name="fuelType" defaultValue={vehicle?.fuelType ?? 'gasoline'}>
-                  <SelectTrigger>
-                    <SelectValue />
-                  </SelectTrigger>
-                  <SelectContent>
-                    <SelectItem value="gasoline">{t('gasoline')}</SelectItem>
-                    <SelectItem value="diesel">{t('diesel')}</SelectItem>
-                    {!isMarine && (
-                      <>
-                        <SelectItem value="electric">{t('electric')}</SelectItem>
-                        <SelectItem value="hybrid">{t('hybrid')}</SelectItem>
-                      </>
-                    )}
-                    {isMarine && <SelectItem value="two-stroke">{t('twoStroke')}</SelectItem>}
-                    <SelectItem value="other">{t('other')}</SelectItem>
-                  </SelectContent>
-                </Select>
+
+              <div className="grid grid-cols-2 gap-4">
+                <div className="space-y-2">
+                  <Label htmlFor="color">{t('color')}</Label>
+                  <Input
+                    id="color"
+                    name="color"
+                    placeholder="Silver"
+                    defaultValue={vehicle?.color ?? ''}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="fuelType">{t('fuelType')}</Label>
+                  <Select name="fuelType" value={fuelType} onValueChange={setFuelType}>
+                    <SelectTrigger>
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="gasoline">{t('gasoline')}</SelectItem>
+                      <SelectItem value="diesel">{t('diesel')}</SelectItem>
+                      {!isMarine && (
+                        <>
+                          <SelectItem value="electric">{t('electric')}</SelectItem>
+                          <SelectItem value="hybrid">{t('hybrid')}</SelectItem>
+                        </>
+                      )}
+                      {isMarine && <SelectItem value="two-stroke">{t('twoStroke')}</SelectItem>}
+                      <SelectItem value="other">{t('other')}</SelectItem>
+                    </SelectContent>
+                  </Select>
+                </div>
               </div>
+
               <div className="space-y-2">
                 <Label htmlFor="transmission">
                   {isMarine ? t('transmissionMarine') : t('transmission')}
@@ -451,32 +602,32 @@ export function VehicleForm({
                   </SelectContent>
                 </Select>
               </div>
-            </div>
 
-            <div className="grid grid-cols-2 gap-4">
-              <div className="space-y-2">
-                <Label htmlFor="engineSize">
-                  {isMarine ? t('engineSizeMarine') : t('engineSize')}
-                </Label>
-                <Input
-                  id="engineSize"
-                  name="engineSize"
-                  placeholder={isMarine ? 'Mercury F 350 XXL Verado V-10 (350 hp)' : '2.5L'}
-                  defaultValue={vehicle?.engineSize ?? ''}
-                />
-              </div>
-              <div className="space-y-2">
-                <Label htmlFor="engineCode">{t('engineCode')}</Label>
-                <Input
-                  id="engineCode"
-                  name="engineCode"
-                  placeholder="2AR-FE"
-                  defaultValue={vehicle?.engineCode ?? ''}
-                />
+              <div className="grid grid-cols-2 gap-4">
+                <div className="space-y-2">
+                  <Label htmlFor="engineSize">
+                    {isMarine ? t('engineSizeMarine') : t('engineSize')}
+                  </Label>
+                  <Input
+                    id="engineSize"
+                    name="engineSize"
+                    placeholder={isMarine ? 'Mercury F 350 XXL Verado V-10 (350 hp)' : '2.5L'}
+                    defaultValue={vehicle?.engineSize ?? ''}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="engineCode">{t('engineCode')}</Label>
+                  <Input
+                    id="engineCode"
+                    name="engineCode"
+                    placeholder="2AR-FE"
+                    defaultValue={vehicle?.engineCode ?? ''}
+                  />
+                </div>
               </div>
             </div>
 
-            <div className="flex justify-end gap-3 pt-4">
+            <div className="flex justify-end gap-3 border-t pt-4 md:col-span-2">
               <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
                 {tc('cancel')}
               </Button>
@@ -489,12 +640,69 @@ export function VehicleForm({
         </DialogContent>
       </Dialog>
 
+      {/* Close matches for the keeper on the papers */}
+      <Dialog open={showOwnerMatch} onOpenChange={setShowOwnerMatch}>
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle>{t('ownerMatchTitle')}</DialogTitle>
+            <DialogDescription>
+              {t('ownerMatchDescription', { name: scannedOwner?.name ?? '' })}
+            </DialogDescription>
+          </DialogHeader>
+
+          <div className="space-y-2">
+            {ownerMatches.map(({ customer, score }) => (
+              <button
+                key={customer.id}
+                type="button"
+                className="flex w-full items-center justify-between gap-3 rounded-lg border border-border p-3 text-left transition-colors hover:bg-accent"
+                onClick={() => {
+                  setSelectedCustomerId(customer.id)
+                  setScannedOwner(null)
+                  setShowOwnerMatch(false)
+                }}
+              >
+                <span className="min-w-0">
+                  <span className="block truncate text-sm font-medium">{customer.name}</span>
+                  {customer.company && (
+                    <span className="block truncate text-xs text-muted-foreground">
+                      {customer.company}
+                    </span>
+                  )}
+                </span>
+                <span className="shrink-0 text-xs text-muted-foreground">
+                  {t('ownerMatchScore', { percent: Math.round(score * 100) })}
+                </span>
+              </button>
+            ))}
+          </div>
+
+          <div className="flex justify-end gap-2">
+            <Button type="button" variant="outline" onClick={() => setShowOwnerMatch(false)}>
+              {tc('cancel')}
+            </Button>
+            <Button
+              type="button"
+              onClick={() => {
+                setShowOwnerMatch(false)
+                setShowCustomerForm(true)
+              }}
+            >
+              <Plus className="mr-1 h-4 w-4" />
+              {t('ownerMatchCreateNew')}
+            </Button>
+          </div>
+        </DialogContent>
+      </Dialog>
+
       <CustomerForm
         open={showCustomerForm}
         onOpenChange={setShowCustomerForm}
+        defaults={scannedOwner ?? undefined}
         onCreated={(created) => {
           setLocalCustomers((prev) => [...prev, created])
           setSelectedCustomerId(created.id)
+          setScannedOwner(null)
         }}
       />
     </>

+ 68 - 0
src/features/vehicles/Lib/documentImage.ts

@@ -0,0 +1,68 @@
+/**
+ * Turns a photographed document into a JPEG data URI for the vision model.
+ *
+ * Kept separate from `compressImage`: that one is tuned for photos that end up
+ * in PDFs and may hand back the original file untouched. A vision call needs a
+ * format the API accepts every time, and registration papers carry small print,
+ * so this keeps a longer edge and a higher quality than a plain photo would need.
+ */
+
+/**
+ * A server action body has to stay under a megabyte, and base64 adds a third on
+ * top of the JPEG. Anything larger gets re-encoded smaller rather than failing
+ * at the request boundary.
+ */
+const MAX_DATA_URI_BYTES = 800_000
+
+function encode(img: HTMLImageElement, maxEdge: number, quality: number): string {
+  let { naturalWidth: width, naturalHeight: height } = img
+  if (width > maxEdge || height > maxEdge) {
+    const scale = maxEdge / Math.max(width, height)
+    width = Math.round(width * scale)
+    height = Math.round(height * scale)
+  }
+
+  const canvas = document.createElement('canvas')
+  canvas.width = width
+  canvas.height = height
+  const ctx = canvas.getContext('2d')
+  if (!ctx) throw new Error('Canvas not available')
+
+  ctx.drawImage(img, 0, 0, width, height)
+  return canvas.toDataURL('image/jpeg', quality)
+}
+
+export function documentToDataUri(file: File, maxEdge = 1600, quality = 0.85): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const img = new window.Image()
+    const objectUrl = URL.createObjectURL(file)
+
+    img.onload = () => {
+      URL.revokeObjectURL(objectUrl)
+      try {
+        // Small print is worth a large image, so give up resolution only as far
+        // as the request limit demands.
+        const attempts: Array<[number, number]> = [
+          [maxEdge, quality],
+          [1280, 0.75],
+          [1024, 0.7],
+        ]
+        let encoded = ''
+        for (const [edge, q] of attempts) {
+          encoded = encode(img, edge, q)
+          if (encoded.length <= MAX_DATA_URI_BYTES) break
+        }
+        resolve(encoded)
+      } catch (error) {
+        reject(error instanceof Error ? error : new Error('Could not read image'))
+      }
+    }
+
+    img.onerror = () => {
+      URL.revokeObjectURL(objectUrl)
+      reject(new Error('Could not read image'))
+    }
+
+    img.src = objectUrl
+  })
+}

+ 40 - 0
src/lib/ai-error.ts

@@ -0,0 +1,40 @@
+import OpenAI from 'openai'
+
+/**
+ * Turns whatever a provider threw into one short line fit for a toast.
+ *
+ * A rejected key rarely comes back as tidy JSON: a gateway in front of the
+ * provider answers with an HTML error page, and the SDK puts that entire page
+ * in `error.message`. Users were seeing raw markup in the toast.
+ */
+export function describeAiError(error: unknown): string {
+  const generic = 'The AI provider returned an unexpected response.'
+  const status = error instanceof OpenAI.APIError ? error.status : undefined
+
+  switch (status) {
+    case 401:
+    case 403:
+      return 'The AI provider rejected the API key. Check it in Settings → AI.'
+    case 402:
+      return 'The AI provider reports no usable credit for this API key.'
+    case 404:
+      return 'The AI provider does not offer the selected model. Pick another in Settings → AI.'
+    case 413:
+      return 'The image was too large for the AI provider.'
+    case 429:
+      return 'The AI provider is rate limiting this API key. Try again in a moment.'
+  }
+  if (status && status >= 500) {
+    return 'The AI provider is unavailable right now. Try again in a moment.'
+  }
+
+  const raw = error instanceof Error ? error.message : String(error ?? '')
+
+  // An HTML body says nothing a workshop can act on, and stripping the tags
+  // just leaves the debris of a status page.
+  if (/<\s*(!doctype|html|head|body|div|p|span|title)\b/i.test(raw)) return generic
+
+  const text = raw.replace(/\s+/g, ' ').trim()
+  if (!text) return generic
+  return text.length > 200 ? `${text.slice(0, 200)}...` : text
+}

+ 119 - 115
src/lib/ai.ts

@@ -1,13 +1,14 @@
-import "server-only";
-import { db } from "@/lib/db";
-import { AI_KEYS } from "@/features/ai/Schema/aiSettingsSchema";
-import { localeNames, type Locale } from "@/i18n/config";
-import OpenAI from "openai";
+import 'server-only'
+import { db } from '@/lib/db'
+import { AI_KEYS } from '@/features/ai/Schema/aiSettingsSchema'
+import { localeNames, type Locale } from '@/i18n/config'
+import OpenAI from 'openai'
+import { describeAiError } from '@/lib/ai-error'
 
 interface AiConfig {
-  provider: string;
-  apiKey: string;
-  model: string;
+  provider: string
+  apiKey: string
+  model: string
 }
 
 export async function getAiConfig(organizationId: string): Promise<AiConfig> {
@@ -15,138 +16,143 @@ export async function getAiConfig(organizationId: string): Promise<AiConfig> {
     where: {
       organizationId,
       key: {
-        in: [
-          AI_KEYS.AI_ENABLED,
-          AI_KEYS.AI_PROVIDER,
-          AI_KEYS.AI_API_KEY,
-          AI_KEYS.AI_MODEL,
-        ],
+        in: [AI_KEYS.AI_ENABLED, AI_KEYS.AI_PROVIDER, AI_KEYS.AI_API_KEY, AI_KEYS.AI_MODEL],
       },
     },
-  });
+  })
 
-  const map = new Map(settings.map((s) => [s.key, s.value]));
+  const map = new Map(settings.map((s) => [s.key, s.value]))
 
-  if (map.get(AI_KEYS.AI_ENABLED) !== "true") {
-    throw new Error("AI is not enabled. Configure it in Settings → AI.");
+  if (map.get(AI_KEYS.AI_ENABLED) !== 'true') {
+    throw new Error('AI is not enabled. Configure it in Settings → AI.')
   }
 
-  const provider = map.get(AI_KEYS.AI_PROVIDER);
-  const apiKey = map.get(AI_KEYS.AI_API_KEY);
-  const model = map.get(AI_KEYS.AI_MODEL);
+  const provider = map.get(AI_KEYS.AI_PROVIDER)
+  const apiKey = map.get(AI_KEYS.AI_API_KEY)
+  const model = map.get(AI_KEYS.AI_MODEL)
 
   if (!provider || !apiKey || !model) {
     throw new Error(
-      "AI is not fully configured. Set provider, API key, and model in Settings → AI.",
-    );
+      'AI is not fully configured. Set provider, API key, and model in Settings → AI.'
+    )
   }
 
-  return { provider, apiKey, model };
+  return { provider, apiKey, model }
 }
 
 export function createClient(config: AiConfig): OpenAI {
-  if (config.provider === "anthropic") {
+  if (config.provider === 'anthropic') {
     return new OpenAI({
       apiKey: config.apiKey,
-      baseURL: "https://api.anthropic.com/v1/",
+      baseURL: 'https://api.anthropic.com/v1/',
       defaultHeaders: {
-        "anthropic-version": "2023-06-01",
+        'anthropic-version': '2023-06-01',
       },
-    });
+    })
   }
-  return new OpenAI({ apiKey: config.apiKey });
+  return new OpenAI({ apiKey: config.apiKey })
 }
 
 function languageInstruction(locale: Locale): string {
-  if (locale === "en") return "";
-  const name = localeNames[locale] || locale;
-  return `\n\nIMPORTANT: You MUST respond entirely in ${name}.`;
+  if (locale === 'en') return ''
+  const name = localeNames[locale] || locale
+  return `\n\nIMPORTANT: You MUST respond entirely in ${name}.`
 }
 
 async function chatCompletion(
   organizationId: string,
   systemPrompt: string,
-  userPrompt: string,
+  userPrompt: string
 ): Promise<string> {
-  const config = await getAiConfig(organizationId);
-  const client = createClient(config);
-
-  const response = await client.chat.completions.create({
-    model: config.model,
-    messages: [
-      { role: "system", content: systemPrompt },
-      { role: "user", content: userPrompt },
-    ],
-    temperature: 0.7,
-    max_tokens: 2000,
-  });
-
-  return response.choices[0]?.message?.content ?? "";
+  const config = await getAiConfig(organizationId)
+  const client = createClient(config)
+
+  try {
+    const response = await client.chat.completions.create({
+      model: config.model,
+      messages: [
+        { role: 'system', content: systemPrompt },
+        { role: 'user', content: userPrompt },
+      ],
+      temperature: 0.7,
+      max_tokens: 2000,
+    })
+
+    return response.choices[0]?.message?.content ?? ''
+  } catch (error) {
+    console.error('[ai] chat completion failed:', error)
+    throw new Error(describeAiError(error))
+  }
 }
 
 export async function visionCompletion(
   organizationId: string,
   systemPrompt: string,
   userText: string,
-  imageUrls: string | string[],
+  imageUrls: string | string[]
 ): Promise<string> {
-  const config = await getAiConfig(organizationId);
-  const client = createClient(config);
-
-  const urls = Array.isArray(imageUrls) ? imageUrls : [imageUrls];
-  const content: Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }> = [
-    { type: "text", text: userText },
-    ...urls.map((url) => ({ type: "image_url" as const, image_url: { url } })),
-  ];
-
-  const response = await client.chat.completions.create({
-    model: config.model,
-    messages: [
-      { role: "system", content: systemPrompt },
-      { role: "user", content },
-    ],
-    temperature: 0.3,
-    max_tokens: 1000,
-  });
-
-  return response.choices[0]?.message?.content ?? "";
+  const config = await getAiConfig(organizationId)
+  const client = createClient(config)
+
+  const urls = Array.isArray(imageUrls) ? imageUrls : [imageUrls]
+  const content: Array<
+    { type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }
+  > = [
+    { type: 'text', text: userText },
+    ...urls.map((url) => ({ type: 'image_url' as const, image_url: { url } })),
+  ]
+
+  try {
+    const response = await client.chat.completions.create({
+      model: config.model,
+      messages: [
+        { role: 'system', content: systemPrompt },
+        { role: 'user', content },
+      ],
+      temperature: 0.3,
+      max_tokens: 1000,
+    })
+
+    return response.choices[0]?.message?.content ?? ''
+  } catch (error) {
+    console.error('[ai] vision completion failed:', error)
+    throw new Error(describeAiError(error))
+  }
 }
 
 // ─── AI Feature Functions ────────────────────────────────────────────────────
 
 export interface ServiceContext {
-  vehicleMake: string | null;
-  vehicleModel: string | null;
-  vehicleYear: number | null;
-  licensePlate?: string | null;
-  serviceType: string;
-  serviceTitle: string;
-  parts: { name: string; quantity: number }[];
-  labor: { description: string; hours: number }[];
+  vehicleMake: string | null
+  vehicleModel: string | null
+  vehicleYear: number | null
+  licensePlate?: string | null
+  serviceType: string
+  serviceTitle: string
+  parts: { name: string; quantity: number }[]
+  labor: { description: string; hours: number }[]
 }
 
 export async function generateServiceDescription(
   organizationId: string,
   context: ServiceContext,
-  locale: Locale = "en",
+  locale: Locale = 'en'
 ): Promise<string> {
-  const systemPrompt = `You are a professional automotive service writer. Generate a clear, professional service description for a customer-facing invoice. Be concise but thorough. Do not include pricing. Write in plain text, no markdown.${languageInstruction(locale)}`;
+  const systemPrompt = `You are a professional automotive service writer. Generate a clear, professional service description for a customer-facing invoice. Be concise but thorough. Do not include pricing. Write in plain text, no markdown.${languageInstruction(locale)}`
 
   const partsStr =
     context.parts.length > 0
-      ? context.parts.map((p) => `- ${p.name} (qty: ${p.quantity})`).join("\n")
-      : "No parts listed";
+      ? context.parts.map((p) => `- ${p.name} (qty: ${p.quantity})`).join('\n')
+      : 'No parts listed'
 
   const laborStr =
     context.labor.length > 0
-      ? context.labor
-          .map((l) => `- ${l.description} (${l.hours}h)`)
-          .join("\n")
-      : "No labor listed";
+      ? context.labor.map((l) => `- ${l.description} (${l.hours}h)`).join('\n')
+      : 'No labor listed'
 
   const vehicleStr = context.vehicleMake
-    ? `${context.vehicleYear} ${context.vehicleMake} ${context.vehicleModel}${context.licensePlate ? ` (${context.licensePlate})` : ""}`
-    : "None (parts-only sale, no vehicle)";
+    ? `${context.vehicleYear} ${context.vehicleMake} ${context.vehicleModel}${context.licensePlate ? ` (${context.licensePlate})` : ''}`
+    : 'None (parts-only sale, no vehicle)'
 
   const userPrompt = `Vehicle: ${vehicleStr}
 Service type: ${context.serviceType}
@@ -158,26 +164,26 @@ ${partsStr}
 Labor performed:
 ${laborStr}
 
-Write a professional service description and diagnostic notes for the invoice.`;
+Write a professional service description and diagnostic notes for the invoice.`
 
-  return chatCompletion(organizationId, systemPrompt, userPrompt);
+  return chatCompletion(organizationId, systemPrompt, userPrompt)
 }
 
 export interface ServiceHistoryRecord {
-  title: string;
-  description: string | null;
-  serviceDate: Date | null;
-  startDateTime?: Date | null;
-  type: string;
-  cost: number;
-  mileage: number | null;
+  title: string
+  description: string | null
+  serviceDate: Date | null
+  startDateTime?: Date | null
+  type: string
+  cost: number
+  mileage: number | null
 }
 
 export async function summarizeServiceHistory(
   organizationId: string,
   vehicle: { make: string; model: string; year: number; licensePlate?: string | null },
   records: ServiceHistoryRecord[],
-  locale: Locale = "en",
+  locale: Locale = 'en'
 ): Promise<string> {
   const systemPrompt = `You are an automotive service advisor. Summarize a vehicle's complete service history as structured JSON.
 
@@ -186,53 +192,51 @@ Return ONLY valid JSON, no markdown, no explanation. Use this exact schema:
 
 majorWork: list the 3-5 most significant services performed (most recent first).
 recurringIssues: list any patterns or repeated problems with title and description (empty array if none).
-upcomingMaintenance: predict 2-4 likely upcoming needs based on mileage, age, and service history.${languageInstruction(locale)}`;
+upcomingMaintenance: predict 2-4 likely upcoming needs based on mileage, age, and service history.${languageInstruction(locale)}`
 
   const recordsStr = records
     .map(
       (r) =>
-        `- ${r.serviceDate ? new Date(r.startDateTime ?? r.serviceDate).toISOString().slice(0, 10) : "Unknown date"}: ${r.title} (${r.type}) — Cost: ${r.cost}${r.mileage ? `, Mileage: ${r.mileage}` : ""}${r.description ? `\n  Notes: ${r.description}` : ""}`,
+        `- ${r.serviceDate ? new Date(r.startDateTime ?? r.serviceDate).toISOString().slice(0, 10) : 'Unknown date'}: ${r.title} (${r.type}) — Cost: ${r.cost}${r.mileage ? `, Mileage: ${r.mileage}` : ''}${r.description ? `\n  Notes: ${r.description}` : ''}`
     )
-    .join("\n");
+    .join('\n')
 
-  const userPrompt = `Vehicle: ${vehicle.year} ${vehicle.make} ${vehicle.model}${vehicle.licensePlate ? ` (${vehicle.licensePlate})` : ""}
+  const userPrompt = `Vehicle: ${vehicle.year} ${vehicle.make} ${vehicle.model}${vehicle.licensePlate ? ` (${vehicle.licensePlate})` : ''}
 
 Service history (${records.length} records):
-${recordsStr || "No service records found."}
+${recordsStr || 'No service records found.'}
 
-Return JSON only.`;
+Return JSON only.`
 
-  return chatCompletion(organizationId, systemPrompt, userPrompt);
+  return chatCompletion(organizationId, systemPrompt, userPrompt)
 }
 
 export async function getCommonIssues(
   organizationId: string,
   vehicle: { make: string; model: string; year: number },
-  locale: Locale = "en",
+  locale: Locale = 'en'
 ): Promise<string> {
   const systemPrompt = `You are an expert automotive technician. Return a JSON array of exactly 5 critical known issues for the requested vehicle, sorted by severity and cost (highest first). Only serious, well-documented problems.
 
 Return ONLY valid JSON, no markdown, no explanation. Use this exact schema:
 [{"title":"Issue name","description":"1-2 sentence explanation","cost":"X,XXX–X,XXX","risk":"safety|engine|transmission|electrical|other","severity":5}]
 
-severity is 1-5 based on how widely reported the issue is (5 = extremely common/well-documented, affects majority of vehicles; 1 = rare but critical).${languageInstruction(locale)}`;
+severity is 1-5 based on how widely reported the issue is (5 = extremely common/well-documented, affects majority of vehicles; 1 = rare but critical).${languageInstruction(locale)}`
 
-  const userPrompt = `What are the 5 most critical and common issues with the ${vehicle.year} ${vehicle.make} ${vehicle.model}? Return JSON only.`;
+  const userPrompt = `What are the 5 most critical and common issues with the ${vehicle.year} ${vehicle.make} ${vehicle.model}? Return JSON only.`
 
-  return chatCompletion(organizationId, systemPrompt, userPrompt);
+  return chatCompletion(organizationId, systemPrompt, userPrompt)
 }
 
-export async function testAiConnection(
-  organizationId: string,
-): Promise<boolean> {
-  const config = await getAiConfig(organizationId);
-  const client = createClient(config);
+export async function testAiConnection(organizationId: string): Promise<boolean> {
+  const config = await getAiConfig(organizationId)
+  const client = createClient(config)
 
   const response = await client.chat.completions.create({
     model: config.model,
-    messages: [{ role: "user", content: "Say OK" }],
+    messages: [{ role: 'user', content: 'Say OK' }],
     max_tokens: 5,
-  });
+  })
 
-  return !!response.choices[0]?.message?.content;
+  return !!response.choices[0]?.message?.content
 }

+ 74 - 0
src/lib/name-similarity.ts

@@ -0,0 +1,74 @@
+/**
+ * Fuzzy name matching, used to line a name read off a scanned document up
+ * against the customers a workshop already has.
+ *
+ * Papers write names in their own way: "Lücking, Manuel" for a customer saved
+ * as "Manuel Lücking", an OCR pass that turns "ü" into "u", a middle initial
+ * that never made it into the app. Sorting the words and measuring edit
+ * distance absorbs all three without matching two genuinely different people.
+ */
+
+/** Lowercases, strips punctuation and accents, and sorts the words. */
+export function normalizeName(value: string): string {
+  return value
+    .normalize('NFD')
+    .replace(/[\u0300-\u036f]/g, '')
+    .toLowerCase()
+    .replace(/[^\p{L}\p{N}]+/gu, ' ')
+    .trim()
+    .split(' ')
+    .filter(Boolean)
+    .sort()
+    .join(' ')
+}
+
+/** Levenshtein distance, iterative with a single row of state. */
+function editDistance(a: string, b: string): number {
+  if (a === b) return 0
+  if (!a.length) return b.length
+  if (!b.length) return a.length
+
+  let previous = Array.from({ length: b.length + 1 }, (_, i) => i)
+
+  for (let i = 1; i <= a.length; i++) {
+    const current = [i]
+    for (let j = 1; j <= b.length; j++) {
+      const cost = a[i - 1] === b[j - 1] ? 0 : 1
+      current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost)
+    }
+    previous = current
+  }
+
+  return previous[b.length]
+}
+
+/**
+ * A name that is another name plus extra words scores just under an exact
+ * match: "Autohaus Meyer" and "Autohaus Meyer GmbH" are almost always the same
+ * customer, but the papers are not proof of it.
+ */
+const SUBSET_SCORE = 0.95
+
+/**
+ * Similarity of two names from 0 (nothing in common) to 1 (the same name once
+ * spelling and word order are set aside).
+ */
+export function nameSimilarity(a: string, b: string): number {
+  const left = normalizeName(a)
+  const right = normalizeName(b)
+  if (!left || !right) return 0
+  if (left === right) return 1
+
+  const ratio = 1 - editDistance(left, right) / Math.max(left.length, right.length)
+
+  // Edit distance alone punishes a legal form or a middle name harshly enough
+  // to hide a real customer, so check whether one name simply contains the
+  // other. Two words minimum: a lone surname matches far too much.
+  const leftWords = left.split(' ')
+  const rightWords = right.split(' ')
+  const [shorter, longer] =
+    leftWords.length <= rightWords.length ? [leftWords, rightWords] : [rightWords, leftWords]
+  const contained = shorter.length >= 2 && shorter.every((word) => longer.includes(word))
+
+  return contained ? Math.max(ratio, SUBSET_SCORE) : ratio
+}