소스 검색

Universal spreadsheet importer with dry run, undo and templates (#299)

* Universal spreadsheet importer with dry run, undo, and templates

Replace the customer-only CSV dialog with an import wizard for customers,
vehicles and service history from CSV, Excel and vCard files. Columns are
mapped from multilingual header synonyms and sample values, with optional
AI suggestions; a dry run shows every row's outcome against live data
before anything is written; imports run in chunked transactions stamped
with an ImportBatch id so they can be undone from Settings → Data.

Also: downloadable templates, Google Contacts and Outlook presets, phone,
date, decimal, VIN, plate, fuel and transmission normalisation, and the
import step on the getting-started card. All twelve locales translated.

Claude-Session: https://claude.ai/code/session_01GMVLMKzNAyR4pAyDYaenXD

* Data page: one import card with a row per source, generic template examples

- Spreadsheet, LubeLog, Invoice Ninja and backup restore are four rows in
  one Import card instead of four cards; the destructive restore moved
  behind a dialog with its warning
- Export options in a three-column grid on a full-width card
- The downloadable template uses placeholder names, addresses, a reserved
  example email and a fictional phone number instead of plausible real ones
Bernt Christian Egeland 4 주 전
부모
커밋
7c46847eca
100개의 변경된 파일과 9832개의 추가작업 그리고 1479개의 파일을 삭제
  1. 4 0
      messages/de/audit.json
  2. 0 39
      messages/de/customers.json
  3. 228 0
      messages/de/dataImport.json
  4. 3 1
      messages/de/onboarding.json
  5. 11 2
      messages/de/settings.json
  6. 1 0
      messages/de/vehicles.json
  7. 4 0
      messages/en/audit.json
  8. 0 39
      messages/en/customers.json
  9. 228 0
      messages/en/dataImport.json
  10. 3 1
      messages/en/onboarding.json
  11. 11 2
      messages/en/settings.json
  12. 1 0
      messages/en/vehicles.json
  13. 4 0
      messages/es/audit.json
  14. 0 39
      messages/es/customers.json
  15. 228 0
      messages/es/dataImport.json
  16. 3 1
      messages/es/onboarding.json
  17. 11 2
      messages/es/settings.json
  18. 1 0
      messages/es/vehicles.json
  19. 4 0
      messages/fr/audit.json
  20. 0 39
      messages/fr/customers.json
  21. 228 0
      messages/fr/dataImport.json
  22. 3 1
      messages/fr/onboarding.json
  23. 11 2
      messages/fr/settings.json
  24. 1 0
      messages/fr/vehicles.json
  25. 4 0
      messages/it/audit.json
  26. 0 39
      messages/it/customers.json
  27. 228 0
      messages/it/dataImport.json
  28. 3 1
      messages/it/onboarding.json
  29. 11 2
      messages/it/settings.json
  30. 1 0
      messages/it/vehicles.json
  31. 4 0
      messages/lt/audit.json
  32. 0 39
      messages/lt/customers.json
  33. 228 0
      messages/lt/dataImport.json
  34. 3 1
      messages/lt/onboarding.json
  35. 11 2
      messages/lt/settings.json
  36. 1 0
      messages/lt/vehicles.json
  37. 4 0
      messages/nb/audit.json
  38. 0 39
      messages/nb/customers.json
  39. 228 0
      messages/nb/dataImport.json
  40. 3 1
      messages/nb/onboarding.json
  41. 11 2
      messages/nb/settings.json
  42. 1 0
      messages/nb/vehicles.json
  43. 4 0
      messages/nl/audit.json
  44. 0 39
      messages/nl/customers.json
  45. 228 0
      messages/nl/dataImport.json
  46. 3 1
      messages/nl/onboarding.json
  47. 11 2
      messages/nl/settings.json
  48. 1 0
      messages/nl/vehicles.json
  49. 4 0
      messages/pl/audit.json
  50. 0 39
      messages/pl/customers.json
  51. 228 0
      messages/pl/dataImport.json
  52. 3 1
      messages/pl/onboarding.json
  53. 11 2
      messages/pl/settings.json
  54. 1 0
      messages/pl/vehicles.json
  55. 4 0
      messages/pt-BR/audit.json
  56. 0 39
      messages/pt-BR/customers.json
  57. 228 0
      messages/pt-BR/dataImport.json
  58. 3 1
      messages/pt-BR/onboarding.json
  59. 11 2
      messages/pt-BR/settings.json
  60. 1 0
      messages/pt-BR/vehicles.json
  61. 4 0
      messages/ru/audit.json
  62. 0 39
      messages/ru/customers.json
  63. 228 0
      messages/ru/dataImport.json
  64. 3 1
      messages/ru/onboarding.json
  65. 11 2
      messages/ru/settings.json
  66. 1 0
      messages/ru/vehicles.json
  67. 4 0
      messages/tr/audit.json
  68. 0 39
      messages/tr/customers.json
  69. 228 0
      messages/tr/dataImport.json
  70. 3 1
      messages/tr/onboarding.json
  71. 11 2
      messages/tr/settings.json
  72. 1 0
      messages/tr/vehicles.json
  73. 729 10
      package-lock.json
  74. 4 0
      package.json
  75. 62 0
      prisma/migrations/20260902190000_import_batches/migration.sql
  76. 1 0
      prisma/schema/auth.prisma
  77. 4 0
      prisma/schema/customers.prisma
  78. 33 0
      prisma/schema/import.prisma
  79. 3 0
      prisma/schema/inspections.prisma
  80. 1 0
      prisma/schema/organization.prisma
  81. 4 0
      prisma/schema/service-records.prisma
  82. 4 0
      prisma/schema/vehicles.prisma
  83. 176 0
      src/__tests__/features/import/normalize.test.ts
  84. 112 0
      src/__tests__/features/import/parse.test.ts
  85. 293 0
      src/__tests__/features/import/pipeline.test.ts
  86. 151 0
      src/__tests__/features/import/suggest.test.ts
  87. 2 2
      src/app/(authenticated)/customers/customers-client.tsx
  88. 202 103
      src/app/(authenticated)/settings/data/data-settings.tsx
  89. 28 10
      src/app/(authenticated)/vehicles/vehicles-client.tsx
  90. 117 0
      src/app/api/protected/import/analyze/route.ts
  91. 54 0
      src/app/api/protected/import/template/route.ts
  92. 0 154
      src/features/customers/Actions/customerActions.ts
  93. 0 696
      src/features/customers/Components/ImportCustomersDialog.tsx
  94. 830 0
      src/features/import/Actions/importActions.ts
  95. 162 0
      src/features/import/Components/ImportHistoryCard.tsx
  96. 1070 0
      src/features/import/Components/ImportWizard.tsx
  97. 1119 0
      src/features/import/Lib/fields.ts
  98. 710 0
      src/features/import/Lib/normalize.ts
  99. 290 0
      src/features/import/Lib/parse.ts
  100. 707 0
      src/features/import/Lib/pipeline.ts

+ 4 - 0
messages/de/audit.json

@@ -152,6 +152,8 @@
     "team_giveTechnicianTheApp": "Board-Techniker ein Konto gegeben",
     "team_createDefaultRoles": "Standardrollen angelegt",
     "settings_setCustomFieldPlacement": "Position eines benutzerdefinierten Felds geändert",
+    "import_commit": "Tabelle importiert",
+    "import_undo": "Import rückgängig gemacht",
     "integration_connect": "Integration verbunden",
     "integration_disconnect": "Integration getrennt"
   },
@@ -278,6 +280,8 @@
     "work_bay_update": "Arbeitsplatz „{name}“ aktualisiert",
     "work_bay_delete": "Arbeitsplatz {id} gelöscht",
     "settings_setCustomFieldPlacement": "Hat geändert, wo ein benutzerdefiniertes Feld gedruckt wird",
+    "import_commit": "Tabelle importiert: {count} Datensätze angelegt",
+    "import_undo": "Tabellenimport rückgängig gemacht: {records} Datensätze entfernt",
     "integration_connect": "{name} verbunden",
     "integration_disconnect": "{name} getrennt"
   }

+ 0 - 39
messages/de/customers.json

@@ -117,44 +117,5 @@
     "notesSaveError": "Notizen konnten nicht gespeichert werden",
     "smsLabel": "Serviceanfrage-Nachricht",
     "smsDefault": "Hallo {name}, bezüglich Ihrer Serviceanfrage für Ihr Fahrzeug {vehicle}: "
-  },
-  "import": {
-    "title": "Kunden importieren",
-    "description": "Kunden aus CSV-, TSV- oder vCard-Dateien importieren.",
-    "dropFile": "Datei hierher ziehen und ablegen",
-    "supportedFormats": "CSV, TSV, TXT oder vCard (.vcf)",
-    "browse": "Dateien durchsuchen",
-    "noDataFound": "Keine Kundendaten in der Datei gefunden",
-    "rowsFound": "{count} Kunde(n) gefunden",
-    "colName": "Name",
-    "colEmail": "E-Mail",
-    "colPhone": "Telefon",
-    "colCompany": "Unternehmen",
-    "colAddress": "Adresse",
-    "chooseAnother": "Andere Datei wählen",
-    "cancel": "Abbrechen",
-    "importButton": "{count} Kunde(n) importieren",
-    "importComplete": "Import abgeschlossen",
-    "importedCount": "{count} Kunde(n) erfolgreich importiert",
-    "skippedRows": "{count} Zeile(n) wegen Fehlern übersprungen",
-    "rowError": "Zeile {row} ({name}): {error}",
-    "done": "Fertig",
-    "importFailed": "Kunden konnten nicht importiert werden",
-    "checkingDuplicates": "Duplikate werden geprüft...",
-    "duplicatesFound": "{count} Duplikat(e) gefunden",
-    "duplicatesBanner": "{count} bestehende(r) Kunde(n) stimmen mit Ihren Importdaten überein.",
-    "duplicatesHint": "Klicken Sie auf Überspringen oder Zusammenführen, um festzulegen, wie jedes Duplikat behandelt wird.",
-    "colStatus": "Status",
-    "actionSkip": "Überspringen",
-    "actionMerge": "Zusammenführen",
-    "actionNew": "Neu",
-    "summaryNew": "{count} neu",
-    "summaryMerge": "{count} zusammenführen",
-    "summarySkip": "{count} überspringen",
-    "skipAll": "Alle überspringen",
-    "mergeAll": "Alle zusammenführen",
-    "resultCreated": "{count} Kunde(n) erstellt",
-    "resultMerged": "{count} Kunde(n) zusammengeführt",
-    "resultSkipped": "{count} Kunde(n) übersprungen"
   }
 }

+ 228 - 0
messages/de/dataImport.json

@@ -0,0 +1,228 @@
+{
+  "title": "{entity} importieren",
+  "cancel": "Abbrechen",
+  "done": "Fertig",
+  "entities": {
+    "customers": "Kunden",
+    "vehicles": "Fahrzeuge",
+    "services": "Servicehistorie"
+  },
+  "entityHints": {
+    "customers": "Namen, Kontaktdaten, Adressen. Eine Zeile pro Kunde.",
+    "vehicles": "Marke, Modell, Baujahr, Kennzeichen, FIN. Eine Besitzer-Spalte verknüpft oder erstellt den Kunden.",
+    "services": "Frühere Aufträge und Rechnungen. Eine Zeile pro Auftrag, mit dem zugehörigen Fahrzeug."
+  },
+  "groups": {
+    "customer": "Kunde",
+    "vehicle": "Fahrzeug",
+    "service": "Service"
+  },
+  "steps": {
+    "upload": "Wählen Sie, was importiert werden soll, und laden Sie die Datei aus Ihrem bisherigen System hoch.",
+    "mapping": "Prüfen Sie, welche Spalte zu welchem Feld wird. Es wird noch nichts geschrieben.",
+    "preview": "Testlauf: Genau das wird der Import tun. Prüfen Sie es und importieren Sie dann.",
+    "importing": "Zeilen werden geschrieben. Lassen Sie dieses Fenster geöffnet.",
+    "result": "Import abgeschlossen."
+  },
+  "upload": {
+    "drop": "Datei hierher ziehen und ablegen",
+    "formats": "CSV, Excel (.xlsx) oder vCard (.vcf), bis zu 25 MB und 50.000 Zeilen",
+    "browse": "Dateien durchsuchen",
+    "reading": "Datei wird gelesen...",
+    "templateHint": "Noch kein Export? Füllen Sie eine Vorlage aus und laden Sie diese hoch.",
+    "templateXlsx": "Excel-Vorlage",
+    "templateCsv": "CSV-Vorlage",
+    "sourcesHint": "Funktioniert mit Exporten aus jedem Werkstattsystem, Buchhaltungsprogramm, Google Kontakte, Outlook oder einer Tabelle. Spaltennamen werden in vielen Sprachen erkannt; Sie bestätigen die Zuordnung, bevor etwas geschrieben wird."
+  },
+  "mapping": {
+    "rows": "{count, plural, one {# Zeile} other {# Zeilen}}",
+    "colColumn": "Spalte in Ihrer Datei",
+    "colSample": "Beispielwerte",
+    "colField": "Wird zu",
+    "ignore": "Diese Spalte ignorieren",
+    "source": {
+      "preset": "Erkanntes Layout",
+      "header": "Über Spaltenüberschrift zugeordnet",
+      "values": "Aus den Werten abgeleitet"
+    },
+    "suggestWithAi": "Mit KI vorschlagen",
+    "aiApplied": "{count, plural, one {# Spalte} other {# Spalten}} durch KI zugeordnet. Prüfen Sie die Zuordnung, bevor Sie fortfahren.",
+    "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"
+  },
+  "options": {
+    "dateFormat": "Datumsformat",
+    "dateAuto": "Automatisch erkennen",
+    "dateDMY": "Tag / Monat / Jahr",
+    "dateMDY": "Monat / Tag / Jahr",
+    "dateYMD": "Jahr / Monat / Tag",
+    "decimal": "Dezimaltrennzeichen",
+    "decimalAuto": "Automatisch erkennen",
+    "decimalDot": "Punkt (1,234.56)",
+    "decimalComma": "Komma (1.234,56)",
+    "countryCode": "Ländervorwahl für Telefonnummern",
+    "duplicates": "Wenn eine Zeile einem vorhandenen Datensatz entspricht",
+    "duplicatesUpdate": "Vorhandenen Datensatz aktualisieren",
+    "duplicatesSkip": "Zeile überspringen",
+    "duplicatesCreate": "Trotzdem neuen Datensatz anlegen"
+  },
+  "summary": {
+    "create": "Wird angelegt",
+    "update": "Wird aktualisiert",
+    "skip": "Wird übersprungen",
+    "error": "Kann nicht importiert werden",
+    "warnings": "Mit Warnungen",
+    "customersToCreate": "{count, plural, one {# neuer Kunde} other {# neue Kunden}}",
+    "customersToUpdate": "{count, plural, one {# Kunde aktualisiert} other {# Kunden aktualisiert}}",
+    "vehiclesToCreate": "{count, plural, one {# neues Fahrzeug} other {# neue Fahrzeuge}}",
+    "vehiclesToUpdate": "{count, plural, one {# Fahrzeug aktualisiert} other {# Fahrzeuge aktualisiert}}",
+    "servicesToCreate": "{count, plural, one {# Serviceeintrag} other {# Serviceeinträge}}"
+  },
+  "actions": {
+    "create": "Anlegen",
+    "update": "Aktualisieren",
+    "skip": "Überspringen",
+    "error": "Fehler"
+  },
+  "matchOn": {
+    "number": "Kundennummer",
+    "email": "E-Mail",
+    "phone": "Telefon",
+    "name": "Name",
+    "vin": "FIN",
+    "plate": "Kennzeichen",
+    "makeModelYear": "Marke, Modell und Baujahr"
+  },
+  "preview": {
+    "colAction": "Aktion",
+    "colOwner": "Besitzer",
+    "colDetails": "Details",
+    "empty": "Keine Zeilen entsprechen diesem Filter.",
+    "matchedOn": "Entspricht {name} (über {on})",
+    "linksCustomer": "Verknüpft mit Kunde {name}",
+    "linksVehicle": "Verknüpft mit {label}",
+    "sameCustomerAsRow": "Gleicher Kunde wie Zeile {row}",
+    "sameVehicleAsRow": "Gleiches Fahrzeug wie Zeile {row}",
+    "createsCustomer": "Legt den Kunden an",
+    "createsVehicle": "Legt das Fahrzeug an",
+    "downloadReport": "Testlauf-Bericht herunterladen",
+    "limitExceeded": "Ihr Tarif erlaubt noch {remaining} weitere Kunden, dieser Import würde aber {needed} anlegen. Entfernen Sie Zeilen oder wechseln Sie zu einem höheren Tarif.",
+    "pageInfo": "{from}–{to} von {total}",
+    "prev": "Zurück",
+    "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."
+  },
+  "importing": {
+    "title": "Import läuft...",
+    "starting": "Wird gestartet",
+    "progress": "{done} von {total} Zeilen",
+    "keepOpen": "Große Dateien brauchen ein bis zwei Minuten."
+  },
+  "result": {
+    "title": "Import abgeschlossen",
+    "undoneTitle": "Import rückgängig gemacht",
+    "created": "{count, plural, one {# Datensatz angelegt} other {# Datensätze angelegt}}",
+    "updated": "{count, plural, one {# Datensatz aktualisiert} other {# Datensätze aktualisiert}}",
+    "skipped": "{count, plural, one {# Zeile übersprungen} other {# Zeilen übersprungen}}",
+    "failed": "{count, plural, one {# Zeile nicht importiert} other {# Zeilen nicht importiert}}",
+    "writeFailures": "{count, plural, one {# Zeile beim Schreiben fehlgeschlagen} other {# Zeilen beim Schreiben fehlgeschlagen}}",
+    "rowFailure": "Zeile {row}: {error}",
+    "undo": "Diesen Import rückgängig machen",
+    "undone": "{customers} Kunden, {vehicles} Fahrzeuge und {services} Serviceeinträge entfernt.",
+    "undoHint": "Rückgängig machen entfernt jeden Datensatz, den dieser Import angelegt hat. Datensätze, die nur aktualisiert wurden, behalten ihre neuen Werte."
+  },
+  "history": {
+    "title": "Importverlauf",
+    "description": "In dieser Werkstatt ausgeführte Tabellenimporte. Rückgängig machen entfernt, was ein Import angelegt hat.",
+    "loading": "Wird geladen...",
+    "statusUndone": "Rückgängig gemacht",
+    "counts": "{created} angelegt, {updated} aktualisiert, {skipped} übersprungen, {failed} fehlgeschlagen",
+    "undo": "Rückgängig",
+    "undone": "{customers} Kunden, {vehicles} Fahrzeuge und {services} Serviceeinträge entfernt.",
+    "undoFailed": "Der Import konnte nicht rückgängig gemacht werden",
+    "confirmTitle": "Diesen Import rückgängig machen?",
+    "confirmDescription": "Entfernt, was {file} angelegt hat: {customers} Kunden, {vehicles} Fahrzeuge und {services} Serviceeinträge. Datensätze, die der Import nur aktualisiert hat, bleiben erhalten.",
+    "cancel": "Abbrechen",
+    "confirm": "Import rückgängig machen"
+  },
+  "fields": {
+    "customer": {
+      "name": "Kundenname",
+      "firstName": "Vorname",
+      "lastName": "Nachname",
+      "customerNumber": "Kundennummer",
+      "email": "E-Mail",
+      "phone": "Telefon",
+      "company": "Unternehmen",
+      "address": "Adresse",
+      "street": "Straße",
+      "postalCode": "Postleitzahl",
+      "city": "Ort",
+      "state": "Bundesland / Region",
+      "country": "Land",
+      "taxId": "Steuer-/USt-IdNr.",
+      "notes": "Kundennotizen"
+    },
+    "vehicle": {
+      "description": "Fahrzeug (Baujahr Marke Modell in einer Spalte)",
+      "make": "Marke",
+      "model": "Modell",
+      "year": "Baujahr",
+      "vin": "FIN",
+      "licensePlate": "Kennzeichen",
+      "color": "Farbe",
+      "mileage": "Kilometerstand",
+      "fuelType": "Kraftstoffart",
+      "transmission": "Getriebe",
+      "engineSize": "Hubraum",
+      "engineCode": "Motorcode",
+      "purchaseDate": "Kaufdatum",
+      "purchasePrice": "Kaufpreis"
+    },
+    "service": {
+      "date": "Servicedatum",
+      "title": "Servicebezeichnung",
+      "description": "Beschreibung",
+      "total": "Gesamtbetrag",
+      "mileage": "Kilometerstand beim Service",
+      "invoiceNumber": "Rechnungsnummer",
+      "notes": "Servicenotizen",
+      "technician": "Techniker"
+    }
+  },
+  "issues": {
+    "customer_name_required": "Kundenname fehlt",
+    "invalid_email": "E-Mail \"{value}\" ist ungültig und wurde weggelassen",
+    "invalid_vin": "FIN \"{value}\" hat nicht 17 Zeichen; wie eingegeben übernommen",
+    "vehicle_make_model_required": "Marke und Modell fehlen",
+    "vehicle_year_required": "Baujahr fehlt",
+    "vehicle_year_invalid": "Baujahr \"{value}\" ist kein gültiges Jahr",
+    "vehicle_identifier_required": "Kein Kennzeichen, keine FIN und keine Marke mit Modell, um das Fahrzeug zu identifizieren",
+    "vehicle_not_found": "Kein vorhandenes Fahrzeug entspricht \"{value}\" und die Angaben reichen nicht aus, um eines anzulegen",
+    "service_date_required": "Servicedatum fehlt",
+    "service_date_invalid": "Datum \"{value}\" konnte nicht gelesen werden",
+    "service_title_required": "Servicebezeichnung oder Beschreibung fehlt",
+    "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}"
+  },
+  "errors": {
+    "uploadFailed": "Die Datei konnte nicht gelesen werden",
+    "aiFailed": "Die KI konnte keine Zuordnung vorschlagen",
+    "dryRunFailed": "Der Testlauf ist fehlgeschlagen",
+    "importFailed": "Der Import ist fehlgeschlagen",
+    "undoFailed": "Der Import konnte nicht rückgängig gemacht werden"
+  },
+  "entry": {
+    "title": "Aus einer Tabelle importieren",
+    "description": "Übernehmen Sie Kunden, Fahrzeuge und Servicehistorie aus jedem System, das CSV oder Excel exportiert. Ordnen Sie die Spalten zu, starten Sie einen Testlauf und importieren Sie dann. Jeder Import kann rückgängig gemacht werden.",
+    "customers": "Kunden importieren",
+    "vehicles": "Fahrzeuge importieren",
+    "services": "Servicehistorie importieren"
+  }
+}

+ 3 - 1
messages/de/onboarding.json

@@ -76,7 +76,9 @@
     "allDoneTitle": "Alles erledigt",
     "allDoneDescription": "Alle Schritte sind abgeschlossen und Ihre Werkstatt ist startklar.",
     "dismiss": "Karte ausblenden",
-    "dismissAriaLabel": "Diese Checkliste ausblenden"
+    "dismissAriaLabel": "Diese Checkliste ausblenden",
+    "importTitle": "Haben Sie bereits Kunden und Fahrzeuge in einem anderen System?",
+    "importDescription": "Importieren Sie sie aus einer Tabelle oder Ihrem bisherigen System"
   },
   "sampleData": {
     "title": "Beispieldaten",

+ 11 - 2
messages/de/settings.json

@@ -1116,7 +1116,7 @@
   },
   "data": {
     "title": "Datenverwaltung",
-    "description": "Exportieren Sie Ihre Daten als Backup oder importieren Sie eine zuvor exportierte Datei.",
+    "description": "Übernehmen Sie Daten aus Ihrem alten System, laden Sie eine Sicherung von allem herunter oder stellen Sie eine wieder her.",
     "exportTitle": "Daten exportieren",
     "exportDescription": "Laden Sie Ihre Daten als ZIP-Datei herunter. Wählen Sie die Kategorien für das Backup aus.",
     "includeInBackup": "Im Backup enthalten:",
@@ -1203,7 +1203,16 @@
     "optTireHotel": "Reifenhotel",
     "optTireHotelDesc": "Eingelagerte Reifensätze, Regale, Vorbereitung und Lagervereinbarungen",
     "optWorkshopConfig": "Werkstatt-Einrichtung",
-    "optWorkshopConfigDesc": "Arbeitsvorlagen, Rollen, Webhooks und geplante Berichte"
+    "optWorkshopConfigDesc": "Arbeitsvorlagen, Rollen, Webhooks und geplante Berichte",
+    "importHubDescription": "Wählen Sie, wo Ihre Daten heute liegen. Importe aus Tabellen und Apps ergänzen Ihre Daten und lassen sich rückgängig machen; eine Wiederherstellung ersetzt alles.",
+    "sourceSpreadsheet": "Tabelle oder CSV",
+    "sourceSpreadsheetDesc": "Exporte aus jedem Werkstattsystem, Buchhaltungsprogramm, Google Kontakte, Outlook oder Excel. Sie bestätigen die Spaltenzuordnung und sehen einen Testlauf, bevor etwas geschrieben wird.",
+    "sourceLubelogDesc": "Fahrzeuge, Serviceeinträge und Notizen aus einer LubeLog-Sicherung.",
+    "sourceInvoiceNinjaDesc": "Kunden, Produkte, Rechnungen und Zahlungen aus einem Invoice-Ninja-Export.",
+    "sourceBackup": "Torqvoice-Sicherung",
+    "sourceBackupDesc": "Eine auf dieser Seite heruntergeladene .zip- oder .json-Datei. Die Wiederherstellung ersetzt alles in dieser Werkstatt.",
+    "restore": "Wiederherstellen",
+    "restoreTitle": "Sicherung wiederherstellen"
   },
   "license": {
     "title": "White-Label-Lizenz",

+ 1 - 0
messages/de/vehicles.json

@@ -4,6 +4,7 @@
     "archived": "Archiviert",
     "searchPlaceholder": "Suche nach Kennzeichen, Marke, Modell, FIN...",
     "addVehicle": "Fahrzeug hinzufügen",
+    "importVehicles": "Importieren",
     "emptySearch": "Keine Fahrzeuge entsprechen Ihrer Suche.",
     "emptyArchived": "Keine archivierten Fahrzeuge.",
     "empty": "Noch keine Fahrzeuge.",

+ 4 - 0
messages/en/audit.json

@@ -152,6 +152,8 @@
     "team_giveTechnicianTheApp": "Gave a board-only technician an account",
     "team_createDefaultRoles": "Created the standard roles",
     "settings_setCustomFieldPlacement": "Updated Custom Field Placement",
+    "import_commit": "Imported Spreadsheet",
+    "import_undo": "Undid Import",
     "integration_connect": "Connected Integration",
     "integration_disconnect": "Disconnected Integration"
   },
@@ -278,6 +280,8 @@
     "work_bay_update": "Updated work bay \"{name}\"",
     "work_bay_delete": "Deleted work bay {id}",
     "settings_setCustomFieldPlacement": "Changed where a custom field prints",
+    "import_commit": "Imported a spreadsheet: {count} records created",
+    "import_undo": "Undid a spreadsheet import: {records} records removed",
     "integration_connect": "Connected {name}",
     "integration_disconnect": "Disconnected {name}"
   }

+ 0 - 39
messages/en/customers.json

@@ -117,44 +117,5 @@
     "notesSaveError": "Failed to save notes",
     "smsLabel": "Service Request Message",
     "smsDefault": "Hi {name}, regarding your service request for your {vehicle}: "
-  },
-  "import": {
-    "title": "Import Customers",
-    "description": "Import customers from CSV, TSV, or vCard files.",
-    "dropFile": "Drag & drop a file here",
-    "supportedFormats": "CSV, TSV, TXT, or vCard (.vcf)",
-    "browse": "Browse Files",
-    "noDataFound": "No customer data found in file",
-    "rowsFound": "{count} customer(s) found",
-    "colName": "Name",
-    "colEmail": "Email",
-    "colPhone": "Phone",
-    "colCompany": "Company",
-    "colAddress": "Address",
-    "chooseAnother": "Choose another file",
-    "cancel": "Cancel",
-    "importButton": "Import {count} customer(s)",
-    "importComplete": "Import complete",
-    "importedCount": "{count} customer(s) imported successfully",
-    "skippedRows": "{count} row(s) skipped due to errors",
-    "rowError": "Row {row} ({name}): {error}",
-    "done": "Done",
-    "importFailed": "Failed to import customers",
-    "checkingDuplicates": "Checking for duplicates...",
-    "duplicatesFound": "{count} duplicate(s) found",
-    "duplicatesBanner": "{count} existing customer(s) match your import data.",
-    "duplicatesHint": "Click Skip or Merge to change how each duplicate is handled.",
-    "colStatus": "Status",
-    "actionSkip": "Skip",
-    "actionMerge": "Merge",
-    "actionNew": "New",
-    "summaryNew": "{count} new",
-    "summaryMerge": "{count} merge",
-    "summarySkip": "{count} skip",
-    "skipAll": "Skip All",
-    "mergeAll": "Merge All",
-    "resultCreated": "{count} customer(s) created",
-    "resultMerged": "{count} customer(s) merged",
-    "resultSkipped": "{count} customer(s) skipped"
   }
 }

+ 228 - 0
messages/en/dataImport.json

@@ -0,0 +1,228 @@
+{
+  "title": "Import {entity}",
+  "cancel": "Cancel",
+  "done": "Done",
+  "entities": {
+    "customers": "customers",
+    "vehicles": "vehicles",
+    "services": "service history"
+  },
+  "entityHints": {
+    "customers": "Names, contact details, addresses. One row per customer.",
+    "vehicles": "Make, model, year, plate, VIN. An owner column links or creates the customer.",
+    "services": "Past jobs and invoices. One row per job, with the vehicle it belongs to."
+  },
+  "groups": {
+    "customer": "Customer",
+    "vehicle": "Vehicle",
+    "service": "Service"
+  },
+  "steps": {
+    "upload": "Choose what to import and upload the file from your current system.",
+    "mapping": "Check which column becomes which field. Nothing is written yet.",
+    "preview": "Dry run: this is exactly what the import will do. Review it, then import.",
+    "importing": "Writing rows. Keep this window open.",
+    "result": "Import finished."
+  },
+  "upload": {
+    "drop": "Drag and drop a file here",
+    "formats": "CSV, Excel (.xlsx) or vCard (.vcf), up to 25 MB and 50,000 rows",
+    "browse": "Browse files",
+    "reading": "Reading the file...",
+    "templateHint": "No export yet? Fill in a template and upload that.",
+    "templateXlsx": "Excel template",
+    "templateCsv": "CSV template",
+    "sourcesHint": "Works with exports from any shop system, accounting tool, Google Contacts, Outlook or a spreadsheet. Column names are recognised in many languages; you confirm the mapping before anything is written."
+  },
+  "mapping": {
+    "rows": "{count, plural, one {# row} other {# rows}}",
+    "colColumn": "Column in your file",
+    "colSample": "Sample values",
+    "colField": "Becomes",
+    "ignore": "Ignore this column",
+    "source": {
+      "preset": "Recognised layout",
+      "header": "Matched by header",
+      "values": "Guessed from the values"
+    },
+    "suggestWithAi": "Suggest with AI",
+    "aiApplied": "{count, plural, one {# column mapped} other {# columns mapped}} by AI. Check them before continuing.",
+    "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"
+  },
+  "options": {
+    "dateFormat": "Date format",
+    "dateAuto": "Detect automatically",
+    "dateDMY": "Day / month / year",
+    "dateMDY": "Month / day / year",
+    "dateYMD": "Year / month / day",
+    "decimal": "Decimal mark",
+    "decimalAuto": "Detect automatically",
+    "decimalDot": "Dot (1,234.56)",
+    "decimalComma": "Comma (1.234,56)",
+    "countryCode": "Country code for phone numbers",
+    "duplicates": "When a row matches an existing record",
+    "duplicatesUpdate": "Update the existing record",
+    "duplicatesSkip": "Skip the row",
+    "duplicatesCreate": "Create a new record anyway"
+  },
+  "summary": {
+    "create": "Will be created",
+    "update": "Will be updated",
+    "skip": "Will be skipped",
+    "error": "Cannot be imported",
+    "warnings": "With warnings",
+    "customersToCreate": "{count, plural, one {# new customer} other {# new customers}}",
+    "customersToUpdate": "{count, plural, one {# customer updated} other {# customers updated}}",
+    "vehiclesToCreate": "{count, plural, one {# new vehicle} other {# new vehicles}}",
+    "vehiclesToUpdate": "{count, plural, one {# vehicle updated} other {# vehicles updated}}",
+    "servicesToCreate": "{count, plural, one {# service record} other {# service records}}"
+  },
+  "actions": {
+    "create": "Create",
+    "update": "Update",
+    "skip": "Skip",
+    "error": "Error"
+  },
+  "matchOn": {
+    "number": "customer number",
+    "email": "email",
+    "phone": "phone",
+    "name": "name",
+    "vin": "VIN",
+    "plate": "plate",
+    "makeModelYear": "make, model and year"
+  },
+  "preview": {
+    "colAction": "Action",
+    "colOwner": "Owner",
+    "colDetails": "Details",
+    "empty": "No rows match this filter.",
+    "matchedOn": "Matches {name} (by {on})",
+    "linksCustomer": "Linked to customer {name}",
+    "linksVehicle": "Linked to {label}",
+    "sameCustomerAsRow": "Same customer as row {row}",
+    "sameVehicleAsRow": "Same vehicle as row {row}",
+    "createsCustomer": "Creates the customer",
+    "createsVehicle": "Creates the vehicle",
+    "downloadReport": "Download dry run report",
+    "limitExceeded": "Your plan allows {remaining} more customers, but this import would create {needed}. Remove rows or upgrade your plan.",
+    "pageInfo": "{from}–{to} of {total}",
+    "prev": "Previous",
+    "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."
+  },
+  "importing": {
+    "title": "Importing...",
+    "starting": "Starting",
+    "progress": "{done} of {total} rows",
+    "keepOpen": "Large files take a minute or two."
+  },
+  "result": {
+    "title": "Import complete",
+    "undoneTitle": "Import undone",
+    "created": "{count, plural, one {# record created} other {# records created}}",
+    "updated": "{count, plural, one {# record updated} other {# records updated}}",
+    "skipped": "{count, plural, one {# row skipped} other {# rows skipped}}",
+    "failed": "{count, plural, one {# row not imported} other {# rows not imported}}",
+    "writeFailures": "{count, plural, one {# row failed while writing} other {# rows failed while writing}}",
+    "rowFailure": "Row {row}: {error}",
+    "undo": "Undo this import",
+    "undone": "Removed {customers} customers, {vehicles} vehicles and {services} service records.",
+    "undoHint": "Undo removes every record this import created. Records it only updated keep their new values."
+  },
+  "history": {
+    "title": "Import history",
+    "description": "Spreadsheet imports run on this workshop. Undo removes what an import created.",
+    "loading": "Loading...",
+    "statusUndone": "Undone",
+    "counts": "{created} created, {updated} updated, {skipped} skipped, {failed} failed",
+    "undo": "Undo",
+    "undone": "Removed {customers} customers, {vehicles} vehicles and {services} service records.",
+    "undoFailed": "Could not undo the import",
+    "confirmTitle": "Undo this import?",
+    "confirmDescription": "Removes what {file} created: {customers} customers, {vehicles} vehicles and {services} service records. Records the import only updated are kept.",
+    "cancel": "Cancel",
+    "confirm": "Undo import"
+  },
+  "fields": {
+    "customer": {
+      "name": "Customer name",
+      "firstName": "First name",
+      "lastName": "Last name",
+      "customerNumber": "Customer number",
+      "email": "Email",
+      "phone": "Phone",
+      "company": "Company",
+      "address": "Address",
+      "street": "Street",
+      "postalCode": "Postal code",
+      "city": "City",
+      "state": "State / region",
+      "country": "Country",
+      "taxId": "Tax ID",
+      "notes": "Customer notes"
+    },
+    "vehicle": {
+      "description": "Vehicle (year make model in one column)",
+      "make": "Make",
+      "model": "Model",
+      "year": "Year",
+      "vin": "VIN",
+      "licensePlate": "License plate",
+      "color": "Color",
+      "mileage": "Mileage",
+      "fuelType": "Fuel type",
+      "transmission": "Transmission",
+      "engineSize": "Engine size",
+      "engineCode": "Engine code",
+      "purchaseDate": "Purchase date",
+      "purchasePrice": "Purchase price"
+    },
+    "service": {
+      "date": "Service date",
+      "title": "Service title",
+      "description": "Description",
+      "total": "Total amount",
+      "mileage": "Mileage at service",
+      "invoiceNumber": "Invoice number",
+      "notes": "Service notes",
+      "technician": "Technician"
+    }
+  },
+  "issues": {
+    "customer_name_required": "Customer name is missing",
+    "invalid_email": "Email \"{value}\" is not valid and was left out",
+    "invalid_vin": "VIN \"{value}\" is not 17 characters; kept as typed",
+    "vehicle_make_model_required": "Make and model are missing",
+    "vehicle_year_required": "Year is missing",
+    "vehicle_year_invalid": "Year \"{value}\" is not a valid year",
+    "vehicle_identifier_required": "No plate, VIN or make and model to identify the vehicle",
+    "vehicle_not_found": "No existing vehicle matches \"{value}\" and there is not enough to create one",
+    "service_date_required": "Service date is missing",
+    "service_date_invalid": "Date \"{value}\" could not be read",
+    "service_title_required": "Service title or description is missing",
+    "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}"
+  },
+  "errors": {
+    "uploadFailed": "The file could not be read",
+    "aiFailed": "AI could not suggest a mapping",
+    "dryRunFailed": "The dry run failed",
+    "importFailed": "The import failed",
+    "undoFailed": "Could not undo the import"
+  },
+  "entry": {
+    "title": "Import from a spreadsheet",
+    "description": "Bring customers, vehicles and service history from any system that exports CSV or Excel. Map the columns, run a dry run, then import. Every import can be undone.",
+    "customers": "Import customers",
+    "vehicles": "Import vehicles",
+    "services": "Import service history"
+  }
+}

+ 3 - 1
messages/en/onboarding.json

@@ -76,7 +76,9 @@
     "allDoneTitle": "You are all set",
     "allDoneDescription": "All steps are done and your workshop is up and running.",
     "dismiss": "Hide card",
-    "dismissAriaLabel": "Hide this checklist"
+    "dismissAriaLabel": "Hide this checklist",
+    "importTitle": "Already have customers and vehicles somewhere?",
+    "importDescription": "Import them from a spreadsheet or your old system"
   },
   "sampleData": {
     "title": "Sample data",

+ 11 - 2
messages/en/settings.json

@@ -1116,7 +1116,7 @@
   },
   "data": {
     "title": "Data Management",
-    "description": "Export your data as a backup or import a previously exported file.",
+    "description": "Bring data in from your old system, download a backup of everything, or restore one.",
     "exportTitle": "Export Data",
     "exportDescription": "Download your data as a zip file. Select which categories to include in the backup.",
     "includeInBackup": "Include in backup:",
@@ -1203,7 +1203,16 @@
     "optTireHotel": "Tire hotel",
     "optTireHotelDesc": "Stored tire sets, shelves, prep work and storage agreements",
     "optWorkshopConfig": "Workshop setup",
-    "optWorkshopConfigDesc": "Labour presets, roles, webhooks and scheduled reports"
+    "optWorkshopConfigDesc": "Labour presets, roles, webhooks and scheduled reports",
+    "importHubDescription": "Pick where your data is today. Spreadsheet and app imports add to what you have and can be undone; restoring a backup replaces everything.",
+    "sourceSpreadsheet": "Spreadsheet or CSV",
+    "sourceSpreadsheetDesc": "Exports from any shop system, accounting tool, Google Contacts, Outlook or Excel. You confirm the column mapping and see a dry run before anything is written.",
+    "sourceLubelogDesc": "Vehicles, service records and notes from a LubeLog backup.",
+    "sourceInvoiceNinjaDesc": "Clients, products, invoices and payments from an Invoice Ninja export.",
+    "sourceBackup": "Torqvoice backup",
+    "sourceBackupDesc": "A .zip or .json file downloaded from this page. Restoring replaces everything in this workshop.",
+    "restore": "Restore",
+    "restoreTitle": "Restore a backup"
   },
   "license": {
     "title": "White-Label License",

+ 1 - 0
messages/en/vehicles.json

@@ -4,6 +4,7 @@
     "archived": "Archived",
     "searchPlaceholder": "Search by plate, make, model, VIN...",
     "addVehicle": "Add Vehicle",
+    "importVehicles": "Import",
     "emptySearch": "No vehicles match your search.",
     "emptyArchived": "No archived vehicles.",
     "empty": "No vehicles yet.",

+ 4 - 0
messages/es/audit.json

@@ -152,6 +152,8 @@
     "team_giveTechnicianTheApp": "Cuenta dada a un técnico solo de tablero",
     "team_createDefaultRoles": "Roles estándar creados",
     "settings_setCustomFieldPlacement": "Ubicación de campo personalizado actualizada",
+    "import_commit": "Hoja de cálculo importada",
+    "import_undo": "Importación deshecha",
     "integration_connect": "Integración conectada",
     "integration_disconnect": "Integración desconectada"
   },
@@ -278,6 +280,8 @@
     "work_bay_update": "Puesto de trabajo «{name}» actualizado",
     "work_bay_delete": "Puesto de trabajo {id} eliminado",
     "settings_setCustomFieldPlacement": "Cambió dónde se imprime un campo personalizado",
+    "import_commit": "Importó una hoja de cálculo: {count} registros creados",
+    "import_undo": "Deshizo una importación de hoja de cálculo: {records} registros eliminados",
     "integration_connect": "{name} conectado",
     "integration_disconnect": "{name} desconectado"
   }

+ 0 - 39
messages/es/customers.json

@@ -117,44 +117,5 @@
     "notesSaveError": "Error al guardar las notas",
     "smsLabel": "Mensaje de Solicitud de Servicio",
     "smsDefault": "Hola {name}, sobre tu solicitud de servicio para tu {vehicle}: "
-  },
-  "import": {
-    "title": "Importar Clientes",
-    "description": "Importar clientes desde archivos CSV, TSV o vCard.",
-    "dropFile": "Arrastra y suelta un archivo aquí",
-    "supportedFormats": "CSV, TSV, TXT o vCard (.vcf)",
-    "browse": "Explorar archivos",
-    "noDataFound": "No se encontraron datos de clientes en el archivo",
-    "rowsFound": "{count} cliente(s) encontrado(s)",
-    "colName": "Nombre",
-    "colEmail": "Correo electrónico",
-    "colPhone": "Teléfono",
-    "colCompany": "Empresa",
-    "colAddress": "Dirección",
-    "chooseAnother": "Elegir otro archivo",
-    "cancel": "Cancelar",
-    "importButton": "Importar {count} cliente(s)",
-    "importComplete": "Importación completada",
-    "importedCount": "{count} cliente(s) importado(s) correctamente",
-    "skippedRows": "{count} fila(s) omitida(s) por errores",
-    "rowError": "Fila {row} ({name}): {error}",
-    "done": "Listo",
-    "importFailed": "Error al importar clientes",
-    "checkingDuplicates": "Comprobando duplicados...",
-    "duplicatesFound": "{count} duplicado(s) encontrado(s)",
-    "duplicatesBanner": "{count} cliente(s) existente(s) coinciden con los datos de importación.",
-    "duplicatesHint": "Haga clic en Omitir o Fusionar para cambiar cómo se maneja cada duplicado.",
-    "colStatus": "Estado",
-    "actionSkip": "Omitir",
-    "actionMerge": "Fusionar",
-    "actionNew": "Nuevo",
-    "summaryNew": "{count} nuevos",
-    "summaryMerge": "{count} fusionar",
-    "summarySkip": "{count} omitir",
-    "skipAll": "Omitir todos",
-    "mergeAll": "Fusionar todos",
-    "resultCreated": "{count} cliente(s) creado(s)",
-    "resultMerged": "{count} cliente(s) fusionado(s)",
-    "resultSkipped": "{count} cliente(s) omitido(s)"
   }
 }

+ 228 - 0
messages/es/dataImport.json

@@ -0,0 +1,228 @@
+{
+  "title": "Importar {entity}",
+  "cancel": "Cancelar",
+  "done": "Listo",
+  "entities": {
+    "customers": "clientes",
+    "vehicles": "vehículos",
+    "services": "historial de servicio"
+  },
+  "entityHints": {
+    "customers": "Nombres, datos de contacto, direcciones. Una fila por cliente.",
+    "vehicles": "Marca, modelo, año, matrícula, VIN. Una columna de propietario vincula o crea el cliente.",
+    "services": "Trabajos y facturas anteriores. Una fila por trabajo, con el vehículo al que pertenece."
+  },
+  "groups": {
+    "customer": "Cliente",
+    "vehicle": "Vehículo",
+    "service": "Servicio"
+  },
+  "steps": {
+    "upload": "Elija qué importar y suba el archivo desde su sistema actual.",
+    "mapping": "Compruebe qué columna se convierte en qué campo. Todavía no se escribe nada.",
+    "preview": "Simulación: esto es exactamente lo que hará la importación. Revíselo y luego importe.",
+    "importing": "Escribiendo filas. Mantenga esta ventana abierta.",
+    "result": "Importación finalizada."
+  },
+  "upload": {
+    "drop": "Arrastre y suelte un archivo aquí",
+    "formats": "CSV, Excel (.xlsx) o vCard (.vcf), hasta 25 MB y 50.000 filas",
+    "browse": "Buscar archivos",
+    "reading": "Leyendo el archivo...",
+    "templateHint": "¿Aún no tiene una exportación? Rellene una plantilla y súbala.",
+    "templateXlsx": "Plantilla de Excel",
+    "templateCsv": "Plantilla CSV",
+    "sourcesHint": "Funciona con exportaciones de cualquier sistema de taller, herramienta de contabilidad, Google Contacts, Outlook o una hoja de cálculo. Los nombres de columna se reconocen en muchos idiomas; usted confirma la asignación antes de que se escriba nada."
+  },
+  "mapping": {
+    "rows": "{count, plural, one {# fila} other {# filas}}",
+    "colColumn": "Columna en su archivo",
+    "colSample": "Valores de ejemplo",
+    "colField": "Se convierte en",
+    "ignore": "Ignorar esta columna",
+    "source": {
+      "preset": "Formato reconocido",
+      "header": "Coincidencia por encabezado",
+      "values": "Deducido de los valores"
+    },
+    "suggestWithAi": "Sugerir con IA",
+    "aiApplied": "{count, plural, one {# columna asignada} other {# columnas asignadas}} por IA. Compruébelas antes de continuar.",
+    "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"
+  },
+  "options": {
+    "dateFormat": "Formato de fecha",
+    "dateAuto": "Detectar automáticamente",
+    "dateDMY": "Día / mes / año",
+    "dateMDY": "Mes / día / año",
+    "dateYMD": "Año / mes / día",
+    "decimal": "Separador decimal",
+    "decimalAuto": "Detectar automáticamente",
+    "decimalDot": "Punto (1,234.56)",
+    "decimalComma": "Coma (1.234,56)",
+    "countryCode": "Código de país para números de teléfono",
+    "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"
+  },
+  "summary": {
+    "create": "Se crearán",
+    "update": "Se actualizarán",
+    "skip": "Se omitirán",
+    "error": "No se pueden importar",
+    "warnings": "Con advertencias",
+    "customersToCreate": "{count, plural, one {# cliente nuevo} other {# clientes nuevos}}",
+    "customersToUpdate": "{count, plural, one {# cliente actualizado} other {# clientes actualizados}}",
+    "vehiclesToCreate": "{count, plural, one {# vehículo nuevo} other {# vehículos nuevos}}",
+    "vehiclesToUpdate": "{count, plural, one {# vehículo actualizado} other {# vehículos actualizados}}",
+    "servicesToCreate": "{count, plural, one {# registro de servicio} other {# registros de servicio}}"
+  },
+  "actions": {
+    "create": "Crear",
+    "update": "Actualizar",
+    "skip": "Omitir",
+    "error": "Error"
+  },
+  "matchOn": {
+    "number": "número de cliente",
+    "email": "correo electrónico",
+    "phone": "teléfono",
+    "name": "nombre",
+    "vin": "VIN",
+    "plate": "matrícula",
+    "makeModelYear": "marca, modelo y año"
+  },
+  "preview": {
+    "colAction": "Acción",
+    "colOwner": "Propietario",
+    "colDetails": "Detalles",
+    "empty": "Ninguna fila coincide con este filtro.",
+    "matchedOn": "Coincide con {name} (por {on})",
+    "linksCustomer": "Vinculado al cliente {name}",
+    "linksVehicle": "Vinculado a {label}",
+    "sameCustomerAsRow": "Mismo cliente que la fila {row}",
+    "sameVehicleAsRow": "Mismo vehículo que la fila {row}",
+    "createsCustomer": "Crea el cliente",
+    "createsVehicle": "Crea el vehículo",
+    "downloadReport": "Descargar informe de la simulación",
+    "limitExceeded": "Su plan permite {remaining} clientes más, pero esta importación crearía {needed}. Elimine filas o mejore su plan.",
+    "pageInfo": "{from}–{to} de {total}",
+    "prev": "Anterior",
+    "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."
+  },
+  "importing": {
+    "title": "Importando...",
+    "starting": "Iniciando",
+    "progress": "{done} de {total} filas",
+    "keepOpen": "Los archivos grandes tardan uno o dos minutos."
+  },
+  "result": {
+    "title": "Importación completada",
+    "undoneTitle": "Importación deshecha",
+    "created": "{count, plural, one {# registro creado} other {# registros creados}}",
+    "updated": "{count, plural, one {# registro actualizado} other {# registros actualizados}}",
+    "skipped": "{count, plural, one {# fila omitida} other {# filas omitidas}}",
+    "failed": "{count, plural, one {# fila no importada} other {# filas no importadas}}",
+    "writeFailures": "{count, plural, one {# fila falló al escribir} other {# filas fallaron al escribir}}",
+    "rowFailure": "Fila {row}: {error}",
+    "undo": "Deshacer esta importación",
+    "undone": "Se eliminaron {customers} clientes, {vehicles} vehículos y {services} registros de servicio.",
+    "undoHint": "Deshacer elimina todos los registros que creó esta importación. Los registros que solo actualizó conservan sus nuevos valores."
+  },
+  "history": {
+    "title": "Historial de importaciones",
+    "description": "Importaciones de hojas de cálculo realizadas en este taller. Deshacer elimina lo que creó una importación.",
+    "loading": "Cargando...",
+    "statusUndone": "Deshecha",
+    "counts": "{created} creados, {updated} actualizados, {skipped} omitidos, {failed} fallidos",
+    "undo": "Deshacer",
+    "undone": "Se eliminaron {customers} clientes, {vehicles} vehículos y {services} registros de servicio.",
+    "undoFailed": "No se pudo deshacer la importación",
+    "confirmTitle": "¿Deshacer esta importación?",
+    "confirmDescription": "Elimina lo que creó {file}: {customers} clientes, {vehicles} vehículos y {services} registros de servicio. Los registros que la importación solo actualizó se conservan.",
+    "cancel": "Cancelar",
+    "confirm": "Deshacer importación"
+  },
+  "fields": {
+    "customer": {
+      "name": "Nombre del cliente",
+      "firstName": "Nombre",
+      "lastName": "Apellidos",
+      "customerNumber": "Número de cliente",
+      "email": "Correo electrónico",
+      "phone": "Teléfono",
+      "company": "Empresa",
+      "address": "Dirección",
+      "street": "Calle",
+      "postalCode": "Código postal",
+      "city": "Ciudad",
+      "state": "Estado / región",
+      "country": "País",
+      "taxId": "Identificación fiscal",
+      "notes": "Notas del cliente"
+    },
+    "vehicle": {
+      "description": "Vehículo (año marca modelo en una sola columna)",
+      "make": "Marca",
+      "model": "Modelo",
+      "year": "Año",
+      "vin": "VIN",
+      "licensePlate": "Matrícula",
+      "color": "Color",
+      "mileage": "Kilometraje",
+      "fuelType": "Tipo de combustible",
+      "transmission": "Transmisión",
+      "engineSize": "Cilindrada",
+      "engineCode": "Código de motor",
+      "purchaseDate": "Fecha de compra",
+      "purchasePrice": "Precio de compra"
+    },
+    "service": {
+      "date": "Fecha del servicio",
+      "title": "Título del servicio",
+      "description": "Descripción",
+      "total": "Importe total",
+      "mileage": "Kilometraje en el servicio",
+      "invoiceNumber": "Número de factura",
+      "notes": "Notas del servicio",
+      "technician": "Técnico"
+    }
+  },
+  "issues": {
+    "customer_name_required": "Falta el nombre del cliente",
+    "invalid_email": "El correo electrónico \"{value}\" no es válido y se omitió",
+    "invalid_vin": "El VIN \"{value}\" no tiene 17 caracteres; se conservó tal como se escribió",
+    "vehicle_make_model_required": "Faltan la marca y el modelo",
+    "vehicle_year_required": "Falta el año",
+    "vehicle_year_invalid": "El año \"{value}\" no es un año válido",
+    "vehicle_identifier_required": "No hay matrícula, VIN ni marca y modelo para identificar el vehículo",
+    "vehicle_not_found": "Ningún vehículo existente coincide con \"{value}\" y no hay datos suficientes para crear uno",
+    "service_date_required": "Falta la fecha del servicio",
+    "service_date_invalid": "No se pudo leer la fecha \"{value}\"",
+    "service_title_required": "Falta el título o la descripción del servicio",
+    "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}"
+  },
+  "errors": {
+    "uploadFailed": "No se pudo leer el archivo",
+    "aiFailed": "La IA no pudo sugerir una asignación",
+    "dryRunFailed": "La simulación falló",
+    "importFailed": "La importación falló",
+    "undoFailed": "No se pudo deshacer la importación"
+  },
+  "entry": {
+    "title": "Importar desde una hoja de cálculo",
+    "description": "Traiga clientes, vehículos e historial de servicio desde cualquier sistema que exporte CSV o Excel. Asigne las columnas, ejecute una simulación y luego importe. Toda importación se puede deshacer.",
+    "customers": "Importar clientes",
+    "vehicles": "Importar vehículos",
+    "services": "Importar historial de servicio"
+  }
+}

+ 3 - 1
messages/es/onboarding.json

@@ -76,7 +76,9 @@
     "allDoneTitle": "Todo listo",
     "allDoneDescription": "Todos los pasos están completados y su taller ya está en marcha.",
     "dismiss": "Ocultar tarjeta",
-    "dismissAriaLabel": "Ocultar esta lista"
+    "dismissAriaLabel": "Ocultar esta lista",
+    "importTitle": "¿Ya tiene clientes y vehículos en algún otro sitio?",
+    "importDescription": "Impórtelos desde una hoja de cálculo o desde su sistema anterior"
   },
   "sampleData": {
     "title": "Datos de ejemplo",

+ 11 - 2
messages/es/settings.json

@@ -1116,7 +1116,7 @@
   },
   "data": {
     "title": "Gestión de datos",
-    "description": "Exporte sus datos como respaldo o importe un archivo previamente exportado.",
+    "description": "Traiga datos de su sistema anterior, descargue una copia de seguridad de todo o restaure una.",
     "exportTitle": "Exportar datos",
     "exportDescription": "Descargue sus datos como archivo zip. Seleccione las categorías a incluir en el respaldo.",
     "includeInBackup": "Incluir en el respaldo:",
@@ -1203,7 +1203,16 @@
     "optTireHotel": "Hotel de neumáticos",
     "optTireHotelDesc": "Juegos guardados, estantes, preparación y contratos de almacenaje",
     "optWorkshopConfig": "Configuración del taller",
-    "optWorkshopConfigDesc": "Plantillas de mano de obra, roles, webhooks e informes programados"
+    "optWorkshopConfigDesc": "Plantillas de mano de obra, roles, webhooks e informes programados",
+    "importHubDescription": "Elija dónde están sus datos hoy. Las importaciones desde hojas de cálculo y aplicaciones se añaden a lo que tiene y se pueden deshacer; restaurar una copia de seguridad lo reemplaza todo.",
+    "sourceSpreadsheet": "Hoja de cálculo o CSV",
+    "sourceSpreadsheetDesc": "Exportaciones de cualquier sistema de taller, programa contable, Contactos de Google, Outlook o Excel. Confirma la asignación de columnas y ve una prueba antes de que se escriba nada.",
+    "sourceLubelogDesc": "Vehículos, registros de servicio y notas de una copia de seguridad de LubeLog.",
+    "sourceInvoiceNinjaDesc": "Clientes, productos, facturas y pagos de una exportación de Invoice Ninja.",
+    "sourceBackup": "Copia de seguridad de Torqvoice",
+    "sourceBackupDesc": "Un archivo .zip o .json descargado desde esta página. Restaurar reemplaza todo en este taller.",
+    "restore": "Restaurar",
+    "restoreTitle": "Restaurar una copia de seguridad"
   },
   "license": {
     "title": "Licencia marca blanca",

+ 1 - 0
messages/es/vehicles.json

@@ -4,6 +4,7 @@
     "archived": "Archivados",
     "searchPlaceholder": "Buscar por placa, marca, modelo, VIN...",
     "addVehicle": "Agregar vehículo",
+    "importVehicles": "Importar",
     "emptySearch": "Ningún vehículo coincide con su búsqueda.",
     "emptyArchived": "No hay vehículos archivados.",
     "empty": "Aún no hay vehículos.",

+ 4 - 0
messages/fr/audit.json

@@ -152,6 +152,8 @@
     "team_giveTechnicianTheApp": "Compte donné à un technicien du tableau",
     "team_createDefaultRoles": "Rôles standard créés",
     "settings_setCustomFieldPlacement": "Emplacement de champ personnalisé mis à jour",
+    "import_commit": "Feuille de calcul importée",
+    "import_undo": "Import annulé",
     "integration_connect": "Intégration connectée",
     "integration_disconnect": "Intégration déconnectée"
   },
@@ -278,6 +280,8 @@
     "work_bay_update": "Poste de travail « {name} » mis à jour",
     "work_bay_delete": "Poste de travail {id} supprimé",
     "settings_setCustomFieldPlacement": "A modifié l'emplacement d'impression d'un champ personnalisé",
+    "import_commit": "Feuille de calcul importée : {count} enregistrements créés",
+    "import_undo": "Import de feuille de calcul annulé : {records} enregistrements supprimés",
     "integration_connect": "{name} connecté",
     "integration_disconnect": "{name} déconnecté"
   }

+ 0 - 39
messages/fr/customers.json

@@ -117,44 +117,5 @@
     "notesSaveError": "Échec de l'enregistrement des notes",
     "smsLabel": "Message de demande de service",
     "smsDefault": "Bonjour {name}, concernant votre demande de service pour votre {vehicle} : "
-  },
-  "import": {
-    "title": "Importer des clients",
-    "description": "Importer des clients depuis des fichiers CSV, TSV ou vCard.",
-    "dropFile": "Glissez-déposez un fichier ici",
-    "supportedFormats": "CSV, TSV, TXT ou vCard (.vcf)",
-    "browse": "Parcourir les fichiers",
-    "noDataFound": "Aucune donnée client trouvée dans le fichier",
-    "rowsFound": "{count} client(s) trouvé(s)",
-    "colName": "Nom",
-    "colEmail": "E-mail",
-    "colPhone": "Téléphone",
-    "colCompany": "Entreprise",
-    "colAddress": "Adresse",
-    "chooseAnother": "Choisir un autre fichier",
-    "cancel": "Annuler",
-    "importButton": "Importer {count} client(s)",
-    "importComplete": "Importation terminée",
-    "importedCount": "{count} client(s) importé(s) avec succès",
-    "skippedRows": "{count} ligne(s) ignorée(s) en raison d'erreurs",
-    "rowError": "Ligne {row} ({name}) : {error}",
-    "done": "Terminé",
-    "importFailed": "Échec de l'importation des clients",
-    "checkingDuplicates": "Vérification des doublons...",
-    "duplicatesFound": "{count} doublon(s) trouvé(s)",
-    "duplicatesBanner": "{count} client(s) existant(s) correspondent à vos données d'importation.",
-    "duplicatesHint": "Cliquez sur Ignorer ou Fusionner pour changer le traitement de chaque doublon.",
-    "colStatus": "Statut",
-    "actionSkip": "Ignorer",
-    "actionMerge": "Fusionner",
-    "actionNew": "Nouveau",
-    "summaryNew": "{count} nouveau(x)",
-    "summaryMerge": "{count} fusionner",
-    "summarySkip": "{count} ignorer",
-    "skipAll": "Tout ignorer",
-    "mergeAll": "Tout fusionner",
-    "resultCreated": "{count} client(s) créé(s)",
-    "resultMerged": "{count} client(s) fusionné(s)",
-    "resultSkipped": "{count} client(s) ignoré(s)"
   }
 }

+ 228 - 0
messages/fr/dataImport.json

@@ -0,0 +1,228 @@
+{
+  "title": "Importer des {entity}",
+  "cancel": "Annuler",
+  "done": "Terminé",
+  "entities": {
+    "customers": "clients",
+    "vehicles": "véhicules",
+    "services": "historiques de service"
+  },
+  "entityHints": {
+    "customers": "Noms, coordonnées, adresses. Une ligne par client.",
+    "vehicles": "Marque, modèle, année, plaque, NIV. Une colonne propriétaire associe ou crée le client.",
+    "services": "Interventions et factures passées. Une ligne par intervention, avec le véhicule concerné."
+  },
+  "groups": {
+    "customer": "Client",
+    "vehicle": "Véhicule",
+    "service": "Service"
+  },
+  "steps": {
+    "upload": "Choisissez quoi importer et téléversez le fichier exporté depuis votre système actuel.",
+    "mapping": "Vérifiez quelle colonne devient quel champ. Rien n'est encore écrit.",
+    "preview": "Simulation : voici exactement ce que l'import va faire. Vérifiez, puis importez.",
+    "importing": "Écriture des lignes. Gardez cette fenêtre ouverte.",
+    "result": "Import terminé."
+  },
+  "upload": {
+    "drop": "Glissez-déposez un fichier ici",
+    "formats": "CSV, Excel (.xlsx) ou vCard (.vcf), jusqu'à 25 Mo et 50 000 lignes",
+    "browse": "Parcourir les fichiers",
+    "reading": "Lecture du fichier...",
+    "templateHint": "Pas encore d'export ? Remplissez un modèle et téléversez-le.",
+    "templateXlsx": "Modèle Excel",
+    "templateCsv": "Modèle CSV",
+    "sourcesHint": "Fonctionne avec les exports de n'importe quel logiciel d'atelier, outil de comptabilité, Google Contacts, Outlook ou tableur. Les noms de colonnes sont reconnus dans de nombreuses langues ; vous confirmez la correspondance avant que quoi que ce soit ne soit écrit."
+  },
+  "mapping": {
+    "rows": "{count, plural, one {# ligne} other {# lignes}}",
+    "colColumn": "Colonne dans votre fichier",
+    "colSample": "Exemples de valeurs",
+    "colField": "Devient",
+    "ignore": "Ignorer cette colonne",
+    "source": {
+      "preset": "Format reconnu",
+      "header": "Correspondance par en-tête",
+      "values": "Déduit des valeurs"
+    },
+    "suggestWithAi": "Suggérer avec l'IA",
+    "aiApplied": "{count, plural, one {# colonne associée} other {# colonnes associées}} par l'IA. Vérifiez-les avant de continuer.",
+    "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"
+  },
+  "options": {
+    "dateFormat": "Format de date",
+    "dateAuto": "Détecter automatiquement",
+    "dateDMY": "Jour / mois / année",
+    "dateMDY": "Mois / jour / année",
+    "dateYMD": "Année / mois / jour",
+    "decimal": "Séparateur décimal",
+    "decimalAuto": "Détecter automatiquement",
+    "decimalDot": "Point (1,234.56)",
+    "decimalComma": "Virgule (1.234,56)",
+    "countryCode": "Indicatif pays pour les numéros de téléphone",
+    "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"
+  },
+  "summary": {
+    "create": "Seront créés",
+    "update": "Seront mis à jour",
+    "skip": "Seront ignorés",
+    "error": "Impossibles à importer",
+    "warnings": "Avec avertissements",
+    "customersToCreate": "{count, plural, one {# nouveau client} other {# nouveaux clients}}",
+    "customersToUpdate": "{count, plural, one {# client mis à jour} other {# clients mis à jour}}",
+    "vehiclesToCreate": "{count, plural, one {# nouveau véhicule} other {# nouveaux véhicules}}",
+    "vehiclesToUpdate": "{count, plural, one {# véhicule mis à jour} other {# véhicules mis à jour}}",
+    "servicesToCreate": "{count, plural, one {# fiche de service} other {# fiches de service}}"
+  },
+  "actions": {
+    "create": "Créer",
+    "update": "Mettre à jour",
+    "skip": "Ignorer",
+    "error": "Erreur"
+  },
+  "matchOn": {
+    "number": "numéro de client",
+    "email": "e-mail",
+    "phone": "téléphone",
+    "name": "nom",
+    "vin": "NIV",
+    "plate": "plaque",
+    "makeModelYear": "marque, modèle et année"
+  },
+  "preview": {
+    "colAction": "Action",
+    "colOwner": "Propriétaire",
+    "colDetails": "Détails",
+    "empty": "Aucune ligne ne correspond à ce filtre.",
+    "matchedOn": "Correspond à {name} (par {on})",
+    "linksCustomer": "Associé au client {name}",
+    "linksVehicle": "Associé à {label}",
+    "sameCustomerAsRow": "Même client que la ligne {row}",
+    "sameVehicleAsRow": "Même véhicule que la ligne {row}",
+    "createsCustomer": "Crée le client",
+    "createsVehicle": "Crée le véhicule",
+    "downloadReport": "Télécharger le rapport de simulation",
+    "limitExceeded": "Votre forfait autorise encore {remaining} clients, mais cet import en créerait {needed}. Retirez des lignes ou changez de forfait.",
+    "pageInfo": "{from}–{to} sur {total}",
+    "prev": "Précédent",
+    "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."
+  },
+  "importing": {
+    "title": "Import en cours...",
+    "starting": "Démarrage",
+    "progress": "{done} sur {total} lignes",
+    "keepOpen": "Les fichiers volumineux prennent une à deux minutes."
+  },
+  "result": {
+    "title": "Import terminé",
+    "undoneTitle": "Import annulé",
+    "created": "{count, plural, one {# enregistrement créé} other {# enregistrements créés}}",
+    "updated": "{count, plural, one {# enregistrement mis à jour} other {# enregistrements mis à jour}}",
+    "skipped": "{count, plural, one {# ligne ignorée} other {# lignes ignorées}}",
+    "failed": "{count, plural, one {# ligne non importée} other {# lignes non importées}}",
+    "writeFailures": "{count, plural, one {# ligne a échoué à l'écriture} other {# lignes ont échoué à l'écriture}}",
+    "rowFailure": "Ligne {row} : {error}",
+    "undo": "Annuler cet import",
+    "undone": "{customers} clients, {vehicles} véhicules et {services} fiches de service supprimés.",
+    "undoHint": "L'annulation supprime tous les enregistrements créés par cet import. Ceux qu'il a seulement mis à jour conservent leurs nouvelles valeurs."
+  },
+  "history": {
+    "title": "Historique des imports",
+    "description": "Imports de tableur effectués sur cet atelier. L'annulation supprime ce qu'un import a créé.",
+    "loading": "Chargement...",
+    "statusUndone": "Annulé",
+    "counts": "{created} créés, {updated} mis à jour, {skipped} ignorés, {failed} en échec",
+    "undo": "Annuler",
+    "undone": "{customers} clients, {vehicles} véhicules et {services} fiches de service supprimés.",
+    "undoFailed": "Impossible d'annuler l'import",
+    "confirmTitle": "Annuler cet import ?",
+    "confirmDescription": "Supprime ce que {file} a créé : {customers} clients, {vehicles} véhicules et {services} fiches de service. Les enregistrements que l'import a seulement mis à jour sont conservés.",
+    "cancel": "Annuler",
+    "confirm": "Annuler l'import"
+  },
+  "fields": {
+    "customer": {
+      "name": "Nom du client",
+      "firstName": "Prénom",
+      "lastName": "Nom",
+      "customerNumber": "Numéro de client",
+      "email": "E-mail",
+      "phone": "Téléphone",
+      "company": "Entreprise",
+      "address": "Adresse",
+      "street": "Rue",
+      "postalCode": "Code postal",
+      "city": "Ville",
+      "state": "État / région",
+      "country": "Pays",
+      "taxId": "Numéro fiscal",
+      "notes": "Notes client"
+    },
+    "vehicle": {
+      "description": "Véhicule (année marque modèle dans une seule colonne)",
+      "make": "Marque",
+      "model": "Modèle",
+      "year": "Année",
+      "vin": "NIV",
+      "licensePlate": "Plaque d'immatriculation",
+      "color": "Couleur",
+      "mileage": "Kilométrage",
+      "fuelType": "Type de carburant",
+      "transmission": "Transmission",
+      "engineSize": "Cylindrée",
+      "engineCode": "Code moteur",
+      "purchaseDate": "Date d'achat",
+      "purchasePrice": "Prix d'achat"
+    },
+    "service": {
+      "date": "Date du service",
+      "title": "Titre du service",
+      "description": "Description",
+      "total": "Montant total",
+      "mileage": "Kilométrage au moment du service",
+      "invoiceNumber": "Numéro de facture",
+      "notes": "Notes de service",
+      "technician": "Technicien"
+    }
+  },
+  "issues": {
+    "customer_name_required": "Le nom du client est manquant",
+    "invalid_email": "L'e-mail \"{value}\" n'est pas valide et a été ignoré",
+    "invalid_vin": "Le NIV \"{value}\" ne fait pas 17 caractères ; conservé tel quel",
+    "vehicle_make_model_required": "La marque et le modèle sont manquants",
+    "vehicle_year_required": "L'année est manquante",
+    "vehicle_year_invalid": "L'année \"{value}\" n'est pas une année valide",
+    "vehicle_identifier_required": "Aucune plaque, NIV ou marque et modèle pour identifier le véhicule",
+    "vehicle_not_found": "Aucun véhicule existant ne correspond à \"{value}\" et il n'y a pas assez d'informations pour en créer un",
+    "service_date_required": "La date du service est manquante",
+    "service_date_invalid": "La date \"{value}\" n'a pas pu être lue",
+    "service_title_required": "Le titre ou la description du service est manquant",
+    "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}"
+  },
+  "errors": {
+    "uploadFailed": "Le fichier n'a pas pu être lu",
+    "aiFailed": "L'IA n'a pas pu suggérer de correspondance",
+    "dryRunFailed": "La simulation a échoué",
+    "importFailed": "L'import a échoué",
+    "undoFailed": "Impossible d'annuler l'import"
+  },
+  "entry": {
+    "title": "Importer depuis un tableur",
+    "description": "Récupérez clients, véhicules et historique de service depuis n'importe quel système capable d'exporter en CSV ou Excel. Associez les colonnes, lancez une simulation, puis importez. Chaque import peut être annulé.",
+    "customers": "Importer des clients",
+    "vehicles": "Importer des véhicules",
+    "services": "Importer l'historique de service"
+  }
+}

+ 3 - 1
messages/fr/onboarding.json

@@ -76,7 +76,9 @@
     "allDoneTitle": "Tout est prêt",
     "allDoneDescription": "Toutes les étapes sont terminées et votre atelier est opérationnel.",
     "dismiss": "Masquer la carte",
-    "dismissAriaLabel": "Masquer cette liste"
+    "dismissAriaLabel": "Masquer cette liste",
+    "importTitle": "Vous avez déjà des clients et des véhicules ailleurs ?",
+    "importDescription": "Importez-les depuis un tableur ou votre ancien système"
   },
   "sampleData": {
     "title": "Données d'exemple",

+ 11 - 2
messages/fr/settings.json

@@ -1116,7 +1116,7 @@
   },
   "data": {
     "title": "Gestion des données",
-    "description": "Exportez vos données comme sauvegarde ou importez un fichier precedemment exporte.",
+    "description": "Récupérez les données de votre ancien système, téléchargez une sauvegarde complète ou restaurez-en une.",
     "exportTitle": "Exporter les données",
     "exportDescription": "Telechargez vos données sous forme de fichier zip. Sélectionnez les catégories a inclure.",
     "includeInBackup": "Inclure dans la sauvegarde :",
@@ -1203,7 +1203,16 @@
     "optTireHotel": "Hôtel à pneus",
     "optTireHotelDesc": "Jeux stockés, étagères, préparation et contrats de stockage",
     "optWorkshopConfig": "Configuration de l'atelier",
-    "optWorkshopConfigDesc": "Modèles de main-d'œuvre, rôles, webhooks et rapports programmés"
+    "optWorkshopConfigDesc": "Modèles de main-d'œuvre, rôles, webhooks et rapports programmés",
+    "importHubDescription": "Indiquez où se trouvent vos données aujourd'hui. Les imports depuis un tableur ou une application s'ajoutent à l'existant et peuvent être annulés ; la restauration d'une sauvegarde remplace tout.",
+    "sourceSpreadsheet": "Tableur ou CSV",
+    "sourceSpreadsheetDesc": "Exports de n'importe quel logiciel d'atelier, outil comptable, Google Contacts, Outlook ou Excel. Vous confirmez la correspondance des colonnes et voyez une simulation avant toute écriture.",
+    "sourceLubelogDesc": "Véhicules, historiques d'entretien et notes issus d'une sauvegarde LubeLog.",
+    "sourceInvoiceNinjaDesc": "Clients, produits, factures et paiements issus d'un export Invoice Ninja.",
+    "sourceBackup": "Sauvegarde Torqvoice",
+    "sourceBackupDesc": "Un fichier .zip ou .json téléchargé depuis cette page. La restauration remplace tout dans cet atelier.",
+    "restore": "Restaurer",
+    "restoreTitle": "Restaurer une sauvegarde"
   },
   "license": {
     "title": "Licence marque blanche",

+ 1 - 0
messages/fr/vehicles.json

@@ -4,6 +4,7 @@
     "archived": "Archivés",
     "searchPlaceholder": "Rechercher par plaque, marque, modèle, NIV...",
     "addVehicle": "Ajouter un véhicule",
+    "importVehicles": "Importer",
     "emptySearch": "Aucun véhicule ne correspond à votre recherche.",
     "emptyArchived": "Aucun véhicule archivé.",
     "empty": "Aucun véhicule pour le moment.",

+ 4 - 0
messages/it/audit.json

@@ -152,6 +152,8 @@
     "team_giveTechnicianTheApp": "Account dato a un tecnico solo da lavagna",
     "team_createDefaultRoles": "Ruoli standard creati",
     "settings_setCustomFieldPlacement": "Posizione del campo personalizzato aggiornata",
+    "import_commit": "Foglio di calcolo importato",
+    "import_undo": "Importazione annullata",
     "integration_connect": "Integrazione collegata",
     "integration_disconnect": "Integrazione scollegata"
   },
@@ -278,6 +280,8 @@
     "work_bay_update": "Postazione «{name}» aggiornata",
     "work_bay_delete": "Postazione {id} eliminata",
     "settings_setCustomFieldPlacement": "Ha modificato dove viene stampato un campo personalizzato",
+    "import_commit": "Foglio di calcolo importato: {count} record creati",
+    "import_undo": "Importazione da foglio di calcolo annullata: {records} record rimossi",
     "integration_connect": "{name} collegato",
     "integration_disconnect": "{name} scollegato"
   }

+ 0 - 39
messages/it/customers.json

@@ -117,44 +117,5 @@
     "notesSaveError": "Salvataggio delle note non riuscito",
     "smsLabel": "Messaggio di richiesta servizio",
     "smsDefault": "Salve {name}, riguardo alla sua richiesta di servizio per il suo {vehicle}: "
-  },
-  "import": {
-    "title": "Importa clienti",
-    "description": "Importa clienti da file CSV, TSV o vCard.",
-    "dropFile": "Trascina e rilascia un file qui",
-    "supportedFormats": "CSV, TSV, TXT o vCard (.vcf)",
-    "browse": "Sfoglia file",
-    "noDataFound": "Nessun dato cliente trovato nel file",
-    "rowsFound": "{count} cliente/i trovato/i",
-    "colName": "Nome",
-    "colEmail": "Email",
-    "colPhone": "Telefono",
-    "colCompany": "Azienda",
-    "colAddress": "Indirizzo",
-    "chooseAnother": "Scegli un altro file",
-    "cancel": "Annulla",
-    "importButton": "Importa {count} cliente/i",
-    "importComplete": "Importazione completata",
-    "importedCount": "{count} cliente/i importato/i con successo",
-    "skippedRows": "{count} riga/e ignorata/e a causa di errori",
-    "rowError": "Riga {row} ({name}): {error}",
-    "done": "Fatto",
-    "importFailed": "Importazione dei clienti non riuscita",
-    "checkingDuplicates": "Controllo duplicati...",
-    "duplicatesFound": "{count} duplicato/i trovato/i",
-    "duplicatesBanner": "{count} cliente/i esistente/i corrispondono ai dati importati.",
-    "duplicatesHint": "Clicca Salta o Unisci per cambiare come viene gestito ogni duplicato.",
-    "colStatus": "Stato",
-    "actionSkip": "Salta",
-    "actionMerge": "Unisci",
-    "actionNew": "Nuovo",
-    "summaryNew": "{count} nuovo/i",
-    "summaryMerge": "{count} unisci",
-    "summarySkip": "{count} salta",
-    "skipAll": "Salta tutti",
-    "mergeAll": "Unisci tutti",
-    "resultCreated": "{count} cliente/i creato/i",
-    "resultMerged": "{count} cliente/i unito/i",
-    "resultSkipped": "{count} cliente/i saltato/i"
   }
 }

+ 228 - 0
messages/it/dataImport.json

@@ -0,0 +1,228 @@
+{
+  "title": "Importa {entity}",
+  "cancel": "Annulla",
+  "done": "Fatto",
+  "entities": {
+    "customers": "clienti",
+    "vehicles": "veicoli",
+    "services": "storico interventi"
+  },
+  "entityHints": {
+    "customers": "Nomi, contatti, indirizzi. Una riga per cliente.",
+    "vehicles": "Marca, modello, anno, targa, VIN. Una colonna proprietario collega o crea il cliente.",
+    "services": "Lavori e fatture passati. Una riga per lavoro, con il veicolo a cui appartiene."
+  },
+  "groups": {
+    "customer": "Cliente",
+    "vehicle": "Veicolo",
+    "service": "Intervento"
+  },
+  "steps": {
+    "upload": "Scegli cosa importare e carica il file dal tuo sistema attuale.",
+    "mapping": "Controlla quale colonna diventa quale campo. Non viene ancora scritto nulla.",
+    "preview": "Simulazione: questo è esattamente ciò che farà l'importazione. Controlla, poi importa.",
+    "importing": "Scrittura delle righe in corso. Tieni aperta questa finestra.",
+    "result": "Importazione terminata."
+  },
+  "upload": {
+    "drop": "Trascina qui un file",
+    "formats": "CSV, Excel (.xlsx) o vCard (.vcf), fino a 25 MB e 50.000 righe",
+    "browse": "Sfoglia file",
+    "reading": "Lettura del file...",
+    "templateHint": "Non hai ancora un'esportazione? Compila un modello e carica quello.",
+    "templateXlsx": "Modello Excel",
+    "templateCsv": "Modello CSV",
+    "sourcesHint": "Funziona con le esportazioni di qualsiasi gestionale per officine, software di contabilità, Google Contatti, Outlook o foglio di calcolo. I nomi delle colonne vengono riconosciuti in molte lingue; confermi la mappatura prima che venga scritto qualsiasi dato."
+  },
+  "mapping": {
+    "rows": "{count, plural, one {# riga} other {# righe}}",
+    "colColumn": "Colonna nel tuo file",
+    "colSample": "Valori di esempio",
+    "colField": "Diventa",
+    "ignore": "Ignora questa colonna",
+    "source": {
+      "preset": "Formato riconosciuto",
+      "header": "Abbinata dall'intestazione",
+      "values": "Dedotta dai valori"
+    },
+    "suggestWithAi": "Suggerisci con IA",
+    "aiApplied": "{count, plural, one {# colonna mappata} other {# colonne mappate}} dall'IA. Controllale prima di continuare.",
+    "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"
+  },
+  "options": {
+    "dateFormat": "Formato data",
+    "dateAuto": "Rileva automaticamente",
+    "dateDMY": "Giorno / mese / anno",
+    "dateMDY": "Mese / giorno / anno",
+    "dateYMD": "Anno / mese / giorno",
+    "decimal": "Separatore decimale",
+    "decimalAuto": "Rileva automaticamente",
+    "decimalDot": "Punto (1,234.56)",
+    "decimalComma": "Virgola (1.234,56)",
+    "countryCode": "Prefisso internazionale per i numeri di telefono",
+    "duplicates": "Quando una riga corrisponde a un record esistente",
+    "duplicatesUpdate": "Aggiorna il record esistente",
+    "duplicatesSkip": "Salta la riga",
+    "duplicatesCreate": "Crea comunque un nuovo record"
+  },
+  "summary": {
+    "create": "Verranno creati",
+    "update": "Verranno aggiornati",
+    "skip": "Verranno saltati",
+    "error": "Non importabili",
+    "warnings": "Con avvisi",
+    "customersToCreate": "{count, plural, one {# nuovo cliente} other {# nuovi clienti}}",
+    "customersToUpdate": "{count, plural, one {# cliente aggiornato} other {# clienti aggiornati}}",
+    "vehiclesToCreate": "{count, plural, one {# nuovo veicolo} other {# nuovi veicoli}}",
+    "vehiclesToUpdate": "{count, plural, one {# veicolo aggiornato} other {# veicoli aggiornati}}",
+    "servicesToCreate": "{count, plural, one {# intervento} other {# interventi}}"
+  },
+  "actions": {
+    "create": "Crea",
+    "update": "Aggiorna",
+    "skip": "Salta",
+    "error": "Errore"
+  },
+  "matchOn": {
+    "number": "numero cliente",
+    "email": "email",
+    "phone": "telefono",
+    "name": "nome",
+    "vin": "VIN",
+    "plate": "targa",
+    "makeModelYear": "marca, modello e anno"
+  },
+  "preview": {
+    "colAction": "Azione",
+    "colOwner": "Proprietario",
+    "colDetails": "Dettagli",
+    "empty": "Nessuna riga corrisponde a questo filtro.",
+    "matchedOn": "Corrisponde a {name} (per {on})",
+    "linksCustomer": "Collegato al cliente {name}",
+    "linksVehicle": "Collegato a {label}",
+    "sameCustomerAsRow": "Stesso cliente della riga {row}",
+    "sameVehicleAsRow": "Stesso veicolo della riga {row}",
+    "createsCustomer": "Crea il cliente",
+    "createsVehicle": "Crea il veicolo",
+    "downloadReport": "Scarica il report della simulazione",
+    "limitExceeded": "Il tuo piano consente ancora {remaining} clienti, ma questa importazione ne creerebbe {needed}. Rimuovi delle righe o passa a un piano superiore.",
+    "pageInfo": "{from}–{to} di {total}",
+    "prev": "Precedente",
+    "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."
+  },
+  "importing": {
+    "title": "Importazione in corso...",
+    "starting": "Avvio",
+    "progress": "{done} di {total} righe",
+    "keepOpen": "I file grandi richiedono un minuto o due."
+  },
+  "result": {
+    "title": "Importazione completata",
+    "undoneTitle": "Importazione annullata",
+    "created": "{count, plural, one {# record creato} other {# record creati}}",
+    "updated": "{count, plural, one {# record aggiornato} other {# record aggiornati}}",
+    "skipped": "{count, plural, one {# riga saltata} other {# righe saltate}}",
+    "failed": "{count, plural, one {# riga non importata} other {# righe non importate}}",
+    "writeFailures": "{count, plural, one {# riga non riuscita durante la scrittura} other {# righe non riuscite durante la scrittura}}",
+    "rowFailure": "Riga {row}: {error}",
+    "undo": "Annulla questa importazione",
+    "undone": "Rimossi {customers} clienti, {vehicles} veicoli e {services} interventi.",
+    "undoHint": "L'annullamento rimuove ogni record creato da questa importazione. I record che sono stati solo aggiornati mantengono i nuovi valori."
+  },
+  "history": {
+    "title": "Cronologia importazioni",
+    "description": "Importazioni da foglio di calcolo eseguite in questa officina. L'annullamento rimuove ciò che un'importazione ha creato.",
+    "loading": "Caricamento...",
+    "statusUndone": "Annullata",
+    "counts": "{created} creati, {updated} aggiornati, {skipped} saltati, {failed} non riusciti",
+    "undo": "Annulla",
+    "undone": "Rimossi {customers} clienti, {vehicles} veicoli e {services} interventi.",
+    "undoFailed": "Impossibile annullare l'importazione",
+    "confirmTitle": "Annullare questa importazione?",
+    "confirmDescription": "Rimuove ciò che {file} ha creato: {customers} clienti, {vehicles} veicoli e {services} interventi. I record che l'importazione ha solo aggiornato vengono conservati.",
+    "cancel": "Annulla",
+    "confirm": "Annulla importazione"
+  },
+  "fields": {
+    "customer": {
+      "name": "Nome cliente",
+      "firstName": "Nome",
+      "lastName": "Cognome",
+      "customerNumber": "Numero cliente",
+      "email": "Email",
+      "phone": "Telefono",
+      "company": "Azienda",
+      "address": "Indirizzo",
+      "street": "Via",
+      "postalCode": "CAP",
+      "city": "Città",
+      "state": "Provincia / regione",
+      "country": "Paese",
+      "taxId": "Partita IVA / codice fiscale",
+      "notes": "Note cliente"
+    },
+    "vehicle": {
+      "description": "Veicolo (anno marca modello in una sola colonna)",
+      "make": "Marca",
+      "model": "Modello",
+      "year": "Anno",
+      "vin": "VIN",
+      "licensePlate": "Targa",
+      "color": "Colore",
+      "mileage": "Chilometraggio",
+      "fuelType": "Tipo di carburante",
+      "transmission": "Cambio",
+      "engineSize": "Cilindrata",
+      "engineCode": "Codice motore",
+      "purchaseDate": "Data di acquisto",
+      "purchasePrice": "Prezzo di acquisto"
+    },
+    "service": {
+      "date": "Data intervento",
+      "title": "Titolo intervento",
+      "description": "Descrizione",
+      "total": "Importo totale",
+      "mileage": "Chilometraggio all'intervento",
+      "invoiceNumber": "Numero fattura",
+      "notes": "Note intervento",
+      "technician": "Tecnico"
+    }
+  },
+  "issues": {
+    "customer_name_required": "Manca il nome del cliente",
+    "invalid_email": "L'email \"{value}\" non è valida ed è stata omessa",
+    "invalid_vin": "Il VIN \"{value}\" non è di 17 caratteri; conservato così com'è",
+    "vehicle_make_model_required": "Mancano marca e modello",
+    "vehicle_year_required": "Manca l'anno",
+    "vehicle_year_invalid": "L'anno \"{value}\" non è un anno valido",
+    "vehicle_identifier_required": "Nessuna targa, VIN o marca e modello per identificare il veicolo",
+    "vehicle_not_found": "Nessun veicolo esistente corrisponde a \"{value}\" e non ci sono abbastanza dati per crearne uno",
+    "service_date_required": "Manca la data dell'intervento",
+    "service_date_invalid": "Impossibile leggere la data \"{value}\"",
+    "service_title_required": "Mancano il titolo o la descrizione dell'intervento",
+    "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}"
+  },
+  "errors": {
+    "uploadFailed": "Impossibile leggere il file",
+    "aiFailed": "L'IA non è riuscita a suggerire una mappatura",
+    "dryRunFailed": "La simulazione non è riuscita",
+    "importFailed": "L'importazione non è riuscita",
+    "undoFailed": "Impossibile annullare l'importazione"
+  },
+  "entry": {
+    "title": "Importa da un foglio di calcolo",
+    "description": "Porta clienti, veicoli e storico interventi da qualsiasi sistema che esporta in CSV o Excel. Mappa le colonne, esegui una simulazione, poi importa. Ogni importazione può essere annullata.",
+    "customers": "Importa clienti",
+    "vehicles": "Importa veicoli",
+    "services": "Importa storico interventi"
+  }
+}

+ 3 - 1
messages/it/onboarding.json

@@ -76,7 +76,9 @@
     "allDoneTitle": "Tutto pronto",
     "allDoneDescription": "Tutti i passaggi sono completati e la Sua officina è operativa.",
     "dismiss": "Nascondi scheda",
-    "dismissAriaLabel": "Nascondi questa lista"
+    "dismissAriaLabel": "Nascondi questa lista",
+    "importTitle": "Ha già clienti e veicoli da qualche parte?",
+    "importDescription": "Li importi da un foglio di calcolo o dal Suo vecchio sistema"
   },
   "sampleData": {
     "title": "Dati di esempio",

+ 11 - 2
messages/it/settings.json

@@ -1116,7 +1116,7 @@
   },
   "data": {
     "title": "Gestione dati",
-    "description": "Esporta i tuoi dati come backup o importa un file precedentemente esportato.",
+    "description": "Porta i dati dal tuo vecchio sistema, scarica un backup di tutto o ripristinane uno.",
     "exportTitle": "Esporta dati",
     "exportDescription": "Scarica i tuoi dati come file zip. Seleziona le categorie da includere nel backup.",
     "includeInBackup": "Includi nel backup:",
@@ -1203,7 +1203,16 @@
     "optTireHotel": "Hotel pneumatici",
     "optTireHotelDesc": "Treni in deposito, scaffali, preparazione e contratti di deposito",
     "optWorkshopConfig": "Configurazione officina",
-    "optWorkshopConfigDesc": "Preset di manodopera, ruoli, webhook e report programmati"
+    "optWorkshopConfigDesc": "Preset di manodopera, ruoli, webhook e report programmati",
+    "importHubDescription": "Scegli dove sono oggi i tuoi dati. Le importazioni da fogli di calcolo e app si aggiungono a ciò che hai e si possono annullare; il ripristino di un backup sostituisce tutto.",
+    "sourceSpreadsheet": "Foglio di calcolo o CSV",
+    "sourceSpreadsheetDesc": "Esportazioni da qualsiasi gestionale per officine, programma di contabilità, Contatti Google, Outlook o Excel. Confermi l'abbinamento delle colonne e vedi una prova prima che venga scritto qualcosa.",
+    "sourceLubelogDesc": "Veicoli, registri di manutenzione e note da un backup LubeLog.",
+    "sourceInvoiceNinjaDesc": "Clienti, prodotti, fatture e pagamenti da un'esportazione di Invoice Ninja.",
+    "sourceBackup": "Backup Torqvoice",
+    "sourceBackupDesc": "Un file .zip o .json scaricato da questa pagina. Il ripristino sostituisce tutto in questa officina.",
+    "restore": "Ripristina",
+    "restoreTitle": "Ripristina un backup"
   },
   "license": {
     "title": "Licenza White-Label",

+ 1 - 0
messages/it/vehicles.json

@@ -4,6 +4,7 @@
     "archived": "Archiviati",
     "searchPlaceholder": "Cerca per targa, marca, modello, VIN...",
     "addVehicle": "Aggiungi Veicolo",
+    "importVehicles": "Importa",
     "emptySearch": "Nessun veicolo corrisponde alla ricerca.",
     "emptyArchived": "Nessun veicolo archiviato.",
     "empty": "Nessun veicolo ancora.",

+ 4 - 0
messages/lt/audit.json

@@ -152,6 +152,8 @@
     "team_giveTechnicianTheApp": "Lentos technikui suteikta paskyra",
     "team_createDefaultRoles": "Sukurtos standartinės rolės",
     "settings_setCustomFieldPlacement": "Atnaujinta pasirinktinio lauko vieta",
+    "import_commit": "Importuota skaičiuoklė",
+    "import_undo": "Atšauktas importas",
     "integration_connect": "Prijungta integracija",
     "integration_disconnect": "Atjungta integracija"
   },
@@ -278,6 +280,8 @@
     "work_bay_update": "Atnaujinta darbo vieta „{name}“",
     "work_bay_delete": "Ištrinta darbo vieta {id}",
     "settings_setCustomFieldPlacement": "Pakeitė, kur spausdinamas pasirinktinis laukas",
+    "import_commit": "Importuota skaičiuoklė: sukurta įrašų: {count}",
+    "import_undo": "Atšauktas skaičiuoklės importas: pašalinta įrašų: {records}",
     "integration_connect": "Prijungta {name}",
     "integration_disconnect": "Atjungta {name}"
   }

+ 0 - 39
messages/lt/customers.json

@@ -117,44 +117,5 @@
     "notesSaveError": "Nepavyko išsaugoti pastabų",
     "smsLabel": "Paslaugos užklausos žinutė",
     "smsDefault": "Sveiki, {name}, dėl jūsų paslaugos užklausos jūsų {vehicle}: "
-  },
-  "import": {
-    "title": "Importuoti klientus",
-    "description": "Importuokite klientus iš CSV, TSV arba vCard failų.",
-    "dropFile": "Nuvilkite failą čia",
-    "supportedFormats": "CSV, TSV, TXT arba vCard (.vcf)",
-    "browse": "Naršyti failus",
-    "noDataFound": "Faile nerasta kliento duomenų",
-    "rowsFound": "Rasta {count} klientas(-ų)",
-    "colName": "Vardas",
-    "colEmail": "El. paštas",
-    "colPhone": "Telefonas",
-    "colCompany": "Įmonė",
-    "colAddress": "Adresas",
-    "chooseAnother": "Pasirinkti kitą failą",
-    "cancel": "Atšaukti",
-    "importButton": "Importuoti {count} klientą(-ų)",
-    "importComplete": "Importas baigtas",
-    "importedCount": "{count} klientas(-ų) sėkmingai importuotas",
-    "skippedRows": "{count} eilutė(-ių) praleista dėl klaidų",
-    "rowError": "Eilutė {row} ({name}): {error}",
-    "done": "Baigta",
-    "importFailed": "Nepavyko importuoti klientų",
-    "checkingDuplicates": "Tikrinami dublikatai...",
-    "duplicatesFound": "Rasta {count} dublikatas(-ų)",
-    "duplicatesBanner": "{count} esamas(-ų) klientas(-ų) atitinka importuojamus duomenis.",
-    "duplicatesHint": "Spustelėkite \"Praleisti\" arba \"Sujungti\", kad pakeistumėte kiekvieno dublikato apdorojimą.",
-    "colStatus": "Būsena",
-    "actionSkip": "Praleisti",
-    "actionMerge": "Sujungti",
-    "actionNew": "Naujas",
-    "summaryNew": "{count} naujas(-ų)",
-    "summaryMerge": "{count} sujungtas(-ų)",
-    "summarySkip": "{count} praleistas(-ų)",
-    "skipAll": "Praleisti visus",
-    "mergeAll": "Sujungti visus",
-    "resultCreated": "{count} klientas(-ų) sukurtas",
-    "resultMerged": "{count} klientas(-ų) sujungtas",
-    "resultSkipped": "{count} klientas(-ų) praleistas"
   }
 }

+ 228 - 0
messages/lt/dataImport.json

@@ -0,0 +1,228 @@
+{
+  "title": "Importuoti {entity}",
+  "cancel": "Atšaukti",
+  "done": "Baigta",
+  "entities": {
+    "customers": "klientus",
+    "vehicles": "transporto priemones",
+    "services": "paslaugų istoriją"
+  },
+  "entityHints": {
+    "customers": "Vardai, kontaktiniai duomenys, adresai. Viena eilutė vienam klientui.",
+    "vehicles": "Markė, modelis, metai, valst. numeris, VIN. Savininko stulpelis susieja arba sukuria klientą.",
+    "services": "Ankstesni darbai ir sąskaitos. Viena eilutė vienam darbui su transporto priemone, kuriai jis priklauso."
+  },
+  "groups": {
+    "customer": "Klientas",
+    "vehicle": "Transporto priemonė",
+    "service": "Paslauga"
+  },
+  "steps": {
+    "upload": "Pasirinkite, ką importuoti, ir įkelkite failą iš dabartinės sistemos.",
+    "mapping": "Patikrinkite, kuris stulpelis taps kuriuo lauku. Kol kas niekas neįrašoma.",
+    "preview": "Bandomasis importas: tai tiksliai, ką atliks importas. Peržiūrėkite ir tada importuokite.",
+    "importing": "Įrašomos eilutės. Neuždarykite šio lango.",
+    "result": "Importas baigtas."
+  },
+  "upload": {
+    "drop": "Nutempkite failą čia",
+    "formats": "CSV, Excel (.xlsx) arba vCard (.vcf), iki 25 MB ir 50 000 eilučių",
+    "browse": "Naršyti failus",
+    "reading": "Skaitomas failas...",
+    "templateHint": "Dar neturite eksporto? Užpildykite šabloną ir įkelkite jį.",
+    "templateXlsx": "Excel šablonas",
+    "templateCsv": "CSV šablonas",
+    "sourcesHint": "Veikia su eksportais iš bet kurios dirbtuvių sistemos, apskaitos programos, Google Contacts, Outlook ar skaičiuoklės. Stulpelių pavadinimai atpažįstami daugeliu kalbų; susiejimą patvirtinate prieš ką nors įrašant."
+  },
+  "mapping": {
+    "rows": "{count, plural, one {# eilutė} few {# eilutės} many {# eilutės} other {# eilučių}}",
+    "colColumn": "Stulpelis jūsų faile",
+    "colSample": "Reikšmių pavyzdžiai",
+    "colField": "Taps",
+    "ignore": "Ignoruoti šį stulpelį",
+    "source": {
+      "preset": "Atpažintas išdėstymas",
+      "header": "Pagal antraštę",
+      "values": "Spėta pagal reikšmes"
+    },
+    "suggestWithAi": "Pasiūlyti su DI",
+    "aiApplied": "DI susiejo {count, plural, one {# stulpelį} few {# stulpelius} many {# stulpelio} other {# stulpelių}}. Patikrinkite prieš tęsdami.",
+    "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ą"
+  },
+  "options": {
+    "dateFormat": "Datos formatas",
+    "dateAuto": "Nustatyti automatiškai",
+    "dateDMY": "Diena / mėnuo / metai",
+    "dateMDY": "Mėnuo / diena / metai",
+    "dateYMD": "Metai / mėnuo / diena",
+    "decimal": "Dešimtainis skirtukas",
+    "decimalAuto": "Nustatyti automatiškai",
+    "decimalDot": "Taškas (1,234.56)",
+    "decimalComma": "Kablelis (1.234,56)",
+    "countryCode": "Šalies kodas telefono numeriams",
+    "duplicates": "Kai eilutė atitinka esamą įrašą",
+    "duplicatesUpdate": "Atnaujinti esamą įrašą",
+    "duplicatesSkip": "Praleisti eilutę",
+    "duplicatesCreate": "Vis tiek sukurti naują įrašą"
+  },
+  "summary": {
+    "create": "Bus sukurta",
+    "update": "Bus atnaujinta",
+    "skip": "Bus praleista",
+    "error": "Negalima importuoti",
+    "warnings": "Su įspėjimais",
+    "customersToCreate": "{count, plural, one {# naujas klientas} few {# nauji klientai} many {# naujo kliento} other {# naujų klientų}}",
+    "customersToUpdate": "{count, plural, one {atnaujintas # klientas} few {atnaujinti # klientai} many {atnaujinta # kliento} other {atnaujinta # klientų}}",
+    "vehiclesToCreate": "{count, plural, one {# nauja transporto priemonė} few {# naujos transporto priemonės} many {# naujos transporto priemonės} other {# naujų transporto priemonių}}",
+    "vehiclesToUpdate": "{count, plural, one {atnaujinta # transporto priemonė} few {atnaujintos # transporto priemonės} many {atnaujinta # transporto priemonės} other {atnaujinta # transporto priemonių}}",
+    "servicesToCreate": "{count, plural, one {# paslaugos įrašas} few {# paslaugų įrašai} many {# paslaugos įrašo} other {# paslaugų įrašų}}"
+  },
+  "actions": {
+    "create": "Sukurti",
+    "update": "Atnaujinti",
+    "skip": "Praleisti",
+    "error": "Klaida"
+  },
+  "matchOn": {
+    "number": "kliento numerį",
+    "email": "el. paštą",
+    "phone": "telefoną",
+    "name": "vardą",
+    "vin": "VIN",
+    "plate": "valst. numerį",
+    "makeModelYear": "markę, modelį ir metus"
+  },
+  "preview": {
+    "colAction": "Veiksmas",
+    "colOwner": "Savininkas",
+    "colDetails": "Išsami informacija",
+    "empty": "Nėra šį filtrą atitinkančių eilučių.",
+    "matchedOn": "Atitinka {name} (pagal {on})",
+    "linksCustomer": "Susieta su klientu {name}",
+    "linksVehicle": "Susieta su {label}",
+    "sameCustomerAsRow": "Tas pats klientas kaip {row} eilutėje",
+    "sameVehicleAsRow": "Ta pati transporto priemonė kaip {row} eilutėje",
+    "createsCustomer": "Sukuria klientą",
+    "createsVehicle": "Sukuria transporto priemonę",
+    "downloadReport": "Atsisiųsti bandomojo importo ataskaitą",
+    "limitExceeded": "Jūsų planas leidžia dar {remaining} klientų, bet šis importas sukurtų {needed}. Pašalinkite eilutes arba atnaujinkite planą.",
+    "pageInfo": "{from}–{to} iš {total}",
+    "prev": "Ankstesnis",
+    "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."
+  },
+  "importing": {
+    "title": "Importuojama...",
+    "starting": "Pradedama",
+    "progress": "{done} iš {total} eilučių",
+    "keepOpen": "Dideli failai užtrunka minutę ar dvi."
+  },
+  "result": {
+    "title": "Importas baigtas",
+    "undoneTitle": "Importas atšauktas",
+    "created": "{count, plural, one {sukurtas # įrašas} few {sukurti # įrašai} many {sukurta # įrašo} other {sukurta # įrašų}}",
+    "updated": "{count, plural, one {atnaujintas # įrašas} few {atnaujinti # įrašai} many {atnaujinta # įrašo} other {atnaujinta # įrašų}}",
+    "skipped": "{count, plural, one {praleista # eilutė} few {praleistos # eilutės} many {praleista # eilutės} other {praleista # eilučių}}",
+    "failed": "{count, plural, one {neimportuota # eilutė} few {neimportuotos # eilutės} many {neimportuota # eilutės} other {neimportuota # eilučių}}",
+    "writeFailures": "{count, plural, one {įrašant nepavyko # eilutė} few {įrašant nepavyko # eilutės} many {įrašant nepavyko # eilutės} other {įrašant nepavyko # eilučių}}",
+    "rowFailure": "{row} eilutė: {error}",
+    "undo": "Atšaukti šį importą",
+    "undone": "Pašalinta: {customers} klientų, {vehicles} transporto priemonių ir {services} paslaugų įrašų.",
+    "undoHint": "Atšaukimas pašalina visus šio importo sukurtus įrašus. Įrašai, kuriuos importas tik atnaujino, išlaiko naujas reikšmes."
+  },
+  "history": {
+    "title": "Importų istorija",
+    "description": "Šiose dirbtuvėse atlikti skaičiuoklių importai. Atšaukimas pašalina tai, ką importas sukūrė.",
+    "loading": "Įkeliama...",
+    "statusUndone": "Atšauktas",
+    "counts": "{created} sukurta, {updated} atnaujinta, {skipped} praleista, {failed} nepavyko",
+    "undo": "Atšaukti",
+    "undone": "Pašalinta: {customers} klientų, {vehicles} transporto priemonių ir {services} paslaugų įrašų.",
+    "undoFailed": "Nepavyko atšaukti importo",
+    "confirmTitle": "Atšaukti šį importą?",
+    "confirmDescription": "Pašalina tai, ką sukūrė {file}: {customers} klientų, {vehicles} transporto priemonių ir {services} paslaugų įrašų. Įrašai, kuriuos importas tik atnaujino, išsaugomi.",
+    "cancel": "Atšaukti",
+    "confirm": "Atšaukti importą"
+  },
+  "fields": {
+    "customer": {
+      "name": "Kliento vardas",
+      "firstName": "Vardas",
+      "lastName": "Pavardė",
+      "customerNumber": "Kliento numeris",
+      "email": "El. paštas",
+      "phone": "Telefonas",
+      "company": "Įmonė",
+      "address": "Adresas",
+      "street": "Gatvė",
+      "postalCode": "Pašto kodas",
+      "city": "Miestas",
+      "state": "Valstija / regionas",
+      "country": "Šalis",
+      "taxId": "PVM kodas",
+      "notes": "Kliento pastabos"
+    },
+    "vehicle": {
+      "description": "Transporto priemonė (metai, markė ir modelis viename stulpelyje)",
+      "make": "Markė",
+      "model": "Modelis",
+      "year": "Metai",
+      "vin": "VIN",
+      "licensePlate": "Valstybinis numeris",
+      "color": "Spalva",
+      "mileage": "Rida",
+      "fuelType": "Degalų tipas",
+      "transmission": "Pavarų dėžė",
+      "engineSize": "Variklio tūris",
+      "engineCode": "Variklio kodas",
+      "purchaseDate": "Pirkimo data",
+      "purchasePrice": "Pirkimo kaina"
+    },
+    "service": {
+      "date": "Paslaugos data",
+      "title": "Paslaugos pavadinimas",
+      "description": "Aprašymas",
+      "total": "Bendra suma",
+      "mileage": "Rida paslaugos metu",
+      "invoiceNumber": "Sąskaitos numeris",
+      "notes": "Paslaugos pastabos",
+      "technician": "Technikas"
+    }
+  },
+  "issues": {
+    "customer_name_required": "Trūksta kliento vardo",
+    "invalid_email": "El. paštas \"{value}\" netinkamas ir buvo praleistas",
+    "invalid_vin": "VIN \"{value}\" nėra 17 simbolių; paliktas kaip įvestas",
+    "vehicle_make_model_required": "Trūksta markės ir modelio",
+    "vehicle_year_required": "Trūksta metų",
+    "vehicle_year_invalid": "Metai \"{value}\" nėra tinkami metai",
+    "vehicle_identifier_required": "Nėra valst. numerio, VIN arba markės ir modelio transporto priemonei atpažinti",
+    "vehicle_not_found": "Jokia esama transporto priemonė neatitinka \"{value}\" ir nepakanka duomenų naujai sukurti",
+    "service_date_required": "Trūksta paslaugos datos",
+    "service_date_invalid": "Nepavyko nuskaityti datos \"{value}\"",
+    "service_title_required": "Trūksta paslaugos pavadinimo arba aprašymo",
+    "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}"
+  },
+  "errors": {
+    "uploadFailed": "Nepavyko nuskaityti failo",
+    "aiFailed": "DI nepavyko pasiūlyti susiejimo",
+    "dryRunFailed": "Bandomasis importas nepavyko",
+    "importFailed": "Importas nepavyko",
+    "undoFailed": "Nepavyko atšaukti importo"
+  },
+  "entry": {
+    "title": "Importuoti iš skaičiuoklės",
+    "description": "Perkelkite klientus, transporto priemones ir paslaugų istoriją iš bet kurios sistemos, eksportuojančios CSV arba Excel. Susiekite stulpelius, paleiskite bandomąjį importą ir tada importuokite. Kiekvieną importą galima atšaukti.",
+    "customers": "Importuoti klientus",
+    "vehicles": "Importuoti transporto priemones",
+    "services": "Importuoti paslaugų istoriją"
+  }
+}

+ 3 - 1
messages/lt/onboarding.json

@@ -76,7 +76,9 @@
     "allDoneTitle": "Viskas paruošta",
     "allDoneDescription": "Visi žingsniai atlikti ir jūsų dirbtuvės veikia.",
     "dismiss": "Slėpti kortelę",
-    "dismissAriaLabel": "Slėpti šį sąrašą"
+    "dismissAriaLabel": "Slėpti šį sąrašą",
+    "importTitle": "Jau turite klientų ir transporto priemonių kitur?",
+    "importDescription": "Importuokite juos iš skaičiuoklės arba senos sistemos"
   },
   "sampleData": {
     "title": "Pavyzdiniai duomenys",

+ 11 - 2
messages/lt/settings.json

@@ -1116,7 +1116,7 @@
   },
   "data": {
     "title": "Duomenų valdymas",
-    "description": "Eksportuokite duomenis kaip atsarginę kopiją arba importuokite anksčiau eksportuotą failą.",
+    "description": "Perkelkite duomenis iš senos sistemos, atsisiųskite visko atsarginę kopiją arba ją atkurkite.",
     "exportTitle": "Eksportuoti duomenis",
     "exportDescription": "Atsisiųskite duomenis kaip zip failą. Pasirinkite, kurias kategorijas įtraukti į atsarginę kopiją.",
     "includeInBackup": "Įtraukti į atsarginę kopiją:",
@@ -1203,7 +1203,16 @@
     "optTireHotel": "Padangų viešbutis",
     "optTireHotelDesc": "Saugomi komplektai, lentynos, paruošimas ir saugojimo sutartys",
     "optWorkshopConfig": "Dirbtuvių nustatymai",
-    "optWorkshopConfigDesc": "Darbo šablonai, rolės, webhook'ai ir suplanuotos ataskaitos"
+    "optWorkshopConfigDesc": "Darbo šablonai, rolės, webhook'ai ir suplanuotos ataskaitos",
+    "importHubDescription": "Pasirinkite, kur šiandien yra jūsų duomenys. Importas iš skaičiuoklių ir programų papildo tai, ką turite, ir gali būti atšauktas; atsarginės kopijos atkūrimas pakeičia viską.",
+    "sourceSpreadsheet": "Skaičiuoklė arba CSV",
+    "sourceSpreadsheetDesc": "Eksportas iš bet kurios dirbtuvių sistemos, apskaitos programos, „Google“ kontaktų, „Outlook“ ar „Excel“. Patvirtinate stulpelių atitikimą ir matote bandomąjį paleidimą prieš ką nors įrašant.",
+    "sourceLubelogDesc": "Transporto priemonės, aptarnavimo įrašai ir pastabos iš „LubeLog“ atsarginės kopijos.",
+    "sourceInvoiceNinjaDesc": "Klientai, produktai, sąskaitos ir mokėjimai iš „Invoice Ninja“ eksporto.",
+    "sourceBackup": "„Torqvoice“ atsarginė kopija",
+    "sourceBackupDesc": ".zip arba .json failas, atsisiųstas iš šio puslapio. Atkūrimas pakeičia viską šiose dirbtuvėse.",
+    "restore": "Atkurti",
+    "restoreTitle": "Atkurti atsarginę kopiją"
   },
   "license": {
     "title": "\"White-Label\" licencija",

+ 1 - 0
messages/lt/vehicles.json

@@ -4,6 +4,7 @@
     "archived": "Archyvuotos",
     "searchPlaceholder": "Ieškoti pagal numerį, markę, modelį, VIN...",
     "addVehicle": "Pridėti transporto priemonę",
+    "importVehicles": "Importuoti",
     "emptySearch": "Nėra transporto priemonių, atitinkančių paiešką.",
     "emptyArchived": "Archyvuotų transporto priemonių nėra.",
     "empty": "Transporto priemonių dar nėra.",

+ 4 - 0
messages/nb/audit.json

@@ -152,6 +152,8 @@
     "team_giveTechnicianTheApp": "Ga en tavle-tekniker en konto",
     "team_createDefaultRoles": "Opprettet standardrollene",
     "settings_setCustomFieldPlacement": "Oppdaterte plassering av egendefinert felt",
+    "import_commit": "Importerte regneark",
+    "import_undo": "Angret import",
     "integration_connect": "Koblet til en integrasjon",
     "integration_disconnect": "Koblet fra en integrasjon"
   },
@@ -278,6 +280,8 @@
     "work_bay_update": "Oppdaterte arbeidsplass «{name}»",
     "work_bay_delete": "Slettet arbeidsplass {id}",
     "settings_setCustomFieldPlacement": "Endret hvor et egendefinert felt skrives ut",
+    "import_commit": "Importerte et regneark: {count} poster opprettet",
+    "import_undo": "Angret en regnearkimport: {records} poster fjernet",
     "integration_connect": "Koblet til {name}",
     "integration_disconnect": "Koblet fra {name}"
   }

+ 0 - 39
messages/nb/customers.json

@@ -117,44 +117,5 @@
     "notesSaveError": "Kunne ikke lagre notater",
     "smsLabel": "Melding om serviceforespørsel",
     "smsDefault": "Hei {name}, angående din serviceforespørsel for {vehicle}: "
-  },
-  "import": {
-    "title": "Importer kunder",
-    "description": "Importer kunder fra CSV-, TSV- eller vCard-filer.",
-    "dropFile": "Dra og slipp en fil her",
-    "supportedFormats": "CSV, TSV, TXT eller vCard (.vcf)",
-    "browse": "Bla gjennom filer",
-    "noDataFound": "Ingen kundedata funnet i filen",
-    "rowsFound": "{count} kunde(r) funnet",
-    "colName": "Navn",
-    "colEmail": "E-post",
-    "colPhone": "Telefon",
-    "colCompany": "Bedrift",
-    "colAddress": "Adresse",
-    "chooseAnother": "Velg en annen fil",
-    "cancel": "Avbryt",
-    "importButton": "Importer {count} kunde(r)",
-    "importComplete": "Import fullført",
-    "importedCount": "{count} kunde(r) importert",
-    "skippedRows": "{count} rad(er) hoppet over på grunn av feil",
-    "rowError": "Rad {row} ({name}): {error}",
-    "done": "Ferdig",
-    "importFailed": "Kunne ikke importere kunder",
-    "checkingDuplicates": "Sjekker for duplikater...",
-    "duplicatesFound": "{count} duplikat(er) funnet",
-    "duplicatesBanner": "{count} eksisterende kunde(r) samsvarer med importdataene dine.",
-    "duplicatesHint": "Klikk Hopp over eller Slå sammen for å endre hvordan hvert duplikat håndteres.",
-    "colStatus": "Status",
-    "actionSkip": "Hopp over",
-    "actionMerge": "Slå sammen",
-    "actionNew": "Ny",
-    "summaryNew": "{count} nye",
-    "summaryMerge": "{count} slå sammen",
-    "summarySkip": "{count} hopp over",
-    "skipAll": "Hopp over alle",
-    "mergeAll": "Slå sammen alle",
-    "resultCreated": "{count} kunde(r) opprettet",
-    "resultMerged": "{count} kunde(r) slått sammen",
-    "resultSkipped": "{count} kunde(r) hoppet over"
   }
 }

+ 228 - 0
messages/nb/dataImport.json

@@ -0,0 +1,228 @@
+{
+  "title": "Importer {entity}",
+  "cancel": "Avbryt",
+  "done": "Ferdig",
+  "entities": {
+    "customers": "kunder",
+    "vehicles": "kjøretøy",
+    "services": "servicehistorikk"
+  },
+  "entityHints": {
+    "customers": "Navn, kontaktinformasjon, adresser. Én rad per kunde.",
+    "vehicles": "Merke, modell, årsmodell, skilt, VIN. En eierkolonne kobler til eller oppretter kunden.",
+    "services": "Tidligere jobber og fakturaer. Én rad per jobb, med kjøretøyet den hører til."
+  },
+  "groups": {
+    "customer": "Kunde",
+    "vehicle": "Kjøretøy",
+    "service": "Service"
+  },
+  "steps": {
+    "upload": "Velg hva du vil importere og last opp filen fra ditt nåværende system.",
+    "mapping": "Sjekk hvilken kolonne som blir hvilket felt. Ingenting er skrevet ennå.",
+    "preview": "Prøvekjøring: dette er nøyaktig hva importen vil gjøre. Gå gjennom det, og importer deretter.",
+    "importing": "Skriver rader. Hold dette vinduet åpent.",
+    "result": "Importen er fullført."
+  },
+  "upload": {
+    "drop": "Dra og slipp en fil her",
+    "formats": "CSV, Excel (.xlsx) eller vCard (.vcf), opptil 25 MB og 50 000 rader",
+    "browse": "Bla gjennom filer",
+    "reading": "Leser filen...",
+    "templateHint": "Ingen eksport ennå? Fyll ut en mal og last opp den.",
+    "templateXlsx": "Excel-mal",
+    "templateCsv": "CSV-mal",
+    "sourcesHint": "Fungerer med eksporter fra alle verkstedsystemer, regnskapsverktøy, Google Kontakter, Outlook eller et regneark. Kolonnenavn gjenkjennes på mange språk; du bekrefter koblingen før noe skrives."
+  },
+  "mapping": {
+    "rows": "{count, plural, one {# rad} other {# rader}}",
+    "colColumn": "Kolonne i filen din",
+    "colSample": "Eksempelverdier",
+    "colField": "Blir til",
+    "ignore": "Ignorer denne kolonnen",
+    "source": {
+      "preset": "Gjenkjent oppsett",
+      "header": "Matchet på overskrift",
+      "values": "Gjettet ut fra verdiene"
+    },
+    "suggestWithAi": "Foreslå med KI",
+    "aiApplied": "{count, plural, one {# kolonne koblet} other {# kolonner koblet}} av KI. Sjekk dem før du fortsetter.",
+    "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"
+  },
+  "options": {
+    "dateFormat": "Datoformat",
+    "dateAuto": "Oppdag automatisk",
+    "dateDMY": "Dag / måned / år",
+    "dateMDY": "Måned / dag / år",
+    "dateYMD": "År / måned / dag",
+    "decimal": "Desimaltegn",
+    "decimalAuto": "Oppdag automatisk",
+    "decimalDot": "Punktum (1,234.56)",
+    "decimalComma": "Komma (1.234,56)",
+    "countryCode": "Landkode for telefonnumre",
+    "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"
+  },
+  "summary": {
+    "create": "Vil bli opprettet",
+    "update": "Vil bli oppdatert",
+    "skip": "Vil bli hoppet over",
+    "error": "Kan ikke importeres",
+    "warnings": "Med advarsler",
+    "customersToCreate": "{count, plural, one {# ny kunde} other {# nye kunder}}",
+    "customersToUpdate": "{count, plural, one {# kunde oppdatert} other {# kunder oppdatert}}",
+    "vehiclesToCreate": "{count, plural, one {# nytt kjøretøy} other {# nye kjøretøy}}",
+    "vehiclesToUpdate": "{count, plural, one {# kjøretøy oppdatert} other {# kjøretøy oppdatert}}",
+    "servicesToCreate": "{count, plural, one {# servicepost} other {# serviceposter}}"
+  },
+  "actions": {
+    "create": "Opprett",
+    "update": "Oppdater",
+    "skip": "Hopp over",
+    "error": "Feil"
+  },
+  "matchOn": {
+    "number": "kundenummer",
+    "email": "e-post",
+    "phone": "telefon",
+    "name": "navn",
+    "vin": "VIN",
+    "plate": "skilt",
+    "makeModelYear": "merke, modell og årsmodell"
+  },
+  "preview": {
+    "colAction": "Handling",
+    "colOwner": "Eier",
+    "colDetails": "Detaljer",
+    "empty": "Ingen rader samsvarer med dette filteret.",
+    "matchedOn": "Samsvarer med {name} (på {on})",
+    "linksCustomer": "Koblet til kunde {name}",
+    "linksVehicle": "Koblet til {label}",
+    "sameCustomerAsRow": "Samme kunde som rad {row}",
+    "sameVehicleAsRow": "Samme kjøretøy som rad {row}",
+    "createsCustomer": "Oppretter kunden",
+    "createsVehicle": "Oppretter kjøretøyet",
+    "downloadReport": "Last ned rapport fra prøvekjøringen",
+    "limitExceeded": "Abonnementet ditt tillater {remaining} kunder til, men denne importen ville opprette {needed}. Fjern rader eller oppgrader abonnementet.",
+    "pageInfo": "{from}–{to} av {total}",
+    "prev": "Forrige",
+    "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."
+  },
+  "importing": {
+    "title": "Importerer...",
+    "starting": "Starter",
+    "progress": "{done} av {total} rader",
+    "keepOpen": "Store filer tar et minutt eller to."
+  },
+  "result": {
+    "title": "Import fullført",
+    "undoneTitle": "Import angret",
+    "created": "{count, plural, one {# post opprettet} other {# poster opprettet}}",
+    "updated": "{count, plural, one {# post oppdatert} other {# poster oppdatert}}",
+    "skipped": "{count, plural, one {# rad hoppet over} other {# rader hoppet over}}",
+    "failed": "{count, plural, one {# rad ikke importert} other {# rader ikke importert}}",
+    "writeFailures": "{count, plural, one {# rad feilet under skriving} other {# rader feilet under skriving}}",
+    "rowFailure": "Rad {row}: {error}",
+    "undo": "Angre denne importen",
+    "undone": "Fjernet {customers} kunder, {vehicles} kjøretøy og {services} serviceposter.",
+    "undoHint": "Angre fjerner alle poster denne importen opprettet. Poster som bare ble oppdatert beholder de nye verdiene."
+  },
+  "history": {
+    "title": "Importhistorikk",
+    "description": "Regnearkimporter kjørt på dette verkstedet. Angre fjerner det en import opprettet.",
+    "loading": "Laster...",
+    "statusUndone": "Angret",
+    "counts": "{created} opprettet, {updated} oppdatert, {skipped} hoppet over, {failed} feilet",
+    "undo": "Angre",
+    "undone": "Fjernet {customers} kunder, {vehicles} kjøretøy og {services} serviceposter.",
+    "undoFailed": "Kunne ikke angre importen",
+    "confirmTitle": "Angre denne importen?",
+    "confirmDescription": "Fjerner det {file} opprettet: {customers} kunder, {vehicles} kjøretøy og {services} serviceposter. Poster importen bare oppdaterte beholdes.",
+    "cancel": "Avbryt",
+    "confirm": "Angre import"
+  },
+  "fields": {
+    "customer": {
+      "name": "Kundenavn",
+      "firstName": "Fornavn",
+      "lastName": "Etternavn",
+      "customerNumber": "Kundenummer",
+      "email": "E-post",
+      "phone": "Telefon",
+      "company": "Firma",
+      "address": "Adresse",
+      "street": "Gate",
+      "postalCode": "Postnummer",
+      "city": "Poststed",
+      "state": "Fylke / region",
+      "country": "Land",
+      "taxId": "Organisasjonsnummer",
+      "notes": "Kundenotater"
+    },
+    "vehicle": {
+      "description": "Kjøretøy (årsmodell merke modell i én kolonne)",
+      "make": "Merke",
+      "model": "Modell",
+      "year": "Årsmodell",
+      "vin": "VIN",
+      "licensePlate": "Registreringsnummer",
+      "color": "Farge",
+      "mileage": "Kilometerstand",
+      "fuelType": "Drivstofftype",
+      "transmission": "Girkasse",
+      "engineSize": "Motorstørrelse",
+      "engineCode": "Motorkode",
+      "purchaseDate": "Kjøpsdato",
+      "purchasePrice": "Kjøpspris"
+    },
+    "service": {
+      "date": "Servicedato",
+      "title": "Servicetittel",
+      "description": "Beskrivelse",
+      "total": "Totalbeløp",
+      "mileage": "Kilometerstand ved service",
+      "invoiceNumber": "Fakturanummer",
+      "notes": "Servicenotater",
+      "technician": "Tekniker"
+    }
+  },
+  "issues": {
+    "customer_name_required": "Kundenavn mangler",
+    "invalid_email": "E-posten \"{value}\" er ikke gyldig og ble utelatt",
+    "invalid_vin": "VIN \"{value}\" er ikke 17 tegn; beholdt som skrevet",
+    "vehicle_make_model_required": "Merke og modell mangler",
+    "vehicle_year_required": "Årsmodell mangler",
+    "vehicle_year_invalid": "Årsmodell \"{value}\" er ikke et gyldig år",
+    "vehicle_identifier_required": "Ingen skilt, VIN eller merke og modell til å identifisere kjøretøyet",
+    "vehicle_not_found": "Ingen eksisterende kjøretøy samsvarer med \"{value}\", og det er ikke nok informasjon til å opprette et",
+    "service_date_required": "Servicedato mangler",
+    "service_date_invalid": "Datoen \"{value}\" kunne ikke leses",
+    "service_title_required": "Servicetittel eller beskrivelse mangler",
+    "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}"
+  },
+  "errors": {
+    "uploadFailed": "Filen kunne ikke leses",
+    "aiFailed": "KI kunne ikke foreslå en kobling",
+    "dryRunFailed": "Prøvekjøringen mislyktes",
+    "importFailed": "Importen mislyktes",
+    "undoFailed": "Kunne ikke angre importen"
+  },
+  "entry": {
+    "title": "Importer fra et regneark",
+    "description": "Hent kunder, kjøretøy og servicehistorikk fra ethvert system som eksporterer CSV eller Excel. Koble kolonnene, kjør en prøvekjøring, og importer deretter. Hver import kan angres.",
+    "customers": "Importer kunder",
+    "vehicles": "Importer kjøretøy",
+    "services": "Importer servicehistorikk"
+  }
+}

+ 3 - 1
messages/nb/onboarding.json

@@ -76,7 +76,9 @@
     "allDoneTitle": "Alt er klart",
     "allDoneDescription": "Alle stegene er fullført og verkstedet ditt er i gang.",
     "dismiss": "Skjul kortet",
-    "dismissAriaLabel": "Skjul denne sjekklisten"
+    "dismissAriaLabel": "Skjul denne sjekklisten",
+    "importTitle": "Har du allerede kunder og kjøretøy et annet sted?",
+    "importDescription": "Importer dem fra et regneark eller det gamle systemet ditt"
   },
   "sampleData": {
     "title": "Eksempeldata",

+ 11 - 2
messages/nb/settings.json

@@ -1116,7 +1116,7 @@
   },
   "data": {
     "title": "Datahandtering",
-    "description": "Eksporter dataene dine som sikkerhetskopi eller importer en tidligere eksportert fil.",
+    "description": "Hent inn data fra det gamle systemet ditt, last ned en sikkerhetskopi av alt, eller gjenopprett en.",
     "exportTitle": "Eksporter data",
     "exportDescription": "Last ned dataene dine som en zip-fil. Velg hvilke kategorier som skal inkluderes.",
     "includeInBackup": "Inkluder i sikkerhetskopi:",
@@ -1203,7 +1203,16 @@
     "optTireHotel": "Dekkhotell",
     "optTireHotelDesc": "Lagrede dekksett, hyller, klargjøring og lagringsavtaler",
     "optWorkshopConfig": "Verkstedoppsett",
-    "optWorkshopConfigDesc": "Arbeidsmaler, roller, webhooks og planlagte rapporter"
+    "optWorkshopConfigDesc": "Arbeidsmaler, roller, webhooks og planlagte rapporter",
+    "importHubDescription": "Velg hvor dataene dine er i dag. Import fra regneark og apper legges til det du har og kan angres; gjenoppretting av en sikkerhetskopi erstatter alt.",
+    "sourceSpreadsheet": "Regneark eller CSV",
+    "sourceSpreadsheetDesc": "Eksporter fra ethvert verkstedsystem, regnskapsprogram, Google Kontakter, Outlook eller Excel. Du bekrefter kolonnetilordningen og ser en prøvekjøring før noe skrives.",
+    "sourceLubelogDesc": "Kjøretøy, serviceoppføringer og notater fra en LubeLog-sikkerhetskopi.",
+    "sourceInvoiceNinjaDesc": "Kunder, produkter, fakturaer og betalinger fra en Invoice Ninja-eksport.",
+    "sourceBackup": "Torqvoice-sikkerhetskopi",
+    "sourceBackupDesc": "En .zip- eller .json-fil lastet ned fra denne siden. Gjenoppretting erstatter alt i dette verkstedet.",
+    "restore": "Gjenopprett",
+    "restoreTitle": "Gjenopprett en sikkerhetskopi"
   },
   "license": {
     "title": "White-label-lisens",

+ 1 - 0
messages/nb/vehicles.json

@@ -4,6 +4,7 @@
     "archived": "Arkivert",
     "searchPlaceholder": "Sok etter skilt, merke, modell, VIN...",
     "addVehicle": "Legg til kjøretøy",
+    "importVehicles": "Importer",
     "emptySearch": "Ingen kjøretøy samsvarer med søket ditt.",
     "emptyArchived": "Ingen arkiverte kjøretøy.",
     "empty": "Ingen kjøretøy ennå.",

+ 4 - 0
messages/nl/audit.json

@@ -152,6 +152,8 @@
     "team_giveTechnicianTheApp": "Bordmonteur een account gegeven",
     "team_createDefaultRoles": "Standaardrollen aangemaakt",
     "settings_setCustomFieldPlacement": "Plaatsing van aangepast veld bijgewerkt",
+    "import_commit": "Spreadsheet geïmporteerd",
+    "import_undo": "Import ongedaan gemaakt",
     "integration_connect": "Integratie gekoppeld",
     "integration_disconnect": "Integratie losgekoppeld"
   },
@@ -278,6 +280,8 @@
     "work_bay_update": "Werkplek \"{name}\" bijgewerkt",
     "work_bay_delete": "Werkplek {id} verwijderd",
     "settings_setCustomFieldPlacement": "Wijzigde waar een aangepast veld wordt afgedrukt",
+    "import_commit": "Spreadsheet geïmporteerd: {count} records aangemaakt",
+    "import_undo": "Spreadsheetimport ongedaan gemaakt: {records} records verwijderd",
     "integration_connect": "{name} gekoppeld",
     "integration_disconnect": "{name} losgekoppeld"
   }

+ 0 - 39
messages/nl/customers.json

@@ -117,44 +117,5 @@
     "notesSaveError": "Opslaan van opmerkingen mislukt",
     "smsLabel": "Serviceaanvraag bericht",
     "smsDefault": "Hallo {name}, betreffende uw serviceaanvraag voor uw {vehicle}: "
-  },
-  "import": {
-    "title": "Klanten importeren",
-    "description": "Importeer klanten vanuit CSV-, TSV- of vCard-bestanden.",
-    "dropFile": "Sleep een bestand hierheen",
-    "supportedFormats": "CSV, TSV, TXT of vCard (.vcf)",
-    "browse": "Bestanden bladeren",
-    "noDataFound": "Geen klantgegevens gevonden in bestand",
-    "rowsFound": "{count} klant(en) gevonden",
-    "colName": "Naam",
-    "colEmail": "E-mail",
-    "colPhone": "Telefoon",
-    "colCompany": "Bedrijf",
-    "colAddress": "Adres",
-    "chooseAnother": "Kies een ander bestand",
-    "cancel": "Annuleren",
-    "importButton": "{count} klant(en) importeren",
-    "importComplete": "Import voltooid",
-    "importedCount": "{count} klant(en) succesvol geïmporteerd",
-    "skippedRows": "{count} rij(en) overgeslagen wegens fouten",
-    "rowError": "Rij {row} ({name}): {error}",
-    "done": "Klaar",
-    "importFailed": "Importeren van klanten mislukt",
-    "checkingDuplicates": "Controleren op duplicaten...",
-    "duplicatesFound": "{count} duplica(a)t(en) gevonden",
-    "duplicatesBanner": "{count} bestaande klant(en) komen overeen met uw importgegevens.",
-    "duplicatesHint": "Klik op Overslaan of Samenvoegen om te wijzigen hoe elk duplicaat wordt behandeld.",
-    "colStatus": "Status",
-    "actionSkip": "Overslaan",
-    "actionMerge": "Samenvoegen",
-    "actionNew": "Nieuw",
-    "summaryNew": "{count} nieuw",
-    "summaryMerge": "{count} samenvoegen",
-    "summarySkip": "{count} overslaan",
-    "skipAll": "Alles overslaan",
-    "mergeAll": "Alles samenvoegen",
-    "resultCreated": "{count} klant(en) aangemaakt",
-    "resultMerged": "{count} klant(en) samengevoegd",
-    "resultSkipped": "{count} klant(en) overgeslagen"
   }
 }

+ 228 - 0
messages/nl/dataImport.json

@@ -0,0 +1,228 @@
+{
+  "title": "Importeren van {entity}",
+  "cancel": "Annuleren",
+  "done": "Gereed",
+  "entities": {
+    "customers": "klanten",
+    "vehicles": "voertuigen",
+    "services": "servicegeschiedenis"
+  },
+  "entityHints": {
+    "customers": "Namen, contactgegevens, adressen. Eén rij per klant.",
+    "vehicles": "Merk, model, bouwjaar, kenteken, VIN. Een eigenaarkolom koppelt of maakt de klant aan.",
+    "services": "Eerdere klussen en facturen. Eén rij per klus, met het voertuig waar die bij hoort."
+  },
+  "groups": {
+    "customer": "Klant",
+    "vehicle": "Voertuig",
+    "service": "Service"
+  },
+  "steps": {
+    "upload": "Kies wat u wilt importeren en upload het bestand uit uw huidige systeem.",
+    "mapping": "Controleer welke kolom welk veld wordt. Er wordt nog niets weggeschreven.",
+    "preview": "Proefimport: dit is precies wat de import gaat doen. Controleer het en importeer daarna.",
+    "importing": "Rijen worden weggeschreven. Houd dit venster open.",
+    "result": "Import voltooid."
+  },
+  "upload": {
+    "drop": "Sleep een bestand hierheen",
+    "formats": "CSV, Excel (.xlsx) of vCard (.vcf), tot 25 MB en 50.000 rijen",
+    "browse": "Bestand kiezen",
+    "reading": "Bestand wordt gelezen...",
+    "templateHint": "Nog geen export? Vul een sjabloon in en upload die.",
+    "templateXlsx": "Excel-sjabloon",
+    "templateCsv": "CSV-sjabloon",
+    "sourcesHint": "Werkt met exports uit elk werkplaatssysteem, boekhoudpakket, Google Contacts, Outlook of een spreadsheet. Kolomnamen worden in veel talen herkend; u bevestigt de toewijzing voordat er iets wordt weggeschreven."
+  },
+  "mapping": {
+    "rows": "{count, plural, one {# rij} other {# rijen}}",
+    "colColumn": "Kolom in uw bestand",
+    "colSample": "Voorbeeldwaarden",
+    "colField": "Wordt",
+    "ignore": "Deze kolom negeren",
+    "source": {
+      "preset": "Herkende indeling",
+      "header": "Gekoppeld op kolomkop",
+      "values": "Afgeleid uit de waarden"
+    },
+    "suggestWithAi": "Voorstel met AI",
+    "aiApplied": "{count, plural, one {# kolom toegewezen} other {# kolommen toegewezen}} door AI. Controleer ze voordat u verdergaat.",
+    "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"
+  },
+  "options": {
+    "dateFormat": "Datumnotatie",
+    "dateAuto": "Automatisch herkennen",
+    "dateDMY": "Dag / maand / jaar",
+    "dateMDY": "Maand / dag / jaar",
+    "dateYMD": "Jaar / maand / dag",
+    "decimal": "Decimaalteken",
+    "decimalAuto": "Automatisch herkennen",
+    "decimalDot": "Punt (1,234.56)",
+    "decimalComma": "Komma (1.234,56)",
+    "countryCode": "Landcode voor telefoonnummers",
+    "duplicates": "Als een rij overeenkomt met een bestaand record",
+    "duplicatesUpdate": "Bestaand record bijwerken",
+    "duplicatesSkip": "Rij overslaan",
+    "duplicatesCreate": "Toch een nieuw record aanmaken"
+  },
+  "summary": {
+    "create": "Wordt aangemaakt",
+    "update": "Wordt bijgewerkt",
+    "skip": "Wordt overgeslagen",
+    "error": "Kan niet worden geïmporteerd",
+    "warnings": "Met waarschuwingen",
+    "customersToCreate": "{count, plural, one {# nieuwe klant} other {# nieuwe klanten}}",
+    "customersToUpdate": "{count, plural, one {# klant bijgewerkt} other {# klanten bijgewerkt}}",
+    "vehiclesToCreate": "{count, plural, one {# nieuw voertuig} other {# nieuwe voertuigen}}",
+    "vehiclesToUpdate": "{count, plural, one {# voertuig bijgewerkt} other {# voertuigen bijgewerkt}}",
+    "servicesToCreate": "{count, plural, one {# servicerecord} other {# servicerecords}}"
+  },
+  "actions": {
+    "create": "Aanmaken",
+    "update": "Bijwerken",
+    "skip": "Overslaan",
+    "error": "Fout"
+  },
+  "matchOn": {
+    "number": "klantnummer",
+    "email": "e-mail",
+    "phone": "telefoon",
+    "name": "naam",
+    "vin": "VIN",
+    "plate": "kenteken",
+    "makeModelYear": "merk, model en bouwjaar"
+  },
+  "preview": {
+    "colAction": "Actie",
+    "colOwner": "Eigenaar",
+    "colDetails": "Details",
+    "empty": "Geen rijen voldoen aan dit filter.",
+    "matchedOn": "Komt overeen met {name} (op {on})",
+    "linksCustomer": "Gekoppeld aan klant {name}",
+    "linksVehicle": "Gekoppeld aan {label}",
+    "sameCustomerAsRow": "Zelfde klant als rij {row}",
+    "sameVehicleAsRow": "Zelfde voertuig als rij {row}",
+    "createsCustomer": "Maakt de klant aan",
+    "createsVehicle": "Maakt het voertuig aan",
+    "downloadReport": "Proefimportrapport downloaden",
+    "limitExceeded": "Uw abonnement staat nog {remaining} klanten toe, maar deze import zou er {needed} aanmaken. Verwijder rijen of upgrade uw abonnement.",
+    "pageInfo": "{from}–{to} van {total}",
+    "prev": "Vorige",
+    "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."
+  },
+  "importing": {
+    "title": "Importeren...",
+    "starting": "Starten",
+    "progress": "{done} van {total} rijen",
+    "keepOpen": "Grote bestanden duren een minuut of twee."
+  },
+  "result": {
+    "title": "Import voltooid",
+    "undoneTitle": "Import ongedaan gemaakt",
+    "created": "{count, plural, one {# record aangemaakt} other {# records aangemaakt}}",
+    "updated": "{count, plural, one {# record bijgewerkt} other {# records bijgewerkt}}",
+    "skipped": "{count, plural, one {# rij overgeslagen} other {# rijen overgeslagen}}",
+    "failed": "{count, plural, one {# rij niet geïmporteerd} other {# rijen niet geïmporteerd}}",
+    "writeFailures": "{count, plural, one {# rij mislukt bij het wegschrijven} other {# rijen mislukt bij het wegschrijven}}",
+    "rowFailure": "Rij {row}: {error}",
+    "undo": "Deze import ongedaan maken",
+    "undone": "{customers} klanten, {vehicles} voertuigen en {services} servicerecords verwijderd.",
+    "undoHint": "Ongedaan maken verwijdert elk record dat deze import heeft aangemaakt. Records die alleen zijn bijgewerkt behouden hun nieuwe waarden."
+  },
+  "history": {
+    "title": "Importgeschiedenis",
+    "description": "Spreadsheetimports die in deze werkplaats zijn uitgevoerd. Ongedaan maken verwijdert wat een import heeft aangemaakt.",
+    "loading": "Laden...",
+    "statusUndone": "Ongedaan gemaakt",
+    "counts": "{created} aangemaakt, {updated} bijgewerkt, {skipped} overgeslagen, {failed} mislukt",
+    "undo": "Ongedaan maken",
+    "undone": "{customers} klanten, {vehicles} voertuigen en {services} servicerecords verwijderd.",
+    "undoFailed": "Kon de import niet ongedaan maken",
+    "confirmTitle": "Deze import ongedaan maken?",
+    "confirmDescription": "Verwijdert wat {file} heeft aangemaakt: {customers} klanten, {vehicles} voertuigen en {services} servicerecords. Records die de import alleen heeft bijgewerkt blijven behouden.",
+    "cancel": "Annuleren",
+    "confirm": "Import ongedaan maken"
+  },
+  "fields": {
+    "customer": {
+      "name": "Klantnaam",
+      "firstName": "Voornaam",
+      "lastName": "Achternaam",
+      "customerNumber": "Klantnummer",
+      "email": "E-mail",
+      "phone": "Telefoon",
+      "company": "Bedrijf",
+      "address": "Adres",
+      "street": "Straat",
+      "postalCode": "Postcode",
+      "city": "Plaats",
+      "state": "Provincie / regio",
+      "country": "Land",
+      "taxId": "Btw-nummer",
+      "notes": "Klantnotities"
+    },
+    "vehicle": {
+      "description": "Voertuig (bouwjaar merk model in één kolom)",
+      "make": "Merk",
+      "model": "Model",
+      "year": "Bouwjaar",
+      "vin": "VIN",
+      "licensePlate": "Kenteken",
+      "color": "Kleur",
+      "mileage": "Kilometerstand",
+      "fuelType": "Brandstoftype",
+      "transmission": "Transmissie",
+      "engineSize": "Motorinhoud",
+      "engineCode": "Motorcode",
+      "purchaseDate": "Aankoopdatum",
+      "purchasePrice": "Aankoopprijs"
+    },
+    "service": {
+      "date": "Servicedatum",
+      "title": "Servicetitel",
+      "description": "Omschrijving",
+      "total": "Totaalbedrag",
+      "mileage": "Kilometerstand bij service",
+      "invoiceNumber": "Factuurnummer",
+      "notes": "Servicenotities",
+      "technician": "Monteur"
+    }
+  },
+  "issues": {
+    "customer_name_required": "Klantnaam ontbreekt",
+    "invalid_email": "E-mailadres \"{value}\" is ongeldig en is weggelaten",
+    "invalid_vin": "VIN \"{value}\" heeft geen 17 tekens; bewaard zoals ingevoerd",
+    "vehicle_make_model_required": "Merk en model ontbreken",
+    "vehicle_year_required": "Bouwjaar ontbreekt",
+    "vehicle_year_invalid": "Bouwjaar \"{value}\" is geen geldig jaar",
+    "vehicle_identifier_required": "Geen kenteken, VIN of merk en model om het voertuig te herkennen",
+    "vehicle_not_found": "Geen bestaand voertuig komt overeen met \"{value}\" en er is te weinig informatie om er een aan te maken",
+    "service_date_required": "Servicedatum ontbreekt",
+    "service_date_invalid": "Datum \"{value}\" kon niet worden gelezen",
+    "service_title_required": "Servicetitel of omschrijving ontbreekt",
+    "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}"
+  },
+  "errors": {
+    "uploadFailed": "Het bestand kon niet worden gelezen",
+    "aiFailed": "AI kon geen toewijzing voorstellen",
+    "dryRunFailed": "De proefimport is mislukt",
+    "importFailed": "De import is mislukt",
+    "undoFailed": "Kon de import niet ongedaan maken"
+  },
+  "entry": {
+    "title": "Importeren uit een spreadsheet",
+    "description": "Haal klanten, voertuigen en servicegeschiedenis over uit elk systeem dat CSV of Excel exporteert. Wijs de kolommen toe, voer een proefimport uit en importeer daarna. Elke import kan ongedaan worden gemaakt.",
+    "customers": "Klanten importeren",
+    "vehicles": "Voertuigen importeren",
+    "services": "Servicegeschiedenis importeren"
+  }
+}

+ 3 - 1
messages/nl/onboarding.json

@@ -76,7 +76,9 @@
     "allDoneTitle": "Alles staat klaar",
     "allDoneDescription": "Alle stappen zijn afgerond en uw werkplaats draait.",
     "dismiss": "Kaart verbergen",
-    "dismissAriaLabel": "Deze checklist verbergen"
+    "dismissAriaLabel": "Deze checklist verbergen",
+    "importTitle": "Heeft u al ergens klanten en voertuigen?",
+    "importDescription": "Importeer ze uit een spreadsheet of uw oude systeem"
   },
   "sampleData": {
     "title": "Voorbeeldgegevens",

+ 11 - 2
messages/nl/settings.json

@@ -1116,7 +1116,7 @@
   },
   "data": {
     "title": "Gegevensbeheer",
-    "description": "Exporteer uw gegevens als back-up of importeer een eerder geëxporteerd bestand.",
+    "description": "Haal gegevens uit uw oude systeem, download een back-up van alles of zet er een terug.",
     "exportTitle": "Gegevens exporteren",
     "exportDescription": "Download uw gegevens als zipbestand. Selecteer welke categorieën u wilt opnemen.",
     "includeInBackup": "Opnemen in back-up:",
@@ -1203,7 +1203,16 @@
     "optTireHotel": "Bandenhotel",
     "optTireHotelDesc": "Opgeslagen bandensets, stellingen, voorbereiding en opslagovereenkomsten",
     "optWorkshopConfig": "Werkplaatsinrichting",
-    "optWorkshopConfigDesc": "Arbeidsjablonen, rollen, webhooks en geplande rapporten"
+    "optWorkshopConfigDesc": "Arbeidsjablonen, rollen, webhooks en geplande rapporten",
+    "importHubDescription": "Kies waar uw gegevens nu staan. Imports uit spreadsheets en apps komen bovenop wat u al hebt en zijn terug te draaien; een back-up terugzetten vervangt alles.",
+    "sourceSpreadsheet": "Spreadsheet of CSV",
+    "sourceSpreadsheetDesc": "Exports uit elk werkplaatssysteem, boekhoudpakket, Google Contacten, Outlook of Excel. U bevestigt de kolomtoewijzing en ziet een proefrun voordat er iets wordt weggeschreven.",
+    "sourceLubelogDesc": "Voertuigen, onderhoudsrecords en notities uit een LubeLog-back-up.",
+    "sourceInvoiceNinjaDesc": "Klanten, producten, facturen en betalingen uit een Invoice Ninja-export.",
+    "sourceBackup": "Torqvoice-back-up",
+    "sourceBackupDesc": "Een .zip- of .json-bestand dat u op deze pagina hebt gedownload. Terugzetten vervangt alles in deze werkplaats.",
+    "restore": "Terugzetten",
+    "restoreTitle": "Een back-up terugzetten"
   },
   "license": {
     "title": "White-label licentie",

+ 1 - 0
messages/nl/vehicles.json

@@ -4,6 +4,7 @@
     "archived": "Gearchiveerd",
     "searchPlaceholder": "Zoek op kenteken, merk, model, VIN...",
     "addVehicle": "Voertuig toevoegen",
+    "importVehicles": "Importeren",
     "emptySearch": "Geen voertuigen gevonden voor uw zoekopdracht.",
     "emptyArchived": "Geen gearchiveerde voertuigen.",
     "empty": "Nog geen voertuigen.",

+ 4 - 0
messages/pl/audit.json

@@ -152,6 +152,8 @@
     "team_giveTechnicianTheApp": "Nadano konto technikowi z tablicy",
     "team_createDefaultRoles": "Utworzono role standardowe",
     "settings_setCustomFieldPlacement": "Zaktualizowano miejsce pola niestandardowego",
+    "import_commit": "Zaimportowano arkusz",
+    "import_undo": "Cofnięto import",
     "integration_connect": "Połączono integrację",
     "integration_disconnect": "Rozłączono integrację"
   },
@@ -278,6 +280,8 @@
     "work_bay_update": "Zaktualizowano stanowisko „{name}”",
     "work_bay_delete": "Usunięto stanowisko {id}",
     "settings_setCustomFieldPlacement": "Zmieniono miejsce drukowania pola niestandardowego",
+    "import_commit": "Zaimportowano arkusz: utworzono {count} rekordów",
+    "import_undo": "Cofnięto import arkusza: usunięto {records} rekordów",
     "integration_connect": "Połączono {name}",
     "integration_disconnect": "Rozłączono {name}"
   }

+ 0 - 39
messages/pl/customers.json

@@ -117,44 +117,5 @@
     "notesSaveError": "Nie udało się zapisać notatek",
     "smsLabel": "Wiadomość dotycząca zlecenia serwisowego",
     "smsDefault": "Cześć {name}, w sprawie Twojego zlecenia serwisowego dotyczącego {vehicle}: "
-  },
-  "import": {
-    "title": "Importuj klientów",
-    "description": "Importuj klientów z plików CSV, TSV lub vCard.",
-    "dropFile": "Przeciągnij i upuść plik tutaj",
-    "supportedFormats": "CSV, TSV, TXT lub vCard (.vcf)",
-    "browse": "Przeglądaj pliki",
-    "noDataFound": "Nie znaleziono danych klientów w pliku",
-    "rowsFound": "Znaleziono {count} klient(ów)",
-    "colName": "Imię i nazwisko",
-    "colEmail": "E-mail",
-    "colPhone": "Telefon",
-    "colCompany": "Firma",
-    "colAddress": "Adres",
-    "chooseAnother": "Wybierz inny plik",
-    "cancel": "Anuluj",
-    "importButton": "Importuj {count} klient(ów)",
-    "importComplete": "Import zakończony",
-    "importedCount": "{count} klient(ów) zaimportowano pomyślnie",
-    "skippedRows": "{count} wiersz(y) pominięto z powodu błędów",
-    "rowError": "Wiersz {row} ({name}): {error}",
-    "done": "Gotowe",
-    "importFailed": "Nie udało się zaimportować klientów",
-    "checkingDuplicates": "Sprawdzanie duplikatów...",
-    "duplicatesFound": "Znaleziono {count} duplikat(ów)",
-    "duplicatesBanner": "{count} istniejący(ch) klient(ów) pasuje do danych importu.",
-    "duplicatesHint": "Kliknij Pomiń lub Scal, aby zmienić sposób obsługi każdego duplikatu.",
-    "colStatus": "Status",
-    "actionSkip": "Pomiń",
-    "actionMerge": "Scal",
-    "actionNew": "Nowy",
-    "summaryNew": "{count} nowy(ch)",
-    "summaryMerge": "{count} scal",
-    "summarySkip": "{count} pomiń",
-    "skipAll": "Pomiń wszystkie",
-    "mergeAll": "Scal wszystkie",
-    "resultCreated": "{count} klient(ów) utworzono",
-    "resultMerged": "{count} klient(ów) scalono",
-    "resultSkipped": "{count} klient(ów) pominięto"
   }
 }

+ 228 - 0
messages/pl/dataImport.json

@@ -0,0 +1,228 @@
+{
+  "title": "Importuj {entity}",
+  "cancel": "Anuluj",
+  "done": "Gotowe",
+  "entities": {
+    "customers": "klientów",
+    "vehicles": "pojazdy",
+    "services": "historię serwisową"
+  },
+  "entityHints": {
+    "customers": "Nazwy, dane kontaktowe, adresy. Jeden wiersz na klienta.",
+    "vehicles": "Marka, model, rok, rejestracja, VIN. Kolumna właściciela łączy z klientem lub go tworzy.",
+    "services": "Wcześniejsze zlecenia i faktury. Jeden wiersz na zlecenie, z pojazdem, którego dotyczy."
+  },
+  "groups": {
+    "customer": "Klient",
+    "vehicle": "Pojazd",
+    "service": "Serwis"
+  },
+  "steps": {
+    "upload": "Wybierz, co zaimportować, i prześlij plik z obecnego systemu.",
+    "mapping": "Sprawdź, która kolumna stanie się którym polem. Nic nie jest jeszcze zapisywane.",
+    "preview": "Próbny import: dokładnie to zrobi import. Przejrzyj wynik, a potem importuj.",
+    "importing": "Zapisywanie wierszy. Nie zamykaj tego okna.",
+    "result": "Import zakończony."
+  },
+  "upload": {
+    "drop": "Przeciągnij i upuść plik tutaj",
+    "formats": "CSV, Excel (.xlsx) lub vCard (.vcf), do 25 MB i 50 000 wierszy",
+    "browse": "Przeglądaj pliki",
+    "reading": "Odczytywanie pliku...",
+    "templateHint": "Nie masz jeszcze eksportu? Wypełnij szablon i prześlij go.",
+    "templateXlsx": "Szablon Excel",
+    "templateCsv": "Szablon CSV",
+    "sourcesHint": "Działa z eksportami z dowolnego systemu warsztatowego, programu księgowego, Kontaktów Google, Outlooka lub arkusza kalkulacyjnego. Nazwy kolumn są rozpoznawane w wielu językach; mapowanie potwierdzasz, zanim cokolwiek zostanie zapisane."
+  },
+  "mapping": {
+    "rows": "{count, plural, one {# wiersz} few {# wiersze} many {# wierszy} other {# wiersza}}",
+    "colColumn": "Kolumna w Twoim pliku",
+    "colSample": "Przykładowe wartości",
+    "colField": "Staje się",
+    "ignore": "Pomiń tę kolumnę",
+    "source": {
+      "preset": "Rozpoznany układ",
+      "header": "Dopasowano po nagłówku",
+      "values": "Odgadnięto z wartości"
+    },
+    "suggestWithAi": "Zaproponuj z AI",
+    "aiApplied": "AI zmapowało {count, plural, one {# kolumnę} few {# kolumny} many {# kolumn} other {# kolumny}}. Sprawdź je przed kontynuowaniem.",
+    "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"
+  },
+  "options": {
+    "dateFormat": "Format daty",
+    "dateAuto": "Wykryj automatycznie",
+    "dateDMY": "Dzień / miesiąc / rok",
+    "dateMDY": "Miesiąc / dzień / rok",
+    "dateYMD": "Rok / miesiąc / dzień",
+    "decimal": "Separator dziesiętny",
+    "decimalAuto": "Wykryj automatycznie",
+    "decimalDot": "Kropka (1,234.56)",
+    "decimalComma": "Przecinek (1.234,56)",
+    "countryCode": "Kod kraju dla numerów telefonów",
+    "duplicates": "Gdy wiersz pasuje do istniejącego rekordu",
+    "duplicatesUpdate": "Zaktualizuj istniejący rekord",
+    "duplicatesSkip": "Pomiń wiersz",
+    "duplicatesCreate": "Mimo to utwórz nowy rekord"
+  },
+  "summary": {
+    "create": "Zostanie utworzonych",
+    "update": "Zostanie zaktualizowanych",
+    "skip": "Zostanie pominiętych",
+    "error": "Nie można zaimportować",
+    "warnings": "Z ostrzeżeniami",
+    "customersToCreate": "{count, plural, one {# nowy klient} few {# nowych klientów} many {# nowych klientów} other {# nowego klienta}}",
+    "customersToUpdate": "{count, plural, one {# zaktualizowany klient} few {# zaktualizowanych klientów} many {# zaktualizowanych klientów} other {# zaktualizowanego klienta}}",
+    "vehiclesToCreate": "{count, plural, one {# nowy pojazd} few {# nowe pojazdy} many {# nowych pojazdów} other {# nowego pojazdu}}",
+    "vehiclesToUpdate": "{count, plural, one {# zaktualizowany pojazd} few {# zaktualizowane pojazdy} many {# zaktualizowanych pojazdów} other {# zaktualizowanego pojazdu}}",
+    "servicesToCreate": "{count, plural, one {# rekord serwisowy} few {# rekordy serwisowe} many {# rekordów serwisowych} other {# rekordu serwisowego}}"
+  },
+  "actions": {
+    "create": "Utwórz",
+    "update": "Zaktualizuj",
+    "skip": "Pomiń",
+    "error": "Błąd"
+  },
+  "matchOn": {
+    "number": "numer klienta",
+    "email": "e-mail",
+    "phone": "telefon",
+    "name": "nazwa",
+    "vin": "VIN",
+    "plate": "rejestracja",
+    "makeModelYear": "marka, model i rok"
+  },
+  "preview": {
+    "colAction": "Akcja",
+    "colOwner": "Właściciel",
+    "colDetails": "Szczegóły",
+    "empty": "Żaden wiersz nie pasuje do tego filtra.",
+    "matchedOn": "Pasuje do {name} (po: {on})",
+    "linksCustomer": "Powiązano z klientem {name}",
+    "linksVehicle": "Powiązano z {label}",
+    "sameCustomerAsRow": "Ten sam klient co w wierszu {row}",
+    "sameVehicleAsRow": "Ten sam pojazd co w wierszu {row}",
+    "createsCustomer": "Tworzy klienta",
+    "createsVehicle": "Tworzy pojazd",
+    "downloadReport": "Pobierz raport próbnego importu",
+    "limitExceeded": "Twój plan pozwala na jeszcze {remaining} klientów, a ten import utworzyłby {needed}. Usuń wiersze lub zmień plan na wyższy.",
+    "pageInfo": "{from}–{to} z {total}",
+    "prev": "Poprzednia",
+    "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."
+  },
+  "importing": {
+    "title": "Importowanie...",
+    "starting": "Rozpoczynanie",
+    "progress": "{done} z {total} wierszy",
+    "keepOpen": "Duże pliki zajmują minutę lub dwie."
+  },
+  "result": {
+    "title": "Import zakończony",
+    "undoneTitle": "Import cofnięty",
+    "created": "{count, plural, one {# rekord utworzony} few {# rekordy utworzone} many {# rekordów utworzonych} other {# rekordu utworzonego}}",
+    "updated": "{count, plural, one {# rekord zaktualizowany} few {# rekordy zaktualizowane} many {# rekordów zaktualizowanych} other {# rekordu zaktualizowanego}}",
+    "skipped": "{count, plural, one {# wiersz pominięty} few {# wiersze pominięte} many {# wierszy pominiętych} other {# wiersza pominiętego}}",
+    "failed": "{count, plural, one {# wiersz niezaimportowany} few {# wiersze niezaimportowane} many {# wierszy niezaimportowanych} other {# wiersza niezaimportowanego}}",
+    "writeFailures": "{count, plural, one {# wiersz nie zapisał się} few {# wiersze nie zapisały się} many {# wierszy nie zapisało się} other {# wiersza nie zapisało się}}",
+    "rowFailure": "Wiersz {row}: {error}",
+    "undo": "Cofnij ten import",
+    "undone": "Usunięto {customers} klientów, {vehicles} pojazdów i {services} rekordów serwisowych.",
+    "undoHint": "Cofnięcie usuwa każdy rekord utworzony przez ten import. Rekordy, które import tylko zaktualizował, zachowują nowe wartości."
+  },
+  "history": {
+    "title": "Historia importów",
+    "description": "Importy z arkuszy wykonane w tym warsztacie. Cofnięcie usuwa to, co import utworzył.",
+    "loading": "Ładowanie...",
+    "statusUndone": "Cofnięty",
+    "counts": "{created} utworzono, {updated} zaktualizowano, {skipped} pominięto, {failed} nieudanych",
+    "undo": "Cofnij",
+    "undone": "Usunięto {customers} klientów, {vehicles} pojazdów i {services} rekordów serwisowych.",
+    "undoFailed": "Nie udało się cofnąć importu",
+    "confirmTitle": "Cofnąć ten import?",
+    "confirmDescription": "Usuwa to, co utworzył plik {file}: {customers} klientów, {vehicles} pojazdów i {services} rekordów serwisowych. Rekordy, które import tylko zaktualizował, zostają zachowane.",
+    "cancel": "Anuluj",
+    "confirm": "Cofnij import"
+  },
+  "fields": {
+    "customer": {
+      "name": "Nazwa klienta",
+      "firstName": "Imię",
+      "lastName": "Nazwisko",
+      "customerNumber": "Numer klienta",
+      "email": "E-mail",
+      "phone": "Telefon",
+      "company": "Firma",
+      "address": "Adres",
+      "street": "Ulica",
+      "postalCode": "Kod pocztowy",
+      "city": "Miasto",
+      "state": "Województwo / region",
+      "country": "Kraj",
+      "taxId": "NIP",
+      "notes": "Notatki o kliencie"
+    },
+    "vehicle": {
+      "description": "Pojazd (rok marka model w jednej kolumnie)",
+      "make": "Marka",
+      "model": "Model",
+      "year": "Rok",
+      "vin": "VIN",
+      "licensePlate": "Numer rejestracyjny",
+      "color": "Kolor",
+      "mileage": "Przebieg",
+      "fuelType": "Rodzaj paliwa",
+      "transmission": "Skrzynia biegów",
+      "engineSize": "Pojemność silnika",
+      "engineCode": "Kod silnika",
+      "purchaseDate": "Data zakupu",
+      "purchasePrice": "Cena zakupu"
+    },
+    "service": {
+      "date": "Data serwisu",
+      "title": "Tytuł serwisu",
+      "description": "Opis",
+      "total": "Kwota łączna",
+      "mileage": "Przebieg przy serwisie",
+      "invoiceNumber": "Numer faktury",
+      "notes": "Notatki serwisowe",
+      "technician": "Technik"
+    }
+  },
+  "issues": {
+    "customer_name_required": "Brak nazwy klienta",
+    "invalid_email": "E-mail \"{value}\" jest nieprawidłowy i został pominięty",
+    "invalid_vin": "VIN \"{value}\" nie ma 17 znaków; zachowano jak wpisano",
+    "vehicle_make_model_required": "Brak marki i modelu",
+    "vehicle_year_required": "Brak roku",
+    "vehicle_year_invalid": "Rok \"{value}\" nie jest prawidłowym rokiem",
+    "vehicle_identifier_required": "Brak rejestracji, VIN lub marki i modelu do identyfikacji pojazdu",
+    "vehicle_not_found": "Żaden istniejący pojazd nie pasuje do \"{value}\" i brakuje danych, aby go utworzyć",
+    "service_date_required": "Brak daty serwisu",
+    "service_date_invalid": "Nie udało się odczytać daty \"{value}\"",
+    "service_title_required": "Brak tytułu lub opisu serwisu",
+    "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}"
+  },
+  "errors": {
+    "uploadFailed": "Nie udało się odczytać pliku",
+    "aiFailed": "AI nie mogło zaproponować mapowania",
+    "dryRunFailed": "Próbny import nie powiódł się",
+    "importFailed": "Import nie powiódł się",
+    "undoFailed": "Nie udało się cofnąć importu"
+  },
+  "entry": {
+    "title": "Importuj z arkusza kalkulacyjnego",
+    "description": "Przenieś klientów, pojazdy i historię serwisową z dowolnego systemu, który eksportuje CSV lub Excel. Zmapuj kolumny, uruchom próbny import, a potem importuj. Każdy import można cofnąć.",
+    "customers": "Importuj klientów",
+    "vehicles": "Importuj pojazdy",
+    "services": "Importuj historię serwisową"
+  }
+}

+ 3 - 1
messages/pl/onboarding.json

@@ -76,7 +76,9 @@
     "allDoneTitle": "Wszystko gotowe",
     "allDoneDescription": "Wszystkie kroki zostały wykonane i Twój warsztat działa.",
     "dismiss": "Ukryj kartę",
-    "dismissAriaLabel": "Ukryj tę listę"
+    "dismissAriaLabel": "Ukryj tę listę",
+    "importTitle": "Masz już gdzieś klientów i pojazdy?",
+    "importDescription": "Zaimportuj ich z arkusza kalkulacyjnego lub ze starego systemu"
   },
   "sampleData": {
     "title": "Dane przykładowe",

+ 11 - 2
messages/pl/settings.json

@@ -1116,7 +1116,7 @@
   },
   "data": {
     "title": "Zarządzanie danymi",
-    "description": "Eksportuj dane jako kopie zapasowa lub importuj wczesniej wyeksportowany plik.",
+    "description": "Przenieś dane ze starego systemu, pobierz kopię zapasową wszystkiego lub ją przywróć.",
     "exportTitle": "Eksportuj dane",
     "exportDescription": "Pobierz dane jako plik zip. Wybierz kategorie do uwzglednienia w kopii zapasowej.",
     "includeInBackup": "Dolacz do kopii zapasowej:",
@@ -1203,7 +1203,16 @@
     "optTireHotel": "Hotel opon",
     "optTireHotelDesc": "Przechowywane komplety, półki, przygotowanie i umowy przechowywania",
     "optWorkshopConfig": "Konfiguracja warsztatu",
-    "optWorkshopConfigDesc": "Szablony robocizny, role, webhooki i zaplanowane raporty"
+    "optWorkshopConfigDesc": "Szablony robocizny, role, webhooki i zaplanowane raporty",
+    "importHubDescription": "Wybierz, gdzie dziś są Twoje dane. Importy z arkuszy i aplikacji dodają się do tego, co masz, i można je cofnąć; przywrócenie kopii zapasowej zastępuje wszystko.",
+    "sourceSpreadsheet": "Arkusz lub CSV",
+    "sourceSpreadsheetDesc": "Eksporty z dowolnego systemu warsztatowego, programu księgowego, Kontaktów Google, Outlooka lub Excela. Potwierdzasz mapowanie kolumn i widzisz próbny przebieg, zanim cokolwiek zostanie zapisane.",
+    "sourceLubelogDesc": "Pojazdy, wpisy serwisowe i notatki z kopii zapasowej LubeLog.",
+    "sourceInvoiceNinjaDesc": "Klienci, produkty, faktury i płatności z eksportu Invoice Ninja.",
+    "sourceBackup": "Kopia zapasowa Torqvoice",
+    "sourceBackupDesc": "Plik .zip lub .json pobrany z tej strony. Przywrócenie zastępuje wszystko w tym warsztacie.",
+    "restore": "Przywróć",
+    "restoreTitle": "Przywróć kopię zapasową"
   },
   "license": {
     "title": "Licencja white-label",

+ 1 - 0
messages/pl/vehicles.json

@@ -4,6 +4,7 @@
     "archived": "Zarchiwizowane",
     "searchPlaceholder": "Szukaj po tablicy, marce, modelu, VIN...",
     "addVehicle": "Dodaj pojazd",
+    "importVehicles": "Importuj",
     "emptySearch": "Brak pojazdów pasujących do wyszukiwania.",
     "emptyArchived": "Brak zarchiwizowanych pojazdów.",
     "empty": "Brak pojazdów.",

+ 4 - 0
messages/pt-BR/audit.json

@@ -152,6 +152,8 @@
     "team_giveTechnicianTheApp": "Conta dada a um técnico só do quadro",
     "team_createDefaultRoles": "Funções padrão criadas",
     "settings_setCustomFieldPlacement": "Posição de campo personalizado atualizada",
+    "import_commit": "Planilha importada",
+    "import_undo": "Importação desfeita",
     "integration_connect": "Integração conectada",
     "integration_disconnect": "Integração desconectada"
   },
@@ -278,6 +280,8 @@
     "work_bay_update": "Baia \"{name}\" atualizada",
     "work_bay_delete": "Baia {id} excluída",
     "settings_setCustomFieldPlacement": "Alterou onde um campo personalizado é impresso",
+    "import_commit": "Importou uma planilha: {count} registros criados",
+    "import_undo": "Desfez uma importação de planilha: {records} registros removidos",
     "integration_connect": "{name} conectado",
     "integration_disconnect": "{name} desconectado"
   }

+ 0 - 39
messages/pt-BR/customers.json

@@ -117,44 +117,5 @@
     "notesSaveError": "Falha ao salvar observações",
     "smsLabel": "Mensagem de Solicitação de Serviço",
     "smsDefault": "Olá {name}, referente à sua solicitação de serviço para o seu {vehicle}: "
-  },
-  "import": {
-    "title": "Importar Clientes",
-    "description": "Importar clientes de arquivos CSV, TSV ou vCard.",
-    "dropFile": "Arraste e solte um arquivo aqui",
-    "supportedFormats": "CSV, TSV, TXT ou vCard (.vcf)",
-    "browse": "Procurar arquivos",
-    "noDataFound": "Nenhum dado de cliente encontrado no arquivo",
-    "rowsFound": "{count} cliente(s) encontrado(s)",
-    "colName": "Nome",
-    "colEmail": "E-mail",
-    "colPhone": "Telefone",
-    "colCompany": "Empresa",
-    "colAddress": "Endereço",
-    "chooseAnother": "Escolher outro arquivo",
-    "cancel": "Cancelar",
-    "importButton": "Importar {count} cliente(s)",
-    "importComplete": "Importação concluída",
-    "importedCount": "{count} cliente(s) importado(s) com sucesso",
-    "skippedRows": "{count} linha(s) ignorada(s) devido a erros",
-    "rowError": "Linha {row} ({name}): {error}",
-    "done": "Concluído",
-    "importFailed": "Falha ao importar clientes",
-    "checkingDuplicates": "Verificando duplicatas...",
-    "duplicatesFound": "{count} duplicata(s) encontrada(s)",
-    "duplicatesBanner": "{count} cliente(s) existente(s) correspondem aos dados de importação.",
-    "duplicatesHint": "Clique em Pular ou Mesclar para alterar como cada duplicata é tratada.",
-    "colStatus": "Status",
-    "actionSkip": "Pular",
-    "actionMerge": "Mesclar",
-    "actionNew": "Novo",
-    "summaryNew": "{count} novo(s)",
-    "summaryMerge": "{count} mesclar",
-    "summarySkip": "{count} pular",
-    "skipAll": "Pular todos",
-    "mergeAll": "Mesclar todos",
-    "resultCreated": "{count} cliente(s) criado(s)",
-    "resultMerged": "{count} cliente(s) mesclado(s)",
-    "resultSkipped": "{count} cliente(s) pulado(s)"
   }
 }

+ 228 - 0
messages/pt-BR/dataImport.json

@@ -0,0 +1,228 @@
+{
+  "title": "Importar {entity}",
+  "cancel": "Cancelar",
+  "done": "Concluído",
+  "entities": {
+    "customers": "clientes",
+    "vehicles": "veículos",
+    "services": "histórico de serviços"
+  },
+  "entityHints": {
+    "customers": "Nomes, dados de contato, endereços. Uma linha por cliente.",
+    "vehicles": "Marca, modelo, ano, placa, chassi (VIN). Uma coluna de proprietário vincula ou cria o cliente.",
+    "services": "Serviços e faturas anteriores. Uma linha por serviço, com o veículo ao qual pertence."
+  },
+  "groups": {
+    "customer": "Cliente",
+    "vehicle": "Veículo",
+    "service": "Serviço"
+  },
+  "steps": {
+    "upload": "Escolha o que importar e envie o arquivo do seu sistema atual.",
+    "mapping": "Confira qual coluna vira qual campo. Nada foi gravado ainda.",
+    "preview": "Simulação: isto é exatamente o que a importação fará. Revise e depois importe.",
+    "importing": "Gravando linhas. Mantenha esta janela aberta.",
+    "result": "Importação finalizada."
+  },
+  "upload": {
+    "drop": "Arraste e solte um arquivo aqui",
+    "formats": "CSV, Excel (.xlsx) ou vCard (.vcf), até 25 MB e 50.000 linhas",
+    "browse": "Procurar arquivos",
+    "reading": "Lendo o arquivo...",
+    "templateHint": "Ainda não tem uma exportação? Preencha um modelo e envie-o.",
+    "templateXlsx": "Modelo Excel",
+    "templateCsv": "Modelo CSV",
+    "sourcesHint": "Funciona com exportações de qualquer sistema de oficina, ferramenta de contabilidade, Google Contatos, Outlook ou planilha. Os nomes das colunas são reconhecidos em vários idiomas; você confirma o mapeamento antes de qualquer gravação."
+  },
+  "mapping": {
+    "rows": "{count, plural, one {# linha} other {# linhas}}",
+    "colColumn": "Coluna no seu arquivo",
+    "colSample": "Valores de exemplo",
+    "colField": "Vira",
+    "ignore": "Ignorar esta coluna",
+    "source": {
+      "preset": "Layout reconhecido",
+      "header": "Correspondido pelo cabeçalho",
+      "values": "Deduzido pelos valores"
+    },
+    "suggestWithAi": "Sugerir com IA",
+    "aiApplied": "{count, plural, one {# coluna mapeada} other {# colunas mapeadas}} pela IA. Confira antes de continuar.",
+    "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"
+  },
+  "options": {
+    "dateFormat": "Formato de data",
+    "dateAuto": "Detectar automaticamente",
+    "dateDMY": "Dia / mês / ano",
+    "dateMDY": "Mês / dia / ano",
+    "dateYMD": "Ano / mês / dia",
+    "decimal": "Separador decimal",
+    "decimalAuto": "Detectar automaticamente",
+    "decimalDot": "Ponto (1,234.56)",
+    "decimalComma": "Vírgula (1.234,56)",
+    "countryCode": "Código do país para números de telefone",
+    "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"
+  },
+  "summary": {
+    "create": "Serão criados",
+    "update": "Serão atualizados",
+    "skip": "Serão pulados",
+    "error": "Não podem ser importados",
+    "warnings": "Com avisos",
+    "customersToCreate": "{count, plural, one {# cliente novo} other {# clientes novos}}",
+    "customersToUpdate": "{count, plural, one {# cliente atualizado} other {# clientes atualizados}}",
+    "vehiclesToCreate": "{count, plural, one {# veículo novo} other {# veículos novos}}",
+    "vehiclesToUpdate": "{count, plural, one {# veículo atualizado} other {# veículos atualizados}}",
+    "servicesToCreate": "{count, plural, one {# registro de serviço} other {# registros de serviço}}"
+  },
+  "actions": {
+    "create": "Criar",
+    "update": "Atualizar",
+    "skip": "Pular",
+    "error": "Erro"
+  },
+  "matchOn": {
+    "number": "número do cliente",
+    "email": "e-mail",
+    "phone": "telefone",
+    "name": "nome",
+    "vin": "chassi (VIN)",
+    "plate": "placa",
+    "makeModelYear": "marca, modelo e ano"
+  },
+  "preview": {
+    "colAction": "Ação",
+    "colOwner": "Proprietário",
+    "colDetails": "Detalhes",
+    "empty": "Nenhuma linha corresponde a este filtro.",
+    "matchedOn": "Corresponde a {name} (por {on})",
+    "linksCustomer": "Vinculado ao cliente {name}",
+    "linksVehicle": "Vinculado a {label}",
+    "sameCustomerAsRow": "Mesmo cliente da linha {row}",
+    "sameVehicleAsRow": "Mesmo veículo da linha {row}",
+    "createsCustomer": "Cria o cliente",
+    "createsVehicle": "Cria o veículo",
+    "downloadReport": "Baixar relatório da simulação",
+    "limitExceeded": "Seu plano permite mais {remaining} clientes, mas esta importação criaria {needed}. Remova linhas ou faça upgrade do seu plano.",
+    "pageInfo": "{from}–{to} de {total}",
+    "prev": "Anterior",
+    "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."
+  },
+  "importing": {
+    "title": "Importando...",
+    "starting": "Iniciando",
+    "progress": "{done} de {total} linhas",
+    "keepOpen": "Arquivos grandes levam um ou dois minutos."
+  },
+  "result": {
+    "title": "Importação concluída",
+    "undoneTitle": "Importação desfeita",
+    "created": "{count, plural, one {# registro criado} other {# registros criados}}",
+    "updated": "{count, plural, one {# registro atualizado} other {# registros atualizados}}",
+    "skipped": "{count, plural, one {# linha pulada} other {# linhas puladas}}",
+    "failed": "{count, plural, one {# linha não importada} other {# linhas não importadas}}",
+    "writeFailures": "{count, plural, one {# linha falhou ao gravar} other {# linhas falharam ao gravar}}",
+    "rowFailure": "Linha {row}: {error}",
+    "undo": "Desfazer esta importação",
+    "undone": "Removidos {customers} clientes, {vehicles} veículos e {services} registros de serviço.",
+    "undoHint": "Desfazer remove todos os registros que esta importação criou. Os registros que ela apenas atualizou mantêm os novos valores."
+  },
+  "history": {
+    "title": "Histórico de importações",
+    "description": "Importações de planilha executadas nesta oficina. Desfazer remove o que uma importação criou.",
+    "loading": "Carregando...",
+    "statusUndone": "Desfeita",
+    "counts": "{created} criados, {updated} atualizados, {skipped} pulados, {failed} com falha",
+    "undo": "Desfazer",
+    "undone": "Removidos {customers} clientes, {vehicles} veículos e {services} registros de serviço.",
+    "undoFailed": "Não foi possível desfazer a importação",
+    "confirmTitle": "Desfazer esta importação?",
+    "confirmDescription": "Remove o que {file} criou: {customers} clientes, {vehicles} veículos e {services} registros de serviço. Os registros que a importação apenas atualizou são mantidos.",
+    "cancel": "Cancelar",
+    "confirm": "Desfazer importação"
+  },
+  "fields": {
+    "customer": {
+      "name": "Nome do cliente",
+      "firstName": "Nome",
+      "lastName": "Sobrenome",
+      "customerNumber": "Número do cliente",
+      "email": "E-mail",
+      "phone": "Telefone",
+      "company": "Empresa",
+      "address": "Endereço",
+      "street": "Rua",
+      "postalCode": "CEP",
+      "city": "Cidade",
+      "state": "Estado / região",
+      "country": "País",
+      "taxId": "CPF/CNPJ",
+      "notes": "Notas do cliente"
+    },
+    "vehicle": {
+      "description": "Veículo (ano marca modelo em uma só coluna)",
+      "make": "Marca",
+      "model": "Modelo",
+      "year": "Ano",
+      "vin": "Chassi (VIN)",
+      "licensePlate": "Placa",
+      "color": "Cor",
+      "mileage": "Quilometragem",
+      "fuelType": "Tipo de combustível",
+      "transmission": "Transmissão",
+      "engineSize": "Cilindrada",
+      "engineCode": "Código do motor",
+      "purchaseDate": "Data de compra",
+      "purchasePrice": "Preço de compra"
+    },
+    "service": {
+      "date": "Data do serviço",
+      "title": "Título do serviço",
+      "description": "Descrição",
+      "total": "Valor total",
+      "mileage": "Quilometragem no serviço",
+      "invoiceNumber": "Número da fatura",
+      "notes": "Notas do serviço",
+      "technician": "Técnico"
+    }
+  },
+  "issues": {
+    "customer_name_required": "Falta o nome do cliente",
+    "invalid_email": "O e-mail \"{value}\" não é válido e foi deixado de fora",
+    "invalid_vin": "O chassi \"{value}\" não tem 17 caracteres; mantido como digitado",
+    "vehicle_make_model_required": "Faltam a marca e o modelo",
+    "vehicle_year_required": "Falta o ano",
+    "vehicle_year_invalid": "O ano \"{value}\" não é um ano válido",
+    "vehicle_identifier_required": "Sem placa, chassi ou marca e modelo para identificar o veículo",
+    "vehicle_not_found": "Nenhum veículo existente corresponde a \"{value}\" e não há dados suficientes para criar um",
+    "service_date_required": "Falta a data do serviço",
+    "service_date_invalid": "Não foi possível ler a data \"{value}\"",
+    "service_title_required": "Falta o título ou a descrição do serviço",
+    "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}"
+  },
+  "errors": {
+    "uploadFailed": "Não foi possível ler o arquivo",
+    "aiFailed": "A IA não conseguiu sugerir um mapeamento",
+    "dryRunFailed": "A simulação falhou",
+    "importFailed": "A importação falhou",
+    "undoFailed": "Não foi possível desfazer a importação"
+  },
+  "entry": {
+    "title": "Importar de uma planilha",
+    "description": "Traga clientes, veículos e histórico de serviços de qualquer sistema que exporte CSV ou Excel. Mapeie as colunas, execute uma simulação e depois importe. Toda importação pode ser desfeita.",
+    "customers": "Importar clientes",
+    "vehicles": "Importar veículos",
+    "services": "Importar histórico de serviços"
+  }
+}

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

@@ -76,7 +76,9 @@
     "allDoneTitle": "Tudo pronto",
     "allDoneDescription": "Todos os passos foram concluídos e sua oficina está em funcionamento.",
     "dismiss": "Ocultar cartão",
-    "dismissAriaLabel": "Ocultar esta lista"
+    "dismissAriaLabel": "Ocultar esta lista",
+    "importTitle": "Já tem clientes e veículos em algum lugar?",
+    "importDescription": "Importe-os de uma planilha ou do seu sistema antigo"
   },
   "sampleData": {
     "title": "Dados de exemplo",

+ 11 - 2
messages/pt-BR/settings.json

@@ -1116,7 +1116,7 @@
   },
   "data": {
     "title": "Gerenciamento de dados",
-    "description": "Exporte seus dados como backup ou importe um arquivo previamente exportado.",
+    "description": "Traga dados do seu sistema antigo, baixe um backup de tudo ou restaure um.",
     "exportTitle": "Exportar dados",
     "exportDescription": "Baixe seus dados como arquivo zip. Selecione as categorias a incluir no backup.",
     "includeInBackup": "Incluir no backup:",
@@ -1203,7 +1203,16 @@
     "optTireHotel": "Hotel de pneus",
     "optTireHotelDesc": "Jogos armazenados, prateleiras, preparação e contratos de armazenagem",
     "optWorkshopConfig": "Configuração da oficina",
-    "optWorkshopConfigDesc": "Modelos de mão de obra, funções, webhooks e relatórios agendados"
+    "optWorkshopConfigDesc": "Modelos de mão de obra, funções, webhooks e relatórios agendados",
+    "importHubDescription": "Escolha onde seus dados estão hoje. Importações de planilhas e aplicativos são adicionadas ao que você já tem e podem ser desfeitas; restaurar um backup substitui tudo.",
+    "sourceSpreadsheet": "Planilha ou CSV",
+    "sourceSpreadsheetDesc": "Exportações de qualquer sistema de oficina, ferramenta contábil, Contatos do Google, Outlook ou Excel. Você confirma o mapeamento das colunas e vê uma simulação antes de qualquer gravação.",
+    "sourceLubelogDesc": "Veículos, registros de serviço e anotações de um backup do LubeLog.",
+    "sourceInvoiceNinjaDesc": "Clientes, produtos, faturas e pagamentos de uma exportação do Invoice Ninja.",
+    "sourceBackup": "Backup do Torqvoice",
+    "sourceBackupDesc": "Um arquivo .zip ou .json baixado nesta página. Restaurar substitui tudo nesta oficina.",
+    "restore": "Restaurar",
+    "restoreTitle": "Restaurar um backup"
   },
   "license": {
     "title": "Licença marca branca",

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

@@ -4,6 +4,7 @@
     "archived": "Arquivados",
     "searchPlaceholder": "Buscar por placa, marca, modelo, chassi...",
     "addVehicle": "Adicionar Veículo",
+    "importVehicles": "Importar",
     "emptySearch": "Nenhum veículo corresponde à sua busca.",
     "emptyArchived": "Nenhum veículo arquivado.",
     "empty": "Nenhum veículo ainda.",

+ 4 - 0
messages/ru/audit.json

@@ -152,6 +152,8 @@
     "team_giveTechnicianTheApp": "Механику с доски выдана учётная запись",
     "team_createDefaultRoles": "Созданы стандартные роли",
     "settings_setCustomFieldPlacement": "Обновлено расположение пользовательского поля",
+    "import_commit": "Импортирована таблица",
+    "import_undo": "Отменён импорт",
     "integration_connect": "Подключена интеграция",
     "integration_disconnect": "Отключена интеграция"
   },
@@ -278,6 +280,8 @@
     "work_bay_update": "Обновлён пост «{name}»",
     "work_bay_delete": "Удалён пост {id}",
     "settings_setCustomFieldPlacement": "Изменено место печати пользовательского поля",
+    "import_commit": "Импортирована таблица, создано записей: {count}",
+    "import_undo": "Отменён импорт таблицы, удалено записей: {records}",
     "integration_connect": "Подключена интеграция {name}",
     "integration_disconnect": "Отключена интеграция {name}"
   }

+ 0 - 39
messages/ru/customers.json

@@ -117,44 +117,5 @@
     "notesSaveError": "Не удалось сохранить заметки",
     "smsLabel": "Сообщение по заявке",
     "smsDefault": "Здравствуйте, {name}, по поводу вашей заявки на обслуживание вашего {vehicle}: "
-  },
-  "import": {
-    "title": "Импорт клиентов",
-    "description": "Импортируйте клиентов из файлов CSV, TSV или vCard.",
-    "dropFile": "Перетащите файл сюда",
-    "supportedFormats": "CSV, TSV, TXT или vCard (.vcf)",
-    "browse": "Обзор файлов",
-    "noDataFound": "Данные клиентов в файле не найдены",
-    "rowsFound": "Найдено клиентов: {count}",
-    "colName": "Имя",
-    "colEmail": "Эл. почта",
-    "colPhone": "Телефон",
-    "colCompany": "Компания",
-    "colAddress": "Адрес",
-    "chooseAnother": "Выбрать другой файл",
-    "cancel": "Отмена",
-    "importButton": "Импортировать клиентов: {count}",
-    "importComplete": "Импорт завершён",
-    "importedCount": "Успешно импортировано клиентов: {count}",
-    "skippedRows": "Пропущено строк из-за ошибок: {count}",
-    "rowError": "Строка {row} ({name}): {error}",
-    "done": "Готово",
-    "importFailed": "Не удалось импортировать клиентов",
-    "checkingDuplicates": "Проверка дубликатов...",
-    "duplicatesFound": "Найдено дубликатов: {count}",
-    "duplicatesBanner": "Совпадают с существующими клиентами: {count}.",
-    "duplicatesHint": "Нажмите «Пропустить» или «Объединить», чтобы изменить обработку каждого дубликата.",
-    "colStatus": "Статус",
-    "actionSkip": "Пропустить",
-    "actionMerge": "Объединить",
-    "actionNew": "Новый",
-    "summaryNew": "{count} новых",
-    "summaryMerge": "{count} объединённых",
-    "summarySkip": "{count} пропущенных",
-    "skipAll": "Пропустить все",
-    "mergeAll": "Объединить все",
-    "resultCreated": "Создано клиентов: {count}",
-    "resultMerged": "Объединено клиентов: {count}",
-    "resultSkipped": "Пропущено клиентов: {count}"
   }
 }

+ 228 - 0
messages/ru/dataImport.json

@@ -0,0 +1,228 @@
+{
+  "title": "Импорт: {entity}",
+  "cancel": "Отмена",
+  "done": "Готово",
+  "entities": {
+    "customers": "клиенты",
+    "vehicles": "автомобили",
+    "services": "история обслуживания"
+  },
+  "entityHints": {
+    "customers": "Имена, контактные данные, адреса. Одна строка на клиента.",
+    "vehicles": "Марка, модель, год, госномер, VIN. Столбец владельца привязывает или создаёт клиента.",
+    "services": "Прошлые работы и счета. Одна строка на работу с указанием автомобиля, к которому она относится."
+  },
+  "groups": {
+    "customer": "Клиент",
+    "vehicle": "Автомобиль",
+    "service": "Обслуживание"
+  },
+  "steps": {
+    "upload": "Выберите, что импортировать, и загрузите файл из вашей текущей системы.",
+    "mapping": "Проверьте, какой столбец станет каким полем. Пока ничего не записывается.",
+    "preview": "Пробный запуск: именно это сделает импорт. Проверьте и затем импортируйте.",
+    "importing": "Запись строк. Не закрывайте это окно.",
+    "result": "Импорт завершён."
+  },
+  "upload": {
+    "drop": "Перетащите файл сюда",
+    "formats": "CSV, Excel (.xlsx) или vCard (.vcf), до 25 МБ и 50 000 строк",
+    "browse": "Выбрать файл",
+    "reading": "Чтение файла...",
+    "templateHint": "Экспорта ещё нет? Заполните шаблон и загрузите его.",
+    "templateXlsx": "Шаблон Excel",
+    "templateCsv": "Шаблон CSV",
+    "sourcesHint": "Работает с экспортом из любой системы для автосервиса, бухгалтерской программы, Google Контактов, Outlook или электронной таблицы. Названия столбцов распознаются на многих языках; вы подтверждаете сопоставление до того, как что-либо будет записано."
+  },
+  "mapping": {
+    "rows": "{count, plural, one {# строка} few {# строки} many {# строк} other {# строки}}",
+    "colColumn": "Столбец в вашем файле",
+    "colSample": "Примеры значений",
+    "colField": "Станет полем",
+    "ignore": "Пропустить этот столбец",
+    "source": {
+      "preset": "Известный формат",
+      "header": "По заголовку",
+      "values": "По значениям"
+    },
+    "suggestWithAi": "Предложить с помощью ИИ",
+    "aiApplied": "ИИ сопоставил {count, plural, one {# столбец} few {# столбца} many {# столбцов} other {# столбца}}. Проверьте их перед продолжением.",
+    "chooseAnother": "Выбрать другой файл",
+    "missingRequired": "Ещё требуется: {fields}",
+    "dryRunHint": "Пробный запуск сверяет каждую строку с вашими существующими данными, ничего не записывая.",
+    "runDryRun": "Выполнить пробный запуск"
+  },
+  "options": {
+    "dateFormat": "Формат даты",
+    "dateAuto": "Определять автоматически",
+    "dateDMY": "День / месяц / год",
+    "dateMDY": "Месяц / день / год",
+    "dateYMD": "Год / месяц / день",
+    "decimal": "Десятичный разделитель",
+    "decimalAuto": "Определять автоматически",
+    "decimalDot": "Точка (1,234.56)",
+    "decimalComma": "Запятая (1.234,56)",
+    "countryCode": "Код страны для телефонных номеров",
+    "duplicates": "Если строка совпадает с существующей записью",
+    "duplicatesUpdate": "Обновить существующую запись",
+    "duplicatesSkip": "Пропустить строку",
+    "duplicatesCreate": "Всё равно создать новую запись"
+  },
+  "summary": {
+    "create": "Будет создано",
+    "update": "Будет обновлено",
+    "skip": "Будет пропущено",
+    "error": "Невозможно импортировать",
+    "warnings": "С предупреждениями",
+    "customersToCreate": "{count, plural, one {# новый клиент} few {# новых клиента} many {# новых клиентов} other {# новых клиента}}",
+    "customersToUpdate": "{count, plural, one {# клиент будет обновлён} few {# клиента будут обновлены} many {# клиентов будут обновлены} other {# клиента будут обновлены}}",
+    "vehiclesToCreate": "{count, plural, one {# новый автомобиль} few {# новых автомобиля} many {# новых автомобилей} other {# новых автомобиля}}",
+    "vehiclesToUpdate": "{count, plural, one {# автомобиль будет обновлён} few {# автомобиля будут обновлены} many {# автомобилей будут обновлены} other {# автомобиля будут обновлены}}",
+    "servicesToCreate": "{count, plural, one {# запись обслуживания} few {# записи обслуживания} many {# записей обслуживания} other {# записи обслуживания}}"
+  },
+  "actions": {
+    "create": "Создать",
+    "update": "Обновить",
+    "skip": "Пропустить",
+    "error": "Ошибка"
+  },
+  "matchOn": {
+    "number": "номеру клиента",
+    "email": "эл. почте",
+    "phone": "телефону",
+    "name": "имени",
+    "vin": "VIN",
+    "plate": "госномеру",
+    "makeModelYear": "марке, модели и году"
+  },
+  "preview": {
+    "colAction": "Действие",
+    "colOwner": "Владелец",
+    "colDetails": "Подробности",
+    "empty": "Нет строк, соответствующих этому фильтру.",
+    "matchedOn": "Совпадает с {name} (по {on})",
+    "linksCustomer": "Привязано к клиенту {name}",
+    "linksVehicle": "Привязано к {label}",
+    "sameCustomerAsRow": "Тот же клиент, что и в строке {row}",
+    "sameVehicleAsRow": "Тот же автомобиль, что и в строке {row}",
+    "createsCustomer": "Создаёт клиента",
+    "createsVehicle": "Создаёт автомобиль",
+    "downloadReport": "Скачать отчёт пробного запуска",
+    "limitExceeded": "Ваш план позволяет добавить ещё {remaining} клиентов, а этот импорт создаст {needed}. Удалите строки или перейдите на другой план.",
+    "pageInfo": "{from}–{to} из {total}",
+    "prev": "Назад",
+    "next": "Далее",
+    "backToMapping": "Назад к сопоставлению",
+    "import": "Импортировать {count, plural, one {# строку} few {# строки} many {# строк} other {# строки}}",
+    "nothingWrittenYet": "Пока ничего не записано. Импорт можно будет отменить позже в разделе Настройки → Данные."
+  },
+  "importing": {
+    "title": "Импорт...",
+    "starting": "Запуск",
+    "progress": "{done} из {total} строк",
+    "keepOpen": "Большие файлы занимают минуту или две."
+  },
+  "result": {
+    "title": "Импорт завершён",
+    "undoneTitle": "Импорт отменён",
+    "created": "{count, plural, one {# запись создана} few {# записи созданы} many {# записей создано} other {# записи создано}}",
+    "updated": "{count, plural, one {# запись обновлена} few {# записи обновлены} many {# записей обновлено} other {# записи обновлено}}",
+    "skipped": "{count, plural, one {# строка пропущена} few {# строки пропущены} many {# строк пропущено} other {# строки пропущено}}",
+    "failed": "{count, plural, one {# строка не импортирована} few {# строки не импортированы} many {# строк не импортировано} other {# строки не импортировано}}",
+    "writeFailures": "{count, plural, one {# строка не записана из-за ошибки} few {# строки не записаны из-за ошибки} many {# строк не записано из-за ошибки} other {# строки не записано из-за ошибки}}",
+    "rowFailure": "Строка {row}: {error}",
+    "undo": "Отменить этот импорт",
+    "undone": "Удалено: клиентов {customers}, автомобилей {vehicles}, записей обслуживания {services}.",
+    "undoHint": "Отмена удаляет все записи, созданные этим импортом. Записи, которые он только обновил, сохраняют новые значения."
+  },
+  "history": {
+    "title": "История импорта",
+    "description": "Импорты из таблиц, выполненные в этой мастерской. Отмена удаляет то, что создал импорт.",
+    "loading": "Загрузка...",
+    "statusUndone": "Отменён",
+    "counts": "создано {created}, обновлено {updated}, пропущено {skipped}, с ошибкой {failed}",
+    "undo": "Отменить",
+    "undone": "Удалено: клиентов {customers}, автомобилей {vehicles}, записей обслуживания {services}.",
+    "undoFailed": "Не удалось отменить импорт",
+    "confirmTitle": "Отменить этот импорт?",
+    "confirmDescription": "Удаляет то, что создал файл {file}: клиентов {customers}, автомобилей {vehicles}, записей обслуживания {services}. Записи, которые импорт только обновил, сохраняются.",
+    "cancel": "Отмена",
+    "confirm": "Отменить импорт"
+  },
+  "fields": {
+    "customer": {
+      "name": "Имя клиента",
+      "firstName": "Имя",
+      "lastName": "Фамилия",
+      "customerNumber": "Номер клиента",
+      "email": "Эл. почта",
+      "phone": "Телефон",
+      "company": "Компания",
+      "address": "Адрес",
+      "street": "Улица",
+      "postalCode": "Почтовый индекс",
+      "city": "Город",
+      "state": "Область / регион",
+      "country": "Страна",
+      "taxId": "ИНН",
+      "notes": "Заметки о клиенте"
+    },
+    "vehicle": {
+      "description": "Автомобиль (год, марка и модель в одном столбце)",
+      "make": "Марка",
+      "model": "Модель",
+      "year": "Год",
+      "vin": "VIN",
+      "licensePlate": "Госномер",
+      "color": "Цвет",
+      "mileage": "Пробег",
+      "fuelType": "Тип топлива",
+      "transmission": "Трансмиссия",
+      "engineSize": "Объём двигателя",
+      "engineCode": "Код двигателя",
+      "purchaseDate": "Дата покупки",
+      "purchasePrice": "Цена покупки"
+    },
+    "service": {
+      "date": "Дата обслуживания",
+      "title": "Название работы",
+      "description": "Описание",
+      "total": "Итоговая сумма",
+      "mileage": "Пробег при обслуживании",
+      "invoiceNumber": "Номер счёта",
+      "notes": "Заметки об обслуживании",
+      "technician": "Механик"
+    }
+  },
+  "issues": {
+    "customer_name_required": "Не указано имя клиента",
+    "invalid_email": "Эл. почта \"{value}\" недействительна и была пропущена",
+    "invalid_vin": "VIN \"{value}\" не содержит 17 символов; сохранён как есть",
+    "vehicle_make_model_required": "Не указаны марка и модель",
+    "vehicle_year_required": "Не указан год",
+    "vehicle_year_invalid": "Год \"{value}\" недействителен",
+    "vehicle_identifier_required": "Нет госномера, VIN или марки и модели, чтобы определить автомобиль",
+    "vehicle_not_found": "Ни один существующий автомобиль не соответствует \"{value}\", и данных недостаточно, чтобы создать новый",
+    "service_date_required": "Не указана дата обслуживания",
+    "service_date_invalid": "Дату \"{value}\" не удалось распознать",
+    "service_title_required": "Не указано название или описание работы",
+    "invalid_date": "{field} \"{value}\" не удалось распознать, значение пропущено",
+    "invalid_number": "{field} \"{value}\" не является числом, значение пропущено",
+    "duplicate_invoice_number": "Номер счёта {value} уже существует",
+    "write_failed": "Не удалось записать: {value}"
+  },
+  "errors": {
+    "uploadFailed": "Не удалось прочитать файл",
+    "aiFailed": "ИИ не смог предложить сопоставление",
+    "dryRunFailed": "Пробный запуск не удался",
+    "importFailed": "Импорт не удался",
+    "undoFailed": "Не удалось отменить импорт"
+  },
+  "entry": {
+    "title": "Импорт из таблицы",
+    "description": "Перенесите клиентов, автомобили и историю обслуживания из любой системы, которая экспортирует CSV или Excel. Сопоставьте столбцы, выполните пробный запуск, затем импортируйте. Любой импорт можно отменить.",
+    "customers": "Импорт клиентов",
+    "vehicles": "Импорт автомобилей",
+    "services": "Импорт истории обслуживания"
+  }
+}

+ 3 - 1
messages/ru/onboarding.json

@@ -76,7 +76,9 @@
     "allDoneTitle": "Все готово",
     "allDoneDescription": "Все шаги выполнены, ваша мастерская работает.",
     "dismiss": "Скрыть карточку",
-    "dismissAriaLabel": "Скрыть этот список"
+    "dismissAriaLabel": "Скрыть этот список",
+    "importTitle": "Уже ведёте учёт клиентов и автомобилей где-то ещё?",
+    "importDescription": "Импортируйте их из таблицы или вашей старой системы"
   },
   "sampleData": {
     "title": "Демонстрационные данные",

+ 11 - 2
messages/ru/settings.json

@@ -1116,7 +1116,7 @@
   },
   "data": {
     "title": "Управление данными",
-    "description": "Экспортируйте данные как резервную копию или импортируйте ранее экспортированный файл.",
+    "description": "Перенесите данные из старой системы, скачайте резервную копию всего или восстановите её.",
     "exportTitle": "Экспорт данных",
     "exportDescription": "Скачайте данные в виде zip-файла. Выберите категории для включения в резервную копию.",
     "includeInBackup": "Включить в резервную копию:",
@@ -1203,7 +1203,16 @@
     "optTireHotel": "Шинный отель",
     "optTireHotelDesc": "Комплекты на хранении, полки, подготовка и договоры хранения",
     "optWorkshopConfig": "Настройки мастерской",
-    "optWorkshopConfigDesc": "Шаблоны работ, роли, вебхуки и запланированные отчёты"
+    "optWorkshopConfigDesc": "Шаблоны работ, роли, вебхуки и запланированные отчёты",
+    "importHubDescription": "Выберите, где сейчас находятся ваши данные. Импорт из таблиц и приложений добавляется к имеющимся данным и может быть отменён; восстановление резервной копии заменяет всё.",
+    "sourceSpreadsheet": "Таблица или CSV",
+    "sourceSpreadsheetDesc": "Экспорт из любой системы для автосервиса, бухгалтерской программы, Google Контактов, Outlook или Excel. Вы подтверждаете сопоставление столбцов и видите пробный запуск до записи данных.",
+    "sourceLubelogDesc": "Автомобили, записи об обслуживании и заметки из резервной копии LubeLog.",
+    "sourceInvoiceNinjaDesc": "Клиенты, товары, счета и платежи из экспорта Invoice Ninja.",
+    "sourceBackup": "Резервная копия Torqvoice",
+    "sourceBackupDesc": "Файл .zip или .json, скачанный с этой страницы. Восстановление заменяет всё в этой мастерской.",
+    "restore": "Восстановить",
+    "restoreTitle": "Восстановить резервную копию"
   },
   "license": {
     "title": "White-Label лицензия",

+ 1 - 0
messages/ru/vehicles.json

@@ -4,6 +4,7 @@
     "archived": "Архивные",
     "searchPlaceholder": "Поиск по госномеру, марке, модели, VIN...",
     "addVehicle": "Добавить транспортное средство",
+    "importVehicles": "Импорт",
     "emptySearch": "Нет транспортных средств, соответствующих поиску.",
     "emptyArchived": "Нет архивных транспортных средств.",
     "empty": "Транспортных средств пока нет.",

+ 4 - 0
messages/tr/audit.json

@@ -152,6 +152,8 @@
     "team_giveTechnicianTheApp": "Pano teknisyenine hesap verildi",
     "team_createDefaultRoles": "Standart roller oluşturuldu",
     "settings_setCustomFieldPlacement": "Özel alan konumu güncellendi",
+    "import_commit": "Hesap Tablosu İçe Aktarıldı",
+    "import_undo": "İçe Aktarma Geri Alındı",
     "integration_connect": "Entegrasyon bağlandı",
     "integration_disconnect": "Entegrasyon bağlantısı kesildi"
   },
@@ -278,6 +280,8 @@
     "work_bay_update": "\"{name}\" çalışma alanı güncellendi",
     "work_bay_delete": "{id} çalışma alanı silindi",
     "settings_setCustomFieldPlacement": "Bir özel alanın yazdırıldığı yer değiştirildi",
+    "import_commit": "Bir hesap tablosu içe aktarıldı: {count} kayıt oluşturuldu",
+    "import_undo": "Bir hesap tablosu içe aktarması geri alındı: {records} kayıt kaldırıldı",
     "integration_connect": "{name} bağlandı",
     "integration_disconnect": "{name} bağlantısı kesildi"
   }

+ 0 - 39
messages/tr/customers.json

@@ -117,44 +117,5 @@
     "notesSaveError": "Notlar kaydedilemedi",
     "smsLabel": "Servis Talebi Mesajı",
     "smsDefault": "Merhaba {name}, {vehicle} aracınıza ilişkin servis talebinizle ilgili olarak: "
-  },
-  "import": {
-    "title": "Müşterileri İçe Aktar",
-    "description": "CSV, TSV veya vCard dosyalarından müşterileri içe aktarın.",
-    "dropFile": "Bir dosyayı buraya sürükleyip bırakın",
-    "supportedFormats": "CSV, TSV, TXT veya vCard (.vcf)",
-    "browse": "Dosyalara Göz At",
-    "noDataFound": "Dosyada müşteri verisi bulunamadı",
-    "rowsFound": "{count} müşteri bulundu",
-    "colName": "Ad Soyad",
-    "colEmail": "E-posta",
-    "colPhone": "Telefon",
-    "colCompany": "Şirket",
-    "colAddress": "Adres",
-    "chooseAnother": "Başka bir dosya seç",
-    "cancel": "İptal",
-    "importButton": "{count} müşteri içe aktar",
-    "importComplete": "İçe aktarma tamamlandı",
-    "importedCount": "{count} müşteri başarıyla içe aktarıldı",
-    "skippedRows": "{count} satır hatalar nedeniyle atlandı",
-    "rowError": "Satır {row} ({name}): {error}",
-    "done": "Tamam",
-    "importFailed": "Müşteriler içe aktarılamadı",
-    "checkingDuplicates": "Tekrarlar kontrol ediliyor...",
-    "duplicatesFound": "{count} tekrar bulundu",
-    "duplicatesBanner": "{count} mevcut müşteri içe aktarma verilerinizle eşleşiyor.",
-    "duplicatesHint": "Her tekrarın nasıl işleneceğini değiştirmek için Atla veya Birleştir'e tıklayın.",
-    "colStatus": "Durum",
-    "actionSkip": "Atla",
-    "actionMerge": "Birleştir",
-    "actionNew": "Yeni",
-    "summaryNew": "{count} yeni",
-    "summaryMerge": "{count} birleştir",
-    "summarySkip": "{count} atla",
-    "skipAll": "Tümünü atla",
-    "mergeAll": "Tümünü birleştir",
-    "resultCreated": "{count} müşteri oluşturuldu",
-    "resultMerged": "{count} müşteri birleştirildi",
-    "resultSkipped": "{count} müşteri atlandı"
   }
 }

+ 228 - 0
messages/tr/dataImport.json

@@ -0,0 +1,228 @@
+{
+  "title": "{entity} içe aktar",
+  "cancel": "İptal",
+  "done": "Bitti",
+  "entities": {
+    "customers": "müşterileri",
+    "vehicles": "araçları",
+    "services": "servis geçmişini"
+  },
+  "entityHints": {
+    "customers": "Adlar, iletişim bilgileri, adresler. Her müşteri için bir satır.",
+    "vehicles": "Marka, model, yıl, plaka, VIN. Bir sahip sütunu müşteriyi bağlar veya oluşturur.",
+    "services": "Geçmiş işler ve faturalar. Her iş için bir satır, ait olduğu araçla birlikte."
+  },
+  "groups": {
+    "customer": "Müşteri",
+    "vehicle": "Araç",
+    "service": "Servis"
+  },
+  "steps": {
+    "upload": "Neyi içe aktaracağınızı seçin ve dosyayı mevcut sisteminizden yükleyin.",
+    "mapping": "Hangi sütunun hangi alana dönüşeceğini kontrol edin. Henüz hiçbir şey yazılmadı.",
+    "preview": "Deneme çalıştırması: içe aktarma tam olarak bunu yapacak. İnceleyin, ardından içe aktarın.",
+    "importing": "Satırlar yazılıyor. Bu pencereyi açık tutun.",
+    "result": "İçe aktarma tamamlandı."
+  },
+  "upload": {
+    "drop": "Bir dosyayı buraya sürükleyip bırakın",
+    "formats": "CSV, Excel (.xlsx) veya vCard (.vcf), en fazla 25 MB ve 50.000 satır",
+    "browse": "Dosyalara göz at",
+    "reading": "Dosya okunuyor...",
+    "templateHint": "Henüz dışa aktarmanız yok mu? Bir şablonu doldurup onu yükleyin.",
+    "templateXlsx": "Excel şablonu",
+    "templateCsv": "CSV şablonu",
+    "sourcesHint": "Herhangi bir atölye sistemi, muhasebe aracı, Google Kişiler, Outlook veya bir hesap tablosundan alınan dışa aktarmalarla çalışır. Sütun adları birçok dilde tanınır; hiçbir şey yazılmadan önce eşlemeyi siz onaylarsınız."
+  },
+  "mapping": {
+    "rows": "{count, plural, one {# satır} other {# satır}}",
+    "colColumn": "Dosyanızdaki sütun",
+    "colSample": "Örnek değerler",
+    "colField": "Şuna dönüşür",
+    "ignore": "Bu sütunu yoksay",
+    "source": {
+      "preset": "Tanınan düzen",
+      "header": "Başlığa göre eşleşti",
+      "values": "Değerlerden tahmin edildi"
+    },
+    "suggestWithAi": "Yapay zeka ile öner",
+    "aiApplied": "{count, plural, one {# sütun} other {# sütun}} yapay zeka tarafından eşlendi. Devam etmeden önce kontrol edin.",
+    "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"
+  },
+  "options": {
+    "dateFormat": "Tarih biçimi",
+    "dateAuto": "Otomatik algıla",
+    "dateDMY": "Gün / ay / yıl",
+    "dateMDY": "Ay / gün / yıl",
+    "dateYMD": "Yıl / ay / gün",
+    "decimal": "Ondalık ayırıcı",
+    "decimalAuto": "Otomatik algıla",
+    "decimalDot": "Nokta (1,234.56)",
+    "decimalComma": "Virgül (1.234,56)",
+    "countryCode": "Telefon numaraları için ülke kodu",
+    "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"
+  },
+  "summary": {
+    "create": "Oluşturulacak",
+    "update": "Güncellenecek",
+    "skip": "Atlanacak",
+    "error": "İçe aktarılamaz",
+    "warnings": "Uyarılı",
+    "customersToCreate": "{count, plural, one {# yeni müşteri} other {# yeni müşteri}}",
+    "customersToUpdate": "{count, plural, one {# müşteri güncellenecek} other {# müşteri güncellenecek}}",
+    "vehiclesToCreate": "{count, plural, one {# yeni araç} other {# yeni araç}}",
+    "vehiclesToUpdate": "{count, plural, one {# araç güncellenecek} other {# araç güncellenecek}}",
+    "servicesToCreate": "{count, plural, one {# servis kaydı} other {# servis kaydı}}"
+  },
+  "actions": {
+    "create": "Oluştur",
+    "update": "Güncelle",
+    "skip": "Atla",
+    "error": "Hata"
+  },
+  "matchOn": {
+    "number": "müşteri numarası",
+    "email": "e-posta",
+    "phone": "telefon",
+    "name": "ad",
+    "vin": "VIN",
+    "plate": "plaka",
+    "makeModelYear": "marka, model ve yıl"
+  },
+  "preview": {
+    "colAction": "İşlem",
+    "colOwner": "Sahip",
+    "colDetails": "Ayrıntılar",
+    "empty": "Bu filtreyle eşleşen satır yok.",
+    "matchedOn": "{name} ile eşleşiyor ({on} üzerinden)",
+    "linksCustomer": "{name} müşterisine bağlandı",
+    "linksVehicle": "{label} aracına bağlandı",
+    "sameCustomerAsRow": "{row}. satırla aynı müşteri",
+    "sameVehicleAsRow": "{row}. satırla aynı araç",
+    "createsCustomer": "Müşteriyi oluşturur",
+    "createsVehicle": "Aracı oluşturur",
+    "downloadReport": "Deneme çalıştırması raporunu indir",
+    "limitExceeded": "Planınız {remaining} müşteri daha eklemenize izin veriyor, ancak bu içe aktarma {needed} müşteri oluşturacak. Satırları kaldırın veya planınızı yükseltin.",
+    "pageInfo": "{from}–{to} / {total}",
+    "prev": "Önceki",
+    "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."
+  },
+  "importing": {
+    "title": "İçe aktarılıyor...",
+    "starting": "Başlatılıyor",
+    "progress": "{done} / {total} satır",
+    "keepOpen": "Büyük dosyalar bir iki dakika sürer."
+  },
+  "result": {
+    "title": "İçe aktarma tamamlandı",
+    "undoneTitle": "İçe aktarma geri alındı",
+    "created": "{count, plural, one {# kayıt oluşturuldu} other {# kayıt oluşturuldu}}",
+    "updated": "{count, plural, one {# kayıt güncellendi} other {# kayıt güncellendi}}",
+    "skipped": "{count, plural, one {# satır atlandı} other {# satır atlandı}}",
+    "failed": "{count, plural, one {# satır içe aktarılmadı} other {# satır içe aktarılmadı}}",
+    "writeFailures": "{count, plural, one {# satır yazılırken başarısız oldu} other {# satır yazılırken başarısız oldu}}",
+    "rowFailure": "Satır {row}: {error}",
+    "undo": "Bu içe aktarmayı geri al",
+    "undone": "{customers} müşteri, {vehicles} araç ve {services} servis kaydı kaldırıldı.",
+    "undoHint": "Geri alma, bu içe aktarmanın oluşturduğu her kaydı kaldırır. Yalnızca güncellenen kayıtlar yeni değerlerini korur."
+  },
+  "history": {
+    "title": "İçe aktarma geçmişi",
+    "description": "Bu atölyede yapılan hesap tablosu içe aktarmaları. Geri alma, bir içe aktarmanın oluşturduklarını kaldırır.",
+    "loading": "Yükleniyor...",
+    "statusUndone": "Geri alındı",
+    "counts": "{created} oluşturuldu, {updated} güncellendi, {skipped} atlandı, {failed} başarısız",
+    "undo": "Geri Al",
+    "undone": "{customers} müşteri, {vehicles} araç ve {services} servis kaydı kaldırıldı.",
+    "undoFailed": "İçe aktarma geri alınamadı",
+    "confirmTitle": "Bu içe aktarma geri alınsın mı?",
+    "confirmDescription": "{file} dosyasının oluşturduklarını kaldırır: {customers} müşteri, {vehicles} araç ve {services} servis kaydı. İçe aktarmanın yalnızca güncellediği kayıtlar korunur.",
+    "cancel": "İptal",
+    "confirm": "İçe aktarmayı geri al"
+  },
+  "fields": {
+    "customer": {
+      "name": "Müşteri adı",
+      "firstName": "Ad",
+      "lastName": "Soyad",
+      "customerNumber": "Müşteri numarası",
+      "email": "E-posta",
+      "phone": "Telefon",
+      "company": "Şirket",
+      "address": "Adres",
+      "street": "Sokak",
+      "postalCode": "Posta kodu",
+      "city": "Şehir",
+      "state": "Eyalet / bölge",
+      "country": "Ülke",
+      "taxId": "Vergi numarası",
+      "notes": "Müşteri notları"
+    },
+    "vehicle": {
+      "description": "Araç (yıl marka model tek sütunda)",
+      "make": "Marka",
+      "model": "Model",
+      "year": "Yıl",
+      "vin": "VIN",
+      "licensePlate": "Plaka",
+      "color": "Renk",
+      "mileage": "Kilometre",
+      "fuelType": "Yakıt türü",
+      "transmission": "Şanzıman",
+      "engineSize": "Motor hacmi",
+      "engineCode": "Motor kodu",
+      "purchaseDate": "Satın alma tarihi",
+      "purchasePrice": "Satın alma fiyatı"
+    },
+    "service": {
+      "date": "Servis tarihi",
+      "title": "Servis başlığı",
+      "description": "Açıklama",
+      "total": "Toplam tutar",
+      "mileage": "Servisteki kilometre",
+      "invoiceNumber": "Fatura numarası",
+      "notes": "Servis notları",
+      "technician": "Teknisyen"
+    }
+  },
+  "issues": {
+    "customer_name_required": "Müşteri adı eksik",
+    "invalid_email": "\"{value}\" e-postası geçerli değil ve dışarıda bırakıldı",
+    "invalid_vin": "\"{value}\" VIN'i 17 karakter değil; yazıldığı gibi tutuldu",
+    "vehicle_make_model_required": "Marka ve model eksik",
+    "vehicle_year_required": "Yıl eksik",
+    "vehicle_year_invalid": "\"{value}\" geçerli bir yıl değil",
+    "vehicle_identifier_required": "Aracı tanımlayacak plaka, VIN veya marka ve model yok",
+    "vehicle_not_found": "\"{value}\" ile eşleşen mevcut bir araç yok ve yeni bir araç oluşturmak için yeterli bilgi bulunmuyor",
+    "service_date_required": "Servis tarihi eksik",
+    "service_date_invalid": "\"{value}\" tarihi okunamadı",
+    "service_title_required": "Servis başlığı veya açıklaması eksik",
+    "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}"
+  },
+  "errors": {
+    "uploadFailed": "Dosya okunamadı",
+    "aiFailed": "Yapay zeka bir eşleme öneremedi",
+    "dryRunFailed": "Deneme çalıştırması başarısız oldu",
+    "importFailed": "İçe aktarma başarısız oldu",
+    "undoFailed": "İçe aktarma geri alınamadı"
+  },
+  "entry": {
+    "title": "Hesap tablosundan içe aktar",
+    "description": "CSV veya Excel dışa aktaran herhangi bir sistemden müşterileri, araçları ve servis geçmişini getirin. Sütunları eşleyin, bir deneme çalıştırması yapın, ardından içe aktarın. Her içe aktarma geri alınabilir.",
+    "customers": "Müşterileri içe aktar",
+    "vehicles": "Araçları içe aktar",
+    "services": "Servis geçmişini içe aktar"
+  }
+}

+ 3 - 1
messages/tr/onboarding.json

@@ -76,7 +76,9 @@
     "allDoneTitle": "Her şey hazır",
     "allDoneDescription": "Tüm adımlar tamamlandı ve atölyeniz çalışıyor.",
     "dismiss": "Kartı gizle",
-    "dismissAriaLabel": "Bu listeyi gizle"
+    "dismissAriaLabel": "Bu listeyi gizle",
+    "importTitle": "Müşterileriniz ve araçlarınız zaten bir yerde kayıtlı mı?",
+    "importDescription": "Bunları bir hesap tablosundan veya eski sisteminizden içe aktarın"
   },
   "sampleData": {
     "title": "Örnek veriler",

+ 11 - 2
messages/tr/settings.json

@@ -1116,7 +1116,7 @@
   },
   "data": {
     "title": "Veri Yönetimi",
-    "description": "Verilerinizi yedek olarak dışa aktarın veya daha önce dışa aktarılmış bir dosyayı içe aktarın.",
+    "description": "Eski sisteminizden veri aktarın, her şeyin yedeğini indirin veya bir yedeği geri yükleyin.",
     "exportTitle": "Veri Dışa Aktar",
     "exportDescription": "Verilerinizi zip dosyası olarak indirin. Yedeğe dahil edilecek kategorileri seçin.",
     "includeInBackup": "Yedeğe dahil et:",
@@ -1203,7 +1203,16 @@
     "optTireHotel": "Lastik oteli",
     "optTireHotelDesc": "Depodaki takımlar, raflar, hazırlık ve depolama sözleşmeleri",
     "optWorkshopConfig": "Atölye kurulumu",
-    "optWorkshopConfigDesc": "İşçilik şablonları, roller, webhook'lar ve zamanlanmış raporlar"
+    "optWorkshopConfigDesc": "İşçilik şablonları, roller, webhook'lar ve zamanlanmış raporlar",
+    "importHubDescription": "Verilerinizin bugün nerede olduğunu seçin. Tablo ve uygulama içe aktarımları mevcut verilerinize eklenir ve geri alınabilir; yedek geri yükleme her şeyi değiştirir.",
+    "sourceSpreadsheet": "Tablo veya CSV",
+    "sourceSpreadsheetDesc": "Herhangi bir atölye sistemi, muhasebe aracı, Google Kişiler, Outlook veya Excel dışa aktarımları. Bir şey yazılmadan önce sütun eşlemesini onaylar ve deneme çalıştırmasını görürsünüz.",
+    "sourceLubelogDesc": "Bir LubeLog yedeğinden araçlar, servis kayıtları ve notlar.",
+    "sourceInvoiceNinjaDesc": "Bir Invoice Ninja dışa aktarımından müşteriler, ürünler, faturalar ve ödemeler.",
+    "sourceBackup": "Torqvoice yedeği",
+    "sourceBackupDesc": "Bu sayfadan indirilmiş bir .zip veya .json dosyası. Geri yükleme bu atölyedeki her şeyi değiştirir.",
+    "restore": "Geri yükle",
+    "restoreTitle": "Bir yedeği geri yükle"
   },
   "license": {
     "title": "Beyaz Etiket Lisansı",

+ 1 - 0
messages/tr/vehicles.json

@@ -4,6 +4,7 @@
     "archived": "Arşivlenmiş",
     "searchPlaceholder": "Plaka, marka, model, VIN ile ara...",
     "addVehicle": "Araç Ekle",
+    "importVehicles": "İçe Aktar",
     "emptySearch": "Aramanızla eşleşen araç bulunamadı.",
     "emptyArchived": "Arşivlenmiş araç yok.",
     "empty": "Henüz araç yok.",

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 729 - 10
package-lock.json


+ 4 - 0
package.json

@@ -46,8 +46,10 @@
     "cron": "^4.4.0",
     "date-fns": "^4.1.0",
     "dotenv": "^17.2.4",
+    "exceljs": "^4.4.0",
     "form-data": "^4.0.6",
     "html5-qrcode": "^2.3.8",
+    "iconv-lite": "^0.7.3",
     "jszip": "^3.10.1",
     "lucide-react": "^0.563.0",
     "mailgun.js": "^12.7.1",
@@ -57,6 +59,7 @@
     "next-ws": "^2.2.11",
     "nodemailer": "^9.0.1",
     "openai": "^6.27.0",
+    "papaparse": "^5.7.0",
     "pdf-lib": "^1.17.1",
     "posthog-js": "^1.417.1",
     "postmark": "^4.0.7",
@@ -92,6 +95,7 @@
     "@types/cron": "^2.0.1",
     "@types/node": "^20",
     "@types/nodemailer": "^7.0.9",
+    "@types/papaparse": "^5.5.2",
     "@types/react": "^19",
     "@types/react-dom": "^19",
     "@types/ws": "^8.18.1",

+ 62 - 0
prisma/migrations/20260902190000_import_batches/migration.sql

@@ -0,0 +1,62 @@
+-- CreateTable
+CREATE TABLE "import_batches" (
+    "id" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "userId" TEXT NOT NULL,
+    "source" TEXT NOT NULL,
+    "entity" TEXT NOT NULL,
+    "fileName" TEXT NOT NULL,
+    "status" TEXT NOT NULL DEFAULT 'completed',
+    "totalRows" INTEGER NOT NULL DEFAULT 0,
+    "created" INTEGER NOT NULL DEFAULT 0,
+    "updated" INTEGER NOT NULL DEFAULT 0,
+    "skipped" INTEGER NOT NULL DEFAULT 0,
+    "failed" INTEGER NOT NULL DEFAULT 0,
+    "mapping" JSONB,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "undoneAt" TIMESTAMP(3),
+
+    CONSTRAINT "import_batches_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "import_batches_organizationId_createdAt_idx" ON "import_batches"("organizationId", "createdAt");
+
+-- AddForeignKey
+ALTER TABLE "import_batches" ADD CONSTRAINT "import_batches_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AlterTable
+ALTER TABLE "customers" ADD COLUMN "importBatchId" TEXT;
+
+-- AlterTable
+ALTER TABLE "vehicles" ADD COLUMN "importBatchId" TEXT;
+
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN "importBatchId" TEXT;
+
+-- CreateIndex
+CREATE INDEX "customers_importBatchId_idx" ON "customers"("importBatchId");
+
+-- CreateIndex
+CREATE INDEX "vehicles_importBatchId_idx" ON "vehicles"("importBatchId");
+
+-- CreateIndex
+CREATE INDEX "service_records_importBatchId_idx" ON "service_records"("importBatchId");
+
+-- AddForeignKey
+ALTER TABLE "customers" ADD CONSTRAINT "customers_importBatchId_fkey" FOREIGN KEY ("importBatchId") REFERENCES "import_batches"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "vehicles" ADD CONSTRAINT "vehicles_importBatchId_fkey" FOREIGN KEY ("importBatchId") REFERENCES "import_batches"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "service_records" ADD CONSTRAINT "service_records_importBatchId_fkey" FOREIGN KEY ("importBatchId") REFERENCES "import_batches"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "import_batches" ADD CONSTRAINT "import_batches_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AlterTable
+ALTER TABLE "inspections" ADD COLUMN "importBatchId" TEXT;
+
+-- AddForeignKey
+ALTER TABLE "inspections" ADD CONSTRAINT "inspections_importBatchId_fkey" FOREIGN KEY ("importBatchId") REFERENCES "import_batches"("id") ON DELETE SET NULL ON UPDATE CASCADE;

+ 1 - 0
prisma/schema/auth.prisma

@@ -51,6 +51,7 @@ model User {
   tireMeasurements       TireMeasurement[]
   tireMovements          TireMovement[]
   tireTreatments         TireTreatment[]
+  importBatches          ImportBatch[]
 
   @@map("users")
 }

+ 4 - 0
prisma/schema/customers.prisma

@@ -35,8 +35,12 @@ model Customer {
   reminders      Reminder[]
   tireSets       TireSet[]
 
+  importBatchId String?
+  importBatch   ImportBatch? @relation(fields: [importBatchId], references: [id], onDelete: SetNull)
+
   @@unique([organizationId, customerNumber])
   @@index([organizationId])
+  @@index([importBatchId])
   @@map("customers")
 }
 

+ 33 - 0
prisma/schema/import.prisma

@@ -0,0 +1,33 @@
+/// One run of the spreadsheet importer. Rows it created point back here so
+/// the run can be undone, and the mapping is kept so the same source can be
+/// imported again without re-mapping.
+model ImportBatch {
+  id             String       @id @default(cuid())
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  userId         String
+  /// What the file was: 'spreadsheet', 'vcard', or a preset id such as 'google-contacts'.
+  source         String
+  /// 'customers' | 'vehicles' | 'services'
+  entity         String
+  fileName       String
+  /// 'completed' | 'undone'
+  status         String       @default("completed")
+  totalRows      Int          @default(0)
+  created        Int          @default(0)
+  updated        Int          @default(0)
+  skipped        Int          @default(0)
+  failed         Int          @default(0)
+  mapping        Json?
+  createdAt      DateTime     @default(now())
+  undoneAt       DateTime?
+
+  customers      Customer[]
+  vehicles       Vehicle[]
+  serviceRecords ServiceRecord[]
+  user           User            @relation(fields: [userId], references: [id])
+  inspections    Inspection[]
+
+  @@index([organizationId, createdAt])
+  @@map("import_batches")
+}

+ 3 - 0
prisma/schema/inspections.prisma

@@ -135,6 +135,9 @@ model Inspection {
   quotes         Quote[]
   serviceRecords ServiceRecord[]
 
+  importBatchId String?
+  importBatch   ImportBatch? @relation(fields: [importBatchId], references: [id], onDelete: SetNull)
+
   @@index([vehicleId])
   @@index([organizationId])
   @@index([technicianId])

+ 1 - 0
prisma/schema/organization.prisma

@@ -75,6 +75,7 @@ model Organization {
   tireTreatments         TireTreatment[]
   integrationConnections IntegrationConnection[]
   externalCalendarEvents ExternalCalendarEvent[]
+  importBatches          ImportBatch[]
 
   @@map("organizations")
 }

+ 4 - 0
prisma/schema/service-records.prisma

@@ -102,12 +102,16 @@ model ServiceRecord {
   discoveredFindings VehicleFinding[] @relation("FindingDiscoveredIn")
   resolvedFindings   VehicleFinding[] @relation("FindingResolvedIn")
 
+  importBatchId String?
+  importBatch   ImportBatch? @relation(fields: [importBatchId], references: [id], onDelete: SetNull)
+
   @@index([vehicleId])
   @@index([technicianId])
   @@index([workBayId])
   @@index([organizationId])
   @@index([customerId])
   @@index([inspectionId])
+  @@index([importBatchId])
   @@map("service_records")
 }
 

+ 4 - 0
prisma/schema/vehicles.prisma

@@ -43,7 +43,11 @@ model Vehicle {
   scheduledMessages ScheduledMessage[]
   tireSets          TireSet[]
 
+  importBatchId String?
+  importBatch   ImportBatch? @relation(fields: [importBatchId], references: [id], onDelete: SetNull)
+
   @@index([organizationId])
+  @@index([importBatchId])
   @@map("vehicles")
 }
 

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

@@ -0,0 +1,176 @@
+import { describe, expect, it } from 'vitest'
+import {
+  detectDateFormat,
+  detectDecimalSeparator,
+  joinAddress,
+  mapFuelType,
+  mapTransmission,
+  normalizeHeader,
+  normalizePhone,
+  normalizeVin,
+  parseDate,
+  parseNumber,
+  parseYear,
+  phoneKey,
+  plateKey,
+  splitVehicleDescription,
+} from '@/features/import/Lib/normalize'
+
+const iso = (d: Date | null) => d?.toISOString().slice(0, 10) ?? null
+
+/**
+ * The importer is used everywhere the app is sold, so the normaliser must not
+ * assume a locale: dates, decimals, headers and fuel types all arrive in the
+ * shape of whatever system the shop is leaving.
+ */
+describe('import normalisation', () => {
+  it('flattens headers across languages and punctuation', () => {
+    expect(normalizeHeader('E-Mail 1 - Value')).toBe('email1value')
+    expect(normalizeHeader('Reg.nr')).toBe('regnr')
+    expect(normalizeHeader('Straße')).toBe('strasse')
+    expect(normalizeHeader('Kjøretøy')).toBe('kjoretoy')
+    expect(normalizeHeader('Nr rejestracyjny')).toBe('nrrejestracyjny')
+    expect(normalizeHeader('Müşteri Adı')).toBe('musteriadi')
+  })
+
+  it('reads numbers in either decimal convention', () => {
+    expect(parseNumber('1,234.56')).toBe(1234.56)
+    expect(parseNumber('1.234,56')).toBe(1234.56)
+    expect(parseNumber('1 234,56')).toBe(1234.56)
+    expect(parseNumber('$1,234.56')).toBe(1234.56)
+    expect(parseNumber('kr 189,50')).toBe(189.5)
+    expect(parseNumber('12,5')).toBe(12.5)
+    expect(parseNumber('12.5')).toBe(12.5)
+    expect(parseNumber('(12.50)')).toBe(-12.5)
+    expect(parseNumber('-45')).toBe(-45)
+    expect(parseNumber('abc')).toBeNull()
+    expect(parseNumber('')).toBeNull()
+  })
+
+  it('treats a lone separator followed by three digits as grouping', () => {
+    expect(parseNumber('125.000')).toBe(125000)
+    expect(parseNumber('125,000')).toBe(125000)
+    expect(parseNumber('0.500')).toBe(0.5)
+    expect(parseNumber('125.000', '.')).toBe(125)
+    expect(parseNumber('125,000', ',')).toBe(125)
+  })
+
+  it('detects the decimal mark from a column', () => {
+    expect(detectDecimalSeparator(['1.234,56', '12,5', '189'])).toBe(',')
+    expect(detectDecimalSeparator(['1,234.56', '12.5'])).toBe('.')
+    expect(detectDecimalSeparator(['125.000', '3.000'])).toBe('auto')
+  })
+
+  it('reads years from many shapes', () => {
+    expect(parseYear('2018')).toBe(2018)
+    expect(parseYear('2018-03-01')).toBe(2018)
+    expect(parseYear('18')).toBe(2018)
+    expect(parseYear('98')).toBe(1998)
+    expect(parseYear('MY2020')).toBe(2020)
+    expect(parseYear('abc')).toBeNull()
+  })
+
+  it('parses dates in ISO, day-first, month-first, month-name and Excel forms', () => {
+    expect(iso(parseDate('2024-03-15'))).toBe('2024-03-15')
+    expect(iso(parseDate('2024-03-15T14:30:00Z'))).toBe('2024-03-15')
+    expect(iso(parseDate('2024/3/5'))).toBe('2024-03-05')
+    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('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')
+    expect(iso(parseDate('15. März 2024'))).toBe('2024-03-15')
+    expect(iso(parseDate('5 janvier 2023'))).toBe('2023-01-05')
+    expect(iso(parseDate(45366))).toBe('2024-03-15')
+    expect(iso(parseDate('45366'))).toBe('2024-03-15')
+    expect(iso(parseDate(new Date(Date.UTC(2024, 2, 15))))).toBe('2024-03-15')
+  })
+
+  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')
+    expect(iso(parseDate('03/04/2024'))).toBe('2024-04-03')
+    expect(iso(parseDate('03/04/24', 'DMY'))).toBe('2024-04-03')
+    expect(parseDate('31/02/2024')).toBeNull()
+    expect(parseDate('not a date')).toBeNull()
+    expect(parseDate('15/03/1850')).toBeNull()
+  })
+
+  it('detects day-first versus month-first from a column', () => {
+    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(['03/04/2024', '05/06/2024'])).toBe('auto')
+  })
+
+  it('normalises phones with a workshop country code and keeps them otherwise', () => {
+    expect(normalizePhone('912 34 567', '+47')).toBe('+4791234567')
+    expect(normalizePhone('+47 912 34 567', null)).toBe('+4791234567')
+    expect(normalizePhone('004791234567', null)).toBe('+4791234567')
+    expect(normalizePhone('91234567', null)).toBe('91234567')
+    expect(normalizePhone('555-1234 ext. 12', '+1')).toBe('555-1234 ext. 12')
+    expect(normalizePhone('', '+47')).toBeNull()
+    expect(phoneKey('+47 912 34 567')).toBe(phoneKey('91234567'))
+  })
+
+  it('validates VINs and compares plates loosely', () => {
+    expect(normalizeVin('jtdbr32e720012345')).toEqual({ value: 'JTDBR32E720012345', valid: true })
+    expect(normalizeVin('ABC123').valid).toBe(false)
+    expect(normalizeVin('JTDBR32E72001234I').valid).toBe(false)
+    expect(plateKey('AB 12345')).toBe('AB12345')
+    expect(plateKey('ab-12345')).toBe('AB12345')
+  })
+
+  it('maps fuel and transmission words from many languages', () => {
+    expect(mapFuelType('Benzin')).toBe('gasoline')
+    expect(mapFuelType('Bensin')).toBe('gasoline')
+    expect(mapFuelType('Petrol')).toBe('gasoline')
+    expect(mapFuelType('Diesel (Euro 6)')).toBe('diesel')
+    expect(mapFuelType('Elektrisk')).toBe('electric')
+    expect(mapFuelType('Plug-in hybrid')).toBe('hybrid')
+    expect(mapFuelType('LPG')).toBe('other')
+    expect(mapFuelType('')).toBeNull()
+    expect(mapTransmission('Automatik')).toBe('automatic')
+    expect(mapTransmission('Manuell')).toBe('manual')
+    expect(mapTransmission('DSG')).toBe('automatic')
+    expect(mapTransmission('CVT')).toBe('cvt')
+    expect(mapTransmission('unknown')).toBeNull()
+  })
+
+  it('splits a single vehicle column into year, make and model', () => {
+    expect(splitVehicleDescription('2018 Toyota Corolla')).toEqual({
+      year: 2018,
+      make: 'Toyota',
+      model: 'Corolla',
+    })
+    expect(splitVehicleDescription('Toyota Corolla 2018')).toEqual({
+      year: 2018,
+      make: 'Toyota',
+      model: 'Corolla',
+    })
+    expect(splitVehicleDescription('Toyota Corolla (2018)')).toEqual({
+      year: 2018,
+      make: 'Toyota',
+      model: 'Corolla',
+    })
+    expect(splitVehicleDescription('Alfa Romeo Giulia')).toEqual({
+      year: null,
+      make: 'Alfa Romeo',
+      model: 'Giulia',
+    })
+    expect(splitVehicleDescription('Land Rover Defender 110 2020')).toEqual({
+      year: 2020,
+      make: 'Land Rover',
+      model: 'Defender 110',
+    })
+  })
+
+  it('assembles an address from its parts', () => {
+    expect(
+      joinAddress({ street: 'Storgata 1', postalCode: '0155', city: 'Oslo', country: 'Norway' })
+    ).toBe('Storgata 1, 0155 Oslo, Norway')
+    expect(joinAddress({ city: 'Oslo' })).toBe('Oslo')
+    expect(joinAddress({})).toBeNull()
+  })
+})

+ 112 - 0
src/__tests__/features/import/parse.test.ts

@@ -0,0 +1,112 @@
+import ExcelJS from 'exceljs'
+import iconv from 'iconv-lite'
+import { describe, expect, it } from 'vitest'
+import { decodeText, parseImportFile, tidyColumns } from '@/features/import/Lib/parse'
+
+/**
+ * Files arrive as whatever the old system produced: semicolon CSV in a
+ * Windows encoding, an Excel workbook with real dates, a phone's vCard. All of
+ * them must come out as the same header-plus-rows shape.
+ */
+describe('import file parsing', () => {
+  it('reads comma and semicolon CSV, with quotes and a BOM', async () => {
+    const csv = Buffer.from(
+      'Name;Email;"Phone"\r\n"Berg, Anna";anna@example.com;"+47 912 34 567"\r\n\r\n'
+    )
+    const sheet = await parseImportFile(csv, 'customers.csv')
+    expect(sheet.format).toBe('csv')
+    expect(sheet.delimiter).toBe(';')
+    expect(sheet.encoding).toBe('UTF-8')
+    expect(sheet.columns).toEqual(['Name', 'Email', 'Phone'])
+    expect(sheet.rows).toEqual([['Berg, Anna', 'anna@example.com', '+47 912 34 567']])
+  })
+
+  it('falls back to Windows-1252 when the bytes are not UTF-8', async () => {
+    const latin = iconv.encode('Navn,By\nBjørn Sæther,Tromsø\n', 'win1252')
+    expect(decodeText(latin).encoding).toBe('Windows-1252')
+    const sheet = await parseImportFile(latin, 'kunder.csv')
+    expect(sheet.rows[0]).toEqual(['Bjørn Sæther', 'Tromsø'])
+  })
+
+  it('names blank headers, disambiguates duplicates and drops empty trailing columns', async () => {
+    expect(tidyColumns(['Name', '', 'Name', null])).toEqual([
+      'Name',
+      'Column 2',
+      'Name (2)',
+      'Column 4',
+    ])
+    const sheet = await parseImportFile(Buffer.from('Name,Email,,\nAnna,a@x.com,,\n'), 'x.csv')
+    expect(sheet.columns).toEqual(['Name', 'Email'])
+    expect(sheet.rows).toEqual([['Anna', 'a@x.com']])
+  })
+
+  it('reads an Excel workbook, turning dates and numbers into text', async () => {
+    const wb = new ExcelJS.Workbook()
+    const ws = wb.addWorksheet('Jobs')
+    ws.addRow(['Date', 'Plate', 'Total'])
+    ws.addRow([new Date(Date.UTC(2024, 2, 15)), 'AB 12345', 1890.5])
+    const buffer = Buffer.from(await wb.xlsx.writeBuffer())
+    const sheet = await parseImportFile(buffer, 'jobs.xlsx')
+    expect(sheet.format).toBe('xlsx')
+    expect(sheet.sheetName).toBe('Jobs')
+    expect(sheet.columns).toEqual(['Date', 'Plate', 'Total'])
+    expect(sheet.rows).toEqual([['2024-03-15', 'AB 12345', '1890.5']])
+  })
+
+  it('reads a workbook that was renamed to .csv', async () => {
+    const wb = new ExcelJS.Workbook()
+    wb.addWorksheet('S').addRows([['Name'], ['Anna']])
+    const buffer = Buffer.from(await wb.xlsx.writeBuffer())
+    const sheet = await parseImportFile(buffer, 'renamed.csv')
+    expect(sheet.format).toBe('xlsx')
+    expect(sheet.rows).toEqual([['Anna']])
+  })
+
+  it('reads vCards into the customer columns', async () => {
+    const vcf = Buffer.from(
+      [
+        'BEGIN:VCARD',
+        'VERSION:3.0',
+        'N:Berg;Anna;;;',
+        'FN:Anna Berg',
+        'ORG:Berg Transport AS;',
+        'TEL;TYPE=HOME:22 33 44 55',
+        'TEL;TYPE=CELL:+47 912 34 567',
+        'EMAIL;TYPE=INTERNET:anna@example.com',
+        'ADR;TYPE=HOME:;;Storgata 1;Oslo;;0155;Norway',
+        'NOTE:Prefers SMS\\, not calls',
+        'END:VCARD',
+        'BEGIN:VCARD',
+        'FN:',
+        'END:VCARD',
+      ].join('\r\n')
+    )
+    const sheet = await parseImportFile(vcf, 'contacts.vcf')
+    expect(sheet.format).toBe('vcard')
+    expect(sheet.columns[0]).toBe('Name')
+    expect(sheet.rows).toEqual([
+      [
+        'Anna Berg',
+        'Anna',
+        'Berg',
+        'anna@example.com',
+        '+47 912 34 567',
+        'Berg Transport AS',
+        'Storgata 1, 0155 Oslo, Norway',
+        'Prefers SMS, not calls',
+      ],
+    ])
+  })
+
+  it('refuses old .xls, unknown types and empty files with a clear code', async () => {
+    await expect(parseImportFile(Buffer.from('x'), 'old.xls')).rejects.toMatchObject({
+      code: 'legacy_xls',
+    })
+    await expect(parseImportFile(Buffer.from('x'), 'doc.pdf')).rejects.toMatchObject({
+      code: 'unsupported_format',
+    })
+    await expect(parseImportFile(Buffer.from('Name\n'), 'empty.csv')).rejects.toMatchObject({
+      code: 'empty',
+    })
+  })
+})

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

@@ -0,0 +1,293 @@
+import { describe, expect, it } from 'vitest'
+import { type ExistingData, type ImportOptions, planImport } from '@/features/import/Lib/pipeline'
+
+const options = (
+  entity: ImportOptions['entity'],
+  extra: Partial<ImportOptions> = {}
+): ImportOptions => ({
+  entity,
+  dateFormat: 'auto',
+  decimalSeparator: 'auto',
+  defaultCountryCode: '+47',
+  duplicates: 'update',
+  ...extra,
+})
+
+const nothing: ExistingData = { customers: [], vehicles: [], invoiceNumbers: [] }
+
+const existing: ExistingData = {
+  customers: [
+    {
+      id: 'c1',
+      name: 'Anna Berg',
+      customerNumber: '1001',
+      email: 'anna@example.com',
+      phone: '+4791234567',
+    },
+    { id: 'c2', name: 'Ola Nordmann', customerNumber: '1002', email: null, phone: null },
+  ],
+  vehicles: [
+    {
+      id: 'v1',
+      make: 'Toyota',
+      model: 'Corolla',
+      year: 2018,
+      vin: 'JTDBR32E720012345',
+      licensePlate: 'AB 12345',
+      customerId: 'c1',
+    },
+  ],
+  invoiceNumbers: ['INV-1001'],
+}
+
+/**
+ * The plan is the dry run and the commit in one: what it says will happen is
+ * what happens. These tests pin the decisions that matter to a shop moving
+ * its data across: matching, sharing, ordering and refusing.
+ */
+describe('import plan', () => {
+  it('creates customers and normalises what it can', () => {
+    const plan = planImport(
+      [['Anna', 'Berg', 'ANNA@Example.com', '912 34 567', 'Storgata 1', '0155', 'Oslo']],
+      {
+        '0': 'customer.firstName',
+        '1': 'customer.lastName',
+        '2': 'customer.email',
+        '3': 'customer.phone',
+        '4': 'customer.street',
+        '5': 'customer.postalCode',
+        '6': 'customer.city',
+      },
+      options('customers'),
+      nothing
+    )
+    expect(plan.summary).toMatchObject({ total: 1, create: 1, error: 0, customersToCreate: 1 })
+    expect(plan.rows[0].customer).toMatchObject({
+      name: 'Anna Berg',
+      email: 'anna@example.com',
+      phone: '+4791234567',
+      address: 'Storgata 1, 0155 Oslo',
+    })
+  })
+
+  it('matches existing customers by number, email, phone, then name', () => {
+    const rows = [
+      ['Someone Else', '', '', '1001'],
+      ['Someone Else', 'anna@example.com', '', ''],
+      ['Someone Else', '', '91234567', ''],
+      ['ola nordmann', '', '', ''],
+      ['Anna Berg', 'other@example.com', '', ''],
+    ]
+    const plan = planImport(
+      rows,
+      {
+        '0': 'customer.name',
+        '1': 'customer.email',
+        '2': 'customer.phone',
+        '3': 'customer.customerNumber',
+      },
+      options('customers'),
+      existing
+    )
+    expect(plan.rows.map((r) => r.customerMatch?.on)).toEqual([
+      'number',
+      'email',
+      'phone',
+      'name',
+      undefined,
+    ])
+    expect(plan.rows.map((r) => r.action)).toEqual([
+      'update',
+      'update',
+      'update',
+      'update',
+      'create',
+    ])
+  })
+
+  it('applies the duplicate rule and per-row overrides', () => {
+    const rows = [
+      ['Anna Berg', 'anna@example.com'],
+      ['Ola Nordmann', ''],
+    ]
+    const mapping = { '0': 'customer.name', '1': 'customer.email' }
+    expect(
+      planImport(rows, mapping, options('customers', { duplicates: 'skip' }), existing).summary.skip
+    ).toBe(2)
+    expect(
+      planImport(rows, mapping, options('customers', { duplicates: 'create' }), existing).summary
+        .create
+    ).toBe(2)
+    const plan = planImport(rows, mapping, options('customers'), existing, { '1': 'skip' })
+    expect(plan.rows.map((r) => r.action)).toEqual(['update', 'skip'])
+  })
+
+  it('refuses rows that cannot be imported and says why', () => {
+    const plan = planImport(
+      [
+        ['', 'anna@example.com'],
+        ['Anna', 'not-an-email'],
+      ],
+      { '0': 'customer.name', '1': 'customer.email' },
+      options('customers'),
+      nothing
+    )
+    expect(plan.rows[0].action).toBe('error')
+    expect(plan.rows[0].errors[0].code).toBe('customer_name_required')
+    expect(plan.rows[1].action).toBe('create')
+    expect(plan.rows[1].warnings[0].code).toBe('invalid_email')
+    expect(plan.rows[1].customer?.email).toBeNull()
+  })
+
+  it('creates a vehicle once when it appears on several rows, and its owner once', () => {
+    const rows = [
+      ['Toyota', 'Corolla', '2018', 'CD 99999', 'Kari Hansen', 'kari@example.com'],
+      ['Toyota', 'Corolla', '2018', 'cd-99999', 'Kari Hansen', 'kari@example.com'],
+      ['Volvo', 'V70', '2012', 'EF 11111', 'Kari Hansen', 'kari@example.com'],
+    ]
+    const plan = planImport(
+      rows,
+      {
+        '0': 'vehicle.make',
+        '1': 'vehicle.model',
+        '2': 'vehicle.year',
+        '3': 'vehicle.licensePlate',
+        '4': 'customer.name',
+        '5': 'customer.email',
+      },
+      options('vehicles'),
+      nothing
+    )
+    expect(plan.summary).toMatchObject({ vehiclesToCreate: 2, customersToCreate: 1 })
+    expect(plan.rows[1].vehicleSameAs).toBe(0)
+    expect(plan.rows[1].customerSameAs).toBe(0)
+    expect(plan.rows[2].customerSameAs).toBe(0)
+    expect(plan.rows[2].createsVehicle).toBe(true)
+  })
+
+  it('links a vehicle row to its existing owner and matches the vehicle by VIN or plate', () => {
+    const plan = planImport(
+      [
+        ['Toyota', 'Corolla', '2018', 'ab12345', '', 'anna@example.com'],
+        ['Toyota', 'Corolla', '2018', '', 'jtdbr32e720012345', ''],
+      ],
+      {
+        '0': 'vehicle.make',
+        '1': 'vehicle.model',
+        '2': 'vehicle.year',
+        '3': 'vehicle.licensePlate',
+        '4': 'vehicle.vin',
+        '5': 'customer.email',
+      },
+      options('vehicles'),
+      existing
+    )
+    expect(plan.rows[0].vehicleMatch?.on).toBe('plate')
+    expect(plan.rows[0].customerMatch?.id).toBe('c1')
+    expect(plan.rows[1].vehicleMatch?.on).toBe('vin')
+    expect(plan.rows.map((r) => r.action)).toEqual(['update', 'update'])
+  })
+
+  it('requires make, model and year for a new vehicle but accepts a single vehicle column', () => {
+    const plan = planImport(
+      [['2018 Toyota Corolla'], ['Toyota Corolla'], ['']],
+      { '0': 'vehicle.description' },
+      options('vehicles'),
+      nothing
+    )
+    expect(plan.rows[0].action).toBe('create')
+    expect(plan.rows[0].vehicle).toMatchObject({ year: 2018, make: 'Toyota', model: 'Corolla' })
+    expect(plan.rows[1].errors[0].code).toBe('vehicle_year_required')
+    expect(plan.rows[2].errors.map((e) => e.code)).toContain('vehicle_make_model_required')
+  })
+
+  it('imports service history against existing vehicles and refuses what it cannot place', () => {
+    const rows = [
+      ['15.03.2024', 'AB 12345', 'Oil change', '1.890,50', '84500', 'INV-2001'],
+      ['15.03.2024', 'ZZ 00000', 'Unknown car', '100', '', ''],
+      ['15.03.2024', 'AB 12345', 'Duplicate', '100', '', 'INV-1001'],
+      ['not a date', 'AB 12345', 'Bad date', '100', '', ''],
+    ]
+    const plan = planImport(
+      rows,
+      {
+        '0': 'service.date',
+        '1': 'vehicle.licensePlate',
+        '2': 'service.title',
+        '3': 'service.total',
+        '4': 'service.mileage',
+        '5': 'service.invoiceNumber',
+      },
+      options('services'),
+      existing
+    )
+    expect(plan.rows[0].action).toBe('create')
+    expect(plan.rows[0].vehicleMatch?.id).toBe('v1')
+    expect(plan.rows[0].service).toMatchObject({
+      total: 1890.5,
+      mileage: 84500,
+      invoiceNumber: 'INV-2001',
+    })
+    expect(plan.rows[0].service?.date?.slice(0, 10)).toBe('2024-03-15')
+    expect(plan.rows[1].errors[0].code).toBe('vehicle_not_found')
+    expect(plan.rows[2].errors[0].code).toBe('duplicate_invoice_number')
+    expect(plan.rows[3].errors[0].code).toBe('service_date_invalid')
+    expect(plan.summary).toMatchObject({ create: 1, error: 3, servicesToCreate: 1 })
+  })
+
+  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']],
+      {
+        '0': 'service.date',
+        '1': 'vehicle.licensePlate',
+        '2': 'vehicle.make',
+        '3': 'vehicle.model',
+        '4': 'vehicle.year',
+        '5': 'service.title',
+        '6': 'customer.name',
+      },
+      options('services'),
+      existing
+    )
+    expect(plan.rows[0].action).toBe('create')
+    expect(plan.rows[0].createsVehicle).toBe(true)
+    expect(plan.rows[0].createsCustomer).toBe(true)
+    expect(plan.summary).toMatchObject({
+      vehiclesToCreate: 1,
+      customersToCreate: 1,
+      servicesToCreate: 1,
+    })
+  })
+
+  it('hands shared-customer creation to the first row that actually runs', () => {
+    // Row 0 is skipped by override; row 1 shares its customer and must now create it.
+    const plan = planImport(
+      [
+        ['Toyota', 'Corolla', '2018', 'Kari Hansen'],
+        ['Volvo', 'V70', '2012', 'Kari Hansen'],
+        ['Saab', '9-3', '2008', 'Kari Hansen'],
+      ],
+      { '0': 'vehicle.make', '1': 'vehicle.model', '2': 'vehicle.year', '3': 'customer.name' },
+      options('vehicles'),
+      nothing,
+      { '0': 'skip' }
+    )
+    expect(plan.rows[0].action).toBe('skip')
+    expect(plan.rows[1].createsCustomer).toBe(true)
+    expect(plan.rows[1].customerSameAs).toBeUndefined()
+    expect(plan.rows[2].customerSameAs).toBe(1)
+    expect(plan.summary.customersToCreate).toBe(1)
+  })
+
+  it('ignores mapped columns that are empty on a row and unmapped columns entirely', () => {
+    const plan = planImport(
+      [['Anna Berg', '', 'ignored']],
+      { '0': 'customer.name', '1': 'customer.email' },
+      options('customers'),
+      nothing
+    )
+    expect(plan.rows[0].customer?.email).toBeNull()
+    expect(plan.rows[0].warnings).toHaveLength(0)
+  })
+})

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

@@ -0,0 +1,151 @@
+import { describe, expect, it } from 'vitest'
+import { suggestMapping } from '@/features/import/Lib/suggest'
+import { detectPreset } from '@/features/import/Lib/presets'
+
+/**
+ * A shop's export arrives with headers in its own language and its own
+ * system's vocabulary. The suggester's job is to get most columns right on
+ * sight so the user only corrects the odd one.
+ */
+describe('mapping suggestion', () => {
+  it('maps English shop-system headers', () => {
+    const { mapping, dateFormat } = suggestMapping(
+      ['Customer', 'Email Address', 'Mobile', 'Company Name', 'Street', 'Zip', 'City'],
+      [['Anna Berg', 'anna@example.com', '91234567', 'Berg AS', 'Storgata 1', '0155', 'Oslo']],
+      'customers'
+    )
+    expect(mapping).toEqual({
+      '0': 'customer.name',
+      '1': 'customer.email',
+      '2': 'customer.phone',
+      '3': 'customer.company',
+      '4': 'customer.street',
+      '5': 'customer.postalCode',
+      '6': 'customer.city',
+    })
+    expect(dateFormat).toBe('auto')
+  })
+
+  it('maps German, Norwegian and Polish vehicle headers', () => {
+    const { mapping } = suggestMapping(
+      [
+        'Kennzeichen',
+        'Fahrgestellnummer',
+        'Marke',
+        'Modell',
+        'Baujahr',
+        'Kilometerstand',
+        'Kraftstoff',
+      ],
+      [['B-AB 1234', 'WVWZZZ1KZAW123456', 'VW', 'Golf', '2015', '125.000', 'Benzin']],
+      'vehicles'
+    )
+    expect(mapping).toEqual({
+      '0': 'vehicle.licensePlate',
+      '1': 'vehicle.vin',
+      '2': 'vehicle.make',
+      '3': 'vehicle.model',
+      '4': 'vehicle.year',
+      '5': 'vehicle.mileage',
+      '6': 'vehicle.fuelType',
+    })
+
+    const nb = suggestMapping(['Reg.nr', 'Merke', 'Modell', 'Årsmodell', 'Eier'], [], 'vehicles')
+    expect(nb.mapping['0']).toBe('vehicle.licensePlate')
+    expect(nb.mapping['3']).toBe('vehicle.year')
+
+    const pl = suggestMapping(
+      ['Nr rejestracyjny', 'Marka', 'Model', 'Rocznik', 'Przebieg'],
+      [],
+      'vehicles'
+    )
+    expect(pl.mapping['0']).toBe('vehicle.licensePlate')
+    expect(pl.mapping['4']).toBe('vehicle.mileage')
+  })
+
+  it('reads a history export where bare words belong to the job', () => {
+    const { mapping, dateFormat, decimalSeparator } = suggestMapping(
+      ['Date', 'Plate', 'Description', 'Mileage', 'Total', 'Notes', 'Invoice #'],
+      [
+        ['15.03.2024', 'AB 12345', 'Oil change', '84500', '1.890,50', 'Customer waited', '1042'],
+        ['28.11.2023', 'AB 12345', 'Brake pads', '79000', '3.200,00', '', '1001'],
+      ],
+      'services'
+    )
+    expect(mapping).toEqual({
+      '0': 'service.date',
+      '1': 'vehicle.licensePlate',
+      '2': 'service.description',
+      '3': 'service.mileage',
+      '4': 'service.total',
+      '5': 'service.notes',
+      '6': 'service.invoiceNumber',
+    })
+    expect(dateFormat).toBe('DMY')
+    expect(decimalSeparator).toBe(',')
+  })
+
+  it('falls back to the values when the header says nothing', () => {
+    const { mapping, source } = suggestMapping(
+      ['Column A', 'Column B', 'Column C', 'Column D'],
+      [
+        ['Anna Berg', 'anna@example.com', '+47 912 34 567', 'JTDBR32E720012345'],
+        ['Ola Nordmann', 'ola@example.com', '+47 913 45 678', 'WVWZZZ1KZAW123456'],
+      ],
+      'vehicles'
+    )
+    expect(mapping['1']).toBe('customer.email')
+    expect(mapping['2']).toBe('customer.phone')
+    expect(mapping['3']).toBe('vehicle.vin')
+    expect(source['1']).toBe('values')
+    expect(mapping['0']).toBeUndefined()
+  })
+
+  it('never assigns one field to two columns', () => {
+    const { mapping } = suggestMapping(['Email', 'E-mail', 'Phone', 'Telefon'], [], 'customers')
+    expect(mapping).toEqual({ '0': 'customer.email', '2': 'customer.phone' })
+  })
+
+  it('only offers fields the entity can carry', () => {
+    const { mapping } = suggestMapping(['Name', 'Invoice number', 'Plate'], [], 'customers')
+    expect(mapping).toEqual({ '0': 'customer.name' })
+  })
+
+  it('recognises Google Contacts and Outlook exports', () => {
+    const google = [
+      'Name',
+      'Given Name',
+      'Family Name',
+      'E-mail 1 - Value',
+      'Phone 1 - Value',
+      'Organization 1 - Name',
+    ]
+    expect(detectPreset(google)?.id).toBe('google-contacts')
+    const { mapping, presetId } = suggestMapping(google, [], 'customers')
+    expect(presetId).toBe('google-contacts')
+    expect(mapping['0']).toBe('customer.name')
+    expect(mapping['3']).toBe('customer.email')
+    expect(mapping['5']).toBe('customer.company')
+
+    const outlook = [
+      'First Name',
+      'Last Name',
+      'E-mail Address',
+      'Mobile Phone',
+      'Company',
+      'Business Street',
+      'Business City',
+    ]
+    const o = suggestMapping(outlook, [], 'customers')
+    expect(o.presetId).toBe('outlook-contacts')
+    expect(o.mapping).toEqual({
+      '0': 'customer.firstName',
+      '1': 'customer.lastName',
+      '2': 'customer.email',
+      '3': 'customer.phone',
+      '4': 'customer.company',
+      '5': 'customer.street',
+      '6': 'customer.city',
+    })
+  })
+})

+ 2 - 2
src/app/(authenticated)/customers/customers-client.tsx

@@ -36,7 +36,7 @@ import { TableContextMenuHint } from '@/components/table-context-menu-hint'
 import { useGlassModal } from '@/components/glass-modal'
 import { useConfirm } from '@/components/confirm-dialog'
 import { CustomerForm } from '@/features/customers/Components/CustomerForm'
-import { ImportCustomersDialog } from '@/features/customers/Components/ImportCustomersDialog'
+import { ImportWizard } from '@/features/import/Components/ImportWizard'
 import { Checkbox } from '@/components/ui/checkbox'
 import { deleteCustomer, deleteCustomers } from '@/features/customers/Actions/customerActions'
 import { toast } from 'sonner'
@@ -563,7 +563,7 @@ export function CustomersClient({
         customer={editCustomer ?? undefined}
       />
 
-      <ImportCustomersDialog open={showImport} onOpenChange={setShowImport} />
+      <ImportWizard open={showImport} onOpenChange={setShowImport} entity="customers" lockEntity />
     </div>
   )
 }

+ 202 - 103
src/app/(authenticated)/settings/data/data-settings.tsx

@@ -19,13 +19,15 @@ import { Input } from '@/components/ui/input'
 import { useGlassModal } from '@/components/glass-modal'
 import {
   AlertTriangle,
-  ArrowRight,
   Download,
   FileArchive,
+  FileSpreadsheet,
   Loader2,
+  Receipt,
   ShieldCheck,
   Trash2,
   Upload,
+  Wrench,
 } from 'lucide-react'
 import { ReadOnlyBanner, ReadOnlyWrapper } from '../read-only-guard'
 import {
@@ -34,6 +36,9 @@ import {
   setSupportBubbleHidden,
 } from '@/features/support/Lib/supportVisibility'
 import { SampleDataCard } from '@/features/onboarding/Components/SampleDataCard'
+import { ImportWizard } from '@/features/import/Components/ImportWizard'
+import { ImportHistoryCard } from '@/features/import/Components/ImportHistoryCard'
+import type { ImportEntity } from '@/features/import/Lib/fields'
 import { deleteContent } from '@/features/settings/Actions/deleteContent'
 import { deleteWorkshop } from '@/features/team/Actions/deleteWorkshop'
 import { deleteAccount } from '@/features/settings/Actions/deleteAccount'
@@ -135,7 +140,11 @@ export function DataSettings({
   const fileInputRef = useRef<HTMLInputElement>(null)
   const [exporting, setExporting] = useState(false)
   const [importing, setImporting] = useState(false)
+  const [spreadsheetEntity, setSpreadsheetEntity] = useState<ImportEntity | null>(null)
+  const [importRuns, setImportRuns] = useState(0)
+  const tImport = useTranslations('dataImport.entry')
   const [selectedFile, setSelectedFile] = useState<File | null>(null)
+  const [restoreOpen, setRestoreOpen] = useState(false)
   const [options, setOptions] = useState<ExportOptions>({ ...ALL_TRUE })
 
   const allChecked = Object.values(options).every(Boolean)
@@ -219,6 +228,7 @@ export function DataSettings({
 
       setSelectedFile(null)
       if (fileInputRef.current) fileInputRef.current.value = ''
+      setRestoreOpen(false)
       modal.open('success', t('data.importComplete'), t('data.importCompleteMessage'))
     } catch (err) {
       const message = err instanceof Error ? err.message : t('data.importFailed')
@@ -453,77 +463,163 @@ export function DataSettings({
         })()}
 
       <ReadOnlyWrapper>
-        <div className="grid gap-6 lg:grid-cols-12">
-          {/* Export Card */}
-          <AppCard
-            icon={Download}
-            title={t('data.exportTitle')}
-            className="lg:col-span-7"
-            contentClassName="space-y-4"
-          >
-            <p className="text-sm text-muted-foreground">{t('data.exportDescription')}</p>
-
-            <div className="space-y-3">
-              <div className="flex items-center justify-between">
-                <span className="text-sm font-medium">{t('data.includeInBackup')}</span>
-                <button
-                  type="button"
-                  onClick={toggleAll}
-                  className="text-xs text-primary hover:underline"
+        {/* Bring data in: one card, one row per source */}
+        <AppCard
+          icon={Upload}
+          title={t('data.importTitle')}
+          description={t('data.importHubDescription')}
+          contentClassName="p-0"
+        >
+          <ul className="divide-y">
+            <ImportSourceRow
+              icon={FileSpreadsheet}
+              title={t('data.sourceSpreadsheet')}
+              description={t('data.sourceSpreadsheetDesc')}
+            >
+              {(['customers', 'vehicles', 'services'] as const).map((entity) => (
+                <Button
+                  key={entity}
+                  variant="outline"
+                  size="sm"
+                  onClick={() => setSpreadsheetEntity(entity)}
                 >
-                  {allChecked ? t('data.deselectAll') : t('data.selectAll')}
-                </button>
-              </div>
+                  {tImport(entity)}
+                </Button>
+              ))}
+            </ImportSourceRow>
+            <ImportSourceRow
+              icon={Wrench}
+              title={
+                <Image
+                  src="/images/import/lubelog.png"
+                  alt="LubeLog"
+                  width={120}
+                  height={30}
+                  className="h-5 w-auto object-contain"
+                  unoptimized
+                />
+              }
+              description={t('data.sourceLubelogDesc')}
+            >
+              <Button variant="outline" size="sm" onClick={() => setLubelogOpen(true)}>
+                {t('data.import')}
+              </Button>
+            </ImportSourceRow>
+            <ImportSourceRow
+              icon={Receipt}
+              title={
+                <Image
+                  src="/images/import/invoice_ninja.png"
+                  alt="Invoice Ninja"
+                  width={140}
+                  height={30}
+                  className="h-5 w-auto object-contain"
+                  unoptimized
+                />
+              }
+              description={t('data.sourceInvoiceNinjaDesc')}
+            >
+              <Button variant="outline" size="sm" onClick={() => setInvoiceNinjaOpen(true)}>
+                {t('data.import')}
+              </Button>
+            </ImportSourceRow>
+            <ImportSourceRow
+              icon={FileArchive}
+              title={t('data.sourceBackup')}
+              description={t('data.sourceBackupDesc')}
+            >
+              <Button variant="outline" size="sm" onClick={() => setRestoreOpen(true)}>
+                {t('data.restore')}
+              </Button>
+            </ImportSourceRow>
+          </ul>
+        </AppCard>
 
-              <div className="grid grid-cols-1 sm:grid-cols-2 gap-1">
-                {OPTION_META.map(({ key, labelKey, descKey }) => (
-                  <label
-                    key={key}
-                    className="flex items-start gap-3 rounded-md p-2 hover:bg-muted/50 cursor-pointer"
-                  >
-                    <Checkbox
-                      checked={options[key]}
-                      onCheckedChange={() => toggleOption(key)}
-                      className="mt-0.5"
-                    />
-                    <div className="space-y-0.5">
-                      <div className="text-sm font-medium leading-none">
-                        {t(`data.${labelKey}`)}
-                      </div>
-                      <div className="text-xs text-muted-foreground">{t(`data.${descKey}`)}</div>
-                    </div>
-                  </label>
-                ))}
-              </div>
+        <ImportHistoryCard refreshKey={importRuns} />
 
-              {options.files && (
-                <p className="text-xs text-muted-foreground flex items-center gap-1.5">
-                  <FileArchive className="h-3.5 w-3.5 shrink-0" />
-                  {t('data.filesWarning')}
-                </p>
-              )}
+        {/* Export */}
+        <AppCard
+          icon={Download}
+          title={t('data.exportTitle')}
+          description={t('data.exportDescription')}
+          contentClassName="space-y-4"
+        >
+          <div className="space-y-3">
+            <div className="flex items-center justify-between">
+              <span className="text-sm font-medium">{t('data.includeInBackup')}</span>
+              <button
+                type="button"
+                onClick={toggleAll}
+                className="text-xs text-primary hover:underline"
+              >
+                {allChecked ? t('data.deselectAll') : t('data.selectAll')}
+              </button>
             </div>
 
-            <Button onClick={handleExport} disabled={exporting || noneChecked}>
-              {exporting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {t('data.downloadBackup')}
-            </Button>
-          </AppCard>
+            <div className="grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3">
+              {OPTION_META.map(({ key, labelKey, descKey }) => (
+                <label
+                  key={key}
+                  className="flex cursor-pointer items-start gap-3 rounded-md p-2 hover:bg-muted/50"
+                >
+                  <Checkbox
+                    checked={options[key]}
+                    onCheckedChange={() => toggleOption(key)}
+                    className="mt-0.5"
+                  />
+                  <div className="space-y-0.5">
+                    <div className="text-sm font-medium leading-none">{t(`data.${labelKey}`)}</div>
+                    <div className="text-xs text-muted-foreground">{t(`data.${descKey}`)}</div>
+                  </div>
+                </label>
+              ))}
+            </div>
+
+            {options.files && (
+              <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                <FileArchive className="h-3.5 w-3.5 shrink-0" />
+                {t('data.filesWarning')}
+              </p>
+            )}
+          </div>
+
+          <Button onClick={handleExport} disabled={exporting || noneChecked}>
+            {exporting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+            {t('data.downloadBackup')}
+          </Button>
+        </AppCard>
+
+        {/* Onboarding sample data removal */}
+        {hasSampleData && <SampleDataCard />}
+      </ReadOnlyWrapper>
 
-          {/* Import Card */}
-          <AppCard
-            icon={Upload}
-            title={t('data.importTitle')}
-            className="lg:col-span-5"
-            contentClassName="space-y-4"
-          >
+      {/* Restore a Torqvoice backup: destructive, so it lives behind a dialog */}
+      <Dialog
+        open={restoreOpen}
+        onOpenChange={(open) => {
+          setRestoreOpen(open)
+          if (!open) {
+            setSelectedFile(null)
+            if (fileInputRef.current) fileInputRef.current.value = ''
+          }
+        }}
+      >
+        <DialogContent className="sm:max-w-lg">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              <FileArchive className="h-5 w-5" />
+              {t('data.restoreTitle')}
+            </DialogTitle>
+            <DialogDescription>{t('data.sourceBackupDesc')}</DialogDescription>
+          </DialogHeader>
+          <div className="space-y-4">
             <div className="flex items-start gap-2 rounded-md bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400">
               <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
               <span>
                 {t.rich('data.importWarning', { bold: (chunks) => <strong>{chunks}</strong> })}
               </span>
             </div>
-            <div className="space-y-3">
+            <div className="space-y-2">
               <input
                 ref={fileInputRef}
                 type="file"
@@ -532,59 +628,32 @@ export function DataSettings({
                 className="block w-full text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary/10 file:px-4 file:py-2 file:text-sm file:font-medium file:text-primary hover:file:bg-primary/20"
               />
               <p className="text-xs text-muted-foreground">{t('data.importFileHint')}</p>
+            </div>
+            <div className="flex justify-end gap-2 pt-2">
+              <Button variant="ghost" onClick={() => setRestoreOpen(false)}>
+                {t('data.cancel')}
+              </Button>
               <Button
+                variant="destructive"
                 onClick={handleImport}
                 disabled={!selectedFile || importing}
-                variant="outline"
               >
                 {importing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                 {t('data.uploadRestore')}
               </Button>
             </div>
-          </AppCard>
-        </div>
-
-        {/* Import from Other Services */}
-        <AppCard icon={ArrowRight} title={t('data.importFromOther')} contentClassName="space-y-4">
-          <p className="text-sm text-muted-foreground">{t('data.importFromOtherDescription')}</p>
-          <div className="flex flex-wrap gap-4">
-            {/* LubeLog */}
-            <button
-              type="button"
-              onClick={() => setLubelogOpen(true)}
-              className="group flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 transition-all hover:border-primary/50 hover:shadow-md"
-            >
-              <Image
-                src="/images/import/lubelog.png"
-                alt="LubeLog"
-                width={120}
-                height={30}
-                className="w-[120px] h-auto object-contain"
-                unoptimized
-              />
-            </button>
-
-            {/* Invoice Ninja */}
-            <button
-              type="button"
-              onClick={() => setInvoiceNinjaOpen(true)}
-              className="group flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 transition-all hover:border-primary/50 hover:shadow-md"
-            >
-              <Image
-                src="/images/import/invoice_ninja.png"
-                alt="Invoice Ninja"
-                width={140}
-                height={30}
-                className="w-[140px] h-auto object-contain"
-                unoptimized
-              />
-            </button>
           </div>
-        </AppCard>
+        </DialogContent>
+      </Dialog>
 
-        {/* Onboarding sample data removal */}
-        {hasSampleData && <SampleDataCard />}
-      </ReadOnlyWrapper>
+      <ImportWizard
+        open={spreadsheetEntity !== null}
+        onOpenChange={(open) => {
+          if (!open) setSpreadsheetEntity(null)
+        }}
+        entity={spreadsheetEntity ?? 'customers'}
+        onImported={() => setImportRuns((n) => n + 1)}
+      />
 
       {/* LubeLog Import Dialog */}
       <Dialog
@@ -1095,3 +1164,33 @@ export function DataSettings({
     </div>
   )
 }
+
+/**
+ * One way in. The icon slot is a neutral chip rather than the vendor's
+ * colours so the four sources read as one list; a product wordmark goes in
+ * the title slot where it has room.
+ */
+function ImportSourceRow({
+  icon: Icon,
+  title,
+  description,
+  children,
+}: {
+  icon: React.ComponentType<{ className?: string }>
+  title: React.ReactNode
+  description: string
+  children: React.ReactNode
+}) {
+  return (
+    <li className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center">
+      <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
+        <Icon className="h-5 w-5" />
+      </div>
+      <div className="min-w-0 flex-1">
+        <div className="flex items-center text-sm font-medium">{title}</div>
+        <p className="text-xs text-muted-foreground">{description}</p>
+      </div>
+      <div className="flex flex-wrap gap-2 sm:justify-end">{children}</div>
+    </li>
+  )
+}

+ 28 - 10
src/app/(authenticated)/vehicles/vehicles-client.tsx

@@ -41,6 +41,7 @@ import { VehicleForm } from '@/features/vehicles/Components/VehicleForm'
 import { deleteVehicle } from '@/features/vehicles/Actions/vehicleActions'
 import { unarchiveVehicle } from '@/features/vehicles/Actions/unarchiveVehicle'
 import { ArchiveVehicleDialog } from '@/features/vehicles/Components/ArchiveVehicleDialog'
+import { ImportWizard } from '@/features/import/Components/ImportWizard'
 import { toast } from 'sonner'
 import {
   Archive,
@@ -59,6 +60,7 @@ import {
   Plus,
   Search,
   Trash2,
+  Upload,
   Users,
   Wrench,
 } from 'lucide-react'
@@ -129,6 +131,7 @@ export function VehiclesClient({
   const tc = useTranslations('common.buttons')
   const tcm = useTranslations('common.contextMenu')
   const [showForm, setShowForm] = useState(false)
+  const [showImport, setShowImport] = useState(false)
   const [createCustomerId, setCreateCustomerId] = useState<string | undefined>(undefined)
   const [editVehicle, setEditVehicle] = useState<Vehicle | null>(null)
   const [view, setView] = useState<'table' | 'grid' | 'grid6'>(initialView)
@@ -313,16 +316,29 @@ export function VehiclesClient({
             </Button>
           </div>
           {!isArchived && (
-            <Button
-              size="sm"
-              onClick={() => setShowForm(true)}
-              aria-label={t('addVehicle')}
-              title={t('addVehicle')}
-              className="ml-auto h-9 w-9 p-0 md:h-8 md:w-auto md:px-3"
-            >
-              <Plus className="h-4 w-4 md:mr-1 md:h-3.5 md:w-3.5" />
-              <span className="hidden md:inline">{t('addVehicle')}</span>
-            </Button>
+            <div className="ml-auto flex items-center gap-2">
+              <Button
+                size="sm"
+                variant="outline"
+                onClick={() => setShowImport(true)}
+                aria-label={t('importVehicles')}
+                title={t('importVehicles')}
+                className="h-9 w-9 p-0 md:h-8 md:w-auto md:px-3"
+              >
+                <Upload className="h-4 w-4 md:mr-1 md:h-3.5 md:w-3.5" />
+                <span className="hidden md:inline">{t('importVehicles')}</span>
+              </Button>
+              <Button
+                size="sm"
+                onClick={() => setShowForm(true)}
+                aria-label={t('addVehicle')}
+                title={t('addVehicle')}
+                className="h-9 w-9 p-0 md:h-8 md:w-auto md:px-3"
+              >
+                <Plus className="h-4 w-4 md:mr-1 md:h-3.5 md:w-3.5" />
+                <span className="hidden md:inline">{t('addVehicle')}</span>
+              </Button>
+            </div>
           )}
         </div>
       </div>
@@ -824,6 +840,8 @@ export function VehiclesClient({
         onNavigate={navigate}
       />
 
+      <ImportWizard open={showImport} onOpenChange={setShowImport} entity="vehicles" lockEntity />
+
       <VehicleForm
         open={showForm}
         onOpenChange={(open) => {

+ 117 - 0
src/app/api/protected/import/analyze/route.ts

@@ -0,0 +1,117 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { isDemoMode } from '@/lib/demo'
+import { db } from '@/lib/db'
+import { getAiConfig } from '@/lib/ai'
+import { getFeatures } from '@/lib/features'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { normalizeCountryCode } from '@/lib/portal-phone'
+import type { ImportEntity } from '@/features/import/Lib/fields'
+import { fieldsFor } from '@/features/import/Lib/fields'
+import {
+  ImportParseError,
+  MAX_IMPORT_FILE_BYTES,
+  parseImportFile,
+} from '@/features/import/Lib/parse'
+import { IMPORT_PRESETS, presetById } from '@/features/import/Lib/presets'
+import { stageImport } from '@/features/import/Lib/staging'
+import { suggestMapping } from '@/features/import/Lib/suggest'
+
+export const maxDuration = 120
+
+const ENTITIES: ImportEntity[] = ['customers', 'vehicles', 'services']
+const SAMPLE_ROWS = 5
+
+/**
+ * Step one of the spreadsheet importer: take the file, parse it, guess the
+ * mapping, and park the rows for the later steps. Nothing is written to the
+ * database here.
+ */
+export async function POST(request: NextRequest) {
+  if (isDemoMode) {
+    return NextResponse.json({ error: 'Data import is disabled on the demo.' }, { status: 403 })
+  }
+  const ctx = await getAuthContext()
+  if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+  const { organizationId } = ctx
+
+  let form: FormData
+  try {
+    form = await request.formData()
+  } catch {
+    return NextResponse.json({ error: 'Expected a multipart upload.' }, { status: 400 })
+  }
+  const file = form.get('file')
+  const entity = String(form.get('entity') ?? '') as ImportEntity
+  const presetId = form.get('preset') ? String(form.get('preset')) : null
+
+  if (!(file instanceof File)) {
+    return NextResponse.json({ error: 'No file was uploaded.' }, { status: 400 })
+  }
+  if (!ENTITIES.includes(entity)) {
+    return NextResponse.json({ error: 'Unknown import type.' }, { status: 400 })
+  }
+  if (presetId && !presetById(presetId)) {
+    return NextResponse.json({ error: 'Unknown preset.' }, { status: 400 })
+  }
+  if (file.size > MAX_IMPORT_FILE_BYTES) {
+    return NextResponse.json(
+      { error: `The file is larger than ${MAX_IMPORT_FILE_BYTES / 1024 / 1024} MB.` },
+      { status: 413 }
+    )
+  }
+
+  let sheet: Awaited<ReturnType<typeof parseImportFile>>
+  try {
+    sheet = await parseImportFile(Buffer.from(await file.arrayBuffer()), file.name)
+  } catch (err) {
+    if (err instanceof ImportParseError) {
+      return NextResponse.json({ error: err.message, code: err.code }, { status: 400 })
+    }
+    console.error('[import] parse failed:', err)
+    return NextResponse.json({ error: 'The file could not be read.' }, { status: 400 })
+  }
+
+  const staged = await stageImport({
+    organizationId,
+    fileName: file.name,
+    entity,
+    presetId,
+    sheet,
+  })
+
+  const suggestion = suggestMapping(sheet.columns, sheet.rows, entity, presetId)
+
+  const [countrySetting, features, aiConfigured] = await Promise.all([
+    db.appSetting.findUnique({
+      where: {
+        organizationId_key: { organizationId, key: SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE },
+      },
+      select: { value: true },
+    }),
+    getFeatures(organizationId),
+    getAiConfig(organizationId).then(
+      () => true,
+      () => false
+    ),
+  ])
+
+  return NextResponse.json({
+    token: staged.token,
+    fileName: file.name,
+    format: sheet.format,
+    sheetName: sheet.sheetName ?? null,
+    delimiter: sheet.delimiter ?? null,
+    encoding: sheet.encoding ?? null,
+    columns: sheet.columns,
+    sampleRows: sheet.rows.slice(0, SAMPLE_ROWS),
+    totalRows: sheet.rows.length,
+    suggestion,
+    fields: fieldsFor(entity).map((f) => ({ key: f.key, group: f.group, type: f.type })),
+    presets: IMPORT_PRESETS.map((p) => ({ id: p.id, name: p.name })),
+    defaults: {
+      countryCode: normalizeCountryCode(countrySetting?.value ?? null),
+    },
+    aiAvailable: features.ai && aiConfigured,
+  })
+}

+ 54 - 0
src/app/api/protected/import/template/route.ts

@@ -0,0 +1,54 @@
+import ExcelJS from 'exceljs'
+import { NextRequest, NextResponse } from 'next/server'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { type ImportEntity, templateFieldsFor } from '@/features/import/Lib/fields'
+
+const ENTITIES: ImportEntity[] = ['customers', 'vehicles', 'services']
+
+/**
+ * A blank spreadsheet with the headers the importer recognises on sight and
+ * one example row. Offered as CSV and as an Excel workbook, since "open it,
+ * paste your data in, upload" is the whole migration for a small shop.
+ */
+export async function GET(request: NextRequest) {
+  const ctx = await getAuthContext()
+  if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+
+  const entity = request.nextUrl.searchParams.get('entity') as ImportEntity
+  const format = request.nextUrl.searchParams.get('format') === 'xlsx' ? 'xlsx' : 'csv'
+  if (!ENTITIES.includes(entity)) {
+    return NextResponse.json({ error: 'Unknown import type.' }, { status: 400 })
+  }
+
+  const fields = templateFieldsFor(entity)
+  const headers = fields.map((f) => f.templateHeader)
+  const example = fields.map((f) => f.example)
+  const fileBase = `torqvoice-${entity}-template`
+
+  if (format === 'csv') {
+    const quote = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)
+    const csv = `${[headers, example].map((r) => r.map(quote).join(',')).join('\r\n')}\r\n`
+    return new NextResponse(csv, {
+      headers: {
+        'Content-Type': 'text/csv; charset=utf-8',
+        'Content-Disposition': `attachment; filename="${fileBase}.csv"`,
+      },
+    })
+  }
+
+  const workbook = new ExcelJS.Workbook()
+  const sheet = workbook.addWorksheet(entity)
+  sheet.addRow(headers)
+  sheet.addRow(example)
+  sheet.getRow(1).font = { bold: true }
+  sheet.columns.forEach((col, i) => {
+    col.width = Math.max(14, headers[i].length + 4)
+  })
+  const buffer = await workbook.xlsx.writeBuffer()
+  return new NextResponse(buffer as unknown as ArrayBuffer, {
+    headers: {
+      'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+      'Content-Disposition': `attachment; filename="${fileBase}.xlsx"`,
+    },
+  })
+}

+ 0 - 154
src/features/customers/Actions/customerActions.ts

@@ -2,7 +2,6 @@
 
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
-import { z } from 'zod'
 import { createCustomerSchema, updateCustomerSchema } from '../Schema/customerSchema'
 import { revalidatePath } from 'next/cache'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
@@ -468,159 +467,6 @@ export async function createWorkOrderFromRequest(requestId: string) {
   )
 }
 
-const importCustomerRowSchema = z.object({
-  name: z.string().min(1),
-  email: z.string().email().optional().or(z.literal('')),
-  phone: z.string().optional(),
-  company: z.string().optional(),
-  address: z.string().optional(),
-})
-
-export async function checkDuplicateCustomers(
-  rows: { name: string; email?: string; phone?: string }[]
-) {
-  return withAuth(
-    async ({ organizationId }) => {
-      const existing = await db.customer.findMany({
-        where: { organizationId },
-        select: { id: true, name: true, email: true, phone: true, company: true, address: true },
-      })
-
-      const duplicates: Record<
-        number,
-        { id: string; name: string; matchedOn: string; isExact: boolean }
-      > = {}
-
-      for (let i = 0; i < rows.length; i++) {
-        const row = rows[i]
-        for (const ex of existing) {
-          // Match by email (non-empty)
-          if (row.email && ex.email && row.email.toLowerCase() === ex.email.toLowerCase()) {
-            const isExact =
-              ex.name.toLowerCase() === row.name.toLowerCase() &&
-              (ex.phone || '') === (row.phone || '')
-            duplicates[i] = { id: ex.id, name: ex.name, matchedOn: 'email', isExact }
-            break
-          }
-          // Match by phone (non-empty)
-          if (
-            row.phone &&
-            ex.phone &&
-            row.phone.replace(/\D/g, '') === ex.phone.replace(/\D/g, '')
-          ) {
-            const isExact =
-              ex.name.toLowerCase() === row.name.toLowerCase() &&
-              (ex.email || '').toLowerCase() === (row.email || '').toLowerCase()
-            duplicates[i] = { id: ex.id, name: ex.name, matchedOn: 'phone', isExact }
-            break
-          }
-          // Match by name (case-insensitive)
-          if (ex.name.toLowerCase() === row.name.toLowerCase()) {
-            const isExact =
-              (ex.email || '').toLowerCase() === (row.email || '').toLowerCase() &&
-              (ex.phone || '') === (row.phone || '')
-            duplicates[i] = { id: ex.id, name: ex.name, matchedOn: 'name', isExact }
-            break
-          }
-        }
-      }
-
-      return duplicates
-    },
-    {
-      requiredPermissions: [
-        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
-      ],
-    }
-  )
-}
-
-export async function importCustomers(
-  rows: { name: string; email?: string; phone?: string; company?: string; address?: string }[],
-  mergeMap?: Record<number, string> // rowIndex → existing customer ID to update
-) {
-  return withAuth(
-    async ({ userId, organizationId }) => {
-      const features = await getFeatures(organizationId)
-      const currentCount = await db.customer.count({ where: { organizationId } })
-
-      type ValidRow = {
-        name: string
-        email: string | null
-        phone: string | null
-        company: string | null
-        address: string | null
-      }
-      const toCreate: ValidRow[] = []
-      const toMerge: { id: string; data: ValidRow }[] = []
-      const errors: { row: number; error: string }[] = []
-      let skipped = 0
-
-      for (let i = 0; i < rows.length; i++) {
-        const result = importCustomerRowSchema.safeParse(rows[i])
-        if (!result.success) {
-          errors.push({ row: i + 1, error: result.error.issues[0]?.message || 'Invalid data' })
-          continue
-        }
-
-        const parsed: ValidRow = {
-          name: result.data.name,
-          email: result.data.email || null,
-          phone: result.data.phone || null,
-          company: result.data.company || null,
-          address: result.data.address || null,
-        }
-
-        const mergeId = mergeMap?.[i]
-        if (mergeId === '__skip__') {
-          skipped++
-        } else if (mergeId) {
-          toMerge.push({ id: mergeId, data: parsed })
-        } else {
-          toCreate.push(parsed)
-        }
-      }
-
-      const remaining = features.maxCustomers - currentCount
-      if (toCreate.length > remaining) {
-        throw new FeatureGatedError(
-          'maxCustomers',
-          `Customer limit reached. You can import ${remaining} more customer(s). Upgrade your plan for more.`
-        )
-      }
-
-      if (toCreate.length > 0) {
-        await db.customer.createMany({
-          data: toCreate.map((c) => ({ ...c, userId, organizationId })),
-        })
-      }
-
-      let merged = 0
-      for (const { id, data } of toMerge) {
-        const res = await db.customer.updateMany({
-          where: { id, organizationId },
-          data: {
-            name: data.name,
-            email: data.email,
-            phone: data.phone,
-            company: data.company,
-            address: data.address,
-          },
-        })
-        if (res.count > 0) merged++
-      }
-
-      revalidatePath('/customers')
-      return { imported: toCreate.length, merged, skipped, errors }
-    },
-    {
-      requiredPermissions: [
-        { action: PermissionAction.CREATE, subject: PermissionSubject.CUSTOMERS },
-      ],
-    }
-  )
-}
-
 export async function getCustomersList() {
   return withAuth(
     async ({ userId, organizationId }) => {

+ 0 - 696
src/features/customers/Components/ImportCustomersDialog.tsx

@@ -1,696 +0,0 @@
-'use client'
-
-import { useCallback, useRef, useState } from 'react'
-import { useRouter } from 'next/navigation'
-import { useTranslations } from 'next-intl'
-import { Button } from '@/components/ui/button'
-import {
-  Dialog,
-  DialogContent,
-  DialogHeader,
-  DialogTitle,
-  DialogDescription,
-} from '@/components/ui/dialog'
-import { DocsLink } from '@/components/docs-link'
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeader,
-  TableRow,
-} from '@/components/ui/table'
-import { Badge } from '@/components/ui/badge'
-import {
-  AlertTriangle,
-  Check,
-  FileUp,
-  Filter,
-  Loader2,
-  Merge,
-  SkipForward,
-  Upload,
-  X,
-} from 'lucide-react'
-import { toast } from 'sonner'
-import {
-  checkDuplicateCustomers,
-  importCustomers,
-} from '@/features/customers/Actions/customerActions'
-
-interface ParsedRow {
-  name: string
-  email?: string
-  phone?: string
-  company?: string
-  address?: string
-}
-
-interface DuplicateInfo {
-  id: string
-  name: string
-  matchedOn: string
-  isExact: boolean
-}
-
-type Step = 'upload' | 'preview' | 'result'
-
-// "skip" = don't import, "merge" = update existing, undefined = create new
-type DuplicateAction = 'skip' | 'merge'
-
-function parseCSV(text: string): ParsedRow[] {
-  const lines = text.split(/\r?\n/).filter((l) => l.trim())
-  if (lines.length < 2) return []
-
-  const parseRow = (line: string): string[] => {
-    const fields: string[] = []
-    let current = ''
-    let inQuotes = false
-    for (let i = 0; i < line.length; i++) {
-      const ch = line[i]
-      if (ch === '"') {
-        if (inQuotes && line[i + 1] === '"') {
-          current += '"'
-          i++
-        } else {
-          inQuotes = !inQuotes
-        }
-      } else if ((ch === ',' || ch === ';') && !inQuotes) {
-        fields.push(current.trim())
-        current = ''
-      } else {
-        current += ch
-      }
-    }
-    fields.push(current.trim())
-    return fields
-  }
-
-  const headers = parseRow(lines[0]).map((h) => h.toLowerCase().replace(/[^a-z0-9]/g, ''))
-
-  const headerMap: Record<string, keyof ParsedRow> = {}
-  for (let i = 0; i < headers.length; i++) {
-    const h = headers[i]
-    if (
-      ['name', 'fullname', 'contactname', 'customername', 'displayname', 'firstname'].includes(h)
-    ) {
-      headerMap[String(i)] = 'name'
-    } else if (['email', 'emailaddress', 'mail', 'primaryemail', 'emailaddress1'].includes(h)) {
-      headerMap[String(i)] = 'email'
-    } else if (
-      [
-        'phone',
-        'phonenumber',
-        'telephone',
-        'mobile',
-        'mobilenumber',
-        'primaryphone',
-        'businessphone',
-        'homephone',
-        'mobilephone',
-      ].includes(h)
-    ) {
-      headerMap[String(i)] = 'phone'
-    } else if (['company', 'companyname', 'organization', 'businessname', 'org'].includes(h)) {
-      headerMap[String(i)] = 'company'
-    } else if (
-      [
-        'address',
-        'streetaddress',
-        'fulladdress',
-        'mailingaddress',
-        'homeaddress',
-        'businessaddress',
-      ].includes(h)
-    ) {
-      headerMap[String(i)] = 'address'
-    }
-  }
-
-  const firstNameIdx = headers.findIndex((h) => ['firstname', 'givenname', 'first'].includes(h))
-  const lastNameIdx = headers.findIndex((h) =>
-    ['lastname', 'surname', 'familyname', 'last'].includes(h)
-  )
-  const hasFirstLast = firstNameIdx >= 0 && lastNameIdx >= 0
-  if (hasFirstLast) {
-    delete headerMap[String(firstNameIdx)]
-  }
-
-  if (
-    !headerMap[
-      String(
-        headers.findIndex((h) =>
-          ['name', 'fullname', 'contactname', 'customername', 'displayname'].includes(h)
-        )
-      )
-    ] &&
-    !hasFirstLast
-  ) {
-    if (Object.keys(headerMap).length === 0) {
-      headerMap['0'] = 'name'
-      if (headers.length > 1) headerMap['1'] = 'email'
-      if (headers.length > 2) headerMap['2'] = 'phone'
-    }
-  }
-
-  const rows: ParsedRow[] = []
-  for (let i = 1; i < lines.length; i++) {
-    const fields = parseRow(lines[i])
-    const row: Partial<ParsedRow> = {}
-
-    if (hasFirstLast) {
-      const first = (fields[firstNameIdx] || '').trim()
-      const last = (fields[lastNameIdx] || '').trim()
-      row.name = [first, last].filter(Boolean).join(' ')
-    }
-
-    for (const [idx, field] of Object.entries(headerMap)) {
-      const val = fields[parseInt(idx)]?.trim()
-      if (val) {
-        if (field === 'name' && !row.name) {
-          row.name = val
-        } else if (field !== 'name') {
-          row[field] = val
-        }
-      }
-    }
-
-    if (row.name) {
-      rows.push(row as ParsedRow)
-    }
-  }
-
-  return rows
-}
-
-function parseVCard(text: string): ParsedRow[] {
-  const cards = text.split('BEGIN:VCARD').filter((c) => c.includes('END:VCARD'))
-  const rows: ParsedRow[] = []
-
-  for (const card of cards) {
-    const lines = card.split(/\r?\n/)
-    let name = ''
-    let email = ''
-    let phone = ''
-    let company = ''
-    let address = ''
-
-    for (const line of lines) {
-      const upper = line.toUpperCase()
-      if (upper.startsWith('FN:') || upper.startsWith('FN;')) {
-        name = line.substring(line.indexOf(':') + 1).trim()
-      } else if ((upper.startsWith('EMAIL:') || upper.startsWith('EMAIL;')) && !email) {
-        email = line.substring(line.indexOf(':') + 1).trim()
-      } else if ((upper.startsWith('TEL:') || upper.startsWith('TEL;')) && !phone) {
-        phone = line.substring(line.indexOf(':') + 1).trim()
-      } else if (upper.startsWith('ORG:') || upper.startsWith('ORG;')) {
-        company = line
-          .substring(line.indexOf(':') + 1)
-          .split(';')[0]
-          .trim()
-      } else if ((upper.startsWith('ADR:') || upper.startsWith('ADR;')) && !address) {
-        const parts = line
-          .substring(line.indexOf(':') + 1)
-          .split(';')
-          .map((s) => s.trim())
-          .filter(Boolean)
-        address = parts.join(', ')
-      }
-    }
-
-    if (name) {
-      rows.push({
-        name,
-        email: email || undefined,
-        phone: phone || undefined,
-        company: company || undefined,
-        address: address || undefined,
-      })
-    }
-  }
-
-  return rows
-}
-
-function parseFile(content: string, fileName: string): ParsedRow[] {
-  const ext = fileName.toLowerCase().split('.').pop()
-  if (ext === 'vcf' || ext === 'vcard') {
-    return parseVCard(content)
-  }
-  return parseCSV(content)
-}
-
-export function ImportCustomersDialog({
-  open,
-  onOpenChange,
-}: {
-  open: boolean
-  onOpenChange: (open: boolean) => void
-}) {
-  const t = useTranslations('customers.import')
-  const router = useRouter()
-  const fileInputRef = useRef<HTMLInputElement>(null)
-  const [step, setStep] = useState<Step>('upload')
-  const [rows, setRows] = useState<ParsedRow[]>([])
-  const [fileName, setFileName] = useState('')
-  const [importing, setImporting] = useState(false)
-  const [checking, setChecking] = useState(false)
-  const [result, setResult] = useState<{
-    imported: number
-    merged: number
-    skipped: number
-    errors: { row: number; error: string }[]
-  } | null>(null)
-  const [dragOver, setDragOver] = useState(false)
-  const [duplicates, setDuplicates] = useState<Record<number, DuplicateInfo>>({})
-  const [dupActions, setDupActions] = useState<Record<number, DuplicateAction>>({})
-  const [showDupsOnly, setShowDupsOnly] = useState(false)
-
-  const reset = useCallback(() => {
-    setStep('upload')
-    setRows([])
-    setFileName('')
-    setResult(null)
-    setDuplicates({})
-    setDupActions({})
-    setShowDupsOnly(false)
-    if (fileInputRef.current) fileInputRef.current.value = ''
-  }, [])
-
-  const handleClose = useCallback(
-    (open: boolean) => {
-      if (!open) reset()
-      onOpenChange(open)
-    },
-    [onOpenChange, reset]
-  )
-
-  const handleFile = useCallback(
-    async (file: File) => {
-      const text = await file.text()
-      const parsed = parseFile(text, file.name)
-      if (parsed.length === 0) {
-        toast.error(t('noDataFound'))
-        return
-      }
-
-      setRows(parsed)
-      setFileName(file.name)
-      setChecking(true)
-
-      const res = await checkDuplicateCustomers(
-        parsed.map((r) => ({ name: r.name, email: r.email, phone: r.phone }))
-      )
-
-      if (res.success && res.data) {
-        setDuplicates(res.data)
-        // Auto-skip exact duplicates, default merge for partial matches
-        const actions: Record<number, DuplicateAction> = {}
-        for (const [idx, info] of Object.entries(res.data)) {
-          actions[Number(idx)] = info.isExact ? 'skip' : 'merge'
-        }
-        setDupActions(actions)
-      }
-
-      setChecking(false)
-      setStep('preview')
-    },
-    [t]
-  )
-
-  const handleFileInput = useCallback(
-    (e: React.ChangeEvent<HTMLInputElement>) => {
-      const file = e.target.files?.[0]
-      if (file) handleFile(file)
-    },
-    [handleFile]
-  )
-
-  const handleDrop = useCallback(
-    (e: React.DragEvent) => {
-      e.preventDefault()
-      setDragOver(false)
-      const file = e.dataTransfer.files[0]
-      if (file) handleFile(file)
-    },
-    [handleFile]
-  )
-
-  const toggleDupAction = (idx: number) => {
-    setDupActions((prev) => ({
-      ...prev,
-      [idx]: prev[idx] === 'skip' ? 'merge' : 'skip',
-    }))
-  }
-
-  const handleImport = async () => {
-    setImporting(true)
-
-    // Build merge map: rowIndex → customer ID (for merge) or "__skip__"
-    const mergeMap: Record<number, string> = {}
-    for (const [idx, action] of Object.entries(dupActions)) {
-      const dup = duplicates[Number(idx)]
-      if (!dup) continue
-      mergeMap[Number(idx)] = action === 'merge' ? dup.id : '__skip__'
-    }
-
-    const res = await importCustomers(rows, mergeMap)
-    if (res.success && res.data) {
-      setResult(res.data)
-      setStep('result')
-      if (res.data.imported > 0 || res.data.merged > 0) {
-        router.refresh()
-      }
-    } else {
-      toast.error(res.error || t('importFailed'))
-    }
-    setImporting(false)
-  }
-
-  const removeRow = (index: number) => {
-    setRows((prev) => prev.filter((_, i) => i !== index))
-    setDuplicates((prev) => {
-      const next: Record<number, DuplicateInfo> = {}
-      for (const [k, v] of Object.entries(prev)) {
-        const ki = Number(k)
-        if (ki < index) next[ki] = v
-        else if (ki > index) next[ki - 1] = v
-      }
-      return next
-    })
-    setDupActions((prev) => {
-      const next: Record<number, DuplicateAction> = {}
-      for (const [k, v] of Object.entries(prev)) {
-        const ki = Number(k)
-        if (ki < index) next[ki] = v
-        else if (ki > index) next[ki - 1] = v
-      }
-      return next
-    })
-  }
-
-  const dupCount = Object.keys(duplicates).length
-  const newCount = rows.length - dupCount
-  const mergeCount = Object.entries(dupActions).filter(([, a]) => a === 'merge').length
-  const skipCount = Object.entries(dupActions).filter(([, a]) => a === 'skip').length
-
-  return (
-    <Dialog open={open} onOpenChange={handleClose}>
-      <DialogContent className="sm:max-w-7xl max-h-[85vh] !overflow-hidden">
-        <DialogHeader>
-          <DialogTitle>{t('title')}</DialogTitle>
-          <DialogDescription>{t('description')}</DialogDescription>
-          <DocsLink
-            href="/docs/tutorials/migrate-customers"
-            variant="hint"
-            className="self-start"
-          />
-        </DialogHeader>
-
-        {step === 'upload' && (
-          <div
-            className={`flex flex-col items-center justify-center gap-4 rounded-lg border-2 border-dashed p-10 transition-colors ${
-              dragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
-            }`}
-            onDragOver={(e) => {
-              e.preventDefault()
-              setDragOver(true)
-            }}
-            onDragLeave={() => setDragOver(false)}
-            onDrop={handleDrop}
-          >
-            {checking ? (
-              <>
-                <Loader2 className="h-10 w-10 animate-spin text-muted-foreground" />
-                <p className="text-sm text-muted-foreground">{t('checkingDuplicates')}</p>
-              </>
-            ) : (
-              <>
-                <FileUp className="h-10 w-10 text-muted-foreground" />
-                <div className="text-center">
-                  <p className="text-sm font-medium">{t('dropFile')}</p>
-                  <p className="text-xs text-muted-foreground mt-1">{t('supportedFormats')}</p>
-                </div>
-                <Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
-                  <Upload className="mr-1.5 h-3.5 w-3.5" />
-                  {t('browse')}
-                </Button>
-              </>
-            )}
-            <input
-              ref={fileInputRef}
-              type="file"
-              accept=".csv,.tsv,.txt,.vcf,.vcard"
-              className="hidden"
-              onChange={handleFileInput}
-            />
-          </div>
-        )}
-
-        {step === 'preview' && (
-          <>
-            <div className="flex items-center justify-between text-sm">
-              <div className="flex items-center gap-2 flex-wrap text-muted-foreground">
-                <span>
-                  {fileName} — {t('rowsFound', { count: rows.length })}
-                </span>
-                {dupCount > 0 && (
-                  <Button
-                    variant={showDupsOnly ? 'default' : 'outline'}
-                    size="sm"
-                    className={`h-7 text-xs gap-1.5 ${
-                      !showDupsOnly
-                        ? 'text-amber-600 border-amber-500/40 hover:bg-amber-50 dark:hover:bg-amber-950/30'
-                        : ''
-                    }`}
-                    onClick={() => setShowDupsOnly((v) => !v)}
-                  >
-                    <Filter className="h-3 w-3" />
-                    {t('duplicatesFound', { count: dupCount })}
-                  </Button>
-                )}
-              </div>
-              <Button variant="ghost" size="sm" onClick={reset}>
-                {t('chooseAnother')}
-              </Button>
-            </div>
-
-            {dupCount > 0 && (
-              <div className="flex items-center justify-between gap-2 rounded-lg border border-amber-500/30 bg-amber-50 dark:bg-amber-950/20 px-3 py-2 text-sm">
-                <div className="flex items-start gap-2">
-                  <AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
-                  <div className="text-amber-900 dark:text-amber-200">
-                    <p>{t('duplicatesBanner', { count: dupCount })}</p>
-                    <p className="text-xs text-amber-800/70 dark:text-amber-300/60 mt-0.5">
-                      {t('duplicatesHint')}
-                    </p>
-                  </div>
-                </div>
-                <div className="flex items-center gap-1.5 shrink-0">
-                  <Button
-                    variant="outline"
-                    size="sm"
-                    className="h-7 text-xs gap-1"
-                    onClick={() => {
-                      const actions: Record<number, DuplicateAction> = {}
-                      for (const idx of Object.keys(duplicates)) actions[Number(idx)] = 'skip'
-                      setDupActions(actions)
-                    }}
-                  >
-                    <SkipForward className="h-3 w-3" />
-                    {t('skipAll')}
-                  </Button>
-                  <Button
-                    variant="outline"
-                    size="sm"
-                    className="h-7 text-xs gap-1"
-                    onClick={() => {
-                      const actions: Record<number, DuplicateAction> = {}
-                      for (const idx of Object.keys(duplicates)) actions[Number(idx)] = 'merge'
-                      setDupActions(actions)
-                    }}
-                  >
-                    <Merge className="h-3 w-3" />
-                    {t('mergeAll')}
-                  </Button>
-                </div>
-              </div>
-            )}
-
-            <div className="rounded-lg border min-w-0">
-              <div className="overflow-x-auto">
-                <Table className="min-w-[800px]">
-                  <TableHeader>
-                    <TableRow>
-                      <TableHead className="w-8">#</TableHead>
-                      <TableHead>{t('colName')}</TableHead>
-                      <TableHead>{t('colEmail')}</TableHead>
-                      <TableHead>{t('colPhone')}</TableHead>
-                      <TableHead>{t('colCompany')}</TableHead>
-                      <TableHead>{t('colAddress')}</TableHead>
-                      <TableHead className="w-24">{t('colStatus')}</TableHead>
-                      <TableHead className="w-8" />
-                    </TableRow>
-                  </TableHeader>
-                </Table>
-              </div>
-              <div className="overflow-y-auto max-h-[50vh]">
-                <div className="overflow-x-auto">
-                  <Table className="min-w-[800px]">
-                    <TableBody>
-                      {rows.map((row, i) => {
-                        const dup = duplicates[i]
-                        const action = dupActions[i]
-                        if (showDupsOnly && !dup) return null
-                        return (
-                          <TableRow
-                            key={i}
-                            className={dup ? (action === 'skip' ? 'opacity-50' : '') : ''}
-                          >
-                            <TableCell className="w-8 text-xs text-muted-foreground">
-                              {i + 1}
-                            </TableCell>
-                            <TableCell className="font-medium whitespace-nowrap">
-                              {row.name}
-                            </TableCell>
-                            <TableCell className="text-muted-foreground text-sm whitespace-nowrap">
-                              {row.email || '-'}
-                            </TableCell>
-                            <TableCell className="text-muted-foreground text-sm whitespace-nowrap">
-                              {row.phone || '-'}
-                            </TableCell>
-                            <TableCell className="text-muted-foreground text-sm max-w-[150px] truncate">
-                              {row.company || '-'}
-                            </TableCell>
-                            <TableCell className="text-muted-foreground text-sm max-w-[200px] truncate">
-                              {row.address || '-'}
-                            </TableCell>
-                            <TableCell className="w-24">
-                              {dup ? (
-                                <button
-                                  type="button"
-                                  onClick={() => toggleDupAction(i)}
-                                  className="inline-flex items-center"
-                                >
-                                  {action === 'skip' ? (
-                                    <Badge
-                                      variant="outline"
-                                      className="text-muted-foreground cursor-pointer hover:border-foreground/40 text-xs gap-1"
-                                    >
-                                      <SkipForward className="h-3 w-3" />
-                                      {t('actionSkip')}
-                                    </Badge>
-                                  ) : (
-                                    <Badge
-                                      variant="outline"
-                                      className="text-blue-600 border-blue-500/30 cursor-pointer hover:border-blue-500/60 text-xs gap-1"
-                                    >
-                                      <Merge className="h-3 w-3" />
-                                      {t('actionMerge')}
-                                    </Badge>
-                                  )}
-                                </button>
-                              ) : (
-                                <Badge
-                                  variant="outline"
-                                  className="text-emerald-600 border-emerald-500/30 text-xs"
-                                >
-                                  {t('actionNew')}
-                                </Badge>
-                              )}
-                            </TableCell>
-                            <TableCell className="w-8">
-                              <button
-                                type="button"
-                                onClick={() => removeRow(i)}
-                                className="text-muted-foreground hover:text-destructive"
-                              >
-                                <X className="h-3.5 w-3.5" />
-                              </button>
-                            </TableCell>
-                          </TableRow>
-                        )
-                      })}
-                    </TableBody>
-                  </Table>
-                </div>
-              </div>
-            </div>
-
-            <div className="flex items-center justify-between pt-2">
-              <div className="text-xs text-muted-foreground">
-                {newCount > 0 && <span>{t('summaryNew', { count: newCount })}</span>}
-                {mergeCount > 0 && (
-                  <span>
-                    {newCount > 0 ? ' · ' : ''}
-                    {t('summaryMerge', { count: mergeCount })}
-                  </span>
-                )}
-                {skipCount > 0 && (
-                  <span>
-                    {newCount > 0 || mergeCount > 0 ? ' · ' : ''}
-                    {t('summarySkip', { count: skipCount })}
-                  </span>
-                )}
-              </div>
-              <div className="flex gap-3">
-                <Button variant="outline" onClick={() => handleClose(false)}>
-                  {t('cancel')}
-                </Button>
-                <Button
-                  onClick={handleImport}
-                  disabled={importing || (newCount === 0 && mergeCount === 0)}
-                >
-                  {importing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                  {t('importButton', { count: newCount + mergeCount })}
-                </Button>
-              </div>
-            </div>
-          </>
-        )}
-
-        {step === 'result' && result && (
-          <div className="space-y-4">
-            <div className="flex items-center gap-3 rounded-lg border p-4">
-              <div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-500/10">
-                <Check className="h-5 w-5 text-emerald-600" />
-              </div>
-              <div>
-                <p className="font-medium">{t('importComplete')}</p>
-                <div className="text-sm text-muted-foreground space-y-0.5">
-                  {result.imported > 0 && <p>{t('resultCreated', { count: result.imported })}</p>}
-                  {result.merged > 0 && <p>{t('resultMerged', { count: result.merged })}</p>}
-                  {result.skipped > 0 && <p>{t('resultSkipped', { count: result.skipped })}</p>}
-                </div>
-              </div>
-            </div>
-
-            {result.errors.length > 0 && (
-              <div className="space-y-2">
-                <div className="flex items-center gap-2 text-sm text-amber-600">
-                  <AlertTriangle className="h-4 w-4" />
-                  {t('skippedRows', { count: result.errors.length })}
-                </div>
-                <div className="max-h-32 overflow-auto rounded border p-2 text-xs text-muted-foreground">
-                  {result.errors.map((err, i) => (
-                    <p key={i}>
-                      {t('rowError', {
-                        row: err.row,
-                        name: rows[err.row - 1]?.name || '?',
-                        error: err.error,
-                      })}
-                    </p>
-                  ))}
-                </div>
-              </div>
-            )}
-
-            <div className="flex justify-end">
-              <Button onClick={() => handleClose(false)}>{t('done')}</Button>
-            </div>
-          </div>
-        )}
-      </DialogContent>
-    </Dialog>
-  )
-}

+ 830 - 0
src/features/import/Actions/importActions.ts

@@ -0,0 +1,830 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject, type PermissionInput } from '@/lib/permissions'
+import { FeatureGatedError, getFeatures } from '@/lib/features'
+import { completionTuning, createClient, getAiConfig } from '@/lib/ai'
+import { describeAiError } from '@/lib/ai-error'
+import { demoGuard } from '@/lib/demo'
+import { type ImportEntity, fieldsFor } from '../Lib/fields'
+import {
+  type DuplicateRule,
+  type ExistingData,
+  type ImportOptions,
+  type ImportPlan,
+  type PlanSummary,
+  type RowAction,
+  type RowIssue,
+  type RowPlan,
+  planImport,
+} from '../Lib/pipeline'
+import {
+  type ImportProgress,
+  discardStagedImport,
+  readImportProgress,
+  readStagedImport,
+  writeImportProgress,
+} from '../Lib/staging'
+import type { ColumnMapping } from '../Lib/suggest'
+import {
+  type DateFormat,
+  type DecimalSeparator,
+  detectDateFormat,
+  detectDecimalSeparator,
+} from '../Lib/normalize'
+
+// ── Input shapes ──────────────────────────────────────────────────────────────
+
+const entitySchema = z.enum(['customers', 'vehicles', 'services'])
+const mappingSchema = z.record(z.string().regex(/^\d+$/), z.string())
+const optionsSchema = z.object({
+  entity: entitySchema,
+  dateFormat: z.enum(['auto', 'DMY', 'MDY', 'YMD']),
+  decimalSeparator: z.enum(['auto', '.', ',']),
+  defaultCountryCode: z.string().max(6).nullable(),
+  duplicates: z.enum(['skip', 'update', 'create']),
+})
+const overridesSchema = z.record(z.string().regex(/^\d+$/), z.enum(['create', 'update', 'skip']))
+const tokenSchema = z.string().uuid()
+
+const runInputSchema = z.object({
+  token: tokenSchema,
+  mapping: mappingSchema,
+  options: optionsSchema,
+  overrides: overridesSchema.default({}),
+})
+export type RunImportInput = z.infer<typeof runInputSchema>
+
+/** Zod problems become a normal failed result instead of an exception on the client. */
+function parseRun(raw: unknown): { input: RunImportInput } | { error: string } {
+  const parsed = runInputSchema.safeParse(raw)
+  if (parsed.success) return { input: parsed.data }
+  return { error: parsed.error.issues[0]?.message ?? 'Invalid import request' }
+}
+const invalid = (error: string) => ({ success: false as const, error })
+
+export type RowFilter = 'all' | 'create' | 'update' | 'skip' | 'error' | 'warning'
+const PAGE_SIZE = 100
+
+export interface PlanPage {
+  rows: RowPlan[]
+  total: number
+  page: number
+  pageSize: number
+}
+
+export interface DryRunResult {
+  summary: PlanSummary
+  limit: { maxCustomers: number; remaining: number; exceeded: boolean } | null
+  page: PlanPage
+}
+
+export interface CommitResult {
+  batchId: string
+  created: number
+  updated: number
+  skipped: number
+  failed: number
+  /** Rows that failed at write time, with what went wrong. */
+  failures: { index: number; issue: RowIssue }[]
+}
+
+function permissionsFor(entity: ImportEntity): PermissionInput[] {
+  switch (entity) {
+    case 'customers':
+      return [{ action: PermissionAction.CREATE, subject: PermissionSubject.CUSTOMERS }]
+    case 'vehicles':
+      return [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.VEHICLES },
+        { action: PermissionAction.CREATE, subject: PermissionSubject.CUSTOMERS },
+      ]
+    case 'services':
+      return [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.SERVICES },
+        { action: PermissionAction.CREATE, subject: PermissionSubject.VEHICLES },
+        { action: PermissionAction.CREATE, subject: PermissionSubject.CUSTOMERS },
+      ]
+  }
+}
+
+/** Only the field keys the entity can carry survive; anything else is ignored. */
+function sanitizeMapping(mapping: ColumnMapping, entity: ImportEntity): ColumnMapping {
+  const allowed = new Set(fieldsFor(entity).map((f) => f.key))
+  const out: ColumnMapping = {}
+  for (const [col, key] of Object.entries(mapping)) if (allowed.has(key)) out[col] = key
+  return out
+}
+
+async function loadExisting(organizationId: string, entity: ImportEntity): Promise<ExistingData> {
+  const [customers, vehicles, records] = await Promise.all([
+    db.customer.findMany({
+      where: { organizationId },
+      select: { id: true, name: true, customerNumber: true, email: true, phone: true },
+    }),
+    entity === 'customers'
+      ? Promise.resolve([])
+      : db.vehicle.findMany({
+          where: { organizationId },
+          select: {
+            id: true,
+            make: true,
+            model: true,
+            year: true,
+            vin: true,
+            licensePlate: true,
+            customerId: true,
+          },
+        }),
+    entity === 'services'
+      ? db.serviceRecord.findMany({
+          where: { organizationId, invoiceNumber: { not: null } },
+          select: { invoiceNumber: true },
+        })
+      : Promise.resolve([]),
+  ])
+  return {
+    customers,
+    vehicles,
+    invoiceNumbers: records.map((r) => r.invoiceNumber).filter((n): n is string => Boolean(n)),
+  }
+}
+
+function pageOf(plan: ImportPlan, page: number, filter: RowFilter): PlanPage {
+  const rows = plan.rows.filter((r) => {
+    if (filter === 'all') return true
+    if (filter === 'warning') return r.warnings.length > 0
+    return r.action === filter
+  })
+  const start = Math.max(0, page) * PAGE_SIZE
+  return {
+    rows: rows.slice(start, start + PAGE_SIZE),
+    total: rows.length,
+    page,
+    pageSize: PAGE_SIZE,
+  }
+}
+
+async function buildPlan(
+  organizationId: string,
+  input: RunImportInput
+): Promise<{
+  plan: ImportPlan
+  staged: NonNullable<Awaited<ReturnType<typeof readStagedImport>>>
+}> {
+  const staged = await readStagedImport(organizationId, input.token)
+  if (!staged) throw new Error('The uploaded file has expired. Upload it again.')
+  const options: ImportOptions = { ...input.options, entity: staged.entity }
+  const mapping = sanitizeMapping(input.mapping, staged.entity)
+  const existing = await loadExisting(organizationId, staged.entity)
+  const plan = planImport(staged.sheet.rows, mapping, options, existing, input.overrides)
+  return { plan, staged }
+}
+
+async function customerLimit(organizationId: string, toCreate: number) {
+  const features = await getFeatures(organizationId)
+  const current = await db.customer.count({ where: { organizationId } })
+  const remaining = Math.max(0, features.maxCustomers - current)
+  return { maxCustomers: features.maxCustomers, remaining, exceeded: toCreate > remaining }
+}
+
+// ── Dry run ───────────────────────────────────────────────────────────────────
+
+/**
+ * Run the whole pipeline against the live database and report what would
+ * happen, row by row, without writing anything. This is the wizard's preview
+ * and it is exactly the plan the commit executes.
+ */
+export async function dryRunImport(raw: unknown, page = 0, filter: RowFilter = 'all') {
+  const parsed = parseRun(raw)
+  if ('error' in parsed) return invalid(parsed.error)
+  const { input } = parsed
+  return withAuth(
+    async ({ organizationId }): Promise<DryRunResult> => {
+      const { plan, staged } = await buildPlan(organizationId, input)
+      const limit =
+        plan.summary.customersToCreate > 0 || staged.entity === 'customers'
+          ? await customerLimit(organizationId, plan.summary.customersToCreate)
+          : null
+      return { summary: plan.summary, limit, page: pageOf(plan, page, filter) }
+    },
+    { requiredPermissions: permissionsFor(input.options.entity) }
+  )
+}
+
+/** A page of the dry-run plan, for browsing and filtering without recomputing on the client. */
+export async function getImportPlanPage(raw: unknown, page: number, filter: RowFilter) {
+  const parsed = parseRun(raw)
+  if ('error' in parsed) return invalid(parsed.error)
+  const { input } = parsed
+  return withAuth(
+    async ({ organizationId }) => {
+      const { plan } = await buildPlan(organizationId, input)
+      return pageOf(plan, page, filter)
+    },
+    { requiredPermissions: permissionsFor(input.options.entity) }
+  )
+}
+
+/** The whole plan as CSV, one line per spreadsheet row, so it can be checked before anything is written. */
+export async function getImportReportCsv(raw: unknown) {
+  const parsed = parseRun(raw)
+  if ('error' in parsed) return invalid(parsed.error)
+  const { input } = parsed
+  return withAuth(
+    async ({ organizationId }) => {
+      const { plan, staged } = await buildPlan(organizationId, input)
+      const esc = (v: unknown) => {
+        const s = v == null ? '' : String(v)
+        return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
+      }
+      const lines = [
+        [
+          'Row',
+          'Action',
+          'Issues',
+          'Customer',
+          'Matched customer',
+          'Vehicle',
+          'Matched vehicle',
+          'Service',
+        ].join(','),
+      ]
+      for (const r of plan.rows) {
+        const issues = [...r.errors, ...r.warnings]
+          .map((i) => `${i.code}${i.value ? ` (${i.value})` : ''}`)
+          .join('; ')
+        const vehicle = r.vehicle
+          ? [r.vehicle.year, r.vehicle.make, r.vehicle.model, r.vehicle.licensePlate]
+              .filter(Boolean)
+              .join(' ')
+          : ''
+        const service = r.service
+          ? [r.service.date?.slice(0, 10), r.service.title].filter(Boolean).join(' ')
+          : ''
+        lines.push(
+          [
+            r.index + 2,
+            r.action,
+            issues,
+            r.customer?.name ?? '',
+            r.customerMatch?.name ?? '',
+            vehicle,
+            r.vehicleMatch?.label ?? '',
+            service,
+          ]
+            .map(esc)
+            .join(',')
+        )
+      }
+      return {
+        fileName: `${staged.fileName.replace(/\.[^.]+$/, '')}-dry-run.csv`,
+        csv: lines.join('\r\n'),
+      }
+    },
+    { requiredPermissions: permissionsFor(input.options.entity) }
+  )
+}
+
+// ── Commit ────────────────────────────────────────────────────────────────────
+
+const CHUNK = 100
+
+type Tx = Parameters<Parameters<typeof db.$transaction>[0]>[0]
+
+interface CommitState {
+  organizationId: string
+  userId: string
+  batchId: string
+  entity: ImportEntity
+  /** Row index → id of the customer/vehicle that row created, for rows that share it. */
+  customerIds: Map<number, string>
+  vehicleIds: Map<number, string>
+  nextCustomerNumber: number
+}
+
+async function nextCustomerNumberStart(organizationId: string): Promise<number> {
+  const existing = await db.customer.findMany({
+    where: { organizationId, customerNumber: { not: null } },
+    select: { customerNumber: true },
+  })
+  return (
+    existing.reduce((acc, c) => {
+      const n = Number.parseInt(c.customerNumber ?? '', 10)
+      return Number.isFinite(n) && n > acc ? n : acc
+    }, 1000) + 1
+  )
+}
+
+/** Only the values the row actually carries; an update never blanks a field. */
+function definedOnly<T extends Record<string, unknown>>(
+  data: T
+): { [K in keyof T]?: NonNullable<T[K]> } {
+  const out: Record<string, unknown> = {}
+  for (const [k, v] of Object.entries(data)) if (v != null) out[k] = v
+  return out as { [K in keyof T]?: NonNullable<T[K]> }
+}
+
+async function writeRow(tx: Tx, state: CommitState, row: RowPlan): Promise<void> {
+  const { organizationId, userId, batchId, entity } = state
+
+  // Customer
+  let customerId: string | null = null
+  if (row.customer) {
+    if (row.customerMatch) {
+      customerId = row.customerMatch.id
+      if (entity === 'customers' && row.action === 'update') {
+        await tx.customer.update({
+          where: { id: customerId },
+          data: definedOnly({
+            name: row.customer.name,
+            email: row.customer.email,
+            phone: row.customer.phone,
+            company: row.customer.company,
+            address: row.customer.address,
+            taxId: row.customer.taxId,
+            notes: row.customer.notes,
+          }),
+        })
+      }
+    } else if (row.customerSameAs != null) {
+      customerId = state.customerIds.get(row.customerSameAs) ?? null
+    } else if (row.customer.name) {
+      const customerNumber = row.customer.customerNumber ?? String(state.nextCustomerNumber++)
+      const created = await tx.customer.create({
+        data: {
+          name: row.customer.name,
+          customerNumber,
+          email: row.customer.email,
+          phone: row.customer.phone,
+          company: row.customer.company,
+          address: row.customer.address,
+          taxId: row.customer.taxId,
+          notes: row.customer.notes,
+          userId,
+          organizationId,
+          importBatchId: batchId,
+        },
+        select: { id: true },
+      })
+      customerId = created.id
+      state.customerIds.set(row.index, customerId)
+    }
+  }
+
+  // Vehicle
+  let vehicleId: string | null = null
+  if (row.vehicle) {
+    const v = row.vehicle
+    if (row.vehicleMatch) {
+      vehicleId = row.vehicleMatch.id
+      if (entity === 'vehicles' && row.action === 'update') {
+        await tx.vehicle.update({
+          where: { id: vehicleId },
+          data: definedOnly({
+            make: v.make,
+            model: v.model,
+            year: v.year,
+            vin: v.vin,
+            licensePlate: v.licensePlate,
+            color: v.color,
+            mileage: v.mileage,
+            fuelType: v.fuelType,
+            transmission: v.transmission,
+            engineSize: v.engineSize,
+            engineCode: v.engineCode,
+            purchaseDate: v.purchaseDate ? new Date(v.purchaseDate) : null,
+            purchasePrice: v.purchasePrice,
+            customerId,
+          }),
+        })
+      } else if (customerId) {
+        // A history row names the owner: attach it when the car has none.
+        await tx.vehicle.updateMany({
+          where: { id: vehicleId, customerId: null },
+          data: { customerId },
+        })
+      }
+    } else if (row.vehicleSameAs != null) {
+      vehicleId = state.vehicleIds.get(row.vehicleSameAs) ?? null
+    } else if (v.make && v.model && v.year != null) {
+      const created = await tx.vehicle.create({
+        data: {
+          make: v.make,
+          model: v.model,
+          year: v.year,
+          vin: v.vin,
+          licensePlate: v.licensePlate,
+          color: v.color,
+          mileage: v.mileage ?? row.service?.mileage ?? 0,
+          // Unknown fuel and gearbox take the schema defaults rather than a guess stored as fact.
+          ...(v.fuelType && { fuelType: v.fuelType }),
+          ...(v.transmission && { transmission: v.transmission }),
+          engineSize: v.engineSize,
+          engineCode: v.engineCode,
+          purchaseDate: v.purchaseDate ? new Date(v.purchaseDate) : null,
+          purchasePrice: v.purchasePrice,
+          customerId,
+          userId,
+          organizationId,
+          importBatchId: batchId,
+        },
+        select: { id: true },
+      })
+      vehicleId = created.id
+      state.vehicleIds.set(row.index, vehicleId)
+    }
+  }
+
+  // Service record
+  if (entity === 'services' && row.service && row.action === 'create') {
+    const s = row.service
+    if (!vehicleId || !s.date || !s.title)
+      throw new Error('Service row is missing its vehicle, date or title')
+    const total = s.total ?? 0
+    await tx.serviceRecord.create({
+      data: {
+        title: s.title,
+        description: s.description,
+        type: 'maintenance',
+        status: 'completed',
+        cost: total,
+        subtotal: total,
+        totalAmount: total,
+        mileage: s.mileage,
+        serviceDate: new Date(s.date),
+        invoiceNumber: s.invoiceNumber,
+        invoiceDate: s.invoiceNumber ? new Date(s.date) : null,
+        diagnosticNotes: s.notes,
+        techName: s.technician,
+        // Historical jobs are settled; without this they would show as receivables.
+        manuallyPaid: total > 0,
+        vehicleId,
+        customerId,
+        organizationId,
+        importBatchId: batchId,
+      },
+    })
+    if (s.mileage != null) {
+      await tx.vehicle.updateMany({
+        where: { id: vehicleId, mileage: { lt: s.mileage } },
+        data: { mileage: s.mileage },
+      })
+    }
+  }
+}
+
+/**
+ * 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.
+ */
+export async function commitImport(raw: unknown) {
+  const parsed = parseRun(raw)
+  if ('error' in parsed) return invalid(parsed.error)
+  const { input } = parsed
+  return withAuth(
+    async ({ organizationId, userId }): Promise<CommitResult> => {
+      demoGuard()
+      const { plan, staged } = await buildPlan(organizationId, input)
+      const { entity } = staged
+
+      if (plan.summary.customersToCreate > 0) {
+        const limit = await customerLimit(organizationId, plan.summary.customersToCreate)
+        if (limit.exceeded) {
+          throw new FeatureGatedError(
+            'maxCustomers',
+            `Customer limit reached. You can import ${limit.remaining} more customer(s). Upgrade your plan for more.`
+          )
+        }
+      }
+
+      const batch = await db.importBatch.create({
+        data: {
+          organizationId,
+          userId,
+          source: staged.sheet.format === 'vcard' ? 'vcard' : (staged.presetId ?? 'spreadsheet'),
+          entity,
+          fileName: staged.fileName,
+          totalRows: plan.summary.total,
+          mapping: { mapping: input.mapping, options: input.options },
+        },
+        select: { id: true },
+      })
+
+      const state: CommitState = {
+        organizationId,
+        userId,
+        batchId: batch.id,
+        entity,
+        customerIds: new Map(),
+        vehicleIds: new Map(),
+        nextCustomerNumber: await nextCustomerNumberStart(organizationId),
+      }
+
+      const runnable = plan.rows.filter((r) => r.action === 'create' || r.action === 'update')
+      const failures: CommitResult['failures'] = []
+      let created = 0
+      let updated = 0
+      let done = 0
+      const total = runnable.length
+      await writeImportProgress(organizationId, input.token, { phase: 'writing', done: 0, total })
+
+      for (let start = 0; start < runnable.length; start += CHUNK) {
+        const chunk = runnable.slice(start, start + CHUNK)
+        try {
+          await db.$transaction(
+            async (tx) => {
+              for (const row of chunk) await writeRow(tx, state, row)
+            },
+            { timeout: 60_000 }
+          )
+          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,
+              },
+            })
+          }
+        }
+        done += chunk.length
+        await writeImportProgress(organizationId, input.token, { phase: 'writing', done, total })
+      }
+
+      const skipped = plan.summary.skip
+      const failed = plan.summary.error + failures.length
+      await db.importBatch.update({
+        where: { id: batch.id },
+        data: { created, updated, skipped, failed },
+      })
+      await writeImportProgress(organizationId, input.token, {
+        phase: 'done',
+        done,
+        total,
+        batchId: batch.id,
+      })
+      await discardStagedImport(organizationId, input.token).catch(() => {
+        // The staged file is a cache; the import is already recorded.
+      })
+
+      revalidatePath('/customers')
+      revalidatePath('/vehicles')
+      revalidatePath('/work-orders')
+      revalidatePath('/settings/data')
+
+      return { batchId: batch.id, created, updated, skipped, failed, failures }
+    },
+    {
+      requiredPermissions: permissionsFor(input.options.entity),
+      audit: ({ result }) => ({
+        action: 'import.commit',
+        entity: 'ImportBatch',
+        entityId: result.batchId,
+        details: {
+          key: 'import_commit',
+          params: {
+            entity: input.options.entity,
+            created: result.created,
+            updated: result.updated,
+          },
+        },
+      }),
+    }
+  )
+}
+
+export async function getImportProgress(token: string) {
+  if (!tokenSchema.safeParse(token).success) return invalid('Invalid import token')
+  return withAuth(async ({ organizationId }): Promise<ImportProgress | null> => {
+    return readImportProgress(organizationId, token)
+  })
+}
+
+// ── History and undo ──────────────────────────────────────────────────────────
+
+export async function listImportBatches(limit = 20) {
+  return withAuth(async ({ organizationId }) => {
+    const batches = await db.importBatch.findMany({
+      where: { organizationId },
+      orderBy: { createdAt: 'desc' },
+      take: limit,
+    })
+    // What is still attached tells the user what an undo would remove now,
+    // which can be fewer than were created if some were deleted since.
+    const attached = await Promise.all(
+      batches.map(async (b) => {
+        if (b.status !== 'completed') return { customers: 0, vehicles: 0, serviceRecords: 0 }
+        const [customers, vehicles, serviceRecords] = await Promise.all([
+          db.customer.count({ where: { importBatchId: b.id } }),
+          db.vehicle.count({ where: { importBatchId: b.id } }),
+          db.serviceRecord.count({ where: { importBatchId: b.id } }),
+        ])
+        return { customers, vehicles, serviceRecords }
+      })
+    )
+    return batches.map((b, i) => ({
+      id: b.id,
+      source: b.source,
+      entity: b.entity as ImportEntity,
+      fileName: b.fileName,
+      status: b.status as 'completed' | 'undone',
+      totalRows: b.totalRows,
+      created: b.created,
+      updated: b.updated,
+      skipped: b.skipped,
+      failed: b.failed,
+      createdAt: b.createdAt.toISOString(),
+      undoneAt: b.undoneAt?.toISOString() ?? null,
+      attached: attached[i],
+    }))
+  })
+}
+
+/**
+ * Remove everything a batch created. Rows the batch only updated or linked
+ * are left as they are, since the previous values are not kept. Service
+ * records go first so cascades do not surprise anyone.
+ */
+export async function undoImportBatch(batchId: string) {
+  if (!batchId) return invalid('Import not found')
+  return withAuth(
+    async ({ organizationId }) => {
+      demoGuard()
+      const batch = await db.importBatch.findFirst({ where: { id: batchId, organizationId } })
+      if (!batch) throw new Error('Import not found')
+      if (batch.status !== 'completed') throw new Error('This import has already been undone')
+
+      const result = await db.$transaction(async (tx) => {
+        const serviceRecords = await tx.serviceRecord.deleteMany({
+          where: { importBatchId: batchId, organizationId },
+        })
+        const vehicles = await tx.vehicle.deleteMany({
+          where: { importBatchId: batchId, organizationId },
+        })
+        const customers = await tx.customer.deleteMany({
+          where: { importBatchId: batchId, organizationId },
+        })
+        await tx.importBatch.update({
+          where: { id: batchId },
+          data: { status: 'undone', undoneAt: new Date() },
+        })
+        return {
+          serviceRecords: serviceRecords.count,
+          vehicles: vehicles.count,
+          customers: customers.count,
+        }
+      })
+
+      revalidatePath('/customers')
+      revalidatePath('/vehicles')
+      revalidatePath('/work-orders')
+      revalidatePath('/settings/data')
+      return result
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS },
+        { action: PermissionAction.DELETE, subject: PermissionSubject.VEHICLES },
+      ],
+      audit: ({ result }) => ({
+        action: 'import.undo',
+        entity: 'ImportBatch',
+        entityId: batchId,
+        details: {
+          key: 'import_undo',
+          params: { records: result.customers + result.vehicles + result.serviceRecords },
+        },
+      }),
+    }
+  )
+}
+
+// ── AI mapping ────────────────────────────────────────────────────────────────
+
+const aiMappingSchema = z.object({
+  mapping: z.record(z.string(), z.string().nullable()),
+  dateFormat: z.enum(['DMY', 'MDY', 'YMD']).nullable().optional(),
+  decimalSeparator: z.enum(['.', ',']).nullable().optional(),
+})
+
+/**
+ * Ask the workshop's configured model to map the columns. It sees the
+ * headers and a few sample values per column, never the whole file, and its
+ * answer is filtered to the fields the entity allows before it reaches the
+ * user, who still confirms every column.
+ */
+export async function suggestMappingWithAi(token: string) {
+  if (!tokenSchema.safeParse(token).success) return invalid('Invalid import token')
+  return withAuth(
+    async ({
+      organizationId,
+    }): Promise<{
+      mapping: ColumnMapping
+      dateFormat: DateFormat
+      decimalSeparator: DecimalSeparator
+    }> => {
+      const staged = await readStagedImport(organizationId, token)
+      if (!staged) throw new Error('The uploaded file has expired. Upload it again.')
+      const features = await getFeatures(organizationId)
+      if (!features.ai)
+        throw new FeatureGatedError('ai', 'AI features are not included in your plan.')
+
+      const config = await getAiConfig(organizationId)
+      const client = createClient(config)
+      const fields = fieldsFor(staged.entity)
+      const { columns, rows } = staged.sheet
+
+      const columnsForPrompt = columns.map((name, i) => {
+        const samples: string[] = []
+        for (const row of rows) {
+          const v = (row[i] ?? '').trim()
+          if (v) samples.push(v.slice(0, 40))
+          if (samples.length >= 4) break
+        }
+        return `${i}. "${name}" e.g. ${samples.map((s) => JSON.stringify(s)).join(', ') || '(empty)'}`
+      })
+
+      const system = [
+        'You map spreadsheet columns to fields for a vehicle workshop system.',
+        'Reply with JSON only, no prose, in this shape:',
+        '{"mapping":{"<column index>":"<field key or null>"},"dateFormat":"DMY|MDY|YMD|null","decimalSeparator":".|,|null"}',
+        'Use each field key at most once. Leave a column null when nothing fits.',
+        'Headers may be in any language. Judge by both the header and the sample values.',
+      ].join('\n')
+      const user = [
+        `Import type: ${staged.entity}`,
+        'Available field keys:',
+        ...fields.map((f) => `- ${f.key} (${f.type})`),
+        '',
+        'Columns:',
+        ...columnsForPrompt,
+      ].join('\n')
+
+      let content = ''
+      try {
+        const response = await client.chat.completions.create({
+          model: config.model,
+          messages: [
+            { role: 'system', content: system },
+            { role: 'user', content: user },
+          ],
+          ...completionTuning(config, 1500, 0),
+        })
+        content = response.choices[0]?.message?.content ?? ''
+      } catch (error) {
+        throw new Error(describeAiError(error))
+      }
+
+      const json = content.match(/\{[\s\S]*\}/)?.[0]
+      if (!json) throw new Error('The model did not return a mapping.')
+      const parsed = aiMappingSchema.parse(JSON.parse(json))
+
+      const allowed = new Set(fields.map((f) => f.key))
+      const used = new Set<string>()
+      const mapping: ColumnMapping = {}
+      for (const [col, key] of Object.entries(parsed.mapping)) {
+        if (!key || !allowed.has(key) || used.has(key)) continue
+        if (!/^\d+$/.test(col) || Number(col) >= columns.length) continue
+        mapping[col] = key
+        used.add(key)
+      }
+
+      const byKey = new Map(fields.map((f) => [f.key, f]))
+      const dateValues: string[] = []
+      const numberValues: string[] = []
+      for (const [col, key] of Object.entries(mapping)) {
+        const type = byKey.get(key)?.type
+        for (const row of rows.slice(0, 50)) {
+          const v = row[Number(col)]
+          if (!v) continue
+          if (type === 'date') dateValues.push(v)
+          if (type === 'number' || type === 'integer') numberValues.push(v)
+        }
+      }
+      const detectedDate = detectDateFormat(dateValues)
+      const detectedDecimal = detectDecimalSeparator(numberValues)
+
+      return {
+        mapping,
+        dateFormat: detectedDate !== 'auto' ? detectedDate : (parsed.dateFormat ?? 'auto'),
+        decimalSeparator:
+          detectedDecimal !== 'auto' ? detectedDecimal : (parsed.decimalSeparator ?? 'auto'),
+      }
+    }
+  )
+}
+
+export type { DuplicateRule, RowAction, RowPlan, RowIssue, PlanSummary }

+ 162 - 0
src/features/import/Components/ImportHistoryCard.tsx

@@ -0,0 +1,162 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useFormatter, useTranslations } from 'next-intl'
+import { History, Loader2, Undo2 } from 'lucide-react'
+import { toast } from 'sonner'
+import { AppCard } from '@/components/app-card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { listImportBatches, undoImportBatch } from '@/features/import/Actions/importActions'
+
+type Batch = NonNullable<Awaited<ReturnType<typeof listImportBatches>>['data']>[number]
+
+/**
+ * Every spreadsheet import the workshop has run, with an undo for the ones
+ * that can still be undone. Lives under Settings → Data next to the
+ * importer itself.
+ */
+export function ImportHistoryCard({ refreshKey = 0 }: { refreshKey?: number }) {
+  const t = useTranslations('dataImport.history')
+  const te = useTranslations('dataImport.entities')
+  const format = useFormatter()
+  const router = useRouter()
+  const [batches, setBatches] = useState<Batch[] | null>(null)
+  const [pending, setPending] = useState<Batch | null>(null)
+  const [busy, setBusy] = useState(false)
+
+  const load = useCallback(async () => {
+    const res = await listImportBatches()
+    if (res.success && res.data) setBatches(res.data)
+    else setBatches([])
+  }, [])
+
+  useEffect(() => {
+    load()
+  }, [load, refreshKey])
+
+  const undo = async () => {
+    if (!pending) return
+    setBusy(true)
+    const res = await undoImportBatch(pending.id)
+    setBusy(false)
+    setPending(null)
+    if (res.success && res.data) {
+      toast.success(
+        t('undone', {
+          customers: res.data.customers,
+          vehicles: res.data.vehicles,
+          services: res.data.serviceRecords,
+        })
+      )
+      router.refresh()
+      load()
+    } else {
+      toast.error(res.error || t('undoFailed'))
+    }
+  }
+
+  if (batches === null) {
+    return (
+      <AppCard icon={History} title={t('title')}>
+        <div className="flex items-center gap-2 text-sm text-muted-foreground">
+          <Loader2 className="h-4 w-4 animate-spin" />
+          {t('loading')}
+        </div>
+      </AppCard>
+    )
+  }
+
+  if (batches.length === 0) return null
+
+  return (
+    <AppCard icon={History} title={t('title')} description={t('description')}>
+      <div className="divide-y">
+        {batches.map((b) => {
+          const attached = b.attached.customers + b.attached.vehicles + b.attached.serviceRecords
+          return (
+            <div key={b.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
+              <div className="min-w-0">
+                <div className="flex flex-wrap items-center gap-2">
+                  <span className="truncate text-sm font-medium">{b.fileName}</span>
+                  <Badge variant="outline" className="text-xs">
+                    {te(b.entity)}
+                  </Badge>
+                  {b.status === 'undone' && (
+                    <Badge variant="secondary" className="text-xs">
+                      {t('statusUndone')}
+                    </Badge>
+                  )}
+                </div>
+                <div className="text-xs text-muted-foreground">
+                  {format.dateTime(new Date(b.createdAt), {
+                    dateStyle: 'medium',
+                    timeStyle: 'short',
+                  })}
+                  {' · '}
+                  {t('counts', {
+                    created: b.created,
+                    updated: b.updated,
+                    skipped: b.skipped,
+                    failed: b.failed,
+                  })}
+                </div>
+              </div>
+              {b.status === 'completed' && attached > 0 && (
+                <Button
+                  variant="outline"
+                  size="sm"
+                  className="h-8"
+                  onClick={() => setPending(b)}
+                  aria-label={t('undo')}
+                  title={t('undo')}
+                >
+                  <Undo2 className="mr-1 h-3.5 w-3.5" />
+                  {t('undo')}
+                </Button>
+              )}
+            </div>
+          )
+        })}
+      </div>
+
+      <AlertDialog
+        open={pending !== null}
+        onOpenChange={(open) => !open && !busy && setPending(null)}
+      >
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>{t('confirmTitle')}</AlertDialogTitle>
+            <AlertDialogDescription>
+              {pending &&
+                t('confirmDescription', {
+                  file: pending.fileName,
+                  customers: pending.attached.customers,
+                  vehicles: pending.attached.vehicles,
+                  services: pending.attached.serviceRecords,
+                })}
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel disabled={busy}>{t('cancel')}</AlertDialogCancel>
+            <AlertDialogAction onClick={undo} disabled={busy}>
+              {busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {t('confirm')}
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </AppCard>
+  )
+}

+ 1070 - 0
src/features/import/Components/ImportWizard.tsx

@@ -0,0 +1,1070 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import {
+  AlertTriangle,
+  ArrowLeft,
+  Car,
+  Check,
+  Download,
+  FileSpreadsheet,
+  FileUp,
+  Loader2,
+  Sparkles,
+  Undo2,
+  Upload,
+  Users,
+  Wrench,
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { DocsLink } from '@/components/docs-link'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectLabel,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table'
+import {
+  type CommitResult,
+  type DryRunResult,
+  type PlanPage,
+  type RowFilter,
+  type RowPlan,
+  commitImport,
+  dryRunImport,
+  getImportPlanPage,
+  getImportProgress,
+  getImportReportCsv,
+  suggestMappingWithAi,
+  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 { 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'
+
+type Step = 'upload' | 'mapping' | 'preview' | 'importing' | 'result'
+
+const IGNORE = '__ignore__'
+
+const ENTITY_ICON: Record<ImportEntity, typeof Users> = {
+  customers: Users,
+  vehicles: Car,
+  services: Wrench,
+}
+
+function downloadText(fileName: string, text: string, type = 'text/csv;charset=utf-8;') {
+  const blob = new Blob([`${text}`], { type })
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = fileName
+  a.click()
+  setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
+
+export function ImportWizard({
+  open,
+  onOpenChange,
+  entity: initialEntity = 'customers',
+  lockEntity = false,
+  onImported,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  entity?: ImportEntity
+  lockEntity?: boolean
+  onImported?: () => void
+}) {
+  const t = useTranslations('dataImport')
+  const router = useRouter()
+  const fileInputRef = useRef<HTMLInputElement>(null)
+
+  const [step, setStep] = useState<Step>('upload')
+  const [entity, setEntity] = useState<ImportEntity>(initialEntity)
+  const [dragOver, setDragOver] = useState(false)
+  const [busy, setBusy] = useState(false)
+
+  const [analysis, setAnalysis] = useState<AnalyzeResponse | null>(null)
+  const [mapping, setMapping] = useState<ColumnMapping>({})
+  const [dateFormat, setDateFormat] = useState<DateFormat>('auto')
+  const [decimalSeparator, setDecimalSeparator] = useState<DecimalSeparator>('auto')
+  const [countryCode, setCountryCode] = useState('')
+  const [duplicates, setDuplicates] = useState<DuplicateRule>('update')
+  const [overrides, setOverrides] = useState<Record<string, RowAction>>({})
+
+  const [dryRun, setDryRun] = useState<DryRunResult | null>(null)
+  const [page, setPage] = useState<PlanPage | null>(null)
+  const [filter, setFilter] = useState<RowFilter>('all')
+
+  const [progress, setProgress] = useState<{ done: number; total: number } | null>(null)
+  const [result, setResult] = useState<CommitResult | null>(null)
+  const [undone, setUndone] = useState(false)
+
+  const reset = useCallback(() => {
+    setStep('upload')
+    setEntity(initialEntity)
+    setAnalysis(null)
+    setMapping({})
+    setOverrides({})
+    setDryRun(null)
+    setPage(null)
+    setFilter('all')
+    setProgress(null)
+    setResult(null)
+    setUndone(false)
+    setBusy(false)
+    if (fileInputRef.current) fileInputRef.current.value = ''
+  }, [initialEntity])
+
+  const handleClose = useCallback(
+    (next: boolean) => {
+      if (!next) {
+        if (step === 'importing') return
+        reset()
+      }
+      onOpenChange(next)
+    },
+    [onOpenChange, reset, step]
+  )
+
+  useEffect(() => {
+    if (open) setEntity(initialEntity)
+  }, [open, initialEntity])
+
+  // ── Upload ────────────────────────────────────────────────────────────────
+
+  const handleFile = useCallback(
+    async (file: File) => {
+      setBusy(true)
+      try {
+        const form = new FormData()
+        form.append('file', file)
+        form.append('entity', entity)
+        const res = await fetch('/api/protected/import/analyze', { method: 'POST', body: form })
+        const body = (await res.json()) as AnalyzeResponse & { error?: string }
+        if (!res.ok) throw new Error(body.error || t('errors.uploadFailed'))
+        setAnalysis(body)
+        setMapping(body.suggestion.mapping)
+        setDateFormat(body.suggestion.dateFormat)
+        setDecimalSeparator(body.suggestion.decimalSeparator)
+        setCountryCode(body.defaults.countryCode ?? '')
+        setOverrides({})
+        setStep('mapping')
+      } catch (err) {
+        toast.error(err instanceof Error ? err.message : t('errors.uploadFailed'))
+      } finally {
+        setBusy(false)
+      }
+    },
+    [entity, t]
+  )
+
+  // ── Mapping ───────────────────────────────────────────────────────────────
+
+  const runInput = useMemo(
+    () =>
+      analysis
+        ? {
+            token: analysis.token,
+            mapping,
+            options: {
+              entity,
+              dateFormat,
+              decimalSeparator,
+              defaultCountryCode: countryCode.trim() || null,
+              duplicates,
+            },
+            overrides,
+          }
+        : null,
+    [analysis, mapping, entity, dateFormat, decimalSeparator, countryCode, duplicates, overrides]
+  )
+
+  const missing = useMemo(() => missingRequired(entity, mapping), [entity, mapping])
+
+  const setColumn = (col: number, key: string) => {
+    setMapping((prev) => {
+      const next: ColumnMapping = {}
+      for (const [c, k] of Object.entries(prev)) {
+        // A field can only come from one column.
+        if (k === key && c !== String(col)) continue
+        next[c] = k
+      }
+      if (key === IGNORE) delete next[String(col)]
+      else next[String(col)] = key
+      return next
+    })
+  }
+
+  const suggestWithAi = async () => {
+    if (!analysis) return
+    setBusy(true)
+    const res = await suggestMappingWithAi(analysis.token)
+    setBusy(false)
+    if (!res.success || !res.data) {
+      toast.error(res.error || t('errors.aiFailed'))
+      return
+    }
+    setMapping(res.data.mapping)
+    setDateFormat(res.data.dateFormat)
+    setDecimalSeparator(res.data.decimalSeparator)
+    toast.success(t('mapping.aiApplied', { count: Object.keys(res.data.mapping).length }))
+  }
+
+  const runDryRun = async (nextOverrides = overrides, nextFilter: RowFilter = 'all') => {
+    if (!runInput) return
+    setBusy(true)
+    const res = await dryRunImport({ ...runInput, overrides: nextOverrides }, 0, nextFilter)
+    setBusy(false)
+    if (!res.success || !res.data) {
+      toast.error(res.error || t('errors.dryRunFailed'))
+      return
+    }
+    setDryRun(res.data)
+    setPage(res.data.page)
+    setFilter(nextFilter)
+    setStep('preview')
+  }
+
+  // ── Preview ───────────────────────────────────────────────────────────────
+
+  const loadPage = async (pageIndex: number, nextFilter: RowFilter) => {
+    if (!runInput) return
+    setBusy(true)
+    const res = await getImportPlanPage(runInput, pageIndex, nextFilter)
+    setBusy(false)
+    if (res.success && res.data) {
+      setPage(res.data)
+      setFilter(nextFilter)
+    }
+  }
+
+  const setRowAction = async (index: number, action: RowAction) => {
+    const next = { ...overrides, [String(index)]: action }
+    setOverrides(next)
+    // Re-plan so the summary and the shared-customer bookkeeping stay honest.
+    if (!runInput) return
+    const res = await dryRunImport({ ...runInput, overrides: next }, page?.page ?? 0, filter)
+    if (res.success && res.data) {
+      setDryRun(res.data)
+      setPage(res.data.page)
+    }
+  }
+
+  const downloadReport = async () => {
+    if (!runInput) return
+    setBusy(true)
+    const res = await getImportReportCsv(runInput)
+    setBusy(false)
+    if (res.success && res.data) downloadText(res.data.fileName, res.data.csv)
+    else toast.error(res.error || t('errors.dryRunFailed'))
+  }
+
+  // ── Commit ────────────────────────────────────────────────────────────────
+
+  const runCommit = async () => {
+    if (!runInput) return
+    setStep('importing')
+    setProgress({ done: 0, total: dryRun ? dryRun.summary.create + dryRun.summary.update : 0 })
+    const token = runInput.token
+    const poll = setInterval(async () => {
+      const res = await getImportProgress(token)
+      if (res.success && res.data) setProgress({ done: res.data.done, total: res.data.total })
+    }, 800)
+    try {
+      const res = await commitImport(runInput)
+      if (!res.success || !res.data) throw new Error(res.error || t('errors.importFailed'))
+      setResult(res.data)
+      setStep('result')
+      router.refresh()
+      onImported?.()
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : t('errors.importFailed'))
+      setStep('preview')
+    } finally {
+      clearInterval(poll)
+    }
+  }
+
+  const undo = async () => {
+    if (!result) return
+    setBusy(true)
+    const res = await undoImportBatch(result.batchId)
+    setBusy(false)
+    if (res.success && res.data) {
+      setUndone(true)
+      toast.success(
+        t('result.undone', {
+          customers: res.data.customers,
+          vehicles: res.data.vehicles,
+          services: res.data.serviceRecords,
+        })
+      )
+      router.refresh()
+    } else {
+      toast.error(res.error || t('errors.undoFailed'))
+    }
+  }
+
+  // ── Rendering helpers ─────────────────────────────────────────────────────
+
+  const fieldLabel = (key: string) => t(`fields.${key}`)
+  const issueText = (issue: RowIssue) =>
+    t(`issues.${issue.code}`, {
+      value: issue.value ?? '',
+      field: issue.field ? fieldLabel(issue.field) : '',
+    })
+
+  const groupsInOrder = useMemo(() => {
+    if (!analysis) return []
+    const order: Record<string, number> = { customer: 0, vehicle: 1, service: 2 }
+    if (entity === 'vehicles') Object.assign(order, { vehicle: 0, customer: 1 })
+    if (entity === 'services') Object.assign(order, { service: 0, vehicle: 1, customer: 2 })
+    const groups = new Map<string, typeof analysis.fields>()
+    for (const f of analysis.fields) {
+      const list = groups.get(f.group) ?? []
+      list.push(f)
+      groups.set(f.group, list)
+    }
+    return [...groups.entries()].sort((a, b) => order[a[0]] - order[b[0]])
+  }, [analysis, entity])
+
+  const sampleFor = (col: number) =>
+    (analysis?.sampleRows ?? [])
+      .map((r) => r[col])
+      .filter((v) => v?.trim())
+      .slice(0, 3)
+
+  const EntityIcon = ENTITY_ICON[entity]
+
+  return (
+    <Dialog open={open} onOpenChange={handleClose}>
+      <DialogContent className="max-h-[90vh] !overflow-hidden sm:max-w-6xl flex flex-col">
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            <EntityIcon className="h-5 w-5" />
+            {t('title', { entity: t(`entities.${entity}`) })}
+          </DialogTitle>
+          <DialogDescription>{t(`steps.${step}`)}</DialogDescription>
+          <DocsLink
+            href="/docs/configuration/importing-data"
+            variant="hint"
+            className="self-start"
+          />
+        </DialogHeader>
+
+        {/* ── Upload ─────────────────────────────────────────────────────── */}
+        {step === 'upload' && (
+          <div className="space-y-4 overflow-y-auto">
+            {!lockEntity && (
+              <div className="grid gap-2 sm:grid-cols-3">
+                {IMPORT_ENTITIES.map((e) => {
+                  const Icon = ENTITY_ICON[e]
+                  const active = e === entity
+                  return (
+                    <button
+                      key={e}
+                      type="button"
+                      onClick={() => setEntity(e)}
+                      className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-colors ${
+                        active ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
+                      }`}
+                    >
+                      <Icon className="mt-0.5 h-4 w-4 shrink-0" />
+                      <div>
+                        <p className="text-sm font-medium">{t(`entities.${e}`)}</p>
+                        <p className="text-xs text-muted-foreground">{t(`entityHints.${e}`)}</p>
+                      </div>
+                    </button>
+                  )
+                })}
+              </div>
+            )}
+
+            <div
+              className={`flex flex-col items-center justify-center gap-4 rounded-lg border-2 border-dashed p-10 transition-colors ${
+                dragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
+              }`}
+              onDragOver={(e) => {
+                e.preventDefault()
+                setDragOver(true)
+              }}
+              onDragLeave={() => setDragOver(false)}
+              onDrop={(e) => {
+                e.preventDefault()
+                setDragOver(false)
+                const file = e.dataTransfer.files[0]
+                if (file) handleFile(file)
+              }}
+            >
+              {busy ? (
+                <>
+                  <Loader2 className="h-10 w-10 animate-spin text-muted-foreground" />
+                  <p className="text-sm text-muted-foreground">{t('upload.reading')}</p>
+                </>
+              ) : (
+                <>
+                  <FileUp className="h-10 w-10 text-muted-foreground" />
+                  <div className="text-center">
+                    <p className="text-sm font-medium">{t('upload.drop')}</p>
+                    <p className="mt-1 text-xs text-muted-foreground">{t('upload.formats')}</p>
+                  </div>
+                  <Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
+                    <Upload className="mr-1.5 h-3.5 w-3.5" />
+                    {t('upload.browse')}
+                  </Button>
+                </>
+              )}
+              <input
+                ref={fileInputRef}
+                type="file"
+                accept=".csv,.tsv,.txt,.xlsx,.xlsm,.vcf,.vcard"
+                className="hidden"
+                onChange={(e) => {
+                  const file = e.target.files?.[0]
+                  if (file) handleFile(file)
+                }}
+              />
+            </div>
+
+            <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-sm">
+              <div className="flex items-center gap-2 text-muted-foreground">
+                <FileSpreadsheet className="h-4 w-4" />
+                {t('upload.templateHint')}
+              </div>
+              <div className="flex gap-2">
+                <Button variant="ghost" size="sm" asChild>
+                  <a href={`/api/protected/import/template?entity=${entity}&format=xlsx`} download>
+                    <Download className="mr-1 h-3.5 w-3.5" />
+                    {t('upload.templateXlsx')}
+                  </a>
+                </Button>
+                <Button variant="ghost" size="sm" asChild>
+                  <a href={`/api/protected/import/template?entity=${entity}&format=csv`} download>
+                    <Download className="mr-1 h-3.5 w-3.5" />
+                    {t('upload.templateCsv')}
+                  </a>
+                </Button>
+              </div>
+            </div>
+            <p className="text-xs text-muted-foreground">{t('upload.sourcesHint')}</p>
+          </div>
+        )}
+
+        {/* ── Mapping ────────────────────────────────────────────────────── */}
+        {step === 'mapping' && analysis && (
+          <div className="flex min-h-0 flex-1 flex-col gap-3">
+            <div className="flex flex-wrap items-center justify-between gap-2 text-sm">
+              <div className="flex flex-wrap items-center gap-2 text-muted-foreground">
+                <span className="font-medium text-foreground">{analysis.fileName}</span>
+                <span>{t('mapping.rows', { count: analysis.totalRows })}</span>
+                {analysis.sheetName && <span>· {analysis.sheetName}</span>}
+                {analysis.encoding && <span>· {analysis.encoding}</span>}
+                {analysis.suggestion.presetId && (
+                  <Badge variant="outline" className="text-xs">
+                    {analysis.presets.find((p) => p.id === analysis.suggestion.presetId)?.name ??
+                      analysis.suggestion.presetId}
+                  </Badge>
+                )}
+              </div>
+              <div className="flex gap-2">
+                {analysis.aiAvailable && (
+                  <Button variant="outline" size="sm" onClick={suggestWithAi} disabled={busy}>
+                    {busy ? (
+                      <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                    ) : (
+                      <Sparkles className="mr-1 h-3.5 w-3.5" />
+                    )}
+                    {t('mapping.suggestWithAi')}
+                  </Button>
+                )}
+                <Button variant="ghost" size="sm" onClick={reset}>
+                  {t('mapping.chooseAnother')}
+                </Button>
+              </div>
+            </div>
+
+            <div className="min-h-0 flex-1 overflow-auto rounded-lg border">
+              <Table>
+                <TableHeader>
+                  <TableRow>
+                    <TableHead className="w-[28%]">{t('mapping.colColumn')}</TableHead>
+                    <TableHead>{t('mapping.colSample')}</TableHead>
+                    <TableHead className="w-[30%]">{t('mapping.colField')}</TableHead>
+                  </TableRow>
+                </TableHeader>
+                <TableBody>
+                  {analysis.columns.map((name, col) => {
+                    const key = mapping[String(col)]
+                    const source = analysis.suggestion.source[String(col)]
+                    return (
+                      <TableRow key={col}>
+                        <TableCell className="align-top">
+                          <div className="font-medium">{name}</div>
+                          {key && source && key === analysis.suggestion.mapping[String(col)] && (
+                            <div className="text-[11px] text-muted-foreground">
+                              {t(`mapping.source.${source}`)}
+                            </div>
+                          )}
+                        </TableCell>
+                        <TableCell className="align-top text-xs text-muted-foreground">
+                          {sampleFor(col).map((v, i) => (
+                            <div key={i} className="max-w-[320px] truncate">
+                              {v}
+                            </div>
+                          ))}
+                        </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>
+                        </TableCell>
+                      </TableRow>
+                    )
+                  })}
+                </TableBody>
+              </Table>
+            </div>
+
+            <div className="grid gap-3 rounded-lg border bg-muted/30 p-3 sm:grid-cols-4">
+              <div className="space-y-1">
+                <Label className="text-xs">{t('options.dateFormat')}</Label>
+                <Select value={dateFormat} onValueChange={(v) => setDateFormat(v as DateFormat)}>
+                  <SelectTrigger className="h-8">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="auto">{t('options.dateAuto')}</SelectItem>
+                    <SelectItem value="DMY">{t('options.dateDMY')}</SelectItem>
+                    <SelectItem value="MDY">{t('options.dateMDY')}</SelectItem>
+                    <SelectItem value="YMD">{t('options.dateYMD')}</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+              <div className="space-y-1">
+                <Label className="text-xs">{t('options.decimal')}</Label>
+                <Select
+                  value={decimalSeparator}
+                  onValueChange={(v) => setDecimalSeparator(v as DecimalSeparator)}
+                >
+                  <SelectTrigger className="h-8">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="auto">{t('options.decimalAuto')}</SelectItem>
+                    <SelectItem value=".">{t('options.decimalDot')}</SelectItem>
+                    <SelectItem value=",">{t('options.decimalComma')}</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+              <div className="space-y-1">
+                <Label className="text-xs">{t('options.countryCode')}</Label>
+                <Input
+                  className="h-8"
+                  placeholder="+47"
+                  value={countryCode}
+                  onChange={(e) => setCountryCode(e.target.value)}
+                />
+              </div>
+              <div className="space-y-1">
+                <Label className="text-xs">{t('options.duplicates')}</Label>
+                <Select value={duplicates} onValueChange={(v) => setDuplicates(v as DuplicateRule)}>
+                  <SelectTrigger className="h-8">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="update">{t('options.duplicatesUpdate')}</SelectItem>
+                    <SelectItem value="skip">{t('options.duplicatesSkip')}</SelectItem>
+                    <SelectItem value="create">{t('options.duplicatesCreate')}</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+            </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" />
+                <span>
+                  {t('mapping.missingRequired', {
+                    fields: missing.map(fieldLabel).join(', '),
+                  })}
+                </span>
+              </div>
+            )}
+
+            <div className="flex items-center justify-between gap-2 pt-1">
+              <p className="text-xs text-muted-foreground">{t('mapping.dryRunHint')}</p>
+              <div className="flex gap-2">
+                <Button variant="outline" onClick={() => handleClose(false)}>
+                  {t('cancel')}
+                </Button>
+                <Button onClick={() => runDryRun({}, 'all')} disabled={busy || missing.length > 0}>
+                  {busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                  {t('mapping.runDryRun')}
+                </Button>
+              </div>
+            </div>
+          </div>
+        )}
+
+        {/* ── Preview (dry run) ──────────────────────────────────────────── */}
+        {step === 'preview' && dryRun && page && (
+          <div className="flex min-h-0 flex-1 flex-col gap-3">
+            <div className="grid gap-2 sm:grid-cols-4">
+              {(['create', 'update', 'skip', 'error'] as const).map((k) => (
+                <button
+                  key={k}
+                  type="button"
+                  onClick={() => loadPage(0, filter === k ? 'all' : k)}
+                  className={`rounded-lg border px-3 py-2 text-left transition-colors ${
+                    filter === k ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
+                  }`}
+                >
+                  <div className="text-lg font-semibold tabular-nums">{dryRun.summary[k]}</div>
+                  <div className="text-xs text-muted-foreground">{t(`summary.${k}`)}</div>
+                </button>
+              ))}
+            </div>
+
+            <div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
+              <div className="flex flex-wrap gap-x-3 gap-y-1">
+                {dryRun.summary.customersToCreate > 0 && (
+                  <span>
+                    {t('summary.customersToCreate', { count: dryRun.summary.customersToCreate })}
+                  </span>
+                )}
+                {dryRun.summary.customersToUpdate > 0 && (
+                  <span>
+                    {t('summary.customersToUpdate', { count: dryRun.summary.customersToUpdate })}
+                  </span>
+                )}
+                {dryRun.summary.vehiclesToCreate > 0 && (
+                  <span>
+                    {t('summary.vehiclesToCreate', { count: dryRun.summary.vehiclesToCreate })}
+                  </span>
+                )}
+                {dryRun.summary.vehiclesToUpdate > 0 && (
+                  <span>
+                    {t('summary.vehiclesToUpdate', { count: dryRun.summary.vehiclesToUpdate })}
+                  </span>
+                )}
+                {dryRun.summary.servicesToCreate > 0 && (
+                  <span>
+                    {t('summary.servicesToCreate', { count: dryRun.summary.servicesToCreate })}
+                  </span>
+                )}
+              </div>
+              <div className="flex items-center gap-2">
+                <Button
+                  variant={filter === 'warning' ? 'default' : 'ghost'}
+                  size="sm"
+                  className="h-7 text-xs"
+                  onClick={() => loadPage(0, filter === 'warning' ? 'all' : 'warning')}
+                >
+                  {t('summary.warnings')}
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="h-7 text-xs"
+                  onClick={downloadReport}
+                  disabled={busy}
+                >
+                  <Download className="mr-1 h-3.5 w-3.5" />
+                  {t('preview.downloadReport')}
+                </Button>
+              </div>
+            </div>
+
+            {dryRun.limit?.exceeded && (
+              <div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm">
+                <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
+                <span>
+                  {t('preview.limitExceeded', {
+                    remaining: dryRun.limit.remaining,
+                    needed: dryRun.summary.customersToCreate,
+                  })}
+                </span>
+              </div>
+            )}
+
+            <div className="min-h-0 flex-1 overflow-auto rounded-lg border">
+              <Table>
+                <TableHeader>
+                  <TableRow>
+                    <TableHead className="w-12">#</TableHead>
+                    <TableHead className="w-24">{t('preview.colAction')}</TableHead>
+                    {entity === 'customers' && (
+                      <>
+                        <TableHead>{t('fields.customer.name')}</TableHead>
+                        <TableHead>{t('fields.customer.email')}</TableHead>
+                        <TableHead>{t('fields.customer.phone')}</TableHead>
+                      </>
+                    )}
+                    {entity === 'vehicles' && (
+                      <>
+                        <TableHead>{t('groups.vehicle')}</TableHead>
+                        <TableHead>{t('fields.vehicle.licensePlate')}</TableHead>
+                        <TableHead>{t('preview.colOwner')}</TableHead>
+                      </>
+                    )}
+                    {entity === 'services' && (
+                      <>
+                        <TableHead>{t('fields.service.date')}</TableHead>
+                        <TableHead>{t('fields.service.title')}</TableHead>
+                        <TableHead>{t('groups.vehicle')}</TableHead>
+                        <TableHead className="text-right">{t('fields.service.total')}</TableHead>
+                      </>
+                    )}
+                    <TableHead>{t('preview.colDetails')}</TableHead>
+                  </TableRow>
+                </TableHeader>
+                <TableBody>
+                  {page.rows.length === 0 && (
+                    <TableRow>
+                      <TableCell
+                        colSpan={7}
+                        className="py-8 text-center text-sm text-muted-foreground"
+                      >
+                        {t('preview.empty')}
+                      </TableCell>
+                    </TableRow>
+                  )}
+                  {page.rows.map((row) => (
+                    <PreviewRow
+                      key={row.index}
+                      row={row}
+                      entity={entity}
+                      issueText={issueText}
+                      onAction={(a) => setRowAction(row.index, a)}
+                    />
+                  ))}
+                </TableBody>
+              </Table>
+            </div>
+
+            <div className="flex flex-wrap items-center justify-between gap-2">
+              <div className="flex items-center gap-2 text-xs text-muted-foreground">
+                <span>
+                  {t('preview.pageInfo', {
+                    from: page.total === 0 ? 0 : page.page * page.pageSize + 1,
+                    to: Math.min(page.total, (page.page + 1) * page.pageSize),
+                    total: page.total,
+                  })}
+                </span>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  className="h-7"
+                  disabled={busy || page.page === 0}
+                  onClick={() => loadPage(page.page - 1, filter)}
+                >
+                  {t('preview.prev')}
+                </Button>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  className="h-7"
+                  disabled={busy || (page.page + 1) * page.pageSize >= page.total}
+                  onClick={() => loadPage(page.page + 1, filter)}
+                >
+                  {t('preview.next')}
+                </Button>
+              </div>
+              <div className="flex gap-2">
+                <Button variant="outline" onClick={() => setStep('mapping')}>
+                  <ArrowLeft className="mr-1 h-4 w-4" />
+                  {t('preview.backToMapping')}
+                </Button>
+                <Button
+                  onClick={runCommit}
+                  disabled={
+                    busy ||
+                    dryRun.limit?.exceeded === true ||
+                    dryRun.summary.create + dryRun.summary.update === 0
+                  }
+                >
+                  {t('preview.import', { count: dryRun.summary.create + dryRun.summary.update })}
+                </Button>
+              </div>
+            </div>
+            <p className="text-xs text-muted-foreground">{t('preview.nothingWrittenYet')}</p>
+          </div>
+        )}
+
+        {/* ── Importing ──────────────────────────────────────────────────── */}
+        {step === 'importing' && (
+          <div className="flex flex-col items-center gap-4 py-10">
+            <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+            <p className="text-sm font-medium">{t('importing.title')}</p>
+            <div className="w-full max-w-md">
+              <div className="h-2 overflow-hidden rounded-full bg-muted">
+                <div
+                  className="h-full bg-primary transition-all"
+                  style={{
+                    width: `${progress && progress.total ? Math.round((progress.done / progress.total) * 100) : 5}%`,
+                  }}
+                />
+              </div>
+              <p className="mt-2 text-center text-xs text-muted-foreground">
+                {progress
+                  ? t('importing.progress', { done: progress.done, total: progress.total })
+                  : t('importing.starting')}
+              </p>
+            </div>
+            <p className="text-xs text-muted-foreground">{t('importing.keepOpen')}</p>
+          </div>
+        )}
+
+        {/* ── Result ─────────────────────────────────────────────────────── */}
+        {step === 'result' && result && (
+          <div className="space-y-4">
+            <div className="flex items-center gap-3 rounded-lg border p-4">
+              <div
+                className={`flex h-10 w-10 items-center justify-center rounded-full ${
+                  undone ? 'bg-muted' : 'bg-emerald-500/10'
+                }`}
+              >
+                {undone ? (
+                  <Undo2 className="h-5 w-5 text-muted-foreground" />
+                ) : (
+                  <Check className="h-5 w-5 text-emerald-600" />
+                )}
+              </div>
+              <div>
+                <p className="font-medium">
+                  {undone ? t('result.undoneTitle') : t('result.title')}
+                </p>
+                <div className="space-y-0.5 text-sm text-muted-foreground">
+                  <p>{t('result.created', { count: result.created })}</p>
+                  {result.updated > 0 && <p>{t('result.updated', { count: result.updated })}</p>}
+                  {result.skipped > 0 && <p>{t('result.skipped', { count: result.skipped })}</p>}
+                  {result.failed > 0 && <p>{t('result.failed', { count: result.failed })}</p>}
+                </div>
+              </div>
+            </div>
+
+            {result.failures.length > 0 && (
+              <div className="space-y-2">
+                <div className="flex items-center gap-2 text-sm text-amber-600">
+                  <AlertTriangle className="h-4 w-4" />
+                  {t('result.writeFailures', { count: result.failures.length })}
+                </div>
+                <div className="max-h-32 overflow-auto rounded border p-2 text-xs text-muted-foreground">
+                  {result.failures.slice(0, 50).map((f) => (
+                    <p key={f.index}>
+                      {t('result.rowFailure', { row: f.index + 2, error: issueText(f.issue) })}
+                    </p>
+                  ))}
+                </div>
+              </div>
+            )}
+
+            <div className="flex items-center justify-between gap-2">
+              {!undone && result.created > 0 ? (
+                <Button variant="outline" onClick={undo} disabled={busy}>
+                  {busy ? (
+                    <Loader2 className="mr-1 h-4 w-4 animate-spin" />
+                  ) : (
+                    <Undo2 className="mr-1 h-4 w-4" />
+                  )}
+                  {t('result.undo')}
+                </Button>
+              ) : (
+                <span />
+              )}
+              <Button onClick={() => handleClose(false)}>{t('done')}</Button>
+            </div>
+            <p className="text-xs text-muted-foreground">{t('result.undoHint')}</p>
+          </div>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}
+
+function ActionBadge({ action, t }: { action: RowAction; t: ReturnType<typeof useTranslations> }) {
+  const styles: Record<RowAction, string> = {
+    create: 'text-emerald-600 border-emerald-500/30',
+    update: 'text-blue-600 border-blue-500/30',
+    skip: 'text-muted-foreground',
+    error: 'text-destructive border-destructive/30',
+  }
+  return (
+    <Badge variant="outline" className={`text-xs ${styles[action]}`}>
+      {t(`actions.${action}`)}
+    </Badge>
+  )
+}
+
+function PreviewRow({
+  row,
+  entity,
+  issueText,
+  onAction,
+}: {
+  row: RowPlan
+  entity: ImportEntity
+  issueText: (issue: RowIssue) => string
+  onAction: (action: RowAction) => void
+}) {
+  const t = useTranslations('dataImport')
+  const matched =
+    entity === 'customers'
+      ? row.customerMatch
+      : entity === 'vehicles'
+        ? row.vehicleMatch
+        : undefined
+  const canToggle = row.action !== 'error'
+  const vehicle = row.vehicle
+    ? [row.vehicle.year, row.vehicle.make, row.vehicle.model].filter(Boolean).join(' ')
+    : ''
+
+  const details: string[] = []
+  if (row.customerMatch && entity !== 'customers')
+    details.push(t('preview.linksCustomer', { name: row.customerMatch.name }))
+  if (row.customerMatch && entity === 'customers')
+    details.push(
+      t('preview.matchedOn', {
+        name: row.customerMatch.name,
+        on: t(`matchOn.${row.customerMatch.on}`),
+      })
+    )
+  if (row.vehicleMatch && entity !== 'vehicles')
+    details.push(t('preview.linksVehicle', { label: row.vehicleMatch.label }))
+  if (row.vehicleMatch && entity === 'vehicles')
+    details.push(
+      t('preview.matchedOn', {
+        name: row.vehicleMatch.label,
+        on: t(`matchOn.${row.vehicleMatch.on}`),
+      })
+    )
+  if (row.customerSameAs != null)
+    details.push(t('preview.sameCustomerAsRow', { row: row.customerSameAs + 2 }))
+  if (row.vehicleSameAs != null)
+    details.push(t('preview.sameVehicleAsRow', { row: row.vehicleSameAs + 2 }))
+  if (row.createsCustomer && entity !== 'customers') details.push(t('preview.createsCustomer'))
+  if (row.createsVehicle && entity !== 'vehicles') details.push(t('preview.createsVehicle'))
+
+  return (
+    <TableRow className={row.action === 'skip' ? 'opacity-60' : ''}>
+      <TableCell className="text-xs text-muted-foreground tabular-nums">{row.index + 2}</TableCell>
+      <TableCell>
+        {canToggle ? (
+          <Select value={row.action} onValueChange={(v) => onAction(v as RowAction)}>
+            <SelectTrigger className="h-7 w-24 text-xs">
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              {matched && <SelectItem value="update">{t('actions.update')}</SelectItem>}
+              <SelectItem value="skip">{t('actions.skip')}</SelectItem>
+              <SelectItem value="create">{t('actions.create')}</SelectItem>
+            </SelectContent>
+          </Select>
+        ) : (
+          <ActionBadge action={row.action} t={t} />
+        )}
+      </TableCell>
+      {entity === 'customers' && (
+        <>
+          <TableCell className="whitespace-nowrap font-medium">
+            {row.customer?.name ?? '-'}
+          </TableCell>
+          <TableCell className="whitespace-nowrap text-sm text-muted-foreground">
+            {row.customer?.email ?? '-'}
+          </TableCell>
+          <TableCell className="whitespace-nowrap text-sm text-muted-foreground">
+            {row.customer?.phone ?? '-'}
+          </TableCell>
+        </>
+      )}
+      {entity === 'vehicles' && (
+        <>
+          <TableCell className="whitespace-nowrap font-medium">{vehicle || '-'}</TableCell>
+          <TableCell className="whitespace-nowrap text-sm text-muted-foreground">
+            {row.vehicle?.licensePlate ?? row.vehicle?.vin ?? '-'}
+          </TableCell>
+          <TableCell className="whitespace-nowrap text-sm text-muted-foreground">
+            {row.customer?.name ?? '-'}
+          </TableCell>
+        </>
+      )}
+      {entity === 'services' && (
+        <>
+          <TableCell className="whitespace-nowrap text-sm">
+            {row.service?.date?.slice(0, 10) ?? '-'}
+          </TableCell>
+          <TableCell className="max-w-[240px] truncate font-medium">
+            {row.service?.title ?? '-'}
+          </TableCell>
+          <TableCell className="whitespace-nowrap text-sm text-muted-foreground">
+            {row.vehicleMatch?.label ??
+              [vehicle, row.vehicle?.licensePlate].filter(Boolean).join(' ') ??
+              '-'}
+          </TableCell>
+          <TableCell className="whitespace-nowrap text-right text-sm tabular-nums">
+            {row.service?.total ?? '-'}
+          </TableCell>
+        </>
+      )}
+      <TableCell className="text-xs">
+        {details.map((d, i) => (
+          <div key={i} className="text-muted-foreground">
+            {d}
+          </div>
+        ))}
+        {row.errors.map((e, i) => (
+          <div key={`e${i}`} className="text-destructive">
+            {issueText(e)}
+          </div>
+        ))}
+        {row.warnings.map((w, i) => (
+          <div key={`w${i}`} className="text-amber-600">
+            {issueText(w)}
+          </div>
+        ))}
+      </TableCell>
+    </TableRow>
+  )
+}

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

@@ -0,0 +1,1119 @@
+/**
+ * What a spreadsheet column can become.
+ *
+ * Every importable field is listed once here, with the header names it is
+ * known by. The synonyms are matched after `normalizeHeader` has lowercased
+ * the header, stripped accents and dropped everything that is not a letter or
+ * digit, so `"E-mail"`, `"E-Mail 1 - Value"` and `"email"` all land on the
+ * same key. The list is deliberately multilingual: a Polish shop's export says
+ * "Nr rejestracyjny", a German one "Kennzeichen", a Norwegian one "Reg.nr",
+ * and all of them are the licence plate.
+ *
+ * Keys are `<group>.<name>`. The group says which record the value belongs
+ * to; an import of vehicles carries an optional owner, an import of service
+ * history carries the vehicle and, optionally, the owner.
+ */
+
+export type ImportEntity = 'customers' | 'vehicles' | 'services'
+export type FieldGroup = 'customer' | 'vehicle' | 'service'
+export type FieldType =
+  | 'text'
+  | 'email'
+  | 'phone'
+  | 'date'
+  | 'number'
+  | 'integer'
+  | 'year'
+  | 'vin'
+  | 'plate'
+  | 'fuel'
+  | 'transmission'
+
+export interface ImportField {
+  key: string
+  group: FieldGroup
+  type: FieldType
+  /** Normalised header names this column is commonly called. */
+  synonyms: readonly string[]
+  /** Header used when we write a template file. */
+  templateHeader: string
+  /** Example value for the template file. */
+  example: string
+}
+
+export const IMPORT_FIELDS: readonly ImportField[] = [
+  // ── Customer ──────────────────────────────────────────────────────────────
+  {
+    key: 'customer.name',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'Customer name',
+    example: 'Example Customer',
+    synonyms: [
+      'name',
+      'fullname',
+      'customername',
+      'customer',
+      'contactname',
+      'displayname',
+      'clientname',
+      'client',
+      'owner',
+      'ownername',
+      'kunde',
+      'kundename',
+      'kundenname',
+      'kundnavn',
+      'kundenavn',
+      'navn',
+      'nombre',
+      'nombrecompleto',
+      'cliente',
+      'nom',
+      'nomcomplet',
+      'nome',
+      'nomecompleto',
+      'nazwa',
+      'klient',
+      'imieinazwisko',
+      'naam',
+      'klant',
+      'klantnaam',
+      'musteri',
+      'musteriadi',
+      'adsoyad',
+      'klientas',
+      'vardaspavarde',
+      'imya',
+      'klient',
+      'fio',
+    ],
+  },
+  {
+    key: 'customer.firstName',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'First name',
+    example: '',
+    synonyms: [
+      'firstname',
+      'givenname',
+      'first',
+      'forename',
+      'vorname',
+      'fornavn',
+      'fornamn',
+      'prenom',
+      'primernombre',
+      'imie',
+      'voornaam',
+      'ad',
+      'vardas',
+      'primeironome',
+    ],
+  },
+  {
+    key: 'customer.lastName',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'Last name',
+    example: '',
+    synonyms: [
+      'lastname',
+      'surname',
+      'familyname',
+      'last',
+      'nachname',
+      'familienname',
+      'etternavn',
+      'efternamn',
+      'nomdefamille',
+      'apellido',
+      'apellidos',
+      'cognome',
+      'nazwisko',
+      'achternaam',
+      'soyad',
+      'soyadi',
+      'pavarde',
+      'sobrenome',
+      'familiya',
+    ],
+  },
+  {
+    key: 'customer.customerNumber',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'Customer number',
+    example: '1001',
+    synonyms: [
+      'customernumber',
+      'customerno',
+      'customerid',
+      'clientnumber',
+      'clientid',
+      'accountnumber',
+      'number',
+      'kundennummer',
+      'kundennr',
+      'kundenr',
+      'kundnummer',
+      'numerocliente',
+      'numeroclient',
+      'nrklienta',
+      'klantnummer',
+      'musterino',
+      'klientonumeris',
+    ],
+  },
+  {
+    key: 'customer.email',
+    group: 'customer',
+    type: 'email',
+    templateHeader: 'Email',
+    example: 'customer@example.com',
+    synonyms: [
+      'email',
+      'emailaddress',
+      'mail',
+      'primaryemail',
+      'emailaddress1',
+      'email1',
+      'email1value',
+      'epost',
+      'epostadresse',
+      'correo',
+      'correoelectronico',
+      'courriel',
+      'adresseemail',
+      'emailadres',
+      'eposta',
+      'elpastas',
+      'elektroninispastas',
+      'pochta',
+    ],
+  },
+  {
+    key: 'customer.phone',
+    group: 'customer',
+    type: 'phone',
+    templateHeader: 'Phone',
+    example: '+1 202 555 0100',
+    synonyms: [
+      'phone',
+      'phonenumber',
+      'telephone',
+      'tel',
+      'mobile',
+      'mobilenumber',
+      'mobilephone',
+      'cell',
+      'cellphone',
+      'primaryphone',
+      'phone1',
+      'phone1value',
+      'mobilephone1',
+      'telefon',
+      'telefonnummer',
+      'mobil',
+      'mobilnummer',
+      'handy',
+      'telefono',
+      'movil',
+      'celular',
+      'telephone',
+      'portable',
+      'cellulare',
+      'telefoon',
+      'mobiel',
+      'telefonas',
+      'mobilusis',
+      'mobilnyj',
+    ],
+  },
+  {
+    key: 'customer.company',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'Company',
+    example: 'Example Company Ltd',
+    synonyms: [
+      'company',
+      'companyname',
+      'organization',
+      'organisation',
+      'organization1name',
+      'businessname',
+      'business',
+      'org',
+      'firma',
+      'firmenname',
+      'unternehmen',
+      'selskap',
+      'bedrift',
+      'foretag',
+      'empresa',
+      'entreprise',
+      'societe',
+      'azienda',
+      'societa',
+      'bedrijf',
+      'bedrijfsnaam',
+      'sirket',
+      'imone',
+      'kompaniya',
+    ],
+  },
+  {
+    key: 'customer.address',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'Address',
+    example: '1 Example Street, Example City',
+    synonyms: [
+      'address',
+      'fulladdress',
+      'streetaddress',
+      'mailingaddress',
+      'homeaddress',
+      'businessaddress',
+      'address1formatted',
+      'adresse',
+      'anschrift',
+      'direccion',
+      'domicilio',
+      'indirizzo',
+      'adres',
+      'endereco',
+      'adresas',
+      'ulica',
+    ],
+  },
+  {
+    key: 'customer.street',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'Street',
+    example: '',
+    synonyms: [
+      'street',
+      'street1',
+      'addressline1',
+      'address1',
+      'line1',
+      'homestreet',
+      'businessstreet',
+      'strasse',
+      'gate',
+      'gateadresse',
+      'calle',
+      'rue',
+      'via',
+      'straat',
+      'sokak',
+      'gatve',
+      'rua',
+    ],
+  },
+  {
+    key: 'customer.postalCode',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'Postal code',
+    example: '',
+    synonyms: [
+      'postalcode',
+      'postcode',
+      'zip',
+      'zipcode',
+      'homepostalcode',
+      'businesspostalcode',
+      'plz',
+      'postleitzahl',
+      'postnummer',
+      'postnr',
+      'codigopostal',
+      'cp',
+      'cap',
+      'kodpocztowy',
+      'postalcode',
+      'postakodu',
+      'pastokodas',
+      'cep',
+    ],
+  },
+  {
+    key: 'customer.city',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'City',
+    example: '',
+    synonyms: [
+      'city',
+      'town',
+      'homecity',
+      'businesscity',
+      'stadt',
+      'ort',
+      'poststed',
+      'sted',
+      'by',
+      'ciudad',
+      'ville',
+      'citta',
+      'miasto',
+      'plaats',
+      'woonplaats',
+      'sehir',
+      'il',
+      'miestas',
+      'cidade',
+      'gorod',
+    ],
+  },
+  {
+    key: 'customer.state',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'State',
+    example: '',
+    synonyms: [
+      'state',
+      'province',
+      'region',
+      'county',
+      'bundesland',
+      'fylke',
+      'provincia',
+      'estado',
+    ],
+  },
+  {
+    key: 'customer.country',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'Country',
+    example: '',
+    synonyms: ['country', 'land', 'pais', 'pays', 'paese', 'kraj', 'ulke', 'salis', 'strana'],
+  },
+  {
+    key: 'customer.taxId',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'Tax ID',
+    example: '',
+    synonyms: [
+      'taxid',
+      'taxnumber',
+      'vat',
+      'vatnumber',
+      'vatid',
+      'orgnr',
+      'orgnumber',
+      'organisationsnummer',
+      'organisasjonsnummer',
+      'ustid',
+      'ustidnr',
+      'steuernummer',
+      'nif',
+      'cif',
+      'rfc',
+      'siret',
+      'siren',
+      'tva',
+      'partitaiva',
+      'piva',
+      'nip',
+      'btwnummer',
+      'kvk',
+      'vergino',
+      'pvmkodas',
+      'cnpj',
+      'cpf',
+      'inn',
+      'ein',
+      'abn',
+    ],
+  },
+  {
+    key: 'customer.notes',
+    group: 'customer',
+    type: 'text',
+    templateHeader: 'Customer notes',
+    example: '',
+    synonyms: [
+      'notes',
+      'note',
+      'customernotes',
+      'privatenotes',
+      'comments',
+      'comment',
+      'remarks',
+      'notizen',
+      'bemerkung',
+      'notater',
+      'merknad',
+      'notas',
+      'observaciones',
+      'commentaires',
+      'uwagi',
+      'notities',
+      'opmerkingen',
+      'notlar',
+      'pastabos',
+      'primechaniya',
+    ],
+  },
+
+  // ── Vehicle ───────────────────────────────────────────────────────────────
+  {
+    key: 'vehicle.description',
+    group: 'vehicle',
+    type: 'text',
+    templateHeader: 'Vehicle',
+    example: '',
+    synonyms: [
+      'vehicle',
+      'vehicledescription',
+      'vehiclename',
+      'car',
+      'makemodel',
+      'makeandmodel',
+      'yearmakemodel',
+      'fahrzeug',
+      'kjoretoy',
+      'bil',
+      'vehiculo',
+      'coche',
+      'vehicule',
+      'voiture',
+      'veicolo',
+      'pojazd',
+      'voertuig',
+      'auto',
+      'arac',
+      'transportopriemone',
+      'automobilis',
+      'veiculo',
+      'avtomobil',
+    ],
+  },
+  {
+    key: 'vehicle.make',
+    group: 'vehicle',
+    type: 'text',
+    templateHeader: 'Make',
+    example: 'Toyota',
+    synonyms: [
+      'make',
+      'manufacturer',
+      'brand',
+      'vehiclemake',
+      'marke',
+      'hersteller',
+      'merke',
+      'fabrikat',
+      'marca',
+      'marque',
+      'marka',
+      'merk',
+      'marke',
+      'gamintojas',
+    ],
+  },
+  {
+    key: 'vehicle.model',
+    group: 'vehicle',
+    type: 'text',
+    templateHeader: 'Model',
+    example: 'Corolla',
+    synonyms: ['model', 'vehiclemodel', 'modell', 'modelo', 'modele', 'modello', 'modelis'],
+  },
+  {
+    key: 'vehicle.year',
+    group: 'vehicle',
+    type: 'year',
+    templateHeader: 'Year',
+    example: '2018',
+    synonyms: [
+      'year',
+      'modelyear',
+      'vehicleyear',
+      'yearofmanufacture',
+      'baujahr',
+      'modelljahr',
+      'arsmodell',
+      'argang',
+      'ano',
+      'anno',
+      'annee',
+      'rok',
+      'rocznik',
+      'bouwjaar',
+      'jaar',
+      'yil',
+      'modelyili',
+      'metai',
+      'god',
+      'godvypuska',
+    ],
+  },
+  {
+    key: 'vehicle.vin',
+    group: 'vehicle',
+    type: 'vin',
+    templateHeader: 'VIN',
+    example: '1HGBH41JXMN109186',
+    synonyms: [
+      'vin',
+      'vinnumber',
+      'chassis',
+      'chassisnumber',
+      'chassisno',
+      'serial',
+      'serialnumber',
+      'fahrgestellnummer',
+      'fin',
+      'understellsnummer',
+      'chassisnr',
+      'bastidor',
+      'numerodebastidor',
+      'numerodechassis',
+      'telaio',
+      'numerotelaio',
+      'nrvin',
+      'numervin',
+      'chassisnummer',
+      'sasino',
+      'sasinumarasi',
+      'kebulonumeris',
+      'nomerkuzova',
+    ],
+  },
+  {
+    key: 'vehicle.licensePlate',
+    group: 'vehicle',
+    type: 'plate',
+    templateHeader: 'License plate',
+    example: 'ABC 123',
+    synonyms: [
+      'licenseplate',
+      'licenceplate',
+      'plate',
+      'platenumber',
+      'registration',
+      'registrationnumber',
+      'regnumber',
+      'regno',
+      'reg',
+      'tag',
+      'numberplate',
+      'kennzeichen',
+      'kfzkennzeichen',
+      'nummernschild',
+      'regnr',
+      'registreringsnummer',
+      'skilt',
+      'kjennemerke',
+      'matricula',
+      'placa',
+      'patente',
+      'immatriculation',
+      'plaque',
+      'targa',
+      'nrrejestracyjny',
+      'numerrejestracyjny',
+      'rejestracja',
+      'kenteken',
+      'plaka',
+      'valstybinisnumeris',
+      'gosnomer',
+      'nomernoyznak',
+    ],
+  },
+  {
+    key: 'vehicle.color',
+    group: 'vehicle',
+    type: 'text',
+    templateHeader: 'Color',
+    example: 'Silver',
+    synonyms: [
+      'color',
+      'colour',
+      'farbe',
+      'farge',
+      'couleur',
+      'colore',
+      'kolor',
+      'kleur',
+      'renk',
+      'spalva',
+      'cor',
+      'cvet',
+    ],
+  },
+  {
+    key: 'vehicle.mileage',
+    group: 'vehicle',
+    type: 'integer',
+    templateHeader: 'Mileage',
+    example: '84500',
+    synonyms: [
+      'mileage',
+      'odometer',
+      'odo',
+      'km',
+      'kilometers',
+      'kilometres',
+      'miles',
+      'currentmileage',
+      'kilometerstand',
+      'kmstand',
+      'laufleistung',
+      'kilometerstand',
+      'kmstand',
+      'kjorelengde',
+      'kilometraje',
+      'kilometrage',
+      'chilometraggio',
+      'przebieg',
+      'kilometrazas',
+      'rida',
+      'probeg',
+      'quilometragem',
+    ],
+  },
+  {
+    key: 'vehicle.fuelType',
+    group: 'vehicle',
+    type: 'fuel',
+    templateHeader: 'Fuel',
+    example: 'gasoline',
+    synonyms: [
+      'fuel',
+      'fueltype',
+      'kraftstoff',
+      'kraftstoffart',
+      'antrieb',
+      'drivstoff',
+      'combustible',
+      'carburant',
+      'carburante',
+      'alimentazione',
+      'paliwo',
+      'rodzajpaliwa',
+      'brandstof',
+      'yakit',
+      'kuras',
+      'toplivo',
+      'combustivel',
+    ],
+  },
+  {
+    key: 'vehicle.transmission',
+    group: 'vehicle',
+    type: 'transmission',
+    templateHeader: 'Transmission',
+    example: 'automatic',
+    synonyms: [
+      'transmission',
+      'gearbox',
+      'getriebe',
+      'girkasse',
+      'transmision',
+      'cambio',
+      'boitedevitesses',
+      'skrzyniabiegow',
+      'versnellingsbak',
+      'vites',
+      'sanziman',
+      'pavaru deze',
+      'korobka',
+    ],
+  },
+  {
+    key: 'vehicle.engineSize',
+    group: 'vehicle',
+    type: 'text',
+    templateHeader: 'Engine',
+    example: '1.8',
+    synonyms: [
+      'engine',
+      'enginesize',
+      'displacement',
+      'motor',
+      'hubraum',
+      'motorstorrelse',
+      'cilindrada',
+      'cylindree',
+      'cilindrata',
+      'pojemnosc',
+      'motorinhoud',
+      'motorhacmi',
+      'variklis',
+      'dvigatel',
+    ],
+  },
+  {
+    key: 'vehicle.engineCode',
+    group: 'vehicle',
+    type: 'text',
+    templateHeader: 'Engine code',
+    example: '',
+    synonyms: [
+      'enginecode',
+      'motorcode',
+      'motorkode',
+      'codigomotor',
+      'codemoteur',
+      'codicemotore',
+      'kodsilnika',
+    ],
+  },
+  {
+    key: 'vehicle.purchaseDate',
+    group: 'vehicle',
+    type: 'date',
+    templateHeader: 'Purchase date',
+    example: '',
+    synonyms: [
+      'purchasedate',
+      'datepurchased',
+      'boughton',
+      'firstregistration',
+      'registrationdate',
+      'kaufdatum',
+      'erstzulassung',
+      'kjopsdato',
+      'forstegangsregistrert',
+      'fechacompra',
+      'dateachat',
+      'dataacquisto',
+      'datazakupu',
+      'aankoopdatum',
+      'satinalmatarihi',
+      'pirkimodata',
+    ],
+  },
+  {
+    key: 'vehicle.purchasePrice',
+    group: 'vehicle',
+    type: 'number',
+    templateHeader: 'Purchase price',
+    example: '',
+    synonyms: [
+      'purchaseprice',
+      'kaufpreis',
+      'kjopspris',
+      'preciocompra',
+      'prixachat',
+      'prezzoacquisto',
+      'cenazakupu',
+      'aankoopprijs',
+    ],
+  },
+
+  // ── Service history ───────────────────────────────────────────────────────
+  {
+    key: 'service.date',
+    group: 'service',
+    type: 'date',
+    templateHeader: 'Service date',
+    example: '2024-03-15',
+    synonyms: [
+      'date',
+      'servicedate',
+      'dateofservice',
+      'invoicedate',
+      'workorderdate',
+      'jobdate',
+      'completed',
+      'completeddate',
+      'datum',
+      'servicedatum',
+      'rechnungsdatum',
+      'dato',
+      'servicedato',
+      'fakturadato',
+      'fecha',
+      'fechaservicio',
+      'fechafactura',
+      'datefacture',
+      'data',
+      'dataservizio',
+      'datafattura',
+      'datauslugi',
+      'datafaktury',
+      'tarih',
+      'servistarihi',
+    ],
+  },
+  {
+    key: 'service.title',
+    group: 'service',
+    type: 'text',
+    templateHeader: 'Service',
+    example: 'Oil change and inspection',
+    synonyms: [
+      'service',
+      'title',
+      'servicetitle',
+      'job',
+      'jobname',
+      'jobtitle',
+      'workorder',
+      'worktype',
+      'subject',
+      'summary',
+      'arbeit',
+      'leistung',
+      'titel',
+      'arbeid',
+      'tittel',
+      'trabajo',
+      'titulo',
+      'travail',
+      'titre',
+      'lavoro',
+      'titolo',
+      'usluga',
+      'tytul',
+      'werk',
+      'hizmet',
+      'baslik',
+      'darbas',
+      'paslauga',
+    ],
+  },
+  {
+    key: 'service.description',
+    group: 'service',
+    type: 'text',
+    templateHeader: 'Description',
+    example: 'Replaced oil and filter, checked brakes',
+    synonyms: [
+      'description',
+      'details',
+      'workdone',
+      'workperformed',
+      'workdescription',
+      'linedescription',
+      'beschreibung',
+      'beskrivelse',
+      'descripcion',
+      'descriptif',
+      'descrizione',
+      'opis',
+      'omschrijving',
+      'beschrijving',
+      'aciklama',
+      'aprasymas',
+      'opisanie',
+      'descricao',
+    ],
+  },
+  {
+    key: 'service.total',
+    group: 'service',
+    type: 'number',
+    templateHeader: 'Total',
+    example: '189.50',
+    synonyms: [
+      'total',
+      'amount',
+      'cost',
+      'price',
+      'totalamount',
+      'grandtotal',
+      'invoicetotal',
+      'totalcost',
+      'sum',
+      'betrag',
+      'gesamt',
+      'gesamtbetrag',
+      'summe',
+      'kosten',
+      'preis',
+      'belop',
+      'totalt',
+      'totalbelop',
+      'pris',
+      'importe',
+      'montant',
+      'totale',
+      'importo',
+      'kwota',
+      'suma',
+      'razem',
+      'bedrag',
+      'totaal',
+      'tutar',
+      'toplam',
+      'suma',
+      'itogo',
+      'valor',
+    ],
+  },
+  {
+    key: 'service.mileage',
+    group: 'service',
+    type: 'integer',
+    templateHeader: 'Mileage at service',
+    example: '84500',
+    synonyms: [
+      'servicemileage',
+      'mileageatservice',
+      'odometeratservice',
+      'kmstandservice',
+      'kmveditjeneste',
+    ],
+  },
+  {
+    key: 'service.invoiceNumber',
+    group: 'service',
+    type: 'text',
+    templateHeader: 'Invoice number',
+    example: 'INV-1042',
+    synonyms: [
+      'invoicenumber',
+      'invoiceno',
+      'invoice',
+      'invoiceid',
+      'workordernumber',
+      'ronumber',
+      'ro',
+      'rechnungsnummer',
+      'rechnungsnr',
+      'fakturanummer',
+      'fakturanr',
+      'numerofactura',
+      'factura',
+      'numerofacture',
+      'facture',
+      'numerofattura',
+      'fattura',
+      'nrfaktury',
+      'numerfaktury',
+      'factuurnummer',
+      'faturano',
+      'saskaitosnumeris',
+      'nomerscheta',
+    ],
+  },
+  {
+    key: 'service.notes',
+    group: 'service',
+    type: 'text',
+    templateHeader: 'Service notes',
+    example: '',
+    synonyms: [
+      'servicenotes',
+      'jobnotes',
+      'internalnotes',
+      'technotes',
+      'technicalnotes',
+      'diagnosticnotes',
+      'workordernotes',
+    ],
+  },
+  {
+    key: 'service.technician',
+    group: 'service',
+    type: 'text',
+    templateHeader: 'Technician',
+    example: '',
+    synonyms: [
+      'technician',
+      'tech',
+      'mechanic',
+      'techniker',
+      'mechaniker',
+      'tekniker',
+      'mekaniker',
+      'tecnico',
+      'technicien',
+      'mecanicien',
+      'meccanico',
+      'mechanik',
+      'monteur',
+      'teknisyen',
+      'mechanikas',
+      'mekhanik',
+    ],
+  },
+]
+
+const BY_KEY = new Map(IMPORT_FIELDS.map((f) => [f.key, f]))
+
+export function fieldByKey(key: string): ImportField | undefined {
+  return BY_KEY.get(key)
+}
+
+/** Which groups an entity's mapping can carry. */
+export function groupsFor(entity: ImportEntity): FieldGroup[] {
+  switch (entity) {
+    case 'customers':
+      return ['customer']
+    case 'vehicles':
+      return ['vehicle', 'customer']
+    case 'services':
+      return ['service', 'vehicle', 'customer']
+  }
+}
+
+export function fieldsFor(entity: ImportEntity): ImportField[] {
+  const groups = new Set(groupsFor(entity))
+  return IMPORT_FIELDS.filter((f) => groups.has(f.group))
+}
+
+/**
+ * Fields the template file carries per entity. The service template also
+ * names the vehicle and its owner, since that is how a history export from a
+ * shop system looks: one row per job, with the car and the customer alongside.
+ */
+export function templateFieldsFor(entity: ImportEntity): ImportField[] {
+  const wanted: Record<ImportEntity, string[]> = {
+    customers: [
+      'customer.name',
+      'customer.email',
+      'customer.phone',
+      'customer.company',
+      'customer.address',
+      'customer.customerNumber',
+      'customer.taxId',
+      'customer.notes',
+    ],
+    vehicles: [
+      'vehicle.make',
+      'vehicle.model',
+      'vehicle.year',
+      'vehicle.licensePlate',
+      'vehicle.vin',
+      'vehicle.mileage',
+      'vehicle.color',
+      'vehicle.fuelType',
+      'vehicle.transmission',
+      'vehicle.engineSize',
+      'customer.name',
+      'customer.email',
+      'customer.phone',
+    ],
+    services: [
+      'service.date',
+      'service.title',
+      'service.description',
+      'service.total',
+      'service.mileage',
+      'service.invoiceNumber',
+      'vehicle.licensePlate',
+      'vehicle.vin',
+      'vehicle.make',
+      'vehicle.model',
+      'vehicle.year',
+      'customer.name',
+      'customer.email',
+      'customer.phone',
+    ],
+  }
+  return wanted[entity].map((k) => BY_KEY.get(k)).filter((f): f is ImportField => Boolean(f))
+}

+ 710 - 0
src/features/import/Lib/normalize.ts

@@ -0,0 +1,710 @@
+/**
+ * Turning what people type in spreadsheets into what the database stores.
+ *
+ * Imports come from every country the app is sold in, so nothing here assumes
+ * a locale: dates can be day-first, month-first or year-first, decimals can
+ * be a comma, phone numbers can lack a country code, and a fuel type can be
+ * "Benzin", "Bensin" or "Petrol". Each function is pure so it can be tested
+ * on its own.
+ */
+
+import { normalizePortalPhone } from '@/lib/portal-phone'
+
+// ── Headers ───────────────────────────────────────────────────────────────────
+
+/** "E-Mail 1 - Value" → "email1value"; "Reg.nr" → "regnr"; "Straße" → "strasse". */
+export function normalizeHeader(header: string): string {
+  return header
+    .normalize('NFD')
+    .replace(/[̀-ͯ]/g, '')
+    .replace(/ß/g, 'ss')
+    .replace(/ø/gi, 'o')
+    .replace(/æ/gi, 'ae')
+    .replace(/å/gi, 'a')
+    .replace(/ł/gi, 'l')
+    .replace(/ı/g, 'i')
+    .toLowerCase()
+    .replace(/[^a-z0-9]/g, '')
+}
+
+// ── Text ──────────────────────────────────────────────────────────────────────
+
+export function cleanText(value: string | null | undefined): string | null {
+  if (value == null) return null
+  const trimmed = String(value).replace(/\s+/g, ' ').trim()
+  return trimmed.length ? trimmed : null
+}
+
+export function normalizeEmail(value: string | null | undefined): {
+  value: string | null
+  valid: boolean
+} {
+  const cleaned = cleanText(value)?.toLowerCase() ?? null
+  if (!cleaned) return { value: null, valid: true }
+  // Same shape zod's email check accepts, kept simple on purpose.
+  const valid = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(cleaned)
+  return { value: cleaned, valid }
+}
+
+/**
+ * Best-effort E.164. With a workshop country code, a local number becomes
+ * international; without one, the number is kept as typed so nothing is
+ * lost. Letters (an "ext. 12" suffix) are left alone rather than mangled.
+ */
+export function normalizePhone(
+  value: string | null | undefined,
+  defaultCountryCode: string | null
+): string | null {
+  const cleaned = cleanText(value)
+  if (!cleaned) return null
+  if (/[a-z]/i.test(cleaned)) return cleaned
+  const e164 = normalizePortalPhone(cleaned, defaultCountryCode)
+  if (e164 && /^\+\d{7,15}$/.test(e164)) return e164
+  return cleaned
+}
+
+/** Digits only, for comparing two phone numbers that may be formatted apart. */
+export function phoneKey(value: string | null | undefined): string | null {
+  if (!value) return null
+  const digits = value.replace(/\D/g, '')
+  if (digits.length < 6) return null
+  // The last eight digits identify a subscriber in every numbering plan the
+  // app has met; comparing on them lets "+47 912 34 567" match "91234567".
+  return digits.slice(-8)
+}
+
+// ── Numbers ───────────────────────────────────────────────────────────────────
+
+export type DecimalSeparator = 'auto' | '.' | ','
+
+/**
+ * "1 234,56", "1,234.56", "$1,234.56", "kr 1.234", "(12.50)" and "12,5" all
+ * come back as numbers. When only one kind of separator appears once and is
+ * followed by exactly three digits, it is read as a thousands separator, so
+ * "125.000" km is 125000 and not 125. A leading "0." or "0," is always a
+ * decimal.
+ */
+export function parseNumber(
+  value: string | number | null | undefined,
+  separator: DecimalSeparator = 'auto'
+): number | null {
+  if (value == null) return null
+  if (typeof value === 'number') return Number.isFinite(value) ? value : null
+  let s = String(value).trim()
+  if (!s) return null
+  const negative = /^\(.*\)$/.test(s) || /^-/.test(s.replace(/[^\d\-(]/g, ''))
+  s = s.replace(/[^\d.,]/g, '')
+  if (!s) return null
+
+  const commas = (s.match(/,/g) ?? []).length
+  const dots = (s.match(/\./g) ?? []).length
+
+  let normalized: string
+  if (separator === ',') {
+    normalized = s.replace(/\./g, '').replace(',', '.')
+  } else if (separator === '.') {
+    normalized = s.replace(/,/g, '')
+  } else if (commas && dots) {
+    // Whichever comes last is the decimal mark.
+    const lastComma = s.lastIndexOf(',')
+    const lastDot = s.lastIndexOf('.')
+    normalized = lastComma > lastDot ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '')
+  } else if (commas === 1 || dots === 1) {
+    const mark = commas === 1 ? ',' : '.'
+    const [intPart, fracPart] = s.split(mark)
+    const thousands = fracPart.length === 3 && intPart.length > 0 && intPart !== '0'
+    normalized = thousands ? intPart + fracPart : `${intPart}.${fracPart}`
+  } else if (commas > 1 || dots > 1) {
+    // Repeated separators can only be grouping.
+    normalized = s.replace(/[.,]/g, '')
+  } else {
+    normalized = s
+  }
+
+  const n = Number(normalized)
+  if (!Number.isFinite(n)) return null
+  return negative ? -Math.abs(n) : n
+}
+
+export function parseInteger(
+  value: string | number | null | undefined,
+  separator: DecimalSeparator = 'auto'
+): number | null {
+  const n = parseNumber(value, separator)
+  if (n == null) return null
+  return Math.round(n)
+}
+
+export function parseYear(value: string | number | null | undefined): number | null {
+  if (value == null) return null
+  const s = String(value).trim()
+  const m = s.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/)
+  if (m) return Number(m[1])
+  // Two-digit model years, "18" → 2018, "98" → 1998.
+  if (/^\d{2}$/.test(s)) {
+    const n = Number(s)
+    return n <= (new Date().getFullYear() % 100) + 1 ? 2000 + n : 1900 + n
+  }
+  return null
+}
+
+// ── Dates ─────────────────────────────────────────────────────────────────────
+
+export type DateFormat = 'auto' | 'DMY' | 'MDY' | 'YMD'
+
+const MONTHS: Record<string, number> = {
+  jan: 1,
+  january: 1,
+  januar: 1,
+  janvier: 1,
+  enero: 1,
+  ene: 1,
+  gennaio: 1,
+  gen: 1,
+  sty: 1,
+  styczen: 1,
+  ocak: 1,
+  oca: 1,
+  sausis: 1,
+  janeiro: 1,
+  yanvar: 1,
+  feb: 2,
+  february: 2,
+  februar: 2,
+  fevrier: 2,
+  fev: 2,
+  febrero: 2,
+  febbraio: 2,
+  lut: 2,
+  luty: 2,
+  subat: 2,
+  sub: 2,
+  vasaris: 2,
+  fevereiro: 2,
+  fevral: 2,
+  mar: 3,
+  march: 3,
+  marz: 3,
+  maerz: 3,
+  mars: 3,
+  marzo: 3,
+  marca: 3,
+  marzec: 3,
+  mart: 3,
+  kovas: 3,
+  marco: 3,
+  apr: 4,
+  april: 4,
+  avril: 4,
+  avr: 4,
+  abril: 4,
+  abr: 4,
+  aprile: 4,
+  kwi: 4,
+  kwiecien: 4,
+  nisan: 4,
+  nis: 4,
+  balandis: 4,
+  aprel: 4,
+  may: 5,
+  mai: 5,
+  mayo: 5,
+  maggio: 5,
+  mag: 5,
+  maj: 5,
+  mayis: 5,
+  geguze: 5,
+  maio: 5,
+  jun: 6,
+  june: 6,
+  juni: 6,
+  juin: 6,
+  junio: 6,
+  giugno: 6,
+  giu: 6,
+  cze: 6,
+  czerwiec: 6,
+  haziran: 6,
+  haz: 6,
+  birzelis: 6,
+  junho: 6,
+  iyun: 6,
+  jul: 7,
+  july: 7,
+  juli: 7,
+  juillet: 7,
+  juil: 7,
+  julio: 7,
+  luglio: 7,
+  lug: 7,
+  lip: 7,
+  lipiec: 7,
+  temmuz: 7,
+  tem: 7,
+  liepa: 7,
+  julho: 7,
+  iyul: 7,
+  aug: 8,
+  august: 8,
+  aout: 8,
+  agosto: 8,
+  ago: 8,
+  sie: 8,
+  sierpien: 8,
+  agustos: 8,
+  agu: 8,
+  rugpjutis: 8,
+  avgust: 8,
+  sep: 9,
+  sept: 9,
+  september: 9,
+  septembre: 9,
+  septiembre: 9,
+  settembre: 9,
+  set: 9,
+  wrz: 9,
+  wrzesien: 9,
+  eylul: 9,
+  eyl: 9,
+  rugsejis: 9,
+  setembro: 9,
+  sentyabr: 9,
+  oct: 10,
+  october: 10,
+  oktober: 10,
+  okt: 10,
+  octobre: 10,
+  octubre: 10,
+  ottobre: 10,
+  ott: 10,
+  paz: 10,
+  pazdziernik: 10,
+  ekim: 10,
+  eki: 10,
+  spalis: 10,
+  outubro: 10,
+  out: 10,
+  oktyabr: 10,
+  nov: 11,
+  november: 11,
+  novembre: 11,
+  noviembre: 11,
+  lis: 11,
+  listopad: 11,
+  kasim: 11,
+  kas: 11,
+  lapkritis: 11,
+  novembro: 11,
+  noyabr: 11,
+  dec: 12,
+  december: 12,
+  dezember: 12,
+  dez: 12,
+  decembre: 12,
+  diciembre: 12,
+  dic: 12,
+  dicembre: 12,
+  gru: 12,
+  grudzien: 12,
+  aralik: 12,
+  ara: 12,
+  gruodis: 12,
+  dezembro: 12,
+  dekabr: 12,
+}
+
+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
+  const date = new Date(Date.UTC(y, m - 1, d, 12, 0, 0))
+  // Reject 31 February and friends, which Date would otherwise roll over.
+  if (date.getUTCMonth() !== m - 1) return null
+  return date
+}
+
+function fullYear(y: number, raw: string): number {
+  if (raw.length === 4) return y
+  return y <= (new Date().getFullYear() % 100) + 1 ? 2000 + y : 1900 + y
+}
+
+/** Excel stores dates as days since 1899-12-30. */
+function fromExcelSerial(n: number): Date | null {
+  if (n < 20000 || n > 80000) return null
+  const ms = Math.round((n - 25569) * 86400 * 1000)
+  const d = new Date(ms)
+  return utcDate(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())
+}
+
+/**
+ * Split a date string into its three numeric parts and say which slot holds
+ * the year, when that is unambiguous. Returns null for anything that is not
+ * three parts.
+ */
+function numericParts(s: string): { a: number; b: number; c: number; raw: string[] } | null {
+  const m = s.match(/^(\d{1,4})[./\-\s](\d{1,2})[./\-\s](\d{1,4})$/)
+  if (!m) return null
+  return { a: Number(m[1]), b: Number(m[2]), c: Number(m[3]), raw: [m[1], m[2], m[3]] }
+}
+
+/**
+ * Parse one cell. `format` decides day-first versus month-first when both
+ * readings are possible; `detectDateFormat` works that out from a whole
+ * column so the caller rarely has to ask.
+ */
+export function parseDate(
+  value: string | number | Date | null | undefined,
+  format: DateFormat = 'auto'
+): Date | null {
+  if (value == null) return null
+  if (value instanceof Date) {
+    return Number.isNaN(value.getTime())
+      ? null
+      : utcDate(value.getUTCFullYear(), value.getUTCMonth() + 1, value.getUTCDate())
+  }
+  if (typeof value === 'number') return fromExcelSerial(value)
+
+  let s = String(value).trim()
+  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))
+
+  // ISO and other year-first forms: 2024-03-15, 2024/3/5, 20240315.
+  let m = s.match(/^(\d{4})[./-](\d{1,2})[./-](\d{1,2})$/)
+  if (m) return utcDate(Number(m[1]), Number(m[2]), Number(m[3]))
+  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})$/)
+  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})$/)
+  if (m && MONTHS[m[1]]) return utcDate(fullYear(Number(m[3]), m[3]), MONTHS[m[1]], Number(m[2]))
+
+  const p = numericParts(s)
+  if (!p) return null
+  const { a, b, c, raw } = p
+
+  if (raw[0].length === 4) return utcDate(a, b, c)
+  if (raw[2].length !== 4 && raw[2].length !== 2) return null
+  const year = fullYear(c, raw[2])
+
+  const dayFirst = (): Date | null => utcDate(year, b, a)
+  const monthFirst = (): Date | null => utcDate(year, a, b)
+
+  if (format === 'DMY') return dayFirst()
+  if (format === 'MDY') return monthFirst()
+  if (format === 'YMD') return utcDate(year, b, a)
+  // auto: whichever reading is possible; day-first when both are, since that
+  // is the convention everywhere the app is used except one country, and the
+  // column-level detector overrides this when the data says otherwise.
+  if (a > 12 && b <= 12) return dayFirst()
+  if (b > 12 && a <= 12) return monthFirst()
+  return dayFirst() ?? monthFirst()
+}
+
+/**
+ * Look at a whole column and say whether it is day-first or month-first.
+ * "unknown" means every value was ambiguous (or empty); the caller then falls
+ * back to the workshop's convention and lets the user override.
+ */
+export function detectDateFormat(values: readonly (string | null | undefined)[]): DateFormat {
+  let dayFirst = 0
+  let monthFirst = 0
+  let yearFirst = 0
+  for (const v of values) {
+    if (!v) continue
+    const s = String(v)
+      .trim()
+      .replace(/[T ]\d{1,2}:\d{2}.*$/, '')
+    if (/^\d{4}[./-]\d{1,2}[./-]\d{1,2}$/.test(s)) {
+      yearFirst++
+      continue
+    }
+    const p = numericParts(s)
+    if (!p || p.raw[0].length === 4) continue
+    if (p.a > 12 && p.b <= 12) dayFirst++
+    else if (p.b > 12 && p.a <= 12) monthFirst++
+  }
+  if (yearFirst && !dayFirst && !monthFirst) return 'YMD'
+  if (dayFirst && !monthFirst) return 'DMY'
+  if (monthFirst && !dayFirst) return 'MDY'
+  if (dayFirst && monthFirst) return dayFirst >= monthFirst ? 'DMY' : 'MDY'
+  return 'auto'
+}
+
+/**
+ * Look at a column of amounts and say whether the comma is the decimal mark.
+ * A value like "1.234,56" or "12,5" settles it; "1,234.56" or "12.5" settles
+ * it the other way.
+ */
+export function detectDecimalSeparator(
+  values: readonly (string | null | undefined)[]
+): DecimalSeparator {
+  let comma = 0
+  let dot = 0
+  for (const v of values) {
+    if (!v) continue
+    const s = String(v).replace(/[^\d.,]/g, '')
+    const lastComma = s.lastIndexOf(',')
+    const lastDot = s.lastIndexOf('.')
+    if (lastComma < 0 && lastDot < 0) continue
+    if (lastComma >= 0 && lastDot >= 0) {
+      if (lastComma > lastDot) comma++
+      else dot++
+      continue
+    }
+    const mark = lastComma >= 0 ? ',' : '.'
+    const frac = s.slice(s.lastIndexOf(mark) + 1)
+    if (frac.length === 3 && (s.match(/[.,]/g) ?? []).length === 1) continue // grouping, says nothing
+    if (mark === ',') comma++
+    else dot++
+  }
+  if (comma && !dot) return ','
+  if (dot && !comma) return '.'
+  return 'auto'
+}
+
+// ── Vehicles ──────────────────────────────────────────────────────────────────
+
+export function normalizeVin(value: string | null | undefined): {
+  value: string | null
+  valid: boolean
+} {
+  const cleaned = cleanText(value)?.toUpperCase().replace(/[\s-]/g, '') ?? null
+  if (!cleaned) return { value: null, valid: true }
+  // Modern VINs are 17 characters and never use I, O or Q. Older vehicles
+  // have shorter chassis numbers, which are kept as typed with a warning.
+  const valid = /^[A-HJ-NPR-Z0-9]{17}$/.test(cleaned)
+  return { value: cleaned, valid }
+}
+
+/** Uppercase, single spaces. The plate is stored as people write it. */
+export function normalizePlate(value: string | null | undefined): string | null {
+  const cleaned = cleanText(value)
+  return cleaned ? cleaned.toUpperCase() : null
+}
+
+/** "AB 12345", "AB-12345" and "ab12345" are the same plate. */
+export function plateKey(value: string | null | undefined): string | null {
+  if (!value) return null
+  const key = value.toUpperCase().replace(/[^A-Z0-9]/g, '')
+  return key.length >= 2 ? key : null
+}
+
+const FUEL_SYNONYMS: Record<string, string> = {
+  gasoline: 'gasoline',
+  gas: 'gasoline',
+  petrol: 'gasoline',
+  benzin: 'gasoline',
+  bensin: 'gasoline',
+  gasolina: 'gasoline',
+  essence: 'gasoline',
+  benzina: 'gasoline',
+  benzyna: 'gasoline',
+  benzine: 'gasoline',
+  benzinas: 'gasoline',
+  unleaded: 'gasoline',
+  super: 'gasoline',
+  e10: 'gasoline',
+  e5: 'gasoline',
+  '95': 'gasoline',
+  '98': 'gasoline',
+  pb: 'gasoline',
+  diesel: 'diesel',
+  gasoil: 'diesel',
+  gasoleo: 'diesel',
+  gazole: 'diesel',
+  dyzelinas: 'diesel',
+  dizel: 'diesel',
+  on: 'diesel',
+  d: 'diesel',
+  tdi: 'diesel',
+  hdi: 'diesel',
+  electric: 'electric',
+  ev: 'electric',
+  bev: 'electric',
+  elektro: 'electric',
+  elektrisk: 'electric',
+  el: 'electric',
+  electrico: 'electric',
+  electrique: 'electric',
+  elettrico: 'electric',
+  elektryczny: 'electric',
+  elektrisch: 'electric',
+  elektrikli: 'electric',
+  elektra: 'electric',
+  eletrico: 'electric',
+  hybrid: 'hybrid',
+  phev: 'hybrid',
+  hev: 'hybrid',
+  mhev: 'hybrid',
+  hibrido: 'hybrid',
+  hybride: 'hybrid',
+  ibrido: 'hybrid',
+  hybryda: 'hybrid',
+  hibrit: 'hybrid',
+  hibridas: 'hybrid',
+  pluginhybrid: 'hybrid',
+  ladbarhybrid: 'hybrid',
+  twostroke: 'two-stroke',
+  '2stroke': 'two-stroke',
+  '2t': 'two-stroke',
+  zweitakt: 'two-stroke',
+  totakt: 'two-stroke',
+  outboard: 'outboard',
+  aussenborder: 'outboard',
+  pahengsmotor: 'outboard',
+  inboard: 'inboard',
+  innenborder: 'inboard',
+  innenbordsmotor: 'inboard',
+  lpg: 'other',
+  cng: 'other',
+  gpl: 'other',
+  autogas: 'other',
+  hydrogen: 'other',
+  other: 'other',
+  annet: 'other',
+  andere: 'other',
+  otro: 'other',
+  autre: 'other',
+  altro: 'other',
+  inny: 'other',
+  anders: 'other',
+  diger: 'other',
+  kita: 'other',
+}
+
+export function mapFuelType(value: string | null | undefined): string | null {
+  const cleaned = cleanText(value)
+  if (!cleaned) return null
+  const key = normalizeHeader(cleaned)
+  if (FUEL_SYNONYMS[key]) return FUEL_SYNONYMS[key]
+  // "Diesel (Euro 6)", "Bensin/hybrid": first recognisable word wins.
+  for (const word of cleaned.split(/[\s/,()-]+/)) {
+    const k = normalizeHeader(word)
+    if (k && FUEL_SYNONYMS[k]) return FUEL_SYNONYMS[k]
+  }
+  return 'other'
+}
+
+const TRANSMISSION_SYNONYMS: Record<string, string> = {
+  automatic: 'automatic',
+  auto: 'automatic',
+  at: 'automatic',
+  automatik: 'automatic',
+  automat: 'automatic',
+  automatico: 'automatic',
+  automatique: 'automatic',
+  automatyczna: 'automatic',
+  automaat: 'automatic',
+  otomatik: 'automatic',
+  automatine: 'automatic',
+  avtomat: 'automatic',
+  dsg: 'automatic',
+  tiptronic: 'automatic',
+  steptronic: 'automatic',
+  dct: 'automatic',
+  a: 'automatic',
+  manual: 'manual',
+  mt: 'manual',
+  manuell: 'manual',
+  schaltgetriebe: 'manual',
+  schalter: 'manual',
+  manuel: 'manual',
+  manuale: 'manual',
+  manualna: 'manual',
+  handgeschakeld: 'manual',
+  mekanik: 'manual',
+  duz: 'manual',
+  mechanine: 'manual',
+  mekhanika: 'manual',
+  stick: 'manual',
+  m: 'manual',
+  cvt: 'cvt',
+  stufenlos: 'cvt',
+  variomatic: 'cvt',
+  multitronic: 'cvt',
+  ecvt: 'cvt',
+}
+
+export function mapTransmission(value: string | null | undefined): string | null {
+  const cleaned = cleanText(value)
+  if (!cleaned) return null
+  const key = normalizeHeader(cleaned)
+  if (TRANSMISSION_SYNONYMS[key]) return TRANSMISSION_SYNONYMS[key]
+  for (const word of cleaned.split(/[\s/,()-]+/)) {
+    const k = normalizeHeader(word)
+    if (k && TRANSMISSION_SYNONYMS[k]) return TRANSMISSION_SYNONYMS[k]
+  }
+  return null
+}
+
+/**
+ * "2018 Toyota Corolla", "Toyota Corolla 2018", "Toyota Corolla (2018)" and
+ * "Toyota Corolla" (no year) come apart into their pieces. The make is the
+ * first word; multi-word makes that matter ("Alfa Romeo", "Land Rover",
+ * "Mercedes Benz") are listed so their model is not eaten.
+ */
+const TWO_WORD_MAKES = [
+  'alfa romeo',
+  'aston martin',
+  'land rover',
+  'range rover',
+  'mercedes benz',
+  'rolls royce',
+  'great wall',
+  'mg motor',
+  'lynk co',
+]
+
+export function splitVehicleDescription(value: string | null | undefined): {
+  year: number | null
+  make: string | null
+  model: string | null
+} {
+  let s = cleanText(value)
+  if (!s) return { year: null, make: null, model: null }
+
+  let year: number | null = null
+  let m = s.match(/^((?:19|20)\d{2})\s+(.+)$/)
+  if (m) {
+    year = Number(m[1])
+    s = m[2]
+  } else {
+    m = s.match(/^(.+?)[\s,(]+((?:19|20)\d{2})\)?$/)
+    if (m) {
+      year = Number(m[2])
+      s = m[1].trim()
+    }
+  }
+
+  const lower = s.toLowerCase().replace(/-/g, ' ')
+  for (const make of TWO_WORD_MAKES) {
+    if (lower.startsWith(make)) {
+      const rest = s.slice(make.length).replace(/^[\s-]+/, '')
+      return { year, make: s.slice(0, make.length), model: cleanText(rest) }
+    }
+  }
+  const [make, ...rest] = s.split(/\s+/)
+  return { year, make: make || null, model: rest.length ? rest.join(' ') : null }
+}
+
+/** "Anna" + "Berg" → "Anna Berg"; either half may be missing. */
+export function joinName(first: string | null, last: string | null): string | null {
+  return cleanText([first, last].filter(Boolean).join(' '))
+}
+
+/** Street, postal code, city, state and country become one address line. */
+export function joinAddress(parts: {
+  street?: string | null
+  postalCode?: string | null
+  city?: string | null
+  state?: string | null
+  country?: string | null
+}): string | null {
+  const locality = cleanText([parts.postalCode, parts.city].filter(Boolean).join(' '))
+  return cleanText([parts.street, locality, parts.state, parts.country].filter(Boolean).join(', '))
+}

+ 290 - 0
src/features/import/Lib/parse.ts

@@ -0,0 +1,290 @@
+/**
+ * Reading a spreadsheet into columns and rows of text.
+ *
+ * Three shapes come in: CSV in any delimiter and either UTF-8 or a legacy
+ * Windows encoding, Excel workbooks, and vCard address books exported from a
+ * phone. Everything comes out the same way, as a header row and string cells,
+ * so the rest of the importer never has to know where the file came from.
+ */
+
+import ExcelJS from 'exceljs'
+import iconv from 'iconv-lite'
+import Papa from 'papaparse'
+
+export type ImportFileFormat = 'csv' | 'xlsx' | 'vcard'
+
+export interface ParsedSheet {
+  format: ImportFileFormat
+  columns: string[]
+  rows: string[][]
+  /** Sheet name for workbooks, so the user can tell which tab was read. */
+  sheetName?: string
+  /** Detected CSV delimiter, for display. */
+  delimiter?: string
+  /** Detected text encoding, for display. */
+  encoding?: string
+}
+
+/** Hard ceiling on rows in one file. Bigger than any shop's customer list. */
+export const MAX_IMPORT_ROWS = 50_000
+export const MAX_IMPORT_FILE_BYTES = 25 * 1024 * 1024
+
+export class ImportParseError extends Error {
+  code: 'unsupported_format' | 'empty' | 'too_many_rows' | 'legacy_xls' | 'unreadable'
+  constructor(code: ImportParseError['code'], message: string) {
+    super(message)
+    this.code = code
+  }
+}
+
+function extensionOf(fileName: string): string {
+  return fileName.toLowerCase().split('.').pop() ?? ''
+}
+
+// ── Text decoding ─────────────────────────────────────────────────────────────
+
+/**
+ * UTF-8 when it is valid UTF-8, otherwise Windows-1252, which is what an old
+ * Windows shop system and Excel's plain "CSV" export both produce. A BOM is
+ * stripped so it does not end up glued to the first header.
+ */
+export function decodeText(buffer: Buffer): { text: string; encoding: string } {
+  if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
+    return { text: iconv.decode(buffer, 'utf16le'), encoding: 'UTF-16' }
+  }
+  if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
+    return { text: iconv.decode(buffer, 'utf16be'), encoding: 'UTF-16' }
+  }
+  try {
+    const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer)
+    return { text: text.replace(/^/, ''), encoding: 'UTF-8' }
+  } catch {
+    return { text: iconv.decode(buffer, 'win1252'), encoding: 'Windows-1252' }
+  }
+}
+
+// ── Headers ───────────────────────────────────────────────────────────────────
+
+/** Blank headers get a name, duplicate headers get a suffix, so a mapping can address every column. */
+export function tidyColumns(raw: (string | null | undefined)[]): string[] {
+  const seen = new Map<string, number>()
+  return raw.map((h, i) => {
+    let name = String(h ?? '')
+      .replace(/\s+/g, ' ')
+      .trim()
+    if (!name) name = `Column ${i + 1}`
+    const count = seen.get(name) ?? 0
+    seen.set(name, count + 1)
+    return count ? `${name} (${count + 1})` : name
+  })
+}
+
+/** Drop trailing empty columns and rows that are entirely blank. */
+function trimSheet(columns: string[], rows: string[][]): { columns: string[]; rows: string[][] } {
+  let width = columns.length
+  while (width > 0) {
+    const col = width - 1
+    const headerBlank = /^Column \d+$/.test(columns[col])
+    const allBlank = rows.every((r) => !(r[col] ?? '').trim())
+    if (headerBlank && allBlank) width--
+    else break
+  }
+  const trimmedColumns = columns.slice(0, width)
+  const trimmedRows = rows
+    .map((r) => {
+      const out = r.slice(0, width)
+      while (out.length < width) out.push('')
+      return out
+    })
+    .filter((r) => r.some((c) => c.trim()))
+  return { columns: trimmedColumns, rows: trimmedRows }
+}
+
+// ── CSV ───────────────────────────────────────────────────────────────────────
+
+export function parseCsv(buffer: Buffer): ParsedSheet {
+  const { text, encoding } = decodeText(buffer)
+  const result = Papa.parse<string[]>(text, {
+    skipEmptyLines: 'greedy',
+    delimitersToGuess: [',', ';', '\t', '|'],
+  })
+  const data = result.data.filter((r) => Array.isArray(r))
+  if (data.length === 0) throw new ImportParseError('empty', 'The file has no rows.')
+  const columns = tidyColumns(data[0])
+  const body = data.slice(1).map((r) => r.map((c) => String(c ?? '')))
+  const sheet = trimSheet(columns, body)
+  return {
+    format: 'csv',
+    ...sheet,
+    delimiter: result.meta.delimiter,
+    encoding,
+  }
+}
+
+// ── Excel ─────────────────────────────────────────────────────────────────────
+
+function cellText(value: ExcelJS.CellValue): string {
+  if (value == null) return ''
+  if (value instanceof Date) {
+    if (Number.isNaN(value.getTime())) return ''
+    return value.toISOString().slice(0, 10)
+  }
+  if (typeof value === 'object') {
+    if ('richText' in value) return value.richText.map((r) => r.text).join('')
+    if ('text' in value) return cellText(value.text as ExcelJS.CellValue)
+    if ('result' in value) return cellText(value.result as ExcelJS.CellValue)
+    if ('error' in value) return ''
+    if ('hyperlink' in value) return String((value as { hyperlink: string }).hyperlink)
+    return ''
+  }
+  return String(value)
+}
+
+export async function parseXlsx(buffer: Buffer): Promise<ParsedSheet> {
+  const workbook = new ExcelJS.Workbook()
+  try {
+    await workbook.xlsx.load(buffer as unknown as ArrayBuffer)
+  } catch {
+    throw new ImportParseError('unreadable', 'The workbook could not be read.')
+  }
+  const sheet = workbook.worksheets.find((ws) => ws.rowCount > 0) ?? workbook.worksheets[0]
+  if (!sheet) throw new ImportParseError('empty', 'The workbook has no sheets.')
+
+  const grid: string[][] = []
+  sheet.eachRow({ includeEmpty: false }, (row) => {
+    const values = row.values as ExcelJS.CellValue[]
+    // ExcelJS row values are 1-based; index 0 is always empty.
+    const cells: string[] = []
+    for (let i = 1; i < values.length; i++) cells.push(cellText(values[i]).trim())
+    grid.push(cells)
+  })
+  if (grid.length === 0) throw new ImportParseError('empty', 'The sheet has no rows.')
+
+  const width = Math.max(...grid.map((r) => r.length))
+  const columns = tidyColumns(grid[0].concat(Array(width - grid[0].length).fill('')))
+  const body = grid.slice(1)
+  const trimmed = trimSheet(columns, body)
+  return { format: 'xlsx', ...trimmed, sheetName: sheet.name }
+}
+
+// ── vCard ─────────────────────────────────────────────────────────────────────
+
+const VCARD_COLUMNS = [
+  'Name',
+  'First name',
+  'Last name',
+  'Email',
+  'Phone',
+  'Company',
+  'Address',
+  'Notes',
+]
+
+function unfoldVcard(text: string): string[] {
+  // RFC 6350 folds long lines with a leading space; join them back.
+  return text.replace(/\r?\n[ \t]/g, '').split(/\r?\n/)
+}
+
+function vcardValue(line: string): string {
+  const value = line.slice(line.indexOf(':') + 1).trim()
+  return value.replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\n/gi, ' ')
+}
+
+export function parseVcard(buffer: Buffer): ParsedSheet {
+  const { text } = decodeText(buffer)
+  const cards = text.split(/BEGIN:VCARD/i).filter((c) => /END:VCARD/i.test(c))
+  const rows: string[][] = []
+  for (const card of cards) {
+    let name = ''
+    let first = ''
+    let last = ''
+    let email = ''
+    let phone = ''
+    let company = ''
+    let address = ''
+    let notes = ''
+    for (const line of unfoldVcard(card)) {
+      const upper = line.toUpperCase()
+      const key = upper.split(/[;:]/)[0]
+      switch (key) {
+        case 'FN':
+          name = vcardValue(line)
+          break
+        case 'N': {
+          const parts = vcardValue(line).split(';')
+          last = parts[0]?.trim() ?? ''
+          first = parts[1]?.trim() ?? ''
+          break
+        }
+        case 'EMAIL':
+          if (!email) email = vcardValue(line)
+          break
+        case 'TEL':
+          if (!phone || upper.includes('TYPE=CELL') || upper.includes('TYPE=MOBILE')) {
+            const v = vcardValue(line).replace(/^tel:/i, '')
+            if (!phone || upper.includes('TYPE=CELL') || upper.includes('TYPE=MOBILE')) phone = v
+          }
+          break
+        case 'ORG':
+          company = vcardValue(line).split(';')[0].trim()
+          break
+        case 'ADR':
+          if (!address) {
+            // post office box; extended; street; locality; region; postal code; country
+            const p = vcardValue(line).split(';')
+            address = [p[2], [p[5], p[3]].filter(Boolean).join(' '), p[4], p[6]]
+              .map((s) => (s ?? '').trim())
+              .filter(Boolean)
+              .join(', ')
+          }
+          break
+        case 'NOTE':
+          notes = vcardValue(line)
+          break
+      }
+    }
+    if (!name) name = [first, last].filter(Boolean).join(' ')
+    if (!name && !company) continue
+    rows.push([name || company, first, last, email, phone, company, address, notes])
+  }
+  if (rows.length === 0) throw new ImportParseError('empty', 'No contacts were found in the file.')
+  return { format: 'vcard', columns: [...VCARD_COLUMNS], rows }
+}
+
+// ── Entry point ───────────────────────────────────────────────────────────────
+
+export async function parseImportFile(buffer: Buffer, fileName: string): Promise<ParsedSheet> {
+  const ext = extensionOf(fileName)
+  let sheet: ParsedSheet
+  if (ext === 'xlsx' || ext === 'xlsm') {
+    sheet = await parseXlsx(buffer)
+  } else if (ext === 'xls') {
+    throw new ImportParseError(
+      'legacy_xls',
+      'Old Excel .xls files are not supported. Open the file in Excel and save it as .xlsx or .csv.'
+    )
+  } else if (ext === 'vcf' || ext === 'vcard') {
+    sheet = parseVcard(buffer)
+  } else if (['csv', 'tsv', 'txt', ''].includes(ext)) {
+    // A file renamed .csv that is really a workbook starts with the zip magic.
+    if (buffer.length > 4 && buffer[0] === 0x50 && buffer[1] === 0x4b) {
+      sheet = await parseXlsx(buffer)
+    } else {
+      sheet = parseCsv(buffer)
+    }
+  } else {
+    throw new ImportParseError(
+      'unsupported_format',
+      'Unsupported file type. Upload a .csv, .xlsx or .vcf file.'
+    )
+  }
+
+  if (sheet.rows.length === 0) throw new ImportParseError('empty', 'The file has no data rows.')
+  if (sheet.rows.length > MAX_IMPORT_ROWS) {
+    throw new ImportParseError(
+      'too_many_rows',
+      `The file has ${sheet.rows.length} rows; the limit is ${MAX_IMPORT_ROWS}. Split it and import in parts.`
+    )
+  }
+  return sheet
+}

+ 707 - 0
src/features/import/Lib/pipeline.ts

@@ -0,0 +1,707 @@
+/**
+ * From mapped columns to a plan of what the import will do.
+ *
+ * Everything here is pure. The caller fetches what already exists, hands it
+ * in, and gets back one decision per row: create, update, skip, or error,
+ * with the normalised values that decision would write. That plan is what the
+ * dry run shows the user, and it is exactly what the commit then executes, so
+ * the preview cannot drift from the result.
+ */
+
+import type { ImportEntity } from './fields'
+import {
+  type DateFormat,
+  type DecimalSeparator,
+  cleanText,
+  joinAddress,
+  joinName,
+  mapFuelType,
+  mapTransmission,
+  normalizeEmail,
+  normalizePhone,
+  normalizePlate,
+  normalizeVin,
+  parseDate,
+  parseInteger,
+  parseNumber,
+  parseYear,
+  phoneKey,
+  plateKey,
+  splitVehicleDescription,
+} from './normalize'
+import type { ColumnMapping } from './suggest'
+
+export type DuplicateRule = 'skip' | 'update' | 'create'
+
+export interface ImportOptions {
+  entity: ImportEntity
+  dateFormat: DateFormat
+  decimalSeparator: DecimalSeparator
+  defaultCountryCode: string | null
+  duplicates: DuplicateRule
+}
+
+export interface CustomerDraft {
+  name: string | null
+  customerNumber: string | null
+  email: string | null
+  phone: string | null
+  company: string | null
+  address: string | null
+  taxId: string | null
+  notes: string | null
+}
+
+export interface VehicleDraft {
+  make: string | null
+  model: string | null
+  year: number | null
+  vin: string | null
+  licensePlate: string | null
+  color: string | null
+  mileage: number | null
+  fuelType: string | null
+  transmission: string | null
+  engineSize: string | null
+  engineCode: string | null
+  /** ISO date, so the plan survives JSON. */
+  purchaseDate: string | null
+  purchasePrice: number | null
+}
+
+export interface ServiceDraft {
+  date: string | null
+  title: string | null
+  description: string | null
+  total: number | null
+  mileage: number | null
+  invoiceNumber: string | null
+  notes: string | null
+  technician: string | null
+}
+
+export type RowIssueCode =
+  | 'customer_name_required'
+  | 'invalid_email'
+  | 'invalid_vin'
+  | 'vehicle_make_model_required'
+  | 'vehicle_year_required'
+  | 'vehicle_year_invalid'
+  | 'vehicle_identifier_required'
+  | 'vehicle_not_found'
+  | 'service_date_required'
+  | 'service_date_invalid'
+  | 'service_title_required'
+  | 'invalid_date'
+  | 'invalid_number'
+  | 'duplicate_invoice_number'
+  | 'write_failed'
+
+export interface RowIssue {
+  code: RowIssueCode
+  /** Field key the issue is about, when there is one. */
+  field?: string
+  /** The offending value, for the message. */
+  value?: string
+}
+
+export type RowAction = 'create' | 'update' | 'skip' | 'error'
+
+export interface CustomerMatch {
+  id: string
+  name: string
+  on: 'number' | 'email' | 'phone' | 'name'
+}
+
+export interface VehicleMatch {
+  id: string
+  label: string
+  on: 'vin' | 'plate' | 'makeModelYear'
+}
+
+export interface RowPlan {
+  /** 0-based data row index; the spreadsheet row is index + 2. */
+  index: number
+  action: RowAction
+  errors: RowIssue[]
+  warnings: RowIssue[]
+  customer?: CustomerDraft
+  vehicle?: VehicleDraft
+  service?: ServiceDraft
+  customerMatch?: CustomerMatch
+  vehicleMatch?: VehicleMatch
+  /** Earlier row that carries the same customer, so it is created once and shared. */
+  customerSameAs?: number
+  vehicleSameAs?: number
+  /** Whether this row is where its customer/vehicle gets created. */
+  createsCustomer: boolean
+  createsVehicle: boolean
+}
+
+export interface PlanSummary {
+  total: number
+  create: number
+  update: number
+  skip: number
+  error: number
+  customersToCreate: number
+  customersToUpdate: number
+  vehiclesToCreate: number
+  vehiclesToUpdate: number
+  servicesToCreate: number
+}
+
+export interface ImportPlan {
+  rows: RowPlan[]
+  summary: PlanSummary
+}
+
+export interface ExistingCustomer {
+  id: string
+  name: string
+  customerNumber: string | null
+  email: string | null
+  phone: string | null
+}
+
+export interface ExistingVehicle {
+  id: string
+  make: string
+  model: string
+  year: number
+  vin: string | null
+  licensePlate: string | null
+  customerId: string | null
+}
+
+export interface ExistingData {
+  customers: ExistingCustomer[]
+  vehicles: ExistingVehicle[]
+  /** Invoice numbers already in use, so a re-imported history does not double up. */
+  invoiceNumbers: string[]
+}
+
+/** Per-row choices the user made in the preview. */
+export type RowOverrides = Record<string, RowAction>
+
+// ── Extraction ────────────────────────────────────────────────────────────────
+
+type Values = Record<string, string>
+
+/** Pick the mapped cells of one row, keyed by field. */
+function valuesOf(row: readonly string[], mapping: ColumnMapping): Values {
+  const out: Values = {}
+  for (const [col, key] of Object.entries(mapping)) {
+    if (!key) continue
+    const v = row[Number(col)]
+    if (v != null && String(v).trim()) out[key] = String(v)
+  }
+  return out
+}
+
+function hasGroup(values: Values, group: string): boolean {
+  return Object.keys(values).some((k) => k.startsWith(`${group}.`))
+}
+
+function extractCustomer(
+  v: Values,
+  options: ImportOptions,
+  errors: RowIssue[],
+  warnings: RowIssue[]
+): CustomerDraft {
+  const name =
+    cleanText(v['customer.name']) ??
+    joinName(cleanText(v['customer.firstName']), cleanText(v['customer.lastName']))
+  const email = normalizeEmail(v['customer.email'])
+  if (!email.valid)
+    warnings.push({ code: 'invalid_email', field: 'customer.email', value: v['customer.email'] })
+  const address =
+    cleanText(v['customer.address']) ??
+    joinAddress({
+      street: cleanText(v['customer.street']),
+      postalCode: cleanText(v['customer.postalCode']),
+      city: cleanText(v['customer.city']),
+      state: cleanText(v['customer.state']),
+      country: cleanText(v['customer.country']),
+    })
+  const company = cleanText(v['customer.company'])
+  return {
+    name: name ?? company,
+    customerNumber: cleanText(v['customer.customerNumber']),
+    email: email.valid ? email.value : null,
+    phone: normalizePhone(v['customer.phone'], options.defaultCountryCode),
+    company,
+    address,
+    taxId: cleanText(v['customer.taxId']),
+    notes: cleanText(v['customer.notes']),
+  }
+}
+
+function extractVehicle(
+  v: Values,
+  options: ImportOptions,
+  errors: RowIssue[],
+  warnings: RowIssue[]
+): VehicleDraft {
+  const described = splitVehicleDescription(v['vehicle.description'])
+  const make = cleanText(v['vehicle.make']) ?? described.make
+  const model = cleanText(v['vehicle.model']) ?? described.model
+  let year: number | null = null
+  if (v['vehicle.year'] != null) {
+    year = parseYear(v['vehicle.year'])
+    if (year == null)
+      errors.push({ code: 'vehicle_year_invalid', field: 'vehicle.year', value: v['vehicle.year'] })
+  } else {
+    year = described.year
+  }
+  const vin = normalizeVin(v['vehicle.vin'])
+  if (!vin.valid)
+    warnings.push({ code: 'invalid_vin', field: 'vehicle.vin', value: v['vehicle.vin'] })
+
+  let purchaseDate: string | null = null
+  if (v['vehicle.purchaseDate']) {
+    const d = parseDate(v['vehicle.purchaseDate'], options.dateFormat)
+    if (d) purchaseDate = d.toISOString()
+    else
+      warnings.push({
+        code: 'invalid_date',
+        field: 'vehicle.purchaseDate',
+        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
+  }
+  let purchasePrice: number | null = null
+  if (v['vehicle.purchasePrice']) {
+    purchasePrice = parseNumber(v['vehicle.purchasePrice'], options.decimalSeparator)
+    if (purchasePrice == null)
+      warnings.push({
+        code: 'invalid_number',
+        field: 'vehicle.purchasePrice',
+        value: v['vehicle.purchasePrice'],
+      })
+  }
+
+  return {
+    make,
+    model,
+    year,
+    vin: vin.value,
+    licensePlate: normalizePlate(v['vehicle.licensePlate']),
+    color: cleanText(v['vehicle.color']),
+    mileage,
+    fuelType: mapFuelType(v['vehicle.fuelType']),
+    transmission: mapTransmission(v['vehicle.transmission']),
+    engineSize: cleanText(v['vehicle.engineSize']),
+    engineCode: cleanText(v['vehicle.engineCode']),
+    purchaseDate,
+    purchasePrice,
+  }
+}
+
+function extractService(
+  v: Values,
+  options: ImportOptions,
+  errors: RowIssue[],
+  warnings: RowIssue[]
+): ServiceDraft {
+  let date: string | null = null
+  if (v['service.date']) {
+    const d = parseDate(v['service.date'], options.dateFormat)
+    if (d) date = d.toISOString()
+    else
+      errors.push({ code: 'service_date_invalid', field: 'service.date', value: v['service.date'] })
+  } else {
+    errors.push({ code: 'service_date_required', field: 'service.date' })
+  }
+  let total: number | null = null
+  if (v['service.total']) {
+    total = parseNumber(v['service.total'], options.decimalSeparator)
+    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 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 {
+    date,
+    title,
+    description,
+    total,
+    mileage,
+    invoiceNumber: cleanText(v['service.invoiceNumber']),
+    notes: cleanText(v['service.notes']),
+    technician: cleanText(v['service.technician']),
+  }
+}
+
+// ── Keys for matching ─────────────────────────────────────────────────────────
+
+function customerKeys(c: CustomerDraft): {
+  number: string | null
+  email: string | null
+  phone: string | null
+  name: string | null
+} {
+  return {
+    number: c.customerNumber?.toLowerCase() ?? null,
+    email: c.email?.toLowerCase() ?? null,
+    phone: phoneKey(c.phone),
+    name: c.name?.toLowerCase() ?? null,
+  }
+}
+
+/** One string that identifies the customer within the file. */
+function customerFileKey(c: CustomerDraft): string | null {
+  const k = customerKeys(c)
+  return k.number
+    ? `n:${k.number}`
+    : k.email
+      ? `e:${k.email}`
+      : k.phone
+        ? `p:${k.phone}`
+        : k.name
+          ? `m:${k.name}`
+          : null
+}
+
+function vehicleFileKey(v: VehicleDraft, ownerKey: string | null): string | null {
+  if (v.vin) return `v:${v.vin}`
+  const p = plateKey(v.licensePlate)
+  if (p) return `p:${p}`
+  if (v.make && v.model && v.year)
+    return `m:${v.make.toLowerCase()}|${v.model.toLowerCase()}|${v.year}|${ownerKey ?? ''}`
+  return null
+}
+
+function vehicleLabel(v: {
+  year: number
+  make: string
+  model: string
+  licensePlate: string | null
+}): string {
+  const plate = v.licensePlate ? ` (${v.licensePlate})` : ''
+  return `${v.year} ${v.make} ${v.model}${plate}`
+}
+
+class ExistingIndex {
+  private byNumber = new Map<string, ExistingCustomer>()
+  private byEmail = new Map<string, ExistingCustomer>()
+  private byPhone = new Map<string, ExistingCustomer>()
+  private byName = new Map<string, ExistingCustomer>()
+  private byVin = new Map<string, ExistingVehicle>()
+  private byPlate = new Map<string, ExistingVehicle>()
+  private byMmy = new Map<string, ExistingVehicle>()
+  readonly invoiceNumbers: Set<string>
+
+  constructor(existing: ExistingData) {
+    for (const c of existing.customers) {
+      if (c.customerNumber) this.byNumber.set(c.customerNumber.toLowerCase(), c)
+      if (c.email) this.byEmail.set(c.email.toLowerCase(), c)
+      const p = phoneKey(c.phone)
+      if (p) this.byPhone.set(p, c)
+      if (!this.byName.has(c.name.toLowerCase())) this.byName.set(c.name.toLowerCase(), c)
+    }
+    for (const v of existing.vehicles) {
+      const vin = normalizeVin(v.vin).value
+      if (vin) this.byVin.set(vin, v)
+      const p = plateKey(v.licensePlate)
+      if (p) this.byPlate.set(p, v)
+      this.byMmy.set(
+        `${v.make.toLowerCase()}|${v.model.toLowerCase()}|${v.year}|${v.customerId ?? ''}`,
+        v
+      )
+    }
+    this.invoiceNumbers = new Set(existing.invoiceNumbers.map((n) => n.toLowerCase()))
+  }
+
+  customer(c: CustomerDraft): CustomerMatch | undefined {
+    const k = customerKeys(c)
+    const hit = (found: ExistingCustomer | undefined, on: CustomerMatch['on']) =>
+      found ? { id: found.id, name: found.name, on } : undefined
+    if (k.number) {
+      const m = hit(this.byNumber.get(k.number), 'number')
+      if (m) return m
+    }
+    if (k.email) {
+      const m = hit(this.byEmail.get(k.email), 'email')
+      if (m) return m
+    }
+    if (k.phone) {
+      const m = hit(this.byPhone.get(k.phone), 'phone')
+      if (m) return m
+    }
+    if (k.name) {
+      const found = this.byName.get(k.name)
+      // A name alone is only a match when nothing contradicts it.
+      if (found) {
+        const emailClash = k.email && found.email && found.email.toLowerCase() !== k.email
+        const phoneClash = k.phone && found.phone && phoneKey(found.phone) !== k.phone
+        if (!emailClash && !phoneClash) return hit(found, 'name')
+      }
+    }
+    return undefined
+  }
+
+  vehicle(v: VehicleDraft, customerId: string | null): VehicleMatch | undefined {
+    const hit = (found: ExistingVehicle | undefined, on: VehicleMatch['on']) =>
+      found ? { id: found.id, label: vehicleLabel(found), on } : undefined
+    if (v.vin) {
+      const m = hit(this.byVin.get(v.vin), 'vin')
+      if (m) return m
+    }
+    const p = plateKey(v.licensePlate)
+    if (p) {
+      const m = hit(this.byPlate.get(p), 'plate')
+      if (m) return m
+    }
+    if (v.make && v.model && v.year) {
+      const m = hit(
+        this.byMmy.get(
+          `${v.make.toLowerCase()}|${v.model.toLowerCase()}|${v.year}|${customerId ?? ''}`
+        ),
+        'makeModelYear'
+      )
+      if (m) return m
+    }
+    return undefined
+  }
+}
+
+// ── Planning ──────────────────────────────────────────────────────────────────
+
+export function planImport(
+  rows: readonly string[][],
+  mapping: ColumnMapping,
+  options: ImportOptions,
+  existing: ExistingData,
+  overrides: RowOverrides = {}
+): ImportPlan {
+  const index = new ExistingIndex(existing)
+  const { entity } = options
+  const plans: RowPlan[] = []
+
+  // Rows seen earlier in this file, so the same customer or vehicle in ten
+  // rows is created once.
+  const seenCustomers = new Map<string, number>()
+  const seenVehicles = new Map<string, number>()
+  const seenInvoiceNumbers = new Set<string>()
+
+  rows.forEach((row, i) => {
+    const v = valuesOf(row, mapping)
+    const errors: RowIssue[] = []
+    const warnings: RowIssue[] = []
+    const plan: RowPlan = {
+      index: i,
+      action: 'create',
+      errors,
+      warnings,
+      createsCustomer: false,
+      createsVehicle: false,
+    }
+
+    // Customer part: required for a customer import, optional owner otherwise.
+    const wantsCustomer = entity === 'customers' || hasGroup(v, 'customer')
+    if (wantsCustomer) {
+      const customer = extractCustomer(v, options, errors, warnings)
+      const identifiable = Boolean(
+        customer.name || customer.email || customer.phone || customer.customerNumber
+      )
+      if (entity === 'customers') {
+        if (!customer.name) errors.push({ code: 'customer_name_required', field: 'customer.name' })
+        plan.customer = customer
+      } else if (identifiable) {
+        // An owner given only by email or phone can still be linked to an
+        // existing customer; it just cannot be created. An empty owner
+        // column is simply no owner.
+        plan.customer = customer
+      }
+    }
+
+    // Vehicle part
+    const wantsVehicle = entity !== 'customers' && (entity === 'vehicles' || hasGroup(v, 'vehicle'))
+    if (wantsVehicle) {
+      const vehicle = extractVehicle(v, options, errors, warnings)
+      const identified = Boolean(
+        vehicle.vin || vehicle.licensePlate || (vehicle.make && vehicle.model)
+      )
+      if (entity === 'vehicles') {
+        if (!vehicle.make || !vehicle.model)
+          errors.push({ code: 'vehicle_make_model_required', field: 'vehicle.make' })
+        if (vehicle.year == null && !errors.some((e) => e.code === 'vehicle_year_invalid')) {
+          errors.push({ code: 'vehicle_year_required', field: 'vehicle.year' })
+        }
+      } else if (!identified) {
+        errors.push({ code: 'vehicle_identifier_required', field: 'vehicle.licensePlate' })
+      }
+      plan.vehicle = vehicle
+    } else if (entity === 'services') {
+      errors.push({ code: 'vehicle_identifier_required', field: 'vehicle.licensePlate' })
+    }
+
+    // Service part
+    if (entity === 'services') {
+      const service = extractService(v, options, errors, warnings)
+      plan.service = service
+      if (service.invoiceNumber) {
+        const key = service.invoiceNumber.toLowerCase()
+        if (index.invoiceNumbers.has(key) || seenInvoiceNumbers.has(key)) {
+          errors.push({
+            code: 'duplicate_invoice_number',
+            field: 'service.invoiceNumber',
+            value: service.invoiceNumber,
+          })
+        } else {
+          seenInvoiceNumbers.add(key)
+        }
+      }
+    }
+
+    // Matching
+    let customerId: string | null = null
+    if (plan.customer) {
+      const match = index.customer(plan.customer)
+      if (match) {
+        plan.customerMatch = match
+        customerId = match.id
+      } else {
+        const key = customerFileKey(plan.customer)
+        if (key && seenCustomers.has(key)) plan.customerSameAs = seenCustomers.get(key)
+        else if (key) seenCustomers.set(key, i)
+      }
+    }
+    if (plan.vehicle) {
+      const match = index.vehicle(plan.vehicle, customerId)
+      if (match) {
+        plan.vehicleMatch = match
+      } else {
+        const ownerKey = plan.customer ? customerFileKey(plan.customer) : null
+        const key = vehicleFileKey(plan.vehicle, ownerKey)
+        if (key && seenVehicles.has(key)) plan.vehicleSameAs = seenVehicles.get(key)
+        else if (key) seenVehicles.set(key, i)
+      }
+      // A service row whose car does not exist and cannot be created has nowhere to go.
+      if (
+        entity === 'services' &&
+        !match &&
+        !(plan.vehicle.make && plan.vehicle.model && plan.vehicle.year != null)
+      ) {
+        errors.push({
+          code: 'vehicle_not_found',
+          field: 'vehicle.licensePlate',
+          value: plan.vehicle.licensePlate ?? plan.vehicle.vin ?? undefined,
+        })
+      }
+    }
+
+    // Decision
+    if (errors.length) {
+      plan.action = 'error'
+    } else {
+      const primaryMatched =
+        entity === 'customers'
+          ? Boolean(plan.customerMatch)
+          : entity === 'vehicles'
+            ? Boolean(plan.vehicleMatch)
+            : false
+      const override = overrides[String(i)]
+      if (override && override !== 'error') {
+        plan.action = override
+      } else if (primaryMatched) {
+        plan.action = options.duplicates === 'create' ? 'create' : options.duplicates
+      } else {
+        plan.action = 'create'
+      }
+      // "Create anyway" on a matched row means: forget the match.
+      if (plan.action === 'create') {
+        if (entity === 'customers') plan.customerMatch = undefined
+        if (entity === 'vehicles') plan.vehicleMatch = undefined
+      }
+    }
+
+    if (plan.action === 'create' || plan.action === 'update') {
+      plan.createsCustomer = Boolean(
+        plan.customer?.name && !plan.customerMatch && plan.customerSameAs == null
+      )
+      plan.createsVehicle = Boolean(
+        plan.vehicle && !plan.vehicleMatch && plan.vehicleSameAs == null
+      )
+    }
+
+    plans.push(plan)
+  })
+
+  // A row that shares its customer with an earlier row that ended up skipped
+  // or in error still needs that customer; hand creation to the first row
+  // that actually runs.
+  const promoted = new Map<number, number>()
+  for (const plan of plans) {
+    if (plan.action !== 'create' && plan.action !== 'update') continue
+    for (const kind of ['customer', 'vehicle'] as const) {
+      const sameAs = kind === 'customer' ? plan.customerSameAs : plan.vehicleSameAs
+      if (sameAs == null) continue
+      const origin = plans[sameAs]
+      const originRuns = origin.action === 'create' || origin.action === 'update'
+      if (originRuns) continue
+      const key = sameAs * 2 + (kind === 'customer' ? 0 : 1)
+      const replacement = promoted.get(key)
+      if (replacement == null) {
+        promoted.set(key, plan.index)
+        if (kind === 'customer') {
+          plan.customerSameAs = undefined
+          plan.createsCustomer = true
+        } else {
+          plan.vehicleSameAs = undefined
+          plan.createsVehicle = true
+        }
+      } else if (kind === 'customer') plan.customerSameAs = replacement
+      else plan.vehicleSameAs = replacement
+    }
+  }
+
+  const summary: PlanSummary = {
+    total: plans.length,
+    create: 0,
+    update: 0,
+    skip: 0,
+    error: 0,
+    customersToCreate: 0,
+    customersToUpdate: 0,
+    vehiclesToCreate: 0,
+    vehiclesToUpdate: 0,
+    servicesToCreate: 0,
+  }
+  for (const plan of plans) {
+    summary[plan.action]++
+    if (plan.action === 'skip' || plan.action === 'error') continue
+    if (plan.createsCustomer) summary.customersToCreate++
+    if (plan.createsVehicle) summary.vehiclesToCreate++
+    if (entity === 'customers' && plan.action === 'update') summary.customersToUpdate++
+    if (entity === 'vehicles' && plan.action === 'update') summary.vehiclesToUpdate++
+    if (entity === 'services' && plan.action === 'create') summary.servicesToCreate++
+  }
+
+  return { rows: plans, summary }
+}

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.