Răsfoiți Sursa

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 zile în urmă
părinte
comite
8e9fc4ea15
50 a modificat fișierele cu 585 adăugiri și 106 ștergeri
  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)",
     "miles": "Meilen (mi)",
     "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",
     "defaultStockUnitHint": "Wird bei neuen Lagerteilen vorausgefüllt. Jedes Teil kann weiterhin eine eigene Einheit haben.",
     "saveWorkshop": "Werkstatteinstellungen speichern",

+ 1 - 0
messages/de/vehicles.json

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

+ 4 - 0
messages/en/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "liters (L)",
     "miles": "miles (mi)",
     "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",
     "defaultStockUnitHint": "Pre-filled on new inventory parts. Each part can still use its own unit.",
     "saveWorkshop": "Save Workshop Settings",

+ 1 - 0
messages/en/vehicles.json

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

+ 4 - 0
messages/es/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litros (L)",
     "miles": "millas (mi)",
     "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",
     "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",

+ 1 - 0
messages/es/vehicles.json

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

+ 4 - 0
messages/fr/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litres (L)",
     "miles": "miles (mi)",
     "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",
     "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",

+ 1 - 0
messages/fr/vehicles.json

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

+ 4 - 0
messages/it/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litri (L)",
     "miles": "miglia (mi)",
     "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",
     "defaultStockUnitHint": "Precompilata per i nuovi ricambi a magazzino. Ogni ricambio può comunque usare la propria unità.",
     "saveWorkshop": "Salva impostazioni officina",

+ 1 - 0
messages/it/vehicles.json

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

+ 4 - 0
messages/lt/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litrai (l)",
     "miles": "mylios (mi)",
     "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",
     "defaultStockUnitHint": "Automatiškai įrašomas naujoms sandėlio dalims. Kiekviena dalis vis tiek gali turėti savo vienetą.",
     "saveWorkshop": "Išsaugoti dirbtuvių nustatymus",

+ 1 - 0
messages/lt/vehicles.json

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

+ 4 - 0
messages/nb/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "liter (L)",
     "miles": "miles (mi)",
     "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",
     "defaultStockUnitHint": "Fylles ut automatisk for nye lagerdeler. Hver del kan fortsatt ha sin egen enhet.",
     "saveWorkshop": "Lagre verkstedinnstillinger",

+ 1 - 0
messages/nb/vehicles.json

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

+ 4 - 0
messages/nl/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "liters (L)",
     "miles": "mijlen (mi)",
     "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",
     "defaultStockUnitHint": "Vooraf ingevuld bij nieuwe voorraadonderdelen. Elk onderdeel kan nog steeds een eigen eenheid gebruiken.",
     "saveWorkshop": "Werkplaatsinstellingen opslaan",

+ 1 - 0
messages/nl/vehicles.json

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

+ 4 - 0
messages/pl/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litry (L)",
     "miles": "mile (mi)",
     "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",
     "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",

+ 1 - 0
messages/pl/vehicles.json

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

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

@@ -1199,6 +1199,10 @@
     "liters": "litros (L)",
     "miles": "milhas (mi)",
     "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",
     "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",

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

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

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

@@ -1199,6 +1199,10 @@
     "liters": "litros (L)",
     "miles": "milhas (mi)",
     "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",
     "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",

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

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

+ 4 - 0
messages/ru/settings.json

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

+ 1 - 0
messages/ru/vehicles.json

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

+ 4 - 0
messages/tr/settings.json

@@ -1199,6 +1199,10 @@
     "liters": "litre (L)",
     "miles": "mil (mi)",
     "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",
     "defaultStockUnitHint": "Yeni stok parçalarında önceden doldurulur. Her parça yine de kendi birimini kullanabilir.",
     "saveWorkshop": "Atölye Ayarlarını Kaydet",

+ 1 - 0
messages/tr/vehicles.json

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

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

@@ -97,6 +97,7 @@ describe('checkcardetails mapping', () => {
       color: 'Black',
       fuelType: 'diesel',
       engineSize: '3.0 L',
+      engineCc: 2967,
       vehicleClass: 'Car',
       inspectionDue: '2027-01-26',
     })
@@ -113,6 +114,7 @@ describe('checkcardetails mapping', () => {
       fuelType: 'gasoline',
       transmission: 'automatic',
       engineSize: '1.6 L',
+      engineCc: 1598,
       vehicleClass: 'Car',
       firstRegistered: '2007-05-14',
       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',
       transmission: 'automatic',
       engineSize: '3.0',
+      engineCc: 2999,
       engineCode: 'J30A4',
       vehicleClass: 'Coupe',
     })

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

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

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

@@ -73,6 +73,7 @@ describe('rdw mapper', () => {
       color: 'Zwart',
       fuelType: 'hybrid',
       engineSize: '1.8 L',
+      engineCc: 1798,
       vehicleClass: 'Personenauto',
       firstRegistered: '2018-04-26',
       inspectionDue: '2027-04-26',
@@ -90,6 +91,7 @@ describe('rdw mapper', () => {
       color: 'Zwart',
       fuelType: 'gasoline',
       engineSize: '1.2 L',
+      engineCc: 1197,
       vehicleClass: 'Personenauto',
       firstRegistered: '2010-05-06',
       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',
       transmission: 'manual',
       engineSize: '1.6 L',
+      engineCc: 1600,
       vehicleClass: 'Lx 5-Speed Manual',
     })
   })
@@ -88,6 +89,7 @@ describe('regcheck mapper, Australia state by state', () => {
       vin: 'WV1ZZZ2HZEA044303',
       fuelType: 'diesel',
       engineSize: '2.0 L',
+      engineCc: 1968,
       engineCode: 'CNEA|CSHA',
       vehicleClass: 'Ute',
     })
@@ -144,6 +146,7 @@ describe('regcheck mapper, other countries', () => {
       vin: '7AT0H64TX13059294',
       fuelType: 'gasoline',
       engineSize: '2.5 L',
+      engineCc: 2499,
       vehicleClass: 'SEDAN',
     })
   })
@@ -155,6 +158,7 @@ describe('regcheck mapper, other countries', () => {
       year: 2009,
       vin: 'KNADE223696445551',
       engineSize: '1.6 L',
+      engineCc: 1600,
       vehicleClass: 'Sedan 4D',
     })
   })
@@ -167,6 +171,7 @@ describe('regcheck mapper, other countries', () => {
       vin: 'WAUZZZ4BX4N093080',
       fuelType: 'diesel',
       engineSize: '1.9 L',
+      engineCc: 1896,
       vehicleClass: 'SALOON',
     })
   })
@@ -181,6 +186,7 @@ describe('regcheck mapper, other countries', () => {
       fuelType: 'gasoline',
       transmission: 'manual',
       engineSize: '1.8 L',
+      engineCc: 1798,
       vehicleClass: 'Halvkombi',
     })
   })
@@ -193,6 +199,7 @@ describe('regcheck mapper, other countries', () => {
       vin: 'ZFFKA36B000096243',
       fuelType: 'gasoline',
       engineSize: '3.4 L',
+      engineCc: 3405,
       firstRegistered: '1993-05-03',
       lastInspected: '2025-04-10',
       weights: { kerb: 1440, grossMax: 1680 },
@@ -208,6 +215,7 @@ describe('regcheck mapper, other countries', () => {
       fuelType: 'diesel',
       transmission: 'automatic',
       engineSize: '1.6 L',
+      engineCc: 1598,
       engineCode: 'CAYC',
       vehicleClass: 'Car',
       firstRegistered: '2010-09-21',
@@ -230,6 +238,7 @@ describe('regcheck mapper, other countries', () => {
       vin: 'TMBCK11U4Y2370804',
       color: 'Modrá',
       engineSize: '1.6 L',
+      engineCc: 1595,
     })
     expect(mapVehicle(fixture('HR'))).toEqual({
       make: 'BMW',
@@ -246,6 +255,7 @@ describe('regcheck mapper, other countries', () => {
       vin: 'TMBCS21Z262149586',
       fuelType: 'diesel',
       engineSize: '1.9 L',
+      engineCc: 1896,
       vehicleClass: 'sedan/limuzína',
       firstRegistered: '2005-10-04',
       weights: { kerb: undefined, grossMax: 1970 },
@@ -262,6 +272,7 @@ describe('regcheck mapper, other countries', () => {
       color: 'Hvit',
       fuelType: 'diesel',
       engineSize: '2.3 L',
+      engineCc: 2287,
       firstRegistered: '2014-08-19',
       inspectionDue: '2018-10-31',
       tyres: [
@@ -282,6 +293,7 @@ describe('regcheck mapper, other countries', () => {
       vin: 'VTTBK232100104138',
       fuelType: 'gasoline',
       engineSize: '0.5 L',
+      engineCc: 487,
       firstRegistered: '2006-11-23',
       weights: { kerb: 380, grossMax: 380 },
     })
@@ -299,6 +311,7 @@ describe('regcheck mapper, other countries', () => {
       color: 'Silver',
       fuelType: 'gasoline',
       engineSize: '1.4 L',
+      engineCc: 1360,
       vehicleClass: 'Motorbike',
     })
     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',
       transmission: 'automatic',
       engineSize: '2.0 L',
+      engineCc: 1969,
       engineCode: 'D4204T14',
       vehicleClass: 'Personbil',
       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 { ServiceTypeProvider } from '@/components/service-type-context'
 import { TypeKeyProvider } from '@/components/type-key-context'
+import { EngineSizeUnitProvider } from '@/components/engine-size-context'
 import { LicenseExpiryProvider } from '@/components/license-expiry-context'
 import {
   LICENSE_TOKEN_MAX_AGE_DAYS,
@@ -268,118 +269,124 @@ export default async function DashboardLayout({ children }: { children: React.Re
   return (
     <ServiceTypeProvider serviceType={data.serviceType}>
       <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
         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
               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. */}
-            {!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
                           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.
                           Without it a page that wants to fill the window (the
                           work board's week timeline) stopped at its content and
                           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>
     </ServiceTypeProvider>
   )

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

@@ -32,6 +32,7 @@ import {
 } from '@/components/ui/alert-dialog'
 import { toast } from 'sonner'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
+import { type EngineSizeUnit, engineSizeUnitOf } from '@/lib/engine-size'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { assignTechToUnassignedWorkOrders } from '@/features/workboard/Actions/technicianActions'
 import {
@@ -101,6 +102,9 @@ export function WorkshopSettings({
     settings[SETTING_KEYS.DEFAULT_LABOR_RATE] || ''
   )
   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(
     settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''
   )
@@ -198,6 +202,7 @@ export function WorkshopSettings({
       [SETTING_KEYS.DEFAULT_TECHNICIAN]: selectedTechName,
       [SETTING_KEYS.DEFAULT_LABOR_RATE]: defaultLaborRate,
       [SETTING_KEYS.UNIT_SYSTEM]: unitSystem,
+      [SETTING_KEYS.ENGINE_SIZE_UNIT]: engineSizeUnit,
       [SETTING_KEYS.INVENTORY_DEFAULT_UNIT]: defaultUnit.trim(),
       [SETTING_KEYS.WORKBOARD_WORK_DAY_START]: workDayStart,
       [SETTING_KEYS.WORKBOARD_WORK_DAY_END]: workDayEnd,
@@ -550,6 +555,26 @@ export function WorkshopSettings({
               )}
             </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">
               <Label htmlFor="defaultUnit">{t('workshop.defaultStockUnit')}</Label>
               {/* 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
   /** One of the form's transmission options, already normalised. */
   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
+  /** Exact displacement in cubic centimetres, when the registry gives it. */
+  engineCc?: number
   engineCode?: string
   /** Registry's own class label, such as "Personbil" or "Light goods vehicle". */
   vehicleClass?: string

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

@@ -1,6 +1,7 @@
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { getManifest } from '@/integrations/registry'
 import { db } from '@/lib/db'
+import { type EngineSizeUnit, engineSizeUnitOf, formatEngineSize } from '@/lib/engine-size'
 import { loadConnection } from './connections'
 import type { VehicleLookupKey, VehicleLookupQuery, VehicleLookupResult } from './types'
 
@@ -134,5 +135,18 @@ export async function askRegistry(
   const { ctx, server } = await loadConnection(connectionId)
   if (!server.lookupVehicle) throw new Error('This integration cannot look up vehicles')
   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 }
 }
+
+/** 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.
   SETTING_KEYS.CURRENCY_CODE,
   SETTING_KEYS.UNIT_SYSTEM,
+  SETTING_KEYS.ENGINE_SIZE_UNIT,
   SETTING_KEYS.DATE_FORMAT,
   SETTING_KEYS.TIME_FORMAT,
   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_TRIGGER: 'quote.lockTrigger',
   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_ID: 'workshop.defaultTechnicianId',
   DEFAULT_LABOR_RATE: 'workshop.defaultLaborRate',

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

@@ -1,7 +1,9 @@
 'use client'
 
+import { useEngineSizeUnit } from '@/components/engine-size-context'
+import { engineSizeHint, engineSizePlaceholder, engineSizeToSave } from '@/lib/engine-size'
 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 Image from 'next/image'
 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
       // empty field out is the same thing as clearing it.
       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 } = {
         make: formData.get('make') as string,
         model: formData.get('model') as string,
@@ -363,7 +369,8 @@ export function VehicleForm({
         mileage: Number(formData.get('mileage')) || 0,
         fuelType: fuelType || 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'),
         hsn: showTypeKey ? optional('hsn') : undefined,
         tsn: showTypeKey ? optional('tsn') : undefined,
@@ -758,12 +765,19 @@ export function VehicleForm({
                   <Label htmlFor="engineSize">
                     {isMarine ? t('engineSizeMarine') : t('engineSize')}
                   </Label>
-                  <Input
-                    id="engineSize"
-                    name="engineSize"
-                    placeholder={isMarine ? 'Mercury F 350 XXL Verado V-10 (350 hp)' : '2.5L'}
-                    defaultValue={vehicle?.engineSize ?? ''}
-                  />
+                  {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 className="space-y-2">
                   <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`
 }
 
+/** 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. */
 export function isoDate(value: string | undefined): string | undefined {
   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,
     fuelType: fuelType(text(record.fuelType)),
     engineSize: engineSize(record.engineCapacity),
+    engineCc: cubicCentimetres(record.engineCapacity),
     vehicleClass: vehicleClass(record.typeApproval),
     inspectionDue: isoDate(text(record.mot?.motDueDate)),
   })
@@ -200,6 +207,7 @@ export function mapFull(record: FullRecord): VehicleLookupResult {
     fuelType: fuelType(text(reg.FuelType)),
     transmission: transmission(text(reg.TransmissionType) ?? text(reg.Transmission)),
     engineSize: engineSize(reg.EngineCapacity),
+    engineCc: cubicCentimetres(reg.EngineCapacity),
     vehicleClass: text(reg.VehicleClass),
     firstRegistered: isoDate(text(reg.DateFirstRegistered)),
     weights: kerb || grossMax ? { kerb, grossMax } : undefined,

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

@@ -35,6 +35,7 @@ export interface VpicRow {
   ElectrificationLevel?: string
   TransmissionStyle?: string
   DisplacementL?: string
+  DisplacementCC?: string
   EngineModel?: string
   EngineCylinders?: string
   VehicleType?: string
@@ -143,6 +144,14 @@ export function engineSize(row: VpicRow): string | undefined {
   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 {
   if (decodeFailed(row)) return null
   const year = Number(text(row.ModelYear))
@@ -155,6 +164,7 @@ export function decodeToLookup(row: VpicRow, vin: string): VehicleLookupResult |
     fuelType: fuelType(row),
     transmission: transmission(row),
     engineSize: engineSize(row),
+    engineCc: engineCc(row),
     engineCode: text(row.EngineModel),
     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`
 }
 
+/** 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. */
 export function isoDate(value: string | undefined): string | undefined {
   if (!value) return undefined
@@ -202,6 +208,7 @@ export function mapRecord(record: AutomotiveRecord, plate?: string): VehicleLook
       text(record.Transmission) ?? text(record.ExtendedData?.boiteDeVitesse)
     ),
     engineSize: engineSize(record.ExtendedData?.EngineCC) ?? engineSize(record.EngineSize),
+    engineCc: engineCc(record.ExtendedData?.EngineCC) ?? engineCc(record.EngineSize),
     engineCode: text(record.EngineCode),
     vehicleClass: text(record.BodyStyle),
     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. */
 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. */
 function engineSize(value: string | undefined): string | undefined {
   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,
     fuelType: fuelType(fuels),
     engineSize: engineSize(vehicle.cilinderinhoud),
+    engineCc: positive(vehicle.cilinderinhoud),
     vehicleClass: text(vehicle.voertuigsoort),
     firstRegistered,
     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.
  */
 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
   const m = /(\d+(?:[.,]\d+)?)\s*(l\b|litre|liter)?/i.exec(value)
   if (!m) return undefined
   const n = Number(m[1].replace(',', '.'))
   if (!Number.isFinite(n) || n <= 0) return undefined
   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
 }
 
+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 {
   for (const candidate of [
     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.EngineSize)) ??
       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),
     vehicleClass: text(record.BodyStyle) ?? text(record.VehicleType),
     firstRegistered,

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

@@ -208,6 +208,7 @@ export function mapKjoretoydata(data: Kjoretoydata): VehicleLookupResult {
     fuelType: fuelType(godkjenning),
     transmission: transmission(tekniske?.motorOgDrivverk?.girkassetype),
     engineSize: engineSize(motor?.slagvolum),
+    engineCc: motor?.slagvolum && motor.slagvolum > 0 ? motor.slagvolum : undefined,
     engineCode: motor?.motorKode || undefined,
     vehicleClass:
       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 { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { typeKeyEnabledIn } from '@/features/vehicles/Lib/typeKeySetting'
+import { type EngineSizeUnit, engineSizeUnitOf } from '@/lib/engine-size'
 
 type AuthResult =
   | { status: 'unauthenticated' }
@@ -27,6 +28,8 @@ type AuthResult =
       serviceType: string | undefined
       /** Whether vehicles carry the German type key (HSN/TSN). */
       typeKeyEnabled: boolean
+      /** The unit a plate lookup writes engine size in. */
+      engineSizeUnit: EngineSizeUnit
       currencyCode: string | undefined
       currencyFormat: string | undefined
       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.SERVICE_TYPE,
                 SETTING_KEYS.VEHICLE_TYPE_KEY_ENABLED,
+                SETTING_KEYS.ENGINE_SIZE_UNIT,
                 SETTING_KEYS.CURRENCY_CODE,
                 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),
     serviceType: orgMap.get(SETTING_KEYS.SERVICE_TYPE) || undefined,
     typeKeyEnabled: typeKeyEnabledIn(Object.fromEntries(orgMap)),
+    engineSizeUnit: engineSizeUnitOf(orgMap.get(SETTING_KEYS.ENGINE_SIZE_UNIT)),
     currencyCode: orgMap.get(SETTING_KEYS.CURRENCY_CODE) || undefined,
     currencyFormat: orgMap.get(SETTING_KEYS.CURRENCY_FORMAT) || undefined,
     organizations: memberships.map((m) => ({