4
0
Эх сурвалжийг харах

Let workshops write engine size in litres or cc (#472)

* Let workshops write engine size in litres or cc

* Keep saved engine sizes as they were and announce the unit to screen readers
Bernt Christian Egeland 5 өдөр өмнө
parent
commit
8e9fc4ea15
50 өөрчлөгдсөн 585 нэмэгдсэн , 106 устгасан
  1. 4 0
      messages/de/settings.json
  2. 1 0
      messages/de/vehicles.json
  3. 4 0
      messages/en/settings.json
  4. 1 0
      messages/en/vehicles.json
  5. 4 0
      messages/es/settings.json
  6. 1 0
      messages/es/vehicles.json
  7. 4 0
      messages/fr/settings.json
  8. 1 0
      messages/fr/vehicles.json
  9. 4 0
      messages/it/settings.json
  10. 1 0
      messages/it/vehicles.json
  11. 4 0
      messages/lt/settings.json
  12. 1 0
      messages/lt/vehicles.json
  13. 4 0
      messages/nb/settings.json
  14. 1 0
      messages/nb/vehicles.json
  15. 4 0
      messages/nl/settings.json
  16. 1 0
      messages/nl/vehicles.json
  17. 4 0
      messages/pl/settings.json
  18. 1 0
      messages/pl/vehicles.json
  19. 4 0
      messages/pt-BR/settings.json
  20. 1 0
      messages/pt-BR/vehicles.json
  21. 4 0
      messages/pt-PT/settings.json
  22. 1 0
      messages/pt-PT/vehicles.json
  23. 4 0
      messages/ru/settings.json
  24. 1 0
      messages/ru/vehicles.json
  25. 4 0
      messages/tr/settings.json
  26. 1 0
      messages/tr/vehicles.json
  27. 2 0
      src/__tests__/features/integrations/checkcardetails.test.ts
  28. 49 0
      src/__tests__/features/integrations/engine-size-lookup.test.ts
  29. 1 0
      src/__tests__/features/integrations/nhtsa.test.ts
  30. 6 0
      src/__tests__/features/integrations/openapi-automotive.test.ts
  31. 2 0
      src/__tests__/features/integrations/rdw.test.ts
  32. 13 0
      src/__tests__/features/integrations/regcheck.test.ts
  33. 1 0
      src/__tests__/features/integrations/vegvesen.test.ts
  34. 89 0
      src/__tests__/lib/engine-size.test.ts
  35. 104 97
      src/app/(authenticated)/layout.tsx
  36. 25 0
      src/app/(authenticated)/settings/workshop/workshop-settings.tsx
  37. 24 0
      src/components/engine-size-context.tsx
  38. 7 0
      src/features/integrations/Lib/types.ts
  39. 14 0
      src/features/integrations/Lib/vehicle-lookup.ts
  40. 1 0
      src/features/settings/Lib/memberReadableSettings.ts
  41. 2 0
      src/features/settings/Schema/settingsSchema.ts
  42. 60 8
      src/features/vehicles/Components/VehicleForm.tsx
  43. 8 0
      src/integrations/checkcardetails/server.ts
  44. 10 0
      src/integrations/nhtsa/mapping.ts
  45. 7 0
      src/integrations/openapi-automotive/server.ts
  46. 7 0
      src/integrations/rdw/server.ts
  47. 18 1
      src/integrations/regcheck/server.ts
  48. 1 0
      src/integrations/vegvesen/server.ts
  49. 64 0
      src/lib/engine-size.ts
  50. 5 0
      src/lib/get-layout-data.ts

+ 4 - 0
messages/de/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "Liter (L)",
     "liters": "Liter (L)",
     "miles": "Meilen (mi)",
     "miles": "Meilen (mi)",
     "gallons": "Gallonen (gal)",
     "gallons": "Gallonen (gal)",
+    "engineSizeUnit": "Hubraum",
+    "engineSizeLitres": "Liter (1,6 L)",
+    "engineSizeCc": "Kubikzentimeter (1598 cc)",
+    "engineSizeUnitHint": "Wie eine Kennzeichenabfrage den Hubraum einträgt und was das leere Feld vorschlägt. Bereits gespeicherte Werte bleiben unverändert.",
     "defaultStockUnit": "Standard-Mengeneinheit",
     "defaultStockUnit": "Standard-Mengeneinheit",
     "defaultStockUnitHint": "Wird bei neuen Lagerteilen vorausgefüllt. Jedes Teil kann weiterhin eine eigene Einheit haben.",
     "defaultStockUnitHint": "Wird bei neuen Lagerteilen vorausgefüllt. Jedes Teil kann weiterhin eine eigene Einheit haben.",
     "saveWorkshop": "Werkstatteinstellungen speichern",
     "saveWorkshop": "Werkstatteinstellungen speichern",

+ 1 - 0
messages/de/vehicles.json

@@ -336,6 +336,7 @@
     "outboard": "Außenborder",
     "outboard": "Außenborder",
     "inboard": "Innenborder",
     "inboard": "Innenborder",
     "engineSizeMarine": "Motor",
     "engineSizeMarine": "Motor",
+    "engineSizeSavedAs": "Wird als {unit} gespeichert",
     "twoStroke": "Zweitakt",
     "twoStroke": "Zweitakt",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Registrierungsnummer",
     "licensePlateMarine": "Registrierungsnummer",

+ 4 - 0
messages/en/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "liters (L)",
     "liters": "liters (L)",
     "miles": "miles (mi)",
     "miles": "miles (mi)",
     "gallons": "gallons (gal)",
     "gallons": "gallons (gal)",
+    "engineSizeUnit": "Engine size",
+    "engineSizeLitres": "Litres (1.6 L)",
+    "engineSizeCc": "Cubic centimetres (1598 cc)",
+    "engineSizeUnitHint": "How a plate lookup fills in engine size, and what the empty field suggests. Engine sizes already saved are left as they are.",
     "defaultStockUnit": "Default stock unit",
     "defaultStockUnit": "Default stock unit",
     "defaultStockUnitHint": "Pre-filled on new inventory parts. Each part can still use its own unit.",
     "defaultStockUnitHint": "Pre-filled on new inventory parts. Each part can still use its own unit.",
     "saveWorkshop": "Save Workshop Settings",
     "saveWorkshop": "Save Workshop Settings",

+ 1 - 0
messages/en/vehicles.json

@@ -336,6 +336,7 @@
     "outboard": "Outboard",
     "outboard": "Outboard",
     "inboard": "Inboard",
     "inboard": "Inboard",
     "engineSizeMarine": "Engine",
     "engineSizeMarine": "Engine",
+    "engineSizeSavedAs": "Saved as {unit}",
     "twoStroke": "Two-Stroke",
     "twoStroke": "Two-Stroke",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Registration Number",
     "licensePlateMarine": "Registration Number",

+ 4 - 0
messages/es/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litros (L)",
     "liters": "litros (L)",
     "miles": "millas (mi)",
     "miles": "millas (mi)",
     "gallons": "galones (gal)",
     "gallons": "galones (gal)",
+    "engineSizeUnit": "Cilindrada",
+    "engineSizeLitres": "Litros (1,6 L)",
+    "engineSizeCc": "Centímetros cúbicos (1598 cc)",
+    "engineSizeUnitHint": "Cómo rellena la cilindrada una búsqueda por matrícula y qué sugiere el campo vacío. Las cilindradas ya guardadas se quedan como están.",
     "defaultStockUnit": "Unidad de stock predeterminada",
     "defaultStockUnit": "Unidad de stock predeterminada",
     "defaultStockUnitHint": "Se rellena automáticamente en las piezas nuevas del inventario. Cada pieza puede seguir usando su propia unidad.",
     "defaultStockUnitHint": "Se rellena automáticamente en las piezas nuevas del inventario. Cada pieza puede seguir usando su propia unidad.",
     "saveWorkshop": "Guardar configuración del taller",
     "saveWorkshop": "Guardar configuración del taller",

+ 1 - 0
messages/es/vehicles.json

@@ -336,6 +336,7 @@
     "outboard": "Fueraborda",
     "outboard": "Fueraborda",
     "inboard": "Intraborda",
     "inboard": "Intraborda",
     "engineSizeMarine": "Motor",
     "engineSizeMarine": "Motor",
+    "engineSizeSavedAs": "Se guarda como {unit}",
     "twoStroke": "Dos Tiempos",
     "twoStroke": "Dos Tiempos",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Número de Registro",
     "licensePlateMarine": "Número de Registro",

+ 4 - 0
messages/fr/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litres (L)",
     "liters": "litres (L)",
     "miles": "miles (mi)",
     "miles": "miles (mi)",
     "gallons": "gallons (gal)",
     "gallons": "gallons (gal)",
+    "engineSizeUnit": "Cylindrée",
+    "engineSizeLitres": "Litres (1,6 L)",
+    "engineSizeCc": "Centimètres cubes (1598 cc)",
+    "engineSizeUnitHint": "Comment une recherche de plaque remplit la cylindrée, et ce que propose le champ vide. Les cylindrées déjà enregistrées restent telles quelles.",
     "defaultStockUnit": "Unité de stock par défaut",
     "defaultStockUnit": "Unité de stock par défaut",
     "defaultStockUnitHint": "Préremplie pour les nouvelles pièces en stock. Chaque pièce peut toujours utiliser sa propre unité.",
     "defaultStockUnitHint": "Préremplie pour les nouvelles pièces en stock. Chaque pièce peut toujours utiliser sa propre unité.",
     "saveWorkshop": "Enregistrer les paramètres de l'atelier",
     "saveWorkshop": "Enregistrer les paramètres de l'atelier",

+ 1 - 0
messages/fr/vehicles.json

@@ -336,6 +336,7 @@
     "outboard": "Hors-bord",
     "outboard": "Hors-bord",
     "inboard": "In-bord",
     "inboard": "In-bord",
     "engineSizeMarine": "Moteur",
     "engineSizeMarine": "Moteur",
+    "engineSizeSavedAs": "Enregistré en {unit}",
     "twoStroke": "Deux Temps",
     "twoStroke": "Deux Temps",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Numéro d'Immatriculation",
     "licensePlateMarine": "Numéro d'Immatriculation",

+ 4 - 0
messages/it/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litri (L)",
     "liters": "litri (L)",
     "miles": "miglia (mi)",
     "miles": "miglia (mi)",
     "gallons": "galloni (gal)",
     "gallons": "galloni (gal)",
+    "engineSizeUnit": "Cilindrata",
+    "engineSizeLitres": "Litri (1,6 L)",
+    "engineSizeCc": "Centimetri cubi (1598 cc)",
+    "engineSizeUnitHint": "Come una ricerca per targa compila la cilindrata e cosa suggerisce il campo vuoto. Le cilindrate già salvate restano come sono.",
     "defaultStockUnit": "Unità di magazzino predefinita",
     "defaultStockUnit": "Unità di magazzino predefinita",
     "defaultStockUnitHint": "Precompilata per i nuovi ricambi a magazzino. Ogni ricambio può comunque usare la propria unità.",
     "defaultStockUnitHint": "Precompilata per i nuovi ricambi a magazzino. Ogni ricambio può comunque usare la propria unità.",
     "saveWorkshop": "Salva impostazioni officina",
     "saveWorkshop": "Salva impostazioni officina",

+ 1 - 0
messages/it/vehicles.json

@@ -336,6 +336,7 @@
     "outboard": "Fuoribordo",
     "outboard": "Fuoribordo",
     "inboard": "Entrobordo",
     "inboard": "Entrobordo",
     "engineSizeMarine": "Motore",
     "engineSizeMarine": "Motore",
+    "engineSizeSavedAs": "Salvato come {unit}",
     "twoStroke": "Due Tempi",
     "twoStroke": "Due Tempi",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Numero di Registrazione",
     "licensePlateMarine": "Numero di Registrazione",

+ 4 - 0
messages/lt/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litrai (l)",
     "liters": "litrai (l)",
     "miles": "mylios (mi)",
     "miles": "mylios (mi)",
     "gallons": "galonai (gal)",
     "gallons": "galonai (gal)",
+    "engineSizeUnit": "Variklio tūris",
+    "engineSizeLitres": "Litrai (1,6 L)",
+    "engineSizeCc": "Kubiniai centimetrai (1598 cc)",
+    "engineSizeUnitHint": "Kaip numerio paieška įrašo variklio tūrį ir ką siūlo tuščias laukas. Jau išsaugotos reikšmės nekeičiamos.",
     "defaultStockUnit": "Numatytasis sandėlio vienetas",
     "defaultStockUnit": "Numatytasis sandėlio vienetas",
     "defaultStockUnitHint": "Automatiškai įrašomas naujoms sandėlio dalims. Kiekviena dalis vis tiek gali turėti savo vienetą.",
     "defaultStockUnitHint": "Automatiškai įrašomas naujoms sandėlio dalims. Kiekviena dalis vis tiek gali turėti savo vienetą.",
     "saveWorkshop": "Išsaugoti dirbtuvių nustatymus",
     "saveWorkshop": "Išsaugoti dirbtuvių nustatymus",

+ 1 - 0
messages/lt/vehicles.json

@@ -336,6 +336,7 @@
     "outboard": "Pakabinamas",
     "outboard": "Pakabinamas",
     "inboard": "Įmontuotas",
     "inboard": "Įmontuotas",
     "engineSizeMarine": "Variklis",
     "engineSizeMarine": "Variklis",
+    "engineSizeSavedAs": "Bus išsaugota kaip {unit}",
     "twoStroke": "Dvitaktis",
     "twoStroke": "Dvitaktis",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Registracijos numeris",
     "licensePlateMarine": "Registracijos numeris",

+ 4 - 0
messages/nb/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "liter (L)",
     "liters": "liter (L)",
     "miles": "miles (mi)",
     "miles": "miles (mi)",
     "gallons": "gallon (gal)",
     "gallons": "gallon (gal)",
+    "engineSizeUnit": "Motorvolum",
+    "engineSizeLitres": "Liter (1,6 L)",
+    "engineSizeCc": "Kubikkcentimeter (1598 cc)",
+    "engineSizeUnitHint": "Hvordan et skiltoppslag fyller inn motorvolum, og hva det tomme feltet foreslår. Motorvolum som allerede er lagret, blir stående som de er.",
     "defaultStockUnit": "Standard lagerenhet",
     "defaultStockUnit": "Standard lagerenhet",
     "defaultStockUnitHint": "Fylles ut automatisk for nye lagerdeler. Hver del kan fortsatt ha sin egen enhet.",
     "defaultStockUnitHint": "Fylles ut automatisk for nye lagerdeler. Hver del kan fortsatt ha sin egen enhet.",
     "saveWorkshop": "Lagre verkstedinnstillinger",
     "saveWorkshop": "Lagre verkstedinnstillinger",

+ 1 - 0
messages/nb/vehicles.json

@@ -336,6 +336,7 @@
     "outboard": "Utenbords",
     "outboard": "Utenbords",
     "inboard": "Innenbords",
     "inboard": "Innenbords",
     "engineSizeMarine": "Motor",
     "engineSizeMarine": "Motor",
+    "engineSizeSavedAs": "Lagres som {unit}",
     "twoStroke": "Totakt",
     "twoStroke": "Totakt",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Registreringsnummer",
     "licensePlateMarine": "Registreringsnummer",

+ 4 - 0
messages/nl/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "liters (L)",
     "liters": "liters (L)",
     "miles": "mijlen (mi)",
     "miles": "mijlen (mi)",
     "gallons": "gallons (gal)",
     "gallons": "gallons (gal)",
+    "engineSizeUnit": "Cilinderinhoud",
+    "engineSizeLitres": "Liter (1,6 L)",
+    "engineSizeCc": "Kubieke centimeter (1598 cc)",
+    "engineSizeUnitHint": "Hoe een kentekenopzoeking de cilinderinhoud invult, en wat het lege veld voorstelt. Al opgeslagen waarden blijven zoals ze zijn.",
     "defaultStockUnit": "Standaard voorraadeenheid",
     "defaultStockUnit": "Standaard voorraadeenheid",
     "defaultStockUnitHint": "Vooraf ingevuld bij nieuwe voorraadonderdelen. Elk onderdeel kan nog steeds een eigen eenheid gebruiken.",
     "defaultStockUnitHint": "Vooraf ingevuld bij nieuwe voorraadonderdelen. Elk onderdeel kan nog steeds een eigen eenheid gebruiken.",
     "saveWorkshop": "Werkplaatsinstellingen opslaan",
     "saveWorkshop": "Werkplaatsinstellingen opslaan",

+ 1 - 0
messages/nl/vehicles.json

@@ -336,6 +336,7 @@
     "outboard": "Buitenboord",
     "outboard": "Buitenboord",
     "inboard": "Binnenboord",
     "inboard": "Binnenboord",
     "engineSizeMarine": "Motor",
     "engineSizeMarine": "Motor",
+    "engineSizeSavedAs": "Wordt opgeslagen als {unit}",
     "twoStroke": "Tweetakt",
     "twoStroke": "Tweetakt",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Registratienummer",
     "licensePlateMarine": "Registratienummer",

+ 4 - 0
messages/pl/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litry (L)",
     "liters": "litry (L)",
     "miles": "mile (mi)",
     "miles": "mile (mi)",
     "gallons": "galony (gal)",
     "gallons": "galony (gal)",
+    "engineSizeUnit": "Pojemność silnika",
+    "engineSizeLitres": "Litry (1,6 L)",
+    "engineSizeCc": "Centymetry sześcienne (1598 cc)",
+    "engineSizeUnitHint": "Jak wyszukiwanie po tablicy wpisuje pojemność silnika i co podpowiada puste pole. Już zapisane wartości pozostają bez zmian.",
     "defaultStockUnit": "Domyślna jednostka magazynowa",
     "defaultStockUnit": "Domyślna jednostka magazynowa",
     "defaultStockUnitHint": "Wstępnie uzupełniana dla nowych części w magazynie. Każda część może nadal mieć własną jednostkę.",
     "defaultStockUnitHint": "Wstępnie uzupełniana dla nowych części w magazynie. Każda część może nadal mieć własną jednostkę.",
     "saveWorkshop": "Zapisz ustawienia warsztatu",
     "saveWorkshop": "Zapisz ustawienia warsztatu",

+ 1 - 0
messages/pl/vehicles.json

@@ -336,6 +336,7 @@
     "outboard": "Zaburtowy",
     "outboard": "Zaburtowy",
     "inboard": "Stacjonarny",
     "inboard": "Stacjonarny",
     "engineSizeMarine": "Silnik",
     "engineSizeMarine": "Silnik",
+    "engineSizeSavedAs": "Zostanie zapisane jako {unit}",
     "twoStroke": "Dwusuwowy",
     "twoStroke": "Dwusuwowy",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Numer Rejestracyjny",
     "licensePlateMarine": "Numer Rejestracyjny",

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

@@ -1199,6 +1199,10 @@
     "liters": "litros (L)",
     "liters": "litros (L)",
     "miles": "milhas (mi)",
     "miles": "milhas (mi)",
     "gallons": "galões (gal)",
     "gallons": "galões (gal)",
+    "engineSizeUnit": "Cilindrada",
+    "engineSizeLitres": "Litros (1,6 L)",
+    "engineSizeCc": "Centímetros cúbicos (1598 cc)",
+    "engineSizeUnitHint": "Como a consulta de placa preenche a cilindrada e o que o campo vazio sugere. As cilindradas já salvas ficam como estão.",
     "defaultStockUnit": "Unidade de estoque padrão",
     "defaultStockUnit": "Unidade de estoque padrão",
     "defaultStockUnitHint": "Preenchida automaticamente em novas peças do estoque. Cada peça ainda pode usar sua própria unidade.",
     "defaultStockUnitHint": "Preenchida automaticamente em novas peças do estoque. Cada peça ainda pode usar sua própria unidade.",
     "saveWorkshop": "Salvar configurações da oficina",
     "saveWorkshop": "Salvar configurações da oficina",

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

@@ -336,6 +336,7 @@
     "outboard": "De Popa",
     "outboard": "De Popa",
     "inboard": "De Centro",
     "inboard": "De Centro",
     "engineSizeMarine": "Motor",
     "engineSizeMarine": "Motor",
+    "engineSizeSavedAs": "Salvo como {unit}",
     "twoStroke": "Dois Tempos",
     "twoStroke": "Dois Tempos",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Número de Registro",
     "licensePlateMarine": "Número de Registro",

+ 4 - 0
messages/pt-PT/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litros (L)",
     "liters": "litros (L)",
     "miles": "milhas (mi)",
     "miles": "milhas (mi)",
     "gallons": "galões (gal)",
     "gallons": "galões (gal)",
+    "engineSizeUnit": "Cilindrada",
+    "engineSizeLitres": "Litros (1,6 L)",
+    "engineSizeCc": "Centímetros cúbicos (1598 cc)",
+    "engineSizeUnitHint": "Como a consulta de matrícula preenche a cilindrada e o que o campo vazio sugere. As cilindradas já guardadas ficam como estão.",
     "defaultStockUnit": "Unidade de stock predefinida",
     "defaultStockUnit": "Unidade de stock predefinida",
     "defaultStockUnitHint": "Preenchida automaticamente em novas peças do stock. Cada peça pode continuar a usar a sua própria unidade.",
     "defaultStockUnitHint": "Preenchida automaticamente em novas peças do stock. Cada peça pode continuar a usar a sua própria unidade.",
     "saveWorkshop": "Guardar definições da oficina",
     "saveWorkshop": "Guardar definições da oficina",

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

@@ -336,6 +336,7 @@
     "outboard": "Fora de Borda",
     "outboard": "Fora de Borda",
     "inboard": "Interior",
     "inboard": "Interior",
     "engineSizeMarine": "Motor",
     "engineSizeMarine": "Motor",
+    "engineSizeSavedAs": "Guardado como {unit}",
     "twoStroke": "Dois Tempos",
     "twoStroke": "Dois Tempos",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Número de Registo",
     "licensePlateMarine": "Número de Registo",

+ 4 - 0
messages/ru/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "литры (л)",
     "liters": "литры (л)",
     "miles": "мили (mi)",
     "miles": "мили (mi)",
     "gallons": "галлоны (gal)",
     "gallons": "галлоны (gal)",
+    "engineSizeUnit": "Объём двигателя",
+    "engineSizeLitres": "Литры (1,6 L)",
+    "engineSizeCc": "Кубические сантиметры (1598 cc)",
+    "engineSizeUnitHint": "Как поиск по номеру заполняет объём двигателя и что подсказывает пустое поле. Уже сохранённые значения не меняются.",
     "defaultStockUnit": "Единица склада по умолчанию",
     "defaultStockUnit": "Единица склада по умолчанию",
     "defaultStockUnitHint": "Подставляется для новых складских позиций. У каждой позиции может быть своя единица.",
     "defaultStockUnitHint": "Подставляется для новых складских позиций. У каждой позиции может быть своя единица.",
     "saveWorkshop": "Сохранить настройки мастерской",
     "saveWorkshop": "Сохранить настройки мастерской",

+ 1 - 0
messages/ru/vehicles.json

@@ -336,6 +336,7 @@
     "outboard": "Подвесной",
     "outboard": "Подвесной",
     "inboard": "Стационарный",
     "inboard": "Стационарный",
     "engineSizeMarine": "Двигатель",
     "engineSizeMarine": "Двигатель",
+    "engineSizeSavedAs": "Сохранится как {unit}",
     "twoStroke": "Двухтактный",
     "twoStroke": "Двухтактный",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Регистрационный Номер",
     "licensePlateMarine": "Регистрационный Номер",

+ 4 - 0
messages/tr/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litre (L)",
     "liters": "litre (L)",
     "miles": "mil (mi)",
     "miles": "mil (mi)",
     "gallons": "galon (gal)",
     "gallons": "galon (gal)",
+    "engineSizeUnit": "Motor hacmi",
+    "engineSizeLitres": "Litre (1,6 L)",
+    "engineSizeCc": "Santimetreküp (1598 cc)",
+    "engineSizeUnitHint": "Plaka sorgusunun motor hacmini nasıl dolduracağı ve boş alanın ne önereceği. Önceden kaydedilmiş değerler olduğu gibi kalır.",
     "defaultStockUnit": "Varsayılan stok birimi",
     "defaultStockUnit": "Varsayılan stok birimi",
     "defaultStockUnitHint": "Yeni stok parçalarında önceden doldurulur. Her parça yine de kendi birimini kullanabilir.",
     "defaultStockUnitHint": "Yeni stok parçalarında önceden doldurulur. Her parça yine de kendi birimini kullanabilir.",
     "saveWorkshop": "Atölye Ayarlarını Kaydet",
     "saveWorkshop": "Atölye Ayarlarını Kaydet",

+ 1 - 0
messages/tr/vehicles.json

@@ -336,6 +336,7 @@
     "outboard": "Dıştan Takma",
     "outboard": "Dıştan Takma",
     "inboard": "İçten Takma",
     "inboard": "İçten Takma",
     "engineSizeMarine": "Motor",
     "engineSizeMarine": "Motor",
+    "engineSizeSavedAs": "{unit} olarak kaydedilir",
     "twoStroke": "İki Zamanlı",
     "twoStroke": "İki Zamanlı",
     "vinMarine": "HIN",
     "vinMarine": "HIN",
     "licensePlateMarine": "Tescil Numarası",
     "licensePlateMarine": "Tescil Numarası",

+ 2 - 0
src/__tests__/features/integrations/checkcardetails.test.ts

@@ -97,6 +97,7 @@ describe('checkcardetails mapping', () => {
       color: 'Black',
       color: 'Black',
       fuelType: 'diesel',
       fuelType: 'diesel',
       engineSize: '3.0 L',
       engineSize: '3.0 L',
+      engineCc: 2967,
       vehicleClass: 'Car',
       vehicleClass: 'Car',
       inspectionDue: '2027-01-26',
       inspectionDue: '2027-01-26',
     })
     })
@@ -113,6 +114,7 @@ describe('checkcardetails mapping', () => {
       fuelType: 'gasoline',
       fuelType: 'gasoline',
       transmission: 'automatic',
       transmission: 'automatic',
       engineSize: '1.6 L',
       engineSize: '1.6 L',
+      engineCc: 1598,
       vehicleClass: 'Car',
       vehicleClass: 'Car',
       firstRegistered: '2007-05-14',
       firstRegistered: '2007-05-14',
       weights: { kerb: 1155, grossMax: 1605 },
       weights: { kerb: 1155, grossMax: 1605 },

+ 49 - 0
src/__tests__/features/integrations/engine-size-lookup.test.ts

@@ -0,0 +1,49 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const lookupVehicle = vi.fn()
+vi.mock('@/features/integrations/Lib/connections', () => ({
+  loadConnection: vi.fn(async () => ({
+    ctx: { connection: { organizationId: 'org1' } },
+    server: { manifest: { id: 'checkcardetails', name: 'Check Car Details' }, lookupVehicle },
+  })),
+}))
+vi.mock('@/lib/db', () => ({
+  db: { appSetting: { findUnique: vi.fn() } },
+}))
+
+import { askRegistry } from '@/features/integrations/Lib/vehicle-lookup'
+import { db } from '@/lib/db'
+
+function workshopUnit(value: string | null) {
+  vi.mocked(db.appSetting.findUnique).mockResolvedValue(
+    (value === null ? null : { value }) as never
+  )
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  // A 649 cc motorcycle, as a registry answers it.
+  lookupVehicle.mockResolvedValue({ make: 'Kawasaki', engineSize: '0.6 L', engineCc: 649 })
+})
+
+describe('askRegistry writes engine size in the workshop unit', () => {
+  it('writes cubic centimetres for a workshop that chose them', async () => {
+    workshopUnit('cc')
+    const { result } = await askRegistry('conn1', { plate: 'EA65AMX' })
+    expect(result?.engineSize).toBe('649 cc')
+  })
+
+  it('writes exact litres by default, never a rounded 0.6 L', async () => {
+    workshopUnit(null)
+    const { result } = await askRegistry('conn1', { plate: 'EA65AMX' })
+    expect(result?.engineSize).toBe('0.649 L')
+  })
+
+  it("keeps the registry's own text when it has no exact figure", async () => {
+    workshopUnit('cc')
+    lookupVehicle.mockResolvedValue({ make: 'Ford', engineSize: '3.0' })
+    const { result } = await askRegistry('conn1', { vin: '1FA6P8TH0J5100000' })
+    expect(result?.engineSize).toBe('3.0')
+    expect(db.appSetting.findUnique).not.toHaveBeenCalled()
+  })
+})

+ 1 - 0
src/__tests__/features/integrations/nhtsa.test.ts

@@ -56,6 +56,7 @@ describe('NHTSA VIN decode', () => {
       fuelType: 'gasoline',
       fuelType: 'gasoline',
       transmission: 'automatic',
       transmission: 'automatic',
       engineSize: '3.0',
       engineSize: '3.0',
+      engineCc: 2999,
       engineCode: 'J30A4',
       engineCode: 'J30A4',
       vehicleClass: 'Coupe',
       vehicleClass: 'Coupe',
     })
     })

+ 6 - 0
src/__tests__/features/integrations/openapi-automotive.test.ts

@@ -141,6 +141,7 @@ describe('openapi automotive mapper', () => {
       fuelType: 'gasoline',
       fuelType: 'gasoline',
       transmission: 'manual',
       transmission: 'manual',
       engineSize: '1.6 L',
       engineSize: '1.6 L',
+      engineCc: 1598,
       vehicleClass: 'BERLINE',
       vehicleClass: 'BERLINE',
       firstRegistered: '2008-10-22',
       firstRegistered: '2008-10-22',
     })
     })
@@ -157,6 +158,7 @@ describe('openapi automotive mapper', () => {
       fuelType: 'diesel',
       fuelType: 'diesel',
       transmission: 'automatic',
       transmission: 'automatic',
       engineSize: '3.0 L',
       engineSize: '3.0 L',
+      engineCc: 2987,
       engineCode: 'B630WM',
       engineCode: 'B630WM',
       vehicleClass: 'SUV',
       vehicleClass: 'SUV',
     })
     })
@@ -278,6 +280,7 @@ describe('openapi automotive sandbox contract', () => {
       fuelType: 'diesel',
       fuelType: 'diesel',
       transmission: 'manual',
       transmission: 'manual',
       engineSize: '1.5 L',
       engineSize: '1.5 L',
+      engineCc: 1461,
       vehicleClass: 'MONOSPACE COMPACT',
       vehicleClass: 'MONOSPACE COMPACT',
       firstRegistered: '2016-06-24',
       firstRegistered: '2016-06-24',
     })
     })
@@ -307,6 +310,7 @@ describe('openapi automotive sandbox contract', () => {
       licensePlate: 'FS918BG',
       licensePlate: 'FS918BG',
       fuelType: 'diesel',
       fuelType: 'diesel',
       engineSize: '1.6 L',
       engineSize: '1.6 L',
+      engineCc: 1560,
     })
     })
   })
   })
 
 
@@ -321,6 +325,7 @@ describe('openapi automotive sandbox contract', () => {
       fuelType: 'diesel',
       fuelType: 'diesel',
       transmission: 'automatic',
       transmission: 'automatic',
       engineSize: '3.0 L',
       engineSize: '3.0 L',
+      engineCc: 2987,
       engineCode: 'B630WM',
       engineCode: 'B630WM',
       vehicleClass: 'SUV',
       vehicleClass: 'SUV',
     })
     })
@@ -337,6 +342,7 @@ describe('openapi automotive sandbox contract', () => {
       licensePlate: '5428GXS',
       licensePlate: '5428GXS',
       fuelType: 'diesel',
       fuelType: 'diesel',
       engineSize: '1.5 L',
       engineSize: '1.5 L',
+      engineCc: 1461,
       firstRegistered: '2010-07-06',
       firstRegistered: '2010-07-06',
     })
     })
   })
   })

+ 2 - 0
src/__tests__/features/integrations/rdw.test.ts

@@ -73,6 +73,7 @@ describe('rdw mapper', () => {
       color: 'Zwart',
       color: 'Zwart',
       fuelType: 'hybrid',
       fuelType: 'hybrid',
       engineSize: '1.8 L',
       engineSize: '1.8 L',
+      engineCc: 1798,
       vehicleClass: 'Personenauto',
       vehicleClass: 'Personenauto',
       firstRegistered: '2018-04-26',
       firstRegistered: '2018-04-26',
       inspectionDue: '2027-04-26',
       inspectionDue: '2027-04-26',
@@ -90,6 +91,7 @@ describe('rdw mapper', () => {
       color: 'Zwart',
       color: 'Zwart',
       fuelType: 'gasoline',
       fuelType: 'gasoline',
       engineSize: '1.2 L',
       engineSize: '1.2 L',
+      engineCc: 1197,
       vehicleClass: 'Personenauto',
       vehicleClass: 'Personenauto',
       firstRegistered: '2010-05-06',
       firstRegistered: '2010-05-06',
       inspectionDue: '2027-09-30',
       inspectionDue: '2027-09-30',

+ 13 - 0
src/__tests__/features/integrations/regcheck.test.ts

@@ -65,6 +65,7 @@ describe('regcheck mapper, Australia state by state', () => {
       licensePlate: 'BEW76P',
       licensePlate: 'BEW76P',
       transmission: 'manual',
       transmission: 'manual',
       engineSize: '1.6 L',
       engineSize: '1.6 L',
+      engineCc: 1600,
       vehicleClass: 'Lx 5-Speed Manual',
       vehicleClass: 'Lx 5-Speed Manual',
     })
     })
   })
   })
@@ -88,6 +89,7 @@ describe('regcheck mapper, Australia state by state', () => {
       vin: 'WV1ZZZ2HZEA044303',
       vin: 'WV1ZZZ2HZEA044303',
       fuelType: 'diesel',
       fuelType: 'diesel',
       engineSize: '2.0 L',
       engineSize: '2.0 L',
+      engineCc: 1968,
       engineCode: 'CNEA|CSHA',
       engineCode: 'CNEA|CSHA',
       vehicleClass: 'Ute',
       vehicleClass: 'Ute',
     })
     })
@@ -144,6 +146,7 @@ describe('regcheck mapper, other countries', () => {
       vin: '7AT0H64TX13059294',
       vin: '7AT0H64TX13059294',
       fuelType: 'gasoline',
       fuelType: 'gasoline',
       engineSize: '2.5 L',
       engineSize: '2.5 L',
+      engineCc: 2499,
       vehicleClass: 'SEDAN',
       vehicleClass: 'SEDAN',
     })
     })
   })
   })
@@ -155,6 +158,7 @@ describe('regcheck mapper, other countries', () => {
       year: 2009,
       year: 2009,
       vin: 'KNADE223696445551',
       vin: 'KNADE223696445551',
       engineSize: '1.6 L',
       engineSize: '1.6 L',
+      engineCc: 1600,
       vehicleClass: 'Sedan 4D',
       vehicleClass: 'Sedan 4D',
     })
     })
   })
   })
@@ -167,6 +171,7 @@ describe('regcheck mapper, other countries', () => {
       vin: 'WAUZZZ4BX4N093080',
       vin: 'WAUZZZ4BX4N093080',
       fuelType: 'diesel',
       fuelType: 'diesel',
       engineSize: '1.9 L',
       engineSize: '1.9 L',
+      engineCc: 1896,
       vehicleClass: 'SALOON',
       vehicleClass: 'SALOON',
     })
     })
   })
   })
@@ -181,6 +186,7 @@ describe('regcheck mapper, other countries', () => {
       fuelType: 'gasoline',
       fuelType: 'gasoline',
       transmission: 'manual',
       transmission: 'manual',
       engineSize: '1.8 L',
       engineSize: '1.8 L',
+      engineCc: 1798,
       vehicleClass: 'Halvkombi',
       vehicleClass: 'Halvkombi',
     })
     })
   })
   })
@@ -193,6 +199,7 @@ describe('regcheck mapper, other countries', () => {
       vin: 'ZFFKA36B000096243',
       vin: 'ZFFKA36B000096243',
       fuelType: 'gasoline',
       fuelType: 'gasoline',
       engineSize: '3.4 L',
       engineSize: '3.4 L',
+      engineCc: 3405,
       firstRegistered: '1993-05-03',
       firstRegistered: '1993-05-03',
       lastInspected: '2025-04-10',
       lastInspected: '2025-04-10',
       weights: { kerb: 1440, grossMax: 1680 },
       weights: { kerb: 1440, grossMax: 1680 },
@@ -208,6 +215,7 @@ describe('regcheck mapper, other countries', () => {
       fuelType: 'diesel',
       fuelType: 'diesel',
       transmission: 'automatic',
       transmission: 'automatic',
       engineSize: '1.6 L',
       engineSize: '1.6 L',
+      engineCc: 1598,
       engineCode: 'CAYC',
       engineCode: 'CAYC',
       vehicleClass: 'Car',
       vehicleClass: 'Car',
       firstRegistered: '2010-09-21',
       firstRegistered: '2010-09-21',
@@ -230,6 +238,7 @@ describe('regcheck mapper, other countries', () => {
       vin: 'TMBCK11U4Y2370804',
       vin: 'TMBCK11U4Y2370804',
       color: 'Modrá',
       color: 'Modrá',
       engineSize: '1.6 L',
       engineSize: '1.6 L',
+      engineCc: 1595,
     })
     })
     expect(mapVehicle(fixture('HR'))).toEqual({
     expect(mapVehicle(fixture('HR'))).toEqual({
       make: 'BMW',
       make: 'BMW',
@@ -246,6 +255,7 @@ describe('regcheck mapper, other countries', () => {
       vin: 'TMBCS21Z262149586',
       vin: 'TMBCS21Z262149586',
       fuelType: 'diesel',
       fuelType: 'diesel',
       engineSize: '1.9 L',
       engineSize: '1.9 L',
+      engineCc: 1896,
       vehicleClass: 'sedan/limuzína',
       vehicleClass: 'sedan/limuzína',
       firstRegistered: '2005-10-04',
       firstRegistered: '2005-10-04',
       weights: { kerb: undefined, grossMax: 1970 },
       weights: { kerb: undefined, grossMax: 1970 },
@@ -262,6 +272,7 @@ describe('regcheck mapper, other countries', () => {
       color: 'Hvit',
       color: 'Hvit',
       fuelType: 'diesel',
       fuelType: 'diesel',
       engineSize: '2.3 L',
       engineSize: '2.3 L',
+      engineCc: 2287,
       firstRegistered: '2014-08-19',
       firstRegistered: '2014-08-19',
       inspectionDue: '2018-10-31',
       inspectionDue: '2018-10-31',
       tyres: [
       tyres: [
@@ -282,6 +293,7 @@ describe('regcheck mapper, other countries', () => {
       vin: 'VTTBK232100104138',
       vin: 'VTTBK232100104138',
       fuelType: 'gasoline',
       fuelType: 'gasoline',
       engineSize: '0.5 L',
       engineSize: '0.5 L',
+      engineCc: 487,
       firstRegistered: '2006-11-23',
       firstRegistered: '2006-11-23',
       weights: { kerb: 380, grossMax: 380 },
       weights: { kerb: 380, grossMax: 380 },
     })
     })
@@ -299,6 +311,7 @@ describe('regcheck mapper, other countries', () => {
       color: 'Silver',
       color: 'Silver',
       fuelType: 'gasoline',
       fuelType: 'gasoline',
       engineSize: '1.4 L',
       engineSize: '1.4 L',
+      engineCc: 1360,
       vehicleClass: 'Motorbike',
       vehicleClass: 'Motorbike',
     })
     })
     expect(vehicleJsonOf(envelope('AU-NSW'))?.State).toBe('NSW')
     expect(vehicleJsonOf(envelope('AU-NSW'))?.State).toBe('NSW')

+ 1 - 0
src/__tests__/features/integrations/vegvesen.test.ts

@@ -90,6 +90,7 @@ describe('vegvesen mapper', () => {
       fuelType: 'diesel',
       fuelType: 'diesel',
       transmission: 'automatic',
       transmission: 'automatic',
       engineSize: '2.0 L',
       engineSize: '2.0 L',
+      engineCc: 1969,
       engineCode: 'D4204T14',
       engineCode: 'D4204T14',
       vehicleClass: 'Personbil',
       vehicleClass: 'Personbil',
       firstRegistered: '2017-03-21',
       firstRegistered: '2017-03-21',

+ 89 - 0
src/__tests__/lib/engine-size.test.ts

@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest'
+import {
+  bareEngineUnit,
+  engineSizeHint,
+  engineSizePlaceholder,
+  engineSizeToSave,
+  engineSizeUnitOf,
+  formatEngineSize,
+  withEngineUnit,
+} from '@/lib/engine-size'
+
+describe('formatEngineSize', () => {
+  it('writes cubic centimetres exactly for a cc workshop', () => {
+    expect(formatEngineSize(649, 'cc')).toBe('649 cc')
+    expect(formatEngineSize(1598, 'cc')).toBe('1598 cc')
+    expect(formatEngineSize(2998.83, 'cc')).toBe('2999 cc')
+  })
+
+  it('writes litres for a litres workshop, keeping small engines exact', () => {
+    expect(formatEngineSize(1598, 'litres')).toBe('1.6 L')
+    expect(formatEngineSize(649, 'litres')).toBe('0.649 L')
+    expect(formatEngineSize(49, 'litres')).toBe('0.049 L')
+    expect(formatEngineSize(125, 'litres')).toBe('0.125 L')
+  })
+
+  it('writes nothing for a figure that is not a displacement', () => {
+    expect(formatEngineSize(undefined, 'cc')).toBeUndefined()
+    expect(formatEngineSize(0, 'litres')).toBeUndefined()
+    expect(formatEngineSize(Number.NaN, 'cc')).toBeUndefined()
+  })
+})
+
+describe('typing a bare number', () => {
+  it('reads small numbers as litres and large ones as cubic centimetres', () => {
+    expect(bareEngineUnit('1.6')).toBe('L')
+    expect(bareEngineUnit('2,0')).toBe('L')
+    expect(bareEngineUnit('649')).toBe('cc')
+    expect(bareEngineUnit(' 1598 ')).toBe('cc')
+  })
+
+  it('leaves anything that is not just a number alone', () => {
+    expect(bareEngineUnit('2.0 TDI')).toBeNull()
+    expect(bareEngineUnit('1598 cc')).toBeNull()
+    expect(bareEngineUnit('316i mk1')).toBeNull()
+    expect(bareEngineUnit('')).toBeNull()
+    expect(bareEngineUnit('0')).toBeNull()
+  })
+
+  it('saves a bare number with its unit and everything else as typed', () => {
+    expect(withEngineUnit('649')).toBe('649 cc')
+    expect(withEngineUnit('1.6')).toBe('1.6 L')
+    expect(withEngineUnit('2,0')).toBe('2.0 L')
+    expect(withEngineUnit('2.0 TDI')).toBe('2.0 TDI')
+    expect(withEngineUnit('')).toBe('')
+  })
+})
+
+describe('the workshop setting', () => {
+  it('is litres until cubic centimetres are chosen', () => {
+    expect(engineSizeUnitOf(undefined)).toBe('litres')
+    expect(engineSizeUnitOf('litres')).toBe('litres')
+    expect(engineSizeUnitOf('cc')).toBe('cc')
+    expect(engineSizeUnitOf('nonsense')).toBe('litres')
+  })
+
+  it('suggests the same engine in either unit', () => {
+    expect(engineSizePlaceholder('litres')).toBe('1.6 L')
+    expect(engineSizePlaceholder('cc')).toBe('1598 cc')
+  })
+})
+
+describe('editing a vehicle that already has an engine size', () => {
+  it('keeps the saved value exactly when the field was not changed', () => {
+    expect(engineSizeToSave('649', '649')).toBe('649')
+    expect(engineSizeToSave('2,0', '2,0')).toBe('2,0')
+    expect(engineSizeHint('649', '649')).toBeNull()
+  })
+
+  it('adds the unit once the field is changed', () => {
+    expect(engineSizeToSave('690', '649')).toBe('690 cc')
+    expect(engineSizeHint('690', '649')).toBe('cc')
+  })
+
+  it('adds the unit to a new vehicle', () => {
+    expect(engineSizeToSave('1.6', undefined)).toBe('1.6 L')
+    expect(engineSizeToSave('', null)).toBe('')
+    expect(engineSizeHint('1.6', '')).toBe('L')
+  })
+})

+ 104 - 97
src/app/(authenticated)/layout.tsx

@@ -28,6 +28,7 @@ import { SupportBubble } from '@/features/support/Components/SupportBubble'
 import { isSupportEnabled } from '@/lib/support'
 import { isSupportEnabled } from '@/lib/support'
 import { ServiceTypeProvider } from '@/components/service-type-context'
 import { ServiceTypeProvider } from '@/components/service-type-context'
 import { TypeKeyProvider } from '@/components/type-key-context'
 import { TypeKeyProvider } from '@/components/type-key-context'
+import { EngineSizeUnitProvider } from '@/components/engine-size-context'
 import { LicenseExpiryProvider } from '@/components/license-expiry-context'
 import { LicenseExpiryProvider } from '@/components/license-expiry-context'
 import {
 import {
   LICENSE_TOKEN_MAX_AGE_DAYS,
   LICENSE_TOKEN_MAX_AGE_DAYS,
@@ -268,118 +269,124 @@ export default async function DashboardLayout({ children }: { children: React.Re
   return (
   return (
     <ServiceTypeProvider serviceType={data.serviceType}>
     <ServiceTypeProvider serviceType={data.serviceType}>
       <TypeKeyProvider enabled={data.typeKeyEnabled}>
       <TypeKeyProvider enabled={data.typeKeyEnabled}>
-        <LicenseExpiryProvider
-          daysUntilExpiry={daysUntilExpiry}
-          unverifiedDaysLeft={unverifiedDaysLeft}
-          dismissed={licenseExpiryDismissed}
-        >
-          <WhiteLabelCtaProvider show={showWhiteLabelCta}>
-            {/* Accent line along the very top of the viewport — the card hairline at
+        <EngineSizeUnitProvider unit={data.engineSizeUnit}>
+          <LicenseExpiryProvider
+            daysUntilExpiry={daysUntilExpiry}
+            unverifiedDaysLeft={unverifiedDaysLeft}
+            dismissed={licenseExpiryDismissed}
+          >
+            <WhiteLabelCtaProvider show={showWhiteLabelCta}>
+              {/* Accent line along the very top of the viewport — the card hairline at
         page scale: primary on the left, gone by the far edge. Marks where the
         page scale: primary on the left, gone by the far edge. Marks where the
         app begins against the browser chrome. */}
         app begins against the browser chrome. */}
-            <div
-              aria-hidden
-              className="pointer-events-none fixed inset-x-0 top-0 z-50 h-px bg-linear-to-r from-primary via-primary/35 to-transparent"
-            />
-            {/* Watches for a notice posted while this page is already open. Here
+              <div
+                aria-hidden
+                className="pointer-events-none fixed inset-x-0 top-0 z-50 h-px bg-linear-to-r from-primary via-primary/35 to-transparent"
+              />
+              {/* Watches for a notice posted while this page is already open. Here
               rather than beside the banner because the socket authenticates on
               rather than beside the banner because the socket authenticates on
               the session cookie, which the sign-in page does not have. */}
               the session cookie, which the sign-in page does not have. */}
-            <BroadcastLive />
-            {/* The demo banner already occupies the header, and demo image tags
+              <BroadcastLive />
+              {/* The demo banner already occupies the header, and demo image tags
         (demo-abc1234) are not versions a visitor should be notified about. */}
         (demo-abc1234) are not versions a visitor should be notified about. */}
-            {!isDemoMode && (
-              <UpdateBanner
-                currentVersion={process.env.APP_VERSION || 'development'}
-                lastSeenVersion={data.lastSeenVersion}
-                shownVersion={data.updateBannerVersion}
-                shownAt={data.updateBannerShownAt?.toISOString() ?? null}
-                releaseNotesUrl={
-                  process.env.RELEASE_NOTES_URL || 'https://github.com/Torqvoice/torqvoice/releases'
+              {!isDemoMode && (
+                <UpdateBanner
+                  currentVersion={process.env.APP_VERSION || 'development'}
+                  lastSeenVersion={data.lastSeenVersion}
+                  shownVersion={data.updateBannerVersion}
+                  shownAt={data.updateBannerShownAt?.toISOString() ?? null}
+                  releaseNotesUrl={
+                    process.env.RELEASE_NOTES_URL ||
+                    'https://github.com/Torqvoice/torqvoice/releases'
+                  }
+                />
+              )}
+              <SidebarProvider
+                defaultOpen={sidebarOpen}
+                style={
+                  {
+                    '--sidebar-width': '19rem',
+                  } as React.CSSProperties
                 }
                 }
-              />
-            )}
-            <SidebarProvider
-              defaultOpen={sidebarOpen}
-              style={
-                {
-                  '--sidebar-width': '19rem',
-                } as React.CSSProperties
-              }
-            >
-              <DateSettingsProvider
-                dateFormat={data.dateFormat}
-                timeFormat={data.timeFormat}
-                timezone={data.timezone}
-                weekStartDay={data.weekStartDay}
               >
               >
-                <CurrencySettingsProvider
-                  currencyCode={data.currencyCode}
-                  currencyFormat={data.currencyFormat}
+                <DateSettingsProvider
+                  dateFormat={data.dateFormat}
+                  timeFormat={data.timeFormat}
+                  timezone={data.timezone}
+                  weekStartDay={data.weekStartDay}
                 >
                 >
-                  <ConfirmProvider>
-                    <PlateLookupProvider
-                      value={{
-                        available: plateLookupAvailable,
-                        canCreate: canCreateVehicles,
-                        registryName: plateLookupRegistry,
-                      }}
-                    >
-                      <FeatureHintProvider
-                        initialSeen={seenHints}
-                        pending={[...pendingHints, ...announcements]}
+                  <CurrencySettingsProvider
+                    currencyCode={data.currencyCode}
+                    currencyFormat={data.currencyFormat}
+                  >
+                    <ConfirmProvider>
+                      <PlateLookupProvider
+                        value={{
+                          available: plateLookupAvailable,
+                          canCreate: canCreateVehicles,
+                          registryName: plateLookupRegistry,
+                        }}
                       >
                       >
-                        {/* One socket for the whole app: record changes, who
+                        <FeatureHintProvider
+                          initialSeen={seenHints}
+                          pending={[...pendingHints, ...announcements]}
+                        >
+                          {/* One socket for the whole app: record changes, who
                           else is on a record, and the older workshop-wide
                           else is on a record, and the older workshop-wide
                           channels the remaining pages still read. */}
                           channels the remaining pages still read. */}
-                        <RealtimeProvider>
-                          <TimeClockProvider technicianIds={technicianIds}>
-                            <SaveShortcutListener />
-                            <AnalyticsIdentity
-                              userId={data.userId}
-                              organizationId={data.organizationId}
-                            />
-                            <AppSidebar
-                              companyLogo={data.companyLogo}
-                              organizations={data.organizations}
-                              activeOrgId={data.organizationId}
-                              isSuperAdmin={data.isSuperAdmin}
-                              features={features}
-                              tireHotelEnabled={tireHotelEnabled}
-                              aiEnabled={aiEnabled}
-                              visibleSubjects={visibleSubjects}
-                              announcement={announcements[0] ?? null}
-                              isAdminOrOwner={isOwnerOrAdmin}
-                              counts={sidebarCounts}
-                              isTechnician={technicianIds.length > 0}
-                            />
-                            <SidebarInset>
-                              {/* A flex column with a real height, so the `flex-1` every
+                          <RealtimeProvider>
+                            <TimeClockProvider technicianIds={technicianIds}>
+                              <SaveShortcutListener />
+                              <AnalyticsIdentity
+                                userId={data.userId}
+                                organizationId={data.organizationId}
+                              />
+                              <AppSidebar
+                                companyLogo={data.companyLogo}
+                                organizations={data.organizations}
+                                activeOrgId={data.organizationId}
+                                isSuperAdmin={data.isSuperAdmin}
+                                features={features}
+                                tireHotelEnabled={tireHotelEnabled}
+                                aiEnabled={aiEnabled}
+                                visibleSubjects={visibleSubjects}
+                                announcement={announcements[0] ?? null}
+                                isAdminOrOwner={isOwnerOrAdmin}
+                                counts={sidebarCounts}
+                                isTechnician={technicianIds.length > 0}
+                              />
+                              <SidebarInset>
+                                {/* A flex column with a real height, so the `flex-1` every
                           page already writes on its wrapper actually resolves.
                           page already writes on its wrapper actually resolves.
                           Without it a page that wants to fill the window (the
                           Without it a page that wants to fill the window (the
                           work board's week timeline) stopped at its content and
                           work board's week timeline) stopped at its content and
                           left the rest of the screen blank. */}
                           left the rest of the screen blank. */}
-                              <div className="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">
-                                {children}
-                              </div>
-                            </SidebarInset>
-                            <SearchCommand />
-                            <PlateLookupCommand />
-                            {isOwnerOrAdmin && <NotificationInitializer />}
-                            <OnlineTracker />
-                            <UpdateWatcher version={buildVersion()} />
-                            <InstallBanner />
-                          </TimeClockProvider>
-                        </RealtimeProvider>
-                      </FeatureHintProvider>
-                    </PlateLookupProvider>
-                  </ConfirmProvider>
-                </CurrencySettingsProvider>
-              </DateSettingsProvider>
-            </SidebarProvider>
-            <MobileBottomNav isSuperAdmin={data.isSuperAdmin} tireHotelEnabled={tireHotelEnabled} />
-            {supportEnabled && <SupportBubble />}
-          </WhiteLabelCtaProvider>
-        </LicenseExpiryProvider>
+                                <div className="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">
+                                  {children}
+                                </div>
+                              </SidebarInset>
+                              <SearchCommand />
+                              <PlateLookupCommand />
+                              {isOwnerOrAdmin && <NotificationInitializer />}
+                              <OnlineTracker />
+                              <UpdateWatcher version={buildVersion()} />
+                              <InstallBanner />
+                            </TimeClockProvider>
+                          </RealtimeProvider>
+                        </FeatureHintProvider>
+                      </PlateLookupProvider>
+                    </ConfirmProvider>
+                  </CurrencySettingsProvider>
+                </DateSettingsProvider>
+              </SidebarProvider>
+              <MobileBottomNav
+                isSuperAdmin={data.isSuperAdmin}
+                tireHotelEnabled={tireHotelEnabled}
+              />
+              {supportEnabled && <SupportBubble />}
+            </WhiteLabelCtaProvider>
+          </LicenseExpiryProvider>
+        </EngineSizeUnitProvider>
       </TypeKeyProvider>
       </TypeKeyProvider>
     </ServiceTypeProvider>
     </ServiceTypeProvider>
   )
   )

+ 25 - 0
src/app/(authenticated)/settings/workshop/workshop-settings.tsx

@@ -32,6 +32,7 @@ import {
 } from '@/components/ui/alert-dialog'
 } from '@/components/ui/alert-dialog'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
+import { type EngineSizeUnit, engineSizeUnitOf } from '@/lib/engine-size'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { assignTechToUnassignedWorkOrders } from '@/features/workboard/Actions/technicianActions'
 import { assignTechToUnassignedWorkOrders } from '@/features/workboard/Actions/technicianActions'
 import {
 import {
@@ -101,6 +102,9 @@ export function WorkshopSettings({
     settings[SETTING_KEYS.DEFAULT_LABOR_RATE] || ''
     settings[SETTING_KEYS.DEFAULT_LABOR_RATE] || ''
   )
   )
   const [unitSystem, setUnitSystem] = useState(settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial')
   const [unitSystem, setUnitSystem] = useState(settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial')
+  const [engineSizeUnit, setEngineSizeUnit] = useState<EngineSizeUnit>(
+    engineSizeUnitOf(settings[SETTING_KEYS.ENGINE_SIZE_UNIT])
+  )
   const [defaultUnit, setDefaultUnit] = useState(
   const [defaultUnit, setDefaultUnit] = useState(
     settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''
     settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''
   )
   )
@@ -198,6 +202,7 @@ export function WorkshopSettings({
       [SETTING_KEYS.DEFAULT_TECHNICIAN]: selectedTechName,
       [SETTING_KEYS.DEFAULT_TECHNICIAN]: selectedTechName,
       [SETTING_KEYS.DEFAULT_LABOR_RATE]: defaultLaborRate,
       [SETTING_KEYS.DEFAULT_LABOR_RATE]: defaultLaborRate,
       [SETTING_KEYS.UNIT_SYSTEM]: unitSystem,
       [SETTING_KEYS.UNIT_SYSTEM]: unitSystem,
+      [SETTING_KEYS.ENGINE_SIZE_UNIT]: engineSizeUnit,
       [SETTING_KEYS.INVENTORY_DEFAULT_UNIT]: defaultUnit.trim(),
       [SETTING_KEYS.INVENTORY_DEFAULT_UNIT]: defaultUnit.trim(),
       [SETTING_KEYS.WORKBOARD_WORK_DAY_START]: workDayStart,
       [SETTING_KEYS.WORKBOARD_WORK_DAY_START]: workDayStart,
       [SETTING_KEYS.WORKBOARD_WORK_DAY_END]: workDayEnd,
       [SETTING_KEYS.WORKBOARD_WORK_DAY_END]: workDayEnd,
@@ -550,6 +555,26 @@ export function WorkshopSettings({
               )}
               )}
             </div>
             </div>
 
 
+            {/* A marine workshop's engine field holds a model and power, not a displacement. */}
+            {serviceType !== 'marine' && (
+              <div className="space-y-2">
+                <Label htmlFor="engineSizeUnit">{t('workshop.engineSizeUnit')}</Label>
+                <Select
+                  value={engineSizeUnit}
+                  onValueChange={(value) => setEngineSizeUnit(engineSizeUnitOf(value))}
+                >
+                  <SelectTrigger id="engineSizeUnit" className="w-48">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="litres">{t('workshop.engineSizeLitres')}</SelectItem>
+                    <SelectItem value="cc">{t('workshop.engineSizeCc')}</SelectItem>
+                  </SelectContent>
+                </Select>
+                <p className="text-sm text-muted-foreground">{t('workshop.engineSizeUnitHint')}</p>
+              </div>
+            )}
+
             <div className="space-y-2">
             <div className="space-y-2">
               <Label htmlFor="defaultUnit">{t('workshop.defaultStockUnit')}</Label>
               <Label htmlFor="defaultUnit">{t('workshop.defaultStockUnit')}</Label>
               {/* Suggestions follow the (unsaved) system picked above, so
               {/* Suggestions follow the (unsaved) system picked above, so

+ 24 - 0
src/components/engine-size-context.tsx

@@ -0,0 +1,24 @@
+'use client'
+
+import { createContext, useContext } from 'react'
+import type { EngineSizeUnit } from '@/lib/engine-size'
+
+/**
+ * The unit this workshop writes engine size in, chosen under Settings →
+ * Workshop → Units. Litres until somebody picks cubic centimetres.
+ */
+const EngineSizeUnitContext = createContext<EngineSizeUnit>('litres')
+
+export function EngineSizeUnitProvider({
+  unit,
+  children,
+}: {
+  unit: EngineSizeUnit
+  children: React.ReactNode
+}) {
+  return <EngineSizeUnitContext.Provider value={unit}>{children}</EngineSizeUnitContext.Provider>
+}
+
+export function useEngineSizeUnit(): EngineSizeUnit {
+  return useContext(EngineSizeUnitContext)
+}

+ 7 - 0
src/features/integrations/Lib/types.ts

@@ -272,7 +272,14 @@ export interface VehicleLookupResult {
   fuelType?: string
   fuelType?: string
   /** One of the form's transmission options, already normalised. */
   /** One of the form's transmission options, already normalised. */
   transmission?: string
   transmission?: string
+  /**
+   * Displacement as the form shows it. Registries that know the exact
+   * figure also give engineCc, and the lookup rewrites this from it in the
+   * workshop's own unit (litres or cubic centimetres).
+   */
   engineSize?: string
   engineSize?: string
+  /** Exact displacement in cubic centimetres, when the registry gives it. */
+  engineCc?: number
   engineCode?: string
   engineCode?: string
   /** Registry's own class label, such as "Personbil" or "Light goods vehicle". */
   /** Registry's own class label, such as "Personbil" or "Light goods vehicle". */
   vehicleClass?: string
   vehicleClass?: string

+ 14 - 0
src/features/integrations/Lib/vehicle-lookup.ts

@@ -1,6 +1,7 @@
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { getManifest } from '@/integrations/registry'
 import { getManifest } from '@/integrations/registry'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
+import { type EngineSizeUnit, engineSizeUnitOf, formatEngineSize } from '@/lib/engine-size'
 import { loadConnection } from './connections'
 import { loadConnection } from './connections'
 import type { VehicleLookupKey, VehicleLookupQuery, VehicleLookupResult } from './types'
 import type { VehicleLookupKey, VehicleLookupQuery, VehicleLookupResult } from './types'
 
 
@@ -134,5 +135,18 @@ export async function askRegistry(
   const { ctx, server } = await loadConnection(connectionId)
   const { ctx, server } = await loadConnection(connectionId)
   if (!server.lookupVehicle) throw new Error('This integration cannot look up vehicles')
   if (!server.lookupVehicle) throw new Error('This integration cannot look up vehicles')
   const result = await server.lookupVehicle(ctx, query)
   const result = await server.lookupVehicle(ctx, query)
+  if (result?.engineCc !== undefined) {
+    const unit = await engineSizeUnitFor(ctx.connection.organizationId)
+    result.engineSize = formatEngineSize(result.engineCc, unit) ?? result.engineSize
+  }
   return { result, source: server.manifest.name, connectorId: server.manifest.id }
   return { result, source: server.manifest.name, connectorId: server.manifest.id }
 }
 }
+
+/** The unit this workshop writes engine size in, from Settings → Workshop → Units. */
+async function engineSizeUnitFor(organizationId: string): Promise<EngineSizeUnit> {
+  const row = await db.appSetting.findUnique({
+    where: { organizationId_key: { organizationId, key: SETTING_KEYS.ENGINE_SIZE_UNIT } },
+    select: { value: true },
+  })
+  return engineSizeUnitOf(row?.value)
+}

+ 1 - 0
src/features/settings/Lib/memberReadableSettings.ts

@@ -24,6 +24,7 @@ export const MEMBER_READABLE_SETTINGS: ReadonlySet<SettingKey> = new Set<Setting
   // Money and measures, as shown on every list and document.
   // Money and measures, as shown on every list and document.
   SETTING_KEYS.CURRENCY_CODE,
   SETTING_KEYS.CURRENCY_CODE,
   SETTING_KEYS.UNIT_SYSTEM,
   SETTING_KEYS.UNIT_SYSTEM,
+  SETTING_KEYS.ENGINE_SIZE_UNIT,
   SETTING_KEYS.DATE_FORMAT,
   SETTING_KEYS.DATE_FORMAT,
   SETTING_KEYS.TIME_FORMAT,
   SETTING_KEYS.TIME_FORMAT,
   SETTING_KEYS.TIMEZONE,
   SETTING_KEYS.TIMEZONE,

+ 2 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -68,6 +68,8 @@ export const SETTING_KEYS = {
   QUOTE_LOCK_ENABLED: 'quote.lockEnabled',
   QUOTE_LOCK_ENABLED: 'quote.lockEnabled',
   QUOTE_LOCK_TRIGGER: 'quote.lockTrigger',
   QUOTE_LOCK_TRIGGER: 'quote.lockTrigger',
   UNIT_SYSTEM: 'workshop.unitSystem',
   UNIT_SYSTEM: 'workshop.unitSystem',
+  /** 'litres' | 'cc': how a plate lookup writes engine size. Unset means litres. */
+  ENGINE_SIZE_UNIT: 'workshop.engineSizeUnit',
   DEFAULT_TECHNICIAN: 'workshop.defaultTechnician',
   DEFAULT_TECHNICIAN: 'workshop.defaultTechnician',
   DEFAULT_TECHNICIAN_ID: 'workshop.defaultTechnicianId',
   DEFAULT_TECHNICIAN_ID: 'workshop.defaultTechnicianId',
   DEFAULT_LABOR_RATE: 'workshop.defaultLaborRate',
   DEFAULT_LABOR_RATE: 'workshop.defaultLaborRate',

+ 60 - 8
src/features/vehicles/Components/VehicleForm.tsx

@@ -1,7 +1,9 @@
 'use client'
 'use client'
 
 
+import { useEngineSizeUnit } from '@/components/engine-size-context'
+import { engineSizeHint, engineSizePlaceholder, engineSizeToSave } from '@/lib/engine-size'
 import { interactiveRow } from '@/lib/interactive-row'
 import { interactiveRow } from '@/lib/interactive-row'
-import { useState, useRef, useMemo, useEffect } from 'react'
+import { useState, useRef, useMemo, useEffect, useId } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import Image from 'next/image'
 import Image from 'next/image'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
@@ -353,6 +355,10 @@ export function VehicleForm({
       // touched" and would bring the old value back. On create, leaving an
       // touched" and would bring the old value back. On create, leaving an
       // empty field out is the same thing as clearing it.
       // empty field out is the same thing as clearing it.
       const optional = (name: string) => clearableInput(formData.get(name), Boolean(vehicle))
       const optional = (name: string) => clearableInput(formData.get(name), Boolean(vehicle))
+      const withUnit = <T,>(value: T) =>
+        typeof value === 'string' && !isMarine
+          ? engineSizeToSave(value, vehicle?.engineSize)
+          : value
       const data: CreateVehicleInput & { imageUrl?: string } = {
       const data: CreateVehicleInput & { imageUrl?: string } = {
         make: formData.get('make') as string,
         make: formData.get('make') as string,
         model: formData.get('model') as string,
         model: formData.get('model') as string,
@@ -363,7 +369,8 @@ export function VehicleForm({
         mileage: Number(formData.get('mileage')) || 0,
         mileage: Number(formData.get('mileage')) || 0,
         fuelType: fuelType || undefined,
         fuelType: fuelType || undefined,
         transmission: transmission || undefined,
         transmission: transmission || undefined,
-        engineSize: optional('engineSize'),
+        // A bare number is saved with its unit, so "649" is kept as "649 cc".
+        engineSize: withUnit(optional('engineSize')),
         engineCode: optional('engineCode'),
         engineCode: optional('engineCode'),
         hsn: showTypeKey ? optional('hsn') : undefined,
         hsn: showTypeKey ? optional('hsn') : undefined,
         tsn: showTypeKey ? optional('tsn') : undefined,
         tsn: showTypeKey ? optional('tsn') : undefined,
@@ -758,12 +765,19 @@ export function VehicleForm({
                   <Label htmlFor="engineSize">
                   <Label htmlFor="engineSize">
                     {isMarine ? t('engineSizeMarine') : t('engineSize')}
                     {isMarine ? t('engineSizeMarine') : t('engineSize')}
                   </Label>
                   </Label>
-                  <Input
-                    id="engineSize"
-                    name="engineSize"
-                    placeholder={isMarine ? 'Mercury F 350 XXL Verado V-10 (350 hp)' : '2.5L'}
-                    defaultValue={vehicle?.engineSize ?? ''}
-                  />
+                  {isMarine ? (
+                    <Input
+                      id="engineSize"
+                      name="engineSize"
+                      placeholder="Mercury F 350 XXL Verado V-10 (350 hp)"
+                      defaultValue={vehicle?.engineSize ?? ''}
+                    />
+                  ) : (
+                    <EngineSizeInput
+                      key={vehicle?.id ?? 'new'}
+                      defaultValue={vehicle?.engineSize ?? ''}
+                    />
+                  )}
                 </div>
                 </div>
                 <div className="space-y-2">
                 <div className="space-y-2">
                   <Label htmlFor="engineCode">{t('engineCode')}</Label>
                   <Label htmlFor="engineCode">{t('engineCode')}</Label>
@@ -917,3 +931,41 @@ export function VehicleForm({
     </>
     </>
   )
   )
 }
 }
+
+/**
+ * The engine size field. Its placeholder is in the workshop's unit, and a
+ * bare number shows the unit it will be saved with, read from its size: 1.6
+ * is litres and 649 is cubic centimetres.
+ */
+function EngineSizeInput({ defaultValue }: { defaultValue: string }) {
+  const t = useTranslations('vehicles.form')
+  const unit = useEngineSizeUnit()
+  const hintId = useId()
+  const [draft, setDraft] = useState(defaultValue)
+  const suffix = engineSizeHint(draft, defaultValue)
+  return (
+    <div className="relative">
+      <Input
+        id="engineSize"
+        name="engineSize"
+        placeholder={engineSizePlaceholder(unit)}
+        defaultValue={defaultValue}
+        onInput={(e) => setDraft(e.currentTarget.value)}
+        aria-describedby={hintId}
+        className={suffix ? 'pr-10' : undefined}
+      />
+      {suffix && (
+        <span
+          aria-hidden="true"
+          className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-sm text-muted-foreground"
+        >
+          {suffix}
+        </span>
+      )}
+      {/* Always mounted, so a screen reader hears the unit as it changes. */}
+      <span id={hintId} className="sr-only" aria-live="polite">
+        {suffix ? t('engineSizeSavedAs', { unit: suffix }) : ''}
+      </span>
+    </div>
+  )
+}

+ 8 - 0
src/integrations/checkcardetails/server.ts

@@ -118,6 +118,12 @@ export function engineSize(value: string | number | undefined): string | undefin
   return `${litres} L`
   return `${litres} L`
 }
 }
 
 
+/** Exact displacement, for the lookup to write in the workshop's unit. */
+function cubicCentimetres(value: string | number | undefined): number | undefined {
+  const cc = Number(text(value))
+  return Number.isFinite(cc) && cc >= 30 ? cc : undefined
+}
+
 /** The date part of 2027-01-26 or 2007-05-14T00:00:00. */
 /** The date part of 2027-01-26 or 2007-05-14T00:00:00. */
 export function isoDate(value: string | undefined): string | undefined {
 export function isoDate(value: string | undefined): string | undefined {
   const m = value ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim()) : null
   const m = value ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim()) : null
@@ -178,6 +184,7 @@ export function mapRegistration(record: RegistrationRecord): VehicleLookupResult
     color: colour ? makeCase(colour) : undefined,
     color: colour ? makeCase(colour) : undefined,
     fuelType: fuelType(text(record.fuelType)),
     fuelType: fuelType(text(record.fuelType)),
     engineSize: engineSize(record.engineCapacity),
     engineSize: engineSize(record.engineCapacity),
+    engineCc: cubicCentimetres(record.engineCapacity),
     vehicleClass: vehicleClass(record.typeApproval),
     vehicleClass: vehicleClass(record.typeApproval),
     inspectionDue: isoDate(text(record.mot?.motDueDate)),
     inspectionDue: isoDate(text(record.mot?.motDueDate)),
   })
   })
@@ -200,6 +207,7 @@ export function mapFull(record: FullRecord): VehicleLookupResult {
     fuelType: fuelType(text(reg.FuelType)),
     fuelType: fuelType(text(reg.FuelType)),
     transmission: transmission(text(reg.TransmissionType) ?? text(reg.Transmission)),
     transmission: transmission(text(reg.TransmissionType) ?? text(reg.Transmission)),
     engineSize: engineSize(reg.EngineCapacity),
     engineSize: engineSize(reg.EngineCapacity),
+    engineCc: cubicCentimetres(reg.EngineCapacity),
     vehicleClass: text(reg.VehicleClass),
     vehicleClass: text(reg.VehicleClass),
     firstRegistered: isoDate(text(reg.DateFirstRegistered)),
     firstRegistered: isoDate(text(reg.DateFirstRegistered)),
     weights: kerb || grossMax ? { kerb, grossMax } : undefined,
     weights: kerb || grossMax ? { kerb, grossMax } : undefined,

+ 10 - 0
src/integrations/nhtsa/mapping.ts

@@ -35,6 +35,7 @@ export interface VpicRow {
   ElectrificationLevel?: string
   ElectrificationLevel?: string
   TransmissionStyle?: string
   TransmissionStyle?: string
   DisplacementL?: string
   DisplacementL?: string
+  DisplacementCC?: string
   EngineModel?: string
   EngineModel?: string
   EngineCylinders?: string
   EngineCylinders?: string
   VehicleType?: string
   VehicleType?: string
@@ -143,6 +144,14 @@ export function engineSize(row: VpicRow): string | undefined {
   return (Math.round(litres * 10) / 10).toFixed(1)
   return (Math.round(litres * 10) / 10).toFixed(1)
 }
 }
 
 
+/** Exact displacement: vPIC gives cubic centimetres, worked out from litres when it does not. */
+export function engineCc(row: VpicRow): number | undefined {
+  const cc = Number(text(row.DisplacementCC))
+  if (Number.isFinite(cc) && cc > 0) return Math.round(cc)
+  const litres = Number(text(row.DisplacementL))
+  return Number.isFinite(litres) && litres > 0 ? Math.round(litres * 1000) : undefined
+}
+
 export function decodeToLookup(row: VpicRow, vin: string): VehicleLookupResult | null {
 export function decodeToLookup(row: VpicRow, vin: string): VehicleLookupResult | null {
   if (decodeFailed(row)) return null
   if (decodeFailed(row)) return null
   const year = Number(text(row.ModelYear))
   const year = Number(text(row.ModelYear))
@@ -155,6 +164,7 @@ export function decodeToLookup(row: VpicRow, vin: string): VehicleLookupResult |
     fuelType: fuelType(row),
     fuelType: fuelType(row),
     transmission: transmission(row),
     transmission: transmission(row),
     engineSize: engineSize(row),
     engineSize: engineSize(row),
+    engineCc: engineCc(row),
     engineCode: text(row.EngineModel),
     engineCode: text(row.EngineModel),
     vehicleClass: text(row.BodyClass) ?? text(row.VehicleType),
     vehicleClass: text(row.BodyClass) ?? text(row.VehicleType),
   }
   }

+ 7 - 0
src/integrations/openapi-automotive/server.ts

@@ -147,6 +147,12 @@ export function engineSize(value: string | number | undefined): string | undefin
   return `${(cc / 1000).toFixed(1)} L`
   return `${(cc / 1000).toFixed(1)} L`
 }
 }
 
 
+/** The same capacity as a number, for the lookup to write in the workshop's unit. */
+export function engineCc(value: string | number | undefined): number | undefined {
+  const cc = Number(text(value))
+  return Number.isFinite(cc) && cc >= 100 ? cc : undefined
+}
+
 /** ISO date from the formats the five countries use: 2008-10-22, 17/11/2003, 1/4/2025 and 22102008. */
 /** ISO date from the formats the five countries use: 2008-10-22, 17/11/2003, 1/4/2025 and 22102008. */
 export function isoDate(value: string | undefined): string | undefined {
 export function isoDate(value: string | undefined): string | undefined {
   if (!value) return undefined
   if (!value) return undefined
@@ -202,6 +208,7 @@ export function mapRecord(record: AutomotiveRecord, plate?: string): VehicleLook
       text(record.Transmission) ?? text(record.ExtendedData?.boiteDeVitesse)
       text(record.Transmission) ?? text(record.ExtendedData?.boiteDeVitesse)
     ),
     ),
     engineSize: engineSize(record.ExtendedData?.EngineCC) ?? engineSize(record.EngineSize),
     engineSize: engineSize(record.ExtendedData?.EngineCC) ?? engineSize(record.EngineSize),
+    engineCc: engineCc(record.ExtendedData?.EngineCC) ?? engineCc(record.EngineSize),
     engineCode: text(record.EngineCode),
     engineCode: text(record.EngineCode),
     vehicleClass: text(record.BodyStyle),
     vehicleClass: text(record.BodyStyle),
     firstRegistered,
     firstRegistered,

+ 7 - 0
src/integrations/rdw/server.ts

@@ -87,6 +87,12 @@ export function fuelType(fuels: RdwFuel[]): string | undefined {
 /** Colours RDW records for vehicles that have none, or has not recorded. */
 /** Colours RDW records for vehicles that have none, or has not recorded. */
 const NO_COLOUR = new Set(['n.v.t.', 'niet geregistreerd', 'diversen'])
 const NO_COLOUR = new Set(['n.v.t.', 'niet geregistreerd', 'diversen'])
 
 
+/** A positive number from the register's text, or nothing. */
+function positive(value: string | undefined): number | undefined {
+  const n = Number(value)
+  return Number.isFinite(n) && n > 0 ? n : undefined
+}
+
 /** Displacement as the form shows it: litres with one decimal, from cubic centimetres. */
 /** Displacement as the form shows it: litres with one decimal, from cubic centimetres. */
 function engineSize(value: string | undefined): string | undefined {
 function engineSize(value: string | undefined): string | undefined {
   const cc = Number(value)
   const cc = Number(value)
@@ -121,6 +127,7 @@ export function mapVehicle(vehicle: RdwVehicle, fuels: RdwFuel[]): VehicleLookup
     color: colour && !NO_COLOUR.has(colour.toLowerCase()) ? makeCase(colour) : undefined,
     color: colour && !NO_COLOUR.has(colour.toLowerCase()) ? makeCase(colour) : undefined,
     fuelType: fuelType(fuels),
     fuelType: fuelType(fuels),
     engineSize: engineSize(vehicle.cilinderinhoud),
     engineSize: engineSize(vehicle.cilinderinhoud),
+    engineCc: positive(vehicle.cilinderinhoud),
     vehicleClass: text(vehicle.voertuigsoort),
     vehicleClass: text(vehicle.voertuigsoort),
     firstRegistered,
     firstRegistered,
     inspectionDue: isoDate(vehicle.vervaldatum_apk),
     inspectionDue: isoDate(vehicle.vervaldatum_apk),

+ 18 - 1
src/integrations/regcheck/server.ts

@@ -196,13 +196,19 @@ export function transmission(value: string | undefined): string | undefined {
  * from Australia's extended data, or "5.7L V8 MPI" from the United States.
  * from Australia's extended data, or "5.7L V8 MPI" from the United States.
  */
  */
 export function engineSize(value: string | undefined, unit?: string): string | undefined {
 export function engineSize(value: string | undefined, unit?: string): string | undefined {
+  const cc = engineCc(value, unit)
+  return cc === undefined ? undefined : `${(cc / 1000).toFixed(1)} L`
+}
+
+/** The same capacity in cubic centimetres, for the lookup to write in the workshop's unit. */
+export function engineCc(value: string | undefined, unit?: string): number | undefined {
   if (!value) return undefined
   if (!value) return undefined
   const m = /(\d+(?:[.,]\d+)?)\s*(l\b|litre|liter)?/i.exec(value)
   const m = /(\d+(?:[.,]\d+)?)\s*(l\b|litre|liter)?/i.exec(value)
   if (!m) return undefined
   if (!m) return undefined
   const n = Number(m[1].replace(',', '.'))
   const n = Number(m[1].replace(',', '.'))
   if (!Number.isFinite(n) || n <= 0) return undefined
   if (!Number.isFinite(n) || n <= 0) return undefined
   const litres = Boolean(m[2]) || unit?.toUpperCase() === 'L' || n < 30
   const litres = Boolean(m[2]) || unit?.toUpperCase() === 'L' || n < 30
-  return `${(litres ? n : n / 1000).toFixed(1)} L`
+  return Math.round(litres ? n * 1000 : n)
 }
 }
 
 
 /**
 /**
@@ -233,6 +239,12 @@ function engineFieldSize(value: string | undefined): string | undefined {
   return undefined
   return undefined
 }
 }
 
 
+function engineFieldCc(value: string | undefined): number | undefined {
+  if (!value) return undefined
+  if (/litre|liter|\bl\b/i.test(value) || /^\d{3,5}$/.test(value.trim())) return engineCc(value)
+  return undefined
+}
+
 function vin(record: RegCheckVehicle): string | undefined {
 function vin(record: RegCheckVehicle): string | undefined {
   for (const candidate of [
   for (const candidate of [
     record.VIN,
     record.VIN,
@@ -323,6 +335,11 @@ export function mapVehicle(record: RegCheckVehicle, plate?: string): VehicleLook
       engineSize(text(record.extended?.capacityValue), record.extended?.capacityUnit) ??
       engineSize(text(record.extended?.capacityValue), record.extended?.capacityUnit) ??
       engineSize(text(record.EngineSize)) ??
       engineSize(text(record.EngineSize)) ??
       engineFieldSize(text(record.Engine)),
       engineFieldSize(text(record.Engine)),
+    engineCc:
+      engineCc(text(capacity?.value), capacity?.unit) ??
+      engineCc(text(record.extended?.capacityValue), record.extended?.capacityUnit) ??
+      engineCc(text(record.EngineSize)) ??
+      engineFieldCc(text(record.Engine)),
     engineCode: text(record.EngineCode),
     engineCode: text(record.EngineCode),
     vehicleClass: text(record.BodyStyle) ?? text(record.VehicleType),
     vehicleClass: text(record.BodyStyle) ?? text(record.VehicleType),
     firstRegistered,
     firstRegistered,

+ 1 - 0
src/integrations/vegvesen/server.ts

@@ -208,6 +208,7 @@ export function mapKjoretoydata(data: Kjoretoydata): VehicleLookupResult {
     fuelType: fuelType(godkjenning),
     fuelType: fuelType(godkjenning),
     transmission: transmission(tekniske?.motorOgDrivverk?.girkassetype),
     transmission: transmission(tekniske?.motorOgDrivverk?.girkassetype),
     engineSize: engineSize(motor?.slagvolum),
     engineSize: engineSize(motor?.slagvolum),
+    engineCc: motor?.slagvolum && motor.slagvolum > 0 ? motor.slagvolum : undefined,
     engineCode: motor?.motorKode || undefined,
     engineCode: motor?.motorKode || undefined,
     vehicleClass:
     vehicleClass:
       godkjenning?.kjoretoyklassifisering?.tekniskKode?.kodeNavn ||
       godkjenning?.kjoretoyklassifisering?.tekniskKode?.kodeNavn ||

+ 64 - 0
src/lib/engine-size.ts

@@ -0,0 +1,64 @@
+/**
+ * Engine size, as the vehicle form writes it.
+ *
+ * The field is free text: people type "2.0", "1598 cc", "2.0 TDI" or an
+ * engine family, and all of it is kept as typed. What a workshop chooses is
+ * the unit a plate lookup writes in, because registries answer with the
+ * exact displacement in cubic centimetres and the app used to round it to a
+ * tenth of a litre, so a 649 cc motorcycle arrived as "0.6 L".
+ */
+export type EngineSizeUnit = 'litres' | 'cc'
+
+export function engineSizeUnitOf(value: string | null | undefined): EngineSizeUnit {
+  return value === 'cc' ? 'cc' : 'litres'
+}
+
+/**
+ * Exact displacement in the workshop's unit. Litres keep a tenth for cars, as
+ * the badge does, and every cubic centimetre below a litre, so a 49 cc moped
+ * is 0.049 L and never rounds to zero.
+ */
+export function formatEngineSize(cc: number | undefined, unit: EngineSizeUnit): string | undefined {
+  if (cc === undefined || !Number.isFinite(cc) || cc < 30) return undefined
+  if (unit === 'cc') return `${Math.round(cc)} cc`
+  const litres = cc < 1000 ? Number((cc / 1000).toFixed(3)) : (cc / 1000).toFixed(1)
+  return `${litres} L`
+}
+
+/**
+ * The unit a bare number in the field stands for, or null when the field
+ * holds anything else. Engines are under thirty litres and over thirty cubic
+ * centimetres, so the size of the number says which one was meant.
+ */
+export function bareEngineUnit(value: string): 'L' | 'cc' | null {
+  const v = value.trim().replace(',', '.')
+  if (!/^\d+(\.\d+)?$/.test(v)) return null
+  const n = Number(v)
+  if (!(n > 0)) return null
+  return n < 30 ? 'L' : 'cc'
+}
+
+/** A bare number with its unit added; anything else exactly as typed. */
+export function withEngineUnit(value: string): string {
+  const unit = bareEngineUnit(value)
+  return unit ? `${value.trim().replace(',', '.')} ${unit}` : value
+}
+
+/**
+ * What the form saves for the field: a value typed or changed here gets its
+ * unit; one saved before is kept exactly as it was, so editing the mileage
+ * never rewrites an old "649" as "649 cc".
+ */
+export function engineSizeToSave(value: string, saved: string | null | undefined): string {
+  return value.trim() === (saved ?? '').trim() ? value : withEngineUnit(value)
+}
+
+/** The unit the field shows beside an edited bare number, matching what will be saved. */
+export function engineSizeHint(draft: string, saved: string | null | undefined): 'L' | 'cc' | null {
+  return draft.trim() === (saved ?? '').trim() ? null : bareEngineUnit(draft)
+}
+
+/** What the empty field shows, in the workshop's unit. */
+export function engineSizePlaceholder(unit: EngineSizeUnit): string {
+  return unit === 'cc' ? '1598 cc' : '1.6 L'
+}

+ 5 - 0
src/lib/get-layout-data.ts

@@ -3,6 +3,7 @@ import { db } from './db'
 import { sessionOrganizationScope } from './session-scope'
 import { sessionOrganizationScope } from './session-scope'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { typeKeyEnabledIn } from '@/features/vehicles/Lib/typeKeySetting'
 import { typeKeyEnabledIn } from '@/features/vehicles/Lib/typeKeySetting'
+import { type EngineSizeUnit, engineSizeUnitOf } from '@/lib/engine-size'
 
 
 type AuthResult =
 type AuthResult =
   | { status: 'unauthenticated' }
   | { status: 'unauthenticated' }
@@ -27,6 +28,8 @@ type AuthResult =
       serviceType: string | undefined
       serviceType: string | undefined
       /** Whether vehicles carry the German type key (HSN/TSN). */
       /** Whether vehicles carry the German type key (HSN/TSN). */
       typeKeyEnabled: boolean
       typeKeyEnabled: boolean
+      /** The unit a plate lookup writes engine size in. */
+      engineSizeUnit: EngineSizeUnit
       currencyCode: string | undefined
       currencyCode: string | undefined
       currencyFormat: string | undefined
       currencyFormat: string | undefined
       organizations: { id: string; name: string; role: string }[]
       organizations: { id: string; name: string; role: string }[]
@@ -73,6 +76,7 @@ export async function getLayoutData(): Promise<AuthResult> {
                 SETTING_KEYS.WORKBOARD_WEEK_START_DAY,
                 SETTING_KEYS.WORKBOARD_WEEK_START_DAY,
                 SETTING_KEYS.SERVICE_TYPE,
                 SETTING_KEYS.SERVICE_TYPE,
                 SETTING_KEYS.VEHICLE_TYPE_KEY_ENABLED,
                 SETTING_KEYS.VEHICLE_TYPE_KEY_ENABLED,
+                SETTING_KEYS.ENGINE_SIZE_UNIT,
                 SETTING_KEYS.CURRENCY_CODE,
                 SETTING_KEYS.CURRENCY_CODE,
                 SETTING_KEYS.CURRENCY_FORMAT,
                 SETTING_KEYS.CURRENCY_FORMAT,
               ],
               ],
@@ -112,6 +116,7 @@ export async function getLayoutData(): Promise<AuthResult> {
     weekStartDay: parseInt(orgMap.get(SETTING_KEYS.WORKBOARD_WEEK_START_DAY) || '1', 10),
     weekStartDay: parseInt(orgMap.get(SETTING_KEYS.WORKBOARD_WEEK_START_DAY) || '1', 10),
     serviceType: orgMap.get(SETTING_KEYS.SERVICE_TYPE) || undefined,
     serviceType: orgMap.get(SETTING_KEYS.SERVICE_TYPE) || undefined,
     typeKeyEnabled: typeKeyEnabledIn(Object.fromEntries(orgMap)),
     typeKeyEnabled: typeKeyEnabledIn(Object.fromEntries(orgMap)),
+    engineSizeUnit: engineSizeUnitOf(orgMap.get(SETTING_KEYS.ENGINE_SIZE_UNIT)),
     currencyCode: orgMap.get(SETTING_KEYS.CURRENCY_CODE) || undefined,
     currencyCode: orgMap.get(SETTING_KEYS.CURRENCY_CODE) || undefined,
     currencyFormat: orgMap.get(SETTING_KEYS.CURRENCY_FORMAT) || undefined,
     currencyFormat: orgMap.get(SETTING_KEYS.CURRENCY_FORMAT) || undefined,
     organizations: memberships.map((m) => ({
     organizations: memberships.map((m) => ({