Преглед изворни кода

Print the condition map on invoices and quotes, with the linked inspection (#431)

* condition map

* add option in designer and invoice to show in invoice

* condition types

* locales

* work-order template
Bernt Christian Egeland пре 1 дан
родитељ
комит
8e578dd78f
100 измењених фајлова са 2314 додато и 141 уклоњено
  1. 18 5
      e2e/specs/invoices/designer-drag.spec.ts
  2. 78 0
      e2e/specs/settings/mark-types.spec.ts
  3. 9 2
      messages/de/audit.json
  4. 5 1
      messages/de/conditionMap.json
  5. 1 0
      messages/de/portal.json
  6. 5 1
      messages/de/service.json
  7. 57 4
      messages/de/settings.json
  8. 2 1
      messages/de/share.json
  9. 9 2
      messages/en/audit.json
  10. 5 1
      messages/en/conditionMap.json
  11. 1 0
      messages/en/portal.json
  12. 5 1
      messages/en/service.json
  13. 57 4
      messages/en/settings.json
  14. 2 1
      messages/en/share.json
  15. 9 2
      messages/es/audit.json
  16. 5 1
      messages/es/conditionMap.json
  17. 1 0
      messages/es/portal.json
  18. 5 1
      messages/es/service.json
  19. 57 4
      messages/es/settings.json
  20. 2 1
      messages/es/share.json
  21. 9 2
      messages/fr/audit.json
  22. 5 1
      messages/fr/conditionMap.json
  23. 1 0
      messages/fr/portal.json
  24. 5 1
      messages/fr/service.json
  25. 57 4
      messages/fr/settings.json
  26. 2 1
      messages/fr/share.json
  27. 9 2
      messages/it/audit.json
  28. 5 1
      messages/it/conditionMap.json
  29. 1 0
      messages/it/portal.json
  30. 5 1
      messages/it/service.json
  31. 57 4
      messages/it/settings.json
  32. 2 1
      messages/it/share.json
  33. 9 2
      messages/lt/audit.json
  34. 5 1
      messages/lt/conditionMap.json
  35. 1 0
      messages/lt/portal.json
  36. 5 1
      messages/lt/service.json
  37. 57 4
      messages/lt/settings.json
  38. 2 1
      messages/lt/share.json
  39. 9 2
      messages/nb/audit.json
  40. 5 1
      messages/nb/conditionMap.json
  41. 1 0
      messages/nb/portal.json
  42. 5 1
      messages/nb/service.json
  43. 57 4
      messages/nb/settings.json
  44. 2 1
      messages/nb/share.json
  45. 9 2
      messages/nl/audit.json
  46. 5 1
      messages/nl/conditionMap.json
  47. 1 0
      messages/nl/portal.json
  48. 5 1
      messages/nl/service.json
  49. 57 4
      messages/nl/settings.json
  50. 2 1
      messages/nl/share.json
  51. 9 2
      messages/pl/audit.json
  52. 5 1
      messages/pl/conditionMap.json
  53. 1 0
      messages/pl/portal.json
  54. 5 1
      messages/pl/service.json
  55. 57 4
      messages/pl/settings.json
  56. 2 1
      messages/pl/share.json
  57. 9 2
      messages/pt-BR/audit.json
  58. 5 1
      messages/pt-BR/conditionMap.json
  59. 1 0
      messages/pt-BR/portal.json
  60. 5 1
      messages/pt-BR/service.json
  61. 57 4
      messages/pt-BR/settings.json
  62. 2 1
      messages/pt-BR/share.json
  63. 9 2
      messages/pt-PT/audit.json
  64. 5 1
      messages/pt-PT/conditionMap.json
  65. 1 0
      messages/pt-PT/portal.json
  66. 5 1
      messages/pt-PT/service.json
  67. 57 4
      messages/pt-PT/settings.json
  68. 2 1
      messages/pt-PT/share.json
  69. 9 2
      messages/ru/audit.json
  70. 5 1
      messages/ru/conditionMap.json
  71. 1 0
      messages/ru/portal.json
  72. 5 1
      messages/ru/service.json
  73. 57 4
      messages/ru/settings.json
  74. 2 1
      messages/ru/share.json
  75. 9 2
      messages/tr/audit.json
  76. 5 1
      messages/tr/conditionMap.json
  77. 1 0
      messages/tr/portal.json
  78. 5 1
      messages/tr/service.json
  79. 57 4
      messages/tr/settings.json
  80. 2 1
      messages/tr/share.json
  81. 30 0
      prisma/migrations/20260930063108_condition_map_on_invoice/migration.sql
  82. 31 0
      prisma/schema/condition-marks.prisma
  83. 4 0
      prisma/schema/inspections.prisma
  84. 1 0
      prisma/schema/organization.prisma
  85. 5 0
      prisma/schema/service-records.prisma
  86. 184 0
      src/__tests__/features/condition-map/mark-types.test.ts
  87. 24 0
      src/__tests__/features/condition-map/marks.test.ts
  88. 292 1
      src/__tests__/features/condition-map/print.test.ts
  89. 316 0
      src/__tests__/features/condition-map/visit-documents.test.ts
  90. 123 0
      src/__tests__/features/email/inspection-email-certificate.test.ts
  91. 2 2
      src/__tests__/features/invoice-designer/every-block.test.ts
  92. 1 0
      src/__tests__/lib/files/references.test.ts
  93. 2 0
      src/__tests__/multitenancy/inspection-isolation.test.ts
  94. 2 0
      src/__tests__/multitenancy/quote-inspection-isolation.test.ts
  95. 11 2
      src/app/(authenticated)/inspections/[id]/page.tsx
  96. 4 0
      src/app/(authenticated)/settings/templates/page.tsx
  97. 50 9
      src/app/(authenticated)/settings/templates/template-settings.tsx
  98. 83 0
      src/app/(public)/portal/[orgId]/invoices/[invoiceId]/certificate/route.ts
  99. 12 2
      src/app/(public)/portal/[orgId]/invoices/[invoiceId]/page.tsx
  100. 14 3
      src/app/(public)/share/inspection/[orgId]/[token]/page.tsx

+ 18 - 5
e2e/specs/invoices/designer-drag.spec.ts

@@ -211,23 +211,36 @@ test.describe('dragging in the designer', () => {
     const [paymentBefore, partsBefore] = await printedOrder(page, ['PAYMENT INFORMATION', 'Parts'])
     expect(paymentBefore).toBeGreaterThan(partsBefore)
 
+    // Both rows have to be on screen before the drag starts. The rail
+    // scrolls, and Playwright's drag scrolls the target into view mid-drag,
+    // which takes the dragged row out of it and drops it on nothing; a
+    // person's browser would scroll the rail as the pointer nears its edge.
+    const parts = page.getByTestId('rail-parts_table')
+    const payment = page.getByTestId('rail-bank_account')
+    await parts.evaluate((el) => el.scrollIntoView({ block: 'start' }))
+    await expect(parts).toBeInViewport()
+    await expect(payment).toBeInViewport()
+
     await expect(async () => {
-      await page.getByTestId('rail-bank_account').dragTo(page.getByTestId('rail-parts_table'))
+      await payment.dragTo(parts)
       const positionOf = (id: string) =>
         page
           .getByTestId(`rail-${id}`)
           .evaluate((el) => Array.from(el.parentElement?.children ?? []).indexOf(el))
-      const [payment, parts] = await Promise.all([
+      const [paymentAt, partsAt] = await Promise.all([
         positionOf('bank_account'),
         positionOf('parts_table'),
       ])
-      expect(payment, 'the payment panel sits above the parts in the rail').toBeLessThan(parts)
+      expect(paymentAt, 'the payment panel sits above the parts in the rail').toBeLessThan(partsAt)
     }).toPass({ timeout: 30_000 })
 
     await saveDesign(page)
 
-    const [payment, parts] = await printedOrder(page, ['PAYMENT INFORMATION', 'Parts'])
-    expect(payment, 'and above them on the sheet').toBeLessThan(parts)
+    const [paymentPrinted, partsPrinted] = await printedOrder(page, [
+      'PAYMENT INFORMATION',
+      'Parts',
+    ])
+    expect(paymentPrinted, 'and above them on the sheet').toBeLessThan(partsPrinted)
   })
 
   test('a block dragged across the canvas is left where it was put', async ({ page }) => {

+ 78 - 0
e2e/specs/settings/mark-types.spec.ts

@@ -0,0 +1,78 @@
+import { expect, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+
+/**
+ * The workshop's kinds of mark, edited under Templates → Checklists →
+ * Condition map: the eight built-in kinds are there, one can be renamed, a
+ * kind of the workshop's own added, and both put back, so the seeded
+ * workshop draws with the app's own kinds again afterwards.
+ */
+
+const rows = (page: Page) => page.getByTestId('mark-type-row')
+const rowNamed = (page: Page, name: string) => rows(page).filter({ hasText: name })
+
+async function openCatalogue(page: Page) {
+  await page.goto('/settings/templates?tab=conditionMap')
+  await expect(page.getByRole('tab', { name: 'Condition map', exact: true })).toHaveAttribute(
+    'aria-selected',
+    'true'
+  )
+  await expect(rows(page).first()).toBeVisible({ timeout: 30_000 })
+  await settle(page)
+}
+
+async function saveDialog(page: Page) {
+  const dialog = page.getByRole('dialog')
+  await dialog.getByRole('button', { name: 'Save', exact: true }).click()
+  await expect(dialog).toBeHidden({ timeout: 15_000 })
+}
+
+test.describe.configure({ mode: 'serial' })
+
+test.describe('kinds of mark', () => {
+  test('ships the eight built-in kinds', async ({ page }) => {
+    await openCatalogue(page)
+    await expect(rows(page)).toHaveCount(8)
+    for (const name of ['Dent', 'Scratch', 'Stone chip', 'Crack', 'Rust', 'Paint damage']) {
+      await expect(rowNamed(page, name), `${name} is listed`).toHaveCount(1)
+    }
+  })
+
+  test('renames a built-in kind and restores it', async ({ page }) => {
+    await openCatalogue(page)
+    await rowNamed(page, 'Dent').getByRole('button', { name: 'Edit' }).click()
+    const dialog = page.getByRole('dialog', { name: 'Edit kind of mark' })
+    await dialog.getByLabel('Name').fill('Ding')
+    await saveDialog(page)
+    await expect(rowNamed(page, 'Ding')).toHaveCount(1)
+    await expect(rowNamed(page, 'Dent')).toHaveCount(0)
+
+    // The restore puts the app's own name back.
+    await rowNamed(page, 'Ding').getByRole('button', { name: 'Restore' }).click()
+    await page.getByRole('alertdialog').getByRole('button', { name: 'Restore' }).click()
+    await expect(rowNamed(page, 'Dent')).toHaveCount(1, { timeout: 15_000 })
+    await expect(rowNamed(page, 'Ding')).toHaveCount(0)
+  })
+
+  test('adds a kind of the workshop’s own and removes it again', async ({ page }) => {
+    await openCatalogue(page)
+    await page.getByRole('button', { name: 'Add kind' }).click()
+    const dialog = page.getByRole('dialog', { name: 'New kind of mark' })
+    await dialog.getByLabel('Name').fill('Curb rash')
+    await dialog.getByRole('button', { name: 'Hexagon' }).click()
+    await dialog.getByRole('button', { name: '#e11d48' }).click()
+    await saveDialog(page)
+    await expect(rows(page)).toHaveCount(9)
+    const own = rowNamed(page, 'Curb rash')
+    await expect(own).toHaveCount(1)
+    await expect(own).toContainText('Hexagon')
+
+    // Hidden kinds stay listed here, greyed, so they can be brought back.
+    await own.getByRole('button', { name: 'Hide from the picker' }).click()
+    await expect(own.getByText('Hidden')).toBeVisible({ timeout: 15_000 })
+
+    await own.getByRole('button', { name: 'Remove' }).click()
+    await page.getByRole('alertdialog').getByRole('button', { name: 'Remove' }).click()
+    await expect(rows(page)).toHaveCount(8, { timeout: 15_000 })
+  })
+})

+ 9 - 2
messages/de/audit.json

@@ -187,7 +187,12 @@
     "signature_save": "Unterschrift gespeichert",
     "signature_remove": "Unterschrift entfernt",
     "conditionMark_add": "Zustandsmarkierung hinzugefügt",
-    "conditionMark_remove": "Zustandsmarkierung entfernt"
+    "conditionMark_remove": "Zustandsmarkierung entfernt",
+    "conditionMark_onInvoice": "Zustandskarte auf Rechnung",
+    "settings_markType_create": "Markierungsart hinzugefügt",
+    "settings_markType_update": "Markierungsart geändert",
+    "settings_markType_remove": "Markierungsart entfernt",
+    "settings_markType_reorder": "Markierungsarten neu geordnet"
   },
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "Zeiteintrag {id} gelöscht",
     "inspection_phoneUpload": "Datei vom Handy zur Prüfung {ref} hinzugefügt",
     "condition_mark_add": "Markierung zur Zustandskarte hinzugefügt",
-    "condition_mark_remove": "Markierung von der Zustandskarte entfernt"
+    "condition_mark_remove": "Markierung von der Zustandskarte entfernt",
+    "condition_map_on_invoice_on": "Zustandskarte auf die Rechnung gesetzt",
+    "condition_map_on_invoice_off": "Zustandskarte von der Rechnung genommen"
   }
 }

+ 5 - 1
messages/de/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Zum Ändern der Zustandskarte wieder öffnen.",
   "count": "{count, plural, =0 {Keine Markierungen} one {# Markierung} other {# Markierungen}}",
   "previousCount": "{count, plural, one {# von einem früheren Besuch} other {# von früheren Besuchen}}",
+  "fromInspection": "Aus der verknüpften Inspektion, dort änderbar",
   "elsewhere": "{count, plural, one {# Markierung wurde auf einer anderen Karosserie gesetzt und erscheint nur im Ausdruck} other {# Markierungen wurden auf einer anderen Karosserie gesetzt und erscheinen nur im Ausdruck}}",
   "photoCount": "{count, plural, one {# Foto} other {# Fotos}}",
   "clearBody": "Markierung {n} an {area} wird als repariert vermerkt. Sie bleibt in der Historie und wird nicht mehr gedruckt.",
@@ -142,5 +143,8 @@
   },
   "print": {
     "previous": "früher erfasst"
-  }
+  },
+  "manageKinds": "Arten verwalten",
+  "photosHeading": "Fotos",
+  "previousShort": "früher"
 }

+ 1 - 0
messages/de/portal.json

@@ -103,6 +103,7 @@
     "payment": "Zahlung",
     "view": "Ansehen",
     "download": "Herunterladen",
+    "downloadCertificate": "Prüfbericht herunterladen",
     "paid": "bezahlt",
     "partial": "teilweise",
     "unpaid": "unbezahlt",

+ 5 - 1
messages/de/service.json

@@ -636,7 +636,11 @@
       "failed": "{name} konnte nicht hochgeladen werden",
       "wrongType": "Nicht die richtige Dateiart für diesen Tab: {names}",
       "limit": "Limit erreicht ({count}/{max})",
-      "dropoffHint": "Das Fahrzeug, wie es ankam, mit der Aufnahmezeit jedes Fotos. Für die Werkstatt, für den Kunden nur sichtbar, wenn Sie es wählen."
+      "dropoffHint": "Das Fahrzeug, wie es ankam, mit der Aufnahmezeit jedes Fotos. Für die Werkstatt, für den Kunden nur sichtbar, wenn Sie es wählen.",
+      "onInvoice": "Fahrzeugzustand auf dieser Rechnung ausgeben",
+      "onInvoiceHint": "Aus, solange Sie es hier nicht ankreuzen. Für alle Rechnungen schalten Sie Fahrzeugzustand im Rechnungsdesigner ein.",
+      "onInvoiceByDesign": "Im Rechnungsdesigner für alle Rechnungen eingeschaltet. Abwählen, um sie auf dieser wegzulassen.",
+      "onInvoiceFailed": "Auswahl konnte nicht gespeichert werden"
     },
     "moneyBar": {
       "total": "Gesamt",

+ 57 - 4
messages/de/settings.json

@@ -774,7 +774,8 @@
       "work_bay": "Arbeitsplatz",
       "customer_line": "Unterschriftszeile Kunde",
       "legend": "Legende",
-      "previous_marks": "Frühere Markierungen"
+      "previous_marks": "Frühere Markierungen",
+      "blank_sheet": "Leeres Blatt zum Ausfüllen von Hand"
     },
     "presetsTitle": "Mit einer Anordnung beginnen",
     "presetsHint": "Ersetzt die Anordnung unten. Nichts wird gespeichert, bis Sie speichern.",
@@ -859,7 +860,9 @@
       "quotation": "Angebot",
       "workOrders": "Arbeitsauftrag",
       "certificates": "Inspektionszertifikat",
-      "layout": "Layout"
+      "layout": "Layout",
+      "conditionMap": "Zustandskarte",
+      "inspections": "Checklisten"
     },
     "sections": {
       "documents": "Dokumente",
@@ -970,7 +973,8 @@
     "designerIntro": "Wählen Sie einen Ausgangspunkt und öffnen Sie den Designer, um alles zu ändern.",
     "designerHint": "Farben, Schriften, Abstände und alle Abschnitte liegen im Designer.",
     "certificatesDescription": "Gestalten Sie das Zertifikat, das eine abgeschlossene Inspektion druckt und teilt.",
-    "workOrdersDescription": "Gestalten Sie das Blatt, das ein Arbeitsauftrag druckt: was der Kunde unterschreibt und was der Techniker von der Tafel nimmt."
+    "workOrdersDescription": "Gestalten Sie das Blatt, das ein Arbeitsauftrag druckt: was der Kunde unterschreibt und was der Techniker von der Tafel nimmt.",
+    "conditionMapDescription": "Die Markierungsarten, die Ihre Techniker auf der Zustandskarte setzen: Namen, Formen und Farben."
   },
   "payment": {
     "readMore": "Mehr erfahren",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "Wird immer an derselben Stelle gedruckt und kann nicht verschoben werden.",
     "certificate": "Inspektionszertifikat",
     "resultWidthHint": "Leer füllt die Zeile. Mit einem Wert wird das Band schmaler und sitzt an der Seite, die die Ausrichtung wählt; in einer Spalte nimmt es die Spaltenbreite.",
-    "work_order": "Arbeitsauftrag"
+    "work_order": "Arbeitsauftrag",
+    "conditionMapDrawing": "Zeichnung",
+    "conditionMapViews": "Ansichten",
+    "conditionMapViewsAll": "Alle fünf",
+    "conditionMapViewsTop": "Nur von oben",
+    "conditionMapViewsSides": "Nur die Seiten",
+    "conditionMapWidthHint": "Leer füllt die Zeile. Punkte angeben, um die Zeichnung zu verkleinern; sie sitzt dann nach der Ausrichtung, und die Legende behält die volle Breite."
   },
   "preview": {
     "title": "Bremsenservice und Ölwechsel",
@@ -2710,5 +2720,48 @@
     "failed": "Der Status konnte nicht gespeichert werden",
     "messageEmptyHint": "Bleibt das Feld leer, wird der Text oben gesendet.",
     "useSuggested": "Diesen Text bearbeiten"
+  },
+  "markTypes": {
+    "title": "Markierungsarten",
+    "description": "Benennen oder färben Sie die eingebauten Arten um, blenden Sie ungenutzte aus und fügen Sie eigene hinzu. Jede Art hat neben der Farbe eine Form, damit sie auch im Schwarz-Weiß-Druck lesbar ist.",
+    "cardTitle": "Arten",
+    "add": "Art hinzufügen",
+    "addTitle": "Neue Markierungsart",
+    "editTitle": "Markierungsart bearbeiten",
+    "dialogBody": "Ein Name in Ihren Worten, eine Form und eine Farbe. Gefüllt bedeutet eine schwere Markierung.",
+    "previewHint": "Leicht und schwer",
+    "name": "Name",
+    "namePlaceholder": "z. B. Bordsteinschaden",
+    "shape": "Form",
+    "color": "Farbe",
+    "customColor": "Hex-Farbe",
+    "colorInvalid": "Farbe als sechs Hex-Zeichen angeben, z. B. #2563eb",
+    "hidden": "In der Auswahl ausgeblendet",
+    "hiddenHint": "Markierungen dieser Art werden weiterhin angezeigt und gedruckt.",
+    "hiddenBadge": "Ausgeblendet",
+    "builtin": "Eingebaut",
+    "show": "In der Auswahl zeigen",
+    "hide": "Aus der Auswahl ausblenden",
+    "edit": "Bearbeiten",
+    "moveUp": "Nach oben",
+    "moveDown": "Nach unten",
+    "restore": "Zurücksetzen",
+    "restoreTitle": "„{name}“ zurücksetzen?",
+    "restoreBody": "Die Art erhält wieder Name, Form und Farbe, wie die App sie mitbringt.",
+    "remove": "Entfernen",
+    "removeTitle": "„{name}“ entfernen?",
+    "removeBody": "Nur eine Art, die keine Markierung verwendet, kann entfernt werden. Blenden Sie sie aus, wenn es Markierungen davon gibt.",
+    "save": "Speichern",
+    "cancel": "Abbrechen",
+    "saved": "Gespeichert",
+    "failed": "Konnte nicht speichern",
+    "shapes": {
+      "circle": "Kreis",
+      "diamond": "Raute",
+      "triangle": "Dreieck",
+      "square": "Quadrat",
+      "cross": "Kreuz",
+      "hex": "Sechseck"
+    }
   }
 }

+ 2 - 1
messages/de/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "Sehen Sie Ihre Fahrzeuge, Rechnungen und mehr in Ihrem",
     "customerPortal": "Kundenportal",
     "telegramConnect": "Chatten Sie mit uns auf Telegram",
-    "telegramScan": "Scannen Sie diesen QR-Code, um uns auf Telegram für Updates und Support zu kontaktieren."
+    "telegramScan": "Scannen Sie diesen QR-Code, um uns auf Telegram für Updates und Support zu kontaktieren.",
+    "downloadCertificate": "Prüfbericht herunterladen"
   }
 }

+ 9 - 2
messages/en/audit.json

@@ -187,7 +187,12 @@
     "signature_save": "Saved Signature",
     "signature_remove": "Removed Signature",
     "conditionMark_add": "Added a condition mark",
-    "conditionMark_remove": "Removed a condition mark"
+    "conditionMark_remove": "Removed a condition mark",
+    "conditionMark_onInvoice": "Condition map on invoice",
+    "settings_markType_create": "Added a kind of mark",
+    "settings_markType_update": "Changed a kind of mark",
+    "settings_markType_remove": "Removed a kind of mark",
+    "settings_markType_reorder": "Reordered the kinds of mark"
   },
   "summary": {
     "customField_create": "Created custom field \"{name}\"",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "Deleted time entry {id}",
     "inspection_phoneUpload": "Added a file from a phone to inspection {ref}",
     "condition_mark_add": "Added a mark to the condition map",
-    "condition_mark_remove": "Removed a mark from the condition map"
+    "condition_mark_remove": "Removed a mark from the condition map",
+    "condition_map_on_invoice_on": "Printed the condition map on the invoice",
+    "condition_map_on_invoice_off": "Left the condition map off the invoice"
   }
 }

+ 5 - 1
messages/en/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Reopen to change the condition map.",
   "count": "{count, plural, =0 {No marks} one {# mark} other {# marks}}",
   "previousCount": "{count, plural, one {# from an earlier visit} other {# from earlier visits}}",
+  "fromInspection": "From the linked inspection, changed there",
   "views": {
     "top": "Top",
     "left": "Left side",
@@ -142,5 +143,8 @@
   },
   "elsewhere": "{count, plural, one {# mark was drawn on another body type and is listed on the print only} other {# marks were drawn on another body type and are listed on the print only}}",
   "photoCount": "{count, plural, one {# photo} other {# photos}}",
-  "clearBody": "Mark {n} on the {area} will be recorded as repaired. It stays in the history and stops printing."
+  "clearBody": "Mark {n} on the {area} will be recorded as repaired. It stays in the history and stops printing.",
+  "manageKinds": "Manage kinds",
+  "photosHeading": "Photos",
+  "previousShort": "earlier"
 }

+ 1 - 0
messages/en/portal.json

@@ -103,6 +103,7 @@
     "payment": "Payment",
     "view": "View",
     "download": "Download",
+    "downloadCertificate": "Download inspection certificate",
     "paid": "paid",
     "partial": "partial",
     "unpaid": "unpaid",

+ 5 - 1
messages/en/service.json

@@ -636,7 +636,11 @@
       "failed": "Could not upload {name}",
       "wrongType": "Not the right kind of file for this tab: {names}",
       "limit": "Limit reached ({count}/{max})",
-      "dropoffHint": "The car as it arrived, with the time each photo was taken. Kept for the workshop, not shown to the customer unless you choose to."
+      "dropoffHint": "The car as it arrived, with the time each photo was taken. Kept for the workshop, not shown to the customer unless you choose to.",
+      "onInvoice": "Include the vehicle condition on this invoice",
+      "onInvoiceHint": "Off unless you tick it here. To print it on every invoice, switch on Vehicle Condition in the invoice designer.",
+      "onInvoiceByDesign": "On in the invoice designer for every invoice. Untick to leave it off this one.",
+      "onInvoiceFailed": "Could not save the choice"
     },
     "moneyBar": {
       "total": "Total",

+ 57 - 4
messages/en/settings.json

@@ -774,7 +774,8 @@
       "work_bay": "Work Bay",
       "customer_line": "Customer signature line",
       "legend": "Legend",
-      "previous_marks": "Earlier marks"
+      "previous_marks": "Earlier marks",
+      "blank_sheet": "Blank sheet to fill in by hand"
     },
     "presetsTitle": "Start from an arrangement",
     "presetsHint": "Replaces the arrangement below. Nothing is saved until you save.",
@@ -859,7 +860,9 @@
       "quotation": "Quotation",
       "workOrders": "Work order",
       "certificates": "Inspection certificate",
-      "layout": "Layout"
+      "layout": "Layout",
+      "conditionMap": "Condition map",
+      "inspections": "Checklists"
     },
     "sections": {
       "documents": "Documents",
@@ -970,7 +973,8 @@
     "designerIntro": "Pick a starting point, then open the designer to change anything.",
     "designerHint": "Colors, fonts, spacing and every section live in the designer.",
     "certificatesDescription": "Design the certificate a completed inspection prints and shares.",
-    "workOrdersDescription": "Design the sheet a work order prints: what the customer signs and what the technician takes off the board."
+    "workOrdersDescription": "Design the sheet a work order prints: what the customer signs and what the technician takes off the board.",
+    "conditionMapDescription": "The kinds of mark your technicians draw on the condition map: their names, shapes and colours."
   },
   "payment": {
     "readMore": "Read More",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "Always prints in the same place, so it cannot be moved.",
     "certificate": "Inspection certificate",
     "resultWidthHint": "Empty spans the row. Set points to make the band narrower; it then sits at the side the alignment picks, and in a column it takes the column's width.",
-    "work_order": "Work order"
+    "work_order": "Work order",
+    "conditionMapDrawing": "Drawing",
+    "conditionMapViews": "Views",
+    "conditionMapViewsAll": "All five",
+    "conditionMapViewsTop": "Top only",
+    "conditionMapViewsSides": "Sides only",
+    "conditionMapWidthHint": "Empty spans the row. Set points to make the drawing smaller; it then sits where the alignment puts it, and the legend keeps the full width."
   },
   "preview": {
     "title": "Brake Service & Oil Change",
@@ -2710,5 +2720,48 @@
     "failed": "Could not save the status",
     "messageEmptyHint": "Left empty, the text above is sent.",
     "useSuggested": "Edit this text"
+  },
+  "markTypes": {
+    "title": "Kinds of mark",
+    "description": "Rename or recolour the built-in kinds, hide the ones you never use, and add your own. Each kind has a shape as well as a colour, so it reads on a black-and-white print too.",
+    "cardTitle": "Kinds",
+    "add": "Add kind",
+    "addTitle": "New kind of mark",
+    "editTitle": "Edit kind of mark",
+    "dialogBody": "A name in your own words, a shape and a colour. Filled means a major mark.",
+    "previewHint": "Minor and major",
+    "name": "Name",
+    "namePlaceholder": "e.g. Curb rash",
+    "shape": "Shape",
+    "color": "Colour",
+    "customColor": "Hex colour",
+    "colorInvalid": "Enter a colour as six hex digits, like #2563eb",
+    "hidden": "Hidden from the picker",
+    "hiddenHint": "Marks of this kind still show and print.",
+    "hiddenBadge": "Hidden",
+    "builtin": "Built in",
+    "show": "Show in the picker",
+    "hide": "Hide from the picker",
+    "edit": "Edit",
+    "moveUp": "Move up",
+    "moveDown": "Move down",
+    "restore": "Restore",
+    "restoreTitle": "Restore \"{name}\"?",
+    "restoreBody": "The kind goes back to the name, shape and colour the app ships with.",
+    "remove": "Remove",
+    "removeTitle": "Remove \"{name}\"?",
+    "removeBody": "Only a kind no mark uses can be removed. Hide it if marks of it exist.",
+    "save": "Save",
+    "cancel": "Cancel",
+    "saved": "Saved",
+    "failed": "Could not save",
+    "shapes": {
+      "circle": "Circle",
+      "diamond": "Diamond",
+      "triangle": "Triangle",
+      "square": "Square",
+      "cross": "Cross",
+      "hex": "Hexagon"
+    }
   }
 }

+ 2 - 1
messages/en/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "View your vehicles, invoices, and more at your",
     "customerPortal": "customer portal",
     "telegramConnect": "Chat with us on Telegram",
-    "telegramScan": "Scan this QR code to message us on Telegram for updates and support."
+    "telegramScan": "Scan this QR code to message us on Telegram for updates and support.",
+    "downloadCertificate": "Download inspection certificate"
   }
 }

+ 9 - 2
messages/es/audit.json

@@ -187,7 +187,12 @@
     "signature_save": "Guardó la firma",
     "signature_remove": "Eliminó la firma",
     "conditionMark_add": "Marca de estado añadida",
-    "conditionMark_remove": "Marca de estado eliminada"
+    "conditionMark_remove": "Marca de estado eliminada",
+    "conditionMark_onInvoice": "Mapa de estado en la factura",
+    "settings_markType_create": "Tipo de marca añadido",
+    "settings_markType_update": "Tipo de marca modificado",
+    "settings_markType_remove": "Tipo de marca eliminado",
+    "settings_markType_reorder": "Tipos de marca reordenados"
   },
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "Entrada de tiempo {id} eliminada",
     "inspection_phoneUpload": "Añadió un archivo desde un teléfono a la inspección {ref}",
     "condition_mark_add": "Añadió una marca al mapa de estado",
-    "condition_mark_remove": "Eliminó una marca del mapa de estado"
+    "condition_mark_remove": "Eliminó una marca del mapa de estado",
+    "condition_map_on_invoice_on": "Puso el mapa de estado en la factura",
+    "condition_map_on_invoice_off": "Quitó el mapa de estado de la factura"
   }
 }

+ 5 - 1
messages/es/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Reabra para cambiar el mapa de estado.",
   "count": "{count, plural, =0 {Sin marcas} one {# marca} other {# marcas}}",
   "previousCount": "{count, plural, one {# de una visita anterior} other {# de visitas anteriores}}",
+  "fromInspection": "De la inspección vinculada; se modifica allí",
   "elsewhere": "{count, plural, one {# marca se hizo en otra carrocería y solo aparece en la impresión} other {# marcas se hicieron en otra carrocería y solo aparecen en la impresión}}",
   "photoCount": "{count, plural, one {# foto} other {# fotos}}",
   "clearBody": "La marca {n} en {area} quedará registrada como reparada. Se conserva en el historial y deja de imprimirse.",
@@ -142,5 +143,8 @@
   },
   "print": {
     "previous": "registrado antes"
-  }
+  },
+  "manageKinds": "Gestionar tipos",
+  "photosHeading": "Fotos",
+  "previousShort": "anterior"
 }

+ 1 - 0
messages/es/portal.json

@@ -103,6 +103,7 @@
     "payment": "Pago",
     "view": "Ver",
     "download": "Descargar",
+    "downloadCertificate": "Descargar informe de inspección",
     "paid": "pagado",
     "partial": "parcial",
     "unpaid": "sin pagar",

+ 5 - 1
messages/es/service.json

@@ -636,7 +636,11 @@
       "failed": "No se pudo subir {name}",
       "wrongType": "No es el tipo de archivo adecuado para esta pestaña: {names}",
       "limit": "Límite alcanzado ({count}/{max})",
-      "dropoffHint": "El coche tal como llegó, con la hora de cada foto. Se guarda para el taller y no se muestra al cliente salvo que usted lo decida."
+      "dropoffHint": "El coche tal como llegó, con la hora de cada foto. Se guarda para el taller y no se muestra al cliente salvo que usted lo decida.",
+      "onInvoice": "Incluir el estado del vehículo en esta factura",
+      "onInvoiceHint": "Desactivado salvo que lo marque aquí. Para imprimirlo en todas las facturas, active Estado del vehículo en el diseñador de facturas.",
+      "onInvoiceByDesign": "Activado en el diseñador de facturas para todas las facturas. Desmarque para omitirlo en esta.",
+      "onInvoiceFailed": "No se pudo guardar la opción"
     },
     "moneyBar": {
       "total": "Total",

+ 57 - 4
messages/es/settings.json

@@ -774,7 +774,8 @@
       "work_bay": "Puesto",
       "customer_line": "Línea de firma del cliente",
       "legend": "Leyenda",
-      "previous_marks": "Marcas anteriores"
+      "previous_marks": "Marcas anteriores",
+      "blank_sheet": "Hoja en blanco para rellenar a mano"
     },
     "presetsTitle": "Empezar con una disposición",
     "presetsHint": "Sustituye la disposición de abajo. No se guarda nada hasta que guardes.",
@@ -859,7 +860,9 @@
       "quotation": "Cotización",
       "workOrders": "Orden de trabajo",
       "certificates": "Certificado de inspección",
-      "layout": "Disposición"
+      "layout": "Disposición",
+      "conditionMap": "Mapa de estado",
+      "inspections": "Listas de control"
     },
     "sections": {
       "documents": "Documentos",
@@ -970,7 +973,8 @@
     "designerIntro": "Elige un punto de partida y abre el diseñador para cambiar lo que quieras.",
     "designerHint": "Los colores, las fuentes, los espacios y todas las secciones están en el diseñador.",
     "certificatesDescription": "Diseña el certificado que imprime y comparte una inspección completada.",
-    "workOrdersDescription": "Diseñe la hoja que imprime una orden de trabajo: lo que firma el cliente y lo que el técnico recoge del tablero."
+    "workOrdersDescription": "Diseñe la hoja que imprime una orden de trabajo: lo que firma el cliente y lo que el técnico recoge del tablero.",
+    "conditionMapDescription": "Los tipos de marca que sus técnicos dibujan en el mapa de estado: nombres, formas y colores."
   },
   "payment": {
     "readMore": "Leer más",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "Siempre se imprime en el mismo lugar, así que no se puede mover.",
     "certificate": "Certificado de inspección",
     "resultWidthHint": "Vacío ocupa toda la fila. Con un valor la banda se estrecha y se coloca en el lado que marca la alineación; en una columna ocupa el ancho de la columna.",
-    "work_order": "Orden de trabajo"
+    "work_order": "Orden de trabajo",
+    "conditionMapDrawing": "Dibujo",
+    "conditionMapViews": "Vistas",
+    "conditionMapViewsAll": "Las cinco",
+    "conditionMapViewsTop": "Solo desde arriba",
+    "conditionMapViewsSides": "Solo los laterales",
+    "conditionMapWidthHint": "Vacío ocupa toda la fila. Indique puntos para reducir el dibujo; se colocará según la alineación y la leyenda conserva todo el ancho."
   },
   "preview": {
     "title": "Servicio de frenos y cambio de aceite",
@@ -2710,5 +2720,48 @@
     "failed": "No se pudo guardar el estado",
     "messageEmptyHint": "Si se deja vacío, se envía el texto de arriba.",
     "useSuggested": "Editar este texto"
+  },
+  "markTypes": {
+    "title": "Tipos de marca",
+    "description": "Renombre o recoloree los tipos incorporados, oculte los que nunca usa y añada los suyos. Cada tipo tiene una forma además de un color, para que se distinga también en una impresión en blanco y negro.",
+    "cardTitle": "Tipos",
+    "add": "Añadir tipo",
+    "addTitle": "Nuevo tipo de marca",
+    "editTitle": "Editar tipo de marca",
+    "dialogBody": "Un nombre con sus propias palabras, una forma y un color. Relleno significa una marca grave.",
+    "previewHint": "Leve y grave",
+    "name": "Nombre",
+    "namePlaceholder": "p. ej. Roce de bordillo",
+    "shape": "Forma",
+    "color": "Color",
+    "customColor": "Color hex",
+    "colorInvalid": "Escriba el color como seis dígitos hex, como #2563eb",
+    "hidden": "Oculto en el selector",
+    "hiddenHint": "Las marcas de este tipo se siguen mostrando e imprimiendo.",
+    "hiddenBadge": "Oculto",
+    "builtin": "Incorporado",
+    "show": "Mostrar en el selector",
+    "hide": "Ocultar del selector",
+    "edit": "Editar",
+    "moveUp": "Subir",
+    "moveDown": "Bajar",
+    "restore": "Restaurar",
+    "restoreTitle": "¿Restaurar «{name}»?",
+    "restoreBody": "El tipo vuelve al nombre, la forma y el color con los que viene la aplicación.",
+    "remove": "Eliminar",
+    "removeTitle": "¿Eliminar «{name}»?",
+    "removeBody": "Solo se puede eliminar un tipo que ninguna marca use. Ocúltelo si existen marcas de este tipo.",
+    "save": "Guardar",
+    "cancel": "Cancelar",
+    "saved": "Guardado",
+    "failed": "No se pudo guardar",
+    "shapes": {
+      "circle": "Círculo",
+      "diamond": "Rombo",
+      "triangle": "Triángulo",
+      "square": "Cuadrado",
+      "cross": "Cruz",
+      "hex": "Hexágono"
+    }
   }
 }

+ 2 - 1
messages/es/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "Vea sus vehículos, facturas y más en su",
     "customerPortal": "portal de clientes",
     "telegramConnect": "Chatea con nosotros en Telegram",
-    "telegramScan": "Escanea este código QR para enviarnos un mensaje en Telegram para actualizaciones y soporte."
+    "telegramScan": "Escanea este código QR para enviarnos un mensaje en Telegram para actualizaciones y soporte.",
+    "downloadCertificate": "Descargar informe de inspección"
   }
 }

+ 9 - 2
messages/fr/audit.json

@@ -187,7 +187,12 @@
     "signature_save": "Signature enregistrée",
     "signature_remove": "Signature supprimée",
     "conditionMark_add": "Marque d'état ajoutée",
-    "conditionMark_remove": "Marque d'état supprimée"
+    "conditionMark_remove": "Marque d'état supprimée",
+    "conditionMark_onInvoice": "Carte d'état sur la facture",
+    "settings_markType_create": "Type de marque ajouté",
+    "settings_markType_update": "Type de marque modifié",
+    "settings_markType_remove": "Type de marque supprimé",
+    "settings_markType_reorder": "Types de marque réordonnés"
   },
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "Entrée de temps {id} supprimée",
     "inspection_phoneUpload": "Fichier ajouté depuis un téléphone à l'inspection {ref}",
     "condition_mark_add": "A ajouté une marque à la carte d'état",
-    "condition_mark_remove": "A supprimé une marque de la carte d'état"
+    "condition_mark_remove": "A supprimé une marque de la carte d'état",
+    "condition_map_on_invoice_on": "A mis la carte d'état sur la facture",
+    "condition_map_on_invoice_off": "A retiré la carte d'état de la facture"
   }
 }

+ 5 - 1
messages/fr/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Rouvrez pour modifier la carte d'état.",
   "count": "{count, plural, =0 {Aucune marque} one {# marque} other {# marques}}",
   "previousCount": "{count, plural, one {# d'une visite précédente} other {# de visites précédentes}}",
+  "fromInspection": "De l'inspection liée, à modifier là-bas",
   "elsewhere": "{count, plural, one {# marque a été posée sur une autre carrosserie et n'apparaît que sur l'impression} other {# marques ont été posées sur une autre carrosserie et n'apparaissent que sur l'impression}}",
   "photoCount": "{count, plural, one {# photo} other {# photos}}",
   "clearBody": "La marque {n} sur {area} sera notée comme réparée. Elle reste dans l'historique et ne s'imprime plus.",
@@ -142,5 +143,8 @@
   },
   "print": {
     "previous": "relevé auparavant"
-  }
+  },
+  "manageKinds": "Gérer les types",
+  "photosHeading": "Photos",
+  "previousShort": "antérieur"
 }

+ 1 - 0
messages/fr/portal.json

@@ -103,6 +103,7 @@
     "payment": "Paiement",
     "view": "Voir",
     "download": "Télécharger",
+    "downloadCertificate": "Télécharger le procès-verbal d'inspection",
     "paid": "payé",
     "partial": "partiel",
     "unpaid": "impayé",

+ 5 - 1
messages/fr/service.json

@@ -636,7 +636,11 @@
       "failed": "Impossible d'envoyer {name}",
       "wrongType": "Type de fichier non adapté à cet onglet : {names}",
       "limit": "Limite atteinte ({count}/{max})",
-      "dropoffHint": "Le véhicule tel qu’il est arrivé, avec l’heure de chaque photo. Conservé pour l’atelier, non montré au client sauf si vous le choisissez."
+      "dropoffHint": "Le véhicule tel qu’il est arrivé, avec l’heure de chaque photo. Conservé pour l’atelier, non montré au client sauf si vous le choisissez.",
+      "onInvoice": "Inclure l'état du véhicule sur cette facture",
+      "onInvoiceHint": "Désactivé sauf si vous cochez ici. Pour l'imprimer sur toutes les factures, activez État du véhicule dans le concepteur de factures.",
+      "onInvoiceByDesign": "Activé dans le concepteur de factures pour toutes les factures. Décochez pour l'omettre sur celle-ci.",
+      "onInvoiceFailed": "Impossible d'enregistrer le choix"
     },
     "moneyBar": {
       "total": "Total",

+ 57 - 4
messages/fr/settings.json

@@ -774,7 +774,8 @@
       "work_bay": "Poste",
       "customer_line": "Ligne de signature du client",
       "legend": "Légende",
-      "previous_marks": "Marques antérieures"
+      "previous_marks": "Marques antérieures",
+      "blank_sheet": "Feuille vierge à remplir à la main"
     },
     "presetsTitle": "Partir d’une disposition",
     "presetsHint": "Remplace la disposition ci-dessous. Rien n’est enregistré avant votre validation.",
@@ -859,7 +860,9 @@
       "quotation": "Devis",
       "workOrders": "Ordre de travail",
       "certificates": "Certificat d'inspection",
-      "layout": "Mise en page"
+      "layout": "Mise en page",
+      "conditionMap": "Carte d'état",
+      "inspections": "Listes de contrôle"
     },
     "sections": {
       "documents": "Documents",
@@ -970,7 +973,8 @@
     "designerIntro": "Choisissez un point de départ, puis ouvrez le concepteur pour tout modifier.",
     "designerHint": "Les couleurs, les polices, les espacements et toutes les sections sont dans le concepteur.",
     "certificatesDescription": "Concevez le certificat qu'une inspection terminée imprime et partage.",
-    "workOrdersDescription": "Concevez la feuille qu'imprime un ordre de travail : ce que le client signe et ce que le technicien prend au tableau."
+    "workOrdersDescription": "Concevez la feuille qu'imprime un ordre de travail : ce que le client signe et ce que le technicien prend au tableau.",
+    "conditionMapDescription": "Les types de marque que vos techniciens dessinent sur la carte d'état : noms, formes et couleurs."
   },
   "payment": {
     "readMore": "En savoir plus",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "S'imprime toujours au même endroit et ne peut pas être déplacé.",
     "certificate": "Certificat d'inspection",
     "resultWidthHint": "Vide occupe toute la ligne. Une valeur rétrécit le bandeau, placé du côté choisi par l'alignement ; dans une colonne il prend la largeur de la colonne.",
-    "work_order": "Ordre de travail"
+    "work_order": "Ordre de travail",
+    "conditionMapDrawing": "Dessin",
+    "conditionMapViews": "Vues",
+    "conditionMapViewsAll": "Les cinq",
+    "conditionMapViewsTop": "Dessus seulement",
+    "conditionMapViewsSides": "Côtés seulement",
+    "conditionMapWidthHint": "Vide occupe toute la ligne. Indiquez des points pour réduire le dessin ; il se place selon l'alignement et la légende garde toute la largeur."
   },
   "preview": {
     "title": "Révision des freins et vidange",
@@ -2710,5 +2720,48 @@
     "failed": "Impossible d’enregistrer le statut",
     "messageEmptyHint": "Si le champ reste vide, le texte ci-dessus est envoyé.",
     "useSuggested": "Modifier ce texte"
+  },
+  "markTypes": {
+    "title": "Types de marque",
+    "description": "Renommez ou recolorez les types intégrés, masquez ceux que vous n'utilisez jamais et ajoutez les vôtres. Chaque type a une forme en plus d'une couleur, pour rester lisible sur une impression en noir et blanc.",
+    "cardTitle": "Types",
+    "add": "Ajouter un type",
+    "addTitle": "Nouveau type de marque",
+    "editTitle": "Modifier le type de marque",
+    "dialogBody": "Un nom dans vos mots, une forme et une couleur. Plein signifie une marque grave.",
+    "previewHint": "Mineure et grave",
+    "name": "Nom",
+    "namePlaceholder": "p. ex. Frottement de trottoir",
+    "shape": "Forme",
+    "color": "Couleur",
+    "customColor": "Couleur hex",
+    "colorInvalid": "Saisissez la couleur en six chiffres hex, comme #2563eb",
+    "hidden": "Masqué dans le sélecteur",
+    "hiddenHint": "Les marques de ce type s'affichent et s'impriment toujours.",
+    "hiddenBadge": "Masqué",
+    "builtin": "Intégré",
+    "show": "Afficher dans le sélecteur",
+    "hide": "Masquer du sélecteur",
+    "edit": "Modifier",
+    "moveUp": "Monter",
+    "moveDown": "Descendre",
+    "restore": "Restaurer",
+    "restoreTitle": "Restaurer « {name} » ?",
+    "restoreBody": "Le type retrouve le nom, la forme et la couleur fournis avec l'application.",
+    "remove": "Supprimer",
+    "removeTitle": "Supprimer « {name} » ?",
+    "removeBody": "Seul un type qu'aucune marque n'utilise peut être supprimé. Masquez-le s'il existe des marques de ce type.",
+    "save": "Enregistrer",
+    "cancel": "Annuler",
+    "saved": "Enregistré",
+    "failed": "Impossible d'enregistrer",
+    "shapes": {
+      "circle": "Cercle",
+      "diamond": "Losange",
+      "triangle": "Triangle",
+      "square": "Carré",
+      "cross": "Croix",
+      "hex": "Hexagone"
+    }
   }
 }

+ 2 - 1
messages/fr/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "Consultez vos véhicules, factures et plus encore sur votre",
     "customerPortal": "portail client",
     "telegramConnect": "Discutez avec nous sur Telegram",
-    "telegramScan": "Scannez ce code QR pour nous envoyer un message sur Telegram pour les mises à jour et le support."
+    "telegramScan": "Scannez ce code QR pour nous envoyer un message sur Telegram pour les mises à jour et le support.",
+    "downloadCertificate": "Télécharger le procès-verbal d'inspection"
   }
 }

+ 9 - 2
messages/it/audit.json

@@ -187,7 +187,12 @@
     "signature_save": "Firma salvata",
     "signature_remove": "Firma rimossa",
     "conditionMark_add": "Segno di stato aggiunto",
-    "conditionMark_remove": "Segno di stato rimosso"
+    "conditionMark_remove": "Segno di stato rimosso",
+    "conditionMark_onInvoice": "Mappa dello stato in fattura",
+    "settings_markType_create": "Tipo di segno aggiunto",
+    "settings_markType_update": "Tipo di segno modificato",
+    "settings_markType_remove": "Tipo di segno rimosso",
+    "settings_markType_reorder": "Tipi di segno riordinati"
   },
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "Voce oraria {id} eliminata",
     "inspection_phoneUpload": "Aggiunto un file da telefono all'ispezione {ref}",
     "condition_mark_add": "Ha aggiunto un segno alla mappa dello stato",
-    "condition_mark_remove": "Ha rimosso un segno dalla mappa dello stato"
+    "condition_mark_remove": "Ha rimosso un segno dalla mappa dello stato",
+    "condition_map_on_invoice_on": "Ha messo la mappa dello stato sulla fattura",
+    "condition_map_on_invoice_off": "Ha tolto la mappa dello stato dalla fattura"
   }
 }

+ 5 - 1
messages/it/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Riapri per modificare la mappa dello stato.",
   "count": "{count, plural, =0 {Nessun segno} one {# segno} other {# segni}}",
   "previousCount": "{count, plural, one {# da una visita precedente} other {# da visite precedenti}}",
+  "fromInspection": "Dall'ispezione collegata, si modifica lì",
   "elsewhere": "{count, plural, one {# segno è stato fatto su un'altra carrozzeria e compare solo nella stampa} other {# segni sono stati fatti su un'altra carrozzeria e compaiono solo nella stampa}}",
   "photoCount": "{count, plural, one {# foto} other {# foto}}",
   "clearBody": "Il segno {n} su {area} verrà registrato come riparato. Resta nello storico e non viene più stampato.",
@@ -142,5 +143,8 @@
   },
   "print": {
     "previous": "registrato in precedenza"
-  }
+  },
+  "manageKinds": "Gestisci i tipi",
+  "photosHeading": "Foto",
+  "previousShort": "precedente"
 }

+ 1 - 0
messages/it/portal.json

@@ -103,6 +103,7 @@
     "payment": "Pagamento",
     "view": "Visualizza",
     "download": "Scarica",
+    "downloadCertificate": "Scarica il verbale di ispezione",
     "paid": "pagato",
     "partial": "parziale",
     "unpaid": "non pagato",

+ 5 - 1
messages/it/service.json

@@ -636,7 +636,11 @@
       "failed": "Impossibile caricare {name}",
       "wrongType": "Tipo di file non adatto a questa scheda: {names}",
       "limit": "Limite raggiunto ({count}/{max})",
-      "dropoffHint": "L’auto com’era all’arrivo, con l’ora di ogni foto. Resta all’officina e non è mostrata al cliente, salvo tua scelta."
+      "dropoffHint": "L’auto com’era all’arrivo, con l’ora di ogni foto. Resta all’officina e non è mostrata al cliente, salvo tua scelta.",
+      "onInvoice": "Includi lo stato del veicolo in questa fattura",
+      "onInvoiceHint": "Disattivato a meno che non lo spunti qui. Per stamparlo su tutte le fatture, attiva Stato del veicolo nel designer delle fatture.",
+      "onInvoiceByDesign": "Attivo nel designer delle fatture per tutte le fatture. Deseleziona per ometterlo su questa.",
+      "onInvoiceFailed": "Impossibile salvare la scelta"
     },
     "moneyBar": {
       "total": "Totale",

+ 57 - 4
messages/it/settings.json

@@ -774,7 +774,8 @@
       "work_bay": "Postazione",
       "customer_line": "Riga della firma del cliente",
       "legend": "Legenda",
-      "previous_marks": "Segni precedenti"
+      "previous_marks": "Segni precedenti",
+      "blank_sheet": "Foglio vuoto da compilare a mano"
     },
     "presetsTitle": "Partire da una disposizione",
     "presetsHint": "Sostituisce la disposizione qui sotto. Nulla viene salvato finché non salvi.",
@@ -859,7 +860,9 @@
       "quotation": "Preventivo",
       "workOrders": "Ordine di lavoro",
       "certificates": "Certificato di ispezione",
-      "layout": "Layout"
+      "layout": "Layout",
+      "conditionMap": "Mappa dello stato",
+      "inspections": "Checklist"
     },
     "sections": {
       "documents": "Documenti",
@@ -970,7 +973,8 @@
     "designerIntro": "Scegli un punto di partenza, poi apri il designer per cambiare tutto.",
     "designerHint": "Colori, caratteri, spaziature e tutte le sezioni sono nel designer.",
     "certificatesDescription": "Progetta il certificato che un'ispezione completata stampa e condivide.",
-    "workOrdersDescription": "Progetta il foglio che stampa un ordine di lavoro: ciò che firma il cliente e ciò che il tecnico prende dalla bacheca."
+    "workOrdersDescription": "Progetta il foglio che stampa un ordine di lavoro: ciò che firma il cliente e ciò che il tecnico prende dalla bacheca.",
+    "conditionMapDescription": "I tipi di segno che i tecnici disegnano sulla mappa dello stato: nomi, forme e colori."
   },
   "payment": {
     "readMore": "Scopri di più",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "Viene sempre stampato nello stesso punto, quindi non può essere spostato.",
     "certificate": "Certificato di ispezione",
     "resultWidthHint": "Vuoto occupa tutta la riga. Un valore restringe la fascia, che si posiziona sul lato scelto dall'allineamento; in una colonna prende la larghezza della colonna.",
-    "work_order": "Ordine di lavoro"
+    "work_order": "Ordine di lavoro",
+    "conditionMapDrawing": "Disegno",
+    "conditionMapViews": "Viste",
+    "conditionMapViewsAll": "Tutte e cinque",
+    "conditionMapViewsTop": "Solo dall’alto",
+    "conditionMapViewsSides": "Solo i lati",
+    "conditionMapWidthHint": "Vuoto occupa tutta la riga. Indica i punti per rimpicciolire il disegno; si posiziona secondo l’allineamento e la legenda mantiene tutta la larghezza."
   },
   "preview": {
     "title": "Servizio freni e cambio olio",
@@ -2710,5 +2720,48 @@
     "failed": "Impossibile salvare lo stato",
     "messageEmptyHint": "Se lasciato vuoto, viene inviato il testo qui sopra.",
     "useSuggested": "Modifica questo testo"
+  },
+  "markTypes": {
+    "title": "Tipi di segno",
+    "description": "Rinomina o ricolora i tipi integrati, nascondi quelli che non usi mai e aggiungi i tuoi. Ogni tipo ha una forma oltre a un colore, così si distingue anche su una stampa in bianco e nero.",
+    "cardTitle": "Tipi",
+    "add": "Aggiungi tipo",
+    "addTitle": "Nuovo tipo di segno",
+    "editTitle": "Modifica tipo di segno",
+    "dialogBody": "Un nome con parole tue, una forma e un colore. Pieno significa un segno grave.",
+    "previewHint": "Lieve e grave",
+    "name": "Nome",
+    "namePlaceholder": "es. Strisciata sul cordolo",
+    "shape": "Forma",
+    "color": "Colore",
+    "customColor": "Colore hex",
+    "colorInvalid": "Inserisci il colore come sei cifre hex, ad esempio #2563eb",
+    "hidden": "Nascosto nel selettore",
+    "hiddenHint": "I segni di questo tipo vengono comunque mostrati e stampati.",
+    "hiddenBadge": "Nascosto",
+    "builtin": "Integrato",
+    "show": "Mostra nel selettore",
+    "hide": "Nascondi dal selettore",
+    "edit": "Modifica",
+    "moveUp": "Sposta su",
+    "moveDown": "Sposta giù",
+    "restore": "Ripristina",
+    "restoreTitle": "Ripristinare «{name}»?",
+    "restoreBody": "Il tipo torna al nome, alla forma e al colore forniti con l'app.",
+    "remove": "Rimuovi",
+    "removeTitle": "Rimuovere «{name}»?",
+    "removeBody": "Si può rimuovere solo un tipo che nessun segno usa. Nascondilo se esistono segni di questo tipo.",
+    "save": "Salva",
+    "cancel": "Annulla",
+    "saved": "Salvato",
+    "failed": "Impossibile salvare",
+    "shapes": {
+      "circle": "Cerchio",
+      "diamond": "Rombo",
+      "triangle": "Triangolo",
+      "square": "Quadrato",
+      "cross": "Croce",
+      "hex": "Esagono"
+    }
   }
 }

+ 2 - 1
messages/it/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "Visualizza i tuoi veicoli, fatture e altro nel tuo",
     "customerPortal": "portale clienti",
     "telegramConnect": "Chatta con noi su Telegram",
-    "telegramScan": "Scansiona questo codice QR per inviarci un messaggio su Telegram per aggiornamenti e supporto."
+    "telegramScan": "Scansiona questo codice QR per inviarci un messaggio su Telegram per aggiornamenti e supporto.",
+    "downloadCertificate": "Scarica il verbale di ispezione"
   }
 }

+ 9 - 2
messages/lt/audit.json

@@ -187,7 +187,12 @@
     "signature_save": "Išsaugotas parašas",
     "signature_remove": "Pašalintas parašas",
     "conditionMark_add": "Pridėta būklės žyma",
-    "conditionMark_remove": "Pašalinta būklės žyma"
+    "conditionMark_remove": "Pašalinta būklės žyma",
+    "conditionMark_onInvoice": "Būklės žemėlapis sąskaitoje",
+    "settings_markType_create": "Pridėtas žymos tipas",
+    "settings_markType_update": "Pakeistas žymos tipas",
+    "settings_markType_remove": "Pašalintas žymos tipas",
+    "settings_markType_reorder": "Pakeista žymų tipų tvarka"
   },
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "Ištrintas laiko įrašas {id}",
     "inspection_phoneUpload": "Pridėtas failas iš telefono prie apžiūros {ref}",
     "condition_mark_add": "Pridėjo žymą būklės žemėlapyje",
-    "condition_mark_remove": "Pašalino žymą iš būklės žemėlapio"
+    "condition_mark_remove": "Pašalino žymą iš būklės žemėlapio",
+    "condition_map_on_invoice_on": "Įtraukė būklės žemėlapį į sąskaitą",
+    "condition_map_on_invoice_off": "Pašalino būklės žemėlapį iš sąskaitos"
   }
 }

+ 5 - 1
messages/lt/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Norėdami keisti būklės žemėlapį, atidarykite iš naujo.",
   "count": "{count, plural, =0 {Žymų nėra} one {# žyma} few {# žymos} other {# žymų}}",
   "previousCount": "{count, plural, one {# iš ankstesnio vizito} few {# iš ankstesnių vizitų} other {# iš ankstesnių vizitų}}",
+  "fromInspection": "Iš susietos apžiūros, keičiama ten",
   "elsewhere": "{count, plural, one {# žyma padėta ant kito kėbulo tipo ir rodoma tik spaudinyje} few {# žymos padėtos ant kito kėbulo tipo ir rodomos tik spaudinyje} other {# žymų padėta ant kito kėbulo tipo ir rodomos tik spaudinyje}}",
   "photoCount": "{count, plural, one {# nuotrauka} few {# nuotraukos} other {# nuotraukų}}",
   "clearBody": "Žyma {n} ant {area} bus pažymėta kaip sutaisyta. Ji lieka istorijoje ir nebespausdinama.",
@@ -142,5 +143,8 @@
   },
   "print": {
     "previous": "užfiksuota anksčiau"
-  }
+  },
+  "manageKinds": "Tvarkyti tipus",
+  "photosHeading": "Nuotraukos",
+  "previousShort": "ankstesnė"
 }

+ 1 - 0
messages/lt/portal.json

@@ -103,6 +103,7 @@
     "payment": "Mokėjimas",
     "view": "Peržiūrėti",
     "download": "Atsisiųsti",
+    "downloadCertificate": "Atsisiųsti apžiūros pažymą",
     "paid": "apmokėta",
     "partial": "dalinai",
     "unpaid": "neapmokėta",

+ 5 - 1
messages/lt/service.json

@@ -636,7 +636,11 @@
       "failed": "Nepavyko įkelti {name}",
       "wrongType": "Netinkamas failo tipas šiam skirtukui: {names}",
       "limit": "Pasiekta riba ({count}/{max})",
-      "dropoffHint": "Automobilis toks, koks atvyko, su kiekvienos nuotraukos laiku. Saugoma dirbtuvėms, klientui nerodoma, nebent pasirinksite."
+      "dropoffHint": "Automobilis toks, koks atvyko, su kiekvienos nuotraukos laiku. Saugoma dirbtuvėms, klientui nerodoma, nebent pasirinksite.",
+      "onInvoice": "Įtraukti transporto priemonės būklę į šią sąskaitą",
+      "onInvoiceHint": "Išjungta, nebent pažymėsite čia. Kad būtų spausdinama visose sąskaitose, įjunkite Transporto priemonės būklę sąskaitų dizaineryje.",
+      "onInvoiceByDesign": "Įjungta sąskaitų dizaineryje visoms sąskaitoms. Nuimkite žymę, kad praleistumėte šioje.",
+      "onInvoiceFailed": "Nepavyko išsaugoti pasirinkimo"
     },
     "moneyBar": {
       "total": "Iš viso",

+ 57 - 4
messages/lt/settings.json

@@ -774,7 +774,8 @@
       "work_bay": "Darbo vieta",
       "customer_line": "Kliento parašo eilutė",
       "legend": "Legenda",
-      "previous_marks": "Ankstesnės žymos"
+      "previous_marks": "Ankstesnės žymos",
+      "blank_sheet": "Tuščias lapas pildyti ranka"
     },
     "presetsTitle": "Pradėti nuo išdėstymo",
     "presetsHint": "Pakeičia žemiau esantį išdėstymą. Niekas neįrašoma, kol neišsaugote.",
@@ -859,7 +860,9 @@
       "quotation": "Pasiūlymas",
       "workOrders": "Darbo užsakymas",
       "certificates": "Apžiūros sertifikatas",
-      "layout": "Išdėstymas"
+      "layout": "Išdėstymas",
+      "conditionMap": "Būklės žemėlapis",
+      "inspections": "Kontroliniai sąrašai"
     },
     "sections": {
       "documents": "Dokumentai",
@@ -970,7 +973,8 @@
     "designerIntro": "Pasirinkite pradžios tašką ir atverkite dizainerį, kad pakeistumėte viską.",
     "designerHint": "Spalvos, šriftai, tarpai ir visos skiltys yra dizaineryje.",
     "certificatesDescription": "Sukurkite sertifikatą, kurį spausdina ir dalijasi užbaigta apžiūra.",
-    "workOrdersDescription": "Sukurkite lapą, kurį spausdina darbo užsakymas: ką pasirašo klientas ir ką technikas pasiima nuo lentos."
+    "workOrdersDescription": "Sukurkite lapą, kurį spausdina darbo užsakymas: ką pasirašo klientas ir ką technikas pasiima nuo lentos.",
+    "conditionMapDescription": "Žymų tipai, kuriuos technikai piešia būklės žemėlapyje: pavadinimai, formos ir spalvos."
   },
   "payment": {
     "readMore": "Skaityti daugiau",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "Visada spausdinama toje pačioje vietoje, todėl negalima perkelti.",
     "certificate": "Apžiūros sertifikatas",
     "resultWidthHint": "Tuščia užima visą eilutę. Nurodžius taškus juosta susiaurėja ir atsiduria lygiavimo pusėje; stulpelyje ji užima stulpelio plotį.",
-    "work_order": "Darbo užsakymas"
+    "work_order": "Darbo užsakymas",
+    "conditionMapDrawing": "Brėžinys",
+    "conditionMapViews": "Vaizdai",
+    "conditionMapViewsAll": "Visi penki",
+    "conditionMapViewsTop": "Tik iš viršaus",
+    "conditionMapViewsSides": "Tik šonai",
+    "conditionMapWidthHint": "Tuščia užima visą eilutę. Nurodykite taškus, kad brėžinys būtų mažesnis; jis dedamas pagal lygiavimą, o legenda išlaiko visą plotį."
   },
   "preview": {
     "title": "Stabdžių aptarnavimas ir tepalų keitimas",
@@ -2710,5 +2720,48 @@
     "failed": "Nepavyko išsaugoti būsenos",
     "messageEmptyHint": "Palikus tuščią, siunčiamas aukščiau esantis tekstas.",
     "useSuggested": "Redaguoti šį tekstą"
+  },
+  "markTypes": {
+    "title": "Žymų tipai",
+    "description": "Pervadinkite ar perspalvinkite įtaisytus tipus, paslėpkite nenaudojamus ir pridėkite savų. Kiekvienas tipas turi formą ir spalvą, todėl skaitomas ir nespalvotame spaudinyje.",
+    "cardTitle": "Tipai",
+    "add": "Pridėti tipą",
+    "addTitle": "Naujas žymos tipas",
+    "editTitle": "Redaguoti žymos tipą",
+    "dialogBody": "Pavadinimas savais žodžiais, forma ir spalva. Užpildyta reiškia rimtą žymą.",
+    "previewHint": "Nedidelė ir rimta",
+    "name": "Pavadinimas",
+    "namePlaceholder": "pvz., Bortelio įbrėžimas",
+    "shape": "Forma",
+    "color": "Spalva",
+    "customColor": "Hex spalva",
+    "colorInvalid": "Įveskite spalvą kaip šešis hex simbolius, pvz., #2563eb",
+    "hidden": "Paslėpta parinkiklyje",
+    "hiddenHint": "Šio tipo žymos vis tiek rodomos ir spausdinamos.",
+    "hiddenBadge": "Paslėpta",
+    "builtin": "Įtaisyta",
+    "show": "Rodyti parinkiklyje",
+    "hide": "Slėpti parinkiklyje",
+    "edit": "Redaguoti",
+    "moveUp": "Aukštyn",
+    "moveDown": "Žemyn",
+    "restore": "Atkurti",
+    "restoreTitle": "Atkurti „{name}“?",
+    "restoreBody": "Tipas grįžta prie programos pateikto pavadinimo, formos ir spalvos.",
+    "remove": "Pašalinti",
+    "removeTitle": "Pašalinti „{name}“?",
+    "removeBody": "Pašalinti galima tik tipą, kurio nenaudoja jokia žyma. Jei tokių žymų yra, paslėpkite jį.",
+    "save": "Išsaugoti",
+    "cancel": "Atšaukti",
+    "saved": "Išsaugota",
+    "failed": "Nepavyko išsaugoti",
+    "shapes": {
+      "circle": "Apskritimas",
+      "diamond": "Rombas",
+      "triangle": "Trikampis",
+      "square": "Kvadratas",
+      "cross": "Kryžius",
+      "hex": "Šešiakampis"
+    }
   }
 }

+ 2 - 1
messages/lt/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "Peržiūrėkite savo transporto priemones, sąskaitas ir daugiau savo",
     "customerPortal": "kliento portale",
     "telegramConnect": "Susisiekite su mumis per Telegram",
-    "telegramScan": "Nuskaitykite šį QR kodą, kad parašytumėte mums per Telegram dėl atnaujinimų ir palaikymo."
+    "telegramScan": "Nuskaitykite šį QR kodą, kad parašytumėte mums per Telegram dėl atnaujinimų ir palaikymo.",
+    "downloadCertificate": "Atsisiųsti apžiūros pažymą"
   }
 }

+ 9 - 2
messages/nb/audit.json

@@ -187,7 +187,12 @@
     "signature_save": "Lagret signatur",
     "signature_remove": "Fjernet signatur",
     "conditionMark_add": "La til tilstandsmerke",
-    "conditionMark_remove": "Fjernet tilstandsmerke"
+    "conditionMark_remove": "Fjernet tilstandsmerke",
+    "conditionMark_onInvoice": "Tilstandskart på faktura",
+    "settings_markType_create": "La til en merketype",
+    "settings_markType_update": "Endret en merketype",
+    "settings_markType_remove": "Fjernet en merketype",
+    "settings_markType_reorder": "Endret rekkefølgen på merketypene"
   },
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "Slettet tidsoppføring {id}",
     "inspection_phoneUpload": "La til en fil fra telefon på kontroll {ref}",
     "condition_mark_add": "La til et merke på tilstandskartet",
-    "condition_mark_remove": "Fjernet et merke fra tilstandskartet"
+    "condition_mark_remove": "Fjernet et merke fra tilstandskartet",
+    "condition_map_on_invoice_on": "Satte tilstandskartet på fakturaen",
+    "condition_map_on_invoice_off": "Tok tilstandskartet av fakturaen"
   }
 }

+ 5 - 1
messages/nb/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Åpne på nytt for å endre tilstandskartet.",
   "count": "{count, plural, =0 {Ingen merker} one {# merke} other {# merker}}",
   "previousCount": "{count, plural, one {# fra et tidligere besøk} other {# fra tidligere besøk}}",
+  "fromInspection": "Fra den koblede inspeksjonen, endres der",
   "elsewhere": "{count, plural, one {# merke ble satt på et annet karosseri og vises bare på utskriften} other {# merker ble satt på et annet karosseri og vises bare på utskriften}}",
   "photoCount": "{count, plural, one {# bilde} other {# bilder}}",
   "clearBody": "Merke {n} på {area} registreres som reparert. Det blir i historikken og skrives ikke ut lenger.",
@@ -142,5 +143,8 @@
   },
   "print": {
     "previous": "registrert tidligere"
-  }
+  },
+  "manageKinds": "Administrer typer",
+  "photosHeading": "Bilder",
+  "previousShort": "tidligere"
 }

+ 1 - 0
messages/nb/portal.json

@@ -103,6 +103,7 @@
     "payment": "Betaling",
     "view": "Se",
     "download": "Last ned",
+    "downloadCertificate": "Last ned kontrollseddel",
     "paid": "betalt",
     "partial": "delbetalt",
     "unpaid": "ubetalt",

+ 5 - 1
messages/nb/service.json

@@ -636,7 +636,11 @@
       "failed": "Kunne ikke laste opp {name}",
       "wrongType": "Feil filtype for denne fanen: {names}",
       "limit": "Grensen er nådd ({count}/{max})",
-      "dropoffHint": "Bilen slik den kom inn, med tidspunktet for hvert bilde. Beholdes av verkstedet og vises ikke til kunden med mindre du velger det."
+      "dropoffHint": "Bilen slik den kom inn, med tidspunktet for hvert bilde. Beholdes av verkstedet og vises ikke til kunden med mindre du velger det.",
+      "onInvoice": "Ta med kjøretøyets tilstand på denne fakturaen",
+      "onInvoiceHint": "Av med mindre du huker av her. For å skrive den ut på alle fakturaer, slå på Kjøretøytilstand i fakturadesigneren.",
+      "onInvoiceByDesign": "På i fakturadesigneren for alle fakturaer. Fjern haken for å utelate den på denne.",
+      "onInvoiceFailed": "Kunne ikke lagre valget"
     },
     "moneyBar": {
       "total": "Totalt",

+ 57 - 4
messages/nb/settings.json

@@ -774,7 +774,8 @@
       "work_bay": "Arbeidsplass",
       "customer_line": "Signaturlinje for kunde",
       "legend": "Forklaring",
-      "previous_marks": "Tidligere merker"
+      "previous_marks": "Tidligere merker",
+      "blank_sheet": "Tomt ark til utfylling for hånd"
     },
     "presetsTitle": "Start fra et oppsett",
     "presetsHint": "Erstatter oppsettet under. Ingenting lagres før du lagrer.",
@@ -859,7 +860,9 @@
       "quotation": "Tilbud",
       "workOrders": "Arbeidsordre",
       "certificates": "Inspeksjonssertifikat",
-      "layout": "Oppsett"
+      "layout": "Oppsett",
+      "conditionMap": "Tilstandskart",
+      "inspections": "Sjekklister"
     },
     "sections": {
       "documents": "Dokumenter",
@@ -970,7 +973,8 @@
     "designerIntro": "Velg et utgangspunkt, og åpne designeren for å endre alt.",
     "designerHint": "Farger, skrifter, avstander og alle seksjoner ligger i designeren.",
     "certificatesDescription": "Utform sertifikatet en fullført inspeksjon skriver ut og deler.",
-    "workOrdersDescription": "Utform arket en arbeidsordre skriver ut: det kunden signerer og det teknikeren tar med seg fra tavla."
+    "workOrdersDescription": "Utform arket en arbeidsordre skriver ut: det kunden signerer og det teknikeren tar med seg fra tavla.",
+    "conditionMapDescription": "Merketypene teknikerne tegner på tilstandskartet: navn, form og farge."
   },
   "payment": {
     "readMore": "Les mer",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "Skrives alltid ut på samme sted, så den kan ikke flyttes.",
     "certificate": "Inspeksjonssertifikat",
     "resultWidthHint": "Tomt fyller raden. Angi punkter for et smalere bånd; det legger seg på siden justeringen velger, og i en kolonne tar det kolonnens bredde.",
-    "work_order": "Arbeidsordre"
+    "work_order": "Arbeidsordre",
+    "conditionMapDrawing": "Tegning",
+    "conditionMapViews": "Visninger",
+    "conditionMapViewsAll": "Alle fem",
+    "conditionMapViewsTop": "Bare ovenfra",
+    "conditionMapViewsSides": "Bare sidene",
+    "conditionMapWidthHint": "Tomt fyller raden. Angi punkter for å gjøre tegningen mindre; den plasseres da etter justeringen, og listen beholder full bredde."
   },
   "preview": {
     "title": "Bremseservice og oljeskift",
@@ -2710,5 +2720,48 @@
     "failed": "Kunne ikke lagre statusen",
     "messageEmptyHint": "Står feltet tomt, sendes teksten over.",
     "useSuggested": "Rediger denne teksten"
+  },
+  "markTypes": {
+    "title": "Merketyper",
+    "description": "Gi de innebygde typene nytt navn eller ny farge, skjul dem du aldri bruker, og legg til egne. Hver type har en form i tillegg til en farge, så den er lesbar også på en svart-hvitt utskrift.",
+    "cardTitle": "Typer",
+    "add": "Legg til type",
+    "addTitle": "Ny merketype",
+    "editTitle": "Rediger merketype",
+    "dialogBody": "Et navn med dine egne ord, en form og en farge. Fylt betyr et alvorlig merke.",
+    "previewHint": "Mindre og alvorlig",
+    "name": "Navn",
+    "namePlaceholder": "f.eks. Fortauskant",
+    "shape": "Form",
+    "color": "Farge",
+    "customColor": "Hex-farge",
+    "colorInvalid": "Skriv fargen som seks hex-tegn, som #2563eb",
+    "hidden": "Skjult i velgeren",
+    "hiddenHint": "Merker av denne typen vises og skrives ut likevel.",
+    "hiddenBadge": "Skjult",
+    "builtin": "Innebygd",
+    "show": "Vis i velgeren",
+    "hide": "Skjul i velgeren",
+    "edit": "Rediger",
+    "moveUp": "Flytt opp",
+    "moveDown": "Flytt ned",
+    "restore": "Tilbakestill",
+    "restoreTitle": "Tilbakestille «{name}»?",
+    "restoreBody": "Typen får tilbake navnet, formen og fargen appen leveres med.",
+    "remove": "Fjern",
+    "removeTitle": "Fjerne «{name}»?",
+    "removeBody": "Bare en type ingen merker bruker kan fjernes. Skjul den hvis det finnes merker av den.",
+    "save": "Lagre",
+    "cancel": "Avbryt",
+    "saved": "Lagret",
+    "failed": "Kunne ikke lagre",
+    "shapes": {
+      "circle": "Sirkel",
+      "diamond": "Rombe",
+      "triangle": "Trekant",
+      "square": "Firkant",
+      "cross": "Kryss",
+      "hex": "Sekskant"
+    }
   }
 }

+ 2 - 1
messages/nb/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "Se kjøretøyene dine, fakturaer og mer i din",
     "customerPortal": "kundeportal",
     "telegramConnect": "Chat med oss på Telegram",
-    "telegramScan": "Skann denne QR-koden for å sende oss en melding på Telegram for oppdateringer og støtte."
+    "telegramScan": "Skann denne QR-koden for å sende oss en melding på Telegram for oppdateringer og støtte.",
+    "downloadCertificate": "Last ned kontrollseddel"
   }
 }

+ 9 - 2
messages/nl/audit.json

@@ -187,7 +187,12 @@
     "signature_save": "Handtekening opgeslagen",
     "signature_remove": "Handtekening verwijderd",
     "conditionMark_add": "Staatmarkering toegevoegd",
-    "conditionMark_remove": "Staatmarkering verwijderd"
+    "conditionMark_remove": "Staatmarkering verwijderd",
+    "conditionMark_onInvoice": "Staatkaart op factuur",
+    "settings_markType_create": "Markeringstype toegevoegd",
+    "settings_markType_update": "Markeringstype gewijzigd",
+    "settings_markType_remove": "Markeringstype verwijderd",
+    "settings_markType_reorder": "Markeringstypen herschikt"
   },
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "Tijdregistratie {id} verwijderd",
     "inspection_phoneUpload": "Bestand vanaf een telefoon toegevoegd aan keuring {ref}",
     "condition_mark_add": "Markering aan de staatkaart toegevoegd",
-    "condition_mark_remove": "Markering van de staatkaart verwijderd"
+    "condition_mark_remove": "Markering van de staatkaart verwijderd",
+    "condition_map_on_invoice_on": "Staatkaart op de factuur gezet",
+    "condition_map_on_invoice_off": "Staatkaart van de factuur gehaald"
   }
 }

+ 5 - 1
messages/nl/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Heropen om de staatkaart te wijzigen.",
   "count": "{count, plural, =0 {Geen markeringen} one {# markering} other {# markeringen}}",
   "previousCount": "{count, plural, one {# van een eerder bezoek} other {# van eerdere bezoeken}}",
+  "fromInspection": "Uit de gekoppelde inspectie, daar te wijzigen",
   "elsewhere": "{count, plural, one {# markering is op een andere carrosserie gezet en staat alleen op de afdruk} other {# markeringen zijn op een andere carrosserie gezet en staan alleen op de afdruk}}",
   "photoCount": "{count, plural, one {# foto} other {# foto's}}",
   "clearBody": "Markering {n} op {area} wordt als hersteld vastgelegd. Hij blijft in de historie en wordt niet meer afgedrukt.",
@@ -142,5 +143,8 @@
   },
   "print": {
     "previous": "eerder vastgelegd"
-  }
+  },
+  "manageKinds": "Typen beheren",
+  "photosHeading": "Foto's",
+  "previousShort": "eerder"
 }

+ 1 - 0
messages/nl/portal.json

@@ -103,6 +103,7 @@
     "payment": "Betaling",
     "view": "Bekijken",
     "download": "Downloaden",
+    "downloadCertificate": "Keuringsrapport downloaden",
     "paid": "betaald",
     "partial": "gedeeltelijk",
     "unpaid": "onbetaald",

+ 5 - 1
messages/nl/service.json

@@ -636,7 +636,11 @@
       "failed": "Kon {name} niet uploaden",
       "wrongType": "Niet het juiste soort bestand voor dit tabblad: {names}",
       "limit": "Limiet bereikt ({count}/{max})",
-      "dropoffHint": "De auto zoals die binnenkwam, met het tijdstip van elke foto. Bewaard voor de werkplaats, niet zichtbaar voor de klant tenzij u dat kiest."
+      "dropoffHint": "De auto zoals die binnenkwam, met het tijdstip van elke foto. Bewaard voor de werkplaats, niet zichtbaar voor de klant tenzij u dat kiest.",
+      "onInvoice": "Voertuigstaat op deze factuur opnemen",
+      "onInvoiceHint": "Uit tenzij u het hier aanvinkt. Om het op elke factuur af te drukken, zet u Voertuigstaat aan in de factuurontwerper.",
+      "onInvoiceByDesign": "Aan in de factuurontwerper voor elke factuur. Vink uit om het op deze factuur weg te laten.",
+      "onInvoiceFailed": "Keuze kon niet worden opgeslagen"
     },
     "moneyBar": {
       "total": "Totaal",

+ 57 - 4
messages/nl/settings.json

@@ -774,7 +774,8 @@
       "work_bay": "Werkplek",
       "customer_line": "Handtekeningregel klant",
       "legend": "Legenda",
-      "previous_marks": "Eerdere markeringen"
+      "previous_marks": "Eerdere markeringen",
+      "blank_sheet": "Leeg blad om met de hand in te vullen"
     },
     "presetsTitle": "Begin met een indeling",
     "presetsHint": "Vervangt de indeling hieronder. Er wordt niets opgeslagen tot u opslaat.",
@@ -859,7 +860,9 @@
       "quotation": "Offerte",
       "workOrders": "Werkorder",
       "certificates": "Inspectiecertificaat",
-      "layout": "Indeling"
+      "layout": "Indeling",
+      "conditionMap": "Staatkaart",
+      "inspections": "Checklists"
     },
     "sections": {
       "documents": "Documenten",
@@ -970,7 +973,8 @@
     "designerIntro": "Kies een startpunt en open de ontwerper om alles aan te passen.",
     "designerHint": "Kleuren, lettertypen, ruimte en alle secties staan in de ontwerper.",
     "certificatesDescription": "Ontwerp het certificaat dat een afgeronde inspectie afdrukt en deelt.",
-    "workOrdersDescription": "Ontwerp het blad dat een werkorder afdrukt: wat de klant tekent en wat de monteur van het bord pakt."
+    "workOrdersDescription": "Ontwerp het blad dat een werkorder afdrukt: wat de klant tekent en wat de monteur van het bord pakt.",
+    "conditionMapDescription": "De markeringstypen die uw monteurs op de staatkaart tekenen: namen, vormen en kleuren."
   },
   "payment": {
     "readMore": "Lees meer",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "Wordt altijd op dezelfde plek afgedrukt en kan niet worden verplaatst.",
     "certificate": "Inspectiecertificaat",
     "resultWidthHint": "Leeg vult de rij. Met een waarde wordt de band smaller en komt aan de kant die de uitlijning kiest; in een kolom neemt hij de kolombreedte.",
-    "work_order": "Werkorder"
+    "work_order": "Werkorder",
+    "conditionMapDrawing": "Tekening",
+    "conditionMapViews": "Aanzichten",
+    "conditionMapViewsAll": "Alle vijf",
+    "conditionMapViewsTop": "Alleen bovenaanzicht",
+    "conditionMapViewsSides": "Alleen de zijkanten",
+    "conditionMapWidthHint": "Leeg vult de rij. Geef punten op om de tekening kleiner te maken; hij staat dan waar de uitlijning hem zet en de legenda houdt de volle breedte."
   },
   "preview": {
     "title": "Remservice en oliewissel",
@@ -2710,5 +2720,48 @@
     "failed": "De status kon niet worden opgeslagen",
     "messageEmptyHint": "Blijft het veld leeg, dan wordt de tekst hierboven verstuurd.",
     "useSuggested": "Deze tekst bewerken"
+  },
+  "markTypes": {
+    "title": "Markeringstypen",
+    "description": "Hernoem of herkleur de ingebouwde typen, verberg wat u nooit gebruikt en voeg eigen typen toe. Elk type heeft naast een kleur een vorm, zodat het ook op een zwart-witafdruk leesbaar is.",
+    "cardTitle": "Typen",
+    "add": "Type toevoegen",
+    "addTitle": "Nieuw markeringstype",
+    "editTitle": "Markeringstype bewerken",
+    "dialogBody": "Een naam in uw eigen woorden, een vorm en een kleur. Gevuld betekent een ernstige markering.",
+    "previewHint": "Licht en ernstig",
+    "name": "Naam",
+    "namePlaceholder": "bijv. Stoeprandschade",
+    "shape": "Vorm",
+    "color": "Kleur",
+    "customColor": "Hex-kleur",
+    "colorInvalid": "Voer de kleur in als zes hex-tekens, zoals #2563eb",
+    "hidden": "Verborgen in de kiezer",
+    "hiddenHint": "Markeringen van dit type worden nog steeds getoond en afgedrukt.",
+    "hiddenBadge": "Verborgen",
+    "builtin": "Ingebouwd",
+    "show": "Tonen in de kiezer",
+    "hide": "Verbergen uit de kiezer",
+    "edit": "Bewerken",
+    "moveUp": "Omhoog",
+    "moveDown": "Omlaag",
+    "restore": "Herstellen",
+    "restoreTitle": "\"{name}\" herstellen?",
+    "restoreBody": "Het type krijgt de naam, vorm en kleur terug waarmee de app wordt geleverd.",
+    "remove": "Verwijderen",
+    "removeTitle": "\"{name}\" verwijderen?",
+    "removeBody": "Alleen een type dat geen enkele markering gebruikt kan worden verwijderd. Verberg het als er markeringen van bestaan.",
+    "save": "Opslaan",
+    "cancel": "Annuleren",
+    "saved": "Opgeslagen",
+    "failed": "Kon niet opslaan",
+    "shapes": {
+      "circle": "Cirkel",
+      "diamond": "Ruit",
+      "triangle": "Driehoek",
+      "square": "Vierkant",
+      "cross": "Kruis",
+      "hex": "Zeshoek"
+    }
   }
 }

+ 2 - 1
messages/nl/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "Bekijk uw voertuigen, facturen en meer in uw",
     "customerPortal": "klantenportaal",
     "telegramConnect": "Chat met ons op Telegram",
-    "telegramScan": "Scan deze QR-code om ons een bericht te sturen op Telegram voor updates en ondersteuning."
+    "telegramScan": "Scan deze QR-code om ons een bericht te sturen op Telegram voor updates en ondersteuning.",
+    "downloadCertificate": "Keuringsrapport downloaden"
   }
 }

+ 9 - 2
messages/pl/audit.json

@@ -187,7 +187,12 @@
     "signature_save": "Zapisano podpis",
     "signature_remove": "Usunięto podpis",
     "conditionMark_add": "Dodano oznaczenie stanu",
-    "conditionMark_remove": "Usunięto oznaczenie stanu"
+    "conditionMark_remove": "Usunięto oznaczenie stanu",
+    "conditionMark_onInvoice": "Mapa stanu na fakturze",
+    "settings_markType_create": "Dodano rodzaj oznaczenia",
+    "settings_markType_update": "Zmieniono rodzaj oznaczenia",
+    "settings_markType_remove": "Usunięto rodzaj oznaczenia",
+    "settings_markType_reorder": "Zmieniono kolejność rodzajów oznaczeń"
   },
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "Usunięto wpis czasu {id}",
     "inspection_phoneUpload": "Dodano plik z telefonu do przeglądu {ref}",
     "condition_mark_add": "Dodano oznaczenie do mapy stanu",
-    "condition_mark_remove": "Usunięto oznaczenie z mapy stanu"
+    "condition_mark_remove": "Usunięto oznaczenie z mapy stanu",
+    "condition_map_on_invoice_on": "Umieszczono mapę stanu na fakturze",
+    "condition_map_on_invoice_off": "Usunięto mapę stanu z faktury"
   }
 }

+ 5 - 1
messages/pl/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Otwórz ponownie, aby zmienić mapę stanu.",
   "count": "{count, plural, =0 {Brak oznaczeń} one {# oznaczenie} few {# oznaczenia} other {# oznaczeń}}",
   "previousCount": "{count, plural, one {# z wcześniejszej wizyty} few {# z wcześniejszych wizyt} other {# z wcześniejszych wizyt}}",
+  "fromInspection": "Z powiązanego przeglądu, zmieniane tam",
   "elsewhere": "{count, plural, one {# oznaczenie naniesiono na inne nadwozie i widać je tylko na wydruku} few {# oznaczenia naniesiono na inne nadwozie i widać je tylko na wydruku} other {# oznaczeń naniesiono na inne nadwozie i widać je tylko na wydruku}}",
   "photoCount": "{count, plural, one {# zdjęcie} few {# zdjęcia} other {# zdjęć}}",
   "clearBody": "Oznaczenie {n} na {area} zostanie zapisane jako naprawione. Pozostaje w historii i nie jest już drukowane.",
@@ -142,5 +143,8 @@
   },
   "print": {
     "previous": "zarejestrowane wcześniej"
-  }
+  },
+  "manageKinds": "Zarządzaj rodzajami",
+  "photosHeading": "Zdjęcia",
+  "previousShort": "wcześniej"
 }

+ 1 - 0
messages/pl/portal.json

@@ -103,6 +103,7 @@
     "payment": "Płatność",
     "view": "Zobacz",
     "download": "Pobierz",
+    "downloadCertificate": "Pobierz zaświadczenie z przeglądu",
     "paid": "zapłacono",
     "partial": "częściowo",
     "unpaid": "niezapłacono",

+ 5 - 1
messages/pl/service.json

@@ -636,7 +636,11 @@
       "failed": "Nie udało się przesłać {name}",
       "wrongType": "Nieodpowiedni rodzaj pliku dla tej karty: {names}",
       "limit": "Osiągnięto limit ({count}/{max})",
-      "dropoffHint": "Auto w stanie, w jakim przyjechało, z godziną każdego zdjęcia. Dla warsztatu, niewidoczne dla klienta, chyba że zdecydujesz inaczej."
+      "dropoffHint": "Auto w stanie, w jakim przyjechało, z godziną każdego zdjęcia. Dla warsztatu, niewidoczne dla klienta, chyba że zdecydujesz inaczej.",
+      "onInvoice": "Uwzględnij stan pojazdu na tej fakturze",
+      "onInvoiceHint": "Wyłączone, chyba że zaznaczysz tutaj. Aby drukować na każdej fakturze, włącz Stan pojazdu w projektancie faktur.",
+      "onInvoiceByDesign": "Włączone w projektancie faktur dla wszystkich faktur. Odznacz, aby pominąć na tej.",
+      "onInvoiceFailed": "Nie udało się zapisać wyboru"
     },
     "moneyBar": {
       "total": "Razem",

+ 57 - 4
messages/pl/settings.json

@@ -774,7 +774,8 @@
       "work_bay": "Stanowisko",
       "customer_line": "Linia podpisu klienta",
       "legend": "Legenda",
-      "previous_marks": "Wcześniejsze oznaczenia"
+      "previous_marks": "Wcześniejsze oznaczenia",
+      "blank_sheet": "Pusty arkusz do wypełnienia ręcznie"
     },
     "presetsTitle": "Zacznij od układu",
     "presetsHint": "Zastępuje układ poniżej. Nic nie zostanie zapisane, dopóki nie zapiszesz.",
@@ -859,7 +860,9 @@
       "quotation": "Wycena",
       "workOrders": "Zlecenie",
       "certificates": "Certyfikat przeglądu",
-      "layout": "Układ"
+      "layout": "Układ",
+      "conditionMap": "Mapa stanu",
+      "inspections": "Listy kontrolne"
     },
     "sections": {
       "documents": "Dokumenty",
@@ -970,7 +973,8 @@
     "designerIntro": "Wybierz punkt wyjścia, a potem otwórz kreator, aby zmienić wszystko.",
     "designerHint": "Kolory, czcionki, odstępy i wszystkie sekcje są w kreatorze.",
     "certificatesDescription": "Zaprojektuj certyfikat, który drukuje i udostępnia zakończony przegląd.",
-    "workOrdersDescription": "Zaprojektuj arkusz drukowany ze zlecenia: to, co podpisuje klient, i to, co technik zdejmuje z tablicy."
+    "workOrdersDescription": "Zaprojektuj arkusz drukowany ze zlecenia: to, co podpisuje klient, i to, co technik zdejmuje z tablicy.",
+    "conditionMapDescription": "Rodzaje oznaczeń, które technicy rysują na mapie stanu: nazwy, kształty i kolory."
   },
   "payment": {
     "readMore": "Czytaj więcej",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "Zawsze drukuje się w tym samym miejscu, więc nie można go przenieść.",
     "certificate": "Certyfikat przeglądu",
     "resultWidthHint": "Puste wypełnia wiersz. Wpisz punkty, by zwęzić pasek; ułoży się po stronie wskazanej przez wyrównanie, a w kolumnie zajmie jej szerokość.",
-    "work_order": "Zlecenie"
+    "work_order": "Zlecenie",
+    "conditionMapDrawing": "Rysunek",
+    "conditionMapViews": "Widoki",
+    "conditionMapViewsAll": "Wszystkie pięć",
+    "conditionMapViewsTop": "Tylko z góry",
+    "conditionMapViewsSides": "Tylko boki",
+    "conditionMapWidthHint": "Puste wypełnia wiersz. Podaj punkty, aby zmniejszyć rysunek; ustawi się według wyrównania, a legenda zachowa pełną szerokość."
   },
   "preview": {
     "title": "Serwis hamulców i wymiana oleju",
@@ -2710,5 +2720,48 @@
     "failed": "Nie udało się zapisać statusu",
     "messageEmptyHint": "Jeśli pole pozostanie puste, zostanie wysłany tekst powyżej.",
     "useSuggested": "Edytuj ten tekst"
+  },
+  "markTypes": {
+    "title": "Rodzaje oznaczeń",
+    "description": "Zmień nazwę lub kolor wbudowanych rodzajów, ukryj nieużywane i dodaj własne. Każdy rodzaj ma kształt oprócz koloru, więc jest czytelny także na czarno-białym wydruku.",
+    "cardTitle": "Rodzaje",
+    "add": "Dodaj rodzaj",
+    "addTitle": "Nowy rodzaj oznaczenia",
+    "editTitle": "Edytuj rodzaj oznaczenia",
+    "dialogBody": "Nazwa własnymi słowami, kształt i kolor. Wypełniony oznacza poważne oznaczenie.",
+    "previewHint": "Drobne i poważne",
+    "name": "Nazwa",
+    "namePlaceholder": "np. Otarcie o krawężnik",
+    "shape": "Kształt",
+    "color": "Kolor",
+    "customColor": "Kolor hex",
+    "colorInvalid": "Podaj kolor jako sześć znaków hex, np. #2563eb",
+    "hidden": "Ukryty w wyborze",
+    "hiddenHint": "Oznaczenia tego rodzaju nadal są pokazywane i drukowane.",
+    "hiddenBadge": "Ukryty",
+    "builtin": "Wbudowany",
+    "show": "Pokaż w wyborze",
+    "hide": "Ukryj z wyboru",
+    "edit": "Edytuj",
+    "moveUp": "W górę",
+    "moveDown": "W dół",
+    "restore": "Przywróć",
+    "restoreTitle": "Przywrócić „{name}”?",
+    "restoreBody": "Rodzaj wraca do nazwy, kształtu i koloru dostarczanych z aplikacją.",
+    "remove": "Usuń",
+    "removeTitle": "Usunąć „{name}”?",
+    "removeBody": "Można usunąć tylko rodzaj, którego nie używa żadne oznaczenie. Ukryj go, jeśli takie oznaczenia istnieją.",
+    "save": "Zapisz",
+    "cancel": "Anuluj",
+    "saved": "Zapisano",
+    "failed": "Nie udało się zapisać",
+    "shapes": {
+      "circle": "Koło",
+      "diamond": "Romb",
+      "triangle": "Trójkąt",
+      "square": "Kwadrat",
+      "cross": "Krzyżyk",
+      "hex": "Sześciokąt"
+    }
   }
 }

+ 2 - 1
messages/pl/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "Zobacz swoje pojazdy, faktury i więcej w swoim",
     "customerPortal": "portalu klienta",
     "telegramConnect": "Porozmawiaj z nami na Telegramie",
-    "telegramScan": "Zeskanuj ten kod QR, aby napisać do nas na Telegramie w sprawie aktualizacji i wsparcia."
+    "telegramScan": "Zeskanuj ten kod QR, aby napisać do nas na Telegramie w sprawie aktualizacji i wsparcia.",
+    "downloadCertificate": "Pobierz zaświadczenie z przeglądu"
   }
 }

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

@@ -187,7 +187,12 @@
     "signature_save": "Salvou a assinatura",
     "signature_remove": "Removeu a assinatura",
     "conditionMark_add": "Marca de estado adicionada",
-    "conditionMark_remove": "Marca de estado removida"
+    "conditionMark_remove": "Marca de estado removida",
+    "conditionMark_onInvoice": "Mapa de estado na fatura",
+    "settings_markType_create": "Tipo de marca adicionado",
+    "settings_markType_update": "Tipo de marca alterado",
+    "settings_markType_remove": "Tipo de marca removido",
+    "settings_markType_reorder": "Tipos de marca reordenados"
   },
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "Registro de tempo {id} excluído",
     "inspection_phoneUpload": "Adicionou um arquivo pelo celular à inspeção {ref}",
     "condition_mark_add": "Adicionou uma marca ao mapa de estado",
-    "condition_mark_remove": "Removeu uma marca do mapa de estado"
+    "condition_mark_remove": "Removeu uma marca do mapa de estado",
+    "condition_map_on_invoice_on": "Colocou o mapa de estado na fatura",
+    "condition_map_on_invoice_off": "Retirou o mapa de estado da fatura"
   }
 }

+ 5 - 1
messages/pt-BR/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Reabra para alterar o mapa de estado.",
   "count": "{count, plural, =0 {Sem marcas} one {# marca} other {# marcas}}",
   "previousCount": "{count, plural, one {# de uma visita anterior} other {# de visitas anteriores}}",
+  "fromInspection": "Da inspeção vinculada; altere lá",
   "elsewhere": "{count, plural, one {# marca foi feita em outra carroceria e aparece só na impressão} other {# marcas foram feitas em outra carroceria e aparecem só na impressão}}",
   "photoCount": "{count, plural, one {# foto} other {# fotos}}",
   "clearBody": "A marca {n} em {area} será registrada como reparada. Ela fica no histórico e deixa de ser impressa.",
@@ -142,5 +143,8 @@
   },
   "print": {
     "previous": "registrado antes"
-  }
+  },
+  "manageKinds": "Gerenciar tipos",
+  "photosHeading": "Fotos",
+  "previousShort": "anterior"
 }

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

@@ -103,6 +103,7 @@
     "payment": "Pagamento",
     "view": "Ver",
     "download": "Baixar",
+    "downloadCertificate": "Baixar laudo de inspeção",
     "paid": "pago",
     "partial": "parcial",
     "unpaid": "não pago",

+ 5 - 1
messages/pt-BR/service.json

@@ -636,7 +636,11 @@
       "failed": "Não foi possível enviar {name}",
       "wrongType": "Tipo de arquivo errado para esta aba: {names}",
       "limit": "Limite atingido ({count}/{max})",
-      "dropoffHint": "O carro como chegou, com a hora de cada foto. Fica com a oficina e não é mostrado ao cliente, a menos que você escolha."
+      "dropoffHint": "O carro como chegou, com a hora de cada foto. Fica com a oficina e não é mostrado ao cliente, a menos que você escolha.",
+      "onInvoice": "Incluir o estado do veículo nesta fatura",
+      "onInvoiceHint": "Desligado a menos que você marque aqui. Para imprimir em todas as faturas, ative Estado do veículo no designer de faturas.",
+      "onInvoiceByDesign": "Ativado no designer de faturas para todas as faturas. Desmarque para omitir nesta.",
+      "onInvoiceFailed": "Não foi possível salvar a escolha"
     },
     "moneyBar": {
       "total": "Total",

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

@@ -774,7 +774,8 @@
       "work_bay": "Box",
       "customer_line": "Linha de assinatura do cliente",
       "legend": "Legenda",
-      "previous_marks": "Marcas anteriores"
+      "previous_marks": "Marcas anteriores",
+      "blank_sheet": "Folha em branco para preencher à mão"
     },
     "presetsTitle": "Começar por uma disposição",
     "presetsHint": "Substitui a disposição abaixo. Nada é salvo até você salvar.",
@@ -859,7 +860,9 @@
       "quotation": "Orçamento",
       "workOrders": "Ordem de serviço",
       "certificates": "Certificado de inspeção",
-      "layout": "Layout"
+      "layout": "Layout",
+      "conditionMap": "Mapa de estado",
+      "inspections": "Checklists"
     },
     "sections": {
       "documents": "Documentos",
@@ -970,7 +973,8 @@
     "designerIntro": "Escolha um ponto de partida e abra o designer para mudar tudo.",
     "designerHint": "Cores, fontes, espaçamento e todas as seções ficam no designer.",
     "certificatesDescription": "Desenhe o certificado que uma inspeção concluída imprime e compartilha.",
-    "workOrdersDescription": "Desenhe a folha que uma ordem de serviço imprime: o que o cliente assina e o que o técnico pega do quadro."
+    "workOrdersDescription": "Desenhe a folha que uma ordem de serviço imprime: o que o cliente assina e o que o técnico pega do quadro.",
+    "conditionMapDescription": "Os tipos de marca que seus técnicos desenham no mapa de estado: nomes, formas e cores."
   },
   "payment": {
     "readMore": "Saiba mais",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "Sempre é impresso no mesmo lugar, portanto não pode ser movido.",
     "certificate": "Certificado de inspeção",
     "resultWidthHint": "Vazio ocupa a linha inteira. Com um valor a faixa fica mais estreita e se posiciona no lado do alinhamento; em uma coluna ocupa a largura da coluna.",
-    "work_order": "Ordem de serviço"
+    "work_order": "Ordem de serviço",
+    "conditionMapDrawing": "Desenho",
+    "conditionMapViews": "Vistas",
+    "conditionMapViewsAll": "Todas as cinco",
+    "conditionMapViewsTop": "Só de cima",
+    "conditionMapViewsSides": "Só as laterais",
+    "conditionMapWidthHint": "Vazio ocupa a linha toda. Informe pontos para reduzir o desenho; ele fica onde o alinhamento colocar e a legenda mantém a largura total."
   },
   "preview": {
     "title": "Serviço de freios e troca de óleo",
@@ -2710,5 +2720,48 @@
     "failed": "Não foi possível salvar o status",
     "messageEmptyHint": "Se ficar vazio, o texto acima é enviado.",
     "useSuggested": "Editar este texto"
+  },
+  "markTypes": {
+    "title": "Tipos de marca",
+    "description": "Renomeie ou recolora os tipos incorporados, oculte os que nunca usa e adicione os seus. Cada tipo tem uma forma além de uma cor, para se distinguir também em uma impressão em preto e branco.",
+    "cardTitle": "Tipos",
+    "add": "Adicionar tipo",
+    "addTitle": "Novo tipo de marca",
+    "editTitle": "Editar tipo de marca",
+    "dialogBody": "Um nome com suas próprias palavras, uma forma e uma cor. Preenchido significa uma marca grave.",
+    "previewHint": "Leve e grave",
+    "name": "Nome",
+    "namePlaceholder": "ex.: Raspão de meio-fio",
+    "shape": "Forma",
+    "color": "Cor",
+    "customColor": "Cor hex",
+    "colorInvalid": "Informe a cor como seis dígitos hex, como #2563eb",
+    "hidden": "Oculto no seletor",
+    "hiddenHint": "Marcas deste tipo continuam sendo mostradas e impressas.",
+    "hiddenBadge": "Oculto",
+    "builtin": "Incorporado",
+    "show": "Mostrar no seletor",
+    "hide": "Ocultar do seletor",
+    "edit": "Editar",
+    "moveUp": "Mover para cima",
+    "moveDown": "Mover para baixo",
+    "restore": "Restaurar",
+    "restoreTitle": "Restaurar \"{name}\"?",
+    "restoreBody": "O tipo volta ao nome, à forma e à cor com que o app é fornecido.",
+    "remove": "Remover",
+    "removeTitle": "Remover \"{name}\"?",
+    "removeBody": "Só um tipo que nenhuma marca usa pode ser removido. Oculte-o se existirem marcas dele.",
+    "save": "Salvar",
+    "cancel": "Cancelar",
+    "saved": "Salvo",
+    "failed": "Não foi possível salvar",
+    "shapes": {
+      "circle": "Círculo",
+      "diamond": "Losango",
+      "triangle": "Triângulo",
+      "square": "Quadrado",
+      "cross": "Cruz",
+      "hex": "Hexágono"
+    }
   }
 }

+ 2 - 1
messages/pt-BR/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "Veja seus veículos, faturas e muito mais em seu",
     "customerPortal": "portal do cliente",
     "telegramConnect": "Fale conosco no Telegram",
-    "telegramScan": "Escaneie este código QR para nos enviar uma mensagem no Telegram para atualizações e suporte."
+    "telegramScan": "Escaneie este código QR para nos enviar uma mensagem no Telegram para atualizações e suporte.",
+    "downloadCertificate": "Baixar laudo de inspeção"
   }
 }

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

@@ -187,7 +187,12 @@
     "signature_save": "Guardou a assinatura",
     "signature_remove": "Removeu a assinatura",
     "conditionMark_add": "Marca de estado adicionada",
-    "conditionMark_remove": "Marca de estado removida"
+    "conditionMark_remove": "Marca de estado removida",
+    "conditionMark_onInvoice": "Mapa de estado na fatura",
+    "settings_markType_create": "Tipo de marca adicionado",
+    "settings_markType_update": "Tipo de marca alterado",
+    "settings_markType_remove": "Tipo de marca removido",
+    "settings_markType_reorder": "Tipos de marca reordenados"
   },
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "Registo de tempo {id} eliminado",
     "inspection_phoneUpload": "Adicionou um ficheiro a partir de um telemóvel à inspeção {ref}",
     "condition_mark_add": "Adicionou uma marca ao mapa de estado",
-    "condition_mark_remove": "Removeu uma marca do mapa de estado"
+    "condition_mark_remove": "Removeu uma marca do mapa de estado",
+    "condition_map_on_invoice_on": "Colocou o mapa de estado na fatura",
+    "condition_map_on_invoice_off": "Retirou o mapa de estado da fatura"
   }
 }

+ 5 - 1
messages/pt-PT/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Reabra para alterar o mapa de estado.",
   "count": "{count, plural, =0 {Sem marcas} one {# marca} other {# marcas}}",
   "previousCount": "{count, plural, one {# de uma visita anterior} other {# de visitas anteriores}}",
+  "fromInspection": "Da inspeção associada; altere lá",
   "elsewhere": "{count, plural, one {# marca foi feita noutra carroçaria e aparece só na impressão} other {# marcas foram feitas noutra carroçaria e aparecem só na impressão}}",
   "photoCount": "{count, plural, one {# fotografia} other {# fotografias}}",
   "clearBody": "A marca {n} em {area} será registada como reparada. Fica no histórico e deixa de ser impressa.",
@@ -142,5 +143,8 @@
   },
   "print": {
     "previous": "registado antes"
-  }
+  },
+  "manageKinds": "Gerir tipos",
+  "photosHeading": "Fotografias",
+  "previousShort": "anterior"
 }

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

@@ -103,6 +103,7 @@
     "payment": "Pagamento",
     "view": "Ver",
     "download": "Transferir",
+    "downloadCertificate": "Transferir certificado de inspeção",
     "paid": "paga",
     "partial": "paga parcialmente",
     "unpaid": "por pagar",

+ 5 - 1
messages/pt-PT/service.json

@@ -636,7 +636,11 @@
       "failed": "Não foi possível carregar {name}",
       "wrongType": "Tipo de ficheiro errado para este separador: {names}",
       "limit": "Limite atingido ({count}/{max})",
-      "dropoffHint": "A viatura como chegou, com a hora de cada foto. Fica para a oficina e não é mostrada ao cliente, salvo se o escolher."
+      "dropoffHint": "A viatura como chegou, com a hora de cada foto. Fica para a oficina e não é mostrada ao cliente, salvo se o escolher.",
+      "onInvoice": "Incluir o estado da viatura nesta fatura",
+      "onInvoiceHint": "Desligado a menos que o assinale aqui. Para imprimir em todas as faturas, ative Estado da viatura no designer de faturas.",
+      "onInvoiceByDesign": "Ativado no designer de faturas para todas as faturas. Desmarque para omitir nesta.",
+      "onInvoiceFailed": "Não foi possível guardar a escolha"
     },
     "moneyBar": {
       "total": "Total",

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

@@ -774,7 +774,8 @@
       "work_bay": "Posto",
       "customer_line": "Linha de assinatura do cliente",
       "legend": "Legenda",
-      "previous_marks": "Marcas anteriores"
+      "previous_marks": "Marcas anteriores",
+      "blank_sheet": "Folha em branco para preencher à mão"
     },
     "presetsTitle": "Começar por uma disposição",
     "presetsHint": "Substitui a disposição abaixo. Nada é guardado até clicar em Guardar.",
@@ -859,7 +860,9 @@
       "quotation": "Orçamento",
       "workOrders": "Ordem de trabalho",
       "certificates": "Certificado de inspeção",
-      "layout": "Layout"
+      "layout": "Layout",
+      "conditionMap": "Mapa de estado",
+      "inspections": "Listas de verificação"
     },
     "sections": {
       "documents": "Documentos",
@@ -970,7 +973,8 @@
     "designerIntro": "Escolha um ponto de partida e depois abra o editor para alterar o que quiser.",
     "designerHint": "Cores, tipos de letra, espaçamento e todas as secções estão no editor.",
     "certificatesDescription": "Desenhe o certificado que uma inspeção concluída imprime e partilha.",
-    "workOrdersDescription": "Desenhe a folha que uma ordem de trabalho imprime: o que o cliente assina e o que o técnico leva do quadro."
+    "workOrdersDescription": "Desenhe a folha que uma ordem de trabalho imprime: o que o cliente assina e o que o técnico leva do quadro.",
+    "conditionMapDescription": "Os tipos de marca que os seus técnicos desenham no mapa de estado: nomes, formas e cores."
   },
   "payment": {
     "readMore": "Saiba mais",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "É sempre impresso no mesmo sítio, por isso não pode ser movido.",
     "certificate": "Certificado de inspeção",
     "resultWidthHint": "Vazio ocupa a linha inteira. Com um valor a faixa fica mais estreita e coloca-se no lado do alinhamento; numa coluna ocupa a largura da coluna.",
-    "work_order": "Ordem de trabalho"
+    "work_order": "Ordem de trabalho",
+    "conditionMapDrawing": "Desenho",
+    "conditionMapViews": "Vistas",
+    "conditionMapViewsAll": "Todas as cinco",
+    "conditionMapViewsTop": "Só de cima",
+    "conditionMapViewsSides": "Só as laterais",
+    "conditionMapWidthHint": "Vazio ocupa a linha toda. Indique pontos para reduzir o desenho; fica onde o alinhamento o colocar e a legenda mantém a largura total."
   },
   "preview": {
     "title": "Revisão dos travões e mudança de óleo",
@@ -2710,5 +2720,48 @@
     "failed": "Não foi possível guardar o estado",
     "messageEmptyHint": "Se ficar vazio, é enviado o texto acima.",
     "useSuggested": "Editar este texto"
+  },
+  "markTypes": {
+    "title": "Tipos de marca",
+    "description": "Mude o nome ou a cor dos tipos incorporados, oculte os que nunca usa e adicione os seus. Cada tipo tem uma forma além de uma cor, para se distinguir também numa impressão a preto e branco.",
+    "cardTitle": "Tipos",
+    "add": "Adicionar tipo",
+    "addTitle": "Novo tipo de marca",
+    "editTitle": "Editar tipo de marca",
+    "dialogBody": "Um nome nas suas palavras, uma forma e uma cor. Preenchido significa uma marca grave.",
+    "previewHint": "Ligeira e grave",
+    "name": "Nome",
+    "namePlaceholder": "ex.: Raspão no lancil",
+    "shape": "Forma",
+    "color": "Cor",
+    "customColor": "Cor hex",
+    "colorInvalid": "Indique a cor como seis dígitos hex, como #2563eb",
+    "hidden": "Oculto no seletor",
+    "hiddenHint": "As marcas deste tipo continuam a ser mostradas e impressas.",
+    "hiddenBadge": "Oculto",
+    "builtin": "Incorporado",
+    "show": "Mostrar no seletor",
+    "hide": "Ocultar do seletor",
+    "edit": "Editar",
+    "moveUp": "Mover para cima",
+    "moveDown": "Mover para baixo",
+    "restore": "Repor",
+    "restoreTitle": "Repor \"{name}\"?",
+    "restoreBody": "O tipo volta ao nome, à forma e à cor com que a aplicação é fornecida.",
+    "remove": "Remover",
+    "removeTitle": "Remover \"{name}\"?",
+    "removeBody": "Só um tipo que nenhuma marca usa pode ser removido. Oculte-o se existirem marcas dele.",
+    "save": "Guardar",
+    "cancel": "Cancelar",
+    "saved": "Guardado",
+    "failed": "Não foi possível guardar",
+    "shapes": {
+      "circle": "Círculo",
+      "diamond": "Losango",
+      "triangle": "Triângulo",
+      "square": "Quadrado",
+      "cross": "Cruz",
+      "hex": "Hexágono"
+    }
   }
 }

+ 2 - 1
messages/pt-PT/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "Consulte as suas viaturas, faturas e muito mais no seu",
     "customerPortal": "portal do cliente",
     "telegramConnect": "Fale connosco no Telegram",
-    "telegramScan": "Leia este código QR para nos enviar uma mensagem no Telegram e receber atualizações e apoio."
+    "telegramScan": "Leia este código QR para nos enviar uma mensagem no Telegram e receber atualizações e apoio.",
+    "downloadCertificate": "Transferir certificado de inspeção"
   }
 }

+ 9 - 2
messages/ru/audit.json

@@ -187,7 +187,12 @@
     "signature_save": "Сохранена подпись",
     "signature_remove": "Удалена подпись",
     "conditionMark_add": "Добавлена отметка состояния",
-    "conditionMark_remove": "Удалена отметка состояния"
+    "conditionMark_remove": "Удалена отметка состояния",
+    "conditionMark_onInvoice": "Карта состояния в счёте",
+    "settings_markType_create": "Добавлен тип отметки",
+    "settings_markType_update": "Изменён тип отметки",
+    "settings_markType_remove": "Удалён тип отметки",
+    "settings_markType_reorder": "Изменён порядок типов отметок"
   },
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "Удалена запись времени {id}",
     "inspection_phoneUpload": "Добавлен файл с телефона к осмотру {ref}",
     "condition_mark_add": "Добавил(а) отметку на карту состояния",
-    "condition_mark_remove": "Удалил(а) отметку с карты состояния"
+    "condition_mark_remove": "Удалил(а) отметку с карты состояния",
+    "condition_map_on_invoice_on": "Добавил(а) карту состояния в счёт",
+    "condition_map_on_invoice_off": "Убрал(а) карту состояния из счёта"
   }
 }

+ 5 - 1
messages/ru/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Откройте заново, чтобы изменить карту состояния.",
   "count": "{count, plural, =0 {Нет отметок} one {# отметка} few {# отметки} other {# отметок}}",
   "previousCount": "{count, plural, one {# с прошлого визита} few {# с прошлых визитов} other {# с прошлых визитов}}",
+  "fromInspection": "Из связанного осмотра, изменяется там",
   "elsewhere": "{count, plural, one {# отметка сделана на другом типе кузова и есть только в распечатке} few {# отметки сделаны на другом типе кузова и есть только в распечатке} other {# отметок сделано на другом типе кузова и есть только в распечатке}}",
   "photoCount": "{count, plural, one {# фото} few {# фото} other {# фото}}",
   "clearBody": "Отметка {n} на {area} будет записана как отремонтированная. Она остаётся в истории и больше не печатается.",
@@ -142,5 +143,8 @@
   },
   "print": {
     "previous": "зафиксировано ранее"
-  }
+  },
+  "manageKinds": "Управлять типами",
+  "photosHeading": "Фото",
+  "previousShort": "ранее"
 }

+ 1 - 0
messages/ru/portal.json

@@ -103,6 +103,7 @@
     "payment": "Оплата",
     "view": "Просмотр",
     "download": "Скачать",
+    "downloadCertificate": "Скачать заключение осмотра",
     "paid": "оплачено",
     "partial": "частично",
     "unpaid": "не оплачено",

+ 5 - 1
messages/ru/service.json

@@ -636,7 +636,11 @@
       "failed": "Не удалось загрузить {name}",
       "wrongType": "Эти файлы не подходят для этой вкладки: {names}",
       "limit": "Достигнут лимит ({count}/{max})",
-      "dropoffHint": "Автомобиль в том виде, в каком он прибыл, со временем каждого снимка. Хранится для мастерской и не показывается клиенту, если вы не решите иначе."
+      "dropoffHint": "Автомобиль в том виде, в каком он прибыл, со временем каждого снимка. Хранится для мастерской и не показывается клиенту, если вы не решите иначе.",
+      "onInvoice": "Включить состояние автомобиля в этот счёт",
+      "onInvoiceHint": "Выключено, пока вы не отметите здесь. Чтобы печатать на каждом счёте, включите «Состояние автомобиля» в конструкторе счетов.",
+      "onInvoiceByDesign": "Включено в конструкторе счетов для всех счетов. Снимите отметку, чтобы не печатать на этом.",
+      "onInvoiceFailed": "Не удалось сохранить выбор"
     },
     "moneyBar": {
       "total": "Итого",

+ 57 - 4
messages/ru/settings.json

@@ -774,7 +774,8 @@
       "work_bay": "Пост",
       "customer_line": "Строка подписи клиента",
       "legend": "Легенда",
-      "previous_marks": "Прежние отметки"
+      "previous_marks": "Прежние отметки",
+      "blank_sheet": "Пустой лист для заполнения от руки"
     },
     "presetsTitle": "Начать с расположения",
     "presetsHint": "Заменяет расположение ниже. Ничего не сохранится, пока вы не сохраните.",
@@ -859,7 +860,9 @@
       "quotation": "Предложение",
       "workOrders": "Заказ-наряд",
       "certificates": "Сертификат осмотра",
-      "layout": "Макет"
+      "layout": "Макет",
+      "conditionMap": "Карта состояния",
+      "inspections": "Чек-листы"
     },
     "sections": {
       "documents": "Документы",
@@ -970,7 +973,8 @@
     "designerIntro": "Выберите отправную точку и откройте дизайнер, чтобы изменить всё.",
     "designerHint": "Цвета, шрифты, отступы и все разделы находятся в дизайнере.",
     "certificatesDescription": "Оформите сертификат, который печатает и отправляет завершённый осмотр.",
-    "workOrdersDescription": "Оформите лист, который печатает заказ-наряд: то, что подписывает клиент, и то, что механик снимает с доски."
+    "workOrdersDescription": "Оформите лист, который печатает заказ-наряд: то, что подписывает клиент, и то, что механик снимает с доски.",
+    "conditionMapDescription": "Типы отметок, которые техники ставят на карте состояния: названия, формы и цвета."
   },
   "payment": {
     "readMore": "Подробнее",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "Всегда печатается на одном и том же месте, поэтому переместить нельзя.",
     "certificate": "Сертификат осмотра",
     "resultWidthHint": "Пусто — на всю строку. Укажите пункты, чтобы сузить полосу; она встанет со стороны, заданной выравниванием, а в колонке займёт её ширину.",
-    "work_order": "Заказ-наряд"
+    "work_order": "Заказ-наряд",
+    "conditionMapDrawing": "Рисунок",
+    "conditionMapViews": "Виды",
+    "conditionMapViewsAll": "Все пять",
+    "conditionMapViewsTop": "Только сверху",
+    "conditionMapViewsSides": "Только бока",
+    "conditionMapWidthHint": "Пусто занимает всю строку. Укажите пункты, чтобы уменьшить рисунок; он встанет по выравниванию, а легенда сохранит полную ширину."
   },
   "preview": {
     "title": "Обслуживание тормозов и замена масла",
@@ -2710,5 +2720,48 @@
     "failed": "Не удалось сохранить статус",
     "messageEmptyHint": "Если оставить поле пустым, будет отправлен текст выше.",
     "useSuggested": "Изменить этот текст"
+  },
+  "markTypes": {
+    "title": "Типы отметок",
+    "description": "Переименуйте или перекрасьте встроенные типы, скройте неиспользуемые и добавьте свои. У каждого типа есть форма и цвет, поэтому он различим и на чёрно-белой распечатке.",
+    "cardTitle": "Типы",
+    "add": "Добавить тип",
+    "addTitle": "Новый тип отметки",
+    "editTitle": "Изменить тип отметки",
+    "dialogBody": "Название своими словами, форма и цвет. Заливка означает серьёзную отметку.",
+    "previewHint": "Незначительная и серьёзная",
+    "name": "Название",
+    "namePlaceholder": "напр., Царапина о бордюр",
+    "shape": "Форма",
+    "color": "Цвет",
+    "customColor": "Цвет hex",
+    "colorInvalid": "Введите цвет шестью hex-символами, например #2563eb",
+    "hidden": "Скрыт в выборе",
+    "hiddenHint": "Отметки этого типа по-прежнему показываются и печатаются.",
+    "hiddenBadge": "Скрыт",
+    "builtin": "Встроенный",
+    "show": "Показывать в выборе",
+    "hide": "Скрыть из выбора",
+    "edit": "Изменить",
+    "moveUp": "Выше",
+    "moveDown": "Ниже",
+    "restore": "Вернуть",
+    "restoreTitle": "Вернуть «{name}»?",
+    "restoreBody": "Тип получит название, форму и цвет, с которыми поставляется приложение.",
+    "remove": "Удалить",
+    "removeTitle": "Удалить «{name}»?",
+    "removeBody": "Удалить можно только тип, который не использует ни одна отметка. Если такие отметки есть, скройте его.",
+    "save": "Сохранить",
+    "cancel": "Отмена",
+    "saved": "Сохранено",
+    "failed": "Не удалось сохранить",
+    "shapes": {
+      "circle": "Круг",
+      "diamond": "Ромб",
+      "triangle": "Треугольник",
+      "square": "Квадрат",
+      "cross": "Крест",
+      "hex": "Шестиугольник"
+    }
   }
 }

+ 2 - 1
messages/ru/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "Просматривайте свои транспортные средства, счета и многое другое на вашем",
     "customerPortal": "портале клиента",
     "telegramConnect": "Свяжитесь с нами в Telegram",
-    "telegramScan": "Отсканируйте этот QR-код, чтобы написать нам в Telegram для получения обновлений и поддержки."
+    "telegramScan": "Отсканируйте этот QR-код, чтобы написать нам в Telegram для получения обновлений и поддержки.",
+    "downloadCertificate": "Скачать заключение осмотра"
   }
 }

+ 9 - 2
messages/tr/audit.json

@@ -187,7 +187,12 @@
     "signature_save": "İmza kaydedildi",
     "signature_remove": "İmza kaldırıldı",
     "conditionMark_add": "Durum işareti eklendi",
-    "conditionMark_remove": "Durum işareti kaldırıldı"
+    "conditionMark_remove": "Durum işareti kaldırıldı",
+    "conditionMark_onInvoice": "Faturada durum haritası",
+    "settings_markType_create": "İşaret türü eklendi",
+    "settings_markType_update": "İşaret türü değiştirildi",
+    "settings_markType_remove": "İşaret türü kaldırıldı",
+    "settings_markType_reorder": "İşaret türleri yeniden sıralandı"
   },
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
@@ -336,6 +341,8 @@
     "timeEntry_delete": "{id} zaman kaydı silindi",
     "inspection_phoneUpload": "{ref} muayenesine telefondan dosya eklendi",
     "condition_mark_add": "Durum haritasına işaret ekledi",
-    "condition_mark_remove": "Durum haritasından işaret kaldırdı"
+    "condition_mark_remove": "Durum haritasından işaret kaldırdı",
+    "condition_map_on_invoice_on": "Durum haritasını faturaya ekledi",
+    "condition_map_on_invoice_off": "Durum haritasını faturadan kaldırdı"
   }
 }

+ 5 - 1
messages/tr/conditionMap.json

@@ -38,6 +38,7 @@
   "readOnly": "Durum haritasını değiştirmek için yeniden açın.",
   "count": "{count, plural, =0 {İşaret yok} one {# işaret} other {# işaret}}",
   "previousCount": "{count, plural, one {# önceki ziyaretten} other {# önceki ziyaretlerden}}",
+  "fromInspection": "Bağlı muayeneden, orada değiştirilir",
   "elsewhere": "{count, plural, one {# işaret başka bir kasa tipine konuldu ve yalnızca çıktıda listelenir} other {# işaret başka bir kasa tipine konuldu ve yalnızca çıktıda listelenir}}",
   "photoCount": "{count, plural, one {# fotoğraf} other {# fotoğraf}}",
   "clearBody": "{area} üzerindeki {n} numaralı işaret onarıldı olarak kaydedilecek. Geçmişte kalır ve artık yazdırılmaz.",
@@ -142,5 +143,8 @@
   },
   "print": {
     "previous": "daha önce kaydedildi"
-  }
+  },
+  "manageKinds": "Türleri yönet",
+  "photosHeading": "Fotoğraflar",
+  "previousShort": "önceki"
 }

+ 1 - 0
messages/tr/portal.json

@@ -103,6 +103,7 @@
     "payment": "Ödeme",
     "view": "Görüntüle",
     "download": "İndir",
+    "downloadCertificate": "Muayene raporunu indir",
     "paid": "ödendi",
     "partial": "kısmi",
     "unpaid": "ödenmedi",

+ 5 - 1
messages/tr/service.json

@@ -636,7 +636,11 @@
       "failed": "{name} yüklenemedi",
       "wrongType": "Bu sekmeye uygun dosya türü değil: {names}",
       "limit": "Sınıra ulaşıldı ({count}/{max})",
-      "dropoffHint": "Aracın geldiği hali, her fotoğrafın çekildiği saatle. Atölye için saklanır, siz seçmedikçe müşteriye gösterilmez."
+      "dropoffHint": "Aracın geldiği hali, her fotoğrafın çekildiği saatle. Atölye için saklanır, siz seçmedikçe müşteriye gösterilmez.",
+      "onInvoice": "Araç durumunu bu faturaya ekle",
+      "onInvoiceHint": "Burada işaretlemediğiniz sürece kapalı. Her faturada yazdırmak için fatura tasarımcısında Araç Durumu seçeneğini açın.",
+      "onInvoiceByDesign": "Fatura tasarımcısında tüm faturalar için açık. Bu faturada yer almaması için işareti kaldırın.",
+      "onInvoiceFailed": "Seçim kaydedilemedi"
     },
     "moneyBar": {
       "total": "Toplam",

+ 57 - 4
messages/tr/settings.json

@@ -774,7 +774,8 @@
       "work_bay": "Çalışma alanı",
       "customer_line": "Müşteri imza satırı",
       "legend": "Açıklama",
-      "previous_marks": "Önceki işaretler"
+      "previous_marks": "Önceki işaretler",
+      "blank_sheet": "Elle doldurulacak boş sayfa"
     },
     "presetsTitle": "Bir düzenle başlayın",
     "presetsHint": "Aşağıdaki düzeni değiştirir. Siz kaydetmeden hiçbir şey kaydedilmez.",
@@ -859,7 +860,9 @@
       "quotation": "Teklif",
       "workOrders": "İş emri",
       "certificates": "Muayene sertifikası",
-      "layout": "Düzen"
+      "layout": "Düzen",
+      "conditionMap": "Durum haritası",
+      "inspections": "Kontrol listeleri"
     },
     "sections": {
       "documents": "Belgeler",
@@ -970,7 +973,8 @@
     "designerIntro": "Bir başlangıç noktası seçin, sonra her şeyi değiştirmek için tasarımcıyı açın.",
     "designerHint": "Renkler, yazı tipleri, boşluklar ve tüm bölümler tasarımcıda.",
     "certificatesDescription": "Tamamlanan bir muayenenin yazdırıp paylaştığı sertifikayı tasarlayın.",
-    "workOrdersDescription": "Bir iş emrinin yazdırdığı sayfayı tasarlayın: müşterinin imzaladığı ve teknisyenin panodan aldığı sayfa."
+    "workOrdersDescription": "Bir iş emrinin yazdırdığı sayfayı tasarlayın: müşterinin imzaladığı ve teknisyenin panodan aldığı sayfa.",
+    "conditionMapDescription": "Teknisyenlerinizin durum haritasına çizdiği işaret türleri: adları, şekilleri ve renkleri."
   },
   "payment": {
     "readMore": "Devamını oku",
@@ -2261,7 +2265,13 @@
     "fieldFixedSlot": "Her zaman aynı yerde yazdırılır, bu yüzden taşınamaz.",
     "certificate": "Muayene sertifikası",
     "resultWidthHint": "Boş bırakılırsa satırı kaplar. Punto girerseniz bant daralır ve hizalamanın seçtiği tarafa yerleşir; sütunda sütun genişliğini alır.",
-    "work_order": "İş emri"
+    "work_order": "İş emri",
+    "conditionMapDrawing": "Çizim",
+    "conditionMapViews": "Görünümler",
+    "conditionMapViewsAll": "Beşi de",
+    "conditionMapViewsTop": "Yalnızca üstten",
+    "conditionMapViewsSides": "Yalnızca yanlar",
+    "conditionMapWidthHint": "Boş bırakılırsa satırı doldurur. Çizimi küçültmek için punto girin; hizalamaya göre yerleşir ve açıklama tam genişlikte kalır."
   },
   "preview": {
     "title": "Fren bakımı ve yağ değişimi",
@@ -2710,5 +2720,48 @@
     "failed": "Durum kaydedilemedi",
     "messageEmptyHint": "Boş bırakılırsa yukarıdaki metin gönderilir.",
     "useSuggested": "Bu metni düzenle"
+  },
+  "markTypes": {
+    "title": "İşaret türleri",
+    "description": "Yerleşik türleri yeniden adlandırın veya renklendirin, hiç kullanmadıklarınızı gizleyin ve kendi türlerinizi ekleyin. Her türün rengin yanında bir şekli vardır; böylece siyah-beyaz baskıda da okunur.",
+    "cardTitle": "Türler",
+    "add": "Tür ekle",
+    "addTitle": "Yeni işaret türü",
+    "editTitle": "İşaret türünü düzenle",
+    "dialogBody": "Kendi sözcüklerinizle bir ad, bir şekil ve bir renk. Dolu olan ciddi işareti gösterir.",
+    "previewHint": "Hafif ve ciddi",
+    "name": "Ad",
+    "namePlaceholder": "örn. Kaldırım sürtmesi",
+    "shape": "Şekil",
+    "color": "Renk",
+    "customColor": "Hex renk",
+    "colorInvalid": "Rengi #2563eb gibi altı hex karakterle girin",
+    "hidden": "Seçicide gizli",
+    "hiddenHint": "Bu türdeki işaretler yine gösterilir ve yazdırılır.",
+    "hiddenBadge": "Gizli",
+    "builtin": "Yerleşik",
+    "show": "Seçicide göster",
+    "hide": "Seçiciden gizle",
+    "edit": "Düzenle",
+    "moveUp": "Yukarı taşı",
+    "moveDown": "Aşağı taşı",
+    "restore": "Geri al",
+    "restoreTitle": "\"{name}\" geri alınsın mı?",
+    "restoreBody": "Tür, uygulamayla gelen adına, şekline ve rengine döner.",
+    "remove": "Kaldır",
+    "removeTitle": "\"{name}\" kaldırılsın mı?",
+    "removeBody": "Yalnızca hiçbir işaretin kullanmadığı bir tür kaldırılabilir. Bu türde işaretler varsa gizleyin.",
+    "save": "Kaydet",
+    "cancel": "İptal",
+    "saved": "Kaydedildi",
+    "failed": "Kaydedilemedi",
+    "shapes": {
+      "circle": "Daire",
+      "diamond": "Eşkenar dörtgen",
+      "triangle": "Üçgen",
+      "square": "Kare",
+      "cross": "Çarpı",
+      "hex": "Altıgen"
+    }
   }
 }

+ 2 - 1
messages/tr/share.json

@@ -232,6 +232,7 @@
     "portalMessage": "Araçlarınızı, faturalarınızı ve daha fazlasını görüntüleyin:",
     "customerPortal": "müşteri portalı",
     "telegramConnect": "Telegram'da bizimle sohbet edin",
-    "telegramScan": "Güncellemeler ve destek için Telegram'da bize mesaj göndermek üzere bu QR kodunu tarayın."
+    "telegramScan": "Güncellemeler ve destek için Telegram'da bize mesaj göndermek üzere bu QR kodunu tarayın.",
+    "downloadCertificate": "Muayene raporunu indir"
   }
 }

+ 30 - 0
prisma/migrations/20260930063108_condition_map_on_invoice/migration.sql

@@ -0,0 +1,30 @@
+-- AlterTable
+ALTER TABLE "inspections" ADD COLUMN     "markTypesSnapshot" JSONB;
+
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN     "conditionMapOnInvoice" BOOLEAN;
+
+-- CreateTable
+CREATE TABLE "condition_mark_types" (
+    "id" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "key" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "shape" TEXT NOT NULL,
+    "color" TEXT NOT NULL,
+    "sortOrder" INTEGER NOT NULL DEFAULT 0,
+    "hidden" BOOLEAN NOT NULL DEFAULT false,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "condition_mark_types_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "condition_mark_types_organizationId_idx" ON "condition_mark_types"("organizationId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "condition_mark_types_organizationId_key_key" ON "condition_mark_types"("organizationId", "key");
+
+-- AddForeignKey
+ALTER TABLE "condition_mark_types" ADD CONSTRAINT "condition_mark_types_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 31 - 0
prisma/schema/condition-marks.prisma

@@ -59,3 +59,34 @@ model ConditionMark {
   @@index([organizationId])
   @@map("condition_marks")
 }
+
+/// A workshop's own kind of mark on the condition map: one of the eight
+/// built-in kinds renamed or recoloured (the row carries the built-in key),
+/// or a kind of the workshop's own. A mark refers to a kind by its key, so a
+/// renamed kind changes on every open document at once; an issued invoice and
+/// a completed inspection keep the kinds as they were in their own snapshots.
+model ConditionMarkType {
+  id             String       @id @default(cuid())
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  /// dent, scratch, chip, crack, rust, paint, previous_repair, missing for a
+  /// built-in kind; a key made from the id for a workshop's own.
+  key       String
+  name      String
+  /// circle | diamond | triangle | square | cross | hex
+  shape     String
+  /// A hex colour, printed as it is.
+  color     String
+  sortOrder Int     @default(0)
+  /// Kept out of the picker; marks that carry it still show and print.
+  hidden    Boolean @default(false)
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@unique([organizationId, key])
+  @@index([organizationId])
+  @@map("condition_mark_types")
+}
+

+ 4 - 0
prisma/schema/inspections.prisma

@@ -140,6 +140,10 @@ model Inspection {
   /// a redrawn signature never changes a certificate already issued.
   signatureSnapshotId String?
   signatureSnapshot   DocumentAssetSnapshot?  @relation(fields: [signatureSnapshotId], references: [id], onDelete: SetNull)
+  /// The condition map's kinds of mark as they were named and drawn at
+  /// completion, for the same reason: a kind renamed later never changes a
+  /// certificate already issued. Null before the kinds could be changed.
+  markTypesSnapshot   Json?
 
   organizationId String
 

+ 1 - 0
prisma/schema/organization.prisma

@@ -54,6 +54,7 @@ model Organization {
   invitations                 TeamInvitation[]
   inspectionTemplates         InspectionTemplate[]
   conditionMarks              ConditionMark[]
+  conditionMarkTypes          ConditionMarkType[]
   smsMessages                 SmsMessage[]
   telegramMessages            TelegramMessage[]
   whatsappMessages            WhatsappMessage[]

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

@@ -49,6 +49,11 @@ model ServiceRecord {
   warrantyExpiresAt DateTime?
   warrantyNotes     String?
 
+  /// Print the car's condition map on this job's invoice: the job's own
+  /// answer from the drop-off tab, on or off, or null to follow the invoice
+  /// design, which answers for every invoice.
+  conditionMapOnInvoice Boolean?
+
   publicToken  String?   @unique
   sharedAt     DateTime?
   viewCount    Int       @default(0)

+ 184 - 0
src/__tests__/features/condition-map/mark-types.test.ts

@@ -0,0 +1,184 @@
+/**
+ * The workshop's kinds of mark: the built-in eight as the workshop left
+ * them, its own beside them, a kind nobody knows still drawn, and a print
+ * that names and draws each mark from the catalogue it was given.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+  builtinMarkTypes,
+  markStyleOf,
+  markTypeOf,
+  markTypeRefs,
+  ownMarkKey,
+  resolveMarkTypes,
+} from '@/features/condition-map/Lib/markTypes'
+import { MARK_KINDS, MARK_STYLE, type ConditionMarkData } from '@/features/condition-map/Lib/marks'
+import { conditionMapForPrint } from '@/features/condition-map/Lib/print'
+
+const names = { dent: 'Dent', scratch: 'Scratch', chip: 'Stone chip' }
+
+describe('the catalogue of kinds', () => {
+  it('ships the eight built-in kinds, named in the reader’s language', () => {
+    const types = builtinMarkTypes(names)
+    expect(types.map((t) => t.key)).toEqual([...MARK_KINDS])
+    expect(types[0]).toMatchObject({ key: 'dent', name: 'Dent', shape: 'circle', builtin: true })
+    expect(types[0].color).toBe(MARK_STYLE.dent.color)
+    expect(types.every((t) => !t.changed && !t.hidden)).toBe(true)
+    // A kind with no name in this language keeps its key rather than nothing.
+    expect(types.find((t) => t.key === 'rust')?.name).toBe('rust')
+  })
+
+  it("applies the workshop's changes and lists its own kinds after", () => {
+    const types = resolveMarkTypes(
+      [
+        {
+          key: 'dent',
+          name: 'Ding',
+          shape: 'square',
+          color: '#111111',
+          sortOrder: 7,
+          hidden: true,
+        },
+        {
+          key: 'own_abc',
+          name: 'Curb rash',
+          shape: 'hex',
+          color: '#e11d48',
+          sortOrder: 1,
+          hidden: false,
+        },
+        { key: 'own_bad', name: 'Odd', shape: 'blob', color: '', sortOrder: 9, hidden: false },
+      ],
+      names
+    )
+    const dent = types.find((t) => t.key === 'dent')!
+    expect(dent).toMatchObject({
+      name: 'Ding',
+      shape: 'square',
+      color: '#111111',
+      hidden: true,
+      changed: true,
+    })
+    // Sorted by the workshop's order: the own kind at 1 comes right after scratch at 1? No: stable by sortOrder.
+    expect(types.map((t) => t.key).indexOf('own_abc')).toBeLessThan(
+      types.map((t) => t.key).indexOf('dent')
+    )
+    const odd = types.find((t) => t.key === 'own_bad')!
+    // A shape or colour the app does not know falls back rather than breaking the drawing.
+    expect(odd.shape).toBe('circle')
+    expect(odd.color).toBe('#6b7280')
+    expect(odd.builtin).toBe(false)
+  })
+
+  it('draws a kind nobody knows as a grey circle named by its key', () => {
+    const types = builtinMarkTypes(names)
+    expect(markTypeOf(types, 'own_gone')).toMatchObject({ name: 'own gone', shape: 'circle' })
+    expect(markStyleOf(types, 'chip')).toEqual({ shape: 'triangle', color: MARK_STYLE.chip.color })
+    expect(markTypeRefs(types)[0]).toEqual({
+      key: 'dent',
+      name: 'Dent',
+      shape: 'circle',
+      color: MARK_STYLE.dent.color,
+    })
+    expect(ownMarkKey('abc')).toBe('own_abc')
+  })
+})
+
+describe('a print with the workshop’s kinds', () => {
+  const mark = (over: Partial<ConditionMarkData>): ConditionMarkData => ({
+    id: 'm',
+    vehicleId: 'v',
+    inspectionId: null,
+    inspectionItemId: null,
+    serviceRecordId: 's1',
+    bodyType: 'sedan',
+    view: 'left',
+    panel: 'left_front_door',
+    x: 0.5,
+    y: 0.5,
+    kind: 'dent',
+    severity: 'minor',
+    note: null,
+    imageUrls: [],
+    recordedAt: '2026-09-01T00:00:00Z',
+    resolvedAt: null,
+    ...over,
+  })
+  const labels = {
+    views: { top: 'Top', left: 'Left', right: 'Right', front: 'Front', rear: 'Rear' },
+    panels: { left_front_door: 'Left front door' },
+    kinds: { dent: 'Dent' },
+    severities: { minor: 'Minor', major: 'Major' },
+    previous: 'earlier',
+  }
+
+  it('names the legend and shapes the glyph from the catalogue it is given', () => {
+    const types = resolveMarkTypes(
+      [
+        {
+          key: 'dent',
+          name: 'Ding',
+          shape: 'square',
+          color: '#123456',
+          sortOrder: 0,
+          hidden: false,
+        },
+        {
+          key: 'own_x',
+          name: 'Curb rash',
+          shape: 'hex',
+          color: '#654321',
+          sortOrder: 8,
+          hidden: false,
+        },
+      ],
+      { dent: 'Dent' }
+    )
+    const map = conditionMapForPrint({
+      bodyType: 'sedan',
+      marks: [
+        mark({ id: 'a' }),
+        mark({ id: 'b', kind: 'own_x', recordedAt: '2026-09-02T00:00:00Z' }),
+      ],
+      includePrevious: false,
+      labels: { ...labels, types },
+      width: 500,
+    })!
+    expect(map.rows.map((r) => r.kind)).toEqual(['Ding', 'Curb rash'])
+    const strokes = map.shapes
+      .filter((s) => s.type === 'path' && s.fill === '#ffffff')
+      .map((s: any) => s.stroke)
+    expect(strokes).toEqual(expect.arrayContaining(['#123456', '#654321']))
+  })
+
+  it('falls back to the built-in kinds when no catalogue is given', () => {
+    const map = conditionMapForPrint({
+      bodyType: 'sedan',
+      marks: [mark({ id: 'a' })],
+      includePrevious: false,
+      labels,
+      width: 500,
+    })!
+    expect(map.rows[0].kind).toBe('Dent')
+    expect(map.shapes.some((s) => s.type === 'circle' && s.stroke === MARK_STYLE.dent.color)).toBe(
+      true
+    )
+  })
+})
+
+describe('the icon of a kind', () => {
+  it('draws the kind’s own shape, not a circle for everything', async () => {
+    const { renderToStaticMarkup } = await import('react-dom/server')
+    const React = await import('react')
+    const { MarkIcon } = await import('@/features/condition-map/Components/ConditionMap')
+    const html = renderToStaticMarkup(
+      React.createElement(MarkIcon, {
+        type: { shape: 'triangle', color: '#7c3aed' },
+        severity: 'minor',
+      })
+    )
+    expect(html).toContain('<path')
+    expect(html).not.toContain('<circle')
+    expect(html).toContain('#7c3aed')
+  })
+})

+ 24 - 0
src/__tests__/features/condition-map/marks.test.ts

@@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'
 import {
   bodyTypeFor,
   guessBodyType,
+  isDrawnOnSheet,
   markInputSchema,
   numberedMarks,
   splitMarks,
@@ -50,6 +51,29 @@ describe('condition marks', () => {
     expect(onJob.previous.map((m) => m.id)).toEqual(['a', 'b'])
   })
 
+  it("counts the linked inspection's marks as the job's visit, but only the drop-off's as drawn on it", () => {
+    const marks = [
+      mark({ id: 'checkin', inspectionId: 'i1', inspectionItemId: 'c1' }),
+      mark({ id: 'older', inspectionId: 'i0', inspectionItemId: 'c0' }),
+      mark({ id: 'dropoff', serviceRecordId: 's1' }),
+      mark({ id: 'otherJob', serviceRecordId: 's0' }),
+    ]
+    const scope = { serviceRecordId: 's1', linkedInspectionId: 'i1' }
+    const onJob = splitMarks(marks, scope)
+    expect(onJob.own.map((m) => m.id)).toEqual(['checkin', 'dropoff'])
+    expect(onJob.previous.map((m) => m.id)).toEqual(['older', 'otherJob'])
+    expect(marks.filter((m) => isDrawnOnSheet(m, scope)).map((m) => m.id)).toEqual(['dropoff'])
+
+    // A quote has no drop-off: its visit is the inspection it came from.
+    const onQuote = splitMarks(marks, { linkedInspectionId: 'i1' })
+    expect(onQuote.own.map((m) => m.id)).toEqual(['checkin'])
+    expect(marks.filter((m) => isDrawnOnSheet(m, { linkedInspectionId: 'i1' }))).toEqual([])
+
+    // Nothing linked and no job: nothing is this visit's.
+    expect(splitMarks(marks, {}).own).toEqual([])
+    expect(splitMarks(marks, { serviceRecordId: null, linkedInspectionId: null }).own).toEqual([])
+  })
+
   it('numbers marks by when they were recorded', () => {
     const ordered = numberedMarks([
       mark({ id: 'late', recordedAt: '2026-09-03T00:00:00Z' }),

+ 292 - 1
src/__tests__/features/condition-map/print.test.ts

@@ -17,6 +17,7 @@ import { conditionMapForPrint } from '@/features/condition-map/Lib/print'
 import type { ConditionMarkData } from '@/features/condition-map/Lib/marks'
 import { buildCertificatePrintSpec } from '@/features/inspections/Pdf/buildCertificatePrint'
 import { buildWorkOrderPrintSpec } from '@/features/invoice-designer/Pdf/buildWorkOrderPrint'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
 import { getDefaultLayout } from '@/features/settings/Schema/invoiceLayoutSchema'
 import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
 
@@ -234,7 +235,11 @@ describe('the documents', () => {
     const spec = certificate()
     expect(ids(spec)).toContain('condition_map')
     const block = spec.blocks.find((b: any) => b.id === 'condition_map').content
-    expect(block.children.some((c: any) => c.kind === 'drawing')).toBe(true)
+    // The heading is drawn on the sheet, so a page break never leaves it behind.
+    const drawing = block.children.find((c: any) => c.kind === 'drawing')
+    expect(drawing.viewBoxY).toBeLessThan(0)
+    expect(drawing.shapes[0]).toMatchObject({ type: 'text', text: 'VEHICLE CONDITION' })
+    expect(block.children.some((c: any) => c.kind === 'text')).toBe(false)
     expect(block.children.some((c: any) => c.kind === 'table')).toBe(true)
     expect(ids(certificate({ conditionMarks: [] }))).not.toContain('condition_map')
   })
@@ -334,3 +339,289 @@ describe('the documents', () => {
     expect(ids(spec)).not.toContain('condition_map')
   })
 })
+
+describe("this visit's marks", () => {
+  const jobData = {
+    id: 'job1',
+    title: 'Job',
+    type: 'repair',
+    serviceDate: new Date('2026-09-24T09:00:00Z'),
+    subtotal: 0,
+    taxRate: 0,
+    taxAmount: 0,
+    totalAmount: 0,
+    cost: 0,
+    invoiceNumber: '1',
+    partItems: [],
+    laborItems: [],
+    customer: { name: 'A' },
+    vehicle: {
+      make: 'Volvo',
+      model: 'V60',
+      year: 2020,
+      vin: null,
+      licensePlate: 'AB 1',
+      mileage: 1,
+      customer: null,
+    },
+  } as unknown as InvoiceData
+  const workOrder = (marks: ConditionMarkData[], linkedInspectionId: string | null) =>
+    buildWorkOrderPrintSpec({
+      data: jobData,
+      job: {
+        orderNumber: '1',
+        statusLabel: 'Open',
+        concerns: [],
+        printedAt: new Date('2026-09-25T00:00:00Z'),
+        conditionMarks: marks,
+        linkedInspectionId,
+        bodyType: 'sedan',
+        conditionMapLabels: labels,
+      },
+      labels: {},
+    }) as any
+  const legend = (spec: any) =>
+    spec.blocks
+      .find((b: any) => b.id === 'condition_map')
+      .content.children.find((c: any) => c.kind === 'table')
+      .rows.map((r: any) => r.area)
+
+  it("prints the linked check-in inspection's marks on the work order as the job's own", () => {
+    const checkin = mark({ id: 'checkin', inspectionId: 'insp1', inspectionItemId: 'c1' })
+    const older = mark({
+      id: 'older',
+      inspectionId: 'insp0',
+      inspectionItemId: 'c0',
+      panel: 'rear_bumper',
+      view: 'rear',
+      recordedAt: '2026-01-01T00:00:00Z',
+    })
+    expect(legend(workOrder([older, checkin], 'insp1'))).toEqual([
+      'Rear bumper (earlier)',
+      'Left front door',
+    ])
+    // Not linked, the same check-in is somebody else's report.
+    expect(ids(workOrder([older, checkin], null))).not.toContain('condition_map')
+  })
+
+  it('lists a mark drawn on another body type without placing it on this drawing', () => {
+    const onSedan = mark({ id: 'sedan', serviceRecordId: 's1' })
+    const onEstate = mark({
+      id: 'estate',
+      serviceRecordId: 's1',
+      bodyType: 'estate',
+      panel: 'rear_bumper',
+      view: 'rear',
+      recordedAt: '2026-09-02T00:00:00Z',
+    })
+    const map = conditionMapForPrint({
+      bodyType: 'sedan',
+      marks: [onSedan, onEstate],
+      scope: { serviceRecordId: 's1' },
+      includePrevious: true,
+      labels,
+      width: 500,
+    })!
+    expect(map.rows.map((r) => r.area)).toEqual(['Left front door', 'Rear bumper'])
+    const numbers = map.shapes.filter((s) => s.type === 'text').map((s: any) => s.text)
+    expect(numbers).toContain('1')
+    expect(numbers).not.toContain('2')
+  })
+})
+
+describe('the drawing sized by the design', () => {
+  const marks = [mark({ id: 'a', serviceRecordId: 's1' })]
+  const invoiceWith = (section: Record<string, unknown>) => {
+    const layout = getDefaultLayout('invoice')
+    return {
+      ...layout,
+      version: 3,
+      sections: layout.sections.map((s) =>
+        s.id === 'condition_map' ? { ...s, visible: true, ...section } : s
+      ),
+    }
+  }
+  const full = conditionMapForPrint({
+    bodyType: 'sedan',
+    marks,
+    includePrevious: false,
+    labels,
+    width: 515,
+  })!
+  const drawingOf = (spec: any) => {
+    const block = spec.blocks.find((b: any) => b.id === 'condition_map').content
+    const first = block.children[0]
+    return first.kind === 'row'
+      ? { row: first, drawing: first.children[0].node }
+      : { drawing: first }
+  }
+  const print = (section: Record<string, unknown>) =>
+    buildInvoicePrintSpec({
+      data: {
+        id: 's1',
+        title: 'Job',
+        type: 'repair',
+        serviceDate: new Date('2026-08-14'),
+        invoiceDate: new Date('2026-08-14'),
+        subtotal: 0,
+        taxRate: 0,
+        taxAmount: 0,
+        totalAmount: 0,
+        cost: 0,
+        invoiceNumber: 'INV-1',
+        discountValue: 0,
+        partItems: [],
+        laborItems: [],
+        customFields: [],
+        findings: [],
+        customer: { name: 'A' },
+        vehicle: null,
+      } as any,
+      template: { layoutConfig: invoiceWith(section) } as any,
+      conditionMap: { marks, bodyType: 'sedan', labels },
+    }) as any
+
+  it('spans the row with every view unless the design says otherwise', () => {
+    const { row, drawing } = drawingOf(print({}))
+    expect(row).toBeUndefined()
+    expect(drawing.width).toBe(full.width)
+    expect(drawing.shapes.filter((s: any) => s.type === 'text').map((s: any) => s.text)).toEqual(
+      expect.arrayContaining(['Top', 'Left', 'Right', 'Front', 'Rear'])
+    )
+  })
+
+  it('draws narrower where the design sets a width, placed by its alignment', () => {
+    const { row, drawing } = drawingOf(print({ style: { width: 200, align: 'center' } }))
+    expect(row.justify).toBe('center')
+    expect(row.children[0].width).toBe(200)
+    expect(drawing.width).toBe(200)
+    // The same sheet, only smaller: its aspect follows the width.
+    expect(drawing.height / drawing.width).toBeCloseTo((full.height + 18) / full.width, 0)
+  })
+
+  it('shows only the views the design asked for, and still lists a mark off them', () => {
+    const top = drawingOf(print({ variant: 'top' })).drawing
+    const captions = (d: any) =>
+      d.shapes.filter((s: any) => s.type === 'text' && s.size === 36).map((s: any) => s.text)
+    expect(captions(top)).toEqual(['Top'])
+    const sides = drawingOf(print({ variant: 'sides' })).drawing
+    expect(captions(sides)).toEqual(['Left', 'Right'])
+    // The mark is on the left door: on the top-only sheet it has no spot,
+    // and the legend lists it all the same.
+    const legend = (spec: any) =>
+      spec.blocks
+        .find((b: any) => b.id === 'condition_map')
+        .content.children.find((c: any) => c.kind === 'table').rows
+    expect(legend(print({ variant: 'top' }))).toHaveLength(1)
+    expect(top.shapes.some((s: any) => s.type === 'text' && s.text === '1')).toBe(false)
+    expect(sides.shapes.some((s: any) => s.type === 'text' && s.text === '1')).toBe(true)
+  })
+})
+
+describe('the blank sheet on a work order', () => {
+  const workOrder = (blankOn: boolean, marks: ConditionMarkData[] = []) => {
+    const layout = getDefaultLayout('work_order')
+    return buildWorkOrderPrintSpec({
+      data: {
+        id: 'job1',
+        title: 'Job',
+        type: 'repair',
+        serviceDate: new Date('2026-09-24T09:00:00Z'),
+        subtotal: 0,
+        taxRate: 0,
+        taxAmount: 0,
+        totalAmount: 0,
+        cost: 0,
+        invoiceNumber: '1',
+        partItems: [],
+        laborItems: [],
+        customer: { name: 'A' },
+        vehicle: null,
+      } as unknown as InvoiceData,
+      job: {
+        orderNumber: '1',
+        statusLabel: 'Open',
+        concerns: [],
+        printedAt: new Date('2026-09-25T00:00:00Z'),
+        conditionMarks: marks,
+        bodyType: 'estate',
+        conditionMapLabels: labels,
+      },
+      template: {
+        layoutConfig: {
+          ...layout,
+          sections: layout.sections.map((s) =>
+            s.id === 'condition_map'
+              ? {
+                  ...s,
+                  fields: s.fields?.map((f) =>
+                    f.id === 'blank_sheet' ? { ...f, visible: blankOn } : f
+                  ),
+                }
+              : s
+          ),
+        },
+      } as any,
+      labels: {},
+    }) as any
+  }
+  const block = (spec: any) => spec.blocks.find((b: any) => b.id === 'condition_map')?.content
+
+  it('is off in a new work order design, and offered nowhere else', () => {
+    const field = (type: 'work_order' | 'certificate' | 'invoice') =>
+      getDefaultLayout(type)
+        .sections.find((s) => s.id === 'condition_map')
+        ?.fields?.find((f) => f.id === 'blank_sheet')
+    expect(field('work_order')).toEqual({ id: 'blank_sheet', visible: false })
+    expect(field('certificate')).toBeUndefined()
+    expect(field('invoice')).toBeUndefined()
+  })
+
+  it('prints nothing for an unmarked job while it is off', () => {
+    expect(block(workOrder(false))).toBeUndefined()
+  })
+
+  it('prints the empty drawing, the key of kinds and rows to write in when on', () => {
+    const content = block(workOrder(true))
+    const [sheet, key, table] = content.children
+    expect(sheet.kind).toBe('drawing')
+    // No mark on the sheet: no numbered disc.
+    expect(sheet.shapes.some((s: any) => s.type === 'text' && s.text === '1')).toBe(false)
+    // The key names every built-in kind the labels carry, with a glyph each.
+    expect(key.kind).toBe('drawing')
+    const names = key.shapes.filter((s: any) => s.type === 'text').map((s: any) => s.text)
+    expect(names).toEqual(expect.arrayContaining(['Dent', 'Scratch']))
+    expect(names).toHaveLength(8)
+    expect(table.kind).toBe('table')
+    expect(table.rows.map((r: any) => [r.n, r.area])).toEqual([
+      ['1', ''],
+      ['2', ''],
+      ['3', ''],
+      ['4', ''],
+      ['5', ''],
+      ['6', ''],
+    ])
+  })
+
+  it('prints the marks, not the form, once the job has any', () => {
+    const content = block(
+      workOrder(true, [mark({ id: 'own', serviceRecordId: 'job1', bodyType: 'estate' })])
+    )
+    const table = content.children.find((c: any) => c.kind === 'table')
+    expect(table.rows).toHaveLength(1)
+    expect(table.rows[0].area).toBe('Left front door')
+    expect(content.children.filter((c: any) => c.kind === 'drawing')).toHaveLength(1)
+  })
+
+  it('leaves another visit’s marks off the form', () => {
+    const earlier = mark({
+      id: 'old',
+      inspectionId: 'x',
+      inspectionItemId: 'y',
+      bodyType: 'estate',
+    })
+    const content = block(workOrder(true, [earlier]))
+    const table = content.children.find((c: any) => c.kind === 'table')
+    expect(table.rows.every((r: any) => r.area === '')).toBe(true)
+  })
+})

+ 316 - 0
src/__tests__/features/condition-map/visit-documents.test.ts

@@ -0,0 +1,316 @@
+/**
+ * The car's condition on the invoice and the quote: this visit's marks only,
+ * printed last as an appendix, off until the design or the job asks for it,
+ * and an issued invoice that keeps the marks it was sent with.
+ */
+import { describe, expect, it } from 'vitest'
+import type { ConditionMarkData } from '@/features/condition-map/Lib/marks'
+import type { VisitConditionMap } from '@/features/condition-map/Lib/print'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { buildQuotePrintSpec } from '@/features/invoice-designer/Pdf/buildQuotePrint'
+import {
+  freezeConditionMap,
+  readIssuedInvoiceData,
+  thawConditionMap,
+} from '@/features/invoices/Lib/issuedInvoice'
+import {
+  getBuiltinFieldsForSection,
+  getDefaultLayout,
+  mergeWithDefaults,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+const mark = (over: Partial<ConditionMarkData>): ConditionMarkData => ({
+  id: 'm',
+  vehicleId: 'v',
+  inspectionId: null,
+  inspectionItemId: null,
+  serviceRecordId: 'svc_1',
+  bodyType: 'estate',
+  view: 'front',
+  panel: 'front_bumper',
+  x: 0.4,
+  y: 0.7,
+  kind: 'dent',
+  severity: 'minor',
+  note: 'Behind the plate',
+  imageUrls: [],
+  recordedAt: '2026-09-29T15:56:23.413Z',
+  resolvedAt: null,
+  ...over,
+})
+
+const map: VisitConditionMap = {
+  bodyType: 'estate',
+  marks: [
+    mark({ id: 'a' }),
+    mark({
+      id: 'b',
+      view: 'right',
+      panel: 'right_front_door',
+      kind: 'previous_repair',
+      severity: 'major',
+      note: null,
+      recordedAt: '2026-09-29T15:56:25.783Z',
+    }),
+  ],
+  labels: {
+    views: { top: 'Top', left: 'Left side', right: 'Right side', front: 'Front', rear: 'Rear' },
+    panels: { front_bumper: 'Front bumper', right_front_door: 'Right front door' },
+    kinds: { dent: 'Dent', previous_repair: 'Previous repair' },
+    severities: { minor: 'Minor', major: 'Major' },
+    previous: 'recorded earlier',
+  },
+}
+
+const invoice = {
+  id: 'svc_1',
+  title: 'Brakes',
+  type: 'repair',
+  serviceDate: new Date('2026-08-14'),
+  invoiceDate: new Date('2026-08-14'),
+  subtotal: 100,
+  taxRate: 0,
+  taxAmount: 0,
+  totalAmount: 100,
+  cost: 100,
+  invoiceNumber: 'INV-1',
+  discountValue: 0,
+  partItems: [],
+  laborItems: [{ description: 'Fit', hours: 1, rate: 100, total: 100 }],
+  customFields: [],
+  findings: [],
+  customer: { name: 'Alex' },
+  vehicle: null,
+} as unknown as InvoiceData
+
+const quote = {
+  id: 'quote-1',
+  quoteNumber: 'QT-0001',
+  title: 'Oil Change',
+  description: null,
+  status: 'sent',
+  validUntil: null,
+  createdAt: new Date('2026-08-14'),
+  subtotal: 100,
+  taxRate: 0,
+  taxAmount: 0,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  totalAmount: 100,
+  notes: null,
+  partItems: [],
+  laborItems: [],
+  customer: null,
+  vehicle: null,
+} as any
+
+/** A design saved before the section existed: every section it knew, none of this one. */
+function savedBeforeTheSection() {
+  const layout = getDefaultLayout('invoice')
+  return {
+    ...layout,
+    version: 3,
+    sections: layout.sections.filter((s) => s.id !== 'condition_map'),
+  }
+}
+
+const blockOf = (spec: any) => spec.blocks.find((b: any) => b.id === 'condition_map')
+const legendOf = (spec: any) =>
+  blockOf(spec)
+    .content.children.find((c: any) => c.kind === 'table')
+    .rows.map((r: any) => [r.area, r.kind, r.severity, r.note])
+
+describe('the Vehicle Condition section on an invoice and a quote', () => {
+  it('is off in a new design, last before the signature', () => {
+    const layout = getDefaultLayout('invoice')
+    const ids = layout.sections.map((s) => s.id)
+    expect(ids.indexOf('condition_map')).toBe(ids.indexOf('signature') - 1)
+    expect(layout.sections.find((s) => s.id === 'condition_map')?.visible).toBe(false)
+  })
+
+  it('arrives off in a design saved before it existed, in the same place', () => {
+    const merged = mergeWithDefaults(savedBeforeTheSection())
+    const section = merged.sections.find((s) => s.id === 'condition_map')
+    expect(section?.visible).toBe(false)
+    const ids = merged.sections.map((s) => s.id)
+    expect(ids.indexOf('condition_map')).toBe(ids.indexOf('bank_account') + 1)
+  })
+
+  it('goes in front of the signing line wherever a design put it', () => {
+    // Bank details under the signature, as some workshops arrange it: the
+    // appendix still ends up before the signature, not after it.
+    const layout = savedBeforeTheSection()
+    const order = [
+      'header',
+      'document_title',
+      'customer',
+      'vehicle',
+      'items_table',
+      'totals',
+      'signature',
+      'bank_account',
+      'footer',
+    ]
+    const rearranged = {
+      ...layout,
+      sections: order.map((id, i) => ({
+        ...layout.sections.find((s) => s.id === id)!,
+        order: i,
+        visible: true,
+      })),
+    }
+    const ids = mergeWithDefaults(rearranged).sections.map((s) => s.id)
+    expect(ids.indexOf('condition_map')).toBe(ids.indexOf('signature') - 1)
+    expect(ids.indexOf('condition_map')).toBeGreaterThan(ids.indexOf('totals'))
+  })
+
+  it('keeps the choice a workshop saved', () => {
+    const layout = getDefaultLayout('invoice')
+    const on = {
+      ...layout,
+      version: 3,
+      sections: layout.sections.map((s) =>
+        s.id === 'condition_map' ? { ...s, visible: true } : s
+      ),
+    }
+    expect(mergeWithDefaults(on).sections.find((s) => s.id === 'condition_map')?.visible).toBe(true)
+  })
+
+  it('leaves the work order and the certificate as they were', () => {
+    for (const type of ['work_order', 'certificate'] as const) {
+      const layout = getDefaultLayout(type)
+      const saved = { ...layout, sections: layout.sections.filter((s) => s.id !== 'condition_map') }
+      expect(mergeWithDefaults(saved).sections.find((s) => s.id === 'condition_map')?.visible).toBe(
+        true
+      )
+      expect(getBuiltinFieldsForSection('condition_map', type).map((f) => f.id)).toContain(
+        'previous_marks'
+      )
+    }
+  })
+
+  it('offers no switch for earlier visits, which an invoice never prints', () => {
+    for (const type of ['invoice', 'quote'] as const) {
+      expect(getBuiltinFieldsForSection('condition_map', type).map((f) => f.id)).toEqual(['legend'])
+      const section = getDefaultLayout(type).sections.find((s) => s.id === 'condition_map')
+      expect(section?.fields?.map((f) => f.id)).toEqual(['legend'])
+    }
+  })
+})
+
+describe('the printed invoice', () => {
+  const print = (layout: any, conditionMap?: VisitConditionMap) =>
+    buildInvoicePrintSpec({
+      data: invoice,
+      template: { layoutConfig: layout } as any,
+      conditionMap,
+    }) as any
+
+  const withSection = () => {
+    const layout = getDefaultLayout('invoice')
+    return {
+      ...layout,
+      version: 3,
+      sections: layout.sections.map((s) =>
+        s.id === 'condition_map' ? { ...s, visible: true } : s
+      ),
+    }
+  }
+
+  it("draws this visit's marks with their legend, none of them greyed", () => {
+    const spec = print(withSection(), map)
+    const block = blockOf(spec)
+    expect(block.content.children.some((c: any) => c.kind === 'drawing')).toBe(true)
+    expect(legendOf(spec)).toEqual([
+      ['Front bumper', 'Dent', 'Minor', 'Behind the plate'],
+      ['Right front door', 'Previous repair', 'Major', ''],
+    ])
+  })
+
+  it('prints nothing for a visit with no marks, or with the section off', () => {
+    expect(blockOf(print(withSection()))).toBeUndefined()
+    expect(blockOf(print({ ...getDefaultLayout('invoice'), version: 3 }, map))).toBeUndefined()
+  })
+
+  it('prints the map when the job asked for it, whatever the design says', () => {
+    // The switch on the drop-off tab: this invoice, without touching the design.
+    const spec = print({ ...getDefaultLayout('invoice'), version: 3 }, { ...map, onInvoice: true })
+    expect(legendOf(spec)).toHaveLength(2)
+    // Asked for on a job with nothing to show is still nothing.
+    expect(
+      blockOf(
+        print(
+          { ...getDefaultLayout('invoice'), version: 3 },
+          { ...map, marks: [], onInvoice: true }
+        )
+      )
+    ).toBeUndefined()
+    // Declined on the job, the map stays off even where the design has it on.
+    expect(blockOf(print(withSection(), { ...map, onInvoice: false }))).toBeUndefined()
+    // Neither asked nor declined: the design decides.
+    expect(legendOf(print(withSection(), { ...map, onInvoice: null }))).toHaveLength(2)
+  })
+})
+
+describe('the printed quote', () => {
+  it('draws the marks of the inspection it came from', () => {
+    const layout = getDefaultLayout('quote')
+    const on = {
+      ...layout,
+      sections: layout.sections.map((s) =>
+        s.id === 'condition_map' ? { ...s, visible: true } : s
+      ),
+    }
+    const spec = buildQuotePrintSpec({ data: quote, layoutConfig: on, conditionMap: map }) as any
+    expect(legendOf(spec).map((row: string[]) => row[0])).toEqual([
+      'Front bumper',
+      'Right front door',
+    ])
+    expect(blockOf(buildQuotePrintSpec({ data: quote, layoutConfig: on }))).toBeUndefined()
+    // Off by default: the inspection's marks wait for the design to ask.
+    expect(
+      blockOf(buildQuotePrintSpec({ data: quote, layoutConfig: layout, conditionMap: map }))
+    ).toBeUndefined()
+  })
+})
+
+describe('an issued invoice', () => {
+  it('keeps the marks it was issued with, through the stored JSON', () => {
+    const frozen = freezeConditionMap({ ...map, onInvoice: true })
+    const stored = JSON.parse(
+      JSON.stringify({ version: 1, workshop: {}, invoiceSettings: {}, conditionMap: frozen })
+    )
+    const read = readIssuedInvoiceData(stored)
+    const thawed = thawConditionMap(read?.conditionMap)
+    expect(thawed?.bodyType).toBe('estate')
+    // The job's own ask travels with the snapshot, so the design cannot
+    // take the map off an invoice that went out with it.
+    expect(thawed?.onInvoice).toBe(true)
+    expect(thawed?.marks.map((m) => [m.id, m.panel, m.kind, m.severity, m.note])).toEqual([
+      ['a', 'front_bumper', 'dent', 'minor', 'Behind the plate'],
+      ['b', 'right_front_door', 'previous_repair', 'major', null],
+    ])
+    // Printed from the snapshot, it says what the invoice said when it went out.
+    const spec = buildInvoicePrintSpec({
+      data: invoice,
+      template: { layoutConfig: { ...getDefaultLayout('invoice'), version: 3 } } as any,
+      conditionMap: { ...thawed!, labels: map.labels },
+    }) as any
+    expect(legendOf(spec)).toHaveLength(2)
+  })
+
+  it('prints none when it was issued before the invoice printed the map', () => {
+    const read = readIssuedInvoiceData({ version: 1, workshop: {}, invoiceSettings: {} })
+    expect(read).not.toBeNull()
+    expect(thawConditionMap(read?.conditionMap)).toBeNull()
+    expect(freezeConditionMap(null)).toBeNull()
+    expect(freezeConditionMap({ bodyType: 'sedan', marks: [], onInvoice: true })).toBeNull()
+    // A job that never answered stays that way through the snapshot.
+    const undecided = freezeConditionMap({ ...map, onInvoice: null })
+    expect(thawConditionMap(undecided)?.onInvoice).toBeNull()
+  })
+})

+ 123 - 0
src/__tests__/features/email/inspection-email-certificate.test.ts

@@ -0,0 +1,123 @@
+/**
+ * The certificate a customer gets by email is the one they would download.
+ *
+ * The inspection mail used to render the built-in sheet on its own, so a
+ * workshop that had designed its certificate sent one look by email and
+ * another through the share link. It now attaches what the shared customer
+ * builder returns: the design when there is one, the built-in sheet otherwise.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const inspection = { findFirst: vi.fn(), update: vi.fn().mockResolvedValue({}) }
+vi.mock('@/lib/db', () => ({
+  db: {
+    inspection,
+    appSetting: { findMany: vi.fn().mockResolvedValue([]) },
+    organization: { findUnique: vi.fn().mockResolvedValue({ name: 'Egeland Auto' }) },
+    user: { findUnique: vi.fn().mockResolvedValue({ name: 'Bernt' }) },
+  },
+}))
+vi.mock('@/lib/with-auth', () => ({
+  withAuth: async (fn: (ctx: { organizationId: string; userId: string }) => Promise<unknown>) => {
+    try {
+      return { success: true, data: await fn({ organizationId: 'org', userId: 'user' }) }
+    } catch (error) {
+      return { success: false, error: (error as Error).message }
+    }
+  },
+}))
+vi.mock('@/lib/features', () => ({ requireFeature: vi.fn() }))
+vi.mock('@/lib/demo', () => ({ demoGuard: vi.fn() }))
+vi.mock('@/lib/document-lock.server', () => ({ markInvoiceSent: vi.fn(), markQuoteSent: vi.fn() }))
+vi.mock('@/features/invoices/Lib/issueInvoice', () => ({ issueInvoice: vi.fn() }))
+vi.mock('@/features/invoices/Lib/assembleInvoicePrint', () => ({
+  assembleInvoicePrint: vi.fn(),
+  invoiceNumberOf: vi.fn(),
+}))
+vi.mock('@/features/invoices/Pdf/buildInvoicePdfBuffer', () => ({ renderInvoicePdf: vi.fn() }))
+vi.mock('@/features/quotes/Pdf/buildQuotePdfBuffer', () => ({ buildQuotePdfBuffer: vi.fn() }))
+vi.mock('@/i18n/locale-from-request', () => ({
+  resolveCustomerLocale: vi.fn().mockResolvedValue('nb'),
+}))
+vi.mock('@/lib/app-url', () => ({ getAppBaseUrl: () => 'https://app.example' }))
+
+const buildCustomerCertificatePdf = vi.fn()
+vi.mock('@/features/inspections/Pdf/customerCertificatePdf', () => ({
+  buildCustomerCertificatePdf: (input: unknown) => buildCustomerCertificatePdf(input),
+  linkedCertificateAttachment: vi.fn(),
+}))
+const sendTemplatedMail = vi.fn()
+vi.mock('@/features/email/Lib/sendTemplatedMail', () => ({
+  sendTemplatedMail: (org: string, options: unknown) => sendTemplatedMail(org, options),
+}))
+
+const { sendInspectionEmail } = await import('@/features/email/Actions/emailActions')
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  inspection.findFirst.mockResolvedValue({
+    publicToken: null,
+    vehicle: {
+      make: 'BMW',
+      model: '5-Serie F11',
+      year: 2011,
+      vin: null,
+      licensePlate: 'BS48364',
+      hsn: null,
+      tsn: null,
+      mileage: 0,
+      customer: { name: 'Ola', email: 'ola@example.com', phone: null },
+    },
+  })
+})
+
+describe('the inspection email', () => {
+  it('attaches the certificate the download gives, in the mail’s language', async () => {
+    const designed = new TextEncoder().encode('%PDF designed').buffer
+    buildCustomerCertificatePdf.mockResolvedValue({ body: designed, fileName: 'ignored.pdf' })
+
+    const result = await sendInspectionEmail({
+      inspectionId: 'insp',
+      recipientEmail: 'ola@example.com',
+      attachPdf: true,
+    })
+
+    expect(result.success).toBe(true)
+    expect(buildCustomerCertificatePdf).toHaveBeenCalledWith({
+      inspectionId: 'insp',
+      organizationId: 'org',
+      locale: 'nb',
+    })
+    const [, options] = sendTemplatedMail.mock.calls[0]
+    expect(options.locale).toBe('nb')
+    expect(options.attached).toBe(true)
+    expect(options.attachments).toHaveLength(1)
+    // The file keeps the name the mail has always given it.
+    expect(options.attachments[0].filename).toBe('Inspection-2011 BMW 5-Serie F11.pdf')
+    expect(options.attachments[0].content.toString()).toBe('%PDF designed')
+  })
+
+  it('builds nothing for a link-only mail, and mints the link', async () => {
+    const result = await sendInspectionEmail({
+      inspectionId: 'insp',
+      recipientEmail: 'ola@example.com',
+      attachPdf: false,
+    })
+    expect(result.success).toBe(true)
+    expect(buildCustomerCertificatePdf).not.toHaveBeenCalled()
+    const [, options] = sendTemplatedMail.mock.calls[0]
+    expect(options.attachments).toBeUndefined()
+    expect(options.context.shareLink).toMatch(/^https:\/\/app\.example\/share\/inspection\/org\//)
+  })
+
+  it('sends nothing when the certificate cannot be built', async () => {
+    buildCustomerCertificatePdf.mockResolvedValue(null)
+    const result = await sendInspectionEmail({
+      inspectionId: 'insp',
+      recipientEmail: 'ola@example.com',
+      attachPdf: true,
+    })
+    expect(result.success).toBe(false)
+    expect(sendTemplatedMail).not.toHaveBeenCalled()
+  })
+})

+ 2 - 2
src/__tests__/features/invoice-designer/every-block.test.ts

@@ -96,9 +96,9 @@ describe('the sections a default sheet prints', () => {
   it('accounts for every section the designer offers', () => {
     // The guard on the two lists above: a section added to the schema
     // tomorrow is either on the default sheet, and so covered by the
-    // hides-it test, or one of the four known to start off. Neither, and
+    // hides-it test, or one of the five known to start off. Neither, and
     // this fails rather than quietly leaving a block untested.
-    const offByDefault = ['items_table', 'telegram_qr', 'general', 'signature']
+    const offByDefault = ['items_table', 'telegram_qr', 'general', 'signature', 'condition_map']
     expect([...DEFAULT_SHEET, ...offByDefault].sort()).toEqual(
       BUILTIN_SECTIONS.map((section) => section.id).sort()
     )

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

@@ -24,6 +24,7 @@ const NOT_FILES: Record<string, string> = {
   'DocumentDesign.documentType': 'invoice or quote',
   'DocumentDesign.layout': 'section order and switches; the logo is in `template`',
   'DocumentDesignSnapshot.layout': 'the same, frozen',
+  'Inspection.markTypesSnapshot': 'the kinds of mark at completion: names, shapes and colours',
   'ExternalCalendarEvent.remoteUrl': 'a link into the calendar provider',
   'ImportBatch.fileName': 'the name of a spreadsheet staged in the temp folder',
   'ImportBatch.mapping': 'column mapping of an import',

+ 2 - 0
src/__tests__/multitenancy/inspection-isolation.test.ts

@@ -35,6 +35,8 @@ vi.mock('@/lib/files/manager', () => ({
 vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },
+    // Completing freezes the workshop's kinds of mark; nobody here changed any.
+    conditionMarkType: { findMany: vi.fn().mockResolvedValue([]) },
     inspection: {
       findFirst: vi.fn(),
       updateMany: vi.fn(),

+ 2 - 0
src/__tests__/multitenancy/quote-inspection-isolation.test.ts

@@ -37,6 +37,8 @@ vi.mock('@/lib/files/manager', () => ({
 vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },
+    // Completing freezes the workshop's kinds of mark; nobody here changed any.
+    conditionMarkType: { findMany: vi.fn().mockResolvedValue([]) },
     appSetting: { findMany: vi.fn().mockResolvedValue([]) },
     // Completing freezes the inspector's signature; nobody here has one.
     memberSignature: { findFirst: vi.fn().mockResolvedValue(null) },

+ 11 - 2
src/app/(authenticated)/inspections/[id]/page.tsx

@@ -12,7 +12,11 @@ import {
 } from '@/features/inspections/Components/InspectionPageClient'
 import { PageHeader } from '@/components/page-header'
 import { getAuthContext } from '@/lib/get-auth-context'
-import { loadVehicleConditionMarks } from '@/features/condition-map/Actions/conditionMarkActions'
+import {
+  loadVehicleConditionMarks,
+  markTypeCatalogue,
+} from '@/features/condition-map/Lib/loadMarks.server'
+import { getLocale } from 'next-intl/server'
 import { getFeatures } from '@/lib/features'
 import { redirect } from 'next/navigation'
 
@@ -30,7 +34,7 @@ export default async function InspectionDetailPage({
   }
 
   const hasConditionMap = result.data.items.some((item) => item.inputType === 'condition_map')
-  const [features, defectHistory, technicians, settings, statusReports, conditionMarks] =
+  const [features, defectHistory, technicians, settings, statusReports, conditionMarks, markTypes] =
     await Promise.all([
       authContext?.organizationId ? getFeatures(authContext.organizationId) : null,
       getCommonDefectNotes(id),
@@ -40,6 +44,10 @@ export default async function InspectionDetailPage({
       hasConditionMap && authContext?.organizationId
         ? loadVehicleConditionMarks(authContext.organizationId, result.data.vehicleId)
         : [],
+      ,
+      hasConditionMap && authContext?.organizationId
+        ? markTypeCatalogue(authContext.organizationId, await getLocale())
+        : [],
     ])
 
   return (
@@ -66,6 +74,7 @@ export default async function InspectionDetailPage({
           defectHistory={defectHistory.success ? defectHistory.data : {}}
           technicians={technicians.success ? technicians.data : []}
           conditionMarks={conditionMarks}
+          markTypes={markTypes}
           workshopAddress={
             (settings.success && settings.data?.[SETTING_KEYS.WORKSHOP_ADDRESS]) || ''
           }

+ 4 - 0
src/app/(authenticated)/settings/templates/page.tsx

@@ -7,6 +7,7 @@ import { getFeatures, isCloudMode } from '@/lib/features'
 import { FeatureLocked } from '../feature-locked-message'
 import { redirect } from 'next/navigation'
 import { getChecklistLanguage, getTemplates } from '@/features/inspections/Actions/templateActions'
+import { listMarkTypes } from '@/features/condition-map/Actions/markTypeActions'
 import { db } from '@/lib/db'
 import { getTranslations } from 'next-intl/server'
 import {
@@ -28,6 +29,7 @@ export default async function TemplatePage() {
   const [
     result,
     inspectionTemplatesResult,
+    markTypesResult,
     invoiceLayoutResult,
     quoteLayoutResult,
     organization,
@@ -75,6 +77,7 @@ export default async function TemplatePage() {
       SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED,
     ]),
     getTemplates(),
+    listMarkTypes(),
     getInvoiceLayoutConfig(),
     getQuoteLayoutConfig(),
     // Read straight off the organization, the way the real PDF does. Going
@@ -165,6 +168,7 @@ export default async function TemplatePage() {
         logoSize: Number(settings[SETTING_KEYS.QUOTE_LOGO_SIZE]) || 100,
       }}
       inspectionTemplates={inspectionTemplates}
+      markTypes={markTypesResult.success && markTypesResult.data ? markTypesResult.data : []}
       checklistLanguage={checklistLanguage.success ? (checklistLanguage.data ?? null) : null}
       smsEnabled={features.sms ?? false}
       initialSmsTemplates={smsTemplates}

+ 50 - 9
src/app/(authenticated)/settings/templates/template-settings.tsx

@@ -27,6 +27,8 @@ import {
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 import { cn } from '@/lib/utils'
 import { TemplateListClient } from '@/features/inspections/Components/TemplateListClient'
+import { MarkTypeSettings } from '@/features/condition-map/Components/MarkTypeSettings'
+import type { MarkType } from '@/features/condition-map/Lib/markTypes'
 import { DesignPreview, PresetPreview } from '@/features/invoice-designer/Components/PresetPreview'
 import type { SavedDesign } from '@/features/invoice-designer/Components/types'
 import {
@@ -48,15 +50,25 @@ interface TemplateValues {
   logoSize: number
 }
 
-type TabType = 'invoice' | 'quotation' | 'workOrders' | 'certificates' | 'inspections' | 'sms'
+type TabType =
+  | 'invoice'
+  | 'quotation'
+  | 'workOrders'
+  | 'certificates'
+  | 'inspections'
+  | 'conditionMap'
+  | 'sms'
 
 type SectionType = 'documents' | 'checklists' | 'messages'
 
 /** The printed documents, in the order their chips run. */
 const DOCUMENT_TABS = ['invoice', 'quotation', 'workOrders', 'certificates'] as const
 
+/** The checklists and what they draw on, in the order their chips run. */
+const CHECKLIST_TABS = ['inspections', 'conditionMap'] as const
+
 function sectionOf(tab: TabType): SectionType {
-  if (tab === 'inspections') return 'checklists'
+  if (tab === 'inspections' || tab === 'conditionMap') return 'checklists'
   if (tab === 'sms') return 'messages'
   return 'documents'
 }
@@ -539,6 +551,7 @@ export function TemplateSettings({
   quoteLayoutConfig,
   savedDesigns = [],
   activeDesigns = { invoice: '', quote: '', certificate: '', work_order: '' },
+  markTypes = [],
 }: {
   initialInvoiceValues: TemplateValues
   initialQuoteValues: TemplateValues
@@ -553,6 +566,8 @@ export function TemplateSettings({
   quoteLayoutConfig?: InvoiceLayoutConfig
   savedDesigns?: SavedDesign[]
   activeDesigns?: { invoice: string; quote: string; certificate: string; work_order: string }
+  /** The workshop's kinds of mark on the condition map. */
+  markTypes?: MarkType[]
 }) {
   const router = useRouter()
   const searchParams = useSearchParams()
@@ -663,6 +678,28 @@ export function TemplateSettings({
           ))}
         </div>
 
+        {section === 'checklists' && (
+          <div role="tablist" className="flex gap-2 overflow-x-auto">
+            {CHECKLIST_TABS.map((item) => (
+              <button
+                key={item}
+                type="button"
+                role="tab"
+                aria-selected={tab === item}
+                onClick={() => setTab(item)}
+                className={cn(
+                  'shrink-0 whitespace-nowrap rounded-full border px-3 py-1 text-sm font-medium transition-colors',
+                  tab === item
+                    ? 'border-foreground bg-foreground text-background'
+                    : 'bg-card text-muted-foreground hover:text-foreground'
+                )}
+              >
+                {t(`templates.tabs.${item}`)}
+              </button>
+            ))}
+          </div>
+        )}
+
         {section === 'documents' && (
           <div role="tablist" className="flex gap-2 overflow-x-auto">
             {DOCUMENT_TABS.map((doc) => (
@@ -689,13 +726,15 @@ export function TemplateSettings({
           <p className="text-sm text-muted-foreground">
             {tab === 'inspections'
               ? t('templates.inspectionsDescription')
-              : tab === 'certificates'
-                ? t('templates.certificatesDescription')
-                : tab === 'workOrders'
-                  ? t('templates.workOrdersDescription')
-                  : tab === 'sms'
-                    ? t('templates.smsDescription')
-                    : t('templates.invoiceDescription')}
+              : tab === 'conditionMap'
+                ? t('templates.conditionMapDescription')
+                : tab === 'certificates'
+                  ? t('templates.certificatesDescription')
+                  : tab === 'workOrders'
+                    ? t('templates.workOrdersDescription')
+                    : tab === 'sms'
+                      ? t('templates.smsDescription')
+                      : t('templates.invoiceDescription')}
           </p>
           {/* Colors live here and arrangement lives there, which is easy to
               get lost in. Each page says where the other half is. */}
@@ -712,6 +751,8 @@ export function TemplateSettings({
 
       {tab === 'inspections' ? (
         <TemplateListClient templates={inspectionTemplates} checklistLanguage={checklistLanguage} />
+      ) : tab === 'conditionMap' ? (
+        <MarkTypeSettings types={markTypes} />
       ) : tab === 'workOrders' ? (
         <ReadOnlyWrapper>
           <TemplateTab

+ 83 - 0
src/app/(public)/portal/[orgId]/invoices/[invoiceId]/certificate/route.ts

@@ -0,0 +1,83 @@
+import { NextResponse } from 'next/server'
+import { headers } from 'next/headers'
+import { db } from '@/lib/db'
+import { rateLimit } from '@/lib/rate-limit'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { getCustomerSession } from '@/lib/customer-session'
+import { resolveCustomerLocale } from '@/i18n/locale-from-request'
+import {
+  certificateDownload,
+  linkedCertificatePdf,
+} from '@/features/inspections/Pdf/customerCertificatePdf'
+
+// Under /portal/... like the invoice PDF beside it, so the customer-session
+// cookie scoped to "/portal" is sent.
+
+/** The certificate of the inspection linked to one of the customer's invoices. */
+export async function GET(
+  request: Request,
+  { params }: { params: Promise<{ orgId: string; invoiceId: string }> }
+) {
+  const limited = rateLimit(request, { limit: 20, windowMs: 60_000 })
+  if (limited) return limited
+
+  try {
+    const { orgId: orgParam, invoiceId } = await params
+
+    const session = await getCustomerSession()
+    if (!session) {
+      return NextResponse.redirect(new URL(`/portal/${orgParam}/auth/login`, request.url), 302)
+    }
+
+    const org = await resolvePortalOrg(orgParam)
+    if (!org) {
+      return NextResponse.json({ error: 'Not found' }, { status: 404 })
+    }
+    if (org.id !== session.organizationId) {
+      return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+    }
+
+    const record = await db.serviceRecord.findUnique({
+      where: { id: invoiceId },
+      select: {
+        status: true,
+        customerId: true,
+        organizationId: true,
+        inspectionId: true,
+        vehicle: { select: { customerId: true } },
+      },
+    })
+    if (!record) {
+      return NextResponse.json({ error: 'Not found' }, { status: 404 })
+    }
+
+    const recordCustomerId = record.customerId ?? record.vehicle?.customerId ?? null
+    if (
+      recordCustomerId !== session.customerId ||
+      record.organizationId !== session.organizationId
+    ) {
+      return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+    }
+    // The same rule as the invoice itself: only a finished job is an invoice.
+    if (record.status !== 'completed') {
+      return NextResponse.json({ error: 'Not found' }, { status: 404 })
+    }
+
+    const locale = await resolveCustomerLocale(
+      session.organizationId,
+      (await headers()).get('accept-language')
+    )
+    const certificate = await linkedCertificatePdf(
+      session.organizationId,
+      record.inspectionId,
+      locale
+    )
+    if (!certificate) {
+      return NextResponse.json({ error: 'Not found' }, { status: 404 })
+    }
+    return certificateDownload(certificate)
+  } catch (error) {
+    console.error('[Portal invoice certificate] Error:', error)
+    return NextResponse.json({ error: 'Failed to generate PDF' }, { status: 500 })
+  }
+}

+ 12 - 2
src/app/(public)/portal/[orgId]/invoices/[invoiceId]/page.tsx

@@ -1,4 +1,4 @@
-import { ArrowLeft, Download, ExternalLink } from 'lucide-react'
+import { ArrowLeft, Download, ExternalLink, FileCheck2 } from 'lucide-react'
 import Link from 'next/link'
 import { notFound } from 'next/navigation'
 import { getTranslations } from 'next-intl/server'
@@ -50,7 +50,7 @@ export default async function PortalInvoicePage({
               {invoice.invoiceNumber ? `${t('invoice')} ${invoice.invoiceNumber}` : invoice.title}
             </h1>
           </div>
-          <div className="flex items-center gap-4">
+          <div className="flex flex-wrap items-center gap-4">
             {invoice.publicToken && (
               <Link
                 href={`/share/invoice/${orgId}/${invoice.publicToken}`}
@@ -68,6 +68,16 @@ export default async function PortalInvoicePage({
               <Download className="h-4 w-4" />
               {t('download')}
             </a>
+            {invoice.hasCertificate && (
+              <a
+                href={`/portal/${orgId}/invoices/${invoice.id}/certificate`}
+                download
+                className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
+              >
+                <FileCheck2 className="h-4 w-4" />
+                {t('downloadCertificate')}
+              </a>
+            )}
           </div>
         </div>
 

+ 14 - 3
src/app/(public)/share/inspection/[orgId]/[token]/page.tsx

@@ -12,8 +12,11 @@ import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { templateConfigFromSource } from '@/features/invoice-designer/Lib/designSource'
 import type { DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
 import { buildCertificatePrintSpec } from '@/features/inspections/Pdf/buildCertificatePrint'
-import { loadVehicleConditionMarks } from '@/features/condition-map/Actions/conditionMarkActions'
-import { loadConditionMapLabels } from '@/features/condition-map/Lib/labels'
+import type { MarkTypeRow } from '@/features/condition-map/Lib/markTypes'
+import {
+  conditionMapLabelsFor,
+  loadVehicleConditionMarks,
+} from '@/features/condition-map/Lib/loadMarks.server'
 import { certificateSignatureDataUri } from '@/features/signatures/Lib/memberSignature.server'
 import {
   certificateDesignSource,
@@ -178,7 +181,15 @@ export default async function PublicInspectionPage({
       },
       conditionMarks: hasMap ? await loadVehicleConditionMarks(orgId, inspection.vehicleId) : [],
       bodyType: hasMap ? inspection.vehicle.bodyType : null,
-      conditionMapLabels: hasMap ? await loadConditionMapLabels(locale) : undefined,
+      conditionMapLabels: hasMap
+        ? await conditionMapLabelsFor(
+            orgId,
+            locale,
+            Array.isArray(inspection.markTypesSnapshot)
+              ? (inspection.markTypesSnapshot as unknown as MarkTypeRow[])
+              : null
+          )
+        : undefined,
       workshop: { ...workshop, slogan: settingsMap['workshop.slogan'] || undefined },
       labels,
       logoDataUri: toPublic(documentLogoPath(settingsMap, 'certificate')) || undefined,

Неке датотеке нису приказане због велике количине промена