Kaynağa Gözat

Staging fixes: mark photos through the file manager, desk video recorder, inspection mark kinds, sent-invoice confirm, Regulatory certificate by default (#436)

Bernt Christian Egeland 1 ay önce
ebeveyn
işleme
e21af37d38
33 değiştirilmiş dosya ile 1068 ekleme ve 54 silme
  1. 59 0
      e2e/specs/condition-map/dropoff.spec.ts
  2. 34 6
      e2e/specs/condition-map/inspection.spec.ts
  3. 16 0
      e2e/specs/condition-map/printing.spec.ts
  4. 78 0
      e2e/specs/status-reports/recorder.spec.ts
  5. 19 1
      messages/de/common.json
  6. 19 1
      messages/en/common.json
  7. 19 1
      messages/es/common.json
  8. 19 1
      messages/fr/common.json
  9. 19 1
      messages/it/common.json
  10. 19 1
      messages/lt/common.json
  11. 19 1
      messages/nb/common.json
  12. 19 1
      messages/nl/common.json
  13. 19 1
      messages/pl/common.json
  14. 19 1
      messages/pt-BR/common.json
  15. 19 1
      messages/pt-PT/common.json
  16. 19 1
      messages/ru/common.json
  17. 19 1
      messages/tr/common.json
  18. 82 0
      src/__tests__/features/inspections/certificate-design-default.test.ts
  19. 7 1
      src/__tests__/multitenancy/inspection-isolation.test.ts
  20. 6 0
      src/__tests__/multitenancy/quote-inspection-isolation.test.ts
  21. 0 1
      src/app/(authenticated)/inspections/[id]/page.tsx
  22. 33 3
      src/app/(designer)/invoice-designer/page.tsx
  23. 42 0
      src/components/sent-copy-note.tsx
  24. 281 0
      src/components/video-recorder-dialog.tsx
  25. 41 1
      src/features/condition-map/Actions/conditionMarkActions.ts
  26. 3 0
      src/features/condition-map/Components/ConditionMapCard.tsx
  27. 19 14
      src/features/condition-map/Components/MarkEditor.tsx
  28. 3 6
      src/features/inspections/Actions/inspectionActions.ts
  29. 22 0
      src/features/inspections/Lib/defaultCertificateDesign.ts
  30. 38 6
      src/features/inspections/Pdf/certificateDesign.ts
  31. 13 1
      src/features/status-reports/Components/CreateStatusReportDialog.tsx
  32. 19 2
      src/features/vehicles/Components/service-page/modern/FilesMediaCard.tsx
  33. 25 0
      src/hooks/use-native-camera.ts

+ 59 - 0
e2e/specs/condition-map/dropoff.spec.ts

@@ -1,3 +1,5 @@
+import { existsSync } from 'node:fs'
+import path from 'node:path'
 import { expect, test } from '@playwright/test'
 import { expect, test } from '@playwright/test'
 import {
 import {
   addMark,
   addMark,
@@ -17,6 +19,7 @@ import {
   removeOwnMarkKind,
   removeOwnMarkKind,
   userIdFor,
   userIdFor,
 } from '../../support/db'
 } from '../../support/db'
+import { sharesDiskWithServer } from '../../support/files'
 import { TINY_PNG } from '../../support/pdf'
 import { TINY_PNG } from '../../support/pdf'
 
 
 /**
 /**
@@ -238,3 +241,59 @@ test('a dozen photos at once all reach the mark', async ({ page }) => {
   const [row] = await conditionMarksOf(job.vehicleId)
   const [row] = await conditionMarksOf(job.vehicleId)
   expect(row.imageUrls).toHaveLength(12)
   expect(row.imageUrls).toHaveLength(12)
 })
 })
+
+test.describe('the photos on a mark, on disk', () => {
+  /** Where the server keeps an uploaded file, from the URL a row stores. */
+  const diskPath = (url: string) =>
+    path.join('data', 'uploads', url.replace('/api/protected/files/', ''))
+
+  async function markWithPhotos(
+    page: import('@playwright/test').Page,
+    label: string,
+    count: number
+  ) {
+    const job = await plantConditionJob(organizationId, userId, `${label} ${stamp}`)
+    const map = await openDropoff(page, jobUrl(job))
+    const editor = await addMark(page, map, 'left', 'left_front_door')
+    await editor.locator('input[type="file"][multiple]').setInputFiles(
+      Array.from({ length: count }, (_, i) => ({
+        name: `e2e-disk-${stamp}-${i}.png`,
+        mimeType: 'image/png',
+        buffer: TINY_PNG,
+      }))
+    )
+    await expect(editor.getByRole('img', { name: /^Photos of mark 1/ })).toHaveCount(count, {
+      timeout: 30_000,
+    })
+    const [row] = await conditionMarksOf(job.vehicleId)
+    for (const url of row.imageUrls)
+      expect(existsSync(diskPath(url)), `${url} was written`).toBe(true)
+    return { job, map, editor, urls: row.imageUrls }
+  }
+
+  test('a photo taken off a mark leaves the disk', async ({ page }) => {
+    test.skip(!sharesDiskWithServer(), 'the server’s uploads are not on this disk')
+    const { job, editor, urls } = await markWithPhotos(page, 'E2E photo off', 2)
+
+    await editor.getByRole('button', { name: 'Remove photo 1' }).click()
+    await expect(editor.getByRole('img', { name: /^Photos of mark 1/ })).toHaveCount(1)
+    const [row] = await conditionMarksOf(job.vehicleId)
+    const removed = urls.filter((url) => !row.imageUrls.includes(url))
+    expect(removed).toHaveLength(1)
+    await expect.poll(() => existsSync(diskPath(removed[0]))).toBe(false)
+    // The one still on the mark is untouched.
+    expect(existsSync(diskPath(row.imageUrls[0]))).toBe(true)
+  })
+
+  test('a mark removed takes its photos off the disk', async ({ page }) => {
+    test.skip(!sharesDiskWithServer(), 'the server’s uploads are not on this disk')
+    const { job, editor, urls } = await markWithPhotos(page, 'E2E mark off', 2)
+
+    await editor.getByRole('button', { name: 'Remove mark' }).click()
+    await expect(editor).toBeHidden()
+    expect(await conditionMarksOf(job.vehicleId)).toHaveLength(0)
+    for (const url of urls) {
+      await expect.poll(() => existsSync(diskPath(url)), `${url} left the disk`).toBe(false)
+    }
+  })
+})

+ 34 - 6
e2e/specs/condition-map/inspection.spec.ts

@@ -20,6 +20,7 @@ import {
   plantJobOnVehicle,
   plantJobOnVehicle,
   setWorkshopSetting,
   setWorkshopSetting,
   userIdFor,
   userIdFor,
+  workshopSetting,
 } from '../../support/db'
 } from '../../support/db'
 import { settle } from '../../support/hydration'
 import { settle } from '../../support/hydration'
 
 
@@ -99,7 +100,12 @@ test('a mark drawn on the inspection is on the linked job, where it cannot be ch
   })
   })
 
 
   const map = await openInspection(page, inspection.inspectionId)
   const map = await openInspection(page, inspection.inspectionId)
-  await addMark(page, map, 'right', 'right_front_door')
+  const editor = await addMark(page, map, 'right', 'right_front_door')
+  // The inspection offers the workshop's kinds, and names the mark by them.
+  await expect(editor.getByRole('heading')).toContainText('Dent on the Right front door')
+  const kinds = editor.getByRole('group', { name: 'Type' }).getByRole('button')
+  await expect(kinds).toHaveCount(8)
+  await expect(kinds.filter({ hasText: 'Scratch' })).toHaveCount(1)
   await closeEditor(page)
   await closeEditor(page)
   await expect(ownMarks(map)).toHaveCount(1)
   await expect(ownMarks(map)).toHaveCount(1)
 
 
@@ -115,12 +121,12 @@ test('a mark drawn on the inspection is on the linked job, where it cannot be ch
   await expect(ownMarks(onJob)).toHaveCount(1)
   await expect(ownMarks(onJob)).toHaveCount(1)
   await expect(onJob).toContainText('From the linked inspection, changed there')
   await expect(onJob).toContainText('From the linked inspection, changed there')
   await ownMarks(onJob).first().click()
   await ownMarks(onJob).first().click()
-  const editor = markEditor(page)
-  await expect(editor.getByRole('button', { name: 'Scratch' })).toBeDisabled()
-  await expect(editor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
+  const onJobEditor = markEditor(page)
+  await expect(onJobEditor.getByRole('button', { name: 'Scratch' })).toBeDisabled()
+  await expect(onJobEditor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
   // And says where it can be changed, rather than asking to reopen the job.
   // And says where it can be changed, rather than asking to reopen the job.
-  await expect(editor).toContainText('Drawn on the linked inspection. Change it there.')
-  await expect(editor).not.toContainText('Reopen to change the condition map')
+  await expect(onJobEditor).toContainText('Drawn on the linked inspection. Change it there.')
+  await expect(onJobEditor).not.toContainText('Reopen to change the condition map')
 })
 })
 
 
 test('a job’s drop-off mark is grey on the inspection', async ({ page }) => {
 test('a job’s drop-off mark is grey on the inspection', async ({ page }) => {
@@ -207,6 +213,28 @@ test.describe('the certificate', () => {
     expect(pdf.flat).toContain('Dent by the handle')
     expect(pdf.flat).toContain('Dent by the handle')
   })
   })
 
 
+  test('prints from the default Regulatory design when the workshop designed none', async ({
+    page,
+  }) => {
+    const job = await plantConditionJob(organizationId, userId, `E2E cert default ${stamp}`)
+    const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`)
+    await plantConditionMark(organizationId, job.vehicleId, inspection, {
+      view: 'left',
+      panel: 'left_front_door',
+    })
+    // No certificate design at all: before, this printed the built-in sheet,
+    // which has no condition map.
+    const planted = await workshopSetting(organizationId, DESIGN_KEY)
+    await forgetWorkshopSetting(organizationId, DESIGN_KEY)
+    try {
+      const pdf = await certificatePdf(page, inspection.inspectionId)
+      expect(printsMap(pdf), 'the designed certificate prints the map').toBe(true)
+      expect(pdf.flat).toContain('Left front door')
+    } finally {
+      if (planted) await setWorkshopSetting(organizationId, DESIGN_KEY, planted)
+    }
+  })
+
   test('prints no map when the inspection recorded no mark of its own', async ({ page }) => {
   test('prints no map when the inspection recorded no mark of its own', async ({ page }) => {
     const job = await plantConditionJob(organizationId, userId, `E2E cert bare ${stamp}`)
     const job = await plantConditionJob(organizationId, userId, `E2E cert bare ${stamp}`)
     await plantConditionMark(
     await plantConditionMark(

+ 16 - 0
e2e/specs/condition-map/printing.spec.ts

@@ -196,5 +196,21 @@ test.describe('the invoice', () => {
     await expect(page.getByTestId('sent-copy-note')).toContainText(
     await expect(page.getByTestId('sent-copy-note')).toContainText(
       'Changes here go on the invoice when it is sent again'
       'Changes here go on the invoice when it is sent again'
     )
     )
+
+    // Unticked on the sent invoice, it asks first: the sent copy keeps its map.
+    const asked = page.getByRole('alertdialog', { name: 'This invoice has already been sent' })
+    await onInvoice.click()
+    await expect(asked).toBeVisible()
+    await asked.getByRole('button', { name: 'Cancel' }).click()
+    await expect(asked).toBeHidden()
+    await expect(onInvoice).toBeChecked()
+    expect(await conditionMapOnInvoice(job.serviceRecordId)).toBe(true)
+
+    await onInvoice.click()
+    await asked.getByRole('button', { name: 'Save for the next send' }).click()
+    await expect(onInvoice).not.toBeChecked()
+    await expect.poll(() => conditionMapOnInvoice(job.serviceRecordId)).toBe(false)
+    // Saved for the job; the invoice the customer holds still has its map.
+    expect(printsMap(await invoicePdf(page, job.serviceRecordId))).toBe(true)
   })
   })
 })
 })

+ 78 - 0
e2e/specs/status-reports/recorder.spec.ts

@@ -0,0 +1,78 @@
+import { expect, test } from '@playwright/test'
+import { addMark, openDropoff } from '../../support/condition-map'
+import { ownerOrganizationId, plantConditionJob, userIdFor } from '../../support/db'
+import { settle } from '../../support/hydration'
+
+/**
+ * Recording a video at the desk. A phone's "Record" opens its camera app; a
+ * desk computer has none to open, so the page records itself, and the take is
+ * used exactly as an uploaded file would be.
+ *
+ * Chromium is handed a fake camera and microphone, and the permission is
+ * granted up front, so the recorder runs as it would for a person who said yes.
+ */
+
+test.use({
+  launchOptions: {
+    args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
+  },
+  permissions: ['camera', 'microphone'],
+})
+
+const stamp = Date.now()
+
+test('Record on a desk computer opens the recorder, and the take becomes the report’s video', async ({
+  page,
+}) => {
+  const organizationId = await ownerOrganizationId()
+  const userId = await userIdFor('demo@torqvoice.com')
+  const job = await plantConditionJob(organizationId, userId, `E2E recorder ${stamp}`)
+  await page.goto(`/vehicles/${job.vehicleId}/service/${job.serviceRecordId}?tab=statusReports`)
+  await settle(page)
+
+  await expect(async () => {
+    await page.getByRole('button', { name: 'New Status Report' }).first().click()
+    await expect(page.getByRole('dialog', { name: 'Status Report' })).toBeVisible({
+      timeout: 2_000,
+    })
+  }).toPass({ timeout: 30_000 })
+  const report = page.getByRole('dialog', { name: 'Status Report' })
+
+  // No file picker: the page's own recorder.
+  let picked = false
+  page.on('filechooser', () => {
+    picked = true
+  })
+  await report.getByRole('button', { name: 'Record', exact: true }).click()
+  const recorder = page.getByTestId('video-recorder')
+  await expect(recorder).toBeVisible()
+  expect(picked, 'a desk computer is not sent to the file picker').toBe(false)
+
+  const start = recorder.getByRole('button', { name: 'Start recording' })
+  await expect(start).toBeEnabled({ timeout: 15_000 })
+  await start.click()
+  await expect(recorder).toContainText('Recording 0:02', { timeout: 10_000 })
+  await recorder.getByRole('button', { name: 'Stop' }).click()
+
+  // Watched back before it is used.
+  await expect(recorder.locator('video[controls]')).toBeVisible()
+  await recorder.getByRole('button', { name: 'Use this video' }).click()
+  await expect(recorder).toBeHidden()
+  await expect(report).toContainText(/recording-\d+\.webm/, { timeout: 30_000 })
+
+  await report.getByLabel('Title').fill(`E2E recorded ${stamp}`)
+  await report.getByRole('button', { name: 'Create', exact: true }).click()
+  await expect(page.getByText('Status report created')).toBeVisible()
+})
+
+test('Take photo is not offered on a desk computer, where it would only open the file picker', async ({
+  page,
+}) => {
+  const organizationId = await ownerOrganizationId()
+  const userId = await userIdFor('demo@torqvoice.com')
+  const job = await plantConditionJob(organizationId, userId, `E2E take photo ${stamp}`)
+  const map = await openDropoff(page, `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`)
+  const editor = await addMark(page, map, 'left', 'left_front_door')
+  await expect(editor.getByRole('button', { name: 'Add photo' })).toBeVisible()
+  await expect(editor.getByRole('button', { name: 'Take photo' })).toHaveCount(0)
+})

+ 19 - 1
messages/de/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "PDF anhängen",
   "attachPdf": "PDF anhängen",
   "attachPdfHint": "Ausschalten, um stattdessen einen Link zu senden, damit Sie sehen, wann der Kunde ihn öffnet.",
   "attachPdfHint": "Ausschalten, um stattdessen einen Link zu senden, damit Sie sehen, wann der Kunde ihn öffnet.",
   "sentCopy": {
   "sentCopy": {
-    "note": "Gesendet am {date}. Änderungen hier kommen auf die Rechnung, wenn sie erneut gesendet wird."
+    "note": "Gesendet am {date}. Änderungen hier kommen auf die Rechnung, wenn sie erneut gesendet wird.",
+    "confirmTitle": "Diese Rechnung wurde bereits gesendet",
+    "confirmBody": "Die Kopie des Kunden bleibt so, wie sie gesendet wurde. Ihre Änderung wird am Auftrag gespeichert und kommt auf die Rechnung, wenn sie wieder geöffnet und erneut gesendet wird.",
+    "confirm": "Für den nächsten Versand speichern",
+    "cancel": "Abbrechen"
+  },
+  "videoRecorder": {
+    "title": "Video aufnehmen",
+    "starting": "Kamera wird gestartet...",
+    "denied": "Der Browser durfte Kamera oder Mikrofon nicht verwenden. Erlauben Sie beides für diese Seite über die Adressleiste des Browsers und versuchen Sie es erneut.",
+    "noCamera": "Auf diesem Computer wurde keine Kamera gefunden.",
+    "unsupported": "Dieser Browser kann kein Video aufnehmen. Laden Sie stattdessen eine Datei hoch.",
+    "camera": "Kamera",
+    "start": "Aufnahme starten",
+    "stop": "Stopp",
+    "recording": "Aufnahme läuft",
+    "retake": "Neu aufnehmen",
+    "use": "Dieses Video verwenden",
+    "limit": "Die Aufnahme endet automatisch nach {minutes} Minuten."
   }
   }
 }
 }

+ 19 - 1
messages/en/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "Attach PDF",
   "attachPdf": "Attach PDF",
   "attachPdfHint": "Turn this off to send a link instead, so you can see when the customer opens it.",
   "attachPdfHint": "Turn this off to send a link instead, so you can see when the customer opens it.",
   "sentCopy": {
   "sentCopy": {
-    "note": "Sent {date}. Changes here go on the invoice when it is sent again."
+    "note": "Sent {date}. Changes here go on the invoice when it is sent again.",
+    "confirmTitle": "This invoice has already been sent",
+    "confirmBody": "The customer's copy keeps what it was sent with. Your change is saved on the job and goes on the invoice when it is reopened and sent again.",
+    "confirm": "Save for the next send",
+    "cancel": "Cancel"
+  },
+  "videoRecorder": {
+    "title": "Record a video",
+    "starting": "Starting the camera...",
+    "denied": "The browser was not allowed to use the camera or microphone. Allow them for this site from the browser's address bar, then try again.",
+    "noCamera": "No camera was found on this computer.",
+    "unsupported": "This browser cannot record video. Upload a file instead.",
+    "camera": "Camera",
+    "start": "Start recording",
+    "stop": "Stop",
+    "recording": "Recording",
+    "retake": "Record again",
+    "use": "Use this video",
+    "limit": "Recording stops by itself after {minutes} minutes."
   }
   }
 }
 }

+ 19 - 1
messages/es/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "Adjuntar PDF",
   "attachPdf": "Adjuntar PDF",
   "attachPdfHint": "Desactívalo para enviar un enlace en su lugar y ver cuándo lo abre el cliente.",
   "attachPdfHint": "Desactívalo para enviar un enlace en su lugar y ver cuándo lo abre el cliente.",
   "sentCopy": {
   "sentCopy": {
-    "note": "Enviada el {date}. Los cambios hechos aquí pasan a la factura cuando se vuelva a enviar."
+    "note": "Enviada el {date}. Los cambios hechos aquí pasan a la factura cuando se vuelva a enviar.",
+    "confirmTitle": "Esta factura ya se ha enviado",
+    "confirmBody": "La copia del cliente se queda como se envió. Su cambio se guarda en el trabajo y pasa a la factura cuando se reabra y se vuelva a enviar.",
+    "confirm": "Guardar para el próximo envío",
+    "cancel": "Cancelar"
+  },
+  "videoRecorder": {
+    "title": "Grabar un vídeo",
+    "starting": "Iniciando la cámara...",
+    "denied": "El navegador no tiene permiso para usar la cámara o el micrófono. Permítalos para este sitio desde la barra de direcciones del navegador y vuelva a intentarlo.",
+    "noCamera": "No se encontró ninguna cámara en este ordenador.",
+    "unsupported": "Este navegador no puede grabar vídeo. Suba un archivo en su lugar.",
+    "camera": "Cámara",
+    "start": "Empezar a grabar",
+    "stop": "Detener",
+    "recording": "Grabando",
+    "retake": "Volver a grabar",
+    "use": "Usar este vídeo",
+    "limit": "La grabación se detiene sola a los {minutes} minutos."
   }
   }
 }
 }

+ 19 - 1
messages/fr/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "Joindre le PDF",
   "attachPdf": "Joindre le PDF",
   "attachPdfHint": "Désactivez pour envoyer un lien à la place, afin de voir quand le client l'ouvre.",
   "attachPdfHint": "Désactivez pour envoyer un lien à la place, afin de voir quand le client l'ouvre.",
   "sentCopy": {
   "sentCopy": {
-    "note": "Envoyée le {date}. Les modifications faites ici passent sur la facture lorsqu'elle est renvoyée."
+    "note": "Envoyée le {date}. Les modifications faites ici passent sur la facture lorsqu'elle est renvoyée.",
+    "confirmTitle": "Cette facture a déjà été envoyée",
+    "confirmBody": "La copie du client reste telle qu'elle a été envoyée. Votre modification est enregistrée sur le travail et passe sur la facture lorsqu'elle est rouverte et renvoyée.",
+    "confirm": "Enregistrer pour le prochain envoi",
+    "cancel": "Annuler"
+  },
+  "videoRecorder": {
+    "title": "Enregistrer une vidéo",
+    "starting": "Démarrage de la caméra...",
+    "denied": "Le navigateur n'a pas été autorisé à utiliser la caméra ou le micro. Autorisez-les pour ce site depuis la barre d'adresse du navigateur, puis réessayez.",
+    "noCamera": "Aucune caméra n'a été trouvée sur cet ordinateur.",
+    "unsupported": "Ce navigateur ne peut pas enregistrer de vidéo. Importez plutôt un fichier.",
+    "camera": "Caméra",
+    "start": "Commencer l'enregistrement",
+    "stop": "Arrêter",
+    "recording": "Enregistrement",
+    "retake": "Recommencer",
+    "use": "Utiliser cette vidéo",
+    "limit": "L'enregistrement s'arrête tout seul après {minutes} minutes."
   }
   }
 }
 }

+ 19 - 1
messages/it/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "Allega PDF",
   "attachPdf": "Allega PDF",
   "attachPdfHint": "Disattiva per inviare invece un link, così vedi quando il cliente lo apre.",
   "attachPdfHint": "Disattiva per inviare invece un link, così vedi quando il cliente lo apre.",
   "sentCopy": {
   "sentCopy": {
-    "note": "Inviata il {date}. Le modifiche fatte qui entrano nella fattura quando viene inviata di nuovo."
+    "note": "Inviata il {date}. Le modifiche fatte qui entrano nella fattura quando viene inviata di nuovo.",
+    "confirmTitle": "Questa fattura è già stata inviata",
+    "confirmBody": "La copia del cliente resta come è stata inviata. La modifica viene salvata sul lavoro ed entra nella fattura quando viene riaperta e inviata di nuovo.",
+    "confirm": "Salva per il prossimo invio",
+    "cancel": "Annulla"
+  },
+  "videoRecorder": {
+    "title": "Registra un video",
+    "starting": "Avvio della fotocamera...",
+    "denied": "Il browser non ha il permesso di usare fotocamera o microfono. Consentili per questo sito dalla barra degli indirizzi del browser, poi riprova.",
+    "noCamera": "Nessuna fotocamera trovata su questo computer.",
+    "unsupported": "Questo browser non può registrare video. Carica invece un file.",
+    "camera": "Fotocamera",
+    "start": "Avvia registrazione",
+    "stop": "Ferma",
+    "recording": "Registrazione in corso",
+    "retake": "Registra di nuovo",
+    "use": "Usa questo video",
+    "limit": "La registrazione si ferma da sola dopo {minutes} minuti."
   }
   }
 }
 }

+ 19 - 1
messages/lt/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "Pridėti PDF",
   "attachPdf": "Pridėti PDF",
   "attachPdfHint": "Išjunkite, kad būtų siunčiama nuoroda, tada matysite, kada klientas ją atidarė.",
   "attachPdfHint": "Išjunkite, kad būtų siunčiama nuoroda, tada matysite, kada klientas ją atidarė.",
   "sentCopy": {
   "sentCopy": {
-    "note": "Išsiųsta {date}. Čia atlikti pakeitimai pateks į sąskaitą, kai ji bus išsiųsta dar kartą."
+    "note": "Išsiųsta {date}. Čia atlikti pakeitimai pateks į sąskaitą, kai ji bus išsiųsta dar kartą.",
+    "confirmTitle": "Ši sąskaita jau išsiųsta",
+    "confirmBody": "Kliento kopija lieka tokia, kokia buvo išsiųsta. Jūsų pakeitimas išsaugomas darbe ir pateks į sąskaitą, kai ji bus vėl atidaryta ir išsiųsta iš naujo.",
+    "confirm": "Išsaugoti kitam siuntimui",
+    "cancel": "Atšaukti"
+  },
+  "videoRecorder": {
+    "title": "Įrašyti vaizdo įrašą",
+    "starting": "Paleidžiama kamera...",
+    "denied": "Naršyklei neleista naudoti kameros ar mikrofono. Leiskite juos šiai svetainei naršyklės adreso juostoje ir bandykite dar kartą.",
+    "noCamera": "Šiame kompiuteryje kameros nerasta.",
+    "unsupported": "Ši naršyklė negali įrašyti vaizdo. Vietoj to įkelkite failą.",
+    "camera": "Kamera",
+    "start": "Pradėti įrašymą",
+    "stop": "Stabdyti",
+    "recording": "Įrašoma",
+    "retake": "Įrašyti iš naujo",
+    "use": "Naudoti šį įrašą",
+    "limit": "Įrašymas sustoja savaime po {minutes} min."
   }
   }
 }
 }

+ 19 - 1
messages/nb/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "Legg ved PDF",
   "attachPdf": "Legg ved PDF",
   "attachPdfHint": "Slå av for å sende en lenke i stedet, så ser du når kunden åpner den.",
   "attachPdfHint": "Slå av for å sende en lenke i stedet, så ser du når kunden åpner den.",
   "sentCopy": {
   "sentCopy": {
-    "note": "Sendt {date}. Endringer her kommer på fakturaen når den sendes på nytt."
+    "note": "Sendt {date}. Endringer her kommer på fakturaen når den sendes på nytt.",
+    "confirmTitle": "Denne fakturaen er allerede sendt",
+    "confirmBody": "Kundens kopi forblir slik den ble sendt. Endringen lagres på jobben og kommer på fakturaen når den åpnes igjen og sendes på nytt.",
+    "confirm": "Lagre til neste sending",
+    "cancel": "Avbryt"
+  },
+  "videoRecorder": {
+    "title": "Ta opp en video",
+    "starting": "Starter kameraet...",
+    "denied": "Nettleseren fikk ikke lov til å bruke kameraet eller mikrofonen. Tillat dem for dette nettstedet fra adressefeltet i nettleseren, og prøv igjen.",
+    "noCamera": "Fant ikke noe kamera på denne datamaskinen.",
+    "unsupported": "Denne nettleseren kan ikke ta opp video. Last opp en fil i stedet.",
+    "camera": "Kamera",
+    "start": "Start opptak",
+    "stop": "Stopp",
+    "recording": "Tar opp",
+    "retake": "Ta opp på nytt",
+    "use": "Bruk denne videoen",
+    "limit": "Opptaket stopper av seg selv etter {minutes} minutter."
   }
   }
 }
 }

+ 19 - 1
messages/nl/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "Pdf toevoegen",
   "attachPdf": "Pdf toevoegen",
   "attachPdfHint": "Zet dit uit om in plaats daarvan een link te sturen, zodat je ziet wanneer de klant hem opent.",
   "attachPdfHint": "Zet dit uit om in plaats daarvan een link te sturen, zodat je ziet wanneer de klant hem opent.",
   "sentCopy": {
   "sentCopy": {
-    "note": "Verzonden op {date}. Wijzigingen hier komen op de factuur wanneer die opnieuw wordt verzonden."
+    "note": "Verzonden op {date}. Wijzigingen hier komen op de factuur wanneer die opnieuw wordt verzonden.",
+    "confirmTitle": "Deze factuur is al verzonden",
+    "confirmBody": "De kopie van de klant blijft zoals die is verzonden. Uw wijziging wordt op de opdracht bewaard en komt op de factuur wanneer die wordt heropend en opnieuw verzonden.",
+    "confirm": "Bewaren voor de volgende verzending",
+    "cancel": "Annuleren"
+  },
+  "videoRecorder": {
+    "title": "Een video opnemen",
+    "starting": "Camera wordt gestart...",
+    "denied": "De browser mocht de camera of microfoon niet gebruiken. Sta ze voor deze site toe via de adresbalk van de browser en probeer het opnieuw.",
+    "noCamera": "Er is geen camera gevonden op deze computer.",
+    "unsupported": "Deze browser kan geen video opnemen. Upload in plaats daarvan een bestand.",
+    "camera": "Camera",
+    "start": "Opname starten",
+    "stop": "Stoppen",
+    "recording": "Bezig met opnemen",
+    "retake": "Opnieuw opnemen",
+    "use": "Deze video gebruiken",
+    "limit": "De opname stopt vanzelf na {minutes} minuten."
   }
   }
 }
 }

+ 19 - 1
messages/pl/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "Dołącz PDF",
   "attachPdf": "Dołącz PDF",
   "attachPdfHint": "Wyłącz, aby wysłać zamiast tego link i widzieć, kiedy klient go otworzy.",
   "attachPdfHint": "Wyłącz, aby wysłać zamiast tego link i widzieć, kiedy klient go otworzy.",
   "sentCopy": {
   "sentCopy": {
-    "note": "Wysłano {date}. Zmiany wprowadzone tutaj trafią na fakturę, gdy zostanie wysłana ponownie."
+    "note": "Wysłano {date}. Zmiany wprowadzone tutaj trafią na fakturę, gdy zostanie wysłana ponownie.",
+    "confirmTitle": "Ta faktura została już wysłana",
+    "confirmBody": "Kopia klienta pozostaje taka, jaką wysłano. Zmiana zostanie zapisana w zleceniu i trafi na fakturę, gdy zostanie ona ponownie otwarta i wysłana.",
+    "confirm": "Zapisz na następną wysyłkę",
+    "cancel": "Anuluj"
+  },
+  "videoRecorder": {
+    "title": "Nagraj wideo",
+    "starting": "Uruchamianie kamery...",
+    "denied": "Przeglądarka nie ma zgody na użycie kamery lub mikrofonu. Zezwól na nie dla tej witryny na pasku adresu przeglądarki i spróbuj ponownie.",
+    "noCamera": "Na tym komputerze nie znaleziono kamery.",
+    "unsupported": "Ta przeglądarka nie może nagrywać wideo. Prześlij plik.",
+    "camera": "Kamera",
+    "start": "Rozpocznij nagrywanie",
+    "stop": "Zatrzymaj",
+    "recording": "Nagrywanie",
+    "retake": "Nagraj ponownie",
+    "use": "Użyj tego nagrania",
+    "limit": "Nagrywanie zatrzyma się samo po {minutes} min."
   }
   }
 }
 }

+ 19 - 1
messages/pt-BR/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "Anexar PDF",
   "attachPdf": "Anexar PDF",
   "attachPdfHint": "Desligue para enviar um link, assim você vê quando o cliente abre.",
   "attachPdfHint": "Desligue para enviar um link, assim você vê quando o cliente abre.",
   "sentCopy": {
   "sentCopy": {
-    "note": "Enviada em {date}. As alterações feitas aqui entram na fatura quando ela for enviada novamente."
+    "note": "Enviada em {date}. As alterações feitas aqui entram na fatura quando ela for enviada novamente.",
+    "confirmTitle": "Esta fatura já foi enviada",
+    "confirmBody": "A cópia do cliente fica como foi enviada. Sua alteração é salva no serviço e entra na fatura quando ela for reaberta e enviada novamente.",
+    "confirm": "Salvar para o próximo envio",
+    "cancel": "Cancelar"
+  },
+  "videoRecorder": {
+    "title": "Gravar um vídeo",
+    "starting": "Iniciando a câmera...",
+    "denied": "O navegador não tem permissão para usar a câmera ou o microfone. Permita-os para este site na barra de endereços do navegador e tente novamente.",
+    "noCamera": "Nenhuma câmera foi encontrada neste computador.",
+    "unsupported": "Este navegador não consegue gravar vídeo. Envie um arquivo.",
+    "camera": "Câmera",
+    "start": "Iniciar gravação",
+    "stop": "Parar",
+    "recording": "Gravando",
+    "retake": "Gravar novamente",
+    "use": "Usar este vídeo",
+    "limit": "A gravação para sozinha após {minutes} minutos."
   }
   }
 }
 }

+ 19 - 1
messages/pt-PT/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "Anexar PDF",
   "attachPdf": "Anexar PDF",
   "attachPdfHint": "Desative para enviar um link em vez do anexo e assim saber quando o cliente o abre.",
   "attachPdfHint": "Desative para enviar um link em vez do anexo e assim saber quando o cliente o abre.",
   "sentCopy": {
   "sentCopy": {
-    "note": "Enviada a {date}. As alterações feitas aqui entram na fatura quando for enviada novamente."
+    "note": "Enviada a {date}. As alterações feitas aqui entram na fatura quando for enviada novamente.",
+    "confirmTitle": "Esta fatura já foi enviada",
+    "confirmBody": "A cópia do cliente fica como foi enviada. A alteração é guardada no trabalho e entra na fatura quando for reaberta e enviada novamente.",
+    "confirm": "Guardar para o próximo envio",
+    "cancel": "Cancelar"
+  },
+  "videoRecorder": {
+    "title": "Gravar um vídeo",
+    "starting": "A iniciar a câmara...",
+    "denied": "O navegador não tem permissão para usar a câmara ou o microfone. Permita-os para este site na barra de endereço do navegador e tente novamente.",
+    "noCamera": "Não foi encontrada nenhuma câmara neste computador.",
+    "unsupported": "Este navegador não consegue gravar vídeo. Carregue um ficheiro.",
+    "camera": "Câmara",
+    "start": "Iniciar gravação",
+    "stop": "Parar",
+    "recording": "A gravar",
+    "retake": "Gravar novamente",
+    "use": "Usar este vídeo",
+    "limit": "A gravação para sozinha ao fim de {minutes} minutos."
   }
   }
 }
 }

+ 19 - 1
messages/ru/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "Прикрепить PDF",
   "attachPdf": "Прикрепить PDF",
   "attachPdfHint": "Отключите, чтобы отправить ссылку вместо файла и видеть, когда клиент её открыл.",
   "attachPdfHint": "Отключите, чтобы отправить ссылку вместо файла и видеть, когда клиент её открыл.",
   "sentCopy": {
   "sentCopy": {
-    "note": "Отправлен {date}. Изменения здесь попадут в счёт, когда он будет отправлен повторно."
+    "note": "Отправлен {date}. Изменения здесь попадут в счёт, когда он будет отправлен повторно.",
+    "confirmTitle": "Этот счёт уже отправлен",
+    "confirmBody": "Копия клиента остаётся такой, какой была отправлена. Изменение сохранится в заказе и попадёт в счёт, когда его откроют заново и отправят повторно.",
+    "confirm": "Сохранить до следующей отправки",
+    "cancel": "Отмена"
+  },
+  "videoRecorder": {
+    "title": "Записать видео",
+    "starting": "Запуск камеры...",
+    "denied": "Браузеру не разрешено использовать камеру или микрофон. Разрешите их для этого сайта в адресной строке браузера и попробуйте снова.",
+    "noCamera": "На этом компьютере не найдена камера.",
+    "unsupported": "Этот браузер не умеет записывать видео. Загрузите файл.",
+    "camera": "Камера",
+    "start": "Начать запись",
+    "stop": "Стоп",
+    "recording": "Идёт запись",
+    "retake": "Записать заново",
+    "use": "Использовать это видео",
+    "limit": "Запись остановится сама через {minutes} мин."
   }
   }
 }
 }

+ 19 - 1
messages/tr/common.json

@@ -112,6 +112,24 @@
   "attachPdf": "PDF ekle",
   "attachPdf": "PDF ekle",
   "attachPdfHint": "Bunun yerine bağlantı göndermek için kapatın; böylece müşterinin ne zaman açtığını görürsünüz.",
   "attachPdfHint": "Bunun yerine bağlantı göndermek için kapatın; böylece müşterinin ne zaman açtığını görürsünüz.",
   "sentCopy": {
   "sentCopy": {
-    "note": "{date} tarihinde gönderildi. Buradaki değişiklikler fatura yeniden gönderildiğinde faturaya yansır."
+    "note": "{date} tarihinde gönderildi. Buradaki değişiklikler fatura yeniden gönderildiğinde faturaya yansır.",
+    "confirmTitle": "Bu fatura zaten gönderildi",
+    "confirmBody": "Müşterinin kopyası gönderildiği gibi kalır. Değişikliğiniz işte kaydedilir ve fatura yeniden açılıp tekrar gönderildiğinde faturaya yansır.",
+    "confirm": "Sonraki gönderim için kaydet",
+    "cancel": "İptal"
+  },
+  "videoRecorder": {
+    "title": "Video kaydet",
+    "starting": "Kamera başlatılıyor...",
+    "denied": "Tarayıcının kamerayı veya mikrofonu kullanmasına izin verilmedi. Tarayıcının adres çubuğundan bu site için izin verin ve yeniden deneyin.",
+    "noCamera": "Bu bilgisayarda kamera bulunamadı.",
+    "unsupported": "Bu tarayıcı video kaydedemiyor. Bunun yerine bir dosya yükleyin.",
+    "camera": "Kamera",
+    "start": "Kaydı başlat",
+    "stop": "Durdur",
+    "recording": "Kaydediliyor",
+    "retake": "Yeniden kaydet",
+    "use": "Bu videoyu kullan",
+    "limit": "Kayıt {minutes} dakika sonra kendiliğinden durur."
   }
   }
 }
 }

+ 82 - 0
src/__tests__/features/inspections/certificate-design-default.test.ts

@@ -0,0 +1,82 @@
+/**
+ * Which design a certificate prints from. Until a workshop designs its own,
+ * the default is the Regulatory preset; a certificate completed on the
+ * built-in sheet before that keeps the sheet it was issued on; a workshop's
+ * own design always wins.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('server-only', () => ({}))
+vi.mock('@/lib/db', () => ({
+  db: { documentDesignSnapshot: { findFirst: vi.fn(async () => null) } },
+}))
+
+import {
+  certificateDesignSource,
+  liveCertificateDesign,
+} from '@/features/inspections/Pdf/certificateDesign'
+import {
+  DEFAULT_CERTIFICATE_PRESET_ID,
+  defaultCertificateDesign,
+} from '@/features/inspections/Lib/defaultCertificateDesign'
+import { isDesignerLayout } from '@/features/settings/Schema/invoiceLayoutSchema'
+import { buildLayoutFromPreset, certificatePresets } from '@/features/settings/Schema/layoutPresets'
+
+const visibleOrder = (layout: { sections?: { id: string; visible?: boolean; order?: number }[] }) =>
+  [...(layout.sections ?? [])]
+    .filter((s) => s.visible !== false)
+    .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
+    .map((s) => s.id)
+
+const regulatory = certificatePresets.find((p) => p.id === 'certificate-regulator')!
+
+describe('the default certificate design', () => {
+  beforeEach(() => vi.clearAllMocks())
+
+  it('is the Regulatory preset', () => {
+    expect(DEFAULT_CERTIFICATE_PRESET_ID).toBe('certificate-regulator')
+    expect(visibleOrder(defaultCertificateDesign().layout)).toEqual(
+      visibleOrder(buildLayoutFromPreset(regulatory))
+    )
+  })
+
+  it('prints an open inspection in Regulatory when the workshop designed nothing', async () => {
+    const source = await certificateDesignSource('org', { designSnapshotId: null }, {})
+    expect(source).not.toBeNull()
+    expect(isDesignerLayout(source!.layout)).toBe(true)
+    expect(visibleOrder(source!.layout)).toEqual(visibleOrder(buildLayoutFromPreset(regulatory)))
+    expect(source!.template.fontFamily).toBe('Times-Roman')
+    expect(source!.template.primaryColor).toBe('#111827')
+  })
+
+  it('keeps a workshop’s own certificate colour and logo on the default', () => {
+    const source = liveCertificateDesign({
+      'certificate.primaryColor': '#0055aa',
+      'certificate.logo': '/api/protected/files/org/logos/a.png',
+    })
+    expect(source.template.primaryColor).toBe('#0055aa')
+    expect(source.template.logoUrl).toBe('/api/protected/files/org/logos/a.png')
+    expect(source.template.fontFamily).toBe('Times-Roman')
+  })
+
+  it('leaves a certificate completed on the built-in sheet as it was issued', async () => {
+    const source = await certificateDesignSource(
+      'org',
+      { designSnapshotId: null, completedAt: new Date('2026-09-01T10:00:00Z') },
+      {}
+    )
+    expect(source).toBeNull()
+  })
+
+  it('prints the workshop’s own design when it has one', async () => {
+    const own = { ...buildLayoutFromPreset(certificatePresets[0]), version: 3 }
+    const source = await certificateDesignSource(
+      'org',
+      { designSnapshotId: null },
+      {
+        'certificate.layoutConfig': JSON.stringify(own),
+      }
+    )
+    expect(visibleOrder(source!.layout)).toEqual(visibleOrder(own))
+  })
+})

+ 7 - 1
src/__tests__/multitenancy/inspection-isolation.test.ts

@@ -48,6 +48,11 @@ vi.mock('@/lib/db', () => ({
     },
     },
     // Completion reads the certificate design settings to freeze them.
     // Completion reads the certificate design settings to freeze them.
     appSetting: { findMany: vi.fn() },
     appSetting: { findMany: vi.fn() },
+    // Completing freezes the certificate design (the default when none was made).
+    documentDesignSnapshot: {
+      findUnique: vi.fn(),
+      create: vi.fn(),
+    },
     // Completing freezes the inspector's signature; nobody here has one.
     // Completing freezes the inspector's signature; nobody here has one.
     memberSignature: { findFirst: vi.fn().mockResolvedValue(null) },
     memberSignature: { findFirst: vi.fn().mockResolvedValue(null) },
   },
   },
@@ -82,6 +87,7 @@ function setupOrgAOwner() {
 
 
 beforeEach(() => {
 beforeEach(() => {
   vi.resetAllMocks()
   vi.resetAllMocks()
+  vi.mocked(db.documentDesignSnapshot.findUnique).mockResolvedValue({ id: 'snap-1' } as any)
 })
 })
 
 
 describe('getInspection — cross-org isolation', () => {
 describe('getInspection — cross-org isolation', () => {
@@ -176,7 +182,7 @@ describe('completeInspection — cross-org isolation', () => {
       items: [],
       items: [],
     } as any)
     } as any)
     vi.mocked(db.inspection.updateMany).mockResolvedValue({ count: 1 } as any)
     vi.mocked(db.inspection.updateMany).mockResolvedValue({ count: 1 } as any)
-    // No certificate design saved, so completion has nothing to freeze.
+    // No certificate design saved: completion freezes the default one.
     vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
     vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
 
 
     await completeInspection('insp-a')
     await completeInspection('insp-a')

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

@@ -40,6 +40,11 @@ vi.mock('@/lib/db', () => ({
     // Completing freezes the workshop's kinds of mark; nobody here changed any.
     // Completing freezes the workshop's kinds of mark; nobody here changed any.
     conditionMarkType: { findMany: vi.fn().mockResolvedValue([]) },
     conditionMarkType: { findMany: vi.fn().mockResolvedValue([]) },
     appSetting: { findMany: vi.fn().mockResolvedValue([]) },
     appSetting: { findMany: vi.fn().mockResolvedValue([]) },
+    // Completing freezes the certificate design (the default when none was made).
+    documentDesignSnapshot: {
+      findUnique: vi.fn(),
+      create: vi.fn(),
+    },
     // Completing freezes the inspector's signature; nobody here has one.
     // Completing freezes the inspector's signature; nobody here has one.
     memberSignature: { findFirst: vi.fn().mockResolvedValue(null) },
     memberSignature: { findFirst: vi.fn().mockResolvedValue(null) },
     quote: {
     quote: {
@@ -130,6 +135,7 @@ const ORG_A_INSPECTION = {
 
 
 beforeEach(() => {
 beforeEach(() => {
   vi.resetAllMocks()
   vi.resetAllMocks()
+  vi.mocked(db.documentDesignSnapshot.findUnique).mockResolvedValue({ id: 'snap-1' } as any)
   // Locking is read before any edit to an invoice or quote; no settings
   // Locking is read before any edit to an invoice or quote; no settings
   // rows means locking is off, which is how these tests expect to run.
   // rows means locking is off, which is how these tests expect to run.
   vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
   vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)

+ 0 - 1
src/app/(authenticated)/inspections/[id]/page.tsx

@@ -44,7 +44,6 @@ export default async function InspectionDetailPage({
       hasConditionMap && authContext?.organizationId
       hasConditionMap && authContext?.organizationId
         ? loadVehicleConditionMarks(authContext.organizationId, result.data.vehicleId)
         ? loadVehicleConditionMarks(authContext.organizationId, result.data.vehicleId)
         : [],
         : [],
-      ,
       hasConditionMap && authContext?.organizationId
       hasConditionMap && authContext?.organizationId
         ? markTypeCatalogue(authContext.organizationId, await getLocale())
         ? markTypeCatalogue(authContext.organizationId, await getLocale())
         : [],
         : [],

+ 33 - 3
src/app/(designer)/invoice-designer/page.tsx

@@ -1,4 +1,6 @@
 import { redirect } from 'next/navigation'
 import { redirect } from 'next/navigation'
+import { defaultCertificateDesign } from '@/features/inspections/Lib/defaultCertificateDesign'
+
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { getLayoutData } from '@/lib/get-layout-data'
 import { getLayoutData } from '@/lib/get-layout-data'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
@@ -17,7 +19,11 @@ import { InvoiceDesigner } from '@/features/invoice-designer/Components/InvoiceD
 import { DismissOnArrival } from '@/components/feature-hint'
 import { DismissOnArrival } from '@/components/feature-hint'
 import { INVOICE_DESIGNER_ANNOUNCEMENT, parseHintIds } from '@/features/settings/Lib/featureHints'
 import { INVOICE_DESIGNER_ANNOUNCEMENT, parseHintIds } from '@/features/settings/Lib/featureHints'
 import type { SavedDesign } from '@/features/invoice-designer/Components/types'
 import type { SavedDesign } from '@/features/invoice-designer/Components/types'
-import { WORK_ORDER_TEMPLATE_DEFAULTS } from '@/features/settings/Schema/invoiceLayoutSchema'
+import {
+  DESIGNER_LAYOUT_VERSION,
+  isDesignerLayout,
+  WORK_ORDER_TEMPLATE_DEFAULTS,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
 import { telegramBotLink as botLinkOf } from '@/features/invoices/Lib/telegramQr'
 import { telegramBotLink as botLinkOf } from '@/features/invoices/Lib/telegramQr'
 import { getOrgTelegramBotUsername } from '@/lib/telegram'
 import { getOrgTelegramBotUsername } from '@/lib/telegram'
 import { memberSignatureDataUri } from '@/features/signatures/Lib/memberSignature.server'
 import { memberSignatureDataUri } from '@/features/signatures/Lib/memberSignature.server'
@@ -147,11 +153,35 @@ export default async function InvoiceDesignerPage({
         }}
         }}
         invoiceLayout={invoiceLayout.success ? invoiceLayout.data : undefined}
         invoiceLayout={invoiceLayout.success ? invoiceLayout.data : undefined}
         quoteLayout={quoteLayout.success ? quoteLayout.data : undefined}
         quoteLayout={quoteLayout.success ? quoteLayout.data : undefined}
-        certificateLayout={certificateLayout.success ? certificateLayout.data : undefined}
+        certificateLayout={
+          // Never designed: the canvas starts from the design that prints, Regulatory.
+          certificateLayout.success && isDesignerLayout(certificateLayout.data)
+            ? certificateLayout.data
+            : { ...defaultCertificateDesign().layout, version: DESIGNER_LAYOUT_VERSION }
+        }
         workOrderLayout={workOrderLayout.success ? workOrderLayout.data : undefined}
         workOrderLayout={workOrderLayout.success ? workOrderLayout.data : undefined}
         invoiceTemplate={templateFor('invoice')}
         invoiceTemplate={templateFor('invoice')}
         quoteTemplate={templateFor('quote')}
         quoteTemplate={templateFor('quote')}
-        certificateTemplate={templateFor('certificate')}
+        certificateTemplate={
+          certificateLayout.success && isDesignerLayout(certificateLayout.data)
+            ? templateFor('certificate')
+            : {
+                ...templateFor('certificate'),
+                primaryColor:
+                  settings['certificate.primaryColor'] ||
+                  defaultCertificateDesign().template.primaryColor,
+                fontFamily:
+                  settings['certificate.fontFamily'] ||
+                  defaultCertificateDesign().template.fontFamily,
+                headerStyle:
+                  settings['certificate.headerStyle'] ||
+                  defaultCertificateDesign().template.headerStyle,
+                textColor:
+                  settings['certificate.textColor'] ||
+                  defaultCertificateDesign().template.textColor ||
+                  '',
+              }
+        }
         workOrderTemplate={templateFor('work_order')}
         workOrderTemplate={templateFor('work_order')}
         initialSavedDesigns={savedDesigns}
         initialSavedDesigns={savedDesigns}
         telegramBotLink={telegramBotUsername ? botLinkOf(telegramBotUsername) : undefined}
         telegramBotLink={telegramBotUsername ? botLinkOf(telegramBotUsername) : undefined}

+ 42 - 0
src/components/sent-copy-note.tsx

@@ -1,6 +1,16 @@
 'use client'
 'use client'
 
 
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
 import { useDayFormatter } from '@/features/calendar/Components/useDayFormatter'
 import { useDayFormatter } from '@/features/calendar/Components/useDayFormatter'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 
 
@@ -31,3 +41,35 @@ export function SentCopyNote({
     </p>
     </p>
   )
   )
 }
 }
+
+/**
+ * Asked before a change on a sent job that the sent invoice will not show:
+ * the change is kept on the job and goes on the invoice when it is reopened
+ * and sent again. The same question for every tool, so the rule reads the
+ * same wherever it applies.
+ */
+export function SentCopyConfirm({
+  open,
+  onConfirm,
+  onCancel,
+}: {
+  open: boolean
+  onConfirm: () => void
+  onCancel: () => void
+}) {
+  const t = useTranslations('common.sentCopy')
+  return (
+    <AlertDialog open={open} onOpenChange={(next) => !next && onCancel()}>
+      <AlertDialogContent>
+        <AlertDialogHeader>
+          <AlertDialogTitle>{t('confirmTitle')}</AlertDialogTitle>
+          <AlertDialogDescription>{t('confirmBody')}</AlertDialogDescription>
+        </AlertDialogHeader>
+        <AlertDialogFooter>
+          <AlertDialogCancel onClick={onCancel}>{t('cancel')}</AlertDialogCancel>
+          <AlertDialogAction onClick={onConfirm}>{t('confirm')}</AlertDialogAction>
+        </AlertDialogFooter>
+      </AlertDialogContent>
+    </AlertDialog>
+  )
+}

+ 281 - 0
src/components/video-recorder-dialog.tsx

@@ -0,0 +1,281 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Circle, Loader2, RotateCcw, Square } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+
+/** A recording stops by itself here: ten minutes stays well under the upload limit. */
+const MAX_MINUTES = 10
+/** About 2.5 Mbit/s: a clear picture at a size that uploads from a workshop line. */
+const VIDEO_BITS_PER_SECOND = 2_500_000
+
+/** What the browser can record, best first. Chrome and Firefox write WebM, Safari MP4. */
+const MIME_TYPES = [
+  'video/webm;codecs=vp9,opus',
+  'video/webm;codecs=vp8,opus',
+  'video/webm',
+  'video/mp4',
+]
+
+type Phase = 'starting' | 'ready' | 'recording' | 'review' | 'failed'
+type Failure = 'denied' | 'noCamera' | 'unsupported'
+
+function pickMimeType(): string | null {
+  if (typeof MediaRecorder === 'undefined') return null
+  return MIME_TYPES.find((type) => MediaRecorder.isTypeSupported(type)) ?? null
+}
+
+function clock(seconds: number): string {
+  const m = Math.floor(seconds / 60)
+  const s = seconds % 60
+  return `${m}:${String(s).padStart(2, '0')}`
+}
+
+/**
+ * Records a video with the computer's own camera and microphone, for the desk:
+ * a phone opens its camera app from a file input, a desk computer has nothing
+ * to open, so the page records itself. The take is watched back before it is
+ * used, and handed over as a file, exactly as if it had been uploaded.
+ */
+export function VideoRecorderDialog({
+  open,
+  onOpenChange,
+  onRecorded,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  onRecorded: (file: File) => void
+}) {
+  const t = useTranslations('common.videoRecorder')
+  const liveRef = useRef<HTMLVideoElement>(null)
+  const streamRef = useRef<MediaStream | null>(null)
+  const recorderRef = useRef<MediaRecorder | null>(null)
+  const chunksRef = useRef<Blob[]>([])
+  const [phase, setPhase] = useState<Phase>('starting')
+  const [failure, setFailure] = useState<Failure | null>(null)
+  const [cameras, setCameras] = useState<MediaDeviceInfo[]>([])
+  const [cameraId, setCameraId] = useState<string | null>(null)
+  const [elapsed, setElapsed] = useState(0)
+  const [take, setTake] = useState<{ file: File; url: string } | null>(null)
+
+  const stopStream = useCallback(() => {
+    for (const track of streamRef.current?.getTracks() ?? []) track.stop()
+    streamRef.current = null
+  }, [])
+
+  const dropTake = useCallback(() => {
+    setTake((current) => {
+      if (current) URL.revokeObjectURL(current.url)
+      return null
+    })
+  }, [])
+
+  // The camera runs while the dialog is open and waiting to record; it is
+  // switched off for the review and whenever the dialog closes.
+  const reviewing = phase === 'review'
+  useEffect(() => {
+    if (!open || reviewing) return
+    let cancelled = false
+    async function start() {
+      if (!pickMimeType() || !navigator.mediaDevices?.getUserMedia) {
+        setFailure('unsupported')
+        setPhase('failed')
+        return
+      }
+      setPhase('starting')
+      try {
+        const stream = await navigator.mediaDevices.getUserMedia({
+          video: cameraId ? { deviceId: { exact: cameraId } } : true,
+          audio: true,
+        })
+        if (cancelled) {
+          for (const track of stream.getTracks()) track.stop()
+          return
+        }
+        streamRef.current = stream
+        if (liveRef.current) liveRef.current.srcObject = stream
+        const devices = await navigator.mediaDevices.enumerateDevices()
+        setCameras(devices.filter((d) => d.kind === 'videoinput' && d.deviceId))
+        setFailure(null)
+        setPhase('ready')
+      } catch (err) {
+        if (cancelled) return
+        const name = err instanceof DOMException ? err.name : ''
+        setFailure(
+          name === 'NotFoundError' || name === 'OverconstrainedError' ? 'noCamera' : 'denied'
+        )
+        setPhase('failed')
+      }
+    }
+    void start()
+    return () => {
+      cancelled = true
+      stopStream()
+    }
+  }, [open, cameraId, reviewing, stopStream])
+
+  // A dialog closed mid-recording keeps nothing.
+  useEffect(() => {
+    if (open) return
+    if (recorderRef.current?.state === 'recording') {
+      recorderRef.current.onstop = null
+      recorderRef.current.stop()
+    }
+    recorderRef.current = null
+    setElapsed(0)
+    dropTake()
+    setPhase('starting')
+  }, [open, dropTake])
+
+  // The running clock, and the limit.
+  useEffect(() => {
+    if (phase !== 'recording') return
+    const started = Date.now() - elapsed * 1000
+    const timer = window.setInterval(() => {
+      const seconds = Math.floor((Date.now() - started) / 1000)
+      setElapsed(seconds)
+      if (seconds >= MAX_MINUTES * 60) recorderRef.current?.stop()
+    }, 250)
+    return () => window.clearInterval(timer)
+  }, [phase])
+
+  function startRecording() {
+    const stream = streamRef.current
+    const mimeType = pickMimeType()
+    if (!stream || !mimeType) return
+    chunksRef.current = []
+    const recorder = new MediaRecorder(stream, {
+      mimeType,
+      videoBitsPerSecond: VIDEO_BITS_PER_SECOND,
+    })
+    recorder.ondataavailable = (event) => {
+      if (event.data.size > 0) chunksRef.current.push(event.data)
+    }
+    recorder.onstop = () => {
+      // The upload route takes the plain type, without the codec list.
+      const type = mimeType.split(';')[0]
+      const extension = type === 'video/mp4' ? 'mp4' : 'webm'
+      const blob = new Blob(chunksRef.current, { type })
+      const file = new File([blob], `recording-${Date.now()}.${extension}`, { type })
+      setTake({ file, url: URL.createObjectURL(blob) })
+      setPhase('review')
+    }
+    recorderRef.current = recorder
+    setElapsed(0)
+    recorder.start(1000)
+    setPhase('recording')
+  }
+
+  function retake() {
+    dropTake()
+    setElapsed(0)
+    setPhase('starting')
+  }
+
+  function acceptTake() {
+    if (!take) return
+    onRecorded(take.file)
+    onOpenChange(false)
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-2xl" data-testid="video-recorder">
+        <DialogHeader>
+          <DialogTitle>{t('title')}</DialogTitle>
+          <DialogDescription>{t('limit', { minutes: MAX_MINUTES })}</DialogDescription>
+        </DialogHeader>
+
+        {phase === 'failed' ? (
+          <p
+            role="alert"
+            className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm"
+          >
+            {failure ? t(failure) : t('unsupported')}
+          </p>
+        ) : (
+          <div className="relative overflow-hidden rounded-lg bg-black">
+            {phase === 'review' && take ? (
+              <video src={take.url} controls playsInline className="aspect-video w-full" />
+            ) : (
+              <video
+                ref={liveRef}
+                autoPlay
+                muted
+                playsInline
+                className="aspect-video w-full -scale-x-100"
+              />
+            )}
+            {phase === 'starting' && (
+              <div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-white">
+                <Loader2 className="h-4 w-4 animate-spin" />
+                {t('starting')}
+              </div>
+            )}
+            {phase === 'recording' && (
+              <div className="absolute left-3 top-3 flex items-center gap-1.5 rounded bg-black/60 px-2 py-1 text-xs font-medium text-white">
+                <Circle className="h-2.5 w-2.5 animate-pulse fill-red-500 text-red-500" />
+                {t('recording')} {clock(elapsed)}
+              </div>
+            )}
+          </div>
+        )}
+
+        {cameras.length > 1 && (phase === 'ready' || phase === 'starting') && (
+          <Select value={cameraId ?? cameras[0]?.deviceId} onValueChange={setCameraId}>
+            <SelectTrigger aria-label={t('camera')} className="w-full sm:w-72">
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              {cameras.map((camera, index) => (
+                <SelectItem key={camera.deviceId} value={camera.deviceId}>
+                  {camera.label || `${t('camera')} ${index + 1}`}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        )}
+
+        <DialogFooter>
+          {phase === 'review' ? (
+            <>
+              <Button type="button" variant="outline" onClick={retake}>
+                <RotateCcw className="mr-1.5 h-4 w-4" />
+                {t('retake')}
+              </Button>
+              <Button type="button" onClick={acceptTake}>
+                {t('use')}
+              </Button>
+            </>
+          ) : phase === 'recording' ? (
+            <Button type="button" variant="destructive" onClick={() => recorderRef.current?.stop()}>
+              <Square className="mr-1.5 h-4 w-4 fill-current" />
+              {t('stop')}
+            </Button>
+          ) : phase !== 'failed' ? (
+            <Button type="button" onClick={startRecording} disabled={phase !== 'ready'}>
+              <Circle className="mr-1.5 h-4 w-4 fill-red-500 text-red-500" />
+              {t('start')}
+            </Button>
+          ) : null}
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 41 - 1
src/features/condition-map/Actions/conditionMarkActions.ts

@@ -7,6 +7,7 @@ import { withAuth } from '@/lib/with-auth'
 import { auditDetails } from '@/lib/audit'
 import { auditDetails } from '@/lib/audit'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { assertOwnUploads } from '@/lib/upload-url'
 import { assertOwnUploads } from '@/lib/upload-url'
+import { releaseFiles } from '@/lib/files/manager'
 import { BODY_TYPES } from '../Lib/drawingTypes'
 import { BODY_TYPES } from '../Lib/drawingTypes'
 import {
 import {
   type ConditionMarkData,
   type ConditionMarkData,
@@ -204,6 +205,9 @@ export async function removeConditionMark(id: string) {
       const mark = await ownMark(organizationId, id)
       const mark = await ownMark(organizationId, id)
       await assertSheetOpen(organizationId, mark)
       await assertSheetOpen(organizationId, mark)
       await db.conditionMark.delete({ where: { id } })
       await db.conditionMark.delete({ where: { id } })
+      // Its photos go with it, through the file manager like every delete:
+      // to the trash, unless some other row still uses the same file.
+      await releaseFiles(mark.imageUrls, { organizationId, reason: 'condition mark removed' })
       for (const path of pathsFor(mark)) revalidatePath(path)
       for (const path of pathsFor(mark)) revalidatePath(path)
       return { id, vehicleId: mark.vehicleId }
       return { id, vehicleId: mark.vehicleId }
     },
     },
@@ -276,13 +280,19 @@ export async function addConditionMarkPhotos(input: unknown) {
 }
 }
 
 
 export async function removeConditionMarkPhoto(input: unknown) {
 export async function removeConditionMarkPhoto(input: unknown) {
-  const { id, url } = z.object({ id: z.string(), url: z.string() }).parse(input)
+  const parsed = z.object({ id: z.string(), url: z.string() }).safeParse(input)
+  if (!parsed.success) return { success: false as const, error: 'Invalid photo' }
+  const { id, url } = parsed.data
   return withAuth(
   return withAuth(
     async ({ organizationId }): Promise<string[]> => {
     async ({ organizationId }): Promise<string[]> => {
       const mark = await ownMark(organizationId, id)
       const mark = await ownMark(organizationId, id)
       await assertSheetOpen(organizationId, mark)
       await assertSheetOpen(organizationId, mark)
       const next = mark.imageUrls.filter((u) => u !== url)
       const next = mark.imageUrls.filter((u) => u !== url)
       await db.conditionMark.update({ where: { id }, data: { imageUrls: next } })
       await db.conditionMark.update({ where: { id }, data: { imageUrls: next } })
+      // Only a photo this mark had: a URL it never held is not ours to release.
+      if (next.length !== mark.imageUrls.length) {
+        await releaseFiles([url], { organizationId, reason: 'condition mark photo removed' })
+      }
       for (const path of pathsFor(mark)) revalidatePath(path)
       for (const path of pathsFor(mark)) revalidatePath(path)
       return next
       return next
     },
     },
@@ -348,3 +358,33 @@ export async function setConditionMapOnInvoice(serviceRecordId: string, on: bool
     }
     }
   )
   )
 }
 }
+
+const unsavedSchema = z.object({ urls: z.array(z.string()).min(1).max(50) })
+
+/**
+ * Photos uploaded for a mark whose save then failed (the sheet was closed in
+ * the meantime, or the mark was removed). They are on disk with no row, so
+ * they go to the trash now rather than waiting a week for the sweep. The file
+ * manager checks every one against the database first, so a URL some row does
+ * use is kept.
+ */
+export async function discardConditionMarkUploads(input: unknown) {
+  const parsed = unsavedSchema.safeParse(input)
+  if (!parsed.success) return { success: false as const, error: 'Invalid photos' }
+  const { urls } = parsed.data
+  return withAuth(
+    async ({ organizationId }) => {
+      assertOwnUploads(urls, organizationId)
+      const result = await releaseFiles(urls, {
+        organizationId,
+        reason: 'condition mark photos not saved',
+      })
+      return { removed: result.removed.length }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+      ],
+    }
+  )
+}

+ 3 - 0
src/features/condition-map/Components/ConditionMapCard.tsx

@@ -27,6 +27,7 @@ import { cn } from '@/lib/utils'
 import {
 import {
   addConditionMark,
   addConditionMark,
   addConditionMarkPhotos,
   addConditionMarkPhotos,
+  discardConditionMarkUploads,
   removeConditionMark,
   removeConditionMark,
   removeConditionMarkPhoto,
   removeConditionMarkPhoto,
   resolveConditionMark,
   resolveConditionMark,
@@ -241,6 +242,8 @@ export function ConditionMapCard({
       const result = await addConditionMarkPhotos({ id: editingId, urls: batch })
       const result = await addConditionMarkPhotos({ id: editingId, urls: batch })
       if (!result.success || !result.data) {
       if (!result.success || !result.data) {
         toast.error(result.success ? t('uploadFailed') : result.error || t('uploadFailed'))
         toast.error(result.success ? t('uploadFailed') : result.error || t('uploadFailed'))
+        // What was uploaded but not saved is on disk with nothing using it.
+        await discardConditionMarkUploads({ urls: urls.slice(start) })
         break
         break
       }
       }
       if (before) replace({ ...before, imageUrls: result.data })
       if (before) replace({ ...before, imageUrls: result.data })

+ 19 - 14
src/features/condition-map/Components/MarkEditor.tsx

@@ -19,6 +19,7 @@ import { cn } from '@/lib/utils'
 import { type MarkSeverity, SEVERITIES } from '../Lib/marks'
 import { type MarkSeverity, SEVERITIES } from '../Lib/marks'
 import { type MarkType, markTypeOf } from '../Lib/markTypes'
 import { type MarkType, markTypeOf } from '../Lib/markTypes'
 import { MarkIcon } from './ConditionMap'
 import { MarkIcon } from './ConditionMap'
+import { useNativeCamera } from '@/hooks/use-native-camera'
 
 
 export interface EditableMark {
 export interface EditableMark {
   id: string
   id: string
@@ -63,6 +64,7 @@ export function MarkEditor({
   onClose: () => void
   onClose: () => void
 }) {
 }) {
   const t = useTranslations('conditionMap')
   const t = useTranslations('conditionMap')
+  const nativeCamera = useNativeCamera()
   // The kinds on offer: the ones not hidden, plus the mark's own kind even
   // The kinds on offer: the ones not hidden, plus the mark's own kind even
   // when it is, so a mark of a retired kind still says what it is.
   // when it is, so a mark of a retired kind still says what it is.
   const offered = types.filter((type) => !type.hidden || type.key === mark?.kind)
   const offered = types.filter((type) => !type.hidden || type.key === mark?.kind)
@@ -234,20 +236,23 @@ export function MarkEditor({
                 ))}
                 ))}
                 {!readOnly && (
                 {!readOnly && (
                   <>
                   <>
-                    <Button
-                      type="button"
-                      variant="outline"
-                      className="h-20 w-20 flex-col gap-1 text-xs"
-                      disabled={uploading}
-                      onClick={() => cameraRef.current?.click()}
-                    >
-                      {uploading ? (
-                        <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
-                      ) : (
-                        <Camera className="h-4 w-4" aria-hidden="true" />
-                      )}
-                      {t('takePhoto')}
-                    </Button>
+                    {/* Only where it opens a camera: on a desk it is the same file picker as Add photo. */}
+                    {nativeCamera && (
+                      <Button
+                        type="button"
+                        variant="outline"
+                        className="h-20 w-20 flex-col gap-1 text-xs"
+                        disabled={uploading}
+                        onClick={() => cameraRef.current?.click()}
+                      >
+                        {uploading ? (
+                          <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
+                        ) : (
+                          <Camera className="h-4 w-4" aria-hidden="true" />
+                        )}
+                        {t('takePhoto')}
+                      </Button>
+                    )}
                     <Button
                     <Button
                       type="button"
                       type="button"
                       variant="outline"
                       variant="outline"

+ 3 - 6
src/features/inspections/Actions/inspectionActions.ts

@@ -484,9 +484,8 @@ export async function completeInspection(id: string) {
 
 
       // The certificate design as it is today, frozen onto the inspection the
       // The certificate design as it is today, frozen onto the inspection the
       // way an issued invoice freezes its design: a design edited next year
       // way an issued invoice freezes its design: a design edited next year
-      // must not relabel a certificate a customer already holds. Nothing to
-      // freeze for a workshop that has never designed one; those print the
-      // built-in sheet, which does not change.
+      // must not relabel a certificate a customer already holds. A workshop
+      // that has never designed one freezes the default (Regulatory) design.
       const settingRows = await db.appSetting.findMany({
       const settingRows = await db.appSetting.findMany({
         where: { organizationId, key: { startsWith: 'certificate.' } },
         where: { organizationId, key: { startsWith: 'certificate.' } },
         select: { key: true, value: true },
         select: { key: true, value: true },
@@ -494,9 +493,7 @@ export async function completeInspection(id: string) {
       const certificateSettings: Record<string, string> = {}
       const certificateSettings: Record<string, string> = {}
       for (const row of settingRows) certificateSettings[row.key] = row.value
       for (const row of settingRows) certificateSettings[row.key] = row.value
       const liveDesign = liveCertificateDesign(certificateSettings)
       const liveDesign = liveCertificateDesign(certificateSettings)
-      const designSnapshotId = liveDesign
-        ? await ensureDesignSnapshot(organizationId, liveDesign)
-        : null
+      const designSnapshotId = await ensureDesignSnapshot(organizationId, liveDesign)
 
 
       // The inspector's signature, frozen with the design for the same reason.
       // The inspector's signature, frozen with the design for the same reason.
       const signatureDataUri = await memberSignatureDataUri(
       const signatureDataUri = await memberSignatureDataUri(

+ 22 - 0
src/features/inspections/Lib/defaultCertificateDesign.ts

@@ -0,0 +1,22 @@
+import {
+  buildLayoutFromPreset,
+  certificatePresets,
+  type PresetTemplate,
+} from '@/features/settings/Schema/layoutPresets'
+import type { InvoiceLayoutConfig } from '@/features/settings/Schema/invoiceLayoutSchema'
+
+/**
+ * The certificate a workshop gets until it designs its own: the Regulatory
+ * preset, a plain form that lists every check with its grade and note. One
+ * place, so the printed certificate and the designer's starting canvas agree.
+ */
+export const DEFAULT_CERTIFICATE_PRESET_ID = 'certificate-regulator'
+
+export function defaultCertificateDesign(): {
+  layout: InvoiceLayoutConfig
+  template: PresetTemplate
+} {
+  const preset = certificatePresets.find((p) => p.id === DEFAULT_CERTIFICATE_PRESET_ID)
+  if (!preset) throw new Error(`missing certificate preset ${DEFAULT_CERTIFICATE_PRESET_ID}`)
+  return { layout: buildLayoutFromPreset(preset), template: preset.template }
+}

+ 38 - 6
src/features/inspections/Pdf/certificateDesign.ts

@@ -1,7 +1,11 @@
 import 'server-only'
 import 'server-only'
 
 
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
-import { isDesignerLayout } from '@/features/settings/Schema/invoiceLayoutSchema'
+import {
+  DESIGNER_LAYOUT_VERSION,
+  isDesignerLayout,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import { defaultCertificateDesign } from '../Lib/defaultCertificateDesign'
 import {
 import {
   type DesignSource,
   type DesignSource,
   designSourceFromSettings,
   designSourceFromSettings,
@@ -19,10 +23,16 @@ import { inspectionPrintLabels } from '../Lib/inspectionLabels'
  * pulling the PDF renderer into its bundle.
  * pulling the PDF renderer into its bundle.
  */
  */
 
 
-/** The design this inspection prints from, or null when it has none. */
+/**
+ * The design this inspection prints from, or null for the built-in sheet.
+ *
+ * A completed inspection prints what it was issued with: its frozen design,
+ * or, completed before it had one, the built-in sheet it went out on. An
+ * open one prints the workshop's certificate design, or the default one.
+ */
 export async function certificateDesignSource(
 export async function certificateDesignSource(
   organizationId: string,
   organizationId: string,
-  inspection: { designSnapshotId: string | null },
+  inspection: { designSnapshotId: string | null; completedAt?: Date | null },
   settingsMap: Record<string, string>
   settingsMap: Record<string, string>
 ): Promise<DesignSource | null> {
 ): Promise<DesignSource | null> {
   if (inspection.designSnapshotId) {
   if (inspection.designSnapshotId) {
@@ -33,13 +43,35 @@ export async function certificateDesignSource(
     const frozen = snapshot ? designSourceFromSnapshot(snapshot.layout, snapshot.template) : null
     const frozen = snapshot ? designSourceFromSnapshot(snapshot.layout, snapshot.template) : null
     if (frozen) return frozen
     if (frozen) return frozen
   }
   }
+  if (inspection.completedAt) {
+    const live = designSourceFromSettings(settingsMap, 'certificate')
+    return isDesignerLayout(live.layout) ? live : null
+  }
   return liveCertificateDesign(settingsMap)
   return liveCertificateDesign(settingsMap)
 }
 }
 
 
-/** The live certificate design, for freezing when an inspection is completed. */
-export function liveCertificateDesign(settingsMap: Record<string, string>): DesignSource | null {
+/**
+ * The live certificate design, for printing an open inspection and freezing
+ * when one is completed: the workshop's own, or the default (Regulatory) for a
+ * workshop that has not designed one.
+ */
+export function liveCertificateDesign(settingsMap: Record<string, string>): DesignSource {
   const live = designSourceFromSettings(settingsMap, 'certificate')
   const live = designSourceFromSettings(settingsMap, 'certificate')
-  return isDesignerLayout(live.layout) ? live : null
+  if (isDesignerLayout(live.layout)) return live
+  const fallback = defaultCertificateDesign()
+  const set = (key: string) => settingsMap[`certificate.${key}`]
+  return {
+    layout: { ...fallback.layout, version: DESIGNER_LAYOUT_VERSION },
+    // The look is the preset's, except what the workshop set for certificates
+    // itself (its logo always stays).
+    template: {
+      ...live.template,
+      primaryColor: set('primaryColor') || fallback.template.primaryColor,
+      headerStyle: set('headerStyle') || fallback.template.headerStyle,
+      fontFamily: set('fontFamily') || fallback.template.fontFamily,
+      textColor: set('textColor') || fallback.template.textColor || '',
+    },
+  }
 }
 }
 
 
 export async function loadPdfMessages(locale: string) {
 export async function loadPdfMessages(locale: string) {

+ 13 - 1
src/features/status-reports/Components/CreateStatusReportDialog.tsx

@@ -22,6 +22,8 @@ import {
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { DateInput } from '@/components/ui/date-input'
 import { DateInput } from '@/components/ui/date-input'
+import { VideoRecorderDialog } from '@/components/video-recorder-dialog'
+import { useNativeCamera } from '@/hooks/use-native-camera'
 import { Textarea } from '@/components/ui/textarea'
 import { Textarea } from '@/components/ui/textarea'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
 import { Camera, Loader2, Upload, Video, Send, X } from 'lucide-react'
 import { Camera, Loader2, Upload, Video, Send, X } from 'lucide-react'
@@ -62,6 +64,9 @@ export function CreateStatusReportDialog({
   const isMobile = useIsMobile()
   const isMobile = useIsMobile()
   const fileInputRef = useRef<HTMLInputElement>(null)
   const fileInputRef = useRef<HTMLInputElement>(null)
   const captureInputRef = useRef<HTMLInputElement>(null)
   const captureInputRef = useRef<HTMLInputElement>(null)
+  // A phone records with its camera app; a desk computer with the page's own recorder.
+  const nativeCamera = useNativeCamera()
+  const [recorderOpen, setRecorderOpen] = useState(false)
   const [title, setTitle] = useState('')
   const [title, setTitle] = useState('')
   const [message, setMessage] = useState('')
   const [message, setMessage] = useState('')
   const [videoUrl, setVideoUrl] = useState<string | null>(null)
   const [videoUrl, setVideoUrl] = useState<string | null>(null)
@@ -200,7 +205,9 @@ export function CreateStatusReportDialog({
               type="button"
               type="button"
               variant="outline"
               variant="outline"
               size="sm"
               size="sm"
-              onClick={() => captureInputRef.current?.click()}
+              onClick={() =>
+                nativeCamera ? captureInputRef.current?.click() : setRecorderOpen(true)
+              }
             >
             >
               <Camera className="mr-1.5 h-4 w-4" />
               <Camera className="mr-1.5 h-4 w-4" />
               {t('record')}
               {t('record')}
@@ -270,6 +277,11 @@ export function CreateStatusReportDialog({
         <Label htmlFor="sr-expires">{t('expiresLabel')}</Label>
         <Label htmlFor="sr-expires">{t('expiresLabel')}</Label>
         <DateInput id="sr-expires" value={expiresAt} onChange={setExpiresAt} />
         <DateInput id="sr-expires" value={expiresAt} onChange={setExpiresAt} />
       </div>
       </div>
+      <VideoRecorderDialog
+        open={recorderOpen}
+        onOpenChange={setRecorderOpen}
+        onRecorded={(file) => void handleFileUpload(file)}
+      />
     </div>
     </div>
   )
   )
 
 

+ 19 - 2
src/features/vehicles/Components/service-page/modern/FilesMediaCard.tsx

@@ -4,7 +4,7 @@ import { useEffect, useState, type ReactNode } from 'react'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { Paperclip } from 'lucide-react'
 import { Paperclip } from 'lucide-react'
 import { AppCard } from '@/components/app-card'
 import { AppCard } from '@/components/app-card'
-import { SentCopyNote } from '@/components/sent-copy-note'
+import { SentCopyConfirm, SentCopyNote } from '@/components/sent-copy-note'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import { MediaGrid } from './MediaGrid'
 import { MediaGrid } from './MediaGrid'
 import { PhotoHandoffButton } from '../PhotoHandoffButton'
 import { PhotoHandoffButton } from '../PhotoHandoffButton'
@@ -98,6 +98,14 @@ export function FilesMediaCard({
     conditionMap?.onInvoice ?? conditionMap?.byDesign ?? false
     conditionMap?.onInvoice ?? conditionMap?.byDesign ?? false
   )
   )
   const [savingOnInvoice, setSavingOnInvoice] = useState(false)
   const [savingOnInvoice, setSavingOnInvoice] = useState(false)
+  // On a sent invoice the switch still works, but the sent copy keeps its
+  // map until it is sent again, so it asks first rather than seem to fail.
+  const [pendingOnInvoice, setPendingOnInvoice] = useState<boolean | null>(null)
+  const requestOnInvoice = (next: boolean) => {
+    if (conditionMap?.sentAt) setPendingOnInvoice(next)
+    else void toggleOnInvoice(next)
+  }
+
   const toggleOnInvoice = async (next: boolean) => {
   const toggleOnInvoice = async (next: boolean) => {
     const before = onInvoice
     const before = onInvoice
     setOnInvoice(next)
     setOnInvoice(next)
@@ -243,7 +251,7 @@ export function FilesMediaCard({
                       className="mt-0.5"
                       className="mt-0.5"
                       checked={onInvoice}
                       checked={onInvoice}
                       disabled={savingOnInvoice}
                       disabled={savingOnInvoice}
-                      onCheckedChange={(checked) => void toggleOnInvoice(checked === true)}
+                      onCheckedChange={(checked) => requestOnInvoice(checked === true)}
                       data-testid="condition-map-on-invoice"
                       data-testid="condition-map-on-invoice"
                     />
                     />
                     <span className="min-w-0">
                     <span className="min-w-0">
@@ -256,6 +264,15 @@ export function FilesMediaCard({
                       <SentCopyNote sentAt={conditionMap.sentAt} className="mt-1" />
                       <SentCopyNote sentAt={conditionMap.sentAt} className="mt-1" />
                     </span>
                     </span>
                   </label>
                   </label>
+                  <SentCopyConfirm
+                    open={pendingOnInvoice !== null}
+                    onConfirm={() => {
+                      const next = pendingOnInvoice
+                      setPendingOnInvoice(null)
+                      if (next !== null) void toggleOnInvoice(next)
+                    }}
+                    onCancel={() => setPendingOnInvoice(null)}
+                  />
                 </div>
                 </div>
               )}
               )}
               <p className="px-5 pt-4 text-[13px] text-muted-foreground">
               <p className="px-5 pt-4 text-[13px] text-muted-foreground">

+ 25 - 0
src/hooks/use-native-camera.ts

@@ -0,0 +1,25 @@
+import * as React from 'react'
+
+const QUERY = '(pointer: coarse)'
+
+/**
+ * Whether this is a touch-first device, where a file input with `capture`
+ * opens the camera app. A desk computer ignores `capture` and shows the file
+ * picker instead, so there a "take a photo" button duplicates "add a photo",
+ * and recording a video needs the page's own recorder.
+ *
+ * By the pointer, not the width: a narrow window on a desk is still a desk,
+ * and a tablet held sideways is still a camera. False until mounted, since
+ * the server cannot tell.
+ */
+export function useNativeCamera(): boolean {
+  const [native, setNative] = React.useState(false)
+  React.useEffect(() => {
+    const mql = window.matchMedia(QUERY)
+    const onChange = () => setNative(mql.matches)
+    onChange()
+    mql.addEventListener('change', onChange)
+    return () => mql.removeEventListener('change', onChange)
+  }, [])
+  return native
+}