Przeglądaj źródła

Import more service history exports without errors and make long form dropdowns searchable (#514)

* Import more service history exports without errors and make long form dropdowns searchable

The importer reads dates with unpadded times, AM/PM, weekdays and more month
names, recognises common workshop headers (Rego, Job No, Job Description,
Total Inc GST, Kms), names untitled jobs from their description, notes or a
stand-in title in the workshop language, keeps line breaks in descriptions,
drops impossible odometer readings and retries a failed batch row by row.
The mapping step gets a searchable field picker and a date preview, and the
dry run lists why rows cannot be imported.

A shared SearchableSelect replaces the plain dropdown in forms and filters
with long or workshop-specific lists.

* Keep short dropdowns keyboard-usable and import histories without a title column

A dropdown without a search box focuses its list, so the arrow keys and Enter
work. A row that creates a customer or vehicle in place of a failed row files
it under that row too, so the rows sharing it still land on one record. A
service history needs only a date and a vehicle column; when nothing says
what was done, the mapping step notes that jobs get a stand-in name.

* Satisfy the empty-block lint rule in the import commit test
Bernt Christian Egeland 2 dni temu
rodzic
commit
498996708e
61 zmienionych plików z 1752 dodań i 671 usunięć
  1. 5 0
      messages/de/common.json
  2. 19 4
      messages/de/dataImport.json
  3. 5 0
      messages/en/common.json
  4. 19 4
      messages/en/dataImport.json
  5. 5 0
      messages/es/common.json
  6. 19 4
      messages/es/dataImport.json
  7. 5 0
      messages/fr/common.json
  8. 19 4
      messages/fr/dataImport.json
  9. 5 0
      messages/it/common.json
  10. 19 4
      messages/it/dataImport.json
  11. 5 0
      messages/lt/common.json
  12. 19 4
      messages/lt/dataImport.json
  13. 5 0
      messages/nb/common.json
  14. 19 4
      messages/nb/dataImport.json
  15. 5 0
      messages/nl/common.json
  16. 19 4
      messages/nl/dataImport.json
  17. 5 0
      messages/pl/common.json
  18. 19 4
      messages/pl/dataImport.json
  19. 5 0
      messages/pt-BR/common.json
  20. 19 4
      messages/pt-BR/dataImport.json
  21. 5 0
      messages/pt-PT/common.json
  22. 19 4
      messages/pt-PT/dataImport.json
  23. 5 0
      messages/ru/common.json
  24. 19 4
      messages/ru/dataImport.json
  25. 5 0
      messages/tr/common.json
  26. 19 4
      messages/tr/dataImport.json
  27. 108 0
      src/__tests__/components/searchable-select.test.tsx
  28. 177 0
      src/__tests__/features/import/commit.test.ts
  29. 23 0
      src/__tests__/features/import/normalize.test.ts
  30. 81 0
      src/__tests__/features/import/pipeline.test.ts
  31. 32 0
      src/__tests__/features/import/suggest.test.ts
  32. 36 46
      src/app/(authenticated)/audit-log/audit-log-client.tsx
  33. 9 21
      src/app/(authenticated)/inventory/inventory-client.tsx
  34. 8 12
      src/app/(authenticated)/settings/localization/localization-settings.tsx
  35. 19 17
      src/app/(authenticated)/timesheets/timesheets-client.tsx
  36. 210 0
      src/components/ui/searchable-select.tsx
  37. 23 26
      src/features/billing/Components/RecurringInvoicesClient.tsx
  38. 14 25
      src/features/custom-fields/Components/CustomFieldsForm.tsx
  39. 25 33
      src/features/custom-fields/Components/CustomFieldsManager.tsx
  40. 9 20
      src/features/customers/Components/CustomerForm.tsx
  41. 19 31
      src/features/dashboard/Components/CustomCardDialog.tsx
  42. 11 19
      src/features/fault-codes/Components/ImportCodesDialog.tsx
  43. 7 19
      src/features/fault-codes/Components/ShareFixDialog.tsx
  44. 7 19
      src/features/fault-codes/Components/ShareWaveformDialog.tsx
  45. 144 28
      src/features/import/Actions/importActions.ts
  46. 118 22
      src/features/import/Components/ImportWizard.tsx
  47. 88 0
      src/features/import/Lib/fields.ts
  48. 66 10
      src/features/import/Lib/normalize.ts
  49. 47 25
      src/features/import/Lib/pipeline.ts
  50. 7 1
      src/features/import/Lib/suggest.ts
  51. 2 1
      src/features/import/Lib/types.ts
  52. 39 57
      src/features/inspections/Components/InspectionCertificateCard.tsx
  53. 9 21
      src/features/inspections/Components/InspectionItemRow.tsx
  54. 10 20
      src/features/inspections/Components/NewInspectionDialog.tsx
  55. 14 13
      src/features/inspections/Components/TemplateForm.tsx
  56. 3 17
      src/features/onboarding/Components/setup/SearchPicker.tsx
  57. 18 28
      src/features/portal/Components/ServiceRequestForm.tsx
  58. 8 20
      src/features/quotes/Components/QuotePageClient.tsx
  59. 18 25
      src/features/time-tracking/Components/TimeEntryDialog.tsx
  60. 10 21
      src/features/tire-hotel/Components/OwnerVehicleFields.tsx
  61. 21 22
      src/features/vehicles/Components/service-edit/InvoiceDetailsSection.tsx

+ 5 - 0
messages/de/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "Torqvoice ist nicht erreichbar",
     "description": "Ihre letzte Änderung wurde möglicherweise nicht gespeichert. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut."
+  },
+  "searchableSelect": {
+    "choose": "Auswählen...",
+    "search": "Suchen...",
+    "noMatch": "Nichts gefunden"
   }
 }

+ 19 - 4
messages/de/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Andere Datei wählen",
     "missingRequired": "Noch erforderlich: {fields}",
     "dryRunHint": "Der Testlauf prüft jede Zeile gegen Ihre vorhandenen Daten, ohne etwas zu schreiben.",
-    "runDryRun": "Testlauf starten"
+    "runDryRun": "Testlauf starten",
+    "usedBy": "Belegt durch {column}",
+    "searchFields": "Felder suchen...",
+    "fieldFor": "Feld für {column}",
+    "noJobText": "Keine Spalte ist Titel, Beschreibung oder Notizen zugeordnet, daher erhält jeder Auftrag einen Platzhalternamen. Ordnen Sie die Spalte zu, die beschreibt, was gemacht wurde, falls die Datei eine hat."
   },
   "options": {
     "dateFormat": "Datumsformat",
@@ -66,7 +70,9 @@
     "duplicates": "Wenn eine Zeile einem vorhandenen Datensatz entspricht",
     "duplicatesUpdate": "Vorhandenen Datensatz aktualisieren",
     "duplicatesSkip": "Zeile überspringen",
-    "duplicatesCreate": "Trotzdem neuen Datensatz anlegen"
+    "duplicatesCreate": "Trotzdem neuen Datensatz anlegen",
+    "dateReadsAs": "\"{value}\" wird gelesen als {date}",
+    "dateUnreadable": "\"{value}\" kann nicht als Datum gelesen werden"
   },
   "summary": {
     "create": "Wird angelegt",
@@ -114,7 +120,11 @@
     "next": "Weiter",
     "backToMapping": "Zurück zur Zuordnung",
     "import": "{count, plural, one {# Zeile} other {# Zeilen}} importieren",
-    "nothingWrittenYet": "Es wurde noch nichts geschrieben. Der Import kann anschließend unter Einstellungen → Daten rückgängig gemacht werden."
+    "nothingWrittenYet": "Es wurde noch nichts geschrieben. Der Import kann anschließend unter Einstellungen → Daten rückgängig gemacht werden.",
+    "issuesTitle": "Warum einige Zeilen nicht importiert werden können",
+    "issueCount": "{count, plural, one {# Zeile} other {# Zeilen}}, zuerst in Zeile {row}",
+    "issuesHint": "Meist ist eine Spalte dem falschen Feld zugeordnet, oder die Zellen sind in der Datei leer. Gehen Sie zurück zur Zuordnung, um das zu ändern, oder importieren Sie den Rest und ergänzen Sie diese später.",
+    "showIssueRows": "Diese Zeilen anzeigen"
   },
   "importing": {
     "title": "Import läuft...",
@@ -212,7 +222,9 @@
     "invalid_date": "{field} \"{value}\" konnte nicht gelesen werden und wurde weggelassen",
     "invalid_number": "{field} \"{value}\" ist keine Zahl und wurde weggelassen",
     "duplicate_invoice_number": "Rechnungsnummer {value} existiert bereits",
-    "write_failed": "Konnte nicht geschrieben werden: {value}"
+    "write_failed": "Konnte nicht geschrieben werden: {value}",
+    "invalid_mileage": "{field} \"{value}\" ist zu hoch für einen echten Zählerstand und wurde weggelassen",
+    "service_title_placeholder": "Kein Titel und keine Beschreibung, daher heißt er \"{value}\""
   },
   "errors": {
     "uploadFailed": "Die Datei konnte nicht gelesen werden",
@@ -227,5 +239,8 @@
     "customers": "Kunden importieren",
     "vehicles": "Fahrzeuge importieren",
     "services": "Servicehistorie importieren"
+  },
+  "placeholders": {
+    "serviceTitle": "Importierter Auftrag"
   }
 }

+ 5 - 0
messages/en/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "Couldn't reach Torqvoice",
     "description": "Your last change may not have been saved. Check your connection and try again."
+  },
+  "searchableSelect": {
+    "choose": "Choose...",
+    "search": "Search...",
+    "noMatch": "Nothing matches"
   }
 }

+ 19 - 4
messages/en/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Choose another file",
     "missingRequired": "Still needed: {fields}",
     "dryRunHint": "The dry run checks every row against your existing data without writing anything.",
-    "runDryRun": "Run dry run"
+    "runDryRun": "Run dry run",
+    "usedBy": "In use by {column}",
+    "searchFields": "Search fields...",
+    "fieldFor": "Field for {column}",
+    "noJobText": "No column is mapped to the service title, description or notes, so every job gets a stand-in name. Map the column that says what was done, if the file has one."
   },
   "options": {
     "dateFormat": "Date format",
@@ -66,7 +70,9 @@
     "duplicates": "When a row matches an existing record",
     "duplicatesUpdate": "Update the existing record",
     "duplicatesSkip": "Skip the row",
-    "duplicatesCreate": "Create a new record anyway"
+    "duplicatesCreate": "Create a new record anyway",
+    "dateReadsAs": "\"{value}\" reads as {date}",
+    "dateUnreadable": "\"{value}\" cannot be read as a date"
   },
   "summary": {
     "create": "Will be created",
@@ -114,7 +120,11 @@
     "next": "Next",
     "backToMapping": "Back to mapping",
     "import": "Import {count, plural, one {# row} other {# rows}}",
-    "nothingWrittenYet": "Nothing has been written yet. The import can be undone afterwards from Settings → Data."
+    "nothingWrittenYet": "Nothing has been written yet. The import can be undone afterwards from Settings → Data.",
+    "issuesTitle": "Why some rows cannot be imported",
+    "issueCount": "{count, plural, one {# row} other {# rows}}, first on row {row}",
+    "issuesHint": "Usually a column mapped to the wrong field, or cells left empty in the file. Go back to mapping to change it, or import the rest and add these later.",
+    "showIssueRows": "Show these rows"
   },
   "importing": {
     "title": "Importing...",
@@ -212,7 +222,9 @@
     "invalid_date": "{field} \"{value}\" could not be read and was left out",
     "invalid_number": "{field} \"{value}\" is not a number and was left out",
     "duplicate_invoice_number": "Invoice number {value} already exists",
-    "write_failed": "Could not be written: {value}"
+    "write_failed": "Could not be written: {value}",
+    "invalid_mileage": "{field} \"{value}\" is too high to be a real reading and was left out",
+    "service_title_placeholder": "No title or description, so it is named \"{value}\""
   },
   "errors": {
     "uploadFailed": "The file could not be read",
@@ -227,5 +239,8 @@
     "customers": "Import customers",
     "vehicles": "Import vehicles",
     "services": "Import service history"
+  },
+  "placeholders": {
+    "serviceTitle": "Imported service"
   }
 }

+ 5 - 0
messages/es/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "No se pudo conectar con Torqvoice",
     "description": "Puede que tu último cambio no se haya guardado. Revisa tu conexión e inténtalo de nuevo."
+  },
+  "searchableSelect": {
+    "choose": "Elegir...",
+    "search": "Buscar...",
+    "noMatch": "Nada coincide"
   }
 }

+ 19 - 4
messages/es/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Elegir otro archivo",
     "missingRequired": "Todavía falta: {fields}",
     "dryRunHint": "La simulación comprueba cada fila contra sus datos existentes sin escribir nada.",
-    "runDryRun": "Ejecutar simulación"
+    "runDryRun": "Ejecutar simulación",
+    "usedBy": "Usado por {column}",
+    "searchFields": "Buscar campos...",
+    "fieldFor": "Campo para {column}",
+    "noJobText": "Ninguna columna está asignada al título, la descripción o las notas, así que cada trabajo recibirá un nombre provisional. Asigne la columna que describe lo que se hizo, si el archivo la tiene."
   },
   "options": {
     "dateFormat": "Formato de fecha",
@@ -66,7 +70,9 @@
     "duplicates": "Cuando una fila coincide con un registro existente",
     "duplicatesUpdate": "Actualizar el registro existente",
     "duplicatesSkip": "Omitir la fila",
-    "duplicatesCreate": "Crear un registro nuevo de todos modos"
+    "duplicatesCreate": "Crear un registro nuevo de todos modos",
+    "dateReadsAs": "\"{value}\" se lee como {date}",
+    "dateUnreadable": "\"{value}\" no se puede leer como fecha"
   },
   "summary": {
     "create": "Se crearán",
@@ -114,7 +120,11 @@
     "next": "Siguiente",
     "backToMapping": "Volver a la asignación",
     "import": "Importar {count, plural, one {# fila} other {# filas}}",
-    "nothingWrittenYet": "Todavía no se ha escrito nada. La importación se puede deshacer después desde Configuración → Datos."
+    "nothingWrittenYet": "Todavía no se ha escrito nada. La importación se puede deshacer después desde Configuración → Datos.",
+    "issuesTitle": "Por qué algunas filas no se pueden importar",
+    "issueCount": "{count, plural, one {# fila} other {# filas}}, la primera en la fila {row}",
+    "issuesHint": "Normalmente es una columna asignada al campo equivocado o celdas vacías en el archivo. Vuelva a la asignación para cambiarlo, o importe el resto y añada estas más tarde.",
+    "showIssueRows": "Mostrar estas filas"
   },
   "importing": {
     "title": "Importando...",
@@ -212,7 +222,9 @@
     "invalid_date": "No se pudo leer {field} \"{value}\" y se omitió",
     "invalid_number": "{field} \"{value}\" no es un número y se omitió",
     "duplicate_invoice_number": "El número de factura {value} ya existe",
-    "write_failed": "No se pudo escribir: {value}"
+    "write_failed": "No se pudo escribir: {value}",
+    "invalid_mileage": "{field} \"{value}\" es demasiado alto para ser una lectura real y se omitió",
+    "service_title_placeholder": "Sin título ni descripción, así que se llamará \"{value}\""
   },
   "errors": {
     "uploadFailed": "No se pudo leer el archivo",
@@ -227,5 +239,8 @@
     "customers": "Importar clientes",
     "vehicles": "Importar vehículos",
     "services": "Importar historial de servicio"
+  },
+  "placeholders": {
+    "serviceTitle": "Servicio importado"
   }
 }

+ 5 - 0
messages/fr/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "Impossible de joindre Torqvoice",
     "description": "Votre dernière modification n'a peut-être pas été enregistrée. Vérifiez votre connexion et réessayez."
+  },
+  "searchableSelect": {
+    "choose": "Choisir...",
+    "search": "Rechercher...",
+    "noMatch": "Aucun résultat"
   }
 }

+ 19 - 4
messages/fr/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Choisir un autre fichier",
     "missingRequired": "Encore requis : {fields}",
     "dryRunHint": "La simulation vérifie chaque ligne par rapport à vos données existantes sans rien écrire.",
-    "runDryRun": "Lancer la simulation"
+    "runDryRun": "Lancer la simulation",
+    "usedBy": "Utilisé par {column}",
+    "searchFields": "Rechercher un champ...",
+    "fieldFor": "Champ pour {column}",
+    "noJobText": "Aucune colonne n'est associée au titre, à la description ou aux notes, donc chaque intervention recevra un nom provisoire. Associez la colonne qui décrit le travail effectué, si le fichier en contient une."
   },
   "options": {
     "dateFormat": "Format de date",
@@ -66,7 +70,9 @@
     "duplicates": "Quand une ligne correspond à un enregistrement existant",
     "duplicatesUpdate": "Mettre à jour l'enregistrement existant",
     "duplicatesSkip": "Ignorer la ligne",
-    "duplicatesCreate": "Créer quand même un nouvel enregistrement"
+    "duplicatesCreate": "Créer quand même un nouvel enregistrement",
+    "dateReadsAs": "\"{value}\" se lit comme {date}",
+    "dateUnreadable": "\"{value}\" ne peut pas être lu comme une date"
   },
   "summary": {
     "create": "Seront créés",
@@ -114,7 +120,11 @@
     "next": "Suivant",
     "backToMapping": "Retour à la correspondance",
     "import": "Importer {count, plural, one {# ligne} other {# lignes}}",
-    "nothingWrittenYet": "Rien n'a encore été écrit. L'import pourra ensuite être annulé depuis Paramètres → Données."
+    "nothingWrittenYet": "Rien n'a encore été écrit. L'import pourra ensuite être annulé depuis Paramètres → Données.",
+    "issuesTitle": "Pourquoi certaines lignes ne peuvent pas être importées",
+    "issueCount": "{count, plural, one {# ligne} other {# lignes}}, la première à la ligne {row}",
+    "issuesHint": "Le plus souvent, une colonne est associée au mauvais champ ou des cellules sont vides dans le fichier. Revenez à la correspondance pour la modifier, ou importez le reste et ajoutez celles-ci plus tard.",
+    "showIssueRows": "Afficher ces lignes"
   },
   "importing": {
     "title": "Import en cours...",
@@ -212,7 +222,9 @@
     "invalid_date": "{field} \"{value}\" n'a pas pu être lu et a été ignoré",
     "invalid_number": "{field} \"{value}\" n'est pas un nombre et a été ignoré",
     "duplicate_invoice_number": "Le numéro de facture {value} existe déjà",
-    "write_failed": "Impossible d'écrire : {value}"
+    "write_failed": "Impossible d'écrire : {value}",
+    "invalid_mileage": "{field} \"{value}\" est trop élevé pour être un relevé réel et a été ignoré",
+    "service_title_placeholder": "Ni titre ni description, elle sera donc nommée \"{value}\""
   },
   "errors": {
     "uploadFailed": "Le fichier n'a pas pu être lu",
@@ -227,5 +239,8 @@
     "customers": "Importer des clients",
     "vehicles": "Importer des véhicules",
     "services": "Importer l'historique de service"
+  },
+  "placeholders": {
+    "serviceTitle": "Intervention importée"
   }
 }

+ 5 - 0
messages/it/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "Impossibile raggiungere Torqvoice",
     "description": "L'ultima modifica potrebbe non essere stata salvata. Controlla la connessione e riprova."
+  },
+  "searchableSelect": {
+    "choose": "Scegli...",
+    "search": "Cerca...",
+    "noMatch": "Nessuna corrispondenza"
   }
 }

+ 19 - 4
messages/it/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Scegli un altro file",
     "missingRequired": "Ancora necessari: {fields}",
     "dryRunHint": "La simulazione confronta ogni riga con i tuoi dati esistenti senza scrivere nulla.",
-    "runDryRun": "Esegui simulazione"
+    "runDryRun": "Esegui simulazione",
+    "usedBy": "Usato da {column}",
+    "searchFields": "Cerca campi...",
+    "fieldFor": "Campo per {column}",
+    "noJobText": "Nessuna colonna è associata a titolo, descrizione o note, quindi ogni intervento riceverà un nome provvisorio. Associa la colonna che descrive il lavoro svolto, se il file ne ha una."
   },
   "options": {
     "dateFormat": "Formato data",
@@ -66,7 +70,9 @@
     "duplicates": "Quando una riga corrisponde a un record esistente",
     "duplicatesUpdate": "Aggiorna il record esistente",
     "duplicatesSkip": "Salta la riga",
-    "duplicatesCreate": "Crea comunque un nuovo record"
+    "duplicatesCreate": "Crea comunque un nuovo record",
+    "dateReadsAs": "\"{value}\" viene letto come {date}",
+    "dateUnreadable": "\"{value}\" non può essere letto come data"
   },
   "summary": {
     "create": "Verranno creati",
@@ -114,7 +120,11 @@
     "next": "Successivo",
     "backToMapping": "Torna alla mappatura",
     "import": "Importa {count, plural, one {# riga} other {# righe}}",
-    "nothingWrittenYet": "Non è ancora stato scritto nulla. L'importazione può essere annullata in seguito da Impostazioni → Dati."
+    "nothingWrittenYet": "Non è ancora stato scritto nulla. L'importazione può essere annullata in seguito da Impostazioni → Dati.",
+    "issuesTitle": "Perché alcune righe non possono essere importate",
+    "issueCount": "{count, plural, one {# riga} other {# righe}}, la prima alla riga {row}",
+    "issuesHint": "Di solito una colonna è associata al campo sbagliato, oppure le celle sono vuote nel file. Torna alla mappatura per cambiarla, oppure importa il resto e aggiungi queste in seguito.",
+    "showIssueRows": "Mostra queste righe"
   },
   "importing": {
     "title": "Importazione in corso...",
@@ -212,7 +222,9 @@
     "invalid_date": "Impossibile leggere {field} \"{value}\", omesso",
     "invalid_number": "{field} \"{value}\" non è un numero ed è stato omesso",
     "duplicate_invoice_number": "Il numero fattura {value} esiste già",
-    "write_failed": "Impossibile scrivere: {value}"
+    "write_failed": "Impossibile scrivere: {value}",
+    "invalid_mileage": "{field} \"{value}\" è troppo alto per essere una lettura reale ed è stato omesso",
+    "service_title_placeholder": "Nessun titolo né descrizione, quindi si chiamerà \"{value}\""
   },
   "errors": {
     "uploadFailed": "Impossibile leggere il file",
@@ -227,5 +239,8 @@
     "customers": "Importa clienti",
     "vehicles": "Importa veicoli",
     "services": "Importa storico interventi"
+  },
+  "placeholders": {
+    "serviceTitle": "Intervento importato"
   }
 }

+ 5 - 0
messages/lt/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "Nepavyko pasiekti Torqvoice",
     "description": "Paskutinis pakeitimas galėjo būti neišsaugotas. Patikrinkite ryšį ir bandykite dar kartą."
+  },
+  "searchableSelect": {
+    "choose": "Pasirinkite...",
+    "search": "Ieškoti...",
+    "noMatch": "Nieko nerasta"
   }
 }

+ 19 - 4
messages/lt/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Pasirinkti kitą failą",
     "missingRequired": "Dar reikia: {fields}",
     "dryRunHint": "Bandomasis importas patikrina kiekvieną eilutę pagal esamus duomenis nieko neįrašydamas.",
-    "runDryRun": "Paleisti bandomąjį importą"
+    "runDryRun": "Paleisti bandomąjį importą",
+    "usedBy": "Naudoja {column}",
+    "searchFields": "Ieškoti laukų...",
+    "fieldFor": "Laukas stulpeliui {column}",
+    "noJobText": "Nė vienas stulpelis nesusietas su pavadinimu, aprašymu ar pastabomis, todėl kiekvienas darbas gaus laikiną pavadinimą. Susiekite stulpelį, kuriame aprašyta, kas atlikta, jei faile toks yra."
   },
   "options": {
     "dateFormat": "Datos formatas",
@@ -66,7 +70,9 @@
     "duplicates": "Kai eilutė atitinka esamą įrašą",
     "duplicatesUpdate": "Atnaujinti esamą įrašą",
     "duplicatesSkip": "Praleisti eilutę",
-    "duplicatesCreate": "Vis tiek sukurti naują įrašą"
+    "duplicatesCreate": "Vis tiek sukurti naują įrašą",
+    "dateReadsAs": "\"{value}\" nuskaitoma kaip {date}",
+    "dateUnreadable": "\"{value}\" negalima nuskaityti kaip datos"
   },
   "summary": {
     "create": "Bus sukurta",
@@ -114,7 +120,11 @@
     "next": "Kitas",
     "backToMapping": "Grįžti prie susiejimo",
     "import": "Importuoti {count, plural, one {# eilutę} few {# eilutes} many {# eilutės} other {# eilučių}}",
-    "nothingWrittenYet": "Kol kas niekas neįrašyta. Importą vėliau galima atšaukti skiltyje Nustatymai → Duomenys."
+    "nothingWrittenYet": "Kol kas niekas neįrašyta. Importą vėliau galima atšaukti skiltyje Nustatymai → Duomenys.",
+    "issuesTitle": "Kodėl kai kurių eilučių negalima importuoti",
+    "issueCount": "{count, plural, one {# eilutė} few {# eilutės} other {# eilučių}}, pirmoji {row} eilutėje",
+    "issuesHint": "Dažniausiai stulpelis susietas su netinkamu lauku arba failo langeliai tušti. Grįžkite prie susiejimo ir pakeiskite, arba importuokite likusias ir šias pridėkite vėliau.",
+    "showIssueRows": "Rodyti šias eilutes"
   },
   "importing": {
     "title": "Importuojama...",
@@ -212,7 +222,9 @@
     "invalid_date": "{field} \"{value}\" nepavyko nuskaityti ir buvo praleista",
     "invalid_number": "{field} \"{value}\" nėra skaičius ir buvo praleista",
     "duplicate_invoice_number": "Sąskaitos numeris {value} jau egzistuoja",
-    "write_failed": "Nepavyko įrašyti: {value}"
+    "write_failed": "Nepavyko įrašyti: {value}",
+    "invalid_mileage": "{field} \"{value}\" per didelė, kad būtų tikras rodmuo, ir buvo praleista",
+    "service_title_placeholder": "Nėra pavadinimo ar aprašymo, todėl jis bus pavadintas \"{value}\""
   },
   "errors": {
     "uploadFailed": "Nepavyko nuskaityti failo",
@@ -227,5 +239,8 @@
     "customers": "Importuoti klientus",
     "vehicles": "Importuoti transporto priemones",
     "services": "Importuoti paslaugų istoriją"
+  },
+  "placeholders": {
+    "serviceTitle": "Importuotas darbas"
   }
 }

+ 5 - 0
messages/nb/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "Får ikke kontakt med Torqvoice",
     "description": "Den siste endringen din ble kanskje ikke lagret. Sjekk tilkoblingen og prøv igjen."
+  },
+  "searchableSelect": {
+    "choose": "Velg...",
+    "search": "Søk...",
+    "noMatch": "Ingen treff"
   }
 }

+ 19 - 4
messages/nb/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Velg en annen fil",
     "missingRequired": "Mangler fortsatt: {fields}",
     "dryRunHint": "Prøvekjøringen sjekker hver rad mot eksisterende data uten å skrive noe.",
-    "runDryRun": "Kjør prøvekjøring"
+    "runDryRun": "Kjør prøvekjøring",
+    "usedBy": "Brukt av {column}",
+    "searchFields": "Søk i felt...",
+    "fieldFor": "Felt for {column}",
+    "noJobText": "Ingen kolonne er koblet til tittel, beskrivelse eller notater, så hver jobb får et midlertidig navn. Koble kolonnen som sier hva som ble gjort, hvis filen har en."
   },
   "options": {
     "dateFormat": "Datoformat",
@@ -66,7 +70,9 @@
     "duplicates": "Når en rad samsvarer med en eksisterende post",
     "duplicatesUpdate": "Oppdater den eksisterende posten",
     "duplicatesSkip": "Hopp over raden",
-    "duplicatesCreate": "Opprett en ny post likevel"
+    "duplicatesCreate": "Opprett en ny post likevel",
+    "dateReadsAs": "\"{value}\" leses som {date}",
+    "dateUnreadable": "\"{value}\" kan ikke leses som en dato"
   },
   "summary": {
     "create": "Vil bli opprettet",
@@ -114,7 +120,11 @@
     "next": "Neste",
     "backToMapping": "Tilbake til kobling",
     "import": "Importer {count, plural, one {# rad} other {# rader}}",
-    "nothingWrittenYet": "Ingenting er skrevet ennå. Importen kan angres etterpå fra Innstillinger → Data."
+    "nothingWrittenYet": "Ingenting er skrevet ennå. Importen kan angres etterpå fra Innstillinger → Data.",
+    "issuesTitle": "Hvorfor noen rader ikke kan importeres",
+    "issueCount": "{count, plural, one {# rad} other {# rader}}, første på rad {row}",
+    "issuesHint": "Som regel er en kolonne koblet til feil felt, eller cellene er tomme i filen. Gå tilbake til kobling for å endre det, eller importer resten og legg til disse senere.",
+    "showIssueRows": "Vis disse radene"
   },
   "importing": {
     "title": "Importerer...",
@@ -212,7 +222,9 @@
     "invalid_date": "{field} \"{value}\" kunne ikke leses og ble utelatt",
     "invalid_number": "{field} \"{value}\" er ikke et tall og ble utelatt",
     "duplicate_invoice_number": "Fakturanummer {value} finnes allerede",
-    "write_failed": "Kunne ikke skrives: {value}"
+    "write_failed": "Kunne ikke skrives: {value}",
+    "invalid_mileage": "{field} \"{value}\" er for høy til å være en ekte avlesning og ble utelatt",
+    "service_title_placeholder": "Ingen tittel eller beskrivelse, så den får navnet \"{value}\""
   },
   "errors": {
     "uploadFailed": "Filen kunne ikke leses",
@@ -227,5 +239,8 @@
     "customers": "Importer kunder",
     "vehicles": "Importer kjøretøy",
     "services": "Importer servicehistorikk"
+  },
+  "placeholders": {
+    "serviceTitle": "Importert service"
   }
 }

+ 5 - 0
messages/nl/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "Torqvoice is niet bereikbaar",
     "description": "Je laatste wijziging is mogelijk niet opgeslagen. Controleer je verbinding en probeer het opnieuw."
+  },
+  "searchableSelect": {
+    "choose": "Kiezen...",
+    "search": "Zoeken...",
+    "noMatch": "Niets gevonden"
   }
 }

+ 19 - 4
messages/nl/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Ander bestand kiezen",
     "missingRequired": "Nog nodig: {fields}",
     "dryRunHint": "De proefimport controleert elke rij tegen uw bestaande gegevens zonder iets weg te schrijven.",
-    "runDryRun": "Proefimport uitvoeren"
+    "runDryRun": "Proefimport uitvoeren",
+    "usedBy": "In gebruik door {column}",
+    "searchFields": "Velden zoeken...",
+    "fieldFor": "Veld voor {column}",
+    "noJobText": "Er is geen kolom gekoppeld aan titel, omschrijving of notities, dus elke werkorder krijgt een tijdelijke naam. Koppel de kolom die beschrijft wat er is gedaan, als het bestand die heeft."
   },
   "options": {
     "dateFormat": "Datumnotatie",
@@ -66,7 +70,9 @@
     "duplicates": "Als een rij overeenkomt met een bestaand record",
     "duplicatesUpdate": "Bestaand record bijwerken",
     "duplicatesSkip": "Rij overslaan",
-    "duplicatesCreate": "Toch een nieuw record aanmaken"
+    "duplicatesCreate": "Toch een nieuw record aanmaken",
+    "dateReadsAs": "\"{value}\" wordt gelezen als {date}",
+    "dateUnreadable": "\"{value}\" kan niet als datum worden gelezen"
   },
   "summary": {
     "create": "Wordt aangemaakt",
@@ -114,7 +120,11 @@
     "next": "Volgende",
     "backToMapping": "Terug naar toewijzing",
     "import": "{count, plural, one {# rij} other {# rijen}} importeren",
-    "nothingWrittenYet": "Er is nog niets weggeschreven. De import kan achteraf ongedaan worden gemaakt via Instellingen → Gegevens."
+    "nothingWrittenYet": "Er is nog niets weggeschreven. De import kan achteraf ongedaan worden gemaakt via Instellingen → Gegevens.",
+    "issuesTitle": "Waarom sommige rijen niet kunnen worden geïmporteerd",
+    "issueCount": "{count, plural, one {# rij} other {# rijen}}, eerst op rij {row}",
+    "issuesHint": "Meestal is een kolom aan het verkeerde veld gekoppeld, of zijn cellen in het bestand leeg. Ga terug naar de toewijzing om dat te wijzigen, of importeer de rest en voeg deze later toe.",
+    "showIssueRows": "Deze rijen tonen"
   },
   "importing": {
     "title": "Importeren...",
@@ -212,7 +222,9 @@
     "invalid_date": "{field} \"{value}\" kon niet worden gelezen en is weggelaten",
     "invalid_number": "{field} \"{value}\" is geen getal en is weggelaten",
     "duplicate_invoice_number": "Factuurnummer {value} bestaat al",
-    "write_failed": "Kon niet worden weggeschreven: {value}"
+    "write_failed": "Kon niet worden weggeschreven: {value}",
+    "invalid_mileage": "{field} \"{value}\" is te hoog voor een echte stand en is weggelaten",
+    "service_title_placeholder": "Geen titel of omschrijving, dus de naam wordt \"{value}\""
   },
   "errors": {
     "uploadFailed": "Het bestand kon niet worden gelezen",
@@ -227,5 +239,8 @@
     "customers": "Klanten importeren",
     "vehicles": "Voertuigen importeren",
     "services": "Servicegeschiedenis importeren"
+  },
+  "placeholders": {
+    "serviceTitle": "Geïmporteerde werkorder"
   }
 }

+ 5 - 0
messages/pl/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "Nie można połączyć się z Torqvoice",
     "description": "Ostatnia zmiana mogła nie zostać zapisana. Sprawdź połączenie i spróbuj ponownie."
+  },
+  "searchableSelect": {
+    "choose": "Wybierz...",
+    "search": "Szukaj...",
+    "noMatch": "Brak wyników"
   }
 }

+ 19 - 4
messages/pl/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Wybierz inny plik",
     "missingRequired": "Nadal wymagane: {fields}",
     "dryRunHint": "Próbny import sprawdza każdy wiersz względem istniejących danych, niczego nie zapisując.",
-    "runDryRun": "Uruchom próbny import"
+    "runDryRun": "Uruchom próbny import",
+    "usedBy": "Używane przez {column}",
+    "searchFields": "Szukaj pól...",
+    "fieldFor": "Pole dla {column}",
+    "noJobText": "Żadna kolumna nie jest przypisana do tytułu, opisu ani notatek, więc każde zlecenie otrzyma nazwę zastępczą. Przypisz kolumnę opisującą wykonaną pracę, jeśli plik ją zawiera."
   },
   "options": {
     "dateFormat": "Format daty",
@@ -66,7 +70,9 @@
     "duplicates": "Gdy wiersz pasuje do istniejącego rekordu",
     "duplicatesUpdate": "Zaktualizuj istniejący rekord",
     "duplicatesSkip": "Pomiń wiersz",
-    "duplicatesCreate": "Mimo to utwórz nowy rekord"
+    "duplicatesCreate": "Mimo to utwórz nowy rekord",
+    "dateReadsAs": "\"{value}\" odczytano jako {date}",
+    "dateUnreadable": "\"{value}\" nie da się odczytać jako daty"
   },
   "summary": {
     "create": "Zostanie utworzonych",
@@ -114,7 +120,11 @@
     "next": "Następna",
     "backToMapping": "Wróć do mapowania",
     "import": "Importuj {count, plural, one {# wiersz} few {# wiersze} many {# wierszy} other {# wiersza}}",
-    "nothingWrittenYet": "Nic nie zostało jeszcze zapisane. Import można później cofnąć w Ustawienia → Dane."
+    "nothingWrittenYet": "Nic nie zostało jeszcze zapisane. Import można później cofnąć w Ustawienia → Dane.",
+    "issuesTitle": "Dlaczego niektórych wierszy nie można zaimportować",
+    "issueCount": "{count, plural, one {# wiersz} few {# wiersze} many {# wierszy} other {# wiersza}}, pierwszy w wierszu {row}",
+    "issuesHint": "Zwykle kolumna jest przypisana do złego pola albo komórki w pliku są puste. Wróć do mapowania, aby to zmienić, albo zaimportuj resztę i dodaj te później.",
+    "showIssueRows": "Pokaż te wiersze"
   },
   "importing": {
     "title": "Importowanie...",
@@ -212,7 +222,9 @@
     "invalid_date": "Nie udało się odczytać {field} \"{value}\" i pole zostało pominięte",
     "invalid_number": "{field} \"{value}\" nie jest liczbą i pole zostało pominięte",
     "duplicate_invoice_number": "Numer faktury {value} już istnieje",
-    "write_failed": "Nie udało się zapisać: {value}"
+    "write_failed": "Nie udało się zapisać: {value}",
+    "invalid_mileage": "{field} \"{value}\" jest zbyt wysoki jak na prawdziwy odczyt i pole zostało pominięte",
+    "service_title_placeholder": "Brak tytułu i opisu, więc otrzyma nazwę \"{value}\""
   },
   "errors": {
     "uploadFailed": "Nie udało się odczytać pliku",
@@ -227,5 +239,8 @@
     "customers": "Importuj klientów",
     "vehicles": "Importuj pojazdy",
     "services": "Importuj historię serwisową"
+  },
+  "placeholders": {
+    "serviceTitle": "Zaimportowane zlecenie"
   }
 }

+ 5 - 0
messages/pt-BR/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "Não foi possível conectar ao Torqvoice",
     "description": "Sua última alteração pode não ter sido salva. Verifique sua conexão e tente novamente."
+  },
+  "searchableSelect": {
+    "choose": "Escolher...",
+    "search": "Buscar...",
+    "noMatch": "Nada encontrado"
   }
 }

+ 19 - 4
messages/pt-BR/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Escolher outro arquivo",
     "missingRequired": "Ainda falta: {fields}",
     "dryRunHint": "A simulação verifica cada linha em relação aos seus dados existentes sem gravar nada.",
-    "runDryRun": "Executar simulação"
+    "runDryRun": "Executar simulação",
+    "usedBy": "Em uso por {column}",
+    "searchFields": "Buscar campos...",
+    "fieldFor": "Campo para {column}",
+    "noJobText": "Nenhuma coluna está mapeada para título, descrição ou notas, então cada serviço receberá um nome provisório. Mapeie a coluna que descreve o que foi feito, se o arquivo tiver uma."
   },
   "options": {
     "dateFormat": "Formato de data",
@@ -66,7 +70,9 @@
     "duplicates": "Quando uma linha corresponder a um registro existente",
     "duplicatesUpdate": "Atualizar o registro existente",
     "duplicatesSkip": "Pular a linha",
-    "duplicatesCreate": "Criar um novo registro mesmo assim"
+    "duplicatesCreate": "Criar um novo registro mesmo assim",
+    "dateReadsAs": "\"{value}\" é lido como {date}",
+    "dateUnreadable": "\"{value}\" não pode ser lido como data"
   },
   "summary": {
     "create": "Serão criados",
@@ -114,7 +120,11 @@
     "next": "Próximo",
     "backToMapping": "Voltar ao mapeamento",
     "import": "Importar {count, plural, one {# linha} other {# linhas}}",
-    "nothingWrittenYet": "Nada foi gravado ainda. A importação pode ser desfeita depois em Configurações → Dados."
+    "nothingWrittenYet": "Nada foi gravado ainda. A importação pode ser desfeita depois em Configurações → Dados.",
+    "issuesTitle": "Por que algumas linhas não podem ser importadas",
+    "issueCount": "{count, plural, one {# linha} other {# linhas}}, a primeira na linha {row}",
+    "issuesHint": "Geralmente é uma coluna mapeada para o campo errado ou células vazias no arquivo. Volte ao mapeamento para alterar, ou importe o restante e adicione estas depois.",
+    "showIssueRows": "Mostrar estas linhas"
   },
   "importing": {
     "title": "Importando...",
@@ -212,7 +222,9 @@
     "invalid_date": "Não foi possível ler {field} \"{value}\" e o valor foi deixado de fora",
     "invalid_number": "{field} \"{value}\" não é um número e foi deixado de fora",
     "duplicate_invoice_number": "O número de fatura {value} já existe",
-    "write_failed": "Não foi possível gravar: {value}"
+    "write_failed": "Não foi possível gravar: {value}",
+    "invalid_mileage": "{field} \"{value}\" é alto demais para ser uma leitura real e foi deixado de fora",
+    "service_title_placeholder": "Sem título nem descrição, então será chamado \"{value}\""
   },
   "errors": {
     "uploadFailed": "Não foi possível ler o arquivo",
@@ -227,5 +239,8 @@
     "customers": "Importar clientes",
     "vehicles": "Importar veículos",
     "services": "Importar histórico de serviços"
+  },
+  "placeholders": {
+    "serviceTitle": "Serviço importado"
   }
 }

+ 5 - 0
messages/pt-PT/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "Não foi possível contactar o Torqvoice",
     "description": "A sua última alteração pode não ter sido guardada. Verifique a sua ligação e tente novamente."
+  },
+  "searchableSelect": {
+    "choose": "Escolher...",
+    "search": "Pesquisar...",
+    "noMatch": "Nada encontrado"
   }
 }

+ 19 - 4
messages/pt-PT/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Escolher outro ficheiro",
     "missingRequired": "Ainda falta: {fields}",
     "dryRunHint": "A simulação verifica cada linha face aos seus dados existentes sem guardar nada.",
-    "runDryRun": "Executar simulação"
+    "runDryRun": "Executar simulação",
+    "usedBy": "Em uso por {column}",
+    "searchFields": "Pesquisar campos...",
+    "fieldFor": "Campo para {column}",
+    "noJobText": "Nenhuma coluna está associada ao título, à descrição ou às notas, por isso cada serviço ficará com um nome provisório. Associe a coluna que descreve o que foi feito, se o ficheiro tiver uma."
   },
   "options": {
     "dateFormat": "Formato de data",
@@ -66,7 +70,9 @@
     "duplicates": "Quando uma linha corresponde a um registo existente",
     "duplicatesUpdate": "Atualizar o registo existente",
     "duplicatesSkip": "Ignorar a linha",
-    "duplicatesCreate": "Criar um novo registo na mesma"
+    "duplicatesCreate": "Criar um novo registo na mesma",
+    "dateReadsAs": "\"{value}\" é lido como {date}",
+    "dateUnreadable": "\"{value}\" não pode ser lido como data"
   },
   "summary": {
     "create": "Serão criados",
@@ -114,7 +120,11 @@
     "next": "Seguinte",
     "backToMapping": "Voltar ao mapeamento",
     "import": "Importar {count, plural, one {# linha} other {# linhas}}",
-    "nothingWrittenYet": "Ainda nada foi guardado. A importação pode ser anulada mais tarde em Definições → Dados."
+    "nothingWrittenYet": "Ainda nada foi guardado. A importação pode ser anulada mais tarde em Definições → Dados.",
+    "issuesTitle": "Porque é que algumas linhas não podem ser importadas",
+    "issueCount": "{count, plural, one {# linha} other {# linhas}}, a primeira na linha {row}",
+    "issuesHint": "Normalmente é uma coluna associada ao campo errado ou células vazias no ficheiro. Volte ao mapeamento para alterar, ou importe o resto e adicione estas mais tarde.",
+    "showIssueRows": "Mostrar estas linhas"
   },
   "importing": {
     "title": "A importar...",
@@ -212,7 +222,9 @@
     "invalid_date": "Não foi possível ler {field} \"{value}\" e o valor foi deixado de fora",
     "invalid_number": "{field} \"{value}\" não é um número e foi deixado de fora",
     "duplicate_invoice_number": "O número de fatura {value} já existe",
-    "write_failed": "Não foi possível guardar: {value}"
+    "write_failed": "Não foi possível guardar: {value}",
+    "invalid_mileage": "{field} \"{value}\" é demasiado alto para ser uma leitura real e foi deixado de fora",
+    "service_title_placeholder": "Sem título nem descrição, por isso ficará com o nome \"{value}\""
   },
   "errors": {
     "uploadFailed": "Não foi possível ler o ficheiro",
@@ -227,5 +239,8 @@
     "customers": "Importar clientes",
     "vehicles": "Importar viaturas",
     "services": "Importar histórico de serviços"
+  },
+  "placeholders": {
+    "serviceTitle": "Serviço importado"
   }
 }

+ 5 - 0
messages/ru/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "Не удалось связаться с Torqvoice",
     "description": "Последнее изменение могло не сохраниться. Проверьте подключение и повторите попытку."
+  },
+  "searchableSelect": {
+    "choose": "Выберите...",
+    "search": "Поиск...",
+    "noMatch": "Ничего не найдено"
   }
 }

+ 19 - 4
messages/ru/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Выбрать другой файл",
     "missingRequired": "Ещё требуется: {fields}",
     "dryRunHint": "Пробный запуск сверяет каждую строку с вашими существующими данными, ничего не записывая.",
-    "runDryRun": "Выполнить пробный запуск"
+    "runDryRun": "Выполнить пробный запуск",
+    "usedBy": "Используется в {column}",
+    "searchFields": "Поиск полей...",
+    "fieldFor": "Поле для {column}",
+    "noJobText": "Ни один столбец не сопоставлен с названием, описанием или заметками, поэтому каждая работа получит временное название. Сопоставьте столбец с описанием выполненных работ, если он есть в файле."
   },
   "options": {
     "dateFormat": "Формат даты",
@@ -66,7 +70,9 @@
     "duplicates": "Если строка совпадает с существующей записью",
     "duplicatesUpdate": "Обновить существующую запись",
     "duplicatesSkip": "Пропустить строку",
-    "duplicatesCreate": "Всё равно создать новую запись"
+    "duplicatesCreate": "Всё равно создать новую запись",
+    "dateReadsAs": "\"{value}\" читается как {date}",
+    "dateUnreadable": "\"{value}\" не удаётся прочитать как дату"
   },
   "summary": {
     "create": "Будет создано",
@@ -114,7 +120,11 @@
     "next": "Далее",
     "backToMapping": "Назад к сопоставлению",
     "import": "Импортировать {count, plural, one {# строку} few {# строки} many {# строк} other {# строки}}",
-    "nothingWrittenYet": "Пока ничего не записано. Импорт можно будет отменить позже в разделе Настройки → Данные."
+    "nothingWrittenYet": "Пока ничего не записано. Импорт можно будет отменить позже в разделе Настройки → Данные.",
+    "issuesTitle": "Почему некоторые строки нельзя импортировать",
+    "issueCount": "{count, plural, one {# строка} few {# строки} many {# строк} other {# строки}}, первая в строке {row}",
+    "issuesHint": "Обычно столбец сопоставлен не с тем полем или ячейки в файле пусты. Вернитесь к сопоставлению, чтобы исправить, или импортируйте остальное и добавьте эти строки позже.",
+    "showIssueRows": "Показать эти строки"
   },
   "importing": {
     "title": "Импорт...",
@@ -212,7 +222,9 @@
     "invalid_date": "{field} \"{value}\" не удалось распознать, значение пропущено",
     "invalid_number": "{field} \"{value}\" не является числом, значение пропущено",
     "duplicate_invoice_number": "Номер счёта {value} уже существует",
-    "write_failed": "Не удалось записать: {value}"
+    "write_failed": "Не удалось записать: {value}",
+    "invalid_mileage": "{field} \"{value}\" слишком велико для реального показания, значение пропущено",
+    "service_title_placeholder": "Нет названия и описания, поэтому она будет называться \"{value}\""
   },
   "errors": {
     "uploadFailed": "Не удалось прочитать файл",
@@ -227,5 +239,8 @@
     "customers": "Импорт клиентов",
     "vehicles": "Импорт автомобилей",
     "services": "Импорт истории обслуживания"
+  },
+  "placeholders": {
+    "serviceTitle": "Импортированная работа"
   }
 }

+ 5 - 0
messages/tr/common.json

@@ -169,5 +169,10 @@
   "connectionLost": {
     "title": "Torqvoice'a ulaşılamadı",
     "description": "Son değişikliğiniz kaydedilmemiş olabilir. Bağlantınızı kontrol edip tekrar deneyin."
+  },
+  "searchableSelect": {
+    "choose": "Seçin...",
+    "search": "Ara...",
+    "noMatch": "Eşleşme yok"
   }
 }

+ 19 - 4
messages/tr/dataImport.json

@@ -50,7 +50,11 @@
     "chooseAnother": "Başka bir dosya seç",
     "missingRequired": "Hâlâ gerekli: {fields}",
     "dryRunHint": "Deneme çalıştırması, hiçbir şey yazmadan her satırı mevcut verilerinizle karşılaştırır.",
-    "runDryRun": "Deneme çalıştırması yap"
+    "runDryRun": "Deneme çalıştırması yap",
+    "usedBy": "{column} tarafından kullanılıyor",
+    "searchFields": "Alan ara...",
+    "fieldFor": "{column} için alan",
+    "noJobText": "Başlık, açıklama veya notlarla eşlenmiş bir sütun yok, bu yüzden her iş geçici bir ad alacak. Dosyada varsa, yapılan işi anlatan sütunu eşleyin."
   },
   "options": {
     "dateFormat": "Tarih biçimi",
@@ -66,7 +70,9 @@
     "duplicates": "Bir satır mevcut bir kayıtla eşleştiğinde",
     "duplicatesUpdate": "Mevcut kaydı güncelle",
     "duplicatesSkip": "Satırı atla",
-    "duplicatesCreate": "Yine de yeni bir kayıt oluştur"
+    "duplicatesCreate": "Yine de yeni bir kayıt oluştur",
+    "dateReadsAs": "\"{value}\" şöyle okunuyor: {date}",
+    "dateUnreadable": "\"{value}\" tarih olarak okunamıyor"
   },
   "summary": {
     "create": "Oluşturulacak",
@@ -114,7 +120,11 @@
     "next": "Sonraki",
     "backToMapping": "Eşlemeye dön",
     "import": "{count, plural, one {# satırı} other {# satırı}} içe aktar",
-    "nothingWrittenYet": "Henüz hiçbir şey yazılmadı. İçe aktarma daha sonra Ayarlar → Veri bölümünden geri alınabilir."
+    "nothingWrittenYet": "Henüz hiçbir şey yazılmadı. İçe aktarma daha sonra Ayarlar → Veri bölümünden geri alınabilir.",
+    "issuesTitle": "Bazı satırlar neden içe aktarılamıyor",
+    "issueCount": "{count, plural, one {# satır} other {# satır}}, ilki {row}. satırda",
+    "issuesHint": "Genellikle bir sütun yanlış alanla eşlenmiştir ya da dosyadaki hücreler boştur. Değiştirmek için eşlemeye dönün veya geri kalanını içe aktarıp bunları sonra ekleyin.",
+    "showIssueRows": "Bu satırları göster"
   },
   "importing": {
     "title": "İçe aktarılıyor...",
@@ -212,7 +222,9 @@
     "invalid_date": "{field} \"{value}\" okunamadı ve dışarıda bırakıldı",
     "invalid_number": "{field} \"{value}\" bir sayı değil ve dışarıda bırakıldı",
     "duplicate_invoice_number": "{value} fatura numarası zaten mevcut",
-    "write_failed": "Yazılamadı: {value}"
+    "write_failed": "Yazılamadı: {value}",
+    "invalid_mileage": "{field} \"{value}\" gerçek bir okuma olamayacak kadar yüksek ve dışarıda bırakıldı",
+    "service_title_placeholder": "Başlık veya açıklama yok, bu yüzden adı \"{value}\" olacak"
   },
   "errors": {
     "uploadFailed": "Dosya okunamadı",
@@ -227,5 +239,8 @@
     "customers": "Müşterileri içe aktar",
     "vehicles": "Araçları içe aktar",
     "services": "Servis geçmişini içe aktar"
+  },
+  "placeholders": {
+    "serviceTitle": "İçe aktarılan servis"
   }
 }

+ 108 - 0
src/__tests__/components/searchable-select.test.tsx

@@ -0,0 +1,108 @@
+/**
+ * The app's dropdown for form fields. A long list gets a search box, so the
+ * one field out of thirty is a few letters away instead of a long scroll; a
+ * short list stays a plain list.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { type SearchableOption, SearchableSelect } from '@/components/ui/searchable-select'
+
+// Radix measures the popover's anchor; jsdom has no ResizeObserver, and
+// nothing here needs measuring.
+// biome-ignore lint/suspicious/noEmptyBlockStatements: a stub that measures nothing
+const noop = () => {}
+vi.stubGlobal(
+  'ResizeObserver',
+  class {
+    observe = noop
+    unobserve = noop
+    disconnect = noop
+  }
+)
+Element.prototype.scrollIntoView = noop
+
+const fields: SearchableOption[] = [
+  { value: '__ignore__', label: 'Ignore this column' },
+  { value: 'service.date', label: 'Service date', group: 'Service' },
+  { value: 'service.title', label: 'Service title', group: 'Service' },
+  {
+    value: 'service.description',
+    label: 'Description',
+    group: 'Service',
+    hint: 'In use by Job Desc',
+  },
+  { value: 'service.total', label: 'Total', group: 'Service' },
+  { value: 'vehicle.licensePlate', label: 'Licence plate', group: 'Vehicle', keywords: 'rego' },
+  { value: 'vehicle.vin', label: 'VIN', group: 'Vehicle' },
+  { value: 'customer.name', label: 'Customer name', group: 'Customer' },
+  { value: 'customer.phone', label: 'Phone', group: 'Customer' },
+]
+
+function open() {
+  fireEvent.click(screen.getByRole('combobox'))
+}
+
+describe('SearchableSelect', () => {
+  it('shows the chosen option on the button, or the placeholder', () => {
+    const { rerender } = render(
+      <SearchableSelect value={null} onValueChange={vi.fn()} options={fields} />
+    )
+    expect(screen.getByRole('combobox')).toHaveTextContent('Choose...')
+    rerender(<SearchableSelect value="vehicle.vin" onValueChange={vi.fn()} options={fields} />)
+    expect(screen.getByRole('combobox')).toHaveTextContent('VIN')
+  })
+
+  it('finds an option by typing part of its name or a keyword, and picks it', () => {
+    const onValueChange = vi.fn()
+    render(<SearchableSelect value={null} onValueChange={onValueChange} options={fields} />)
+    open()
+
+    fireEvent.change(screen.getByPlaceholderText('Search...'), { target: { value: 'rego' } })
+    expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['Licence plate'])
+
+    fireEvent.click(screen.getByRole('option', { name: 'Licence plate' }))
+    expect(onValueChange).toHaveBeenCalledWith('vehicle.licensePlate')
+    expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
+  })
+
+  it('lists options under their headings with their hints', () => {
+    render(<SearchableSelect value={null} onValueChange={vi.fn()} options={fields} />)
+    open()
+    expect(screen.getByText('Vehicle')).toBeInTheDocument()
+    expect(screen.getByText('Customer')).toBeInTheDocument()
+    expect(screen.getByText('In use by Job Desc')).toBeInTheDocument()
+  })
+
+  it('says so when nothing matches', () => {
+    render(<SearchableSelect value={null} onValueChange={vi.fn()} options={fields} />)
+    open()
+    fireEvent.change(screen.getByPlaceholderText('Search...'), { target: { value: 'zzz' } })
+    expect(screen.getByText('Nothing matches')).toBeInTheDocument()
+  })
+
+  it('leaves the search box out of a short list', () => {
+    render(<SearchableSelect value={null} onValueChange={vi.fn()} options={fields.slice(0, 3)} />)
+    open()
+    expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument()
+    expect(screen.getAllByRole('option')).toHaveLength(3)
+  })
+
+  it('can be worked from the keyboard when the list has no search box', () => {
+    const onValueChange = vi.fn()
+    render(
+      <SearchableSelect
+        value="service.date"
+        onValueChange={onValueChange}
+        options={fields.slice(0, 3)}
+      />
+    )
+    open()
+    // The list has the focus, starting on the current choice.
+    const focused = document.activeElement as HTMLElement
+    expect(focused).toHaveAttribute('data-slot', 'command')
+    fireEvent.keyDown(focused, { key: 'ArrowDown' })
+    fireEvent.keyDown(focused, { key: 'Enter' })
+    expect(onValueChange).toHaveBeenCalledWith('service.title')
+  })
+})

+ 177 - 0
src/__tests__/features/import/commit.test.ts

@@ -0,0 +1,177 @@
+/**
+ * Writing an import. Rows go in chunks, each chunk in one transaction; a chunk
+ * that fails is written again a row at a time, so one bad row costs only
+ * itself. When the bad row is the one that was to create a vehicle other rows
+ * share, the next row creates it instead and the rest still land on it.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+interface Store {
+  customers: Record<string, unknown>[]
+  vehicles: Record<string, unknown>[]
+  services: Record<string, unknown>[]
+}
+
+let committed: Store
+let seq = 0
+
+/** A transaction client that keeps its writes aside until the transaction succeeds. */
+function txClient(pending: Store) {
+  const create = (into: Record<string, unknown>[], prefix: string) =>
+    vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
+      const id = `${prefix}${++seq}`
+      into.push({ id, ...data })
+      return { id }
+    })
+  return {
+    customer: { create: create(pending.customers, 'c'), update: vi.fn() },
+    vehicle: {
+      create: create(pending.vehicles, 'v'),
+      update: vi.fn(),
+      updateMany: vi.fn(async () => ({ count: 0 })),
+    },
+    serviceRecord: {
+      create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
+        // Stands in for a row the database refuses.
+        if (data.title === 'Refused') throw new Error('write refused')
+        pending.services.push(data)
+        return { id: `s${++seq}` }
+      }),
+    },
+  }
+}
+
+const db = {
+  $transaction: vi.fn(async (fn: (tx: ReturnType<typeof txClient>) => Promise<unknown>) => {
+    const pending: Store = { customers: [], vehicles: [], services: [] }
+    const result = await fn(txClient(pending))
+    committed.customers.push(...pending.customers)
+    committed.vehicles.push(...pending.vehicles)
+    committed.services.push(...pending.services)
+    return result
+  }),
+  customer: { findMany: vi.fn(async () => []) },
+  vehicle: { findMany: vi.fn(async () => []) },
+  serviceRecord: { findMany: vi.fn(async () => []) },
+  importBatch: {
+    create: vi.fn(async () => ({ id: 'batch' })),
+    update: vi.fn(async () => ({})),
+  },
+}
+vi.mock('@/lib/db', () => ({ db }))
+
+vi.mock('@/lib/with-auth', () => ({
+  withAuth: async (fn: (ctx: { organizationId: string; userId: string }) => Promise<unknown>) => {
+    try {
+      return { success: true, data: await fn({ organizationId: 'org', userId: 'user' }) }
+    } catch (error) {
+      return { success: false, error: (error as Error).message }
+    }
+  },
+}))
+vi.mock('@/lib/demo', () => ({ demoGuard: vi.fn() }))
+vi.mock('@/lib/features', () => ({
+  FeatureGatedError: class extends Error {},
+  getFeatures: async () => ({ maxCustomers: 1000 }),
+}))
+vi.mock('@/lib/customer-limit', () => ({ countCustomersTowardLimit: async () => 0 }))
+vi.mock('@/lib/ai', () => ({
+  completionTuning: vi.fn(),
+  createClient: vi.fn(),
+  getAiConfig: vi.fn(),
+}))
+vi.mock('@/lib/ai-error', () => ({ describeAiError: vi.fn() }))
+vi.mock('@/lib/files/manager', () => ({ releaseFiles: vi.fn() }))
+vi.mock('@/lib/files/collect', () => ({
+  serviceRecordFileUrls: vi.fn(),
+  vehicleFileUrls: vi.fn(),
+}))
+vi.mock('@/features/vehicles/Lib/typeKeySetting', () => ({ isTypeKeyEnabled: async () => false }))
+vi.mock('@/i18n/organization-translator', () => ({
+  organizationTranslator: async () => () => 'Imported service',
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+
+const TOKEN = '6f1c2b0e-8d4a-4c7e-9a3b-2f5d6e7a8b9c'
+const HEADER = ['Date', 'Rego', 'Make', 'Model', 'Year', 'Job']
+let rows: string[][] = []
+vi.mock('@/features/import/Lib/staging', () => ({
+  readStagedImport: async () => ({
+    token: TOKEN,
+    organizationId: 'org',
+    fileName: 'history.csv',
+    entity: 'services',
+    presetId: null,
+    sheet: { format: 'csv', columns: HEADER, rows },
+    createdAt: new Date().toISOString(),
+  }),
+  writeImportProgress: vi.fn(),
+  discardStagedImport: vi.fn(async () => undefined),
+  readImportProgress: vi.fn(),
+}))
+
+const { commitImport } = await import('@/features/import/Actions/importActions')
+
+const input = {
+  token: TOKEN,
+  mapping: {
+    '0': 'service.date',
+    '1': 'vehicle.licensePlate',
+    '2': 'vehicle.make',
+    '3': 'vehicle.model',
+    '4': 'vehicle.year',
+    '5': 'service.title',
+  },
+  options: {
+    entity: 'services',
+    dateFormat: 'auto',
+    decimalSeparator: 'auto',
+    defaultCountryCode: null,
+    duplicates: 'update',
+  },
+}
+
+/** Commit and hand back the result, failing the test when the action itself fails. */
+async function run() {
+  const res = await commitImport(input)
+  if (!('data' in res) || !res.data) throw new Error(`import failed: ${res.error}`)
+  return res.data
+}
+
+beforeEach(() => {
+  committed = { customers: [], vehicles: [], services: [] }
+  seq = 0
+})
+
+describe('commitImport', () => {
+  it('fails only the row the database refuses, not the rows written with it', async () => {
+    rows = [
+      ['2014-12-10', 'ABC123', 'Toyota', 'Hilux', '2000', 'Oil change'],
+      ['2014-12-12', 'ZOV432', 'Kenworth', 'K125', '1974', 'Refused'],
+      ['2014-12-16', 'OVB334', 'Land Rover', 'Discovery', '1998', 'Brakes'],
+    ]
+    const result = await run()
+    expect(result).toMatchObject({ created: 2, failed: 1 })
+    expect(result.failures.map((f) => f.index)).toEqual([1])
+    expect(committed.services.map((s) => s.title)).toEqual(['Oil change', 'Brakes'])
+  })
+
+  it('lets the next row create a shared vehicle when the row meant to could not be written', async () => {
+    rows = [
+      ['2014-12-10', 'ABC123', 'Toyota', 'Hilux', '2000', 'Refused'],
+      ['2014-12-12', 'ABC123', 'Toyota', 'Hilux', '2000', 'Oil change'],
+      ['2014-12-16', 'ABC123', 'Toyota', 'Hilux', '2000', 'Brakes'],
+    ]
+    const result = await run()
+    expect(result).toMatchObject({ created: 2, failed: 1 })
+    expect(result.failures.map((f) => f.index)).toEqual([0])
+    // One car, with both jobs on it.
+    expect(committed.vehicles).toHaveLength(1)
+    const carId = committed.vehicles[0].id
+    expect(committed.services.map((s) => [s.title, s.vehicleId])).toEqual([
+      ['Oil change', carId],
+      ['Brakes', carId],
+    ])
+  })
+})

+ 23 - 0
src/__tests__/features/import/normalize.test.ts

@@ -77,6 +77,11 @@ describe('import normalisation', () => {
     expect(iso(parseDate('20240315'))).toBe('2024-03-15')
     expect(iso(parseDate('15.03.2024'))).toBe('2024-03-15')
     expect(iso(parseDate('15/03/2024 09:00'))).toBe('2024-03-15')
+    expect(iso(parseDate('2014-12-10 0:0:0'))).toBe('2014-12-10')
+    expect(iso(parseDate('10/12/2014 0:0'))).toBe('2014-12-10')
+    expect(iso(parseDate('12/10/2014 12:00:00 AM', 'MDY'))).toBe('2014-12-10')
+    expect(iso(parseDate('10/12/2014 2:15 p.m.'))).toBe('2014-12-10')
+    expect(iso(parseDate('2024-03-15T14:30:00.000+02:00'))).toBe('2024-03-15')
     expect(iso(parseDate('03/15/2024'))).toBe('2024-03-15')
     expect(iso(parseDate('15 Mar 2024'))).toBe('2024-03-15')
     expect(iso(parseDate('Mar 15, 2024'))).toBe('2024-03-15')
@@ -87,6 +92,22 @@ describe('import normalisation', () => {
     expect(iso(parseDate(new Date(Date.UTC(2024, 2, 15))))).toBe('2024-03-15')
   })
 
+  it('reads dates written with month names, weekdays and ordinals', () => {
+    expect(iso(parseDate('10-Dec-2014'))).toBe('2014-12-10')
+    expect(iso(parseDate('10-Dec-14'))).toBe('2014-12-10')
+    expect(iso(parseDate('Dec 10th, 2014'))).toBe('2014-12-10')
+    expect(iso(parseDate('Wednesday, 10 December 2014'))).toBe('2014-12-10')
+    expect(iso(parseDate('Wed Dec 10 2014 00:00:00 GMT+1100 (AEDT)'))).toBe('2014-12-10')
+    expect(iso(parseDate('Mon, 10/12/2014'))).toBe('2014-12-10')
+    expect(iso(parseDate('10 de diciembre de 2014'))).toBe('2014-12-10')
+    expect(iso(parseDate('10. desember 2014'))).toBe('2014-12-10')
+    expect(iso(parseDate('10 maart 2014'))).toBe('2014-03-10')
+    expect(iso(parseDate('10 augusti 2014'))).toBe('2014-08-10')
+    // A month and a year name no day.
+    expect(parseDate('Dec 2014')).toBeNull()
+    expect(parseDate('March 2024')).toBeNull()
+  })
+
   it('uses the chosen format for ambiguous dates and rejects impossible ones', () => {
     expect(iso(parseDate('03/04/2024', 'DMY'))).toBe('2024-04-03')
     expect(iso(parseDate('03/04/2024', 'MDY'))).toBe('2024-03-04')
@@ -101,6 +122,8 @@ describe('import normalisation', () => {
     expect(detectDateFormat(['03/04/2024', '15/03/2024', '01/01/2024'])).toBe('DMY')
     expect(detectDateFormat(['03/04/2024', '03/15/2024'])).toBe('MDY')
     expect(detectDateFormat(['2024-03-15', '2024-04-01'])).toBe('YMD')
+    expect(detectDateFormat(['2014-12-10 0:0:0', '2014-12-25 0:0:0'])).toBe('YMD')
+    expect(detectDateFormat(['10/12/2014 0:0:0', '25/12/2014 0:0:0'])).toBe('DMY')
     expect(detectDateFormat(['03/04/2024', '05/06/2024'])).toBe('auto')
   })
 

+ 81 - 0
src/__tests__/features/import/pipeline.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import { type ExistingData, type ImportOptions, planImport } from '@/features/import/Lib/pipeline'
+import { missingRequired } from '@/features/import/Lib/types'
 
 const options = (
   entity: ImportOptions['entity'],
@@ -235,6 +236,86 @@ describe('import plan', () => {
     expect(plan.summary).toMatchObject({ create: 1, error: 3, servicesToCreate: 1 })
   })
 
+  it('names a job from its description or notes and keeps their lines', () => {
+    const plan = planImport(
+      [
+        ['2014-12-10 0:0:0', 'AB 12345', '', 'Replaced pads\r\nMachined rotors', ''],
+        ['2014-12-12 0:0:0', 'AB 12345', '', '', 'Customer reports noise'],
+        ['2014-12-16 0:0:0', 'AB 12345', '', '', ''],
+      ],
+      {
+        '0': 'service.date',
+        '1': 'vehicle.licensePlate',
+        '2': 'service.title',
+        '3': 'service.description',
+        '4': 'service.notes',
+      },
+      options('services'),
+      existing
+    )
+    expect(plan.rows[0].action).toBe('create')
+    expect(plan.rows[0].service).toMatchObject({
+      title: 'Replaced pads',
+      description: 'Replaced pads\nMachined rotors',
+    })
+    expect(plan.rows[0].service?.date?.slice(0, 10)).toBe('2014-12-10')
+    expect(plan.rows[1].service?.title).toBe('Customer reports noise')
+    expect(plan.rows[2].errors.map((e) => e.code)).toEqual(['service_title_required'])
+  })
+
+  it('imports a job with no words under the stand-in name and says so', () => {
+    const plan = planImport(
+      [
+        ['2014-12-16 0:0:0', 'AB 12345', '', '', '', '412.50'],
+        ['2014-12-17 0:0:0', 'AB 12345', 'Brakes', '', '', '88'],
+      ],
+      {
+        '0': 'service.date',
+        '1': 'vehicle.licensePlate',
+        '2': 'service.title',
+        '3': 'service.description',
+        '4': 'service.notes',
+        '5': 'service.total',
+      },
+      options('services', { untitledServiceTitle: 'Imported service' }),
+      existing
+    )
+    expect(plan.rows[0].action).toBe('create')
+    expect(plan.rows[0].service).toMatchObject({ title: 'Imported service', total: 412.5 })
+    expect(plan.rows[0].warnings).toEqual([
+      { code: 'service_title_placeholder', field: 'service.title', value: 'Imported service' },
+    ])
+    expect(plan.rows[1].service?.title).toBe('Brakes')
+    expect(plan.rows[1].warnings).toEqual([])
+    expect(plan.summary).toMatchObject({ create: 2, error: 0, servicesToCreate: 2 })
+  })
+
+  it('needs only a date and a vehicle to run a service history', () => {
+    expect(
+      missingRequired('services', { '0': 'service.date', '1': 'vehicle.licensePlate' })
+    ).toEqual([])
+    expect(missingRequired('services', { '1': 'vehicle.licensePlate' })).toEqual(['service.date'])
+  })
+
+  it('leaves out an odometer reading no vehicle could have', () => {
+    const plan = planImport(
+      [['2024-03-15', 'AB 12345', 'Service', '61412345678']],
+      {
+        '0': 'service.date',
+        '1': 'vehicle.licensePlate',
+        '2': 'service.title',
+        '3': 'service.mileage',
+      },
+      options('services'),
+      existing
+    )
+    expect(plan.rows[0].action).toBe('create')
+    expect(plan.rows[0].service?.mileage).toBeNull()
+    expect(plan.rows[0].warnings).toEqual([
+      { code: 'invalid_mileage', field: 'service.mileage', value: '61412345678' },
+    ])
+  })
+
   it('creates the vehicle for a history row when the row describes it', () => {
     const plan = planImport(
       [['2024-03-15', 'GH 22222', 'Volvo', 'V60', '2016', 'Service', 'Kari Hansen']],

+ 32 - 0
src/__tests__/features/import/suggest.test.ts

@@ -85,6 +85,38 @@ describe('mapping suggestion', () => {
     expect(decimalSeparator).toBe(',')
   })
 
+  it('maps an Australian workshop export with times on its dates', () => {
+    const { mapping, dateFormat } = suggestMapping(
+      ['Job No', 'Job Date', 'Rego', 'Job Description', 'Kms', 'Total Inc GST', 'Total Ex GST'],
+      [
+        ['10442', '2014-12-10 0:0:0', '143CF2', 'Service\nReplace pads', '184500', '412.50', '375'],
+        ['10443', '2014-12-12 0:0:0', 'ZOV432', 'Rego check', '921300', '88.00', '80'],
+      ],
+      'services'
+    )
+    expect(mapping).toEqual({
+      '0': 'service.invoiceNumber',
+      '1': 'service.date',
+      '2': 'vehicle.licensePlate',
+      '3': 'service.description',
+      '4': 'service.mileage',
+      '5': 'service.total',
+    })
+    expect(dateFormat).toBe('YMD')
+  })
+
+  it('recognises a date column by its values when the time is not padded', () => {
+    const { mapping } = suggestMapping(
+      ['Column A', 'Column B'],
+      [
+        ['10/12/2014 0:0:0', 'Oil change'],
+        ['25/12/2014 9:5:0', 'Brakes'],
+      ],
+      'services'
+    )
+    expect(mapping['0']).toBe('service.date')
+  })
+
   it('falls back to the values when the header says nothing', () => {
     const { mapping, source } = suggestMapping(
       ['Column A', 'Column B', 'Column C', 'Column D'],

+ 36 - 46
src/app/(authenticated)/audit-log/audit-log-client.tsx

@@ -7,13 +7,7 @@ import { useRouter, usePathname, useSearchParams } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { Input } from '@/components/ui/input'
 import { Badge } from '@/components/ui/badge'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import {
   Table,
   TableBody,
@@ -177,45 +171,41 @@ export function AuditLogClient({
           )}
         </div>
         <div className="flex flex-wrap gap-2">
-          <Select value={actionFilter} onValueChange={(v) => navigate({ action: v })}>
-            <SelectTrigger className="h-9 w-[180px]">
-              <SelectValue placeholder={t('allActions')} />
-            </SelectTrigger>
-            <SelectContent>
-              <SelectItem value="all">{t('allActions')}</SelectItem>
-              {data.filters.actions.map((a) => (
-                <SelectItem key={a} value={a}>
-                  {getActionLabel(a)}
-                </SelectItem>
-              ))}
-            </SelectContent>
-          </Select>
-          <Select value={entityFilter} onValueChange={(v) => navigate({ entity: v })}>
-            <SelectTrigger className="h-9 w-[160px]">
-              <SelectValue placeholder={t('allEntities')} />
-            </SelectTrigger>
-            <SelectContent>
-              <SelectItem value="all">{t('allEntities')}</SelectItem>
-              {data.filters.entities.map((e) => (
-                <SelectItem key={e} value={e}>
-                  {e}
-                </SelectItem>
-              ))}
-            </SelectContent>
-          </Select>
-          <Select value={userFilter} onValueChange={(v) => navigate({ userId: v })}>
-            <SelectTrigger className="h-9 w-[160px]">
-              <SelectValue placeholder={t('allUsers')} />
-            </SelectTrigger>
-            <SelectContent>
-              <SelectItem value="all">{t('allUsers')}</SelectItem>
-              {data.filters.users.map((u) => (
-                <SelectItem key={u.id} value={u.id}>
-                  {u.name || u.email || u.id.substring(0, 8)}
-                </SelectItem>
-              ))}
-            </SelectContent>
-          </Select>
+          <SearchableSelect
+            value={actionFilter}
+            onValueChange={(v) => navigate({ action: v })}
+            className="w-[180px]"
+            options={[
+              { value: 'all', label: t('allActions') },
+              ...data.filters.actions.map((a) => ({
+                value: a,
+                label: getActionLabel(a),
+                keywords: a,
+              })),
+            ]}
+          />
+          <SearchableSelect
+            value={entityFilter}
+            onValueChange={(v) => navigate({ entity: v })}
+            className="w-[160px]"
+            options={[
+              { value: 'all', label: t('allEntities') },
+              ...data.filters.entities.map((e) => ({ value: e, label: e })),
+            ]}
+          />
+          <SearchableSelect
+            value={userFilter}
+            onValueChange={(v) => navigate({ userId: v })}
+            className="w-[160px]"
+            options={[
+              { value: 'all', label: t('allUsers') },
+              ...data.filters.users.map((u) => ({
+                value: u.id,
+                label: u.name || u.email || u.id.substring(0, 8),
+                keywords: u.email ?? '',
+              })),
+            ]}
+          />
         </div>
       </div>
 

+ 9 - 21
src/app/(authenticated)/inventory/inventory-client.tsx

@@ -26,13 +26,7 @@ import {
   TableHeader,
   TableRow,
 } from '@/components/ui/table'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import {
   DropdownMenu,
   DropdownMenuContent,
@@ -404,22 +398,16 @@ export function InventoryClient({
                 {...tableNav.searchInputProps}
               />
             </form>
-            <Select
+            <SearchableSelect
               value={category || 'all'}
               onValueChange={(v) => navigate({ category: v === 'all' ? undefined : v })}
-            >
-              <SelectTrigger className="h-9 w-[120px] shrink-0 sm:w-[150px]">
-                <SelectValue placeholder={t('categoryPlaceholder')} />
-              </SelectTrigger>
-              <SelectContent>
-                <SelectItem value="all">{t('allCategories')}</SelectItem>
-                {categories.map((cat) => (
-                  <SelectItem key={cat} value={cat}>
-                    {cat}
-                  </SelectItem>
-                ))}
-              </SelectContent>
-            </Select>
+              placeholder={t('categoryPlaceholder')}
+              className="w-[120px] shrink-0 sm:w-[150px]"
+              options={[
+                { value: 'all', label: t('allCategories') },
+                ...categories.map((cat) => ({ value: cat, label: cat })),
+              ]}
+            />
             {/* Toggle rather than a third dropdown: low stock is a yes/no lens
                 on the list, and it needs to be reachable from the page itself
                 (the dashboard card links straight here with ?lowStock=1). */}

+ 8 - 12
src/app/(authenticated)/settings/localization/localization-settings.tsx

@@ -19,6 +19,7 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { Switch } from '@/components/ui/switch'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 import {
@@ -223,18 +224,13 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
         <p className="text-sm text-muted-foreground">{t('localization.languageDescription')}</p>
         <div id="localization-workshop-language" className="space-y-2">
           <Label>{t('localization.language')}</Label>
-          <Select value={workshopLocale} onValueChange={handleLanguageChange}>
-            <SelectTrigger className="w-64">
-              <SelectValue />
-            </SelectTrigger>
-            <SelectContent>
-              {locales.map((loc) => (
-                <SelectItem key={loc} value={loc}>
-                  {localeNames[loc]}
-                </SelectItem>
-              ))}
-            </SelectContent>
-          </Select>
+          <SearchableSelect
+            value={workshopLocale}
+            onValueChange={handleLanguageChange}
+            className="w-64"
+            aria-label={t('localization.language')}
+            options={locales.map((loc) => ({ value: loc, label: localeNames[loc], keywords: loc }))}
+          />
         </div>
 
         <Separator />

+ 19 - 17
src/app/(authenticated)/timesheets/timesheets-client.tsx

@@ -34,6 +34,7 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import {
   DropdownMenu,
   DropdownMenuContent,
@@ -413,28 +414,29 @@ export default function TimesheetsClient({
 
         <div className="ml-auto flex flex-wrap items-center gap-2">
           {loading && <Loader2 className="size-4 animate-spin text-muted-foreground" />}
-          <Select
+          <SearchableSelect
+            size="sm"
             value={technicianId}
             onValueChange={(v) => {
               setTechnicianId(v)
               void load(data.fromKey, data.toKey, v)
             }}
-          >
-            <SelectTrigger className="h-8 w-44" size="sm">
-              <SelectValue />
-            </SelectTrigger>
-            <SelectContent>
-              <SelectItem value="all">{t('allTechnicians')}</SelectItem>
-              {data.technicians.map((tech) => (
-                <SelectItem key={tech.id} value={tech.id}>
-                  <span className="flex items-center gap-2">
-                    <span className="size-2 rounded-full" style={{ backgroundColor: tech.color }} />
-                    {tech.name}
-                  </span>
-                </SelectItem>
-              ))}
-            </SelectContent>
-          </Select>
+            className="w-44"
+            options={[
+              { value: 'all', label: t('allTechnicians') },
+              ...data.technicians.map((tech) => ({
+                value: tech.id,
+                label: tech.name,
+                icon: (
+                  <span
+                    className="size-2 shrink-0 rounded-full"
+                    style={{ backgroundColor: tech.color }}
+                    aria-hidden
+                  />
+                ),
+              })),
+            ]}
+          />
           <Select value={sortBy} onValueChange={(v) => setSortBy(v as TimesheetSort)}>
             <SelectTrigger className="h-8 w-44" size="sm" aria-label={t('sortBy')}>
               <SelectValue />

+ 210 - 0
src/components/ui/searchable-select.tsx

@@ -0,0 +1,210 @@
+'use client'
+
+import { type ReactNode, useMemo, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Check, ChevronDown } from 'lucide-react'
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/command'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { cn } from '@/lib/utils'
+
+export interface SearchableOption {
+  value: string
+  label: string
+  /** Extra words the search matches, such as a code, an email or a dial code. */
+  keywords?: string
+  /** Heading the option is listed under; options keep the order they come in. */
+  group?: string
+  /** A short note on the right of the option, such as where a field is already used. */
+  hint?: string
+  /** Shown before the label, in the list and on the button: a technician's colour, a flag. */
+  icon?: ReactNode
+  disabled?: boolean
+}
+
+/**
+ * Ranks an option for what is typed. Whole words first, so "UK" finds the
+ * United Kingdom before Ukraine; then names starting with it, words starting
+ * with it, and anything containing it. No fuzzy matching: "UK" matching
+ * Uzbekistan letter by letter only buries the answer.
+ */
+export function pickerScore(value: string, search: string): number {
+  const q = search.trim().toLowerCase()
+  if (!q) return 1
+  const text = value.toLowerCase()
+  const words = text.split(/[\s,()/-]+/)
+  if (words.includes(q)) return 1
+  if (text.startsWith(q)) return 0.9
+  if (words.some((w) => w.startsWith(q))) return 0.7
+  if (text.includes(q)) return 0.4
+  return 0
+}
+
+/** Below this many options the list is short enough to read, and a search box is in the way. */
+const SEARCH_FROM = 8
+
+/**
+ * The app's dropdown for a form field: a button that opens a list, with a
+ * search box once the list is long enough to need one. It takes the same
+ * value and onValueChange as a Select, so swapping one for the other is a
+ * matter of handing it the options.
+ */
+export function SearchableSelect({
+  value,
+  onValueChange,
+  options,
+  placeholder,
+  searchPlaceholder,
+  emptyText,
+  disabled,
+  id,
+  className,
+  contentClassName,
+  size = 'default',
+  align = 'start',
+  searchable,
+  'aria-label': ariaLabel,
+  'aria-invalid': ariaInvalid,
+}: {
+  value: string | null | undefined
+  onValueChange: (value: string) => void
+  options: readonly SearchableOption[]
+  placeholder?: string
+  searchPlaceholder?: string
+  emptyText?: string
+  disabled?: boolean
+  id?: string
+  className?: string
+  contentClassName?: string
+  size?: 'sm' | 'default'
+  align?: 'start' | 'center' | 'end'
+  /** Force the search box on or off; by default it shows for longer lists. */
+  searchable?: boolean
+  'aria-label'?: string
+  'aria-invalid'?: boolean
+}) {
+  const t = useTranslations('common.searchableSelect')
+  const [open, setOpen] = useState(false)
+  const [search, setSearch] = useState('')
+  const selected = options.find((o) => o.value === value)
+  const withSearch = searchable ?? options.length >= SEARCH_FROM
+
+  // Options under their headings, in the order the headings first appear.
+  const groups = useMemo(() => {
+    const out = new Map<string, SearchableOption[]>()
+    for (const option of options) {
+      const key = option.group ?? ''
+      const list = out.get(key)
+      if (list) list.push(option)
+      else out.set(key, [option])
+    }
+    return [...out.entries()]
+  }, [options])
+
+  const onOpenChange = (next: boolean) => {
+    setOpen(next)
+    if (!next) setSearch('')
+  }
+
+  return (
+    // Modal, like a Select: inside a dialog a non-modal popover loses its wheel
+    // events to the dialog's scroll lock, and a long list cannot be scrolled.
+    <Popover open={open} onOpenChange={onOpenChange} modal>
+      <PopoverTrigger asChild>
+        <button
+          id={id}
+          type="button"
+          role="combobox"
+          aria-expanded={open}
+          aria-haspopup="listbox"
+          aria-label={ariaLabel}
+          aria-invalid={ariaInvalid}
+          disabled={disabled}
+          data-size={size}
+          // The button can be narrower than the name in it; hovering shows all of it.
+          title={selected?.label}
+          className={cn(
+            'border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-left text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8',
+            className
+          )}
+        >
+          <span
+            className={cn('flex min-w-0 items-center gap-2', !selected && 'text-muted-foreground')}
+          >
+            {selected?.icon}
+            <span className="truncate">{selected?.label ?? placeholder ?? t('choose')}</span>
+          </span>
+          <ChevronDown className="size-4 shrink-0 text-muted-foreground opacity-50" aria-hidden />
+        </button>
+      </PopoverTrigger>
+      <PopoverContent
+        className={cn(
+          'w-(--radix-popover-trigger-width) min-w-56 max-w-[calc(100vw-2rem)] p-0',
+          contentClassName
+        )}
+        align={align}
+      >
+        <Command
+          // Matches what the user can read, never the value: an id would match random letters.
+          filter={(_, query, keywords) => pickerScore(keywords?.join(' ') ?? '', query)}
+          // Opens on the current choice, so Enter keeps it and the arrows move from it.
+          defaultValue={value ?? undefined}
+          // Without a search box nothing inside can take focus, and the arrow keys
+          // and Enter would never reach the list: the list itself takes it instead.
+          tabIndex={withSearch ? undefined : 0}
+          className={withSearch ? undefined : 'outline-none'}
+        >
+          {withSearch && (
+            <CommandInput
+              placeholder={searchPlaceholder ?? t('search')}
+              value={search}
+              onValueChange={setSearch}
+            />
+          )}
+          <CommandList className="max-h-72">
+            <CommandEmpty>{emptyText ?? t('noMatch')}</CommandEmpty>
+            {groups.map(([heading, items]) => (
+              <CommandGroup key={heading || '_'} heading={heading || undefined}>
+                {items.map((option) => (
+                  <CommandItem
+                    key={option.value}
+                    // cmdk keys items by this, so it is the unique value; the
+                    // search runs on the label and keywords.
+                    value={option.value}
+                    keywords={[option.label, option.keywords ?? '', option.group ?? '']}
+                    disabled={option.disabled}
+                    onSelect={() => {
+                      onValueChange(option.value)
+                      onOpenChange(false)
+                    }}
+                  >
+                    <Check
+                      className={cn(
+                        'size-4 shrink-0',
+                        option.value === value ? 'opacity-100' : 'opacity-0'
+                      )}
+                      aria-hidden
+                    />
+                    {option.icon}
+                    <span className="min-w-0 flex-1 break-words">{option.label}</span>
+                    {option.hint && (
+                      <span className="ml-2 shrink-0 text-xs text-muted-foreground">
+                        {option.hint}
+                      </span>
+                    )}
+                  </CommandItem>
+                ))}
+              </CommandGroup>
+            ))}
+          </CommandList>
+        </Command>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 23 - 26
src/features/billing/Components/RecurringInvoicesClient.tsx

@@ -35,6 +35,7 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import {
   ContextMenu,
   ContextMenuContent,
@@ -588,19 +589,15 @@ export default function RecurringInvoicesClient({
               </div>
               <div className="space-y-1.5">
                 <Label>{t('recurring.vehicleLabel')}</Label>
-                <Select value={vehicleId} onValueChange={setVehicleId}>
-                  <SelectTrigger>
-                    <SelectValue placeholder={t('recurring.selectVehicle')} />
-                  </SelectTrigger>
-                  <SelectContent>
-                    {vehicles.map((v) => (
-                      <SelectItem key={v.id} value={v.id}>
-                        {v.year} {v.make} {v.model}
-                        {v.customer ? ` (${v.customer.name})` : ''}
-                      </SelectItem>
-                    ))}
-                  </SelectContent>
-                </Select>
+                <SearchableSelect
+                  value={vehicleId}
+                  onValueChange={setVehicleId}
+                  placeholder={t('recurring.selectVehicle')}
+                  options={vehicles.map((v) => ({
+                    value: v.id,
+                    label: `${v.year} ${v.make} ${v.model}${v.customer ? ` (${v.customer.name})` : ''}`,
+                  }))}
+                />
               </div>
             </div>
 
@@ -814,19 +811,19 @@ export default function RecurringInvoicesClient({
             {/* Actions */}
           </DialogBody>
           <DialogFooter>
-              <Button
-                variant="outline"
-                onClick={() => {
-                  setShowCreate(false)
-                  resetForm()
-                }}
-              >
-                {t('recurring.cancel')}
-              </Button>
-              <Button data-save-shortcut onClick={handleCreate} disabled={isPending}>
-                {isPending && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
-                {t('recurring.create')}
-              </Button>
+            <Button
+              variant="outline"
+              onClick={() => {
+                setShowCreate(false)
+                resetForm()
+              }}
+            >
+              {t('recurring.cancel')}
+            </Button>
+            <Button data-save-shortcut onClick={handleCreate} disabled={isPending}>
+              {isPending && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
+              {t('recurring.create')}
+            </Button>
           </DialogFooter>
         </DialogContent>
       </Dialog>

+ 14 - 25
src/features/custom-fields/Components/CustomFieldsForm.tsx

@@ -1,18 +1,13 @@
 'use client'
 
 import { useEffect, useState, useCallback } from 'react'
+import { useTranslations } from 'next-intl'
 import { Input } from '@/components/ui/input'
 import { DateInput } from '@/components/ui/date-input'
 import { Label } from '@/components/ui/label'
 import { Textarea } from '@/components/ui/textarea'
 import { Checkbox } from '@/components/ui/checkbox'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { AppCard } from '@/components/app-card'
 import { Card, CardContent } from '@/components/ui/card'
 import {
@@ -44,6 +39,7 @@ export function CustomFieldsForm({
   onValuesReady?: (save: () => Promise<{ valid: boolean }>) => void
   onChange?: () => void
 }) {
+  const tSettings = useTranslations('settings.customFields')
   const [fields, setFields] = useState<FieldWithValue[]>([])
   const [values, setValues] = useState<Record<string, string>>({})
   const [loading, setLoading] = useState(true)
@@ -189,28 +185,21 @@ export function CustomFieldsForm({
             )}
 
             {field.fieldType === 'select' && (
-              <Select
-                value={val || 'none'}
+              <SearchableSelect
+                value={val || (field.required ? null : 'none')}
                 onValueChange={(v) => updateValue(field.id, v === 'none' ? '' : v)}
-              >
-                <SelectTrigger
-                  className={cn(hasError && 'border-destructive focus-visible:ring-destructive')}
-                >
-                  <SelectValue placeholder="Select..." />
-                </SelectTrigger>
-                <SelectContent>
-                  {!field.required && <SelectItem value="none">None</SelectItem>}
-                  {(field.options || '')
+                aria-invalid={hasError || undefined}
+                options={[
+                  ...(field.required
+                    ? []
+                    : [{ value: 'none', label: tSettings('defaultValueNone') }]),
+                  ...(field.options || '')
                     .split(',')
                     .map((o) => o.trim())
                     .filter(Boolean)
-                    .map((option) => (
-                      <SelectItem key={option} value={option}>
-                        {option}
-                      </SelectItem>
-                    ))}
-                </SelectContent>
-              </Select>
+                    .map((option) => ({ value: option, label: option })),
+                ]}
+              />
             )}
 
             {hasError && <p className="text-xs text-destructive">{errors[field.id]}</p>}

+ 25 - 33
src/features/custom-fields/Components/CustomFieldsManager.tsx

@@ -17,6 +17,7 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import {
   Dialog,
   DialogBody,
@@ -519,28 +520,20 @@ export function CustomFieldsManager({
                 </div>
               )}
               {formData.fieldType === 'select' && (
-                <Select
+                <SearchableSelect
                   value={formData.defaultValue || 'none'}
                   onValueChange={(v) =>
                     setFormData({ ...formData, defaultValue: v === 'none' ? '' : v })
                   }
-                >
-                  <SelectTrigger>
-                    <SelectValue />
-                  </SelectTrigger>
-                  <SelectContent>
-                    <SelectItem value="none">{t('customFields.defaultValueNone')}</SelectItem>
-                    {formData.options
+                  options={[
+                    { value: 'none', label: t('customFields.defaultValueNone') },
+                    ...formData.options
                       .split(',')
                       .map((o) => o.trim())
                       .filter(Boolean)
-                      .map((option) => (
-                        <SelectItem key={option} value={option}>
-                          {option}
-                        </SelectItem>
-                      ))}
-                  </SelectContent>
-                </Select>
+                      .map((option) => ({ value: option, label: option })),
+                  ]}
+                />
               )}
               <p className="text-xs text-muted-foreground">{t('customFields.defaultValueHint')}</p>
             </div>
@@ -579,26 +572,25 @@ export function CustomFieldsManager({
                 <Label>{t('customFields.active')}</Label>
               </div>
             </div>
-
           </DialogBody>
           <DialogFooter>
-              <Button
-                variant="ghost"
-                onClick={() => {
-                  setShowDialog(false)
-                  resetForm()
-                }}
-              >
-                {t('customFields.cancel')}
-              </Button>
-              <Button
-                data-save-shortcut
-                onClick={handleSave}
-                disabled={loading || !formData.name || !formData.label}
-              >
-                {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                {editing ? t('customFields.update') : t('customFields.create')}
-              </Button>
+            <Button
+              variant="ghost"
+              onClick={() => {
+                setShowDialog(false)
+                resetForm()
+              }}
+            >
+              {t('customFields.cancel')}
+            </Button>
+            <Button
+              data-save-shortcut
+              onClick={handleSave}
+              disabled={loading || !formData.name || !formData.label}
+            >
+              {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {editing ? t('customFields.update') : t('customFields.create')}
+            </Button>
           </DialogFooter>
         </DialogContent>
       </Dialog>

+ 9 - 20
src/features/customers/Components/CustomerForm.tsx

@@ -10,13 +10,7 @@ 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 {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { listDesignOptions } from '@/features/invoice-designer/Actions/documentDesignActions'
 import {
   Dialog,
@@ -341,19 +335,14 @@ export function CustomerForm({
               {designOptions.length > 0 && (
                 <div className="space-y-2">
                   <Label>{t('invoiceDesign')}</Label>
-                  <Select value={invoiceDesignId} onValueChange={setInvoiceDesignId}>
-                    <SelectTrigger className="w-full">
-                      <SelectValue />
-                    </SelectTrigger>
-                    <SelectContent>
-                      <SelectItem value={FOLLOW_DEFAULT}>{t('invoiceDesignDefault')}</SelectItem>
-                      {designOptions.map((option) => (
-                        <SelectItem key={option.id} value={option.id}>
-                          {option.name}
-                        </SelectItem>
-                      ))}
-                    </SelectContent>
-                  </Select>
+                  <SearchableSelect
+                    value={invoiceDesignId}
+                    onValueChange={setInvoiceDesignId}
+                    options={[
+                      { value: FOLLOW_DEFAULT, label: t('invoiceDesignDefault') },
+                      ...designOptions.map((option) => ({ value: option.id, label: option.name })),
+                    ]}
+                  />
                   <p className="text-xs text-muted-foreground">{t('invoiceDesignHint')}</p>
                 </div>
               )}

+ 19 - 31
src/features/dashboard/Components/CustomCardDialog.tsx

@@ -23,6 +23,7 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { DateInput } from '@/components/ui/date-input'
 import { Loader2, Plus, X } from 'lucide-react'
 import {
@@ -183,10 +184,7 @@ export function CustomCardDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent
-        className="max-h-[90vh] sm:max-w-2xl"
-        aria-describedby={undefined}
-      >
+      <DialogContent className="max-h-[90vh] sm:max-w-2xl" aria-describedby={undefined}>
         <DialogHeader>
           <DialogTitle>{widget ? t('editCard') : t('addCard')}</DialogTitle>
           <DocsLink href="/docs/features/dashboard" variant="hint" className="self-start" />
@@ -227,23 +225,14 @@ export function CustomCardDialog({
               const def = getField(entity, filter.field)
               return (
                 <div key={index} className="flex items-center gap-2">
-                  <Select
+                  <SearchableSelect
                     value={filter.field}
                     onValueChange={(v) => updateFilter(index, { field: v })}
-                  >
-                    <SelectTrigger className="h-9 w-[34%]">
-                      <SelectValue />
-                    </SelectTrigger>
-                    <SelectContent>
-                      {fields
-                        .filter((f) => f.filterable !== false)
-                        .map((f) => (
-                          <SelectItem key={f.id} value={f.id}>
-                            {t(`fields.${f.labelKey}`)}
-                          </SelectItem>
-                        ))}
-                    </SelectContent>
-                  </Select>
+                    className="w-[34%] shrink-0"
+                    options={fields
+                      .filter((f) => f.filterable !== false)
+                      .map((f) => ({ value: f.id, label: t(`fields.${f.labelKey}`) }))}
+                  />
                   <Select
                     value={filter.operator}
                     onValueChange={(v) => updateFilter(index, { operator: v })}
@@ -309,20 +298,19 @@ export function CustomCardDialog({
               </SelectContent>
             </Select>
           </div>
-
         </DialogBody>
         <DialogFooter>
-            <Button variant="outline" onClick={() => onOpenChange(false)}>
-              {t('cancel')}
-            </Button>
-            <Button
-              data-save-shortcut
-              onClick={handleSave}
-              disabled={saving || !name.trim() || columns.length === 0}
-            >
-              {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {t('save')}
-            </Button>
+          <Button variant="outline" onClick={() => onOpenChange(false)}>
+            {t('cancel')}
+          </Button>
+          <Button
+            data-save-shortcut
+            onClick={handleSave}
+            disabled={saving || !name.trim() || columns.length === 0}
+          >
+            {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+            {t('save')}
+          </Button>
         </DialogFooter>
       </DialogContent>
     </Dialog>

+ 11 - 19
src/features/fault-codes/Components/ImportCodesDialog.tsx

@@ -18,13 +18,7 @@ import {
 } from '@/components/ui/dialog'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { useFormatDate } from '@/lib/use-format-date'
 import { track } from '@/lib/analytics'
 import { cn } from '@/lib/utils'
@@ -359,18 +353,16 @@ export function ImportCodesDialog({
               <Label htmlFor="import-lang" className="text-xs text-muted-foreground">
                 {t('language')}
               </Label>
-              <Select value={lang} onValueChange={setLang}>
-                <SelectTrigger id="import-lang" className="w-full">
-                  <SelectValue />
-                </SelectTrigger>
-                <SelectContent>
-                  {locales.map((l) => (
-                    <SelectItem key={l} value={l}>
-                      {localeNames[l as Locale]}
-                    </SelectItem>
-                  ))}
-                </SelectContent>
-              </Select>
+              <SearchableSelect
+                id="import-lang"
+                value={lang}
+                onValueChange={setLang}
+                options={locales.map((l) => ({
+                  value: l,
+                  label: localeNames[l as Locale],
+                  keywords: l,
+                }))}
+              />
             </div>
             <div className="space-y-1.5">
               <Label htmlFor="import-source" className="text-xs text-muted-foreground">

+ 7 - 19
src/features/fault-codes/Components/ShareFixDialog.tsx

@@ -20,13 +20,7 @@ import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { compressImage } from '@/lib/compress-image'
 import { cn } from '@/lib/utils'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { RichTextEditor } from '@/features/vehicles/Components/service-edit/RichTextEditor'
 import { htmlToFixMarkdown } from '../Lib/fixMarkdown'
 import type { CodeVehicle, OwnFix } from '../Lib/types'
@@ -245,18 +239,12 @@ export function ShareFixDialog({
                     {t('make')}
                   </Label>
                   {makes ? (
-                    <Select value={make} onValueChange={setMake}>
-                      <SelectTrigger id={`fix-make-${code}`} className="w-full">
-                        <SelectValue />
-                      </SelectTrigger>
-                      <SelectContent>
-                        {makes.map((m) => (
-                          <SelectItem key={m} value={m}>
-                            {m}
-                          </SelectItem>
-                        ))}
-                      </SelectContent>
-                    </Select>
+                    <SearchableSelect
+                      id={`fix-make-${code}`}
+                      value={make}
+                      onValueChange={setMake}
+                      options={makes.map((m) => ({ value: m, label: m }))}
+                    />
                   ) : (
                     <Input
                       id={`fix-make-${code}`}

+ 7 - 19
src/features/fault-codes/Components/ShareWaveformDialog.tsx

@@ -29,13 +29,7 @@ import {
 } from '@/components/ui/dialog'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { Textarea } from '@/components/ui/textarea'
 import { cn } from '@/lib/utils'
 import { formatBytes } from '@/features/integrations/Lib/format-bytes'
@@ -571,18 +565,12 @@ export function ShareWaveformDialog({
                     {ts('make')}
                   </Label>
                   {makes ? (
-                    <Select value={make} onValueChange={setMake}>
-                      <SelectTrigger id={`wave-make-${code}`} className="w-full">
-                        <SelectValue />
-                      </SelectTrigger>
-                      <SelectContent>
-                        {makes.map((m) => (
-                          <SelectItem key={m} value={m}>
-                            {m}
-                          </SelectItem>
-                        ))}
-                      </SelectContent>
-                    </Select>
+                    <SearchableSelect
+                      id={`wave-make-${code}`}
+                      value={make}
+                      onValueChange={setMake}
+                      options={makes.map((m) => ({ value: m, label: m }))}
+                    />
                   ) : (
                     <Input
                       id={`wave-make-${code}`}

+ 144 - 28
src/features/import/Actions/importActions.ts

@@ -38,6 +38,8 @@ import {
   detectDecimalSeparator,
 } from '../Lib/normalize'
 import { releaseFiles } from '@/lib/files/manager'
+import { escapeHtml } from '@/features/email/Render/escape'
+import { organizationTranslator } from '@/i18n/organization-translator'
 import { serviceRecordFileUrls, vehicleFileUrls } from '@/lib/files/collect'
 import { isTypeKeyEnabled } from '@/features/vehicles/Lib/typeKeySetting'
 
@@ -81,10 +83,44 @@ export interface PlanPage {
   pageSize: number
 }
 
+/** One reason rows cannot be imported, and how many rows it stops. */
+export interface IssueCount {
+  code: RowIssue['code']
+  field?: string
+  count: number
+  /** The first spreadsheet row it happens on, so the user can find it. */
+  firstRow: number
+  /** The value on that row, when the issue is about one. */
+  value?: string
+}
+
 export interface DryRunResult {
   summary: PlanSummary
   limit: { maxCustomers: number; remaining: number; exceeded: boolean } | null
   page: PlanPage
+  /** Why rows cannot be imported, most common first. */
+  issues: IssueCount[]
+}
+
+function countIssues(plan: ImportPlan): IssueCount[] {
+  const counts = new Map<string, IssueCount>()
+  for (const row of plan.rows) {
+    if (row.action !== 'error') continue
+    for (const issue of row.errors) {
+      const key = `${issue.code}|${issue.field ?? ''}`
+      const hit = counts.get(key)
+      if (hit) hit.count++
+      else
+        counts.set(key, {
+          code: issue.code,
+          field: issue.field,
+          count: 1,
+          firstRow: row.index + 2,
+          value: issue.value,
+        })
+    }
+  }
+  return [...counts.values()].sort((a, b) => b.count - a.count)
 }
 
 export interface CommitResult {
@@ -158,6 +194,16 @@ function pageOf(plan: ImportPlan, page: number, filter: RowFilter): PlanPage {
   }
 }
 
+/**
+ * The stand-in name for a job with no words in the file. It is stored on the
+ * job, so it is in the workshop's language, not the language of whoever runs
+ * the import.
+ */
+async function untitledServiceTitle(organizationId: string): Promise<string> {
+  const t = await organizationTranslator(organizationId, 'dataImport')
+  return t('placeholders.serviceTitle')
+}
+
 async function buildPlan(
   organizationId: string,
   input: RunImportInput
@@ -172,7 +218,12 @@ async function buildPlan(
   if (staged.entity !== input.options.entity) {
     throw new Error('The uploaded file is a different kind of import')
   }
-  const options: ImportOptions = { ...input.options, entity: staged.entity }
+  const options: ImportOptions = {
+    ...input.options,
+    entity: staged.entity,
+    untitledServiceTitle:
+      staged.entity === 'services' ? await untitledServiceTitle(organizationId) : undefined,
+  }
   const mapping = sanitizeMapping(
     input.mapping,
     staged.entity,
@@ -208,7 +259,12 @@ export async function dryRunImport(raw: unknown, page = 0, filter: RowFilter = '
         plan.summary.customersToCreate > 0 || staged.entity === 'customers'
           ? await customerLimit(organizationId, plan.summary.customersToCreate)
           : null
-      return { summary: plan.summary, limit, page: pageOf(plan, page, filter) }
+      return {
+        summary: plan.summary,
+        limit,
+        page: pageOf(plan, page, filter),
+        issues: countIssues(plan),
+      }
     },
     { requiredPermissions: permissionsFor(input.options.entity) }
   )
@@ -305,19 +361,40 @@ interface CommitState {
   nextCustomerNumber: number
 }
 
-async function nextCustomerNumberStart(organizationId: string): Promise<number> {
+/**
+ * Where numbers for customers without one start: above every number in use and
+ * every number the file itself brings, so a number handed out early in the
+ * import never collides with one a later row carries.
+ */
+async function nextCustomerNumberStart(organizationId: string, rows: RowPlan[]): Promise<number> {
   const existing = await db.customer.findMany({
     where: { organizationId, customerNumber: { not: null } },
     select: { customerNumber: true },
   })
+  const numbers = [
+    ...existing.map((c) => c.customerNumber),
+    ...rows.map((r) => r.customer?.customerNumber),
+  ]
   return (
-    existing.reduce((acc, c) => {
-      const n = Number.parseInt(c.customerNumber ?? '', 10)
+    numbers.reduce<number>((acc, value) => {
+      const n = Number.parseInt(value ?? '', 10)
       return Number.isFinite(n) && n > acc ? n : acc
     }, 1000) + 1
   )
 }
 
+/**
+ * Imported text as the rich text the work order's editors hold, one paragraph
+ * per line, so a list of work done keeps its lines and a "<" stays a "<".
+ */
+function textToHtml(text: string | null): string | null {
+  if (!text) return null
+  return text
+    .split('\n')
+    .map((line) => `<p>${escapeHtml(line)}</p>`)
+    .join('')
+}
+
 /** Only the values the row actually carries; an update never blanks a field. */
 function definedOnly<T extends Record<string, unknown>>(
   data: T
@@ -327,6 +404,28 @@ function definedOnly<T extends Record<string, unknown>>(
   return out as { [K in keyof T]?: NonNullable<T[K]> }
 }
 
+/**
+ * A row that created a customer or vehicle in place of the row meant to (which
+ * could not be written) files it under that row too, so the rows after it
+ * that share it find it there instead of each creating their own.
+ */
+function standIn(ids: Map<number, string>, origin: number | undefined, id: string) {
+  if (origin != null) ids.set(origin, id)
+}
+
+/**
+ * Forget the ids a row handed out, after its transaction rolled back: under
+ * its own index, and under the row it stood in for.
+ */
+function forgetRow(state: CommitState, row: RowPlan) {
+  for (const ids of [state.customerIds, state.vehicleIds]) {
+    const own = ids.get(row.index)
+    ids.delete(row.index)
+    if (own == null) continue
+    for (const [index, id] of ids) if (id === own) ids.delete(index)
+  }
+}
+
 async function writeRow(tx: Tx, state: CommitState, row: RowPlan): Promise<void> {
   const { organizationId, userId, batchId, entity } = state
 
@@ -349,9 +448,11 @@ async function writeRow(tx: Tx, state: CommitState, row: RowPlan): Promise<void>
           }),
         })
       }
-    } else if (row.customerSameAs != null) {
+    } else if (row.customerSameAs != null && state.customerIds.has(row.customerSameAs)) {
       customerId = state.customerIds.get(row.customerSameAs) ?? null
     } else if (row.customer.name) {
+      // Also reached when the row that was to create this customer could not be
+      // written: this row creates it instead (see standIn below).
       const customerNumber = row.customer.customerNumber ?? String(state.nextCustomerNumber++)
       const created = await tx.customer.create({
         data: {
@@ -371,6 +472,7 @@ async function writeRow(tx: Tx, state: CommitState, row: RowPlan): Promise<void>
       })
       customerId = created.id
       state.customerIds.set(row.index, customerId)
+      standIn(state.customerIds, row.customerSameAs, customerId)
     }
   }
 
@@ -409,9 +511,10 @@ async function writeRow(tx: Tx, state: CommitState, row: RowPlan): Promise<void>
           data: { customerId },
         })
       }
-    } else if (row.vehicleSameAs != null) {
+    } else if (row.vehicleSameAs != null && state.vehicleIds.has(row.vehicleSameAs)) {
       vehicleId = state.vehicleIds.get(row.vehicleSameAs) ?? null
     } else if (v.make && v.model && v.year != null) {
+      // Also reached when the row that was to create this vehicle could not be written.
       const created = await tx.vehicle.create({
         data: {
           make: v.make,
@@ -439,6 +542,7 @@ async function writeRow(tx: Tx, state: CommitState, row: RowPlan): Promise<void>
       })
       vehicleId = created.id
       state.vehicleIds.set(row.index, vehicleId)
+      standIn(state.vehicleIds, row.vehicleSameAs, vehicleId)
     }
   }
 
@@ -452,7 +556,7 @@ async function writeRow(tx: Tx, state: CommitState, row: RowPlan): Promise<void>
     await tx.serviceRecord.create({
       data: {
         title: s.title,
-        description: s.description,
+        description: textToHtml(s.description),
         type: 'maintenance',
         status: 'completed',
         cost: total,
@@ -465,7 +569,7 @@ async function writeRow(tx: Tx, state: CommitState, row: RowPlan): Promise<void>
         startDateTime: serviceDate,
         invoiceNumber: s.invoiceNumber,
         invoiceDate: s.invoiceNumber ? new Date(s.date) : null,
-        diagnosticNotes: s.notes,
+        diagnosticNotes: textToHtml(s.notes),
         techName: s.technician,
         // Historical jobs are settled; without this they would show as receivables.
         manuallyPaid: total > 0,
@@ -486,9 +590,9 @@ async function writeRow(tx: Tx, state: CommitState, row: RowPlan): Promise<void>
 
 /**
  * Execute the plan. Rows are written in chunks, each chunk in its own
- * transaction, so one bad row costs its chunk and not the whole file; the
- * result lists exactly which rows failed. Every created row carries the
- * batch id, which is what makes undo possible.
+ * transaction; a chunk that fails is written again a row at a time, so one
+ * bad row costs only itself, and the result lists exactly which rows failed.
+ * Every created row carries the batch id, which is what makes undo possible.
  */
 export async function commitImport(raw: unknown) {
   const parsed = parseRun(raw)
@@ -531,7 +635,7 @@ export async function commitImport(raw: unknown) {
         entity,
         customerIds: new Map(),
         vehicleIds: new Map(),
-        nextCustomerNumber: await nextCustomerNumberStart(organizationId),
+        nextCustomerNumber: await nextCustomerNumberStart(organizationId, plan.rows),
       }
 
       const runnable = plan.rows.filter((r) => r.action === 'create' || r.action === 'update')
@@ -542,8 +646,16 @@ export async function commitImport(raw: unknown) {
       const total = runnable.length
       await writeImportProgress(organizationId, input.token, { phase: 'writing', done: 0, total })
 
+      const count = (row: RowPlan) => {
+        if (row.action === 'create') created++
+        else updated++
+      }
+      // The transaction rolled back, so ids handed out inside it are void.
+      const forget = (row: RowPlan) => forgetRow(state, row)
+
       for (let start = 0; start < runnable.length; start += CHUNK) {
         const chunk = runnable.slice(start, start + CHUNK)
+        const numberBefore = state.nextCustomerNumber
         try {
           await db.$transaction(
             async (tx) => {
@@ -551,22 +663,26 @@ export async function commitImport(raw: unknown) {
             },
             { timeout: 60_000 }
           )
+          chunk.forEach(count)
+        } catch {
+          // One bad row must not cost the rows around it: run the chunk again a
+          // row at a time, so only the row that cannot be written fails.
+          chunk.forEach(forget)
+          state.nextCustomerNumber = numberBefore
           for (const row of chunk) {
-            if (row.action === 'create') created++
-            else updated++
-          }
-        } catch (err) {
-          // The transaction rolled the chunk back, so ids handed out inside it are void.
-          for (const row of chunk) {
-            state.customerIds.delete(row.index)
-            state.vehicleIds.delete(row.index)
-            failures.push({
-              index: row.index,
-              issue: {
-                code: 'write_failed',
-                value: err instanceof Error ? err.message.slice(0, 200) : undefined,
-              },
-            })
+            try {
+              await db.$transaction((tx) => writeRow(tx, state, row), { timeout: 30_000 })
+              count(row)
+            } catch (err) {
+              forget(row)
+              failures.push({
+                index: row.index,
+                issue: {
+                  code: 'write_failed',
+                  value: err instanceof Error ? err.message.slice(0, 200) : undefined,
+                },
+              })
+            }
           }
         }
         done += chunk.length

+ 118 - 22
src/features/import/Components/ImportWizard.tsx

@@ -2,7 +2,7 @@
 
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import { useRouter } from 'next/navigation'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
 import {
   AlertTriangle,
   ArrowLeft,
@@ -11,6 +11,7 @@ import {
   Download,
   FileSpreadsheet,
   FileUp,
+  Info,
   Loader2,
   Sparkles,
   Undo2,
@@ -31,12 +32,11 @@ import {
 import { DocsLink } from '@/components/docs-link'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
+import { SearchableSelect, type SearchableOption } from '@/components/ui/searchable-select'
 import {
   Select,
   SelectContent,
-  SelectGroup,
   SelectItem,
-  SelectLabel,
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
@@ -63,7 +63,7 @@ import {
   undoImportBatch,
 } from '@/features/import/Actions/importActions'
 import type { ImportEntity } from '@/features/import/Lib/fields'
-import type { DateFormat, DecimalSeparator } from '@/features/import/Lib/normalize'
+import { type DateFormat, type DecimalSeparator, parseDate } from '@/features/import/Lib/normalize'
 import type { DuplicateRule, RowAction, RowIssue } from '@/features/import/Lib/pipeline'
 import type { ColumnMapping } from '@/features/import/Lib/suggest'
 import { type AnalyzeResponse, IMPORT_ENTITIES, missingRequired } from '@/features/import/Lib/types'
@@ -103,6 +103,7 @@ export function ImportWizard({
   onImported?: () => void
 }) {
   const t = useTranslations('dataImport')
+  const locale = useLocale()
   const router = useRouter()
   const fileInputRef = useRef<HTMLInputElement>(null)
 
@@ -208,6 +209,13 @@ export function ImportWizard({
   )
 
   const missing = useMemo(() => missingRequired(entity, mapping), [entity, mapping])
+  // A history with nothing mapped to say what was done still imports, under a
+  // stand-in name; most often it means the column was forgotten, so say so.
+  const noJobText =
+    entity === 'services' &&
+    !Object.values(mapping).some(
+      (k) => k === 'service.title' || k === 'service.description' || k === 'service.notes'
+    )
 
   const setColumn = (col: number, key: string) => {
     setMapping((prev) => {
@@ -362,6 +370,50 @@ export function ImportWizard({
     return [...groups.entries()].sort((a, b) => order[a[0]] - order[b[0]])
   }, [analysis, entity])
 
+  // The fields a column can become, under their group, each saying which other
+  // column already has it: picking it here moves it, since a field comes from one column.
+  const fieldOptions = (col: number): SearchableOption[] => {
+    if (!analysis) return []
+    const usedBy = new Map<string, string>()
+    for (const [c, key] of Object.entries(mapping)) {
+      if (c !== String(col)) usedBy.set(key, analysis.columns[Number(c)] ?? '')
+    }
+    return [
+      { value: IGNORE, label: t('mapping.ignore') },
+      ...groupsInOrder.flatMap(([group, fields]) =>
+        fields.map((f) => {
+          const other = usedBy.get(f.key)
+          return {
+            value: f.key,
+            label: fieldLabel(f.key),
+            group: t(`groups.${group}`),
+            hint: other != null ? t('mapping.usedBy', { column: other }) : undefined,
+          }
+        })
+      ),
+    ]
+  }
+
+  // How the first date in the file reads with the chosen format, so a day-first
+  // file read month-first is caught before the dry run, not after the import.
+  const datePreview = useMemo(() => {
+    if (!analysis) return null
+    const dateKeys = analysis.fields.filter((f) => f.type === 'date').map((f) => f.key)
+    const col = dateKeys
+      .map((key) => Object.entries(mapping).find(([, k]) => k === key)?.[0])
+      .find((c) => c != null)
+    if (col == null) return null
+    const raw = analysis.sampleRows.map((r) => r[Number(col)]?.trim()).find(Boolean)
+    if (!raw) return null
+    const date = parseDate(raw, dateFormat)
+    return {
+      raw,
+      text: date
+        ? new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(date)
+        : null,
+    }
+  }, [analysis, mapping, dateFormat, locale])
+
   const sampleFor = (col: number) =>
     (analysis?.sampleRows ?? [])
       .map((r) => r[col])
@@ -548,24 +600,16 @@ export function ImportWizard({
                           ))}
                         </TableCell>
                         <TableCell className="align-top">
-                          <Select value={key ?? IGNORE} onValueChange={(v) => setColumn(col, v)}>
-                            <SelectTrigger className={`h-8 ${key ? '' : 'text-muted-foreground'}`}>
-                              <SelectValue />
-                            </SelectTrigger>
-                            <SelectContent>
-                              <SelectItem value={IGNORE}>{t('mapping.ignore')}</SelectItem>
-                              {groupsInOrder.map(([group, fields]) => (
-                                <SelectGroup key={group}>
-                                  <SelectLabel>{t(`groups.${group}`)}</SelectLabel>
-                                  {fields.map((f) => (
-                                    <SelectItem key={f.key} value={f.key}>
-                                      {fieldLabel(f.key)}
-                                    </SelectItem>
-                                  ))}
-                                </SelectGroup>
-                              ))}
-                            </SelectContent>
-                          </Select>
+                          <SearchableSelect
+                            size="sm"
+                            value={key ?? IGNORE}
+                            onValueChange={(v) => setColumn(col, v)}
+                            options={fieldOptions(col)}
+                            searchPlaceholder={t('mapping.searchFields')}
+                            aria-label={t('mapping.fieldFor', { column: name })}
+                            className={key ? '' : 'text-muted-foreground'}
+                            contentClassName="w-80"
+                          />
                         </TableCell>
                       </TableRow>
                     )
@@ -588,6 +632,15 @@ export function ImportWizard({
                     <SelectItem value="YMD">{t('options.dateYMD')}</SelectItem>
                   </SelectContent>
                 </Select>
+                {datePreview && (
+                  <p
+                    className={`text-[11px] ${datePreview.text ? 'text-muted-foreground' : 'text-destructive'}`}
+                  >
+                    {datePreview.text
+                      ? t('options.dateReadsAs', { value: datePreview.raw, date: datePreview.text })
+                      : t('options.dateUnreadable', { value: datePreview.raw })}
+                  </p>
+                )}
               </div>
               <div className="space-y-1">
                 <Label className="text-xs">{t('options.decimal')}</Label>
@@ -629,6 +682,13 @@ export function ImportWizard({
               </div>
             </div>
 
+            {noJobText && (
+              <div className="flex items-start gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
+                <Info className="mt-0.5 h-4 w-4 shrink-0" />
+                <span>{t('mapping.noJobText')}</span>
+              </div>
+            )}
+
             {missing.length > 0 && (
               <div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-200">
                 <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
@@ -736,6 +796,42 @@ export function ImportWizard({
               </div>
             )}
 
+            {dryRun.issues.length > 0 && (
+              <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm">
+                <div className="flex flex-wrap items-center justify-between gap-2">
+                  <p className="flex items-center gap-2 font-medium">
+                    <AlertTriangle className="h-4 w-4 shrink-0 text-destructive" />
+                    {t('preview.issuesTitle')}
+                  </p>
+                  {filter !== 'error' && (
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      className="h-7 text-xs"
+                      onClick={() => loadPage(0, 'error')}
+                      disabled={busy}
+                    >
+                      {t('preview.showIssueRows')}
+                    </Button>
+                  )}
+                </div>
+                <ul className="mt-1 space-y-0.5">
+                  {dryRun.issues.slice(0, 6).map((issue) => (
+                    <li
+                      key={`${issue.code}|${issue.field ?? ''}`}
+                      className="flex flex-wrap items-baseline justify-between gap-x-3"
+                    >
+                      <span>{issueText(issue)}</span>
+                      <span className="text-xs text-muted-foreground tabular-nums">
+                        {t('preview.issueCount', { count: issue.count, row: issue.firstRow })}
+                      </span>
+                    </li>
+                  ))}
+                </ul>
+                <p className="mt-1 text-xs text-muted-foreground">{t('preview.issuesHint')}</p>
+              </div>
+            )}
+
             <div className="min-h-0 flex-1 overflow-auto rounded-lg border">
               <Table>
                 <TableHeader>

+ 88 - 0
src/features/import/Lib/fields.ts

@@ -629,6 +629,12 @@ export const IMPORT_FIELDS: readonly ImportField[] = [
       'valstybinisnumeris',
       'gosnomer',
       'nomernoyznak',
+      'rego',
+      'regonumber',
+      'regono',
+      'registrationno',
+      'registrationplate',
+      'numberplateno',
     ],
   },
   {
@@ -884,6 +890,14 @@ export const IMPORT_FIELDS: readonly ImportField[] = [
       'datafaktury',
       'tarih',
       'servistarihi',
+      'datecompleted',
+      'completiondate',
+      'dateout',
+      'datein',
+      'workdate',
+      'visitdate',
+      'invdate',
+      'jobcarddate',
     ],
   },
   {
@@ -921,6 +935,15 @@ export const IMPORT_FIELDS: readonly ImportField[] = [
       'baslik',
       'darbas',
       'paslauga',
+      'servicename',
+      'jobsummary',
+      'worksummary',
+      'task',
+      'repair',
+      'complaint',
+      'concern',
+      'customerconcern',
+      'workrequested',
     ],
   },
   {
@@ -948,6 +971,17 @@ export const IMPORT_FIELDS: readonly ImportField[] = [
       'aprasymas',
       'opisanie',
       'descricao',
+      'jobdescription',
+      'servicedescription',
+      'repairdescription',
+      'workcarriedout',
+      'workcompleted',
+      'jobdetails',
+      'workdetails',
+      'servicedetails',
+      'labourdescription',
+      'labordescription',
+      'invoicedescription',
     ],
   },
   {
@@ -990,6 +1024,30 @@ export const IMPORT_FIELDS: readonly ImportField[] = [
       'suma',
       'itogo',
       'valor',
+      'totalincgst',
+      'totalinclgst',
+      'totalinc',
+      'totalincl',
+      'incgst',
+      'inclgst',
+      'amountincgst',
+      'amountinclgst',
+      'totalinctax',
+      'totalincltax',
+      'totalincvat',
+      'totalinclvat',
+      'amountincvat',
+      'amountinclvat',
+      'totalgross',
+      'grosstotal',
+      'grossamount',
+      'invoiceamount',
+      'bruttobetrag',
+      'gesamtbrutto',
+      'totalinklmva',
+      'totalinkmva',
+      'belopinklmva',
+      'suminklmva',
     ],
   },
   {
@@ -1036,6 +1094,25 @@ export const IMPORT_FIELDS: readonly ImportField[] = [
       'faturano',
       'saskaitosnumeris',
       'nomerscheta',
+      'jobnumber',
+      'jobno',
+      'jobid',
+      'jobcard',
+      'jobcardnumber',
+      'jobcardno',
+      'workorderno',
+      'wonumber',
+      'wono',
+      'repairorder',
+      'repairordernumber',
+      'rono',
+      'invno',
+      'invnumber',
+      'invoicenum',
+      'documentnumber',
+      'docnumber',
+      'ordrenummer',
+      'arbeidsordre',
     ],
   },
   {
@@ -1052,6 +1129,11 @@ export const IMPORT_FIELDS: readonly ImportField[] = [
       'technicalnotes',
       'diagnosticnotes',
       'workordernotes',
+      'jobcomments',
+      'workshopnotes',
+      'recommendations',
+      'recommendation',
+      'advisory',
     ],
   },
   {
@@ -1077,6 +1159,12 @@ export const IMPORT_FIELDS: readonly ImportField[] = [
       'teknisyen',
       'mechanikas',
       'mekhanik',
+      'technicianname',
+      'mechanicname',
+      'techname',
+      'servicedby',
+      'performedby',
+      'monteurname',
     ],
   },
 ]

+ 66 - 10
src/features/import/Lib/normalize.ts

@@ -41,6 +41,29 @@ export function cleanText(value: string | null | undefined): string | null {
   return trimmed.length ? trimmed : null
 }
 
+/**
+ * Like `cleanText`, but keeps the line breaks: what was done on a job is often
+ * a list, one item per line, and run together it reads as one long sentence.
+ */
+export function cleanMultiline(value: string | null | undefined): string | null {
+  if (value == null) return null
+  const text = String(value)
+    .replace(/_x000D_/gi, '')
+    .replace(/\r\n?/g, '\n')
+    .split('\n')
+    .map((line) => line.replace(/\s+/g, ' ').trim())
+    .join('\n')
+    .replace(/\n{3,}/g, '\n\n')
+    .trim()
+  return text.length ? text : null
+}
+
+/** The first line of a text, for a title made from a description. */
+export function firstLine(text: string, max = 80): string {
+  const line = text.split('\n').find((l) => l.trim()) ?? ''
+  return line.length > max ? `${line.slice(0, max - 3).trimEnd()}...` : line
+}
+
 export function normalizeEmail(value: string | null | undefined): {
   value: string | null
   valid: boolean
@@ -161,6 +184,8 @@ export type DateFormat = 'auto' | 'DMY' | 'MDY' | 'YMD'
 const MONTHS: Record<string, number> = {
   jan: 1,
   january: 1,
+  januari: 1,
+  janner: 1,
   januar: 1,
   janvier: 1,
   enero: 1,
@@ -176,6 +201,7 @@ const MONTHS: Record<string, number> = {
   yanvar: 1,
   feb: 2,
   february: 2,
+  februari: 2,
   februar: 2,
   fevrier: 2,
   fev: 2,
@@ -190,6 +216,8 @@ const MONTHS: Record<string, number> = {
   fevral: 2,
   mar: 3,
   march: 3,
+  maart: 3,
+  mrt: 3,
   marz: 3,
   maerz: 3,
   mars: 3,
@@ -214,6 +242,7 @@ const MONTHS: Record<string, number> = {
   aprel: 4,
   may: 5,
   mai: 5,
+  mei: 5,
   mayo: 5,
   maggio: 5,
   mag: 5,
@@ -252,6 +281,8 @@ const MONTHS: Record<string, number> = {
   iyul: 7,
   aug: 8,
   august: 8,
+  augusti: 8,
+  augustus: 8,
   aout: 8,
   agosto: 8,
   ago: 8,
@@ -304,6 +335,8 @@ const MONTHS: Record<string, number> = {
   noyabr: 11,
   dec: 12,
   december: 12,
+  des: 12,
+  desember: 12,
   dezember: 12,
   dez: 12,
   decembre: 12,
@@ -319,6 +352,31 @@ const MONTHS: Record<string, number> = {
   dekabr: 12,
 }
 
+/**
+ * A time after the date, which the import drops however it is written: "14:30",
+ * "09:00:00", "0:0:0" from systems that do not pad, "2:15 PM", "T10:00:00.000Z".
+ * A date never holds a colon, so everything from the first h:m on is the time.
+ */
+const TIME_PART = /(?:T|\s+)\d{1,2}:\d{1,2}.*$/
+
+/** Lowercase without accents, so "März" finds "marz". */
+function foldAccents(text: string): string {
+  return text
+    .normalize('NFD')
+    .replace(/[\u0300-\u036f]/g, '')
+    .toLowerCase()
+}
+
+/**
+ * A cell with its time and any weekday in front dropped: "Wed 10 Dec 2014" and
+ * "Mon, 10/12/2014 0:0:0" say nothing the date itself does not.
+ */
+function dateOnly(value: string): string {
+  const s = value.trim().replace(TIME_PART, '').trim()
+  const weekday = s.match(/^(\p{L}+)\.?,?\s+(?=[\p{L}\d])/u)
+  return weekday && !MONTHS[foldAccents(weekday[1])] ? s.slice(weekday[0].length) : s
+}
+
 function utcDate(y: number, m: number, d: number): Date | null {
   if (m < 1 || m > 12 || d < 1 || d > 31) return null
   if (y < 1900 || y > 2100) return null
@@ -369,10 +427,8 @@ export function parseDate(
   }
   if (typeof value === 'number') return fromExcelSerial(value)
 
-  let s = String(value).trim()
+  const s = dateOnly(String(value))
   if (!s) return null
-  // Drop a time part: "2024-03-15 14:30", "15/03/2024 09:00:00", ISO "T".
-  s = s.replace(/[T ]\d{1,2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/i, '').trim()
 
   if (/^\d{5}(\.\d+)?$/.test(s)) return fromExcelSerial(Number(s))
 
@@ -382,11 +438,13 @@ export function parseDate(
   m = s.match(/^(\d{4})(\d{2})(\d{2})$/)
   if (m) return utcDate(Number(m[1]), Number(m[2]), Number(m[3]))
 
-  // Month names: "15 Mar 2024", "Mar 15, 2024", "15. März 2024", "March 2024" is not a day.
-  const lower = s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
-  m = lower.match(/^(\d{1,2})\.?\s*([a-z]+)\.?,?\s*(\d{2,4})$/)
+  // Month names: "15 Mar 2024", "15-Mar-24", "Mar 15th, 2024", "15. März 2024",
+  // "10 de diciembre de 2014", "Dec 10 2014". "March 2024" is not a day.
+  const lower = foldAccents(s).replace(/\s+de\s+/g, ' ')
+  m = lower.match(/^(\d{1,2})(?:st|nd|rd|th)?\.?[\s/-]*([a-z]+)\.?,?[\s/-]*(\d{2,4})$/)
   if (m && MONTHS[m[2]]) return utcDate(fullYear(Number(m[3]), m[3]), MONTHS[m[2]], Number(m[1]))
-  m = lower.match(/^([a-z]+)\.?\s*(\d{1,2}),?\s*(\d{2,4})$/)
+  // The day and the year need something between them, or "Dec 2014" reads as 20 December 2014.
+  m = lower.match(/^([a-z]+)\.?[\s/-]*(\d{1,2})(?:st|nd|rd|th)?(?:,\s*|[\s/-]+)(\d{2,4})$/)
   if (m && MONTHS[m[1]]) return utcDate(fullYear(Number(m[3]), m[3]), MONTHS[m[1]], Number(m[2]))
 
   const p = numericParts(s)
@@ -422,9 +480,7 @@ export function detectDateFormat(values: readonly (string | null | undefined)[])
   let yearFirst = 0
   for (const v of values) {
     if (!v) continue
-    const s = String(v)
-      .trim()
-      .replace(/[T ]\d{1,2}:\d{2}.*$/, '')
+    const s = dateOnly(String(v))
     if (/^\d{4}[./-]\d{1,2}[./-]\d{1,2}$/.test(s)) {
       yearFirst++
       continue

+ 47 - 25
src/features/import/Lib/pipeline.ts

@@ -12,7 +12,9 @@ import type { ImportEntity } from './fields'
 import {
   type DateFormat,
   type DecimalSeparator,
+  cleanMultiline,
   cleanText,
+  firstLine,
   joinAddress,
   joinName,
   mapFuelType,
@@ -40,6 +42,12 @@ export interface ImportOptions {
   decimalSeparator: DecimalSeparator
   defaultCountryCode: string | null
   duplicates: DuplicateRule
+  /**
+   * What a job with no title, description or notes in the file is called, in
+   * the workshop's language. Set by the server, never by the browser; without
+   * it such a row cannot be imported.
+   */
+  untitledServiceTitle?: string
 }
 
 export interface CustomerDraft {
@@ -96,8 +104,10 @@ export type RowIssueCode =
   | 'service_date_required'
   | 'service_date_invalid'
   | 'service_title_required'
+  | 'service_title_placeholder'
   | 'invalid_date'
   | 'invalid_number'
+  | 'invalid_mileage'
   | 'duplicate_invoice_number'
   | 'write_failed'
 
@@ -190,6 +200,29 @@ export type RowOverrides = Record<string, RowAction>
 
 // ── Extraction ────────────────────────────────────────────────────────────────
 
+/**
+ * The highest odometer reading taken as real. Anything above is a phone number
+ * or an id in the wrong column, and the database could not store it anyway.
+ */
+const MAX_MILEAGE = 9_999_999
+
+/** An odometer reading, or null with a warning when it cannot be one. */
+function readMileage(
+  v: Values,
+  field: 'vehicle.mileage' | 'service.mileage',
+  options: ImportOptions,
+  warnings: RowIssue[]
+): number | null {
+  const raw = v[field]
+  if (!raw) return null
+  const n = parseInteger(raw, options.decimalSeparator)
+  if (n == null || n > MAX_MILEAGE) {
+    warnings.push({ code: n == null ? 'invalid_number' : 'invalid_mileage', field, value: raw })
+    return null
+  }
+  return n < 0 ? null : n
+}
+
 type Values = Record<string, string>
 
 /** Pick the mapped cells of one row, keyed by field. */
@@ -278,17 +311,7 @@ function extractVehicle(
         value: v['vehicle.purchaseDate'],
       })
   }
-  let mileage: number | null = null
-  if (v['vehicle.mileage']) {
-    mileage = parseInteger(v['vehicle.mileage'], options.decimalSeparator)
-    if (mileage == null)
-      warnings.push({
-        code: 'invalid_number',
-        field: 'vehicle.mileage',
-        value: v['vehicle.mileage'],
-      })
-    else if (mileage < 0) mileage = null
-  }
+  const mileage = readMileage(v, 'vehicle.mileage', options, warnings)
   let purchasePrice: number | null = null
   if (v['vehicle.purchasePrice']) {
     purchasePrice = parseNumber(v['vehicle.purchasePrice'], options.decimalSeparator)
@@ -340,20 +363,19 @@ function extractService(
     if (total == null)
       warnings.push({ code: 'invalid_number', field: 'service.total', value: v['service.total'] })
   }
-  let mileage: number | null = null
-  if (v['service.mileage']) {
-    mileage = parseInteger(v['service.mileage'], options.decimalSeparator)
-    if (mileage == null)
-      warnings.push({
-        code: 'invalid_number',
-        field: 'service.mileage',
-        value: v['service.mileage'],
-      })
+  const mileage = readMileage(v, 'service.mileage', options, warnings)
+  const description = cleanMultiline(v['service.description'])
+  const notes = cleanMultiline(v['service.notes'])
+  // A job needs a name. Without a title it is the first line of what was done,
+  // then the first line of the notes. A job with no words at all still has its
+  // date, car and total, which is the history the workshop is bringing over, so
+  // it comes in under a stand-in name and the preview says so.
+  const fallback = description ?? notes
+  let title = cleanText(v['service.title']) ?? (fallback ? firstLine(fallback) : null)
+  if (!title && options.untitledServiceTitle) {
+    title = options.untitledServiceTitle
+    warnings.push({ code: 'service_title_placeholder', field: 'service.title', value: title })
   }
-  const description = cleanText(v['service.description'])
-  let title = cleanText(v['service.title'])
-  if (!title && description)
-    title = description.length > 80 ? `${description.slice(0, 77)}...` : description
   if (!title) errors.push({ code: 'service_title_required', field: 'service.title' })
 
   return {
@@ -363,7 +385,7 @@ function extractService(
     total,
     mileage,
     invoiceNumber: cleanText(v['service.invoiceNumber']),
-    notes: cleanText(v['service.notes']),
+    notes,
     technician: cleanText(v['service.technician']),
   }
 }

+ 7 - 1
src/features/import/Lib/suggest.ts

@@ -45,7 +45,12 @@ const ENTITY_OVERRIDES: Partial<Record<ImportEntity, Record<string, string>>> =
     remarks: 'service.notes',
     mileage: 'service.mileage',
     odometer: 'service.mileage',
+    odo: 'service.mileage',
+    odometerreading: 'service.mileage',
     km: 'service.mileage',
+    kms: 'service.mileage',
+    mileagein: 'service.mileage',
+    odometerin: 'service.mileage',
     kilometers: 'service.mileage',
     miles: 'service.mileage',
     kilometerstand: 'service.mileage',
@@ -145,7 +150,8 @@ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
 const VIN = /^[A-HJ-NPR-Z0-9]{17}$/i
 const YEAR = /^(19|20)\d{2}$/
 const PHONE = /^\+?[\d\s().-]{7,20}$/
-const DATE = /^(\d{1,4}[./-]\d{1,2}[./-]\d{1,4})([T ]\d{1,2}:\d{2}.*)?$/
+// A time may follow, padded or not: "2014-12-10 0:0:0", "10/12/2014 2:15 PM".
+const DATE = /^(\d{1,4}[./-]\d{1,2}[./-]\d{1,4})((?:T|\s+)\d{1,2}:\d{1,2}.*)?$/
 const PLATE = /^[A-Z]{1,3}[\s-]?\d{2,5}[\s-]?[A-Z]{0,3}$/i
 
 export function suggestMapping(

+ 2 - 1
src/features/import/Lib/types.ts

@@ -47,8 +47,9 @@ export function missingRequired(entity: ImportEntity, mapping: ColumnMapping): s
     if (!has('vehicle.year', 'vehicle.description')) missing.push('vehicle.year')
   }
   if (entity === 'services') {
+    // No title column is needed: a job takes its name from the description or
+    // notes, or a stand-in, and the mapping step says when that will happen.
     if (!has('service.date')) missing.push('service.date')
-    if (!has('service.title', 'service.description')) missing.push('service.title')
     if (!has('vehicle.licensePlate', 'vehicle.vin', 'vehicle.make', 'vehicle.description'))
       missing.push('vehicle.licensePlate')
   }

+ 39 - 57
src/features/inspections/Components/InspectionCertificateCard.tsx

@@ -8,14 +8,7 @@ import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { DateInput } from '@/components/ui/date-input'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectSeparator,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { FileCheck2, Loader2, RotateCcw, UserPlus } from 'lucide-react'
 import { useTranslations } from 'next-intl'
 import { toast } from 'sonner'
@@ -208,41 +201,36 @@ export function InspectionCertificateCard({
       <div className="grid gap-4 p-4 sm:grid-cols-2">
         <div className="space-y-1.5">
           <Label htmlFor={`${fieldId}-inspector`}>{t('inspector')}</Label>
-          <Select
+          <SearchableSelect
+            id={`${fieldId}-inspector`}
             value={technicianId}
             onValueChange={handleTechnicianChange}
             disabled={isCompleted}
-          >
-            <SelectTrigger id={`${fieldId}-inspector`}>
-              <SelectValue placeholder={t('notRecorded')} />
-            </SelectTrigger>
-            <SelectContent>
-              <SelectItem value={NO_TECHNICIAN}>{t('notRecorded')}</SelectItem>
-              {roster.map((tech) => (
-                <SelectItem key={tech.id} value={tech.id}>
-                  <span className="flex items-center gap-2">
-                    <span
-                      className="h-2.5 w-2.5 shrink-0 rounded-full"
-                      style={{ backgroundColor: tech.color }}
-                      aria-hidden="true"
-                    />
-                    {tech.name}
-                  </span>
-                </SelectItem>
-              ))}
-              {!isCompleted && (
-                <>
-                  <SelectSeparator />
-                  <SelectItem value={ADD_TECHNICIAN}>
-                    <span className="flex items-center gap-2">
-                      <UserPlus className="h-3.5 w-3.5" aria-hidden="true" />
-                      {t('addTechnician')}
-                    </span>
-                  </SelectItem>
-                </>
-              )}
-            </SelectContent>
-          </Select>
+            placeholder={t('notRecorded')}
+            options={[
+              { value: NO_TECHNICIAN, label: t('notRecorded') },
+              ...roster.map((tech) => ({
+                value: tech.id,
+                label: tech.name,
+                icon: (
+                  <span
+                    className="h-2.5 w-2.5 shrink-0 rounded-full"
+                    style={{ backgroundColor: tech.color }}
+                    aria-hidden="true"
+                  />
+                ),
+              })),
+              ...(isCompleted
+                ? []
+                : [
+                    {
+                      value: ADD_TECHNICIAN,
+                      label: t('addTechnician'),
+                      icon: <UserPlus className="h-3.5 w-3.5" aria-hidden="true" />,
+                    },
+                  ]),
+            ]}
+          />
           {/* An inspection carried out before the roster existed still has a
               name on it; say so rather than showing an empty field. */}
           {technicianId === NO_TECHNICIAN && inspection.inspectorName && (
@@ -254,29 +242,23 @@ export function InspectionCertificateCard({
 
         <div className="space-y-1.5">
           <Label htmlFor={`${fieldId}-category`}>{t('vehicleCategory')}</Label>
-          <Select
+          <SearchableSelect
+            id={`${fieldId}-category`}
             value={vehicleCategory}
             onValueChange={(value) => {
               setVehicleCategory(value)
               commit({ vehicleCategory: value === NO_TECHNICIAN ? null : value })
             }}
             disabled={isCompleted}
-          >
-            <SelectTrigger id={`${fieldId}-category`}>
-              <SelectValue placeholder={t('notRecorded')} />
-            </SelectTrigger>
-            <SelectContent>
-              <SelectItem value={NO_TECHNICIAN}>{t('notRecorded')}</SelectItem>
-              {VEHICLE_CATEGORIES.map((code) => (
-                <SelectItem key={code} value={code}>
-                  <span className="flex gap-2">
-                    <span className="font-medium">{code}</span>
-                    <span className="text-muted-foreground">{t(`categories.${code}`)}</span>
-                  </span>
-                </SelectItem>
-              ))}
-            </SelectContent>
-          </Select>
+            placeholder={t('notRecorded')}
+            options={[
+              { value: NO_TECHNICIAN, label: t('notRecorded') },
+              ...VEHICLE_CATEGORIES.map((code) => ({
+                value: code,
+                label: `${code} ${t(`categories.${code}`)}`,
+              })),
+            ]}
+          />
         </div>
 
         <div className="space-y-1.5">

+ 9 - 21
src/features/inspections/Components/InspectionItemRow.tsx

@@ -4,13 +4,7 @@ import { useEffect, useId, useRef, useState, useTransition } from 'react'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Textarea } from '@/components/ui/textarea'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import {
   AlertDialog,
   AlertDialogAction,
@@ -640,25 +634,19 @@ export function InspectionItemRow({
           <label htmlFor={`${fieldId}-choice`} className="text-muted-foreground text-xs">
             {t('answer')}
           </label>
-          <Select
-            value={textValue || undefined}
+          <SearchableSelect
+            id={`${fieldId}-choice`}
+            size="sm"
+            value={textValue}
             disabled={isCompleted}
             onValueChange={(v) => {
               setTextValue(v)
               save({ textValue: v })
             }}
-          >
-            <SelectTrigger id={`${fieldId}-choice`} className="h-8 w-full text-sm sm:w-56">
-              <SelectValue placeholder={t('selectAnswer')} />
-            </SelectTrigger>
-            <SelectContent>
-              {item.choices?.map((choice) => (
-                <SelectItem key={choice} value={choice}>
-                  {choice}
-                </SelectItem>
-              ))}
-            </SelectContent>
-          </Select>
+            placeholder={t('selectAnswer')}
+            className="w-full sm:w-56"
+            options={(item.choices ?? []).map((choice) => ({ value: choice, label: choice }))}
+          />
         </div>
       )}
 

+ 10 - 20
src/features/inspections/Components/NewInspectionDialog.tsx

@@ -8,13 +8,7 @@ import { DocsLink } from '@/components/docs-link'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { Loader2, Settings } from 'lucide-react'
 import Link from 'next/link'
 import { toast } from 'sonner'
@@ -149,19 +143,15 @@ export function NewInspectionDialog({
                 {t('manageTemplates')}
               </Link>
             </div>
-            <Select value={templateId} onValueChange={setTemplateId} required>
-              <SelectTrigger>
-                <SelectValue placeholder={t('selectTemplate')} />
-              </SelectTrigger>
-              <SelectContent>
-                {templates.map((t) => (
-                  <SelectItem key={t.id} value={t.id}>
-                    {t.name}
-                    {t.isDefault ? ' (Default)' : ''}
-                  </SelectItem>
-                ))}
-              </SelectContent>
-            </Select>
+            <SearchableSelect
+              value={templateId}
+              onValueChange={setTemplateId}
+              placeholder={t('selectTemplate')}
+              options={templates.map((template) => ({
+                value: template.id,
+                label: `${template.name}${template.isDefault ? ' (Default)' : ''}`,
+              }))}
+            />
           </div>
 
           <div className="space-y-2">

+ 14 - 13
src/features/inspections/Components/TemplateForm.tsx

@@ -40,6 +40,7 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import {
   ChevronDown,
   Copy,
@@ -818,19 +819,19 @@ export function TemplateForm({
 
               <div className="space-y-2">
                 <Label htmlFor="template-country">{t('country')}</Label>
-                <Select value={country} onValueChange={setCountry}>
-                  <SelectTrigger id="template-country">
-                    <SelectValue />
-                  </SelectTrigger>
-                  <SelectContent>
-                    <SelectItem value="none">{t('notCountrySpecific')}</SelectItem>
-                    {templateCountries(locale).map((c) => (
-                      <SelectItem key={c.code} value={c.code}>
-                        {c.name}
-                      </SelectItem>
-                    ))}
-                  </SelectContent>
-                </Select>
+                <SearchableSelect
+                  id="template-country"
+                  value={country}
+                  onValueChange={setCountry}
+                  options={[
+                    { value: 'none', label: t('notCountrySpecific') },
+                    ...templateCountries(locale).map((c) => ({
+                      value: c.code,
+                      label: c.name,
+                      keywords: c.code,
+                    })),
+                  ]}
+                />
                 <p className="text-muted-foreground text-xs">{t('countryHelp')}</p>
               </div>
 

+ 3 - 17
src/features/onboarding/Components/setup/SearchPicker.tsx

@@ -12,6 +12,7 @@ import {
   CommandList,
 } from '@/components/ui/command'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { pickerScore } from '@/components/ui/searchable-select'
 import { cn } from '@/lib/utils'
 
 export interface PickerOption {
@@ -21,23 +22,8 @@ export interface PickerOption {
   keywords?: string
 }
 
-/**
- * Ranks an option for what is typed. Whole words first, so "UK" finds the
- * United Kingdom before Ukraine; then names starting with it, words starting
- * with it, and anything containing it. No fuzzy matching: "UK" matching
- * Uzbekistan letter by letter only buries the answer.
- */
-export function pickerScore(value: string, search: string): number {
-  const q = search.trim().toLowerCase()
-  if (!q) return 1
-  const text = value.toLowerCase()
-  const words = text.split(/[\s,()/-]+/)
-  if (words.includes(q)) return 1
-  if (text.startsWith(q)) return 0.9
-  if (words.some((w) => w.startsWith(q))) return 0.7
-  if (text.includes(q)) return 0.4
-  return 0
-}
+// Shared with the app's dropdown; still exported here for the code that imports it from here.
+export { pickerScore }
 
 /** A searchable list behind a button, for long lists: countries, currencies, zones. */
 export function SearchPicker({

+ 18 - 28
src/features/portal/Components/ServiceRequestForm.tsx

@@ -9,14 +9,7 @@ import { Label } from '@/components/ui/label'
 import { Textarea } from '@/components/ui/textarea'
 import { Input } from '@/components/ui/input'
 import { AppCard } from '@/components/app-card'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectSeparator,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { Loader2, Plus, Wrench } from 'lucide-react'
 import { createServiceRequest } from '@/features/portal/Actions/portalActions'
 
@@ -99,26 +92,23 @@ export function ServiceRequestForm({ orgId, vehicles }: { orgId: string; vehicle
       <form onSubmit={handleSubmit} className="space-y-4">
         <div className="space-y-2">
           <Label htmlFor="vehicle">{t('vehicleLabel')}</Label>
-          <Select value={vehicleId} onValueChange={setVehicleId}>
-            <SelectTrigger id="vehicle">
-              <SelectValue placeholder={t('vehiclePlaceholder')} />
-            </SelectTrigger>
-            <SelectContent>
-              {vehicles.map((v) => (
-                <SelectItem key={v.id} value={v.id}>
-                  {v.year} {v.make} {v.model}
-                  {v.licensePlate ? ` (${v.licensePlate})` : ''}
-                </SelectItem>
-              ))}
-              {vehicles.length > 0 && <SelectSeparator />}
-              <SelectItem value={NEW_VEHICLE}>
-                <span className="flex items-center gap-1.5">
-                  <Plus className="h-3.5 w-3.5" />
-                  {t('notListedOption')}
-                </span>
-              </SelectItem>
-            </SelectContent>
-          </Select>
+          <SearchableSelect
+            id="vehicle"
+            value={vehicleId}
+            onValueChange={setVehicleId}
+            placeholder={t('vehiclePlaceholder')}
+            options={[
+              ...vehicles.map((v) => ({
+                value: v.id,
+                label: `${v.year} ${v.make} ${v.model}${v.licensePlate ? ` (${v.licensePlate})` : ''}`,
+              })),
+              {
+                value: NEW_VEHICLE,
+                label: t('notListedOption'),
+                icon: <Plus className="h-3.5 w-3.5 shrink-0" aria-hidden />,
+              },
+            ]}
+          />
         </div>
 
         {isNewVehicle && (

+ 8 - 20
src/features/quotes/Components/QuotePageClient.tsx

@@ -23,13 +23,7 @@ import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
 import { ButtonGroup } from '@/components/ui/button-group'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'
 import { sendQuoteEmail } from '@/features/email/Actions/emailActions'
 import { SendEmailDialog } from '@/features/email/Components/SendEmailDialog'
@@ -687,21 +681,15 @@ export function QuotePageClient({
                         )}
                       {state.convertMode === 'existing' && hasConvertTargets && (
                         <>
-                          <Select
+                          <SearchableSelect
                             value={state.convertTargetId}
                             onValueChange={state.setConvertTargetId}
-                          >
-                            <SelectTrigger className="w-full">
-                              <SelectValue placeholder={t('page.convertSelectWorkOrder')} />
-                            </SelectTrigger>
-                            <SelectContent>
-                              {state.convertTargets.map((target) => (
-                                <SelectItem key={target.id} value={target.id}>
-                                  {convertTargetLabel(target)}
-                                </SelectItem>
-                              ))}
-                            </SelectContent>
-                          </Select>
+                            placeholder={t('page.convertSelectWorkOrder')}
+                            options={state.convertTargets.map((target) => ({
+                              value: target.id,
+                              label: convertTargetLabel(target),
+                            }))}
+                          />
                           <p className="text-xs text-muted-foreground">
                             {t('page.convertExistingHint')}
                           </p>

+ 18 - 25
src/features/time-tracking/Components/TimeEntryDialog.tsx

@@ -16,13 +16,7 @@ import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Textarea } from '@/components/ui/textarea'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 import {
   Command,
@@ -180,24 +174,23 @@ export function TimeEntryDialog({
             {editing ? (
               <Input id="te-technician" value={entry?.technicianName ?? ''} disabled />
             ) : (
-              <Select value={technicianId} onValueChange={setTechnicianId}>
-                <SelectTrigger id="te-technician" className="w-full">
-                  <SelectValue placeholder={t('pickTechnician')} />
-                </SelectTrigger>
-                <SelectContent>
-                  {technicians.map((tech) => (
-                    <SelectItem key={tech.id} value={tech.id}>
-                      <span className="flex items-center gap-2">
-                        <span
-                          className="size-2 rounded-full"
-                          style={{ backgroundColor: tech.color }}
-                        />
-                        {tech.name}
-                      </span>
-                    </SelectItem>
-                  ))}
-                </SelectContent>
-              </Select>
+              <SearchableSelect
+                id="te-technician"
+                value={technicianId}
+                onValueChange={setTechnicianId}
+                placeholder={t('pickTechnician')}
+                options={technicians.map((tech) => ({
+                  value: tech.id,
+                  label: tech.name,
+                  icon: (
+                    <span
+                      className="size-2 shrink-0 rounded-full"
+                      style={{ backgroundColor: tech.color }}
+                      aria-hidden
+                    />
+                  ),
+                }))}
+              />
             )}
           </div>
 

+ 10 - 21
src/features/tire-hotel/Components/OwnerVehicleFields.tsx

@@ -3,13 +3,7 @@
 import { useTranslations } from 'next-intl'
 import { Button } from '@/components/ui/button'
 import { Label } from '@/components/ui/label'
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { CustomerCombobox } from '@/features/quotes/Components/CustomerCombobox'
 import { X } from 'lucide-react'
 
@@ -127,23 +121,18 @@ export function OwnerVehicleFields({
       <div className="space-y-2">
         <Label htmlFor={`${idPrefix}Vehicle`}>{t('checkIn.vehicle')}</Label>
         <div className="flex items-center gap-1.5">
-          <Select
+          <SearchableSelect
+            id={`${idPrefix}Vehicle`}
             value={vehicleId}
             onValueChange={handleVehicle}
             disabled={visibleVehicles.length === 0}
-          >
-            <SelectTrigger id={`${idPrefix}Vehicle`} className="min-w-0 flex-1">
-              <SelectValue placeholder={t('checkIn.selectVehicle')} />
-            </SelectTrigger>
-            <SelectContent>
-              {visibleVehicles.map((vehicle) => (
-                <SelectItem key={vehicle.id} value={vehicle.id}>
-                  {vehicle.licensePlate ? `${vehicle.licensePlate} - ` : ''}
-                  {vehicle.year} {vehicle.make} {vehicle.model}
-                </SelectItem>
-              ))}
-            </SelectContent>
-          </Select>
+            placeholder={t('checkIn.selectVehicle')}
+            className="min-w-0 flex-1"
+            options={visibleVehicles.map((vehicle) => ({
+              value: vehicle.id,
+              label: `${vehicle.licensePlate ? `${vehicle.licensePlate} - ` : ''}${vehicle.year} ${vehicle.make} ${vehicle.model}`,
+            }))}
+          />
           {/* The way back to another customer, since choosing a car narrows
               the list to that customer's cars. */}
           {vehicleId && (

+ 21 - 22
src/features/vehicles/Components/service-edit/InvoiceDetailsSection.tsx

@@ -14,6 +14,7 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
+import { SearchableSelect } from '@/components/ui/searchable-select'
 import { Check, Loader2 } from 'lucide-react'
 import { useRouter } from 'next/navigation'
 import { toast } from 'sonner'
@@ -227,28 +228,26 @@ export function InvoiceDetailsSection({
       {designOptions.length > 0 && (
         <div className="space-y-1">
           <Label className="text-xs">{t('design')}</Label>
-          <Select value={design} onValueChange={(v) => void changeDesign(v)}>
-            <SelectTrigger className="w-full" disabled={savingDesign}>
-              <SelectValue />
-            </SelectTrigger>
-            <SelectContent>
-              <SelectItem value={FOLLOW_DEFAULT}>
-                {designFollowsName && designFollowsRule
-                  ? t('designFollowingRule', {
-                      name: designFollowsName,
-                      rule: t(`designRule.${designFollowsRule}`),
-                    })
-                  : designFollowsName
-                    ? t('designFollowing', { name: designFollowsName })
-                    : t('designDefault')}
-              </SelectItem>
-              {designOptions.map((option) => (
-                <SelectItem key={option.id} value={option.id}>
-                  {option.name}
-                </SelectItem>
-              ))}
-            </SelectContent>
-          </Select>
+          <SearchableSelect
+            value={design}
+            onValueChange={(v) => void changeDesign(v)}
+            disabled={savingDesign}
+            options={[
+              {
+                value: FOLLOW_DEFAULT,
+                label:
+                  designFollowsName && designFollowsRule
+                    ? t('designFollowingRule', {
+                        name: designFollowsName,
+                        rule: t(`designRule.${designFollowsRule}`),
+                      })
+                    : designFollowsName
+                      ? t('designFollowing', { name: designFollowsName })
+                      : t('designDefault'),
+              },
+              ...designOptions.map((option) => ({ value: option.id, label: option.name })),
+            ]}
+          />
         </div>
       )}
     </>