Просмотр исходного кода

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 22 часов назад
Родитель
Сommit
e21af37d38
33 измененных файлов с 1068 добавлено и 54 удалено
  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 {
   addMark,
@@ -17,6 +19,7 @@ import {
   removeOwnMarkKind,
   userIdFor,
 } from '../../support/db'
+import { sharesDiskWithServer } from '../../support/files'
 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)
   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,
   setWorkshopSetting,
   userIdFor,
+  workshopSetting,
 } from '../../support/db'
 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)
-  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 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(onJob).toContainText('From the linked inspection, changed there')
   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.
-  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 }) => {
@@ -207,6 +213,28 @@ test.describe('the certificate', () => {
     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 }) => {
     const job = await plantConditionJob(organizationId, userId, `E2E cert bare ${stamp}`)
     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(
       '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",
   "attachPdfHint": "Ausschalten, um stattdessen einen Link zu senden, damit Sie sehen, wann der Kunde ihn öffnet.",
   "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",
   "attachPdfHint": "Turn this off to send a link instead, so you can see when the customer opens it.",
   "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",
   "attachPdfHint": "Desactívalo para enviar un enlace en su lugar y ver cuándo lo abre el cliente.",
   "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",
   "attachPdfHint": "Désactivez pour envoyer un lien à la place, afin de voir quand le client l'ouvre.",
   "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",
   "attachPdfHint": "Disattiva per inviare invece un link, così vedi quando il cliente lo apre.",
   "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",
   "attachPdfHint": "Išjunkite, kad būtų siunčiama nuoroda, tada matysite, kada klientas ją atidarė.",
   "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",
   "attachPdfHint": "Slå av for å sende en lenke i stedet, så ser du når kunden åpner den.",
   "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",
   "attachPdfHint": "Zet dit uit om in plaats daarvan een link te sturen, zodat je ziet wanneer de klant hem opent.",
   "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",
   "attachPdfHint": "Wyłącz, aby wysłać zamiast tego link i widzieć, kiedy klient go otworzy.",
   "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",
   "attachPdfHint": "Desligue para enviar um link, assim você vê quando o cliente abre.",
   "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",
   "attachPdfHint": "Desative para enviar um link em vez do anexo e assim saber quando o cliente o abre.",
   "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",
   "attachPdfHint": "Отключите, чтобы отправить ссылку вместо файла и видеть, когда клиент её открыл.",
   "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",
   "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": {
-    "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.
     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.
     memberSignature: { findFirst: vi.fn().mockResolvedValue(null) },
   },
@@ -82,6 +87,7 @@ function setupOrgAOwner() {
 
 beforeEach(() => {
   vi.resetAllMocks()
+  vi.mocked(db.documentDesignSnapshot.findUnique).mockResolvedValue({ id: 'snap-1' } as any)
 })
 
 describe('getInspection — cross-org isolation', () => {
@@ -176,7 +182,7 @@ describe('completeInspection — cross-org isolation', () => {
       items: [],
     } 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)
 
     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.
     conditionMarkType: { 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.
     memberSignature: { findFirst: vi.fn().mockResolvedValue(null) },
     quote: {
@@ -130,6 +135,7 @@ const ORG_A_INSPECTION = {
 
 beforeEach(() => {
   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
   // rows means locking is off, which is how these tests expect to run.
   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
         ? loadVehicleConditionMarks(authContext.organizationId, result.data.vehicleId)
         : [],
-      ,
       hasConditionMap && authContext?.organizationId
         ? markTypeCatalogue(authContext.organizationId, await getLocale())
         : [],

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

@@ -1,4 +1,6 @@
 import { redirect } from 'next/navigation'
+import { defaultCertificateDesign } from '@/features/inspections/Lib/defaultCertificateDesign'
+
 import { db } from '@/lib/db'
 import { getLayoutData } from '@/lib/get-layout-data'
 import { getFeatures } from '@/lib/features'
@@ -17,7 +19,11 @@ import { InvoiceDesigner } from '@/features/invoice-designer/Components/InvoiceD
 import { DismissOnArrival } from '@/components/feature-hint'
 import { INVOICE_DESIGNER_ANNOUNCEMENT, parseHintIds } from '@/features/settings/Lib/featureHints'
 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 { getOrgTelegramBotUsername } from '@/lib/telegram'
 import { memberSignatureDataUri } from '@/features/signatures/Lib/memberSignature.server'
@@ -147,11 +153,35 @@ export default async function InvoiceDesignerPage({
         }}
         invoiceLayout={invoiceLayout.success ? invoiceLayout.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}
         invoiceTemplate={templateFor('invoice')}
         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')}
         initialSavedDesigns={savedDesigns}
         telegramBotLink={telegramBotUsername ? botLinkOf(telegramBotUsername) : undefined}

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

@@ -1,6 +1,16 @@
 'use client'
 
 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 { cn } from '@/lib/utils'
 
@@ -31,3 +41,35 @@ export function SentCopyNote({
     </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 { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { assertOwnUploads } from '@/lib/upload-url'
+import { releaseFiles } from '@/lib/files/manager'
 import { BODY_TYPES } from '../Lib/drawingTypes'
 import {
   type ConditionMarkData,
@@ -204,6 +205,9 @@ export async function removeConditionMark(id: string) {
       const mark = await ownMark(organizationId, id)
       await assertSheetOpen(organizationId, mark)
       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)
       return { id, vehicleId: mark.vehicleId }
     },
@@ -276,13 +280,19 @@ export async function addConditionMarkPhotos(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(
     async ({ organizationId }): Promise<string[]> => {
       const mark = await ownMark(organizationId, id)
       await assertSheetOpen(organizationId, mark)
       const next = mark.imageUrls.filter((u) => u !== url)
       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)
       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 {
   addConditionMark,
   addConditionMarkPhotos,
+  discardConditionMarkUploads,
   removeConditionMark,
   removeConditionMarkPhoto,
   resolveConditionMark,
@@ -241,6 +242,8 @@ export function ConditionMapCard({
       const result = await addConditionMarkPhotos({ id: editingId, urls: batch })
       if (!result.success || !result.data) {
         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
       }
       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 MarkType, markTypeOf } from '../Lib/markTypes'
 import { MarkIcon } from './ConditionMap'
+import { useNativeCamera } from '@/hooks/use-native-camera'
 
 export interface EditableMark {
   id: string
@@ -63,6 +64,7 @@ export function MarkEditor({
   onClose: () => void
 }) {
   const t = useTranslations('conditionMap')
+  const nativeCamera = useNativeCamera()
   // 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.
   const offered = types.filter((type) => !type.hidden || type.key === mark?.kind)
@@ -234,20 +236,23 @@ export function MarkEditor({
                 ))}
                 {!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
                       type="button"
                       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
       // 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({
         where: { organizationId, key: { startsWith: 'certificate.' } },
         select: { key: true, value: true },
@@ -494,9 +493,7 @@ export async function completeInspection(id: string) {
       const certificateSettings: Record<string, string> = {}
       for (const row of settingRows) certificateSettings[row.key] = row.value
       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.
       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 { 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 {
   type DesignSource,
   designSourceFromSettings,
@@ -19,10 +23,16 @@ import { inspectionPrintLabels } from '../Lib/inspectionLabels'
  * 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(
   organizationId: string,
-  inspection: { designSnapshotId: string | null },
+  inspection: { designSnapshotId: string | null; completedAt?: Date | null },
   settingsMap: Record<string, string>
 ): Promise<DesignSource | null> {
   if (inspection.designSnapshotId) {
@@ -33,13 +43,35 @@ export async function certificateDesignSource(
     const frozen = snapshot ? designSourceFromSnapshot(snapshot.layout, snapshot.template) : null
     if (frozen) return frozen
   }
+  if (inspection.completedAt) {
+    const live = designSourceFromSettings(settingsMap, 'certificate')
+    return isDesignerLayout(live.layout) ? live : null
+  }
   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')
-  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) {

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

@@ -22,6 +22,8 @@ import {
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/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 { Label } from '@/components/ui/label'
 import { Camera, Loader2, Upload, Video, Send, X } from 'lucide-react'
@@ -62,6 +64,9 @@ export function CreateStatusReportDialog({
   const isMobile = useIsMobile()
   const fileInputRef = 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 [message, setMessage] = useState('')
   const [videoUrl, setVideoUrl] = useState<string | null>(null)
@@ -200,7 +205,9 @@ export function CreateStatusReportDialog({
               type="button"
               variant="outline"
               size="sm"
-              onClick={() => captureInputRef.current?.click()}
+              onClick={() =>
+                nativeCamera ? captureInputRef.current?.click() : setRecorderOpen(true)
+              }
             >
               <Camera className="mr-1.5 h-4 w-4" />
               {t('record')}
@@ -270,6 +277,11 @@ export function CreateStatusReportDialog({
         <Label htmlFor="sr-expires">{t('expiresLabel')}</Label>
         <DateInput id="sr-expires" value={expiresAt} onChange={setExpiresAt} />
       </div>
+      <VideoRecorderDialog
+        open={recorderOpen}
+        onOpenChange={setRecorderOpen}
+        onRecorded={(file) => void handleFileUpload(file)}
+      />
     </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 { Paperclip } from 'lucide-react'
 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 { MediaGrid } from './MediaGrid'
 import { PhotoHandoffButton } from '../PhotoHandoffButton'
@@ -98,6 +98,14 @@ export function FilesMediaCard({
     conditionMap?.onInvoice ?? conditionMap?.byDesign ?? 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 before = onInvoice
     setOnInvoice(next)
@@ -243,7 +251,7 @@ export function FilesMediaCard({
                       className="mt-0.5"
                       checked={onInvoice}
                       disabled={savingOnInvoice}
-                      onCheckedChange={(checked) => void toggleOnInvoice(checked === true)}
+                      onCheckedChange={(checked) => requestOnInvoice(checked === true)}
                       data-testid="condition-map-on-invoice"
                     />
                     <span className="min-w-0">
@@ -256,6 +264,15 @@ export function FilesMediaCard({
                       <SentCopyNote sentAt={conditionMap.sentAt} className="mt-1" />
                     </span>
                   </label>
+                  <SentCopyConfirm
+                    open={pendingOnInvoice !== null}
+                    onConfirm={() => {
+                      const next = pendingOnInvoice
+                      setPendingOnInvoice(null)
+                      if (next !== null) void toggleOnInvoice(next)
+                    }}
+                    onCancel={() => setPendingOnInvoice(null)}
+                  />
                 </div>
               )}
               <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
+}