Bernt Christian Egeland 1 неделя назад
Родитель
Сommit
e899d620f2
36 измененных файлов с 2686 добавлено и 44 удалено
  1. 2 0
      messages/de/audit.json
  2. 74 1
      messages/de/reminders.json
  3. 2 0
      messages/en/audit.json
  4. 74 1
      messages/en/reminders.json
  5. 2 0
      messages/es/audit.json
  6. 74 1
      messages/es/reminders.json
  7. 2 0
      messages/fr/audit.json
  8. 74 1
      messages/fr/reminders.json
  9. 2 0
      messages/it/audit.json
  10. 74 1
      messages/it/reminders.json
  11. 2 0
      messages/lt/audit.json
  12. 74 1
      messages/lt/reminders.json
  13. 2 0
      messages/nb/audit.json
  14. 74 1
      messages/nb/reminders.json
  15. 2 0
      messages/nl/audit.json
  16. 74 1
      messages/nl/reminders.json
  17. 2 0
      messages/pl/audit.json
  18. 74 1
      messages/pl/reminders.json
  19. 2 0
      messages/pt-BR/audit.json
  20. 74 1
      messages/pt-BR/reminders.json
  21. 2 0
      messages/pt-PT/audit.json
  22. 74 1
      messages/pt-PT/reminders.json
  23. 2 0
      messages/ru/audit.json
  24. 74 1
      messages/ru/reminders.json
  25. 2 0
      messages/tr/audit.json
  26. 74 1
      messages/tr/reminders.json
  27. 11 0
      src/__tests__/features/import/parse.test.ts
  28. 282 0
      src/__tests__/features/vehicles/reminder-import.test.ts
  29. 7 30
      src/app/api/protected/import/template/route.ts
  30. 18 0
      src/app/api/protected/reminders/template/route.ts
  31. 6 1
      src/features/import/Lib/parse.ts
  32. 44 0
      src/features/import/Lib/template.ts
  33. 206 0
      src/features/vehicles/Actions/reminderImportActions.ts
  34. 466 0
      src/features/vehicles/Components/ReminderImportDialog.tsx
  35. 13 0
      src/features/vehicles/Components/RemindersPageClient.tsx
  36. 645 0
      src/features/vehicles/Lib/reminderImport.ts

+ 2 - 0
messages/de/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Notiz gelöscht",
     "reminder_create": "Erinnerung erstellt",
     "reminder_delete": "Erinnerung gelöscht",
+    "reminder_import": "Erinnerungen importiert",
     "organization_create": "Organisation erstellt",
     "subscription_cancel": "Abonnement gekündigt",
     "subscription_resume": "Abonnement fortgesetzt",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "Wiederkehrende Rechnung „{title}“ aktualisiert",
     "reminder_create": "Erinnerung „{title}“ erstellt",
     "reminder_delete": "Erinnerung {id} gelöscht",
+    "reminder_import": "{count, plural, one {# Erinnerung} other {# Erinnerungen}} aus {fileName} importiert",
     "role_create": "Rolle „{name}“ erstellt",
     "role_delete": "Rolle „{name}“ gelöscht",
     "role_update": "Rolle „{name}“ aktualisiert",

+ 74 - 1
messages/de/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Kunde (optional)",
   "noneOption": "Keiner",
   "workshopHint": "Kein Kunde oder Fahrzeug gewählt: dies ist eine allgemeine Werkstatt-Erinnerung.",
-  "workshopReminder": "Werkstatt"
+  "workshopReminder": "Werkstatt",
+  "import": {
+    "button": "Importieren",
+    "title": "Erinnerungen importieren",
+    "description": "Laden Sie eine CSV- oder Excel-Datei mit einer Erinnerung pro Zeile hoch. Sie sehen jede Zeile, bevor etwas gespeichert wird.",
+    "drop": "CSV- oder Excel-Datei hier ablegen",
+    "formats": "CSV oder Excel (.xlsx), bis zu {rows} Zeilen",
+    "browse": "Datei auswählen",
+    "reading": "Datei wird gelesen...",
+    "templateHint": "Beginnen Sie mit der Vorlage, damit die Spalten stimmen.",
+    "templateXlsx": "Excel-Vorlage",
+    "templateCsv": "CSV-Vorlage",
+    "columnsTitle": "Spalten in der Datei",
+    "required": {
+      "yes": "Pflichtfeld",
+      "dateOrMileage": "Datum oder Kilometerstand",
+      "no": "Optional"
+    },
+    "columns": {
+      "title": "Was zu tun ist, zum Beispiel „Ölwechsel“.",
+      "dueDate": "Der Tag der Fälligkeit. 2026-10-15 funktioniert immer, Formate wie 15.10.2026 oder 10/15/2026 werden erkannt.",
+      "dueTime": "Uhrzeit, zum Beispiel 14:30, in der Zeitzone der Werkstatt. Leer lassen für eine beliebige Uhrzeit an diesem Tag.",
+      "dueMileage": "Der Tachostand, bei dem die Erinnerung fällig ist, in {unit}.",
+      "description": "Weitere Details.",
+      "licensePlate": "Verknüpft die Erinnerung mit einem Fahrzeug. Leerzeichen und Bindestriche werden ignoriert.",
+      "vin": "Verknüpft die Erinnerung mit einem Fahrzeug. Praktisch, wenn kein Kennzeichen vorhanden ist.",
+      "customer": "E-Mail, Kundennummer oder exakter Name. Wird nur verwendet, wenn die Zeile kein Fahrzeug hat.",
+      "emailNotification": "Ja oder nein. Sendet eine E-Mail, wenn die Erinnerung fällig ist. In-App-Benachrichtigungen sind immer aktiv."
+    },
+    "targetRule": "Eine Zeile ohne Fahrzeug und ohne Kunde wird zur Werkstatt-Erinnerung. Zeilen, die einer offenen Erinnerung entsprechen, werden übersprungen. Dieselbe Datei zweimal hochzuladen fügt also nichts hinzu.",
+    "summary": {
+      "ready": "Bereit: {count}",
+      "duplicates": "Bereits vorhanden: {count}",
+      "errors": "Zu korrigieren: {count}"
+    },
+    "ignoredColumns": "Nicht gelesene Spalten: {columns}",
+    "rowLabel": "Zeile {line}",
+    "untitled": "(ohne Titel)",
+    "issues": {
+      "missingTitle": "Der Titel ist leer.",
+      "missingDue": "Benötigt ein Fälligkeitsdatum oder einen Fälligkeitskilometerstand.",
+      "badDate": "Das Datum „{value}“ konnte nicht gelesen werden.",
+      "badTime": "Die Uhrzeit „{value}“ konnte nicht gelesen werden.",
+      "timeWithoutDate": "Eine Uhrzeit braucht ein Fälligkeitsdatum.",
+      "badMileage": "Der Kilometerstand „{value}“ konnte nicht gelesen werden.",
+      "badEmailNotification": "E-Mail-Benachrichtigung muss ja oder nein sein, nicht „{value}“.",
+      "vehicleNotFound": "Kein Fahrzeug passt zu „{value}“.",
+      "vehicleAmbiguous": "Mehr als ein Fahrzeug passt zu „{value}“.",
+      "vehicleMismatch": "Kennzeichen und FIN gehören zu verschiedenen Fahrzeugen ({value}).",
+      "customerNotFound": "Kein Kunde passt zu „{value}“.",
+      "customerAmbiguous": "Mehr als ein Kunde passt zu „{value}“. Verwenden Sie die E-Mail-Adresse oder die Kundennummer.",
+      "duplicateExisting": "Übersprungen: Diese Erinnerung existiert bereits.",
+      "duplicateInFile": "Übersprungen: wiederholt eine frühere Zeile der Datei."
+    },
+    "fileErrors": {
+      "demo": "Der Import ist in der Demo deaktiviert.",
+      "noFile": "Wählen Sie eine Datei zum Hochladen.",
+      "tooLarge": "Die Datei ist größer als 900 KB. Teilen Sie sie auf und importieren Sie sie in Teilen.",
+      "unsupported": "Laden Sie eine .csv- oder .xlsx-Datei hoch.",
+      "legacyXls": "Alte Excel-Dateien im Format .xls können nicht gelesen werden. Speichern Sie die Datei als .xlsx oder .csv und versuchen Sie es erneut.",
+      "empty": "Die Datei enthält keine Zeilen.",
+      "unreadable": "Die Datei konnte nicht gelesen werden.",
+      "tooManyRows": "Die Datei hat mehr als {limit} Zeilen. Teilen Sie sie auf und importieren Sie sie in Teilen.",
+      "noTitleColumn": "Die Datei hat keine Spalte „Title“. Laden Sie die Vorlage herunter, um die erwarteten Spaltenüberschriften zu sehen."
+    },
+    "chooseAnother": "Andere Datei wählen",
+    "downloadSkipped": "Übersprungene Zeilen herunterladen",
+    "problemColumn": "Problem",
+    "submit": "{count, plural, one {# Erinnerung} other {# Erinnerungen}} importieren",
+    "nothingToImport": "Nichts zu importieren",
+    "done": "{count, plural, one {# Erinnerung importiert} other {# Erinnerungen importiert}}",
+    "doneSkipped": "{count, plural, one {# Zeile übersprungen} other {# Zeilen übersprungen}}",
+    "close": "Schließen"
+  }
 }

+ 2 - 0
messages/en/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Deleted Note",
     "reminder_create": "Created Reminder",
     "reminder_delete": "Deleted Reminder",
+    "reminder_import": "Imported Reminders",
     "organization_create": "Created Organization",
     "subscription_cancel": "Cancelled Subscription",
     "subscription_resume": "Resumed Subscription",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "Updated recurring invoice \"{title}\"",
     "reminder_create": "Created reminder \"{title}\"",
     "reminder_delete": "Deleted reminder {id}",
+    "reminder_import": "Imported {count, plural, one {# reminder} other {# reminders}} from {fileName}",
     "role_create": "Created role \"{name}\"",
     "role_delete": "Deleted role \"{name}\"",
     "role_update": "Updated role \"{name}\"",

+ 74 - 1
messages/en/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Customer (optional)",
   "noneOption": "None",
   "workshopHint": "No customer or vehicle selected: this is a general workshop reminder.",
-  "workshopReminder": "Workshop"
+  "workshopReminder": "Workshop",
+  "import": {
+    "button": "Import",
+    "title": "Import reminders",
+    "description": "Upload a CSV or Excel file with one reminder per row. You will see every row before anything is saved.",
+    "drop": "Drop a CSV or Excel file here",
+    "formats": "CSV or Excel (.xlsx), up to {rows} rows",
+    "browse": "Choose file",
+    "reading": "Reading the file...",
+    "templateHint": "Start from the template to get the columns right.",
+    "templateXlsx": "Excel template",
+    "templateCsv": "CSV template",
+    "columnsTitle": "Columns in the file",
+    "required": {
+      "yes": "Required",
+      "dateOrMileage": "Date or mileage",
+      "no": "Optional"
+    },
+    "columns": {
+      "title": "What needs doing, for example \"Oil change\".",
+      "dueDate": "The day it is due. 2026-10-15 always works, and formats like 15.10.2026 or 10/15/2026 are detected.",
+      "dueTime": "Time of day, for example 14:30, in the workshop's time zone. Leave it empty for any time that day.",
+      "dueMileage": "The odometer reading it is due at, in {unit}.",
+      "description": "Any extra detail.",
+      "licensePlate": "Links the reminder to a vehicle. Spaces and dashes are ignored.",
+      "vin": "Links the reminder to a vehicle, useful when there is no plate.",
+      "customer": "Email, customer number or exact name. Used only when the row has no vehicle.",
+      "emailNotification": "Yes or no. Sends an email when the reminder is due. In-app notifications are always on."
+    },
+    "targetRule": "A row with no vehicle and no customer becomes a workshop reminder. Rows that match an open reminder are skipped, so uploading the same file twice adds nothing.",
+    "summary": {
+      "ready": "{count} ready",
+      "duplicates": "{count} already exist",
+      "errors": "{count} need fixing"
+    },
+    "ignoredColumns": "Columns not read: {columns}",
+    "rowLabel": "Row {line}",
+    "untitled": "(no title)",
+    "issues": {
+      "missingTitle": "The title is empty.",
+      "missingDue": "Needs a due date or a due mileage.",
+      "badDate": "Could not read the date \"{value}\".",
+      "badTime": "Could not read the time \"{value}\".",
+      "timeWithoutDate": "A time needs a due date.",
+      "badMileage": "Could not read the mileage \"{value}\".",
+      "badEmailNotification": "Email notification should be yes or no, not \"{value}\".",
+      "vehicleNotFound": "No vehicle matches \"{value}\".",
+      "vehicleAmbiguous": "More than one vehicle matches \"{value}\".",
+      "vehicleMismatch": "The plate and the VIN belong to different vehicles ({value}).",
+      "customerNotFound": "No customer matches \"{value}\".",
+      "customerAmbiguous": "More than one customer matches \"{value}\". Use their email or customer number.",
+      "duplicateExisting": "Skipped: this reminder already exists.",
+      "duplicateInFile": "Skipped: repeats an earlier row in the file."
+    },
+    "fileErrors": {
+      "demo": "Importing is turned off on the demo.",
+      "noFile": "Choose a file to upload.",
+      "tooLarge": "The file is larger than 900 KB. Split it and import it in parts.",
+      "unsupported": "Upload a .csv or .xlsx file.",
+      "legacyXls": "Old Excel .xls files can't be read. Save the file as .xlsx or .csv and try again.",
+      "empty": "The file has no rows.",
+      "unreadable": "The file could not be read.",
+      "tooManyRows": "The file has more than {limit} rows. Split it and import it in parts.",
+      "noTitleColumn": "The file has no Title column. Download the template to see the headers it expects."
+    },
+    "chooseAnother": "Choose another file",
+    "downloadSkipped": "Download skipped rows",
+    "problemColumn": "Problem",
+    "submit": "Import {count, plural, one {# reminder} other {# reminders}}",
+    "nothingToImport": "Nothing to import",
+    "done": "{count, plural, one {# reminder imported} other {# reminders imported}}",
+    "doneSkipped": "{count, plural, one {# row skipped} other {# rows skipped}}",
+    "close": "Close"
+  }
 }

+ 2 - 0
messages/es/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Nota eliminada",
     "reminder_create": "Recordatorio creado",
     "reminder_delete": "Recordatorio eliminado",
+    "reminder_import": "Recordatorios importados",
     "organization_create": "Organización creada",
     "subscription_cancel": "Suscripción cancelada",
     "subscription_resume": "Suscripción reanudada",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "Factura recurrente «{title}» actualizada",
     "reminder_create": "Recordatorio «{title}» creado",
     "reminder_delete": "Recordatorio {id} eliminado",
+    "reminder_import": "{count, plural, one {# recordatorio importado} other {# recordatorios importados}} desde {fileName}",
     "role_create": "Rol «{name}» creado",
     "role_delete": "Rol «{name}» eliminado",
     "role_update": "Rol «{name}» actualizado",

+ 74 - 1
messages/es/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Cliente (opcional)",
   "noneOption": "Ninguno",
   "workshopHint": "Sin cliente ni vehículo: este es un recordatorio general del taller.",
-  "workshopReminder": "Taller"
+  "workshopReminder": "Taller",
+  "import": {
+    "button": "Importar",
+    "title": "Importar recordatorios",
+    "description": "Suba un archivo CSV o Excel con un recordatorio por fila. Verá cada fila antes de que se guarde nada.",
+    "drop": "Suelte aquí un archivo CSV o Excel",
+    "formats": "CSV o Excel (.xlsx), hasta {rows} filas",
+    "browse": "Elegir archivo",
+    "reading": "Leyendo el archivo...",
+    "templateHint": "Empiece con la plantilla para que las columnas sean correctas.",
+    "templateXlsx": "Plantilla de Excel",
+    "templateCsv": "Plantilla CSV",
+    "columnsTitle": "Columnas del archivo",
+    "required": {
+      "yes": "Obligatoria",
+      "dateOrMileage": "Fecha o kilometraje",
+      "no": "Opcional"
+    },
+    "columns": {
+      "title": "Qué hay que hacer, por ejemplo «Cambio de aceite».",
+      "dueDate": "El día en que vence. 2026-10-15 siempre funciona, y se detectan formatos como 15.10.2026 o 10/15/2026.",
+      "dueTime": "Hora del día, por ejemplo 14:30, en la zona horaria del taller. Déjela vacía para cualquier hora de ese día.",
+      "dueMileage": "La lectura del cuentakilómetros en la que vence, en {unit}.",
+      "description": "Cualquier detalle adicional.",
+      "licensePlate": "Vincula el recordatorio a un vehículo. Se ignoran los espacios y los guiones.",
+      "vin": "Vincula el recordatorio a un vehículo. Útil cuando no hay matrícula.",
+      "customer": "Correo electrónico, número de cliente o nombre exacto. Solo se usa si la fila no tiene vehículo.",
+      "emailNotification": "Sí o no. Envía un correo electrónico cuando vence el recordatorio. Las notificaciones en la app están siempre activas."
+    },
+    "targetRule": "Una fila sin vehículo ni cliente se convierte en un recordatorio del taller. Las filas que coinciden con un recordatorio abierto se omiten, así que subir el mismo archivo dos veces no añade nada.",
+    "summary": {
+      "ready": "Listos: {count}",
+      "duplicates": "Ya existen: {count}",
+      "errors": "Por corregir: {count}"
+    },
+    "ignoredColumns": "Columnas no leídas: {columns}",
+    "rowLabel": "Fila {line}",
+    "untitled": "(sin título)",
+    "issues": {
+      "missingTitle": "El título está vacío.",
+      "missingDue": "Necesita una fecha de vencimiento o un kilometraje de vencimiento.",
+      "badDate": "No se pudo leer la fecha «{value}».",
+      "badTime": "No se pudo leer la hora «{value}».",
+      "timeWithoutDate": "Una hora necesita una fecha de vencimiento.",
+      "badMileage": "No se pudo leer el kilometraje «{value}».",
+      "badEmailNotification": "La notificación por correo debe ser sí o no, no «{value}».",
+      "vehicleNotFound": "Ningún vehículo coincide con «{value}».",
+      "vehicleAmbiguous": "Más de un vehículo coincide con «{value}».",
+      "vehicleMismatch": "La matrícula y el VIN pertenecen a vehículos distintos ({value}).",
+      "customerNotFound": "Ningún cliente coincide con «{value}».",
+      "customerAmbiguous": "Más de un cliente coincide con «{value}». Use su correo electrónico o su número de cliente.",
+      "duplicateExisting": "Omitida: este recordatorio ya existe.",
+      "duplicateInFile": "Omitida: repite una fila anterior del archivo."
+    },
+    "fileErrors": {
+      "demo": "La importación está desactivada en la demo.",
+      "noFile": "Elija un archivo para subir.",
+      "tooLarge": "El archivo supera los 900 KB. Divídalo e impórtelo por partes.",
+      "unsupported": "Suba un archivo .csv o .xlsx.",
+      "legacyXls": "Los archivos antiguos de Excel .xls no se pueden leer. Guarde el archivo como .xlsx o .csv e inténtelo de nuevo.",
+      "empty": "El archivo no tiene filas.",
+      "unreadable": "No se pudo leer el archivo.",
+      "tooManyRows": "El archivo tiene más de {limit} filas. Divídalo e impórtelo por partes.",
+      "noTitleColumn": "El archivo no tiene una columna «Title». Descargue la plantilla para ver los encabezados esperados."
+    },
+    "chooseAnother": "Elegir otro archivo",
+    "downloadSkipped": "Descargar filas omitidas",
+    "problemColumn": "Problema",
+    "submit": "Importar {count, plural, one {# recordatorio} other {# recordatorios}}",
+    "nothingToImport": "Nada que importar",
+    "done": "{count, plural, one {# recordatorio importado} other {# recordatorios importados}}",
+    "doneSkipped": "{count, plural, one {# fila omitida} other {# filas omitidas}}",
+    "close": "Cerrar"
+  }
 }

+ 2 - 0
messages/fr/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Note supprimée",
     "reminder_create": "Rappel créé",
     "reminder_delete": "Rappel supprimé",
+    "reminder_import": "Rappels importés",
     "organization_create": "Organisation créée",
     "subscription_cancel": "Abonnement annulé",
     "subscription_resume": "Abonnement repris",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "Facture récurrente « {title} » mise à jour",
     "reminder_create": "Rappel « {title} » créé",
     "reminder_delete": "Rappel {id} supprimé",
+    "reminder_import": "{count, plural, one {# rappel importé} other {# rappels importés}} depuis {fileName}",
     "role_create": "Rôle « {name} » créé",
     "role_delete": "Rôle « {name} » supprimé",
     "role_update": "Rôle « {name} » mis à jour",

+ 74 - 1
messages/fr/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Client (optionnel)",
   "noneOption": "Aucun",
   "workshopHint": "Aucun client ni véhicule sélectionné : rappel général pour l'atelier.",
-  "workshopReminder": "Atelier"
+  "workshopReminder": "Atelier",
+  "import": {
+    "button": "Importer",
+    "title": "Importer des rappels",
+    "description": "Téléversez un fichier CSV ou Excel avec un rappel par ligne. Vous verrez chaque ligne avant tout enregistrement.",
+    "drop": "Déposez un fichier CSV ou Excel ici",
+    "formats": "CSV ou Excel (.xlsx), jusqu'à {rows} lignes",
+    "browse": "Choisir un fichier",
+    "reading": "Lecture du fichier...",
+    "templateHint": "Partez du modèle pour avoir les bonnes colonnes.",
+    "templateXlsx": "Modèle Excel",
+    "templateCsv": "Modèle CSV",
+    "columnsTitle": "Colonnes du fichier",
+    "required": {
+      "yes": "Obligatoire",
+      "dateOrMileage": "Date ou kilométrage",
+      "no": "Facultatif"
+    },
+    "columns": {
+      "title": "Ce qu'il faut faire, par exemple « Vidange d'huile ».",
+      "dueDate": "Le jour de l'échéance. 2026-10-15 fonctionne toujours, et les formats comme 15.10.2026 ou 10/15/2026 sont détectés.",
+      "dueTime": "Heure de la journée, par exemple 14:30, dans le fuseau horaire de l'atelier. Laissez vide pour n'importe quelle heure ce jour-là.",
+      "dueMileage": "Le relevé du compteur à l'échéance, en {unit}.",
+      "description": "Tout détail supplémentaire.",
+      "licensePlate": "Associe le rappel à un véhicule. Les espaces et les tirets sont ignorés.",
+      "vin": "Associe le rappel à un véhicule, utile quand il n'y a pas de plaque.",
+      "customer": "E-mail, numéro de client ou nom exact. Utilisé uniquement si la ligne n'a pas de véhicule.",
+      "emailNotification": "Oui ou non. Envoie un e-mail à l'échéance du rappel. Les notifications dans l'app sont toujours actives."
+    },
+    "targetRule": "Une ligne sans véhicule ni client devient un rappel de l'atelier. Les lignes qui correspondent à un rappel ouvert sont ignorées : importer deux fois le même fichier n'ajoute donc rien.",
+    "summary": {
+      "ready": "Prêts : {count}",
+      "duplicates": "Déjà existants : {count}",
+      "errors": "À corriger : {count}"
+    },
+    "ignoredColumns": "Colonnes non lues : {columns}",
+    "rowLabel": "Ligne {line}",
+    "untitled": "(sans titre)",
+    "issues": {
+      "missingTitle": "Le titre est vide.",
+      "missingDue": "Une date d'échéance ou un kilométrage d'échéance est nécessaire.",
+      "badDate": "Impossible de lire la date « {value} ».",
+      "badTime": "Impossible de lire l'heure « {value} ».",
+      "timeWithoutDate": "Une heure nécessite une date d'échéance.",
+      "badMileage": "Impossible de lire le kilométrage « {value} ».",
+      "badEmailNotification": "La notification par e-mail doit être oui ou non, pas « {value} ».",
+      "vehicleNotFound": "Aucun véhicule ne correspond à « {value} ».",
+      "vehicleAmbiguous": "Plusieurs véhicules correspondent à « {value} ».",
+      "vehicleMismatch": "La plaque et le NIV appartiennent à des véhicules différents ({value}).",
+      "customerNotFound": "Aucun client ne correspond à « {value} ».",
+      "customerAmbiguous": "Plusieurs clients correspondent à « {value} ». Utilisez son e-mail ou son numéro de client.",
+      "duplicateExisting": "Ignorée : ce rappel existe déjà.",
+      "duplicateInFile": "Ignorée : répète une ligne précédente du fichier."
+    },
+    "fileErrors": {
+      "demo": "L'import est désactivé sur la démo.",
+      "noFile": "Choisissez un fichier à téléverser.",
+      "tooLarge": "Le fichier dépasse 900 KB. Divisez-le et importez-le en plusieurs fois.",
+      "unsupported": "Téléversez un fichier .csv ou .xlsx.",
+      "legacyXls": "Les anciens fichiers Excel .xls ne peuvent pas être lus. Enregistrez le fichier en .xlsx ou .csv et réessayez.",
+      "empty": "Le fichier ne contient aucune ligne.",
+      "unreadable": "Impossible de lire le fichier.",
+      "tooManyRows": "Le fichier contient plus de {limit} lignes. Divisez-le et importez-le en plusieurs fois.",
+      "noTitleColumn": "Le fichier n'a pas de colonne « Title ». Téléchargez le modèle pour voir les en-têtes attendus."
+    },
+    "chooseAnother": "Choisir un autre fichier",
+    "downloadSkipped": "Télécharger les lignes ignorées",
+    "problemColumn": "Problème",
+    "submit": "Importer {count, plural, one {# rappel} other {# rappels}}",
+    "nothingToImport": "Rien à importer",
+    "done": "{count, plural, one {# rappel importé} other {# rappels importés}}",
+    "doneSkipped": "{count, plural, one {# ligne ignorée} other {# lignes ignorées}}",
+    "close": "Fermer"
+  }
 }

+ 2 - 0
messages/it/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Nota eliminata",
     "reminder_create": "Promemoria creato",
     "reminder_delete": "Promemoria eliminato",
+    "reminder_import": "Promemoria importati",
     "organization_create": "Organizzazione creata",
     "subscription_cancel": "Abbonamento annullato",
     "subscription_resume": "Abbonamento ripreso",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "Fattura ricorrente «{title}» aggiornata",
     "reminder_create": "Promemoria «{title}» creato",
     "reminder_delete": "Promemoria {id} eliminato",
+    "reminder_import": "{count, plural, one {# promemoria importato} other {# promemoria importati}} da {fileName}",
     "role_create": "Ruolo «{name}» creato",
     "role_delete": "Ruolo «{name}» eliminato",
     "role_update": "Ruolo «{name}» aggiornato",

+ 74 - 1
messages/it/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Cliente (opzionale)",
   "noneOption": "Nessuno",
   "workshopHint": "Nessun cliente o veicolo selezionato: promemoria generale dell'officina.",
-  "workshopReminder": "Officina"
+  "workshopReminder": "Officina",
+  "import": {
+    "button": "Importa",
+    "title": "Importa promemoria",
+    "description": "Carica un file CSV o Excel con un promemoria per riga. Vedrai ogni riga prima che venga salvato qualcosa.",
+    "drop": "Trascina qui un file CSV o Excel",
+    "formats": "CSV o Excel (.xlsx), fino a {rows} righe",
+    "browse": "Scegli file",
+    "reading": "Lettura del file...",
+    "templateHint": "Parti dal modello per avere le colonne giuste.",
+    "templateXlsx": "Modello Excel",
+    "templateCsv": "Modello CSV",
+    "columnsTitle": "Colonne nel file",
+    "required": {
+      "yes": "Obbligatoria",
+      "dateOrMileage": "Data o chilometraggio",
+      "no": "Facoltativa"
+    },
+    "columns": {
+      "title": "Cosa va fatto, per esempio «Cambio olio».",
+      "dueDate": "Il giorno di scadenza. 2026-10-15 funziona sempre, e formati come 15.10.2026 o 10/15/2026 vengono riconosciuti.",
+      "dueTime": "Ora del giorno, per esempio 14:30, nel fuso orario dell'officina. Lasciala vuota per un orario qualsiasi di quel giorno.",
+      "dueMileage": "La lettura del contachilometri alla scadenza, in {unit}.",
+      "description": "Eventuali dettagli aggiuntivi.",
+      "licensePlate": "Collega il promemoria a un veicolo. Spazi e trattini vengono ignorati.",
+      "vin": "Collega il promemoria a un veicolo, utile quando manca la targa.",
+      "customer": "Email, numero cliente o nome esatto. Usato solo se la riga non ha un veicolo.",
+      "emailNotification": "Sì o no. Invia un'email alla scadenza del promemoria. Le notifiche in app sono sempre attive."
+    },
+    "targetRule": "Una riga senza veicolo e senza cliente diventa un promemoria dell'officina. Le righe che corrispondono a un promemoria aperto vengono saltate, quindi caricare due volte lo stesso file non aggiunge nulla.",
+    "summary": {
+      "ready": "Pronti: {count}",
+      "duplicates": "Già presenti: {count}",
+      "errors": "Da correggere: {count}"
+    },
+    "ignoredColumns": "Colonne non lette: {columns}",
+    "rowLabel": "Riga {line}",
+    "untitled": "(senza titolo)",
+    "issues": {
+      "missingTitle": "Il titolo è vuoto.",
+      "missingDue": "Serve una data di scadenza o un chilometraggio di scadenza.",
+      "badDate": "Impossibile leggere la data «{value}».",
+      "badTime": "Impossibile leggere l'ora «{value}».",
+      "timeWithoutDate": "Un orario richiede una data di scadenza.",
+      "badMileage": "Impossibile leggere il chilometraggio «{value}».",
+      "badEmailNotification": "La notifica email deve essere sì o no, non «{value}».",
+      "vehicleNotFound": "Nessun veicolo corrisponde a «{value}».",
+      "vehicleAmbiguous": "Più di un veicolo corrisponde a «{value}».",
+      "vehicleMismatch": "La targa e il VIN appartengono a veicoli diversi ({value}).",
+      "customerNotFound": "Nessun cliente corrisponde a «{value}».",
+      "customerAmbiguous": "Più di un cliente corrisponde a «{value}». Usa la sua email o il numero cliente.",
+      "duplicateExisting": "Saltata: questo promemoria esiste già.",
+      "duplicateInFile": "Saltata: ripete una riga precedente del file."
+    },
+    "fileErrors": {
+      "demo": "L'importazione è disattivata nella demo.",
+      "noFile": "Scegli un file da caricare.",
+      "tooLarge": "Il file supera 900 KB. Dividilo e importalo in più parti.",
+      "unsupported": "Carica un file .csv o .xlsx.",
+      "legacyXls": "I vecchi file Excel .xls non possono essere letti. Salva il file come .xlsx o .csv e riprova.",
+      "empty": "Il file non contiene righe.",
+      "unreadable": "Impossibile leggere il file.",
+      "tooManyRows": "Il file ha più di {limit} righe. Dividilo e importalo in più parti.",
+      "noTitleColumn": "Il file non ha una colonna «Title». Scarica il modello per vedere le intestazioni previste."
+    },
+    "chooseAnother": "Scegli un altro file",
+    "downloadSkipped": "Scarica le righe saltate",
+    "problemColumn": "Problema",
+    "submit": "Importa {count, plural, one {# promemoria} other {# promemoria}}",
+    "nothingToImport": "Niente da importare",
+    "done": "{count, plural, one {# promemoria importato} other {# promemoria importati}}",
+    "doneSkipped": "{count, plural, one {# riga saltata} other {# righe saltate}}",
+    "close": "Chiudi"
+  }
 }

+ 2 - 0
messages/lt/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Ištrinta pastaba",
     "reminder_create": "Sukurtas priminimas",
     "reminder_delete": "Ištrintas priminimas",
+    "reminder_import": "Importuoti priminimai",
     "organization_create": "Sukurta organizacija",
     "subscription_cancel": "Atšaukta prenumerata",
     "subscription_resume": "Atnaujinta prenumerata",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "Atnaujinta pasikartojanti sąskaita „{title}“",
     "reminder_create": "Sukurtas priminimas „{title}“",
     "reminder_delete": "Ištrintas priminimas {id}",
+    "reminder_import": "{count, plural, one {Importuotas # priminimas} few {Importuoti # priminimai} many {Importuota # priminimo} other {Importuota # priminimų}} iš {fileName}",
     "role_create": "Sukurta rolė „{name}“",
     "role_delete": "Ištrinta rolė „{name}“",
     "role_update": "Atnaujinta rolė „{name}“",

+ 74 - 1
messages/lt/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Klientas (neprivaloma)",
   "noneOption": "Nėra",
   "workshopHint": "Nepasirinktas klientas ar transporto priemonė: tai bendras dirbtuvių priminimas.",
-  "workshopReminder": "Dirbtuvės"
+  "workshopReminder": "Dirbtuvės",
+  "import": {
+    "button": "Importuoti",
+    "title": "Importuoti priminimus",
+    "description": "Įkelkite CSV arba Excel failą, kuriame kiekvienoje eilutėje yra vienas priminimas. Kiekvieną eilutę pamatysite prieš išsaugant.",
+    "drop": "Nutempkite CSV arba Excel failą čia",
+    "formats": "CSV arba Excel (.xlsx), iki {rows} eilučių",
+    "browse": "Pasirinkti failą",
+    "reading": "Skaitomas failas...",
+    "templateHint": "Pradėkite nuo šablono, kad stulpeliai būtų teisingi.",
+    "templateXlsx": "Excel šablonas",
+    "templateCsv": "CSV šablonas",
+    "columnsTitle": "Failo stulpeliai",
+    "required": {
+      "yes": "Privaloma",
+      "dateOrMileage": "Data arba rida",
+      "no": "Neprivaloma"
+    },
+    "columns": {
+      "title": "Ką reikia atlikti, pavyzdžiui, „Alyvos keitimas“.",
+      "dueDate": "Termino diena. 2026-10-15 tinka visada, o tokie formatai kaip 15.10.2026 ar 10/15/2026 atpažįstami automatiškai.",
+      "dueTime": "Paros laikas, pavyzdžiui, 14:30, pagal dirbtuvių laiko juostą. Palikite tuščią, jei tinka bet kuris tos dienos laikas.",
+      "dueMileage": "Ridos rodmuo, kuriam esant sueina terminas, {unit}.",
+      "description": "Bet kokia papildoma informacija.",
+      "licensePlate": "Susieja priminimą su transporto priemone. Tarpai ir brūkšneliai ignoruojami.",
+      "vin": "Susieja priminimą su transporto priemone. Naudinga, kai nėra valstybinio numerio.",
+      "customer": "El. paštas, kliento numeris arba tikslus vardas. Naudojama tik tada, kai eilutėje nėra transporto priemonės.",
+      "emailNotification": "Taip arba ne. Suėjus priminimo terminui išsiunčiamas el. laiškas. Pranešimai programoje įjungti visada."
+    },
+    "targetRule": "Eilutė be transporto priemonės ir be kliento tampa dirbtuvių priminimu. Eilutės, sutampančios su atviru priminimu, praleidžiamos, todėl tą patį failą įkėlus du kartus nieko nepridedama.",
+    "summary": {
+      "ready": "Paruošta: {count}",
+      "duplicates": "Jau yra: {count}",
+      "errors": "Reikia pataisyti: {count}"
+    },
+    "ignoredColumns": "Neskaityti stulpeliai: {columns}",
+    "rowLabel": "Eilutė {line}",
+    "untitled": "(be pavadinimo)",
+    "issues": {
+      "missingTitle": "Pavadinimas tuščias.",
+      "missingDue": "Reikia termino datos arba termino ridos.",
+      "badDate": "Nepavyko nuskaityti datos „{value}“.",
+      "badTime": "Nepavyko nuskaityti laiko „{value}“.",
+      "timeWithoutDate": "Nurodžius laiką, reikia ir termino datos.",
+      "badMileage": "Nepavyko nuskaityti ridos „{value}“.",
+      "badEmailNotification": "El. pašto pranešimas turi būti taip arba ne, o ne „{value}“.",
+      "vehicleNotFound": "Jokia transporto priemonė neatitinka „{value}“.",
+      "vehicleAmbiguous": "„{value}“ atitinka daugiau nei viena transporto priemonė.",
+      "vehicleMismatch": "Valstybinis numeris ir VIN priklauso skirtingoms transporto priemonėms ({value}).",
+      "customerNotFound": "Joks klientas neatitinka „{value}“.",
+      "customerAmbiguous": "„{value}“ atitinka daugiau nei vienas klientas. Naudokite kliento el. paštą arba kliento numerį.",
+      "duplicateExisting": "Praleista: toks priminimas jau yra.",
+      "duplicateInFile": "Praleista: kartoja ankstesnę failo eilutę."
+    },
+    "fileErrors": {
+      "demo": "Demonstracinėje versijoje importavimas išjungtas.",
+      "noFile": "Pasirinkite failą įkėlimui.",
+      "tooLarge": "Failas didesnis nei 900 KB. Padalykite jį ir importuokite dalimis.",
+      "unsupported": "Įkelkite .csv arba .xlsx failą.",
+      "legacyXls": "Senų Excel .xls failų nuskaityti negalima. Išsaugokite failą kaip .xlsx arba .csv ir bandykite dar kartą.",
+      "empty": "Faile nėra eilučių.",
+      "unreadable": "Nepavyko nuskaityti failo.",
+      "tooManyRows": "Faile yra daugiau nei {limit} eilučių. Padalykite jį ir importuokite dalimis.",
+      "noTitleColumn": "Faile nėra stulpelio „Title“. Atsisiųskite šabloną ir pažiūrėkite, kokių antraščių tikimasi."
+    },
+    "chooseAnother": "Pasirinkti kitą failą",
+    "downloadSkipped": "Atsisiųsti praleistas eilutes",
+    "problemColumn": "Problema",
+    "submit": "Importuoti {count, plural, one {# priminimą} few {# priminimus} many {# priminimo} other {# priminimų}}",
+    "nothingToImport": "Nėra ką importuoti",
+    "done": "{count, plural, one {Importuotas # priminimas} few {Importuoti # priminimai} many {Importuota # priminimo} other {Importuota # priminimų}}",
+    "doneSkipped": "{count, plural, one {Praleista # eilutė} few {Praleistos # eilutės} many {Praleista # eilutės} other {Praleista # eilučių}}",
+    "close": "Uždaryti"
+  }
 }

+ 2 - 0
messages/nb/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Slettet notat",
     "reminder_create": "Opprettet påminnelse",
     "reminder_delete": "Slettet påminnelse",
+    "reminder_import": "Importerte påminnelser",
     "organization_create": "Opprettet organisasjon",
     "subscription_cancel": "Kansellerte abonnement",
     "subscription_resume": "Gjenopptok abonnement",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "Oppdaterte gjentakende faktura «{title}»",
     "reminder_create": "Opprettet påminnelse «{title}»",
     "reminder_delete": "Slettet påminnelse {id}",
+    "reminder_import": "Importerte {count, plural, one {# påminnelse} other {# påminnelser}} fra {fileName}",
     "role_create": "Opprettet rollen «{name}»",
     "role_delete": "Slettet rollen «{name}»",
     "role_update": "Oppdaterte rollen «{name}»",

+ 74 - 1
messages/nb/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Kunde (valgfritt)",
   "noneOption": "Ingen",
   "workshopHint": "Ingen kunde eller kjøretøy valgt: dette er en generell verkstedpåminnelse.",
-  "workshopReminder": "Verksted"
+  "workshopReminder": "Verksted",
+  "import": {
+    "button": "Importer",
+    "title": "Importer påminnelser",
+    "description": "Last opp en CSV- eller Excel-fil med én påminnelse per rad. Du ser alle radene før noe blir lagret.",
+    "drop": "Slipp en CSV- eller Excel-fil her",
+    "formats": "CSV eller Excel (.xlsx), opptil {rows} rader",
+    "browse": "Velg fil",
+    "reading": "Leser filen...",
+    "templateHint": "Start med malen, så blir kolonnene riktige.",
+    "templateXlsx": "Excel-mal",
+    "templateCsv": "CSV-mal",
+    "columnsTitle": "Kolonner i filen",
+    "required": {
+      "yes": "Påkrevd",
+      "dateOrMileage": "Dato eller kilometerstand",
+      "no": "Valgfri"
+    },
+    "columns": {
+      "title": "Hva som skal gjøres, for eksempel «Oljeskift».",
+      "dueDate": "Dagen den forfaller. 2026-10-15 fungerer alltid, og formater som 15.10.2026 eller 10/15/2026 blir gjenkjent.",
+      "dueTime": "Klokkeslett, for eksempel 14:30, i verkstedets tidssone. La feltet stå tomt for når som helst den dagen.",
+      "dueMileage": "Kilometerstanden den forfaller ved, i {unit}.",
+      "description": "Eventuelle ekstra detaljer.",
+      "licensePlate": "Knytter påminnelsen til et kjøretøy. Mellomrom og bindestreker ignoreres.",
+      "vin": "Knytter påminnelsen til et kjøretøy, nyttig når det ikke finnes skilt.",
+      "customer": "E-post, kundenummer eller nøyaktig navn. Brukes bare når raden ikke har kjøretøy.",
+      "emailNotification": "Ja eller nei. Sender en e-post når påminnelsen forfaller. Varsler i appen er alltid på."
+    },
+    "targetRule": "En rad uten kjøretøy og uten kunde blir en verkstedpåminnelse. Rader som samsvarer med en åpen påminnelse hoppes over, så å laste opp samme fil to ganger legger ikke til noe.",
+    "summary": {
+      "ready": "Klare: {count}",
+      "duplicates": "Finnes allerede: {count}",
+      "errors": "Må rettes: {count}"
+    },
+    "ignoredColumns": "Kolonner som ikke leses: {columns}",
+    "rowLabel": "Rad {line}",
+    "untitled": "(uten tittel)",
+    "issues": {
+      "missingTitle": "Tittelen er tom.",
+      "missingDue": "Trenger en forfallsdato eller en kilometerstand ved forfall.",
+      "badDate": "Kunne ikke lese datoen «{value}».",
+      "badTime": "Kunne ikke lese klokkeslettet «{value}».",
+      "timeWithoutDate": "Et klokkeslett krever en forfallsdato.",
+      "badMileage": "Kunne ikke lese kilometerstanden «{value}».",
+      "badEmailNotification": "E-postvarsel skal være ja eller nei, ikke «{value}».",
+      "vehicleNotFound": "Ingen kjøretøy samsvarer med «{value}».",
+      "vehicleAmbiguous": "Mer enn ett kjøretøy samsvarer med «{value}».",
+      "vehicleMismatch": "Skiltet og VIN hører til forskjellige kjøretøy ({value}).",
+      "customerNotFound": "Ingen kunde samsvarer med «{value}».",
+      "customerAmbiguous": "Mer enn én kunde samsvarer med «{value}». Bruk e-posten eller kundenummeret.",
+      "duplicateExisting": "Hoppet over: denne påminnelsen finnes allerede.",
+      "duplicateInFile": "Hoppet over: gjentar en tidligere rad i filen."
+    },
+    "fileErrors": {
+      "demo": "Import er slått av i demoen.",
+      "noFile": "Velg en fil å laste opp.",
+      "tooLarge": "Filen er større enn 900 KB. Del den opp og importer den i flere omganger.",
+      "unsupported": "Last opp en .csv- eller .xlsx-fil.",
+      "legacyXls": "Gamle Excel-filer i .xls-format kan ikke leses. Lagre filen som .xlsx eller .csv og prøv igjen.",
+      "empty": "Filen har ingen rader.",
+      "unreadable": "Filen kunne ikke leses.",
+      "tooManyRows": "Filen har mer enn {limit} rader. Del den opp og importer den i flere omganger.",
+      "noTitleColumn": "Filen har ingen «Title»-kolonne. Last ned malen for å se hvilke overskrifter den forventer."
+    },
+    "chooseAnother": "Velg en annen fil",
+    "downloadSkipped": "Last ned rader som ble hoppet over",
+    "problemColumn": "Problem",
+    "submit": "Importer {count, plural, one {# påminnelse} other {# påminnelser}}",
+    "nothingToImport": "Ingenting å importere",
+    "done": "{count, plural, one {# påminnelse importert} other {# påminnelser importert}}",
+    "doneSkipped": "{count, plural, one {# rad hoppet over} other {# rader hoppet over}}",
+    "close": "Lukk"
+  }
 }

+ 2 - 0
messages/nl/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Notitie verwijderd",
     "reminder_create": "Herinnering aangemaakt",
     "reminder_delete": "Herinnering verwijderd",
+    "reminder_import": "Herinneringen geïmporteerd",
     "organization_create": "Organisatie aangemaakt",
     "subscription_cancel": "Abonnement opgezegd",
     "subscription_resume": "Abonnement hervat",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "Terugkerende factuur \"{title}\" bijgewerkt",
     "reminder_create": "Herinnering \"{title}\" aangemaakt",
     "reminder_delete": "Herinnering {id} verwijderd",
+    "reminder_import": "{count, plural, one {# herinnering} other {# herinneringen}} geïmporteerd uit {fileName}",
     "role_create": "Rol \"{name}\" aangemaakt",
     "role_delete": "Rol \"{name}\" verwijderd",
     "role_update": "Rol \"{name}\" bijgewerkt",

+ 74 - 1
messages/nl/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Klant (optioneel)",
   "noneOption": "Geen",
   "workshopHint": "Geen klant of voertuig gekozen: dit is een algemene werkplaatsherinnering.",
-  "workshopReminder": "Werkplaats"
+  "workshopReminder": "Werkplaats",
+  "import": {
+    "button": "Importeren",
+    "title": "Herinneringen importeren",
+    "description": "Upload een CSV- of Excel-bestand met één herinnering per rij. U ziet elke rij voordat er iets wordt opgeslagen.",
+    "drop": "Sleep een CSV- of Excel-bestand hierheen",
+    "formats": "CSV of Excel (.xlsx), tot {rows} rijen",
+    "browse": "Bestand kiezen",
+    "reading": "Bestand wordt gelezen...",
+    "templateHint": "Begin met het sjabloon, dan kloppen de kolommen.",
+    "templateXlsx": "Excel-sjabloon",
+    "templateCsv": "CSV-sjabloon",
+    "columnsTitle": "Kolommen in het bestand",
+    "required": {
+      "yes": "Verplicht",
+      "dateOrMileage": "Datum of kilometerstand",
+      "no": "Optioneel"
+    },
+    "columns": {
+      "title": "Wat er moet gebeuren, bijvoorbeeld \"Olie verversen\".",
+      "dueDate": "De dag waarop het vervalt. 2026-10-15 werkt altijd, en notaties als 15.10.2026 of 10/15/2026 worden herkend.",
+      "dueTime": "Tijdstip, bijvoorbeeld 14:30, in de tijdzone van de werkplaats. Laat leeg voor elk tijdstip op die dag.",
+      "dueMileage": "De kilometerstand waarop het vervalt, in {unit}.",
+      "description": "Eventuele extra details.",
+      "licensePlate": "Koppelt de herinnering aan een voertuig. Spaties en streepjes worden genegeerd.",
+      "vin": "Koppelt de herinnering aan een voertuig, handig als er geen kenteken is.",
+      "customer": "E-mail, klantnummer of exacte naam. Wordt alleen gebruikt als de rij geen voertuig heeft.",
+      "emailNotification": "Ja of nee. Stuurt een e-mail wanneer de herinnering vervalt. In-app meldingen staan altijd aan."
+    },
+    "targetRule": "Een rij zonder voertuig en zonder klant wordt een werkplaatsherinnering. Rijen die overeenkomen met een openstaande herinnering worden overgeslagen, dus hetzelfde bestand twee keer uploaden voegt niets toe.",
+    "summary": {
+      "ready": "Klaar: {count}",
+      "duplicates": "Bestaan al: {count}",
+      "errors": "Te corrigeren: {count}"
+    },
+    "ignoredColumns": "Niet gelezen kolommen: {columns}",
+    "rowLabel": "Rij {line}",
+    "untitled": "(geen titel)",
+    "issues": {
+      "missingTitle": "De titel is leeg.",
+      "missingDue": "Een vervaldatum of vervalkilometerstand is nodig.",
+      "badDate": "Kan de datum \"{value}\" niet lezen.",
+      "badTime": "Kan de tijd \"{value}\" niet lezen.",
+      "timeWithoutDate": "Een tijd heeft een vervaldatum nodig.",
+      "badMileage": "Kan de kilometerstand \"{value}\" niet lezen.",
+      "badEmailNotification": "E-mailmelding moet ja of nee zijn, niet \"{value}\".",
+      "vehicleNotFound": "Geen voertuig komt overeen met \"{value}\".",
+      "vehicleAmbiguous": "Meer dan één voertuig komt overeen met \"{value}\".",
+      "vehicleMismatch": "Het kenteken en het VIN horen bij verschillende voertuigen ({value}).",
+      "customerNotFound": "Geen klant komt overeen met \"{value}\".",
+      "customerAmbiguous": "Meer dan één klant komt overeen met \"{value}\". Gebruik het e-mailadres of klantnummer.",
+      "duplicateExisting": "Overgeslagen: deze herinnering bestaat al.",
+      "duplicateInFile": "Overgeslagen: herhaalt een eerdere rij in het bestand."
+    },
+    "fileErrors": {
+      "demo": "Importeren is uitgeschakeld in de demo.",
+      "noFile": "Kies een bestand om te uploaden.",
+      "tooLarge": "Het bestand is groter dan 900 KB. Splits het en importeer het in delen.",
+      "unsupported": "Upload een .csv- of .xlsx-bestand.",
+      "legacyXls": "Oude Excel-bestanden (.xls) kunnen niet worden gelezen. Sla het bestand op als .xlsx of .csv en probeer het opnieuw.",
+      "empty": "Het bestand bevat geen rijen.",
+      "unreadable": "Het bestand kon niet worden gelezen.",
+      "tooManyRows": "Het bestand heeft meer dan {limit} rijen. Splits het en importeer het in delen.",
+      "noTitleColumn": "Het bestand heeft geen kolom \"Title\". Download het sjabloon om te zien welke kolomkoppen worden verwacht."
+    },
+    "chooseAnother": "Ander bestand kiezen",
+    "downloadSkipped": "Overgeslagen rijen downloaden",
+    "problemColumn": "Probleem",
+    "submit": "{count, plural, one {# herinnering} other {# herinneringen}} importeren",
+    "nothingToImport": "Niets om te importeren",
+    "done": "{count, plural, one {# herinnering geïmporteerd} other {# herinneringen geïmporteerd}}",
+    "doneSkipped": "{count, plural, one {# rij overgeslagen} other {# rijen overgeslagen}}",
+    "close": "Sluiten"
+  }
 }

+ 2 - 0
messages/pl/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Usunięto notatkę",
     "reminder_create": "Utworzono przypomnienie",
     "reminder_delete": "Usunięto przypomnienie",
+    "reminder_import": "Zaimportowano przypomnienia",
     "organization_create": "Utworzono organizację",
     "subscription_cancel": "Anulowano subskrypcję",
     "subscription_resume": "Wznowiono subskrypcję",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "Zaktualizowano fakturę cykliczną „{title}”",
     "reminder_create": "Utworzono przypomnienie „{title}”",
     "reminder_delete": "Usunięto przypomnienie {id}",
+    "reminder_import": "Zaimportowano {count, plural, one {# przypomnienie} few {# przypomnienia} many {# przypomnień} other {# przypomnienia}} z {fileName}",
     "role_create": "Utworzono rolę „{name}”",
     "role_delete": "Usunięto rolę „{name}”",
     "role_update": "Zaktualizowano rolę „{name}”",

+ 74 - 1
messages/pl/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Klient (opcjonalnie)",
   "noneOption": "Brak",
   "workshopHint": "Nie wybrano klienta ani pojazdu: to ogólne przypomnienie warsztatu.",
-  "workshopReminder": "Warsztat"
+  "workshopReminder": "Warsztat",
+  "import": {
+    "button": "Importuj",
+    "title": "Importuj przypomnienia",
+    "description": "Prześlij plik CSV lub Excel z jednym przypomnieniem w każdym wierszu. Zobaczysz każdy wiersz, zanim cokolwiek zostanie zapisane.",
+    "drop": "Upuść tutaj plik CSV lub Excel",
+    "formats": "CSV lub Excel (.xlsx), do {rows} wierszy",
+    "browse": "Wybierz plik",
+    "reading": "Odczytywanie pliku...",
+    "templateHint": "Zacznij od szablonu, aby kolumny były poprawne.",
+    "templateXlsx": "Szablon Excel",
+    "templateCsv": "Szablon CSV",
+    "columnsTitle": "Kolumny w pliku",
+    "required": {
+      "yes": "Wymagane",
+      "dateOrMileage": "Data lub przebieg",
+      "no": "Opcjonalne"
+    },
+    "columns": {
+      "title": "Co trzeba zrobić, na przykład „Wymiana oleju”.",
+      "dueDate": "Dzień terminu. 2026-10-15 działa zawsze, a formaty takie jak 15.10.2026 lub 10/15/2026 są rozpoznawane.",
+      "dueTime": "Godzina, na przykład 14:30, w strefie czasowej warsztatu. Zostaw puste, jeśli pasuje dowolna pora tego dnia.",
+      "dueMileage": "Stan licznika, przy którym przypada termin, w {unit}.",
+      "description": "Dodatkowe szczegóły.",
+      "licensePlate": "Łączy przypomnienie z pojazdem. Spacje i łączniki są ignorowane.",
+      "vin": "Łączy przypomnienie z pojazdem. Przydatne, gdy nie ma numeru rejestracyjnego.",
+      "customer": "E-mail, numer klienta lub dokładna nazwa. Używane tylko wtedy, gdy wiersz nie ma pojazdu.",
+      "emailNotification": "Tak lub nie. Wysyła e-mail, gdy nadejdzie termin przypomnienia. Powiadomienia w aplikacji są zawsze włączone."
+    },
+    "targetRule": "Wiersz bez pojazdu i bez klienta staje się przypomnieniem warsztatu. Wiersze zgodne z otwartym przypomnieniem są pomijane, więc dwukrotne przesłanie tego samego pliku niczego nie doda.",
+    "summary": {
+      "ready": "Gotowe: {count}",
+      "duplicates": "Już istnieją: {count}",
+      "errors": "Do poprawy: {count}"
+    },
+    "ignoredColumns": "Nieodczytane kolumny: {columns}",
+    "rowLabel": "Wiersz {line}",
+    "untitled": "(bez tytułu)",
+    "issues": {
+      "missingTitle": "Tytuł jest pusty.",
+      "missingDue": "Potrzebna jest data terminu lub przebieg terminu.",
+      "badDate": "Nie udało się odczytać daty „{value}”.",
+      "badTime": "Nie udało się odczytać godziny „{value}”.",
+      "timeWithoutDate": "Godzina wymaga daty terminu.",
+      "badMileage": "Nie udało się odczytać przebiegu „{value}”.",
+      "badEmailNotification": "Powiadomienie e-mail musi mieć wartość tak lub nie, a nie „{value}”.",
+      "vehicleNotFound": "Żaden pojazd nie pasuje do „{value}”.",
+      "vehicleAmbiguous": "Do „{value}” pasuje więcej niż jeden pojazd.",
+      "vehicleMismatch": "Numer rejestracyjny i VIN należą do różnych pojazdów ({value}).",
+      "customerNotFound": "Żaden klient nie pasuje do „{value}”.",
+      "customerAmbiguous": "Do „{value}” pasuje więcej niż jeden klient. Użyj jego e-maila lub numeru klienta.",
+      "duplicateExisting": "Pominięto: to przypomnienie już istnieje.",
+      "duplicateInFile": "Pominięto: powtarza wcześniejszy wiersz w pliku."
+    },
+    "fileErrors": {
+      "demo": "Import jest wyłączony w wersji demo.",
+      "noFile": "Wybierz plik do przesłania.",
+      "tooLarge": "Plik jest większy niż 900 KB. Podziel go i zaimportuj w częściach.",
+      "unsupported": "Prześlij plik .csv lub .xlsx.",
+      "legacyXls": "Starych plików Excel .xls nie można odczytać. Zapisz plik jako .xlsx lub .csv i spróbuj ponownie.",
+      "empty": "Plik nie zawiera wierszy.",
+      "unreadable": "Nie udało się odczytać pliku.",
+      "tooManyRows": "Plik ma więcej niż {limit} wierszy. Podziel go i zaimportuj w częściach.",
+      "noTitleColumn": "W pliku nie ma kolumny „Title”. Pobierz szablon, aby zobaczyć oczekiwane nagłówki."
+    },
+    "chooseAnother": "Wybierz inny plik",
+    "downloadSkipped": "Pobierz pominięte wiersze",
+    "problemColumn": "Problem",
+    "submit": "Importuj {count, plural, one {# przypomnienie} few {# przypomnienia} many {# przypomnień} other {# przypomnienia}}",
+    "nothingToImport": "Nic do zaimportowania",
+    "done": "Zaimportowano {count, plural, one {# przypomnienie} few {# przypomnienia} many {# przypomnień} other {# przypomnienia}}",
+    "doneSkipped": "Pominięto {count, plural, one {# wiersz} few {# wiersze} many {# wierszy} other {# wiersza}}",
+    "close": "Zamknij"
+  }
 }

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

@@ -70,6 +70,7 @@
     "note_delete": "Nota excluída",
     "reminder_create": "Lembrete criado",
     "reminder_delete": "Lembrete excluído",
+    "reminder_import": "Lembretes importados",
     "organization_create": "Organização criada",
     "subscription_cancel": "Assinatura cancelada",
     "subscription_resume": "Assinatura retomada",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "Fatura recorrente \"{title}\" atualizada",
     "reminder_create": "Lembrete \"{title}\" criado",
     "reminder_delete": "Lembrete {id} excluído",
+    "reminder_import": "{count, plural, one {# lembrete importado} other {# lembretes importados}} de {fileName}",
     "role_create": "Função \"{name}\" criada",
     "role_delete": "Função \"{name}\" excluída",
     "role_update": "Função \"{name}\" atualizada",

+ 74 - 1
messages/pt-BR/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Cliente (opcional)",
   "noneOption": "Nenhum",
   "workshopHint": "Nenhum cliente ou veículo selecionado: este é um lembrete geral da oficina.",
-  "workshopReminder": "Oficina"
+  "workshopReminder": "Oficina",
+  "import": {
+    "button": "Importar",
+    "title": "Importar lembretes",
+    "description": "Envie um arquivo CSV ou Excel com um lembrete por linha. Você verá cada linha antes que qualquer coisa seja salva.",
+    "drop": "Solte aqui um arquivo CSV ou Excel",
+    "formats": "CSV ou Excel (.xlsx), até {rows} linhas",
+    "browse": "Escolher arquivo",
+    "reading": "Lendo o arquivo...",
+    "templateHint": "Comece pelo modelo para acertar as colunas.",
+    "templateXlsx": "Modelo Excel",
+    "templateCsv": "Modelo CSV",
+    "columnsTitle": "Colunas no arquivo",
+    "required": {
+      "yes": "Obrigatória",
+      "dateOrMileage": "Data ou quilometragem",
+      "no": "Opcional"
+    },
+    "columns": {
+      "title": "O que precisa ser feito, por exemplo \"Troca de óleo\".",
+      "dueDate": "O dia do vencimento. 2026-10-15 sempre funciona, e formatos como 15.10.2026 ou 10/15/2026 são detectados.",
+      "dueTime": "Horário do dia, por exemplo 14:30, no fuso horário da oficina. Deixe em branco para qualquer horário daquele dia.",
+      "dueMileage": "A leitura do hodômetro em que vence, em {unit}.",
+      "description": "Qualquer detalhe extra.",
+      "licensePlate": "Vincula o lembrete a um veículo. Espaços e hífens são ignorados.",
+      "vin": "Vincula o lembrete a um veículo, útil quando não há placa.",
+      "customer": "E-mail, número do cliente ou nome exato. Usado apenas quando a linha não tem veículo.",
+      "emailNotification": "Sim ou não. Envia um e-mail quando o lembrete vence. As notificações no app estão sempre ativas."
+    },
+    "targetRule": "Uma linha sem veículo e sem cliente vira um lembrete da oficina. Linhas que correspondem a um lembrete em aberto são puladas, então enviar o mesmo arquivo duas vezes não adiciona nada.",
+    "summary": {
+      "ready": "Prontos: {count}",
+      "duplicates": "Já existem: {count}",
+      "errors": "Para corrigir: {count}"
+    },
+    "ignoredColumns": "Colunas não lidas: {columns}",
+    "rowLabel": "Linha {line}",
+    "untitled": "(sem título)",
+    "issues": {
+      "missingTitle": "O título está vazio.",
+      "missingDue": "Precisa de uma data de vencimento ou de uma quilometragem de vencimento.",
+      "badDate": "Não foi possível ler a data \"{value}\".",
+      "badTime": "Não foi possível ler o horário \"{value}\".",
+      "timeWithoutDate": "Um horário precisa de uma data de vencimento.",
+      "badMileage": "Não foi possível ler a quilometragem \"{value}\".",
+      "badEmailNotification": "A notificação por e-mail deve ser sim ou não, e não \"{value}\".",
+      "vehicleNotFound": "Nenhum veículo corresponde a \"{value}\".",
+      "vehicleAmbiguous": "Mais de um veículo corresponde a \"{value}\".",
+      "vehicleMismatch": "A placa e o chassi (VIN) pertencem a veículos diferentes ({value}).",
+      "customerNotFound": "Nenhum cliente corresponde a \"{value}\".",
+      "customerAmbiguous": "Mais de um cliente corresponde a \"{value}\". Use o e-mail ou o número do cliente.",
+      "duplicateExisting": "Pulada: este lembrete já existe.",
+      "duplicateInFile": "Pulada: repete uma linha anterior do arquivo."
+    },
+    "fileErrors": {
+      "demo": "A importação está desativada na demonstração.",
+      "noFile": "Escolha um arquivo para enviar.",
+      "tooLarge": "O arquivo é maior que 900 KB. Divida-o e importe em partes.",
+      "unsupported": "Envie um arquivo .csv ou .xlsx.",
+      "legacyXls": "Arquivos antigos do Excel (.xls) não podem ser lidos. Salve o arquivo como .xlsx ou .csv e tente novamente.",
+      "empty": "O arquivo não tem linhas.",
+      "unreadable": "Não foi possível ler o arquivo.",
+      "tooManyRows": "O arquivo tem mais de {limit} linhas. Divida-o e importe em partes.",
+      "noTitleColumn": "O arquivo não tem a coluna \"Title\". Baixe o modelo para ver os cabeçalhos esperados."
+    },
+    "chooseAnother": "Escolher outro arquivo",
+    "downloadSkipped": "Baixar linhas puladas",
+    "problemColumn": "Problema",
+    "submit": "Importar {count, plural, one {# lembrete} other {# lembretes}}",
+    "nothingToImport": "Nada para importar",
+    "done": "{count, plural, one {# lembrete importado} other {# lembretes importados}}",
+    "doneSkipped": "{count, plural, one {# linha pulada} other {# linhas puladas}}",
+    "close": "Fechar"
+  }
 }

+ 2 - 0
messages/pt-PT/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Nota eliminada",
     "reminder_create": "Lembrete criado",
     "reminder_delete": "Lembrete eliminado",
+    "reminder_import": "Lembretes importados",
     "organization_create": "Organização criada",
     "subscription_cancel": "Subscrição cancelada",
     "subscription_resume": "Subscrição retomada",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "Fatura recorrente \"{title}\" atualizada",
     "reminder_create": "Lembrete \"{title}\" criado",
     "reminder_delete": "Lembrete {id} eliminado",
+    "reminder_import": "{count, plural, one {# lembrete importado} other {# lembretes importados}} de {fileName}",
     "role_create": "Função \"{name}\" criada",
     "role_delete": "Função \"{name}\" eliminada",
     "role_update": "Função \"{name}\" atualizada",

+ 74 - 1
messages/pt-PT/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Cliente (opcional)",
   "noneOption": "Nenhum",
   "workshopHint": "Nenhum cliente ou viatura selecionado: este é um lembrete geral da oficina.",
-  "workshopReminder": "Oficina"
+  "workshopReminder": "Oficina",
+  "import": {
+    "button": "Importar",
+    "title": "Importar lembretes",
+    "description": "Carregue um ficheiro CSV ou Excel com um lembrete por linha. Verá cada linha antes de qualquer coisa ser guardada.",
+    "drop": "Largue aqui um ficheiro CSV ou Excel",
+    "formats": "CSV ou Excel (.xlsx), até {rows} linhas",
+    "browse": "Escolher ficheiro",
+    "reading": "A ler o ficheiro...",
+    "templateHint": "Comece pelo modelo para acertar nas colunas.",
+    "templateXlsx": "Modelo Excel",
+    "templateCsv": "Modelo CSV",
+    "columnsTitle": "Colunas no ficheiro",
+    "required": {
+      "yes": "Obrigatória",
+      "dateOrMileage": "Data ou quilometragem",
+      "no": "Opcional"
+    },
+    "columns": {
+      "title": "O que é preciso fazer, por exemplo \"Mudança de óleo\".",
+      "dueDate": "O dia em que vence. 2026-10-15 funciona sempre, e formatos como 15.10.2026 ou 10/15/2026 são detetados.",
+      "dueTime": "Hora do dia, por exemplo 14:30, no fuso horário da oficina. Deixe em branco para qualquer hora desse dia.",
+      "dueMileage": "A leitura do conta-quilómetros em que vence, em {unit}.",
+      "description": "Qualquer detalhe adicional.",
+      "licensePlate": "Associa o lembrete a uma viatura. Espaços e hífenes são ignorados.",
+      "vin": "Associa o lembrete a uma viatura, útil quando não há matrícula.",
+      "customer": "E-mail, número de cliente ou nome exato. Usado apenas quando a linha não tem viatura.",
+      "emailNotification": "Sim ou não. Envia um e-mail quando o lembrete vence. As notificações na aplicação estão sempre ativas."
+    },
+    "targetRule": "Uma linha sem viatura e sem cliente torna-se um lembrete da oficina. As linhas que correspondem a um lembrete em aberto são ignoradas, por isso carregar o mesmo ficheiro duas vezes não acrescenta nada.",
+    "summary": {
+      "ready": "Prontos: {count}",
+      "duplicates": "Já existem: {count}",
+      "errors": "Por corrigir: {count}"
+    },
+    "ignoredColumns": "Colunas não lidas: {columns}",
+    "rowLabel": "Linha {line}",
+    "untitled": "(sem título)",
+    "issues": {
+      "missingTitle": "O título está vazio.",
+      "missingDue": "Precisa de uma data de vencimento ou de uma quilometragem de vencimento.",
+      "badDate": "Não foi possível ler a data \"{value}\".",
+      "badTime": "Não foi possível ler a hora \"{value}\".",
+      "timeWithoutDate": "Uma hora precisa de uma data de vencimento.",
+      "badMileage": "Não foi possível ler a quilometragem \"{value}\".",
+      "badEmailNotification": "A notificação por e-mail deve ser sim ou não, e não \"{value}\".",
+      "vehicleNotFound": "Nenhuma viatura corresponde a \"{value}\".",
+      "vehicleAmbiguous": "Mais de uma viatura corresponde a \"{value}\".",
+      "vehicleMismatch": "A matrícula e o número de quadro (VIN) pertencem a viaturas diferentes ({value}).",
+      "customerNotFound": "Nenhum cliente corresponde a \"{value}\".",
+      "customerAmbiguous": "Mais de um cliente corresponde a \"{value}\". Use o e-mail ou o número de cliente.",
+      "duplicateExisting": "Ignorada: este lembrete já existe.",
+      "duplicateInFile": "Ignorada: repete uma linha anterior do ficheiro."
+    },
+    "fileErrors": {
+      "demo": "A importação está desativada na demonstração.",
+      "noFile": "Escolha um ficheiro para carregar.",
+      "tooLarge": "O ficheiro tem mais de 900 KB. Divida-o e importe-o por partes.",
+      "unsupported": "Carregue um ficheiro .csv ou .xlsx.",
+      "legacyXls": "Os ficheiros antigos do Excel (.xls) não podem ser lidos. Guarde o ficheiro como .xlsx ou .csv e tente novamente.",
+      "empty": "O ficheiro não tem linhas.",
+      "unreadable": "Não foi possível ler o ficheiro.",
+      "tooManyRows": "O ficheiro tem mais de {limit} linhas. Divida-o e importe-o por partes.",
+      "noTitleColumn": "O ficheiro não tem a coluna \"Title\". Descarregue o modelo para ver os cabeçalhos esperados."
+    },
+    "chooseAnother": "Escolher outro ficheiro",
+    "downloadSkipped": "Descarregar linhas ignoradas",
+    "problemColumn": "Problema",
+    "submit": "Importar {count, plural, one {# lembrete} other {# lembretes}}",
+    "nothingToImport": "Nada para importar",
+    "done": "{count, plural, one {# lembrete importado} other {# lembretes importados}}",
+    "doneSkipped": "{count, plural, one {# linha ignorada} other {# linhas ignoradas}}",
+    "close": "Fechar"
+  }
 }

+ 2 - 0
messages/ru/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Удалена заметка",
     "reminder_create": "Создано напоминание",
     "reminder_delete": "Удалено напоминание",
+    "reminder_import": "Импортированы напоминания",
     "organization_create": "Создана организация",
     "subscription_cancel": "Отменена подписка",
     "subscription_resume": "Возобновлена подписка",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "Обновлён регулярный счёт «{title}»",
     "reminder_create": "Создано напоминание «{title}»",
     "reminder_delete": "Удалено напоминание {id}",
+    "reminder_import": "Импортировано {count, plural, one {# напоминание} few {# напоминания} many {# напоминаний} other {# напоминания}} из {fileName}",
     "role_create": "Создана роль «{name}»",
     "role_delete": "Удалена роль «{name}»",
     "role_update": "Обновлена роль «{name}»",

+ 74 - 1
messages/ru/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Клиент (необязательно)",
   "noneOption": "Нет",
   "workshopHint": "Клиент или автомобиль не выбраны: это общее напоминание для мастерской.",
-  "workshopReminder": "Мастерская"
+  "workshopReminder": "Мастерская",
+  "import": {
+    "button": "Импорт",
+    "title": "Импорт напоминаний",
+    "description": "Загрузите файл CSV или Excel, по одному напоминанию в строке. Вы увидите каждую строку до того, как что-либо будет сохранено.",
+    "drop": "Перетащите сюда файл CSV или Excel",
+    "formats": "CSV или Excel (.xlsx), до {rows} строк",
+    "browse": "Выбрать файл",
+    "reading": "Чтение файла...",
+    "templateHint": "Начните с шаблона, чтобы столбцы были правильными.",
+    "templateXlsx": "Шаблон Excel",
+    "templateCsv": "Шаблон CSV",
+    "columnsTitle": "Столбцы в файле",
+    "required": {
+      "yes": "Обязательно",
+      "dateOrMileage": "Дата или пробег",
+      "no": "Необязательно"
+    },
+    "columns": {
+      "title": "Что нужно сделать, например «Замена масла».",
+      "dueDate": "День, когда наступает срок. 2026-10-15 подходит всегда, а форматы вроде 15.10.2026 или 10/15/2026 распознаются.",
+      "dueTime": "Время, например 14:30, в часовом поясе мастерской. Оставьте пустым, если подходит любое время этого дня.",
+      "dueMileage": "Показание одометра, при котором наступает срок, в {unit}.",
+      "description": "Любые дополнительные сведения.",
+      "licensePlate": "Привязывает напоминание к транспортному средству. Пробелы и дефисы игнорируются.",
+      "vin": "Привязывает напоминание к транспортному средству. Удобно, если нет госномера.",
+      "customer": "Эл. почта, номер клиента или точное имя. Используется, только если в строке нет транспортного средства.",
+      "emailNotification": "Да или нет. Отправляет письмо, когда наступает срок напоминания. Уведомления в приложении включены всегда."
+    },
+    "targetRule": "Строка без транспортного средства и без клиента становится напоминанием мастерской. Строки, совпадающие с открытым напоминанием, пропускаются, поэтому повторная загрузка того же файла ничего не добавит.",
+    "summary": {
+      "ready": "Готово: {count}",
+      "duplicates": "Уже есть: {count}",
+      "errors": "Нужно исправить: {count}"
+    },
+    "ignoredColumns": "Не прочитаны столбцы: {columns}",
+    "rowLabel": "Строка {line}",
+    "untitled": "(без названия)",
+    "issues": {
+      "missingTitle": "Название не заполнено.",
+      "missingDue": "Нужна дата срока или пробег до срока.",
+      "badDate": "Не удалось прочитать дату «{value}».",
+      "badTime": "Не удалось прочитать время «{value}».",
+      "timeWithoutDate": "Для времени нужна дата срока.",
+      "badMileage": "Не удалось прочитать пробег «{value}».",
+      "badEmailNotification": "Уведомление по эл. почте должно быть «да» или «нет», а не «{value}».",
+      "vehicleNotFound": "Нет транспортного средства, соответствующего «{value}».",
+      "vehicleAmbiguous": "«{value}» соответствует более чем одному транспортному средству.",
+      "vehicleMismatch": "Госномер и VIN относятся к разным транспортным средствам ({value}).",
+      "customerNotFound": "Нет клиента, соответствующего «{value}».",
+      "customerAmbiguous": "«{value}» соответствует более чем одному клиенту. Укажите эл. почту или номер клиента.",
+      "duplicateExisting": "Пропущено: такое напоминание уже есть.",
+      "duplicateInFile": "Пропущено: повторяет одну из предыдущих строк файла."
+    },
+    "fileErrors": {
+      "demo": "В демоверсии импорт отключён.",
+      "noFile": "Выберите файл для загрузки.",
+      "tooLarge": "Файл больше 900 KB. Разделите его и импортируйте по частям.",
+      "unsupported": "Загрузите файл .csv или .xlsx.",
+      "legacyXls": "Старые файлы Excel .xls прочитать нельзя. Сохраните файл в формате .xlsx или .csv и повторите попытку.",
+      "empty": "В файле нет строк.",
+      "unreadable": "Не удалось прочитать файл.",
+      "tooManyRows": "В файле больше {limit} строк. Разделите его и импортируйте по частям.",
+      "noTitleColumn": "В файле нет столбца «Title». Скачайте шаблон, чтобы увидеть ожидаемые заголовки."
+    },
+    "chooseAnother": "Выбрать другой файл",
+    "downloadSkipped": "Скачать пропущенные строки",
+    "problemColumn": "Проблема",
+    "submit": "Импортировать {count, plural, one {# напоминание} few {# напоминания} many {# напоминаний} other {# напоминания}}",
+    "nothingToImport": "Нечего импортировать",
+    "done": "Импортировано {count, plural, one {# напоминание} few {# напоминания} many {# напоминаний} other {# напоминания}}",
+    "doneSkipped": "{count, plural, one {Пропущена # строка} few {Пропущено # строки} many {Пропущено # строк} other {Пропущено # строки}}",
+    "close": "Закрыть"
+  }
 }

+ 2 - 0
messages/tr/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Not silindi",
     "reminder_create": "Hatırlatma oluşturuldu",
     "reminder_delete": "Hatırlatma silindi",
+    "reminder_import": "Hatırlatmalar içe aktarıldı",
     "organization_create": "Organizasyon oluşturuldu",
     "subscription_cancel": "Abonelik iptal edildi",
     "subscription_resume": "Abonelik devam ettirildi",
@@ -226,6 +227,7 @@
     "recurringInvoice_update": "\"{title}\" yinelenen faturası güncellendi",
     "reminder_create": "\"{title}\" hatırlatıcısı oluşturuldu",
     "reminder_delete": "{id} hatırlatıcısı silindi",
+    "reminder_import": "{fileName} dosyasından {count, plural, one {# hatırlatma} other {# hatırlatma}} içe aktarıldı",
     "role_create": "\"{name}\" rolü oluşturuldu",
     "role_delete": "\"{name}\" rolü silindi",
     "role_update": "\"{name}\" rolü güncellendi",

+ 74 - 1
messages/tr/reminders.json

@@ -13,5 +13,78 @@
   "selectCustomer": "Müşteri (isteğe bağlı)",
   "noneOption": "Yok",
   "workshopHint": "Müşteri veya araç seçilmedi: bu genel bir atölye hatırlatıcısıdır.",
-  "workshopReminder": "Atölye"
+  "workshopReminder": "Atölye",
+  "import": {
+    "button": "İçe aktar",
+    "title": "Hatırlatmaları içe aktar",
+    "description": "Her satırda bir hatırlatma olan bir CSV veya Excel dosyası yükleyin. Hiçbir şey kaydedilmeden önce her satırı görürsünüz.",
+    "drop": "CSV veya Excel dosyasını buraya bırakın",
+    "formats": "CSV veya Excel (.xlsx), en fazla {rows} satır",
+    "browse": "Dosya seç",
+    "reading": "Dosya okunuyor...",
+    "templateHint": "Sütunların doğru olması için şablonla başlayın.",
+    "templateXlsx": "Excel şablonu",
+    "templateCsv": "CSV şablonu",
+    "columnsTitle": "Dosyadaki sütunlar",
+    "required": {
+      "yes": "Zorunlu",
+      "dateOrMileage": "Tarih veya kilometre",
+      "no": "İsteğe bağlı"
+    },
+    "columns": {
+      "title": "Yapılması gereken iş, örneğin \"Yağ değişimi\".",
+      "dueDate": "Son tarih günü. 2026-10-15 her zaman çalışır, 15.10.2026 veya 10/15/2026 gibi biçimler de algılanır.",
+      "dueTime": "Günün saati, örneğin 14:30, atölyenin saat diliminde. O gün herhangi bir saat için boş bırakın.",
+      "dueMileage": "Hatırlatmanın geleceği kilometre sayacı değeri, {unit} cinsinden.",
+      "description": "Ek ayrıntılar.",
+      "licensePlate": "Hatırlatmayı bir araca bağlar. Boşluklar ve tireler yok sayılır.",
+      "vin": "Hatırlatmayı bir araca bağlar. Plaka olmadığında işe yarar.",
+      "customer": "E-posta, müşteri numarası veya tam ad. Yalnızca satırda araç yoksa kullanılır.",
+      "emailNotification": "Evet veya hayır. Hatırlatmanın zamanı geldiğinde e-posta gönderir. Uygulama içi bildirimler her zaman açıktır."
+    },
+    "targetRule": "Araç ve müşteri içermeyen bir satır atölye hatırlatması olur. Açık bir hatırlatmayla eşleşen satırlar atlanır, bu yüzden aynı dosyayı iki kez yüklemek hiçbir şey eklemez.",
+    "summary": {
+      "ready": "Hazır: {count}",
+      "duplicates": "Zaten var: {count}",
+      "errors": "Düzeltilmeli: {count}"
+    },
+    "ignoredColumns": "Okunmayan sütunlar: {columns}",
+    "rowLabel": "Satır {line}",
+    "untitled": "(başlıksız)",
+    "issues": {
+      "missingTitle": "Başlık boş.",
+      "missingDue": "Bir son tarih veya kilometre sınırı gerekli.",
+      "badDate": "\"{value}\" tarihi okunamadı.",
+      "badTime": "\"{value}\" saati okunamadı.",
+      "timeWithoutDate": "Saat için bir son tarih gerekir.",
+      "badMileage": "\"{value}\" kilometre değeri okunamadı.",
+      "badEmailNotification": "E-posta bildirimi evet veya hayır olmalı, \"{value}\" değil.",
+      "vehicleNotFound": "\"{value}\" ile eşleşen araç yok.",
+      "vehicleAmbiguous": "\"{value}\" ile birden fazla araç eşleşiyor.",
+      "vehicleMismatch": "Plaka ve VIN farklı araçlara ait ({value}).",
+      "customerNotFound": "\"{value}\" ile eşleşen müşteri yok.",
+      "customerAmbiguous": "\"{value}\" ile birden fazla müşteri eşleşiyor. Müşterinin e-postasını veya müşteri numarasını kullanın.",
+      "duplicateExisting": "Atlandı: bu hatırlatma zaten var.",
+      "duplicateInFile": "Atlandı: dosyadaki önceki bir satırı tekrarlıyor."
+    },
+    "fileErrors": {
+      "demo": "Demoda içe aktarma kapalı.",
+      "noFile": "Yüklenecek bir dosya seçin.",
+      "tooLarge": "Dosya 900 KB'tan büyük. Dosyayı bölün ve parçalar halinde içe aktarın.",
+      "unsupported": "Bir .csv veya .xlsx dosyası yükleyin.",
+      "legacyXls": "Eski Excel .xls dosyaları okunamaz. Dosyayı .xlsx veya .csv olarak kaydedip tekrar deneyin.",
+      "empty": "Dosyada satır yok.",
+      "unreadable": "Dosya okunamadı.",
+      "tooManyRows": "Dosyada {limit} satırdan fazla var. Dosyayı bölün ve parçalar halinde içe aktarın.",
+      "noTitleColumn": "Dosyada \"Title\" sütunu yok. Beklenen başlıkları görmek için şablonu indirin."
+    },
+    "chooseAnother": "Başka bir dosya seç",
+    "downloadSkipped": "Atlanan satırları indir",
+    "problemColumn": "Sorun",
+    "submit": "{count, plural, one {# hatırlatmayı} other {# hatırlatmayı}} içe aktar",
+    "nothingToImport": "İçe aktarılacak bir şey yok",
+    "done": "{count, plural, one {# hatırlatma içe aktarıldı} other {# hatırlatma içe aktarıldı}}",
+    "doneSkipped": "{count, plural, one {# satır atlandı} other {# satır atlandı}}",
+    "close": "Kapat"
+  }
 }

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

@@ -53,6 +53,17 @@ describe('import file parsing', () => {
     expect(sheet.rows).toEqual([['2024-03-15', 'AB 12345', '1890.5']])
   })
 
+  it('keeps the time of Excel time and date-time cells', async () => {
+    const wb = new ExcelJS.Workbook()
+    const ws = wb.addWorksheet('Due')
+    ws.addRow(['Date', 'Time'])
+    // How ExcelJS hands back "15.10.2026 14:30" and a bare "09:30".
+    ws.addRow([new Date(Date.UTC(2026, 9, 15, 14, 30)), new Date(Date.UTC(1899, 11, 30, 9, 30))])
+    const buffer = Buffer.from(await wb.xlsx.writeBuffer())
+    const sheet = await parseImportFile(buffer, 'due.xlsx')
+    expect(sheet.rows).toEqual([['2026-10-15 14:30', '09:30']])
+  })
+
   it('reads a workbook that was renamed to .csv', async () => {
     const wb = new ExcelJS.Workbook()
     wb.addWorksheet('S').addRows([['Name'], ['Anna']])

+ 282 - 0
src/__tests__/features/vehicles/reminder-import.test.ts

@@ -0,0 +1,282 @@
+import { describe, expect, it } from 'vitest'
+import {
+  mapReminderColumns,
+  parseTimeOfDay,
+  parseYesNo,
+  planReminderImport,
+  REMINDER_COLUMNS,
+  REMINDER_TEMPLATE_ROWS,
+  type ReminderImportContext,
+} from '@/features/vehicles/Lib/reminderImport'
+
+const HEADERS = REMINDER_COLUMNS.map((c) => c.header)
+
+function ctx(over: Partial<ReminderImportContext> = {}): ReminderImportContext {
+  return {
+    vehicles: [
+      {
+        id: 'v1',
+        licensePlate: 'AB 12345',
+        vin: '1HGBH41JXMN109186',
+        customerId: 'c1',
+        year: 2018,
+        make: 'Volvo',
+        model: 'V70',
+      },
+      {
+        id: 'v2',
+        licensePlate: 'CD 67890',
+        vin: null,
+        customerId: null,
+        year: 2020,
+        make: 'Tesla',
+        model: 'Model 3',
+      },
+    ],
+    customers: [
+      { id: 'c1', name: 'Anna Berg', email: 'anna@example.com', customerNumber: '1001' },
+      { id: 'c2', name: 'Ola Hansen', email: null, customerNumber: '1002' },
+      { id: 'c3', name: 'Ola Hansen', email: 'ola@example.com', customerNumber: '1003' },
+    ],
+    openReminders: [],
+    timeZone: 'Europe/Oslo',
+    ...over,
+  }
+}
+
+/** A row in template order from the fields given. */
+function row(fields: Partial<Record<(typeof REMINDER_COLUMNS)[number]['key'], string>>): string[] {
+  return REMINDER_COLUMNS.map((c) => fields[c.key] ?? '')
+}
+
+describe('reminder import headers', () => {
+  it('reads the template headers', () => {
+    const { columns, ignored } = mapReminderColumns(HEADERS)
+    expect(ignored).toEqual([])
+    REMINDER_COLUMNS.forEach((c, i) => expect(columns[c.key]).toEqual([i]))
+  })
+
+  it('recognises other names and languages, and lists what it ignores', () => {
+    const { columns, ignored } = mapReminderColumns([
+      'Tittel',
+      'Dato',
+      'Reg.nr',
+      'Kunde',
+      'E-post',
+      'Km',
+      'Colour',
+    ])
+    expect(columns.title).toEqual([0])
+    expect(columns.dueDate).toEqual([1])
+    expect(columns.licensePlate).toEqual([2])
+    // Both customer columns are tried.
+    expect(columns.customer).toEqual([3, 4])
+    expect(columns.dueMileage).toEqual([5])
+    expect(ignored).toEqual(['Colour'])
+  })
+})
+
+describe('reminder import cells', () => {
+  it('reads times in 24-hour and 12-hour forms', () => {
+    expect(parseTimeOfDay('14:30').value).toBe('14:30')
+    expect(parseTimeOfDay('9.05').value).toBe('09:05')
+    expect(parseTimeOfDay('2:30 PM').value).toBe('14:30')
+    expect(parseTimeOfDay('12 am').value).toBe('00:00')
+    expect(parseTimeOfDay('').valid).toBe(true)
+    expect(parseTimeOfDay('25:00').valid).toBe(false)
+    expect(parseTimeOfDay('noon').valid).toBe(false)
+  })
+
+  it('reads yes and no in the app languages', () => {
+    for (const yes of ['yes', 'Ja', 'sí', 'oui', 'x', 'TRUE', '1', 'evet']) {
+      expect(parseYesNo(yes)).toEqual({ value: true, valid: true })
+    }
+    for (const no of ['no', 'Nei', 'nein', 'non', 'não', 'hayır', '', '0']) {
+      expect(parseYesNo(no)).toEqual({ value: false, valid: true })
+    }
+    expect(parseYesNo('maybe').valid).toBe(false)
+  })
+})
+
+describe('planning a reminder import', () => {
+  it('turns the template example rows into a vehicle, a customer and a workshop reminder', () => {
+    const plan = planReminderImport(HEADERS, REMINDER_TEMPLATE_ROWS as string[][], ctx())
+    expect(plan.rows.map((r) => [r.status, r.target?.kind])).toEqual([
+      ['ready', 'vehicle'],
+      ['ready', 'customer'],
+      ['ready', 'workshop'],
+    ])
+    const [oil, call] = plan.rows
+    expect(oil).toMatchObject({
+      line: 2,
+      dueDay: '2026-11-15',
+      dueMileage: 120000,
+      notifyEmail: true,
+    })
+    expect(oil.target).toMatchObject({
+      id: 'v1',
+      customerId: 'c1',
+      label: '2018 Volvo V70 · AB 12345',
+    })
+    expect(call.dueTime).toBe('09:30')
+  })
+
+  it('stores the due time as workshop wall clock, and a day-only reminder at noon', () => {
+    const plan = planReminderImport(
+      HEADERS,
+      [
+        row({ title: 'Timed', dueDate: '2026-10-15', dueTime: '14:30' }),
+        row({ title: 'Day only', dueDate: '2026-10-15' }),
+        row({ title: 'Time in the date cell', dueDate: '2026-10-15 08:15' }),
+      ],
+      ctx()
+    )
+    // Oslo is UTC+2 in October.
+    expect(plan.rows[0].dueAt?.toISOString()).toBe('2026-10-15T12:30:00.000Z')
+    expect(plan.rows[1].dueAt?.toISOString()).toBe('2026-10-15T10:00:00.000Z')
+    expect(plan.rows[1].dueTime).toBeNull()
+    expect(plan.rows[2].dueTime).toBe('08:15')
+  })
+
+  it('detects day-first dates from the column, and follows a chosen format', () => {
+    const rows = [
+      row({ title: 'A', dueDate: '03.04.2026' }),
+      row({ title: 'B', dueDate: '25.04.2026' }),
+    ]
+    const auto = planReminderImport(HEADERS, rows, ctx())
+    expect(auto.dateFormat).toBe('DMY')
+    expect(auto.rows[0].dueDay).toBe('2026-04-03')
+
+    const monthFirst = planReminderImport(HEADERS, rows, ctx(), 'MDY')
+    expect(monthFirst.rows[0].dueDay).toBe('2026-03-04')
+    expect(monthFirst.rows[1]).toMatchObject({ status: 'error', issue: 'badDate' })
+  })
+
+  it('matches plates ignoring spaces and dashes, and by VIN', () => {
+    const plan = planReminderImport(
+      HEADERS,
+      [
+        row({ title: 'A', dueMileage: '100 000', licensePlate: 'ab-12345' }),
+        row({ title: 'B', dueMileage: '5000', vin: '1hgbh41jxmn109186' }),
+      ],
+      ctx()
+    )
+    expect(plan.rows.map((r) => r.target && 'id' in r.target && r.target.id)).toEqual(['v1', 'v1'])
+    expect(plan.rows[0].dueMileage).toBe(100000)
+  })
+
+  it('matches customers by email, number or exact name, and the vehicle wins', () => {
+    const plan = planReminderImport(
+      HEADERS,
+      [
+        row({ title: 'A', dueDate: '2026-10-01', customer: 'ANNA@example.com' }),
+        row({ title: 'B', dueDate: '2026-10-01', customer: '1002' }),
+        row({ title: 'C', dueDate: '2026-10-01', customer: '  anna   berg ' }),
+        row({ title: 'D', dueDate: '2026-10-01', customer: 'Ola Hansen' }),
+        row({ title: 'E', dueDate: '2026-10-01', customer: 'Kari Nordmann' }),
+        row({ title: 'F', dueDate: '2026-10-01', licensePlate: 'CD 67890', customer: 'nobody' }),
+      ],
+      ctx()
+    )
+    expect(plan.rows.slice(0, 3).map((r) => r.target && 'id' in r.target && r.target.id)).toEqual([
+      'c1',
+      'c2',
+      'c1',
+    ])
+    expect(plan.rows[3]).toMatchObject({ status: 'error', issue: 'customerAmbiguous' })
+    expect(plan.rows[4]).toMatchObject({
+      status: 'error',
+      issue: 'customerNotFound',
+      issueValue: 'Kari Nordmann',
+    })
+    expect(plan.rows[5]).toMatchObject({ status: 'ready', target: { kind: 'vehicle', id: 'v2' } })
+  })
+
+  it('tries each customer column until one matches', () => {
+    const plan = planReminderImport(
+      ['Title', 'Due date', 'Customer name', 'Customer email'],
+      [['A', '2026-10-01', 'Ola Hansen', 'ola@example.com']],
+      ctx()
+    )
+    expect(plan.rows[0].target).toMatchObject({ kind: 'customer', id: 'c3' })
+  })
+
+  it('explains every row it cannot import', () => {
+    const plan = planReminderImport(
+      HEADERS,
+      [
+        row({ dueDate: '2026-10-01' }),
+        row({ title: 'No due' }),
+        row({ title: 'Bad date', dueDate: '31.02.2026' }),
+        row({ title: 'Bad time', dueDate: '2026-10-01', dueTime: 'afternoon' }),
+        row({ title: 'Time only', dueTime: '10:00', dueMileage: '5000' }),
+        row({ title: 'Bad mileage', dueMileage: 'lots' }),
+        row({ title: 'Bad flag', dueMileage: '5000', emailNotification: 'maybe' }),
+        row({ title: 'Unknown plate', dueMileage: '5000', licensePlate: 'ZZ 99999' }),
+        row({
+          title: 'Mismatch',
+          dueMileage: '5000',
+          licensePlate: 'CD 67890',
+          vin: '1HGBH41JXMN109186',
+        }),
+      ],
+      ctx()
+    )
+    expect(plan.rows.map((r) => r.issue)).toEqual([
+      'missingTitle',
+      'missingDue',
+      'badDate',
+      'badTime',
+      'timeWithoutDate',
+      'badMileage',
+      'badEmailNotification',
+      'vehicleNotFound',
+      'vehicleMismatch',
+    ])
+    expect(plan.rows.every((r) => r.status === 'error')).toBe(true)
+  })
+
+  it('reports a plate shared by two vehicles instead of guessing', () => {
+    const base = ctx()
+    const plan = planReminderImport(
+      HEADERS,
+      [row({ title: 'A', dueMileage: '5000', licensePlate: 'AB 12345' })],
+      ctx({
+        vehicles: [...base.vehicles, { ...base.vehicles[1], id: 'v3', licensePlate: 'AB12345' }],
+      })
+    )
+    expect(plan.rows[0]).toMatchObject({ status: 'error', issue: 'vehicleAmbiguous' })
+  })
+
+  it('skips reminders that already exist or repeat within the file', () => {
+    const first = planReminderImport(
+      HEADERS,
+      [row({ title: 'Oil change', dueDate: '2026-11-15', licensePlate: 'AB 12345' })],
+      ctx()
+    )
+    const existing = {
+      vehicleId: 'v1',
+      customerId: 'c1',
+      title: 'oil  change',
+      dueDate: first.rows[0].dueAt,
+      dueMileage: null,
+    }
+    const plan = planReminderImport(
+      HEADERS,
+      [
+        row({ title: 'Oil change', dueDate: '2026-11-15', licensePlate: 'AB 12345' }),
+        row({ title: 'Brakes', dueDate: '2026-11-15' }),
+        row({ title: 'brakes', dueDate: '2026-11-15' }),
+        // Same title on another day is a different reminder.
+        row({ title: 'Oil change', dueDate: '2027-05-15', licensePlate: 'AB 12345' }),
+      ],
+      ctx({ openReminders: [existing] })
+    )
+    expect(plan.rows.map((r) => [r.status, r.issue])).toEqual([
+      ['duplicate', 'duplicateExisting'],
+      ['ready', null],
+      ['duplicate', 'duplicateInFile'],
+      ['ready', null],
+    ])
+  })
+})

+ 7 - 30
src/app/api/protected/import/template/route.ts

@@ -1,7 +1,7 @@
-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'
+import { spreadsheetTemplateResponse } from '@/features/import/Lib/template'
 
 const ENTITIES: ImportEntity[] = ['customers', 'vehicles', 'services']
 
@@ -21,34 +21,11 @@ export async function GET(request: NextRequest) {
   }
 
   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"`,
-    },
+  return spreadsheetTemplateResponse({
+    fileBase: `torqvoice-${entity}-template`,
+    sheetName: entity,
+    headers: fields.map((f) => f.templateHeader),
+    rows: [fields.map((f) => f.example)],
+    format,
   })
 }

+ 18 - 0
src/app/api/protected/reminders/template/route.ts

@@ -0,0 +1,18 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { spreadsheetTemplateResponse } from '@/features/import/Lib/template'
+import { REMINDER_COLUMNS, REMINDER_TEMPLATE_ROWS } from '@/features/vehicles/Lib/reminderImport'
+
+/** The reminder import layout, with one example row per kind of reminder. */
+export async function GET(request: NextRequest) {
+  const ctx = await getAuthContext()
+  if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+
+  return spreadsheetTemplateResponse({
+    fileBase: 'torqvoice-reminders-template',
+    sheetName: 'reminders',
+    headers: REMINDER_COLUMNS.map((c) => c.header),
+    rows: REMINDER_TEMPLATE_ROWS,
+    format: request.nextUrl.searchParams.get('format') === 'xlsx' ? 'xlsx' : 'csv',
+  })
+}

+ 6 - 1
src/features/import/Lib/parse.ts

@@ -127,7 +127,12 @@ 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)
+    // Excel keeps wall-clock values as UTC. A time on its own sits on the
+    // 1899-12-30 epoch, and a date typed with a time keeps it.
+    const iso = value.toISOString()
+    const time = iso.slice(11, 16)
+    if (iso.startsWith('1899-12-30') || iso.startsWith('1899-12-31')) return time
+    return time === '00:00' ? iso.slice(0, 10) : `${iso.slice(0, 10)} ${time}`
   }
   if (typeof value === 'object') {
     if ('richText' in value) return value.richText.map((r) => r.text).join('')

+ 44 - 0
src/features/import/Lib/template.ts

@@ -0,0 +1,44 @@
+import ExcelJS from 'exceljs'
+import { NextResponse } from 'next/server'
+
+/**
+ * A download of headers and example rows, as CSV or an Excel workbook. The CSV
+ * carries a BOM so Excel opens it as UTF-8 instead of mangling "ø" and "ü".
+ */
+export async function spreadsheetTemplateResponse(opts: {
+  fileBase: string
+  sheetName: string
+  headers: readonly string[]
+  rows: readonly (readonly string[])[]
+  format: 'csv' | 'xlsx'
+}): Promise<NextResponse> {
+  const { fileBase, sheetName, headers, rows, format } = opts
+
+  if (format === 'csv') {
+    const quote = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)
+    const csv = `${[headers, ...rows].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(sheetName)
+  sheet.addRow([...headers])
+  for (const row of rows) sheet.addRow([...row])
+  sheet.getRow(1).font = { bold: true }
+  sheet.columns.forEach((col, i) => {
+    const longest = Math.max(headers[i].length, ...rows.map((r) => (r[i] ?? '').length))
+    col.width = Math.min(40, Math.max(14, longest + 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"`,
+    },
+  })
+}

+ 206 - 0
src/features/vehicles/Actions/reminderImportActions.ts

@@ -0,0 +1,206 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { db } from '@/lib/db'
+import { isDemoMode } from '@/lib/demo'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import type { DateFormat } from '@/features/import/Lib/normalize'
+import { ImportParseError, parseImportFile } from '@/features/import/Lib/parse'
+import {
+  MAX_REMINDER_IMPORT_BYTES,
+  MAX_REMINDER_IMPORT_ROWS,
+  mapReminderColumns,
+  planReminderImport,
+  type ReminderImportFileError,
+  type ReminderImportPlan,
+  type ReminderImportRow,
+} from '../Lib/reminderImport'
+
+const DATE_FORMATS: DateFormat[] = ['auto', 'DMY', 'MDY', 'YMD']
+
+const permissions = [{ action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES }]
+
+export interface ReminderImportPreviewRow extends Omit<ReminderImportRow, 'dueAt'> {
+  /** The file's own cells, kept for rows that are skipped so they can be downloaded. */
+  raw: string[] | null
+}
+
+export type ReminderImportPreview =
+  | { fileError: ReminderImportFileError; limit?: number }
+  | {
+      fileError: null
+      fileName: string
+      dateFormat: DateFormat
+      columns: string[]
+      ignoredColumns: string[]
+      rows: ReminderImportPreviewRow[]
+    }
+
+export type ReminderImportResult =
+  | { fileError: ReminderImportFileError; limit?: number }
+  | { fileError: null; created: number; skipped: number }
+
+type Loaded =
+  | { fileError: ReminderImportFileError; limit?: number }
+  | {
+      fileError: null
+      fileName: string
+      columns: string[]
+      rows: string[][]
+      plan: ReminderImportPlan
+    }
+
+/**
+ * Read the upload and plan every row against this workshop's vehicles,
+ * customers and open reminders. The preview and the import both come here,
+ * so what is imported is exactly what the preview showed for the same file.
+ */
+async function loadPlan(formData: FormData, organizationId: string): Promise<Loaded> {
+  if (isDemoMode) return { fileError: 'demo' }
+  const file = formData.get('file')
+  if (!(file instanceof File) || file.size === 0) return { fileError: 'noFile' }
+  if (file.size > MAX_REMINDER_IMPORT_BYTES) return { fileError: 'tooLarge' }
+  const requested = String(formData.get('dateFormat') ?? 'auto') as DateFormat
+  const dateFormat = DATE_FORMATS.includes(requested) ? requested : 'auto'
+
+  let sheet: Awaited<ReturnType<typeof parseImportFile>>
+  try {
+    sheet = await parseImportFile(Buffer.from(await file.arrayBuffer()), file.name)
+  } catch (err) {
+    if (err instanceof ImportParseError) {
+      if (err.code === 'too_many_rows') {
+        return { fileError: 'tooManyRows', limit: MAX_REMINDER_IMPORT_ROWS }
+      }
+      if (err.code === 'legacy_xls') return { fileError: 'legacyXls' }
+      if (err.code === 'empty') return { fileError: 'empty' }
+      if (err.code === 'unsupported_format') return { fileError: 'unsupported' }
+      return { fileError: 'unreadable' }
+    }
+    throw err
+  }
+  // A contact list is not a reminder list.
+  if (sheet.format === 'vcard') return { fileError: 'unsupported' }
+  if (sheet.rows.length > MAX_REMINDER_IMPORT_ROWS) {
+    return { fileError: 'tooManyRows', limit: MAX_REMINDER_IMPORT_ROWS }
+  }
+  const { columns } = mapReminderColumns(sheet.columns)
+  if (!columns.title) return { fileError: 'noTitleColumn' }
+
+  const [vehicles, customers, openReminders, timeZone] = await Promise.all([
+    columns.licensePlate || columns.vin
+      ? db.vehicle.findMany({
+          where: { organizationId, isArchived: false },
+          select: {
+            id: true,
+            licensePlate: true,
+            vin: true,
+            customerId: true,
+            year: true,
+            make: true,
+            model: true,
+          },
+        })
+      : [],
+    columns.customer
+      ? db.customer.findMany({
+          where: { organizationId },
+          select: { id: true, name: true, email: true, customerNumber: true },
+        })
+      : [],
+    db.reminder.findMany({
+      where: { organizationId, isCompleted: false },
+      select: { vehicleId: true, customerId: true, title: true, dueDate: true, dueMileage: true },
+    }),
+    workshopTimeZone(organizationId),
+  ])
+
+  const plan = planReminderImport(
+    sheet.columns,
+    sheet.rows,
+    { vehicles, customers, openReminders, timeZone },
+    dateFormat
+  )
+  return { fileError: null, fileName: file.name, columns: sheet.columns, rows: sheet.rows, plan }
+}
+
+export async function previewReminderImport(formData: FormData) {
+  return withAuth(
+    async ({ organizationId }): Promise<ReminderImportPreview> => {
+      const loaded = await loadPlan(formData, organizationId)
+      if (loaded.fileError) return loaded
+      return {
+        fileError: null,
+        fileName: loaded.fileName,
+        dateFormat: loaded.plan.dateFormat,
+        columns: loaded.columns,
+        ignoredColumns: loaded.plan.ignoredColumns,
+        rows: loaded.plan.rows.map(({ dueAt: _dueAt, ...row }, i) => ({
+          ...row,
+          raw: row.status === 'ready' ? null : loaded.rows[i],
+        })),
+      }
+    },
+    { requiredPermissions: permissions }
+  )
+}
+
+export async function importReminders(formData: FormData) {
+  return withAuth(
+    async ({ organizationId }): Promise<ReminderImportResult & { fileName?: string }> => {
+      const loaded = await loadPlan(formData, organizationId)
+      if (loaded.fileError) return loaded
+      const ready = loaded.plan.rows.filter((r) => r.status === 'ready')
+      if (ready.length) {
+        await db.reminder.createMany({
+          data: ready.map((r) => {
+            const target = r.target
+            return {
+              organizationId,
+              title: r.title as string,
+              description: r.description,
+              dueDate: r.dueAt,
+              hasDueTime: Boolean(r.dueAt && r.dueTime),
+              dueMileage: r.dueMileage,
+              notifyInApp: true,
+              notifyEmail: r.notifyEmail,
+              // As in createReminder, a vehicle reminder carries the vehicle's customer.
+              vehicleId: target?.kind === 'vehicle' ? target.id : null,
+              customerId:
+                target?.kind === 'vehicle'
+                  ? target.customerId
+                  : target?.kind === 'customer'
+                    ? target.id
+                    : null,
+            }
+          }),
+        })
+        revalidatePath('/')
+        revalidatePath('/reminders')
+        revalidatePath('/vehicles', 'layout')
+      }
+      return {
+        fileError: null,
+        fileName: loaded.fileName,
+        created: ready.length,
+        skipped: loaded.plan.rows.length - ready.length,
+      }
+    },
+    {
+      requiredPermissions: permissions,
+      audit: ({ result }) =>
+        result.fileError === null && result.created > 0
+          ? {
+              action: 'reminder.import',
+              entity: 'Reminder',
+              details: {
+                key: 'reminder_import',
+                params: { count: result.created, fileName: result.fileName ?? '' },
+              },
+              metadata: { created: result.created, skipped: result.skipped },
+            }
+          : null,
+    }
+  )
+}

+ 466 - 0
src/features/vehicles/Components/ReminderImportDialog.tsx

@@ -0,0 +1,466 @@
+'use client'
+
+import { useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import {
+  AlertCircle,
+  Building2,
+  Car,
+  CheckCircle2,
+  Download,
+  FileSpreadsheet,
+  FileUp,
+  Loader2,
+  MinusCircle,
+  Upload,
+  User,
+} from 'lucide-react'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Label } from '@/components/ui/label'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { useFormatDate } from '@/lib/use-format-date'
+import { cn } from '@/lib/utils'
+import type { DateFormat } from '@/features/import/Lib/normalize'
+import {
+  MAX_REMINDER_IMPORT_BYTES,
+  MAX_REMINDER_IMPORT_ROWS,
+  REMINDER_COLUMNS,
+  type ReminderImportFileError,
+} from '../Lib/reminderImport'
+import {
+  importReminders,
+  previewReminderImport,
+  type ReminderImportPreviewRow,
+} from '../Actions/reminderImportActions'
+
+interface ReminderImportDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  unitSystem: 'metric' | 'imperial'
+}
+
+type Preview = {
+  fileName: string
+  dateFormat: DateFormat
+  columns: string[]
+  ignoredColumns: string[]
+  rows: ReminderImportPreviewRow[]
+}
+
+type Step = 'upload' | 'preview' | 'done'
+
+/** Skipped rows as they were, plus why, so the file can be fixed and uploaded again. */
+function downloadSkipped(
+  preview: Preview,
+  problemHeader: string,
+  problem: (row: ReminderImportPreviewRow) => string
+) {
+  const quote = (v: string) => (/[",\n;]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)
+  const lines = [[...preview.columns, problemHeader]]
+  for (const row of preview.rows) {
+    if (row.status !== 'ready' && row.raw) lines.push([...row.raw, problem(row)])
+  }
+  const csv = `${lines.map((l) => l.map(quote).join(',')).join('\r\n')}\r\n`
+  const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }))
+  const a = document.createElement('a')
+  a.href = url
+  a.download = `${preview.fileName.replace(/\.[^.]+$/, '')}-skipped.csv`
+  a.click()
+  URL.revokeObjectURL(url)
+}
+
+export function ReminderImportDialog({
+  open,
+  onOpenChange,
+  unitSystem,
+}: ReminderImportDialogProps) {
+  const t = useTranslations('reminders.import')
+  const tr = useTranslations('reminders')
+  const to = useTranslations('dataImport.options')
+  const router = useRouter()
+  const { formatDate } = useFormatDate()
+  const distUnit = unitSystem === 'metric' ? 'km' : 'mi'
+  const fileInputRef = useRef<HTMLInputElement>(null)
+
+  const [step, setStep] = useState<Step>('upload')
+  const [file, setFile] = useState<File | null>(null)
+  const [preview, setPreview] = useState<Preview | null>(null)
+  const [busy, setBusy] = useState(false)
+  const [dragOver, setDragOver] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const [result, setResult] = useState<{ created: number; skipped: number } | null>(null)
+
+  const reset = () => {
+    setStep('upload')
+    setFile(null)
+    setPreview(null)
+    setError(null)
+    setResult(null)
+    if (fileInputRef.current) fileInputRef.current.value = ''
+  }
+
+  const handleOpenChange = (next: boolean) => {
+    if (busy) return
+    onOpenChange(next)
+    if (!next) reset()
+  }
+
+  const fileErrorMessage = (code: ReminderImportFileError, limit?: number) =>
+    t(`fileErrors.${code}`, { limit: limit ?? MAX_REMINDER_IMPORT_ROWS })
+
+  const formFor = (f: File, dateFormat: DateFormat) => {
+    const form = new FormData()
+    form.set('file', f)
+    form.set('dateFormat', dateFormat)
+    return form
+  }
+
+  const runPreview = async (f: File, dateFormat: DateFormat) => {
+    setError(null)
+    if (f.size > MAX_REMINDER_IMPORT_BYTES) {
+      setError(fileErrorMessage('tooLarge'))
+      return
+    }
+    setBusy(true)
+    try {
+      const res = await previewReminderImport(formFor(f, dateFormat))
+      if (!res.success || !res.data) {
+        setError(res.error ?? t('fileErrors.unreadable'))
+        return
+      }
+      if (res.data.fileError) {
+        setError(fileErrorMessage(res.data.fileError, res.data.limit))
+        return
+      }
+      setFile(f)
+      setPreview(res.data)
+      setStep('preview')
+    } catch {
+      setError(t('fileErrors.unreadable'))
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  const runImport = async () => {
+    if (!file || !preview) return
+    setBusy(true)
+    setError(null)
+    try {
+      const res = await importReminders(formFor(file, preview.dateFormat))
+      if (!res.success || !res.data) {
+        setError(res.error ?? t('fileErrors.unreadable'))
+        return
+      }
+      if (res.data.fileError) {
+        setError(fileErrorMessage(res.data.fileError, res.data.limit))
+        return
+      }
+      setResult({ created: res.data.created, skipped: res.data.skipped })
+      setStep('done')
+      router.refresh()
+    } catch {
+      setError(t('fileErrors.unreadable'))
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  const problem = (row: ReminderImportPreviewRow) =>
+    row.issue ? t(`issues.${row.issue}`, { value: row.issueValue ?? '' }) : ''
+
+  const ready = preview?.rows.filter((r) => r.status === 'ready').length ?? 0
+  const duplicates = preview?.rows.filter((r) => r.status === 'duplicate').length ?? 0
+  const errors = preview?.rows.filter((r) => r.status === 'error').length ?? 0
+
+  const dueLabel = (row: ReminderImportPreviewRow) => {
+    const parts: string[] = []
+    if (row.dueDay) {
+      // Noon UTC stays on its calendar day in every workshop zone.
+      const day = formatDate(new Date(`${row.dueDay}T12:00:00Z`))
+      parts.push(row.dueTime ? `${day} ${row.dueTime}` : day)
+    }
+    if (row.dueMileage != null) parts.push(`${row.dueMileage.toLocaleString()} ${distUnit}`)
+    return parts.join(' · ')
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={handleOpenChange}>
+      <DialogContent className="flex max-h-[90vh] flex-col sm:max-w-3xl">
+        <DialogHeader>
+          <DialogTitle>{t('title')}</DialogTitle>
+          <DialogDescription>{t('description')}</DialogDescription>
+        </DialogHeader>
+
+        {error && (
+          <div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
+            <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
+            <span>{error}</span>
+          </div>
+        )}
+
+        {/* ── Upload ─────────────────────────────────────────────────────── */}
+        {step === 'upload' && (
+          <div className="flex min-h-0 flex-col gap-4 overflow-y-auto">
+            <div
+              className={cn(
+                'flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 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 dropped = e.dataTransfer.files[0]
+                if (dropped && !busy) runPreview(dropped, 'auto')
+              }}
+            >
+              {busy ? (
+                <>
+                  <Loader2 className="h-9 w-9 animate-spin text-muted-foreground" />
+                  <p className="text-sm text-muted-foreground">{t('reading')}</p>
+                </>
+              ) : (
+                <>
+                  <FileUp className="h-9 w-9 text-muted-foreground" />
+                  <div className="text-center">
+                    <p className="text-sm font-medium">{t('drop')}</p>
+                    <p className="mt-1 text-xs text-muted-foreground">
+                      {t('formats', { rows: MAX_REMINDER_IMPORT_ROWS })}
+                    </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,.xlsx,.xlsm"
+                className="hidden"
+                onChange={(e) => {
+                  const picked = e.target.files?.[0]
+                  e.target.value = ''
+                  if (picked) runPreview(picked, 'auto')
+                }}
+              />
+            </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 shrink-0" />
+                {t('templateHint')}
+              </div>
+              <div className="flex gap-2">
+                <Button variant="ghost" size="sm" asChild>
+                  <a href="/api/protected/reminders/template?format=xlsx" download>
+                    <Download className="mr-1 h-3.5 w-3.5" />
+                    {t('templateXlsx')}
+                  </a>
+                </Button>
+                <Button variant="ghost" size="sm" asChild>
+                  <a href="/api/protected/reminders/template?format=csv" download>
+                    <Download className="mr-1 h-3.5 w-3.5" />
+                    {t('templateCsv')}
+                  </a>
+                </Button>
+              </div>
+            </div>
+
+            <div className="space-y-2">
+              <p className="text-sm font-medium">{t('columnsTitle')}</p>
+              <div className="divide-y rounded-lg border text-sm">
+                {REMINDER_COLUMNS.map((c) => (
+                  <div
+                    key={c.key}
+                    className="grid gap-1 px-3 py-2 sm:grid-cols-[10rem_8rem_1fr] sm:items-baseline sm:gap-3"
+                  >
+                    <code className="font-mono text-xs font-medium">{c.header}</code>
+                    <span
+                      className={cn(
+                        'text-xs',
+                        c.required === 'no' ? 'text-muted-foreground' : 'font-medium'
+                      )}
+                    >
+                      {t(`required.${c.required}`)}
+                    </span>
+                    <span className="text-xs text-muted-foreground">
+                      {t(`columns.${c.key}`, { unit: distUnit })}
+                    </span>
+                  </div>
+                ))}
+              </div>
+              <p className="text-xs text-muted-foreground">{t('targetRule')}</p>
+            </div>
+          </div>
+        )}
+
+        {/* ── Preview ────────────────────────────────────────────────────── */}
+        {step === 'preview' && preview && (
+          <div className="flex min-h-0 flex-1 flex-col gap-3">
+            <div className="flex flex-wrap items-end justify-between gap-3">
+              <div className="space-y-1 text-sm">
+                <p className="font-medium">{preview.fileName}</p>
+                <div className="flex flex-wrap gap-1.5">
+                  <Badge variant="secondary">{t('summary.ready', { count: ready })}</Badge>
+                  {duplicates > 0 && (
+                    <Badge variant="outline">
+                      {t('summary.duplicates', { count: duplicates })}
+                    </Badge>
+                  )}
+                  {errors > 0 && (
+                    <Badge variant="destructive">{t('summary.errors', { count: errors })}</Badge>
+                  )}
+                </div>
+              </div>
+              <div className="space-y-1">
+                <Label className="text-xs">{to('dateFormat')}</Label>
+                <Select
+                  value={preview.dateFormat}
+                  disabled={busy}
+                  onValueChange={(v) => file && runPreview(file, v as DateFormat)}
+                >
+                  <SelectTrigger className="h-8 w-52">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="auto">{to('dateAuto')}</SelectItem>
+                    <SelectItem value="DMY">{to('dateDMY')}</SelectItem>
+                    <SelectItem value="MDY">{to('dateMDY')}</SelectItem>
+                    <SelectItem value="YMD">{to('dateYMD')}</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+            </div>
+
+            {preview.ignoredColumns.length > 0 && (
+              <p className="text-xs text-muted-foreground">
+                {t('ignoredColumns', { columns: preview.ignoredColumns.join(', ') })}
+              </p>
+            )}
+
+            <div className="min-h-0 flex-1 divide-y overflow-y-auto rounded-lg border">
+              {preview.rows.map((row) => (
+                <div key={row.line} className="flex items-start gap-3 px-3 py-2 text-sm">
+                  {row.status === 'ready' ? (
+                    <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
+                  ) : row.status === 'duplicate' ? (
+                    <MinusCircle className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+                  ) : (
+                    <AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
+                  )}
+                  <div className="min-w-0 flex-1">
+                    <div className="flex flex-wrap items-baseline gap-x-2">
+                      <span className="text-xs tabular-nums text-muted-foreground">
+                        {t('rowLabel', { line: row.line })}
+                      </span>
+                      <span
+                        className={cn(
+                          'truncate font-medium',
+                          !row.title && 'text-muted-foreground'
+                        )}
+                      >
+                        {row.title ?? t('untitled')}
+                      </span>
+                    </div>
+                    <div className="mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
+                      {row.target && (
+                        <span className="flex items-center gap-1">
+                          {row.target.kind === 'vehicle' ? (
+                            <Car className="h-3 w-3" />
+                          ) : row.target.kind === 'customer' ? (
+                            <User className="h-3 w-3" />
+                          ) : (
+                            <Building2 className="h-3 w-3" />
+                          )}
+                          {row.target.kind === 'workshop'
+                            ? tr('workshopReminder')
+                            : row.target.label}
+                        </span>
+                      )}
+                      {dueLabel(row) && <span>{dueLabel(row)}</span>}
+                    </div>
+                    {row.issue && (
+                      <p
+                        className={cn(
+                          'mt-0.5 text-xs',
+                          row.status === 'error' ? 'text-destructive' : 'text-muted-foreground'
+                        )}
+                      >
+                        {problem(row)}
+                      </p>
+                    )}
+                  </div>
+                </div>
+              ))}
+            </div>
+          </div>
+        )}
+
+        {/* ── Done ───────────────────────────────────────────────────────── */}
+        {step === 'done' && result && (
+          <div className="flex flex-col items-center gap-2 py-8 text-center">
+            <CheckCircle2 className="h-10 w-10 text-emerald-600" />
+            <p className="font-medium">{t('done', { count: result.created })}</p>
+            {result.skipped > 0 && (
+              <p className="text-sm text-muted-foreground">
+                {t('doneSkipped', { count: result.skipped })}
+              </p>
+            )}
+          </div>
+        )}
+
+        {step !== 'upload' && (
+          <DialogFooter className="gap-2 sm:justify-between">
+            <div className="flex flex-wrap gap-2">
+              {step === 'preview' && (
+                <Button variant="ghost" onClick={reset} disabled={busy}>
+                  {t('chooseAnother')}
+                </Button>
+              )}
+              {preview && duplicates + errors > 0 && (
+                <Button
+                  variant="outline"
+                  onClick={() => downloadSkipped(preview, t('problemColumn'), problem)}
+                >
+                  <Download className="mr-1 h-3.5 w-3.5" />
+                  {t('downloadSkipped')}
+                </Button>
+              )}
+            </div>
+            {step === 'preview' ? (
+              <Button onClick={runImport} disabled={busy || ready === 0}>
+                {busy && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
+                {ready === 0 ? t('nothingToImport') : t('submit', { count: ready })}
+              </Button>
+            ) : (
+              <Button onClick={() => handleOpenChange(false)}>{t('close')}</Button>
+            )}
+          </DialogFooter>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 13 - 0
src/features/vehicles/Components/RemindersPageClient.tsx

@@ -32,11 +32,13 @@ import {
   Pencil,
   Plus,
   Trash2,
+  Upload,
   User,
 } from 'lucide-react'
 import { toast } from 'sonner'
 import { toggleReminder, deleteReminder } from '../Actions/reminderActions'
 import { ReminderFormDialog } from './ReminderFormDialog'
+import { ReminderImportDialog } from './ReminderImportDialog'
 
 interface Reminder {
   id: string
@@ -102,6 +104,7 @@ export function RemindersPageClient({ reminders, vehicles, unitSystem }: Reminde
   const [filter, setFilter] = useState<FilterType>('active')
   const [showForm, setShowForm] = useState(false)
   const [editingReminder, setEditingReminder] = useState<Reminder | undefined>()
+  const [showImport, setShowImport] = useState(false)
 
   const filtered = reminders.filter((r) => {
     if (filter === 'active') return !r.isCompleted
@@ -165,6 +168,10 @@ export function RemindersPageClient({ reminders, vehicles, unitSystem }: Reminde
           <span className="text-xs text-muted-foreground">
             {t('showing', { count: filtered.length, total: reminders.length })}
           </span>
+          <Button size="sm" variant="outline" onClick={() => setShowImport(true)}>
+            <Upload className="mr-1 h-3.5 w-3.5" />
+            {t('import.button')}
+          </Button>
           <Button size="sm" onClick={openAddForm}>
             <Plus className="mr-1 h-3.5 w-3.5" />
             {tv('addReminder')}
@@ -326,6 +333,12 @@ export function RemindersPageClient({ reminders, vehicles, unitSystem }: Reminde
         reminder={editingReminder}
         onSaved={() => router.refresh()}
       />
+
+      <ReminderImportDialog
+        open={showImport}
+        onOpenChange={setShowImport}
+        unitSystem={unitSystem}
+      />
     </>
   )
 }

+ 645 - 0
src/features/vehicles/Lib/reminderImport.ts

@@ -0,0 +1,645 @@
+/**
+ * Reading a spreadsheet of reminders and working out, row by row, what each
+ * one becomes: a vehicle reminder, a customer reminder, a workshop reminder,
+ * or a line the user has to fix. Pure, so the preview and the import run the
+ * same code and a test can drive it without a database.
+ *
+ * The file is laid out like the template, but headers are recognised the way
+ * the spreadsheet importer recognises them, so "Reg.nr" or "Kunde" works as
+ * well as "License plate" or "Customer".
+ */
+
+import { IMPORT_FIELDS } from '@/features/import/Lib/fields'
+import {
+  cleanText,
+  type DateFormat,
+  detectDateFormat,
+  normalizeHeader,
+  normalizeVin,
+  parseDate,
+  parseInteger,
+  plateKey,
+} from '@/features/import/Lib/normalize'
+import { parseWorkshopDateTime } from '@/lib/workshop-datetime'
+
+/** Enough for any shop's reminder list, small enough to preview in one go. */
+export const MAX_REMINDER_IMPORT_ROWS = 2000
+/** Under the 1 MB a server action accepts, with room for the form around it. */
+export const MAX_REMINDER_IMPORT_BYTES = 900 * 1024
+
+/** Why a whole file was refused, before any row was read. */
+export type ReminderImportFileError =
+  | 'demo'
+  | 'noFile'
+  | 'tooLarge'
+  | 'unsupported'
+  | 'legacyXls'
+  | 'empty'
+  | 'unreadable'
+  | 'tooManyRows'
+  | 'noTitleColumn'
+
+export type ReminderColumn =
+  | 'title'
+  | 'dueDate'
+  | 'dueTime'
+  | 'dueMileage'
+  | 'description'
+  | 'licensePlate'
+  | 'vin'
+  | 'customer'
+  | 'emailNotification'
+
+export interface ReminderColumnSpec {
+  key: ReminderColumn
+  /** Header written to the template. */
+  header: string
+  required: 'yes' | 'dateOrMileage' | 'no'
+}
+
+/** Template order. The dialog lists the columns in the same order. */
+export const REMINDER_COLUMNS: readonly ReminderColumnSpec[] = [
+  { key: 'title', header: 'Title', required: 'yes' },
+  { key: 'dueDate', header: 'Due date', required: 'dateOrMileage' },
+  { key: 'dueTime', header: 'Due time', required: 'no' },
+  { key: 'dueMileage', header: 'Due mileage', required: 'dateOrMileage' },
+  { key: 'description', header: 'Description', required: 'no' },
+  { key: 'licensePlate', header: 'License plate', required: 'no' },
+  { key: 'vin', header: 'VIN', required: 'no' },
+  { key: 'customer', header: 'Customer', required: 'no' },
+  { key: 'emailNotification', header: 'Email notification', required: 'no' },
+]
+
+/**
+ * Three example rows, one per kind of reminder, so the template shows how a
+ * vehicle, a customer and a workshop reminder differ.
+ */
+export const REMINDER_TEMPLATE_ROWS: readonly string[][] = [
+  ['Oil change', '2026-11-15', '', '120000', '5W-30, filter in stock', 'AB 12345', '', '', 'yes'],
+  ['Call about winter tyres', '2026-10-20', '09:30', '', '', '', '', 'anna@example.com', 'no'],
+  ['Renew lift inspection certificate', '2027-01-10', '', '', '', '', '', '', 'no'],
+]
+
+function synonymsOf(fieldKey: string): string[] {
+  return [...(IMPORT_FIELDS.find((f) => f.key === fieldKey)?.synonyms ?? [])]
+}
+
+/**
+ * Normalised header names per column. Plate, VIN, customer and mileage reuse
+ * the spreadsheet importer's lists, which already cover every locale.
+ */
+const SYNONYMS: Record<ReminderColumn, readonly string[]> = {
+  title: [
+    'title',
+    'reminder',
+    'remindertitle',
+    'task',
+    ...synonymsOf('service.title').filter((s) => s !== 'description'),
+    'paminnelse',
+    'erinnerung',
+    'recordatorio',
+    'rappel',
+    'promemoria',
+    'przypomnienie',
+    'herinnering',
+    'hatirlatici',
+    'priminimas',
+    'napominanie',
+    'lembrete',
+  ],
+  dueDate: [
+    'duedate',
+    'due',
+    'date',
+    'duedateday',
+    'dueon',
+    'reminderdate',
+    'dato',
+    'forfallsdato',
+    'frist',
+    'fristdato',
+    'datum',
+    'falligam',
+    'falligkeitsdatum',
+    'fecha',
+    'fechalimite',
+    'fechavencimiento',
+    'echeance',
+    'dateecheance',
+    'scadenza',
+    'datascadenza',
+    'data',
+    'termin',
+    'terminwykonania',
+    'vervaldatum',
+    'tarih',
+    'sontarih',
+    'terminas',
+    'srok',
+    'datavencimento',
+    'prazo',
+  ],
+  dueTime: [
+    'duetime',
+    'time',
+    'tid',
+    'klokkeslett',
+    'klokkeslet',
+    'zeit',
+    'uhrzeit',
+    'hora',
+    'heure',
+    'ora',
+    'godzina',
+    'tijd',
+    'tijdstip',
+    'saat',
+    'laikas',
+    'vremya',
+  ],
+  dueMileage: [
+    'duemileage',
+    'dueodometer',
+    'duekm',
+    'duemiles',
+    'duekilometers',
+    'duekilometres',
+    'atmileage',
+    'atkm',
+    ...synonymsOf('vehicle.mileage'),
+    'kmstandfallig',
+    'forfallkm',
+  ],
+  description: [...synonymsOf('service.description'), 'notes', 'note', 'comment', 'comments'],
+  licensePlate: synonymsOf('vehicle.licensePlate'),
+  vin: synonymsOf('vehicle.vin'),
+  customer: [
+    ...synonymsOf('customer.name').filter((s) => s !== 'name'),
+    ...synonymsOf('customer.email'),
+    ...synonymsOf('customer.customerNumber').filter((s) => s !== 'number'),
+    'customeremail',
+  ],
+  emailNotification: [
+    'emailnotification',
+    'emailnotifications',
+    'notifyemail',
+    'notifybyemail',
+    'emailreminder',
+    'sendemail',
+    'epostvarsel',
+    'epostvarsling',
+    'varselepost',
+    'varslepaepost',
+    'emailbenachrichtigung',
+    'benachrichtigungperemail',
+    'notificacionporcorreo',
+    'avisoporcorreo',
+    'notificationparemail',
+    'notificationemail',
+    'notificaemail',
+    'notificaviaemail',
+    'powiadomienieemail',
+    'emailmelding',
+    'emailnotificatie',
+    'epostabildirimi',
+    'pranesimaselpastu',
+    'notificacaoporemail',
+  ],
+}
+
+// ── Headers ───────────────────────────────────────────────────────────────────
+
+export interface ReminderColumnMap {
+  /** Column indexes per field. Only `customer` may hold more than one. */
+  columns: Partial<Record<ReminderColumn, number[]>>
+  /** Headers nothing was read from, shown so a typo does not go unnoticed. */
+  ignored: string[]
+}
+
+/**
+ * Which column holds what. The first column matching a field wins, except for
+ * the customer: a file with both a name and an email column tries each.
+ */
+export function mapReminderColumns(headers: readonly string[]): ReminderColumnMap {
+  const columns: Partial<Record<ReminderColumn, number[]>> = {}
+  const ignored: string[] = []
+  const order = REMINDER_COLUMNS.map((c) => c.key)
+  headers.forEach((header, index) => {
+    const norm = normalizeHeader(header)
+    const key = order.find((k) => SYNONYMS[k].includes(norm))
+    if (!key) {
+      ignored.push(header)
+      return
+    }
+    const existing = columns[key]
+    if (!existing) columns[key] = [index]
+    else if (key === 'customer') existing.push(index)
+    else ignored.push(header)
+  })
+  return { columns, ignored }
+}
+
+// ── Cells ─────────────────────────────────────────────────────────────────────
+
+/** "14:30", "14.30", "2:30 pm", "9 am", "14:30:00" → "HH:MM". */
+export function parseTimeOfDay(value: string | null | undefined): {
+  value: string | null
+  valid: boolean
+} {
+  const s = cleanText(value)?.toLowerCase()
+  if (!s) return { value: null, valid: true }
+  let m = s.match(/^(\d{1,2})[:.](\d{2})(?:[:.]\d{2})?\s*(am|pm|a\.m\.|p\.m\.)?$/)
+  let hours: number
+  let minutes: number
+  let meridiem: string | undefined
+  if (m) {
+    hours = Number(m[1])
+    minutes = Number(m[2])
+    meridiem = m[3]
+  } else {
+    m = s.match(/^(\d{1,2})\s*(am|pm|a\.m\.|p\.m\.)$/)
+    if (!m) return { value: null, valid: false }
+    hours = Number(m[1])
+    minutes = 0
+    meridiem = m[2]
+  }
+  if (meridiem) {
+    if (hours < 1 || hours > 12) return { value: null, valid: false }
+    const pm = meridiem.startsWith('p')
+    hours = (hours % 12) + (pm ? 12 : 0)
+  }
+  if (hours > 23 || minutes > 59) return { value: null, valid: false }
+  return {
+    value: `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`,
+    valid: true,
+  }
+}
+
+const YES = new Set([
+  'yes',
+  'y',
+  'true',
+  '1',
+  'x',
+  'on',
+  'ja',
+  'j',
+  'wahr',
+  'si',
+  'sim',
+  'oui',
+  'tak',
+  'evet',
+  'taip',
+  'da',
+  'да',
+])
+const NO = new Set([
+  'no',
+  'n',
+  'false',
+  '0',
+  '-',
+  'off',
+  'nei',
+  'nein',
+  'falsch',
+  'nee',
+  'non',
+  'nao',
+  'nie',
+  'hayir',
+  'ne',
+  'нет',
+])
+
+/** A yes/no cell in any of the app's languages. Empty is "no". */
+export function parseYesNo(value: string | null | undefined): { value: boolean; valid: boolean } {
+  const s = cleanText(value)?.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/ı/g, 'i')
+  if (!s) return { value: false, valid: true }
+  if (YES.has(s)) return { value: true, valid: true }
+  if (NO.has(s)) return { value: false, valid: true }
+  return { value: false, valid: false }
+}
+
+// ── Rows ──────────────────────────────────────────────────────────────────────
+
+export interface ReminderImportVehicle {
+  id: string
+  licensePlate: string | null
+  vin: string | null
+  customerId: string | null
+  year: number
+  make: string
+  model: string
+}
+
+export interface ReminderImportCustomer {
+  id: string
+  name: string
+  email: string | null
+  customerNumber: string | null
+}
+
+export interface ReminderImportExisting {
+  vehicleId: string | null
+  customerId: string | null
+  title: string
+  dueDate: Date | null
+  dueMileage: number | null
+}
+
+export interface ReminderImportContext {
+  vehicles: readonly ReminderImportVehicle[]
+  customers: readonly ReminderImportCustomer[]
+  /** Open reminders already in the workshop, for skipping repeats. */
+  openReminders: readonly ReminderImportExisting[]
+  timeZone: string
+}
+
+export type ReminderImportIssue =
+  | 'missingTitle'
+  | 'missingDue'
+  | 'badDate'
+  | 'badTime'
+  | 'timeWithoutDate'
+  | 'badMileage'
+  | 'badEmailNotification'
+  | 'vehicleNotFound'
+  | 'vehicleAmbiguous'
+  | 'vehicleMismatch'
+  | 'customerNotFound'
+  | 'customerAmbiguous'
+  | 'duplicateExisting'
+  | 'duplicateInFile'
+
+export type ReminderImportTarget =
+  | { kind: 'vehicle'; id: string; customerId: string | null; label: string }
+  | { kind: 'customer'; id: string; label: string }
+  | { kind: 'workshop' }
+
+export interface ReminderImportRow {
+  /** Row number as the spreadsheet shows it, header being row 1. */
+  line: number
+  status: 'ready' | 'duplicate' | 'error'
+  issue: ReminderImportIssue | null
+  /** What the issue is about, e.g. the plate that was not found. */
+  issueValue: string | null
+  title: string | null
+  description: string | null
+  target: ReminderImportTarget | null
+  /** Workshop calendar day, YYYY-MM-DD. */
+  dueDay: string | null
+  /** Workshop wall clock, HH:MM, when the row set one. */
+  dueTime: string | null
+  /** The instant stored, noon on the day when no time was given. */
+  dueAt: Date | null
+  dueMileage: number | null
+  notifyEmail: boolean
+}
+
+export interface ReminderImportPlan {
+  rows: ReminderImportRow[]
+  /** The date format used, detected unless the caller chose one. */
+  dateFormat: DateFormat
+  ignoredColumns: string[]
+}
+
+function vehicleLabel(v: ReminderImportVehicle): string {
+  const name = [v.year || null, v.make, v.model].filter(Boolean).join(' ')
+  return v.licensePlate ? `${name} · ${v.licensePlate}` : name
+}
+
+function collapse(value: string): string {
+  return value.replace(/\s+/g, ' ').trim().toLowerCase()
+}
+
+function pushTo<K, V>(map: Map<K, V[]>, key: K, value: V) {
+  const list = map.get(key)
+  if (list) list.push(value)
+  else map.set(key, [value])
+}
+
+type Lookup<T> = { found: T } | { issue: 'notFound' | 'ambiguous' }
+
+function pick<T>(list: T[] | undefined): Lookup<T> {
+  if (!list?.length) return { issue: 'notFound' }
+  if (list.length > 1) return { issue: 'ambiguous' }
+  return { found: list[0] }
+}
+
+class Index {
+  private byPlate = new Map<string, ReminderImportVehicle[]>()
+  private byVin = new Map<string, ReminderImportVehicle[]>()
+  private byEmail = new Map<string, ReminderImportCustomer[]>()
+  private byNumber = new Map<string, ReminderImportCustomer[]>()
+  private byName = new Map<string, ReminderImportCustomer[]>()
+
+  constructor(ctx: ReminderImportContext) {
+    for (const v of ctx.vehicles) {
+      const p = plateKey(v.licensePlate)
+      if (p) pushTo(this.byPlate, p, v)
+      const vin = normalizeVin(v.vin).value
+      if (vin) pushTo(this.byVin, vin, v)
+    }
+    for (const c of ctx.customers) {
+      if (c.email) pushTo(this.byEmail, c.email.trim().toLowerCase(), c)
+      if (c.customerNumber) pushTo(this.byNumber, c.customerNumber.trim().toLowerCase(), c)
+      pushTo(this.byName, collapse(c.name), c)
+    }
+  }
+
+  plate(value: string) {
+    return pick(this.byPlate.get(plateKey(value) ?? ''))
+  }
+
+  vin(value: string) {
+    return pick(this.byVin.get(normalizeVin(value).value ?? ''))
+  }
+
+  /** An email, a customer number or the exact name, tried in that order. */
+  customer(value: string): Lookup<ReminderImportCustomer> {
+    const key = collapse(value)
+    if (key.includes('@')) return pick(this.byEmail.get(key))
+    const byNumber = this.byNumber.get(key)
+    if (byNumber?.length) return pick(byNumber)
+    return pick(this.byName.get(key))
+  }
+}
+
+function targetKey(vehicleId: string | null, customerId: string | null): string {
+  return vehicleId ? `v:${vehicleId}` : customerId ? `c:${customerId}` : 'w'
+}
+
+function duplicateKey(
+  target: string,
+  title: string,
+  dueAt: Date | null,
+  dueMileage: number | null
+): string {
+  return [target, collapse(title), dueAt?.getTime() ?? '', dueMileage ?? ''].join('|')
+}
+
+/** YYYY-MM-DD from the UTC-midnight date parseDate returns. */
+function dayKey(date: Date): string {
+  return date.toISOString().slice(0, 10)
+}
+
+/** A time written into the date cell, "2026-10-15 14:30", as parseDate reads it. */
+function timeInDateCell(value: string): string | null {
+  const m = value.match(/[T ](\d{1,2}:\d{2})(?::\d{2})?$/)
+  return m ? parseTimeOfDay(m[1]).value : null
+}
+
+/**
+ * Work out every row. Rows are independent: one bad line never stops the
+ * others, it is reported with the reason and left out of the import.
+ */
+export function planReminderImport(
+  headers: readonly string[],
+  rows: readonly string[][],
+  ctx: ReminderImportContext,
+  dateFormat: DateFormat = 'auto'
+): ReminderImportPlan {
+  const { columns, ignored } = mapReminderColumns(headers)
+  const cell = (row: readonly string[], key: ReminderColumn): string | null => {
+    const index = columns[key]?.[0]
+    return index === undefined ? null : cleanText(row[index])
+  }
+
+  const format =
+    dateFormat === 'auto' ? detectDateFormat(rows.map((r) => cell(r, 'dueDate'))) : dateFormat
+  const index = new Index(ctx)
+  const seen = new Set(
+    ctx.openReminders.map((r) =>
+      duplicateKey(targetKey(r.vehicleId, r.customerId), r.title, r.dueDate, r.dueMileage)
+    )
+  )
+  const inFile = new Set<string>()
+
+  const planned = rows.map((row, i): ReminderImportRow => {
+    const out: ReminderImportRow = {
+      line: i + 2,
+      status: 'error',
+      issue: null,
+      issueValue: null,
+      title: cell(row, 'title'),
+      description: cell(row, 'description'),
+      target: null,
+      dueDay: null,
+      dueTime: null,
+      dueAt: null,
+      dueMileage: null,
+      notifyEmail: false,
+    }
+    const fail = (issue: ReminderImportIssue, value: string | null = null) => {
+      out.issue = issue
+      out.issueValue = value
+      return out
+    }
+
+    if (!out.title) return fail('missingTitle')
+
+    // Due date and time
+    const dateCell = cell(row, 'dueDate')
+    const timeCell = cell(row, 'dueTime')
+    if (dateCell) {
+      const date = parseDate(dateCell, format)
+      if (!date || date.getUTCFullYear() < 1900 || date.getUTCFullYear() > 2100) {
+        return fail('badDate', dateCell)
+      }
+      out.dueDay = dayKey(date)
+    }
+    if (timeCell) {
+      const time = parseTimeOfDay(timeCell)
+      if (!time.valid) return fail('badTime', timeCell)
+      if (!out.dueDay) return fail('timeWithoutDate', timeCell)
+      out.dueTime = time.value
+    } else if (dateCell) {
+      out.dueTime = timeInDateCell(dateCell)
+    }
+    if (out.dueDay) {
+      // Same convention as the reminder dialog: a day-only reminder sits at
+      // midday in the workshop, so it stays on its day in every zone.
+      out.dueAt = parseWorkshopDateTime(`${out.dueDay}T${out.dueTime ?? '12:00'}:00`, ctx.timeZone)
+    }
+
+    const mileageCell = cell(row, 'dueMileage')
+    if (mileageCell) {
+      const mileage = parseInteger(mileageCell)
+      if (mileage == null || mileage < 0 || mileage > 10_000_000) {
+        return fail('badMileage', mileageCell)
+      }
+      out.dueMileage = mileage
+    }
+    if (!out.dueAt && out.dueMileage == null) return fail('missingDue')
+
+    const notify = parseYesNo(cell(row, 'emailNotification'))
+    if (!notify.valid) return fail('badEmailNotification', cell(row, 'emailNotification'))
+    out.notifyEmail = notify.value
+
+    // What it relates to: the vehicle wins, then the customer, else the workshop.
+    const plate = cell(row, 'licensePlate')
+    const vin = cell(row, 'vin')
+    if (plate || vin) {
+      const byPlate = plate ? index.plate(plate) : null
+      const byVin = vin ? index.vin(vin) : null
+      if (byPlate && 'issue' in byPlate) {
+        return fail(byPlate.issue === 'ambiguous' ? 'vehicleAmbiguous' : 'vehicleNotFound', plate)
+      }
+      if (byVin && 'issue' in byVin) {
+        return fail(byVin.issue === 'ambiguous' ? 'vehicleAmbiguous' : 'vehicleNotFound', vin)
+      }
+      const a = byPlate?.found
+      const b = byVin?.found
+      if (a && b && a.id !== b.id) return fail('vehicleMismatch', `${plate} / ${vin}`)
+      const vehicle = (a ?? b) as ReminderImportVehicle
+      out.target = {
+        kind: 'vehicle',
+        id: vehicle.id,
+        customerId: vehicle.customerId,
+        label: vehicleLabel(vehicle),
+      }
+    } else {
+      const values = (columns.customer ?? [])
+        .map((c) => cleanText(row[c]))
+        .filter((v): v is string => Boolean(v))
+      let firstIssue: { issue: ReminderImportIssue; value: string } | null = null
+      for (const value of values) {
+        const match = index.customer(value)
+        if ('found' in match) {
+          out.target = { kind: 'customer', id: match.found.id, label: match.found.name }
+          break
+        }
+        const issue = match.issue === 'ambiguous' ? 'customerAmbiguous' : 'customerNotFound'
+        // A name that fits two customers says more than one that fits none.
+        if (!firstIssue || issue === 'customerAmbiguous') firstIssue = { issue, value }
+      }
+      if (!out.target && firstIssue) return fail(firstIssue.issue, firstIssue.value)
+      if (!out.target) out.target = { kind: 'workshop' }
+    }
+
+    const target =
+      out.target.kind === 'vehicle'
+        ? targetKey(out.target.id, null)
+        : out.target.kind === 'customer'
+          ? targetKey(null, out.target.id)
+          : targetKey(null, null)
+    const key = duplicateKey(target, out.title, out.dueAt, out.dueMileage)
+    if (seen.has(key)) {
+      out.status = 'duplicate'
+      out.issue = 'duplicateExisting'
+      return out
+    }
+    if (inFile.has(key)) {
+      out.status = 'duplicate'
+      out.issue = 'duplicateInFile'
+      return out
+    }
+    inFile.add(key)
+    out.status = 'ready'
+    return out
+  })
+
+  return { rows: planned, dateFormat: format, ignoredColumns: ignored }
+}