Bernt Christian Egeland 1 日 前
親
コミット
ae914906e5
46 ファイル変更、1276 行追加、34 行削除
  1. 240 0
      e2e/specs/condition-map/dropoff.spec.ts
  2. 219 0
      e2e/specs/condition-map/inspection.spec.ts
  3. 200 0
      e2e/specs/condition-map/printing.spec.ts
  4. 100 0
      e2e/support/condition-map.ts
  5. 211 0
      e2e/support/db.ts
  6. 4 1
      messages/de/common.json
  7. 2 0
      messages/de/conditionMap.json
  8. 4 1
      messages/en/common.json
  9. 2 0
      messages/en/conditionMap.json
  10. 4 1
      messages/es/common.json
  11. 2 0
      messages/es/conditionMap.json
  12. 4 1
      messages/fr/common.json
  13. 2 0
      messages/fr/conditionMap.json
  14. 4 1
      messages/it/common.json
  15. 2 0
      messages/it/conditionMap.json
  16. 4 1
      messages/lt/common.json
  17. 2 0
      messages/lt/conditionMap.json
  18. 4 1
      messages/nb/common.json
  19. 2 0
      messages/nb/conditionMap.json
  20. 4 1
      messages/nl/common.json
  21. 2 0
      messages/nl/conditionMap.json
  22. 4 1
      messages/pl/common.json
  23. 2 0
      messages/pl/conditionMap.json
  24. 4 1
      messages/pt-BR/common.json
  25. 2 0
      messages/pt-BR/conditionMap.json
  26. 4 1
      messages/pt-PT/common.json
  27. 2 0
      messages/pt-PT/conditionMap.json
  28. 4 1
      messages/ru/common.json
  29. 2 0
      messages/ru/conditionMap.json
  30. 4 1
      messages/tr/common.json
  31. 2 0
      messages/tr/conditionMap.json
  32. 50 0
      src/__tests__/features/condition-map/marks.test.ts
  33. 33 0
      src/components/sent-copy-note.tsx
  34. 23 3
      src/features/condition-map/Actions/conditionMarkActions.ts
  35. 20 6
      src/features/condition-map/Components/ConditionMapCard.tsx
  36. 6 1
      src/features/condition-map/Components/MarkEditor.tsx
  37. 12 2
      src/features/condition-map/Lib/loadMarks.server.ts
  38. 35 4
      src/features/condition-map/Lib/marks.ts
  39. 4 1
      src/features/inspections/Components/InspectionItemRow.tsx
  40. 1 0
      src/features/inspections/Components/InspectionPageClient.tsx
  41. 26 3
      src/features/inspections/Pdf/buildCertificatePrint.ts
  42. 7 1
      src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts
  43. 2 0
      src/features/invoices/Lib/assembleWorkOrderPrint.ts
  44. 2 0
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  45. 3 0
      src/features/vehicles/Components/service-page/modern/FilesMediaCard.tsx
  46. 4 0
      src/features/vehicles/Components/service-page/service-page-types.ts

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

@@ -0,0 +1,240 @@
+import { expect, test } from '@playwright/test'
+import {
+  addMark,
+  closeEditor,
+  earlierSection,
+  markEditor,
+  openDropoff,
+  ownMarks,
+} from '../../support/condition-map'
+import {
+  conditionMarksOf,
+  ownerOrganizationId,
+  plantConditionJob,
+  plantConditionMark,
+  plantJobOnVehicle,
+  plantOwnMarkKind,
+  removeOwnMarkKind,
+  userIdFor,
+} from '../../support/db'
+import { TINY_PNG } from '../../support/pdf'
+
+/**
+ * The drop-off on a work order: the car's condition as it came in, drawn on
+ * the Drop-off tab of the files card. Marks belong to the vehicle, so a mark
+ * from an earlier visit shows grey on the next job, and is cleared there once
+ * it has been repaired.
+ *
+ * Every test plants a car of its own, so nothing another spec or an earlier
+ * run drew is on it.
+ */
+
+const stamp = Date.now()
+let organizationId = ''
+let userId = ''
+
+const DAY = 24 * 60 * 60 * 1000
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  userId = await userIdFor('demo@torqvoice.com')
+})
+
+const jobUrl = (job: { vehicleId: string; serviceRecordId: string }) =>
+  `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`
+
+test('a click on the drawing records a mark, and what is said about it stays', async ({ page }) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E drop-off ${stamp}`)
+  const map = await openDropoff(page, jobUrl(job))
+  await expect(ownMarks(map)).toHaveCount(0)
+
+  const editor = await addMark(page, map, 'left', 'left_front_door')
+  // The click made the mark with the first kind on offer, on the panel clicked.
+  await expect(editor.getByRole('heading')).toContainText('Dent on the Left front door')
+
+  await editor.getByRole('group', { name: 'Type' }).getByRole('button', { name: 'Scratch' }).click()
+  await expect(
+    editor.getByRole('group', { name: 'Type' }).getByRole('button', { name: 'Scratch' })
+  ).toHaveAttribute('aria-pressed', 'true')
+  await editor
+    .getByRole('group', { name: 'Severity' })
+    .getByRole('button', { name: 'Major' })
+    .click()
+  await expect(
+    editor.getByRole('group', { name: 'Severity' }).getByRole('button', { name: 'Major' })
+  ).toHaveAttribute('aria-pressed', 'true')
+  await editor.getByLabel('Note').fill('Long scratch under the handle')
+  await editor.locator('input[type="file"][multiple]').setInputFiles({
+    name: `e2e-scratch-${stamp}.png`,
+    mimeType: 'image/png',
+    buffer: TINY_PNG,
+  })
+  await expect(editor.getByRole('img', { name: /^Photos of mark 1/ })).toHaveCount(1, {
+    timeout: 30_000,
+  })
+  await closeEditor(page)
+
+  await expect(ownMarks(map)).toHaveCount(1)
+  await expect(ownMarks(map).first()).toHaveAccessibleName(
+    /^Mark 1: Scratch on the Left front door/
+  )
+
+  // What the screen says is what was kept.
+  const [row] = await conditionMarksOf(job.vehicleId)
+  expect(row).toMatchObject({
+    serviceRecordId: job.serviceRecordId,
+    inspectionId: null,
+    view: 'left',
+    panel: 'left_front_door',
+    kind: 'scratch',
+    severity: 'major',
+    note: 'Long scratch under the handle',
+    resolvedAt: null,
+  })
+  expect(row.imageUrls).toHaveLength(1)
+
+  // And a reload draws the same thing, with the tab counting it.
+  const again = await openDropoff(page, jobUrl(job))
+  await expect(ownMarks(again)).toHaveCount(1)
+  await expect(ownMarks(again).first()).toContainText('Long scratch under the handle')
+  await expect(page.getByTestId('condition-map-photos')).toBeVisible()
+})
+
+test('a mark removed is gone from the job and from the vehicle', async ({ page }) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E drop-off remove ${stamp}`)
+  const map = await openDropoff(page, jobUrl(job))
+
+  await addMark(page, map, 'top', 'hood')
+  await closeEditor(page)
+  await addMark(page, map, 'left', 'left_rear_door')
+  await closeEditor(page)
+  await expect(ownMarks(map)).toHaveCount(2)
+
+  await ownMarks(map).first().click()
+  const editor = markEditor(page)
+  await expect(editor).toBeVisible()
+  await editor.getByRole('button', { name: 'Remove mark' }).click()
+  await expect(editor).toBeHidden()
+  await expect(ownMarks(map)).toHaveCount(1)
+
+  expect(await conditionMarksOf(job.vehicleId)).toHaveLength(1)
+  const again = await openDropoff(page, jobUrl(job))
+  await expect(ownMarks(again)).toHaveCount(1)
+  await expect(ownMarks(again).first()).toHaveAccessibleName(/on the Left rear door/)
+})
+
+test.describe('marks from an earlier visit', () => {
+  test('are grey, cannot be changed from here, and clear as repaired', async ({ page }) => {
+    const earlier = await plantConditionJob(
+      organizationId,
+      userId,
+      `E2E earlier visit ${stamp}`,
+      new Date(Date.now() - 30 * DAY)
+    )
+    await plantConditionMark(
+      organizationId,
+      earlier.vehicleId,
+      { serviceRecordId: earlier.serviceRecordId },
+      { view: 'top', panel: 'hood', kind: 'dent', recordedAt: new Date(Date.now() - 30 * DAY) }
+    )
+    // Today's job on the same car.
+    const today = await plantJobOnVehicle(organizationId, earlier.vehicleId, `E2E next ${stamp}`)
+
+    const map = await openDropoff(page, jobUrl(today))
+    await expect(ownMarks(map)).toHaveCount(0)
+    const grey = earlierSection(map)
+    await expect(grey).toContainText('Bonnet')
+
+    // Opened, it is read only: no kind, severity or note to change, no removing it.
+    await grey.getByRole('button', { name: /^Mark 1:/ }).click()
+    const editor = markEditor(page)
+    await expect(editor).toBeVisible()
+    await expect(editor.getByRole('button', { name: 'Scratch' })).toBeDisabled()
+    await expect(editor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
+    await expect(editor).toContainText('Recorded on an earlier visit. Clear it as repaired')
+    await page.keyboard.press('Escape')
+    await expect(editor).toBeHidden()
+
+    await grey.getByRole('button', { name: 'Clear as repaired' }).click()
+    const confirm = page.getByRole('alertdialog', { name: 'Clear as repaired' })
+    await confirm.getByRole('button', { name: 'Clear as repaired' }).click()
+    await expect(confirm).toBeHidden()
+    await expect(earlierSection(map)).toHaveCount(0)
+
+    // Kept in the history, and gone from the page for good.
+    const [row] = await conditionMarksOf(earlier.vehicleId)
+    expect(row.resolvedAt).not.toBeNull()
+    const again = await openDropoff(page, jobUrl(today))
+    await expect(earlierSection(again)).toHaveCount(0)
+  })
+
+  test('only means earlier: a later visit’s mark is not on an older job', async ({ page }) => {
+    const older = await plantConditionJob(
+      organizationId,
+      userId,
+      `E2E older visit ${stamp}`,
+      new Date(Date.now() - 60 * DAY)
+    )
+    await plantConditionMark(
+      organizationId,
+      older.vehicleId,
+      { serviceRecordId: older.serviceRecordId },
+      { view: 'top', panel: 'hood', recordedAt: new Date(Date.now() - 60 * DAY) }
+    )
+    // A later job on the same car found a dent the older one never saw.
+    const later = await plantJobOnVehicle(organizationId, older.vehicleId, `E2E later ${stamp}`)
+    await plantConditionMark(
+      organizationId,
+      older.vehicleId,
+      { serviceRecordId: later.serviceRecordId },
+      { view: 'rear', panel: 'rear_bumper' }
+    )
+
+    const map = await openDropoff(page, jobUrl(older))
+    await expect(ownMarks(map)).toHaveCount(1)
+    await expect(earlierSection(map)).toHaveCount(0)
+    // Not listed anywhere on it: the drawing's own caption still names the panel.
+    await expect(map.getByRole('button', { name: /on the Rear bumper/ })).toHaveCount(0)
+  })
+})
+
+test('a kind of the workshop’s own can be put on a mark', async ({ page }) => {
+  const own = await plantOwnMarkKind(organizationId, `E2E Hail ${stamp}`)
+  try {
+    const job = await plantConditionJob(organizationId, userId, `E2E own kind ${stamp}`)
+    const map = await openDropoff(page, jobUrl(job))
+    const editor = await addMark(page, map, 'top', 'roof')
+    const type = editor.getByRole('group', { name: 'Type' })
+    await type.getByRole('button', { name: own.name }).click()
+    await expect(type.getByRole('button', { name: own.name })).toHaveAttribute(
+      'aria-pressed',
+      'true'
+    )
+    await closeEditor(page)
+    await expect(ownMarks(map).first()).toHaveAccessibleName(new RegExp(own.name))
+
+    const [row] = await conditionMarksOf(job.vehicleId)
+    expect(row.kind).toBe(own.key)
+  } finally {
+    await removeOwnMarkKind(own.id)
+  }
+})
+
+test('a dozen photos at once all reach the mark', async ({ page }) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E many photos ${stamp}`)
+  const map = await openDropoff(page, jobUrl(job))
+  const editor = await addMark(page, map, 'front', 'front_bumper')
+  await editor.locator('input[type="file"][multiple]').setInputFiles(
+    Array.from({ length: 12 }, (_, i) => ({
+      name: `e2e-bumper-${stamp}-${i}.png`,
+      mimeType: 'image/png',
+      buffer: TINY_PNG,
+    }))
+  )
+  await expect(editor.getByRole('img', { name: /^Photos of mark 1/ })).toHaveCount(12, {
+    timeout: 60_000,
+  })
+  await closeEditor(page)
+  const [row] = await conditionMarksOf(job.vehicleId)
+  expect(row.imageUrls).toHaveLength(12)
+})

+ 219 - 0
e2e/specs/condition-map/inspection.spec.ts

@@ -0,0 +1,219 @@
+import { expect, type Page, test } from '@playwright/test'
+import {
+  addMark,
+  certificatePdf,
+  closeEditor,
+  conditionMap,
+  earlierSection,
+  markEditor,
+  openDropoff,
+  ownMarks,
+  printsMap,
+} from '../../support/condition-map'
+import {
+  conditionMarksOf,
+  forgetWorkshopSetting,
+  ownerOrganizationId,
+  plantConditionInspection,
+  plantConditionJob,
+  plantConditionMark,
+  plantJobOnVehicle,
+  setWorkshopSetting,
+  userIdFor,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+
+/**
+ * The condition map as an inspection check, and how it meets the work order.
+ *
+ * Marks drawn on the inspection are this visit on the job it is linked to
+ * (shown there in colour, changed only on the inspection), and a job's
+ * drop-off marks are grey on the inspection. A completed inspection is a
+ * finished document: its map can no longer be changed, and its certificate
+ * keeps what it said when a mark is later cleared as repaired.
+ *
+ * The certificate only prints the map from a designed certificate, so the
+ * file gives the workshop one and takes it away again afterwards.
+ */
+
+const stamp = Date.now()
+const DESIGN_KEY = 'certificate.layoutConfig'
+let organizationId = ''
+let userId = ''
+let designBefore: string | null = null
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  userId = await userIdFor('demo@torqvoice.com')
+  designBefore = await setWorkshopSetting(
+    organizationId,
+    DESIGN_KEY,
+    JSON.stringify({ version: 2, documentType: 'certificate' })
+  )
+})
+
+test.afterAll(async () => {
+  if (designBefore === null) await forgetWorkshopSetting(organizationId, DESIGN_KEY)
+  else await setWorkshopSetting(organizationId, DESIGN_KEY, designBefore)
+})
+
+const jobUrl = (job: { vehicleId: string; serviceRecordId: string }) =>
+  `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`
+
+async function openInspection(page: Page, inspectionId: string) {
+  await page.goto(`/inspections/${inspectionId}`)
+  await settle(page)
+  const map = conditionMap(page)
+  await expect(map).toBeVisible({ timeout: 30_000 })
+  return map
+}
+
+async function complete(page: Page) {
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Complete', exact: true }).first().click()
+    await expect(page.getByRole('alertdialog', { name: 'Complete this inspection?' })).toBeVisible({
+      timeout: 2_000,
+    })
+  }).toPass({ timeout: 30_000 })
+  const confirm = page.getByRole('alertdialog', { name: 'Complete this inspection?' })
+  await confirm.getByRole('button', { name: 'Complete', exact: true }).click()
+  await expect(page.getByText('Inspection completed').first()).toBeVisible()
+  // Completing offers to share the certificate straight away; not today.
+  const share = page.getByRole('dialog').filter({ hasText: 'Share a read-only view' })
+  const offered = await share
+    .waitFor({ state: 'visible', timeout: 5_000 })
+    .then(() => true)
+    .catch(() => false)
+  if (offered) {
+    await page.keyboard.press('Escape')
+    await expect(share).toBeHidden()
+  }
+}
+
+test('a mark drawn on the inspection is on the linked job, where it cannot be changed', async ({
+  page,
+}) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E linked ${stamp}`)
+  const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`, {
+    serviceRecordId: job.serviceRecordId,
+  })
+
+  const map = await openInspection(page, inspection.inspectionId)
+  await addMark(page, map, 'right', 'right_front_door')
+  await closeEditor(page)
+  await expect(ownMarks(map)).toHaveCount(1)
+
+  const [row] = await conditionMarksOf(job.vehicleId)
+  expect(row).toMatchObject({
+    inspectionId: inspection.inspectionId,
+    serviceRecordId: null,
+    panel: 'right_front_door',
+  })
+
+  // On the job it is this visit: in colour, said to come from the inspection.
+  const onJob = await openDropoff(page, jobUrl(job))
+  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)
+  // 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')
+})
+
+test('a job’s drop-off mark is grey on the inspection', async ({ page }) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E dropoff grey ${stamp}`)
+  await plantConditionMark(
+    organizationId,
+    job.vehicleId,
+    { serviceRecordId: job.serviceRecordId },
+    { view: 'top', panel: 'hood', recordedAt: new Date(Date.now() - 60_000) }
+  )
+  const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`, {
+    serviceRecordId: job.serviceRecordId,
+  })
+
+  const map = await openInspection(page, inspection.inspectionId)
+  await expect(ownMarks(map)).toHaveCount(0)
+  await expect(earlierSection(map)).toContainText('Bonnet')
+})
+
+test('a completed inspection’s map cannot be changed until it is reopened', async ({ page }) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E completed ${stamp}`)
+  const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`)
+  await plantConditionMark(organizationId, job.vehicleId, inspection, {
+    view: 'left',
+    panel: 'left_rear_door',
+  })
+
+  const map = await openInspection(page, inspection.inspectionId)
+  await complete(page)
+  await expect(map).toContainText('Reopen to change the condition map.')
+
+  // A click on the drawing adds nothing.
+  await map.locator('path[data-view="top"][data-panel="roof"]').click()
+  await expect(markEditor(page)).toBeHidden()
+  expect(await conditionMarksOf(job.vehicleId)).toHaveLength(1)
+})
+
+test.describe('the certificate', () => {
+  test('prints the inspection’s marks', async ({ page }) => {
+    const job = await plantConditionJob(organizationId, userId, `E2E cert ${stamp}`)
+    const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`)
+    await plantConditionMark(organizationId, job.vehicleId, inspection, {
+      view: 'left',
+      panel: 'left_front_door',
+      note: 'Dent by the handle',
+    })
+
+    const pdf = await certificatePdf(page, inspection.inspectionId)
+    expect(printsMap(pdf)).toBe(true)
+    expect(pdf.flat).toContain('Left front door')
+    expect(pdf.flat).toContain('Dent by the handle')
+  })
+
+  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(
+      organizationId,
+      job.vehicleId,
+      { serviceRecordId: job.serviceRecordId },
+      { view: 'top', panel: 'hood', recordedAt: new Date(Date.now() - 60_000) }
+    )
+    const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`)
+
+    const pdf = await certificatePdf(page, inspection.inspectionId)
+    expect(printsMap(pdf)).toBe(false)
+    expect(pdf.flat).not.toContain('Bonnet')
+  })
+
+  test('keeps what it said when a mark is later cleared as repaired', async ({ page }) => {
+    const job = await plantConditionJob(organizationId, userId, `E2E cert frozen ${stamp}`)
+    const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`)
+    await plantConditionMark(organizationId, job.vehicleId, inspection, {
+      view: 'left',
+      panel: 'left_front_door',
+      recordedAt: new Date(Date.now() - 60_000),
+    })
+    await openInspection(page, inspection.inspectionId)
+    await complete(page)
+    expect((await certificatePdf(page, inspection.inspectionId)).flat).toContain('Left front door')
+
+    // The next job finds it repaired and clears it: allowed, and the history keeps it.
+    const nextJob = await plantJobOnVehicle(organizationId, job.vehicleId, `E2E next ${stamp}`)
+    const next = await openDropoff(page, jobUrl(nextJob))
+    const grey = earlierSection(next)
+    await grey.getByRole('button', { name: 'Clear as repaired' }).click()
+    await page
+      .getByRole('alertdialog', { name: 'Clear as repaired' })
+      .getByRole('button', { name: 'Clear as repaired' })
+      .click()
+    await expect(earlierSection(next)).toHaveCount(0)
+
+    const after = await certificatePdf(page, inspection.inspectionId)
+    expect(printsMap(after), 'the completed certificate still has its map').toBe(true)
+    expect(after.flat).toContain('Left front door')
+  })
+})

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

@@ -0,0 +1,200 @@
+import { expect, test } from '@playwright/test'
+import {
+  addMark,
+  closeEditor,
+  invoicePdf,
+  openDropoff,
+  printsMap,
+  workOrderPdf,
+} from '../../support/condition-map'
+import {
+  conditionMapOnInvoice,
+  ownerOrganizationId,
+  plantConditionInspection,
+  plantConditionJob,
+  plantConditionMark,
+  plantJobOnVehicle,
+  userIdFor,
+} from '../../support/db'
+import { shareLink } from '../../support/work-order'
+
+/**
+ * Where the condition map prints, and what it prints there.
+ *
+ * - The work order prints the job's own marks, earlier ones grey beside them,
+ *   and nothing at all when the job recorded none of its own.
+ * - The invoice prints this visit only (the drop-off and the linked
+ *   inspection), is off unless the design or the job's own switch turns it on,
+ *   and once sent it is the copy the customer holds: a later change on the job
+ *   reaches it only when it is sent again.
+ *
+ * Read from the PDFs themselves, as text. Panel names are the app's English
+ * ("Bonnet" for the hood).
+ */
+
+const stamp = Date.now()
+const DAY = 24 * 60 * 60 * 1000
+let organizationId = ''
+let userId = ''
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  userId = await userIdFor('demo@torqvoice.com')
+})
+
+const jobUrl = (job: { vehicleId: string; serviceRecordId: string }) =>
+  `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`
+
+/** A car with a dent on the bonnet from a visit a month ago, and today's job on it. */
+async function carWithHistory(label: string) {
+  const earlier = await plantConditionJob(
+    organizationId,
+    userId,
+    `${label} earlier`,
+    new Date(Date.now() - 30 * DAY)
+  )
+  await plantConditionMark(
+    organizationId,
+    earlier.vehicleId,
+    { serviceRecordId: earlier.serviceRecordId },
+    { view: 'top', panel: 'hood', recordedAt: new Date(Date.now() - 30 * DAY) }
+  )
+  const today = await plantJobOnVehicle(organizationId, earlier.vehicleId, label)
+  return today
+}
+
+test.describe('the work order', () => {
+  test('prints the job’s own marks, with earlier ones marked as such', async ({ page }) => {
+    const job = await carWithHistory(`E2E WO print ${stamp}`)
+    await plantConditionMark(
+      organizationId,
+      job.vehicleId,
+      { serviceRecordId: job.serviceRecordId },
+      { view: 'left', panel: 'left_front_door', kind: 'scratch', note: 'Keyed along the door' }
+    )
+
+    const pdf = await workOrderPdf(page, job.serviceRecordId)
+    expect(printsMap(pdf), 'the section is there').toBe(true)
+    expect(pdf.flat).toContain('Left front door')
+    expect(pdf.flat).toContain('Keyed along the door')
+    expect(pdf.flat).toContain('Bonnet (recorded earlier)')
+  })
+
+  test('prints nothing when the job recorded no mark of its own', async ({ page }) => {
+    const job = await carWithHistory(`E2E WO bare ${stamp}`)
+    const pdf = await workOrderPdf(page, job.serviceRecordId)
+    expect(printsMap(pdf)).toBe(false)
+    expect(pdf.flat).not.toContain('Bonnet')
+  })
+
+  test('prints no mark from a later visit on an older job', async ({ page }) => {
+    const older = await plantConditionJob(
+      organizationId,
+      userId,
+      `E2E WO older ${stamp}`,
+      new Date(Date.now() - 60 * DAY)
+    )
+    await plantConditionMark(
+      organizationId,
+      older.vehicleId,
+      { serviceRecordId: older.serviceRecordId },
+      { view: 'left', panel: 'left_front_door', recordedAt: new Date(Date.now() - 60 * DAY) }
+    )
+    const later = await plantJobOnVehicle(organizationId, older.vehicleId, `E2E WO later ${stamp}`)
+    await plantConditionMark(
+      organizationId,
+      older.vehicleId,
+      { serviceRecordId: later.serviceRecordId },
+      { view: 'rear', panel: 'rear_bumper' }
+    )
+
+    const pdf = await workOrderPdf(page, older.serviceRecordId)
+    expect(printsMap(pdf)).toBe(true)
+    expect(pdf.flat).toContain('Left front door')
+    expect(pdf.flat, 'a dent found later was not there when this job was done').not.toContain(
+      'Rear bumper'
+    )
+  })
+})
+
+test.describe('the invoice', () => {
+  test('leaves the map off until the job’s switch puts it on, and then prints this visit only', async ({
+    page,
+  }) => {
+    const job = await carWithHistory(`E2E invoice map ${stamp}`)
+    await plantConditionMark(
+      organizationId,
+      job.vehicleId,
+      { serviceRecordId: job.serviceRecordId },
+      { view: 'left', panel: 'left_front_door' }
+    )
+    // The linked inspection's marks are this visit too.
+    const inspection = await plantConditionInspection(
+      organizationId,
+      job.vehicleId,
+      `E2E ${stamp}`,
+      {
+        serviceRecordId: job.serviceRecordId,
+      }
+    )
+    await plantConditionMark(organizationId, job.vehicleId, inspection, {
+      view: 'top',
+      panel: 'roof',
+    })
+
+    expect(printsMap(await invoicePdf(page, job.serviceRecordId)), 'off by default').toBe(false)
+
+    await openDropoff(page, jobUrl(job))
+    const onInvoice = page.getByTestId('condition-map-on-invoice')
+    await expect(onInvoice).not.toBeChecked()
+    await expect(async () => {
+      await onInvoice.click()
+      await expect(onInvoice).toBeChecked({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await expect.poll(() => conditionMapOnInvoice(job.serviceRecordId)).toBe(true)
+
+    const pdf = await invoicePdf(page, job.serviceRecordId)
+    expect(printsMap(pdf)).toBe(true)
+    expect(pdf.flat).toContain('Left front door')
+    expect(pdf.flat, 'the linked inspection’s mark').toContain('Roof')
+    expect(pdf.flat, 'an earlier visit is not billed here').not.toContain('Bonnet')
+    expect(pdf.flat).not.toContain('recorded earlier')
+  })
+
+  test('once sent, keeps the map it was sent with until it is sent again', async ({ page }) => {
+    const job = await plantConditionJob(organizationId, userId, `E2E sent map ${stamp}`)
+    await plantConditionMark(
+      organizationId,
+      job.vehicleId,
+      { serviceRecordId: job.serviceRecordId },
+      { view: 'left', panel: 'left_front_door' }
+    )
+
+    await openDropoff(page, jobUrl(job))
+    const onInvoice = page.getByTestId('condition-map-on-invoice')
+    await expect(async () => {
+      await onInvoice.click()
+      await expect(onInvoice).toBeChecked({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await expect.poll(() => conditionMapOnInvoice(job.serviceRecordId)).toBe(true)
+
+    // Sent: the share link issues the invoice.
+    await shareLink(page)
+    await page.keyboard.press('Escape')
+
+    // A dent found after it went out is the job's, not the sent invoice's.
+    const again = await openDropoff(page, jobUrl(job))
+    await addMark(page, again, 'top', 'trunk')
+    await closeEditor(page)
+    const sent = await invoicePdf(page, job.serviceRecordId)
+    expect(printsMap(sent)).toBe(true)
+    expect(sent.flat).toContain('Left front door')
+    expect(sent.flat, 'added after it was sent').not.toContain('Boot lid')
+
+    // The switch stays usable, and says where its change will land.
+    await expect(onInvoice).toBeEnabled()
+    await expect(page.getByTestId('sent-copy-note')).toContainText(
+      'Changes here go on the invoice when it is sent again'
+    )
+  })
+})

+ 100 - 0
e2e/support/condition-map.ts

@@ -0,0 +1,100 @@
+import { expect, type Locator, type Page } from '@playwright/test'
+import { settle } from './hydration'
+import { type PdfContent, pdfContent } from './pdf'
+
+/**
+ * Driving the vehicle condition map: the drawing on a work order's Drop-off
+ * tab and on an inspection's condition map check, the mark editor, and the
+ * documents that print it.
+ *
+ * A mark is added by clicking a panel of the drawing. Every panel is an SVG
+ * path carrying its view and panel id, so a spec names the panel it means
+ * rather than aiming at pixels. The click creates the mark at once, with the
+ * first kind on offer, and opens the editor on it; the editor saves each
+ * change as it is made.
+ */
+
+/** The map card on the page (one per work order, one per map check). */
+export function conditionMap(page: Page): Locator {
+  return page.getByTestId('condition-map').first()
+}
+
+/** Opens the job's Drop-off tab and waits for its map. */
+export async function openDropoff(page: Page, jobUrl: string): Promise<Locator> {
+  await page.goto(jobUrl)
+  await settle(page)
+  const files = page.getByTestId('files-media')
+  const tab = files.getByRole('tab', { name: /^Drop-off/ })
+  await expect(async () => {
+    await tab.click()
+    await expect(tab).toHaveAttribute('aria-selected', 'true', { timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  const map = conditionMap(page)
+  await expect(map).toBeVisible()
+  return map
+}
+
+/** The count on the Drop-off tab: its photos and this visit's marks. */
+export function dropoffTab(page: Page): Locator {
+  return page.getByTestId('files-media').getByRole('tab', { name: /^Drop-off/ })
+}
+
+/** The mark editor, whichever mark it is open on. */
+export function markEditor(page: Page): Locator {
+  return page.getByRole('dialog').filter({ has: page.getByRole('group', { name: 'Severity' }) })
+}
+
+/**
+ * Clicks a panel of the drawing and returns the editor the new mark opens in.
+ * Repeated until the editor opens: a click before hydration does nothing.
+ */
+export async function addMark(
+  page: Page,
+  map: Locator,
+  view: 'top' | 'left' | 'right' | 'front' | 'rear',
+  panel: string
+): Promise<Locator> {
+  const target = map.locator(`path[data-view="${view}"][data-panel="${panel}"]`)
+  const editor = markEditor(page)
+  await expect(async () => {
+    await target.click()
+    await expect(editor).toBeVisible({ timeout: 3_000 })
+  }).toPass({ timeout: 30_000 })
+  return editor
+}
+
+/** Closes the editor through "Done", which also keeps the note. */
+export async function closeEditor(page: Page): Promise<void> {
+  const editor = markEditor(page)
+  await editor.getByRole('button', { name: 'Done', exact: true }).click()
+  await expect(editor).toBeHidden()
+}
+
+/** This visit's marks, as the legend lists them: "Mark 1: Dent on the Left front door". */
+export function ownMarks(map: Locator): Locator {
+  return map.getByRole('region', { name: 'Marks' }).getByRole('button', { name: /^Mark \d+:/ })
+}
+
+/** The marks from other visits, drawn grey. */
+export function earlierSection(map: Locator): Locator {
+  return map.getByRole('region', { name: 'Recorded on an earlier visit' })
+}
+
+/** A PDF the page's own session may fetch, read as text. */
+export async function pdfAt(page: Page, path: string): Promise<PdfContent> {
+  const response = await page.request.get(path)
+  expect(response.status(), `${path} answers`).toBe(200)
+  return pdfContent(await response.body())
+}
+
+export const workOrderPdf = (page: Page, jobId: string) =>
+  pdfAt(page, `/api/protected/services/${jobId}/work-order-pdf`)
+export const invoicePdf = (page: Page, jobId: string) =>
+  pdfAt(page, `/api/protected/services/${jobId}/pdf`)
+export const certificatePdf = (page: Page, inspectionId: string) =>
+  pdfAt(page, `/api/protected/inspections/${inspectionId}/pdf`)
+
+/** Whether a printed document carries the condition map section at all. */
+export function printsMap(pdf: PdfContent): boolean {
+  return /VEHICLE CONDITION/i.test(pdf.flat)
+}

+ 211 - 0
e2e/support/db.ts

@@ -1596,3 +1596,214 @@ export async function roleIdNamed(organizationId: string, name: string): Promise
     return result.rows[0]?.id ?? null
     return result.rows[0]?.id ?? null
   })
   })
 }
 }
+
+// ─── Condition map ───────────────────────────────────────────────────────────
+
+/** Where a planted mark was drawn: a job's drop-off, or one inspection check. */
+export type MarkSheet =
+  | { serviceRecordId: string }
+  | { inspectionId: string; inspectionItemId: string }
+
+export interface PlantedMark {
+  view?: 'top' | 'left' | 'right' | 'front' | 'rear'
+  panel?: string
+  kind?: string
+  severity?: 'minor' | 'major'
+  note?: string
+  /** When it was recorded; an earlier visit's mark is planted in the past. */
+  recordedAt?: Date
+  bodyType?: string
+}
+
+/**
+ * A job on a vehicle of its own, drawn as a sedan. Each spec plants its own
+ * car, so marks from another spec or an earlier run are never on it.
+ */
+export async function plantConditionJob(
+  organizationId: string,
+  userId: string,
+  title: string,
+  openedAt?: Date
+): Promise<{ serviceRecordId: string; vehicleId: string }> {
+  const job = await plantJob(organizationId, userId, title)
+  await withDb(async (db) => {
+    await db.query(`update vehicles set "bodyType" = 'sedan' where id = $1`, [job.vehicleId])
+    if (openedAt) {
+      await db.query(`update service_records set "createdAt" = $2 where id = $1`, [
+        job.serviceRecordId,
+        openedAt,
+      ])
+    }
+  })
+  return job
+}
+
+/**
+ * An inspection on the vehicle with one condition map check, from a template
+ * of its own (the seed makes none). With `serviceRecordId` the job is linked to
+ * it, the way "Start inspection" links them.
+ */
+export async function plantConditionInspection(
+  organizationId: string,
+  vehicleId: string,
+  label: string,
+  options: { serviceRecordId?: string; completed?: boolean; startedAt?: Date } = {}
+): Promise<{ inspectionId: string; inspectionItemId: string }> {
+  return withDb(async (db) => {
+    const id = () => randomBytes(12).toString('hex')
+    const templateId = id()
+    const sectionId = id()
+    const inspectionId = id()
+    const inspectionItemId = id()
+    await db.query(
+      `insert into inspection_templates (id, name, "organizationId", "updatedAt")
+       values ($1, $2, $3, now())`,
+      [templateId, `${label} checklist`, organizationId]
+    )
+    await db.query(
+      `insert into inspection_template_sections (id, name, "templateId") values ($1, 'Body', $2)`,
+      [sectionId, templateId]
+    )
+    await db.query(
+      `insert into inspection_template_items (id, name, "inputType", "sectionId")
+       values ($1, 'Condition map', 'condition_map', $2)`,
+      [id(), sectionId]
+    )
+    await db.query(
+      `insert into inspections (id, "vehicleId", "organizationId", "templateId", status, "completedAt", "createdAt", "updatedAt")
+       values ($1, $2, $3, $4, $5, $6, coalesce($7, now()), now())`,
+      [
+        inspectionId,
+        vehicleId,
+        organizationId,
+        templateId,
+        options.completed ? 'completed' : 'in_progress',
+        options.completed ? new Date() : null,
+        options.startedAt ?? null,
+      ]
+    )
+    await db.query(
+      `insert into inspection_items (id, "inspectionId", name, section, "inputType", condition)
+       values ($1, $2, 'Condition map', 'Body', 'condition_map', 'ok')`,
+      [inspectionItemId, inspectionId]
+    )
+    if (options.serviceRecordId) {
+      await db.query(`update service_records set "inspectionId" = $2 where id = $1`, [
+        options.serviceRecordId,
+        inspectionId,
+      ])
+    }
+    return { inspectionId, inspectionItemId }
+  })
+}
+
+/** One mark written straight into the table, as if drawn on that sheet. */
+export async function plantConditionMark(
+  organizationId: string,
+  vehicleId: string,
+  sheet: MarkSheet,
+  mark: PlantedMark = {}
+): Promise<string> {
+  return withDb(async (db) => {
+    const markId = randomBytes(12).toString('hex')
+    const onJob = 'serviceRecordId' in sheet
+    await db.query(
+      `insert into condition_marks
+         (id, "organizationId", "vehicleId", "serviceRecordId", "inspectionId", "inspectionItemId",
+          "bodyType", view, panel, x, y, kind, severity, note, "recordedAt", "updatedAt")
+       values ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0.5, 0.5, $10, $11, $12, coalesce($13, now()), now())`,
+      [
+        markId,
+        organizationId,
+        vehicleId,
+        onJob ? sheet.serviceRecordId : null,
+        onJob ? null : sheet.inspectionId,
+        onJob ? null : sheet.inspectionItemId,
+        mark.bodyType ?? 'sedan',
+        mark.view ?? 'left',
+        mark.panel ?? 'left_front_door',
+        mark.kind ?? 'dent',
+        mark.severity ?? 'minor',
+        mark.note ?? null,
+        mark.recordedAt ?? null,
+      ]
+    )
+    return markId
+  })
+}
+
+export interface ConditionMarkRow {
+  id: string
+  serviceRecordId: string | null
+  inspectionId: string | null
+  view: string
+  panel: string
+  kind: string
+  severity: string
+  note: string | null
+  imageUrls: string[]
+  resolvedAt: Date | null
+}
+
+/** Every mark on the vehicle, oldest first, resolved ones included. */
+export async function conditionMarksOf(vehicleId: string): Promise<ConditionMarkRow[]> {
+  return withDb(async (db) => {
+    const result = await db.query<ConditionMarkRow>(
+      `select id, "serviceRecordId", "inspectionId", view, panel, kind, severity, note,
+              "imageUrls", "resolvedAt"
+         from condition_marks where "vehicleId" = $1 order by "recordedAt", "createdAt"`,
+      [vehicleId]
+    )
+    return result.rows
+  })
+}
+
+/** Whether the job's invoice was told to carry the map: true, false, or null to follow the design. */
+export async function conditionMapOnInvoice(serviceRecordId: string): Promise<boolean | null> {
+  return withDb(async (db) => {
+    const result = await db.query<{ on: boolean | null }>(
+      `select "conditionMapOnInvoice" as on from service_records where id = $1`,
+      [serviceRecordId]
+    )
+    return result.rows[0]?.on ?? null
+  })
+}
+
+/** Another job on a car that already has one: the next visit. */
+export async function plantJobOnVehicle(
+  organizationId: string,
+  vehicleId: string,
+  title: string,
+  openedAt?: Date
+): Promise<{ serviceRecordId: string; vehicleId: string }> {
+  return withDb(async (db) => {
+    const job = await db.query<{ id: string }>(
+      `insert into service_records (id, title, "vehicleId", "organizationId", "createdAt", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, coalesce($4, now()), now())
+       returning id`,
+      [title, vehicleId, organizationId, openedAt ?? null]
+    )
+    return { serviceRecordId: job.rows[0].id, vehicleId }
+  })
+}
+
+/** A kind of mark of the workshop's own, keyed the way the settings page keys one. */
+export async function plantOwnMarkKind(
+  organizationId: string,
+  name: string
+): Promise<{ id: string; key: string; name: string }> {
+  return withDb(async (db) => {
+    const id = randomBytes(12).toString('hex')
+    const key = `own_${id}`
+    await db.query(
+      `insert into condition_mark_types (id, "organizationId", key, name, shape, color, "sortOrder", hidden, "updatedAt")
+       values ($1, $2, $3, $4, 'circle', '#7c3aed', 100, false, now())`,
+      [id, organizationId, key, name]
+    )
+    return { id, key, name }
+  })
+}
+
+export async function removeOwnMarkKind(id: string): Promise<void> {
+  await withDb((db) => db.query(`delete from condition_mark_types where id = $1`, [id]))
+}

+ 4 - 1
messages/de/common.json

@@ -110,5 +110,8 @@
     "failed": "Die PDF-Vorschau konnte nicht erstellt werden."
     "failed": "Die PDF-Vorschau konnte nicht erstellt werden."
   },
   },
   "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": {
+    "note": "Gesendet am {date}. Änderungen hier kommen auf die Rechnung, wenn sie erneut gesendet wird."
+  }
 }
 }

+ 2 - 0
messages/de/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "Markierung konnte nicht gespeichert werden",
   "saveFailed": "Markierung konnte nicht gespeichert werden",
   "uploadFailed": "Foto konnte nicht hochgeladen werden",
   "uploadFailed": "Foto konnte nicht hochgeladen werden",
   "readOnly": "Zum Ändern der Zustandskarte wieder öffnen.",
   "readOnly": "Zum Ändern der Zustandskarte wieder öffnen.",
+  "readOnlyLinked": "Auf der verknüpften Inspektion eingezeichnet. Dort ändern.",
+  "readOnlyEarlier": "Bei einem früheren Besuch erfasst. Als repariert entfernen, sobald der Schaden behoben ist.",
   "count": "{count, plural, =0 {Keine Markierungen} one {# Markierung} other {# Markierungen}}",
   "count": "{count, plural, =0 {Keine Markierungen} one {# Markierung} other {# Markierungen}}",
   "previousCount": "{count, plural, one {# von einem früheren Besuch} other {# von früheren Besuchen}}",
   "previousCount": "{count, plural, one {# von einem früheren Besuch} other {# von früheren Besuchen}}",
   "fromInspection": "Aus der verknüpften Inspektion, dort änderbar",
   "fromInspection": "Aus der verknüpften Inspektion, dort änderbar",

+ 4 - 1
messages/en/common.json

@@ -110,5 +110,8 @@
     "failed": "Could not generate the PDF preview."
     "failed": "Could not generate the PDF preview."
   },
   },
   "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": {
+    "note": "Sent {date}. Changes here go on the invoice when it is sent again."
+  }
 }
 }

+ 2 - 0
messages/en/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "Could not save the mark",
   "saveFailed": "Could not save the mark",
   "uploadFailed": "Could not upload the photo",
   "uploadFailed": "Could not upload the photo",
   "readOnly": "Reopen to change the condition map.",
   "readOnly": "Reopen to change the condition map.",
+  "readOnlyLinked": "Drawn on the linked inspection. Change it there.",
+  "readOnlyEarlier": "Recorded on an earlier visit. Clear it as repaired once it is gone.",
   "count": "{count, plural, =0 {No marks} one {# mark} other {# marks}}",
   "count": "{count, plural, =0 {No marks} one {# mark} other {# marks}}",
   "previousCount": "{count, plural, one {# from an earlier visit} other {# from earlier visits}}",
   "previousCount": "{count, plural, one {# from an earlier visit} other {# from earlier visits}}",
   "fromInspection": "From the linked inspection, changed there",
   "fromInspection": "From the linked inspection, changed there",

+ 4 - 1
messages/es/common.json

@@ -110,5 +110,8 @@
     "failed": "No se pudo generar la vista previa del PDF."
     "failed": "No se pudo generar la vista previa del PDF."
   },
   },
   "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": {
+    "note": "Enviada el {date}. Los cambios hechos aquí pasan a la factura cuando se vuelva a enviar."
+  }
 }
 }

+ 2 - 0
messages/es/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "No se pudo guardar la marca",
   "saveFailed": "No se pudo guardar la marca",
   "uploadFailed": "No se pudo subir la foto",
   "uploadFailed": "No se pudo subir la foto",
   "readOnly": "Reabra para cambiar el mapa de estado.",
   "readOnly": "Reabra para cambiar el mapa de estado.",
+  "readOnlyLinked": "Marcada en la inspección vinculada. Cámbiela allí.",
+  "readOnlyEarlier": "Registrada en una visita anterior. Márquela como reparada cuando ya no esté.",
   "count": "{count, plural, =0 {Sin marcas} one {# marca} other {# marcas}}",
   "count": "{count, plural, =0 {Sin marcas} one {# marca} other {# marcas}}",
   "previousCount": "{count, plural, one {# de una visita anterior} other {# de visitas anteriores}}",
   "previousCount": "{count, plural, one {# de una visita anterior} other {# de visitas anteriores}}",
   "fromInspection": "De la inspección vinculada; se modifica allí",
   "fromInspection": "De la inspección vinculada; se modifica allí",

+ 4 - 1
messages/fr/common.json

@@ -110,5 +110,8 @@
     "failed": "Impossible de générer l'aperçu du PDF."
     "failed": "Impossible de générer l'aperçu du PDF."
   },
   },
   "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": {
+    "note": "Envoyée le {date}. Les modifications faites ici passent sur la facture lorsqu'elle est renvoyée."
+  }
 }
 }

+ 2 - 0
messages/fr/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "Impossible d'enregistrer la marque",
   "saveFailed": "Impossible d'enregistrer la marque",
   "uploadFailed": "Impossible d'envoyer la photo",
   "uploadFailed": "Impossible d'envoyer la photo",
   "readOnly": "Rouvrez pour modifier la carte d'état.",
   "readOnly": "Rouvrez pour modifier la carte d'état.",
+  "readOnlyLinked": "Placée sur l'inspection liée. Modifiez-la là-bas.",
+  "readOnlyEarlier": "Relevée lors d'une visite précédente. Marquez-la comme réparée une fois disparue.",
   "count": "{count, plural, =0 {Aucune marque} one {# marque} other {# marques}}",
   "count": "{count, plural, =0 {Aucune marque} one {# marque} other {# marques}}",
   "previousCount": "{count, plural, one {# d'une visite précédente} other {# de visites précédentes}}",
   "previousCount": "{count, plural, one {# d'une visite précédente} other {# de visites précédentes}}",
   "fromInspection": "De l'inspection liée, à modifier là-bas",
   "fromInspection": "De l'inspection liée, à modifier là-bas",

+ 4 - 1
messages/it/common.json

@@ -110,5 +110,8 @@
     "failed": "Impossibile generare l'anteprima del PDF."
     "failed": "Impossibile generare l'anteprima del PDF."
   },
   },
   "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": {
+    "note": "Inviata il {date}. Le modifiche fatte qui entrano nella fattura quando viene inviata di nuovo."
+  }
 }
 }

+ 2 - 0
messages/it/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "Impossibile salvare il segno",
   "saveFailed": "Impossibile salvare il segno",
   "uploadFailed": "Impossibile caricare la foto",
   "uploadFailed": "Impossibile caricare la foto",
   "readOnly": "Riapri per modificare la mappa dello stato.",
   "readOnly": "Riapri per modificare la mappa dello stato.",
+  "readOnlyLinked": "Segnata sull'ispezione collegata. Modificala lì.",
+  "readOnlyEarlier": "Registrata in una visita precedente. Segnala come riparata quando non c'è più.",
   "count": "{count, plural, =0 {Nessun segno} one {# segno} other {# segni}}",
   "count": "{count, plural, =0 {Nessun segno} one {# segno} other {# segni}}",
   "previousCount": "{count, plural, one {# da una visita precedente} other {# da visite precedenti}}",
   "previousCount": "{count, plural, one {# da una visita precedente} other {# da visite precedenti}}",
   "fromInspection": "Dall'ispezione collegata, si modifica lì",
   "fromInspection": "Dall'ispezione collegata, si modifica lì",

+ 4 - 1
messages/lt/common.json

@@ -110,5 +110,8 @@
     "failed": "Nepavyko sugeneruoti PDF peržiūros."
     "failed": "Nepavyko sugeneruoti PDF peržiūros."
   },
   },
   "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": {
+    "note": "Išsiųsta {date}. Čia atlikti pakeitimai pateks į sąskaitą, kai ji bus išsiųsta dar kartą."
+  }
 }
 }

+ 2 - 0
messages/lt/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "Nepavyko išsaugoti žymos",
   "saveFailed": "Nepavyko išsaugoti žymos",
   "uploadFailed": "Nepavyko įkelti nuotraukos",
   "uploadFailed": "Nepavyko įkelti nuotraukos",
   "readOnly": "Norėdami keisti būklės žemėlapį, atidarykite iš naujo.",
   "readOnly": "Norėdami keisti būklės žemėlapį, atidarykite iš naujo.",
+  "readOnlyLinked": "Pažymėta susietoje apžiūroje. Keiskite ten.",
+  "readOnlyEarlier": "Užfiksuota ankstesnio apsilankymo metu. Pažymėkite kaip sutaisytą, kai jos nebeliks.",
   "count": "{count, plural, =0 {Žymų nėra} one {# žyma} few {# žymos} other {# žymų}}",
   "count": "{count, plural, =0 {Žymų nėra} one {# žyma} few {# žymos} other {# žymų}}",
   "previousCount": "{count, plural, one {# iš ankstesnio vizito} few {# iš ankstesnių vizitų} other {# iš ankstesnių vizitų}}",
   "previousCount": "{count, plural, one {# iš ankstesnio vizito} few {# iš ankstesnių vizitų} other {# iš ankstesnių vizitų}}",
   "fromInspection": "Iš susietos apžiūros, keičiama ten",
   "fromInspection": "Iš susietos apžiūros, keičiama ten",

+ 4 - 1
messages/nb/common.json

@@ -110,5 +110,8 @@
     "failed": "Kunne ikke generere PDF-forhåndsvisningen."
     "failed": "Kunne ikke generere PDF-forhåndsvisningen."
   },
   },
   "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": {
+    "note": "Sendt {date}. Endringer her kommer på fakturaen når den sendes på nytt."
+  }
 }
 }

+ 2 - 0
messages/nb/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "Kunne ikke lagre merket",
   "saveFailed": "Kunne ikke lagre merket",
   "uploadFailed": "Kunne ikke laste opp bildet",
   "uploadFailed": "Kunne ikke laste opp bildet",
   "readOnly": "Åpne på nytt for å endre tilstandskartet.",
   "readOnly": "Åpne på nytt for å endre tilstandskartet.",
+  "readOnlyLinked": "Tegnet på den koblede inspeksjonen. Endre den der.",
+  "readOnlyEarlier": "Registrert ved et tidligere besøk. Fjern som reparert når skaden er borte.",
   "count": "{count, plural, =0 {Ingen merker} one {# merke} other {# merker}}",
   "count": "{count, plural, =0 {Ingen merker} one {# merke} other {# merker}}",
   "previousCount": "{count, plural, one {# fra et tidligere besøk} other {# fra tidligere besøk}}",
   "previousCount": "{count, plural, one {# fra et tidligere besøk} other {# fra tidligere besøk}}",
   "fromInspection": "Fra den koblede inspeksjonen, endres der",
   "fromInspection": "Fra den koblede inspeksjonen, endres der",

+ 4 - 1
messages/nl/common.json

@@ -110,5 +110,8 @@
     "failed": "Kon het PDF-voorbeeld niet genereren."
     "failed": "Kon het PDF-voorbeeld niet genereren."
   },
   },
   "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": {
+    "note": "Verzonden op {date}. Wijzigingen hier komen op de factuur wanneer die opnieuw wordt verzonden."
+  }
 }
 }

+ 2 - 0
messages/nl/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "Kon de markering niet opslaan",
   "saveFailed": "Kon de markering niet opslaan",
   "uploadFailed": "Kon de foto niet uploaden",
   "uploadFailed": "Kon de foto niet uploaden",
   "readOnly": "Heropen om de staatkaart te wijzigen.",
   "readOnly": "Heropen om de staatkaart te wijzigen.",
+  "readOnlyLinked": "Getekend op de gekoppelde inspectie. Wijzig hem daar.",
+  "readOnlyEarlier": "Vastgelegd bij een eerder bezoek. Markeer als gerepareerd zodra de schade weg is.",
   "count": "{count, plural, =0 {Geen markeringen} one {# markering} other {# markeringen}}",
   "count": "{count, plural, =0 {Geen markeringen} one {# markering} other {# markeringen}}",
   "previousCount": "{count, plural, one {# van een eerder bezoek} other {# van eerdere bezoeken}}",
   "previousCount": "{count, plural, one {# van een eerder bezoek} other {# van eerdere bezoeken}}",
   "fromInspection": "Uit de gekoppelde inspectie, daar te wijzigen",
   "fromInspection": "Uit de gekoppelde inspectie, daar te wijzigen",

+ 4 - 1
messages/pl/common.json

@@ -110,5 +110,8 @@
     "failed": "Nie udało się wygenerować podglądu PDF."
     "failed": "Nie udało się wygenerować podglądu PDF."
   },
   },
   "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": {
+    "note": "Wysłano {date}. Zmiany wprowadzone tutaj trafią na fakturę, gdy zostanie wysłana ponownie."
+  }
 }
 }

+ 2 - 0
messages/pl/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "Nie udało się zapisać oznaczenia",
   "saveFailed": "Nie udało się zapisać oznaczenia",
   "uploadFailed": "Nie udało się przesłać zdjęcia",
   "uploadFailed": "Nie udało się przesłać zdjęcia",
   "readOnly": "Otwórz ponownie, aby zmienić mapę stanu.",
   "readOnly": "Otwórz ponownie, aby zmienić mapę stanu.",
+  "readOnlyLinked": "Zaznaczone na powiązanym przeglądzie. Zmień je tam.",
+  "readOnlyEarlier": "Zapisane podczas wcześniejszej wizyty. Oznacz jako naprawione, gdy uszkodzenia już nie ma.",
   "count": "{count, plural, =0 {Brak oznaczeń} one {# oznaczenie} few {# oznaczenia} other {# oznaczeń}}",
   "count": "{count, plural, =0 {Brak oznaczeń} one {# oznaczenie} few {# oznaczenia} other {# oznaczeń}}",
   "previousCount": "{count, plural, one {# z wcześniejszej wizyty} few {# z wcześniejszych wizyt} other {# z wcześniejszych wizyt}}",
   "previousCount": "{count, plural, one {# z wcześniejszej wizyty} few {# z wcześniejszych wizyt} other {# z wcześniejszych wizyt}}",
   "fromInspection": "Z powiązanego przeglądu, zmieniane tam",
   "fromInspection": "Z powiązanego przeglądu, zmieniane tam",

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

@@ -110,5 +110,8 @@
     "failed": "Não foi possível gerar a pré-visualização do PDF."
     "failed": "Não foi possível gerar a pré-visualização do PDF."
   },
   },
   "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": {
+    "note": "Enviada em {date}. As alterações feitas aqui entram na fatura quando ela for enviada novamente."
+  }
 }
 }

+ 2 - 0
messages/pt-BR/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "Não foi possível salvar a marca",
   "saveFailed": "Não foi possível salvar a marca",
   "uploadFailed": "Não foi possível enviar a foto",
   "uploadFailed": "Não foi possível enviar a foto",
   "readOnly": "Reabra para alterar o mapa de estado.",
   "readOnly": "Reabra para alterar o mapa de estado.",
+  "readOnlyLinked": "Marcada na inspeção vinculada. Altere lá.",
+  "readOnlyEarlier": "Registrada em uma visita anterior. Marque como reparada quando não existir mais.",
   "count": "{count, plural, =0 {Sem marcas} one {# marca} other {# marcas}}",
   "count": "{count, plural, =0 {Sem marcas} one {# marca} other {# marcas}}",
   "previousCount": "{count, plural, one {# de uma visita anterior} other {# de visitas anteriores}}",
   "previousCount": "{count, plural, one {# de uma visita anterior} other {# de visitas anteriores}}",
   "fromInspection": "Da inspeção vinculada; altere lá",
   "fromInspection": "Da inspeção vinculada; altere lá",

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

@@ -110,5 +110,8 @@
     "failed": "Não foi possível gerar a pré-visualização do PDF."
     "failed": "Não foi possível gerar a pré-visualização do PDF."
   },
   },
   "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": {
+    "note": "Enviada a {date}. As alterações feitas aqui entram na fatura quando for enviada novamente."
+  }
 }
 }

+ 2 - 0
messages/pt-PT/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "Não foi possível guardar a marca",
   "saveFailed": "Não foi possível guardar a marca",
   "uploadFailed": "Não foi possível carregar a fotografia",
   "uploadFailed": "Não foi possível carregar a fotografia",
   "readOnly": "Reabra para alterar o mapa de estado.",
   "readOnly": "Reabra para alterar o mapa de estado.",
+  "readOnlyLinked": "Marcada na inspeção associada. Altere lá.",
+  "readOnlyEarlier": "Registada numa visita anterior. Marque como reparada quando já não existir.",
   "count": "{count, plural, =0 {Sem marcas} one {# marca} other {# marcas}}",
   "count": "{count, plural, =0 {Sem marcas} one {# marca} other {# marcas}}",
   "previousCount": "{count, plural, one {# de uma visita anterior} other {# de visitas anteriores}}",
   "previousCount": "{count, plural, one {# de uma visita anterior} other {# de visitas anteriores}}",
   "fromInspection": "Da inspeção associada; altere lá",
   "fromInspection": "Da inspeção associada; altere lá",

+ 4 - 1
messages/ru/common.json

@@ -110,5 +110,8 @@
     "failed": "Не удалось создать предпросмотр PDF."
     "failed": "Не удалось создать предпросмотр PDF."
   },
   },
   "attachPdf": "Прикрепить PDF",
   "attachPdf": "Прикрепить PDF",
-  "attachPdfHint": "Отключите, чтобы отправить ссылку вместо файла и видеть, когда клиент её открыл."
+  "attachPdfHint": "Отключите, чтобы отправить ссылку вместо файла и видеть, когда клиент её открыл.",
+  "sentCopy": {
+    "note": "Отправлен {date}. Изменения здесь попадут в счёт, когда он будет отправлен повторно."
+  }
 }
 }

+ 2 - 0
messages/ru/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "Не удалось сохранить отметку",
   "saveFailed": "Не удалось сохранить отметку",
   "uploadFailed": "Не удалось загрузить фото",
   "uploadFailed": "Не удалось загрузить фото",
   "readOnly": "Откройте заново, чтобы изменить карту состояния.",
   "readOnly": "Откройте заново, чтобы изменить карту состояния.",
+  "readOnlyLinked": "Отмечено в связанном осмотре. Измените там.",
+  "readOnlyEarlier": "Записано при прошлом визите. Отметьте как отремонтированное, когда повреждения не станет.",
   "count": "{count, plural, =0 {Нет отметок} one {# отметка} few {# отметки} other {# отметок}}",
   "count": "{count, plural, =0 {Нет отметок} one {# отметка} few {# отметки} other {# отметок}}",
   "previousCount": "{count, plural, one {# с прошлого визита} few {# с прошлых визитов} other {# с прошлых визитов}}",
   "previousCount": "{count, plural, one {# с прошлого визита} few {# с прошлых визитов} other {# с прошлых визитов}}",
   "fromInspection": "Из связанного осмотра, изменяется там",
   "fromInspection": "Из связанного осмотра, изменяется там",

+ 4 - 1
messages/tr/common.json

@@ -110,5 +110,8 @@
     "failed": "PDF önizlemesi oluşturulamadı."
     "failed": "PDF önizlemesi oluşturulamadı."
   },
   },
   "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": {
+    "note": "{date} tarihinde gönderildi. Buradaki değişiklikler fatura yeniden gönderildiğinde faturaya yansır."
+  }
 }
 }

+ 2 - 0
messages/tr/conditionMap.json

@@ -36,6 +36,8 @@
   "saveFailed": "İşaret kaydedilemedi",
   "saveFailed": "İşaret kaydedilemedi",
   "uploadFailed": "Fotoğraf yüklenemedi",
   "uploadFailed": "Fotoğraf yüklenemedi",
   "readOnly": "Durum haritasını değiştirmek için yeniden açın.",
   "readOnly": "Durum haritasını değiştirmek için yeniden açın.",
+  "readOnlyLinked": "Bağlı muayenede işaretlendi. Orada değiştirin.",
+  "readOnlyEarlier": "Önceki bir ziyarette kaydedildi. Hasar giderildiğinde onarıldı olarak kaldırın.",
   "count": "{count, plural, =0 {İşaret yok} one {# işaret} other {# işaret}}",
   "count": "{count, plural, =0 {İşaret yok} one {# işaret} other {# işaret}}",
   "previousCount": "{count, plural, one {# önceki ziyaretten} other {# önceki ziyaretlerden}}",
   "previousCount": "{count, plural, one {# önceki ziyaretten} other {# önceki ziyaretlerden}}",
   "fromInspection": "Bağlı muayeneden, orada değiştirilir",
   "fromInspection": "Bağlı muayeneden, orada değiştirilir",

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

@@ -12,6 +12,8 @@ import {
   numberedMarks,
   numberedMarks,
   splitMarks,
   splitMarks,
   type ConditionMarkData,
   type ConditionMarkData,
+  isLaterVisit,
+  markKindSchema,
 } from '@/features/condition-map/Lib/marks'
 } from '@/features/condition-map/Lib/marks'
 import { mirrorPanel } from '@/features/condition-map/Lib/drawingTypes'
 import { mirrorPanel } from '@/features/condition-map/Lib/drawingTypes'
 
 
@@ -116,3 +118,51 @@ describe('condition marks', () => {
     expect(markInputSchema.safeParse({ ...base, panel: 'wing_mirror' }).success).toBe(false)
     expect(markInputSchema.safeParse({ ...base, panel: 'wing_mirror' }).success).toBe(false)
   })
   })
 })
 })
+
+describe('what counts as an earlier visit', () => {
+  const mark = (over: Partial<ConditionMarkData>): ConditionMarkData => ({
+    id: 'm',
+    vehicleId: 'v',
+    inspectionId: null,
+    inspectionItemId: null,
+    serviceRecordId: 'other-job',
+    bodyType: 'sedan',
+    view: 'left',
+    panel: 'left_front_door',
+    x: 0.5,
+    y: 0.5,
+    kind: 'dent',
+    severity: 'minor',
+    note: null,
+    imageUrls: [],
+    recordedAt: '2026-09-01T10:00:00Z',
+    resolvedAt: null,
+    ...over,
+  })
+  const scope = { serviceRecordId: 'this-job', openedAt: '2026-09-10T08:00:00Z' }
+
+  it('keeps a mark from a visit opened before this one, grey', () => {
+    const earlier = mark({ id: 'a', sheetOpenedAt: '2026-09-01T09:00:00Z' })
+    expect(splitMarks([earlier], scope).previous.map((m) => m.id)).toEqual(['a'])
+  })
+
+  it('leaves out a mark from a visit opened after this one', () => {
+    const later = mark({ id: 'b', sheetOpenedAt: '2026-09-20T09:00:00Z' })
+    expect(isLaterVisit(later, scope)).toBe(true)
+    expect(splitMarks([later], scope).previous).toEqual([])
+  })
+
+  it('has nothing to compare without an opening time, as before', () => {
+    const later = mark({ id: 'c', sheetOpenedAt: '2026-09-20T09:00:00Z' })
+    expect(splitMarks([later], { serviceRecordId: 'this-job' }).previous).toHaveLength(1)
+  })
+})
+
+describe('the own-kind key', () => {
+  it('takes a kind of the workshop’s own and refuses anything else', () => {
+    expect(markKindSchema.safeParse('own_cmg4abc123').success).toBe(true)
+    expect(markKindSchema.safeParse('dent').success).toBe(true)
+    expect(markKindSchema.safeParse('own_').success).toBe(false)
+    expect(markKindSchema.safeParse('own_../x').success).toBe(false)
+  })
+})

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

@@ -0,0 +1,33 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { useDayFormatter } from '@/features/calendar/Components/useDayFormatter'
+import { cn } from '@/lib/utils'
+
+/**
+ * The one sentence every part of a job says once its invoice has gone out.
+ *
+ * A sent invoice is the customer's copy: its PDF is what was sent, and a
+ * change made on the job afterwards (a mark, a photo, a switch about what
+ * prints) reaches it only when the invoice is sent again. Tools that keep
+ * working on a sent job show this instead of wording of their own, so the
+ * rule reads the same everywhere.
+ */
+export function SentCopyNote({
+  sentAt,
+  className,
+}: {
+  /** When the invoice was issued; nothing is drawn while it has not been. */
+  sentAt: string | Date | null | undefined
+  className?: string
+}) {
+  const t = useTranslations('common.sentCopy')
+  const format = useDayFormatter()
+  if (!sentAt) return null
+  const date = format.dateTime(new Date(sentAt), { dateStyle: 'medium' })
+  return (
+    <p data-testid="sent-copy-note" className={cn('text-xs text-muted-foreground', className)}>
+      {t('note', { date })}
+    </p>
+  )
+}

+ 23 - 3
src/features/condition-map/Actions/conditionMarkActions.ts

@@ -98,11 +98,24 @@ function pathsFor(scope: {
   return paths
   return paths
 }
 }
 
 
+/**
+ * A kind of the workshop's own has to be one of its own. A built-in key is
+ * always known; the schema has already refused anything else.
+ */
+async function assertKnownKind(organizationId: string, kind: string | undefined) {
+  if (!kind || !kind.startsWith('own_')) return
+  const known = await db.conditionMarkType.count({ where: { organizationId, key: kind } })
+  if (!known) throw new Error('Unknown kind of mark')
+}
+
 export async function addConditionMark(input: unknown) {
 export async function addConditionMark(input: unknown) {
-  const parsed = markInputSchema.parse(input)
+  const result = markInputSchema.safeParse(input)
+  if (!result.success) return { success: false as const, error: 'Invalid mark' }
+  const parsed = result.data
   return withAuth(
   return withAuth(
     async ({ organizationId, userId }): Promise<ConditionMarkData> => {
     async ({ organizationId, userId }): Promise<ConditionMarkData> => {
       await assertSheetOpen(organizationId, parsed)
       await assertSheetOpen(organizationId, parsed)
+      await assertKnownKind(organizationId, parsed.kind)
       const mark = await db.conditionMark.create({
       const mark = await db.conditionMark.create({
         data: {
         data: {
           organizationId,
           organizationId,
@@ -153,11 +166,14 @@ async function ownMark(organizationId: string, id: string) {
 }
 }
 
 
 export async function updateConditionMark(id: string, input: unknown) {
 export async function updateConditionMark(id: string, input: unknown) {
-  const patch = markPatchSchema.parse(input)
+  const result = markPatchSchema.safeParse(input)
+  if (!result.success) return { success: false as const, error: 'Invalid mark' }
+  const patch = result.data
   return withAuth(
   return withAuth(
     async ({ organizationId }): Promise<ConditionMarkData> => {
     async ({ organizationId }): Promise<ConditionMarkData> => {
       const mark = await ownMark(organizationId, id)
       const mark = await ownMark(organizationId, id)
       await assertSheetOpen(organizationId, mark)
       await assertSheetOpen(organizationId, mark)
+      await assertKnownKind(organizationId, patch.kind)
       const updated = await db.conditionMark.update({
       const updated = await db.conditionMark.update({
         where: { id },
         where: { id },
         data: {
         data: {
@@ -222,7 +238,9 @@ export async function resolveConditionMark(id: string, resolved: boolean) {
           : { resolvedAt: null, resolvedById: null },
           : { resolvedAt: null, resolvedById: null },
         select: MARK_SELECT,
         select: MARK_SELECT,
       })
       })
+      // The vehicle page and the sheet the mark was drawn on both show it.
       revalidatePath(`/vehicles/${mark.vehicleId}`)
       revalidatePath(`/vehicles/${mark.vehicleId}`)
+      for (const path of pathsFor(mark)) revalidatePath(path)
       return updated
       return updated
     },
     },
     {
     {
@@ -236,7 +254,9 @@ export async function resolveConditionMark(id: string, resolved: boolean) {
 const photosSchema = z.object({ id: z.string(), urls: z.array(z.string()).min(1).max(10) })
 const photosSchema = z.object({ id: z.string(), urls: z.array(z.string()).min(1).max(10) })
 
 
 export async function addConditionMarkPhotos(input: unknown) {
 export async function addConditionMarkPhotos(input: unknown) {
-  const { id, urls } = photosSchema.parse(input)
+  const result = photosSchema.safeParse(input)
+  if (!result.success) return { success: false as const, error: 'Invalid photos' }
+  const { id, urls } = result.data
   return withAuth(
   return withAuth(
     async ({ organizationId }): Promise<string[]> => {
     async ({ organizationId }): Promise<string[]> => {
       assertOwnUploads(urls, organizationId)
       assertOwnUploads(urls, organizationId)

+ 20 - 6
src/features/condition-map/Components/ConditionMapCard.tsx

@@ -40,6 +40,7 @@ import {
   type MarkSeverity,
   type MarkSeverity,
   bodyTypeFor,
   bodyTypeFor,
   isDrawnOnSheet,
   isDrawnOnSheet,
+  isOwnMark,
   numberedMarks,
   numberedMarks,
   splitMarks,
   splitMarks,
 } from '../Lib/marks'
 } from '../Lib/marks'
@@ -49,6 +50,8 @@ import type { Attachment } from '@/features/vehicles/Components/service-detail/t
 import { type MarkType, markTypeOf } from '../Lib/markTypes'
 import { type MarkType, markTypeOf } from '../Lib/markTypes'
 import { MarkEditor } from './MarkEditor'
 import { MarkEditor } from './MarkEditor'
 
 
+/** Photos saved on a mark per call, the action's own limit. */
+const PHOTOS_PER_SAVE = 10
 /**
 /**
  * The condition map with everything around it: the body type, the view
  * The condition map with everything around it: the body type, the view
  * switcher, the drawing, the legend of this sheet's marks, and the marks
  * switcher, the drawing, the legend of this sheet's marks, and the marks
@@ -231,14 +234,18 @@ export function ConditionMapCard({
       }
       }
     }
     }
     if (urls.length === 0) return
     if (urls.length === 0) return
-    const result = await addConditionMarkPhotos({ id: editingId, urls })
-    if (result.success && result.data) {
-      const before = marks.find((m) => m.id === editingId)
+    // The action takes a handful at a time; a whole camera roll goes in batches.
+    const before = marks.find((m) => m.id === editingId)
+    for (let start = 0; start < urls.length; start += PHOTOS_PER_SAVE) {
+      const batch = urls.slice(start, start + PHOTOS_PER_SAVE)
+      const result = await addConditionMarkPhotos({ id: editingId, urls: batch })
+      if (!result.success || !result.data) {
+        toast.error(result.success ? t('uploadFailed') : result.error || t('uploadFailed'))
+        break
+      }
       if (before) replace({ ...before, imageUrls: result.data })
       if (before) replace({ ...before, imageUrls: result.data })
-      router.refresh()
-    } else {
-      toast.error(result.success ? t('uploadFailed') : result.error || t('uploadFailed'))
     }
     }
+    router.refresh()
   }
   }
 
 
   const handleRemovePhoto = (url: string) => {
   const handleRemovePhoto = (url: string) => {
@@ -485,6 +492,13 @@ export function ConditionMapCard({
         }
         }
         open={editing !== null}
         open={editing !== null}
         readOnly={readOnly || (editing ? !isDrawnOnSheet(editing, scope) : false)}
         readOnly={readOnly || (editing ? !isDrawnOnSheet(editing, scope) : false)}
+        readOnlyReason={
+          readOnly || !editing || isDrawnOnSheet(editing, scope)
+            ? undefined
+            : isOwnMark(editing, scope)
+              ? t('readOnlyLinked')
+              : t('readOnlyEarlier')
+        }
         busy={pending}
         busy={pending}
         onChange={handleChange}
         onChange={handleChange}
         onRemove={handleRemove}
         onRemove={handleRemove}

+ 6 - 1
src/features/condition-map/Components/MarkEditor.tsx

@@ -40,6 +40,7 @@ export function MarkEditor({
   types,
   types,
   open,
   open,
   readOnly = false,
   readOnly = false,
+  readOnlyReason,
   busy = false,
   busy = false,
   onChange,
   onChange,
   onRemove,
   onRemove,
@@ -52,6 +53,8 @@ export function MarkEditor({
   types: readonly MarkType[]
   types: readonly MarkType[]
   open: boolean
   open: boolean
   readOnly?: boolean
   readOnly?: boolean
+  /** Why a read-only mark cannot be changed here; the sheet's own reason when absent. */
+  readOnlyReason?: string
   busy?: boolean
   busy?: boolean
   onChange: (patch: { kind?: string; severity?: MarkSeverity; note?: string | null }) => void
   onChange: (patch: { kind?: string; severity?: MarkSeverity; note?: string | null }) => void
   onRemove: () => void
   onRemove: () => void
@@ -115,7 +118,9 @@ export function MarkEditor({
             )}
             )}
             {mark ? t('markOn', { kind: markTypeOf(types, mark.kind).name, area }) : t('edit')}
             {mark ? t('markOn', { kind: markTypeOf(types, mark.kind).name, area }) : t('edit')}
           </DialogTitle>
           </DialogTitle>
-          <DialogDescription>{readOnly ? t('readOnly') : t('description')}</DialogDescription>
+          <DialogDescription>
+            {readOnly ? (readOnlyReason ?? t('readOnly')) : t('description')}
+          </DialogDescription>
         </DialogHeader>
         </DialogHeader>
 
 
         {mark && (
         {mark && (

+ 12 - 2
src/features/condition-map/Lib/loadMarks.server.ts

@@ -38,10 +38,20 @@ export async function loadVehicleConditionMarks(
 ): Promise<ConditionMarkData[]> {
 ): Promise<ConditionMarkData[]> {
   const rows = await db.conditionMark.findMany({
   const rows = await db.conditionMark.findMany({
     where: { vehicleId, organizationId },
     where: { vehicleId, organizationId },
-    select: MARK_SELECT,
+    select: {
+      ...MARK_SELECT,
+      serviceRecord: { select: { createdAt: true } },
+      inspection: { select: { createdAt: true } },
+    },
     orderBy: { recordedAt: 'asc' },
     orderBy: { recordedAt: 'asc' },
   })
   })
-  return numberedMarks(rows)
+  return numberedMarks(
+    rows.map(({ serviceRecord, inspection, ...mark }) => ({
+      ...mark,
+      // A mark whose sheet was deleted keeps the moment it was recorded.
+      sheetOpenedAt: serviceRecord?.createdAt ?? inspection?.createdAt ?? mark.recordedAt,
+    }))
+  )
 }
 }
 
 
 /**
 /**

+ 35 - 4
src/features/condition-map/Lib/marks.ts

@@ -59,8 +59,26 @@ export interface ConditionMarkData {
   imageUrls: string[]
   imageUrls: string[]
   recordedAt: Date | string
   recordedAt: Date | string
   resolvedAt: Date | string | null
   resolvedAt: Date | string | null
+  /**
+   * When the visit the mark was drawn on was opened: its job's, or its
+   * inspection's. Read with the vehicle's marks; absent on a mark an action
+   * just returned, which is always the sheet in hand.
+   */
+  sheetOpenedAt?: Date | string | null
 }
 }
 
 
+/**
+ * A kind of mark: one of the built-in keys, or a kind of the workshop's own,
+ * keyed `own_<row id>` (`ownMarkKey`). Which own kinds exist is the
+ * workshop's, so the action checks that against its catalogue.
+ */
+export const markKindSchema = z
+  .string()
+  .refine(
+    (key) => (MARK_KINDS as readonly string[]).includes(key) || /^own_[A-Za-z0-9]{1,40}$/.test(key),
+    'Unknown kind of mark'
+  )
+
 export const markInputSchema = z.object({
 export const markInputSchema = z.object({
   vehicleId: z.string().min(1),
   vehicleId: z.string().min(1),
   inspectionId: z.string().optional().nullable(),
   inspectionId: z.string().optional().nullable(),
@@ -71,14 +89,14 @@ export const markInputSchema = z.object({
   panel: z.enum(PANELS),
   panel: z.enum(PANELS),
   x: z.number().min(0).max(1),
   x: z.number().min(0).max(1),
   y: z.number().min(0).max(1),
   y: z.number().min(0).max(1),
-  kind: z.enum(MARK_KINDS),
+  kind: markKindSchema,
   severity: z.enum(SEVERITIES).default('minor'),
   severity: z.enum(SEVERITIES).default('minor'),
   note: z.string().max(500).optional().nullable(),
   note: z.string().max(500).optional().nullable(),
 })
 })
 export type MarkInput = z.infer<typeof markInputSchema>
 export type MarkInput = z.infer<typeof markInputSchema>
 
 
 export const markPatchSchema = z.object({
 export const markPatchSchema = z.object({
-  kind: z.enum(MARK_KINDS).optional(),
+  kind: markKindSchema.optional(),
   severity: z.enum(SEVERITIES).optional(),
   severity: z.enum(SEVERITIES).optional(),
   note: z.string().max(500).nullable().optional(),
   note: z.string().max(500).nullable().optional(),
   x: z.number().min(0).max(1).optional(),
   x: z.number().min(0).max(1).optional(),
@@ -96,9 +114,16 @@ export type MarkPatch = z.infer<typeof markPatchSchema>
  * job's drop-off written on another form. A quote has no drop-off, so its
  * job's drop-off written on another form. A quote has no drop-off, so its
  * visit is only the inspection it was raised from.
  * visit is only the inspection it was raised from.
  */
  */
-export type MarkScope =
+export type MarkScope = (
   | { inspectionId: string; inspectionItemId: string }
   | { inspectionId: string; inspectionItemId: string }
   | { serviceRecordId?: string | null; linkedInspectionId?: string | null }
   | { serviceRecordId?: string | null; linkedInspectionId?: string | null }
+) & {
+  /**
+   * When this visit was opened. Marks from a visit opened after it are not
+   * "earlier": an old job or its print never shows damage found later.
+   */
+  openedAt?: Date | string | null
+}
 
 
 /** Whether a mark is this visit's rather than one still open from an earlier one. */
 /** Whether a mark is this visit's rather than one still open from an earlier one. */
 export function isOwnMark(mark: ConditionMarkData, scope: MarkScope): boolean {
 export function isOwnMark(mark: ConditionMarkData, scope: MarkScope): boolean {
@@ -129,10 +154,16 @@ export function splitMarks(
   const open = marks.filter((m) => !m.resolvedAt)
   const open = marks.filter((m) => !m.resolvedAt)
   return {
   return {
     own: open.filter((m) => isOwnMark(m, scope)),
     own: open.filter((m) => isOwnMark(m, scope)),
-    previous: open.filter((m) => !isOwnMark(m, scope)),
+    previous: open.filter((m) => !isOwnMark(m, scope) && !isLaterVisit(m, scope)),
   }
   }
 }
 }
 
 
+/** Whether a mark was drawn on a visit opened after this one. */
+export function isLaterVisit(mark: ConditionMarkData, scope: MarkScope): boolean {
+  if (!scope.openedAt || !mark.sheetOpenedAt) return false
+  return new Date(mark.sheetOpenedAt).getTime() > new Date(scope.openedAt).getTime()
+}
+
 export function isBodyType(value: unknown): value is BodyType {
 export function isBodyType(value: unknown): value is BodyType {
   return typeof value === 'string' && (BODY_TYPES as readonly string[]).includes(value)
   return typeof value === 'string' && (BODY_TYPES as readonly string[]).includes(value)
 }
 }

+ 4 - 1
src/features/inspections/Components/InspectionItemRow.tsx

@@ -215,6 +215,7 @@ export function InspectionItemRow({
   onChanged,
   onChanged,
   onSaveState,
   onSaveState,
   vehicle,
   vehicle,
+  inspectionOpenedAt,
   conditionMarks = [],
   conditionMarks = [],
   markTypes = [],
   markTypes = [],
   serviceType = 'automotive',
   serviceType = 'automotive',
@@ -224,6 +225,8 @@ export function InspectionItemRow({
   inspectionId?: string
   inspectionId?: string
   /** The car, for a condition map check: which drawing, and whose marks. */
   /** The car, for a condition map check: which drawing, and whose marks. */
   vehicle?: { id: string; bodyType: string | null }
   vehicle?: { id: string; bodyType: string | null }
+  /** When the inspection was opened: marks from a visit opened later are not its history. */
+  inspectionOpenedAt?: Date | string
   /** Every mark on the vehicle, for a condition map check. */
   /** Every mark on the vehicle, for a condition map check. */
   conditionMarks?: ConditionMarkData[]
   conditionMarks?: ConditionMarkData[]
   /** The workshop's kinds of mark. */
   /** The workshop's kinds of mark. */
@@ -663,7 +666,7 @@ export function InspectionItemRow({
         <div className="mt-3">
         <div className="mt-3">
           <ConditionMapCard
           <ConditionMapCard
             vehicle={vehicle}
             vehicle={vehicle}
-            scope={{ inspectionId, inspectionItemId: item.id }}
+            scope={{ inspectionId, inspectionItemId: item.id, openedAt: inspectionOpenedAt }}
             types={markTypes}
             types={markTypes}
             initialMarks={conditionMarks}
             initialMarks={conditionMarks}
             readOnly={isCompleted}
             readOnly={isCompleted}

+ 1 - 0
src/features/inspections/Components/InspectionPageClient.tsx

@@ -760,6 +760,7 @@ export function InspectionPageClient({
                       key={item.id}
                       key={item.id}
                       item={item}
                       item={item}
                       inspectionId={inspection.id}
                       inspectionId={inspection.id}
+                      inspectionOpenedAt={inspection.createdAt}
                       scale={scale}
                       scale={scale}
                       country={country}
                       country={country}
                       standard={inspection.template.standard ?? null}
                       standard={inspection.template.standard ?? null}

+ 26 - 3
src/features/inspections/Pdf/buildCertificatePrint.ts

@@ -299,15 +299,19 @@ export function buildCertificatePrintSpec(input: CertificatePrintInput): Documen
   }
   }
 
 
   // The map: this inspection's own marks in colour and, when the design
   // The map: this inspection's own marks in colour and, when the design
-  // asks, the ones still open from earlier visits in grey.
+  // asks, the ones still open from earlier visits in grey. Like the work
+  // order, an inspection that marked nothing prints no map. A completed one
+  // is a finished document: it shows the marks as they stood when it was
+  // completed, so clearing a dent as repaired later does not change it.
   const mapSection = layout.sections.find((s) => s.id === 'condition_map')
   const mapSection = layout.sections.find((s) => s.id === 'condition_map')
   const mapItem = data.items.find((item) => item.inputType === 'condition_map')
   const mapItem = data.items.find((item) => item.inputType === 'condition_map')
   const conditionMap =
   const conditionMap =
     input.conditionMarks && input.conditionMapLabels && mapItem
     input.conditionMarks && input.conditionMapLabels && mapItem
       ? conditionMapForPrint({
       ? conditionMapForPrint({
           bodyType: input.bodyType,
           bodyType: input.bodyType,
-          marks: input.conditionMarks,
-          scope: { inspectionId: data.id, inspectionItemId: mapItem.id },
+          marks: marksAsOf(input.conditionMarks, data.completedAt),
+          scope: { inspectionId: data.id, inspectionItemId: mapItem.id, openedAt: data.createdAt },
+          requireOwn: true,
           includePrevious:
           includePrevious:
             mapSection?.fields?.find((f) => f.id === 'previous_marks')?.visible !== false,
             mapSection?.fields?.find((f) => f.id === 'previous_marks')?.visible !== false,
           labels: input.conditionMapLabels,
           labels: input.conditionMapLabels,
@@ -391,3 +395,22 @@ export function buildCertificatePrintSpec(input: CertificatePrintInput): Documen
 
 
   return buildDocumentSpec(layout, theme, documentData)
   return buildDocumentSpec(layout, theme, documentData)
 }
 }
+
+/**
+ * The marks as they stood at a moment: none recorded after it, and any cleared
+ * as repaired after it still open. Without a moment, as they are now.
+ */
+export function marksAsOf(
+  marks: ConditionMarkData[],
+  moment: Date | string | null | undefined
+): ConditionMarkData[] {
+  if (!moment) return marks
+  const at = new Date(moment).getTime()
+  return marks
+    .filter((mark) => new Date(mark.recordedAt).getTime() <= at)
+    .map((mark) =>
+      mark.resolvedAt && new Date(mark.resolvedAt).getTime() > at
+        ? { ...mark, resolvedAt: null }
+        : mark
+    )
+}

+ 7 - 1
src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts

@@ -71,6 +71,8 @@ export interface WorkOrderJob {
   conditionMarks?: ConditionMarkData[]
   conditionMarks?: ConditionMarkData[]
   /** The inspection linked to the job, whose marks are this visit's too. */
   /** The inspection linked to the job, whose marks are this visit's too. */
   linkedInspectionId?: string | null
   linkedInspectionId?: string | null
+  /** When the job was opened: a later visit's marks are not its history. */
+  openedAt?: Date | string | null
   bodyType?: string | null
   bodyType?: string | null
   conditionMapLabels?: ConditionMapLabels
   conditionMapLabels?: ConditionMapLabels
 }
 }
@@ -375,7 +377,11 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
       ? conditionMapForPrint({
       ? conditionMapForPrint({
           bodyType: job.bodyType,
           bodyType: job.bodyType,
           marks: job.conditionMarks,
           marks: job.conditionMarks,
-          scope: { serviceRecordId: data.id, linkedInspectionId: job.linkedInspectionId },
+          scope: {
+            serviceRecordId: data.id,
+            linkedInspectionId: job.linkedInspectionId,
+            openedAt: job.openedAt,
+          },
           requireOwn: true,
           requireOwn: true,
           includePrevious:
           includePrevious:
             mapSection?.fields?.find((f) => f.id === 'previous_marks')?.visible !== false,
             mapSection?.fields?.find((f) => f.id === 'previous_marks')?.visible !== false,

+ 2 - 0
src/features/invoices/Lib/assembleWorkOrderPrint.ts

@@ -46,6 +46,7 @@ export async function assembleWorkOrderPrint(
         customStatus: { select: { name: true } },
         customStatus: { select: { name: true } },
         workBay: { select: { name: true } },
         workBay: { select: { name: true } },
         inspectionId: true,
         inspectionId: true,
+        createdAt: true,
       },
       },
     }),
     }),
     db.serviceConcern.findMany({
     db.serviceConcern.findMany({
@@ -100,6 +101,7 @@ export async function assembleWorkOrderPrint(
       printedAt: new Date(),
       printedAt: new Date(),
       conditionMarks,
       conditionMarks,
       linkedInspectionId: job?.inspectionId ?? null,
       linkedInspectionId: job?.inspectionId ?? null,
+      openedAt: job?.createdAt ?? null,
       bodyType: vehicle?.bodyType ?? null,
       bodyType: vehicle?.bodyType ?? null,
     },
     },
   }
   }

+ 2 - 0
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -371,6 +371,8 @@ export async function ServiceRecordPage({
           marks: await loadVehicleConditionMarks(organizationId, vehicleId),
           marks: await loadVehicleConditionMarks(organizationId, vehicleId),
           types: await markTypeCatalogue(organizationId, await getLocale()),
           types: await markTypeCatalogue(organizationId, await getLocale()),
           linkedInspectionId: record.inspection?.id ?? null,
           linkedInspectionId: record.inspection?.id ?? null,
+          openedAt: record.createdAt,
+          sentAt: designPinnedAt,
           onInvoice: record.conditionMapOnInvoice,
           onInvoice: record.conditionMapOnInvoice,
           byDesign: await invoiceDesignPrintsConditionMap(
           byDesign: await invoiceDesignPrintsConditionMap(
             organizationId,
             organizationId,

+ 3 - 0
src/features/vehicles/Components/service-page/modern/FilesMediaCard.tsx

@@ -4,6 +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 { 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'
@@ -86,6 +87,7 @@ export function FilesMediaCard({
   const mapScope: MarkScope = {
   const mapScope: MarkScope = {
     serviceRecordId,
     serviceRecordId,
     linkedInspectionId: conditionMap?.linkedInspectionId ?? null,
     linkedInspectionId: conditionMap?.linkedInspectionId ?? null,
+    openedAt: conditionMap?.openedAt ?? null,
   }
   }
   const [markCount, setMarkCount] = useState(() =>
   const [markCount, setMarkCount] = useState(() =>
     conditionMap ? splitMarks(conditionMap.marks, mapScope).own.length : 0
     conditionMap ? splitMarks(conditionMap.marks, mapScope).own.length : 0
@@ -251,6 +253,7 @@ export function FilesMediaCard({
                           ? t('modern.media.onInvoiceByDesign')
                           ? t('modern.media.onInvoiceByDesign')
                           : t('modern.media.onInvoiceHint')}
                           : t('modern.media.onInvoiceHint')}
                       </span>
                       </span>
+                      <SentCopyNote sentAt={conditionMap.sentAt} className="mt-1" />
                     </span>
                     </span>
                   </label>
                   </label>
                 </div>
                 </div>

+ 4 - 0
src/features/vehicles/Components/service-page/service-page-types.ts

@@ -96,6 +96,10 @@ export interface ServicePageClientProps {
     /** The workshop's kinds of mark, in the reader's language. */
     /** The workshop's kinds of mark, in the reader's language. */
     types: MarkType[]
     types: MarkType[]
     linkedInspectionId: string | null
     linkedInspectionId: string | null
+    /** When the job was opened: marks from a visit opened later are not its history. */
+    openedAt: Date | string
+    /** When its invoice went out as the customer's copy, or null while it has not. */
+    sentAt: string | null
     /** The job's own answer for its invoice, or null to follow the design. */
     /** The job's own answer for its invoice, or null to follow the design. */
     onInvoice: boolean | null
     onInvoice: boolean | null
     /** What the invoice design does for every invoice. */
     /** What the invoice design does for every invoice. */