Explorar o código

Workshops can now look up OBD-II fault (#506)

* new codes page

* Keep one finding per code under concurrent adds, look up codes in batches of 50, cap fix drafts at four photos under concurrent uploads, drop stale search answers, and stop the findings buttons from submitting the work order
Bernt Christian Egeland hai 2 días
pai
achega
bcc84cf2cb
Modificáronse 100 ficheiros con 9289 adicións e 55 borrados
  1. 15 0
      .env.example
  2. 11 0
      .github/workflows/deploy-prod.yml
  3. 11 0
      .github/workflows/deploy-staging.yml
  4. 249 0
      messages/de/faultCodes.json
  5. 13 0
      messages/de/integrations.json
  6. 5 2
      messages/de/navigation.json
  7. 249 0
      messages/en/faultCodes.json
  8. 13 0
      messages/en/integrations.json
  9. 5 2
      messages/en/navigation.json
  10. 249 0
      messages/es/faultCodes.json
  11. 13 0
      messages/es/integrations.json
  12. 5 2
      messages/es/navigation.json
  13. 249 0
      messages/fr/faultCodes.json
  14. 13 0
      messages/fr/integrations.json
  15. 5 2
      messages/fr/navigation.json
  16. 249 0
      messages/it/faultCodes.json
  17. 13 0
      messages/it/integrations.json
  18. 5 2
      messages/it/navigation.json
  19. 249 0
      messages/lt/faultCodes.json
  20. 13 0
      messages/lt/integrations.json
  21. 5 2
      messages/lt/navigation.json
  22. 249 0
      messages/nb/faultCodes.json
  23. 13 0
      messages/nb/integrations.json
  24. 5 2
      messages/nb/navigation.json
  25. 249 0
      messages/nl/faultCodes.json
  26. 13 0
      messages/nl/integrations.json
  27. 5 2
      messages/nl/navigation.json
  28. 249 0
      messages/pl/faultCodes.json
  29. 13 0
      messages/pl/integrations.json
  30. 5 2
      messages/pl/navigation.json
  31. 249 0
      messages/pt-BR/faultCodes.json
  32. 13 0
      messages/pt-BR/integrations.json
  33. 5 2
      messages/pt-BR/navigation.json
  34. 249 0
      messages/pt-PT/faultCodes.json
  35. 13 0
      messages/pt-PT/integrations.json
  36. 5 2
      messages/pt-PT/navigation.json
  37. 249 0
      messages/ru/faultCodes.json
  38. 13 0
      messages/ru/integrations.json
  39. 5 2
      messages/ru/navigation.json
  40. 249 0
      messages/tr/faultCodes.json
  41. 13 0
      messages/tr/integrations.json
  42. 5 2
      messages/tr/navigation.json
  43. 46 0
      prisma/migrations/20261105113016_trouble_codes/migration.sql
  44. 14 0
      prisma/migrations/20261105183723_trouble_codes_keys/migration.sql
  45. 8 0
      prisma/schema/service-records.prisma
  46. 20 0
      prisma/schema/vehicles.prisma
  47. 1 0
      public/images/integrations/torqvoice-codes.svg
  48. 38 0
      src/__tests__/components/integrations-catalog.test.tsx
  49. 132 0
      src/__tests__/features/fault-codes/codes.test.ts
  50. 261 0
      src/__tests__/features/fault-codes/components.test.tsx
  51. 84 0
      src/__tests__/features/fault-codes/fix-drafts.test.ts
  52. 42 0
      src/__tests__/features/fault-codes/fix-markdown.test.ts
  53. 540 0
      src/__tests__/features/fault-codes/pages.test.tsx
  54. 299 0
      src/__tests__/features/fault-codes/service.test.ts
  55. 177 0
      src/__tests__/features/integrations/auto-connect.test.ts
  56. 118 12
      src/__tests__/features/integrations/registry.test.ts
  57. 488 0
      src/__tests__/features/integrations/torqvoice-codes.test.ts
  58. 4 0
      src/__tests__/lib/files/references.test.ts
  59. 57 0
      src/__tests__/lib/fix-handoff-token.test.ts
  60. 71 0
      src/app/(authenticated)/fault-codes/[code]/page.tsx
  61. 46 0
      src/app/(authenticated)/fault-codes/page.tsx
  62. 8 0
      src/app/(authenticated)/layout.tsx
  63. 52 1
      src/app/(authenticated)/settings/integrations/integrations-catalog.tsx
  64. 232 0
      src/app/(public)/p/[token]/fix-handoff-client.tsx
  65. 17 0
      src/app/(public)/p/[token]/page.tsx
  66. 52 0
      src/app/api/protected/fault-codes/fixes/[id]/photos/[n]/route.ts
  67. 148 0
      src/app/api/protected/fault-codes/fixes/route.ts
  68. 1 0
      src/app/api/protected/files/[...path]/route.ts
  69. 66 0
      src/app/api/public/fix-handoff/[token]/route.ts
  70. 67 14
      src/app/api/v1/tech/jobs/[id]/findings/route.ts
  71. 10 1
      src/app/api/v1/tech/jobs/[id]/route.ts
  72. 6 1
      src/components/page-header.tsx
  73. 1 0
      src/features/ai/tools/workshop-columns.ts
  74. 134 0
      src/features/fault-codes/Actions/addFaultCodeFindings.ts
  75. 65 0
      src/features/fault-codes/Actions/checkFindingMeanings.ts
  76. 30 0
      src/features/fault-codes/Actions/createFixPhoneLink.ts
  77. 27 0
      src/features/fault-codes/Actions/getFaultCodeDetail.ts
  78. 19 0
      src/features/fault-codes/Actions/getFaultCodeJobs.ts
  79. 43 0
      src/features/fault-codes/Actions/getFaultCodeOverview.ts
  80. 19 0
      src/features/fault-codes/Actions/getFaultCodeReports.ts
  81. 22 0
      src/features/fault-codes/Actions/listFixDraftPhotos.ts
  82. 33 0
      src/features/fault-codes/Actions/lookupFaultCodes.ts
  83. 22 0
      src/features/fault-codes/Actions/removeFixDraftPhoto.ts
  84. 40 0
      src/features/fault-codes/Actions/reportFaultCodeMeaning.ts
  85. 37 0
      src/features/fault-codes/Actions/searchFaultCodes.ts
  86. 27 0
      src/features/fault-codes/Actions/suggestFaultCodes.ts
  87. 74 0
      src/features/fault-codes/Actions/takeFindingMeaning.ts
  88. 33 0
      src/features/fault-codes/Actions/withdrawFaultCodeFix.ts
  89. 100 0
      src/features/fault-codes/Components/DescriptionSections.tsx
  90. 113 0
      src/features/fault-codes/Components/FaultCodeChip.tsx
  91. 457 0
      src/features/fault-codes/Components/FaultCodeDetail.tsx
  92. 257 0
      src/features/fault-codes/Components/FaultCodeEntryDialog.tsx
  93. 382 0
      src/features/fault-codes/Components/FaultCodeLookup.tsx
  94. 90 0
      src/features/fault-codes/Components/FaultCodeSheet.tsx
  95. 32 0
      src/features/fault-codes/Components/FaultCodesNotice.tsx
  96. 221 0
      src/features/fault-codes/Components/FaultCodesOnJob.tsx
  97. 62 0
      src/features/fault-codes/Components/FixPhotos.tsx
  98. 26 0
      src/features/fault-codes/Components/FixText.tsx
  99. 149 0
      src/features/fault-codes/Components/ReportMeaningPopover.tsx
  100. 161 0
      src/features/fault-codes/Components/ShareFixCard.tsx

+ 15 - 0
.env.example

@@ -79,3 +79,18 @@ FIKEN_INTEGRATION_CLIENT_SECRET=""
 # lookups below (40 unless set).
 # lookups below (40 unless set).
 # OPEN_LABOR_PROJECT_API_KEY=""
 # OPEN_LABOR_PROJECT_API_KEY=""
 # REPAIR_DATA_SERVER_KEY_DAILY_LOOKUPS=40
 # REPAIR_DATA_SERVER_KEY_DAILY_LOOKUPS=40
+# Torqvoice Codes, the fault code service Torqvoice runs (cloud only: a
+# self-hosted install shows the card greyed out and cannot connect it).
+# Where the service answers, never typed by a workshop; in the dev
+# containers it is http://torqvoice-codes:8090 (http://localhost:8090 from
+# the host).
+# TORQVOICE_CODES_URL="http://torqvoice-codes:8090"
+# A key for every workshop, with read:codes, read:fixes and write:fixes
+# (sharing fixes needs the last two). Set, every workshop is connected with
+# it on its first page load, sees no key field, and stays disconnected if it
+# disconnects. Unset, the connect page asks for a key.
+# TORQVOICE_CODES_API_TOKEN=""
+# Development only: cloud-only connectors to show on a self-hosted dev
+# machine without putting the app in cloud mode. Ignored by any production
+# build (it needs `next dev`).
+# TORQVOICE_DEV_CLOUD_CONNECTORS="torqvoice-codes"

+ 11 - 0
.github/workflows/deploy-prod.yml

@@ -96,6 +96,15 @@ jobs:
           QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_QUICKBOOKS_INTEGRATION_CLIENT_SECRET }}
           QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_QUICKBOOKS_INTEGRATION_CLIENT_SECRET }}
           FIKEN_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_FIKEN_INTEGRATION_CLIENT_ID }}
           FIKEN_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_FIKEN_INTEGRATION_CLIENT_ID }}
           FIKEN_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_FIKEN_INTEGRATION_CLIENT_SECRET }}
           FIKEN_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_FIKEN_INTEGRATION_CLIENT_SECRET }}
+          # Torqvoice Codes, the fault code service, run beside the app. The address
+          # is the service's on the server's Docker network (http://torqvoice-codes:8090
+          # when it joins the proxy network), and the key is one key with read:codes,
+          # read:fixes and write:fixes (sharing fixes needs the last two), minted
+          # in the torqvoice.com admin under Code service. With both set every workshop
+          # is connected with our key and never sees a key field; with either empty
+          # the fault code lookups stay off.
+          TORQVOICE_CODES_URL: ${{ secrets.CLOUD_TORQVOICE_CODES_URL }}
+          TORQVOICE_CODES_API_TOKEN: ${{ secrets.CLOUD_TORQVOICE_CODES_API_TOKEN }}
         run: |
         run: |
           mkdir -p $HOME/torqvoice-deploy/prod
           mkdir -p $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
@@ -160,6 +169,8 @@ jobs:
                 QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${QUICKBOOKS_INTEGRATION_CLIENT_SECRET}
                 QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${QUICKBOOKS_INTEGRATION_CLIENT_SECRET}
                 FIKEN_INTEGRATION_CLIENT_ID: ${FIKEN_INTEGRATION_CLIENT_ID}
                 FIKEN_INTEGRATION_CLIENT_ID: ${FIKEN_INTEGRATION_CLIENT_ID}
                 FIKEN_INTEGRATION_CLIENT_SECRET: ${FIKEN_INTEGRATION_CLIENT_SECRET}
                 FIKEN_INTEGRATION_CLIENT_SECRET: ${FIKEN_INTEGRATION_CLIENT_SECRET}
+                TORQVOICE_CODES_URL: ${TORQVOICE_CODES_URL}
+                TORQVOICE_CODES_API_TOKEN: ${TORQVOICE_CODES_API_TOKEN}
               networks:
               networks:
                 - proxy
                 - proxy
           COMPOSE
           COMPOSE

+ 11 - 0
.github/workflows/deploy-staging.yml

@@ -78,6 +78,15 @@ jobs:
           QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_QUICKBOOKS_INTEGRATION_CLIENT_SECRET }}
           QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_QUICKBOOKS_INTEGRATION_CLIENT_SECRET }}
           FIKEN_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_FIKEN_INTEGRATION_CLIENT_ID }}
           FIKEN_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_FIKEN_INTEGRATION_CLIENT_ID }}
           FIKEN_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_FIKEN_INTEGRATION_CLIENT_SECRET }}
           FIKEN_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_FIKEN_INTEGRATION_CLIENT_SECRET }}
+          # Torqvoice Codes, the fault code service, run beside the app. The address
+          # is the service's on the server's Docker network (http://torqvoice-codes:8090
+          # when it joins the proxy network), and the key is one key with read:codes,
+          # read:fixes and write:fixes (sharing fixes needs the last two), minted
+          # in the torqvoice.com admin under Code service. With both set every workshop
+          # is connected with our key and never sees a key field; with either empty
+          # the fault code lookups stay off.
+          TORQVOICE_CODES_URL: ${{ secrets.STAGING_TORQVOICE_CODES_URL }}
+          TORQVOICE_CODES_API_TOKEN: ${{ secrets.STAGING_TORQVOICE_CODES_API_TOKEN }}
         run: |
         run: |
           mkdir -p $HOME/torqvoice-deploy/staging
           mkdir -p $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
@@ -139,6 +148,8 @@ jobs:
                 QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${QUICKBOOKS_INTEGRATION_CLIENT_SECRET}
                 QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${QUICKBOOKS_INTEGRATION_CLIENT_SECRET}
                 FIKEN_INTEGRATION_CLIENT_ID: ${FIKEN_INTEGRATION_CLIENT_ID}
                 FIKEN_INTEGRATION_CLIENT_ID: ${FIKEN_INTEGRATION_CLIENT_ID}
                 FIKEN_INTEGRATION_CLIENT_SECRET: ${FIKEN_INTEGRATION_CLIENT_SECRET}
                 FIKEN_INTEGRATION_CLIENT_SECRET: ${FIKEN_INTEGRATION_CLIENT_SECRET}
+                TORQVOICE_CODES_URL: ${TORQVOICE_CODES_URL}
+                TORQVOICE_CODES_API_TOKEN: ${TORQVOICE_CODES_API_TOKEN}
               networks:
               networks:
                 - proxy
                 - proxy
           COMPOSE
           COMPOSE

+ 249 - 0
messages/de/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Fehlercodes",
+  "notAvailable": "Gerade nicht verfügbar",
+  "noMeaning": "Noch nicht in unserer Codeliste",
+  "manufacturerUnknown": "Herstellerspezifischer Code: Die Bedeutung hängt von der Marke ab.",
+  "chip": {
+    "open": "{code} öffnen"
+  },
+  "source": {
+    "generic": "Allgemein",
+    "manufacturer": "Hersteller",
+    "report": "Aus dem Scanbericht",
+    "machineTranslated": "Maschinell übersetzt"
+  },
+  "status": {
+    "stored": "Gespeichert",
+    "pending": "Ausstehend",
+    "permanent": "Permanent",
+    "history": "Historie"
+  },
+  "flags": {
+    "warningLight": "Warnleuchte",
+    "emissions": "Abgasrelevant",
+    "driveCycle": "Fahrzyklus nötig",
+    "limpMode": "Notlauf"
+  },
+  "sections": {
+    "how_it_works": "So funktioniert es",
+    "meaning": "Was der Code bedeutet",
+    "causes": "Typische Ursachen",
+    "manufacturer": "Herstellerangaben",
+    "related": "Zusammen mit anderen Codes",
+    "diagnosis": "Diagnosetipp",
+    "other": "Gut zu wissen"
+  },
+  "detail": {
+    "whatItMeans": "Was er bedeutet",
+    "likelyCauses": "Wahrscheinliche Ursachen",
+    "relatedCodes": "Verwandte Codes",
+    "inYourWorkshop": "In Ihrer Werkstatt",
+    "noJobs": "In Ihrer Werkstatt noch bei keinem Auftrag ausgelesen.",
+    "resolved": "Erledigt",
+    "notConnected": "Verbinden Sie Torqvoice Codes, um zu sehen, was Codes bedeuten.",
+    "connect": "Verbinden",
+    "serviceDown": "Die Bedeutung ist gerade nicht verfügbar. Ihre Aufträge stehen unten.",
+    "pendingCheck": "Bis der Text geprüft ist, wird nur der Titel angezeigt.",
+    "showMore": "Mehr anzeigen",
+    "showLess": "Weniger anzeigen",
+    "likelihood": {
+      "high": "Hoch",
+      "medium": "Mittel",
+      "low": "Niedrig"
+    },
+    "mostLikelyFirst": "Wahrscheinlichste zuerst"
+  },
+  "report": {
+    "button": "Problem melden",
+    "title": "Was stimmt an der Bedeutung von {code} nicht?",
+    "reasons": {
+      "wrong_for_vehicle": "Falsch für dieses Auto",
+      "wrong_for_vehicleMarine": "Falsch für dieses Wasserfahrzeug",
+      "text_mismatch": "Text passt nicht zum Code",
+      "wrong_position": "Falsche Seite oder Position",
+      "bad_translation": "Schlechte Übersetzung",
+      "other": "Sonstiges"
+    },
+    "note": "Notiz (optional)",
+    "notePlaceholder": "Was Sie gefunden haben",
+    "send": "Senden",
+    "thanks": "Danke, wir prüfen das",
+    "failed": "Die Meldung konnte gerade nicht gesendet werden. Versuchen Sie es gleich noch einmal.",
+    "reportedByYou": "Von Ihnen gemeldet",
+    "outcome": {
+      "fixed": "Nach Ihrer Meldung aktualisiert",
+      "no_change": "Geprüft, keine Änderung",
+      "duplicate": "Bereits gemeldet"
+    }
+  },
+  "share": {
+    "title": "Ihre Lösung teilen",
+    "description": "Erzählen Sie anderen Werkstätten, was {code} behoben hat. Jede Lösung prüft ein Mensch bei Torqvoice, bevor andere sie sehen.",
+    "yourFixes": "Ihre Lösungen für {code}",
+    "whatFixedIt": "Was hat den Fehler behoben?",
+    "placeholder": "Durchgescheuertes Kabel am Stecker der hinteren Lambdasonde hinter dem Getriebe. Repariert, Code nach der Probefahrt gelöscht.",
+    "placeholderMarine": "Korrodierte Kontakte im Stecker des Trimmsensors. Gereinigt und abgedichtet, Code nach einer Probefahrt auf dem Wasser gelöscht.",
+    "photos": "Fotos (optional)",
+    "photosHint": "Bis zu {max} Fotos. Halten Sie Gesichter, Kennzeichen und Kundendaten aus dem Bild.",
+    "photosHintMarine": "Bis zu {max} Fotos. Halten Sie Gesichter, Bootskennzeichen und Kundendaten aus dem Bild.",
+    "addPhotos": "Fotos hinzufügen",
+    "removePhoto": "Foto {n} entfernen",
+    "tooManyPhotos": "Höchstens {max} Fotos",
+    "notAPhoto": "{name} ist kein Foto",
+    "photoTooBig": "{name} ist größer als 15 MB",
+    "vehicle": "Fahrzeug",
+    "vehicleMarine": "Wasserfahrzeug",
+    "vehicleOptional": "Fahrzeug (optional)",
+    "vehicleOptionalMarine": "Wasserfahrzeug (optional)",
+    "make": "Marke",
+    "model": "Modell",
+    "year": "Baujahr",
+    "vehicleHint": "Geteilt werden nur Marke, Modell, Baujahr und Motor, nie Kennzeichen, FIN, Kunde oder der Name Ihrer Werkstatt.",
+    "vehicleHintMarine": "Geteilt werden nur Marke, Modell, Baujahr und Motor, nie Kennzeichen, Rumpfnummer, Kunde oder der Name Ihrer Werkstatt.",
+    "send": "Lösung teilen",
+    "sent": "Danke. Ihre Lösung wartet auf Prüfung.",
+    "shareAnother": "Weitere Lösung teilen",
+    "cancel": "Abbrechen",
+    "sentOn": "Gesendet am {date}",
+    "reason": "Grund: {reason}",
+    "withdraw": "Zurückziehen",
+    "withdrawTitle": "Diese Lösung zurückziehen?",
+    "withdrawConfirm": "Sie wird mit ihren Fotos gelöscht, und keine andere Werkstatt sieht sie.",
+    "withdrawn": "Lösung zurückgezogen",
+    "withdrawFailed": "Die Lösung konnte gerade nicht zurückgezogen werden. Versuchen Sie es gleich noch einmal.",
+    "unavailable": "Das Teilen von Lösungen ist gerade nicht verfügbar.",
+    "status": {
+      "pending": "Wartet auf Prüfung",
+      "published": "Veröffentlicht",
+      "rejected": "Nicht veröffentlicht"
+    },
+    "errors": {
+      "failed": "Ihre Lösung konnte gerade nicht gesendet werden. Versuchen Sie es gleich noch einmal.",
+      "photo": "Eines der Fotos konnte nicht gelesen werden. Versuchen Sie ein anderes.",
+      "too_large": "Die Fotos sind zu groß. Versuchen Sie es mit weniger oder kleineren Fotos.",
+      "paused": "Das Teilen von Lösungen ist gerade pausiert. Versuchen Sie es später noch einmal.",
+      "demo": "Das Teilen von Lösungen ist in der Demo ausgeschaltet."
+    },
+    "invite": "{code} behoben? Erzählen Sie anderen Werkstätten, was geholfen hat.",
+    "inviteHint": "In Ihren eigenen Worten, gern mit ein paar Fotos. Eine Person bei Torqvoice prüft jede Lösung, bevor andere Werkstätten sie sehen.",
+    "start": "Lösung teilen",
+    "dialogTitle": "Lösung für {code} teilen",
+    "dialogDescription": "Andere Werkstätten sehen sie, sobald eine Person bei Torqvoice sie geprüft hat.",
+    "characters": "{count} / {max}",
+    "writingHint": "Was war defekt, wie haben Sie es gefunden und was hat es behoben? Teilenummern und Messwerte helfen anderen am meisten.",
+    "dropHere": "oder hierher ziehen",
+    "tooManyPhotosRemove": "Eine Lösung kann höchstens {max} Fotos haben. Entfernen Sie einige, um sie zu teilen.",
+    "phone": {
+      "button": "Vom Handy hinzufügen",
+      "hide": "Handy-Code ausblenden",
+      "title": "Fotos mit dem Handy aufnehmen",
+      "body": "Scannen Sie den Code mit der Handykamera. Ihre Fotos erscheinen hier, sobald sie ankommen.",
+      "failed": "Der Handy-Code konnte nicht erstellt werden. Schließen Sie das Fenster und versuchen Sie es erneut.",
+      "expires": "Der Code gilt bis {time}",
+      "copy": "Link kopieren",
+      "copied": "Kopiert",
+      "received": "{count, plural, one {# Foto} other {# Fotos}} vom Handy",
+      "badge": "Handy"
+    }
+  },
+  "fixes": {
+    "title": "Lösungen anderer Werkstätten",
+    "empty": "Noch keine andere Werkstatt hat eine Lösung für {code} geteilt.",
+    "unavailable": "Lösungen anderer Werkstätten sind gerade nicht verfügbar.",
+    "sharedOn": "Geteilt am {date}",
+    "unknownVehicle": "Fahrzeug nicht angegeben",
+    "unknownVehicleMarine": "Wasserfahrzeug nicht angegeben",
+    "writtenIn": "Sprache: {language}",
+    "photo": "Foto {n} von {count}",
+    "openPhoto": "Foto {n} öffnen"
+  },
+  "sheet": {
+    "description": "Die Bedeutung, wahrscheinliche Ursachen und die Aufträge Ihrer Werkstatt mit diesem Code.",
+    "loadFailed": "Dieser Code konnte nicht geöffnet werden.",
+    "loading": "Wird geladen…",
+    "openPage": "Codeseite öffnen"
+  },
+  "entry": {
+    "button": "Fehlercodes hinzufügen",
+    "title": "Fehlercodes hinzufügen",
+    "description": "Tippen oder fügen Sie Codes vom Diagnosegerät ein. Jeder wird eine Beobachtung an diesem Auftrag, mit gespeicherter Bedeutung.",
+    "placeholder": "P0420, P0171",
+    "empty": "Eingetippte oder eingefügte Codes erscheinen hier.",
+    "found": "Gefundene Codes",
+    "remove": "{code} entfernen",
+    "concern": "Beantwortet Anliegen",
+    "concernNone": "Kein Anliegen",
+    "cancel": "Abbrechen",
+    "add": "{count, plural, =0 {Codes hinzufügen} one {# Code hinzufügen} other {# Codes hinzufügen}}",
+    "added": "{count, plural, one {# Fehlercode hinzugefügt} other {# Fehlercodes hinzugefügt}}",
+    "skipped": "{count, plural, one {# Code war schon an diesem Auftrag} other {# Codes waren schon an diesem Auftrag}}",
+    "failed": "Die Codes konnten nicht hinzugefügt werden"
+  },
+  "finding": {
+    "seenBefore": "Gesehen {date}",
+    "seenBeforeTitle": "Schon früher an diesem Fahrzeug ausgelesen, zuletzt am {date}",
+    "seenBeforeTitleMarine": "Schon früher an diesem Wasserfahrzeug ausgelesen, zuletzt am {date}",
+    "updatedMeaning": "Neue Bedeutung",
+    "updatedMeaningTitle": "Die Bedeutung lautet jetzt: {meaning}. Tippen, um sie zu übernehmen.",
+    "taken": "Bedeutung aktualisiert",
+    "takeFailed": "Die Bedeutung konnte nicht aktualisiert werden",
+    "noMeaningSaved": "Noch keine Bedeutung gespeichert"
+  },
+  "page": {
+    "title": "Fehlercodes",
+    "intro": "Schlagen Sie einen Code nach oder durchsuchen Sie die Bedeutungen nach Wörtern wie mager Bank 1.",
+    "lookupLabel": "Code oder Wörter",
+    "lookupPlaceholder": "P0420 oder mager Bank 1",
+    "lookup": "Nachschlagen",
+    "cloudOnly": "Die Bedeutungen der Fehlercodes kommen von Torqvoice Codes, einem Teil von Torqvoice Cloud. Codes, die Ihre Werkstatt ausgelesen hat, stehen hier trotzdem.",
+    "results": "Codes zu „{query}“",
+    "noResults": "Kein Code passt zu „{query}“",
+    "recent": "Zuletzt in Ihrer Werkstatt ausgelesen",
+    "noRecent": "Noch keine Fehlercodes an einem Auftrag.",
+    "readTimes": "{count, plural, one {Einmal ausgelesen, {date}} other {# Mal ausgelesen, zuletzt {date}}}",
+    "notACode": "„{text}“ ist kein Fehlercode",
+    "notACodeHint": "Ein Fehlercode ist ein Buchstabe und vier Zeichen, etwa P0420 oder U0100.",
+    "recentLookups": "Ihre letzten Abfragen",
+    "recentLookupsEmpty": "Codes, die Sie öffnen, und Wörter, nach denen Sie suchen, erscheinen hier auf diesem Gerät.",
+    "clearRecent": "Leeren",
+    "tryExamples": "Beispiele",
+    "exampleWords": "mager Bank 1",
+    "searching": "Suche läuft"
+  },
+  "search": {
+    "group": "Fehlercodes",
+    "jobsWith": "Aufträge mit {code}",
+    "searchFor": "Fehlercodes nach „{query}“ durchsuchen"
+  },
+  "notice": {
+    "title": "Fehlercodes sind neu",
+    "body": "Wir fügen laufend Codes hinzu, daher ist noch nicht jeder Code hier. Derzeit sind die standardisierten OBD-II-Codes enthalten; herstellerspezifische Codes noch nicht."
+  },
+  "support": {
+    "button": "Support kontaktieren",
+    "subject": "Fehlercode {code}",
+    "subjectGeneral": "Fehlercodes",
+    "message": "Fehlercode {code}: {title}\n\n"
+  },
+  "systems": {
+    "P": "Antrieb",
+    "B": "Karosserie",
+    "C": "Fahrwerk",
+    "U": "Netzwerk"
+  },
+  "phone": {
+    "title": "Fotos für eine Lösung",
+    "forCode": "für die Lösung, die Sie am Computer schreiben",
+    "intro": "Nehmen Sie bis zu {max} Fotos davon auf, was den Fehler behoben hat, oder wählen Sie sie aus. Sie erscheinen in der Lösung am Computer.",
+    "take": "Foto aufnehmen",
+    "choose": "Fotos auswählen",
+    "added": "{count} von {max} Fotos hinzugefügt",
+    "full": "Eine Lösung kann höchstens {max} Fotos haben.",
+    "notAPhoto": "Diese Datei ist kein Foto.",
+    "failed": "Das Foto konnte nicht hinzugefügt werden. Versuchen Sie es erneut.",
+    "done": "Wenn Sie fertig sind, kehren Sie zum Computer zurück und teilen Sie die Lösung.",
+    "privacy": "Halten Sie Gesichter, Kennzeichen und Kundendaten aus dem Bild.",
+    "expired": "Dieser Code ist abgelaufen. Zeigen Sie am Computer einen neuen an.",
+    "invalid": "Dieser Code funktioniert nicht. Zeigen Sie am Computer einen neuen an."
+  }
+}

+ 13 - 0
messages/de/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Verfügbar",
     "availableTitle": "Verfügbar",
     "availableDescription": "Dienste, die Sie hinzufügen können. Jeder wird einmal eingerichtet und läuft im Hintergrund.",
     "availableDescription": "Dienste, die Sie hinzufügen können. Jeder wird einmal eingerichtet und läuft im Hintergrund.",
     "allConnected": "Alle verfügbaren Dienste sind verbunden.",
     "allConnected": "Alle verfügbaren Dienste sind verbunden.",
+    "cloudOnly": "Nur Cloud",
+    "cloudOnlyHint": "Verfügbar in Torqvoice Cloud",
     "searchPlaceholder": "Integrationen durchsuchen",
     "searchPlaceholder": "Integrationen durchsuchen",
     "noMatches": "Nichts passt zu „{query}“.",
     "noMatches": "Nichts passt zu „{query}“.",
     "readMore": "Mehr erfahren",
     "readMore": "Mehr erfahren",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "Arbeitszeiten in Aufträgen und Angeboten",
       "labor": "Arbeitszeiten in Aufträgen und Angeboten",
       "fluids": "Flüssigkeiten und Füllmengen"
       "fluids": "Flüssigkeiten und Füllmengen"
+    },
+    "diagnostics": {
+      "codes": "Bedeutungen von Fehlercodes"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Monatliche Sicherungen behalten",
         "keepMonthly": "Monatliche Sicherungen behalten",
         "keepMonthlyHelp": "Ältere Sicherungen werden nach jedem Lauf entfernt, wobei jeweils die neuesten jeder Art erhalten bleiben. Setzen Sie eine Zahl auf 0, um von dieser Art keine zu behalten."
         "keepMonthlyHelp": "Ältere Sicherungen werden nach jedem Lauf entfernt, wobei jeweils die neuesten jeder Art erhalten bleiben. Setzen Sie eine Zahl auf 0, um von dieser Art keine zu behalten."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "Sehen Sie, was ein Fehlercode an dem Fahrzeug vor Ihnen bedeutet, in Ihrer Sprache: die Bedeutung, wahrscheinliche Ursachen und verwandte Codes. Tippen oder fügen Sie Codes in einen Auftrag ein, dann werden sie Beobachtungen mit gespeicherter Bedeutung, sodass Ausdrucke und Verlauf nie auf eine Verbindung warten. Von Torqvoice betrieben und in jedem Cloud-Tarif enthalten. Eine Abfrage sendet nur Marke, Modell, Baujahr und Motor, nie eine FIN, ein Kennzeichen oder einen Kunden.",
+      "fields": {
+        "apiToken": "Zugangsschlüssel",
+        "apiTokenHelp": "Der Schlüssel, den Torqvoice Ihnen für den Codedienst gegeben hat."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/de/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Reifensatz",
     "tireSetDetails": "Reifensatz",
     "tireForecast": "Nächste Saison",
     "tireForecast": "Nächste Saison",
     "whatsapp": "WhatsApp",
     "whatsapp": "WhatsApp",
-    "inspectionReminders": "HU-Erinnerungen"
+    "inspectionReminders": "HU-Erinnerungen",
+    "faultCodes": "Fehlercodes",
+    "faultCode": "Fehlercode"
   },
   },
   "search": "Suchen...",
   "search": "Suchen...",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} offene Arbeitsaufträge",
       "workOrders": "{count} offene Arbeitsaufträge",
       "inspections": "{count} laufende Inspektionen",
       "inspections": "{count} laufende Inspektionen",
       "reminders": "{count} fällige Erinnerungen"
       "reminders": "{count} fällige Erinnerungen"
-    }
+    },
+    "faultCodes": "Fehlercodes"
   }
   }
 }
 }

+ 249 - 0
messages/en/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Fault codes",
+  "notAvailable": "Not available right now",
+  "noMeaning": "Not in our code list yet",
+  "manufacturerUnknown": "Manufacturer-specific code: the meaning depends on the make.",
+  "chip": {
+    "open": "Open {code}"
+  },
+  "source": {
+    "generic": "Generic",
+    "manufacturer": "Manufacturer",
+    "report": "From the scan report",
+    "machineTranslated": "Machine translated"
+  },
+  "status": {
+    "stored": "Stored",
+    "pending": "Pending",
+    "permanent": "Permanent",
+    "history": "History"
+  },
+  "flags": {
+    "warningLight": "Warning light",
+    "emissions": "Emissions",
+    "driveCycle": "Needs a drive cycle",
+    "limpMode": "Limp mode"
+  },
+  "sections": {
+    "how_it_works": "How it works",
+    "meaning": "What the code means",
+    "causes": "Typical causes",
+    "manufacturer": "Manufacturer details",
+    "related": "Together with other codes",
+    "diagnosis": "Diagnosis tip",
+    "other": "Good to know"
+  },
+  "detail": {
+    "whatItMeans": "What it means",
+    "likelyCauses": "Likely causes",
+    "relatedCodes": "Related codes",
+    "inYourWorkshop": "In your workshop",
+    "noJobs": "Not read on any job in your workshop yet.",
+    "resolved": "Resolved",
+    "notConnected": "Connect Torqvoice Codes to see what codes mean.",
+    "connect": "Connect",
+    "serviceDown": "The meaning is not available right now. Your jobs are below.",
+    "pendingCheck": "Only the title is shown until the text has been checked.",
+    "showMore": "Show more",
+    "showLess": "Show less",
+    "likelihood": {
+      "high": "High",
+      "medium": "Medium",
+      "low": "Low"
+    },
+    "mostLikelyFirst": "Most likely first"
+  },
+  "report": {
+    "button": "Report a problem",
+    "title": "What is wrong with the meaning of {code}?",
+    "reasons": {
+      "wrong_for_vehicle": "Wrong for this car",
+      "wrong_for_vehicleMarine": "Wrong for this vessel",
+      "text_mismatch": "Text doesn't match the code",
+      "wrong_position": "Wrong side or position",
+      "bad_translation": "Bad translation",
+      "other": "Other"
+    },
+    "note": "Note (optional)",
+    "notePlaceholder": "What you found",
+    "send": "Send",
+    "thanks": "Thanks, we'll check it",
+    "failed": "The report could not be sent right now. Try again in a moment.",
+    "reportedByYou": "Reported by you",
+    "outcome": {
+      "fixed": "Updated after your report",
+      "no_change": "Checked, no change",
+      "duplicate": "Already reported"
+    }
+  },
+  "share": {
+    "title": "Share your fix",
+    "description": "Tell other workshops what fixed {code}. A person at Torqvoice checks every fix before others see it.",
+    "yourFixes": "Your fixes for {code}",
+    "whatFixedIt": "What fixed it",
+    "placeholder": "Chafed wire at the rear O2 sensor plug behind the gearbox. Repaired it, code cleared after a test drive.",
+    "placeholderMarine": "Corroded pins in the trim sensor plug. Cleaned and sealed them, code cleared after a sea trial.",
+    "photos": "Photos (optional)",
+    "photosHint": "Up to {max} photos. Keep faces, number plates and customer details out of the picture.",
+    "photosHintMarine": "Up to {max} photos. Keep faces, registration numbers and customer details out of the picture.",
+    "addPhotos": "Add photos",
+    "removePhoto": "Remove photo {n}",
+    "tooManyPhotos": "At most {max} photos",
+    "notAPhoto": "{name} is not a photo",
+    "photoTooBig": "{name} is larger than 15 MB",
+    "vehicle": "Vehicle",
+    "vehicleMarine": "Vessel",
+    "vehicleOptional": "Vehicle (optional)",
+    "vehicleOptionalMarine": "Vessel (optional)",
+    "make": "Make",
+    "model": "Model",
+    "year": "Year",
+    "vehicleHint": "Only make, model, year and engine are shared, never the plate, VIN, customer or your workshop's name.",
+    "vehicleHintMarine": "Only make, model, year and engine are shared, never the registration, hull number, customer or your workshop's name.",
+    "send": "Share fix",
+    "sent": "Thanks. Your fix is waiting for review.",
+    "shareAnother": "Share another fix",
+    "cancel": "Cancel",
+    "sentOn": "Sent {date}",
+    "reason": "Reason: {reason}",
+    "withdraw": "Withdraw",
+    "withdrawTitle": "Withdraw this fix?",
+    "withdrawConfirm": "It is deleted with its photos, and no other workshop will see it.",
+    "withdrawn": "Fix withdrawn",
+    "withdrawFailed": "The fix could not be withdrawn right now. Try again in a moment.",
+    "unavailable": "Sharing fixes is not available right now.",
+    "status": {
+      "pending": "Waiting for review",
+      "published": "Published",
+      "rejected": "Not published"
+    },
+    "errors": {
+      "failed": "Your fix could not be sent right now. Try again in a moment.",
+      "photo": "One of the photos could not be read. Try another one.",
+      "too_large": "The photos are too large. Try fewer or smaller photos.",
+      "paused": "Sharing fixes is paused right now. Try again later.",
+      "demo": "Sharing fixes is turned off on the demo."
+    },
+    "invite": "Fixed {code}? Tell other workshops what worked.",
+    "inviteHint": "In your own words, with a few photos if you have them. A person at Torqvoice checks every fix before other workshops see it.",
+    "start": "Share your fix",
+    "dialogTitle": "Share your fix for {code}",
+    "dialogDescription": "Other workshops see it once a person at Torqvoice has checked it.",
+    "characters": "{count} / {max}",
+    "writingHint": "What was wrong, how you found it and what fixed it. Part numbers and readings help others most.",
+    "dropHere": "or drop them here",
+    "tooManyPhotosRemove": "A fix can have at most {max} photos. Remove some to share it.",
+    "phone": {
+      "button": "Add from phone",
+      "hide": "Hide phone code",
+      "title": "Take photos with your phone",
+      "body": "Scan the code with your phone's camera. The photos you take show up here as they arrive.",
+      "failed": "The phone code could not be made. Close this and try again.",
+      "expires": "The code works until {time}",
+      "copy": "Copy link",
+      "copied": "Copied",
+      "received": "{count, plural, one {# photo} other {# photos}} from your phone",
+      "badge": "Phone"
+    }
+  },
+  "fixes": {
+    "title": "Fixes from other workshops",
+    "empty": "No other workshop has shared a fix for {code} yet.",
+    "unavailable": "Fixes from other workshops are not available right now.",
+    "sharedOn": "Shared {date}",
+    "unknownVehicle": "Vehicle not given",
+    "unknownVehicleMarine": "Vessel not given",
+    "writtenIn": "Written in {language}",
+    "photo": "Photo {n} of {count}",
+    "openPhoto": "Open photo {n}"
+  },
+  "sheet": {
+    "description": "The meaning, likely causes and the jobs in your workshop with this code.",
+    "loadFailed": "Could not open this code.",
+    "loading": "Loading…",
+    "openPage": "Open the code page"
+  },
+  "entry": {
+    "button": "Add fault codes",
+    "title": "Add fault codes",
+    "description": "Type or paste codes from the scan tool. Each one becomes an observation on this job, with its meaning saved.",
+    "placeholder": "P0420, P0171 stored",
+    "empty": "Codes you type or paste show up here.",
+    "found": "Codes found",
+    "remove": "Remove {code}",
+    "concern": "Answers concern",
+    "concernNone": "No concern",
+    "cancel": "Cancel",
+    "add": "{count, plural, =0 {Add codes} one {Add # code} other {Add # codes}}",
+    "added": "{count, plural, one {# fault code added} other {# fault codes added}}",
+    "skipped": "{count, plural, one {# code was already on this job} other {# codes were already on this job}}",
+    "failed": "Could not add the codes"
+  },
+  "finding": {
+    "seenBefore": "Seen {date}",
+    "seenBeforeTitle": "Read on this vehicle before, last on {date}",
+    "seenBeforeTitleMarine": "Read on this vessel before, last on {date}",
+    "updatedMeaning": "Updated meaning",
+    "updatedMeaningTitle": "The meaning now reads: {meaning}. Tap to use it.",
+    "taken": "Meaning updated",
+    "takeFailed": "Could not update the meaning",
+    "noMeaningSaved": "No meaning saved yet"
+  },
+  "page": {
+    "title": "Fault codes",
+    "intro": "Look up a code, or search the meanings for words such as lean bank 1.",
+    "lookupLabel": "Code or words",
+    "lookupPlaceholder": "P0420 or lean bank 1",
+    "lookup": "Look up",
+    "cloudOnly": "Meanings for fault codes come from Torqvoice Codes, which is part of Torqvoice Cloud. Codes your workshop has read are listed here either way.",
+    "results": "Codes matching “{query}”",
+    "noResults": "No code matches “{query}”",
+    "recent": "Read lately in your workshop",
+    "noRecent": "No fault codes on any job yet.",
+    "readTimes": "{count, plural, one {Read once, {date}} other {Read # times, last {date}}}",
+    "notACode": "“{text}” is not a fault code",
+    "notACodeHint": "A fault code is a letter and four characters, such as P0420 or U0100.",
+    "recentLookups": "Your recent lookups",
+    "recentLookupsEmpty": "Codes you open and words you search for show up here, on this device.",
+    "clearRecent": "Clear",
+    "tryExamples": "Try",
+    "exampleWords": "lean bank 1",
+    "searching": "Searching"
+  },
+  "search": {
+    "group": "Fault codes",
+    "jobsWith": "Jobs with {code}",
+    "searchFor": "Search fault codes for “{query}”"
+  },
+  "notice": {
+    "title": "Fault codes are new",
+    "body": "We are adding codes all the time, so not every code is here yet. For now this covers the standard OBD-II codes; manufacturer-specific codes are not included yet."
+  },
+  "support": {
+    "button": "Contact support",
+    "subject": "Fault code {code}",
+    "subjectGeneral": "Fault codes",
+    "message": "Fault code {code}: {title}\n\n"
+  },
+  "systems": {
+    "P": "Powertrain",
+    "B": "Body",
+    "C": "Chassis",
+    "U": "Network"
+  },
+  "phone": {
+    "title": "Photos for a fix",
+    "forCode": "for the fix you are writing on the computer",
+    "intro": "Take or choose up to {max} photos of what fixed the code. They appear in the fix on the computer.",
+    "take": "Take photo",
+    "choose": "Choose photos",
+    "added": "{count} of {max} photos added",
+    "full": "A fix can have at most {max} photos.",
+    "notAPhoto": "That file is not a photo.",
+    "failed": "The photo could not be added. Try again.",
+    "done": "When you are done, go back to the computer and share the fix.",
+    "privacy": "Keep faces, number plates and customer details out of the picture.",
+    "expired": "This code has run out. Show a new one on the computer.",
+    "invalid": "This code does not work. Show a new one on the computer."
+  }
+}

+ 13 - 0
messages/en/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Available",
     "availableTitle": "Available",
     "availableDescription": "Services you can add. Each one is set up once and runs in the background.",
     "availableDescription": "Services you can add. Each one is set up once and runs in the background.",
     "allConnected": "Every available service is connected.",
     "allConnected": "Every available service is connected.",
+    "cloudOnly": "Cloud only",
+    "cloudOnlyHint": "Available on Torqvoice Cloud",
     "searchPlaceholder": "Search integrations",
     "searchPlaceholder": "Search integrations",
     "noMatches": "Nothing matches \"{query}\".",
     "noMatches": "Nothing matches \"{query}\".",
     "readMore": "Read more",
     "readMore": "Read more",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "Labor times on work orders and quotes",
       "labor": "Labor times on work orders and quotes",
       "fluids": "Fluid types and capacities"
       "fluids": "Fluid types and capacities"
+    },
+    "diagnostics": {
+      "codes": "Fault code meanings"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Keep monthly backups",
         "keepMonthly": "Keep monthly backups",
         "keepMonthlyHelp": "Older backups are removed after each run, keeping the newest of each kind. Set a number to 0 to keep none of that kind."
         "keepMonthlyHelp": "Older backups are removed after each run, keeping the newest of each kind. Set a number to 0 to keep none of that kind."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "See what a fault code means on the vehicle in front of you, in your own language: the meaning, likely causes and related codes. Type or paste codes on a work order and they become observations with their meaning saved, so prints and history never wait on a connection. Run by Torqvoice and included in every cloud plan. A lookup sends only make, model, year and engine, never a VIN, a plate or a customer.",
+      "fields": {
+        "apiToken": "Access key",
+        "apiTokenHelp": "The key Torqvoice gave you for the code service."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/en/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Tire Set",
     "tireSetDetails": "Tire Set",
     "tireForecast": "Next season",
     "tireForecast": "Next season",
     "whatsapp": "WhatsApp",
     "whatsapp": "WhatsApp",
-    "inspectionReminders": "Inspection reminders"
+    "inspectionReminders": "Inspection reminders",
+    "faultCodes": "Fault codes",
+    "faultCode": "Fault code"
   },
   },
   "search": "Search...",
   "search": "Search...",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} open work orders",
       "workOrders": "{count} open work orders",
       "inspections": "{count} inspections in progress",
       "inspections": "{count} inspections in progress",
       "reminders": "{count} reminders due"
       "reminders": "{count} reminders due"
-    }
+    },
+    "faultCodes": "Fault codes"
   }
   }
 }
 }

+ 249 - 0
messages/es/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Códigos de avería",
+  "notAvailable": "No disponible ahora mismo",
+  "noMeaning": "Aún no está en nuestra lista de códigos",
+  "manufacturerUnknown": "Código específico del fabricante: el significado depende de la marca.",
+  "chip": {
+    "open": "Abrir {code}"
+  },
+  "source": {
+    "generic": "Genérico",
+    "manufacturer": "Fabricante",
+    "report": "Del informe de diagnosis",
+    "machineTranslated": "Traducción automática"
+  },
+  "status": {
+    "stored": "Memorizado",
+    "pending": "Pendiente",
+    "permanent": "Permanente",
+    "history": "Historial"
+  },
+  "flags": {
+    "warningLight": "Testigo de avería",
+    "emissions": "Emisiones",
+    "driveCycle": "Requiere ciclo de conducción",
+    "limpMode": "Modo de emergencia"
+  },
+  "sections": {
+    "how_it_works": "Cómo funciona",
+    "meaning": "Qué significa el código",
+    "causes": "Causas típicas",
+    "manufacturer": "Datos de los fabricantes",
+    "related": "Junto con otros códigos",
+    "diagnosis": "Consejo de diagnóstico",
+    "other": "Conviene saber"
+  },
+  "detail": {
+    "whatItMeans": "Qué significa",
+    "likelyCauses": "Causas probables",
+    "relatedCodes": "Códigos relacionados",
+    "inYourWorkshop": "En tu taller",
+    "noJobs": "Aún no se ha leído en ningún trabajo de tu taller.",
+    "resolved": "Resuelto",
+    "notConnected": "Conecta Torqvoice Codes para ver qué significan los códigos.",
+    "connect": "Conectar",
+    "serviceDown": "El significado no está disponible ahora mismo. Tus trabajos están abajo.",
+    "pendingCheck": "Solo se muestra el título hasta que se revise el texto.",
+    "showMore": "Ver más",
+    "showLess": "Ver menos",
+    "likelihood": {
+      "high": "Alta",
+      "medium": "Media",
+      "low": "Baja"
+    },
+    "mostLikelyFirst": "Las más probables primero"
+  },
+  "report": {
+    "button": "Informar de un problema",
+    "title": "¿Qué falla en el significado de {code}?",
+    "reasons": {
+      "wrong_for_vehicle": "Incorrecto para este coche",
+      "wrong_for_vehicleMarine": "Incorrecto para esta embarcación",
+      "text_mismatch": "El texto no coincide con el código",
+      "wrong_position": "Lado o posición incorrectos",
+      "bad_translation": "Mala traducción",
+      "other": "Otro"
+    },
+    "note": "Nota (opcional)",
+    "notePlaceholder": "Lo que encontraste",
+    "send": "Enviar",
+    "thanks": "Gracias, lo revisaremos",
+    "failed": "No se pudo enviar el aviso ahora mismo. Inténtalo de nuevo en un momento.",
+    "reportedByYou": "Informado por ti",
+    "outcome": {
+      "fixed": "Actualizado tras tu aviso",
+      "no_change": "Revisado, sin cambios",
+      "duplicate": "Ya informado"
+    }
+  },
+  "share": {
+    "title": "Comparte tu solución",
+    "description": "Cuenta a otros talleres qué solucionó {code}. Una persona de Torqvoice revisa cada solución antes de que otros la vean.",
+    "yourFixes": "Tus soluciones para {code}",
+    "whatFixedIt": "Qué lo solucionó",
+    "placeholder": "Cable rozado en el conector de la sonda lambda trasera, detrás de la caja de cambios. Reparado, el código se borró tras una prueba de conducción.",
+    "placeholderMarine": "Pines corroídos en el conector del sensor de trim. Limpiados y sellados, el código se borró tras una prueba en el agua.",
+    "photos": "Fotos (opcional)",
+    "photosHint": "Hasta {max} fotos. Deja fuera caras, matrículas y datos del cliente.",
+    "photosHintMarine": "Hasta {max} fotos. Deja fuera caras, matrículas de la embarcación y datos del cliente.",
+    "addPhotos": "Añadir fotos",
+    "removePhoto": "Quitar la foto {n}",
+    "tooManyPhotos": "Como máximo {max} fotos",
+    "notAPhoto": "{name} no es una foto",
+    "photoTooBig": "{name} pesa más de 15 MB",
+    "vehicle": "Vehículo",
+    "vehicleMarine": "Embarcación",
+    "vehicleOptional": "Vehículo (opcional)",
+    "vehicleOptionalMarine": "Embarcación (opcional)",
+    "make": "Marca",
+    "model": "Modelo",
+    "year": "Año",
+    "vehicleHint": "Solo se comparten la marca, el modelo, el año y el motor, nunca la matrícula, el VIN, el cliente ni el nombre de tu taller.",
+    "vehicleHintMarine": "Solo se comparten la marca, el modelo, el año y el motor, nunca la matrícula, el número de casco, el cliente ni el nombre de tu taller.",
+    "send": "Compartir solución",
+    "sent": "Gracias. Tu solución está pendiente de revisión.",
+    "shareAnother": "Compartir otra solución",
+    "cancel": "Cancelar",
+    "sentOn": "Enviada el {date}",
+    "reason": "Motivo: {reason}",
+    "withdraw": "Retirar",
+    "withdrawTitle": "¿Retirar esta solución?",
+    "withdrawConfirm": "Se borrará con sus fotos y ningún otro taller la verá.",
+    "withdrawn": "Solución retirada",
+    "withdrawFailed": "No se ha podido retirar la solución ahora mismo. Inténtalo de nuevo en un momento.",
+    "unavailable": "Compartir soluciones no está disponible ahora mismo.",
+    "status": {
+      "pending": "Pendiente de revisión",
+      "published": "Publicada",
+      "rejected": "No publicada"
+    },
+    "errors": {
+      "failed": "No se ha podido enviar tu solución ahora mismo. Inténtalo de nuevo en un momento.",
+      "photo": "No se ha podido leer una de las fotos. Prueba con otra.",
+      "too_large": "Las fotos son demasiado grandes. Prueba con menos fotos o más pequeñas.",
+      "paused": "Compartir soluciones está en pausa ahora mismo. Inténtalo más tarde.",
+      "demo": "Compartir soluciones está desactivado en la demo."
+    },
+    "invite": "¿Has solucionado {code}? Cuenta a otros talleres qué funcionó.",
+    "inviteHint": "Con tus propias palabras y, si las tienes, algunas fotos. Una persona de Torqvoice revisa cada solución antes de que otros talleres la vean.",
+    "start": "Compartir tu solución",
+    "dialogTitle": "Compartir tu solución para {code}",
+    "dialogDescription": "Otros talleres la verán cuando una persona de Torqvoice la haya revisado.",
+    "characters": "{count} / {max}",
+    "writingHint": "Qué fallaba, cómo lo encontraste y qué lo solucionó. Las referencias de piezas y las mediciones son lo que más ayuda a otros.",
+    "dropHere": "o suéltalas aquí",
+    "tooManyPhotosRemove": "Una solución puede tener como máximo {max} fotos. Quita algunas para compartirla.",
+    "phone": {
+      "button": "Añadir desde el móvil",
+      "hide": "Ocultar código del móvil",
+      "title": "Haz fotos con el móvil",
+      "body": "Escanea el código con la cámara del móvil. Las fotos que hagas aparecerán aquí a medida que lleguen.",
+      "failed": "No se pudo crear el código para el móvil. Cierra esto y vuelve a intentarlo.",
+      "expires": "El código funciona hasta las {time}",
+      "copy": "Copiar enlace",
+      "copied": "Copiado",
+      "received": "{count, plural, one {# foto} other {# fotos}} desde el móvil",
+      "badge": "Móvil"
+    }
+  },
+  "fixes": {
+    "title": "Soluciones de otros talleres",
+    "empty": "Ningún otro taller ha compartido todavía una solución para {code}.",
+    "unavailable": "Las soluciones de otros talleres no están disponibles ahora mismo.",
+    "sharedOn": "Compartida el {date}",
+    "unknownVehicle": "Vehículo no indicado",
+    "unknownVehicleMarine": "Embarcación no indicada",
+    "writtenIn": "Escrita en {language}",
+    "photo": "Foto {n} de {count}",
+    "openPhoto": "Abrir la foto {n}"
+  },
+  "sheet": {
+    "description": "El significado, las causas probables y los trabajos de tu taller con este código.",
+    "loadFailed": "No se pudo abrir este código.",
+    "loading": "Cargando…",
+    "openPage": "Abrir la página del código"
+  },
+  "entry": {
+    "button": "Añadir códigos de avería",
+    "title": "Añadir códigos de avería",
+    "description": "Escribe o pega los códigos del equipo de diagnosis. Cada uno se convierte en una observación de este trabajo, con su significado guardado.",
+    "placeholder": "P0420, P0171",
+    "empty": "Los códigos que escribas o pegues aparecen aquí.",
+    "found": "Códigos encontrados",
+    "remove": "Quitar {code}",
+    "concern": "Responde al punto",
+    "concernNone": "Ningún punto",
+    "cancel": "Cancelar",
+    "add": "{count, plural, =0 {Añadir códigos} one {Añadir # código} other {Añadir # códigos}}",
+    "added": "{count, plural, one {# código de avería añadido} other {# códigos de avería añadidos}}",
+    "skipped": "{count, plural, one {# código ya estaba en este trabajo} other {# códigos ya estaban en este trabajo}}",
+    "failed": "No se pudieron añadir los códigos"
+  },
+  "finding": {
+    "seenBefore": "Visto {date}",
+    "seenBeforeTitle": "Ya leído en este vehículo, la última vez el {date}",
+    "seenBeforeTitleMarine": "Ya leído en esta embarcación, la última vez el {date}",
+    "updatedMeaning": "Significado actualizado",
+    "updatedMeaningTitle": "Ahora el significado es: {meaning}. Toca para usarlo.",
+    "taken": "Significado actualizado",
+    "takeFailed": "No se pudo actualizar el significado",
+    "noMeaningSaved": "Aún no hay significado guardado"
+  },
+  "page": {
+    "title": "Códigos de avería",
+    "intro": "Busca un código, o busca en los significados palabras como pobre banco 1.",
+    "lookupLabel": "Código o palabras",
+    "lookupPlaceholder": "P0420 o pobre banco 1",
+    "lookup": "Buscar",
+    "cloudOnly": "Los significados de los códigos de avería vienen de Torqvoice Codes, que forma parte de Torqvoice Cloud. Los códigos que ha leído tu taller aparecen aquí igualmente.",
+    "results": "Códigos que coinciden con \"{query}\"",
+    "noResults": "Ningún código coincide con \"{query}\"",
+    "recent": "Leídos recientemente en tu taller",
+    "noRecent": "Aún no hay códigos de avería en ningún trabajo.",
+    "readTimes": "{count, plural, one {Leído una vez, {date}} other {Leído # veces, la última {date}}}",
+    "notACode": "\"{text}\" no es un código de avería",
+    "notACodeHint": "Un código de avería es una letra y cuatro caracteres, como P0420 o U0100.",
+    "recentLookups": "Tus búsquedas recientes",
+    "recentLookupsEmpty": "Los códigos que abres y las palabras que buscas aparecen aquí, en este dispositivo.",
+    "clearRecent": "Borrar",
+    "tryExamples": "Prueba",
+    "exampleWords": "pobre banco 1",
+    "searching": "Buscando"
+  },
+  "search": {
+    "group": "Códigos de avería",
+    "jobsWith": "Trabajos con {code}",
+    "searchFor": "Buscar códigos de avería por \"{query}\""
+  },
+  "notice": {
+    "title": "Los códigos de avería son nuevos",
+    "body": "Añadimos códigos continuamente, así que todavía no están todos. Por ahora incluye los códigos OBD-II estándar; los códigos propios de cada fabricante aún no están incluidos."
+  },
+  "support": {
+    "button": "Contactar con soporte",
+    "subject": "Código de avería {code}",
+    "subjectGeneral": "Códigos de avería",
+    "message": "Código de avería {code}: {title}\n\n"
+  },
+  "systems": {
+    "P": "Motor y transmisión",
+    "B": "Carrocería",
+    "C": "Chasis",
+    "U": "Red"
+  },
+  "phone": {
+    "title": "Fotos para una solución",
+    "forCode": "para la solución que estás escribiendo en el ordenador",
+    "intro": "Haz o elige hasta {max} fotos de lo que solucionó el código. Aparecerán en la solución en el ordenador.",
+    "take": "Hacer foto",
+    "choose": "Elegir fotos",
+    "added": "{count} de {max} fotos añadidas",
+    "full": "Una solución puede tener como máximo {max} fotos.",
+    "notAPhoto": "Ese archivo no es una foto.",
+    "failed": "No se pudo añadir la foto. Vuelve a intentarlo.",
+    "done": "Cuando termines, vuelve al ordenador y comparte la solución.",
+    "privacy": "Deja fuera de la imagen caras, matrículas y datos de clientes.",
+    "expired": "Este código ha caducado. Muestra uno nuevo en el ordenador.",
+    "invalid": "Este código no funciona. Muestra uno nuevo en el ordenador."
+  }
+}

+ 13 - 0
messages/es/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Disponibles",
     "availableTitle": "Disponibles",
     "availableDescription": "Servicios que puede añadir. Cada uno se configura una vez y funciona en segundo plano.",
     "availableDescription": "Servicios que puede añadir. Cada uno se configura una vez y funciona en segundo plano.",
     "allConnected": "Todos los servicios disponibles están conectados.",
     "allConnected": "Todos los servicios disponibles están conectados.",
+    "cloudOnly": "Solo en la nube",
+    "cloudOnlyHint": "Disponible en Torqvoice Cloud",
     "searchPlaceholder": "Buscar integraciones",
     "searchPlaceholder": "Buscar integraciones",
     "noMatches": "Nada coincide con «{query}».",
     "noMatches": "Nada coincide con «{query}».",
     "readMore": "Leer más",
     "readMore": "Leer más",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "Tiempos de mano de obra en órdenes de trabajo y presupuestos",
       "labor": "Tiempos de mano de obra en órdenes de trabajo y presupuestos",
       "fluids": "Tipos y capacidades de fluidos"
       "fluids": "Tipos y capacidades de fluidos"
+    },
+    "diagnostics": {
+      "codes": "Significado de los códigos de avería"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Conservar copias mensuales",
         "keepMonthly": "Conservar copias mensuales",
         "keepMonthlyHelp": "Las copias más antiguas se eliminan después de cada ejecución y se conservan las más recientes de cada tipo. Ponga un número a 0 para no conservar ninguna de ese tipo."
         "keepMonthlyHelp": "Las copias más antiguas se eliminan después de cada ejecución y se conservan las más recientes de cada tipo. Ponga un número a 0 para no conservar ninguna de ese tipo."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "Mira qué significa un código de avería en el vehículo que tienes delante, en tu idioma: el significado, las causas probables y los códigos relacionados. Escribe o pega códigos en una orden de trabajo y se convierten en observaciones con su significado guardado, así las impresiones y el historial nunca dependen de una conexión. Lo gestiona Torqvoice y está incluido en todos los planes en la nube. Una consulta solo envía marca, modelo, año y motor, nunca un VIN, una matrícula ni un cliente.",
+      "fields": {
+        "apiToken": "Clave de acceso",
+        "apiTokenHelp": "La clave que Torqvoice te dio para el servicio de códigos."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/es/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Juego de neumáticos",
     "tireSetDetails": "Juego de neumáticos",
     "tireForecast": "La próxima temporada",
     "tireForecast": "La próxima temporada",
     "whatsapp": "WhatsApp",
     "whatsapp": "WhatsApp",
-    "inspectionReminders": "Recordatorios de inspección"
+    "inspectionReminders": "Recordatorios de inspección",
+    "faultCodes": "Códigos de avería",
+    "faultCode": "Código de avería"
   },
   },
   "search": "Buscar...",
   "search": "Buscar...",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} órdenes de trabajo abiertas",
       "workOrders": "{count} órdenes de trabajo abiertas",
       "inspections": "{count} inspecciones en curso",
       "inspections": "{count} inspecciones en curso",
       "reminders": "{count} recordatorios vencidos"
       "reminders": "{count} recordatorios vencidos"
-    }
+    },
+    "faultCodes": "Códigos de avería"
   }
   }
 }
 }

+ 249 - 0
messages/fr/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Codes défaut",
+  "notAvailable": "Indisponible pour le moment",
+  "noMeaning": "Pas encore dans notre liste de codes",
+  "manufacturerUnknown": "Code spécifique au constructeur : la signification dépend de la marque.",
+  "chip": {
+    "open": "Ouvrir {code}"
+  },
+  "source": {
+    "generic": "Générique",
+    "manufacturer": "Constructeur",
+    "report": "Du rapport de diagnostic",
+    "machineTranslated": "Traduction automatique"
+  },
+  "status": {
+    "stored": "Mémorisé",
+    "pending": "En attente",
+    "permanent": "Permanent",
+    "history": "Historique"
+  },
+  "flags": {
+    "warningLight": "Voyant allumé",
+    "emissions": "Émissions",
+    "driveCycle": "Cycle de conduite nécessaire",
+    "limpMode": "Mode dégradé"
+  },
+  "sections": {
+    "how_it_works": "Fonctionnement",
+    "meaning": "Ce que signifie le code",
+    "causes": "Causes typiques",
+    "manufacturer": "Données constructeurs",
+    "related": "Avec d'autres codes",
+    "diagnosis": "Conseil de diagnostic",
+    "other": "Bon à savoir"
+  },
+  "detail": {
+    "whatItMeans": "Ce qu'il signifie",
+    "likelyCauses": "Causes probables",
+    "relatedCodes": "Codes associés",
+    "inYourWorkshop": "Dans votre atelier",
+    "noJobs": "Pas encore lu sur un travail de votre atelier.",
+    "resolved": "Résolu",
+    "notConnected": "Connectez Torqvoice Codes pour voir ce que signifient les codes.",
+    "connect": "Connecter",
+    "serviceDown": "La signification est indisponible pour le moment. Vos travaux figurent ci-dessous.",
+    "pendingCheck": "Seul le titre est affiché tant que le texte n'a pas été vérifié.",
+    "showMore": "Afficher plus",
+    "showLess": "Afficher moins",
+    "likelihood": {
+      "high": "Élevée",
+      "medium": "Moyenne",
+      "low": "Faible"
+    },
+    "mostLikelyFirst": "Les plus probables d’abord"
+  },
+  "report": {
+    "button": "Signaler un problème",
+    "title": "Qu'est-ce qui ne va pas dans la signification de {code} ?",
+    "reasons": {
+      "wrong_for_vehicle": "Faux pour cette voiture",
+      "wrong_for_vehicleMarine": "Faux pour ce navire",
+      "text_mismatch": "Le texte ne correspond pas au code",
+      "wrong_position": "Mauvais côté ou position",
+      "bad_translation": "Mauvaise traduction",
+      "other": "Autre"
+    },
+    "note": "Note (facultatif)",
+    "notePlaceholder": "Ce que vous avez trouvé",
+    "send": "Envoyer",
+    "thanks": "Merci, nous allons vérifier",
+    "failed": "Le signalement n'a pas pu être envoyé pour le moment. Réessayez dans un instant.",
+    "reportedByYou": "Signalé par vous",
+    "outcome": {
+      "fixed": "Mis à jour après votre signalement",
+      "no_change": "Vérifié, aucun changement",
+      "duplicate": "Déjà signalé"
+    }
+  },
+  "share": {
+    "title": "Partagez votre solution",
+    "description": "Dites aux autres ateliers ce qui a résolu {code}. Une personne chez Torqvoice vérifie chaque solution avant que d'autres la voient.",
+    "yourFixes": "Vos solutions pour {code}",
+    "whatFixedIt": "Ce qui a résolu le problème",
+    "placeholder": "Fil frotté au connecteur de la sonde lambda arrière, derrière la boîte de vitesses. Réparé, code effacé après un essai routier.",
+    "placeholderMarine": "Broches corrodées dans le connecteur du capteur de trim. Nettoyées et étanchéifiées, code effacé après un essai en mer.",
+    "photos": "Photos (facultatif)",
+    "photosHint": "Jusqu'à {max} photos. Laissez hors du cadre les visages, les plaques d'immatriculation et les données du client.",
+    "photosHintMarine": "Jusqu'à {max} photos. Laissez hors du cadre les visages, les immatriculations du navire et les données du client.",
+    "addPhotos": "Ajouter des photos",
+    "removePhoto": "Retirer la photo {n}",
+    "tooManyPhotos": "{max} photos au maximum",
+    "notAPhoto": "{name} n'est pas une photo",
+    "photoTooBig": "{name} dépasse 15 Mo",
+    "vehicle": "Véhicule",
+    "vehicleMarine": "Navire",
+    "vehicleOptional": "Véhicule (facultatif)",
+    "vehicleOptionalMarine": "Navire (facultatif)",
+    "make": "Marque",
+    "model": "Modèle",
+    "year": "Année",
+    "vehicleHint": "Seuls la marque, le modèle, l'année et le moteur sont partagés, jamais la plaque, le VIN, le client ni le nom de votre atelier.",
+    "vehicleHintMarine": "Seuls la marque, le modèle, l'année et le moteur sont partagés, jamais l'immatriculation, le numéro de coque, le client ni le nom de votre atelier.",
+    "send": "Partager la solution",
+    "sent": "Merci. Votre solution attend d'être vérifiée.",
+    "shareAnother": "Partager une autre solution",
+    "cancel": "Annuler",
+    "sentOn": "Envoyée le {date}",
+    "reason": "Motif : {reason}",
+    "withdraw": "Retirer",
+    "withdrawTitle": "Retirer cette solution ?",
+    "withdrawConfirm": "Elle sera supprimée avec ses photos et aucun autre atelier ne la verra.",
+    "withdrawn": "Solution retirée",
+    "withdrawFailed": "La solution n'a pas pu être retirée pour le moment. Réessayez dans un instant.",
+    "unavailable": "Le partage de solutions est indisponible pour le moment.",
+    "status": {
+      "pending": "En attente de vérification",
+      "published": "Publiée",
+      "rejected": "Non publiée"
+    },
+    "errors": {
+      "failed": "Votre solution n'a pas pu être envoyée pour le moment. Réessayez dans un instant.",
+      "photo": "Une des photos n'a pas pu être lue. Essayez-en une autre.",
+      "too_large": "Les photos sont trop lourdes. Essayez avec moins de photos ou des photos plus petites.",
+      "paused": "Le partage de solutions est en pause pour le moment. Réessayez plus tard.",
+      "demo": "Le partage de solutions est désactivé dans la démo."
+    },
+    "invite": "Vous avez résolu {code} ? Dites aux autres garages ce qui a marché.",
+    "inviteHint": "Avec vos propres mots et, si vous en avez, quelques photos. Une personne chez Torqvoice vérifie chaque solution avant que d’autres garages la voient.",
+    "start": "Partager votre solution",
+    "dialogTitle": "Partager votre solution pour {code}",
+    "dialogDescription": "Les autres garages la verront une fois qu’une personne chez Torqvoice l’aura vérifiée.",
+    "characters": "{count} / {max}",
+    "writingHint": "Ce qui n’allait pas, comment vous l’avez trouvé et ce qui l’a résolu. Les références de pièces et les mesures aident le plus les autres.",
+    "dropHere": "ou déposez-les ici",
+    "tooManyPhotosRemove": "Une solution peut avoir au plus {max} photos. Retirez-en pour la partager.",
+    "phone": {
+      "button": "Ajouter depuis le téléphone",
+      "hide": "Masquer le code du téléphone",
+      "title": "Prenez des photos avec votre téléphone",
+      "body": "Scannez le code avec l’appareil photo du téléphone. Les photos que vous prenez apparaissent ici dès leur arrivée.",
+      "failed": "Le code pour le téléphone n’a pas pu être créé. Fermez cette fenêtre et réessayez.",
+      "expires": "Le code est valable jusqu’à {time}",
+      "copy": "Copier le lien",
+      "copied": "Copié",
+      "received": "{count, plural, one {# photo} other {# photos}} depuis votre téléphone",
+      "badge": "Téléphone"
+    }
+  },
+  "fixes": {
+    "title": "Solutions d'autres ateliers",
+    "empty": "Aucun autre atelier n'a encore partagé de solution pour {code}.",
+    "unavailable": "Les solutions d'autres ateliers sont indisponibles pour le moment.",
+    "sharedOn": "Partagée le {date}",
+    "unknownVehicle": "Véhicule non indiqué",
+    "unknownVehicleMarine": "Navire non indiqué",
+    "writtenIn": "Écrite en {language}",
+    "photo": "Photo {n} sur {count}",
+    "openPhoto": "Ouvrir la photo {n}"
+  },
+  "sheet": {
+    "description": "La signification, les causes probables et les travaux de votre atelier avec ce code.",
+    "loadFailed": "Impossible d'ouvrir ce code.",
+    "loading": "Chargement…",
+    "openPage": "Ouvrir la page du code"
+  },
+  "entry": {
+    "button": "Ajouter des codes défaut",
+    "title": "Ajouter des codes défaut",
+    "description": "Saisissez ou collez les codes de l'outil de diagnostic. Chacun devient une observation sur ce travail, avec sa signification enregistrée.",
+    "placeholder": "P0420, P0171",
+    "empty": "Les codes saisis ou collés s'affichent ici.",
+    "found": "Codes trouvés",
+    "remove": "Retirer {code}",
+    "concern": "Répond au point",
+    "concernNone": "Aucun point",
+    "cancel": "Annuler",
+    "add": "{count, plural, =0 {Ajouter des codes} one {Ajouter # code} other {Ajouter # codes}}",
+    "added": "{count, plural, one {# code défaut ajouté} other {# codes défaut ajoutés}}",
+    "skipped": "{count, plural, one {# code figurait déjà sur ce travail} other {# codes figuraient déjà sur ce travail}}",
+    "failed": "Impossible d'ajouter les codes"
+  },
+  "finding": {
+    "seenBefore": "Vu {date}",
+    "seenBeforeTitle": "Déjà lu sur ce véhicule, la dernière fois le {date}",
+    "seenBeforeTitleMarine": "Déjà lu sur ce navire, la dernière fois le {date}",
+    "updatedMeaning": "Signification mise à jour",
+    "updatedMeaningTitle": "La signification est désormais : {meaning}. Touchez pour l'utiliser.",
+    "taken": "Signification mise à jour",
+    "takeFailed": "Impossible de mettre à jour la signification",
+    "noMeaningSaved": "Aucune signification enregistrée"
+  },
+  "page": {
+    "title": "Codes défaut",
+    "intro": "Recherchez un code, ou cherchez dans les significations des mots comme pauvre rangée 1.",
+    "lookupLabel": "Code ou mots",
+    "lookupPlaceholder": "P0420 ou pauvre rangée 1",
+    "lookup": "Rechercher",
+    "cloudOnly": "Les significations des codes défaut proviennent de Torqvoice Codes, qui fait partie de Torqvoice Cloud. Les codes lus par votre atelier s'affichent ici dans tous les cas.",
+    "results": "Codes correspondant à « {query} »",
+    "noResults": "Aucun code ne correspond à « {query} »",
+    "recent": "Lus récemment dans votre atelier",
+    "noRecent": "Aucun code défaut sur un travail pour le moment.",
+    "readTimes": "{count, plural, one {Lu une fois, {date}} other {Lu # fois, la dernière le {date}}}",
+    "notACode": "« {text} » n'est pas un code défaut",
+    "notACodeHint": "Un code défaut est une lettre suivie de quatre caractères, comme P0420 ou U0100.",
+    "recentLookups": "Vos recherches récentes",
+    "recentLookupsEmpty": "Les codes que vous ouvrez et les mots que vous recherchez apparaissent ici, sur cet appareil.",
+    "clearRecent": "Effacer",
+    "tryExamples": "Essayez",
+    "exampleWords": "pauvre rangée 1",
+    "searching": "Recherche en cours"
+  },
+  "search": {
+    "group": "Codes défaut",
+    "jobsWith": "Travaux avec {code}",
+    "searchFor": "Chercher « {query} » dans les codes défaut"
+  },
+  "notice": {
+    "title": "Les codes défaut sont nouveaux",
+    "body": "Nous ajoutons des codes en permanence, donc tous ne sont pas encore là. Pour l’instant, ils couvrent les codes OBD-II standard ; les codes propres aux constructeurs ne sont pas encore inclus."
+  },
+  "support": {
+    "button": "Contacter le support",
+    "subject": "Code défaut {code}",
+    "subjectGeneral": "Codes défaut",
+    "message": "Code défaut {code} : {title}\n\n"
+  },
+  "systems": {
+    "P": "Groupe motopropulseur",
+    "B": "Carrosserie",
+    "C": "Châssis",
+    "U": "Réseau"
+  },
+  "phone": {
+    "title": "Photos pour une solution",
+    "forCode": "pour la solution que vous rédigez sur l’ordinateur",
+    "intro": "Prenez ou choisissez jusqu’à {max} photos de ce qui a résolu le code. Elles apparaissent dans la solution sur l’ordinateur.",
+    "take": "Prendre une photo",
+    "choose": "Choisir des photos",
+    "added": "{count} photos sur {max} ajoutées",
+    "full": "Une solution peut avoir au plus {max} photos.",
+    "notAPhoto": "Ce fichier n’est pas une photo.",
+    "failed": "La photo n’a pas pu être ajoutée. Réessayez.",
+    "done": "Quand vous avez fini, revenez à l’ordinateur et partagez la solution.",
+    "privacy": "Laissez hors de l’image les visages, les plaques et les données des clients.",
+    "expired": "Ce code a expiré. Affichez-en un nouveau sur l’ordinateur.",
+    "invalid": "Ce code ne fonctionne pas. Affichez-en un nouveau sur l’ordinateur."
+  }
+}

+ 13 - 0
messages/fr/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Disponibles",
     "availableTitle": "Disponibles",
     "availableDescription": "Services que vous pouvez ajouter. Chacun se configure une fois et fonctionne en arrière-plan.",
     "availableDescription": "Services que vous pouvez ajouter. Chacun se configure une fois et fonctionne en arrière-plan.",
     "allConnected": "Tous les services disponibles sont connectés.",
     "allConnected": "Tous les services disponibles sont connectés.",
+    "cloudOnly": "Cloud uniquement",
+    "cloudOnlyHint": "Disponible sur Torqvoice Cloud",
     "searchPlaceholder": "Rechercher une intégration",
     "searchPlaceholder": "Rechercher une intégration",
     "noMatches": "Rien ne correspond à « {query} ».",
     "noMatches": "Rien ne correspond à « {query} ».",
     "readMore": "En savoir plus",
     "readMore": "En savoir plus",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "Temps de main-d'œuvre sur les ordres de travail et devis",
       "labor": "Temps de main-d'œuvre sur les ordres de travail et devis",
       "fluids": "Types et capacités de fluides"
       "fluids": "Types et capacités de fluides"
+    },
+    "diagnostics": {
+      "codes": "Signification des codes défaut"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Sauvegardes mensuelles à conserver",
         "keepMonthly": "Sauvegardes mensuelles à conserver",
         "keepMonthlyHelp": "Les sauvegardes plus anciennes sont supprimées après chaque exécution, en gardant les plus récentes de chaque type. Mettez un nombre à 0 pour n'en garder aucune de ce type."
         "keepMonthlyHelp": "Les sauvegardes plus anciennes sont supprimées après chaque exécution, en gardant les plus récentes de chaque type. Mettez un nombre à 0 pour n'en garder aucune de ce type."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "Voyez ce que signifie un code défaut sur le véhicule devant vous, dans votre langue : la signification, les causes probables et les codes associés. Saisissez ou collez des codes sur un ordre de travail et ils deviennent des observations avec leur signification enregistrée, pour que les impressions et l'historique n'attendent jamais une connexion. Géré par Torqvoice et inclus dans toutes les offres cloud. Une recherche n'envoie que la marque, le modèle, l'année et le moteur, jamais un VIN, une immatriculation ni un client.",
+      "fields": {
+        "apiToken": "Clé d'accès",
+        "apiTokenHelp": "La clé que Torqvoice vous a fournie pour le service de codes."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/fr/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Jeu de pneus",
     "tireSetDetails": "Jeu de pneus",
     "tireForecast": "La saison prochaine",
     "tireForecast": "La saison prochaine",
     "whatsapp": "WhatsApp",
     "whatsapp": "WhatsApp",
-    "inspectionReminders": "Rappels de contrôle"
+    "inspectionReminders": "Rappels de contrôle",
+    "faultCodes": "Codes défaut",
+    "faultCode": "Code défaut"
   },
   },
   "search": "Rechercher…",
   "search": "Rechercher…",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} ordres de travail ouverts",
       "workOrders": "{count} ordres de travail ouverts",
       "inspections": "{count} inspections en cours",
       "inspections": "{count} inspections en cours",
       "reminders": "{count} rappels à échéance"
       "reminders": "{count} rappels à échéance"
-    }
+    },
+    "faultCodes": "Codes défaut"
   }
   }
 }
 }

+ 249 - 0
messages/it/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Codici di errore",
+  "notAvailable": "Non disponibile al momento",
+  "noMeaning": "Non ancora nel nostro elenco di codici",
+  "manufacturerUnknown": "Codice specifico del costruttore: il significato dipende dalla marca.",
+  "chip": {
+    "open": "Apri {code}"
+  },
+  "source": {
+    "generic": "Generico",
+    "manufacturer": "Costruttore",
+    "report": "Dal rapporto di diagnosi",
+    "machineTranslated": "Traduzione automatica"
+  },
+  "status": {
+    "stored": "Memorizzato",
+    "pending": "In sospeso",
+    "permanent": "Permanente",
+    "history": "Storico"
+  },
+  "flags": {
+    "warningLight": "Spia accesa",
+    "emissions": "Emissioni",
+    "driveCycle": "Serve un ciclo di guida",
+    "limpMode": "Modalità di emergenza"
+  },
+  "sections": {
+    "how_it_works": "Come funziona",
+    "meaning": "Cosa significa il codice",
+    "causes": "Cause tipiche",
+    "manufacturer": "Dati dei costruttori",
+    "related": "Insieme ad altri codici",
+    "diagnosis": "Consiglio di diagnosi",
+    "other": "Buono a sapersi"
+  },
+  "detail": {
+    "whatItMeans": "Cosa significa",
+    "likelyCauses": "Cause probabili",
+    "relatedCodes": "Codici correlati",
+    "inYourWorkshop": "Nella tua officina",
+    "noJobs": "Non ancora letto su nessun lavoro della tua officina.",
+    "resolved": "Risolto",
+    "notConnected": "Collega Torqvoice Codes per vedere cosa significano i codici.",
+    "connect": "Collega",
+    "serviceDown": "Il significato non è disponibile al momento. I tuoi lavori sono qui sotto.",
+    "pendingCheck": "Viene mostrato solo il titolo finché il testo non è stato controllato.",
+    "showMore": "Mostra di più",
+    "showLess": "Mostra meno",
+    "likelihood": {
+      "high": "Alta",
+      "medium": "Media",
+      "low": "Bassa"
+    },
+    "mostLikelyFirst": "Prima le più probabili"
+  },
+  "report": {
+    "button": "Segnala un problema",
+    "title": "Cosa non va nel significato di {code}?",
+    "reasons": {
+      "wrong_for_vehicle": "Sbagliato per questa auto",
+      "wrong_for_vehicleMarine": "Sbagliato per questa imbarcazione",
+      "text_mismatch": "Il testo non corrisponde al codice",
+      "wrong_position": "Lato o posizione sbagliati",
+      "bad_translation": "Traduzione errata",
+      "other": "Altro"
+    },
+    "note": "Nota (facoltativa)",
+    "notePlaceholder": "Cosa hai trovato",
+    "send": "Invia",
+    "thanks": "Grazie, lo verificheremo",
+    "failed": "Impossibile inviare la segnalazione al momento. Riprova tra poco.",
+    "reportedByYou": "Segnalato da te",
+    "outcome": {
+      "fixed": "Aggiornato dopo la tua segnalazione",
+      "no_change": "Verificato, nessuna modifica",
+      "duplicate": "Già segnalato"
+    }
+  },
+  "share": {
+    "title": "Condividi la tua soluzione",
+    "description": "Racconta alle altre officine cosa ha risolto {code}. Una persona di Torqvoice controlla ogni soluzione prima che altri la vedano.",
+    "yourFixes": "Le tue soluzioni per {code}",
+    "whatFixedIt": "Cosa l'ha risolto",
+    "placeholder": "Filo sfregato al connettore della sonda lambda posteriore, dietro il cambio. Riparato, codice cancellato dopo una prova su strada.",
+    "placeholderMarine": "Pin corrosi nel connettore del sensore del trim. Puliti e sigillati, codice cancellato dopo una prova in acqua.",
+    "photos": "Foto (facoltative)",
+    "photosHint": "Fino a {max} foto. Lascia fuori dall'inquadratura volti, targhe e dati del cliente.",
+    "photosHintMarine": "Fino a {max} foto. Lascia fuori dall'inquadratura volti, numeri di immatricolazione e dati del cliente.",
+    "addPhotos": "Aggiungi foto",
+    "removePhoto": "Rimuovi la foto {n}",
+    "tooManyPhotos": "Al massimo {max} foto",
+    "notAPhoto": "{name} non è una foto",
+    "photoTooBig": "{name} supera i 15 MB",
+    "vehicle": "Veicolo",
+    "vehicleMarine": "Imbarcazione",
+    "vehicleOptional": "Veicolo (facoltativo)",
+    "vehicleOptionalMarine": "Imbarcazione (facoltativa)",
+    "make": "Marca",
+    "model": "Modello",
+    "year": "Anno",
+    "vehicleHint": "Si condividono solo marca, modello, anno e motore, mai la targa, il VIN, il cliente o il nome della tua officina.",
+    "vehicleHintMarine": "Si condividono solo marca, modello, anno e motore, mai l'immatricolazione, il numero di scafo, il cliente o il nome della tua officina.",
+    "send": "Condividi soluzione",
+    "sent": "Grazie. La tua soluzione è in attesa di revisione.",
+    "shareAnother": "Condividi un'altra soluzione",
+    "cancel": "Annulla",
+    "sentOn": "Inviata il {date}",
+    "reason": "Motivo: {reason}",
+    "withdraw": "Ritira",
+    "withdrawTitle": "Ritirare questa soluzione?",
+    "withdrawConfirm": "Verrà eliminata con le sue foto e nessun'altra officina la vedrà.",
+    "withdrawn": "Soluzione ritirata",
+    "withdrawFailed": "Non è stato possibile ritirare la soluzione in questo momento. Riprova tra poco.",
+    "unavailable": "La condivisione delle soluzioni non è disponibile al momento.",
+    "status": {
+      "pending": "In attesa di revisione",
+      "published": "Pubblicata",
+      "rejected": "Non pubblicata"
+    },
+    "errors": {
+      "failed": "Non è stato possibile inviare la tua soluzione in questo momento. Riprova tra poco.",
+      "photo": "Una delle foto non può essere letta. Prova con un'altra.",
+      "too_large": "Le foto sono troppo grandi. Prova con meno foto o foto più piccole.",
+      "paused": "La condivisione delle soluzioni è in pausa al momento. Riprova più tardi.",
+      "demo": "La condivisione delle soluzioni è disattivata nella demo."
+    },
+    "invite": "Hai risolto {code}? Racconta alle altre officine cosa ha funzionato.",
+    "inviteHint": "Con parole tue e, se le hai, qualche foto. Una persona di Torqvoice controlla ogni soluzione prima che le altre officine la vedano.",
+    "start": "Condividi la tua soluzione",
+    "dialogTitle": "Condividi la tua soluzione per {code}",
+    "dialogDescription": "Le altre officine la vedranno dopo che una persona di Torqvoice l’avrà controllata.",
+    "characters": "{count} / {max}",
+    "writingHint": "Cosa non andava, come l’hai trovato e cosa l’ha risolto. Codici ricambio e misurazioni sono ciò che aiuta di più gli altri.",
+    "dropHere": "oppure trascinale qui",
+    "tooManyPhotosRemove": "Una soluzione può avere al massimo {max} foto. Rimuovine alcune per condividerla.",
+    "phone": {
+      "button": "Aggiungi dal telefono",
+      "hide": "Nascondi il codice del telefono",
+      "title": "Scatta foto con il telefono",
+      "body": "Inquadra il codice con la fotocamera del telefono. Le foto che scatti compaiono qui appena arrivano.",
+      "failed": "Non è stato possibile creare il codice per il telefono. Chiudi e riprova.",
+      "expires": "Il codice vale fino alle {time}",
+      "copy": "Copia link",
+      "copied": "Copiato",
+      "received": "{count, plural, one {# foto} other {# foto}} dal telefono",
+      "badge": "Telefono"
+    }
+  },
+  "fixes": {
+    "title": "Soluzioni di altre officine",
+    "empty": "Nessun'altra officina ha ancora condiviso una soluzione per {code}.",
+    "unavailable": "Le soluzioni di altre officine non sono disponibili al momento.",
+    "sharedOn": "Condivisa il {date}",
+    "unknownVehicle": "Veicolo non indicato",
+    "unknownVehicleMarine": "Imbarcazione non indicata",
+    "writtenIn": "Scritta in {language}",
+    "photo": "Foto {n} di {count}",
+    "openPhoto": "Apri la foto {n}"
+  },
+  "sheet": {
+    "description": "Il significato, le cause probabili e i lavori della tua officina con questo codice.",
+    "loadFailed": "Impossibile aprire questo codice.",
+    "loading": "Caricamento…",
+    "openPage": "Apri la pagina del codice"
+  },
+  "entry": {
+    "button": "Aggiungi codici di errore",
+    "title": "Aggiungi codici di errore",
+    "description": "Digita o incolla i codici dallo strumento di diagnosi. Ognuno diventa un'osservazione su questo lavoro, con il significato salvato.",
+    "placeholder": "P0420, P0171",
+    "empty": "I codici che digiti o incolli compaiono qui.",
+    "found": "Codici trovati",
+    "remove": "Rimuovi {code}",
+    "concern": "Risponde al punto",
+    "concernNone": "Nessun punto",
+    "cancel": "Annulla",
+    "add": "{count, plural, =0 {Aggiungi codici} one {Aggiungi # codice} other {Aggiungi # codici}}",
+    "added": "{count, plural, one {# codice di errore aggiunto} other {# codici di errore aggiunti}}",
+    "skipped": "{count, plural, one {# codice era già su questo lavoro} other {# codici erano già su questo lavoro}}",
+    "failed": "Impossibile aggiungere i codici"
+  },
+  "finding": {
+    "seenBefore": "Visto {date}",
+    "seenBeforeTitle": "Già letto su questo veicolo, l'ultima volta il {date}",
+    "seenBeforeTitleMarine": "Già letto su questa imbarcazione, l'ultima volta il {date}",
+    "updatedMeaning": "Significato aggiornato",
+    "updatedMeaningTitle": "Ora il significato è: {meaning}. Tocca per usarlo.",
+    "taken": "Significato aggiornato",
+    "takeFailed": "Impossibile aggiornare il significato",
+    "noMeaningSaved": "Nessun significato salvato"
+  },
+  "page": {
+    "title": "Codici di errore",
+    "intro": "Cerca un codice, oppure cerca nei significati parole come magra bancata 1.",
+    "lookupLabel": "Codice o parole",
+    "lookupPlaceholder": "P0420 o magra bancata 1",
+    "lookup": "Cerca",
+    "cloudOnly": "I significati dei codici di errore arrivano da Torqvoice Codes, parte di Torqvoice Cloud. I codici letti dalla tua officina sono comunque elencati qui.",
+    "results": "Codici per \"{query}\"",
+    "noResults": "Nessun codice corrisponde a \"{query}\"",
+    "recent": "Letti di recente nella tua officina",
+    "noRecent": "Ancora nessun codice di errore su un lavoro.",
+    "readTimes": "{count, plural, one {Letto una volta, {date}} other {Letto # volte, l'ultima il {date}}}",
+    "notACode": "\"{text}\" non è un codice di errore",
+    "notACodeHint": "Un codice di errore è una lettera seguita da quattro caratteri, come P0420 o U0100.",
+    "recentLookups": "Le tue ricerche recenti",
+    "recentLookupsEmpty": "I codici che apri e le parole che cerchi compaiono qui, su questo dispositivo.",
+    "clearRecent": "Cancella",
+    "tryExamples": "Prova",
+    "exampleWords": "magra bancata 1",
+    "searching": "Ricerca in corso"
+  },
+  "search": {
+    "group": "Codici di errore",
+    "jobsWith": "Lavori con {code}",
+    "searchFor": "Cerca \"{query}\" nei codici di errore"
+  },
+  "notice": {
+    "title": "I codici guasto sono nuovi",
+    "body": "Aggiungiamo codici continuamente, quindi non tutti sono ancora qui. Per ora sono inclusi i codici OBD-II standard; i codici specifici dei costruttori non sono ancora inclusi."
+  },
+  "support": {
+    "button": "Contatta il supporto",
+    "subject": "Codice di errore {code}",
+    "subjectGeneral": "Codici di errore",
+    "message": "Codice di errore {code}: {title}\n\n"
+  },
+  "systems": {
+    "P": "Motopropulsore",
+    "B": "Carrozzeria",
+    "C": "Telaio",
+    "U": "Rete"
+  },
+  "phone": {
+    "title": "Foto per una soluzione",
+    "forCode": "per la soluzione che stai scrivendo al computer",
+    "intro": "Scatta o scegli fino a {max} foto di ciò che ha risolto il codice. Compariranno nella soluzione al computer.",
+    "take": "Scatta foto",
+    "choose": "Scegli foto",
+    "added": "{count} di {max} foto aggiunte",
+    "full": "Una soluzione può avere al massimo {max} foto.",
+    "notAPhoto": "Questo file non è una foto.",
+    "failed": "Non è stato possibile aggiungere la foto. Riprova.",
+    "done": "Quando hai finito, torna al computer e condividi la soluzione.",
+    "privacy": "Tieni fuori dall’inquadratura volti, targhe e dati dei clienti.",
+    "expired": "Questo codice è scaduto. Mostrane uno nuovo al computer.",
+    "invalid": "Questo codice non funziona. Mostrane uno nuovo al computer."
+  }
+}

+ 13 - 0
messages/it/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Disponibili",
     "availableTitle": "Disponibili",
     "availableDescription": "Servizi che puoi aggiungere. Ognuno si configura una volta e lavora in background.",
     "availableDescription": "Servizi che puoi aggiungere. Ognuno si configura una volta e lavora in background.",
     "allConnected": "Tutti i servizi disponibili sono collegati.",
     "allConnected": "Tutti i servizi disponibili sono collegati.",
+    "cloudOnly": "Solo cloud",
+    "cloudOnlyHint": "Disponibile su Torqvoice Cloud",
     "searchPlaceholder": "Cerca integrazioni",
     "searchPlaceholder": "Cerca integrazioni",
     "noMatches": "Nessun risultato per «{query}».",
     "noMatches": "Nessun risultato per «{query}».",
     "readMore": "Scopri di più",
     "readMore": "Scopri di più",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "Tempi di manodopera su ordini di lavoro e preventivi",
       "labor": "Tempi di manodopera su ordini di lavoro e preventivi",
       "fluids": "Tipi e capacità dei fluidi"
       "fluids": "Tipi e capacità dei fluidi"
+    },
+    "diagnostics": {
+      "codes": "Significato dei codici di errore"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Backup mensili da conservare",
         "keepMonthly": "Backup mensili da conservare",
         "keepMonthlyHelp": "I backup più vecchi vengono rimossi dopo ogni esecuzione, mantenendo i più recenti di ogni tipo. Imposta un numero a 0 per non conservarne nessuno di quel tipo."
         "keepMonthlyHelp": "I backup più vecchi vengono rimossi dopo ogni esecuzione, mantenendo i più recenti di ogni tipo. Imposta un numero a 0 per non conservarne nessuno di quel tipo."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "Scopri cosa significa un codice di errore sul veicolo che hai davanti, nella tua lingua: il significato, le cause probabili e i codici correlati. Digita o incolla i codici in un ordine di lavoro e diventano osservazioni con il significato salvato, così stampe e storico non aspettano mai una connessione. Gestito da Torqvoice e incluso in ogni piano cloud. Una ricerca invia solo marca, modello, anno e motore, mai un VIN, una targa o un cliente.",
+      "fields": {
+        "apiToken": "Chiave di accesso",
+        "apiTokenHelp": "La chiave che Torqvoice ti ha dato per il servizio dei codici."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/it/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Treno di pneumatici",
     "tireSetDetails": "Treno di pneumatici",
     "tireForecast": "La prossima stagione",
     "tireForecast": "La prossima stagione",
     "whatsapp": "WhatsApp",
     "whatsapp": "WhatsApp",
-    "inspectionReminders": "Promemoria revisione"
+    "inspectionReminders": "Promemoria revisione",
+    "faultCodes": "Codici di errore",
+    "faultCode": "Codice di errore"
   },
   },
   "search": "Cerca...",
   "search": "Cerca...",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} ordini di lavoro aperti",
       "workOrders": "{count} ordini di lavoro aperti",
       "inspections": "{count} ispezioni in corso",
       "inspections": "{count} ispezioni in corso",
       "reminders": "{count} promemoria in scadenza"
       "reminders": "{count} promemoria in scadenza"
-    }
+    },
+    "faultCodes": "Codici di errore"
   }
   }
 }
 }

+ 249 - 0
messages/lt/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Gedimų kodai",
+  "notAvailable": "Šiuo metu nepasiekiama",
+  "noMeaning": "Mūsų kodų sąraše jo dar nėra",
+  "manufacturerUnknown": "Gamintojo kodas: reikšmė priklauso nuo markės.",
+  "chip": {
+    "open": "Atidaryti {code}"
+  },
+  "source": {
+    "generic": "Bendrasis",
+    "manufacturer": "Gamintojas",
+    "report": "Iš diagnostikos ataskaitos",
+    "machineTranslated": "Išversta automatiškai"
+  },
+  "status": {
+    "stored": "Įrašytas",
+    "pending": "Laukiantis",
+    "permanent": "Nuolatinis",
+    "history": "Istorija"
+  },
+  "flags": {
+    "warningLight": "Įspėjamoji lemputė",
+    "emissions": "Išmetamosios dujos",
+    "driveCycle": "Reikia važiavimo ciklo",
+    "limpMode": "Avarinis režimas"
+  },
+  "sections": {
+    "how_it_works": "Kaip tai veikia",
+    "meaning": "Ką reiškia kodas",
+    "causes": "Dažniausios priežastys",
+    "manufacturer": "Gamintojų duomenys",
+    "related": "Kartu su kitais kodais",
+    "diagnosis": "Diagnostikos patarimas",
+    "other": "Verta žinoti"
+  },
+  "detail": {
+    "whatItMeans": "Ką tai reiškia",
+    "likelyCauses": "Tikėtinos priežastys",
+    "relatedCodes": "Susiję kodai",
+    "inYourWorkshop": "Jūsų servise",
+    "noJobs": "Jūsų servise dar nenuskaitytas jokiame darbe.",
+    "resolved": "Išspręsta",
+    "notConnected": "Prijunkite Torqvoice Codes, kad matytumėte, ką reiškia kodai.",
+    "connect": "Prijungti",
+    "serviceDown": "Reikšmė šiuo metu nepasiekiama. Jūsų darbai yra žemiau.",
+    "pendingCheck": "Kol tekstas nepatikrintas, rodomas tik pavadinimas.",
+    "showMore": "Rodyti daugiau",
+    "showLess": "Rodyti mažiau",
+    "likelihood": {
+      "high": "Didelė",
+      "medium": "Vidutinė",
+      "low": "Maža"
+    },
+    "mostLikelyFirst": "Tikėtiniausios pirmiau"
+  },
+  "report": {
+    "button": "Pranešti apie klaidą",
+    "title": "Kas negerai su kodo {code} reikšme?",
+    "reasons": {
+      "wrong_for_vehicle": "Netinka šiam automobiliui",
+      "wrong_for_vehicleMarine": "Netinka šiam laivui",
+      "text_mismatch": "Tekstas neatitinka kodo",
+      "wrong_position": "Netinkama pusė ar padėtis",
+      "bad_translation": "Blogas vertimas",
+      "other": "Kita"
+    },
+    "note": "Pastaba (neprivaloma)",
+    "notePlaceholder": "Ką radote",
+    "send": "Siųsti",
+    "thanks": "Ačiū, patikrinsime",
+    "failed": "Pranešimo šiuo metu nepavyko išsiųsti. Netrukus bandykite dar kartą.",
+    "reportedByYou": "Pranešėte jūs",
+    "outcome": {
+      "fixed": "Atnaujinta po jūsų pranešimo",
+      "no_change": "Patikrinta, be pakeitimų",
+      "duplicate": "Jau pranešta"
+    }
+  },
+  "share": {
+    "title": "Pasidalykite sprendimu",
+    "description": "Papasakokite kitiems servisams, kas pašalino {code}. Kiekvieną sprendimą patikrina Torqvoice darbuotojas, prieš jį pamatant kitiems.",
+    "yourFixes": "Jūsų sprendimai kodui {code}",
+    "whatFixedIt": "Kas padėjo",
+    "placeholder": "Nutrintas laidas prie galinio lambda zondo jungties už pavarų dėžės. Sutaisyta, kodas išsitrynė po bandomojo važiavimo.",
+    "placeholderMarine": "Korozijos pažeisti kontaktai trimo jutiklio jungtyje. Išvalyti ir užsandarinti, kodas išsitrynė po bandomojo plaukimo.",
+    "photos": "Nuotraukos (nebūtina)",
+    "photosHint": "Iki {max} nuotraukų. Į kadrą neturi patekti veidai, valstybiniai numeriai ir kliento duomenys.",
+    "photosHintMarine": "Iki {max} nuotraukų. Į kadrą neturi patekti veidai, laivo registracijos numeriai ir kliento duomenys.",
+    "addPhotos": "Pridėti nuotraukų",
+    "removePhoto": "Pašalinti {n} nuotrauką",
+    "tooManyPhotos": "Ne daugiau kaip {max} nuotraukos",
+    "notAPhoto": "{name} nėra nuotrauka",
+    "photoTooBig": "{name} didesnis nei 15 MB",
+    "vehicle": "Transporto priemonė",
+    "vehicleMarine": "Laivas",
+    "vehicleOptional": "Transporto priemonė (nebūtina)",
+    "vehicleOptionalMarine": "Laivas (nebūtina)",
+    "make": "Markė",
+    "model": "Modelis",
+    "year": "Metai",
+    "vehicleHint": "Dalijamasi tik marke, modeliu, metais ir varikliu, niekada valstybiniu numeriu, VIN, klientu ar jūsų serviso pavadinimu.",
+    "vehicleHintMarine": "Dalijamasi tik marke, modeliu, metais ir varikliu, niekada registracijos numeriu, korpuso numeriu, klientu ar jūsų serviso pavadinimu.",
+    "send": "Pasidalyti sprendimu",
+    "sent": "Ačiū. Jūsų sprendimas laukia peržiūros.",
+    "shareAnother": "Pasidalyti kitu sprendimu",
+    "cancel": "Atšaukti",
+    "sentOn": "Išsiųsta {date}",
+    "reason": "Priežastis: {reason}",
+    "withdraw": "Atsiimti",
+    "withdrawTitle": "Atsiimti šį sprendimą?",
+    "withdrawConfirm": "Jis bus ištrintas kartu su nuotraukomis, ir joks kitas servisas jo nematys.",
+    "withdrawn": "Sprendimas atsiimtas",
+    "withdrawFailed": "Šiuo metu nepavyko atsiimti sprendimo. Pabandykite po akimirkos.",
+    "unavailable": "Dalytis sprendimais šiuo metu negalima.",
+    "status": {
+      "pending": "Laukia peržiūros",
+      "published": "Paskelbta",
+      "rejected": "Nepaskelbta"
+    },
+    "errors": {
+      "failed": "Šiuo metu nepavyko išsiųsti jūsų sprendimo. Pabandykite po akimirkos.",
+      "photo": "Vienos iš nuotraukų nepavyko nuskaityti. Pabandykite kitą.",
+      "too_large": "Nuotraukos per didelės. Pabandykite mažiau arba mažesnių nuotraukų.",
+      "paused": "Dalijimasis sprendimais šiuo metu sustabdytas. Pabandykite vėliau.",
+      "demo": "Demonstracinėje versijoje dalijimasis sprendimais išjungtas."
+    },
+    "invite": "Pašalinote {code}? Papasakokite kitiems servisams, kas padėjo.",
+    "inviteHint": "Savais žodžiais ir, jei turite, su keliomis nuotraukomis. Torqvoice darbuotojas patikrina kiekvieną sprendimą, prieš jį pamatant kitiems servisams.",
+    "start": "Pasidalyti sprendimu",
+    "dialogTitle": "Pasidalyti {code} sprendimu",
+    "dialogDescription": "Kiti servisai jį pamatys, kai Torqvoice darbuotojas jį patikrins.",
+    "characters": "{count} / {max}",
+    "writingHint": "Kas buvo negerai, kaip tai radote ir kas padėjo. Detalių numeriai ir matavimai kitiems padeda labiausiai.",
+    "dropHere": "arba nutempkite jas čia",
+    "tooManyPhotosRemove": "Sprendimas gali turėti ne daugiau kaip {max} nuotraukas. Pašalinkite kelias, kad galėtumėte pasidalyti.",
+    "phone": {
+      "button": "Pridėti iš telefono",
+      "hide": "Slėpti telefono kodą",
+      "title": "Fotografuokite telefonu",
+      "body": "Nuskaitykite kodą telefono kamera. Jūsų nuotraukos atsiras čia, kai tik bus gautos.",
+      "failed": "Nepavyko sukurti telefono kodo. Uždarykite ir bandykite dar kartą.",
+      "expires": "Kodas galioja iki {time}",
+      "copy": "Kopijuoti nuorodą",
+      "copied": "Nukopijuota",
+      "received": "{count, plural, one {# nuotrauka} few {# nuotraukos} other {# nuotraukų}} iš telefono",
+      "badge": "Telefonas"
+    }
+  },
+  "fixes": {
+    "title": "Kitų servisų sprendimai",
+    "empty": "Kol kas joks kitas servisas nepasidalijo sprendimu kodui {code}.",
+    "unavailable": "Kitų servisų sprendimai šiuo metu nepasiekiami.",
+    "sharedOn": "Pasidalyta {date}",
+    "unknownVehicle": "Transporto priemonė nenurodyta",
+    "unknownVehicleMarine": "Laivas nenurodytas",
+    "writtenIn": "Kalba: {language}",
+    "photo": "Nuotrauka {n} iš {count}",
+    "openPhoto": "Atidaryti {n} nuotrauką"
+  },
+  "sheet": {
+    "description": "Šio kodo reikšmė, tikėtinos priežastys ir jūsų serviso darbai su šiuo kodu.",
+    "loadFailed": "Nepavyko atidaryti šio kodo.",
+    "loading": "Įkeliama…",
+    "openPage": "Atidaryti kodo puslapį"
+  },
+  "entry": {
+    "button": "Pridėti gedimų kodus",
+    "title": "Pridėti gedimų kodus",
+    "description": "Įveskite arba įklijuokite kodus iš diagnostikos įrankio. Kiekvienas tampa šio darbo pastebėjimu su išsaugota reikšme.",
+    "placeholder": "P0420, P0171",
+    "empty": "Čia atsiras įvesti ar įklijuoti kodai.",
+    "found": "Rasti kodai",
+    "remove": "Pašalinti {code}",
+    "concern": "Atsako į punktą",
+    "concernNone": "Jokio punkto",
+    "cancel": "Atšaukti",
+    "add": "{count, plural, =0 {Pridėti kodus} one {Pridėti # kodą} few {Pridėti # kodus} many {Pridėti # kodo} other {Pridėti # kodų}}",
+    "added": "{count, plural, one {Pridėtas # gedimo kodas} few {Pridėti # gedimų kodai} many {Pridėta # gedimo kodo} other {Pridėta # gedimų kodų}}",
+    "skipped": "{count, plural, one {# kodas jau buvo šiame darbe} few {# kodai jau buvo šiame darbe} many {# kodo jau buvo šiame darbe} other {# kodų jau buvo šiame darbe}}",
+    "failed": "Nepavyko pridėti kodų"
+  },
+  "finding": {
+    "seenBefore": "Matytas {date}",
+    "seenBeforeTitle": "Jau nuskaitytas šiame automobilyje, paskutinį kartą {date}",
+    "seenBeforeTitleMarine": "Jau nuskaitytas šiame laive, paskutinį kartą {date}",
+    "updatedMeaning": "Nauja reikšmė",
+    "updatedMeaningTitle": "Dabar reikšmė yra: {meaning}. Bakstelėkite, kad ją naudotumėte.",
+    "taken": "Reikšmė atnaujinta",
+    "takeFailed": "Nepavyko atnaujinti reikšmės",
+    "noMeaningSaved": "Reikšmė dar neišsaugota"
+  },
+  "page": {
+    "title": "Gedimų kodai",
+    "intro": "Suraskite kodą arba ieškokite reikšmėse žodžių, pavyzdžiui, liesas bankas 1.",
+    "lookupLabel": "Kodas arba žodžiai",
+    "lookupPlaceholder": "P0420 arba liesas bankas 1",
+    "lookup": "Ieškoti",
+    "cloudOnly": "Gedimų kodų reikšmes teikia Torqvoice Codes, Torqvoice Cloud dalis. Jūsų serviso nuskaityti kodai čia rodomi bet kuriuo atveju.",
+    "results": "Kodai pagal „{query}“",
+    "noResults": "Nė vienas kodas neatitinka „{query}“",
+    "recent": "Neseniai nuskaityti jūsų servise",
+    "noRecent": "Jokiame darbe dar nėra gedimų kodų.",
+    "readTimes": "{count, plural, one {Nuskaitytas # kartą, paskutinį {date}} few {Nuskaitytas # kartus, paskutinį {date}} many {Nuskaitytas # karto, paskutinį {date}} other {Nuskaitytas # kartų, paskutinį {date}}}",
+    "notACode": "„{text}“ nėra gedimo kodas",
+    "notACodeHint": "Gedimo kodas yra raidė ir keturi simboliai, pavyzdžiui, P0420 arba U0100.",
+    "recentLookups": "Jūsų naujausios paieškos",
+    "recentLookupsEmpty": "Čia, šiame įrenginyje, rodomi jūsų atidaryti kodai ir ieškoti žodžiai.",
+    "clearRecent": "Išvalyti",
+    "tryExamples": "Išbandykite",
+    "exampleWords": "liesas bankas 1",
+    "searching": "Ieškoma"
+  },
+  "search": {
+    "group": "Gedimų kodai",
+    "jobsWith": "Darbai su {code}",
+    "searchFor": "Ieškoti gedimų koduose „{query}“"
+  },
+  "notice": {
+    "title": "Gedimų kodai yra nauji",
+    "body": "Kodus pildome nuolat, todėl dar ne visi kodai čia yra. Kol kas yra standartiniai OBD-II kodai; gamintojų kodų dar nėra."
+  },
+  "support": {
+    "button": "Susisiekti su pagalba",
+    "subject": "Gedimo kodas {code}",
+    "subjectGeneral": "Gedimų kodai",
+    "message": "Gedimo kodas {code}: {title}\n\n"
+  },
+  "systems": {
+    "P": "Jėgos agregatas",
+    "B": "Kėbulas",
+    "C": "Važiuoklė",
+    "U": "Tinklas"
+  },
+  "phone": {
+    "title": "Nuotraukos sprendimui",
+    "forCode": "sprendimui, kurį rašote kompiuteryje",
+    "intro": "Nufotografuokite arba pasirinkite iki {max} nuotraukų to, kas pašalino gedimą. Jos atsiras sprendime kompiuteryje.",
+    "take": "Fotografuoti",
+    "choose": "Pasirinkti nuotraukas",
+    "added": "Pridėta {count} iš {max} nuotraukų",
+    "full": "Sprendimas gali turėti ne daugiau kaip {max} nuotraukas.",
+    "notAPhoto": "Šis failas nėra nuotrauka.",
+    "failed": "Nepavyko pridėti nuotraukos. Bandykite dar kartą.",
+    "done": "Baigę grįžkite prie kompiuterio ir pasidalykite sprendimu.",
+    "privacy": "Į kadrą neįtraukite veidų, numerių ir klientų duomenų.",
+    "expired": "Šio kodo galiojimas baigėsi. Kompiuteryje parodykite naują.",
+    "invalid": "Šis kodas neveikia. Kompiuteryje parodykite naują."
+  }
+}

+ 13 - 0
messages/lt/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Galima pridėti",
     "availableTitle": "Galima pridėti",
     "availableDescription": "Paslaugos, kurias galite pridėti. Kiekviena nustatoma vieną kartą ir veikia fone.",
     "availableDescription": "Paslaugos, kurias galite pridėti. Kiekviena nustatoma vieną kartą ir veikia fone.",
     "allConnected": "Visos galimos paslaugos prijungtos.",
     "allConnected": "Visos galimos paslaugos prijungtos.",
+    "cloudOnly": "Tik debesyje",
+    "cloudOnlyHint": "Galima naudoti Torqvoice Cloud",
     "searchPlaceholder": "Ieškoti integracijų",
     "searchPlaceholder": "Ieškoti integracijų",
     "noMatches": "Nieko nerasta pagal „{query}“.",
     "noMatches": "Nieko nerasta pagal „{query}“.",
     "readMore": "Skaityti daugiau",
     "readMore": "Skaityti daugiau",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "Darbo laiko normos užsakymuose ir pasiūlymuose",
       "labor": "Darbo laiko normos užsakymuose ir pasiūlymuose",
       "fluids": "Skysčių tipai ir talpos"
       "fluids": "Skysčių tipai ir talpos"
+    },
+    "diagnostics": {
+      "codes": "Gedimų kodų reikšmės"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Laikyti mėnesines atsargines kopijas",
         "keepMonthly": "Laikyti mėnesines atsargines kopijas",
         "keepMonthlyHelp": "Po kiekvieno vykdymo senesnės atsarginės kopijos pašalinamos, paliekant naujausias kiekvienos rūšies. Nustatykite skaičių 0, kad tos rūšies nebūtų laikoma nė vienos."
         "keepMonthlyHelp": "Po kiekvieno vykdymo senesnės atsarginės kopijos pašalinamos, paliekant naujausias kiekvienos rūšies. Nustatykite skaičių 0, kad tos rūšies nebūtų laikoma nė vienos."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "Matykite, ką gedimo kodas reiškia prieš jus esančiam automobiliui, jūsų kalba: reikšmę, tikėtinas priežastis ir susijusius kodus. Įveskite arba įklijuokite kodus darbo užsakyme, ir jie taps pastebėjimais su išsaugota reikšme, todėl spaudiniai ir istorija niekada nelaukia ryšio. Teikia Torqvoice, įtraukta į kiekvieną debesies planą. Užklausoje siunčiama tik markė, modelis, metai ir variklis, niekada VIN, numeris ar klientas.",
+      "fields": {
+        "apiToken": "Prieigos raktas",
+        "apiTokenHelp": "Raktas, kurį Torqvoice jums suteikė kodų paslaugai."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/lt/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Padangų komplektas",
     "tireSetDetails": "Padangų komplektas",
     "tireForecast": "Kitas sezonas",
     "tireForecast": "Kitas sezonas",
     "whatsapp": "„WhatsApp“",
     "whatsapp": "„WhatsApp“",
-    "inspectionReminders": "Apžiūros priminimai"
+    "inspectionReminders": "Apžiūros priminimai",
+    "faultCodes": "Gedimų kodai",
+    "faultCode": "Gedimo kodas"
   },
   },
   "search": "Ieškoti...",
   "search": "Ieškoti...",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} atviri darbo užsakymai",
       "workOrders": "{count} atviri darbo užsakymai",
       "inspections": "{count} vykdomos apžiūros",
       "inspections": "{count} vykdomos apžiūros",
       "reminders": "{count} priminimai, kurių terminas suėjo"
       "reminders": "{count} priminimai, kurių terminas suėjo"
-    }
+    },
+    "faultCodes": "Gedimų kodai"
   }
   }
 }
 }

+ 249 - 0
messages/nb/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Feilkoder",
+  "notAvailable": "Ikke tilgjengelig akkurat nå",
+  "noMeaning": "Ikke i kodelisten vår ennå",
+  "manufacturerUnknown": "Produsentspesifikk kode: betydningen avhenger av merket.",
+  "chip": {
+    "open": "Åpne {code}"
+  },
+  "source": {
+    "generic": "Generell",
+    "manufacturer": "Produsent",
+    "report": "Fra skannerrapporten",
+    "machineTranslated": "Maskinoversatt"
+  },
+  "status": {
+    "stored": "Lagret",
+    "pending": "Ventende",
+    "permanent": "Permanent",
+    "history": "Historikk"
+  },
+  "flags": {
+    "warningLight": "Varsellampe",
+    "emissions": "Utslipp",
+    "driveCycle": "Krever kjøresyklus",
+    "limpMode": "Nødmodus"
+  },
+  "sections": {
+    "how_it_works": "Slik virker det",
+    "meaning": "Hva koden betyr",
+    "causes": "Vanlige årsaker",
+    "manufacturer": "Fra produsentene",
+    "related": "Sammen med andre koder",
+    "diagnosis": "Diagnosetips",
+    "other": "Greit å vite"
+  },
+  "detail": {
+    "whatItMeans": "Hva den betyr",
+    "likelyCauses": "Sannsynlige årsaker",
+    "relatedCodes": "Relaterte koder",
+    "inYourWorkshop": "I ditt verksted",
+    "noJobs": "Ikke lest ut på noen jobb i verkstedet ditt ennå.",
+    "resolved": "Løst",
+    "notConnected": "Koble til Torqvoice Codes for å se hva kodene betyr.",
+    "connect": "Koble til",
+    "serviceDown": "Betydningen er ikke tilgjengelig akkurat nå. Jobbene dine står under.",
+    "pendingCheck": "Bare tittelen vises til teksten er sjekket.",
+    "showMore": "Vis mer",
+    "showLess": "Vis mindre",
+    "likelihood": {
+      "high": "Høy",
+      "medium": "Middels",
+      "low": "Lav"
+    },
+    "mostLikelyFirst": "Mest sannsynlig først"
+  },
+  "report": {
+    "button": "Meld fra om feil",
+    "title": "Hva er galt med betydningen av {code}?",
+    "reasons": {
+      "wrong_for_vehicle": "Feil for denne bilen",
+      "wrong_for_vehicleMarine": "Feil for dette fartøyet",
+      "text_mismatch": "Teksten passer ikke til koden",
+      "wrong_position": "Feil side eller posisjon",
+      "bad_translation": "Dårlig oversettelse",
+      "other": "Annet"
+    },
+    "note": "Notat (valgfritt)",
+    "notePlaceholder": "Hva du fant",
+    "send": "Send",
+    "thanks": "Takk, vi sjekker det",
+    "failed": "Meldingen kunne ikke sendes akkurat nå. Prøv igjen om litt.",
+    "reportedByYou": "Meldt av deg",
+    "outcome": {
+      "fixed": "Oppdatert etter meldingen din",
+      "no_change": "Sjekket, ingen endring",
+      "duplicate": "Allerede meldt"
+    }
+  },
+  "share": {
+    "title": "Del løsningen din",
+    "description": "Fortell andre verksteder hva som løste {code}. En person hos Torqvoice sjekker hver løsning før andre ser den.",
+    "yourFixes": "Dine løsninger for {code}",
+    "whatFixedIt": "Hva løste det",
+    "placeholder": "Gnagd ledning ved pluggen til den bakre lambdasonden bak girkassen. Reparert, koden forsvant etter prøvekjøring.",
+    "placeholderMarine": "Korroderte pinner i pluggen til trimsensoren. Rengjort og tettet, koden forsvant etter en prøvetur på sjøen.",
+    "photos": "Bilder (valgfritt)",
+    "photosHint": "Opptil {max} bilder. Hold ansikter, skilt og kundeopplysninger utenfor bildet.",
+    "photosHintMarine": "Opptil {max} bilder. Hold ansikter, registreringsnumre og kundeopplysninger utenfor bildet.",
+    "addPhotos": "Legg til bilder",
+    "removePhoto": "Fjern bilde {n}",
+    "tooManyPhotos": "Høyst {max} bilder",
+    "notAPhoto": "{name} er ikke et bilde",
+    "photoTooBig": "{name} er større enn 15 MB",
+    "vehicle": "Kjøretøy",
+    "vehicleMarine": "Fartøy",
+    "vehicleOptional": "Kjøretøy (valgfritt)",
+    "vehicleOptionalMarine": "Fartøy (valgfritt)",
+    "make": "Merke",
+    "model": "Modell",
+    "year": "År",
+    "vehicleHint": "Bare merke, modell, år og motor deles, aldri skiltnummer, VIN, kunde eller navnet på verkstedet ditt.",
+    "vehicleHintMarine": "Bare merke, modell, år og motor deles, aldri registreringsnummer, skrognummer, kunde eller navnet på verkstedet ditt.",
+    "send": "Del løsning",
+    "sent": "Takk. Løsningen din venter på gjennomgang.",
+    "shareAnother": "Del en løsning til",
+    "cancel": "Avbryt",
+    "sentOn": "Sendt {date}",
+    "reason": "Grunn: {reason}",
+    "withdraw": "Trekk tilbake",
+    "withdrawTitle": "Trekke tilbake denne løsningen?",
+    "withdrawConfirm": "Den slettes med bildene, og ingen andre verksteder får se den.",
+    "withdrawn": "Løsningen er trukket tilbake",
+    "withdrawFailed": "Løsningen kunne ikke trekkes tilbake akkurat nå. Prøv igjen om litt.",
+    "unavailable": "Deling av løsninger er ikke tilgjengelig akkurat nå.",
+    "status": {
+      "pending": "Venter på gjennomgang",
+      "published": "Publisert",
+      "rejected": "Ikke publisert"
+    },
+    "errors": {
+      "failed": "Løsningen din kunne ikke sendes akkurat nå. Prøv igjen om litt.",
+      "photo": "Ett av bildene kunne ikke leses. Prøv et annet.",
+      "too_large": "Bildene er for store. Prøv færre eller mindre bilder.",
+      "paused": "Deling av løsninger er satt på pause akkurat nå. Prøv igjen senere.",
+      "demo": "Deling av løsninger er slått av i demoen."
+    },
+    "invite": "Fikset {code}? Fortell andre verksteder hva som virket.",
+    "inviteHint": "Med dine egne ord, gjerne med noen bilder. En person hos Torqvoice sjekker hver løsning før andre verksteder ser den.",
+    "start": "Del løsningen din",
+    "dialogTitle": "Del løsningen din for {code}",
+    "dialogDescription": "Andre verksteder ser den når en person hos Torqvoice har sjekket den.",
+    "characters": "{count} / {max}",
+    "writingHint": "Hva som var galt, hvordan du fant det og hva som løste det. Delenumre og måleverdier hjelper andre mest.",
+    "dropHere": "eller slipp dem her",
+    "tooManyPhotosRemove": "En løsning kan ha maks {max} bilder. Fjern noen for å dele den.",
+    "phone": {
+      "button": "Legg til fra mobil",
+      "hide": "Skjul mobilkoden",
+      "title": "Ta bilder med mobilen",
+      "body": "Skann koden med mobilkameraet. Bildene du tar dukker opp her etter hvert som de kommer.",
+      "failed": "Mobilkoden kunne ikke lages. Lukk dette og prøv igjen.",
+      "expires": "Koden virker til {time}",
+      "copy": "Kopier lenke",
+      "copied": "Kopiert",
+      "received": "{count, plural, one {# bilde} other {# bilder}} fra mobilen",
+      "badge": "Mobil"
+    }
+  },
+  "fixes": {
+    "title": "Løsninger fra andre verksteder",
+    "empty": "Ingen andre verksteder har delt en løsning for {code} ennå.",
+    "unavailable": "Løsninger fra andre verksteder er ikke tilgjengelige akkurat nå.",
+    "sharedOn": "Delt {date}",
+    "unknownVehicle": "Kjøretøy ikke oppgitt",
+    "unknownVehicleMarine": "Fartøy ikke oppgitt",
+    "writtenIn": "Skrevet på {language}",
+    "photo": "Bilde {n} av {count}",
+    "openPhoto": "Åpne bilde {n}"
+  },
+  "sheet": {
+    "description": "Betydningen, sannsynlige årsaker og verkstedets jobber med denne koden.",
+    "loadFailed": "Kunne ikke åpne denne koden.",
+    "loading": "Laster…",
+    "openPage": "Åpne kodesiden"
+  },
+  "entry": {
+    "button": "Legg til feilkoder",
+    "title": "Legg til feilkoder",
+    "description": "Skriv eller lim inn koder fra skanneren. Hver kode blir en observasjon på denne jobben, med betydningen lagret.",
+    "placeholder": "P0420, P0171",
+    "empty": "Koder du skriver eller limer inn, vises her.",
+    "found": "Koder funnet",
+    "remove": "Fjern {code}",
+    "concern": "Svarer på punkt",
+    "concernNone": "Ingen punkt",
+    "cancel": "Avbryt",
+    "add": "{count, plural, =0 {Legg til koder} one {Legg til # kode} other {Legg til # koder}}",
+    "added": "{count, plural, one {# feilkode lagt til} other {# feilkoder lagt til}}",
+    "skipped": "{count, plural, one {# kode var allerede på denne jobben} other {# koder var allerede på denne jobben}}",
+    "failed": "Kunne ikke legge til kodene"
+  },
+  "finding": {
+    "seenBefore": "Sett {date}",
+    "seenBeforeTitle": "Lest ut på denne bilen før, sist {date}",
+    "seenBeforeTitleMarine": "Lest ut på dette fartøyet før, sist {date}",
+    "updatedMeaning": "Ny betydning",
+    "updatedMeaningTitle": "Betydningen er nå: {meaning}. Trykk for å bruke den.",
+    "taken": "Betydningen er oppdatert",
+    "takeFailed": "Kunne ikke oppdatere betydningen",
+    "noMeaningSaved": "Ingen betydning lagret ennå"
+  },
+  "page": {
+    "title": "Feilkoder",
+    "intro": "Slå opp en kode, eller søk i betydningene etter ord som mager bank 1.",
+    "lookupLabel": "Kode eller ord",
+    "lookupPlaceholder": "P0420 eller mager bank 1",
+    "lookup": "Slå opp",
+    "cloudOnly": "Betydningene av feilkodene kommer fra Torqvoice Codes, som er en del av Torqvoice Cloud. Koder verkstedet ditt har lest ut, står her uansett.",
+    "results": "Koder som passer til «{query}»",
+    "noResults": "Ingen kode passer til «{query}»",
+    "recent": "Lest ut nylig i verkstedet ditt",
+    "noRecent": "Ingen feilkoder på noen jobb ennå.",
+    "readTimes": "{count, plural, one {Lest ut én gang, {date}} other {Lest ut # ganger, sist {date}}}",
+    "notACode": "«{text}» er ikke en feilkode",
+    "notACodeHint": "En feilkode er en bokstav og fire tegn, som P0420 eller U0100.",
+    "recentLookups": "Dine siste oppslag",
+    "recentLookupsEmpty": "Koder du åpner og ord du søker etter vises her, på denne enheten.",
+    "clearRecent": "Tøm",
+    "tryExamples": "Prøv",
+    "exampleWords": "mager bank 1",
+    "searching": "Søker"
+  },
+  "search": {
+    "group": "Feilkoder",
+    "jobsWith": "Jobber med {code}",
+    "searchFor": "Søk i feilkoder etter «{query}»"
+  },
+  "notice": {
+    "title": "Feilkoder er nytt",
+    "body": "Vi legger til koder hele tiden, så ikke alle koder er her ennå. Foreløpig dekker det standard OBD-II-koder; produsentspesifikke koder er ikke med ennå."
+  },
+  "support": {
+    "button": "Kontakt support",
+    "subject": "Feilkode {code}",
+    "subjectGeneral": "Feilkoder",
+    "message": "Feilkode {code}: {title}\n\n"
+  },
+  "systems": {
+    "P": "Drivverk",
+    "B": "Karosseri",
+    "C": "Understell",
+    "U": "Nettverk"
+  },
+  "phone": {
+    "title": "Bilder til en løsning",
+    "forCode": "til løsningen du skriver på datamaskinen",
+    "intro": "Ta eller velg opptil {max} bilder av det som løste koden. De dukker opp i løsningen på datamaskinen.",
+    "take": "Ta bilde",
+    "choose": "Velg bilder",
+    "added": "{count} av {max} bilder lagt til",
+    "full": "En løsning kan ha maks {max} bilder.",
+    "notAPhoto": "Den filen er ikke et bilde.",
+    "failed": "Bildet kunne ikke legges til. Prøv igjen.",
+    "done": "Når du er ferdig, gå tilbake til datamaskinen og del løsningen.",
+    "privacy": "Hold ansikter, skilt og kundeopplysninger utenfor bildet.",
+    "expired": "Denne koden har gått ut. Vis en ny på datamaskinen.",
+    "invalid": "Denne koden virker ikke. Vis en ny på datamaskinen."
+  }
+}

+ 13 - 0
messages/nb/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Tilgjengelig",
     "availableTitle": "Tilgjengelig",
     "availableDescription": "Tjenester du kan legge til. Hver settes opp én gang og kjører i bakgrunnen.",
     "availableDescription": "Tjenester du kan legge til. Hver settes opp én gang og kjører i bakgrunnen.",
     "allConnected": "Alle tilgjengelige tjenester er tilkoblet.",
     "allConnected": "Alle tilgjengelige tjenester er tilkoblet.",
+    "cloudOnly": "Kun i skyen",
+    "cloudOnlyHint": "Tilgjengelig i Torqvoice Cloud",
     "searchPlaceholder": "Søk i integrasjoner",
     "searchPlaceholder": "Søk i integrasjoner",
     "noMatches": "Ingenting passer med «{query}».",
     "noMatches": "Ingenting passer med «{query}».",
     "readMore": "Les mer",
     "readMore": "Les mer",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "Normtider på arbeidsordrer og tilbud",
       "labor": "Normtider på arbeidsordrer og tilbud",
       "fluids": "Væsketyper og mengder"
       "fluids": "Væsketyper og mengder"
+    },
+    "diagnostics": {
+      "codes": "Betydning av feilkoder"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Behold månedlige sikkerhetskopier",
         "keepMonthly": "Behold månedlige sikkerhetskopier",
         "keepMonthlyHelp": "Eldre sikkerhetskopier fjernes etter hver kjøring, og de nyeste av hver type beholdes. Sett et tall til 0 for ikke å beholde noen av den typen."
         "keepMonthlyHelp": "Eldre sikkerhetskopier fjernes etter hver kjøring, og de nyeste av hver type beholdes. Sett et tall til 0 for ikke å beholde noen av den typen."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "Se hva en feilkode betyr på kjøretøyet foran deg, på ditt eget språk: betydningen, sannsynlige årsaker og relaterte koder. Skriv eller lim inn koder på en arbeidsordre, så blir de observasjoner med betydningen lagret, slik at utskrifter og historikk aldri venter på en tilkobling. Driftes av Torqvoice og inkludert i alle skyabonnementer. Et oppslag sender bare merke, modell, år og motor, aldri VIN, registreringsnummer eller kunde.",
+      "fields": {
+        "apiToken": "Tilgangsnøkkel",
+        "apiTokenHelp": "Nøkkelen Torqvoice ga deg for kodetjenesten."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/nb/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Dekksett",
     "tireSetDetails": "Dekksett",
     "tireForecast": "Neste sesong",
     "tireForecast": "Neste sesong",
     "whatsapp": "WhatsApp",
     "whatsapp": "WhatsApp",
-    "inspectionReminders": "EU-kontroll-påminnelser"
+    "inspectionReminders": "EU-kontroll-påminnelser",
+    "faultCodes": "Feilkoder",
+    "faultCode": "Feilkode"
   },
   },
   "search": "Søk...",
   "search": "Søk...",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} åpne arbeidsordrer",
       "workOrders": "{count} åpne arbeidsordrer",
       "inspections": "{count} pågående inspeksjoner",
       "inspections": "{count} pågående inspeksjoner",
       "reminders": "{count} forfalte påminnelser"
       "reminders": "{count} forfalte påminnelser"
-    }
+    },
+    "faultCodes": "Feilkoder"
   }
   }
 }
 }

+ 249 - 0
messages/nl/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Foutcodes",
+  "notAvailable": "Nu niet beschikbaar",
+  "noMeaning": "Nog niet in onze codelijst",
+  "manufacturerUnknown": "Fabrikantspecifieke code: de betekenis hangt af van het merk.",
+  "chip": {
+    "open": "{code} openen"
+  },
+  "source": {
+    "generic": "Algemeen",
+    "manufacturer": "Fabrikant",
+    "report": "Uit het scanrapport",
+    "machineTranslated": "Machinaal vertaald"
+  },
+  "status": {
+    "stored": "Opgeslagen",
+    "pending": "In afwachting",
+    "permanent": "Permanent",
+    "history": "Historie"
+  },
+  "flags": {
+    "warningLight": "Waarschuwingslampje",
+    "emissions": "Emissies",
+    "driveCycle": "Rijcyclus nodig",
+    "limpMode": "Noodloop"
+  },
+  "sections": {
+    "how_it_works": "Zo werkt het",
+    "meaning": "Wat de code betekent",
+    "causes": "Typische oorzaken",
+    "manufacturer": "Gegevens van fabrikanten",
+    "related": "Samen met andere codes",
+    "diagnosis": "Diagnosetip",
+    "other": "Goed om te weten"
+  },
+  "detail": {
+    "whatItMeans": "Wat het betekent",
+    "likelyCauses": "Waarschijnlijke oorzaken",
+    "relatedCodes": "Verwante codes",
+    "inYourWorkshop": "In uw werkplaats",
+    "noJobs": "Nog bij geen enkele klus in uw werkplaats uitgelezen.",
+    "resolved": "Opgelost",
+    "notConnected": "Koppel Torqvoice Codes om te zien wat codes betekenen.",
+    "connect": "Koppelen",
+    "serviceDown": "De betekenis is nu niet beschikbaar. Uw klussen staan hieronder.",
+    "pendingCheck": "Alleen de titel wordt getoond tot de tekst is gecontroleerd.",
+    "showMore": "Meer tonen",
+    "showLess": "Minder tonen",
+    "likelihood": {
+      "high": "Hoog",
+      "medium": "Gemiddeld",
+      "low": "Laag"
+    },
+    "mostLikelyFirst": "Meest waarschijnlijke eerst"
+  },
+  "report": {
+    "button": "Probleem melden",
+    "title": "Wat klopt er niet aan de betekenis van {code}?",
+    "reasons": {
+      "wrong_for_vehicle": "Verkeerd voor deze auto",
+      "wrong_for_vehicleMarine": "Verkeerd voor dit vaartuig",
+      "text_mismatch": "Tekst past niet bij de code",
+      "wrong_position": "Verkeerde kant of positie",
+      "bad_translation": "Slechte vertaling",
+      "other": "Anders"
+    },
+    "note": "Notitie (optioneel)",
+    "notePlaceholder": "Wat u vond",
+    "send": "Versturen",
+    "thanks": "Bedankt, we kijken ernaar",
+    "failed": "De melding kon nu niet worden verstuurd. Probeer het zo opnieuw.",
+    "reportedByYou": "Door u gemeld",
+    "outcome": {
+      "fixed": "Bijgewerkt na uw melding",
+      "no_change": "Gecontroleerd, geen wijziging",
+      "duplicate": "Al gemeld"
+    }
+  },
+  "share": {
+    "title": "Deel uw oplossing",
+    "description": "Vertel andere werkplaatsen wat {code} heeft opgelost. Een medewerker van Torqvoice controleert elke oplossing voordat anderen die zien.",
+    "yourFixes": "Uw oplossingen voor {code}",
+    "whatFixedIt": "Wat het oploste",
+    "placeholder": "Doorgeschuurde draad bij de stekker van de achterste lambdasonde achter de versnellingsbak. Gerepareerd, code gewist na een proefrit.",
+    "placeholderMarine": "Gecorrodeerde pinnen in de stekker van de trimsensor. Gereinigd en afgedicht, code gewist na een proefvaart.",
+    "photos": "Foto's (optioneel)",
+    "photosHint": "Maximaal {max} foto's. Houd gezichten, kentekens en klantgegevens buiten beeld.",
+    "photosHintMarine": "Maximaal {max} foto's. Houd gezichten, registratienummers en klantgegevens buiten beeld.",
+    "addPhotos": "Foto's toevoegen",
+    "removePhoto": "Foto {n} verwijderen",
+    "tooManyPhotos": "Maximaal {max} foto's",
+    "notAPhoto": "{name} is geen foto",
+    "photoTooBig": "{name} is groter dan 15 MB",
+    "vehicle": "Voertuig",
+    "vehicleMarine": "Vaartuig",
+    "vehicleOptional": "Voertuig (optioneel)",
+    "vehicleOptionalMarine": "Vaartuig (optioneel)",
+    "make": "Merk",
+    "model": "Model",
+    "year": "Bouwjaar",
+    "vehicleHint": "Alleen merk, model, bouwjaar en motor worden gedeeld, nooit het kenteken, het VIN, de klant of de naam van uw werkplaats.",
+    "vehicleHintMarine": "Alleen merk, model, bouwjaar en motor worden gedeeld, nooit het registratienummer, het rompnummer, de klant of de naam van uw werkplaats.",
+    "send": "Oplossing delen",
+    "sent": "Bedankt. Uw oplossing wacht op controle.",
+    "shareAnother": "Nog een oplossing delen",
+    "cancel": "Annuleren",
+    "sentOn": "Verstuurd op {date}",
+    "reason": "Reden: {reason}",
+    "withdraw": "Intrekken",
+    "withdrawTitle": "Deze oplossing intrekken?",
+    "withdrawConfirm": "Ze wordt met haar foto's verwijderd en geen andere werkplaats krijgt haar te zien.",
+    "withdrawn": "Oplossing ingetrokken",
+    "withdrawFailed": "De oplossing kon nu niet worden ingetrokken. Probeer het zo opnieuw.",
+    "unavailable": "Oplossingen delen is nu niet beschikbaar.",
+    "status": {
+      "pending": "Wacht op controle",
+      "published": "Gepubliceerd",
+      "rejected": "Niet gepubliceerd"
+    },
+    "errors": {
+      "failed": "Uw oplossing kon nu niet worden verstuurd. Probeer het zo opnieuw.",
+      "photo": "Een van de foto's kon niet worden gelezen. Probeer een andere.",
+      "too_large": "De foto's zijn te groot. Probeer minder of kleinere foto's.",
+      "paused": "Oplossingen delen is nu gepauzeerd. Probeer het later opnieuw.",
+      "demo": "Oplossingen delen staat uit in de demo."
+    },
+    "invite": "{code} opgelost? Vertel andere werkplaatsen wat werkte.",
+    "inviteHint": "In uw eigen woorden, eventueel met een paar foto’s. Een medewerker van Torqvoice controleert elke oplossing voordat andere werkplaatsen hem zien.",
+    "start": "Uw oplossing delen",
+    "dialogTitle": "Uw oplossing voor {code} delen",
+    "dialogDescription": "Andere werkplaatsen zien hem zodra een medewerker van Torqvoice hem heeft gecontroleerd.",
+    "characters": "{count} / {max}",
+    "writingHint": "Wat er mis was, hoe u het vond en wat het oploste. Onderdeelnummers en meetwaarden helpen anderen het meest.",
+    "dropHere": "of sleep ze hierheen",
+    "tooManyPhotosRemove": "Een oplossing kan maximaal {max} foto’s hebben. Verwijder er een paar om hem te delen.",
+    "phone": {
+      "button": "Toevoegen vanaf telefoon",
+      "hide": "Telefooncode verbergen",
+      "title": "Maak foto’s met uw telefoon",
+      "body": "Scan de code met de camera van uw telefoon. De foto’s die u maakt verschijnen hier zodra ze binnenkomen.",
+      "failed": "De telefooncode kon niet worden gemaakt. Sluit dit en probeer het opnieuw.",
+      "expires": "De code werkt tot {time}",
+      "copy": "Link kopiëren",
+      "copied": "Gekopieerd",
+      "received": "{count, plural, one {# foto} other {# foto’s}} vanaf uw telefoon",
+      "badge": "Telefoon"
+    }
+  },
+  "fixes": {
+    "title": "Oplossingen van andere werkplaatsen",
+    "empty": "Nog geen andere werkplaats heeft een oplossing voor {code} gedeeld.",
+    "unavailable": "Oplossingen van andere werkplaatsen zijn nu niet beschikbaar.",
+    "sharedOn": "Gedeeld op {date}",
+    "unknownVehicle": "Voertuig niet opgegeven",
+    "unknownVehicleMarine": "Vaartuig niet opgegeven",
+    "writtenIn": "Taal: {language}",
+    "photo": "Foto {n} van {count}",
+    "openPhoto": "Foto {n} openen"
+  },
+  "sheet": {
+    "description": "De betekenis, waarschijnlijke oorzaken en de klussen van uw werkplaats met deze code.",
+    "loadFailed": "Deze code kon niet worden geopend.",
+    "loading": "Laden…",
+    "openPage": "Codepagina openen"
+  },
+  "entry": {
+    "button": "Foutcodes toevoegen",
+    "title": "Foutcodes toevoegen",
+    "description": "Typ of plak codes uit de scantool. Elke code wordt een observatie bij deze klus, met de betekenis opgeslagen.",
+    "placeholder": "P0420, P0171",
+    "empty": "Codes die u typt of plakt, verschijnen hier.",
+    "found": "Gevonden codes",
+    "remove": "{code} verwijderen",
+    "concern": "Beantwoordt punt",
+    "concernNone": "Geen punt",
+    "cancel": "Annuleren",
+    "add": "{count, plural, =0 {Codes toevoegen} one {# code toevoegen} other {# codes toevoegen}}",
+    "added": "{count, plural, one {# foutcode toegevoegd} other {# foutcodes toegevoegd}}",
+    "skipped": "{count, plural, one {# code stond al bij deze klus} other {# codes stonden al bij deze klus}}",
+    "failed": "De codes konden niet worden toegevoegd"
+  },
+  "finding": {
+    "seenBefore": "Gezien {date}",
+    "seenBeforeTitle": "Eerder uitgelezen bij dit voertuig, laatst op {date}",
+    "seenBeforeTitleMarine": "Eerder uitgelezen bij dit vaartuig, laatst op {date}",
+    "updatedMeaning": "Nieuwe betekenis",
+    "updatedMeaningTitle": "De betekenis luidt nu: {meaning}. Tik om die te gebruiken.",
+    "taken": "Betekenis bijgewerkt",
+    "takeFailed": "De betekenis kon niet worden bijgewerkt",
+    "noMeaningSaved": "Nog geen betekenis opgeslagen"
+  },
+  "page": {
+    "title": "Foutcodes",
+    "intro": "Zoek een code op, of zoek in de betekenissen naar woorden zoals arm bank 1.",
+    "lookupLabel": "Code of woorden",
+    "lookupPlaceholder": "P0420 of arm bank 1",
+    "lookup": "Opzoeken",
+    "cloudOnly": "De betekenissen van foutcodes komen van Torqvoice Codes, onderdeel van Torqvoice Cloud. Codes die uw werkplaats heeft uitgelezen, staan hier hoe dan ook.",
+    "results": "Codes bij \"{query}\"",
+    "noResults": "Geen code past bij \"{query}\"",
+    "recent": "Onlangs uitgelezen in uw werkplaats",
+    "noRecent": "Nog geen foutcodes bij een klus.",
+    "readTimes": "{count, plural, one {Eén keer uitgelezen, {date}} other {# keer uitgelezen, laatst {date}}}",
+    "notACode": "\"{text}\" is geen foutcode",
+    "notACodeHint": "Een foutcode is een letter met vier tekens, zoals P0420 of U0100.",
+    "recentLookups": "Uw recente zoekopdrachten",
+    "recentLookupsEmpty": "Codes die u opent en woorden waarop u zoekt verschijnen hier, op dit apparaat.",
+    "clearRecent": "Wissen",
+    "tryExamples": "Probeer",
+    "exampleWords": "arm bank 1",
+    "searching": "Bezig met zoeken"
+  },
+  "search": {
+    "group": "Foutcodes",
+    "jobsWith": "Klussen met {code}",
+    "searchFor": "Foutcodes doorzoeken op \"{query}\""
+  },
+  "notice": {
+    "title": "Foutcodes zijn nieuw",
+    "body": "We voegen voortdurend codes toe, dus nog niet elke code staat erin. Voorlopig gaat het om de standaard OBD-II-codes; fabrikantspecifieke codes zitten er nog niet in."
+  },
+  "support": {
+    "button": "Contact met support",
+    "subject": "Foutcode {code}",
+    "subjectGeneral": "Foutcodes",
+    "message": "Foutcode {code}: {title}\n\n"
+  },
+  "systems": {
+    "P": "Aandrijflijn",
+    "B": "Carrosserie",
+    "C": "Chassis",
+    "U": "Netwerk"
+  },
+  "phone": {
+    "title": "Foto’s voor een oplossing",
+    "forCode": "voor de oplossing die u op de computer schrijft",
+    "intro": "Maak of kies tot {max} foto’s van wat de code oploste. Ze verschijnen in de oplossing op de computer.",
+    "take": "Foto maken",
+    "choose": "Foto’s kiezen",
+    "added": "{count} van {max} foto’s toegevoegd",
+    "full": "Een oplossing kan maximaal {max} foto’s hebben.",
+    "notAPhoto": "Dat bestand is geen foto.",
+    "failed": "De foto kon niet worden toegevoegd. Probeer het opnieuw.",
+    "done": "Als u klaar bent, ga dan terug naar de computer en deel de oplossing.",
+    "privacy": "Houd gezichten, kentekens en klantgegevens buiten beeld.",
+    "expired": "Deze code is verlopen. Toon een nieuwe op de computer.",
+    "invalid": "Deze code werkt niet. Toon een nieuwe op de computer."
+  }
+}

+ 13 - 0
messages/nl/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Beschikbaar",
     "availableTitle": "Beschikbaar",
     "availableDescription": "Diensten die u kunt toevoegen. Elke dienst stelt u één keer in en draait daarna op de achtergrond.",
     "availableDescription": "Diensten die u kunt toevoegen. Elke dienst stelt u één keer in en draait daarna op de achtergrond.",
     "allConnected": "Alle beschikbare diensten zijn verbonden.",
     "allConnected": "Alle beschikbare diensten zijn verbonden.",
+    "cloudOnly": "Alleen cloud",
+    "cloudOnlyHint": "Beschikbaar in Torqvoice Cloud",
     "searchPlaceholder": "Integraties zoeken",
     "searchPlaceholder": "Integraties zoeken",
     "noMatches": "Niets komt overeen met \"{query}\".",
     "noMatches": "Niets komt overeen met \"{query}\".",
     "readMore": "Meer lezen",
     "readMore": "Meer lezen",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "Arbeidstijden op werkorders en offertes",
       "labor": "Arbeidstijden op werkorders en offertes",
       "fluids": "Vloeistofsoorten en inhoud"
       "fluids": "Vloeistofsoorten en inhoud"
+    },
+    "diagnostics": {
+      "codes": "Betekenis van foutcodes"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Maandelijkse back-ups bewaren",
         "keepMonthly": "Maandelijkse back-ups bewaren",
         "keepMonthlyHelp": "Oudere back-ups worden na elke run verwijderd, waarbij de nieuwste van elk soort blijven. Zet een aantal op 0 om van dat soort niets te bewaren."
         "keepMonthlyHelp": "Oudere back-ups worden na elke run verwijderd, waarbij de nieuwste van elk soort blijven. Zet een aantal op 0 om van dat soort niets te bewaren."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "Zie wat een foutcode betekent bij het voertuig voor u, in uw eigen taal: de betekenis, waarschijnlijke oorzaken en verwante codes. Typ of plak codes in een werkorder en ze worden observaties met de betekenis opgeslagen, zodat afdrukken en historie nooit op een verbinding wachten. Beheerd door Torqvoice en inbegrepen in elk cloudabonnement. Een opzoeking stuurt alleen merk, model, bouwjaar en motor, nooit een VIN, kenteken of klant.",
+      "fields": {
+        "apiToken": "Toegangssleutel",
+        "apiTokenHelp": "De sleutel die Torqvoice u gaf voor de codedienst."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/nl/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Bandenset",
     "tireSetDetails": "Bandenset",
     "tireForecast": "Volgend seizoen",
     "tireForecast": "Volgend seizoen",
     "whatsapp": "WhatsApp",
     "whatsapp": "WhatsApp",
-    "inspectionReminders": "Keuringsherinneringen"
+    "inspectionReminders": "Keuringsherinneringen",
+    "faultCodes": "Foutcodes",
+    "faultCode": "Foutcode"
   },
   },
   "search": "Zoeken...",
   "search": "Zoeken...",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} open werkorders",
       "workOrders": "{count} open werkorders",
       "inspections": "{count} lopende inspecties",
       "inspections": "{count} lopende inspecties",
       "reminders": "{count} herinneringen vervallen"
       "reminders": "{count} herinneringen vervallen"
-    }
+    },
+    "faultCodes": "Foutcodes"
   }
   }
 }
 }

+ 249 - 0
messages/pl/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Kody usterek",
+  "notAvailable": "Obecnie niedostępne",
+  "noMeaning": "Jeszcze nie ma go na naszej liście kodów",
+  "manufacturerUnknown": "Kod producenta: znaczenie zależy od marki.",
+  "chip": {
+    "open": "Otwórz {code}"
+  },
+  "source": {
+    "generic": "Ogólny",
+    "manufacturer": "Producent",
+    "report": "Z raportu diagnostycznego",
+    "machineTranslated": "Tłumaczenie maszynowe"
+  },
+  "status": {
+    "stored": "Zapisany",
+    "pending": "Oczekujący",
+    "permanent": "Trwały",
+    "history": "Historia"
+  },
+  "flags": {
+    "warningLight": "Kontrolka ostrzegawcza",
+    "emissions": "Emisja spalin",
+    "driveCycle": "Wymaga cyklu jazdy",
+    "limpMode": "Tryb awaryjny"
+  },
+  "sections": {
+    "how_it_works": "Jak to działa",
+    "meaning": "Co oznacza kod",
+    "causes": "Typowe przyczyny",
+    "manufacturer": "Dane producentów",
+    "related": "Razem z innymi kodami",
+    "diagnosis": "Wskazówka diagnostyczna",
+    "other": "Warto wiedzieć"
+  },
+  "detail": {
+    "whatItMeans": "Co oznacza",
+    "likelyCauses": "Prawdopodobne przyczyny",
+    "relatedCodes": "Powiązane kody",
+    "inYourWorkshop": "W twoim warsztacie",
+    "noJobs": "W twoim warsztacie nie odczytano go jeszcze przy żadnym zleceniu.",
+    "resolved": "Rozwiązane",
+    "notConnected": "Połącz Torqvoice Codes, aby widzieć, co oznaczają kody.",
+    "connect": "Połącz",
+    "serviceDown": "Znaczenie jest obecnie niedostępne. Twoje zlecenia są poniżej.",
+    "pendingCheck": "Do czasu sprawdzenia tekstu wyświetlany jest tylko tytuł.",
+    "showMore": "Pokaż więcej",
+    "showLess": "Pokaż mniej",
+    "likelihood": {
+      "high": "Wysokie",
+      "medium": "Średnie",
+      "low": "Niskie"
+    },
+    "mostLikelyFirst": "Najbardziej prawdopodobne najpierw"
+  },
+  "report": {
+    "button": "Zgłoś problem",
+    "title": "Co jest nie tak ze znaczeniem kodu {code}?",
+    "reasons": {
+      "wrong_for_vehicle": "Nie pasuje do tego auta",
+      "wrong_for_vehicleMarine": "Nie pasuje do tej jednostki",
+      "text_mismatch": "Tekst nie pasuje do kodu",
+      "wrong_position": "Zła strona lub położenie",
+      "bad_translation": "Złe tłumaczenie",
+      "other": "Inne"
+    },
+    "note": "Notatka (opcjonalnie)",
+    "notePlaceholder": "Co udało się ustalić",
+    "send": "Wyślij",
+    "thanks": "Dziękujemy, sprawdzimy to",
+    "failed": "Nie udało się teraz wysłać zgłoszenia. Spróbuj ponownie za chwilę.",
+    "reportedByYou": "Zgłoszone przez ciebie",
+    "outcome": {
+      "fixed": "Zaktualizowane po twoim zgłoszeniu",
+      "no_change": "Sprawdzone, bez zmian",
+      "duplicate": "Już zgłoszone"
+    }
+  },
+  "share": {
+    "title": "Podziel się rozwiązaniem",
+    "description": "Napisz innym warsztatom, co usunęło {code}. Każde rozwiązanie sprawdza osoba z Torqvoice, zanim zobaczą je inni.",
+    "yourFixes": "Twoje rozwiązania dla {code}",
+    "whatFixedIt": "Co pomogło",
+    "placeholder": "Przetarty przewód przy wtyczce tylnej sondy lambda za skrzynią biegów. Naprawiony, kod zniknął po jeździe próbnej.",
+    "placeholderMarine": "Skorodowane piny we wtyczce czujnika trymu. Wyczyszczone i uszczelnione, kod zniknął po próbnym rejsie.",
+    "photos": "Zdjęcia (opcjonalnie)",
+    "photosHint": "Do {max} zdjęć. Nie pokazuj twarzy, tablic rejestracyjnych ani danych klienta.",
+    "photosHintMarine": "Do {max} zdjęć. Nie pokazuj twarzy, numerów rejestracyjnych jednostki ani danych klienta.",
+    "addPhotos": "Dodaj zdjęcia",
+    "removePhoto": "Usuń zdjęcie {n}",
+    "tooManyPhotos": "Najwyżej {max} zdjęcia",
+    "notAPhoto": "{name} nie jest zdjęciem",
+    "photoTooBig": "{name} ma więcej niż 15 MB",
+    "vehicle": "Pojazd",
+    "vehicleMarine": "Jednostka",
+    "vehicleOptional": "Pojazd (opcjonalnie)",
+    "vehicleOptionalMarine": "Jednostka (opcjonalnie)",
+    "make": "Marka",
+    "model": "Model",
+    "year": "Rok",
+    "vehicleHint": "Udostępniane są tylko marka, model, rok i silnik, nigdy tablica rejestracyjna, VIN, klient ani nazwa twojego warsztatu.",
+    "vehicleHintMarine": "Udostępniane są tylko marka, model, rok i silnik, nigdy numer rejestracyjny, numer kadłuba, klient ani nazwa twojego warsztatu.",
+    "send": "Udostępnij rozwiązanie",
+    "sent": "Dziękujemy. Twoje rozwiązanie czeka na sprawdzenie.",
+    "shareAnother": "Udostępnij kolejne rozwiązanie",
+    "cancel": "Anuluj",
+    "sentOn": "Wysłano {date}",
+    "reason": "Powód: {reason}",
+    "withdraw": "Wycofaj",
+    "withdrawTitle": "Wycofać to rozwiązanie?",
+    "withdrawConfirm": "Zostanie usunięte razem ze zdjęciami i żaden inny warsztat go nie zobaczy.",
+    "withdrawn": "Rozwiązanie wycofane",
+    "withdrawFailed": "Nie udało się teraz wycofać rozwiązania. Spróbuj ponownie za chwilę.",
+    "unavailable": "Udostępnianie rozwiązań jest teraz niedostępne.",
+    "status": {
+      "pending": "Czeka na sprawdzenie",
+      "published": "Opublikowane",
+      "rejected": "Nieopublikowane"
+    },
+    "errors": {
+      "failed": "Nie udało się teraz wysłać twojego rozwiązania. Spróbuj ponownie za chwilę.",
+      "photo": "Nie udało się odczytać jednego ze zdjęć. Spróbuj innego.",
+      "too_large": "Zdjęcia są za duże. Spróbuj mniej lub mniejszych zdjęć.",
+      "paused": "Udostępnianie rozwiązań jest teraz wstrzymane. Spróbuj później.",
+      "demo": "Udostępnianie rozwiązań jest wyłączone w wersji demo."
+    },
+    "invite": "Usunięto {code}? Napisz innym warsztatom, co pomogło.",
+    "inviteHint": "Własnymi słowami, a jeśli są, z kilkoma zdjęciami. Osoba z Torqvoice sprawdza każde rozwiązanie, zanim zobaczą je inne warsztaty.",
+    "start": "Udostępnij rozwiązanie",
+    "dialogTitle": "Udostępnij rozwiązanie dla {code}",
+    "dialogDescription": "Inne warsztaty zobaczą je, gdy sprawdzi je osoba z Torqvoice.",
+    "characters": "{count} / {max}",
+    "writingHint": "Co było nie tak, jak to znaleziono i co pomogło. Numery części i wyniki pomiarów najbardziej pomagają innym.",
+    "dropHere": "albo upuść je tutaj",
+    "tooManyPhotosRemove": "Rozwiązanie może mieć najwyżej {max} zdjęcia. Usuń kilka, aby je udostępnić.",
+    "phone": {
+      "button": "Dodaj z telefonu",
+      "hide": "Ukryj kod telefonu",
+      "title": "Zrób zdjęcia telefonem",
+      "body": "Zeskanuj kod aparatem telefonu. Zrobione zdjęcia pojawią się tutaj, gdy tylko dotrą.",
+      "failed": "Nie udało się utworzyć kodu dla telefonu. Zamknij to okno i spróbuj ponownie.",
+      "expires": "Kod działa do {time}",
+      "copy": "Kopiuj link",
+      "copied": "Skopiowano",
+      "received": "{count, plural, one {# zdjęcie} few {# zdjęcia} many {# zdjęć} other {# zdjęcia}} z telefonu",
+      "badge": "Telefon"
+    }
+  },
+  "fixes": {
+    "title": "Rozwiązania innych warsztatów",
+    "empty": "Żaden inny warsztat nie udostępnił jeszcze rozwiązania dla {code}.",
+    "unavailable": "Rozwiązania innych warsztatów są teraz niedostępne.",
+    "sharedOn": "Udostępniono {date}",
+    "unknownVehicle": "Nie podano pojazdu",
+    "unknownVehicleMarine": "Nie podano jednostki",
+    "writtenIn": "Język: {language}",
+    "photo": "Zdjęcie {n} z {count}",
+    "openPhoto": "Otwórz zdjęcie {n}"
+  },
+  "sheet": {
+    "description": "Znaczenie, prawdopodobne przyczyny i zlecenia twojego warsztatu z tym kodem.",
+    "loadFailed": "Nie udało się otworzyć tego kodu.",
+    "loading": "Wczytywanie…",
+    "openPage": "Otwórz stronę kodu"
+  },
+  "entry": {
+    "button": "Dodaj kody usterek",
+    "title": "Dodaj kody usterek",
+    "description": "Wpisz lub wklej kody z testera diagnostycznego. Każdy staje się obserwacją w tym zleceniu, z zapisanym znaczeniem.",
+    "placeholder": "P0420, P0171",
+    "empty": "Tu pojawią się wpisane lub wklejone kody.",
+    "found": "Znalezione kody",
+    "remove": "Usuń {code}",
+    "concern": "Dotyczy sprawy",
+    "concernNone": "Żadna sprawa",
+    "cancel": "Anuluj",
+    "add": "{count, plural, =0 {Dodaj kody} one {Dodaj # kod} few {Dodaj # kody} many {Dodaj # kodów} other {Dodaj # kodu}}",
+    "added": "{count, plural, one {Dodano # kod usterki} few {Dodano # kody usterek} many {Dodano # kodów usterek} other {Dodano # kodu usterek}}",
+    "skipped": "{count, plural, one {# kod był już w tym zleceniu} few {# kody były już w tym zleceniu} many {# kodów było już w tym zleceniu} other {# kodu było już w tym zleceniu}}",
+    "failed": "Nie udało się dodać kodów"
+  },
+  "finding": {
+    "seenBefore": "Widziany {date}",
+    "seenBeforeTitle": "Odczytany już w tym pojeździe, ostatnio {date}",
+    "seenBeforeTitleMarine": "Odczytany już w tej jednostce, ostatnio {date}",
+    "updatedMeaning": "Nowe znaczenie",
+    "updatedMeaningTitle": "Znaczenie brzmi teraz: {meaning}. Dotknij, aby go użyć.",
+    "taken": "Znaczenie zaktualizowane",
+    "takeFailed": "Nie udało się zaktualizować znaczenia",
+    "noMeaningSaved": "Brak zapisanego znaczenia"
+  },
+  "page": {
+    "title": "Kody usterek",
+    "intro": "Sprawdź kod albo wyszukaj w znaczeniach słowa, takie jak uboga bank 1.",
+    "lookupLabel": "Kod lub słowa",
+    "lookupPlaceholder": "P0420 lub uboga bank 1",
+    "lookup": "Sprawdź",
+    "cloudOnly": "Znaczenia kodów usterek pochodzą z Torqvoice Codes, części Torqvoice Cloud. Kody odczytane przez twój warsztat są tu widoczne niezależnie od tego.",
+    "results": "Kody dla „{query}”",
+    "noResults": "Żaden kod nie pasuje do „{query}”",
+    "recent": "Ostatnio odczytane w twoim warsztacie",
+    "noRecent": "Brak kodów usterek w zleceniach.",
+    "readTimes": "{count, plural, one {Odczytany raz, {date}} few {Odczytany # razy, ostatnio {date}} many {Odczytany # razy, ostatnio {date}} other {Odczytany # razy, ostatnio {date}}}",
+    "notACode": "„{text}” to nie jest kod usterki",
+    "notACodeHint": "Kod usterki to litera i cztery znaki, na przykład P0420 lub U0100.",
+    "recentLookups": "Twoje ostatnie wyszukiwania",
+    "recentLookupsEmpty": "Otwierane kody i wyszukiwane słowa pojawiają się tutaj, na tym urządzeniu.",
+    "clearRecent": "Wyczyść",
+    "tryExamples": "Spróbuj",
+    "exampleWords": "uboga bank 1",
+    "searching": "Wyszukiwanie"
+  },
+  "search": {
+    "group": "Kody usterek",
+    "jobsWith": "Zlecenia z {code}",
+    "searchFor": "Szukaj „{query}” w kodach usterek"
+  },
+  "notice": {
+    "title": "Kody usterek to nowość",
+    "body": "Stale dodajemy kody, więc nie ma tu jeszcze wszystkich. Na razie obejmuje to standardowe kody OBD-II; kodów specyficznych dla producentów jeszcze nie ma."
+  },
+  "support": {
+    "button": "Kontakt z pomocą",
+    "subject": "Kod usterki {code}",
+    "subjectGeneral": "Kody usterek",
+    "message": "Kod usterki {code}: {title}\n\n"
+  },
+  "systems": {
+    "P": "Układ napędowy",
+    "B": "Nadwozie",
+    "C": "Podwozie",
+    "U": "Sieć"
+  },
+  "phone": {
+    "title": "Zdjęcia do rozwiązania",
+    "forCode": "do rozwiązania pisanego na komputerze",
+    "intro": "Zrób lub wybierz do {max} zdjęć tego, co usunęło usterkę. Pojawią się w rozwiązaniu na komputerze.",
+    "take": "Zrób zdjęcie",
+    "choose": "Wybierz zdjęcia",
+    "added": "Dodano {count} z {max} zdjęć",
+    "full": "Rozwiązanie może mieć najwyżej {max} zdjęcia.",
+    "notAPhoto": "Ten plik nie jest zdjęciem.",
+    "failed": "Nie udało się dodać zdjęcia. Spróbuj ponownie.",
+    "done": "Po zakończeniu wróć do komputera i udostępnij rozwiązanie.",
+    "privacy": "Nie pokazuj na zdjęciach twarzy, tablic rejestracyjnych ani danych klientów.",
+    "expired": "Ten kod wygasł. Wyświetl nowy na komputerze.",
+    "invalid": "Ten kod nie działa. Wyświetl nowy na komputerze."
+  }
+}

+ 13 - 0
messages/pl/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Dostępne",
     "availableTitle": "Dostępne",
     "availableDescription": "Usługi, które możesz dodać. Każdą konfiguruje się raz, a potem działa w tle.",
     "availableDescription": "Usługi, które możesz dodać. Każdą konfiguruje się raz, a potem działa w tle.",
     "allConnected": "Wszystkie dostępne usługi są połączone.",
     "allConnected": "Wszystkie dostępne usługi są połączone.",
+    "cloudOnly": "Tylko w chmurze",
+    "cloudOnlyHint": "Dostępne w Torqvoice Cloud",
     "searchPlaceholder": "Szukaj integracji",
     "searchPlaceholder": "Szukaj integracji",
     "noMatches": "Nic nie pasuje do „{query}”.",
     "noMatches": "Nic nie pasuje do „{query}”.",
     "readMore": "Czytaj więcej",
     "readMore": "Czytaj więcej",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "Normy czasowe w zleceniach i wycenach",
       "labor": "Normy czasowe w zleceniach i wycenach",
       "fluids": "Rodzaje i pojemności płynów"
       "fluids": "Rodzaje i pojemności płynów"
+    },
+    "diagnostics": {
+      "codes": "Znaczenia kodów usterek"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Zachowaj kopie miesięczne",
         "keepMonthly": "Zachowaj kopie miesięczne",
         "keepMonthlyHelp": "Starsze kopie są usuwane po każdym uruchomieniu, a najnowsze każdego rodzaju zostają. Ustaw liczbę na 0, aby nie zachowywać żadnej kopii danego rodzaju."
         "keepMonthlyHelp": "Starsze kopie są usuwane po każdym uruchomieniu, a najnowsze każdego rodzaju zostają. Ustaw liczbę na 0, aby nie zachowywać żadnej kopii danego rodzaju."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "Zobacz, co oznacza kod usterki w pojeździe przed tobą, w twoim języku: znaczenie, prawdopodobne przyczyny i powiązane kody. Wpisz lub wklej kody w zleceniu, a staną się obserwacjami z zapisanym znaczeniem, dzięki czemu wydruki i historia nigdy nie czekają na połączenie. Prowadzone przez Torqvoice i dostępne w każdym planie w chmurze. Wyszukiwanie wysyła tylko markę, model, rok i silnik, nigdy VIN, numer rejestracyjny ani klienta.",
+      "fields": {
+        "apiToken": "Klucz dostępu",
+        "apiTokenHelp": "Klucz, który Torqvoice przekazał ci do usługi kodów."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/pl/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Komplet opon",
     "tireSetDetails": "Komplet opon",
     "tireForecast": "Następny sezon",
     "tireForecast": "Następny sezon",
     "whatsapp": "WhatsApp",
     "whatsapp": "WhatsApp",
-    "inspectionReminders": "Przypomnienia o przeglądzie"
+    "inspectionReminders": "Przypomnienia o przeglądzie",
+    "faultCodes": "Kody usterek",
+    "faultCode": "Kod usterki"
   },
   },
   "search": "Szukaj...",
   "search": "Szukaj...",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} otwarte zlecenia",
       "workOrders": "{count} otwarte zlecenia",
       "inspections": "{count} przeglądy w toku",
       "inspections": "{count} przeglądy w toku",
       "reminders": "{count} zaległe przypomnienia"
       "reminders": "{count} zaległe przypomnienia"
-    }
+    },
+    "faultCodes": "Kody usterek"
   }
   }
 }
 }

+ 249 - 0
messages/pt-BR/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Códigos de falha",
+  "notAvailable": "Indisponível no momento",
+  "noMeaning": "Ainda não está na nossa lista de códigos",
+  "manufacturerUnknown": "Código específico do fabricante: o significado depende da marca.",
+  "chip": {
+    "open": "Abrir {code}"
+  },
+  "source": {
+    "generic": "Genérico",
+    "manufacturer": "Fabricante",
+    "report": "Do relatório do scanner",
+    "machineTranslated": "Tradução automática"
+  },
+  "status": {
+    "stored": "Armazenado",
+    "pending": "Pendente",
+    "permanent": "Permanente",
+    "history": "Histórico"
+  },
+  "flags": {
+    "warningLight": "Luz de advertência",
+    "emissions": "Emissões",
+    "driveCycle": "Requer ciclo de condução",
+    "limpMode": "Modo de emergência"
+  },
+  "sections": {
+    "how_it_works": "Como funciona",
+    "meaning": "O que o código significa",
+    "causes": "Causas típicas",
+    "manufacturer": "Dados dos fabricantes",
+    "related": "Junto com outros códigos",
+    "diagnosis": "Dica de diagnóstico",
+    "other": "Bom saber"
+  },
+  "detail": {
+    "whatItMeans": "O que significa",
+    "likelyCauses": "Causas prováveis",
+    "relatedCodes": "Códigos relacionados",
+    "inYourWorkshop": "Na sua oficina",
+    "noJobs": "Ainda não foi lido em nenhum serviço da sua oficina.",
+    "resolved": "Resolvido",
+    "notConnected": "Conecte o Torqvoice Codes para ver o que os códigos significam.",
+    "connect": "Conectar",
+    "serviceDown": "O significado está indisponível no momento. Seus serviços estão abaixo.",
+    "pendingCheck": "Só o título é exibido até o texto ser verificado.",
+    "showMore": "Mostrar mais",
+    "showLess": "Mostrar menos",
+    "likelihood": {
+      "high": "Alta",
+      "medium": "Média",
+      "low": "Baixa"
+    },
+    "mostLikelyFirst": "Mais prováveis primeiro"
+  },
+  "report": {
+    "button": "Relatar um problema",
+    "title": "O que está errado no significado de {code}?",
+    "reasons": {
+      "wrong_for_vehicle": "Errado para este carro",
+      "wrong_for_vehicleMarine": "Errado para esta embarcação",
+      "text_mismatch": "O texto não corresponde ao código",
+      "wrong_position": "Lado ou posição errados",
+      "bad_translation": "Tradução ruim",
+      "other": "Outro"
+    },
+    "note": "Nota (opcional)",
+    "notePlaceholder": "O que você encontrou",
+    "send": "Enviar",
+    "thanks": "Obrigado, vamos verificar",
+    "failed": "Não foi possível enviar o relato agora. Tente de novo em instantes.",
+    "reportedByYou": "Relatado por você",
+    "outcome": {
+      "fixed": "Atualizado após o seu relato",
+      "no_change": "Verificado, sem alteração",
+      "duplicate": "Já relatado"
+    }
+  },
+  "share": {
+    "title": "Compartilhe sua solução",
+    "description": "Conte a outras oficinas o que resolveu o {code}. Uma pessoa da Torqvoice confere cada solução antes que outros a vejam.",
+    "yourFixes": "Suas soluções para {code}",
+    "whatFixedIt": "O que resolveu",
+    "placeholder": "Fio desgastado no conector da sonda lambda traseira, atrás do câmbio. Reparado, o código sumiu depois de um test drive.",
+    "placeholderMarine": "Pinos corroídos no conector do sensor de trim. Limpos e vedados, o código sumiu depois de um teste na água.",
+    "photos": "Fotos (opcional)",
+    "photosHint": "Até {max} fotos. Deixe rostos, placas e dados do cliente fora da imagem.",
+    "photosHintMarine": "Até {max} fotos. Deixe rostos, números de inscrição da embarcação e dados do cliente fora da imagem.",
+    "addPhotos": "Adicionar fotos",
+    "removePhoto": "Remover a foto {n}",
+    "tooManyPhotos": "No máximo {max} fotos",
+    "notAPhoto": "{name} não é uma foto",
+    "photoTooBig": "{name} tem mais de 15 MB",
+    "vehicle": "Veículo",
+    "vehicleMarine": "Embarcação",
+    "vehicleOptional": "Veículo (opcional)",
+    "vehicleOptionalMarine": "Embarcação (opcional)",
+    "make": "Marca",
+    "model": "Modelo",
+    "year": "Ano",
+    "vehicleHint": "Só a marca, o modelo, o ano e o motor são compartilhados, nunca a placa, o chassi (VIN), o cliente ou o nome da sua oficina.",
+    "vehicleHintMarine": "Só a marca, o modelo, o ano e o motor são compartilhados, nunca a inscrição, o número do casco, o cliente ou o nome da sua oficina.",
+    "send": "Compartilhar solução",
+    "sent": "Obrigado. Sua solução está aguardando revisão.",
+    "shareAnother": "Compartilhar outra solução",
+    "cancel": "Cancelar",
+    "sentOn": "Enviada em {date}",
+    "reason": "Motivo: {reason}",
+    "withdraw": "Retirar",
+    "withdrawTitle": "Retirar esta solução?",
+    "withdrawConfirm": "Ela será apagada com as fotos e nenhuma outra oficina a verá.",
+    "withdrawn": "Solução retirada",
+    "withdrawFailed": "Não foi possível retirar a solução agora. Tente de novo em instantes.",
+    "unavailable": "O compartilhamento de soluções está indisponível no momento.",
+    "status": {
+      "pending": "Aguardando revisão",
+      "published": "Publicada",
+      "rejected": "Não publicada"
+    },
+    "errors": {
+      "failed": "Não foi possível enviar sua solução agora. Tente de novo em instantes.",
+      "photo": "Não foi possível ler uma das fotos. Tente outra.",
+      "too_large": "As fotos são grandes demais. Tente menos fotos ou fotos menores.",
+      "paused": "O compartilhamento de soluções está pausado no momento. Tente mais tarde.",
+      "demo": "O compartilhamento de soluções está desativado na demonstração."
+    },
+    "invite": "Resolveu o {code}? Conte a outras oficinas o que funcionou.",
+    "inviteHint": "Com suas próprias palavras e, se tiver, algumas fotos. Uma pessoa da Torqvoice confere cada solução antes que outras oficinas a vejam.",
+    "start": "Compartilhar sua solução",
+    "dialogTitle": "Compartilhar sua solução para {code}",
+    "dialogDescription": "Outras oficinas a verão depois que uma pessoa da Torqvoice a conferir.",
+    "characters": "{count} / {max}",
+    "writingHint": "O que estava errado, como você encontrou e o que resolveu. Números de peças e medições são o que mais ajuda os outros.",
+    "dropHere": "ou solte aqui",
+    "tooManyPhotosRemove": "Uma solução pode ter no máximo {max} fotos. Remova algumas para compartilhar.",
+    "phone": {
+      "button": "Adicionar pelo celular",
+      "hide": "Ocultar código do celular",
+      "title": "Tire fotos com o celular",
+      "body": "Escaneie o código com a câmera do celular. As fotos que você tirar aparecem aqui assim que chegam.",
+      "failed": "Não foi possível criar o código para o celular. Feche e tente de novo.",
+      "expires": "O código funciona até {time}",
+      "copy": "Copiar link",
+      "copied": "Copiado",
+      "received": "{count, plural, one {# foto} other {# fotos}} do celular",
+      "badge": "Celular"
+    }
+  },
+  "fixes": {
+    "title": "Soluções de outras oficinas",
+    "empty": "Nenhuma outra oficina compartilhou uma solução para {code} ainda.",
+    "unavailable": "As soluções de outras oficinas estão indisponíveis no momento.",
+    "sharedOn": "Compartilhada em {date}",
+    "unknownVehicle": "Veículo não informado",
+    "unknownVehicleMarine": "Embarcação não informada",
+    "writtenIn": "Escrita em {language}",
+    "photo": "Foto {n} de {count}",
+    "openPhoto": "Abrir a foto {n}"
+  },
+  "sheet": {
+    "description": "O significado, as causas prováveis e os serviços da sua oficina com este código.",
+    "loadFailed": "Não foi possível abrir este código.",
+    "loading": "Carregando…",
+    "openPage": "Abrir a página do código"
+  },
+  "entry": {
+    "button": "Adicionar códigos de falha",
+    "title": "Adicionar códigos de falha",
+    "description": "Digite ou cole os códigos do scanner. Cada um vira uma observação neste serviço, com o significado salvo.",
+    "placeholder": "P0420, P0171",
+    "empty": "Os códigos que você digitar ou colar aparecem aqui.",
+    "found": "Códigos encontrados",
+    "remove": "Remover {code}",
+    "concern": "Responde ao ponto",
+    "concernNone": "Nenhum ponto",
+    "cancel": "Cancelar",
+    "add": "{count, plural, =0 {Adicionar códigos} one {Adicionar # código} other {Adicionar # códigos}}",
+    "added": "{count, plural, one {# código de falha adicionado} other {# códigos de falha adicionados}}",
+    "skipped": "{count, plural, one {# código já estava neste serviço} other {# códigos já estavam neste serviço}}",
+    "failed": "Não foi possível adicionar os códigos"
+  },
+  "finding": {
+    "seenBefore": "Visto {date}",
+    "seenBeforeTitle": "Já lido neste veículo, a última vez em {date}",
+    "seenBeforeTitleMarine": "Já lido nesta embarcação, a última vez em {date}",
+    "updatedMeaning": "Significado atualizado",
+    "updatedMeaningTitle": "Agora o significado é: {meaning}. Toque para usar.",
+    "taken": "Significado atualizado",
+    "takeFailed": "Não foi possível atualizar o significado",
+    "noMeaningSaved": "Nenhum significado salvo ainda"
+  },
+  "page": {
+    "title": "Códigos de falha",
+    "intro": "Consulte um código, ou procure nos significados palavras como pobre banco 1.",
+    "lookupLabel": "Código ou palavras",
+    "lookupPlaceholder": "P0420 ou pobre banco 1",
+    "lookup": "Consultar",
+    "cloudOnly": "Os significados dos códigos de falha vêm do Torqvoice Codes, parte do Torqvoice Cloud. Os códigos lidos pela sua oficina aparecem aqui de qualquer forma.",
+    "results": "Códigos para \"{query}\"",
+    "noResults": "Nenhum código corresponde a \"{query}\"",
+    "recent": "Lidos recentemente na sua oficina",
+    "noRecent": "Ainda não há códigos de falha em nenhum serviço.",
+    "readTimes": "{count, plural, one {Lido uma vez, {date}} other {Lido # vezes, a última em {date}}}",
+    "notACode": "\"{text}\" não é um código de falha",
+    "notACodeHint": "Um código de falha é uma letra e quatro caracteres, como P0420 ou U0100.",
+    "recentLookups": "Suas pesquisas recentes",
+    "recentLookupsEmpty": "Os códigos que você abre e as palavras que pesquisa aparecem aqui, neste dispositivo.",
+    "clearRecent": "Limpar",
+    "tryExamples": "Experimente",
+    "exampleWords": "pobre banco 1",
+    "searching": "Pesquisando"
+  },
+  "search": {
+    "group": "Códigos de falha",
+    "jobsWith": "Serviços com {code}",
+    "searchFor": "Procurar \"{query}\" nos códigos de falha"
+  },
+  "notice": {
+    "title": "Os códigos de falha são novos",
+    "body": "Estamos adicionando códigos o tempo todo, então nem todos estão aqui ainda. Por enquanto, estão incluídos os códigos OBD-II padrão; os códigos específicos de cada fabricante ainda não."
+  },
+  "support": {
+    "button": "Falar com o suporte",
+    "subject": "Código de falha {code}",
+    "subjectGeneral": "Códigos de falha",
+    "message": "Código de falha {code}: {title}\n\n"
+  },
+  "systems": {
+    "P": "Trem de força",
+    "B": "Carroceria",
+    "C": "Chassi",
+    "U": "Rede"
+  },
+  "phone": {
+    "title": "Fotos para uma solução",
+    "forCode": "para a solução que você está escrevendo no computador",
+    "intro": "Tire ou escolha até {max} fotos do que resolveu o código. Elas aparecem na solução no computador.",
+    "take": "Tirar foto",
+    "choose": "Escolher fotos",
+    "added": "{count} de {max} fotos adicionadas",
+    "full": "Uma solução pode ter no máximo {max} fotos.",
+    "notAPhoto": "Esse arquivo não é uma foto.",
+    "failed": "Não foi possível adicionar a foto. Tente de novo.",
+    "done": "Quando terminar, volte ao computador e compartilhe a solução.",
+    "privacy": "Deixe rostos, placas e dados de clientes fora da imagem.",
+    "expired": "Este código expirou. Mostre um novo no computador.",
+    "invalid": "Este código não funciona. Mostre um novo no computador."
+  }
+}

+ 13 - 0
messages/pt-BR/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Disponíveis",
     "availableTitle": "Disponíveis",
     "availableDescription": "Serviços que você pode adicionar. Cada um é configurado uma vez e roda em segundo plano.",
     "availableDescription": "Serviços que você pode adicionar. Cada um é configurado uma vez e roda em segundo plano.",
     "allConnected": "Todos os serviços disponíveis estão conectados.",
     "allConnected": "Todos os serviços disponíveis estão conectados.",
+    "cloudOnly": "Somente na nuvem",
+    "cloudOnlyHint": "Disponível no Torqvoice Cloud",
     "searchPlaceholder": "Buscar integrações",
     "searchPlaceholder": "Buscar integrações",
     "noMatches": "Nada corresponde a \"{query}\".",
     "noMatches": "Nada corresponde a \"{query}\".",
     "readMore": "Saiba mais",
     "readMore": "Saiba mais",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "Tempos de mão de obra em ordens de serviço e orçamentos",
       "labor": "Tempos de mão de obra em ordens de serviço e orçamentos",
       "fluids": "Tipos e capacidades de fluidos"
       "fluids": "Tipos e capacidades de fluidos"
+    },
+    "diagnostics": {
+      "codes": "Significado dos códigos de falha"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Manter backups mensais",
         "keepMonthly": "Manter backups mensais",
         "keepMonthlyHelp": "Backups mais antigos são removidos após cada execução, mantendo os mais recentes de cada tipo. Defina um número como 0 para não manter nenhum daquele tipo."
         "keepMonthlyHelp": "Backups mais antigos são removidos após cada execução, mantendo os mais recentes de cada tipo. Defina um número como 0 para não manter nenhum daquele tipo."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "Veja o que um código de falha significa no veículo à sua frente, no seu idioma: o significado, as causas prováveis e os códigos relacionados. Digite ou cole códigos em uma ordem de serviço e eles viram observações com o significado salvo, para que impressões e histórico nunca dependam de uma conexão. Operado pela Torqvoice e incluído em todos os planos na nuvem. Uma consulta envia apenas marca, modelo, ano e motor, nunca um VIN, uma placa ou um cliente.",
+      "fields": {
+        "apiToken": "Chave de acesso",
+        "apiTokenHelp": "A chave que a Torqvoice forneceu para o serviço de códigos."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/pt-BR/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Jogo de pneus",
     "tireSetDetails": "Jogo de pneus",
     "tireForecast": "Próxima temporada",
     "tireForecast": "Próxima temporada",
     "whatsapp": "WhatsApp",
     "whatsapp": "WhatsApp",
-    "inspectionReminders": "Lembretes de inspeção"
+    "inspectionReminders": "Lembretes de inspeção",
+    "faultCodes": "Códigos de falha",
+    "faultCode": "Código de falha"
   },
   },
   "search": "Buscar...",
   "search": "Buscar...",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} ordens de serviço abertas",
       "workOrders": "{count} ordens de serviço abertas",
       "inspections": "{count} inspeções em andamento",
       "inspections": "{count} inspeções em andamento",
       "reminders": "{count} lembretes vencidos"
       "reminders": "{count} lembretes vencidos"
-    }
+    },
+    "faultCodes": "Códigos de falha"
   }
   }
 }
 }

+ 249 - 0
messages/pt-PT/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Códigos de avaria",
+  "notAvailable": "Indisponível de momento",
+  "noMeaning": "Ainda não está na nossa lista de códigos",
+  "manufacturerUnknown": "Código específico do fabricante: o significado depende da marca.",
+  "chip": {
+    "open": "Abrir {code}"
+  },
+  "source": {
+    "generic": "Genérico",
+    "manufacturer": "Fabricante",
+    "report": "Do relatório de diagnóstico",
+    "machineTranslated": "Tradução automática"
+  },
+  "status": {
+    "stored": "Memorizado",
+    "pending": "Pendente",
+    "permanent": "Permanente",
+    "history": "Histórico"
+  },
+  "flags": {
+    "warningLight": "Luz de aviso",
+    "emissions": "Emissões",
+    "driveCycle": "Requer ciclo de condução",
+    "limpMode": "Modo de emergência"
+  },
+  "sections": {
+    "how_it_works": "Como funciona",
+    "meaning": "O que o código significa",
+    "causes": "Causas típicas",
+    "manufacturer": "Dados dos fabricantes",
+    "related": "Juntamente com outros códigos",
+    "diagnosis": "Dica de diagnóstico",
+    "other": "Bom saber"
+  },
+  "detail": {
+    "whatItMeans": "O que significa",
+    "likelyCauses": "Causas prováveis",
+    "relatedCodes": "Códigos relacionados",
+    "inYourWorkshop": "Na sua oficina",
+    "noJobs": "Ainda não foi lido em nenhum trabalho da sua oficina.",
+    "resolved": "Resolvido",
+    "notConnected": "Ligue o Torqvoice Codes para ver o que os códigos significam.",
+    "connect": "Ligar",
+    "serviceDown": "O significado está indisponível de momento. Os seus trabalhos estão abaixo.",
+    "pendingCheck": "Só o título é mostrado até o texto ser verificado.",
+    "showMore": "Mostrar mais",
+    "showLess": "Mostrar menos",
+    "likelihood": {
+      "high": "Alta",
+      "medium": "Média",
+      "low": "Baixa"
+    },
+    "mostLikelyFirst": "Mais prováveis primeiro"
+  },
+  "report": {
+    "button": "Comunicar um problema",
+    "title": "O que está errado no significado de {code}?",
+    "reasons": {
+      "wrong_for_vehicle": "Errado para este carro",
+      "wrong_for_vehicleMarine": "Errado para esta embarcação",
+      "text_mismatch": "O texto não corresponde ao código",
+      "wrong_position": "Lado ou posição errados",
+      "bad_translation": "Má tradução",
+      "other": "Outro"
+    },
+    "note": "Nota (opcional)",
+    "notePlaceholder": "O que encontrou",
+    "send": "Enviar",
+    "thanks": "Obrigado, vamos verificar",
+    "failed": "Não foi possível enviar a comunicação agora. Tente novamente daqui a pouco.",
+    "reportedByYou": "Comunicado por si",
+    "outcome": {
+      "fixed": "Atualizado após a sua comunicação",
+      "no_change": "Verificado, sem alterações",
+      "duplicate": "Já comunicado"
+    }
+  },
+  "share": {
+    "title": "Partilhe a sua solução",
+    "description": "Diga a outras oficinas o que resolveu o {code}. Uma pessoa da Torqvoice verifica cada solução antes de outros a verem.",
+    "yourFixes": "As suas soluções para {code}",
+    "whatFixedIt": "O que resolveu",
+    "placeholder": "Fio desgastado na ficha da sonda lambda traseira, atrás da caixa de velocidades. Reparado, o código desapareceu após um teste de estrada.",
+    "placeholderMarine": "Pinos corroídos na ficha do sensor de trim. Limpos e vedados, o código desapareceu após um teste no mar.",
+    "photos": "Fotografias (opcional)",
+    "photosHint": "Até {max} fotografias. Deixe rostos, matrículas e dados do cliente fora da imagem.",
+    "photosHintMarine": "Até {max} fotografias. Deixe rostos, números de registo da embarcação e dados do cliente fora da imagem.",
+    "addPhotos": "Adicionar fotografias",
+    "removePhoto": "Remover a fotografia {n}",
+    "tooManyPhotos": "No máximo {max} fotografias",
+    "notAPhoto": "{name} não é uma fotografia",
+    "photoTooBig": "{name} tem mais de 15 MB",
+    "vehicle": "Veículo",
+    "vehicleMarine": "Embarcação",
+    "vehicleOptional": "Veículo (opcional)",
+    "vehicleOptionalMarine": "Embarcação (opcional)",
+    "make": "Marca",
+    "model": "Modelo",
+    "year": "Ano",
+    "vehicleHint": "Só são partilhados a marca, o modelo, o ano e o motor, nunca a matrícula, o VIN, o cliente ou o nome da sua oficina.",
+    "vehicleHintMarine": "Só são partilhados a marca, o modelo, o ano e o motor, nunca o registo, o número do casco, o cliente ou o nome da sua oficina.",
+    "send": "Partilhar solução",
+    "sent": "Obrigado. A sua solução aguarda revisão.",
+    "shareAnother": "Partilhar outra solução",
+    "cancel": "Cancelar",
+    "sentOn": "Enviada a {date}",
+    "reason": "Motivo: {reason}",
+    "withdraw": "Retirar",
+    "withdrawTitle": "Retirar esta solução?",
+    "withdrawConfirm": "Será apagada com as fotografias e nenhuma outra oficina a verá.",
+    "withdrawn": "Solução retirada",
+    "withdrawFailed": "Não foi possível retirar a solução de momento. Tente novamente dentro de instantes.",
+    "unavailable": "A partilha de soluções está indisponível de momento.",
+    "status": {
+      "pending": "A aguardar revisão",
+      "published": "Publicada",
+      "rejected": "Não publicada"
+    },
+    "errors": {
+      "failed": "Não foi possível enviar a sua solução de momento. Tente novamente dentro de instantes.",
+      "photo": "Não foi possível ler uma das fotografias. Experimente outra.",
+      "too_large": "As fotografias são demasiado grandes. Experimente menos fotografias ou mais pequenas.",
+      "paused": "A partilha de soluções está em pausa de momento. Tente mais tarde.",
+      "demo": "A partilha de soluções está desativada na demonstração."
+    },
+    "invite": "Resolveu o {code}? Conte às outras oficinas o que resultou.",
+    "inviteHint": "Por palavras suas e, se as tiver, com algumas fotografias. Uma pessoa da Torqvoice verifica cada solução antes de outras oficinas a verem.",
+    "start": "Partilhar a sua solução",
+    "dialogTitle": "Partilhar a sua solução para {code}",
+    "dialogDescription": "As outras oficinas veem-na depois de uma pessoa da Torqvoice a verificar.",
+    "characters": "{count} / {max}",
+    "writingHint": "O que estava mal, como o encontrou e o que o resolveu. Referências de peças e medições são o que mais ajuda os outros.",
+    "dropHere": "ou largue-as aqui",
+    "tooManyPhotosRemove": "Uma solução pode ter no máximo {max} fotografias. Remova algumas para a partilhar.",
+    "phone": {
+      "button": "Adicionar a partir do telemóvel",
+      "hide": "Ocultar código do telemóvel",
+      "title": "Tire fotografias com o telemóvel",
+      "body": "Leia o código com a câmara do telemóvel. As fotografias que tirar aparecem aqui à medida que chegam.",
+      "failed": "Não foi possível criar o código para o telemóvel. Feche e tente novamente.",
+      "expires": "O código funciona até às {time}",
+      "copy": "Copiar ligação",
+      "copied": "Copiado",
+      "received": "{count, plural, one {# fotografia} other {# fotografias}} do telemóvel",
+      "badge": "Telemóvel"
+    }
+  },
+  "fixes": {
+    "title": "Soluções de outras oficinas",
+    "empty": "Nenhuma outra oficina partilhou ainda uma solução para {code}.",
+    "unavailable": "As soluções de outras oficinas estão indisponíveis de momento.",
+    "sharedOn": "Partilhada a {date}",
+    "unknownVehicle": "Veículo não indicado",
+    "unknownVehicleMarine": "Embarcação não indicada",
+    "writtenIn": "Escrita em {language}",
+    "photo": "Fotografia {n} de {count}",
+    "openPhoto": "Abrir a fotografia {n}"
+  },
+  "sheet": {
+    "description": "O significado, as causas prováveis e os trabalhos da sua oficina com este código.",
+    "loadFailed": "Não foi possível abrir este código.",
+    "loading": "A carregar…",
+    "openPage": "Abrir a página do código"
+  },
+  "entry": {
+    "button": "Adicionar códigos de avaria",
+    "title": "Adicionar códigos de avaria",
+    "description": "Escreva ou cole os códigos do equipamento de diagnóstico. Cada um passa a ser uma observação neste trabalho, com o significado guardado.",
+    "placeholder": "P0420, P0171",
+    "empty": "Os códigos que escrever ou colar aparecem aqui.",
+    "found": "Códigos encontrados",
+    "remove": "Remover {code}",
+    "concern": "Responde à queixa",
+    "concernNone": "Nenhuma queixa",
+    "cancel": "Cancelar",
+    "add": "{count, plural, =0 {Adicionar códigos} one {Adicionar # código} other {Adicionar # códigos}}",
+    "added": "{count, plural, one {# código de avaria adicionado} other {# códigos de avaria adicionados}}",
+    "skipped": "{count, plural, one {# código já estava neste trabalho} other {# códigos já estavam neste trabalho}}",
+    "failed": "Não foi possível adicionar os códigos"
+  },
+  "finding": {
+    "seenBefore": "Visto {date}",
+    "seenBeforeTitle": "Já lido neste veículo, a última vez a {date}",
+    "seenBeforeTitleMarine": "Já lido nesta embarcação, a última vez a {date}",
+    "updatedMeaning": "Significado atualizado",
+    "updatedMeaningTitle": "O significado é agora: {meaning}. Toque para o usar.",
+    "taken": "Significado atualizado",
+    "takeFailed": "Não foi possível atualizar o significado",
+    "noMeaningSaved": "Ainda sem significado guardado"
+  },
+  "page": {
+    "title": "Códigos de avaria",
+    "intro": "Consulte um código, ou pesquise nos significados palavras como pobre banco 1.",
+    "lookupLabel": "Código ou palavras",
+    "lookupPlaceholder": "P0420 ou pobre banco 1",
+    "lookup": "Consultar",
+    "cloudOnly": "Os significados dos códigos de avaria vêm do Torqvoice Codes, que faz parte do Torqvoice Cloud. Os códigos lidos pela sua oficina aparecem aqui de qualquer forma.",
+    "results": "Códigos para \"{query}\"",
+    "noResults": "Nenhum código corresponde a \"{query}\"",
+    "recent": "Lidos recentemente na sua oficina",
+    "noRecent": "Ainda não há códigos de avaria em nenhum trabalho.",
+    "readTimes": "{count, plural, one {Lido uma vez, {date}} other {Lido # vezes, a última a {date}}}",
+    "notACode": "\"{text}\" não é um código de avaria",
+    "notACodeHint": "Um código de avaria é uma letra e quatro caracteres, como P0420 ou U0100.",
+    "recentLookups": "As suas pesquisas recentes",
+    "recentLookupsEmpty": "Os códigos que abre e as palavras que pesquisa aparecem aqui, neste dispositivo.",
+    "clearRecent": "Limpar",
+    "tryExamples": "Experimente",
+    "exampleWords": "pobre banco 1",
+    "searching": "A pesquisar"
+  },
+  "search": {
+    "group": "Códigos de avaria",
+    "jobsWith": "Trabalhos com {code}",
+    "searchFor": "Pesquisar \"{query}\" nos códigos de avaria"
+  },
+  "notice": {
+    "title": "Os códigos de avaria são novos",
+    "body": "Estamos sempre a adicionar códigos, por isso ainda não estão todos aqui. Por agora, estão incluídos os códigos OBD-II normalizados; os códigos específicos de cada fabricante ainda não."
+  },
+  "support": {
+    "button": "Contactar o suporte",
+    "subject": "Código de avaria {code}",
+    "subjectGeneral": "Códigos de avaria",
+    "message": "Código de avaria {code}: {title}\n\n"
+  },
+  "systems": {
+    "P": "Grupo motopropulsor",
+    "B": "Carroçaria",
+    "C": "Chassis",
+    "U": "Rede"
+  },
+  "phone": {
+    "title": "Fotografias para uma solução",
+    "forCode": "para a solução que está a escrever no computador",
+    "intro": "Tire ou escolha até {max} fotografias do que resolveu o código. Aparecem na solução no computador.",
+    "take": "Tirar fotografia",
+    "choose": "Escolher fotografias",
+    "added": "{count} de {max} fotografias adicionadas",
+    "full": "Uma solução pode ter no máximo {max} fotografias.",
+    "notAPhoto": "Esse ficheiro não é uma fotografia.",
+    "failed": "Não foi possível adicionar a fotografia. Tente novamente.",
+    "done": "Quando terminar, volte ao computador e partilhe a solução.",
+    "privacy": "Deixe rostos, matrículas e dados de clientes fora da imagem.",
+    "expired": "Este código expirou. Mostre um novo no computador.",
+    "invalid": "Este código não funciona. Mostre um novo no computador."
+  }
+}

+ 13 - 0
messages/pt-PT/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Disponíveis",
     "availableTitle": "Disponíveis",
     "availableDescription": "Serviços que pode adicionar. Cada um é configurado uma vez e funciona em segundo plano.",
     "availableDescription": "Serviços que pode adicionar. Cada um é configurado uma vez e funciona em segundo plano.",
     "allConnected": "Todos os serviços disponíveis estão ligados.",
     "allConnected": "Todos os serviços disponíveis estão ligados.",
+    "cloudOnly": "Apenas na nuvem",
+    "cloudOnlyHint": "Disponível no Torqvoice Cloud",
     "searchPlaceholder": "Pesquisar integrações",
     "searchPlaceholder": "Pesquisar integrações",
     "noMatches": "Nada corresponde a \"{query}\".",
     "noMatches": "Nada corresponde a \"{query}\".",
     "readMore": "Saiba mais",
     "readMore": "Saiba mais",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "Tempos de mão de obra em ordens de trabalho e orçamentos",
       "labor": "Tempos de mão de obra em ordens de trabalho e orçamentos",
       "fluids": "Tipos e capacidades de fluidos"
       "fluids": "Tipos e capacidades de fluidos"
+    },
+    "diagnostics": {
+      "codes": "Significado dos códigos de avaria"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Manter cópias mensais",
         "keepMonthly": "Manter cópias mensais",
         "keepMonthlyHelp": "As cópias mais antigas são removidas após cada execução, mantendo as mais recentes de cada tipo. Defina um número como 0 para não manter nenhuma desse tipo."
         "keepMonthlyHelp": "As cópias mais antigas são removidas após cada execução, mantendo as mais recentes de cada tipo. Defina um número como 0 para não manter nenhuma desse tipo."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "Veja o que um código de avaria significa no veículo à sua frente, no seu idioma: o significado, as causas prováveis e os códigos relacionados. Escreva ou cole códigos numa ordem de serviço e passam a ser observações com o significado guardado, para que impressões e histórico nunca fiquem à espera de uma ligação. Gerido pela Torqvoice e incluído em todos os planos na nuvem. Uma consulta envia apenas marca, modelo, ano e motor, nunca um VIN, uma matrícula ou um cliente.",
+      "fields": {
+        "apiToken": "Chave de acesso",
+        "apiTokenHelp": "A chave que a Torqvoice lhe deu para o serviço de códigos."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/pt-PT/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Jogo de pneus",
     "tireSetDetails": "Jogo de pneus",
     "tireForecast": "Próxima época",
     "tireForecast": "Próxima época",
     "whatsapp": "WhatsApp",
     "whatsapp": "WhatsApp",
-    "inspectionReminders": "Lembretes de inspeção"
+    "inspectionReminders": "Lembretes de inspeção",
+    "faultCodes": "Códigos de avaria",
+    "faultCode": "Código de avaria"
   },
   },
   "search": "Pesquisar...",
   "search": "Pesquisar...",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} ordens de serviço abertas",
       "workOrders": "{count} ordens de serviço abertas",
       "inspections": "{count} inspeções em curso",
       "inspections": "{count} inspeções em curso",
       "reminders": "{count} lembretes pendentes"
       "reminders": "{count} lembretes pendentes"
-    }
+    },
+    "faultCodes": "Códigos de avaria"
   }
   }
 }
 }

+ 249 - 0
messages/ru/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Коды неисправностей",
+  "notAvailable": "Сейчас недоступно",
+  "noMeaning": "Пока нет в нашем списке кодов",
+  "manufacturerUnknown": "Код производителя: значение зависит от марки.",
+  "chip": {
+    "open": "Открыть {code}"
+  },
+  "source": {
+    "generic": "Общий",
+    "manufacturer": "Производитель",
+    "report": "Из отчёта сканера",
+    "machineTranslated": "Машинный перевод"
+  },
+  "status": {
+    "stored": "Сохранён",
+    "pending": "Ожидающий",
+    "permanent": "Постоянный",
+    "history": "История"
+  },
+  "flags": {
+    "warningLight": "Контрольная лампа",
+    "emissions": "Выбросы",
+    "driveCycle": "Нужен ездовой цикл",
+    "limpMode": "Аварийный режим"
+  },
+  "sections": {
+    "how_it_works": "Как это работает",
+    "meaning": "Что означает код",
+    "causes": "Типичные причины",
+    "manufacturer": "Данные производителей",
+    "related": "Вместе с другими кодами",
+    "diagnosis": "Совет по диагностике",
+    "other": "Полезно знать"
+  },
+  "detail": {
+    "whatItMeans": "Что это значит",
+    "likelyCauses": "Вероятные причины",
+    "relatedCodes": "Связанные коды",
+    "inYourWorkshop": "В вашей мастерской",
+    "noJobs": "В вашей мастерской ещё не считывался ни в одной работе.",
+    "resolved": "Решено",
+    "notConnected": "Подключите Torqvoice Codes, чтобы видеть, что означают коды.",
+    "connect": "Подключить",
+    "serviceDown": "Значение сейчас недоступно. Ваши работы ниже.",
+    "pendingCheck": "Пока текст не проверен, показывается только заголовок.",
+    "showMore": "Показать больше",
+    "showLess": "Показать меньше",
+    "likelihood": {
+      "high": "Высокая",
+      "medium": "Средняя",
+      "low": "Низкая"
+    },
+    "mostLikelyFirst": "Сначала наиболее вероятные"
+  },
+  "report": {
+    "button": "Сообщить о проблеме",
+    "title": "Что не так со значением {code}?",
+    "reasons": {
+      "wrong_for_vehicle": "Неверно для этой машины",
+      "wrong_for_vehicleMarine": "Неверно для этого судна",
+      "text_mismatch": "Текст не соответствует коду",
+      "wrong_position": "Не та сторона или позиция",
+      "bad_translation": "Плохой перевод",
+      "other": "Другое"
+    },
+    "note": "Заметка (необязательно)",
+    "notePlaceholder": "Что вы обнаружили",
+    "send": "Отправить",
+    "thanks": "Спасибо, мы проверим",
+    "failed": "Сейчас не удалось отправить сообщение. Попробуйте ещё раз чуть позже.",
+    "reportedByYou": "Отправлено вами",
+    "outcome": {
+      "fixed": "Обновлено после вашего сообщения",
+      "no_change": "Проверено, без изменений",
+      "duplicate": "Уже сообщали"
+    }
+  },
+  "share": {
+    "title": "Поделитесь решением",
+    "description": "Расскажите другим мастерским, что устранило {code}. Каждое решение проверяет сотрудник Torqvoice, прежде чем его увидят другие.",
+    "yourFixes": "Ваши решения для {code}",
+    "whatFixedIt": "Что помогло",
+    "placeholder": "Перетёртый провод у разъёма заднего лямбда-зонда за коробкой передач. Отремонтировали, код ушёл после пробной поездки.",
+    "placeholderMarine": "Окисленные контакты в разъёме датчика трима. Очистили и загерметизировали, код ушёл после ходовых испытаний.",
+    "photos": "Фото (необязательно)",
+    "photosHint": "До {max} фото. Не снимайте лица, номерные знаки и данные клиента.",
+    "photosHintMarine": "До {max} фото. Не снимайте лица, регистрационные номера судна и данные клиента.",
+    "addPhotos": "Добавить фото",
+    "removePhoto": "Удалить фото {n}",
+    "tooManyPhotos": "Не больше {max} фото",
+    "notAPhoto": "{name} не является фотографией",
+    "photoTooBig": "{name} больше 15 МБ",
+    "vehicle": "Автомобиль",
+    "vehicleMarine": "Судно",
+    "vehicleOptional": "Автомобиль (необязательно)",
+    "vehicleOptionalMarine": "Судно (необязательно)",
+    "make": "Марка",
+    "model": "Модель",
+    "year": "Год",
+    "vehicleHint": "Передаются только марка, модель, год и двигатель, никогда номерной знак, VIN, клиент или название вашей мастерской.",
+    "vehicleHintMarine": "Передаются только марка, модель, год и двигатель, никогда регистрационный номер, номер корпуса, клиент или название вашей мастерской.",
+    "send": "Поделиться решением",
+    "sent": "Спасибо. Ваше решение ждёт проверки.",
+    "shareAnother": "Поделиться ещё одним решением",
+    "cancel": "Отмена",
+    "sentOn": "Отправлено {date}",
+    "reason": "Причина: {reason}",
+    "withdraw": "Отозвать",
+    "withdrawTitle": "Отозвать это решение?",
+    "withdrawConfirm": "Оно будет удалено вместе с фото, и другие мастерские его не увидят.",
+    "withdrawn": "Решение отозвано",
+    "withdrawFailed": "Сейчас не удалось отозвать решение. Попробуйте ещё раз через минуту.",
+    "unavailable": "Делиться решениями сейчас невозможно.",
+    "status": {
+      "pending": "Ждёт проверки",
+      "published": "Опубликовано",
+      "rejected": "Не опубликовано"
+    },
+    "errors": {
+      "failed": "Сейчас не удалось отправить ваше решение. Попробуйте ещё раз через минуту.",
+      "photo": "Не удалось прочитать одно из фото. Попробуйте другое.",
+      "too_large": "Фото слишком большие. Попробуйте меньше фото или фото меньшего размера.",
+      "paused": "Обмен решениями сейчас приостановлен. Попробуйте позже.",
+      "demo": "В демоверсии обмен решениями отключён."
+    },
+    "invite": "Устранили {code}? Расскажите другим мастерским, что помогло.",
+    "inviteHint": "Своими словами и, если есть, с несколькими фото. Сотрудник Torqvoice проверяет каждое решение, прежде чем его увидят другие мастерские.",
+    "start": "Поделиться решением",
+    "dialogTitle": "Поделиться решением для {code}",
+    "dialogDescription": "Другие мастерские увидят его после проверки сотрудником Torqvoice.",
+    "characters": "{count} / {max}",
+    "writingHint": "Что было неисправно, как вы это нашли и что помогло. Номера деталей и результаты измерений помогают другим больше всего.",
+    "dropHere": "или перетащите их сюда",
+    "tooManyPhotosRemove": "У решения может быть не больше {max} фото. Удалите лишние, чтобы поделиться им.",
+    "phone": {
+      "button": "Добавить с телефона",
+      "hide": "Скрыть код для телефона",
+      "title": "Сделайте фото телефоном",
+      "body": "Отсканируйте код камерой телефона. Сделанные фото появятся здесь, как только поступят.",
+      "failed": "Не удалось создать код для телефона. Закройте окно и попробуйте снова.",
+      "expires": "Код действует до {time}",
+      "copy": "Копировать ссылку",
+      "copied": "Скопировано",
+      "received": "{count, plural, one {# фото} few {# фото} many {# фото} other {# фото}} с телефона",
+      "badge": "Телефон"
+    }
+  },
+  "fixes": {
+    "title": "Решения других мастерских",
+    "empty": "Другие мастерские пока не поделились решением для {code}.",
+    "unavailable": "Решения других мастерских сейчас недоступны.",
+    "sharedOn": "Добавлено {date}",
+    "unknownVehicle": "Автомобиль не указан",
+    "unknownVehicleMarine": "Судно не указано",
+    "writtenIn": "Язык: {language}",
+    "photo": "Фото {n} из {count}",
+    "openPhoto": "Открыть фото {n}"
+  },
+  "sheet": {
+    "description": "Значение, вероятные причины и работы вашей мастерской с этим кодом.",
+    "loadFailed": "Не удалось открыть этот код.",
+    "loading": "Загрузка…",
+    "openPage": "Открыть страницу кода"
+  },
+  "entry": {
+    "button": "Добавить коды неисправностей",
+    "title": "Добавить коды неисправностей",
+    "description": "Введите или вставьте коды со сканера. Каждый станет наблюдением в этой работе, со сохранённым значением.",
+    "placeholder": "P0420, P0171",
+    "empty": "Здесь появятся введённые или вставленные коды.",
+    "found": "Найденные коды",
+    "remove": "Убрать {code}",
+    "concern": "Относится к пункту",
+    "concernNone": "Без пункта",
+    "cancel": "Отмена",
+    "add": "{count, plural, =0 {Добавить коды} one {Добавить # код} few {Добавить # кода} many {Добавить # кодов} other {Добавить # кода}}",
+    "added": "{count, plural, one {Добавлен # код неисправности} few {Добавлено # кода неисправности} many {Добавлено # кодов неисправностей} other {Добавлено # кода неисправности}}",
+    "skipped": "{count, plural, one {# код уже был в этой работе} few {# кода уже были в этой работе} many {# кодов уже были в этой работе} other {# кода уже были в этой работе}}",
+    "failed": "Не удалось добавить коды"
+  },
+  "finding": {
+    "seenBefore": "Был {date}",
+    "seenBeforeTitle": "Уже считывался на этом автомобиле, последний раз {date}",
+    "seenBeforeTitleMarine": "Уже считывался на этом судне, последний раз {date}",
+    "updatedMeaning": "Новое значение",
+    "updatedMeaningTitle": "Теперь значение такое: {meaning}. Нажмите, чтобы применить.",
+    "taken": "Значение обновлено",
+    "takeFailed": "Не удалось обновить значение",
+    "noMeaningSaved": "Значение ещё не сохранено"
+  },
+  "page": {
+    "title": "Коды неисправностей",
+    "intro": "Найдите код или ищите в значениях слова, например бедная смесь банк 1.",
+    "lookupLabel": "Код или слова",
+    "lookupPlaceholder": "P0420 или бедная смесь банк 1",
+    "lookup": "Найти",
+    "cloudOnly": "Значения кодов неисправностей приходят из Torqvoice Codes, части Torqvoice Cloud. Коды, считанные вашей мастерской, видны здесь в любом случае.",
+    "results": "Коды по запросу «{query}»",
+    "noResults": "Ни один код не подходит под «{query}»",
+    "recent": "Недавно считанные в вашей мастерской",
+    "noRecent": "Ни в одной работе ещё нет кодов неисправностей.",
+    "readTimes": "{count, plural, one {Считан # раз, последний {date}} few {Считан # раза, последний {date}} many {Считан # раз, последний {date}} other {Считан # раза, последний {date}}}",
+    "notACode": "«{text}» не является кодом неисправности",
+    "notACodeHint": "Код неисправности состоит из буквы и четырёх символов, например P0420 или U0100.",
+    "recentLookups": "Ваши недавние запросы",
+    "recentLookupsEmpty": "Здесь, на этом устройстве, появляются открытые вами коды и слова, которые вы искали.",
+    "clearRecent": "Очистить",
+    "tryExamples": "Попробуйте",
+    "exampleWords": "бедная смесь банк 1",
+    "searching": "Поиск"
+  },
+  "search": {
+    "group": "Коды неисправностей",
+    "jobsWith": "Работы с {code}",
+    "searchFor": "Искать «{query}» в кодах неисправностей"
+  },
+  "notice": {
+    "title": "Коды неисправностей появились недавно",
+    "body": "Мы постоянно добавляем коды, поэтому здесь пока есть не все. Сейчас доступны стандартные коды OBD-II; коды конкретных производителей пока не включены."
+  },
+  "support": {
+    "button": "Связаться с поддержкой",
+    "subject": "Код неисправности {code}",
+    "subjectGeneral": "Коды неисправностей",
+    "message": "Код неисправности {code}: {title}\n\n"
+  },
+  "systems": {
+    "P": "Силовой агрегат",
+    "B": "Кузов",
+    "C": "Шасси",
+    "U": "Сеть"
+  },
+  "phone": {
+    "title": "Фото для решения",
+    "forCode": "для решения, которое вы пишете на компьютере",
+    "intro": "Сделайте или выберите до {max} фото того, что устранило неисправность. Они появятся в решении на компьютере.",
+    "take": "Сделать фото",
+    "choose": "Выбрать фото",
+    "added": "Добавлено {count} из {max} фото",
+    "full": "У решения может быть не больше {max} фото.",
+    "notAPhoto": "Этот файл не является фото.",
+    "failed": "Не удалось добавить фото. Попробуйте снова.",
+    "done": "Когда закончите, вернитесь к компьютеру и поделитесь решением.",
+    "privacy": "Не снимайте лица, номерные знаки и данные клиентов.",
+    "expired": "Срок действия кода истёк. Покажите новый на компьютере.",
+    "invalid": "Этот код не работает. Покажите новый на компьютере."
+  }
+}

+ 13 - 0
messages/ru/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Доступно",
     "availableTitle": "Доступно",
     "availableDescription": "Сервисы, которые можно добавить. Каждый настраивается один раз и работает в фоне.",
     "availableDescription": "Сервисы, которые можно добавить. Каждый настраивается один раз и работает в фоне.",
     "allConnected": "Все доступные сервисы подключены.",
     "allConnected": "Все доступные сервисы подключены.",
+    "cloudOnly": "Только в облаке",
+    "cloudOnlyHint": "Доступно в Torqvoice Cloud",
     "searchPlaceholder": "Поиск интеграций",
     "searchPlaceholder": "Поиск интеграций",
     "noMatches": "Ничего не найдено по запросу «{query}».",
     "noMatches": "Ничего не найдено по запросу «{query}».",
     "readMore": "Подробнее",
     "readMore": "Подробнее",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "Нормы времени в заказ-нарядах и сметах",
       "labor": "Нормы времени в заказ-нарядах и сметах",
       "fluids": "Типы и объёмы жидкостей"
       "fluids": "Типы и объёмы жидкостей"
+    },
+    "diagnostics": {
+      "codes": "Значения кодов неисправностей"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Хранить ежемесячные копии",
         "keepMonthly": "Хранить ежемесячные копии",
         "keepMonthlyHelp": "После каждого запуска старые копии удаляются, а самые новые копии каждого вида остаются. Укажите 0, чтобы не хранить копии этого вида."
         "keepMonthlyHelp": "После каждого запуска старые копии удаляются, а самые новые копии каждого вида остаются. Укажите 0, чтобы не хранить копии этого вида."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "Узнайте, что означает код неисправности на автомобиле перед вами, на вашем языке: значение, вероятные причины и связанные коды. Введите или вставьте коды в заказ-наряд, и они станут наблюдениями с сохранённым значением, так что печать и история никогда не ждут соединения. Работает на стороне Torqvoice и входит во все облачные тарифы. Запрос передаёт только марку, модель, год и двигатель, но никогда VIN, госномер или клиента.",
+      "fields": {
+        "apiToken": "Ключ доступа",
+        "apiTokenHelp": "Ключ, который Torqvoice выдал вам для сервиса кодов."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/ru/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Комплект шин",
     "tireSetDetails": "Комплект шин",
     "tireForecast": "Следующий сезон",
     "tireForecast": "Следующий сезон",
     "whatsapp": "WhatsApp",
     "whatsapp": "WhatsApp",
-    "inspectionReminders": "Напоминания о техосмотре"
+    "inspectionReminders": "Напоминания о техосмотре",
+    "faultCodes": "Коды неисправностей",
+    "faultCode": "Код неисправности"
   },
   },
   "search": "Поиск...",
   "search": "Поиск...",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} открытых заказ-нарядов",
       "workOrders": "{count} открытых заказ-нарядов",
       "inspections": "{count} проверок в работе",
       "inspections": "{count} проверок в работе",
       "reminders": "{count} напоминаний просрочено"
       "reminders": "{count} напоминаний просрочено"
-    }
+    },
+    "faultCodes": "Коды неисправностей"
   }
   }
 }
 }

+ 249 - 0
messages/tr/faultCodes.json

@@ -0,0 +1,249 @@
+{
+  "title": "Arıza kodları",
+  "notAvailable": "Şu anda kullanılamıyor",
+  "noMeaning": "Henüz kod listemizde yok",
+  "manufacturerUnknown": "Üreticiye özel kod: anlamı markaya göre değişir.",
+  "chip": {
+    "open": "{code} kodunu aç"
+  },
+  "source": {
+    "generic": "Genel",
+    "manufacturer": "Üretici",
+    "report": "Tarama raporundan",
+    "machineTranslated": "Makine çevirisi"
+  },
+  "status": {
+    "stored": "Kayıtlı",
+    "pending": "Beklemede",
+    "permanent": "Kalıcı",
+    "history": "Geçmiş"
+  },
+  "flags": {
+    "warningLight": "Uyarı lambası",
+    "emissions": "Emisyon",
+    "driveCycle": "Sürüş döngüsü gerekir",
+    "limpMode": "Acil durum modu"
+  },
+  "sections": {
+    "how_it_works": "Nasıl çalışır",
+    "meaning": "Kodun anlamı",
+    "causes": "Tipik nedenler",
+    "manufacturer": "Üretici bilgileri",
+    "related": "Diğer kodlarla birlikte",
+    "diagnosis": "Teşhis ipucu",
+    "other": "Bilmekte yarar var"
+  },
+  "detail": {
+    "whatItMeans": "Ne anlama geliyor",
+    "likelyCauses": "Olası nedenler",
+    "relatedCodes": "İlgili kodlar",
+    "inYourWorkshop": "Atölyenizde",
+    "noJobs": "Atölyenizde henüz hiçbir işte okunmadı.",
+    "resolved": "Çözüldü",
+    "notConnected": "Kodların ne anlama geldiğini görmek için Torqvoice Codes bağlantısını kurun.",
+    "connect": "Bağla",
+    "serviceDown": "Anlam şu anda kullanılamıyor. İşleriniz aşağıda.",
+    "pendingCheck": "Metin kontrol edilene kadar yalnızca başlık gösterilir.",
+    "showMore": "Daha fazla göster",
+    "showLess": "Daha az göster",
+    "likelihood": {
+      "high": "Yüksek",
+      "medium": "Orta",
+      "low": "Düşük"
+    },
+    "mostLikelyFirst": "En olası olanlar önce"
+  },
+  "report": {
+    "button": "Sorun bildir",
+    "title": "{code} kodunun anlamında yanlış olan ne?",
+    "reasons": {
+      "wrong_for_vehicle": "Bu araç için yanlış",
+      "wrong_for_vehicleMarine": "Bu tekne için yanlış",
+      "text_mismatch": "Metin koda uymuyor",
+      "wrong_position": "Yanlış taraf veya konum",
+      "bad_translation": "Kötü çeviri",
+      "other": "Diğer"
+    },
+    "note": "Not (isteğe bağlı)",
+    "notePlaceholder": "Ne buldunuz",
+    "send": "Gönder",
+    "thanks": "Teşekkürler, kontrol edeceğiz",
+    "failed": "Bildirim şu anda gönderilemedi. Birazdan tekrar deneyin.",
+    "reportedByYou": "Sizin tarafınızdan bildirildi",
+    "outcome": {
+      "fixed": "Bildiriminizden sonra güncellendi",
+      "no_change": "Kontrol edildi, değişiklik yok",
+      "duplicate": "Zaten bildirildi"
+    }
+  },
+  "share": {
+    "title": "Çözümünüzü paylaşın",
+    "description": "Diğer atölyelere {code} kodunu neyin çözdüğünü anlatın. Her çözümü, başkaları görmeden önce Torqvoice'tan bir kişi kontrol eder.",
+    "yourFixes": "{code} için çözümleriniz",
+    "whatFixedIt": "Sorunu ne çözdü",
+    "placeholder": "Şanzımanın arkasındaki arka oksijen sensörü soketinde sürtünmüş kablo. Onarıldı, test sürüşünden sonra kod silindi.",
+    "placeholderMarine": "Trim sensörü soketinde paslanmış pinler. Temizlendi ve yalıtıldı, deniz testinden sonra kod silindi.",
+    "photos": "Fotoğraflar (isteğe bağlı)",
+    "photosHint": "En fazla {max} fotoğraf. Yüzleri, plakaları ve müşteri bilgilerini kadrajın dışında tutun.",
+    "photosHintMarine": "En fazla {max} fotoğraf. Yüzleri, tekne sicil numaralarını ve müşteri bilgilerini kadrajın dışında tutun.",
+    "addPhotos": "Fotoğraf ekle",
+    "removePhoto": "{n}. fotoğrafı kaldır",
+    "tooManyPhotos": "En fazla {max} fotoğraf",
+    "notAPhoto": "{name} bir fotoğraf değil",
+    "photoTooBig": "{name} 15 MB'tan büyük",
+    "vehicle": "Araç",
+    "vehicleMarine": "Tekne",
+    "vehicleOptional": "Araç (isteğe bağlı)",
+    "vehicleOptionalMarine": "Tekne (isteğe bağlı)",
+    "make": "Marka",
+    "model": "Model",
+    "year": "Yıl",
+    "vehicleHint": "Yalnızca marka, model, yıl ve motor paylaşılır; plaka, şasi numarası (VIN), müşteri veya atölyenizin adı asla paylaşılmaz.",
+    "vehicleHintMarine": "Yalnızca marka, model, yıl ve motor paylaşılır; sicil numarası, gövde numarası, müşteri veya atölyenizin adı asla paylaşılmaz.",
+    "send": "Çözümü paylaş",
+    "sent": "Teşekkürler. Çözümünüz inceleme bekliyor.",
+    "shareAnother": "Başka bir çözüm paylaş",
+    "cancel": "İptal",
+    "sentOn": "Gönderildi: {date}",
+    "reason": "Neden: {reason}",
+    "withdraw": "Geri çek",
+    "withdrawTitle": "Bu çözüm geri çekilsin mi?",
+    "withdrawConfirm": "Fotoğraflarıyla birlikte silinir ve başka hiçbir atölye onu görmez.",
+    "withdrawn": "Çözüm geri çekildi",
+    "withdrawFailed": "Çözüm şu anda geri çekilemedi. Birazdan tekrar deneyin.",
+    "unavailable": "Çözüm paylaşımı şu anda kullanılamıyor.",
+    "status": {
+      "pending": "İnceleme bekliyor",
+      "published": "Yayınlandı",
+      "rejected": "Yayınlanmadı"
+    },
+    "errors": {
+      "failed": "Çözümünüz şu anda gönderilemedi. Birazdan tekrar deneyin.",
+      "photo": "Fotoğraflardan biri okunamadı. Başka bir fotoğraf deneyin.",
+      "too_large": "Fotoğraflar çok büyük. Daha az veya daha küçük fotoğraf deneyin.",
+      "paused": "Çözüm paylaşımı şu anda duraklatıldı. Daha sonra tekrar deneyin.",
+      "demo": "Çözüm paylaşımı demoda kapalı."
+    },
+    "invite": "{code} sorununu çözdünüz mü? Diğer servislere neyin işe yaradığını anlatın.",
+    "inviteHint": "Kendi sözlerinizle, varsa birkaç fotoğrafla. Torqvoice’ta bir kişi, diğer servisler görmeden önce her çözümü kontrol eder.",
+    "start": "Çözümünüzü paylaşın",
+    "dialogTitle": "{code} için çözümünüzü paylaşın",
+    "dialogDescription": "Diğer servisler, Torqvoice’ta bir kişi kontrol ettikten sonra görür.",
+    "characters": "{count} / {max}",
+    "writingHint": "Sorun neydi, nasıl buldunuz ve ne çözdü? Parça numaraları ve ölçüm değerleri başkalarına en çok yardımcı olur.",
+    "dropHere": "ya da buraya bırakın",
+    "tooManyPhotosRemove": "Bir çözümde en fazla {max} fotoğraf olabilir. Paylaşmak için bazılarını kaldırın.",
+    "phone": {
+      "button": "Telefondan ekle",
+      "hide": "Telefon kodunu gizle",
+      "title": "Telefonunuzla fotoğraf çekin",
+      "body": "Kodu telefonunuzun kamerasıyla tarayın. Çektiğiniz fotoğraflar geldikçe burada görünür.",
+      "failed": "Telefon kodu oluşturulamadı. Bunu kapatıp tekrar deneyin.",
+      "expires": "Kod {time} saatine kadar geçerli",
+      "copy": "Bağlantıyı kopyala",
+      "copied": "Kopyalandı",
+      "received": "{count, plural, one {# fotoğraf} other {# fotoğraf}} telefondan",
+      "badge": "Telefon"
+    }
+  },
+  "fixes": {
+    "title": "Diğer atölyelerin çözümleri",
+    "empty": "Henüz başka hiçbir atölye {code} için bir çözüm paylaşmadı.",
+    "unavailable": "Diğer atölyelerin çözümleri şu anda kullanılamıyor.",
+    "sharedOn": "Paylaşıldı: {date}",
+    "unknownVehicle": "Araç belirtilmemiş",
+    "unknownVehicleMarine": "Tekne belirtilmemiş",
+    "writtenIn": "Dil: {language}",
+    "photo": "Fotoğraf {n} / {count}",
+    "openPhoto": "{n}. fotoğrafı aç"
+  },
+  "sheet": {
+    "description": "Bu kodun anlamı, olası nedenleri ve atölyenizde bu kodun okunduğu işler.",
+    "loadFailed": "Bu kod açılamadı.",
+    "loading": "Yükleniyor…",
+    "openPage": "Kod sayfasını aç"
+  },
+  "entry": {
+    "button": "Arıza kodu ekle",
+    "title": "Arıza kodu ekle",
+    "description": "Arıza tespit cihazındaki kodları yazın veya yapıştırın. Her biri, anlamı kaydedilmiş olarak bu işte bir gözlem olur.",
+    "placeholder": "P0420, P0171",
+    "empty": "Yazdığınız veya yapıştırdığınız kodlar burada görünür.",
+    "found": "Bulunan kodlar",
+    "remove": "{code} kodunu kaldır",
+    "concern": "Yanıtladığı madde",
+    "concernNone": "Madde yok",
+    "cancel": "İptal",
+    "add": "{count, plural, =0 {Kod ekle} one {# kod ekle} other {# kod ekle}}",
+    "added": "{count, plural, one {# arıza kodu eklendi} other {# arıza kodu eklendi}}",
+    "skipped": "{count, plural, one {# kod bu işte zaten vardı} other {# kod bu işte zaten vardı}}",
+    "failed": "Kodlar eklenemedi"
+  },
+  "finding": {
+    "seenBefore": "Görüldü: {date}",
+    "seenBeforeTitle": "Bu araçta daha önce okundu, son olarak {date}",
+    "seenBeforeTitleMarine": "Bu teknede daha önce okundu, son olarak {date}",
+    "updatedMeaning": "Güncel anlam",
+    "updatedMeaningTitle": "Anlam artık şöyle: {meaning}. Kullanmak için dokunun.",
+    "taken": "Anlam güncellendi",
+    "takeFailed": "Anlam güncellenemedi",
+    "noMeaningSaved": "Henüz kayıtlı anlam yok"
+  },
+  "page": {
+    "title": "Arıza kodları",
+    "intro": "Bir kod arayın veya anlamlarda fakir bank 1 gibi kelimeler arayın.",
+    "lookupLabel": "Kod veya kelimeler",
+    "lookupPlaceholder": "P0420 veya fakir bank 1",
+    "lookup": "Ara",
+    "cloudOnly": "Arıza kodlarının anlamları, Torqvoice Cloud'un bir parçası olan Torqvoice Codes'tan gelir. Atölyenizin okuduğu kodlar her durumda burada listelenir.",
+    "results": "\"{query}\" ile eşleşen kodlar",
+    "noResults": "\"{query}\" ile eşleşen kod yok",
+    "recent": "Atölyenizde son okunanlar",
+    "noRecent": "Henüz hiçbir işte arıza kodu yok.",
+    "readTimes": "{count, plural, one {Bir kez okundu, {date}} other {# kez okundu, son {date}}}",
+    "notACode": "\"{text}\" bir arıza kodu değil",
+    "notACodeHint": "Arıza kodu bir harf ve dört karakterden oluşur, örneğin P0420 veya U0100.",
+    "recentLookups": "Son aramalarınız",
+    "recentLookupsEmpty": "Açtığınız kodlar ve aradığınız kelimeler bu cihazda burada görünür.",
+    "clearRecent": "Temizle",
+    "tryExamples": "Deneyin",
+    "exampleWords": "fakir bank 1",
+    "searching": "Aranıyor"
+  },
+  "search": {
+    "group": "Arıza kodları",
+    "jobsWith": "{code} olan işler",
+    "searchFor": "Arıza kodlarında \"{query}\" ara"
+  },
+  "notice": {
+    "title": "Arıza kodları yeni",
+    "body": "Sürekli yeni kodlar ekliyoruz, bu yüzden henüz her kod burada değil. Şimdilik standart OBD-II kodları var; üreticiye özel kodlar henüz eklenmedi."
+  },
+  "support": {
+    "button": "Destekle iletişime geç",
+    "subject": "Arıza kodu {code}",
+    "subjectGeneral": "Arıza kodları",
+    "message": "Arıza kodu {code}: {title}\n\n"
+  },
+  "systems": {
+    "P": "Güç aktarma",
+    "B": "Gövde",
+    "C": "Şasi",
+    "U": "Ağ"
+  },
+  "phone": {
+    "title": "Bir çözüm için fotoğraflar",
+    "forCode": "bilgisayarda yazdığınız çözüm için",
+    "intro": "Kodu çözen şeyin en fazla {max} fotoğrafını çekin veya seçin. Bilgisayardaki çözümde görünürler.",
+    "take": "Fotoğraf çek",
+    "choose": "Fotoğraf seç",
+    "added": "{max} fotoğraftan {count} tanesi eklendi",
+    "full": "Bir çözümde en fazla {max} fotoğraf olabilir.",
+    "notAPhoto": "Bu dosya bir fotoğraf değil.",
+    "failed": "Fotoğraf eklenemedi. Tekrar deneyin.",
+    "done": "Bitirdiğinizde bilgisayara dönün ve çözümü paylaşın.",
+    "privacy": "Yüzleri, plakaları ve müşteri bilgilerini fotoğrafın dışında tutun.",
+    "expired": "Bu kodun süresi doldu. Bilgisayarda yeni bir kod gösterin.",
+    "invalid": "Bu kod çalışmıyor. Bilgisayarda yeni bir kod gösterin."
+  }
+}

+ 13 - 0
messages/tr/integrations.json

@@ -13,6 +13,8 @@
     "availableTitle": "Kullanılabilir",
     "availableTitle": "Kullanılabilir",
     "availableDescription": "Ekleyebileceğiniz hizmetler. Her biri bir kez kurulur ve arka planda çalışır.",
     "availableDescription": "Ekleyebileceğiniz hizmetler. Her biri bir kez kurulur ve arka planda çalışır.",
     "allConnected": "Kullanılabilir tüm hizmetler bağlı.",
     "allConnected": "Kullanılabilir tüm hizmetler bağlı.",
+    "cloudOnly": "Yalnızca bulut",
+    "cloudOnlyHint": "Torqvoice Cloud'da kullanılabilir",
     "searchPlaceholder": "Entegrasyonlarda ara",
     "searchPlaceholder": "Entegrasyonlarda ara",
     "noMatches": "\"{query}\" ile eşleşen bir şey yok.",
     "noMatches": "\"{query}\" ile eşleşen bir şey yok.",
     "readMore": "Daha fazla bilgi",
     "readMore": "Daha fazla bilgi",
@@ -78,6 +80,9 @@
     "repair": {
     "repair": {
       "labor": "İş emirlerinde ve tekliflerde işçilik süreleri",
       "labor": "İş emirlerinde ve tekliflerde işçilik süreleri",
       "fluids": "Sıvı türleri ve kapasiteleri"
       "fluids": "Sıvı türleri ve kapasiteleri"
+    },
+    "diagnostics": {
+      "codes": "Arıza kodu anlamları"
     }
     }
   },
   },
   "statuses": {
   "statuses": {
@@ -936,6 +941,14 @@
         "keepMonthly": "Aylık yedekleri sakla",
         "keepMonthly": "Aylık yedekleri sakla",
         "keepMonthlyHelp": "Her çalıştırmadan sonra eski yedekler silinir ve her türün en yenileri saklanır. O türden hiç saklamamak için sayıyı 0 yapın."
         "keepMonthlyHelp": "Her çalıştırmadan sonra eski yedekler silinir ve her türün en yenileri saklanır. O türden hiç saklamamak için sayıyı 0 yapın."
       }
       }
+    },
+    "torqvoice-codes": {
+      "description": "Önünüzdeki araçta bir arıza kodunun ne anlama geldiğini kendi dilinizde görün: anlamı, olası nedenleri ve ilgili kodlar. Kodları bir iş emrine yazın veya yapıştırın; anlamı kaydedilmiş gözlemlere dönüşürler, böylece çıktılar ve geçmiş hiçbir zaman bir bağlantıyı beklemez. Torqvoice tarafından işletilir ve tüm bulut planlarına dahildir. Bir sorgu yalnızca marka, model, yıl ve motoru gönderir; asla VIN, plaka veya müşteri bilgisi göndermez.",
+      "fields": {
+        "apiToken": "Erişim anahtarı",
+        "apiTokenHelp": "Torqvoice'un kod hizmeti için size verdiği anahtar."
+      },
+      "settings": {}
     }
     }
   },
   },
   "jobKinds": {
   "jobKinds": {

+ 5 - 2
messages/tr/navigation.json

@@ -65,7 +65,9 @@
     "tireSetDetails": "Lastik takımı",
     "tireSetDetails": "Lastik takımı",
     "tireForecast": "Gelecek sezon",
     "tireForecast": "Gelecek sezon",
     "whatsapp": "WhatsApp",
     "whatsapp": "WhatsApp",
-    "inspectionReminders": "Muayene hatırlatmaları"
+    "inspectionReminders": "Muayene hatırlatmaları",
+    "faultCodes": "Arıza kodları",
+    "faultCode": "Arıza kodu"
   },
   },
   "search": "Ara...",
   "search": "Ara...",
   "shortcut": "Ctrl+K",
   "shortcut": "Ctrl+K",
@@ -141,6 +143,7 @@
       "workOrders": "{count} açık iş emri",
       "workOrders": "{count} açık iş emri",
       "inspections": "{count} devam eden muayene",
       "inspections": "{count} devam eden muayene",
       "reminders": "{count} vadesi gelen hatırlatma"
       "reminders": "{count} vadesi gelen hatırlatma"
-    }
+    },
+    "faultCodes": "Arıza kodları"
   }
   }
 }
 }

+ 46 - 0
prisma/migrations/20261105113016_trouble_codes/migration.sql

@@ -0,0 +1,46 @@
+-- AlterTable
+ALTER TABLE "service_attachments" ADD COLUMN     "scan" JSONB;
+
+-- AlterTable
+ALTER TABLE "vehicle_findings" ADD COLUMN     "faultCode" TEXT,
+ADD COLUMN     "faultMeaning" TEXT,
+ADD COLUMN     "faultMeaningSource" TEXT,
+ADD COLUMN     "faultModule" TEXT,
+ADD COLUMN     "faultStatus" TEXT,
+ADD COLUMN     "sourceAttachmentId" TEXT;
+
+-- CreateTable
+CREATE TABLE "workshop_fault_codes" (
+    "id" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "code" TEXT NOT NULL,
+    "make" TEXT NOT NULL DEFAULT '',
+    "title" TEXT NOT NULL DEFAULT '',
+    "notes" TEXT,
+    "updatedById" TEXT,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "workshop_fault_codes_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "workshop_fault_codes_organizationId_idx" ON "workshop_fault_codes"("organizationId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "workshop_fault_codes_organizationId_code_make_key" ON "workshop_fault_codes"("organizationId", "code", "make");
+
+-- CreateIndex
+CREATE INDEX "vehicle_findings_vehicleId_faultCode_idx" ON "vehicle_findings"("vehicleId", "faultCode");
+
+-- CreateIndex
+CREATE INDEX "vehicle_findings_sourceAttachmentId_idx" ON "vehicle_findings"("sourceAttachmentId");
+
+-- AddForeignKey
+ALTER TABLE "vehicle_findings" ADD CONSTRAINT "vehicle_findings_sourceAttachmentId_fkey" FOREIGN KEY ("sourceAttachmentId") REFERENCES "service_attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "workshop_fault_codes" ADD CONSTRAINT "workshop_fault_codes_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "workshop_fault_codes" ADD CONSTRAINT "workshop_fault_codes_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

+ 14 - 0
prisma/migrations/20261105183723_trouble_codes_keys/migration.sql

@@ -0,0 +1,14 @@
+/*
+  Warnings:
+
+  - You are about to drop the `workshop_fault_codes` table. If the table is not empty, all the data it contains will be lost.
+
+*/
+-- DropForeignKey
+ALTER TABLE "workshop_fault_codes" DROP CONSTRAINT "workshop_fault_codes_organizationId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "workshop_fault_codes" DROP CONSTRAINT "workshop_fault_codes_updatedById_fkey";
+
+-- DropTable
+DROP TABLE "workshop_fault_codes";

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

@@ -288,6 +288,14 @@ model ServiceAttachment {
   concernId String?
   concernId String?
   concern   ServiceConcern? @relation(fields: [concernId], references: [id], onDelete: SetNull)
   concern   ServiceConcern? @relation(fields: [concernId], references: [id], onDelete: SetNull)
 
 
+  /// What a scan for fault codes found in this file, when one ran: the codes
+  /// with their status and module, the VIN and odometer the report printed,
+  /// how it was read (text or vision), when, and whether it was dismissed.
+  scan Json?
+
+  /// Findings whose fault code was read from this file.
+  faultFindings VehicleFinding[]
+
   @@index([serviceRecordId])
   @@index([serviceRecordId])
   @@index([concernId])
   @@index([concernId])
   @@map("service_attachments")
   @@map("service_attachments")

+ 20 - 0
prisma/schema/vehicles.prisma

@@ -167,9 +167,29 @@ model VehicleFinding {
   concernId String?
   concernId String?
   concern   ServiceConcern? @relation(fields: [concernId], references: [id], onDelete: SetNull)
   concern   ServiceConcern? @relation(fields: [concernId], references: [id], onDelete: SetNull)
 
 
+  /// A diagnostic trouble code, when the finding is one: normalised and
+  /// upper case, such as P0420 (`^[PCBU][0-3][0-9A-F]{3}$`).
+  faultCode          String?
+  /// stored, pending, permanent or history, when the scan tool said.
+  faultStatus        String?
+  /// The control unit the code was read from ("Engine", "ABS"), when known.
+  faultModule        String?
+  /// The meaning as it was when the code was added, in the workshop's
+  /// language. Kept on the finding so prints, history and search never wait
+  /// on the code service and survive a disconnect.
+  faultMeaning       String?
+  /// Where that meaning came from: generic, manufacturer or report.
+  faultMeaningSource String?
+  /// The diagnostic report the code was read from. SetNull: the finding is
+  /// a fact about the vehicle and outlives the file.
+  sourceAttachmentId String?
+  sourceAttachment   ServiceAttachment? @relation(fields: [sourceAttachmentId], references: [id], onDelete: SetNull)
+
   @@index([vehicleId])
   @@index([vehicleId])
   @@index([serviceRecordId])
   @@index([serviceRecordId])
   @@index([concernId])
   @@index([concernId])
+  @@index([vehicleId, faultCode])
+  @@index([sourceAttachmentId])
   @@map("vehicle_findings")
   @@map("vehicle_findings")
 }
 }
 
 

+ 1 - 0
public/images/integrations/torqvoice-codes.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="Torqvoice Codes"><title>Torqvoice Codes</title><rect width="48" height="48" rx="11" fill="#1e293b"/><path d="M15.5 22.5a8.5 8.5 0 0 1 17 0v2.2l2.2 3.3a1 1 0 0 1-.83 1.55H14.13a1 1 0 0 1-.83-1.55l2.2-3.3z" fill="none" stroke="#f8fafc" stroke-width="2.2" stroke-linejoin="round"/><path d="M20 33.5h8" stroke="#f8fafc" stroke-width="2.2" stroke-linecap="round"/><path d="M21 19.5h6M24 16.5v6" stroke="#f59e0b" stroke-width="2.2" stroke-linecap="round"/></svg>

+ 38 - 0
src/__tests__/components/integrations-catalog.test.tsx

@@ -15,6 +15,7 @@ import { IntegrationsCatalog } from '@/app/(authenticated)/settings/integrations
 import type { CatalogEntry } from '@/features/integrations/Actions/integrationActions'
 import type { CatalogEntry } from '@/features/integrations/Actions/integrationActions'
 import { openSupport } from '@/features/support/Lib/supportVisibility'
 import { openSupport } from '@/features/support/Lib/supportVisibility'
 import { getManifest } from '@/integrations/registry'
 import { getManifest } from '@/integrations/registry'
+import { manifest as torqvoiceCodes } from '@/integrations/torqvoice-codes/manifest'
 
 
 vi.mock('@/features/support/Lib/supportVisibility', () => ({ openSupport: vi.fn() }))
 vi.mock('@/features/support/Lib/supportVisibility', () => ({ openSupport: vi.fn() }))
 
 
@@ -29,6 +30,7 @@ function entry(id: string, overrides: Partial<CatalogEntry> = {}): CatalogEntry
     platformApp: true,
     platformApp: true,
     featured: true,
     featured: true,
     enabled: true,
     enabled: true,
+    cloudOnly: false,
     ...overrides,
     ...overrides,
   }
   }
 }
 }
@@ -162,3 +164,39 @@ describe('suggesting an integration', () => {
     expect(link).toHaveAttribute('target', '_blank')
     expect(link).toHaveAttribute('target', '_blank')
   })
   })
 })
 })
+
+/**
+ * A service Torqvoice runs on its cloud is not in a self-hosted install's
+ * registry, but the catalog still shows it, greyed, so the workshop knows it
+ * exists and where. There is nothing behind the card to open.
+ */
+describe('a cloud-only service on a self-hosted install', () => {
+  const codes: CatalogEntry = {
+    manifest: torqvoiceCodes,
+    status: null,
+    externalAccountName: null,
+    lastError: null,
+    platformApp: false,
+    featured: false,
+    enabled: false,
+    cloudOnly: true,
+  }
+
+  it('is shown greyed out, saying where it is offered, with nothing to open', () => {
+    render(<IntegrationsCatalog entries={[...ENTRIES, codes]} canSuggest={false} />)
+    expect(screen.getByText('Torqvoice Codes')).toBeInTheDocument()
+    expect(screen.getByText('Cloud only')).toBeInTheDocument()
+    expect(screen.getByText('Available on Torqvoice Cloud')).toBeInTheDocument()
+    const hrefs = screen.queryAllByRole('link').map((l) => l.getAttribute('href'))
+    expect(hrefs).not.toContain('/settings/integrations/torqvoice-codes')
+    // The other cards still offer Connect.
+    expect(offered()).toEqual(['OpenAI', 'Google Calendar', 'Twilio'])
+  })
+
+  it('is found by search like any other card', () => {
+    render(<IntegrationsCatalog entries={[...ENTRIES, codes]} canSuggest={false} />)
+    fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'fault code' } })
+    expect(screen.getByText('Torqvoice Codes')).toBeInTheDocument()
+    expect(offered()).toEqual([])
+  })
+})

+ 132 - 0
src/__tests__/features/fault-codes/codes.test.ts

@@ -0,0 +1,132 @@
+import { describe, expect, it } from 'vitest'
+import {
+  extractFaultCodes,
+  faultLine,
+  findingLine,
+  isManufacturerCode,
+  looksLikeFaultCode,
+  normalizeFaultCode,
+} from '@/features/fault-codes/Lib/codes'
+import { visiblePageCommands } from '@/features/search/Lib/commands'
+
+/**
+ * What a technician types or pastes from a scan tool has to come out as the
+ * codes they meant, once each, with the status the tool printed beside them,
+ * and nothing that only looks like a code.
+ */
+describe('reading codes out of pasted text', () => {
+  it('turns "p0420, P0171 stored" into two codes, the second stored', () => {
+    expect(extractFaultCodes('p0420, P0171 stored')).toEqual([
+      { code: 'P0420' },
+      { code: 'P0171', status: 'stored' },
+    ])
+  })
+
+  it('reads a scan tool export line by line with its statuses', () => {
+    const report = [
+      'Engine control module',
+      'P0300  Random/Multiple Cylinder Misfire Detected   Confirmed',
+      'P0171  System Too Lean (Bank 1)                    Pending',
+      'U0100:87 Lost Communication With ECM/PCM "A"      History',
+      'B1A2F-00 Driver seat belt                          Permanent',
+    ].join('\n')
+    expect(extractFaultCodes(report)).toEqual([
+      { code: 'P0300', status: 'stored' },
+      { code: 'P0171', status: 'pending' },
+      { code: 'U0100', status: 'history' },
+      { code: 'B1A2F', status: 'permanent' },
+    ])
+  })
+
+  it('lists a code once, keeping a status given the second time', () => {
+    expect(extractFaultCodes('P0420 and again P0420 pending')).toEqual([
+      { code: 'P0420', status: 'pending' },
+    ])
+  })
+
+  it('leaves out what is not a code: a VIN, a part number, a fifth digit, a P4 code', () => {
+    expect(extractFaultCodes('VIN WVWZZZ1KZP0420123, part 1K0P04201, P04201, P4000')).toEqual([])
+  })
+
+  it('does not read a status out of words a title uses', () => {
+    expect(extractFaultCodes('P0562 System voltage low current draw past limit')).toEqual([
+      { code: 'P0562' },
+    ])
+  })
+})
+
+describe('writing a code the one way it is stored', () => {
+  it('normalises case, spaces and the failure-type byte', () => {
+    expect(normalizeFaultCode(' p0420 ')).toBe('P0420')
+    expect(normalizeFaultCode('p 0420')).toBe('P0420')
+    expect(normalizeFaultCode('U0100:87')).toBe('U0100')
+    expect(normalizeFaultCode('P0420-00')).toBe('P0420')
+    expect(normalizeFaultCode('c1a2f')).toBe('C1A2F')
+  })
+
+  it('refuses anything that is not a code', () => {
+    for (const text of ['', 'P042', 'P04200', 'X0420', 'P4420', 'banana', null, undefined]) {
+      expect(normalizeFaultCode(text)).toBeNull()
+    }
+  })
+
+  it('tells manufacturer ranges from the standard ones', () => {
+    expect(isManufacturerCode('P1570')).toBe(true)
+    expect(isManufacturerCode('P3001')).toBe(true)
+    expect(isManufacturerCode('B1A2F')).toBe(true)
+    expect(isManufacturerCode('U2100')).toBe(true)
+    expect(isManufacturerCode('P0420')).toBe(false)
+    expect(isManufacturerCode('P2187')).toBe(false)
+    expect(isManufacturerCode('P3400')).toBe(false)
+    expect(isManufacturerCode('U0100')).toBe(false)
+    expect(isManufacturerCode('B3000')).toBe(false)
+  })
+})
+
+describe('the search palette', () => {
+  it('treats a code or the start of one as a code, and words as words', () => {
+    for (const q of ['p0420', 'P042', 'u01', ' P0 ']) expect(looksLikeFaultCode(q), q).toBe(true)
+    for (const q of ['p', 'lean bank 1', 'Peugeot', 'P4', 'P04201', 'AB12345']) {
+      expect(looksLikeFaultCode(q), q).toBe(false)
+    }
+  })
+
+  it('offers the fault code page only once the code service is connected', () => {
+    const ids = (enabled: boolean) =>
+      visiblePageCommands({ faultCodesEnabled: enabled }).map((p) => p.id)
+    expect(ids(true)).toContain('page:fault-codes')
+    expect(ids(false)).not.toContain('page:fault-codes')
+  })
+})
+
+describe('how a code finding reads on a print', () => {
+  it('prints the code and the meaning saved with it', () => {
+    expect(faultLine('P0420', 'Catalyst efficiency below threshold')).toBe(
+      'P0420 Catalyst efficiency below threshold'
+    )
+    expect(
+      findingLine({
+        description: 'P0420 Catalyst efficiency below threshold',
+        faultCode: 'P0420',
+        faultMeaning: 'Catalyst efficiency below threshold',
+      })
+    ).toBe('P0420 Catalyst efficiency below threshold')
+  })
+
+  it('prints the code alone when no meaning was saved, and own words with the code in front', () => {
+    expect(findingLine({ description: 'P0420', faultCode: 'P0420', faultMeaning: null })).toBe(
+      'P0420'
+    )
+    expect(
+      findingLine({
+        description: 'Rear O2 sensor plug chafed',
+        faultCode: 'P0420',
+        faultMeaning: 'x',
+      })
+    ).toBe('P0420 Rear O2 sensor plug chafed')
+  })
+
+  it('leaves a finding without a code as it was', () => {
+    expect(findingLine({ description: 'Worn front pads', faultCode: null })).toBe('Worn front pads')
+  })
+})

+ 261 - 0
src/__tests__/features/fault-codes/components.test.tsx

@@ -0,0 +1,261 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { FaultCodeView } from '@/features/fault-codes/Lib/types'
+
+/**
+ * The fault code screens as a technician reads them: codes typed into the
+ * work order turn into chips with their meaning, a service that is down
+ * says so without stopping anything, the code sheet's text comes in its
+ * labelled parts when the service cuts it so, and a finding with a code
+ * says when it was seen before and when its meaning has changed.
+ */
+
+const h = vi.hoisted(() => ({
+  lookup: vi.fn(),
+  add: vi.fn(),
+  refresh: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ push: vi.fn(), refresh: h.refresh }),
+}))
+vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }))
+vi.mock('@/features/fault-codes/Actions/lookupFaultCodes', () => ({ lookupFaultCodes: h.lookup }))
+vi.mock('@/features/fault-codes/Actions/addFaultCodeFindings', () => ({
+  addFaultCodeFindings: h.add,
+}))
+vi.mock('@/features/fault-codes/Actions/checkFindingMeanings', () => ({
+  checkFindingMeanings: vi.fn(async () => ({
+    success: true,
+    data: { available: true, findings: [] },
+  })),
+}))
+vi.mock('@/features/fault-codes/Actions/takeFindingMeaning', () => ({
+  takeFindingMeaning: vi.fn(async () => ({ success: true })),
+}))
+vi.mock('@/features/fault-codes/Actions/getFaultCodeDetail', () => ({
+  getFaultCodeDetail: vi.fn(async () => ({ success: false })),
+}))
+
+const { DescriptionSections } = await import(
+  '@/features/fault-codes/Components/DescriptionSections'
+)
+const { MeaningText } = await import('@/features/fault-codes/Components/FaultCodeChip')
+const { FaultCodeEntryDialog } = await import(
+  '@/features/fault-codes/Components/FaultCodeEntryDialog'
+)
+const { FaultCodeFindingLine, FaultCodesJobProvider } = await import(
+  '@/features/fault-codes/Components/FaultCodesOnJob'
+)
+
+function view(code: string, patch: Partial<FaultCodeView> = {}): FaultCodeView {
+  return {
+    code,
+    state: 'found',
+    range: 'standard',
+    title: null,
+    source: 'generic',
+    make: null,
+    lang: 'en',
+    machineTranslated: false,
+    verified: true,
+    version: 1,
+    flags: null,
+    ...patch,
+  }
+}
+
+beforeEach(() => {
+  h.lookup.mockReset()
+  h.add.mockReset()
+  h.refresh.mockReset()
+})
+
+describe('typing codes into a work order', () => {
+  function open() {
+    return render(
+      <FaultCodeEntryDialog
+        open
+        onOpenChange={vi.fn()}
+        serviceRecordId="job1"
+        vehicleId="veh1"
+        concerns={[]}
+      />
+    )
+  }
+
+  it('turns pasted text into code chips with their meaning, and adds them to the job', async () => {
+    h.lookup.mockResolvedValueOnce({
+      success: true,
+      data: {
+        available: true,
+        results: [
+          view('P0420', { title: 'Catalyst efficiency below threshold (bank 1)' }),
+          view('P0171', { title: 'System too lean (bank 1)' }),
+        ],
+      },
+    })
+    h.add.mockResolvedValueOnce({ success: true, data: { added: 2, skipped: 0, findings: [] } })
+    open()
+
+    fireEvent.change(screen.getByRole('textbox'), { target: { value: 'p0420, P0171 stored' } })
+    expect(screen.getByText('P0420')).toBeInTheDocument()
+    expect(screen.getByText('P0171')).toBeInTheDocument()
+    expect(screen.getByText('Stored')).toBeInTheDocument()
+
+    expect(
+      await screen.findByText('Catalyst efficiency below threshold (bank 1)')
+    ).toBeInTheDocument()
+    expect(screen.getByText('System too lean (bank 1)')).toBeInTheDocument()
+    expect(h.lookup).toHaveBeenCalledWith({
+      codes: ['P0420', 'P0171'],
+      vehicleId: 'veh1',
+      language: 'document',
+    })
+
+    fireEvent.click(screen.getByRole('button', { name: 'Add 2 codes' }))
+    await waitFor(() =>
+      expect(h.add).toHaveBeenCalledWith({
+        serviceRecordId: 'job1',
+        codes: [
+          { code: 'P0420', status: null },
+          { code: 'P0171', status: 'stored' },
+        ],
+        concernId: null,
+      })
+    )
+    await waitFor(() => expect(h.refresh).toHaveBeenCalled())
+  })
+
+  it('says a meaning is not available while the service is down, and still lets the codes be added', async () => {
+    h.lookup.mockResolvedValueOnce({
+      success: true,
+      data: { available: false, results: [view('P0420', { state: 'unavailable', title: null })] },
+    })
+    open()
+    fireEvent.change(screen.getByRole('textbox'), { target: { value: 'P0420' } })
+    expect(await screen.findByText('Not available right now')).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'Add 1 code' })).toBeEnabled()
+  })
+
+  it('lets a code be taken out before adding', async () => {
+    h.lookup.mockResolvedValue({ success: true, data: { available: true, results: [] } })
+    open()
+    fireEvent.change(screen.getByRole('textbox'), { target: { value: 'P0420 P0171' } })
+    fireEvent.click(screen.getByRole('button', { name: 'Remove P0420' }))
+    expect(screen.queryByText('P0420')).not.toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'Add 1 code' })).toBeInTheDocument()
+  })
+})
+
+describe('a code the service has no meaning for', () => {
+  it('says a manufacturer code depends on the make', () => {
+    render(<MeaningText view={view('P1570', { state: 'unknown', range: 'manufacturer' })} />)
+    expect(
+      screen.getByText('Manufacturer-specific code: the meaning depends on the make.')
+    ).toBeInTheDocument()
+  })
+
+  it('says a standard code is not in the list yet', () => {
+    render(<MeaningText view={view('P0420', { state: 'unknown', range: 'standard' })} />)
+    expect(screen.getByText('Not in our code list yet')).toBeInTheDocument()
+  })
+})
+
+describe('what it means, on the code sheet', () => {
+  it('shows each section under its own heading when the service sends sections', () => {
+    render(
+      <DescriptionSections
+        description="The catalyst stores oxygen. The code sets when it no longer does."
+        sections={[
+          { kind: 'how_it_works', text: 'The catalyst stores oxygen.' },
+          { kind: 'causes', text: 'Oil consumption, a misfire, an aged catalyst.' },
+          { kind: 'meaning', text: 'The code sets when it no longer does.' },
+          { kind: 'from_the_future' as never, text: 'Bank 2 exists only on V and boxer engines.' },
+        ]}
+      />
+    )
+    expect(screen.getByRole('heading', { name: 'How it works' })).toBeInTheDocument()
+    expect(screen.getByRole('heading', { name: 'Typical causes' })).toBeInTheDocument()
+    expect(screen.getByRole('heading', { name: 'What the code means' })).toBeInTheDocument()
+    // A kind the app does not know yet reads under "Good to know".
+    expect(screen.getByRole('heading', { name: 'Good to know' })).toBeInTheDocument()
+    expect(screen.getByText('Bank 2 exists only on V and boxer engines.')).toBeInTheDocument()
+    expect(screen.queryByText(/The catalyst stores oxygen. The code sets/)).not.toBeInTheDocument()
+  })
+
+  it('shows the description as one paragraph when there are no sections', () => {
+    render(
+      <DescriptionSections
+        description="The catalyst stores oxygen. The code sets when it no longer does."
+        sections={[]}
+      />
+    )
+    expect(
+      screen.getByText('The catalyst stores oxygen. The code sets when it no longer does.')
+    ).toBeInTheDocument()
+    expect(screen.queryByRole('heading')).not.toBeInTheDocument()
+  })
+
+  it('folds a long section away behind Show more', () => {
+    render(
+      <DescriptionSections
+        description={null}
+        sections={[{ kind: 'diagnosis', text: 'x'.repeat(400) }]}
+      />
+    )
+    fireEvent.click(screen.getByRole('button', { name: 'Show more' }))
+    expect(screen.getByRole('button', { name: 'Show less' })).toBeInTheDocument()
+  })
+})
+
+describe('a finding that is a fault code', () => {
+  function line(
+    props: Partial<React.ComponentProps<typeof FaultCodeFindingLine>> = {},
+    seenBefore: Record<string, string> = {}
+  ) {
+    return render(
+      <FaultCodesJobProvider
+        info={{ connected: true, seenBefore }}
+        vehicleId="veh1"
+        serviceRecordId="job1"
+        concerns={[]}
+        locked={false}
+      >
+        <FaultCodeFindingLine
+          finding={{
+            faultCode: 'P0420',
+            faultStatus: 'stored',
+            faultMeaning: 'Catalyst efficiency below threshold (bank 1)',
+            faultMeaningSource: 'generic',
+            description: 'P0420 Catalyst efficiency below threshold (bank 1)',
+          }}
+          {...props}
+        />
+      </FaultCodesJobProvider>
+    )
+  }
+
+  it('shows the code, its status and the meaning saved with it', () => {
+    line()
+    expect(screen.getByRole('button', { name: 'Open P0420' })).toBeInTheDocument()
+    expect(screen.getByText('Stored')).toBeInTheDocument()
+    expect(screen.getByText('Catalyst efficiency below threshold (bank 1)')).toBeInTheDocument()
+    expect(screen.queryByText(/^Seen /)).not.toBeInTheDocument()
+  })
+
+  it('says when the vehicle had the code in an earlier job', () => {
+    line({}, { P0420: '2026-03-12T10:00:00.000Z' })
+    expect(screen.getByText(/^Seen /)).toBeInTheDocument()
+  })
+
+  it('offers the updated meaning in one tap when the service has changed it', () => {
+    const onTake = vi.fn()
+    line({
+      update: { current: 'Catalyst system efficiency below threshold', source: 'generic' },
+      onTake,
+    })
+    fireEvent.click(screen.getByRole('button', { name: 'Updated meaning' }))
+    expect(onTake).toHaveBeenCalled()
+  })
+})

+ 84 - 0
src/__tests__/features/fault-codes/fix-drafts.test.ts

@@ -0,0 +1,84 @@
+import { mkdtemp, readdir, rm } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { isGeneratedName } from '@/lib/files/manager'
+
+vi.mock('server-only', () => ({}))
+const roots = vi.hoisted(() => ({ current: '' }))
+vi.mock('@/lib/upload-root', () => ({
+  uploadsRoot: () => roots.current,
+  uploadsRoots: () => [roots.current],
+}))
+// Discarding needs no database: nothing references a draft photo.
+vi.mock('@/lib/files/references', () => ({
+  referencedSuffixes: async () => new Set<string>(),
+  allReferencedSuffixes: async () => new Set<string>(),
+  existingOrganizationIds: async (ids: string[]) => new Set(ids),
+  hasAnyOrganization: async () => true,
+}))
+
+const drafts = await import('@/features/fault-codes/Lib/fixDrafts.server')
+
+const DRAFT = '3f2b1c4d-5e6f-4a7b-8c9d-0123456789ab'
+const OTHER = '9a8b7c6d-5e4f-4a3b-8c2d-ba9876543210'
+const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xd9])
+
+beforeEach(async () => {
+  roots.current = await mkdtemp(path.join(os.tmpdir(), 'torqvoice-fix-drafts-'))
+})
+afterEach(async () => {
+  await rm(roots.current, { recursive: true, force: true })
+})
+
+describe('phone photos waiting in a fix draft', () => {
+  it("are ordinary uploads in the workshop's fix-drafts folder, named for the sweep", async () => {
+    const added = await drafts.addDraftPhoto('org1', DRAFT, JPEG)
+    expect(added?.count).toBe(1)
+    const files = await readdir(path.join(roots.current, 'org1', 'fix-drafts'))
+    expect(files).toEqual([added?.name])
+    // Named like any generated upload, so the orphan sweep cleans up an abandoned draft.
+    expect(isGeneratedName(files[0])).toBe(true)
+    expect(drafts.draftPhotoUrl('org1', files[0])).toBe(
+      `/api/protected/files/org1/fix-drafts/${files[0]}`
+    )
+  })
+
+  it("lists only this draft's photos, and holds at most four", async () => {
+    for (let i = 0; i < 4; i++) await drafts.addDraftPhoto('org1', DRAFT, JPEG)
+    await drafts.addDraftPhoto('org1', OTHER, JPEG)
+    expect(await drafts.addDraftPhoto('org1', DRAFT, JPEG)).toBeNull()
+    expect(await drafts.listDraftPhotos('org1', DRAFT)).toHaveLength(4)
+    expect(await drafts.listDraftPhotos('org1', OTHER)).toHaveLength(1)
+    expect(await drafts.listDraftPhotos('org2', DRAFT)).toHaveLength(0)
+  })
+
+  it('never hold more than four, even when uploads land at the same time', async () => {
+    await drafts.addDraftPhoto('org1', DRAFT, JPEG)
+    await drafts.addDraftPhoto('org1', DRAFT, JPEG)
+    await drafts.addDraftPhoto('org1', DRAFT, JPEG)
+    const replies = await Promise.all(
+      Array.from({ length: 4 }, () => drafts.addDraftPhoto('org1', DRAFT, JPEG))
+    )
+    expect(await drafts.listDraftPhotos('org1', DRAFT)).toHaveLength(4)
+    expect(replies.filter((r) => r !== null)).toHaveLength(1)
+  })
+
+  it('are discarded through the file manager once the fix is shared', async () => {
+    await drafts.addDraftPhoto('org1', DRAFT, JPEG)
+    await drafts.addDraftPhoto('org1', DRAFT, JPEG)
+    const kept = await drafts.addDraftPhoto('org1', OTHER, JPEG)
+    expect(await drafts.readDraftPhotos('org1', DRAFT)).toHaveLength(2)
+    await drafts.discardDraft('org1', DRAFT)
+    expect(await drafts.listDraftPhotos('org1', DRAFT)).toEqual([])
+    expect(await drafts.listDraftPhotos('org1', OTHER)).toEqual([kept?.name])
+  })
+
+  it("never touches a name outside the draft, or another workshop's folder", async () => {
+    const added = await drafts.addDraftPhoto('org1', DRAFT, JPEG)
+    await drafts.removeDraftPhoto('org1', OTHER, added?.name as string)
+    await drafts.removeDraftPhoto('org1', DRAFT, '../org2/fix-drafts/x.jpg')
+    expect(await drafts.listDraftPhotos('org1', DRAFT)).toHaveLength(1)
+    expect(await drafts.addDraftPhoto('../org1', DRAFT, JPEG)).toBeNull()
+  })
+})

+ 42 - 0
src/__tests__/features/fault-codes/fix-markdown.test.ts

@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import { htmlToFixMarkdown } from '@/features/fault-codes/Lib/fixMarkdown'
+
+describe('a fix written in the editor', () => {
+  it('keeps paragraphs, bold and italic', () => {
+    expect(
+      htmlToFixMarkdown(
+        '<p>The <strong>rear O2 plug</strong> was <em>chafed</em>.</p><p>Repaired it.</p>'
+      )
+    ).toBe('The **rear O2 plug** was *chafed*.\n\nRepaired it.')
+  })
+
+  it('keeps bullet and numbered lists', () => {
+    expect(
+      htmlToFixMarkdown(
+        '<ul><li><p>Checked fuses</p></li><li><p>Measured 0.2 V</p></li></ul><ol><li><p>Unplug</p></li><li><p>Clean pins</p></li></ol>'
+      )
+    ).toBe('- Checked fuses\n- Measured 0.2 V\n\n1. Unplug\n2. Clean pins')
+  })
+
+  it('keeps a quote and a line break', () => {
+    expect(
+      htmlToFixMarkdown(
+        '<blockquote><p>Bulletin 18-2190</p></blockquote><p>Line one<br>line two</p>'
+      )
+    ).toBe('> Bulletin 18-2190\n\nLine one\\\nline two')
+  })
+
+  it('keeps only the text of what Markdown has no place for', () => {
+    expect(
+      htmlToFixMarkdown('<p><u>Underlined</u> and <a href="https://example.com">a link</a></p>')
+    ).toBe('Underlined and a link')
+  })
+
+  it('escapes characters Markdown would read as formatting', () => {
+    expect(htmlToFixMarkdown('<p>Pin 3_4 at 5*2 Nm</p>')).toBe('Pin 3\\_4 at 5\\*2 Nm')
+  })
+
+  it('is empty for an empty editor', () => {
+    expect(htmlToFixMarkdown('<p></p>')).toBe('')
+  })
+})

+ 540 - 0
src/__tests__/features/fault-codes/pages.test.tsx

@@ -0,0 +1,540 @@
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { toast } from 'sonner'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ConfirmProvider } from '@/components/confirm-dialog'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import type { FaultCodePageData, OwnFix, SharedFix } from '@/features/fault-codes/Lib/types'
+
+/**
+ * The two fault code pages as a technician uses them: a code's own page
+ * spread over two columns with the likeliest cause first, a way to ask
+ * support about the code on screen, sharing what fixed it and reading what
+ * fixed it elsewhere, and a lookup page that remembers what this person
+ * opened and searched, and lists matches while they type.
+ */
+
+const h = vi.hoisted(() => ({
+  push: vi.fn(),
+  openSupport: vi.fn(),
+  suggest: vi.fn(),
+  withdraw: vi.fn(),
+  report: vi.fn(),
+  phoneLink: vi.fn(),
+  draftPhotos: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ push: h.push, refresh: vi.fn() }),
+}))
+vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }))
+vi.mock('@/features/support/Lib/supportVisibility', () => ({ openSupport: h.openSupport }))
+vi.mock('@/features/fault-codes/Actions/suggestFaultCodes', () => ({
+  suggestFaultCodes: h.suggest,
+}))
+vi.mock('@/features/fault-codes/Actions/withdrawFaultCodeFix', () => ({
+  withdrawFaultCodeFix: h.withdraw,
+}))
+// The browser's half of the compression is the app's own, tested on its own.
+vi.mock('@/lib/compress-image', () => ({ compressImage: async (file: File) => file }))
+// The notes editor is tiptap, which jsdom cannot type into; a text box stands
+// in, handing back the same HTML the editor would.
+vi.mock('@/features/vehicles/Components/service-edit/RichTextEditor', () => ({
+  RichTextEditor: ({
+    content,
+    onChange,
+    ariaLabel,
+  }: {
+    content: string
+    onChange: (html: string) => void
+    ariaLabel?: string
+  }) => (
+    <textarea
+      aria-label={ariaLabel}
+      value={content.replace(/<\/?p>/g, '')}
+      onChange={(e) => onChange(e.target.value ? `<p>${e.target.value}</p>` : '')}
+    />
+  ),
+}))
+vi.mock('@/features/fault-codes/Actions/createFixPhoneLink', () => ({
+  createFixPhoneLink: h.phoneLink,
+}))
+vi.mock('@/features/fault-codes/Actions/listFixDraftPhotos', () => ({
+  listFixDraftPhotos: h.draftPhotos,
+}))
+vi.mock('@/features/fault-codes/Actions/removeFixDraftPhoto', () => ({
+  removeFixDraftPhoto: vi.fn(async () => ({ success: true })),
+}))
+vi.mock('@/features/fault-codes/Actions/reportFaultCodeMeaning', () => ({
+  reportFaultCodeMeaning: h.report,
+}))
+
+const { FaultCodeDetail } = await import('@/features/fault-codes/Components/FaultCodeDetail')
+const { FaultCodeLookup } = await import('@/features/fault-codes/Components/FaultCodeLookup')
+const { readRecentLookups, pushRecentLookup } = await import('@/features/fault-codes/Lib/lookups')
+
+function pageData(patch: Partial<FaultCodePageData> = {}): FaultCodePageData {
+  return {
+    code: 'P0420',
+    connected: true,
+    connectable: true,
+    available: true,
+    detail: {
+      code: 'P0420',
+      state: 'found',
+      range: 'standard',
+      title: 'Catalyst System Efficiency Below Threshold Bank 1',
+      source: 'generic',
+      make: null,
+      lang: 'en',
+      machineTranslated: false,
+      verified: true,
+      version: 2,
+      flags: { warningLight: true, emissions: true, driveCycle: false, limpMode: false },
+      description: null,
+      sections: [{ kind: 'how_it_works', text: 'x'.repeat(500) }],
+      causes: [
+        { key: 'sensor', likelihood: 'low', label: 'Rear oxygen sensor slow' },
+        { key: 'aged', likelihood: 'high', label: 'Catalyst aged' },
+        { key: 'leak', likelihood: 'medium', label: 'Exhaust leak before the sensor' },
+      ],
+      related: [{ code: 'P0430', title: 'Catalyst System Efficiency Below Threshold Bank 2' }],
+      pendingCheck: false,
+    },
+    jobs: [],
+    fixes: { available: true, published: [], own: [] },
+    report: null,
+    vehicle: null,
+    ...patch,
+  }
+}
+
+const renderPage = (ui: React.ReactElement) =>
+  render(
+    <ConfirmProvider>
+      <TooltipProvider>{ui}</TooltipProvider>
+    </ConfirmProvider>
+  )
+
+const fetchMock = vi.fn()
+
+beforeEach(() => {
+  window.localStorage.clear()
+  h.push.mockReset()
+  h.openSupport.mockReset()
+  h.suggest.mockReset()
+  h.withdraw.mockReset()
+  h.report.mockReset()
+  fetchMock.mockReset()
+  vi.mocked(toast.error).mockReset()
+  vi.mocked(toast.success).mockReset()
+  vi.stubGlobal('fetch', fetchMock)
+  URL.createObjectURL = vi.fn(() => 'blob:preview')
+  URL.revokeObjectURL = vi.fn()
+})
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+})
+
+describe("a code's own page", () => {
+  it('lays the code out in cards, says it is in development, and reads in full', () => {
+    renderPage(<FaultCodeDetail data={pageData()} layout="page" />)
+    expect(
+      screen.getByRole('heading', {
+        level: 1,
+        name: 'Catalyst System Efficiency Below Threshold Bank 1',
+      })
+    ).toBeInTheDocument()
+    expect(screen.getByText('Powertrain')).toBeInTheDocument()
+    // Says plainly that codes are still being added, and only OBD codes for now.
+    expect(screen.getByText('Fault codes are new')).toBeInTheDocument()
+    expect(screen.getByText(/standard OBD-II codes/)).toBeInTheDocument()
+    // A card's heading carries its count, as in "Related codes 1".
+    for (const title of [
+      'What it means',
+      'Likely causes',
+      'Related codes',
+      'Fixes from other workshops',
+      'Share your fix',
+    ])
+      expect(screen.getByRole('heading', { name: new RegExp(`^${title}`) })).toBeInTheDocument()
+    expect(screen.queryByText(/Your note/)).not.toBeInTheDocument()
+    // There is room on the page: no folding behind Show more.
+    expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument()
+  })
+
+  it('lists the likeliest cause first', () => {
+    renderPage(<FaultCodeDetail data={pageData()} layout="page" />)
+    const causes = screen
+      .getByRole('heading', { name: 'Likely causes' })
+      .closest('[data-slot="card"]')
+    const items = within(causes as HTMLElement).getAllByRole('listitem')
+    expect(items.map((li) => li.textContent)).toEqual([
+      expect.stringContaining('Catalyst aged'),
+      expect.stringContaining('Exhaust leak before the sensor'),
+      expect.stringContaining('Rear oxygen sensor slow'),
+    ])
+  })
+
+  it('opens support with the code and its meaning already written in', () => {
+    renderPage(<FaultCodeDetail data={pageData()} layout="page" supportEnabled />)
+    fireEvent.click(screen.getByRole('button', { name: 'Contact support' }))
+    expect(h.openSupport).toHaveBeenCalledWith({
+      subject: 'Fault code P0420',
+      message: expect.stringContaining('P0420: Catalyst System Efficiency Below Threshold Bank 1'),
+    })
+  })
+
+  it('reports a wrong meaning with a reason and what was found, nothing more', async () => {
+    h.report.mockResolvedValueOnce({
+      success: true,
+      data: {
+        sent: true,
+        report: {
+          code: 'P0420',
+          reportedAt: '2026-11-05T10:00:00Z',
+          reason: 'wrong_for_vehicle',
+          status: 'open',
+          outcome: null,
+          message: null,
+        },
+      },
+    })
+    renderPage(<FaultCodeDetail data={pageData()} layout="page" />)
+    fireEvent.click(screen.getByRole('button', { name: 'Report a problem' }))
+    expect(screen.queryByText('What does your scan tool say?')).not.toBeInTheDocument()
+    fireEvent.click(screen.getByRole('radio', { name: 'Wrong for this car' }))
+    fireEvent.change(screen.getByPlaceholderText('What you found'), {
+      target: { value: 'Rear O2 sensor, not the catalyst' },
+    })
+    fireEvent.click(screen.getByRole('button', { name: 'Send' }))
+    await waitFor(() => expect(screen.getByText('Reported by you')).toBeInTheDocument())
+    expect(h.report).toHaveBeenCalledWith({
+      code: 'P0420',
+      version: 2,
+      lang: 'en',
+      reason: 'wrong_for_vehicle',
+      note: 'Rear O2 sensor, not the catalyst',
+      vehicleId: null,
+    })
+  })
+
+  it('offers no support button where there is no support desk', () => {
+    renderPage(<FaultCodeDetail data={pageData()} layout="page" />)
+    expect(screen.queryByRole('button', { name: 'Contact support' })).not.toBeInTheDocument()
+  })
+
+  it('remembers the code as a recent lookup', () => {
+    renderPage(<FaultCodeDetail data={pageData()} layout="page" />)
+    expect(readRecentLookups()[0]).toMatchObject({
+      kind: 'code',
+      code: 'P0420',
+      title: 'Catalyst System Efficiency Below Threshold Bank 1',
+    })
+  })
+
+  it('stays one quiet column beside a job', () => {
+    renderPage(<FaultCodeDetail data={pageData({ fixes: null })} />)
+    expect(screen.queryByText('Fault codes are new')).not.toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'Show more' })).toBeInTheDocument()
+    expect(screen.queryByText('Share your fix')).not.toBeInTheDocument()
+    expect(readRecentLookups()).toEqual([])
+  })
+})
+
+const GOLF = { id: 'v1', make: 'Volkswagen', model: 'Golf', year: 2014, engine: 'CAXA' }
+const FIXED_IT =
+  'Chafed wire at the rear O2 sensor plug behind the gearbox. Repaired it, code cleared after a test drive.'
+
+function ownFix(patch: Partial<OwnFix> = {}): OwnFix {
+  return {
+    id: '0190f3c2-7d1a-7000-8000-000000000001',
+    lang: 'en',
+    text: FIXED_IT,
+    vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: 'CAXA' },
+    photos: [],
+    createdAt: '2026-11-05T10:00:00Z',
+    status: 'pending',
+    reason: null,
+    reviewedAt: null,
+    ...patch,
+  }
+}
+
+function card(title: string) {
+  return within(
+    screen
+      .getByRole('heading', { name: new RegExp(`^${title}`) })
+      .closest('[data-slot="card"]') as HTMLElement
+  )
+}
+
+describe('sharing what fixed a code', () => {
+  /** Opens the share dialog from the card's invitation. */
+  async function openShare() {
+    const share = card('Share your fix')
+    fireEvent.click(share.getByRole('button', { name: 'Share your fix' }))
+    return { share, dialog: within(await screen.findByRole('dialog')) }
+  }
+
+  it("sends the text, the photos and the job's vehicle, and shows the fix waiting for review", async () => {
+    fetchMock.mockResolvedValueOnce(
+      new Response(JSON.stringify({ success: true, data: ownFix({ photos: [] }) }))
+    )
+    renderPage(<FaultCodeDetail data={pageData({ vehicle: GOLF })} layout="page" />)
+    const { share, dialog } = await openShare()
+    expect(dialog.getByText('Share your fix for P0420')).toBeInTheDocument()
+    // The vehicle comes from the job: shown, never asked for.
+    expect(dialog.getByText('2014 Volkswagen Golf · CAXA')).toBeInTheDocument()
+    expect(dialog.queryByLabelText('Make')).not.toBeInTheDocument()
+
+    fireEvent.change(dialog.getByLabelText('What fixed it'), { target: { value: FIXED_IT } })
+    const photo = new File(['jpeg'], 'plug.jpg', { type: 'image/jpeg' })
+    fireEvent.change(dialog.getByTestId('fix-photo-input'), { target: { files: [photo] } })
+    await waitFor(() =>
+      expect(dialog.getByRole('button', { name: 'Remove photo 1' })).toBeVisible()
+    )
+    fireEvent.click(dialog.getByRole('button', { name: 'Share fix' }))
+
+    await waitFor(() => expect(share.getByText('Waiting for review')).toBeInTheDocument())
+    const [url, init] = fetchMock.mock.calls[0]
+    expect(url).toBe('/api/protected/fault-codes/fixes')
+    const form = init.body as FormData
+    expect(form.get('code')).toBe('P0420')
+    expect(form.get('text')).toBe(FIXED_IT)
+    expect(form.get('vehicleId')).toBe('v1')
+    expect(form.get('make')).toBeNull()
+    expect(form.get('draftId')).toBeNull()
+    expect(form.getAll('photo')).toHaveLength(1)
+    expect(share.getByText(FIXED_IT)).toBeInTheDocument()
+    expect(toast.success).toHaveBeenCalledWith('Thanks. Your fix is waiting for review.')
+    // The dialog closes, and another fix starts from the card's header.
+    await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+    expect(share.getByRole('button', { name: 'Share another fix' })).toBeInTheDocument()
+  })
+
+  it('sends formatting as Markdown', async () => {
+    fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, data: ownFix() })))
+    renderPage(<FaultCodeDetail data={pageData()} layout="page" />)
+    const { dialog } = await openShare()
+    fireEvent.change(dialog.getByLabelText('What fixed it'), {
+      target: { value: '<strong>Chafed wire</strong> behind the gearbox' },
+    })
+    fireEvent.click(dialog.getByRole('button', { name: 'Share fix' }))
+    await waitFor(() => expect(fetchMock).toHaveBeenCalled())
+    const form = fetchMock.mock.calls[0][1].body as FormData
+    expect(form.get('text')).toBe('**Chafed wire** behind the gearbox')
+  })
+
+  it('asks for make, model and year when the page was not opened from a job', async () => {
+    fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, data: ownFix() })))
+    renderPage(<FaultCodeDetail data={pageData()} layout="page" />)
+    const { dialog } = await openShare()
+    fireEvent.change(dialog.getByLabelText('What fixed it'), { target: { value: FIXED_IT } })
+    fireEvent.change(dialog.getByLabelText('Make'), { target: { value: 'Volkswagen' } })
+    fireEvent.change(dialog.getByLabelText('Year'), { target: { value: '2014' } })
+    fireEvent.click(dialog.getByRole('button', { name: 'Share fix' }))
+    await waitFor(() => expect(fetchMock).toHaveBeenCalled())
+    const form = fetchMock.mock.calls[0][1].body as FormData
+    expect(form.get('make')).toBe('Volkswagen')
+    expect(form.get('year')).toBe('2014')
+    expect(form.get('model')).toBeNull()
+    expect(form.get('vehicleId')).toBeNull()
+  })
+
+  it('shows a phone code, shows the photos the phone adds, and sends them with the fix', async () => {
+    h.phoneLink.mockResolvedValue({
+      success: true,
+      data: { token: 'fx1.abc.sig', expiresAt: '2026-11-05T20:00:00Z' },
+    })
+    const name = '3f2b1c4d-5e6f-4a7b-8c9d-0123456789ab.jpg'
+    h.draftPhotos.mockResolvedValue({
+      success: true,
+      data: [{ id: name, url: `/api/protected/files/org1/fix-drafts/${name}` }],
+    })
+    fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, data: ownFix() })))
+    renderPage(<FaultCodeDetail data={pageData()} layout="page" />)
+    const { dialog } = await openShare()
+
+    fireEvent.click(dialog.getByRole('button', { name: 'Add from phone' }))
+    expect(await dialog.findByText('Take photos with your phone')).toBeInTheDocument()
+    await waitFor(() => expect(dialog.getByText('1 photo from your phone')).toBeInTheDocument())
+    expect(dialog.getByText('Phone')).toBeInTheDocument()
+    const draftId = h.phoneLink.mock.calls[0][0].draftId as string
+    expect(h.phoneLink).toHaveBeenCalledWith({ code: 'P0420', draftId })
+
+    fireEvent.change(dialog.getByLabelText('What fixed it'), { target: { value: FIXED_IT } })
+    fireEvent.click(dialog.getByRole('button', { name: 'Share fix' }))
+    await waitFor(() => expect(fetchMock).toHaveBeenCalled())
+    const form = fetchMock.mock.calls[0][1].body as FormData
+    // The phone's photos are on the server already; the draft says which.
+    expect(form.get('draftId')).toBe(draftId)
+    expect(form.getAll('photo')).toHaveLength(0)
+  })
+
+  it('keeps what was written when sharing is paused', async () => {
+    fetchMock.mockResolvedValueOnce(
+      new Response(JSON.stringify({ success: false, error: 'paused' }), { status: 503 })
+    )
+    renderPage(<FaultCodeDetail data={pageData()} layout="page" />)
+    const { dialog } = await openShare()
+    fireEvent.change(dialog.getByLabelText('What fixed it'), { target: { value: FIXED_IT } })
+    fireEvent.click(dialog.getByRole('button', { name: 'Share fix' }))
+    await waitFor(() =>
+      expect(toast.error).toHaveBeenCalledWith(
+        'Sharing fixes is paused right now. Try again later.'
+      )
+    )
+    expect(dialog.getByLabelText('What fixed it')).toHaveValue(FIXED_IT)
+  })
+
+  it('says why a fix was not published, and withdraws it', async () => {
+    h.withdraw.mockResolvedValueOnce({ success: true, data: { withdrawn: true } })
+    const rejected = ownFix({ status: 'rejected', reason: 'The photo shows a number plate.' })
+    renderPage(
+      <FaultCodeDetail
+        data={pageData({ fixes: { available: true, published: [], own: [rejected] } })}
+        layout="page"
+      />
+    )
+    const share = card('Share your fix')
+    expect(share.getByText('Not published')).toBeInTheDocument()
+    expect(share.getByText('Reason: The photo shows a number plate.')).toBeInTheDocument()
+
+    fireEvent.click(share.getByRole('button', { name: 'Withdraw' }))
+    const dialog = await screen.findByRole('alertdialog')
+    fireEvent.click(within(dialog).getByRole('button', { name: 'Withdraw' }))
+    await waitFor(() => expect(share.queryByText('Not published')).not.toBeInTheDocument())
+    expect(h.withdraw).toHaveBeenCalledWith({ code: 'P0420', id: rejected.id })
+    expect(toast.success).toHaveBeenCalledWith('Fix withdrawn')
+  })
+
+  it("lists other workshops' fixes with the vehicle and photos, never the workshop", () => {
+    const theirs: SharedFix = {
+      id: '0190f3c2-7d1a-7000-8000-000000000002',
+      lang: 'en',
+      text: 'Replaced the rear O2 sensor; the plug was fine.',
+      vehicle: { make: 'Skoda', model: 'Octavia', year: 2016, engine: null },
+      photos: [{ n: 0, width: 1200, height: 900 }],
+      createdAt: '2026-11-04T08:00:00Z',
+    }
+    renderPage(
+      <FaultCodeDetail
+        data={pageData({ fixes: { available: true, published: [theirs], own: [] } })}
+        layout="page"
+      />
+    )
+    const fixes = card('Fixes from other workshops')
+    expect(fixes.getByText('2016 Skoda Octavia')).toBeInTheDocument()
+    expect(fixes.getByText(theirs.text)).toBeInTheDocument()
+    const thumb = fixes.getByRole('button', { name: 'Open photo 1' })
+    expect(thumb.querySelector('img')).toHaveAttribute(
+      'src',
+      `/api/protected/fault-codes/fixes/${theirs.id}/photos/0?size=thumb`
+    )
+  })
+
+  it('says when no other workshop has shared one, and when the service does not answer', () => {
+    const { unmount } = renderPage(<FaultCodeDetail data={pageData()} layout="page" />)
+    expect(
+      card('Fixes from other workshops').getByText(
+        'No other workshop has shared a fix for P0420 yet.'
+      )
+    ).toBeInTheDocument()
+    unmount()
+    renderPage(
+      <FaultCodeDetail
+        data={pageData({ fixes: { available: false, published: [], own: [] } })}
+        layout="page"
+      />
+    )
+    expect(
+      screen.getByText('Fixes from other workshops are not available right now.')
+    ).toBeInTheDocument()
+    expect(screen.getByText('Sharing fixes is not available right now.')).toBeInTheDocument()
+  })
+})
+
+const overview = {
+  connected: true,
+  connectable: true,
+  query: '',
+  available: true,
+  hits: [],
+  recent: [],
+}
+
+describe('the lookup page', () => {
+  it('shows what this person opened and searched lately, and clears it', () => {
+    pushRecentLookup({ kind: 'words', query: 'lean bank 1' })
+    pushRecentLookup({ kind: 'code', code: 'P0171', title: 'System Too Lean Bank 1' })
+    renderPage(<FaultCodeLookup overview={overview} />)
+    const card = screen
+      .getByRole('heading', { name: 'Your recent lookups' })
+      .closest('[data-slot="card"]')
+    const rows = within(card as HTMLElement).getAllByRole('listitem')
+    expect(rows[0]).toHaveTextContent('P0171')
+    expect(rows[0]).toHaveTextContent('System Too Lean Bank 1')
+    expect(rows[1]).toHaveTextContent('lean bank 1')
+
+    fireEvent.click(within(card as HTMLElement).getByRole('button', { name: 'Clear' }))
+    expect(screen.getByText(/show up here, on this device/)).toBeInTheDocument()
+    expect(readRecentLookups()).toEqual([])
+  })
+
+  it('lists matches while typing', async () => {
+    vi.useFakeTimers()
+    try {
+      h.suggest.mockResolvedValue({
+        success: true,
+        data: {
+          query: 'catalyst',
+          available: true,
+          hits: [{ code: 'P0420', title: 'Catalyst System Efficiency Below Threshold Bank 1' }],
+        },
+      })
+      renderPage(<FaultCodeLookup overview={overview} />)
+      fireEvent.change(screen.getByLabelText('Code or words'), { target: { value: 'catalyst' } })
+      await act(async () => {
+        await vi.advanceTimersByTimeAsync(300)
+      })
+      expect(h.suggest).toHaveBeenCalledWith('catalyst')
+      expect(screen.getByText('Codes matching “catalyst”')).toBeInTheDocument()
+      expect(
+        screen.getByRole('link', { name: /P0420.*Catalyst System Efficiency/ })
+      ).toHaveAttribute('href', '/fault-codes/P0420')
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  it('goes straight to a code, and remembers searched words', () => {
+    renderPage(<FaultCodeLookup overview={overview} />)
+    const box = screen.getByLabelText('Code or words')
+    fireEvent.change(box, { target: { value: 'p0420' } })
+    fireEvent.submit(box.closest('form') as HTMLFormElement)
+    expect(h.push).toHaveBeenLastCalledWith('/fault-codes/P0420')
+
+    fireEvent.change(box, { target: { value: 'lean bank 1' } })
+    fireEvent.submit(box.closest('form') as HTMLFormElement)
+    expect(h.push).toHaveBeenLastCalledWith('/fault-codes?q=lean%20bank%201')
+    expect(readRecentLookups()[0]).toMatchObject({ kind: 'words', query: 'lean bank 1' })
+  })
+})
+
+describe('recent lookups', () => {
+  it('keeps each lookup once, newest first, and at most ten', () => {
+    for (let i = 0; i < 12; i++)
+      pushRecentLookup({ kind: 'code', code: `P01${10 + i}`, title: null })
+    pushRecentLookup({ kind: 'code', code: 'P0115', title: null })
+    const codes = readRecentLookups().map((r) => (r.kind === 'code' ? r.code : r.query))
+    expect(codes).toHaveLength(10)
+    expect(codes[0]).toBe('P0115')
+    expect(codes.filter((c) => c === 'P0115')).toHaveLength(1)
+  })
+
+  it('treats a damaged store as empty', () => {
+    window.localStorage.setItem('torqvoice.faultCodes.recent', '{not json')
+    expect(readRecentLookups()).toEqual([])
+  })
+})

+ 299 - 0
src/__tests__/features/fault-codes/service.test.ts

@@ -0,0 +1,299 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * The layer between the screens and the code service: a service that is
+ * down gives "not available" rather than an error, a cloud plan without the
+ * feature, or an install without the connector, never reaches the service
+ * at all, and a shared fix is remembered here by its id alone.
+ */
+
+const h = vi.hoisted(() => ({
+  connections: [] as { id: string; connectorId: string }[],
+  lookupCodes: vi.fn(),
+  shareFix: vi.fn(),
+  fixStatus: vi.fn(),
+  withdrawFix: vi.fn(),
+  publishedFixes: vi.fn(),
+  links: new Map<string, { remoteId: string; metadata: Record<string, unknown> | null }>(),
+  autoConnectFor: vi.fn(async () => false),
+  features: { faultCodes: true, integrations: false } as Record<string, boolean>,
+  manifests: [
+    { id: 'torqvoice-codes', capabilities: ['diagnostics.codes'], plan: 'faultCodes' },
+  ] as { id: string; capabilities: string[]; plan?: string }[],
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    integrationConnection: { findMany: vi.fn(async () => h.connections) },
+  },
+}))
+vi.mock('@/lib/features', () => ({ getFeatures: vi.fn(async () => h.features) }))
+vi.mock('@/integrations/registry', () => ({
+  listManifests: () => h.manifests,
+  getManifest: (id: string) => h.manifests.find((m) => m.id === id) ?? null,
+}))
+vi.mock('@/features/integrations/Lib/auto-connect', () => ({
+  autoConnectFor: h.autoConnectFor,
+}))
+/** The connection's link store, in memory. */
+const links = {
+  get: vi.fn(async (type: string, id: string) => {
+    const link = h.links.get(`${type}|${id}`)
+    return link ? { ...link, remoteUrl: null, checksum: null } : null
+  }),
+  set: vi.fn(async (type: string, id: string, link: { remoteId: string; metadata?: unknown }) => {
+    h.links.set(`${type}|${id}`, {
+      remoteId: link.remoteId,
+      metadata: (link.metadata as Record<string, unknown>) ?? null,
+    })
+  }),
+  remove: vi.fn(async (type: string, id: string) => {
+    h.links.delete(`${type}|${id}`)
+  }),
+  remoteIds: vi.fn(
+    async (type: string) =>
+      new Set(
+        [...h.links.entries()].filter(([k]) => k.startsWith(`${type}|`)).map(([, v]) => v.remoteId)
+      )
+  ),
+}
+
+vi.mock('@/features/integrations/Lib/connections', () => ({
+  loadConnection: vi.fn(async () => ({
+    ctx: { links },
+    server: {
+      lookupCodes: h.lookupCodes,
+      shareFix: h.shareFix,
+      fixStatus: h.fixStatus,
+      withdrawFix: h.withdrawFix,
+      publishedFixes: h.publishedFixes,
+    },
+  })),
+}))
+
+const { fixesFor, lookupMeanings, shareFix, viewOf, withdrawSharedFix } = await import(
+  '@/features/fault-codes/Lib/service.server'
+)
+
+const P0420 = {
+  code: 'P0420',
+  found: true,
+  range: 'standard' as const,
+  meaning: {
+    system: 'P' as const,
+    category: 'powertrain',
+    scope: 'generic' as const,
+    source: 'standard' as const,
+    version: 2,
+    verified: true,
+    pendingCheck: false,
+    title: {
+      text: 'Catalyst efficiency below threshold (bank 1)',
+      lang: 'en',
+      machineTranslated: false,
+    },
+    flags: { warningLight: true, emissions: true, driveCycle: false, limpMode: false },
+    related: [],
+    causes: [],
+  },
+}
+
+beforeEach(() => {
+  h.connections = [{ id: 'conn1', connectorId: 'torqvoice-codes' }]
+  h.links.clear()
+  for (const fn of [h.shareFix, h.fixStatus, h.withdrawFix, h.publishedFixes]) fn.mockReset()
+  h.features = { faultCodes: true, integrations: false }
+  h.manifests = [{ id: 'torqvoice-codes', capabilities: ['diagnostics.codes'], plan: 'faultCodes' }]
+  h.lookupCodes.mockReset()
+  h.autoConnectFor.mockReset()
+  h.autoConnectFor.mockResolvedValue(false)
+})
+
+describe('fault code meanings for a workshop', () => {
+  it("gives the service's meaning, marked generic", async () => {
+    h.lookupCodes.mockResolvedValueOnce([P0420])
+    const { available, results } = await lookupMeanings(
+      'org1',
+      ['P0420'],
+      { make: 'Volkswagen' },
+      'en'
+    )
+    expect(available).toBe(true)
+    expect(results[0]).toMatchObject({
+      code: 'P0420',
+      state: 'found',
+      title: 'Catalyst efficiency below threshold (bank 1)',
+      source: 'generic',
+      version: 2,
+    })
+  })
+
+  it('says not available when the service is down', async () => {
+    h.lookupCodes.mockRejectedValueOnce(new Error('fetch failed'))
+    const { available, results } = await lookupMeanings('org1', ['P0420', 'P1570'], null, 'en')
+    expect(available).toBe(false)
+    expect(results[0]).toMatchObject({ code: 'P0420', state: 'unavailable', title: null })
+    expect(results[1]).toMatchObject({ code: 'P1570', state: 'unavailable', title: null })
+  })
+
+  it('asks in batches of 50, so a job with more codes still gets every meaning', async () => {
+    const codes = Array.from({ length: 60 }, (_, i) => `P0${(300 + i).toString(16).toUpperCase()}`)
+    h.lookupCodes.mockImplementation(async (_ctx: unknown, q: { codes: string[] }) =>
+      q.codes.map((code) => ({ ...P0420, code }))
+    )
+    const { available, results } = await lookupMeanings('org1', codes, null, 'en')
+    expect(h.lookupCodes).toHaveBeenCalledTimes(2)
+    expect(h.lookupCodes.mock.calls[0][1].codes).toHaveLength(50)
+    expect(h.lookupCodes.mock.calls[1][1].codes).toHaveLength(10)
+    expect(available).toBe(true)
+    expect(results.filter((r) => r.state === 'found')).toHaveLength(60)
+  })
+
+  it('marks only the codes of a batch that failed as unavailable', async () => {
+    const codes = Array.from({ length: 51 }, (_, i) => `P0${(300 + i).toString(16).toUpperCase()}`)
+    h.lookupCodes
+      .mockImplementationOnce(async (_ctx: unknown, q: { codes: string[] }) =>
+        q.codes.map((code) => ({ ...P0420, code }))
+      )
+      .mockRejectedValueOnce(new Error('fetch failed'))
+    const { available, results } = await lookupMeanings('org1', codes, null, 'en')
+    expect(available).toBe(true)
+    expect(results.slice(0, 50).every((r) => r.state === 'found')).toBe(true)
+    expect(results[50]).toMatchObject({ state: 'unavailable', title: null })
+  })
+
+  it('never asks the service when the plan does not include it or nothing is connected', async () => {
+    h.features = { faultCodes: false, integrations: true }
+    expect((await lookupMeanings('org1', ['P0420'], null, 'en')).available).toBe(false)
+    h.features = { faultCodes: true, integrations: false }
+    h.connections = []
+    expect((await lookupMeanings('org1', ['P0420'], null, 'en')).available).toBe(false)
+    h.manifests = []
+    expect((await lookupMeanings('org1', ['P0420'], null, 'en')).available).toBe(false)
+    expect(h.lookupCodes).not.toHaveBeenCalled()
+  })
+
+  it('answers on the first lookup of a workshop the cloud connects by itself', async () => {
+    h.connections = []
+    h.autoConnectFor.mockImplementationOnce(async () => {
+      h.connections = [{ id: 'auto', connectorId: 'torqvoice-codes' }]
+      return true
+    })
+    h.lookupCodes.mockResolvedValueOnce([P0420])
+    const { available, results } = await lookupMeanings('org1', ['P0420'], null, 'en')
+    expect(available).toBe(true)
+    expect(results[0]).toMatchObject({ code: 'P0420', state: 'found' })
+    expect(h.autoConnectFor).toHaveBeenCalledWith('org1', 'diagnostics.codes')
+  })
+
+  it('tells a manufacturer code the service does not know from a standard one', () => {
+    expect(
+      viewOf('P1570', { code: 'P1570', found: false, range: 'manufacturer' }, true, 'VW')
+    ).toMatchObject({ state: 'unknown', range: 'manufacturer' })
+    expect(
+      viewOf('P0420', { code: 'P0420', found: false, range: 'standard' }, true, 'VW')
+    ).toMatchObject({ state: 'unknown', range: 'standard' })
+    // No answer at all: the range still follows from the code.
+    expect(viewOf('U2100', null, false, undefined)).toMatchObject({
+      state: 'unavailable',
+      range: 'manufacturer',
+    })
+  })
+})
+
+const fix = (id: string, patch: Record<string, unknown> = {}) => ({
+  id,
+  code: 'P0420',
+  lang: 'en',
+  text: `Fix ${id}`,
+  vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014 },
+  photos: [],
+  createdAt: '2026-11-05T10:00:00Z',
+  ...patch,
+})
+
+describe('shared fixes for a workshop', () => {
+  it('remembers only the id of a fix it shared, and shows it waiting for review', async () => {
+    h.shareFix.mockResolvedValueOnce({ ok: true, id: 'f1' })
+    h.fixStatus.mockResolvedValueOnce([
+      fix('f1', { status: 'pending', reason: null, reviewedAt: null }),
+    ])
+    const photo = new Blob(['jpeg'], { type: 'image/jpeg' })
+    const result = await shareFix('org1', {
+      code: 'P0420',
+      text: 'Chafed wire at the rear O2 sensor plug',
+      lang: 'nb',
+      vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: 'CAXA' },
+      photos: [photo],
+    })
+    expect(h.shareFix).toHaveBeenCalledWith(expect.anything(), {
+      code: 'P0420',
+      text: 'Chafed wire at the rear O2 sensor plug',
+      lang: 'nb',
+      vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: 'CAXA' },
+      workshopRef: 'org1',
+      photos: [photo],
+    })
+    expect(result).toMatchObject({ ok: true, fix: { id: 'f1', status: 'pending' } })
+    expect([...h.links.entries()]).toEqual([
+      [
+        'fault-code-fix:P0420|f1',
+        { remoteId: 'f1', metadata: { code: 'P0420', sentAt: expect.any(String) } },
+      ],
+    ])
+  })
+
+  it('passes on why the service would not take it, and remembers nothing', async () => {
+    h.shareFix.mockResolvedValueOnce({ ok: false, reason: 'paused' })
+    const result = await shareFix('org1', {
+      code: 'P0420',
+      text: 'x',
+      lang: 'en',
+      vehicle: null,
+      photos: [],
+    })
+    expect(result).toEqual({ ok: false, reason: 'paused' })
+    h.shareFix.mockRejectedValueOnce(new Error('fetch failed'))
+    expect(
+      await shareFix('org1', { code: 'P0420', text: 'x', lang: 'en', vehicle: null, photos: [] })
+    ).toEqual({ ok: false, reason: 'unavailable' })
+    expect(h.links.size).toBe(0)
+  })
+
+  it("lists other workshops' fixes apart from its own, and forgets one the service no longer has", async () => {
+    h.links.set('fault-code-fix:P0420|mine', { remoteId: 'mine', metadata: null })
+    h.links.set('fault-code-fix:P0420|gone', { remoteId: 'gone', metadata: null })
+    h.publishedFixes.mockResolvedValueOnce([fix('theirs'), fix('mine')])
+    h.fixStatus.mockResolvedValueOnce([
+      fix('mine', { status: 'published', reason: null, reviewedAt: '2026-11-06T09:00:00Z' }),
+    ])
+    const fixes = await fixesFor('org1', 'P0420')
+    expect(fixes.available).toBe(true)
+    expect(fixes.published.map((f) => f.id)).toEqual(['theirs'])
+    expect(fixes.own).toMatchObject([{ id: 'mine', status: 'published' }])
+    expect(h.links.has('fault-code-fix:P0420|gone')).toBe(false)
+  })
+
+  it('says not available when the service does not answer', async () => {
+    h.publishedFixes.mockRejectedValueOnce(new Error('fetch failed'))
+    expect(await fixesFor('org1', 'P0420')).toEqual({
+      available: false,
+      published: [],
+      own: [],
+    })
+  })
+
+  it('withdraws only a fix this workshop sent, and forgets it', async () => {
+    expect(await withdrawSharedFix('org1', 'P0420', 'someone-elses')).toBe('not_found')
+    expect(h.withdrawFix).not.toHaveBeenCalled()
+
+    h.links.set('fault-code-fix:P0420|mine', { remoteId: 'mine', metadata: null })
+    h.withdrawFix.mockResolvedValueOnce(true)
+    expect(await withdrawSharedFix('org1', 'P0420', 'mine')).toBe('withdrawn')
+    expect(h.withdrawFix).toHaveBeenCalledWith(expect.anything(), {
+      id: 'mine',
+      workshopRef: 'org1',
+    })
+    expect(h.links.size).toBe(0)
+  })
+})

+ 177 - 0
src/__tests__/features/integrations/auto-connect.test.ts

@@ -0,0 +1,177 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * A service Torqvoice runs, such as the fault code service on the cloud, is
+ * on for every workshop without anyone visiting the connect page, and off for
+ * good once a workshop turns it off. Where the server holds no key, nothing
+ * is connected for anyone: the workshop would have to bring a key.
+ */
+
+const h = vi.hoisted(() => ({
+  connections: new Map<string, { id: string; status: string; createdById: string }>(),
+  settings: new Map<string, string>(),
+  features: { faultCodes: true, integrations: false } as Record<string, boolean>,
+  manifests: [] as ConnectorManifest[],
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    integrationConnection: {
+      findUnique: vi.fn(
+        async ({
+          where,
+        }: {
+          where: { organizationId_connectorId: { organizationId: string; connectorId: string } }
+        }) => {
+          const k = where.organizationId_connectorId
+          return h.connections.get(`${k.organizationId}:${k.connectorId}`) ?? null
+        }
+      ),
+      create: vi.fn(
+        async ({
+          data,
+        }: {
+          data: { organizationId: string; connectorId: string; status: string; createdById: string }
+        }) => {
+          const key = `${data.organizationId}:${data.connectorId}`
+          if (h.connections.has(key)) throw Object.assign(new Error('unique'), { code: 'P2002' })
+          const row = {
+            id: `c${h.connections.size + 1}`,
+            status: data.status,
+            createdById: data.createdById,
+          }
+          h.connections.set(key, row)
+          return row
+        }
+      ),
+    },
+    appSetting: {
+      findUnique: vi.fn(
+        async ({
+          where,
+        }: {
+          where: { organizationId_key: { organizationId: string; key: string } }
+        }) => {
+          const k = where.organizationId_key
+          return h.settings.has(`${k.organizationId}:${k.key}`) ? { id: 's1' } : null
+        }
+      ),
+      upsert: vi.fn(
+        async ({
+          where,
+          create,
+        }: {
+          where: { organizationId_key: { organizationId: string; key: string } }
+          create: { value: string }
+        }) => {
+          const k = where.organizationId_key
+          const key = `${k.organizationId}:${k.key}`
+          if (!h.settings.has(key)) h.settings.set(key, create.value)
+          return {}
+        }
+      ),
+    },
+  },
+}))
+vi.mock('@/lib/features', () => ({ getFeatures: vi.fn(async () => h.features) }))
+vi.mock('@/integrations/registry', () => ({
+  listManifests: () => h.manifests,
+  getManifest: (id: string) => h.manifests.find((m) => m.id === id) ?? null,
+}))
+vi.mock('@/features/integrations/Lib/vault', () => ({
+  sealCredentials: (v: unknown) => JSON.stringify(v),
+}))
+
+const { withServerKeys } =
+  await vi.importActual<typeof import('@/integrations/registry')>('@/integrations/registry')
+const { manifest: codes } = await import('@/integrations/torqvoice-codes/manifest')
+const { db } = await import('@/lib/db')
+const { autoConnect, autoConnectFor, markAutoConnectOptOut, readyToAutoConnect } = await import(
+  '@/features/integrations/Lib/auto-connect'
+)
+
+/** The codes manifest as the registry hands it out, with or without our key on the server. */
+function install(serverKey: string | null) {
+  h.manifests = [withServerKeys(codes, serverKey ? { TORQVOICE_CODES_API_TOKEN: serverKey } : {})]
+}
+
+const row = (org = 'org1') => h.connections.get(`${org}:torqvoice-codes`)
+
+beforeEach(() => {
+  h.connections.clear()
+  h.settings.clear()
+  h.features = { faultCodes: true, integrations: false }
+  vi.mocked(db.integrationConnection.create).mockClear()
+})
+
+describe('a service that is on for every workshop', () => {
+  it('is connected on the first ask, with our key and nothing typed', async () => {
+    install('tvc_live_ourkey')
+    expect(readyToAutoConnect(h.manifests[0])).toBe(true)
+    expect(await autoConnect('org1', 'torqvoice-codes')).toBe(true)
+    expect(row()).toMatchObject({ status: 'active', createdById: 'system' })
+    // Asked again, the connection it has is left alone.
+    expect(await autoConnect('org1', 'torqvoice-codes')).toBe(false)
+    expect(db.integrationConnection.create).toHaveBeenCalledTimes(1)
+  })
+
+  it('is found by the capability a screen asks for', async () => {
+    install('tvc_live_ourkey')
+    expect(await autoConnectFor('org1', 'diagnostics.codes')).toBe(true)
+    expect(await autoConnectFor('org2', 'calendar.push')).toBe(false)
+    expect(row('org2')).toBeUndefined()
+  })
+
+  it('connects nobody where the server holds no key', async () => {
+    install(null)
+    expect(readyToAutoConnect(h.manifests[0])).toBe(false)
+    expect(await autoConnect('org1', 'torqvoice-codes')).toBe(false)
+    expect(row()).toBeUndefined()
+  })
+
+  it('stays off once the workshop disconnects it', async () => {
+    install('tvc_live_ourkey')
+    await autoConnect('org1', 'torqvoice-codes')
+    // What disconnectIntegration does: the marker, then the row goes.
+    await markAutoConnectOptOut('org1', 'torqvoice-codes', 'user1')
+    h.connections.delete('org1:torqvoice-codes')
+
+    expect(await autoConnect('org1', 'torqvoice-codes')).toBe(false)
+    expect(row()).toBeUndefined()
+    // Another workshop is not affected.
+    expect(await autoConnect('org2', 'torqvoice-codes')).toBe(true)
+  })
+
+  it('never writes over a connection the workshop has, whatever its state', async () => {
+    install('tvc_live_ourkey')
+    h.connections.set('org1:torqvoice-codes', { id: 'own', status: 'error', createdById: 'user1' })
+    expect(await autoConnect('org1', 'torqvoice-codes')).toBe(false)
+    expect(row()).toMatchObject({ id: 'own', status: 'error' })
+  })
+
+  it('waits for a plan that includes it', async () => {
+    install('tvc_live_ourkey')
+    h.features = { faultCodes: false, integrations: true }
+    expect(await autoConnect('org1', 'torqvoice-codes')).toBe(false)
+    expect(row()).toBeUndefined()
+    h.features = { faultCodes: true, integrations: false }
+    expect(await autoConnect('org1', 'torqvoice-codes')).toBe(true)
+  })
+
+  it('makes one connection when two pages ask at once', async () => {
+    install('tvc_live_ourkey')
+    const both = await Promise.all([
+      autoConnect('org1', 'torqvoice-codes'),
+      autoConnect('org1', 'torqvoice-codes'),
+    ])
+    expect(both).toEqual([true, true])
+    expect(h.connections.size).toBe(1)
+  })
+
+  it('leaves no marker when an ordinary connector is disconnected', async () => {
+    h.manifests = [{ ...codes, id: 'other', autoConnect: undefined }]
+    await markAutoConnectOptOut('org1', 'other', 'user1')
+    expect(h.settings.size).toBe(0)
+  })
+})

+ 118 - 12
src/__tests__/features/integrations/registry.test.ts

@@ -19,6 +19,34 @@ const messages = JSON.parse(
   fs.readFileSync(path.join(ROOT, 'messages/en/integrations.json'), 'utf-8')
   fs.readFileSync(path.join(ROOT, 'messages/en/integrations.json'), 'utf-8')
 )
 )
 
 
+/** The registry as the cloud instance builds it. */
+async function cloudRegistry() {
+  vi.stubEnv('TORQVOICE_MODE', 'cloud')
+  vi.resetModules()
+  try {
+    return await import('@/integrations/registry')
+  } finally {
+    vi.unstubAllEnvs()
+    vi.resetModules()
+  }
+}
+
+/**
+ * Every connector the app ships, whichever install it exists on: the
+ * self-hosted list plus the cloud-only ones, each with the registry that
+ * can load it.
+ */
+async function everyConnector() {
+  const cloud = await cloudRegistry()
+  const own = listManifests().map((m) => ({ manifest: m, load: () => getConnector(m.id) }))
+  const ids = new Set(own.map((e) => e.manifest.id))
+  const cloudOnly = cloud
+    .listManifests()
+    .filter((m) => !ids.has(m.id))
+    .map((m) => ({ manifest: m, load: () => cloud.getConnector(m.id) }))
+  return [...own, ...cloudOnly]
+}
+
 /**
 /**
  * A connector is a manifest plus a server module, and the catalog, the
  * A connector is a manifest plus a server module, and the catalog, the
  * settings page and the translations all read the manifest. These checks
  * settings page and the translations all read the manifest. These checks
@@ -26,21 +54,19 @@ const messages = JSON.parse(
  * that cannot be opened.
  * that cannot be opened.
  */
  */
 describe('integration registry', () => {
 describe('integration registry', () => {
-  it('lists every connector folder and nothing else', () => {
+  it('lists every connector folder and nothing else', async () => {
     const dir = path.join(ROOT, 'src/integrations')
     const dir = path.join(ROOT, 'src/integrations')
     const folders = fs
     const folders = fs
       .readdirSync(dir, { withFileTypes: true })
       .readdirSync(dir, { withFileTypes: true })
       .filter((d) => d.isDirectory() && fs.existsSync(path.join(dir, d.name, 'manifest.ts')))
       .filter((d) => d.isDirectory() && fs.existsSync(path.join(dir, d.name, 'manifest.ts')))
       .map((d) => d.name)
       .map((d) => d.name)
       .sort()
       .sort()
-    const ids = listManifests()
-      .map((m) => m.id)
-      .sort()
+    const ids = (await everyConnector()).map((e) => e.manifest.id).sort()
     expect(ids).toEqual(folders)
     expect(ids).toEqual(folders)
   })
   })
 
 
-  it('has well-formed manifests', () => {
-    for (const m of listManifests()) {
+  it('has well-formed manifests', async () => {
+    for (const { manifest: m } of await everyConnector()) {
       expect(m.id).toMatch(/^[a-z0-9-]+$/)
       expect(m.id).toMatch(/^[a-z0-9-]+$/)
       expect(fs.existsSync(path.join(ROOT, 'public', m.logo))).toBe(true)
       expect(fs.existsSync(path.join(ROOT, 'public', m.logo))).toBe(true)
       expect(m.docs.startsWith('/docs/')).toBe(true)
       expect(m.docs.startsWith('/docs/')).toBe(true)
@@ -61,8 +87,8 @@ describe('integration registry', () => {
     }
     }
   })
   })
 
 
-  it('has English text for every label a manifest points at', () => {
-    for (const m of listManifests()) {
+  it('has English text for every label a manifest points at', async () => {
+    for (const { manifest: m } of await everyConnector()) {
       const block = messages.connectors[m.id]
       const block = messages.connectors[m.id]
       expect(block, `${m.id} has no connectors block`).toBeTruthy()
       expect(block, `${m.id} has no connectors block`).toBeTruthy()
       expect(typeof block.description).toBe('string')
       expect(typeof block.description).toBe('string')
@@ -88,8 +114,8 @@ describe('integration registry', () => {
   })
   })
 
 
   it('loads a server module whose jobs match the manifest', async () => {
   it('loads a server module whose jobs match the manifest', async () => {
-    for (const m of listManifests()) {
-      const server = await getConnector(m.id)
+    for (const { manifest: m, load } of await everyConnector()) {
+      const server = await load()
       expect(server.manifest.id).toBe(m.id)
       expect(server.manifest.id).toBe(m.id)
       const declared = new Set([
       const declared = new Set([
         ...(m.subscriptions ?? []).map((s) => s.job),
         ...(m.subscriptions ?? []).map((s) => s.job),
@@ -128,17 +154,97 @@ describe('integration registry', () => {
         expect(cloud.getManifest(m.id), m.id).toBeNull()
         expect(cloud.getManifest(m.id), m.id).toBeNull()
         await expect(cloud.getConnector(m.id), m.id).rejects.toThrow()
         await expect(cloud.getConnector(m.id), m.id).rejects.toThrow()
       }
       }
-      expect(cloud.listManifests().length).toBe(listManifests().length - selfHostedOnly.length)
+      const cloudOnly = cloud.listManifests().filter((m) => m.cloudOnly)
+      expect(cloud.listManifests().length).toBe(
+        listManifests().length - selfHostedOnly.length + cloudOnly.length
+      )
     } finally {
     } finally {
       vi.unstubAllEnvs()
       vi.unstubAllEnvs()
       vi.resetModules()
       vi.resetModules()
     }
     }
   })
   })
 
 
+  /**
+   * The mirror: a service Torqvoice runs on its own network exists on the
+   * cloud and nowhere else, so a self-hosted install neither lists it nor
+   * loads it, wherever the id comes from.
+   */
+  it('leaves cloud-only connectors out on a self-hosted install', async () => {
+    expect(listManifests().some((m) => m.cloudOnly)).toBe(false)
+    expect(getManifest('torqvoice-codes')).toBeNull()
+    await expect(getConnector('torqvoice-codes')).rejects.toThrow()
+
+    const cloud = await cloudRegistry()
+    const cloudOnly = cloud.listManifests().filter((m) => m.cloudOnly)
+    expect(cloudOnly.map((m) => m.id)).toContain('torqvoice-codes')
+    expect(cloud.getManifest('torqvoice-codes')?.name).toBe('Torqvoice Codes')
+    expect((await cloud.getConnector('torqvoice-codes')).manifest.id).toBe('torqvoice-codes')
+  })
+
+  it('decides per install which connectors exist', async () => {
+    const { availableOnThisInstall } = await import('@/integrations/registry')
+    const plain = { selfHostedOnly: undefined, cloudOnly: undefined } as never
+    expect(availableOnThisInstall(plain, false)).toBe(true)
+    expect(availableOnThisInstall(plain, true)).toBe(true)
+    expect(availableOnThisInstall({ cloudOnly: true } as never, false)).toBe(false)
+    expect(availableOnThisInstall({ cloudOnly: true } as never, true)).toBe(true)
+    expect(availableOnThisInstall({ selfHostedOnly: true } as never, true)).toBe(false)
+    expect(availableOnThisInstall({ selfHostedOnly: true } as never, false)).toBe(true)
+  })
+
+  it('lets a developer try a cloud-only connector, never in production', async () => {
+    const { availableOnThisInstall, devCloudConnectors } = await import('@/integrations/registry')
+    const codes = { id: 'torqvoice-codes', cloudOnly: true } as never
+    const other = { id: 'some-other', cloudOnly: true } as never
+
+    const dev = devCloudConnectors({
+      NODE_ENV: 'development',
+      TORQVOICE_DEV_CLOUD_CONNECTORS: ' torqvoice-codes , ',
+    })
+    expect(availableOnThisInstall(codes, false, dev)).toBe(true)
+    expect(availableOnThisInstall(other, false, dev)).toBe(false)
+
+    const prod = devCloudConnectors({
+      NODE_ENV: 'production',
+      TORQVOICE_DEV_CLOUD_CONNECTORS: 'torqvoice-codes',
+    })
+    expect(prod.size).toBe(0)
+    expect(availableOnThisInstall(codes, false, prod)).toBe(false)
+
+    expect(devCloudConnectors({ NODE_ENV: 'development' }).size).toBe(0)
+  })
+
+  /**
+   * On the cloud Torqvoice holds the code service key for every workshop, so
+   * there is nothing to type and no key of its own to put in its place. A
+   * self-hosted install, with no key set, would be asked for one.
+   */
+  it('leaves out a key field the server holds for everyone', async () => {
+    const { withServerKeys } = await import('@/integrations/registry')
+    const { manifest } = await import('@/integrations/torqvoice-codes/manifest')
+    const fields = (m: typeof manifest) => (m.auth.type === 'api-key' ? m.auth.fields : [])
+    expect(fields(withServerKeys(manifest, {})).map((f) => [f.key, f.required])).toEqual([
+      ['apiToken', true],
+    ])
+    expect(fields(withServerKeys(manifest, { TORQVOICE_CODES_API_TOKEN: 'tvc_live_x' }))).toEqual(
+      []
+    )
+    expect(fields(withServerKeys(manifest, { TORQVOICE_CODES_API_TOKEN: '  ' }))).toHaveLength(1)
+    expect(manifest.autoConnect).toBe(true)
+  })
+
+  it('shows cloud-only connectors as cards on a self-hosted install only', async () => {
+    const { cloudOnlyElsewhere } = await import('@/integrations/registry')
+    expect(cloudOnlyElsewhere().map((m) => m.id)).toContain('torqvoice-codes')
+    expect(cloudOnlyElsewhere(true)).toEqual([])
+    // Tried on a dev machine through the dev switch, it is a real connector, not a card.
+    expect(cloudOnlyElsewhere(false, (id) => id === 'torqvoice-codes')).toEqual([])
+  })
+
   it('subscribes only to events the webhook dispatcher knows', async () => {
   it('subscribes only to events the webhook dispatcher knows', async () => {
     const { WEBHOOK_EVENTS } = await import('@/features/webhooks/Schema/webhookSchema')
     const { WEBHOOK_EVENTS } = await import('@/features/webhooks/Schema/webhookSchema')
     const known = new Set<string>(WEBHOOK_EVENTS)
     const known = new Set<string>(WEBHOOK_EVENTS)
-    for (const m of listManifests()) {
+    for (const { manifest: m } of await everyConnector()) {
       for (const s of m.subscriptions ?? [])
       for (const s of m.subscriptions ?? [])
         expect(known.has(s.event), `${m.id} subscribes to ${s.event}`).toBe(true)
         expect(known.has(s.event), `${m.id} subscribes to ${s.event}`).toBe(true)
     }
     }

+ 488 - 0
src/__tests__/features/integrations/torqvoice-codes.test.ts

@@ -0,0 +1,488 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { type ConnectorContext, FaultCodesUnavailable } from '@/features/integrations/Lib/types'
+import { connector, mapAnswer } from '@/integrations/torqvoice-codes/server'
+
+/**
+ * The code service's connector against replies shaped exactly like the API
+ * contract (v1): what a workshop reads comes from these mappings, and a
+ * service that is down or slow must turn into "not available", never an
+ * error on the screen.
+ */
+
+function ctx(credentials: Record<string, unknown> = {}): ConnectorContext {
+  return {
+    connection: {
+      id: 'c1',
+      organizationId: 'org1',
+      connectorId: 'torqvoice-codes',
+      settings: {},
+      state: {},
+      externalAccountId: null,
+    },
+    credentials,
+    http: { fetch: vi.fn(), json: vi.fn() },
+    links: {} as ConnectorContext['links'],
+    log: vi.fn(async () => undefined),
+    saveState: vi.fn(async () => undefined),
+    timezone: 'UTC',
+    appUrl: 'http://localhost',
+  } as unknown as ConnectorContext
+}
+
+function reply(status: number, body: unknown): Response {
+  return new Response(JSON.stringify(body), {
+    status,
+    headers: { 'content-type': 'application/json', 'x-request-id': 'req-1' },
+  })
+}
+
+/** P0420 as the service answers it in Norwegian, with details. */
+const P0420 = {
+  code: 'P0420',
+  found: true,
+  meaning: {
+    system: 'P',
+    category: 'powertrain',
+    scope: 'generic',
+    source: 'standard',
+    version: 3,
+    verified: true,
+    pending_check: false,
+    title: {
+      text: 'Katalysatorsystem virkningsgrad under terskel (bank 1)',
+      lang: 'nb',
+      machine_translated: true,
+    },
+    flags: { warning_light: true, emissions: true, drive_cycle: false, limp_mode: false },
+    related: [{ code: 'P0430', title: 'Bank 2' }, { code: 'P0136' }],
+    details: {
+      description: {
+        text: 'The catalyst stores oxygen. The code sets when it no longer does.',
+        lang: 'en',
+        machine_translated: false,
+      },
+      causes: [
+        {
+          key: 'aged',
+          likelihood: 'high',
+          label: { text: 'Catalyst aged', lang: 'nb', machine_translated: true },
+        },
+      ],
+    },
+  },
+}
+
+let fetchMock: ReturnType<typeof vi.fn>
+
+beforeEach(() => {
+  fetchMock = vi.fn()
+  vi.stubGlobal('fetch', fetchMock)
+  vi.stubEnv('TORQVOICE_CODES_URL', 'http://torqvoice-codes:8090/')
+  vi.stubEnv('TORQVOICE_CODES_API_TOKEN', 'tvc_test_server_key')
+})
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  vi.unstubAllEnvs()
+})
+
+describe('Torqvoice Codes lookups', () => {
+  it('asks for every code at once with the vehicle and the language, and maps the answers', async () => {
+    fetchMock.mockResolvedValueOnce(
+      reply(200, {
+        data_version: 15,
+        results: [
+          P0420,
+          { code: 'P1570', found: false },
+          { code: 'BANANA', found: false, error: 'not_a_code' },
+        ],
+      })
+    )
+    const answers = await connector.lookupCodes!(ctx(), {
+      codes: ['P0420', 'P1570', 'BANANA'],
+      vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: '' },
+      locale: 'nb',
+    })
+
+    const [url, init] = fetchMock.mock.calls[0]
+    expect(url).toBe('http://torqvoice-codes:8090/v1/lookups')
+    expect(init.method).toBe('POST')
+    expect(init.headers.Authorization).toBe('Bearer tvc_test_server_key')
+    expect(JSON.parse(init.body)).toEqual({
+      codes: ['P0420', 'P1570', 'BANANA'],
+      vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014 },
+      lang: 'nb',
+      details: false,
+    })
+
+    expect(answers).toHaveLength(3)
+    const meaning = answers[0].meaning!
+    expect(meaning.title).toEqual({
+      text: 'Katalysatorsystem virkningsgrad under terskel (bank 1)',
+      lang: 'nb',
+      machineTranslated: true,
+    })
+    expect(meaning.flags).toEqual({
+      warningLight: true,
+      emissions: true,
+      driveCycle: false,
+      limpMode: false,
+    })
+    expect(meaning.related).toEqual([{ code: 'P0430', title: 'Bank 2' }, { code: 'P0136' }])
+    expect(meaning.causes[0]).toEqual({
+      key: 'aged',
+      likelihood: 'high',
+      label: { text: 'Catalyst aged', lang: 'nb', machineTranslated: true },
+    })
+    expect(meaning.version).toBe(3)
+    expect(answers[1]).toEqual({ code: 'P1570', found: false, range: 'manufacturer' })
+    expect(answers[2]).toEqual({ code: 'BANANA', found: false, range: 'standard' })
+  })
+
+  it('keeps the range the service gives, and works it out from the code when it gives none', () => {
+    // The contract's later answer: type and range on every valid code.
+    expect(mapAnswer({ code: 'P0420', type: 'obd', range: 'standard', found: false })).toEqual({
+      code: 'P0420',
+      found: false,
+      range: 'standard',
+    })
+    expect(
+      mapAnswer({ code: 'B1A2F', type: 'obd', range: 'manufacturer', found: false })?.range
+    ).toBe('manufacturer')
+    // The running service today: no range, so the SAE split decides.
+    expect(mapAnswer({ code: 'P1570', found: false })?.range).toBe('manufacturer')
+    expect(mapAnswer({ code: 'P3001', found: false })?.range).toBe('manufacturer')
+    expect(mapAnswer({ code: 'P3400', found: false })?.range).toBe('standard')
+    expect(mapAnswer({ code: 'U0100', found: false })?.range).toBe('standard')
+    expect(mapAnswer({ code: 'C1234', found: false })?.range).toBe('manufacturer')
+    expect(mapAnswer(P0420)?.range).toBe('standard')
+  })
+
+  it("uses the workshop's own key over the server's", async () => {
+    fetchMock.mockResolvedValueOnce(reply(200, { results: [] }))
+    await connector.lookupCodes!(ctx({ apiToken: ' tvc_test_own ' }), {
+      codes: ['P0420'],
+      locale: 'en',
+    })
+    expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer tvc_test_own')
+  })
+
+  it('gives a detail with sections when the service sends them, and without when it does not', () => {
+    const withSections = mapAnswer({
+      ...P0420,
+      meaning: {
+        ...P0420.meaning,
+        details: {
+          ...P0420.meaning.details,
+          sections: [
+            {
+              kind: 'how_it_works',
+              text: { text: 'The catalyst stores oxygen.', lang: 'en', machine_translated: false },
+            },
+            {
+              kind: 'something_new',
+              text: { text: 'More text.', lang: 'en', machine_translated: false },
+            },
+          ],
+        },
+      },
+    })
+    expect(withSections?.meaning?.sections).toEqual([
+      {
+        kind: 'how_it_works',
+        text: { text: 'The catalyst stores oxygen.', lang: 'en', machineTranslated: false },
+      },
+      { kind: 'other', text: { text: 'More text.', lang: 'en', machineTranslated: false } },
+    ])
+    const without = mapAnswer(P0420)
+    expect(without?.meaning?.sections).toBeUndefined()
+    expect(without?.meaning?.description?.text).toContain('stores oxygen')
+  })
+
+  it('reads one code with details, and says null for a code the service does not have', async () => {
+    fetchMock.mockResolvedValueOnce(reply(200, P0420))
+    const answer = await connector.codeDetail!(ctx(), {
+      code: 'P0420',
+      vehicle: { make: 'Volkswagen' },
+      locale: 'de',
+    })
+    expect(fetchMock.mock.calls[0][0]).toBe(
+      'http://torqvoice-codes:8090/v1/codes/P0420?lang=de&details=true&make=Volkswagen'
+    )
+    expect(answer?.meaning?.description?.text).toContain('stores oxygen')
+
+    fetchMock.mockResolvedValueOnce(reply(404, { title: 'Not Found', status: 404 }))
+    expect(await connector.codeDetail!(ctx(), { code: 'P1570', locale: 'en' })).toBeNull()
+  })
+
+  it('searches by prefix or words with a small limit', async () => {
+    fetchMock.mockResolvedValueOnce(
+      reply(200, {
+        results: [
+          {
+            code: 'P0171',
+            title: { text: 'System too lean (bank 1)', lang: 'en', machine_translated: false },
+          },
+        ],
+      })
+    )
+    const hits = await connector.searchCodes!(ctx(), {
+      query: 'lean bank 1',
+      locale: 'en',
+      limit: 5,
+    })
+    expect(fetchMock.mock.calls[0][0]).toBe(
+      'http://torqvoice-codes:8090/v1/search?q=lean+bank+1&lang=en&limit=5'
+    )
+    expect(hits).toEqual([
+      {
+        code: 'P0171',
+        title: { text: 'System too lean (bank 1)', lang: 'en', machineTranslated: false },
+      },
+    ])
+  })
+
+  it('sends a report with nothing that identifies the car, and reads report statuses back', async () => {
+    fetchMock.mockResolvedValueOnce(reply(201, { id: 'r1', status: 'open' }))
+    const receipt = await connector.reportDefinition!(ctx(), {
+      code: 'P0420',
+      version: 3,
+      lang: 'nb',
+      reason: 'wrong_for_vehicle',
+      note: 'Our tester says rear O2 sensor',
+      scanText: '',
+      vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: '1.4 TSI' },
+      automatic: false,
+      workshopRef: 'org1',
+    })
+    expect(receipt).toEqual({ id: 'r1', status: 'open' })
+    const sent = JSON.parse(fetchMock.mock.calls[0][1].body)
+    expect(sent).toEqual({
+      code: 'P0420',
+      version: 3,
+      lang: 'nb',
+      reason: 'wrong_for_vehicle',
+      note: 'Our tester says rear O2 sensor',
+      vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: '1.4 TSI' },
+      automatic: false,
+      workshop_ref: 'org1',
+    })
+
+    fetchMock.mockResolvedValueOnce(
+      reply(200, {
+        reports: [
+          {
+            id: 'r1',
+            code: 'P0420',
+            status: 'resolved',
+            outcome: 'fixed',
+            resolved_at: '2026-11-06T09:00:00Z',
+            message: 'Title corrected',
+          },
+        ],
+      })
+    )
+    const statuses = await connector.reportStatus!(ctx(), ['r1', 'r1'])
+    expect(fetchMock.mock.calls[1][0]).toBe(
+      'http://torqvoice-codes:8090/v1/definition-reports?ids=r1'
+    )
+    expect(statuses).toEqual([
+      {
+        id: 'r1',
+        code: 'P0420',
+        status: 'resolved',
+        outcome: 'fixed',
+        resolvedAt: '2026-11-06T09:00:00Z',
+        message: 'Title corrected',
+      },
+    ])
+  })
+})
+
+/** A fix as the service answers it. */
+const FIX = {
+  id: '0190f3c2-7d1a-7000-8000-000000000001',
+  code: 'P0420',
+  lang: 'nb',
+  text: 'Gnagd ledning ved pluggen bak girkassen',
+  vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: 'CAXA' },
+  photos: [{ n: 0, width: 1200, height: 900 }],
+  created_at: '2026-11-05T10:00:00Z',
+}
+
+describe('Torqvoice Codes shared fixes', () => {
+  it('sends a fix as multipart with its photos, the vehicle and nothing that names the car', async () => {
+    fetchMock.mockResolvedValueOnce(reply(201, { id: FIX.id, status: 'pending' }))
+    const photo = new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' })
+    const result = await connector.shareFix!(ctx(), {
+      code: 'P0420',
+      text: 'Chafed wire at the rear O2 sensor plug',
+      lang: 'nb',
+      vehicle: { make: ' Volkswagen ', model: 'Golf', year: 2014, engine: 'CAXA' },
+      workshopRef: 'org1',
+      photos: [photo, photo],
+    })
+    expect(result).toEqual({ ok: true, id: FIX.id })
+    const [url, init] = fetchMock.mock.calls[0]
+    expect(url).toBe('http://torqvoice-codes:8090/v1/fixes')
+    expect(init.method).toBe('POST')
+    expect(init.headers.Authorization).toBe('Bearer tvc_test_server_key')
+    const form = init.body as FormData
+    expect(JSON.parse(await (form.get('fix') as Blob).text())).toEqual({
+      code: 'P0420',
+      text: 'Chafed wire at the rear O2 sensor plug',
+      lang: 'nb',
+      vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: 'CAXA' },
+      workshop_ref: 'org1',
+    })
+    expect(form.getAll('photo')).toHaveLength(2)
+  })
+
+  it('says why the service would not take a fix', async () => {
+    const send = () =>
+      connector.shareFix!(ctx(), {
+        code: 'P0420',
+        text: 'x',
+        lang: 'en',
+        workshopRef: 'org1',
+        photos: [],
+      })
+    fetchMock.mockResolvedValueOnce(reply(503, { detail: 'not taking new fixes' }))
+    expect(await send()).toEqual({ ok: false, reason: 'paused' })
+    fetchMock.mockResolvedValueOnce(reply(400, { detail: 'photo 1: not a JPEG' }))
+    expect(await send()).toEqual({ ok: false, reason: 'photo' })
+    fetchMock.mockResolvedValueOnce(reply(413, { detail: 'photo 2 is too large' }))
+    expect(await send()).toEqual({ ok: false, reason: 'too_large' })
+    fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'))
+    await expect(send()).rejects.toBeInstanceOf(FaultCodesUnavailable)
+  })
+
+  it('reads published fixes and its own with their review', async () => {
+    fetchMock.mockResolvedValueOnce(reply(200, { fixes: [FIX] }))
+    const published = await connector.publishedFixes!(ctx(), { code: 'P0420' })
+    expect(fetchMock.mock.calls[0][0]).toBe(
+      'http://torqvoice-codes:8090/v1/codes/P0420/fixes?limit=20'
+    )
+    expect(published).toEqual([
+      {
+        id: FIX.id,
+        code: 'P0420',
+        lang: 'nb',
+        text: FIX.text,
+        vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: 'CAXA' },
+        photos: [{ n: 0, width: 1200, height: 900 }],
+        createdAt: '2026-11-05T10:00:00Z',
+      },
+    ])
+
+    fetchMock.mockResolvedValueOnce(
+      reply(200, {
+        fixes: [
+          {
+            ...FIX,
+            vehicle: null,
+            status: 'rejected',
+            reason: 'The photo shows a plate.',
+            reviewed_at: '2026-11-06T09:00:00Z',
+          },
+        ],
+      })
+    )
+    const own = await connector.fixStatus!(ctx(), [FIX.id, FIX.id])
+    expect(fetchMock.mock.calls[1][0]).toBe(`http://torqvoice-codes:8090/v1/fixes?ids=${FIX.id}`)
+    expect(own[0]).toMatchObject({
+      vehicle: null,
+      status: 'rejected',
+      reason: 'The photo shows a plate.',
+      reviewedAt: '2026-11-06T09:00:00Z',
+    })
+  })
+
+  it('withdraws with the workshop, and streams a photo or says there is none', async () => {
+    fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }))
+    expect(await connector.withdrawFix!(ctx(), { id: FIX.id, workshopRef: 'org1' })).toBe(true)
+    expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ workshop_ref: 'org1' })
+    fetchMock.mockResolvedValueOnce(reply(404, { detail: 'no fix with that id' }))
+    expect(await connector.withdrawFix!(ctx(), { id: FIX.id, workshopRef: 'org1' })).toBe(false)
+
+    fetchMock.mockResolvedValueOnce(
+      new Response(new Uint8Array([0xff, 0xd8, 0xff]), {
+        status: 200,
+        headers: { 'content-type': 'image/jpeg', 'content-length': '3' },
+      })
+    )
+    const photo = await connector.fixPhoto!(ctx(), {
+      id: FIX.id,
+      n: 0,
+      size: 'thumb',
+      workshopRef: 'org1',
+    })
+    expect(fetchMock.mock.calls[2][0]).toBe(
+      `http://torqvoice-codes:8090/v1/fixes/${FIX.id}/photos/0?size=thumb&workshop_ref=org1`
+    )
+    expect(photo?.length).toBe(3)
+    expect(new Uint8Array(await new Response(photo?.body).arrayBuffer())).toEqual(
+      new Uint8Array([0xff, 0xd8, 0xff])
+    )
+    fetchMock.mockResolvedValueOnce(reply(404, { detail: 'no such photo' }))
+    expect(
+      await connector.fixPhoto!(ctx(), { id: FIX.id, n: 1, size: 'full', workshopRef: 'org1' })
+    ).toBeNull()
+  })
+})
+
+describe('Torqvoice Codes when the service is not there', () => {
+  it('turns a connection failure into FaultCodesUnavailable and logs it', async () => {
+    fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'))
+    const c = ctx()
+    await expect(
+      connector.lookupCodes!(c, { codes: ['P0420'], locale: 'en' })
+    ).rejects.toBeInstanceOf(FaultCodesUnavailable)
+    expect(c.log).toHaveBeenCalledWith(
+      'warn',
+      'The code service could not be reached',
+      expect.any(Object)
+    )
+  })
+
+  it('turns a timeout, a 5xx and an unreadable answer into FaultCodesUnavailable', async () => {
+    fetchMock.mockRejectedValueOnce(new DOMException('The operation timed out.', 'TimeoutError'))
+    await expect(
+      connector.searchCodes!(ctx(), { query: 'p04', locale: 'en' })
+    ).rejects.toBeInstanceOf(FaultCodesUnavailable)
+    fetchMock.mockResolvedValueOnce(reply(503, { title: 'Service Unavailable', status: 503 }))
+    await expect(
+      connector.lookupCodes!(ctx(), { codes: ['P0420'], locale: 'en' })
+    ).rejects.toBeInstanceOf(FaultCodesUnavailable)
+    fetchMock.mockResolvedValueOnce(reply(200, { nothing: true }))
+    await expect(
+      connector.lookupCodes!(ctx(), { codes: ['P0420'], locale: 'en' })
+    ).rejects.toBeInstanceOf(FaultCodesUnavailable)
+  })
+
+  it('does not call out at all without an address, and says so on the connect check', async () => {
+    vi.stubEnv('TORQVOICE_CODES_URL', '')
+    await expect(
+      connector.lookupCodes!(ctx(), { codes: ['P0420'], locale: 'en' })
+    ).rejects.toBeInstanceOf(FaultCodesUnavailable)
+    expect(fetchMock).not.toHaveBeenCalled()
+    expect(await connector.test(ctx())).toEqual({
+      ok: false,
+      message: 'The code service is not set up on this server',
+    })
+  })
+
+  it('tells a refused key apart from a service that is down on the connect check', async () => {
+    fetchMock.mockResolvedValueOnce(reply(200, { results: [] }))
+    expect(await connector.test(ctx())).toEqual({ ok: true })
+    fetchMock.mockResolvedValueOnce(
+      reply(401, { title: 'Unauthorized', status: 401, detail: 'revoked' })
+    )
+    expect(await connector.test(ctx())).toEqual({
+      ok: false,
+      message: 'Torqvoice Codes refused the key',
+    })
+    fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'))
+    expect((await connector.test(ctx())).ok).toBe(false)
+  })
+})

+ 4 - 0
src/__tests__/lib/files/references.test.ts

@@ -46,6 +46,10 @@ const NOT_FILES: Record<string, string> = {
     'frozen issuer and customer details; the issued logo is bytes in DocumentAssetSnapshot. The linked inspection results in it remember where a defect photo was, as a note and not a use: the photo belongs to the inspection, and a print leaves out one that has gone',
     'frozen issuer and customer details; the issued logo is bytes in DocumentAssetSnapshot. The linked inspection results in it remember where a defect photo was, as a note and not a use: the photo belongs to the inspection, and a print leaves out one that has gone',
   'ServiceRecord.issuedLogoSnapshotId': 'the id of a row holding the logo bytes',
   'ServiceRecord.issuedLogoSnapshotId': 'the id of a row holding the logo bytes',
   'ServiceAttachment.fileName': 'a label',
   'ServiceAttachment.fileName': 'a label',
+  'ServiceAttachment.scan':
+    'what a fault code scan read out of the file: codes, VIN, odometer; the file is the row itself',
+  'VehicleFinding.sourceAttachmentId':
+    'the id of the diagnostic report a fault code was read from; the file is that row’s fileUrl',
   'ServiceAttachment.fileType': 'a MIME type',
   'ServiceAttachment.fileType': 'a MIME type',
   'QuoteAttachment.fileName': 'a label',
   'QuoteAttachment.fileName': 'a label',
   'QuoteAttachment.fileType': 'a MIME type',
   'QuoteAttachment.fileType': 'a MIME type',

+ 57 - 0
src/__tests__/lib/fix-handoff-token.test.ts

@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import {
+  createFixHandoffToken,
+  createInspectionHandoffToken,
+  isFixHandoffToken,
+  verifyFixHandoffToken,
+  verifyInspectionHandoffToken,
+} from '@/lib/photo-handoff'
+
+const HANDOFF = {
+  organizationId: 'org1',
+  code: 'P0420',
+  draftId: '3f2b1c4d-5e6f-4a7b-8c9d-0123456789ab',
+  userId: 'user1',
+}
+
+beforeEach(() => {
+  process.env.BETTER_AUTH_SECRET = 'test-secret-for-fix-handoff'
+})
+
+describe("a fix's phone code", () => {
+  it('reads back what it was made for', () => {
+    const { token } = createFixHandoffToken(HANDOFF)
+    expect(isFixHandoffToken(token)).toBe(true)
+    const check = verifyFixHandoffToken(token)
+    expect(check.ok && check.handoff).toMatchObject(HANDOFF)
+  })
+
+  it('stops working after 30 minutes, and says so', () => {
+    const now = Date.now()
+    const { token } = createFixHandoffToken(HANDOFF, now)
+    expect(verifyFixHandoffToken(token, now + 31 * 60 * 1000)).toEqual({
+      ok: false,
+      reason: 'expired',
+    })
+  })
+
+  it('refuses a changed draft', () => {
+    const { token } = createFixHandoffToken(HANDOFF)
+    const [prefix, body, signature] = token.split('.')
+    const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
+    payload.d = '00000000-0000-4000-8000-000000000000'
+    const forged = `${prefix}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.${signature}`
+    expect(verifyFixHandoffToken(forged)).toEqual({ ok: false, reason: 'invalid' })
+  })
+
+  it('cannot pass for an inspection code, nor the other way round', () => {
+    const fix = createFixHandoffToken(HANDOFF).token
+    const inspection = createInspectionHandoffToken({
+      organizationId: 'org1',
+      inspectionId: 'i1',
+      userId: 'user1',
+    }).token
+    expect(verifyInspectionHandoffToken(fix).ok).toBe(false)
+    expect(verifyFixHandoffToken(inspection).ok).toBe(false)
+  })
+})

+ 71 - 0
src/app/(authenticated)/fault-codes/[code]/page.tsx

@@ -0,0 +1,71 @@
+import { redirect } from 'next/navigation'
+import { getTranslations } from 'next-intl/server'
+import { PageHeader } from '@/components/page-header'
+import { getFaultCodeDetail } from '@/features/fault-codes/Actions/getFaultCodeDetail'
+import { FaultCodeDetail } from '@/features/fault-codes/Components/FaultCodeDetail'
+import { normalizeFaultCode } from '@/features/fault-codes/Lib/codes'
+import { isSupportEnabled } from '@/lib/support'
+
+/**
+ * One fault code's own page, so search results and links have an address.
+ * `?vehicle=<id>` reads the meaning for that vehicle's make, and a fix
+ * shared from here carries that vehicle's make, model, year and engine.
+ */
+export default async function FaultCodePage({
+  params,
+  searchParams,
+}: {
+  params: Promise<{ code: string }>
+  searchParams: Promise<{ vehicle?: string }>
+}) {
+  const [{ code: raw }, { vehicle }] = await Promise.all([params, searchParams])
+  const code = normalizeFaultCode(decodeURIComponent(raw))
+  if (!code) {
+    const t = await getTranslations('faultCodes.page')
+    return (
+      <>
+        <PageHeader />
+        <div className="flex h-[50vh] flex-col items-center justify-center gap-1 px-4 text-center">
+          <p className="font-medium">
+            {t('notACode', { text: decodeURIComponent(raw).slice(0, 40) })}
+          </p>
+          <p className="text-sm text-muted-foreground">{t('notACodeHint')}</p>
+        </div>
+      </>
+    )
+  }
+  // One address per code: p0420 is P0420.
+  if (code !== raw)
+    redirect(`/fault-codes/${code}${vehicle ? `?vehicle=${encodeURIComponent(vehicle)}` : ''}`)
+
+  const [result, supportEnabled] = await Promise.all([
+    getFaultCodeDetail({ code, vehicleId: vehicle ?? null, fixes: true }),
+    isSupportEnabled(),
+  ])
+  if (!result.success || !result.data) {
+    return (
+      <>
+        <PageHeader />
+        <div className="flex h-[50vh] items-center justify-center">
+          <p className="text-muted-foreground">{result.error}</p>
+        </div>
+      </>
+    )
+  }
+
+  return (
+    <>
+      <PageHeader />
+      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+        <div className="mx-auto w-full max-w-[100rem]">
+          <FaultCodeDetail
+            key={code}
+            data={result.data}
+            layout="page"
+            supportEnabled={supportEnabled}
+          />
+        </div>
+      </div>
+    </>
+  )
+}

+ 46 - 0
src/app/(authenticated)/fault-codes/page.tsx

@@ -0,0 +1,46 @@
+import { redirect } from 'next/navigation'
+import { PageHeader } from '@/components/page-header'
+import { getFaultCodeOverview } from '@/features/fault-codes/Actions/getFaultCodeOverview'
+import { FaultCodeLookup } from '@/features/fault-codes/Components/FaultCodeLookup'
+import { normalizeFaultCode } from '@/features/fault-codes/Lib/codes'
+import { isSupportEnabled } from '@/lib/support'
+
+/**
+ * Fault codes: a lookup box for a code or for words, the meanings that match
+ * the words, this person's recent lookups and the codes read lately in this
+ * workshop.
+ */
+export default async function FaultCodesPage({
+  searchParams,
+}: {
+  searchParams: Promise<{ q?: string }>
+}) {
+  const { q = '' } = await searchParams
+  // A code typed in the address bar or the palette goes straight to its page.
+  const code = normalizeFaultCode(q)
+  if (code) redirect(`/fault-codes/${code}`)
+
+  const [result, supportEnabled] = await Promise.all([
+    getFaultCodeOverview({ query: q.slice(0, 100) }),
+    isSupportEnabled(),
+  ])
+  if (!result.success || !result.data) {
+    return (
+      <>
+        <PageHeader />
+        <div className="flex h-[50vh] items-center justify-center">
+          <p className="text-muted-foreground">{result.error}</p>
+        </div>
+      </>
+    )
+  }
+
+  return (
+    <>
+      <PageHeader />
+      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+        <FaultCodeLookup overview={result.data} supportEnabled={supportEnabled} />
+      </div>
+    </>
+  )
+}

+ 8 - 0
src/app/(authenticated)/layout.tsx

@@ -42,6 +42,7 @@ import { db } from '@/lib/db'
 import { isDemoMode } from '@/lib/demo'
 import { isDemoMode } from '@/lib/demo'
 import { isTireHotelEnabled } from '@/features/tire-hotel/Lib/tireHotelSettings'
 import { isTireHotelEnabled } from '@/features/tire-hotel/Lib/tireHotelSettings'
 import { findLookupConnection } from '@/features/integrations/Lib/vehicle-lookup'
 import { findLookupConnection } from '@/features/integrations/Lib/vehicle-lookup'
+import { faultCodesConnected } from '@/features/fault-codes/Lib/service.server'
 import { isAiConfigured } from '@/features/integrations/Lib/ai'
 import { isAiConfigured } from '@/features/integrations/Lib/ai'
 import { WORKSHOP_CHAT_ENABLED } from '@/features/ai/constants'
 import { WORKSHOP_CHAT_ENABLED } from '@/features/ai/constants'
 import { getManifest, listManifests } from '@/integrations/registry'
 import { getManifest, listManifests } from '@/integrations/registry'
@@ -239,6 +240,12 @@ export default async function DashboardLayout({ children }: { children: React.Re
       : null
       : null
   const plateLookupAvailable = lookupConnection !== null
   const plateLookupAvailable = lookupConnection !== null
 
 
+  // The palette's fault code groups and the fault code page, once the code
+  // service is connected. Off on a self-hosted install, where it does not exist.
+  const faultCodesEnabled =
+    visibleSubjects.includes(PermissionSubject.VEHICLES) &&
+    (await faultCodesConnected(data.organizationId))
+
   // What the search palette can reach without asking the server: which
   // What the search palette can reach without asking the server: which
   // settings pages exist on this install, and the connectors the catalog
   // settings pages exist on this install, and the connectors the catalog
   // offers, trimmed to a name and a logo.
   // offers, trimmed to a name and a logo.
@@ -400,6 +407,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
                                 supportEnabled={supportEnabled}
                                 supportEnabled={supportEnabled}
                                 isMarine={data.serviceType === 'marine'}
                                 isMarine={data.serviceType === 'marine'}
                                 connectors={searchConnectors}
                                 connectors={searchConnectors}
+                                faultCodesEnabled={faultCodesEnabled}
                               />
                               />
                               <PlateLookupCommand />
                               <PlateLookupCommand />
                               {isOwnerOrAdmin && <NotificationInitializer />}
                               {isOwnerOrAdmin && <NotificationInitializer />}

+ 52 - 1
src/app/(authenticated)/settings/integrations/integrations-catalog.tsx

@@ -6,7 +6,16 @@ import Image from 'next/image'
 import Link from 'next/link'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { useSearchParams } from 'next/navigation'
 import { useSearchParams } from 'next/navigation'
-import { AlertTriangle, ArrowRight, Check, Lightbulb, Plug, Search, Settings2 } from 'lucide-react'
+import {
+  AlertTriangle,
+  ArrowRight,
+  Check,
+  Cloud,
+  Lightbulb,
+  Plug,
+  Search,
+  Settings2,
+} from 'lucide-react'
 import { AppCard } from '@/components/app-card'
 import { AppCard } from '@/components/app-card'
 import { Badge } from '@/components/ui/badge'
 import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
@@ -255,6 +264,48 @@ export function IntegrationsCatalog({
           <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
           <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
             {visible.map((entry) => {
             {visible.map((entry) => {
               const m = entry.manifest
               const m = entry.manifest
+              // A cloud service seen from a self-hosted install: the same card,
+              // greyed, saying where it is offered, with nothing to open.
+              if (entry.cloudOnly) {
+                return (
+                  <div
+                    key={m.id}
+                    aria-disabled="true"
+                    className="flex flex-col gap-3 rounded-lg border border-dashed bg-muted/30 p-4"
+                  >
+                    <div className="flex items-start gap-3">
+                      <Image
+                        src={m.logo}
+                        alt=""
+                        width={40}
+                        height={40}
+                        className="h-10 w-10 shrink-0 rounded-lg opacity-60 grayscale"
+                        unoptimized
+                      />
+                      <div className="min-w-0 flex-1">
+                        <div className="flex flex-wrap items-center gap-2">
+                          <span className="font-medium text-muted-foreground">
+                            {connectorName(t, m)}
+                          </span>
+                          <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
+                            {t('catalog.cloudOnly')}
+                          </span>
+                        </div>
+                        <p className="mt-0.5 text-xs text-muted-foreground">
+                          {t(`connectors.${m.id}.description`)}
+                        </p>
+                      </div>
+                    </div>
+                    <div className="mt-auto flex items-center justify-between gap-2 text-xs">
+                      <span className="text-muted-foreground">{t(`categories.${m.category}`)}</span>
+                      <span className="inline-flex items-center gap-1 text-muted-foreground">
+                        <Cloud className="h-3.5 w-3.5" />
+                        {t('catalog.cloudOnlyHint')}
+                      </span>
+                    </div>
+                  </div>
+                )
+              }
               return (
               return (
                 <Link
                 <Link
                   key={m.id}
                   key={m.id}

+ 232 - 0
src/app/(public)/p/[token]/fix-handoff-client.tsx

@@ -0,0 +1,232 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Camera, Check, ImagePlus, Loader2, TriangleAlert, X } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+type Upload = { key: string; preview: string; status: 'uploading' | 'added' | 'failed' }
+
+function initials(name: string): string {
+  return (
+    name
+      .split(/\s+/)
+      .filter(Boolean)
+      .slice(0, 2)
+      .map((w) => w[0]?.toUpperCase() ?? '')
+      .join('') || '?'
+  )
+}
+
+/**
+ * The phone side of a fix's "Add from phone" code: take or pick up to four
+ * photos of what fixed the code, and they appear in the share dialog on the
+ * computer. Shows the workshop, the code and nothing else.
+ */
+export function FixHandoffClient({
+  token,
+  brand,
+  code,
+  problem,
+}: {
+  token: string
+  brand: { name: string; logoUrl: string | null } | null
+  code: string | null
+  problem?: 'invalid' | 'expired'
+}) {
+  const t = useTranslations('faultCodes.phone')
+  const [logoFailed, setLogoFailed] = useState(false)
+  const [count, setCount] = useState(0)
+  const [max, setMax] = useState(4)
+  const [uploads, setUploads] = useState<Upload[]>([])
+  const [error, setError] = useState<string | null>(null)
+  const [expired, setExpired] = useState(problem === 'expired')
+  const camera = useRef<HTMLInputElement>(null)
+  const library = useRef<HTMLInputElement>(null)
+  const url = `/api/public/fix-handoff/${encodeURIComponent(token)}`
+
+  // A reopened page starts from what the draft already holds.
+  useEffect(() => {
+    if (problem) return
+    fetch(url)
+      .then((r) => (r.ok ? r.json() : null))
+      .then((body: { count: number; max: number } | null) => {
+        if (!body) return
+        setCount(body.count)
+        setMax(body.max)
+      })
+      .catch(() => null)
+  }, [url, problem])
+
+  const send = async (files: FileList | null) => {
+    if (!files || files.length === 0) return
+    setError(null)
+    let room = max - count
+    for (const file of Array.from(files)) {
+      if (room <= 0) {
+        setError(t('full', { max }))
+        break
+      }
+      room--
+      const key = `${Date.now()}-${Math.random()}`
+      const preview = URL.createObjectURL(file)
+      setUploads((u) => [...u, { key, preview, status: 'uploading' }])
+      const body = new FormData()
+      body.append('file', file)
+      const res = await fetch(url, { method: 'POST', body }).catch(() => null)
+      const reply = (await res?.json().catch(() => null)) as {
+        count?: number
+        code?: string
+      } | null
+      const ok = !!res?.ok && typeof reply?.count === 'number'
+      if (ok) setCount(reply.count as number)
+      if (res?.status === 410) setExpired(true)
+      setUploads((u) =>
+        u.map((x) => (x.key === key ? { ...x, status: ok ? 'added' : 'failed' } : x))
+      )
+      if (!ok) {
+        setError(
+          reply?.code === 'limit'
+            ? t('full', { max })
+            : reply?.code === 'type'
+              ? t('notAPhoto')
+              : t('failed')
+        )
+        if (res?.status === 410 || reply?.code === 'limit') break
+      }
+    }
+    if (camera.current) camera.current.value = ''
+    if (library.current) library.current.value = ''
+  }
+
+  const busy = uploads.some((u) => u.status === 'uploading')
+  const full = count >= max
+  const stop = problem === 'invalid' ? 'invalid' : expired ? 'expired' : null
+
+  return (
+    <div className="min-h-dvh bg-gradient-to-b from-muted/70 to-background">
+      <main className="mx-auto flex min-h-dvh w-full max-w-md flex-col gap-6 px-4 pb-8 pt-8">
+        {brand && (
+          <header className="flex flex-col items-center gap-3 text-center">
+            {brand.logoUrl && !logoFailed ? (
+              <img
+                src={brand.logoUrl}
+                alt={brand.name}
+                onError={() => setLogoFailed(true)}
+                className="max-h-16 max-w-[220px] object-contain"
+              />
+            ) : (
+              <span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-sm">
+                {initials(brand.name)}
+              </span>
+            )}
+            <p className="text-sm font-medium text-muted-foreground">{brand.name}</p>
+          </header>
+        )}
+
+        <section className="rounded-2xl border border-border/70 bg-card p-5 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_16px_40px_-20px_rgb(0_0_0/0.25)]">
+          <h1 className="text-xl font-semibold tracking-tight">{t('title')}</h1>
+          {code && (
+            <p className="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
+              <span className="rounded-md border border-primary/30 bg-primary/10 px-2 py-0.5 font-mono text-sm font-semibold text-primary">
+                {code}
+              </span>
+              {t('forCode')}
+            </p>
+          )}
+
+          {stop ? (
+            <div
+              role="alert"
+              className="mt-5 flex gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4"
+            >
+              <TriangleAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" aria-hidden />
+              <p className="text-sm">{stop === 'expired' ? t('expired') : t('invalid')}</p>
+            </div>
+          ) : (
+            <>
+              <p className="mt-3 text-sm text-muted-foreground">{t('intro', { max })}</p>
+              <div className="mt-5 grid gap-2">
+                <button
+                  type="button"
+                  disabled={full || busy}
+                  onClick={() => camera.current?.click()}
+                  className="flex h-14 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-primary text-base font-medium text-primary-foreground shadow-sm transition-opacity active:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
+                >
+                  <Camera className="h-5 w-5" aria-hidden />
+                  {t('take')}
+                </button>
+                <button
+                  type="button"
+                  disabled={full || busy}
+                  onClick={() => library.current?.click()}
+                  className="flex h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-xl border bg-background text-base font-medium transition-colors active:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
+                >
+                  <ImagePlus className="h-5 w-5" aria-hidden />
+                  {t('choose')}
+                </button>
+                <input
+                  ref={camera}
+                  type="file"
+                  accept="image/*"
+                  capture="environment"
+                  hidden
+                  onChange={(e) => void send(e.target.files)}
+                />
+                <input
+                  ref={library}
+                  type="file"
+                  accept="image/*"
+                  multiple
+                  hidden
+                  onChange={(e) => void send(e.target.files)}
+                />
+              </div>
+
+              <p className="mt-4 text-center text-sm font-medium" aria-live="polite">
+                {t('added', { count, max })}
+              </p>
+              {uploads.length > 0 && (
+                <ul className="mt-3 grid grid-cols-4 gap-2">
+                  {uploads.map((u) => (
+                    <li
+                      key={u.key}
+                      className="relative aspect-square overflow-hidden rounded-lg border"
+                    >
+                      <img src={u.preview} alt="" className="size-full object-cover" />
+                      <span
+                        className={cn(
+                          'absolute right-1 bottom-1 flex size-6 items-center justify-center rounded-full shadow-sm',
+                          u.status === 'added' && 'bg-emerald-600 text-white',
+                          u.status === 'uploading' && 'bg-background/90',
+                          u.status === 'failed' && 'bg-destructive text-white'
+                        )}
+                      >
+                        {u.status === 'added' ? (
+                          <Check className="size-3.5" aria-hidden />
+                        ) : u.status === 'uploading' ? (
+                          <Loader2 className="size-3.5 animate-spin" aria-hidden />
+                        ) : (
+                          <X className="size-3.5" aria-hidden />
+                        )}
+                      </span>
+                    </li>
+                  ))}
+                </ul>
+              )}
+              {error && (
+                <p role="alert" className="mt-3 text-center text-sm text-destructive">
+                  {error}
+                </p>
+              )}
+              {count > 0 && !busy && (
+                <p className="mt-4 text-center text-sm text-muted-foreground">{t('done')}</p>
+              )}
+              <p className="mt-5 text-xs text-muted-foreground">{t('privacy')}</p>
+            </>
+          )}
+        </section>
+      </main>
+    </div>
+  )
+}

+ 17 - 0
src/app/(public)/p/[token]/page.tsx

@@ -1,6 +1,8 @@
 import type { Metadata } from 'next'
 import type { Metadata } from 'next'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import {
 import {
+  isFixHandoffToken,
+  verifyFixHandoffToken,
   isInspectionHandoffToken,
   isInspectionHandoffToken,
   verifyInspectionHandoffToken,
   verifyInspectionHandoffToken,
   verifyPhotoHandoffToken,
   verifyPhotoHandoffToken,
@@ -9,6 +11,7 @@ import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { isDefect } from '@/features/inspections/Lib/conditions'
 import { isDefect } from '@/features/inspections/Lib/conditions'
 import { PhotoHandoffClient } from './photo-handoff-client'
 import { PhotoHandoffClient } from './photo-handoff-client'
 import { InspectionHandoffClient } from './inspection-handoff-client'
 import { InspectionHandoffClient } from './inspection-handoff-client'
+import { FixHandoffClient } from './fix-handoff-client'
 
 
 export const metadata: Metadata = { robots: { index: false, follow: false } }
 export const metadata: Metadata = { robots: { index: false, follow: false } }
 export const dynamic = 'force-dynamic'
 export const dynamic = 'force-dynamic'
@@ -28,6 +31,7 @@ export default async function PhotoHandoffPage({
   searchParams: Promise<{ item?: string }>
   searchParams: Promise<{ item?: string }>
 }) {
 }) {
   const { token } = await params
   const { token } = await params
+  if (isFixHandoffToken(token)) return <FixHandoffPage token={token} />
   if (isInspectionHandoffToken(token)) {
   if (isInspectionHandoffToken(token)) {
     const { item } = await searchParams
     const { item } = await searchParams
     return (
     return (
@@ -172,3 +176,16 @@ async function InspectionHandoffPage({
     />
     />
   )
   )
 }
 }
+
+/**
+ * The same page for a fix being shared from a fault code: the phone adds
+ * photos to that fix's draft. It shows the workshop and the code, nothing else.
+ */
+async function FixHandoffPage({ token }: { token: string }) {
+  const check = verifyFixHandoffToken(token)
+  if (!check.ok) {
+    return <FixHandoffClient token={token} brand={null} code={null} problem={check.reason} />
+  }
+  const brand = await workshopBrand(check.handoff.organizationId)
+  return <FixHandoffClient token={token} brand={brand} code={check.handoff.code} />
+}

+ 52 - 0
src/app/api/protected/fault-codes/fixes/[id]/photos/[n]/route.ts

@@ -0,0 +1,52 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { getCachedMembership } from '@/lib/cached-session'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { sharedFixPhoto } from '@/features/fault-codes/Lib/service.server'
+import { MAX_FIX_PHOTOS } from '@/features/fault-codes/Schema/faultCodeSchema'
+
+const paramsSchema = z.object({
+  id: z.string().uuid(),
+  n: z.coerce
+    .number()
+    .int()
+    .min(0)
+    .max(MAX_FIX_PHOTOS - 1),
+})
+
+/**
+ * One photo of a shared fix, from the code service: a published fix's, or
+ * one of this workshop's own while it waits for review. `?size=thumb` for
+ * the small one on a card. Streamed through; nothing is kept here.
+ */
+export async function GET(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string; n: string }> }
+) {
+  const ctx = await getAuthContext()
+  if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+  const membership = ctx.isAdmin ? null : await getCachedMembership(ctx.userId)
+  const allowed =
+    ctx.isAdmin ||
+    hasPermission(membership?.customRole?.permissions ?? [], {
+      action: PermissionAction.READ,
+      subject: PermissionSubject.VEHICLES,
+    })
+  if (!allowed) return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
+
+  const parsed = paramsSchema.safeParse(await params)
+  if (!parsed.success) return NextResponse.json({ error: 'Not found' }, { status: 404 })
+  const size = request.nextUrl.searchParams.get('size') === 'thumb' ? 'thumb' : 'full'
+
+  const photo = await sharedFixPhoto(ctx.organizationId, { ...parsed.data, size })
+  if (!photo) return NextResponse.json({ error: 'Not found' }, { status: 404 })
+  const headers: Record<string, string> = {
+    'Content-Type': 'image/jpeg',
+    // A photo never changes once stored; this person's browser may keep it.
+    'Cache-Control': 'private, max-age=86400',
+    'X-Content-Type-Options': 'nosniff',
+  }
+  if (photo.length) headers['Content-Length'] = String(photo.length)
+  return new Response(photo.body, { headers })
+}

+ 148 - 0
src/app/api/protected/fault-codes/fixes/route.ts

@@ -0,0 +1,148 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { isRestoring, RESTORE_IN_PROGRESS_MESSAGE } from '@/lib/backup/freeze'
+import { assertContentLength } from '@/lib/backup/zip-guard'
+import { getCachedMembership } from '@/lib/cached-session'
+import { isDemoMode } from '@/lib/demo'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { locales } from '@/i18n/config'
+import { discardDraft, readDraftPhotos } from '@/features/fault-codes/Lib/fixDrafts.server'
+import { shareFix, vehicleForCodes } from '@/features/fault-codes/Lib/service.server'
+import type { FaultCodeVehicle } from '@/features/integrations/Lib/types'
+import {
+  FIX_PHOTO_TYPES,
+  MAX_FIX_PHOTO_BYTES,
+  MAX_FIX_PHOTOS,
+  shareFixSchema,
+} from '@/features/fault-codes/Schema/faultCodeSchema'
+
+/** Four photos at their limit and room for the rest of the form. */
+const MAX_BODY = MAX_FIX_PHOTOS * MAX_FIX_PHOTO_BYTES + 1024 * 1024
+
+/**
+ * Why sharing did not go through, as a code the form turns into words.
+ * `unavailable`: not connected, or the code service did not answer.
+ */
+export type ShareFixError =
+  | 'unauthorized'
+  | 'forbidden'
+  | 'demo'
+  | 'restoring'
+  | 'invalid'
+  | 'photo'
+  | 'too_large'
+  | 'paused'
+  | 'unavailable'
+
+function fail(status: number, error: ShareFixError, message?: string) {
+  return NextResponse.json({ success: false, error, message }, { status })
+}
+
+/**
+ * "Share your fix" from a fault code's page: what fixed it, the vehicle and
+ * up to four photos the browser has already compressed. Checks who is
+ * asking, the sizes and that the code service is connected, then sends it
+ * all on to the service, which keeps it for a person to review. Nothing is
+ * written to this server's disk or database but the fix's id, kept in the
+ * connection's link store so the page can show how the review stands.
+ *
+ * A route rather than a server action because it carries photos.
+ */
+export async function POST(request: NextRequest) {
+  const ctx = await getAuthContext()
+  if (!ctx) return fail(401, 'unauthorized')
+  // The same permission as reporting a wrong meaning.
+  const membership = ctx.isAdmin ? null : await getCachedMembership(ctx.userId)
+  const allowed =
+    ctx.isAdmin ||
+    hasPermission(membership?.customRole?.permissions ?? [], {
+      action: PermissionAction.UPDATE,
+      subject: PermissionSubject.VEHICLES,
+    })
+  if (!allowed) return fail(403, 'forbidden')
+  // The public demo shares nothing with other workshops.
+  if (isDemoMode) return fail(403, 'demo')
+  if (isRestoring(ctx.organizationId)) return fail(409, 'restoring', RESTORE_IN_PROGRESS_MESSAGE)
+  try {
+    assertContentLength(request, MAX_BODY)
+  } catch {
+    return fail(413, 'too_large')
+  }
+
+  let form: FormData
+  try {
+    form = await request.formData()
+  } catch {
+    return fail(400, 'invalid')
+  }
+  const text = (name: string) => {
+    const value = form.get(name)
+    return typeof value === 'string' ? value : undefined
+  }
+  const parsed = shareFixSchema.safeParse({
+    code: text('code'),
+    text: text('text'),
+    vehicleId: text('vehicleId') || undefined,
+    draftId: text('draftId') || undefined,
+    make: text('make'),
+    model: text('model'),
+    year: text('year'),
+  })
+  const lang = z.enum(locales).safeParse(text('lang'))
+  if (!parsed.success || !lang.success) return fail(400, 'invalid')
+  const data = parsed.data
+
+  const photos = form.getAll('photo')
+  if (photos.length > MAX_FIX_PHOTOS) return fail(400, 'photo')
+  const files: Blob[] = []
+  for (const photo of photos) {
+    if (!(photo instanceof File)) return fail(400, 'photo')
+    if (!(FIX_PHOTO_TYPES as readonly string[]).includes(photo.type)) return fail(400, 'photo')
+    if (photo.size > MAX_FIX_PHOTO_BYTES) return fail(413, 'too_large')
+    files.push(photo)
+  }
+  // Photos a phone added to this fix's draft, already compressed on the way in.
+  if (data.draftId) {
+    const fromPhone = await readDraftPhotos(ctx.organizationId, data.draftId)
+    if (files.length + fromPhone.length > MAX_FIX_PHOTOS) return fail(400, 'photo')
+    for (const jpeg of fromPhone)
+      files.push(new Blob([new Uint8Array(jpeg)], { type: 'image/jpeg' }))
+  }
+
+  // Opened from a job: the vehicle's make, model, year and engine, read here
+  // and scoped by the workshop. Otherwise what was typed, all of it optional.
+  let vehicle: FaultCodeVehicle | null = null
+  if (data.vehicleId) {
+    const known = await vehicleForCodes(ctx.organizationId, data.vehicleId)
+    if (known) {
+      vehicle = { make: known.make, model: known.model, year: known.year, engine: known.engine }
+    }
+  } else if (data.make || data.model || data.year) {
+    vehicle = { make: data.make, model: data.model, year: data.year }
+  }
+
+  const result = await shareFix(ctx.organizationId, {
+    code: data.code,
+    text: data.text,
+    lang: lang.data,
+    vehicle,
+    photos: files,
+  })
+  if (result.ok) {
+    if (data.draftId) await discardDraft(ctx.organizationId, data.draftId)
+    return NextResponse.json({ success: true, data: result.fix })
+  }
+  switch (result.reason) {
+    case 'photo':
+      return fail(400, 'photo')
+    case 'invalid':
+      return fail(400, 'invalid')
+    case 'too_large':
+      return fail(413, 'too_large')
+    case 'paused':
+      return fail(503, 'paused')
+    default:
+      return fail(503, 'unavailable')
+  }
+}

+ 1 - 0
src/app/api/protected/files/[...path]/route.ts

@@ -56,6 +56,7 @@ export async function GET(
     'portal',
     'portal',
     'tire-hotel',
     'tire-hotel',
     'email',
     'email',
+    'fix-drafts',
   ]
   ]
   if (!allowedCategories.includes(category)) {
   if (!allowedCategories.includes(category)) {
     return NextResponse.json({ error: 'Invalid category' }, { status: 400 })
     return NextResponse.json({ error: 'Invalid category' }, { status: 400 })

+ 66 - 0
src/app/api/public/fix-handoff/[token]/route.ts

@@ -0,0 +1,66 @@
+import { NextResponse } from 'next/server'
+import { isRestoring, RESTORE_IN_PROGRESS_MESSAGE } from '@/lib/backup/freeze'
+import { compressPhoto } from '@/lib/image-upload.server'
+import { verifyFixHandoffToken } from '@/lib/photo-handoff'
+import { rateLimit } from '@/lib/rate-limit'
+import { addDraftPhoto, listDraftPhotos } from '@/features/fault-codes/Lib/fixDrafts.server'
+import { MAX_FIX_PHOTOS } from '@/features/fault-codes/Schema/faultCodeSchema'
+
+const PHOTO_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic'])
+/** What a phone camera sends before compression. */
+const MAX_BYTES = 15 * 1024 * 1024
+const RATE_PER_MINUTE = 30
+
+function refuse(status: number, code: string) {
+  return NextResponse.json({ code }, { status })
+}
+
+/**
+ * A photo from the phone page a fix's "Add from phone" code opens. Nobody is
+ * signed in: the signed code is the permission, and it only reaches that
+ * fix's draft, which holds at most four photos. The photo is compressed as
+ * job photos are and kept until the fix is shared (lib: fixDrafts.server).
+ */
+export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) {
+  const limited = rateLimit(request, { limit: RATE_PER_MINUTE, windowMs: 60_000, anonymous: true })
+  if (limited) return limited
+
+  const { token } = await params
+  const check = verifyFixHandoffToken(token)
+  if (!check.ok) return refuse(check.reason === 'expired' ? 410 : 404, check.reason)
+  const { organizationId, draftId } = check.handoff
+  // No session here, so the restore freeze is checked on the route itself.
+  if (isRestoring(organizationId)) {
+    return NextResponse.json(
+      { code: 'restoring', error: RESTORE_IN_PROGRESS_MESSAGE },
+      { status: 409 }
+    )
+  }
+
+  const form = await request.formData().catch(() => null)
+  const file = form?.get('file')
+  if (!(file instanceof File)) return refuse(400, 'noFile')
+  if (!PHOTO_TYPES.has(file.type)) return refuse(400, 'type')
+  if (file.size === 0) return refuse(400, 'empty')
+  if (file.size > MAX_BYTES) return refuse(400, 'tooLarge')
+
+  let jpeg: Uint8Array
+  try {
+    const photo = await compressPhoto(Buffer.from(await file.arrayBuffer()))
+    jpeg = new Uint8Array(photo.data)
+  } catch {
+    return refuse(400, 'type')
+  }
+  const added = await addDraftPhoto(organizationId, draftId, jpeg)
+  if (!added) return refuse(409, 'limit')
+  return NextResponse.json({ count: added.count, max: MAX_FIX_PHOTOS })
+}
+
+/** How many photos the draft holds, so a reopened phone page starts right. */
+export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) {
+  const { token } = await params
+  const check = verifyFixHandoffToken(token)
+  if (!check.ok) return refuse(check.reason === 'expired' ? 410 : 404, check.reason)
+  const photos = await listDraftPhotos(check.handoff.organizationId, check.handoff.draftId)
+  return NextResponse.json({ count: photos.length, max: MAX_FIX_PHOTOS })
+}

+ 67 - 14
src/app/api/v1/tech/jobs/[id]/findings/route.ts

@@ -1,5 +1,8 @@
 import { z } from 'zod'
 import { z } from 'zod'
+import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
+import { FAULT_STATUSES, faultLine, normalizeFaultCode } from '@/features/fault-codes/Lib/codes'
+import { lookupMeanings, vehicleForCodes } from '@/features/fault-codes/Lib/service.server'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
 import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
 
 
@@ -15,11 +18,43 @@ import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
  * `serviceRecordId` records where it was spotted so the history reads properly
  * `serviceRecordId` records where it was spotted so the history reads properly
  * later.
  * later.
  */
  */
-const bodySchema = z.object({
-  description: z.string().min(1).max(1000),
-  severity: z.enum(['monitor', 'needs_work', 'urgent']).default('needs_work'),
-  notes: z.string().max(2000).optional(),
-})
+const bodySchema = z
+  .object({
+    description: z.string().trim().max(1000).optional(),
+    severity: z.enum(['monitor', 'needs_work', 'urgent']).default('needs_work'),
+    notes: z.string().max(2000).optional(),
+    /**
+     * A fault code read off a scan tool, such as P0420. The app saves its
+     * meaning with it when the code service answers; the description can
+     * then be left out and reads "P0420 <meaning>".
+     */
+    faultCode: z.string().max(16).optional(),
+    faultStatus: z.enum(FAULT_STATUSES).optional(),
+    faultModule: z.string().trim().max(80).optional(),
+  })
+  .superRefine((body, ctx) => {
+    if (body.faultCode !== undefined && !normalizeFaultCode(body.faultCode)) {
+      ctx.addIssue({ code: 'custom', path: ['faultCode'], message: 'Not a fault code' })
+    }
+    if (!body.description && !body.faultCode) {
+      ctx.addIssue({ code: 'custom', path: ['description'], message: 'Required' })
+    }
+  })
+
+/** What the app gets back about a finding, code fields included. */
+const FINDING_SELECT = {
+  id: true,
+  description: true,
+  severity: true,
+  status: true,
+  notes: true,
+  faultCode: true,
+  faultStatus: true,
+  faultModule: true,
+  faultMeaning: true,
+  faultMeaningSource: true,
+  createdAt: true,
+} as const
 
 
 export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
 export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
   return withApiAuth(
   return withApiAuth(
@@ -48,22 +83,40 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
         )
         )
       }
       }
 
 
+      // A code gets its meaning saved with it, as one added in the browser
+      // does. The service not answering costs the meaning, never the code.
+      const faultCode = normalizeFaultCode(body.faultCode)
+      let meaning: { title: string; source: string | null } | null = null
+      if (faultCode) {
+        const [vehicle, locale] = await Promise.all([
+          vehicleForCodes(ctx.organizationId, job.vehicleId),
+          resolveDocumentLocale(ctx.organizationId),
+        ])
+        const { results } = await lookupMeanings(ctx.organizationId, [faultCode], vehicle, locale)
+        const view = results[0]
+        if (view?.state === 'found' && view.title) {
+          meaning = { title: view.title, source: view.source }
+        }
+      }
+
       const finding = await db.vehicleFinding.create({
       const finding = await db.vehicleFinding.create({
         data: {
         data: {
-          description: body.description,
+          description: body.description || faultLine(faultCode ?? '', meaning?.title),
           severity: body.severity,
           severity: body.severity,
           notes: body.notes,
           notes: body.notes,
           vehicleId: job.vehicleId,
           vehicleId: job.vehicleId,
           serviceRecordId: job.id,
           serviceRecordId: job.id,
+          ...(faultCode
+            ? {
+                faultCode,
+                faultStatus: body.faultStatus ?? null,
+                faultModule: body.faultModule || null,
+                faultMeaning: meaning?.title ?? null,
+                faultMeaningSource: meaning?.source ?? null,
+              }
+            : {}),
         },
         },
-        select: {
-          id: true,
-          description: true,
-          severity: true,
-          status: true,
-          notes: true,
-          createdAt: true,
-        },
+        select: FINDING_SELECT,
       })
       })
 
 
       return apiOk({ finding }, 201)
       return apiOk({ finding }, 201)

+ 10 - 1
src/app/api/v1/tech/jobs/[id]/route.ts

@@ -37,7 +37,16 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
               id: true,
               id: true,
               description: true,
               description: true,
               findings: {
               findings: {
-                select: { id: true, description: true, severity: true, status: true },
+                select: {
+                  id: true,
+                  description: true,
+                  severity: true,
+                  status: true,
+                  faultCode: true,
+                  faultStatus: true,
+                  faultModule: true,
+                  faultMeaning: true,
+                },
               },
               },
             },
             },
           },
           },

+ 6 - 1
src/components/page-header.tsx

@@ -180,6 +180,7 @@ const breadcrumbMap: Record<string, BreadcrumbSegment[]> = {
   '/ai': [{ key: 'aiAssistant' }],
   '/ai': [{ key: 'aiAssistant' }],
   '/audit-log': [{ key: 'auditLog' }],
   '/audit-log': [{ key: 'auditLog' }],
   '/observations': [{ key: 'observations' }],
   '/observations': [{ key: 'observations' }],
+  '/fault-codes': [{ key: 'faultCodes' }],
   '/tire-hotel': [{ key: 'tireHotel', href: '/tire-hotel' }, { key: 'allTireSets' }],
   '/tire-hotel': [{ key: 'tireHotel', href: '/tire-hotel' }, { key: 'allTireSets' }],
   '/tire-hotel/storage': [{ key: 'tireHotel', href: '/tire-hotel' }, { key: 'tireStorage' }],
   '/tire-hotel/storage': [{ key: 'tireHotel', href: '/tire-hotel' }, { key: 'tireStorage' }],
   '/tire-hotel/forecast': [{ key: 'tireHotel', href: '/tire-hotel' }, { key: 'tireForecast' }],
   '/tire-hotel/forecast': [{ key: 'tireHotel', href: '/tire-hotel' }, { key: 'tireForecast' }],
@@ -226,8 +227,12 @@ export function PageHeader() {
   let segments = breadcrumbMap[pathname]
   let segments = breadcrumbMap[pathname]
 
 
   if (!segments) {
   if (!segments) {
+    // /fault-codes/[code]
+    if (/^\/fault-codes\/[^/]+$/.test(pathname)) {
+      segments = [{ key: 'faultCodes', href: '/fault-codes' }, { key: 'faultCode' }]
+    }
     // /tire-hotel/[id]
     // /tire-hotel/[id]
-    if (/^\/tire-hotel\/[^/]+$/.test(pathname)) {
+    else if (/^\/tire-hotel\/[^/]+$/.test(pathname)) {
       segments = [{ key: 'tireHotel', href: '/tire-hotel' }, { key: 'tireSetDetails' }]
       segments = [{ key: 'tireHotel', href: '/tire-hotel' }, { key: 'tireSetDetails' }]
     }
     }
     // /quotes/[id]/edit
     // /quotes/[id]/edit

+ 1 - 0
src/features/ai/tools/workshop-columns.ts

@@ -156,6 +156,7 @@ export const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = {
     'includeInInvoice',
     'includeInInvoice',
     'createdAt',
     'createdAt',
     'serviceRecordId',
     'serviceRecordId',
+    'scan',
   ],
   ],
   payments: [
   payments: [
     'id',
     'id',

+ 134 - 0
src/features/fault-codes/Actions/addFaultCodeFindings.ts

@@ -0,0 +1,134 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { resolveDocumentLocale } from '@/i18n/locale-from-request'
+import { logAudit } from '@/lib/audit'
+import { db } from '@/lib/db'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { faultLine } from '../Lib/codes'
+import { findingWebhookFields } from '../Lib/findings'
+import { lookupMeanings, vehicleForCodes } from '../Lib/service.server'
+import { addFaultCodeFindingsSchema } from '../Schema/faultCodeSchema'
+
+/**
+ * Typed or pasted codes become findings on the job's vehicle, one each, with
+ * the meaning as it reads right now saved on the finding in the workshop's
+ * language, so prints and history never wait on the code service. When the
+ * service does not answer, the codes are saved all the same and their
+ * meaning can be taken later. A code already on this job is not added twice.
+ */
+export async function addFaultCodeFindings(input: unknown) {
+  return withAuth(
+    async (ctx) => {
+      const { organizationId } = ctx
+      const data = addFaultCodeFindingsSchema.parse(input)
+      const job = await db.serviceRecord.findFirst({
+        where: { id: data.serviceRecordId, organizationId },
+        select: { id: true, vehicleId: true },
+      })
+      if (!job) throw new Error('Work order not found')
+      if (!job.vehicleId) throw new Error('This job has no vehicle to record a fault code against')
+      if (data.concernId) {
+        const concern = await db.serviceConcern.findFirst({
+          where: { id: data.concernId, serviceRecordId: job.id, serviceRecord: { organizationId } },
+          select: { id: true },
+        })
+        if (!concern) throw new Error('Concern not found')
+      }
+
+      // One row per code, the first status and module given for it kept.
+      const wanted = new Map<string, { status: string | null; module: string | null }>()
+      for (const c of data.codes) {
+        if (!wanted.has(c.code))
+          wanted.set(c.code, { status: c.status ?? null, module: c.module || null })
+      }
+      const already = await db.vehicleFinding.findMany({
+        where: {
+          serviceRecordId: job.id,
+          vehicle: { organizationId },
+          faultCode: { in: [...wanted.keys()] },
+        },
+        select: { faultCode: true },
+      })
+      for (const row of already) if (row.faultCode) wanted.delete(row.faultCode)
+      if (wanted.size === 0) return { added: 0, skipped: data.codes.length, findings: [] }
+
+      const [vehicle, locale] = await Promise.all([
+        vehicleForCodes(organizationId, job.vehicleId),
+        resolveDocumentLocale(organizationId),
+      ])
+      const { results } = await lookupMeanings(organizationId, [...wanted.keys()], vehicle, locale)
+      const meanings = new Map(results.map((r) => [r.code, r]))
+
+      // The check above saves a lookup; this one keeps a code to one finding
+      // per job when two people add it at once. The job's row is locked for
+      // the transaction, so the second waits, then finds the first's rows.
+      const findings = await db.$transaction(async (tx) => {
+        await tx.$queryRaw`SELECT id FROM "public"."service_records" WHERE id = ${job.id} FOR UPDATE`
+        const taken = await tx.vehicleFinding.findMany({
+          where: {
+            serviceRecordId: job.id,
+            vehicle: { organizationId },
+            faultCode: { in: [...wanted.keys()] },
+          },
+          select: { faultCode: true },
+        })
+        for (const row of taken) if (row.faultCode) wanted.delete(row.faultCode)
+        const created = []
+        for (const [code, extra] of wanted) {
+          const view = meanings.get(code)
+          const meaning = view?.state === 'found' ? view.title : null
+          created.push(
+            await tx.vehicleFinding.create({
+              data: {
+                description: faultLine(code, meaning),
+                severity: data.severity,
+                vehicleId: job.vehicleId as string,
+                serviceRecordId: job.id,
+                concernId: data.concernId ?? null,
+                faultCode: code,
+                faultStatus: extra.status,
+                faultModule: extra.module,
+                faultMeaning: meaning,
+                faultMeaningSource: meaning ? (view?.source ?? null) : null,
+              },
+            })
+          )
+        }
+        return created
+      })
+
+      // One audit row, and one finding.create webhook, per finding, the same
+      // as adding them one at a time.
+      for (const finding of findings) {
+        void logAudit(ctx, {
+          action: 'finding.create',
+          entity: 'VehicleFinding',
+          entityId: finding.id,
+          details: {
+            key: 'finding_create',
+            params: { description: finding.description, vehicleId: finding.vehicleId },
+          },
+          metadata: {
+            findingId: finding.id,
+            vehicleId: finding.vehicleId,
+            ...findingWebhookFields(finding),
+          },
+        })
+      }
+      revalidatePath(`/vehicles/${job.vehicleId}`)
+      revalidatePath('/')
+      return {
+        added: findings.length,
+        skipped: data.codes.length - findings.length,
+        findings: findings.map((f) => ({ id: f.id, code: f.faultCode, meaning: f.faultMeaning })),
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+      ],
+    }
+  )
+}

+ 65 - 0
src/features/fault-codes/Actions/checkFindingMeanings.ts

@@ -0,0 +1,65 @@
+'use server'
+
+import { resolveDocumentLocale } from '@/i18n/locale-from-request'
+import { db } from '@/lib/db'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { lookupMeanings, vehicleForCodes } from '../Lib/service.server'
+import { checkFindingMeaningsSchema } from '../Schema/faultCodeSchema'
+
+/**
+ * For the job's code findings, what each code means now against what the
+ * finding saved when it was added. A finding whose meaning has changed since
+ * (or that was added while the service was away) gets an "Updated meaning"
+ * chip on an open job; the saved text itself never changes on its own.
+ */
+export async function checkFindingMeanings(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const { serviceRecordId } = checkFindingMeaningsSchema.parse(input)
+      const job = await db.serviceRecord.findFirst({
+        where: { id: serviceRecordId, organizationId },
+        select: { id: true, vehicleId: true },
+      })
+      if (!job?.vehicleId) return { available: false, findings: [] }
+      const findings = await db.vehicleFinding.findMany({
+        where: {
+          serviceRecordId: job.id,
+          vehicle: { organizationId },
+          faultCode: { not: null },
+          status: { not: 'resolved' },
+        },
+        select: { id: true, faultCode: true, faultMeaning: true },
+      })
+      if (findings.length === 0) return { available: true, findings: [] }
+      const [vehicle, locale] = await Promise.all([
+        vehicleForCodes(organizationId, job.vehicleId),
+        resolveDocumentLocale(organizationId),
+      ])
+      const { available, results } = await lookupMeanings(
+        organizationId,
+        findings.map((f) => f.faultCode as string),
+        vehicle,
+        locale
+      )
+      const byCode = new Map(results.map((r) => [r.code, r]))
+      return {
+        available,
+        findings: findings.map((f) => {
+          const now = byCode.get(f.faultCode as string)
+          const current = now?.state === 'found' ? now.title : null
+          return {
+            findingId: f.id,
+            code: f.faultCode as string,
+            current,
+            source: now?.source ?? null,
+            changed: current !== null && current !== (f.faultMeaning ?? null),
+          }
+        }),
+      }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}

+ 30 - 0
src/features/fault-codes/Actions/createFixPhoneLink.ts

@@ -0,0 +1,30 @@
+'use server'
+
+import { createFixHandoffToken } from '@/lib/photo-handoff'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { fixPhoneLinkSchema } from '../Schema/faultCodeSchema'
+
+/**
+ * The phone code in the share dialog: a signed link, good for 30 minutes,
+ * that lets a phone add photos to this fix's draft without signing in.
+ */
+export async function createFixPhoneLink(input: unknown) {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      const data = fixPhoneLinkSchema.parse(input)
+      const { token, expiresAt } = createFixHandoffToken({
+        organizationId,
+        code: data.code,
+        draftId: data.draftId,
+        userId,
+      })
+      return { token, expiresAt: expiresAt.toISOString() }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+      ],
+    }
+  )
+}

+ 27 - 0
src/features/fault-codes/Actions/getFaultCodeDetail.ts

@@ -0,0 +1,27 @@
+'use server'
+
+import { getLocale } from 'next-intl/server'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { loadFaultCodePage } from '../Lib/page.server'
+import { faultCodeDetailSchema } from '../Schema/faultCodeSchema'
+
+/**
+ * Everything the code sheet shows for one code: the meaning with its
+ * description and causes, the jobs here where it was read, and what this
+ * workshop reported about it. The code's own page asks for the shared fixes
+ * too.
+ */
+export async function getFaultCodeDetail(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = faultCodeDetailSchema.parse(input)
+      return loadFaultCodePage(organizationId, data.code, data.vehicleId, await getLocale(), {
+        fixes: data.fixes === true,
+      })
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}

+ 19 - 0
src/features/fault-codes/Actions/getFaultCodeJobs.ts

@@ -0,0 +1,19 @@
+'use server'
+
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { jobsWithCode } from '../Lib/service.server'
+import { faultCodeJobsSchema } from '../Schema/faultCodeSchema'
+
+/** Jobs in this workshop where the code was read, newest first. */
+export async function getFaultCodeJobs(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const { code } = faultCodeJobsSchema.parse(input)
+      return jobsWithCode(organizationId, code)
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}

+ 43 - 0
src/features/fault-codes/Actions/getFaultCodeOverview.ts

@@ -0,0 +1,43 @@
+'use server'
+
+import { getLocale } from 'next-intl/server'
+import { z } from 'zod'
+import { getManifest } from '@/integrations/registry'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { recentCodes } from '../Lib/recent.server'
+import { faultCodesConnected, searchServiceCodes } from '../Lib/service.server'
+
+const inputSchema = z.object({ query: z.string().trim().max(100).default('') })
+
+/**
+ * The fault code page: whether the code service is connected, what it
+ * finds for words like "lean bank 1", and the codes read lately in this
+ * workshop, which come from its own jobs and show either way.
+ */
+export async function getFaultCodeOverview(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const { query } = inputSchema.parse(input)
+      const [connected, recent] = await Promise.all([
+        faultCodesConnected(organizationId),
+        recentCodes(organizationId),
+      ])
+      const search =
+        connected && query
+          ? await searchServiceCodes(organizationId, query, await getLocale(), 25)
+          : { available: connected, hits: [] }
+      return {
+        connected,
+        connectable: getManifest('torqvoice-codes') !== null,
+        query,
+        available: search.available,
+        hits: search.hits,
+        recent,
+      }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}

+ 19 - 0
src/features/fault-codes/Actions/getFaultCodeReports.ts

@@ -0,0 +1,19 @@
+'use server'
+
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { reportStates } from '../Lib/service.server'
+import { faultCodeReportsSchema } from '../Schema/faultCodeSchema'
+
+/** What this workshop reported about these codes' meanings, and where each report stands. */
+export async function getFaultCodeReports(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const { codes } = faultCodeReportsSchema.parse(input)
+      return reportStates(organizationId, codes)
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}

+ 22 - 0
src/features/fault-codes/Actions/listFixDraftPhotos.ts

@@ -0,0 +1,22 @@
+'use server'
+
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { draftPhotoUrl, listDraftPhotos } from '../Lib/fixDrafts.server'
+import { fixDraftSchema } from '../Schema/faultCodeSchema'
+
+/** The photos a phone has added to this fix's draft, for the dialog to show. */
+export async function listFixDraftPhotos(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = fixDraftSchema.parse(input)
+      const names = await listDraftPhotos(organizationId, data.draftId)
+      return names.map((name) => ({ id: name, url: draftPhotoUrl(organizationId, name) }))
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+      ],
+    }
+  )
+}

+ 33 - 0
src/features/fault-codes/Actions/lookupFaultCodes.ts

@@ -0,0 +1,33 @@
+'use server'
+
+import { getLocale } from 'next-intl/server'
+import { resolveDocumentLocale } from '@/i18n/locale-from-request'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { normalizeFaultCode } from '../Lib/codes'
+import { lookupMeanings, vehicleForCodes } from '../Lib/service.server'
+import { lookupFaultCodesSchema } from '../Schema/faultCodeSchema'
+
+/**
+ * What each code means on the job's vehicle, for codes being typed or
+ * pasted. Text that is not a code is left out. `available` is false when the
+ * service did not answer; the workshop's own meanings come back either way.
+ */
+export async function lookupFaultCodes(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = lookupFaultCodesSchema.parse(input)
+      const codes = data.codes
+        .map((c) => normalizeFaultCode(c))
+        .filter((c): c is string => c !== null)
+      const [vehicle, locale] = await Promise.all([
+        vehicleForCodes(organizationId, data.vehicleId),
+        data.language === 'document' ? resolveDocumentLocale(organizationId) : getLocale(),
+      ])
+      return lookupMeanings(organizationId, codes, vehicle, locale)
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}

+ 22 - 0
src/features/fault-codes/Actions/removeFixDraftPhoto.ts

@@ -0,0 +1,22 @@
+'use server'
+
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { removeDraftPhoto } from '../Lib/fixDrafts.server'
+import { fixDraftPhotoSchema } from '../Schema/faultCodeSchema'
+
+/** Takes one phone photo out of the fix's draft before it is shared. */
+export async function removeFixDraftPhoto(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = fixDraftPhotoSchema.parse(input)
+      await removeDraftPhoto(organizationId, data.draftId, data.photoId)
+      return { removed: true }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+      ],
+    }
+  )
+}

+ 40 - 0
src/features/fault-codes/Actions/reportFaultCodeMeaning.ts

@@ -0,0 +1,40 @@
+'use server'
+
+import { demoGuard } from '@/lib/demo'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { sendReport, vehicleForCodes } from '../Lib/service.server'
+import { reportFaultCodeSchema } from '../Schema/faultCodeSchema'
+
+/**
+ * "This doesn't match": tells the code service a meaning looks wrong. Sends
+ * the code, the meaning's version and language, the reason, the note and
+ * the vehicle's make, model, year and engine; never a VIN, a plate or a
+ * customer. `sent` is false when the service could not
+ * take it, and nothing is remembered then.
+ */
+export async function reportFaultCodeMeaning(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      demoGuard()
+      const data = reportFaultCodeSchema.parse(input)
+      const vehicle = await vehicleForCodes(organizationId, data.vehicleId)
+      const report = await sendReport(organizationId, {
+        code: data.code,
+        version: data.version,
+        lang: data.lang,
+        reason: data.reason,
+        note: data.note || undefined,
+        vehicle: vehicle
+          ? { make: vehicle.make, model: vehicle.model, year: vehicle.year, engine: vehicle.engine }
+          : null,
+      })
+      return { sent: report !== null, report }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+      ],
+    }
+  )
+}

+ 37 - 0
src/features/fault-codes/Actions/searchFaultCodes.ts

@@ -0,0 +1,37 @@
+'use server'
+
+import { getLocale } from 'next-intl/server'
+import { z } from 'zod'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { looksLikeFaultCode, normalizeFaultCode } from '../Lib/codes'
+import { jobsWithCode, searchServiceCodes } from '../Lib/service.server'
+
+const querySchema = z.string().max(100)
+
+/**
+ * The search palette's fault code groups, asked for beside the normal search
+ * so a slow answer from the code service never holds up vehicles, customers
+ * or jobs. Only a query that looks like a code goes anywhere: the service's
+ * codes for it, and for a whole code the jobs here where it was read, which
+ * are the workshop's own data and come back with or without the service.
+ */
+export async function searchFaultCodes(query: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const q = querySchema.parse(query).trim()
+      if (!looksLikeFaultCode(q)) {
+        return { query: q, available: false, codes: [], jobs: [] }
+      }
+      const code = normalizeFaultCode(q)
+      const [service, jobs] = await Promise.all([
+        searchServiceCodes(organizationId, q, await getLocale()),
+        code ? jobsWithCode(organizationId, code, 6) : Promise.resolve([]),
+      ])
+      return { query: q, available: service.available, codes: service.hits, jobs }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}

+ 27 - 0
src/features/fault-codes/Actions/suggestFaultCodes.ts

@@ -0,0 +1,27 @@
+'use server'
+
+import { getLocale } from 'next-intl/server'
+import { z } from 'zod'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { searchServiceCodes } from '../Lib/service.server'
+
+const querySchema = z.string().trim().min(2).max(100)
+
+/**
+ * Matches while typing on the fault code page: a code prefix ("P04") or
+ * words ("lean bank 1"), asked of the code service. Nothing is stored; a
+ * service that is down or not connected answers `available: false`.
+ */
+export async function suggestFaultCodes(query: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const q = querySchema.parse(query)
+      const found = await searchServiceCodes(organizationId, q, await getLocale(), 12)
+      return { query: q, available: found.available, hits: found.hits }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}

+ 74 - 0
src/features/fault-codes/Actions/takeFindingMeaning.ts

@@ -0,0 +1,74 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { resolveDocumentLocale } from '@/i18n/locale-from-request'
+import { db } from '@/lib/db'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { faultLine } from '../Lib/codes'
+import { findingWebhookFields } from '../Lib/findings'
+import { lookupMeanings, vehicleForCodes } from '../Lib/service.server'
+import { takeFindingMeaningSchema } from '../Schema/faultCodeSchema'
+
+/**
+ * One tap on "Updated meaning": the finding takes the code's meaning as it
+ * reads now. A description the technician wrote in their own words is left
+ * as it is; only the one the app wrote from the code and the old meaning
+ * follows.
+ */
+export async function takeFindingMeaning(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const { findingId } = takeFindingMeaningSchema.parse(input)
+      const finding = await db.vehicleFinding.findFirst({
+        where: { id: findingId, vehicle: { organizationId }, faultCode: { not: null } },
+        select: {
+          id: true,
+          vehicleId: true,
+          description: true,
+          faultCode: true,
+          faultMeaning: true,
+        },
+      })
+      if (!finding?.faultCode) throw new Error('Finding not found')
+      const [vehicle, locale] = await Promise.all([
+        vehicleForCodes(organizationId, finding.vehicleId),
+        resolveDocumentLocale(organizationId),
+      ])
+      const { results } = await lookupMeanings(organizationId, [finding.faultCode], vehicle, locale)
+      const now = results[0]
+      if (now?.state !== 'found' || !now.title) {
+        throw new Error('The meaning is not available right now')
+      }
+      const ownWords =
+        finding.description !== finding.faultCode &&
+        finding.description !== faultLine(finding.faultCode, finding.faultMeaning)
+      const updated = await db.vehicleFinding.update({
+        where: { id: finding.id },
+        data: {
+          faultMeaning: now.title,
+          faultMeaningSource: now.source,
+          ...(ownWords ? {} : { description: faultLine(finding.faultCode, now.title) }),
+        },
+      })
+      revalidatePath(`/vehicles/${finding.vehicleId}`)
+      return updated
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+      ],
+      audit: ({ result }) => ({
+        action: 'finding.update',
+        entity: 'VehicleFinding',
+        entityId: result.id,
+        details: { key: 'finding_update', params: { description: result.description } },
+        metadata: {
+          findingId: result.id,
+          vehicleId: result.vehicleId,
+          ...findingWebhookFields(result),
+        },
+      }),
+    }
+  )
+}

+ 33 - 0
src/features/fault-codes/Actions/withdrawFaultCodeFix.ts

@@ -0,0 +1,33 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { demoGuard } from '@/lib/demo'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { withdrawSharedFix } from '../Lib/service.server'
+import { withdrawFixSchema } from '../Schema/faultCodeSchema'
+
+/**
+ * Withdraws a fix this workshop shared: the code service deletes it with its
+ * photos, whether it was waiting for review or already published. Only a fix
+ * this workshop sent can be withdrawn.
+ */
+export async function withdrawFaultCodeFix(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      demoGuard()
+      const data = withdrawFixSchema.parse(input)
+      const outcome = await withdrawSharedFix(organizationId, data.code, data.id)
+      if (outcome === 'unavailable') {
+        throw new Error('The fix could not be withdrawn right now. Try again in a moment.')
+      }
+      revalidatePath(`/fault-codes/${data.code}`)
+      return { withdrawn: outcome === 'withdrawn' }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+      ],
+    }
+  )
+}

+ 100 - 0
src/features/fault-codes/Components/DescriptionSections.tsx

@@ -0,0 +1,100 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+import type { FaultCodeSectionKind } from '@/features/integrations/Lib/types'
+import type { FaultCodeSection } from '../Lib/types'
+
+/** Above this many characters a section folds away behind "Show more". */
+const FOLD_AT = 320
+
+const HEADINGS: readonly FaultCodeSectionKind[] = [
+  'how_it_works',
+  'meaning',
+  'causes',
+  'manufacturer',
+  'related',
+  'diagnosis',
+  'other',
+]
+
+/**
+ * "What it means": the description in its labelled parts when the service
+ * cuts it so, each under a small heading of the app's own, and the whole
+ * description as one paragraph when it does not. A kind the app does not
+ * know yet reads under "Good to know".
+ *
+ * Beside a job (`sheet`) long parts fold behind "Show more"; on the code's
+ * own page there is room, so every part reads in full at a comfortable
+ * line length, each marked by a thin line in the theme colour.
+ */
+export function DescriptionSections({
+  sections,
+  description,
+  layout = 'sheet',
+}: {
+  sections: FaultCodeSection[]
+  description: string | null
+  layout?: 'sheet' | 'page'
+}) {
+  const t = useTranslations('faultCodes.sections')
+  const page = layout === 'page'
+  if (sections.length > 0) {
+    return (
+      <div className={page ? 'space-y-5' : 'space-y-3'}>
+        {sections.map((section, i) => {
+          const kind = HEADINGS.includes(section.kind) ? section.kind : 'other'
+          return (
+            <section
+              key={`${section.kind}-${i}`}
+              className={page ? 'space-y-1.5 border-l-2 border-primary/30 pl-4' : 'space-y-1'}
+            >
+              <h4
+                className={
+                  page
+                    ? 'text-xs font-semibold uppercase tracking-wide text-muted-foreground'
+                    : 'text-[11px] font-medium uppercase tracking-wide text-muted-foreground'
+                }
+              >
+                {t(kind)}
+              </h4>
+              <Foldable text={section.text} fold={!page} />
+            </section>
+          )
+        })}
+      </div>
+    )
+  }
+  if (!description) return null
+  return <Foldable text={description} fold={!page} />
+}
+
+function Foldable({ text, fold }: { text: string; fold: boolean }) {
+  const t = useTranslations('faultCodes.detail')
+  const long = fold && text.length > FOLD_AT
+  const [open, setOpen] = useState(false)
+  return (
+    <div>
+      <p
+        className={cn(
+          'whitespace-pre-line text-sm',
+          fold ? 'leading-relaxed' : 'max-w-[88ch] leading-7 text-foreground/90',
+          long && !open && 'line-clamp-4'
+        )}
+      >
+        {text}
+      </p>
+      {long && (
+        <button
+          type="button"
+          className="mt-1 text-xs font-medium text-primary hover:underline"
+          onClick={() => setOpen((v) => !v)}
+          aria-expanded={open}
+        >
+          {open ? t('showLess') : t('showMore')}
+        </button>
+      )}
+    </div>
+  )
+}

+ 113 - 0
src/features/fault-codes/Components/FaultCodeChip.tsx

@@ -0,0 +1,113 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+import type { FaultCodeView, MeaningSource } from '../Lib/types'
+
+/**
+ * A code as the app draws it everywhere: mono, boxed, the same on a
+ * finding, in the search palette and on the code page. A button when it
+ * opens something.
+ */
+export function FaultCodeChip({
+  code,
+  onClick,
+  className,
+  size = 'sm',
+}: {
+  code: string
+  onClick?: () => void
+  className?: string
+  size?: 'sm' | 'lg'
+}) {
+  const t = useTranslations('faultCodes')
+  const classes = cn(
+    'inline-flex shrink-0 items-center rounded-md border bg-muted font-mono font-semibold tracking-wide text-foreground',
+    size === 'lg' ? 'px-2 py-0.5 text-base' : 'px-1.5 py-px text-xs',
+    onClick && 'cursor-pointer transition-colors hover:border-primary/40 hover:bg-primary/10',
+    className
+  )
+  if (onClick) {
+    return (
+      <button
+        type="button"
+        className={classes}
+        onClick={onClick}
+        aria-label={t('chip.open', { code })}
+      >
+        {code}
+      </button>
+    )
+  }
+  return <span className={classes}>{code}</span>
+}
+
+/** "Generic", "Volkswagen", "From the scan report": where the meaning on screen comes from. */
+export function MeaningSourceLabel({
+  source,
+  make,
+  machineTranslated,
+  className,
+}: {
+  source: MeaningSource | null
+  make?: string | null
+  machineTranslated?: boolean
+  className?: string
+}) {
+  const t = useTranslations('faultCodes.source')
+  if (!source) return null
+  const label =
+    source === 'manufacturer'
+      ? make || t('manufacturer')
+      : source === 'report'
+        ? t('report')
+        : t('generic')
+  return (
+    <span className={cn('text-[11px] text-muted-foreground', className)}>
+      {label}
+      {machineTranslated ? ` · ${t('machineTranslated')}` : null}
+    </span>
+  )
+}
+
+/** Stored, pending, permanent, history, as the scan tool said. */
+export function FaultStatusBadge({ status }: { status: string | null | undefined }) {
+  const t = useTranslations('faultCodes.status')
+  if (!status || !['stored', 'pending', 'permanent', 'history'].includes(status)) return null
+  return (
+    <span className="inline-flex shrink-0 items-center rounded border px-1.5 py-px text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
+      {t(status as 'stored' | 'pending' | 'permanent' | 'history')}
+    </span>
+  )
+}
+
+/** The meaning, or why there is none, in one line beside a chip. */
+export function MeaningText({
+  view,
+  loading,
+  className,
+}: {
+  view: FaultCodeView | undefined
+  loading?: boolean
+  className?: string
+}) {
+  const t = useTranslations('faultCodes')
+  if (loading && !view) {
+    return (
+      <span className={cn('inline-block h-3 w-40 animate-pulse rounded bg-muted', className)} />
+    )
+  }
+  if (!view || view.state === 'unavailable') {
+    return (
+      <span className={cn('text-sm text-muted-foreground', className)}>{t('notAvailable')}</span>
+    )
+  }
+  if (view.state === 'unknown' || !view.title) {
+    return (
+      <span className={cn('text-sm text-muted-foreground', className)}>
+        {t(view.range === 'manufacturer' ? 'manufacturerUnknown' : 'noMeaning')}
+      </span>
+    )
+  }
+  return <span className={cn('text-sm', className)}>{view.title}</span>
+}

+ 457 - 0
src/features/fault-codes/Components/FaultCodeDetail.tsx

@@ -0,0 +1,457 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import {
+  AlertTriangle,
+  BookOpen,
+  ChevronRight,
+  CloudOff,
+  Gauge,
+  GitBranch,
+  Leaf,
+  LifeBuoy,
+  Lightbulb,
+  ListChecks,
+  RotateCw,
+  Wrench,
+} from 'lucide-react'
+import { AppCard } from '@/components/app-card'
+import { Button } from '@/components/ui/button'
+import { openSupport } from '@/features/support/Lib/supportVisibility'
+import { useFormatDate } from '@/lib/use-format-date'
+import { cn } from '@/lib/utils'
+import { pushRecentLookup } from '../Lib/lookups'
+import type { FaultCodeFlags, FaultCodePageData, ReportState } from '../Lib/types'
+import { DescriptionSections } from './DescriptionSections'
+import { FaultCodeChip, FaultStatusBadge, MeaningSourceLabel } from './FaultCodeChip'
+import { FaultCodesNotice } from './FaultCodesNotice'
+import { ReportMeaningPopover } from './ReportMeaningPopover'
+import { ShareFixCard } from './ShareFixCard'
+import { WorkshopFixesCard } from './WorkshopFixesCard'
+
+const LIKELIHOOD_RANK = { high: 0, medium: 1, low: 2 } as const
+const SYSTEMS = ['P', 'B', 'C', 'U'] as const
+
+/**
+ * One code: the meaning and where it comes from, what it means, likely
+ * causes, related codes and the jobs here where it was read. The workshop's
+ * jobs show whether or not the code service answered.
+ *
+ * Beside a job (`sheet`) it is one quiet column. On the code's own page
+ * (`page`) it spreads out: a header card across the top, the reading
+ * (meaning, causes, fixes from other workshops) in a wide column, and the
+ * workshop's side (related codes, jobs, share your fix) in a narrower one,
+ * stacking on a phone.
+ */
+export function FaultCodeDetail({
+  data,
+  onOpenCode,
+  layout = 'sheet',
+  supportEnabled = false,
+}: {
+  data: FaultCodePageData
+  /** Opens a related code in place, as the sheet does; links to its page otherwise. */
+  onOpenCode?: (code: string) => void
+  layout?: 'sheet' | 'page'
+  /** Whether the support widget is there to receive a question (cloud, switched on). */
+  supportEnabled?: boolean
+}) {
+  const t = useTranslations('faultCodes')
+  const { formatDate } = useFormatDate()
+  const { detail } = data
+  const page = layout === 'page'
+  const [report, setReport] = useState<ReportState | null>(data.report)
+  const title = detail.title
+
+  // Opening a code's page puts it first in this person's recent lookups.
+  useEffect(() => {
+    if (page) pushRecentLookup({ kind: 'code', code: data.code, title: title ?? null })
+  }, [page, data.code, title])
+
+  const askSupport = () =>
+    openSupport({
+      subject: t('support.subject', { code: data.code }),
+      message: t('support.message', { code: data.code, title: title ?? t('noMeaning') }),
+    })
+
+  const causes = [...detail.causes].sort(
+    (a, b) => LIKELIHOOD_RANK[a.likelihood] - LIKELIHOOD_RANK[b.likelihood]
+  )
+  const system = SYSTEMS.find((s) => data.code.startsWith(s))
+  const Heading = page ? 'h1' : 'h2'
+
+  const heading = (
+    <div className="min-w-0 space-y-2">
+      <div className="flex flex-wrap items-center gap-2">
+        <FaultCodeChip
+          code={data.code}
+          size="lg"
+          className={page ? 'px-2.5 py-1 text-lg' : undefined}
+        />
+        {page && system && (
+          <span className="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground">
+            {t(`systems.${system}`)}
+          </span>
+        )}
+        <MeaningSourceLabel
+          source={detail.source}
+          make={detail.make}
+          machineTranslated={detail.machineTranslated}
+        />
+      </div>
+      <Heading
+        className={
+          page
+            ? 'font-display text-xl font-semibold leading-snug tracking-tight sm:text-2xl'
+            : 'text-lg font-semibold leading-snug'
+        }
+      >
+        {title ?? (
+          <span className="font-normal text-muted-foreground">
+            {data.connected && !data.available
+              ? t('notAvailable')
+              : detail.range === 'manufacturer'
+                ? t('manufacturerUnknown')
+                : t('noMeaning')}
+          </span>
+        )}
+      </Heading>
+      {detail.flags && <Flags flags={detail.flags} />}
+    </div>
+  )
+
+  const reportButton = data.connected && (
+    <ReportMeaningPopover
+      code={data.code}
+      version={detail.version}
+      lang={detail.lang}
+      vehicleId={data.vehicle?.id}
+      report={report}
+      onReported={setReport}
+    />
+  )
+
+  const notices = (
+    <>
+      {!data.connected && data.connectable && (
+        <Notice icon={CloudOff}>
+          <span>{t('detail.notConnected')}</span>
+          <Button asChild size="xs" variant="outline">
+            <Link href="/settings/integrations/torqvoice-codes">{t('detail.connect')}</Link>
+          </Button>
+        </Notice>
+      )}
+      {data.connected && !data.available && (
+        <Notice icon={CloudOff}>{t('detail.serviceDown')}</Notice>
+      )}
+    </>
+  )
+
+  const meaning = (detail.sections.length > 0 || detail.description || detail.pendingCheck) && (
+    <Part card={page} icon={BookOpen} title={t('detail.whatItMeans')}>
+      <DescriptionSections
+        sections={detail.sections}
+        description={detail.description}
+        layout={layout}
+      />
+      {detail.pendingCheck && (
+        <p className="text-xs text-muted-foreground">{t('detail.pendingCheck')}</p>
+      )}
+    </Part>
+  )
+
+  const causeList = causes.length > 0 && (
+    <Part
+      card={page}
+      icon={ListChecks}
+      title={t('detail.likelyCauses')}
+      description={page ? t('detail.mostLikelyFirst') : undefined}
+      flush={page}
+    >
+      <ul className={page ? 'divide-y divide-card-edge/60' : 'space-y-2'}>
+        {causes.map((cause) => (
+          <li
+            key={cause.key}
+            className={cn('flex items-start gap-3', page && 'items-center px-5 py-3')}
+          >
+            <Likelihood level={cause.likelihood} />
+            <span className="min-w-0 flex-1 text-sm">{cause.label}</span>
+            {page && (
+              <span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
+                {t(`detail.likelihood.${cause.likelihood}`)}
+              </span>
+            )}
+          </li>
+        ))}
+      </ul>
+    </Part>
+  )
+
+  const related = detail.related.length > 0 && (
+    <Part
+      card={page}
+      icon={GitBranch}
+      title={t('detail.relatedCodes')}
+      badge={page ? detail.related.length : undefined}
+      flush={page}
+    >
+      <ul className={page ? 'divide-y divide-card-edge/60' : 'space-y-1.5'}>
+        {detail.related.map((r) =>
+          page ? (
+            <li key={r.code}>
+              <Link
+                href={`/fault-codes/${r.code}`}
+                className="group flex items-center gap-3 px-5 py-2.5 transition-colors hover:bg-muted/50"
+              >
+                <FaultCodeChip code={r.code} />
+                <span className="line-clamp-2 min-w-0 flex-1 text-sm text-muted-foreground group-hover:text-foreground">
+                  {r.title}
+                </span>
+                <ChevronRight className="size-4 shrink-0 text-muted-foreground/60" />
+              </Link>
+            </li>
+          ) : (
+            <li key={r.code} className="flex items-center gap-2">
+              {onOpenCode ? (
+                <FaultCodeChip code={r.code} onClick={() => onOpenCode(r.code)} />
+              ) : (
+                <Link href={`/fault-codes/${r.code}`} aria-label={t('chip.open', { code: r.code })}>
+                  <FaultCodeChip
+                    code={r.code}
+                    className="cursor-pointer hover:border-primary/40 hover:bg-primary/10"
+                  />
+                </Link>
+              )}
+              {r.title && <span className="truncate text-sm text-muted-foreground">{r.title}</span>}
+            </li>
+          )
+        )}
+      </ul>
+    </Part>
+  )
+
+  const jobs = (
+    <Part
+      card={page}
+      icon={Wrench}
+      title={t('detail.inYourWorkshop')}
+      badge={page && data.jobs.length > 0 ? data.jobs.length : undefined}
+      flush={page && data.jobs.length > 0}
+    >
+      {data.jobs.length === 0 ? (
+        <p className="text-sm text-muted-foreground">{t('detail.noJobs')}</p>
+      ) : (
+        <ul className={page ? 'divide-y divide-card-edge/60' : 'divide-y rounded-md border'}>
+          {data.jobs.map((job) => {
+            const href = job.serviceRecordId
+              ? `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`
+              : `/vehicles/${job.vehicleId}`
+            return (
+              <li key={job.findingId}>
+                <Link
+                  href={href}
+                  className={cn(
+                    'flex flex-col gap-0.5 text-sm hover:bg-muted/50 sm:flex-row sm:items-center sm:justify-between sm:gap-3',
+                    page ? 'px-5 py-2.5' : 'px-3 py-2'
+                  )}
+                >
+                  <span className="min-w-0">
+                    <span className="block truncate font-medium">{job.title ?? job.vehicle}</span>
+                    <span className="block truncate text-xs text-muted-foreground">
+                      {[job.title ? job.vehicle : null, job.licensePlate]
+                        .filter(Boolean)
+                        .join(' · ')}
+                    </span>
+                  </span>
+                  <span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
+                    <FaultStatusBadge status={job.faultStatus} />
+                    {job.status === 'resolved' && <span>{t('detail.resolved')}</span>}
+                    <span>{formatDate(job.at)}</span>
+                  </span>
+                </Link>
+              </li>
+            )
+          })}
+        </ul>
+      )}
+    </Part>
+  )
+
+  // Shared fixes live on the code's own page, where there is room for them.
+  const fixes = page && data.connected && data.fixes
+  const fromOtherWorkshops = fixes && (
+    <WorkshopFixesCard
+      code={data.code}
+      fixes={data.fixes?.published ?? []}
+      available={data.fixes?.available ?? false}
+    />
+  )
+  const shareFix = fixes && (
+    <ShareFixCard
+      code={data.code}
+      vehicle={data.vehicle}
+      own={data.fixes?.own ?? []}
+      available={data.fixes?.available ?? false}
+    />
+  )
+
+  if (!page) {
+    return (
+      <div className="space-y-6">
+        <header className="space-y-2">
+          {heading}
+          {reportButton}
+        </header>
+        {notices}
+        {meaning}
+        {causeList}
+        {related}
+        {jobs}
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-4">
+      <header className="relative overflow-hidden rounded-lg border border-card-edge bg-card shadow-[0_1px_2px_rgb(0_0_0/0.05),0_12px_32px_-16px_rgb(0_0_0/0.18)] before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(28rem_10rem_at_0%_0%,color-mix(in_oklab,var(--primary)_9%,transparent),transparent_70%)]">
+        <div className="relative flex flex-col gap-4 p-5 sm:p-6 lg:flex-row lg:items-start lg:justify-between">
+          {heading}
+          <div className="flex shrink-0 flex-wrap items-center gap-2 lg:flex-col lg:items-end">
+            <div className="flex flex-wrap items-center gap-1">
+              {reportButton}
+              {supportEnabled && (
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="xs"
+                  onClick={askSupport}
+                  className="gap-1.5"
+                >
+                  <LifeBuoy className="size-3.5" />
+                  {t('support.button')}
+                </Button>
+              )}
+            </div>
+          </div>
+        </div>
+      </header>
+      <FaultCodesNotice />
+      {notices}
+      <div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_26rem]">
+        <div className="min-w-0 space-y-4">
+          {meaning}
+          {causeList}
+          {fromOtherWorkshops}
+          {shareFix}
+        </div>
+        <div className="min-w-0 space-y-4">
+          {related}
+          {jobs}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+/**
+ * One part of the detail: a plain titled block beside a job, a house card
+ * on the code's page. `flush` lets a list draw its own rows edge to edge.
+ */
+function Part({
+  card,
+  icon,
+  title,
+  description,
+  badge,
+  flush,
+  children,
+}: {
+  card: boolean
+  icon: typeof AlertTriangle
+  title: string
+  description?: string
+  badge?: number
+  flush?: boolean
+  children: React.ReactNode
+}) {
+  if (card) {
+    return (
+      <AppCard
+        icon={icon}
+        title={title}
+        description={description}
+        badge={badge}
+        contentClassName={flush ? 'p-0 py-1' : 'space-y-3'}
+      >
+        {children}
+      </AppCard>
+    )
+  }
+  return (
+    <section className="space-y-2">
+      <h3 className="text-sm font-semibold">{title}</h3>
+      {children}
+    </section>
+  )
+}
+
+function Notice({
+  icon: Icon,
+  children,
+}: {
+  icon: typeof AlertTriangle
+  children: React.ReactNode
+}) {
+  return (
+    <div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
+      <Icon className="size-4 shrink-0" />
+      {children}
+    </div>
+  )
+}
+
+/** High, medium or low as a small bar of three, with the word for screen readers. */
+function Likelihood({ level }: { level: 'low' | 'medium' | 'high' }) {
+  const t = useTranslations('faultCodes.detail.likelihood')
+  const filled = level === 'high' ? 3 : level === 'medium' ? 2 : 1
+  return (
+    <span className="mt-1.5 flex shrink-0 items-center gap-0.5" title={t(level)}>
+      {[1, 2, 3].map((i) => (
+        <span
+          key={i}
+          className={cn('h-1.5 w-3 rounded-full', i <= filled ? 'bg-primary' : 'bg-muted')}
+        />
+      ))}
+      <span className="sr-only">{t(level)}</span>
+    </span>
+  )
+}
+
+function Flags({ flags }: { flags: FaultCodeFlags }) {
+  const t = useTranslations('faultCodes.flags')
+  const items = [
+    flags.warningLight && { key: 'warningLight', icon: Lightbulb },
+    flags.emissions && { key: 'emissions', icon: Leaf },
+    flags.driveCycle && { key: 'driveCycle', icon: RotateCw },
+    flags.limpMode && { key: 'limpMode', icon: Gauge },
+  ].filter(Boolean) as {
+    key: 'warningLight' | 'emissions' | 'driveCycle' | 'limpMode'
+    icon: typeof Wrench
+  }[]
+  if (items.length === 0) return null
+  return (
+    <ul className="flex flex-wrap gap-1.5">
+      {items.map(({ key, icon: Icon }) => (
+        <li
+          key={key}
+          className="inline-flex items-center gap-1 rounded-full border bg-muted/40 px-2 py-0.5 text-[11px] text-muted-foreground"
+        >
+          <Icon className="size-3" />
+          {t(key)}
+        </li>
+      ))}
+    </ul>
+  )
+}

+ 257 - 0
src/features/fault-codes/Components/FaultCodeEntryDialog.tsx

@@ -0,0 +1,257 @@
+'use client'
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { Loader2, X } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogBody,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Label } from '@/components/ui/label'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { Textarea } from '@/components/ui/textarea'
+import { addFaultCodeFindings } from '../Actions/addFaultCodeFindings'
+import { lookupFaultCodes } from '../Actions/lookupFaultCodes'
+import { extractFaultCodes, isManufacturerCode } from '../Lib/codes'
+import type { FaultCodeView } from '../Lib/types'
+import { FaultCodeChip, FaultStatusBadge, MeaningSourceLabel, MeaningText } from './FaultCodeChip'
+
+/** Wait this long after the last keystroke before asking for meanings. */
+const LOOKUP_DEBOUNCE_MS = 250
+
+function unavailable(code: string): FaultCodeView {
+  return {
+    code,
+    state: 'unavailable',
+    range: isManufacturerCode(code) ? 'manufacturer' : 'standard',
+    title: null,
+    source: null,
+    make: null,
+    lang: null,
+    machineTranslated: false,
+    verified: false,
+    version: null,
+    flags: null,
+  }
+}
+
+/**
+ * "Add fault codes" on a work order: a box that turns whatever is typed or
+ * pasted ("p0420, P0171 stored") into code chips as it goes, each with its
+ * meaning on this vehicle as soon as it arrives, and adds them to the job's
+ * findings in one go. The codes are there the moment they are typed; a
+ * meaning that does not come says so and the code is added all the same.
+ */
+export function FaultCodeEntryDialog({
+  open,
+  onOpenChange,
+  serviceRecordId,
+  vehicleId,
+  concerns = [],
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  serviceRecordId: string
+  vehicleId: string
+  concerns?: { id: string; description: string }[]
+}) {
+  const t = useTranslations('faultCodes.entry')
+  const router = useRouter()
+  const [text, setText] = useState('')
+  const [removed, setRemoved] = useState<Set<string>>(new Set())
+  const [views, setViews] = useState<Record<string, FaultCodeView>>({})
+  const [pending, setPending] = useState<Set<string>>(new Set())
+  // 'none' rather than '' because Radix treats an empty value as unset.
+  const [concernId, setConcernId] = useState('none')
+  const [saving, setSaving] = useState(false)
+  const asked = useRef(new Set<string>())
+
+  useEffect(() => {
+    if (open) return
+    setText('')
+    setRemoved(new Set())
+    setConcernId('none')
+  }, [open])
+
+  const found = useMemo(() => extractFaultCodes(text), [text])
+  const codes = found.filter((c) => !removed.has(c.code))
+
+  // Ask once per code, for the codes not asked about yet.
+  useEffect(() => {
+    const fresh = found.map((c) => c.code).filter((code) => !asked.current.has(code))
+    if (fresh.length === 0) return
+    const timer = setTimeout(() => {
+      for (const code of fresh) asked.current.add(code)
+      setPending((prev) => new Set([...prev, ...fresh]))
+      lookupFaultCodes({ codes: fresh, vehicleId, language: 'document' }).then((result) => {
+        setPending((prev) => {
+          const next = new Set(prev)
+          for (const code of fresh) next.delete(code)
+          return next
+        })
+        if (!result.success || !result.data) {
+          // Shown as not available, and asked again on the next keystroke.
+          for (const code of fresh) asked.current.delete(code)
+          setViews((prev) => {
+            const next = { ...prev }
+            for (const code of fresh) next[code] = unavailable(code)
+            return next
+          })
+          return
+        }
+        const answered = result.data.results
+        setViews((prev) => {
+          const next = { ...prev }
+          for (const view of answered) next[view.code] = view
+          return next
+        })
+        // A code the service did not answer for is asked again on the next keystroke.
+        if (!result.data.available) {
+          for (const view of answered) {
+            if (view.state === 'unavailable') asked.current.delete(view.code)
+          }
+        }
+      })
+    }, LOOKUP_DEBOUNCE_MS)
+    return () => clearTimeout(timer)
+  }, [found, vehicleId])
+
+  const add = async () => {
+    if (codes.length === 0) return
+    setSaving(true)
+    const result = await addFaultCodeFindings({
+      serviceRecordId,
+      codes: codes.map((c) => ({ code: c.code, status: c.status ?? null })),
+      concernId: concernId === 'none' ? null : concernId,
+    })
+    setSaving(false)
+    if (!result.success || !result.data) {
+      toast.error(result.error || t('failed'))
+      return
+    }
+    const { added, skipped } = result.data
+    if (added > 0) toast.success(t('added', { count: added }))
+    if (skipped > 0) toast.info(t('skipped', { count: skipped }))
+    onOpenChange(false)
+    router.refresh()
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-lg">
+        <DialogHeader>
+          <DialogTitle>{t('title')}</DialogTitle>
+          <DialogDescription>{t('description')}</DialogDescription>
+        </DialogHeader>
+        <DialogBody className="min-w-0 space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="fault-code-entry" className="sr-only">
+              {t('title')}
+            </Label>
+            <Textarea
+              id="fault-code-entry"
+              autoFocus
+              value={text}
+              onChange={(e) => setText(e.target.value)}
+              placeholder={t('placeholder')}
+              rows={3}
+              className="font-mono"
+              spellCheck={false}
+              autoCapitalize="characters"
+            />
+          </div>
+
+          {codes.length === 0 ? (
+            <p className="text-sm text-muted-foreground">{t('empty')}</p>
+          ) : (
+            <ul className="space-y-1.5" aria-label={t('found')}>
+              {codes.map((c) => {
+                const view = views[c.code]
+                return (
+                  <li
+                    key={c.code}
+                    className="flex items-start gap-2 rounded-md border px-2.5 py-2"
+                    data-testid="fault-code-row"
+                  >
+                    <div className="min-w-0 flex-1 space-y-0.5">
+                      <div className="flex flex-wrap items-center gap-1.5">
+                        <FaultCodeChip code={c.code} />
+                        <FaultStatusBadge status={c.status} />
+                        {view?.state === 'found' && (
+                          <MeaningSourceLabel
+                            source={view.source}
+                            make={view.make}
+                            machineTranslated={view.machineTranslated}
+                          />
+                        )}
+                      </div>
+                      <MeaningText
+                        view={view}
+                        loading={pending.has(c.code) || !view}
+                        className="block"
+                      />
+                    </div>
+                    <Button
+                      type="button"
+                      variant="ghost"
+                      size="icon-xs"
+                      aria-label={t('remove', { code: c.code })}
+                      onClick={() => setRemoved((prev) => new Set([...prev, c.code]))}
+                    >
+                      <X />
+                    </Button>
+                  </li>
+                )
+              })}
+            </ul>
+          )}
+
+          {concerns.length > 0 && (
+            <div className="min-w-0 space-y-2">
+              <Label htmlFor="fault-code-concern">{t('concern')}</Label>
+              <Select value={concernId} onValueChange={setConcernId}>
+                <SelectTrigger
+                  id="fault-code-concern"
+                  className="w-full min-w-0 *:data-[slot=select-value]:block *:data-[slot=select-value]:min-w-0 *:data-[slot=select-value]:truncate"
+                >
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent className="max-w-[min(28rem,calc(100vw-4rem))]">
+                  <SelectItem value="none">{t('concernNone')}</SelectItem>
+                  {concerns.map((concern) => (
+                    <SelectItem key={concern.id} value={concern.id} className="whitespace-normal">
+                      {concern.description}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+            </div>
+          )}
+        </DialogBody>
+        <DialogFooter>
+          <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+            {t('cancel')}
+          </Button>
+          <Button type="button" disabled={codes.length === 0 || saving} onClick={add}>
+            {saving && <Loader2 className="animate-spin" />}
+            {t('add', { count: codes.length })}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 382 - 0
src/features/fault-codes/Components/FaultCodeLookup.tsx

@@ -0,0 +1,382 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { useLocale, useTranslations } from 'next-intl'
+import {
+  ChevronRight,
+  CloudOff,
+  History,
+  LifeBuoy,
+  Loader2,
+  ScanSearch,
+  Search,
+  Wrench,
+} from 'lucide-react'
+import { AppCard } from '@/components/app-card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { openSupport } from '@/features/support/Lib/supportVisibility'
+import { useFormatDate } from '@/lib/use-format-date'
+import { cn } from '@/lib/utils'
+import { suggestFaultCodes } from '../Actions/suggestFaultCodes'
+import { normalizeFaultCode } from '../Lib/codes'
+import {
+  clearRecentLookups,
+  pushRecentLookup,
+  readRecentLookups,
+  type RecentLookup,
+} from '../Lib/lookups'
+import { FaultCodeChip } from './FaultCodeChip'
+import { FaultCodesNotice } from './FaultCodesNotice'
+
+export interface FaultCodeOverview {
+  connected: boolean
+  connectable: boolean
+  query: string
+  available: boolean
+  hits: { code: string; title: string }[]
+  recent: { code: string; meaning: string | null; at: string; count: number }[]
+}
+
+type Results = { query: string; available: boolean; hits: { code: string; title: string }[] }
+
+/** Codes to try that every workshop knows. */
+const EXAMPLE_CODES = ['P0420', 'P0300', 'P0171']
+/** How long typing rests before the service is asked. */
+const DEBOUNCE_MS = 250
+
+/**
+ * The fault code page. A code goes straight to its page; words ("lean bank
+ * 1") or the start of a code ("P04") list the matching meanings as they are
+ * typed. Under it, this person's recent lookups (kept in this browser) and
+ * the codes this workshop read lately.
+ */
+export function FaultCodeLookup({
+  overview,
+  supportEnabled = false,
+}: {
+  overview: FaultCodeOverview
+  /** Whether the support widget is there to receive a question (cloud, switched on). */
+  supportEnabled?: boolean
+}) {
+  const t = useTranslations('faultCodes.page')
+  const tc = useTranslations('faultCodes')
+  const router = useRouter()
+  const locale = useLocale()
+  const { formatDate } = useFormatDate()
+  const [value, setValue] = useState(overview.query)
+  const [results, setResults] = useState<Results | null>(
+    overview.query
+      ? { query: overview.query, available: overview.available, hits: overview.hits }
+      : null
+  )
+  const [loading, setLoading] = useState(false)
+  const latest = useRef(overview.query)
+  const [recent, setRecent] = useState<RecentLookup[]>([])
+  const [now, setNow] = useState(0)
+
+  // Recent lookups live in this browser, so they appear after the first paint.
+  // A search that arrived in the address bar counts as one.
+  useEffect(() => {
+    setNow(Date.now())
+    setRecent(
+      overview.query
+        ? pushRecentLookup({ kind: 'words', query: overview.query })
+        : readRecentLookups()
+    )
+  }, [overview.query])
+
+  // Matches while typing, once typing rests; a slower answer to an older
+  // query never replaces a newer one.
+  useEffect(() => {
+    const text = value.trim()
+    latest.current = text
+    if (text.length < 2 || !overview.connected) {
+      setResults(null)
+      setLoading(false)
+      return
+    }
+    if (text === overview.query) {
+      setResults({ query: overview.query, available: overview.available, hits: overview.hits })
+      setLoading(false)
+      return
+    }
+    setLoading(true)
+    const timer = setTimeout(async () => {
+      const reply = await suggestFaultCodes(text)
+      if (latest.current !== text) return
+      setLoading(false)
+      if (reply.success && reply.data) setResults(reply.data)
+    }, DEBOUNCE_MS)
+    return () => clearTimeout(timer)
+  }, [value, overview.connected, overview.query, overview.available, overview.hits])
+
+  const go = (raw: string) => {
+    const text = raw.trim()
+    if (!text) return
+    const code = normalizeFaultCode(text)
+    if (code) {
+      router.push(`/fault-codes/${code}`)
+      return
+    }
+    setRecent(pushRecentLookup({ kind: 'words', query: text }))
+    router.push(`/fault-codes?q=${encodeURIComponent(text)}`)
+  }
+
+  const submit = (e: React.FormEvent) => {
+    e.preventDefault()
+    go(value)
+  }
+
+  const examples = [...EXAMPLE_CODES, t('exampleWords')]
+
+  return (
+    <div className="mx-auto w-full max-w-[100rem] space-y-4">
+      <section className="relative overflow-hidden rounded-lg border border-card-edge bg-card shadow-[0_1px_2px_rgb(0_0_0/0.05),0_12px_32px_-16px_rgb(0_0_0/0.18)] before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(32rem_12rem_at_0%_0%,color-mix(in_oklab,var(--primary)_10%,transparent),transparent_70%)]">
+        <div className="relative space-y-5 p-5 sm:p-8">
+          <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
+            <div className="flex items-start gap-3">
+              <div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-linear-to-b from-primary/15 to-primary/5 text-primary ring-1 ring-inset ring-primary/20">
+                <ScanSearch className="size-5" />
+              </div>
+              <div className="space-y-1">
+                <h1 className="font-display text-xl font-semibold tracking-tight sm:text-2xl">
+                  {t('title')}
+                </h1>
+                <p className="text-sm text-muted-foreground">{t('intro')}</p>
+              </div>
+            </div>
+            <div className="flex shrink-0 flex-wrap items-center gap-2">
+              {supportEnabled && (
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="xs"
+                  className="gap-1.5"
+                  onClick={() => openSupport({ subject: tc('support.subjectGeneral') })}
+                >
+                  <LifeBuoy className="size-3.5" />
+                  {tc('support.button')}
+                </Button>
+              )}
+            </div>
+          </div>
+
+          <FaultCodesNotice className="bg-background/60" />
+
+          <form onSubmit={submit} className="flex flex-col gap-2 sm:flex-row" role="search">
+            <label htmlFor="fault-code-lookup" className="sr-only">
+              {t('lookupLabel')}
+            </label>
+            <div className="relative flex-1">
+              <Search className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
+              <Input
+                id="fault-code-lookup"
+                value={value}
+                onChange={(e) => setValue(e.target.value)}
+                placeholder={t('lookupPlaceholder')}
+                autoFocus
+                autoComplete="off"
+                spellCheck={false}
+                className="h-12 bg-background/60 pl-11 pr-10 font-mono text-base"
+              />
+              {loading && (
+                <Loader2
+                  className="absolute right-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-muted-foreground"
+                  aria-label={t('searching')}
+                />
+              )}
+            </div>
+            <Button type="submit" className="h-12 px-6">
+              {t('lookup')}
+            </Button>
+          </form>
+
+          <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
+            <span>{t('tryExamples')}</span>
+            {examples.map((example) => (
+              <button
+                key={example}
+                type="button"
+                onClick={() => {
+                  setValue(example)
+                  go(example)
+                }}
+                className={cn(
+                  'rounded-md border bg-background/60 px-2 py-1 text-foreground transition-colors hover:border-primary/40 hover:bg-primary/10',
+                  normalizeFaultCode(example) && 'font-mono font-semibold'
+                )}
+              >
+                {example}
+              </button>
+            ))}
+          </div>
+        </div>
+      </section>
+
+      {!overview.connected && overview.connectable && (
+        <div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
+          <CloudOff className="size-4 shrink-0" />
+          <span>{tc('detail.notConnected')}</span>
+          <Button asChild size="xs" variant="outline">
+            <Link href="/settings/integrations/torqvoice-codes">{tc('detail.connect')}</Link>
+          </Button>
+        </div>
+      )}
+      {!overview.connectable && <p className="text-sm text-muted-foreground">{t('cloudOnly')}</p>}
+
+      {results && (
+        <AppCard
+          icon={Search}
+          title={t('results', { query: results.query })}
+          badge={results.available && results.hits.length > 0 ? results.hits.length : undefined}
+          contentClassName={results.available && results.hits.length > 0 ? 'p-0' : undefined}
+          className={cn('transition-opacity', loading && 'opacity-60')}
+        >
+          {!results.available ? (
+            <p className="text-sm text-muted-foreground">{tc('notAvailable')}</p>
+          ) : results.hits.length === 0 ? (
+            <p className="text-sm text-muted-foreground">
+              {t('noResults', { query: results.query })}
+            </p>
+          ) : (
+            <ul className="grid md:grid-cols-2">
+              {results.hits.map((hit, i) => (
+                <li
+                  key={hit.code}
+                  className={cn(
+                    'border-card-edge/60',
+                    i > 0 && 'border-t',
+                    i === 1 && 'md:border-t-0',
+                    i % 2 === 0 && 'md:border-r'
+                  )}
+                >
+                  <CodeRow code={hit.code} title={hit.title} />
+                </li>
+              ))}
+            </ul>
+          )}
+        </AppCard>
+      )}
+
+      <div className="grid items-start gap-4 lg:grid-cols-2">
+        <AppCard
+          icon={History}
+          title={t('recentLookups')}
+          action={
+            recent.length > 0 ? (
+              <Button
+                type="button"
+                variant="ghost"
+                size="xs"
+                className="text-muted-foreground"
+                onClick={() => {
+                  clearRecentLookups()
+                  setRecent([])
+                }}
+              >
+                {t('clearRecent')}
+              </Button>
+            ) : undefined
+          }
+          contentClassName={recent.length > 0 ? 'p-0 py-1' : undefined}
+        >
+          {recent.length === 0 ? (
+            <p className="text-sm text-muted-foreground">{t('recentLookupsEmpty')}</p>
+          ) : (
+            <ul className="divide-y divide-card-edge/60">
+              {recent.map((r) => (
+                <li key={r.kind === 'code' ? `c:${r.code}` : `w:${r.query}`}>
+                  {r.kind === 'code' ? (
+                    <CodeRow
+                      code={r.code}
+                      title={r.title}
+                      meta={now ? timeAgo(r.at, now, locale) : undefined}
+                    />
+                  ) : (
+                    <button
+                      type="button"
+                      onClick={() => {
+                        setValue(r.query)
+                        go(r.query)
+                      }}
+                      className="group flex w-full items-center gap-3 px-5 py-2.5 text-left transition-colors hover:bg-muted/50"
+                    >
+                      <span className="flex size-6 shrink-0 items-center justify-center rounded-md border bg-muted">
+                        <Search className="size-3.5 text-muted-foreground" />
+                      </span>
+                      <span className="min-w-0 flex-1 truncate text-sm">“{r.query}”</span>
+                      {now > 0 && (
+                        <span className="shrink-0 text-xs text-muted-foreground">
+                          {timeAgo(r.at, now, locale)}
+                        </span>
+                      )}
+                      <ChevronRight className="size-4 shrink-0 text-muted-foreground/60" />
+                    </button>
+                  )}
+                </li>
+              ))}
+            </ul>
+          )}
+        </AppCard>
+
+        <AppCard
+          icon={Wrench}
+          title={t('recent')}
+          badge={overview.recent.length || undefined}
+          contentClassName={overview.recent.length > 0 ? 'p-0 py-1' : undefined}
+        >
+          {overview.recent.length === 0 ? (
+            <p className="text-sm text-muted-foreground">{t('noRecent')}</p>
+          ) : (
+            <ul className="divide-y divide-card-edge/60">
+              {overview.recent.map((r) => (
+                <li key={r.code}>
+                  <CodeRow
+                    code={r.code}
+                    title={r.meaning}
+                    meta={t('readTimes', { count: r.count, date: formatDate(r.at) })}
+                  />
+                </li>
+              ))}
+            </ul>
+          )}
+        </AppCard>
+      </div>
+    </div>
+  )
+}
+
+/** "2 minutes ago", "yesterday", in the reader's language. */
+function timeAgo(at: number, now: number, locale: string): string {
+  const seconds = Math.round((at - now) / 1000)
+  const abs = Math.abs(seconds)
+  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
+  if (abs < 60) return rtf.format(seconds, 'second')
+  if (abs < 3600) return rtf.format(Math.round(seconds / 60), 'minute')
+  if (abs < 86_400) return rtf.format(Math.round(seconds / 3600), 'hour')
+  if (abs < 604_800) return rtf.format(Math.round(seconds / 86_400), 'day')
+  if (abs < 2_629_800) return rtf.format(Math.round(seconds / 604_800), 'week')
+  return rtf.format(Math.round(seconds / 2_629_800), 'month')
+}
+
+/** A code, its meaning and a quiet note on the right, opening the code's page. */
+function CodeRow({ code, title, meta }: { code: string; title: string | null; meta?: string }) {
+  return (
+    <Link
+      href={`/fault-codes/${code}`}
+      className="group flex items-center gap-3 px-5 py-2.5 transition-colors hover:bg-muted/50"
+    >
+      <FaultCodeChip code={code} />
+      <span className="min-w-0 flex-1 truncate text-sm">
+        {title ?? <span className="text-muted-foreground">{code}</span>}
+      </span>
+      {meta && (
+        <span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">{meta}</span>
+      )}
+      <ChevronRight className="size-4 shrink-0 text-muted-foreground/60" />
+    </Link>
+  )
+}

+ 90 - 0
src/features/fault-codes/Components/FaultCodeSheet.tsx

@@ -0,0 +1,90 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { ExternalLink, Loader2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+  Sheet,
+  SheetContent,
+  SheetDescription,
+  SheetHeader,
+  SheetTitle,
+} from '@/components/ui/sheet'
+import { getFaultCodeDetail } from '../Actions/getFaultCodeDetail'
+import type { FaultCodePageData } from '../Lib/types'
+import { FaultCodeDetail } from './FaultCodeDetail'
+
+/**
+ * The code sheet: a code opened from a finding, beside the job it was read
+ * on, so the meaning is for that vehicle's make. A related code opens in
+ * the same sheet; the code's own page is a link away.
+ */
+export function FaultCodeSheet({
+  code,
+  vehicleId,
+  onOpenChange,
+}: {
+  /** The code to show; null keeps the sheet closed. */
+  code: string | null
+  vehicleId?: string | null
+  onOpenChange: (open: boolean) => void
+}) {
+  const t = useTranslations('faultCodes')
+  const [shown, setShown] = useState<string | null>(code)
+  const [data, setData] = useState<FaultCodePageData | null>(null)
+  const [failed, setFailed] = useState(false)
+
+  useEffect(() => {
+    setShown(code)
+  }, [code])
+
+  useEffect(() => {
+    if (!shown) return
+    let cancelled = false
+    setData(null)
+    setFailed(false)
+    getFaultCodeDetail({ code: shown, vehicleId: vehicleId ?? null }).then((result) => {
+      if (cancelled) return
+      if (result.success && result.data) setData(result.data)
+      else setFailed(true)
+    })
+    return () => {
+      cancelled = true
+    }
+  }, [shown, vehicleId])
+
+  return (
+    <Sheet open={code !== null} onOpenChange={onOpenChange}>
+      <SheetContent side="right" className="w-full gap-0 overflow-y-auto sm:max-w-lg">
+        <SheetHeader className="border-b">
+          <SheetTitle>{shown ?? t('title')}</SheetTitle>
+          <SheetDescription className="sr-only">{t('sheet.description')}</SheetDescription>
+        </SheetHeader>
+        <div className="p-4">
+          {data && data.code === shown ? (
+            <FaultCodeDetail key={data.code} data={data} onOpenCode={setShown} />
+          ) : failed ? (
+            <p className="text-sm text-muted-foreground">{t('sheet.loadFailed')}</p>
+          ) : (
+            <div className="flex items-center gap-2 text-sm text-muted-foreground">
+              <Loader2 className="size-4 animate-spin" />
+              {t('sheet.loading')}
+            </div>
+          )}
+        </div>
+        {shown && (
+          <div className="mt-auto border-t p-4">
+            <Button asChild variant="outline" size="sm">
+              <Link href={`/fault-codes/${shown}${vehicleId ? `?vehicle=${vehicleId}` : ''}`}>
+                <ExternalLink />
+                {t('sheet.openPage')}
+              </Link>
+            </Button>
+          </div>
+        )}
+      </SheetContent>
+    </Sheet>
+  )
+}

+ 32 - 0
src/features/fault-codes/Components/FaultCodesNotice.tsx

@@ -0,0 +1,32 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { Sparkles } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+/**
+ * Says fault codes are new and still growing, so a code that is not here yet
+ * reads as "coming" rather than "broken", and that only the standard OBD
+ * codes are covered for now. Visible text rather than a tooltip, because a
+ * tooltip never opens on a phone.
+ */
+export function FaultCodesNotice({ className }: { className?: string }) {
+  const t = useTranslations('faultCodes.notice')
+  return (
+    <div
+      role="note"
+      className={cn(
+        'flex items-start gap-3 rounded-lg border border-primary/20 bg-primary/5 px-4 py-3',
+        className
+      )}
+    >
+      <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
+        <Sparkles className="size-3.5" aria-hidden />
+      </span>
+      <div className="min-w-0 space-y-0.5 text-sm">
+        <p className="font-medium">{t('title')}</p>
+        <p className="text-muted-foreground">{t('body')}</p>
+      </div>
+    </div>
+  )
+}

+ 221 - 0
src/features/fault-codes/Components/FaultCodesOnJob.tsx

@@ -0,0 +1,221 @@
+'use client'
+
+import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { History, RefreshCw } from 'lucide-react'
+import { toast } from 'sonner'
+import { useServiceType } from '@/components/service-type-context'
+import { useFormatDate } from '@/lib/use-format-date'
+import { checkFindingMeanings } from '../Actions/checkFindingMeanings'
+import { takeFindingMeaning } from '../Actions/takeFindingMeaning'
+import { FaultCodeChip, FaultStatusBadge, MeaningSourceLabel } from './FaultCodeChip'
+import { FaultCodeEntryDialog } from './FaultCodeEntryDialog'
+import { FaultCodeSheet } from './FaultCodeSheet'
+import type { MeaningSource } from '../Lib/types'
+
+/** What the work order page knows about fault codes on this job, from the server. */
+export interface FaultCodesJobInfo {
+  /** The workshop has the code service connected, so codes can be typed in. */
+  connected: boolean
+  /** Code to when it was last read on this vehicle in another job (ISO). */
+  seenBefore: Record<string, string>
+}
+
+interface JobFaultCodes extends FaultCodesJobInfo {
+  vehicleId: string
+  serviceRecordId: string
+  locked: boolean
+  openEntry: () => void
+  openCode: (code: string) => void
+}
+
+const Ctx = createContext<JobFaultCodes | null>(null)
+
+/** The job's fault code tools, or null outside a work order with a vehicle. */
+export function useFaultCodesOnJob(): JobFaultCodes | null {
+  return useContext(Ctx)
+}
+
+/**
+ * Fault codes on a work order: the entry box and the code sheet, held once
+ * for the page, so the findings section and the toolbar's button open the
+ * same ones.
+ */
+export function FaultCodesJobProvider({
+  info,
+  vehicleId,
+  serviceRecordId,
+  concerns,
+  locked,
+  children,
+}: {
+  info: FaultCodesJobInfo
+  vehicleId: string
+  serviceRecordId: string
+  concerns: { id: string; description: string }[]
+  locked: boolean
+  children: ReactNode
+}) {
+  const [entryOpen, setEntryOpen] = useState(false)
+  const [sheetCode, setSheetCode] = useState<string | null>(null)
+  const value: JobFaultCodes = {
+    ...info,
+    vehicleId,
+    serviceRecordId,
+    locked,
+    openEntry: () => setEntryOpen(true),
+    openCode: setSheetCode,
+  }
+  return (
+    <Ctx.Provider value={value}>
+      {children}
+      {info.connected && (
+        <FaultCodeEntryDialog
+          open={entryOpen}
+          onOpenChange={setEntryOpen}
+          serviceRecordId={serviceRecordId}
+          vehicleId={vehicleId}
+          concerns={concerns}
+        />
+      )}
+      <FaultCodeSheet
+        code={sheetCode}
+        vehicleId={vehicleId}
+        onOpenChange={(open) => !open && setSheetCode(null)}
+      />
+    </Ctx.Provider>
+  )
+}
+
+export type MeaningUpdate = { current: string; source: MeaningSource | null }
+
+/**
+ * Which of the job's code findings now read differently at the service than
+ * when they were added, asked once the page is up so nothing waits on it.
+ * Only on an open job: a locked invoice keeps what it was issued with.
+ */
+export function useUpdatedMeanings(findingIds: string[]) {
+  const job = useFaultCodesOnJob()
+  const router = useRouter()
+  const t = useTranslations('faultCodes.finding')
+  const [updates, setUpdates] = useState<Record<string, MeaningUpdate>>({})
+  const key = findingIds.slice().sort().join(',')
+  const enabled = Boolean(job?.connected && !job.locked && key)
+  const serviceRecordId = job?.serviceRecordId
+
+  useEffect(() => {
+    if (!enabled || !serviceRecordId) return
+    let cancelled = false
+    checkFindingMeanings({ serviceRecordId }).then((result) => {
+      if (cancelled || !result.success || !result.data) return
+      const next: Record<string, MeaningUpdate> = {}
+      for (const f of result.data.findings) {
+        if (f.changed && f.current) next[f.findingId] = { current: f.current, source: f.source }
+      }
+      setUpdates(next)
+    })
+    return () => {
+      cancelled = true
+    }
+  }, [enabled, serviceRecordId, key])
+
+  const take = useCallback(
+    async (findingId: string) => {
+      const result = await takeFindingMeaning({ findingId })
+      if (!result.success) {
+        toast.error(result.error || t('takeFailed'))
+        return
+      }
+      setUpdates((prev) => {
+        const { [findingId]: _, ...rest } = prev
+        return rest
+      })
+      toast.success(t('taken'))
+      router.refresh()
+    },
+    [router, t]
+  )
+
+  return { updates, take }
+}
+
+/**
+ * A finding that is a fault code, as the work order lists it: the code
+ * chip, its status and module, the meaning saved with it, "Seen" when the
+ * vehicle had the code in an earlier job, and "Updated meaning" when the
+ * service has changed it since.
+ */
+export function FaultCodeFindingLine({
+  finding,
+  update,
+  onTake,
+}: {
+  finding: {
+    faultCode: string
+    faultStatus?: string | null
+    faultModule?: string | null
+    faultMeaning?: string | null
+    faultMeaningSource?: string | null
+    description: string
+  }
+  update?: MeaningUpdate
+  onTake?: () => void
+}) {
+  const t = useTranslations('faultCodes')
+  const job = useFaultCodesOnJob()
+  const isMarine = useServiceType() === 'marine'
+  const { formatDate } = useFormatDate()
+  const seen = job?.seenBefore[finding.faultCode]
+  const meaning = finding.faultMeaning?.trim()
+  const ownWords =
+    finding.description.trim() !== finding.faultCode &&
+    finding.description.trim() !== `${finding.faultCode} ${meaning ?? ''}`.trim()
+
+  return (
+    <div className="min-w-0 space-y-0.5">
+      <div className="flex flex-wrap items-center gap-1.5">
+        <FaultCodeChip
+          code={finding.faultCode}
+          onClick={job ? () => job.openCode(finding.faultCode) : undefined}
+        />
+        <FaultStatusBadge status={finding.faultStatus} />
+        {finding.faultModule && (
+          <span className="text-[11px] text-muted-foreground">{finding.faultModule}</span>
+        )}
+        {seen && (
+          <span
+            className="inline-flex items-center gap-1 rounded-full border bg-muted px-1.5 py-px text-[10px] text-muted-foreground"
+            title={t(isMarine ? 'finding.seenBeforeTitleMarine' : 'finding.seenBeforeTitle', {
+              date: formatDate(seen),
+            })}
+          >
+            <History className="size-3" />
+            {t('finding.seenBefore', { date: formatDate(seen) })}
+          </span>
+        )}
+        {update && onTake && (
+          <button
+            type="button"
+            onClick={onTake}
+            title={t('finding.updatedMeaningTitle', { meaning: update.current })}
+            className="inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary hover:bg-primary/15"
+          >
+            <RefreshCw className="size-3" />
+            {t('finding.updatedMeaning')}
+          </button>
+        )}
+      </div>
+      <p className="text-sm">
+        {meaning ?? <span className="text-muted-foreground">{t('finding.noMeaningSaved')}</span>}
+      </p>
+      {meaning && finding.faultMeaningSource && (
+        <MeaningSourceLabel
+          source={finding.faultMeaningSource as MeaningSource}
+          className="block"
+        />
+      )}
+      {ownWords && <p className="text-xs text-muted-foreground">{finding.description}</p>}
+    </div>
+  )
+}

+ 62 - 0
src/features/fault-codes/Components/FixPhotos.tsx

@@ -0,0 +1,62 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { MediaLightbox } from '@/features/inspections/Components/MediaLightbox'
+import type { FixPhoto, SharedFix } from '../Lib/types'
+
+/** Where the app serves a shared fix's photo from the code service. */
+export function fixPhotoUrl(id: string, n: number, size: 'full' | 'thumb' = 'full'): string {
+  const base = `/api/protected/fault-codes/fixes/${encodeURIComponent(id)}/photos/${n}`
+  return size === 'thumb' ? `${base}?size=thumb` : base
+}
+
+/** "2014 Volkswagen Golf · CAXA", or null when the fix names no vehicle. */
+export function fixVehicleLine(vehicle: SharedFix['vehicle']): string | null {
+  if (!vehicle) return null
+  const name = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ')
+  const line = [name, vehicle.engine].filter(Boolean).join(' · ')
+  return line || null
+}
+
+/** A fix's photos as small thumbnails; a tap opens them full size. */
+export function FixPhotos({ fixId, photos }: { fixId: string; photos: FixPhoto[] }) {
+  const t = useTranslations('faultCodes.fixes')
+  const [open, setOpen] = useState<number | null>(null)
+  if (photos.length === 0) return null
+  const images = photos.map((p, i) => ({
+    url: fixPhotoUrl(fixId, p.n),
+    caption: t('photo', { n: i + 1, count: photos.length }),
+  }))
+  return (
+    <>
+      <ul className="flex flex-wrap gap-2">
+        {photos.map((p, i) => (
+          <li key={p.n}>
+            <button
+              type="button"
+              onClick={() => setOpen(i)}
+              className="block overflow-hidden rounded-md border border-card-edge bg-muted transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+              aria-label={t('openPhoto', { n: i + 1 })}
+            >
+              <img
+                src={fixPhotoUrl(fixId, p.n, 'thumb')}
+                alt=""
+                loading="lazy"
+                width={p.width}
+                height={p.height}
+                className="size-16 object-cover sm:size-20"
+              />
+            </button>
+          </li>
+        ))}
+      </ul>
+      <MediaLightbox
+        images={images}
+        index={open}
+        onClose={() => setOpen(null)}
+        onNavigate={setOpen}
+      />
+    </>
+  )
+}

+ 26 - 0
src/features/fault-codes/Components/FixText.tsx

@@ -0,0 +1,26 @@
+'use client'
+
+import ReactMarkdown from 'react-markdown'
+import { cn } from '@/lib/utils'
+
+/** Only what the share dialog's editor writes. Raw HTML is never rendered. */
+const ALLOWED = ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'blockquote']
+
+/**
+ * A shared fix's text, formatted. Fixes are stored as Markdown; one sent as
+ * plain text before the editor keeps its line breaks.
+ */
+export function FixText({ text, className }: { text: string; className?: string }) {
+  return (
+    <div
+      className={cn(
+        'space-y-2 break-words text-sm leading-relaxed [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_li]:my-0.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:whitespace-pre-line [&_strong]:font-semibold [&_ul]:list-disc [&_ul]:pl-5',
+        className
+      )}
+    >
+      <ReactMarkdown allowedElements={ALLOWED} unwrapDisallowed skipHtml>
+        {text}
+      </ReactMarkdown>
+    </div>
+  )
+}

+ 149 - 0
src/features/fault-codes/Components/ReportMeaningPopover.tsx

@@ -0,0 +1,149 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Flag, Loader2 } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { useServiceType } from '@/components/service-type-context'
+import { cn } from '@/lib/utils'
+import { reportFaultCodeMeaning } from '../Actions/reportFaultCodeMeaning'
+import type { ReportState } from '../Lib/types'
+import { REPORT_REASONS } from '../Schema/faultCodeSchema'
+
+type Reason = (typeof REPORT_REASONS)[number]
+
+/**
+ * "This doesn't match": a flag beside a meaning that opens a small popover.
+ * A reason and an optional note on what was found, then Send. Once sent,
+ * the meaning says "Reported by you", and later how it was settled.
+ */
+export function ReportMeaningPopover({
+  code,
+  version,
+  lang,
+  vehicleId,
+  report,
+  onReported,
+}: {
+  code: string
+  /** The service's version of the meaning shown; nothing to report without one. */
+  version: number | null
+  lang: string | null
+  vehicleId?: string | null
+  report: ReportState | null
+  onReported: (report: ReportState) => void
+}) {
+  const t = useTranslations('faultCodes.report')
+  const isMarine = useServiceType() === 'marine'
+  const [open, setOpen] = useState(false)
+  const [reason, setReason] = useState<Reason | null>(null)
+  const [note, setNote] = useState('')
+  const [sending, setSending] = useState(false)
+
+  const send = async () => {
+    if (!reason || version === null) return
+    setSending(true)
+    const result = await reportFaultCodeMeaning({
+      code,
+      version,
+      lang: lang ?? 'en',
+      reason,
+      note: note.trim() || undefined,
+      vehicleId: vehicleId ?? null,
+    })
+    setSending(false)
+    if (result.success && result.data?.sent && result.data.report) {
+      toast.success(t('thanks'))
+      onReported(result.data.report)
+      setOpen(false)
+      setReason(null)
+      setNote('')
+      return
+    }
+    toast.error(result.success ? t('failed') : result.error || t('failed'))
+  }
+
+  return (
+    <div className="flex flex-wrap items-center gap-1.5">
+      {report && <ReportedPill report={report} />}
+      {version !== null && (
+        <Popover open={open} onOpenChange={setOpen}>
+          <PopoverTrigger asChild>
+            <Button type="button" variant="ghost" size="xs" className="text-muted-foreground">
+              <Flag />
+              {t('button')}
+            </Button>
+          </PopoverTrigger>
+          <PopoverContent align="start" className="w-[min(22rem,calc(100vw-2rem))] space-y-3">
+            <p className="text-sm font-medium">{t('title', { code })}</p>
+            <div
+              className="flex flex-wrap gap-1.5"
+              role="radiogroup"
+              aria-label={t('title', { code })}
+            >
+              {REPORT_REASONS.map((r) => (
+                <button
+                  key={r}
+                  type="button"
+                  role="radio"
+                  aria-checked={reason === r}
+                  onClick={() => setReason(r)}
+                  className={cn(
+                    'rounded-full border px-2.5 py-1 text-xs transition-colors',
+                    reason === r
+                      ? 'border-primary bg-primary/10 text-primary'
+                      : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
+                  )}
+                >
+                  {r === 'wrong_for_vehicle' && isMarine
+                    ? t('reasons.wrong_for_vehicleMarine')
+                    : t(`reasons.${r}`)}
+                </button>
+              ))}
+            </div>
+            <div className="space-y-1">
+              <Label htmlFor={`report-note-${code}`} className="text-xs">
+                {t('note')}
+              </Label>
+              <Input
+                id={`report-note-${code}`}
+                value={note}
+                maxLength={500}
+                onChange={(e) => setNote(e.target.value)}
+                placeholder={t('notePlaceholder')}
+              />
+            </div>
+            <div className="flex justify-end pt-1">
+              <Button type="button" size="sm" disabled={!reason || sending} onClick={send}>
+                {sending && <Loader2 className="animate-spin" />}
+                {t('send')}
+              </Button>
+            </div>
+          </PopoverContent>
+        </Popover>
+      )}
+    </div>
+  )
+}
+
+/** "Reported by you", or how the report was settled once it was. */
+export function ReportedPill({ report }: { report: ReportState }) {
+  const t = useTranslations('faultCodes.report')
+  const label =
+    report.status === 'resolved' && report.outcome
+      ? t(`outcome.${report.outcome}`)
+      : t('reportedByYou')
+  return (
+    <span
+      className="inline-flex items-center gap-1 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground"
+      title={report.message ?? undefined}
+    >
+      <Flag className="size-3" />
+      {label}
+    </span>
+  )
+}

+ 161 - 0
src/features/fault-codes/Components/ShareFixCard.tsx

@@ -0,0 +1,161 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Loader2, Share2, Trash2, Wrench } from 'lucide-react'
+import { toast } from 'sonner'
+import { AppCard } from '@/components/app-card'
+import { useConfirm } from '@/components/confirm-dialog'
+import { Button } from '@/components/ui/button'
+import { useFormatDate } from '@/lib/use-format-date'
+import { cn } from '@/lib/utils'
+import { withdrawFaultCodeFix } from '../Actions/withdrawFaultCodeFix'
+import type { CodeVehicle, OwnFix } from '../Lib/types'
+import { FixPhotos, fixVehicleLine } from './FixPhotos'
+import { FixText } from './FixText'
+import { ShareFixDialog } from './ShareFixDialog'
+
+/**
+ * "Share your fix" on a code's page, in the main column: an invitation to
+ * tell other workshops what fixed the code, this workshop's fixes with how
+ * their review stands, and the dialog the fix is written in. Fixes go to
+ * Torqvoice Codes, where a person checks each one before others see it.
+ */
+export function ShareFixCard({
+  code,
+  vehicle,
+  own: initialOwn,
+  available,
+}: {
+  code: string
+  vehicle: CodeVehicle | null
+  own: OwnFix[]
+  /** The service answered when the page was read. */
+  available: boolean
+}) {
+  const t = useTranslations('faultCodes.share')
+  const confirm = useConfirm()
+  const { formatDate } = useFormatDate()
+  const [own, setOwn] = useState<OwnFix[]>(initialOwn)
+  const [open, setOpen] = useState(false)
+  const [withdrawing, setWithdrawing] = useState<string | null>(null)
+
+  const withdraw = async (fix: OwnFix) => {
+    const ok = await confirm({
+      title: t('withdrawTitle'),
+      description: t('withdrawConfirm'),
+      confirmLabel: t('withdraw'),
+      destructive: true,
+    })
+    if (!ok) return
+    setWithdrawing(fix.id)
+    const result = await withdrawFaultCodeFix({ code, id: fix.id })
+    setWithdrawing(null)
+    if (!result.success) {
+      toast.error(t('withdrawFailed'))
+      return
+    }
+    setOwn((current) => current.filter((f) => f.id !== fix.id))
+    toast.success(t('withdrawn'))
+  }
+
+  const statusLabel: Record<OwnFix['status'], string> = {
+    pending: t('status.pending'),
+    published: t('status.published'),
+    rejected: t('status.rejected'),
+  }
+
+  return (
+    <AppCard
+      icon={Share2}
+      title={t('title')}
+      description={t('description', { code })}
+      action={
+        available && own.length > 0 ? (
+          <Button type="button" size="sm" variant="outline" onClick={() => setOpen(true)}>
+            <Share2 />
+            {t('shareAnother')}
+          </Button>
+        ) : undefined
+      }
+      contentClassName="space-y-4"
+    >
+      {!available ? (
+        <p className="text-sm text-muted-foreground">{t('unavailable')}</p>
+      ) : own.length === 0 ? (
+        <div className="flex flex-col items-start gap-4 rounded-lg border border-dashed border-border bg-muted/20 p-5 sm:flex-row sm:items-center">
+          <span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
+            <Wrench className="size-5" aria-hidden />
+          </span>
+          <div className="min-w-0 flex-1 space-y-1">
+            <p className="font-medium">{t('invite', { code })}</p>
+            <p className="text-sm text-muted-foreground">{t('inviteHint')}</p>
+          </div>
+          <Button type="button" onClick={() => setOpen(true)} className="shrink-0">
+            <Share2 />
+            {t('start')}
+          </Button>
+        </div>
+      ) : null}
+
+      {own.length > 0 && (
+        <ul className="space-y-3" aria-label={t('yourFixes', { code })}>
+          {own.map((fix) => {
+            const line = fixVehicleLine(fix.vehicle)
+            return (
+              <li key={fix.id} className="space-y-3 rounded-lg border border-card-edge p-4">
+                <div className="flex flex-wrap items-center justify-between gap-2">
+                  <span
+                    className={cn(
+                      'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium',
+                      fix.status === 'published' && 'border-primary/40 bg-primary/10 text-primary',
+                      fix.status === 'pending' && 'bg-muted text-muted-foreground',
+                      fix.status === 'rejected' &&
+                        'border-destructive/40 bg-destructive/10 text-destructive'
+                    )}
+                  >
+                    {statusLabel[fix.status]}
+                  </span>
+                  <span className="text-xs text-muted-foreground">
+                    {t('sentOn', { date: formatDate(fix.createdAt) })}
+                  </span>
+                </div>
+                {fix.status === 'rejected' && fix.reason && (
+                  <p className="text-xs text-muted-foreground">
+                    {t('reason', { reason: fix.reason })}
+                  </p>
+                )}
+                <FixText text={fix.text} />
+                {line && <p className="text-xs text-muted-foreground">{line}</p>}
+                <FixPhotos fixId={fix.id} photos={fix.photos} />
+                <div className="flex justify-end">
+                  <Button
+                    type="button"
+                    variant="ghost"
+                    size="xs"
+                    className="text-muted-foreground"
+                    disabled={withdrawing === fix.id}
+                    onClick={() => withdraw(fix)}
+                  >
+                    {withdrawing === fix.id ? <Loader2 className="animate-spin" /> : <Trash2 />}
+                    {t('withdraw')}
+                  </Button>
+                </div>
+              </li>
+            )
+          })}
+        </ul>
+      )}
+
+      {available && (
+        <ShareFixDialog
+          open={open}
+          onOpenChange={setOpen}
+          code={code}
+          vehicle={vehicle}
+          onShared={(fix) => setOwn((current) => [fix, ...current])}
+        />
+      )}
+    </AppCard>
+  )
+}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio