Browse Source

Inspection results on documents, dense table option and preview loading state (#447)

* Add a dense table option to the vehicle condition section

* vehicle inspection

* Hold shared documents back until laid out and remove review leftovers

* Keep the no-defects note on the default certificate

* Match issued invoices to their design by content

* Ask what the work order keeps when a quote is added to it

Adding a quote to an open work order now names where the two disagree
(a percentage discount, the tax basis, the warranty) and refuses to go on
until each has an answer; the dialog asks. The action requires update
rights on services and quotes, both conversion paths claim the quote
inside the transaction, and re-totalling writes the discount amount.

A confirmed condition mark points at its successor (carriedToId), so the
sheet that drew it keeps printing it as its own record while the vehicle
shows the dent once; a tab that confirms what another already confirmed
re-reads the page instead of guessing.

End-to-end specs cover the add, the conflict prompt and the carry.

* Search for the seeded customer in the smoke test

The customers list shows the twenty touched last, and the specs that share
the smoke test's shard plant customers of their own, so the seed's were no
longer on the first page.
Bernt Christian Egeland 3 weeks ago
parent
commit
fba74d7611
100 changed files with 6121 additions and 272 deletions
  1. 513 0
      e2e/specs/condition-map/carry-forward.spec.ts
  2. 653 0
      e2e/specs/quotes/add-to-work-order-conflicts.spec.ts
  3. 396 0
      e2e/specs/quotes/add-to-work-order.spec.ts
  4. 4 1
      e2e/specs/smoke/app.spec.ts
  5. 57 0
      e2e/support/condition-map-db.ts
  6. 246 0
      e2e/support/db.ts
  7. 155 0
      e2e/support/quote-conversion.ts
  8. 1 0
      messages/de/conditionMap.json
  9. 32 0
      messages/de/quotes.json
  10. 6 1
      messages/de/settings.json
  11. 1 0
      messages/en/conditionMap.json
  12. 32 0
      messages/en/quotes.json
  13. 6 1
      messages/en/settings.json
  14. 1 0
      messages/es/conditionMap.json
  15. 32 0
      messages/es/quotes.json
  16. 6 1
      messages/es/settings.json
  17. 1 0
      messages/fr/conditionMap.json
  18. 32 0
      messages/fr/quotes.json
  19. 6 1
      messages/fr/settings.json
  20. 1 0
      messages/it/conditionMap.json
  21. 32 0
      messages/it/quotes.json
  22. 6 1
      messages/it/settings.json
  23. 1 0
      messages/lt/conditionMap.json
  24. 32 0
      messages/lt/quotes.json
  25. 6 1
      messages/lt/settings.json
  26. 1 0
      messages/nb/conditionMap.json
  27. 32 0
      messages/nb/quotes.json
  28. 6 1
      messages/nb/settings.json
  29. 1 0
      messages/nl/conditionMap.json
  30. 32 0
      messages/nl/quotes.json
  31. 6 1
      messages/nl/settings.json
  32. 1 0
      messages/pl/conditionMap.json
  33. 32 0
      messages/pl/quotes.json
  34. 6 1
      messages/pl/settings.json
  35. 1 0
      messages/pt-BR/conditionMap.json
  36. 32 0
      messages/pt-BR/quotes.json
  37. 6 1
      messages/pt-BR/settings.json
  38. 1 0
      messages/pt-PT/conditionMap.json
  39. 32 0
      messages/pt-PT/quotes.json
  40. 6 1
      messages/pt-PT/settings.json
  41. 1 0
      messages/ru/conditionMap.json
  42. 32 0
      messages/ru/quotes.json
  43. 6 1
      messages/ru/settings.json
  44. 1 0
      messages/tr/conditionMap.json
  45. 32 0
      messages/tr/quotes.json
  46. 6 1
      messages/tr/settings.json
  47. 2 0
      prisma/migrations/20261007180500_condition_mark_carried_to/migration.sql
  48. 5 0
      prisma/schema/condition-marks.prisma
  49. 19 1
      src/__tests__/features/condition-map/carry-marks.test.ts
  50. 20 0
      src/__tests__/features/condition-map/marks.test.ts
  51. 21 0
      src/__tests__/features/condition-map/print.test.ts
  52. 4 1
      src/__tests__/features/condition-map/visit-documents.test.ts
  53. 156 2
      src/__tests__/features/inspections/certificate-print.test.ts
  54. 296 0
      src/__tests__/features/invoice-designer/canvas-first-paint.test.tsx
  55. 13 3
      src/__tests__/features/invoice-designer/every-block.test.ts
  56. 9 1
      src/__tests__/features/invoice-designer/fixed-slot-fields.test.ts
  57. 187 0
      src/__tests__/features/invoice-designer/inspection-results-render.test.tsx
  58. 419 0
      src/__tests__/features/invoice-designer/invoice-quote-inspection-results.test.ts
  59. 4 2
      src/__tests__/features/invoice-designer/marine-labels.test.ts
  60. 87 0
      src/__tests__/features/invoice-designer/sheet-first-paint.test.tsx
  61. 500 0
      src/__tests__/features/invoice-designer/work-order-inspection-results.test.ts
  62. 437 0
      src/__tests__/features/invoices/invoice-inspection-results.test.ts
  63. 118 0
      src/__tests__/features/invoices/issued-design-state.test.ts
  64. 6 1
      src/__tests__/features/invoices/reissue-invoice.test.ts
  65. 200 0
      src/__tests__/features/quotes/add-quote-to-work-order.test.ts
  66. 168 0
      src/__tests__/features/quotes/conversion-conflicts.test.ts
  67. 49 2
      src/__tests__/features/quotes/convert-quote-inventory.test.ts
  68. 6 0
      src/__tests__/features/settings/layout-editor-labels.test.ts
  69. 32 0
      src/__tests__/features/settings/preset-no-defects-note.test.ts
  70. 58 1
      src/__tests__/lib/backup-file-urls.test.ts
  71. 1 1
      src/__tests__/lib/files/references.test.ts
  72. 1 0
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  73. 5 1
      src/app/(public)/share/quote/[orgId]/[token]/page.tsx
  74. 7 2
      src/app/api/protected/backup/import/route.ts
  75. 32 27
      src/features/condition-map/Actions/conditionMarkActions.ts
  76. 25 17
      src/features/condition-map/Components/ConditionMapCard.tsx
  77. 1 0
      src/features/condition-map/Lib/loadMarks.server.ts
  78. 10 1
      src/features/condition-map/Lib/marks.ts
  79. 4 3
      src/features/condition-map/Lib/print.ts
  80. 64 0
      src/features/inspections/Lib/inspectionResults.ts
  81. 110 0
      src/features/inspections/Lib/linkedInspectionResults.server.ts
  82. 128 83
      src/features/inspections/Pdf/buildCertificatePrint.ts
  83. 10 0
      src/features/invoice-designer/Components/DesignerInspector.tsx
  84. 2 8
      src/features/invoice-designer/Components/InvoiceDesigner.tsx
  85. 2 5
      src/features/invoice-designer/Components/PresetPreview.tsx
  86. 100 63
      src/features/invoice-designer/Components/sample.ts
  87. 30 0
      src/features/invoice-designer/Lib/invoiceLabels.ts
  88. 16 3
      src/features/invoice-designer/Lib/workOrderLabels.ts
  89. 13 0
      src/features/invoice-designer/Pdf/buildInvoicePrint.ts
  90. 13 0
      src/features/invoice-designer/Pdf/buildQuotePrint.ts
  91. 16 0
      src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts
  92. 1 1
      src/features/invoice-designer/Pdf/estimateHeights.ts
  93. 5 10
      src/features/invoice-designer/Pdf/printLabels.ts
  94. 1 1
      src/features/invoice-designer/Pdf/renderPdf.tsx
  95. 59 1
      src/features/invoice-designer/Render/SpecCanvas.tsx
  96. 67 5
      src/features/invoice-designer/Render/SpecSheet.tsx
  97. 1 1
      src/features/invoice-designer/Render/renderHtml.tsx
  98. 2 0
      src/features/invoice-designer/Spec/buildSpec.ts
  99. 25 7
      src/features/invoice-designer/Spec/certificateBlocks.ts
  100. 23 4
      src/features/invoice-designer/Spec/certificateData.ts

+ 513 - 0
e2e/specs/condition-map/carry-forward.spec.ts

@@ -0,0 +1,513 @@
+import { expect, type Locator, type Page, type Request, test } from '@playwright/test'
+import {
+  certificatePdf,
+  conditionMap,
+  earlierSection,
+  markEditor,
+  openDropoff,
+  ownMarks,
+  printsMap,
+  workOrderPdf,
+} from '../../support/condition-map'
+import { type CarryMarkRow, marksWithCarry, setMarkPhotos } from '../../support/condition-map-db'
+import {
+  ownerOrganizationId,
+  plantConditionInspection,
+  plantConditionJob,
+  plantConditionMark,
+  plantJobOnVehicle,
+  userIdFor,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { TINY_PNG } from '../../support/pdf'
+
+/**
+ * "Confirm all as still there": the marks still open from earlier visits,
+ * listed grey under the map, recorded again on this sheet as this visit's
+ * own in one press. Each earlier mark is closed in the same go and points at
+ * the mark that took its place (`carriedToId`), so:
+ *
+ * - this sheet now holds them in colour, with the note and photos they had;
+ * - the earlier sheet still shows and prints them as its own record, read
+ *   only, since the newer copy is the one that changes now;
+ * - the vehicle meets the dent once: a newer visit sees the copies, never
+ *   the originals as well;
+ * - pressing it again finds nothing left to take.
+ *
+ * Every test plants a car of its own, with the earlier job a month back and
+ * the job doing the confirming ten days back, so a third job opened today is
+ * the newest visit.
+ */
+
+const stamp = Date.now()
+const DAY = 24 * 60 * 60 * 1000
+/** The grey every earlier visit's mark is drawn in (PREVIOUS_MARK_COLOR). */
+const GREY = '#9ca3af'
+const CARRIED_TEXT =
+  "Confirmed again on a later visit. It stays here as this visit's record and is changed there."
+
+// Each test opens three or four pages and reads PDFs; a minute is tight.
+test.describe.configure({ timeout: 180_000 })
+
+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}`
+
+/** Every mark drawn on the map itself (the legend's icons carry no role). */
+const glyphs = (map: Locator) => map.locator('svg[role="img"] g[role="button"]')
+/** The ones drawn grey: an earlier visit's. */
+const greyGlyphs = (map: Locator) =>
+  glyphs(map).filter({ has: map.page().locator(`[stroke="${GREY}"]`) })
+/** The buttons that open a mark listed under "Recorded on an earlier visit". */
+const earlierMarks = (map: Locator) =>
+  earlierSection(map).getByRole('button', { name: /^Mark \d+:/ })
+
+/**
+ * A car with two marks from a visit a month ago (a dent on the bonnet with a
+ * note, a major scratch on the left front door), and the job ten days ago
+ * that is to confirm them.
+ */
+async function carWithTwoEarlierMarks(label: string) {
+  const earlier = await plantConditionJob(
+    organizationId,
+    userId,
+    `${label} earlier ${stamp}`,
+    new Date(Date.now() - 30 * DAY)
+  )
+  const note = `Dent by the badge ${label} ${stamp}`
+  const hood = await plantConditionMark(
+    organizationId,
+    earlier.vehicleId,
+    { serviceRecordId: earlier.serviceRecordId },
+    {
+      view: 'top',
+      panel: 'hood',
+      kind: 'dent',
+      note,
+      recordedAt: new Date(Date.now() - 30 * DAY),
+    }
+  )
+  const door = await plantConditionMark(
+    organizationId,
+    earlier.vehicleId,
+    { serviceRecordId: earlier.serviceRecordId },
+    {
+      view: 'left',
+      panel: 'left_front_door',
+      kind: 'scratch',
+      severity: 'major',
+      recordedAt: new Date(Date.now() - 30 * DAY + 60_000),
+    }
+  )
+  const later = await plantJobOnVehicle(
+    organizationId,
+    earlier.vehicleId,
+    `${label} later ${stamp}`,
+    new Date(Date.now() - 10 * DAY)
+  )
+  return { earlier, later, hood, door, note, vehicleId: earlier.vehicleId }
+}
+
+/**
+ * Presses "Confirm all as still there" and waits for the earlier marks to be
+ * closed in the database. Repeated until the grey list goes: a press before
+ * hydration does nothing.
+ */
+async function confirmAll(map: Locator, vehicleId: string, expected: number) {
+  const button = map.getByTestId('condition-map-confirm-all')
+  await expect(button).toHaveText('Confirm all as still there')
+  await expect(async () => {
+    await button.click()
+    await expect(earlierSection(map)).toHaveCount(0, { timeout: 3_000 })
+  }).toPass({ timeout: 30_000 })
+  await expect
+    .poll(async () => (await marksWithCarry(vehicleId)).filter((m) => m.carriedToId).length, {
+      message: 'the earlier marks are closed and point at their copies',
+    })
+    .toBe(expected)
+}
+
+/** The planted originals and the copies that took their place, by the original's id. */
+function carried(rows: CarryMarkRow[], originalIds: string[]) {
+  return originalIds.map((id) => {
+    const original = rows.find((m) => m.id === id)
+    if (!original) throw new Error(`mark ${id} is gone`)
+    const copy = rows.find((m) => m.id === original.carriedToId)
+    if (!copy) throw new Error(`mark ${id} has no copy`)
+    return { original, copy }
+  })
+}
+
+/** The carry action's request from this job's page: a server action naming the sheet. */
+function isCarryRequest(request: Request, serviceRecordId: string): boolean {
+  return (
+    request.method() === 'POST' &&
+    Boolean(request.headers()['next-action']) &&
+    (request.postData() ?? '').includes(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
+}
+
+test('confirming all makes the earlier marks this job’s own, with their note and photos', async ({
+  page,
+}) => {
+  const car = await carWithTwoEarlierMarks('E2E carry')
+  // A real photo on the earlier dent, uploaded the way the editor uploads one.
+  const uploaded = await page.request.post('/api/protected/upload/service-files', {
+    multipart: {
+      file: { name: `e2e-carry-${stamp}.png`, mimeType: 'image/png', buffer: TINY_PNG },
+    },
+  })
+  expect(uploaded.status()).toBe(200)
+  const photo = ((await uploaded.json()) as { url: string }).url
+  await setMarkPhotos(car.hood, [photo])
+
+  // A job with no marks of its own prints no map yet.
+  expect(printsMap(await workOrderPdf(page, car.later.serviceRecordId))).toBe(false)
+
+  const map = await openDropoff(page, jobUrl(car.later))
+  await expect(ownMarks(map)).toHaveCount(0)
+  await expect(earlierMarks(map)).toHaveCount(2)
+  await expect(glyphs(map)).toHaveCount(2)
+  await expect(greyGlyphs(map)).toHaveCount(2)
+  await expect(map.getByTestId('condition-map-confirm-all')).toHaveAttribute(
+    'title',
+    "Records them on this sheet as this visit's marks, in colour."
+  )
+
+  await confirmAll(map, car.vehicleId, 2)
+
+  // On screen: the grey list is gone, the own list grew by two, the drawing is in colour.
+  await expect(earlierSection(map)).toHaveCount(0)
+  await expect(ownMarks(map)).toHaveCount(2)
+  await expect(ownMarks(map).nth(0)).toHaveAccessibleName(/^Mark 1: Dent on the Bonnet/)
+  await expect(ownMarks(map).nth(0)).toContainText(car.note)
+  await expect(ownMarks(map).nth(1)).toHaveAccessibleName(/^Mark 2: Scratch on the Left front door/)
+  await expect(glyphs(map)).toHaveCount(2)
+  await expect(greyGlyphs(map)).toHaveCount(0)
+
+  // In the database: each original closed and pointing at its copy, the copy
+  // this job's and open, with everything the original said.
+  const rows = await marksWithCarry(car.vehicleId)
+  expect(rows).toHaveLength(4)
+  for (const { original, copy } of carried(rows, [car.hood, car.door])) {
+    expect(original.serviceRecordId).toBe(car.earlier.serviceRecordId)
+    expect(original.resolvedAt).not.toBeNull()
+    expect(copy).toMatchObject({
+      serviceRecordId: car.later.serviceRecordId,
+      inspectionId: null,
+      inspectionItemId: null,
+      resolvedAt: null,
+      carriedToId: null,
+      bodyType: original.bodyType,
+      view: original.view,
+      panel: original.panel,
+      x: original.x,
+      y: original.y,
+      kind: original.kind,
+      severity: original.severity,
+      note: original.note,
+      imageUrls: original.imageUrls,
+    })
+  }
+  const [hood] = carried(rows, [car.hood])
+  expect(hood.copy.note).toBe(car.note)
+  expect(hood.copy.imageUrls).toEqual([photo])
+
+  // A reload draws the same: this job's, in colour, its photo captioned as this visit's.
+  const again = await openDropoff(page, jobUrl(car.later))
+  await expect(ownMarks(again)).toHaveCount(2)
+  await expect(earlierSection(again)).toHaveCount(0)
+  await expect(greyGlyphs(again)).toHaveCount(0)
+  await expect(
+    page
+      .getByTestId('condition-map-photos')
+      .getByRole('button', { name: 'Photos of mark 1: 1. Dent on the Bonnet', exact: true })
+  ).toBeVisible()
+
+  // It is this sheet's to change now.
+  await ownMarks(again).first().click()
+  const editor = markEditor(page)
+  await expect(editor).toBeVisible()
+  await expect(editor.getByRole('button', { name: 'Remove mark' })).toBeVisible()
+  await expect(editor).not.toContainText(CARRIED_TEXT)
+  await page.keyboard.press('Escape')
+  await expect(editor).toBeHidden()
+
+  // And the work order prints them as its own, not as earlier ones.
+  const pdf = await workOrderPdf(page, car.later.serviceRecordId)
+  expect(printsMap(pdf), 'the job now has marks of its own').toBe(true)
+  expect(pdf.flat).toContain('Bonnet')
+  expect(pdf.flat).toContain('Left front door')
+  expect(pdf.flat).toContain(car.note)
+  expect(pdf.flat).not.toContain('recorded earlier')
+})
+
+test('the earlier job still shows and prints its marks as its own record, read only', async ({
+  page,
+}) => {
+  const car = await carWithTwoEarlierMarks('E2E carry kept')
+  await confirmAll(await openDropoff(page, jobUrl(car.later)), car.vehicleId, 2)
+
+  const map = await openDropoff(page, jobUrl(car.earlier))
+  await expect(ownMarks(map)).toHaveCount(2)
+  await expect(earlierSection(map)).toHaveCount(0)
+  await expect(ownMarks(map).nth(0)).toHaveAccessibleName(/^Mark 1: Dent on the Bonnet/)
+  await expect(ownMarks(map).nth(1)).toHaveAccessibleName(/^Mark 2: Scratch on the Left front door/)
+  await expect(glyphs(map)).toHaveCount(2)
+  await expect(greyGlyphs(map)).toHaveCount(0)
+
+  // Opened, it says where it lives on, and cannot be changed or removed here.
+  await ownMarks(map).first().click()
+  const editor = markEditor(page)
+  await expect(editor).toBeVisible()
+  await expect(editor).toContainText(CARRIED_TEXT)
+  await expect(editor.getByRole('button', { name: 'Scratch' })).toBeDisabled()
+  await expect(editor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
+  await page.keyboard.press('Escape')
+  await expect(editor).toBeHidden()
+
+  // Its work order still prints the map with both, as this job's own.
+  const pdf = await workOrderPdf(page, car.earlier.serviceRecordId)
+  expect(printsMap(pdf), 'the earlier job keeps its map').toBe(true)
+  expect(pdf.flat).toContain('Bonnet')
+  expect(pdf.flat).toContain('Left front door')
+  expect(pdf.flat).toContain(car.note)
+  expect(pdf.flat).not.toContain('recorded earlier')
+
+  // And the later one prints them as its own too.
+  const later = await workOrderPdf(page, car.later.serviceRecordId)
+  expect(printsMap(later)).toBe(true)
+  expect(later.flat).toContain('Bonnet')
+  expect(later.flat).toContain(car.note)
+  expect(later.flat).not.toContain('recorded earlier')
+})
+
+test('the vehicle shows the dent once: a newer job meets the copies, not the originals', async ({
+  page,
+}) => {
+  const car = await carWithTwoEarlierMarks('E2E carry once')
+  await confirmAll(await openDropoff(page, jobUrl(car.later)), car.vehicleId, 2)
+  const copies = carried(await marksWithCarry(car.vehicleId), [car.hood, car.door]).map(
+    ({ copy }) => copy.id
+  )
+
+  // Today's job, the newest visit.
+  const newest = await plantJobOnVehicle(organizationId, car.vehicleId, `E2E carry newest ${stamp}`)
+  const map = await openDropoff(page, jobUrl(newest))
+  await expect(ownMarks(map)).toHaveCount(0)
+  await expect(earlierMarks(map)).toHaveCount(2)
+  await expect(earlierSection(map).getByRole('button', { name: /on the Bonnet/ })).toHaveCount(1)
+  await expect(
+    earlierSection(map).getByRole('button', { name: /on the Left front door/ })
+  ).toHaveCount(1)
+  await expect(glyphs(map)).toHaveCount(2)
+  await expect(greyGlyphs(map)).toHaveCount(2)
+
+  // What is still open on the car is exactly the copies.
+  const open = (await marksWithCarry(car.vehicleId)).filter((m) => !m.resolvedAt).map((m) => m.id)
+  expect(open.sort()).toEqual([...copies].sort())
+
+  // Its work order has nothing of its own to print, so no map.
+  expect(printsMap(await workOrderPdf(page, newest.serviceRecordId))).toBe(false)
+})
+
+test('pressing it again from a tab opened before changes nothing', async ({ page }) => {
+  const car = await carWithTwoEarlierMarks('E2E carry twice')
+  // A second tab on the same job, opened while the marks were still earlier ones.
+  const stale = await page.context().newPage()
+  const staleMap = await openDropoff(stale, jobUrl(car.later))
+  await expect(earlierMarks(staleMap)).toHaveCount(2)
+
+  await confirmAll(await openDropoff(page, jobUrl(car.later)), car.vehicleId, 2)
+  const before = await marksWithCarry(car.vehicleId)
+  expect(before).toHaveLength(4)
+
+  // The stale tab still offers the button; pressing it reaches the server and takes nothing.
+  const button = staleMap.getByTestId('condition-map-confirm-all')
+  await expect(button).toBeVisible()
+  const answered = stale.waitForResponse((response) =>
+    isCarryRequest(response.request(), car.later.serviceRecordId)
+  )
+  await button.click()
+  await answered
+
+  await expect
+    .poll(async () => (await marksWithCarry(car.vehicleId)).length, {
+      message: 'no further marks were written',
+    })
+    .toBe(4)
+  const after = await marksWithCarry(car.vehicleId)
+  expect(after.map((m) => [m.id, m.carriedToId, m.resolvedAt?.toISOString() ?? null])).toEqual(
+    before.map((m) => [m.id, m.carriedToId, m.resolvedAt?.toISOString() ?? null])
+  )
+
+  // Both tabs, reloaded, show the same two marks as this job's own.
+  for (const tab of [page, stale]) {
+    const map = await openDropoff(tab, jobUrl(car.later))
+    await expect(ownMarks(map)).toHaveCount(2)
+    await expect(earlierSection(map)).toHaveCount(0)
+  }
+  await stale.close()
+})
+
+test('a tab that confirms what another already confirmed shows them as this job’s', async ({
+  page,
+}) => {
+  const car = await carWithTwoEarlierMarks('E2E carry stale')
+  const stale = await page.context().newPage()
+  const staleMap = await openDropoff(stale, jobUrl(car.later))
+  await expect(earlierMarks(staleMap)).toHaveCount(2)
+
+  await confirmAll(await openDropoff(page, jobUrl(car.later)), car.vehicleId, 2)
+
+  const answered = stale.waitForResponse((response) =>
+    isCarryRequest(response.request(), car.later.serviceRecordId)
+  )
+  await staleMap.getByTestId('condition-map-confirm-all').click()
+  await answered
+
+  // Without a reload: the marks are this job's now, whoever pressed first,
+  // so the tab should not go on offering them as earlier ones.
+  await expect(ownMarks(staleMap)).toHaveCount(2)
+  await expect(earlierSection(staleMap)).toHaveCount(0)
+  await stale.close()
+})
+
+test('a mark cleared as repaired leaves its own sheet; a confirmed one stays there', async ({
+  page,
+}) => {
+  const car = await carWithTwoEarlierMarks('E2E carry repaired')
+  const map = await openDropoff(page, jobUrl(car.later))
+
+  // The bonnet was repaired; the scratch is still there.
+  const grey = earlierSection(map)
+  await grey
+    .getByRole('listitem')
+    .filter({ hasText: 'Bonnet' })
+    .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(earlierMarks(map)).toHaveCount(1)
+  await expect
+    .poll(async () => (await marksWithCarry(car.vehicleId)).find((m) => m.id === car.hood))
+    .toMatchObject({ resolvedAt: expect.any(Date), carriedToId: null })
+
+  await confirmAll(map, car.vehicleId, 1)
+  await expect(ownMarks(map)).toHaveCount(1)
+  await expect(ownMarks(map).first()).toHaveAccessibleName(/on the Left front door/)
+
+  // The earlier job keeps the confirmed scratch and has lost the repaired dent.
+  const earlier = await openDropoff(page, jobUrl(car.earlier))
+  await expect(ownMarks(earlier)).toHaveCount(1)
+  await expect(ownMarks(earlier).first()).toHaveAccessibleName(/on the Left front door/)
+})
+
+test.describe('on an inspection’s map check', () => {
+  test('confirming all records them on the check and leaves the earlier job its record', async ({
+    page,
+  }) => {
+    const earlier = await plantConditionJob(
+      organizationId,
+      userId,
+      `E2E carry inspection earlier ${stamp}`,
+      new Date(Date.now() - 30 * DAY)
+    )
+    const note = `Chip by the mirror ${stamp}`
+    const hood = await plantConditionMark(
+      organizationId,
+      earlier.vehicleId,
+      { serviceRecordId: earlier.serviceRecordId },
+      { view: 'top', panel: 'hood', kind: 'dent', recordedAt: new Date(Date.now() - 30 * DAY) }
+    )
+    const door = await plantConditionMark(
+      organizationId,
+      earlier.vehicleId,
+      { serviceRecordId: earlier.serviceRecordId },
+      {
+        view: 'right',
+        panel: 'right_front_door',
+        kind: 'chip',
+        note,
+        recordedAt: new Date(Date.now() - 30 * DAY + 60_000),
+      }
+    )
+    // Today's inspection on the same car, not linked to that job.
+    const inspection = await plantConditionInspection(
+      organizationId,
+      earlier.vehicleId,
+      `E2E carry ${stamp}`
+    )
+
+    const map = await openInspection(page, inspection.inspectionId)
+    await expect(ownMarks(map)).toHaveCount(0)
+    await expect(earlierMarks(map)).toHaveCount(2)
+    await expect(greyGlyphs(map)).toHaveCount(2)
+
+    await confirmAll(map, earlier.vehicleId, 2)
+    await expect(ownMarks(map)).toHaveCount(2)
+    await expect(earlierSection(map)).toHaveCount(0)
+    await expect(greyGlyphs(map)).toHaveCount(0)
+    await expect(ownMarks(map).nth(1)).toContainText(note)
+
+    // The copies are the check's, the originals closed and pointing at them.
+    const rows = await marksWithCarry(earlier.vehicleId)
+    expect(rows).toHaveLength(4)
+    for (const { original, copy } of carried(rows, [hood, door])) {
+      expect(original.resolvedAt).not.toBeNull()
+      expect(copy).toMatchObject({
+        serviceRecordId: null,
+        inspectionId: inspection.inspectionId,
+        inspectionItemId: inspection.inspectionItemId,
+        resolvedAt: null,
+        view: original.view,
+        panel: original.panel,
+        kind: original.kind,
+        severity: original.severity,
+        note: original.note,
+      })
+    }
+
+    // A reload keeps them on the check.
+    const again = await openInspection(page, inspection.inspectionId)
+    await expect(ownMarks(again)).toHaveCount(2)
+    await expect(earlierSection(again)).toHaveCount(0)
+
+    // The certificate prints them as the inspection's own.
+    const certificate = await certificatePdf(page, inspection.inspectionId)
+    expect(printsMap(certificate)).toBe(true)
+    expect(certificate.flat).toContain('Right front door')
+    expect(certificate.flat).toContain(note)
+
+    // The earlier job still holds them as its own record, read only, and prints them.
+    const onJob = await openDropoff(page, jobUrl(earlier))
+    await expect(ownMarks(onJob)).toHaveCount(2)
+    await expect(earlierSection(onJob)).toHaveCount(0)
+    await ownMarks(onJob).nth(1).click()
+    const editor = markEditor(page)
+    await expect(editor).toContainText(CARRIED_TEXT)
+    await expect(editor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
+    await page.keyboard.press('Escape')
+
+    const pdf = await workOrderPdf(page, earlier.serviceRecordId)
+    expect(printsMap(pdf)).toBe(true)
+    expect(pdf.flat).toContain('Right front door')
+    expect(pdf.flat).toContain(note)
+  })
+})

+ 653 - 0
e2e/specs/quotes/add-to-work-order-conflicts.spec.ts

@@ -0,0 +1,653 @@
+import { expect, type Locator, type Page, type Response, test } from '@playwright/test'
+import {
+  addServiceRecordLines,
+  type DocumentFields,
+  ownerOrganizationId,
+  plantJob,
+  plantJobOnVehicle,
+  plantQuote,
+  quoteConversion,
+  type ServiceRecordFields,
+  servicePartLines,
+  setQuoteFields,
+  setServiceRecordFields,
+  storedServiceRecord,
+  userIdFor,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { expectWarrantyStatement } from '../../support/warranty'
+import { partRowOf, partRows, totalsRow } from '../../support/work-order'
+
+/**
+ * A quote added to a work order the car already has open, where the two
+ * disagree on a standing answer.
+ *
+ * The lines come across either way. What the dialog cannot decide for the
+ * workshop is which discount, tax basis and warranty the job carries
+ * afterwards: its own, or the quote's, which is what the customer accepted.
+ * So it asks, one question per disagreement, and "Add to work order" waits
+ * until every question has an answer. Each answer is followed onto the job's
+ * Totals panel or warranty panel, and into what the job stores.
+ *
+ * Every difference is written straight onto the quote's and the job's own
+ * columns. The workshop's tax and warranty settings are shared with specs
+ * running beside this one and are never touched. Each test plants its own
+ * car, job and quote, so no other job on the car can be offered.
+ *
+ * The figures: the job has a part at 300 and an hour and a half at 400, 900
+ * in all, taxed 25% on top; the quote has one part at 200. Added together the
+ * job's lines come to 1,100 before discount and tax.
+ */
+
+test.describe.configure({ mode: 'serial', timeout: 120_000 })
+
+const stamp = Date.now()
+const OWNER = 'demo@torqvoice.com'
+
+let organizationId = ''
+let userId = ''
+
+/** The job as it stands before the quote: 900 of lines at 25% on top, nothing else. */
+const JOB_BASE: ServiceRecordFields = {
+  status: 'in-progress',
+  subtotal: 900,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  taxRate: 25,
+  taxInclusive: false,
+  taxComponents: null,
+  taxAmount: 225,
+  totalAmount: 1125,
+  warrantyStatus: null,
+  warrantyMonths: null,
+  warrantyMileage: null,
+  warrantyNotes: null,
+}
+
+/** The quote as it stands with nothing to disagree about: 200 at 25% on top. */
+const QUOTE_BASE: DocumentFields = {
+  subtotal: 200,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  taxRate: 25,
+  taxInclusive: false,
+  taxComponents: null,
+  taxAmount: 50,
+  totalAmount: 250,
+  warrantyStatus: null,
+  warrantyMonths: null,
+  warrantyMileage: null,
+  warrantyNotes: null,
+}
+
+/** A 10% discount on the job: 90 of its 900, so 810 taxed at 25%. */
+const JOB_TEN_PERCENT: ServiceRecordFields = {
+  discountType: 'percentage',
+  discountValue: 10,
+  discountAmount: 90,
+  taxAmount: 202.5,
+  totalAmount: 1012.5,
+}
+
+/** A discount of 18 on the quote. `discountAmount` is what the conflict reads. */
+const QUOTE_EIGHTEEN_OFF: DocumentFields = {
+  discountType: 'fixed',
+  discountValue: 18,
+  discountAmount: 18,
+  taxAmount: 45.5,
+  totalAmount: 227.5,
+}
+
+/** The quote priced with no tax at all. */
+const QUOTE_UNTAXED: DocumentFields = { taxRate: 0, taxAmount: 0, totalAmount: 200 }
+
+/** The quote stating twelve months of the workshop's warranty. */
+const QUOTE_TWELVE_MONTHS: DocumentFields = { warrantyStatus: 'included', warrantyMonths: 12 }
+
+interface Scenario {
+  jobId: string
+  jobTitle: string
+  vehicleId: string
+  quoteId: string
+  quoteUrl: string
+  jobPart: string
+  quotePart: string
+}
+
+/** One car with one open job and one quote for it, differing only as asked. */
+async function plantScenario(
+  label: string,
+  differences: { job?: ServiceRecordFields; quote?: DocumentFields } = {}
+): Promise<Scenario> {
+  const jobTitle = `E2E conflicts ${label} ${stamp}`
+  const { serviceRecordId: jobId, vehicleId } = await plantJob(organizationId, userId, jobTitle)
+  const jobPart = `E2E job part ${label} ${stamp}`
+  const quotePart = `E2E quote part ${label} ${stamp}`
+  await addServiceRecordLines(jobId, {
+    parts: [{ name: jobPart, quantity: 1, unitPrice: 300 }],
+    labor: [{ description: `E2E job labour ${label}`, hours: 1.5, rate: 400 }],
+  })
+  await setServiceRecordFields(jobId, { ...JOB_BASE, ...differences.job })
+
+  const quoteId = await plantQuote(organizationId, userId, {
+    vehicleId,
+    title: `E2E conflicts quote ${label} ${stamp}`,
+    parts: [{ name: quotePart, quantity: 1, unitPrice: 200 }],
+  })
+  await setQuoteFields(quoteId, { ...QUOTE_BASE, ...differences.quote })
+
+  return {
+    jobId,
+    jobTitle,
+    vehicleId,
+    quoteId,
+    quoteUrl: `/quotes/${quoteId}`,
+    jobPart,
+    quotePart,
+  }
+}
+
+/** The server action that asks whether the quote and this job disagree. */
+function conflictCheckFor(jobId: string) {
+  return (response: Response) => {
+    const request = response.request()
+    return (
+      request.method() === 'POST' &&
+      Boolean(request.headers()['next-action']) &&
+      (request.postData() ?? '').includes(jobId)
+    )
+  }
+}
+
+function conflictBox(dialog: Locator): Locator {
+  return dialog.getByText('Before adding, decide what the work order keeps', { exact: true })
+}
+
+function addButton(dialog: Locator): Locator {
+  return dialog.getByRole('button', { name: 'Add to work order', exact: true })
+}
+
+/**
+ * Opens the convert dialog on the quote, picks "Add to an existing work
+ * order", and waits for the answer to whether the quote and the job the
+ * dialog picked first disagree.
+ */
+async function openAddToWorkOrder(page: Page, scenario: Scenario, firstJobId = scenario.jobId) {
+  await page.goto(scenario.quoteUrl)
+  await settle(page)
+
+  const dialog = page.getByRole('dialog', { name: 'Convert Quote to Work Order' })
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Convert to Work Order', exact: true }).click()
+    await expect(dialog).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  // Enabled once the car's open jobs have been read.
+  const existing = dialog.getByRole('radio', { name: 'Add to an existing work order' })
+  await expect(existing).toBeEnabled({ timeout: 30_000 })
+  const checked = page.waitForResponse(conflictCheckFor(firstJobId), { timeout: 30_000 })
+  await existing.check()
+  await checked
+  await expect(dialog.getByText('Checking the quote against the work order...')).toHaveCount(0)
+  return dialog
+}
+
+/** Adds the quote and lands on the job it went to. */
+async function addAndOpenJob(page: Page, dialog: Locator, jobId: string) {
+  const add = addButton(dialog)
+  await expect(add).toBeEnabled()
+  await add.click()
+  await page.waitForURL(new RegExp(`/vehicles/[^/]+/service/${jobId}$`), { timeout: 60_000 })
+  await settle(page)
+}
+
+async function expectTotals(page: Page, lines: Record<string, string>) {
+  for (const [label, figure] of Object.entries(lines)) {
+    await expect(totalsRow(page, label), `${label} on the work order`).toContainText(figure)
+  }
+}
+
+/** The editor row of the part with this name, wherever it sits in the list. */
+async function partRowNamed(page: Page, name: string): Promise<Locator> {
+  const rows = partRows(page)
+  let index = -1
+  await expect(async () => {
+    const names = await rows.evaluateAll((inputs) =>
+      inputs.map((input) => (input as HTMLInputElement).value)
+    )
+    index = names.indexOf(name)
+    expect(index, `${name} among ${names.join(', ')}`).toBeGreaterThanOrEqual(0)
+  }).toPass({ timeout: 30_000 })
+  return partRowOf(rows.nth(index))
+}
+
+/** The quote's part is on the job at the unit price it was quoted at, beside the job's own. */
+async function expectQuotePartArrived(page: Page, scenario: Scenario) {
+  const row = await partRowNamed(page, scenario.quotePart)
+  // Quantity, cost, markup, unit price.
+  await expect(row.locator('input[type="number"]').nth(3)).toHaveValue('200')
+  await partRowNamed(page, scenario.jobPart)
+
+  const lines = await servicePartLines(scenario.jobId)
+  expect(lines.map((line) => line.name).sort()).toEqual(
+    [scenario.jobPart, scenario.quotePart].sort()
+  )
+  const carried = lines.find((line) => line.name === scenario.quotePart)
+  expect(carried?.unitPrice).toBe(200)
+  expect(carried?.total).toBe(200)
+
+  expect(await quoteConversion(scenario.quoteId)).toEqual({
+    status: 'converted',
+    convertedToId: scenario.jobId,
+  })
+}
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId(OWNER)
+  userId = await userIdFor(OWNER)
+})
+
+test.describe('nothing to decide', () => {
+  test('a quote that agrees with the job is added without a question', async ({ page }) => {
+    const scenario = await plantScenario('agree')
+    const dialog = await openAddToWorkOrder(page, scenario)
+
+    await expect(addButton(dialog)).toBeEnabled()
+    await expect(conflictBox(dialog)).toHaveCount(0)
+
+    await addAndOpenJob(page, dialog, scenario.jobId)
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Tax: '$275.00',
+      Total: '$1,375.00',
+    })
+    await expectQuotePartArrived(page, scenario)
+  })
+
+  test("a job with a fixed discount takes the quote's on top without asking", async ({ page }) => {
+    const scenario = await plantScenario('fixed', {
+      job: {
+        discountType: 'fixed',
+        discountValue: 50,
+        discountAmount: 50,
+        taxAmount: 212.5,
+        totalAmount: 1062.5,
+      },
+      quote: QUOTE_EIGHTEEN_OFF,
+    })
+    const dialog = await openAddToWorkOrder(page, scenario)
+
+    await expect(addButton(dialog)).toBeEnabled()
+    await expect(conflictBox(dialog)).toHaveCount(0)
+
+    await addAndOpenJob(page, dialog, scenario.jobId)
+    // 50 of the job's and 18 of the quote's: 1,100 less 68 is 1,032, taxed 258.
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Discount: '-$68.00',
+      Tax: '$258.00',
+      Total: '$1,290.00',
+    })
+    await expect(totalsRow(page, 'Discount').getByRole('combobox')).toContainText('Fixed')
+    await expect(totalsRow(page, 'Discount').locator('input[type="number"]')).toHaveValue('68')
+    await expectQuotePartArrived(page, scenario)
+
+    const stored = await storedServiceRecord(scenario.jobId)
+    expect(stored).toMatchObject({
+      discountType: 'fixed',
+      discountValue: 68,
+      discountAmount: 68,
+      taxAmount: 258,
+      totalAmount: 1290,
+    })
+  })
+})
+
+test.describe('a percentage discount on the job and a discount on the quote', () => {
+  async function askedAboutDiscount(page: Page, scenario: Scenario) {
+    const dialog = await openAddToWorkOrder(page, scenario)
+    await expect(conflictBox(dialog)).toBeVisible()
+    const question = dialog.getByTestId('convert-conflict-discount')
+    await expect(question).toBeVisible()
+    await expect(question).toContainText(
+      'The work order has a 10% discount, $90.00 as it stands. The quote carries a discount of $18.00.'
+    )
+    // Only the discount is in question; the tax and the warranty agree.
+    await expect(dialog.getByTestId('convert-conflict-tax')).toHaveCount(0)
+    await expect(dialog.getByTestId('convert-conflict-warranty')).toHaveCount(0)
+    await expect(addButton(dialog)).toBeDisabled()
+    return { dialog, question }
+  }
+
+  test('combined: the percentage becomes its amount and the quote adds its own', async ({
+    page,
+  }) => {
+    const scenario = await plantScenario('combine', {
+      job: JOB_TEN_PERCENT,
+      quote: QUOTE_EIGHTEEN_OFF,
+    })
+    const { dialog, question } = await askedAboutDiscount(page, scenario)
+
+    await question
+      .getByRole('radio', {
+        name: "Turn the 10% into $90.00 and add the quote's $18.00, $108.00 in all",
+      })
+      .check()
+    await addAndOpenJob(page, dialog, scenario.jobId)
+
+    // A fixed 108: 1,100 less 108 is 992, taxed 248.
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Discount: '-$108.00',
+      Tax: '$248.00',
+      Total: '$1,240.00',
+    })
+    await expect(totalsRow(page, 'Discount').getByRole('combobox')).toContainText('Fixed')
+    await expect(totalsRow(page, 'Discount').locator('input[type="number"]')).toHaveValue('108')
+    await expectQuotePartArrived(page, scenario)
+
+    expect(await storedServiceRecord(scenario.jobId)).toMatchObject({
+      discountType: 'fixed',
+      discountValue: 108,
+      discountAmount: 108,
+      taxAmount: 248,
+      totalAmount: 1240,
+    })
+  })
+
+  test("kept: the job's percentage stays and now covers the quote's lines too", async ({
+    page,
+  }) => {
+    const scenario = await plantScenario('keep-discount', {
+      job: JOB_TEN_PERCENT,
+      quote: QUOTE_EIGHTEEN_OFF,
+    })
+    const { dialog, question } = await askedAboutDiscount(page, scenario)
+
+    await question
+      .getByRole('radio', { name: "Keep the 10% discount and drop the quote's $18.00" })
+      .check()
+    await addAndOpenJob(page, dialog, scenario.jobId)
+
+    // 10% of 1,100 is 110: 990 taxed 247.50.
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Discount: '-$110.00',
+      Tax: '$247.50',
+      Total: '$1,237.50',
+    })
+    await expect(totalsRow(page, 'Discount').getByRole('combobox')).toContainText('Percentage')
+    await expect(totalsRow(page, 'Discount').locator('input[type="number"]')).toHaveValue('10')
+    await expectQuotePartArrived(page, scenario)
+
+    expect(await storedServiceRecord(scenario.jobId)).toMatchObject({
+      discountType: 'percentage',
+      discountValue: 10,
+      discountAmount: 110,
+      taxAmount: 247.5,
+      totalAmount: 1237.5,
+    })
+  })
+})
+
+test.describe('the job taxed at 25% on top, the quote priced without tax', () => {
+  async function askedAboutTax(page: Page, scenario: Scenario) {
+    const dialog = await openAddToWorkOrder(page, scenario)
+    await expect(conflictBox(dialog)).toBeVisible()
+    const question = dialog.getByTestId('convert-conflict-tax')
+    await expect(question).toContainText(
+      'The work order is priced with 25% tax added on top. The quote was priced with 0% tax added on top.'
+    )
+    await expect(dialog.getByTestId('convert-conflict-discount')).toHaveCount(0)
+    await expect(dialog.getByTestId('convert-conflict-warranty')).toHaveCount(0)
+    await expect(addButton(dialog)).toBeDisabled()
+    return { dialog, question }
+  }
+
+  test("kept: the quote's part comes across at its price and is taxed like the rest", async ({
+    page,
+  }) => {
+    const scenario = await plantScenario('keep-tax', { quote: QUOTE_UNTAXED })
+    const { dialog, question } = await askedAboutTax(page, scenario)
+
+    await question
+      .getByRole('radio', {
+        name: "Keep the work order's tax. The quote's lines come across at the amounts quoted.",
+      })
+      .check()
+    await addAndOpenJob(page, dialog, scenario.jobId)
+
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Tax: '$275.00',
+      Total: '$1,375.00',
+    })
+    await expect(totalsRow(page, 'Tax').locator('input[type="number"]')).toHaveValue('25')
+    await expectQuotePartArrived(page, scenario)
+
+    expect(await storedServiceRecord(scenario.jobId)).toMatchObject({
+      taxRate: 25,
+      taxInclusive: false,
+      taxAmount: 275,
+      totalAmount: 1375,
+    })
+  })
+
+  test("switched: the whole job is re-totalled at the quote's tax", async ({ page }) => {
+    const scenario = await plantScenario('use-tax', { quote: QUOTE_UNTAXED })
+    const { dialog, question } = await askedAboutTax(page, scenario)
+
+    await question
+      .getByRole('radio', {
+        name: "Switch the work order to the quote's tax. Everything on it is re-totalled.",
+      })
+      .check()
+    await addAndOpenJob(page, dialog, scenario.jobId)
+
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Tax: '$0.00',
+      Total: '$1,100.00',
+    })
+    await expect(totalsRow(page, 'Tax').locator('input[type="number"]')).toHaveValue('0')
+    await expectQuotePartArrived(page, scenario)
+
+    expect(await storedServiceRecord(scenario.jobId)).toMatchObject({
+      taxRate: 0,
+      taxInclusive: false,
+      taxAmount: 0,
+      totalAmount: 1100,
+    })
+  })
+})
+
+test.describe('the quote states a warranty the job does not', () => {
+  async function askedAboutWarranty(page: Page, scenario: Scenario) {
+    const dialog = await openAddToWorkOrder(page, scenario)
+    await expect(conflictBox(dialog)).toBeVisible()
+    const question = dialog.getByTestId('convert-conflict-warranty')
+    await expect(question).toContainText(
+      'The work order says nothing about warranty. The quote the customer accepted says warranty included, 12 months.'
+    )
+    await expect(dialog.getByTestId('convert-conflict-discount')).toHaveCount(0)
+    await expect(dialog.getByTestId('convert-conflict-tax')).toHaveCount(0)
+    await expect(addButton(dialog)).toBeDisabled()
+    return { dialog, question }
+  }
+
+  test("the quote's: the job now includes twelve months from its own date", async ({ page }) => {
+    const scenario = await plantScenario('use-warranty', { quote: QUOTE_TWELVE_MONTHS })
+    const { dialog, question } = await askedAboutWarranty(page, scenario)
+
+    await question
+      .getByRole('radio', { name: "Use the quote's warranty, as the customer accepted it" })
+      .check()
+    await addAndOpenJob(page, dialog, scenario.jobId)
+
+    const panel = await expectWarrantyStatement(page, 'Included')
+    await expect(panel.locator('#warrantyMonths')).toHaveValue('12')
+    await expect(panel.getByText('Expires', { exact: true })).toBeVisible()
+    await expectQuotePartArrived(page, scenario)
+
+    const stored = await storedServiceRecord(scenario.jobId)
+    expect(stored).toMatchObject({ warrantyStatus: 'included', warrantyMonths: 12 })
+    expect(stored.warrantyExpiresAt).not.toBeNull()
+  })
+
+  test("the job's: it still says nothing about warranty", async ({ page }) => {
+    const scenario = await plantScenario('keep-warranty', { quote: QUOTE_TWELVE_MONTHS })
+    const { dialog, question } = await askedAboutWarranty(page, scenario)
+
+    await question.getByRole('radio', { name: "Keep the work order's warranty" }).check()
+    await addAndOpenJob(page, dialog, scenario.jobId)
+
+    await expectWarrantyStatement(page, 'Not stated')
+    await expectQuotePartArrived(page, scenario)
+
+    const stored = await storedServiceRecord(scenario.jobId)
+    expect(stored).toMatchObject({ warrantyStatus: null, warrantyMonths: null })
+    expect(stored.warrantyExpiresAt).toBeNull()
+  })
+})
+
+test.describe('every question needs an answer', () => {
+  test('Add waits for the last answer, and answers belong to the job they were given for', async ({
+    page,
+  }) => {
+    // The job in question disagrees on all three; a second, newer job on the
+    // same car agrees with the quote on everything, and the dialog offers the
+    // newest first.
+    const scenario = await plantScenario('all-three', {
+      job: JOB_TEN_PERCENT,
+      quote: { ...QUOTE_EIGHTEEN_OFF, ...QUOTE_UNTAXED, ...QUOTE_TWELVE_MONTHS },
+    })
+    const agreeingTitle = `${scenario.jobTitle} agreeing`
+    const { serviceRecordId: agreeingId } = await plantJobOnVehicle(
+      organizationId,
+      scenario.vehicleId,
+      agreeingTitle
+    )
+    await setServiceRecordFields(agreeingId, {
+      ...JOB_BASE,
+      subtotal: 0,
+      taxRate: 0,
+      taxAmount: 0,
+      totalAmount: 0,
+      warrantyStatus: 'included',
+      warrantyMonths: 12,
+    })
+
+    const dialog = await openAddToWorkOrder(page, scenario, agreeingId)
+    // The vehicle picker comes first; the job picker is the dialog's other combobox.
+    const jobPicker = dialog.getByRole('combobox').last()
+    await expect(jobPicker).toContainText(agreeingTitle)
+    await expect(conflictBox(dialog)).toHaveCount(0)
+    await expect(addButton(dialog)).toBeEnabled()
+
+    async function pickJob(title: string, id: string) {
+      const checked = page.waitForResponse(conflictCheckFor(id), { timeout: 30_000 })
+      await jobPicker.click()
+      await page.getByRole('option', { name: title, exact: true }).click()
+      await checked
+      await expect(jobPicker).toContainText(title)
+    }
+
+    const discount = dialog.getByTestId('convert-conflict-discount')
+    const tax = dialog.getByTestId('convert-conflict-tax')
+    const warranty = dialog.getByTestId('convert-conflict-warranty')
+    const combine = discount.getByRole('radio', {
+      name: "Turn the 10% into $90.00 and add the quote's $18.00, $108.00 in all",
+    })
+    const keepTax = tax.getByRole('radio', {
+      name: "Keep the work order's tax. The quote's lines come across at the amounts quoted.",
+    })
+    const quoteWarranty = warranty.getByRole('radio', {
+      name: "Use the quote's warranty, as the customer accepted it",
+    })
+
+    await pickJob(scenario.jobTitle, scenario.jobId)
+    await expect(conflictBox(dialog)).toBeVisible()
+    await expect(discount).toBeVisible()
+    await expect(tax).toBeVisible()
+    await expect(warranty).toBeVisible()
+    await expect(addButton(dialog)).toBeDisabled()
+
+    // Two answers of three: still waiting.
+    await quoteWarranty.check()
+    await expect(addButton(dialog)).toBeDisabled()
+    await combine.check()
+    await expect(addButton(dialog)).toBeDisabled()
+
+    // Another job is another set of questions; this one has none.
+    await pickJob(agreeingTitle, agreeingId)
+    await expect(conflictBox(dialog)).toHaveCount(0)
+    await expect(addButton(dialog)).toBeEnabled()
+
+    // Back again, the answers given for it are gone: they were about the job
+    // as it was read then, and it is read afresh.
+    await pickJob(scenario.jobTitle, scenario.jobId)
+    await expect(conflictBox(dialog)).toBeVisible()
+    for (const question of [discount, tax, warranty]) {
+      for (const choice of await question.getByRole('radio').all()) {
+        await expect(choice).not.toBeChecked()
+      }
+    }
+    await expect(addButton(dialog)).toBeDisabled()
+
+    // Answered in another order than listed: enabled by the last, whichever it is.
+    await keepTax.check()
+    await expect(addButton(dialog)).toBeDisabled()
+    await quoteWarranty.check()
+    await expect(addButton(dialog)).toBeDisabled()
+    await combine.check()
+    await expect(addButton(dialog)).toBeEnabled()
+
+    // Each answer is applied on its own: a fixed 108, the job's 25%, and the
+    // quote's twelve months.
+    await addAndOpenJob(page, dialog, scenario.jobId)
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Discount: '-$108.00',
+      Tax: '$248.00',
+      Total: '$1,240.00',
+    })
+    const panel = await expectWarrantyStatement(page, 'Included')
+    await expect(panel.locator('#warrantyMonths')).toHaveValue('12')
+    await expectQuotePartArrived(page, scenario)
+
+    // The other job was not touched.
+    expect(await servicePartLines(agreeingId)).toEqual([])
+  })
+
+  test('a job changed after the dialog asked is refused, and nothing moves', async ({ page }) => {
+    const scenario = await plantScenario('changed', { quote: QUOTE_EIGHTEEN_OFF })
+    const dialog = await openAddToWorkOrder(page, scenario)
+    await expect(conflictBox(dialog)).toHaveCount(0)
+    await expect(addButton(dialog)).toBeEnabled()
+
+    // Someone gives the job a percentage while the dialog is open: the quote's
+    // 18 now needs an answer the dialog never asked for.
+    await setServiceRecordFields(scenario.jobId, JOB_TEN_PERCENT)
+
+    await addButton(dialog).click()
+    await expect(
+      page.getByText('The quote and the work order differ; choose what the work order keeps')
+    ).toBeVisible({ timeout: 30_000 })
+    expect(page.url()).toContain(scenario.quoteUrl)
+
+    expect(await quoteConversion(scenario.quoteId)).toEqual({
+      status: 'draft',
+      convertedToId: null,
+    })
+    expect((await servicePartLines(scenario.jobId)).map((line) => line.name)).toEqual([
+      scenario.jobPart,
+    ])
+    expect(await storedServiceRecord(scenario.jobId)).toMatchObject({
+      discountType: 'percentage',
+      discountValue: 10,
+      totalAmount: 1012.5,
+    })
+  })
+})

+ 396 - 0
e2e/specs/quotes/add-to-work-order.spec.ts

@@ -0,0 +1,396 @@
+import { expect, type Page, test } from '@playwright/test'
+import { partQuantity, stockMovements } from '../../support/db'
+import { settle } from '../../support/hydration'
+import { addQuoteLabor, addQuotePart, newQuote, saveQuote } from '../../support/quote'
+import {
+  archiveInventoryPart,
+  chooseExistingOrder,
+  existingOrderRadio,
+  newOrderRadio,
+  openConvertDialog,
+  type PlantedVehicle,
+  plantInventoryPart,
+  plantVehicle,
+  quoteNumberOf,
+} from '../../support/quote-conversion'
+import {
+  addLabor,
+  addPart,
+  jobIdOf,
+  laborRowOf,
+  laborRows,
+  newWorkOrder,
+  partRowOf,
+  partRows,
+  saveWorkOrder,
+  totalsRow,
+} from '../../support/work-order'
+
+/**
+ * A quote added to a work order the car already has open.
+ *
+ * Diagnostics usually come first: by the time the customer accepts the quote
+ * the car is already on a job, and converting it the old way raised a second
+ * job and split one visit across two invoices. The convert dialog now offers
+ * the vehicle's open jobs. What the quote puts on the job has to be what the
+ * customer accepted (its included lines, not the ones left out of the total),
+ * appended after what the job already said, which stays exactly as it was;
+ * stock moves once, and the job and the quote point at each other.
+ *
+ * Each describe gets a vehicle of its own, written straight into the
+ * database. The seeded Camry has open jobs of the seed's and of other specs,
+ * and `/service/new` hands back any untouched draft on the same car younger
+ * than five seconds, which another spec running beside this one could own.
+ *
+ * The quote and the job are left on the workshop's tax with no discount and
+ * no warranty of their own, so the dialog has no conflict to ask about; that
+ * prompt is another spec's.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+
+/** What the dialog lists a job as: its number and title, unless the title has the number. */
+function targetLabel(invoiceNumber: string, title: string): string {
+  return title.includes(invoiceNumber) ? title : `${invoiceNumber} · ${title}`
+}
+
+interface PartLine {
+  name: string
+  quantity: number
+  unitPrice: number
+}
+
+interface LaborLine {
+  description: string
+  hours: number
+  rate: number
+}
+
+/** Every part line on the open work order: name, quantity and unit price. */
+async function jobParts(page: Page): Promise<PartLine[]> {
+  const rows = partRows(page)
+  const lines: PartLine[] = []
+  for (let i = 0; i < (await rows.count()); i++) {
+    const numbers = partRowOf(rows.nth(i)).locator('input[type="number"]')
+    lines.push({
+      name: await rows.nth(i).inputValue(),
+      quantity: Number(await numbers.nth(0).inputValue()),
+      // Quantity, cost, markup, unit price, in the order the editor prints them.
+      unitPrice: Number(await numbers.nth(3).inputValue()),
+    })
+  }
+  return lines
+}
+
+/** Every labour line on the open work order: description, hours and rate. */
+async function jobLabor(page: Page): Promise<LaborLine[]> {
+  const rows = laborRows(page)
+  const lines: LaborLine[] = []
+  for (let i = 0; i < (await rows.count()); i++) {
+    const numbers = laborRowOf(rows.nth(i)).locator('input[type="number"]')
+    lines.push({
+      description: await rows.nth(i).inputValue(),
+      hours: Number(await numbers.nth(0).inputValue()),
+      rate: Number(await numbers.nth(1).inputValue()),
+    })
+  }
+  return lines
+}
+
+/**
+ * The quote's last part row, for its exclude switch and its numbers. The
+ * quote editor appends, and its number inputs carry no placeholders, so the
+ * row is the nearest ancestor holding one.
+ */
+function lastQuotePartRow(page: Page) {
+  return page
+    .getByPlaceholder('Name *')
+    .last()
+    .locator('xpath=ancestor::div[.//input[@type="number"]][1]')
+}
+
+test.describe('a quote added to the open work order', () => {
+  // The job as it was written up before the quote: two units of a part and an hour.
+  const JOB_TITLE = `E2E open job ${stamp}`
+  const JOB_PART: PartLine = { name: `E2E job brake fluid ${stamp}`, quantity: 2, unitPrice: 150 }
+  const JOB_LABOR: LaborLine = { description: `E2E job diagnosis ${stamp}`, hours: 1, rate: 400 }
+
+  // What the quote prices, and a line the customer left out of the total.
+  const QUOTE_TITLE = `E2E add to job quote ${stamp}`
+  const QUOTE_PART: PartLine = { name: `E2E quote pads ${stamp}`, quantity: 1, unitPrice: 275 }
+  const QUOTE_LABOR: LaborLine = {
+    description: `E2E quote fit pads ${stamp}`,
+    hours: 1.5,
+    rate: 600,
+  }
+  const EXCLUDED_PART: PartLine = {
+    name: `E2E quote left out ${stamp}`,
+    quantity: 1,
+    unitPrice: 999,
+  }
+
+  let vehicle: PlantedVehicle
+  let jobUrl = ''
+  let invoiceNumber = ''
+  let quoteUrl = ''
+
+  test.beforeAll(async () => {
+    vehicle = await plantVehicle(`AddToJob${stamp}`)
+  })
+
+  test('the job is written up and the quote priced, one line left out', async ({ page }) => {
+    jobUrl = await newWorkOrder(page, `/vehicles/${vehicle.id}`, JOB_TITLE)
+    await addPart(page, JOB_PART)
+    await addLabor(page, JOB_LABOR)
+    await saveWorkOrder(page)
+    await expect(totalsRow(page, 'Subtotal')).toContainText('$700.00')
+    invoiceNumber = await page.getByLabel('Invoice Number').inputValue()
+    expect(invoiceNumber).not.toBe('')
+
+    quoteUrl = await newQuote(page, QUOTE_TITLE, vehicle.model)
+    await addQuotePart(page, QUOTE_PART)
+    await addQuoteLabor(page, {
+      description: QUOTE_LABOR.description,
+      hours: QUOTE_LABOR.hours,
+      rate: QUOTE_LABOR.rate,
+    })
+    await addQuotePart(page, EXCLUDED_PART)
+    // The per-line switch at the end of the row: struck through, out of the total.
+    const exclude = lastQuotePartRow(page).getByRole('checkbox', {
+      name: 'Exclude from total',
+    })
+    await exclude.check()
+    await expect(exclude).toBeChecked()
+    await saveQuote(page)
+
+    // Read back after a reload: the switch is what the conversion reads.
+    await page.reload()
+    await settle(page)
+    await expect(page.getByPlaceholder('Name *').last()).toHaveValue(EXCLUDED_PART.name)
+    await expect(
+      lastQuotePartRow(page).getByRole('checkbox', { name: 'Exclude from total' })
+    ).toBeChecked()
+  })
+
+  test('the dialog offers the open job and adds the quote to it', async ({ page }) => {
+    await page.goto(quoteUrl)
+    const dialog = await openConvertDialog(page)
+
+    await expect(newOrderRadio(dialog)).toBeChecked()
+    const confirm = await chooseExistingOrder(page, dialog, targetLabel(invoiceNumber, JOB_TITLE))
+
+    // Enabled only once the dialog has finished comparing the two, so the
+    // check for a conflict box below is made after that answer is in.
+    await expect(confirm).toBeEnabled({ timeout: 15_000 })
+    // Same tax, no discount, no warranty: nothing for the workshop to decide.
+    // If this box appears the fixture has drifted from the job, not the app.
+    await expect(dialog.getByText('Before adding, decide what the work order keeps')).toHaveCount(0)
+    await expect(dialog.getByRole('button', { name: 'Convert', exact: true })).toHaveCount(0)
+    await confirm.click()
+
+    // The same job, not a new one.
+    await page.waitForURL(jobUrl, { timeout: 60_000 })
+    await settle(page)
+    await expect(page.getByLabel('Invoice Number')).toHaveValue(invoiceNumber)
+  })
+
+  test("the job carries the quote's included lines and the totals add up", async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+
+    // Two parts and two labour lines: the left-out part stayed on the quote.
+    await expect(partRows(page)).toHaveCount(2)
+    await expect(laborRows(page)).toHaveCount(2)
+
+    const parts = await jobParts(page)
+    expect(parts).toContainEqual(QUOTE_PART)
+    expect(parts.map((p) => p.name)).not.toContain(EXCLUDED_PART.name)
+    const labor = await jobLabor(page)
+    expect(labor).toContainEqual(QUOTE_LABOR)
+
+    // 300 + 400 from the job, 275 + 900 from the quote.
+    await expect(totalsRow(page, 'Parts')).toContainText('$575.00')
+    await expect(totalsRow(page, 'Labor')).toContainText('$1,300.00')
+    await expect(totalsRow(page, 'Subtotal')).toContainText('$1,875.00')
+  })
+
+  test("the job's own lines are as they were", async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+
+    const parts = await jobParts(page)
+    const labor = await jobLabor(page)
+    // Untouched: one line each, with the name, quantity and price it was saved with.
+    expect(parts.filter((p) => p.name === JOB_PART.name)).toEqual([JOB_PART])
+    expect(labor.filter((l) => l.description === JOB_LABOR.description)).toEqual([JOB_LABOR])
+  })
+
+  test('the job links back to the quote, which reads converted', async ({ page }) => {
+    const quoteNumber = await quoteNumberOf(quoteUrl)
+
+    await page.goto(jobUrl)
+    await settle(page)
+    const link = page.getByTestId('service-from-quote')
+    await expect(link).toHaveText(`From quote ${quoteNumber}`)
+    await expect(async () => {
+      await link.click()
+      await page.waitForURL(quoteUrl, { timeout: 5_000 })
+    }).toPass({ timeout: 30_000 })
+    await settle(page)
+
+    await expect(page.locator('#title')).toHaveValue(QUOTE_TITLE)
+    await expect(page.getByRole('combobox', { name: 'Status' })).toContainText('Converted')
+    // A quote already on a job cannot be put on one again.
+    await expect(
+      page.getByRole('button', { name: 'Convert to Work Order', exact: true })
+    ).toHaveCount(0)
+  })
+})
+
+test.describe('a finished job is not offered', () => {
+  const JOB_TITLE = `E2E job to finish ${stamp}`
+  let vehicle: PlantedVehicle
+  let jobUrl = ''
+  let invoiceNumber = ''
+  let quoteUrl = ''
+
+  /** The status stepper in the work order's header: one button per stage. */
+  function stage(page: Page, name: string) {
+    return page.getByTestId('status-stepper').getByRole('button', { name: new RegExp(name) })
+  }
+
+  test.beforeAll(async () => {
+    vehicle = await plantVehicle(`Finished${stamp}`)
+  })
+
+  test('while the job is open it is offered', async ({ page }) => {
+    jobUrl = await newWorkOrder(page, `/vehicles/${vehicle.id}`, JOB_TITLE)
+    await addPart(page, { name: `E2E finished job part ${stamp}`, quantity: 1, unitPrice: 100 })
+    await saveWorkOrder(page)
+    invoiceNumber = await page.getByLabel('Invoice Number').inputValue()
+
+    quoteUrl = await newQuote(page, `E2E quote after finish ${stamp}`, vehicle.model)
+    await addQuotePart(page, { name: `E2E later part ${stamp}`, quantity: 1, unitPrice: 80 })
+    await saveQuote(page)
+
+    // The control for the test below: this car's one job is listed while open.
+    const dialog = await openConvertDialog(page)
+    await chooseExistingOrder(page, dialog, targetLabel(invoiceNumber, JOB_TITLE))
+    await dialog.getByRole('button', { name: 'Cancel', exact: true }).click()
+    await expect(dialog).toBeHidden()
+  })
+
+  test('once completed, the vehicle has no open work orders to add to', async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+    await expect(async () => {
+      await stage(page, 'Completed').click()
+      await expect(stage(page, 'Completed')).toHaveAttribute('aria-current', 'step', {
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+    await saveWorkOrder(page)
+    await page.reload()
+    await settle(page)
+    await expect(stage(page, 'Completed')).toHaveAttribute('aria-current', 'step')
+
+    await page.goto(quoteUrl)
+    const dialog = await openConvertDialog(page)
+    await expect(dialog.getByText('This vehicle has no open work orders.')).toBeVisible({
+      timeout: 15_000,
+    })
+    await expect(existingOrderRadio(dialog)).toBeDisabled()
+    await expect(newOrderRadio(dialog)).toBeChecked()
+    // Only the vehicle picker: no list of jobs to choose from.
+    await expect(dialog.getByRole('combobox')).toHaveCount(1)
+    await expect(dialog.getByRole('button', { name: 'Convert', exact: true })).toBeEnabled()
+    await expect(dialog.getByRole('button', { name: 'Add to work order' })).toHaveCount(0)
+  })
+})
+
+test.describe('a stocked part on the quote', () => {
+  const PART_NAME = `E2E quote stock part ${stamp}`
+  // Below ten on hand on purpose: `stockedPart` in support/db.ts picks a part
+  // with ten or more, so the inventory spec never takes this one.
+  const OPENING = 5
+  const JOB_TITLE = `E2E stock job ${stamp}`
+  let vehicle: PlantedVehicle
+  let partId = ''
+  let jobUrl = ''
+  let invoiceNumber = ''
+  let quoteUrl = ''
+
+  test.beforeAll(async () => {
+    vehicle = await plantVehicle(`StockJob${stamp}`)
+    partId = await plantInventoryPart({
+      name: PART_NAME,
+      quantity: OPENING,
+      unitCost: 100,
+      sellPrice: 250,
+    })
+  })
+
+  test.afterAll(async () => {
+    if (partId) await archiveInventoryPart(partId)
+  })
+
+  test('leaves the shelf once when the quote is added to the job', async ({ page }) => {
+    jobUrl = await newWorkOrder(page, `/vehicles/${vehicle.id}`, JOB_TITLE)
+    await addLabor(page, { description: `E2E stock job check ${stamp}`, hours: 1, rate: 300 })
+    await saveWorkOrder(page)
+    invoiceNumber = await page.getByLabel('Invoice Number').inputValue()
+
+    quoteUrl = await newQuote(page, `E2E stock quote ${stamp}`, vehicle.model)
+    await settle(page)
+
+    // The picker is the only way a quote line carries the stock link.
+    const rows = page.getByPlaceholder('Name *')
+    const before = await rows.count()
+    const picker = page.getByRole('dialog', { name: 'Select Part from Inventory' })
+    await expect(async () => {
+      await page.getByRole('button', { name: 'From stock', exact: true }).click()
+      await expect(picker).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await picker.getByPlaceholder('Search inventory...').fill(PART_NAME)
+    await picker
+      .getByRole('button', { name: new RegExp(PART_NAME) })
+      .first()
+      .click()
+    await expect(rows).toHaveCount(before + 1)
+    await expect(rows.last()).toHaveValue(PART_NAME)
+    await lastQuotePartRow(page).locator('input[type="number"]').nth(0).fill('2')
+    await saveQuote(page)
+
+    // An estimate moves nothing.
+    expect(await partQuantity(partId), 'a quote moves no stock').toBe(OPENING)
+
+    await page.reload()
+    const dialog = await openConvertDialog(page)
+    const confirm = await chooseExistingOrder(page, dialog, targetLabel(invoiceNumber, JOB_TITLE))
+    await expect(confirm).toBeEnabled({ timeout: 15_000 })
+    await confirm.click()
+    await page.waitForURL(jobUrl, { timeout: 60_000 })
+    await settle(page)
+
+    const parts = await jobParts(page)
+    expect(parts).toContainEqual({ name: PART_NAME, quantity: 2, unitPrice: 250 })
+
+    expect(await partQuantity(partId)).toBe(OPENING - 2)
+    const ledger = await stockMovements(partId, jobIdOf(jobUrl))
+    expect(ledger).toHaveLength(1)
+    expect(ledger[0].delta).toBe(-2)
+    expect(ledger[0].quantityAfter, 'the ledger agrees with the count').toBe(OPENING - 2)
+    expect(ledger[0].reason).toBe('quote_conversion')
+  })
+
+  test('and not again when the job is saved afterwards', async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+    await saveWorkOrder(page)
+
+    expect(await partQuantity(partId)).toBe(OPENING - 2)
+    expect(await stockMovements(partId, jobIdOf(jobUrl))).toHaveLength(1)
+  })
+})

+ 4 - 1
e2e/specs/smoke/app.spec.ts

@@ -16,7 +16,10 @@ test.describe('smoke', () => {
   test('the seeded workshop has customers and vehicles', async ({ page }) => {
     // Lists render a card for phones and a table for wider screens and hide
     // one of them; the table is the visible one at the desktop size used here.
-    await page.goto('/customers')
+    // Searched, like the vehicles below: the list shows the twenty customers
+    // touched last, and the specs that run before this one plant their own,
+    // so the seed's are not on the first page of an unsearched list.
+    await page.goto('/customers?search=James%20Mitchell')
     await expect(page.getByRole('table').getByText('James Mitchell').first()).toBeVisible()
 
     // The vehicle list opens as a grid of cards, each headed by the vehicle's

+ 57 - 0
e2e/support/condition-map-db.ts

@@ -0,0 +1,57 @@
+import { Client } from 'pg'
+
+/**
+ * Database peeks for the condition map's carry forward: what "Confirm all as
+ * still there" wrote, read back with the column that links an earlier mark to
+ * the one a later visit recorded in its place. Kept apart from `db.ts`, in the
+ * same plain-pg style, so the carry specs can grow without touching it.
+ */
+async function withDb<T>(fn: (db: Client) => Promise<T>): Promise<T> {
+  const url = process.env.E2E_DATABASE_URL
+  if (!url) throw new Error('E2E_DATABASE_URL is not set. See e2e/README.md.')
+  const db = new Client({ connectionString: url })
+  await db.connect()
+  try {
+    return await fn(db)
+  } finally {
+    await db.end()
+  }
+}
+
+export interface CarryMarkRow {
+  id: string
+  serviceRecordId: string | null
+  inspectionId: string | null
+  inspectionItemId: string | null
+  bodyType: string
+  view: string
+  panel: string
+  x: number
+  y: number
+  kind: string
+  severity: string
+  note: string | null
+  imageUrls: string[]
+  resolvedAt: Date | null
+  carriedToId: string | null
+}
+
+/** Every mark on the vehicle with its carry link, oldest first, resolved ones included. */
+export async function marksWithCarry(vehicleId: string): Promise<CarryMarkRow[]> {
+  return withDb(async (db) => {
+    const result = await db.query<CarryMarkRow>(
+      `select id, "serviceRecordId", "inspectionId", "inspectionItemId", "bodyType", view, panel,
+              x, y, kind, severity, note, "imageUrls", "resolvedAt", "carriedToId"
+         from condition_marks where "vehicleId" = $1 order by "recordedAt", "createdAt"`,
+      [vehicleId]
+    )
+    return result.rows
+  })
+}
+
+/** Puts photos on a planted mark, as if they had been taken in its editor. */
+export async function setMarkPhotos(markId: string, urls: string[]): Promise<void> {
+  await withDb((db) =>
+    db.query(`update condition_marks set "imageUrls" = $2 where id = $1`, [markId, urls])
+  )
+}

+ 246 - 0
e2e/support/db.ts

@@ -1821,3 +1821,249 @@ export async function plantOwnMarkKind(
 export async function removeOwnMarkKind(id: string): Promise<void> {
   await withDb((db) => db.query(`delete from condition_mark_types where id = $1`, [id]))
 }
+
+// ─── A quote added to an open work order ─────────────────────────────────────
+
+/**
+ * The columns a spec may write on a quote or a work order to make the two
+ * disagree, without touching the workshop's own settings (which other specs
+ * read at the same time). The stored totals are columns too: a discount or a
+ * tax written here leaves them stale until the document is saved or
+ * re-totalled, so a spec writes them alongside when it reads them back.
+ */
+export interface DocumentFields {
+  subtotal?: number
+  discountType?: 'percentage' | 'fixed' | null
+  discountValue?: number
+  discountAmount?: number
+  taxRate?: number
+  taxInclusive?: boolean
+  taxComponents?: { name: string; rate: number }[] | null
+  taxAmount?: number
+  totalAmount?: number
+  warrantyStatus?: 'included' | 'not_included' | null
+  warrantyMonths?: number | null
+  warrantyMileage?: number | null
+  warrantyNotes?: string | null
+}
+
+export interface ServiceRecordFields extends DocumentFields {
+  status?: string
+}
+
+const DOCUMENT_COLUMNS = new Set([
+  'subtotal',
+  'discountType',
+  'discountValue',
+  'discountAmount',
+  'taxRate',
+  'taxInclusive',
+  'taxComponents',
+  'taxAmount',
+  'totalAmount',
+  'warrantyStatus',
+  'warrantyMonths',
+  'warrantyMileage',
+  'warrantyNotes',
+  'status',
+])
+
+/** `"col" = $n, ...` for the given fields, refusing anything not listed above. */
+function assignments(fields: object, offset: number): { sql: string; values: unknown[] } {
+  const entries = Object.entries(fields).filter(([, value]) => value !== undefined)
+  if (entries.length === 0) throw new Error('nothing to write')
+  for (const [column] of entries) {
+    if (!DOCUMENT_COLUMNS.has(column)) throw new Error(`not a writable column: ${column}`)
+  }
+  return {
+    sql: entries.map(([column], i) => `"${column}" = $${i + offset}`).join(', '),
+    values: entries.map(([column, value]) =>
+      column === 'taxComponents' && value !== null ? JSON.stringify(value) : value
+    ),
+  }
+}
+
+/** Writes columns on a quote, found by its id or its number. */
+export async function setQuoteFields(
+  quoteNumberOrId: string,
+  fields: DocumentFields
+): Promise<void> {
+  await withDb(async (db) => {
+    const { sql, values } = assignments(fields, 2)
+    const result = await db.query(
+      `update quotes set ${sql}, "updatedAt" = now() where id = $1 or "quoteNumber" = $1`,
+      [quoteNumberOrId, ...values]
+    )
+    if (result.rowCount !== 1) {
+      throw new Error(`expected one quote for ${quoteNumberOrId}, found ${result.rowCount}`)
+    }
+  })
+}
+
+/** Writes columns on a work order. */
+export async function setServiceRecordFields(
+  serviceRecordId: string,
+  fields: ServiceRecordFields
+): Promise<void> {
+  await withDb(async (db) => {
+    const { sql, values } = assignments(fields, 2)
+    const result = await db.query(
+      `update service_records set ${sql}, "updatedAt" = now() where id = $1`,
+      [serviceRecordId, ...values]
+    )
+    if (result.rowCount !== 1) throw new Error(`no work order ${serviceRecordId}`)
+  })
+}
+
+export interface PlantedPartLine {
+  name: string
+  quantity: number
+  unitPrice: number
+}
+
+export interface PlantedLaborLine {
+  description: string
+  hours: number
+  rate: number
+}
+
+/**
+ * Part and labour lines written onto a work order, priced the way the editor
+ * prices them (quantity × unit price, hours × rate). Nothing is re-totalled:
+ * the spec writes the stored totals it wants with `setServiceRecordFields`.
+ */
+export async function addServiceRecordLines(
+  serviceRecordId: string,
+  lines: { parts?: PlantedPartLine[]; labor?: PlantedLaborLine[] }
+): Promise<void> {
+  await withDb(async (db) => {
+    for (const part of lines.parts ?? []) {
+      await db.query(
+        `insert into service_parts (id, name, quantity, "unitPrice", total, "serviceRecordId")
+         values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5)`,
+        [part.name, part.quantity, part.unitPrice, part.quantity * part.unitPrice, serviceRecordId]
+      )
+    }
+    for (const labor of lines.labor ?? []) {
+      await db.query(
+        `insert into service_labor (id, description, hours, rate, total, "serviceRecordId")
+         values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5)`,
+        [labor.description, labor.hours, labor.rate, labor.hours * labor.rate, serviceRecordId]
+      )
+    }
+  })
+}
+
+/**
+ * A draft quote for a vehicle and its owner, with its lines, as if typed into
+ * the editor and saved. Its tax, discount and warranty are whatever the spec
+ * writes next with `setQuoteFields`; the stored subtotal and total follow the
+ * lines here so the quote is consistent before that.
+ */
+export async function plantQuote(
+  organizationId: string,
+  userId: string,
+  input: {
+    vehicleId: string
+    title: string
+    parts?: PlantedPartLine[]
+    labor?: PlantedLaborLine[]
+  }
+): Promise<string> {
+  return withDb(async (db) => {
+    const parts = input.parts ?? []
+    const labor = input.labor ?? []
+    const subtotal =
+      parts.reduce((sum, p) => sum + p.quantity * p.unitPrice, 0) +
+      labor.reduce((sum, l) => sum + l.hours * l.rate, 0)
+    const quote = await db.query<{ id: string }>(
+      `insert into quotes (id, title, status, subtotal, "totalAmount", "userId", "organizationId",
+                           "customerId", "vehicleId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, 'draft', $2, $2, $3, $4,
+               (select "customerId" from vehicles where id = $5), $5, now())
+       returning id`,
+      [input.title, subtotal, userId, organizationId, input.vehicleId]
+    )
+    const quoteId = quote.rows[0].id
+    for (const part of parts) {
+      await db.query(
+        `insert into quote_parts (id, name, quantity, "unitPrice", total, "quoteId")
+         values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5)`,
+        [part.name, part.quantity, part.unitPrice, part.quantity * part.unitPrice, quoteId]
+      )
+    }
+    for (const line of labor) {
+      await db.query(
+        `insert into quote_labor (id, description, hours, rate, total, "quoteId")
+         values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5)`,
+        [line.description, line.hours, line.rate, line.hours * line.rate, quoteId]
+      )
+    }
+    return quoteId
+  })
+}
+
+export interface StoredServiceRecord {
+  status: string
+  subtotal: number
+  discountType: string | null
+  discountValue: number
+  discountAmount: number
+  taxRate: number
+  taxInclusive: boolean
+  taxAmount: number
+  totalAmount: number
+  warrantyStatus: string | null
+  warrantyMonths: number | null
+  warrantyExpiresAt: Date | null
+}
+
+/** What a work order stores about its money and its warranty. */
+export async function storedServiceRecord(serviceRecordId: string): Promise<StoredServiceRecord> {
+  return withDb(async (db) => {
+    const result = await db.query<StoredServiceRecord>(
+      `select status, subtotal, "discountType", "discountValue", "discountAmount", "taxRate",
+              "taxInclusive", "taxAmount", "totalAmount", "warrantyStatus", "warrantyMonths",
+              "warrantyExpiresAt"
+         from service_records where id = $1`,
+      [serviceRecordId]
+    )
+    const row = result.rows[0]
+    if (!row) throw new Error(`no work order ${serviceRecordId}`)
+    return row
+  })
+}
+
+/** The work order's part lines, by name. */
+export async function servicePartLines(
+  serviceRecordId: string
+): Promise<{ name: string; quantity: number; unitPrice: number; total: number }[]> {
+  return withDb(async (db) => {
+    const result = await db.query<{
+      name: string
+      quantity: number
+      unitPrice: number
+      total: number
+    }>(
+      `select name, quantity, "unitPrice", total from service_parts
+        where "serviceRecordId" = $1 order by name`,
+      [serviceRecordId]
+    )
+    return result.rows
+  })
+}
+
+/** A quote's status and the job it went to, if any. */
+export async function quoteConversion(
+  quoteId: string
+): Promise<{ status: string; convertedToId: string | null }> {
+  return withDb(async (db) => {
+    const result = await db.query<{ status: string; convertedToId: string | null }>(
+      `select status, "convertedToId" from quotes where id = $1`,
+      [quoteId]
+    )
+    const row = result.rows[0]
+    if (!row) throw new Error(`no quote ${quoteId}`)
+    return row
+  })
+}

+ 155 - 0
e2e/support/quote-conversion.ts

@@ -0,0 +1,155 @@
+import { expect, type Locator, type Page } from '@playwright/test'
+import { Client } from 'pg'
+import { ownerOrganizationId, userIdFor } from './db'
+import { settle } from './hydration'
+
+/**
+ * Adding a quote to a work order the vehicle already has open.
+ *
+ * The fixtures are written straight into the database because they are not
+ * what is under test: a vehicle of the spec's own (so no other spec's jobs are
+ * on it, and `/service/new` cannot hand back another spec's five-second draft)
+ * and a stocked part nobody else counts. Everything the feature does is then
+ * driven through the page.
+ */
+
+async function withDb<T>(fn: (db: Client) => Promise<T>): Promise<T> {
+  const url = process.env.E2E_DATABASE_URL
+  if (!url) throw new Error('E2E_DATABASE_URL is not set. See e2e/README.md.')
+  const db = new Client({ connectionString: url })
+  await db.connect()
+  try {
+    return await fn(db)
+  } finally {
+    await db.end()
+  }
+}
+
+export interface PlantedVehicle {
+  id: string
+  /** Unique across runs, so the quote dialog's vehicle search finds this one only. */
+  model: string
+}
+
+/** A customer and a vehicle of their own in the seeded workshop, with no jobs on it. */
+export async function plantVehicle(model: string): Promise<PlantedVehicle> {
+  const organizationId = await ownerOrganizationId()
+  const userId = await userIdFor('demo@torqvoice.com')
+  return withDb(async (db) => {
+    const customer = await db.query<{ id: string }>(
+      `insert into customers (id, name, "userId", "organizationId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, now())
+       returning id`,
+      [`${model} owner`, userId, organizationId]
+    )
+    const vehicle = await db.query<{ id: string }>(
+      `insert into vehicles (id, make, model, year, "licensePlate", "userId", "organizationId", "customerId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), 'E2E', $1, 2021, $2, $3, $4, $5, now())
+       returning id`,
+      [model, `E2E-${model.slice(-6)}`, userId, organizationId, customer.rows[0].id]
+    )
+    return { id: vehicle.rows[0].id, model }
+  })
+}
+
+/** The number a quote was given, which is what the job's hero names it by. */
+export async function quoteNumberOf(quoteUrl: string): Promise<string> {
+  const id = new URL(quoteUrl).pathname.split('/').pop()
+  return withDb(async (db) => {
+    const result = await db.query<{ quoteNumber: string | null }>(
+      `select "quoteNumber" from quotes where id = $1`,
+      [id]
+    )
+    const number = result.rows[0]?.quoteNumber
+    if (!number) throw new Error(`quote ${id} has no number`)
+    return number
+  })
+}
+
+/** A stocked part of the spec's own, so no other spec moves its count. */
+export async function plantInventoryPart(part: {
+  name: string
+  quantity: number
+  unitCost: number
+  sellPrice: number
+}): Promise<string> {
+  const organizationId = await ownerOrganizationId()
+  const userId = await userIdFor('demo@torqvoice.com')
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into inventory_parts (id, name, quantity, "unitCost", "sellPrice", "userId", "organizationId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5, $6, now())
+       returning id`,
+      [part.name, part.quantity, part.unitCost, part.sellPrice, userId, organizationId]
+    )
+    return result.rows[0].id
+  })
+}
+
+/** Takes a planted part out of the pickers again, keeping its ledger. */
+export async function archiveInventoryPart(id: string): Promise<void> {
+  await withDb((db) =>
+    db.query(`update inventory_parts set "isArchived" = true where id = $1`, [id])
+  )
+}
+
+/** The convert dialog, by name: an announcement card is a dialog too. */
+export function convertDialog(page: Page): Locator {
+  return page.getByRole('dialog', { name: 'Convert Quote to Work Order' })
+}
+
+/** Opens the convert dialog on the quote open in the page. */
+export async function openConvertDialog(page: Page): Promise<Locator> {
+  await settle(page)
+  const dialog = convertDialog(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Convert to Work Order', exact: true }).click()
+    await expect(dialog).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  return dialog
+}
+
+/** The two ways the dialog offers, as radios named by their labels. */
+export function newOrderRadio(dialog: Locator): Locator {
+  return dialog.getByRole('radio', { name: 'Create a new work order' })
+}
+
+export function existingOrderRadio(dialog: Locator): Locator {
+  return dialog.getByRole('radio', { name: 'Add to an existing work order' })
+}
+
+/**
+ * The open-job picker. The dialog holds two comboboxes, the vehicle picker
+ * first and this one under the "existing" radio, so it is the last.
+ */
+export function workOrderPicker(dialog: Locator): Locator {
+  return dialog.getByRole('combobox').last()
+}
+
+/**
+ * Chooses "Add to an existing work order" and the job named by its label.
+ * Returns the dialog's confirm button, which then reads "Add to work order".
+ */
+export async function chooseExistingOrder(
+  page: Page,
+  dialog: Locator,
+  label: string
+): Promise<Locator> {
+  const existing = existingOrderRadio(dialog)
+  // Enabled once the vehicle's open jobs have loaded.
+  await expect(existing).toBeEnabled({ timeout: 15_000 })
+  await existing.check()
+
+  const picker = workOrderPicker(dialog)
+  await expect(picker).toBeVisible()
+  await expect(async () => {
+    await picker.click()
+    await expect(page.getByRole('option', { name: label, exact: true })).toBeVisible({
+      timeout: 2_000,
+    })
+  }).toPass({ timeout: 30_000 })
+  await page.getByRole('option', { name: label, exact: true }).click()
+  await expect(picker).toHaveText(label)
+
+  return dialog.getByRole('button', { name: 'Add to work order', exact: true })
+}

+ 1 - 0
messages/de/conditionMap.json

@@ -40,6 +40,7 @@
   "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.",
+  "readOnlyCarried": "Bei einem späteren Besuch erneut bestätigt. Bleibt hier als Aufzeichnung dieses Besuchs und wird dort geändert.",
   "readOnlyOtherCheck": "Auf einem anderen Prüfpunkt dieser Inspektion eingezeichnet. Dort ändern.",
   "count": "{count, plural, =0 {Keine Markierungen} one {# Markierung} other {# Markierungen}}",
   "previousCount": "{count, plural, one {# von einem früheren Besuch} other {# von früheren Besuchen}}",

+ 32 - 0
messages/de/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "Für dieses Fahrzeug gibt es keine offenen Arbeitsaufträge.",
     "convertSelectWorkOrder": "Arbeitsauftrag auswählen",
     "convertAdd": "Zum Arbeitsauftrag hinzufügen",
+    "convertConflicts": {
+      "heading": "Vor dem Hinzufügen: Was behält der Arbeitsauftrag?",
+      "checking": "Angebot wird mit dem Arbeitsauftrag abgeglichen...",
+      "unanswered": "Legen Sie fest, was der Arbeitsauftrag behält, bevor das Angebot hinzugefügt wird.",
+      "discount": {
+        "title": "Rabatt",
+        "summary": "Der Arbeitsauftrag hat {percent}% Rabatt, derzeit {jobAmount}. Das Angebot enthält einen Rabatt von {quoteAmount}.",
+        "keepJob": "{percent}% Rabatt behalten und die {quoteAmount} des Angebots verwerfen",
+        "combine": "{percent}% in {jobAmount} umwandeln und die {quoteAmount} des Angebots hinzufügen, insgesamt {total}"
+      },
+      "tax": {
+        "title": "Steuer",
+        "summary": "Der Arbeitsauftrag ist mit {job} kalkuliert. Das Angebot wurde mit {quote} kalkuliert.",
+        "keepJob": "Steuer des Arbeitsauftrags behalten. Die Positionen des Angebots werden zu den angebotenen Beträgen übernommen.",
+        "useQuote": "Arbeitsauftrag auf die Steuer des Angebots umstellen. Alles darauf wird neu summiert.",
+        "inclusive": "{rate}% Steuer in den Positionen enthalten",
+        "exclusive": "{rate}% Steuer kommt hinzu",
+        "components": "{rate}% Steuer ({names})"
+      },
+      "warranty": {
+        "title": "Garantie",
+        "summary": "Der Arbeitsauftrag sagt {job}. Das vom Kunden angenommene Angebot sagt {quote}.",
+        "keepJob": "Garantie des Arbeitsauftrags behalten",
+        "useQuote": "Garantie des Angebots übernehmen, wie der Kunde sie angenommen hat",
+        "none": "nichts zur Garantie",
+        "notIncluded": "keine Werkstattgarantie",
+        "included": "Garantie inbegriffen",
+        "includedWithTerms": "Garantie inbegriffen, {terms}",
+        "months": "{count, plural, one {# Monat} other {# Monate}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Umwandeln",
     "convertToWorkOrder": "In Arbeitsauftrag umwandeln",
     "cancel": "Abbrechen",

+ 6 - 1
messages/de/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Nächste Prüfung fällig",
       "defect_notes": "Anmerkungen",
       "defect_photos": "Fotos",
+      "no_defects_note": "Hinweis, wenn keine Mängel vorliegen",
       "passed_checks": "Bestandene Prüfungen",
       "not_applicable_checks": "Nicht zutreffende Prüfungen",
       "check_notes": "Spalte Anmerkungen",
       "result_detail": "Erläuterung",
       "result_summary": "Anzahl je Bewertung",
       "combined_table": "Eine Tabelle für alle Abschnitte",
+      "ungraded_checks": "Noch nicht bewertete Prüfungen",
       "inspector_line": "Unterschriftszeile",
       "inspector_name": "Name des Unterzeichners",
       "date_line": "Datumszeile",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Beleuchtungseinrichtungen",
       "checkHeadlamps": "Zustand der Scheinwerfer",
       "checkFogLamp": "Nebelschlussleuchte",
+      "checkStopLamps": "Bremsleuchten",
       "photoFront": "Vorne",
       "photoOdometer": "Kilometerstand",
       "concernNoise": "Schleifgeräusch vorne beim Bremsen",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "Alle fünf",
     "conditionMapViewsTop": "Nur von oben",
     "conditionMapViewsSides": "Nur die Seiten",
-    "conditionMapWidthHint": "Leer füllt die Zeile. Punkte angeben, um die Zeichnung zu verkleinern; sie sitzt dann nach der Ausrichtung, und die Legende behält die volle Breite."
+    "conditionMapWidthHint": "Leer füllt die Zeile. Punkte angeben, um die Zeichnung zu verkleinern; sie sitzt dann nach der Ausrichtung, und die Legende behält die volle Breite.",
+    "conditionMapDense": "Kompakte Tabelle",
+    "conditionMapDenseHint": "Setzt die Zeilen der Tabelle unter der Zeichnung enger, sodass sie weniger Platz auf der Seite braucht."
   },
   "preview": {
     "title": "Bremsenservice und Ölwechsel",

+ 1 - 0
messages/en/conditionMap.json

@@ -40,6 +40,7 @@
   "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.",
+  "readOnlyCarried": "Confirmed again on a later visit. It stays here as this visit's record and is changed there.",
   "readOnlyOtherCheck": "Drawn on another check of this inspection. Change it there.",
   "count": "{count, plural, =0 {No marks} one {# mark} other {# marks}}",
   "previousCount": "{count, plural, one {# from an earlier visit} other {# from earlier visits}}",

+ 32 - 0
messages/en/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "This vehicle has no open work orders.",
     "convertSelectWorkOrder": "Select a work order",
     "convertAdd": "Add to work order",
+    "convertConflicts": {
+      "heading": "Before adding, decide what the work order keeps",
+      "checking": "Checking the quote against the work order...",
+      "unanswered": "Choose what the work order keeps before adding the quote.",
+      "discount": {
+        "title": "Discount",
+        "summary": "The work order has a {percent}% discount, {jobAmount} as it stands. The quote carries a discount of {quoteAmount}.",
+        "keepJob": "Keep the {percent}% discount and drop the quote's {quoteAmount}",
+        "combine": "Turn the {percent}% into {jobAmount} and add the quote's {quoteAmount}, {total} in all"
+      },
+      "tax": {
+        "title": "Tax",
+        "summary": "The work order is priced with {job}. The quote was priced with {quote}.",
+        "keepJob": "Keep the work order's tax. The quote's lines come across at the amounts quoted.",
+        "useQuote": "Switch the work order to the quote's tax. Everything on it is re-totalled.",
+        "inclusive": "{rate}% tax included in the lines",
+        "exclusive": "{rate}% tax added on top",
+        "components": "{rate}% tax ({names})"
+      },
+      "warranty": {
+        "title": "Warranty",
+        "summary": "The work order says {job}. The quote the customer accepted says {quote}.",
+        "keepJob": "Keep the work order's warranty",
+        "useQuote": "Use the quote's warranty, as the customer accepted it",
+        "none": "nothing about warranty",
+        "notIncluded": "no workshop warranty",
+        "included": "warranty included",
+        "includedWithTerms": "warranty included, {terms}",
+        "months": "{count, plural, one {# month} other {# months}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Convert",
     "convertToWorkOrder": "Convert to Work Order",
     "cancel": "Cancel",

+ 6 - 1
messages/en/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Next test due",
       "defect_notes": "Notes",
       "defect_photos": "Photos",
+      "no_defects_note": "Note when there are no defects",
       "passed_checks": "Passed checks",
       "not_applicable_checks": "Not applicable checks",
       "check_notes": "Notes column",
       "result_detail": "Explanation",
       "result_summary": "Counts per grade",
       "combined_table": "One table for all sections",
+      "ungraded_checks": "Checks not graded yet",
       "inspector_line": "Signature line",
       "inspector_name": "Signer's name",
       "date_line": "Date line",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Lighting equipment",
       "checkHeadlamps": "Headlamp condition",
       "checkFogLamp": "Rear fog lamp",
+      "checkStopLamps": "Stop lamps",
       "photoFront": "Front",
       "photoOdometer": "Odometer",
       "concernNoise": "Grinding noise from the front when braking",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "All five",
     "conditionMapViewsTop": "Top only",
     "conditionMapViewsSides": "Sides only",
-    "conditionMapWidthHint": "Empty spans the row. Set points to make the drawing smaller; it then sits where the alignment puts it, and the legend keeps the full width."
+    "conditionMapWidthHint": "Empty spans the row. Set points to make the drawing smaller; it then sits where the alignment puts it, and the legend keeps the full width.",
+    "conditionMapDense": "Dense table",
+    "conditionMapDenseHint": "Sets the rows of the table under the drawing closer together, so it takes less of the page."
   },
   "preview": {
     "title": "Brake Service & Oil Change",

+ 1 - 0
messages/es/conditionMap.json

@@ -40,6 +40,7 @@
   "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é.",
+  "readOnlyCarried": "Confirmada de nuevo en una visita posterior. Se conserva aquí como registro de esta visita y se modifica allí.",
   "readOnlyOtherCheck": "Marcada en otro punto de esta inspección. Cámbiela allí.",
   "count": "{count, plural, =0 {Sin marcas} one {# marca} other {# marcas}}",
   "previousCount": "{count, plural, one {# de una visita anterior} other {# de visitas anteriores}}",

+ 32 - 0
messages/es/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "Este vehículo no tiene órdenes de trabajo abiertas.",
     "convertSelectWorkOrder": "Seleccione una orden de trabajo",
     "convertAdd": "Añadir a la orden de trabajo",
+    "convertConflicts": {
+      "heading": "Antes de añadir, decida qué conserva la orden de trabajo",
+      "checking": "Comparando el presupuesto con la orden de trabajo...",
+      "unanswered": "Elija qué conserva la orden de trabajo antes de añadir el presupuesto.",
+      "discount": {
+        "title": "Descuento",
+        "summary": "La orden de trabajo tiene un descuento del {percent}%, {jobAmount} a día de hoy. El presupuesto incluye un descuento de {quoteAmount}.",
+        "keepJob": "Mantener el {percent}% de descuento y descartar los {quoteAmount} del presupuesto",
+        "combine": "Convertir el {percent}% en {jobAmount} y añadir los {quoteAmount} del presupuesto, {total} en total"
+      },
+      "tax": {
+        "title": "Impuestos",
+        "summary": "La orden de trabajo está valorada con {job}. El presupuesto se valoró con {quote}.",
+        "keepJob": "Mantener los impuestos de la orden de trabajo. Las líneas del presupuesto pasan por los importes presupuestados.",
+        "useQuote": "Cambiar la orden de trabajo a los impuestos del presupuesto. Se recalcula todo su contenido.",
+        "inclusive": "{rate}% de impuestos incluido en las líneas",
+        "exclusive": "{rate}% de impuestos añadido aparte",
+        "components": "{rate}% de impuestos ({names})"
+      },
+      "warranty": {
+        "title": "Garantía",
+        "summary": "La orden de trabajo indica {job}. El presupuesto que aceptó el cliente indica {quote}.",
+        "keepJob": "Mantener la garantía de la orden de trabajo",
+        "useQuote": "Usar la garantía del presupuesto, tal como la aceptó el cliente",
+        "none": "nada sobre la garantía",
+        "notIncluded": "sin garantía del taller",
+        "included": "garantía incluida",
+        "includedWithTerms": "garantía incluida, {terms}",
+        "months": "{count, plural, one {# mes} other {# meses}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Convertir",
     "convertToWorkOrder": "Convertir en orden de trabajo",
     "cancel": "Cancelar",

+ 6 - 1
messages/es/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Próxima prueba",
       "defect_notes": "Notas",
       "defect_photos": "Fotos",
+      "no_defects_note": "Nota cuando no hay deficiencias",
       "passed_checks": "Comprobaciones superadas",
       "not_applicable_checks": "Comprobaciones no aplicables",
       "check_notes": "Columna de notas",
       "result_detail": "Explicación",
       "result_summary": "Recuento por valoración",
       "combined_table": "Una sola tabla para todas las secciones",
+      "ungraded_checks": "Comprobaciones aún sin valorar",
       "inspector_line": "Línea de firma",
       "inspector_name": "Nombre del firmante",
       "date_line": "Línea de fecha",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Equipo de alumbrado",
       "checkHeadlamps": "Estado de los faros",
       "checkFogLamp": "Luz antiniebla trasera",
+      "checkStopLamps": "Luces de freno",
       "photoFront": "Frontal",
       "photoOdometer": "Cuentakilómetros",
       "concernNoise": "Ruido de roce delante al frenar",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "Las cinco",
     "conditionMapViewsTop": "Solo desde arriba",
     "conditionMapViewsSides": "Solo los laterales",
-    "conditionMapWidthHint": "Vacío ocupa toda la fila. Indique puntos para reducir el dibujo; se colocará según la alineación y la leyenda conserva todo el ancho."
+    "conditionMapWidthHint": "Vacío ocupa toda la fila. Indique puntos para reducir el dibujo; se colocará según la alineación y la leyenda conserva todo el ancho.",
+    "conditionMapDense": "Tabla compacta",
+    "conditionMapDenseHint": "Junta las filas de la tabla bajo el dibujo para que ocupe menos espacio en la página."
   },
   "preview": {
     "title": "Servicio de frenos y cambio de aceite",

+ 1 - 0
messages/fr/conditionMap.json

@@ -40,6 +40,7 @@
   "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.",
+  "readOnlyCarried": "Confirmée à nouveau lors d'une visite ultérieure. Elle reste ici comme trace de cette visite et se modifie là-bas.",
   "readOnlyOtherCheck": "Placée sur un autre point de cette inspection. Modifiez-la là-bas.",
   "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}}",

+ 32 - 0
messages/fr/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "Ce véhicule n'a aucun ordre de travail ouvert.",
     "convertSelectWorkOrder": "Sélectionnez un ordre de travail",
     "convertAdd": "Ajouter à l'ordre de travail",
+    "convertConflicts": {
+      "heading": "Avant d'ajouter, décidez ce que l'ordre de travail conserve",
+      "checking": "Comparaison du devis avec l'ordre de travail...",
+      "unanswered": "Choisissez ce que l'ordre de travail conserve avant d'ajouter le devis.",
+      "discount": {
+        "title": "Remise",
+        "summary": "L'ordre de travail a une remise de {percent} %, soit {jobAmount} à ce jour. Le devis comporte une remise de {quoteAmount}.",
+        "keepJob": "Conserver la remise de {percent} % et abandonner les {quoteAmount} du devis",
+        "combine": "Convertir les {percent} % en {jobAmount} et ajouter les {quoteAmount} du devis, soit {total} au total"
+      },
+      "tax": {
+        "title": "Taxe",
+        "summary": "L'ordre de travail est établi avec {job}. Le devis a été établi avec {quote}.",
+        "keepJob": "Conserver la taxe de l'ordre de travail. Les lignes du devis sont reprises aux montants devisés.",
+        "useQuote": "Passer l'ordre de travail à la taxe du devis. Tout son contenu est recalculé.",
+        "inclusive": "{rate} % de taxe inclus dans les lignes",
+        "exclusive": "{rate} % de taxe ajoutés en sus",
+        "components": "{rate} % de taxe ({names})"
+      },
+      "warranty": {
+        "title": "Garantie",
+        "summary": "L'ordre de travail indique {job}. Le devis accepté par le client indique {quote}.",
+        "keepJob": "Conserver la garantie de l'ordre de travail",
+        "useQuote": "Utiliser la garantie du devis, telle que le client l'a acceptée",
+        "none": "rien sur la garantie",
+        "notIncluded": "pas de garantie atelier",
+        "included": "garantie incluse",
+        "includedWithTerms": "garantie incluse, {terms}",
+        "months": "{count, plural, one {# mois} other {# mois}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Convertir",
     "convertToWorkOrder": "Convertir en ordre de travail",
     "cancel": "Annuler",

+ 6 - 1
messages/fr/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Prochain contrôle",
       "defect_notes": "Remarques",
       "defect_photos": "Photos",
+      "no_defects_note": "Mention en l'absence de défaillance",
       "passed_checks": "Contrôles réussis",
       "not_applicable_checks": "Contrôles sans objet",
       "check_notes": "Colonne remarques",
       "result_detail": "Explication",
       "result_summary": "Nombre par note",
       "combined_table": "Un seul tableau pour toutes les sections",
+      "ungraded_checks": "Contrôles pas encore évalués",
       "inspector_line": "Ligne de signature",
       "inspector_name": "Nom du signataire",
       "date_line": "Ligne de date",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Équipement d'éclairage",
       "checkHeadlamps": "État des projecteurs",
       "checkFogLamp": "Feu de brouillard arrière",
+      "checkStopLamps": "Feux stop",
       "photoFront": "Avant",
       "photoOdometer": "Compteur",
       "concernNoise": "Bruit de frottement à l'avant au freinage",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "Les cinq",
     "conditionMapViewsTop": "Dessus seulement",
     "conditionMapViewsSides": "Côtés seulement",
-    "conditionMapWidthHint": "Vide occupe toute la ligne. Indiquez des points pour réduire le dessin ; il se place selon l'alignement et la légende garde toute la largeur."
+    "conditionMapWidthHint": "Vide occupe toute la ligne. Indiquez des points pour réduire le dessin ; il se place selon l'alignement et la légende garde toute la largeur.",
+    "conditionMapDense": "Tableau compact",
+    "conditionMapDenseHint": "Resserre les lignes du tableau sous le dessin pour qu'il prenne moins de place sur la page."
   },
   "preview": {
     "title": "Révision des freins et vidange",

+ 1 - 0
messages/it/conditionMap.json

@@ -40,6 +40,7 @@
   "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ù.",
+  "readOnlyCarried": "Confermato di nuovo in una visita successiva. Resta qui come registrazione di questa visita e si modifica lì.",
   "readOnlyOtherCheck": "Segnata su un altro controllo di questa ispezione. Modificala lì.",
   "count": "{count, plural, =0 {Nessun segno} one {# segno} other {# segni}}",
   "previousCount": "{count, plural, one {# da una visita precedente} other {# da visite precedenti}}",

+ 32 - 0
messages/it/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "Questo veicolo non ha ordini di lavoro aperti.",
     "convertSelectWorkOrder": "Seleziona un ordine di lavoro",
     "convertAdd": "Aggiungi all'ordine di lavoro",
+    "convertConflicts": {
+      "heading": "Prima di aggiungere, decidi cosa mantiene l'ordine di lavoro",
+      "checking": "Confronto del preventivo con l'ordine di lavoro...",
+      "unanswered": "Scegli cosa mantiene l'ordine di lavoro prima di aggiungere il preventivo.",
+      "discount": {
+        "title": "Sconto",
+        "summary": "L'ordine di lavoro ha uno sconto del {percent}%, pari a {jobAmount} allo stato attuale. Il preventivo include uno sconto di {quoteAmount}.",
+        "keepJob": "Mantieni lo sconto del {percent}% e scarta i {quoteAmount} del preventivo",
+        "combine": "Trasforma il {percent}% in {jobAmount} e aggiungi i {quoteAmount} del preventivo, {total} in totale"
+      },
+      "tax": {
+        "title": "Imposte",
+        "summary": "L'ordine di lavoro è calcolato con {job}. Il preventivo è stato calcolato con {quote}.",
+        "keepJob": "Mantieni le imposte dell'ordine di lavoro. Le righe del preventivo passano agli importi preventivati.",
+        "useQuote": "Passa l'ordine di lavoro alle imposte del preventivo. Tutto il contenuto viene ricalcolato.",
+        "inclusive": "{rate}% di imposte incluso nelle righe",
+        "exclusive": "{rate}% di imposte aggiunto a parte",
+        "components": "{rate}% di imposte ({names})"
+      },
+      "warranty": {
+        "title": "Garanzia",
+        "summary": "L'ordine di lavoro indica {job}. Il preventivo accettato dal cliente indica {quote}.",
+        "keepJob": "Mantieni la garanzia dell'ordine di lavoro",
+        "useQuote": "Usa la garanzia del preventivo, come accettata dal cliente",
+        "none": "nulla sulla garanzia",
+        "notIncluded": "nessuna garanzia dell'officina",
+        "included": "garanzia inclusa",
+        "includedWithTerms": "garanzia inclusa, {terms}",
+        "months": "{count, plural, one {# mese} other {# mesi}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Converti",
     "convertToWorkOrder": "Converti in ordine di lavoro",
     "cancel": "Annulla",

+ 6 - 1
messages/it/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Prossimo controllo",
       "defect_notes": "Note",
       "defect_photos": "Foto",
+      "no_defects_note": "Nota quando non ci sono carenze",
       "passed_checks": "Controlli superati",
       "not_applicable_checks": "Controlli non applicabili",
       "check_notes": "Colonna note",
       "result_detail": "Spiegazione",
       "result_summary": "Conteggio per esito",
       "combined_table": "Una sola tabella per tutte le sezioni",
+      "ungraded_checks": "Controlli non ancora valutati",
       "inspector_line": "Riga della firma",
       "inspector_name": "Nome del firmatario",
       "date_line": "Riga della data",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Dispositivi di illuminazione",
       "checkHeadlamps": "Stato dei proiettori",
       "checkFogLamp": "Retronebbia",
+      "checkStopLamps": "Luci di arresto",
       "photoFront": "Frontale",
       "photoOdometer": "Contachilometri",
       "concernNoise": "Rumore di sfregamento davanti in frenata",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "Tutte e cinque",
     "conditionMapViewsTop": "Solo dall’alto",
     "conditionMapViewsSides": "Solo i lati",
-    "conditionMapWidthHint": "Vuoto occupa tutta la riga. Indica i punti per rimpicciolire il disegno; si posiziona secondo l’allineamento e la legenda mantiene tutta la larghezza."
+    "conditionMapWidthHint": "Vuoto occupa tutta la riga. Indica i punti per rimpicciolire il disegno; si posiziona secondo l’allineamento e la legenda mantiene tutta la larghezza.",
+    "conditionMapDense": "Tabella compatta",
+    "conditionMapDenseHint": "Avvicina le righe della tabella sotto il disegno, così occupa meno spazio sulla pagina."
   },
   "preview": {
     "title": "Servizio freni e cambio olio",

+ 1 - 0
messages/lt/conditionMap.json

@@ -40,6 +40,7 @@
   "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.",
+  "readOnlyCarried": "Dar kartą patvirtinta vėlesnio apsilankymo metu. Lieka čia kaip šio apsilankymo įrašas, o keičiama ten.",
   "readOnlyOtherCheck": "Pažymėta kitame šios apžiūros punkte. Keiskite ten.",
   "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ų}}",

+ 32 - 0
messages/lt/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "Ši transporto priemonė neturi atidarytų darbo užsakymų.",
     "convertSelectWorkOrder": "Pasirinkite darbo užsakymą",
     "convertAdd": "Pridėti prie darbo užsakymo",
+    "convertConflicts": {
+      "heading": "Prieš pridedant nuspręskite, ką darbo užsakymas pasilieka",
+      "checking": "Pasiūlymas lyginamas su darbo užsakymu...",
+      "unanswered": "Prieš pridėdami pasiūlymą pasirinkite, ką darbo užsakymas pasilieka.",
+      "discount": {
+        "title": "Nuolaida",
+        "summary": "Darbo užsakymas turi {percent}% nuolaidą, šiuo metu {jobAmount}. Pasiūlyme yra {quoteAmount} nuolaida.",
+        "keepJob": "Palikti {percent}% nuolaidą ir atmesti pasiūlymo {quoteAmount}",
+        "combine": "Paversti {percent}% į {jobAmount} ir pridėti pasiūlymo {quoteAmount}, iš viso {total}"
+      },
+      "tax": {
+        "title": "Mokesčiai",
+        "summary": "Darbo užsakymas įkainotas su {job}. Pasiūlymas buvo įkainotas su {quote}.",
+        "keepJob": "Palikti darbo užsakymo mokesčius. Pasiūlymo eilutės perkeliamos pasiūlytomis sumomis.",
+        "useQuote": "Perjungti darbo užsakymą į pasiūlymo mokesčius. Viskas jame perskaičiuojama.",
+        "inclusive": "{rate}% mokesčiai įskaičiuoti į eilutes",
+        "exclusive": "{rate}% mokesčiai pridedami papildomai",
+        "components": "{rate}% mokesčiai ({names})"
+      },
+      "warranty": {
+        "title": "Garantija",
+        "summary": "Darbo užsakyme nurodyta {job}. Kliento priimtame pasiūlyme nurodyta {quote}.",
+        "keepJob": "Palikti darbo užsakymo garantiją",
+        "useQuote": "Naudoti pasiūlymo garantiją, kokią klientas priėmė",
+        "none": "nieko apie garantiją",
+        "notIncluded": "be dirbtuvių garantijos",
+        "included": "garantija įtraukta",
+        "includedWithTerms": "garantija įtraukta, {terms}",
+        "months": "{count, plural, one {# mėnuo} few {# mėnesiai} other {# mėnesių}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Konvertuoti",
     "convertToWorkOrder": "Konvertuoti į darbo užsakymą",
     "cancel": "Atšaukti",

+ 6 - 1
messages/lt/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Kita patikra",
       "defect_notes": "Pastabos",
       "defect_photos": "Nuotraukos",
+      "no_defects_note": "Pastaba, kai trūkumų nėra",
       "passed_checks": "Išlaikyti patikrinimai",
       "not_applicable_checks": "Netaikomi patikrinimai",
       "check_notes": "Pastabų stulpelis",
       "result_detail": "Paaiškinimas",
       "result_summary": "Kiekis pagal įvertinimą",
       "combined_table": "Viena lentelė visiems skyriams",
+      "ungraded_checks": "Dar neįvertinti patikrinimai",
       "inspector_line": "Parašo eilutė",
       "inspector_name": "Pasirašiusiojo vardas",
       "date_line": "Datos eilutė",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Apšvietimo įranga",
       "checkHeadlamps": "Priekinių žibintų būklė",
       "checkFogLamp": "Galinis rūko žibintas",
+      "checkStopLamps": "Stabdžių žibintai",
       "photoFront": "Priekis",
       "photoOdometer": "Odometras",
       "concernNoise": "Šlifavimo garsas priekyje stabdant",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "Visi penki",
     "conditionMapViewsTop": "Tik iš viršaus",
     "conditionMapViewsSides": "Tik šonai",
-    "conditionMapWidthHint": "Tuščia užima visą eilutę. Nurodykite taškus, kad brėžinys būtų mažesnis; jis dedamas pagal lygiavimą, o legenda išlaiko visą plotį."
+    "conditionMapWidthHint": "Tuščia užima visą eilutę. Nurodykite taškus, kad brėžinys būtų mažesnis; jis dedamas pagal lygiavimą, o legenda išlaiko visą plotį.",
+    "conditionMapDense": "Glausta lentelė",
+    "conditionMapDenseHint": "Suglaudina lentelės po brėžiniu eilutes, kad ji užimtų mažiau vietos puslapyje."
   },
   "preview": {
     "title": "Stabdžių aptarnavimas ir tepalų keitimas",

+ 1 - 0
messages/nb/conditionMap.json

@@ -40,6 +40,7 @@
   "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.",
+  "readOnlyCarried": "Bekreftet på nytt ved et senere besøk. Den blir stående her som dette besøkets registrering og endres der.",
   "readOnlyOtherCheck": "Tegnet på et annet kontrollpunkt i denne inspeksjonen. Endre den der.",
   "count": "{count, plural, =0 {Ingen merker} one {# merke} other {# merker}}",
   "previousCount": "{count, plural, one {# fra et tidligere besøk} other {# fra tidligere besøk}}",

+ 32 - 0
messages/nb/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "Dette kjøretøyet har ingen åpne arbeidsordrer.",
     "convertSelectWorkOrder": "Velg en arbeidsordre",
     "convertAdd": "Legg til arbeidsordre",
+    "convertConflicts": {
+      "heading": "Før du legger til: bestem hva arbeidsordren beholder",
+      "checking": "Sammenligner tilbudet med arbeidsordren...",
+      "unanswered": "Velg hva arbeidsordren beholder før tilbudet legges til.",
+      "discount": {
+        "title": "Rabatt",
+        "summary": "Arbeidsordren har {percent} % rabatt, {jobAmount} slik den står nå. Tilbudet har en rabatt på {quoteAmount}.",
+        "keepJob": "Behold {percent} % rabatt og dropp tilbudets {quoteAmount}",
+        "combine": "Gjør {percent} % om til {jobAmount} og legg til tilbudets {quoteAmount}, {total} til sammen"
+      },
+      "tax": {
+        "title": "Avgift",
+        "summary": "Arbeidsordren er priset med {job}. Tilbudet ble priset med {quote}.",
+        "keepJob": "Behold arbeidsordrens avgift. Tilbudets linjer kommer over med beløpene som ble tilbudt.",
+        "useQuote": "Bytt arbeidsordren til tilbudets avgift. Alt på den summeres på nytt.",
+        "inclusive": "{rate} % avgift inkludert i linjene",
+        "exclusive": "{rate} % avgift lagt til",
+        "components": "{rate} % avgift ({names})"
+      },
+      "warranty": {
+        "title": "Garanti",
+        "summary": "Arbeidsordren sier {job}. Tilbudet kunden godtok sier {quote}.",
+        "keepJob": "Behold arbeidsordrens garanti",
+        "useQuote": "Bruk tilbudets garanti, slik kunden godtok den",
+        "none": "ingenting om garanti",
+        "notIncluded": "ingen verkstedgaranti",
+        "included": "garanti inkludert",
+        "includedWithTerms": "garanti inkludert, {terms}",
+        "months": "{count, plural, one {# måned} other {# måneder}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Konverter",
     "convertToWorkOrder": "Konverter til arbeidsordre",
     "cancel": "Avbryt",

+ 6 - 1
messages/nb/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Neste test",
       "defect_notes": "Merknader",
       "defect_photos": "Bilder",
+      "no_defects_note": "Merknad når det ikke er mangler",
       "passed_checks": "Beståtte kontroller",
       "not_applicable_checks": "Ikke aktuelle kontroller",
       "check_notes": "Merknadskolonne",
       "result_detail": "Forklaring",
       "result_summary": "Antall per vurdering",
       "combined_table": "Én tabell for alle seksjoner",
+      "ungraded_checks": "Kontroller som ikke er vurdert ennå",
       "inspector_line": "Signaturlinje",
       "inspector_name": "Underskriverens navn",
       "date_line": "Datolinje",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Lysutstyr",
       "checkHeadlamps": "Frontlyktenes tilstand",
       "checkFogLamp": "Tåkelys bak",
+      "checkStopLamps": "Bremselys",
       "photoFront": "Front",
       "photoOdometer": "Kilometerteller",
       "concernNoise": "Skrapelyd foran ved bremsing",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "Alle fem",
     "conditionMapViewsTop": "Bare ovenfra",
     "conditionMapViewsSides": "Bare sidene",
-    "conditionMapWidthHint": "Tomt fyller raden. Angi punkter for å gjøre tegningen mindre; den plasseres da etter justeringen, og listen beholder full bredde."
+    "conditionMapWidthHint": "Tomt fyller raden. Angi punkter for å gjøre tegningen mindre; den plasseres da etter justeringen, og listen beholder full bredde.",
+    "conditionMapDense": "Tett tabell",
+    "conditionMapDenseHint": "Setter radene i tabellen under tegningen tettere, så den tar mindre plass på siden."
   },
   "preview": {
     "title": "Bremseservice og oljeskift",

+ 1 - 0
messages/nl/conditionMap.json

@@ -40,6 +40,7 @@
   "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.",
+  "readOnlyCarried": "Opnieuw bevestigd bij een later bezoek. Blijft hier staan als vastlegging van dit bezoek en wordt daar gewijzigd.",
   "readOnlyOtherCheck": "Getekend bij een ander controlepunt van deze inspectie. Wijzig hem daar.",
   "count": "{count, plural, =0 {Geen markeringen} one {# markering} other {# markeringen}}",
   "previousCount": "{count, plural, one {# van een eerder bezoek} other {# van eerdere bezoeken}}",

+ 32 - 0
messages/nl/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "Dit voertuig heeft geen openstaande werkorders.",
     "convertSelectWorkOrder": "Selecteer een werkorder",
     "convertAdd": "Toevoegen aan werkorder",
+    "convertConflicts": {
+      "heading": "Bepaal voor het toevoegen wat de werkorder behoudt",
+      "checking": "Offerte wordt vergeleken met de werkorder...",
+      "unanswered": "Kies wat de werkorder behoudt voordat de offerte wordt toegevoegd.",
+      "discount": {
+        "title": "Korting",
+        "summary": "De werkorder heeft {percent}% korting, nu {jobAmount}. De offerte bevat een korting van {quoteAmount}.",
+        "keepJob": "{percent}% korting behouden en de {quoteAmount} van de offerte laten vervallen",
+        "combine": "{percent}% omzetten naar {jobAmount} en de {quoteAmount} van de offerte erbij, {total} in totaal"
+      },
+      "tax": {
+        "title": "Btw",
+        "summary": "De werkorder is geprijsd met {job}. De offerte was geprijsd met {quote}.",
+        "keepJob": "Btw van de werkorder behouden. De offerteregels komen over voor de geoffreerde bedragen.",
+        "useQuote": "Werkorder omzetten naar de btw van de offerte. Alles erop wordt opnieuw getotaliseerd.",
+        "inclusive": "{rate}% btw inbegrepen in de regels",
+        "exclusive": "{rate}% btw erbovenop",
+        "components": "{rate}% btw ({names})"
+      },
+      "warranty": {
+        "title": "Garantie",
+        "summary": "De werkorder vermeldt {job}. De offerte die de klant heeft geaccepteerd vermeldt {quote}.",
+        "keepJob": "Garantie van de werkorder behouden",
+        "useQuote": "Garantie van de offerte gebruiken, zoals de klant die heeft geaccepteerd",
+        "none": "niets over garantie",
+        "notIncluded": "geen werkplaatsgarantie",
+        "included": "garantie inbegrepen",
+        "includedWithTerms": "garantie inbegrepen, {terms}",
+        "months": "{count, plural, one {# maand} other {# maanden}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Omzetten",
     "convertToWorkOrder": "Omzetten naar werkorder",
     "cancel": "Annuleren",

+ 6 - 1
messages/nl/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Volgende keuring",
       "defect_notes": "Opmerkingen",
       "defect_photos": "Foto's",
+      "no_defects_note": "Melding wanneer er geen gebreken zijn",
       "passed_checks": "Geslaagde controles",
       "not_applicable_checks": "Niet-toepasselijke controles",
       "check_notes": "Kolom opmerkingen",
       "result_detail": "Toelichting",
       "result_summary": "Aantal per beoordeling",
       "combined_table": "Eén tabel voor alle secties",
+      "ungraded_checks": "Nog niet beoordeelde controles",
       "inspector_line": "Handtekeningregel",
       "inspector_name": "Naam van de ondertekenaar",
       "date_line": "Datumregel",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Verlichting",
       "checkHeadlamps": "Staat van de koplampen",
       "checkFogLamp": "Mistachterlicht",
+      "checkStopLamps": "Remlichten",
       "photoFront": "Voorkant",
       "photoOdometer": "Kilometerteller",
       "concernNoise": "Schurend geluid voor bij het remmen",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "Alle vijf",
     "conditionMapViewsTop": "Alleen bovenaanzicht",
     "conditionMapViewsSides": "Alleen de zijkanten",
-    "conditionMapWidthHint": "Leeg vult de rij. Geef punten op om de tekening kleiner te maken; hij staat dan waar de uitlijning hem zet en de legenda houdt de volle breedte."
+    "conditionMapWidthHint": "Leeg vult de rij. Geef punten op om de tekening kleiner te maken; hij staat dan waar de uitlijning hem zet en de legenda houdt de volle breedte.",
+    "conditionMapDense": "Compacte tabel",
+    "conditionMapDenseHint": "Zet de rijen van de tabel onder de tekening dichter op elkaar, zodat die minder ruimte op de pagina inneemt."
   },
   "preview": {
     "title": "Remservice en oliewissel",

+ 1 - 0
messages/pl/conditionMap.json

@@ -40,6 +40,7 @@
   "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.",
+  "readOnlyCarried": "Potwierdzone ponownie podczas późniejszej wizyty. Pozostaje tutaj jako zapis tej wizyty, a zmienia się tam.",
   "readOnlyOtherCheck": "Zaznaczone w innym punkcie tego przeglądu. Zmień je tam.",
   "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}}",

+ 32 - 0
messages/pl/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "Ten pojazd nie ma otwartych zleceń.",
     "convertSelectWorkOrder": "Wybierz zlecenie",
     "convertAdd": "Dodaj do zlecenia",
+    "convertConflicts": {
+      "heading": "Przed dodaniem zdecyduj, co zachowuje zlecenie",
+      "checking": "Porównywanie oferty ze zleceniem...",
+      "unanswered": "Wybierz, co zachowuje zlecenie, zanim oferta zostanie dodana.",
+      "discount": {
+        "title": "Rabat",
+        "summary": "Zlecenie ma rabat {percent}%, obecnie {jobAmount}. Oferta zawiera rabat {quoteAmount}.",
+        "keepJob": "Zachowaj rabat {percent}% i odrzuć {quoteAmount} z oferty",
+        "combine": "Zamień {percent}% na {jobAmount} i dodaj {quoteAmount} z oferty, łącznie {total}"
+      },
+      "tax": {
+        "title": "Podatek",
+        "summary": "Zlecenie jest wycenione z {job}. Oferta została wyceniona z {quote}.",
+        "keepJob": "Zachowaj podatek zlecenia. Pozycje z oferty przechodzą w zaoferowanych kwotach.",
+        "useQuote": "Przełącz zlecenie na podatek z oferty. Wszystko na nim zostanie przeliczone.",
+        "inclusive": "{rate}% podatku wliczone w pozycje",
+        "exclusive": "{rate}% podatku doliczane",
+        "components": "{rate}% podatku ({names})"
+      },
+      "warranty": {
+        "title": "Gwarancja",
+        "summary": "Zlecenie mówi {job}. Oferta przyjęta przez klienta mówi {quote}.",
+        "keepJob": "Zachowaj gwarancję zlecenia",
+        "useQuote": "Użyj gwarancji z oferty, tak jak przyjął ją klient",
+        "none": "nic o gwarancji",
+        "notIncluded": "brak gwarancji warsztatu",
+        "included": "gwarancja wliczona",
+        "includedWithTerms": "gwarancja wliczona, {terms}",
+        "months": "{count, plural, one {# miesiąc} few {# miesiące} other {# miesięcy}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Przekonwertuj",
     "convertToWorkOrder": "Przekonwertuj na zlecenie",
     "cancel": "Anuluj",

+ 6 - 1
messages/pl/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Następne badanie",
       "defect_notes": "Uwagi",
       "defect_photos": "Zdjęcia",
+      "no_defects_note": "Adnotacja, gdy nie ma usterek",
       "passed_checks": "Pozycje zaliczone",
       "not_applicable_checks": "Pozycje nie dotyczy",
       "check_notes": "Kolumna uwag",
       "result_detail": "Objaśnienie",
       "result_summary": "Liczba według oceny",
       "combined_table": "Jedna tabela dla wszystkich sekcji",
+      "ungraded_checks": "Pozycje jeszcze nieocenione",
       "inspector_line": "Linia podpisu",
       "inspector_name": "Imię i nazwisko podpisującego",
       "date_line": "Linia daty",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Oświetlenie",
       "checkHeadlamps": "Stan reflektorów",
       "checkFogLamp": "Tylne światło przeciwmgłowe",
+      "checkStopLamps": "Światła stopu",
       "photoFront": "Przód",
       "photoOdometer": "Licznik",
       "concernNoise": "Zgrzytanie z przodu przy hamowaniu",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "Wszystkie pięć",
     "conditionMapViewsTop": "Tylko z góry",
     "conditionMapViewsSides": "Tylko boki",
-    "conditionMapWidthHint": "Puste wypełnia wiersz. Podaj punkty, aby zmniejszyć rysunek; ustawi się według wyrównania, a legenda zachowa pełną szerokość."
+    "conditionMapWidthHint": "Puste wypełnia wiersz. Podaj punkty, aby zmniejszyć rysunek; ustawi się według wyrównania, a legenda zachowa pełną szerokość.",
+    "conditionMapDense": "Zwarta tabela",
+    "conditionMapDenseHint": "Zmniejsza odstępy między wierszami tabeli pod rysunkiem, dzięki czemu zajmuje ona mniej miejsca na stronie."
   },
   "preview": {
     "title": "Serwis hamulców i wymiana oleju",

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

@@ -40,6 +40,7 @@
   "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.",
+  "readOnlyCarried": "Confirmada novamente em uma visita posterior. Fica aqui como registro desta visita e é alterada lá.",
   "readOnlyOtherCheck": "Marcada em outro item desta inspeção. Altere lá.",
   "count": "{count, plural, =0 {Sem marcas} one {# marca} other {# marcas}}",
   "previousCount": "{count, plural, one {# de uma visita anterior} other {# de visitas anteriores}}",

+ 32 - 0
messages/pt-BR/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "Este veículo não tem ordens de serviço abertas.",
     "convertSelectWorkOrder": "Selecione uma ordem de serviço",
     "convertAdd": "Adicionar à ordem de serviço",
+    "convertConflicts": {
+      "heading": "Antes de adicionar, decida o que a ordem de serviço mantém",
+      "checking": "Comparando o orçamento com a ordem de serviço...",
+      "unanswered": "Escolha o que a ordem de serviço mantém antes de adicionar o orçamento.",
+      "discount": {
+        "title": "Desconto",
+        "summary": "A ordem de serviço tem {percent}% de desconto, {jobAmount} no momento. O orçamento traz um desconto de {quoteAmount}.",
+        "keepJob": "Manter o desconto de {percent}% e descartar os {quoteAmount} do orçamento",
+        "combine": "Transformar os {percent}% em {jobAmount} e somar os {quoteAmount} do orçamento, {total} no total"
+      },
+      "tax": {
+        "title": "Impostos",
+        "summary": "A ordem de serviço está precificada com {job}. O orçamento foi precificado com {quote}.",
+        "keepJob": "Manter os impostos da ordem de serviço. As linhas do orçamento passam pelos valores orçados.",
+        "useQuote": "Mudar a ordem de serviço para os impostos do orçamento. Tudo nela é recalculado.",
+        "inclusive": "{rate}% de impostos incluídos nas linhas",
+        "exclusive": "{rate}% de impostos acrescidos",
+        "components": "{rate}% de impostos ({names})"
+      },
+      "warranty": {
+        "title": "Garantia",
+        "summary": "A ordem de serviço diz {job}. O orçamento que o cliente aceitou diz {quote}.",
+        "keepJob": "Manter a garantia da ordem de serviço",
+        "useQuote": "Usar a garantia do orçamento, como o cliente aceitou",
+        "none": "nada sobre garantia",
+        "notIncluded": "sem garantia da oficina",
+        "included": "garantia incluída",
+        "includedWithTerms": "garantia incluída, {terms}",
+        "months": "{count, plural, one {# mês} other {# meses}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Converter",
     "convertToWorkOrder": "Converter em ordem de serviço",
     "cancel": "Cancelar",

+ 6 - 1
messages/pt-BR/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Próximo teste",
       "defect_notes": "Observações",
       "defect_photos": "Fotos",
+      "no_defects_note": "Aviso quando não há deficiências",
       "passed_checks": "Itens aprovados",
       "not_applicable_checks": "Itens não aplicáveis",
       "check_notes": "Coluna de observações",
       "result_detail": "Explicação",
       "result_summary": "Contagem por avaliação",
       "combined_table": "Uma tabela para todas as seções",
+      "ungraded_checks": "Itens ainda não avaliados",
       "inspector_line": "Linha de assinatura",
       "inspector_name": "Nome de quem assina",
       "date_line": "Linha da data",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Iluminação",
       "checkHeadlamps": "Estado dos faróis",
       "checkFogLamp": "Luz de neblina traseira",
+      "checkStopLamps": "Luzes de freio",
       "photoFront": "Frente",
       "photoOdometer": "Hodômetro",
       "concernNoise": "Barulho de raspagem na frente ao frear",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "Todas as cinco",
     "conditionMapViewsTop": "Só de cima",
     "conditionMapViewsSides": "Só as laterais",
-    "conditionMapWidthHint": "Vazio ocupa a linha toda. Informe pontos para reduzir o desenho; ele fica onde o alinhamento colocar e a legenda mantém a largura total."
+    "conditionMapWidthHint": "Vazio ocupa a linha toda. Informe pontos para reduzir o desenho; ele fica onde o alinhamento colocar e a legenda mantém a largura total.",
+    "conditionMapDense": "Tabela compacta",
+    "conditionMapDenseHint": "Aproxima as linhas da tabela abaixo do desenho, para que ela ocupe menos espaço na página."
   },
   "preview": {
     "title": "Serviço de freios e troca de óleo",

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

@@ -40,6 +40,7 @@
   "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.",
+  "readOnlyCarried": "Confirmada de novo numa visita posterior. Fica aqui como registo desta visita e é alterada lá.",
   "readOnlyOtherCheck": "Marcada noutro item desta inspeção. Altere lá.",
   "count": "{count, plural, =0 {Sem marcas} one {# marca} other {# marcas}}",
   "previousCount": "{count, plural, one {# de uma visita anterior} other {# de visitas anteriores}}",

+ 32 - 0
messages/pt-PT/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "Esta viatura não tem ordens de serviço abertas.",
     "convertSelectWorkOrder": "Selecione uma ordem de serviço",
     "convertAdd": "Adicionar à ordem de serviço",
+    "convertConflicts": {
+      "heading": "Antes de adicionar, decida o que a ordem de trabalho mantém",
+      "checking": "A comparar o orçamento com a ordem de trabalho...",
+      "unanswered": "Escolha o que a ordem de trabalho mantém antes de adicionar o orçamento.",
+      "discount": {
+        "title": "Desconto",
+        "summary": "A ordem de trabalho tem {percent}% de desconto, {jobAmount} neste momento. O orçamento traz um desconto de {quoteAmount}.",
+        "keepJob": "Manter o desconto de {percent}% e descartar os {quoteAmount} do orçamento",
+        "combine": "Transformar os {percent}% em {jobAmount} e somar os {quoteAmount} do orçamento, {total} no total"
+      },
+      "tax": {
+        "title": "Impostos",
+        "summary": "A ordem de trabalho está valorizada com {job}. O orçamento foi valorizado com {quote}.",
+        "keepJob": "Manter os impostos da ordem de trabalho. As linhas do orçamento passam pelos valores orçamentados.",
+        "useQuote": "Mudar a ordem de trabalho para os impostos do orçamento. Tudo nela é recalculado.",
+        "inclusive": "{rate}% de impostos incluídos nas linhas",
+        "exclusive": "{rate}% de impostos acrescidos",
+        "components": "{rate}% de impostos ({names})"
+      },
+      "warranty": {
+        "title": "Garantia",
+        "summary": "A ordem de trabalho diz {job}. O orçamento que o cliente aceitou diz {quote}.",
+        "keepJob": "Manter a garantia da ordem de trabalho",
+        "useQuote": "Usar a garantia do orçamento, tal como o cliente a aceitou",
+        "none": "nada sobre garantia",
+        "notIncluded": "sem garantia da oficina",
+        "included": "garantia incluída",
+        "includedWithTerms": "garantia incluída, {terms}",
+        "months": "{count, plural, one {# mês} other {# meses}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Converter",
     "convertToWorkOrder": "Converter em ordem de serviço",
     "cancel": "Cancelar",

+ 6 - 1
messages/pt-PT/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Próximo teste",
       "defect_notes": "Observações",
       "defect_photos": "Fotografias",
+      "no_defects_note": "Nota quando não há deficiências",
       "passed_checks": "Itens aprovados",
       "not_applicable_checks": "Itens não aplicáveis",
       "check_notes": "Coluna de observações",
       "result_detail": "Explicação",
       "result_summary": "Contagem por avaliação",
       "combined_table": "Uma tabela para todas as secções",
+      "ungraded_checks": "Itens ainda não avaliados",
       "inspector_line": "Linha de assinatura",
       "inspector_name": "Nome de quem assina",
       "date_line": "Linha da data",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Iluminação",
       "checkHeadlamps": "Estado dos faróis",
       "checkFogLamp": "Farolim de nevoeiro traseiro",
+      "checkStopLamps": "Luzes de travagem",
       "photoFront": "Frente",
       "photoOdometer": "Conta-quilómetros",
       "concernNoise": "Ruído de atrito à frente ao travar",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "Todas as cinco",
     "conditionMapViewsTop": "Só de cima",
     "conditionMapViewsSides": "Só as laterais",
-    "conditionMapWidthHint": "Vazio ocupa a linha toda. Indique pontos para reduzir o desenho; fica onde o alinhamento o colocar e a legenda mantém a largura total."
+    "conditionMapWidthHint": "Vazio ocupa a linha toda. Indique pontos para reduzir o desenho; fica onde o alinhamento o colocar e a legenda mantém a largura total.",
+    "conditionMapDense": "Tabela compacta",
+    "conditionMapDenseHint": "Aproxima as linhas da tabela por baixo do desenho, para que ocupe menos espaço na página."
   },
   "preview": {
     "title": "Revisão dos travões e mudança de óleo",

+ 1 - 0
messages/ru/conditionMap.json

@@ -40,6 +40,7 @@
   "readOnly": "Откройте заново, чтобы изменить карту состояния.",
   "readOnlyLinked": "Отмечено в связанном осмотре. Измените там.",
   "readOnlyEarlier": "Записано при прошлом визите. Отметьте как отремонтированное, когда повреждения не станет.",
+  "readOnlyCarried": "Подтверждено повторно при следующем визите. Остаётся здесь как запись этого визита, а меняется там.",
   "readOnlyOtherCheck": "Отмечено в другом пункте этого осмотра. Измените там.",
   "count": "{count, plural, =0 {Нет отметок} one {# отметка} few {# отметки} other {# отметок}}",
   "previousCount": "{count, plural, one {# с прошлого визита} few {# с прошлых визитов} other {# с прошлых визитов}}",

+ 32 - 0
messages/ru/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "У этого транспортного средства нет открытых заказ-нарядов.",
     "convertSelectWorkOrder": "Выберите заказ-наряд",
     "convertAdd": "Добавить в заказ-наряд",
+    "convertConflicts": {
+      "heading": "Перед добавлением решите, что сохраняет заказ-наряд",
+      "checking": "Сверяем предложение с заказ-нарядом...",
+      "unanswered": "Выберите, что сохраняет заказ-наряд, прежде чем добавить предложение.",
+      "discount": {
+        "title": "Скидка",
+        "summary": "У заказ-наряда скидка {percent}%, сейчас это {jobAmount}. В предложении скидка {quoteAmount}.",
+        "keepJob": "Оставить скидку {percent}% и отбросить {quoteAmount} из предложения",
+        "combine": "Превратить {percent}% в {jobAmount} и добавить {quoteAmount} из предложения, всего {total}"
+      },
+      "tax": {
+        "title": "Налог",
+        "summary": "Заказ-наряд рассчитан с {job}. Предложение было рассчитано с {quote}.",
+        "keepJob": "Оставить налог заказ-наряда. Строки предложения переносятся по предложенным суммам.",
+        "useQuote": "Перевести заказ-наряд на налог предложения. Всё в нём пересчитывается.",
+        "inclusive": "{rate}% налога включено в строки",
+        "exclusive": "{rate}% налога начисляется сверху",
+        "components": "{rate}% налога ({names})"
+      },
+      "warranty": {
+        "title": "Гарантия",
+        "summary": "В заказ-наряде указано {job}. В предложении, принятом клиентом, указано {quote}.",
+        "keepJob": "Оставить гарантию заказ-наряда",
+        "useQuote": "Использовать гарантию предложения, как её принял клиент",
+        "none": "ничего о гарантии",
+        "notIncluded": "без гарантии мастерской",
+        "included": "гарантия включена",
+        "includedWithTerms": "гарантия включена, {terms}",
+        "months": "{count, plural, one {# месяц} few {# месяца} other {# месяцев}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Конвертировать",
     "convertToWorkOrder": "Конвертировать в заказ-наряд",
     "cancel": "Отмена",

+ 6 - 1
messages/ru/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Следующая проверка",
       "defect_notes": "Примечания",
       "defect_photos": "Фото",
+      "no_defects_note": "Пометка, когда неисправностей нет",
       "passed_checks": "Пройденные пункты",
       "not_applicable_checks": "Неприменимые пункты",
       "check_notes": "Столбец примечаний",
       "result_detail": "Пояснение",
       "result_summary": "Количество по оценкам",
       "combined_table": "Одна таблица для всех разделов",
+      "ungraded_checks": "Ещё не оценённые пункты",
       "inspector_line": "Строка подписи",
       "inspector_name": "Имя подписавшего",
       "date_line": "Строка даты",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Световые приборы",
       "checkHeadlamps": "Состояние фар",
       "checkFogLamp": "Задний противотуманный фонарь",
+      "checkStopLamps": "Стоп-сигналы",
       "photoFront": "Спереди",
       "photoOdometer": "Одометр",
       "concernNoise": "Скрежет спереди при торможении",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "Все пять",
     "conditionMapViewsTop": "Только сверху",
     "conditionMapViewsSides": "Только бока",
-    "conditionMapWidthHint": "Пусто занимает всю строку. Укажите пункты, чтобы уменьшить рисунок; он встанет по выравниванию, а легенда сохранит полную ширину."
+    "conditionMapWidthHint": "Пусто занимает всю строку. Укажите пункты, чтобы уменьшить рисунок; он встанет по выравниванию, а легенда сохранит полную ширину.",
+    "conditionMapDense": "Плотная таблица",
+    "conditionMapDenseHint": "Сближает строки таблицы под рисунком, чтобы она занимала меньше места на странице."
   },
   "preview": {
     "title": "Обслуживание тормозов и замена масла",

+ 1 - 0
messages/tr/conditionMap.json

@@ -40,6 +40,7 @@
   "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.",
+  "readOnlyCarried": "Sonraki bir ziyarette yeniden onaylandı. Bu ziyaretin kaydı olarak burada kalır ve orada değiştirilir.",
   "readOnlyOtherCheck": "Bu muayenenin başka bir kontrolünde işaretlendi. Orada değiştirin.",
   "count": "{count, plural, =0 {İşaret yok} one {# işaret} other {# işaret}}",
   "previousCount": "{count, plural, one {# önceki ziyaretten} other {# önceki ziyaretlerden}}",

+ 32 - 0
messages/tr/quotes.json

@@ -43,6 +43,38 @@
     "convertNoOpenWorkOrders": "Bu aracın açık iş emri yok.",
     "convertSelectWorkOrder": "Bir iş emri seçin",
     "convertAdd": "İş emrine ekle",
+    "convertConflicts": {
+      "heading": "Eklemeden önce iş emrinin neyi koruyacağına karar verin",
+      "checking": "Teklif iş emriyle karşılaştırılıyor...",
+      "unanswered": "Teklifi eklemeden önce iş emrinin neyi koruyacağını seçin.",
+      "discount": {
+        "title": "İndirim",
+        "summary": "İş emrinde %{percent} indirim var, şu an {jobAmount}. Teklifte {quoteAmount} indirim var.",
+        "keepJob": "%{percent} indirimi koru ve teklifin {quoteAmount} indirimini bırak",
+        "combine": "%{percent} indirimi {jobAmount} tutara çevir ve teklifin {quoteAmount} indirimini ekle, toplam {total}"
+      },
+      "tax": {
+        "title": "Vergi",
+        "summary": "İş emri {job} ile fiyatlandırılmış. Teklif {quote} ile fiyatlandırılmıştı.",
+        "keepJob": "İş emrinin vergisini koru. Teklif satırları teklif edilen tutarlarla aktarılır.",
+        "useQuote": "İş emrini teklifin vergisine geçir. Üzerindeki her şey yeniden toplanır.",
+        "inclusive": "satırlara dahil %{rate} vergi",
+        "exclusive": "üzerine eklenen %{rate} vergi",
+        "components": "%{rate} vergi ({names})"
+      },
+      "warranty": {
+        "title": "Garanti",
+        "summary": "İş emrinde {job} yazıyor. Müşterinin kabul ettiği teklifte {quote} yazıyor.",
+        "keepJob": "İş emrinin garantisini koru",
+        "useQuote": "Müşterinin kabul ettiği haliyle teklifin garantisini kullan",
+        "none": "garantiyle ilgili bir şey yok",
+        "notIncluded": "atölye garantisi yok",
+        "included": "garanti dahil",
+        "includedWithTerms": "garanti dahil, {terms}",
+        "months": "{count, plural, one {# ay} other {# ay}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Dönüştür",
     "convertToWorkOrder": "İş Emrine Dönüştür",
     "cancel": "İptal",

+ 6 - 1
messages/tr/settings.json

@@ -757,12 +757,14 @@
       "next_test_due": "Sonraki test",
       "defect_notes": "Notlar",
       "defect_photos": "Fotoğraflar",
+      "no_defects_note": "Kusur olmadığında not",
       "passed_checks": "Geçen kontroller",
       "not_applicable_checks": "Uygulanamayan kontroller",
       "check_notes": "Notlar sütunu",
       "result_detail": "Açıklama",
       "result_summary": "Nota göre sayılar",
       "combined_table": "Tüm bölümler için tek tablo",
+      "ungraded_checks": "Henüz değerlendirilmemiş kontroller",
       "inspector_line": "İmza satırı",
       "inspector_name": "İmzalayanın adı",
       "date_line": "Tarih satırı",
@@ -2146,6 +2148,7 @@
       "sectionLighting": "Aydınlatma donanımı",
       "checkHeadlamps": "Far durumu",
       "checkFogLamp": "Arka sis lambası",
+      "checkStopLamps": "Fren lambaları",
       "photoFront": "Ön",
       "photoOdometer": "Kilometre sayacı",
       "concernNoise": "Frenlerken önden sürtünme sesi",
@@ -2218,7 +2221,9 @@
     "conditionMapViewsAll": "Beşi de",
     "conditionMapViewsTop": "Yalnızca üstten",
     "conditionMapViewsSides": "Yalnızca yanlar",
-    "conditionMapWidthHint": "Boş bırakılırsa satırı doldurur. Çizimi küçültmek için punto girin; hizalamaya göre yerleşir ve açıklama tam genişlikte kalır."
+    "conditionMapWidthHint": "Boş bırakılırsa satırı doldurur. Çizimi küçültmek için punto girin; hizalamaya göre yerleşir ve açıklama tam genişlikte kalır.",
+    "conditionMapDense": "Sıkışık tablo",
+    "conditionMapDenseHint": "Çizimin altındaki tablonun satırlarını birbirine yaklaştırır, böylece sayfada daha az yer kaplar."
   },
   "preview": {
     "title": "Fren bakımı ve yağ değişimi",

+ 2 - 0
prisma/migrations/20261007180500_condition_mark_carried_to/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "condition_marks" ADD COLUMN     "carriedToId" TEXT;

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

@@ -48,6 +48,11 @@ model ConditionMark {
   resolvedAt   DateTime?
   resolvedById String?
   resolvedBy   User?     @relation("ConditionMarkResolvedBy", fields: [resolvedById], references: [id], onDelete: SetNull)
+  /// Set with resolvedAt when a later visit confirmed the damage still there
+  /// and recorded it again: the id of that newer mark. Closed because it was
+  /// superseded, not repaired, so the sheet it was drawn on still prints it
+  /// as its own record while the vehicle shows the dent once.
+  carriedToId  String?
 
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt

+ 19 - 1
src/__tests__/features/condition-map/carry-marks.test.ts

@@ -53,6 +53,12 @@ vi.mock('@/lib/db', () => {
       store.marks.push(row)
       return { ...row }
     },
+    update: async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
+      const row = store.marks.find((r) => r.id === where.id)
+      if (!row) throw new Error('not found')
+      Object.assign(row, data)
+      return { ...row }
+    },
   }
   const db = {
     conditionMark,
@@ -281,11 +287,23 @@ describe('confirming every earlier mark on a work order', () => {
       (m) => m.vehicleId === 'car' && m.organizationId === 'org' && !m.resolvedAt
     )
     expect(open).toHaveLength(2)
-    // The earlier rows are kept as history, closed by whoever confirmed them.
+    // The earlier rows are kept as history, closed by whoever confirmed them
+    // and pointing at the mark that took their place.
     expect(store.marks.find((m) => m.id === 'dent')).toMatchObject({
       serviceRecordId: 'job_old',
       resolvedById: 'user',
+      carriedToId: 'new_1',
     })
+    expect(store.marks.find((m) => m.id === 'scratch')).toMatchObject({ carriedToId: 'new_2' })
+  })
+
+  it('leaves the earlier sheet its own record of the dent', async () => {
+    await carryConditionMarks({ vehicleId: 'car', serviceRecordId: 'job_now' })
+    // Closed on the car, but not repaired: the old job still prints what it
+    // found at drop-off, and does not meet the newer copy as "earlier".
+    const { own, previous } = await split(oldJob)
+    expect(ids(own)).toEqual(['dent'])
+    expect(ids(previous)).toEqual([])
   })
 
   it('keeps the photo on both marks, so the file manager still counts two rows', async () => {

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

@@ -53,6 +53,26 @@ describe('condition marks', () => {
     expect(onJob.previous.map((m) => m.id)).toEqual(['a', 'b'])
   })
 
+  it('keeps a mark a later visit carried forward on the sheet that drew it, and nowhere else', () => {
+    // Confirmed still there on job s2 and recorded again as 'b'; 'a' was
+    // closed for it. It is still s1's record of the car, and s2 meets 'b'.
+    const carried = mark({
+      id: 'a',
+      serviceRecordId: 's1',
+      resolvedAt: '2026-09-02T00:00:00Z',
+      carriedToId: 'b',
+    })
+    const successor = mark({ id: 'b', serviceRecordId: 's2' })
+    const marks = [carried, successor]
+    expect(splitMarks(marks, { serviceRecordId: 's1' }).own.map((m) => m.id)).toEqual(['a'])
+    expect(splitMarks(marks, { serviceRecordId: 's1' }).previous.map((m) => m.id)).toEqual(['b'])
+    expect(splitMarks(marks, { serviceRecordId: 's2' }).own.map((m) => m.id)).toEqual(['b'])
+    expect(splitMarks(marks, { serviceRecordId: 's2' }).previous).toEqual([])
+    // Cleared as repaired, with no successor, it is gone from its own sheet too.
+    const repaired = mark({ id: 'a', serviceRecordId: 's1', resolvedAt: '2026-09-02T00:00:00Z' })
+    expect(splitMarks([repaired], { serviceRecordId: 's1' }).own).toEqual([])
+  })
+
   it('counts another map check on the same inspection as this visit, drawn on that check', () => {
     const checkin = mark({ id: 'checkin', inspectionId: 'i1', inspectionItemId: 'c1' })
     const handback = mark({ id: 'handback', inspectionId: 'i1', inspectionItemId: 'c2' })

+ 21 - 0
src/__tests__/features/condition-map/print.test.ts

@@ -249,6 +249,27 @@ describe('the documents', () => {
     expect(ids(certificate({ conditionMarks: [] }))).not.toContain('condition_map')
   })
 
+  it('sets the legend rows closer together when the design asks for a dense table', () => {
+    const tableOf = (spec: any) =>
+      spec.blocks
+        .find((b: any) => b.id === 'condition_map')
+        .content.children.find((c: any) => c.kind === 'table')
+    const layout = getDefaultLayout('certificate')
+    const dense = {
+      ...layout,
+      sections: layout.sections.map((s) =>
+        s.id === 'condition_map' ? { ...s, style: { ...s.style, dense: true } } : s
+      ),
+    }
+    const loose = tableOf(certificate())
+    const tight = tableOf(certificate({ layoutConfig: dense }))
+    expect(tight.rowPadding).toBe(1)
+    expect(tight.headerPadding).toBe(3)
+    expect(loose.headerPadding).toBeUndefined()
+    expect(tight.rowPadding).toBeLessThan(loose.rowPadding)
+    expect(tight.rows).toEqual(loose.rows)
+  })
+
   it('counts every condition map check on the inspection as its own', () => {
     // A mark on a second map check (a hand-back after the check-in) is this
     // inspection's too: printed in colour, not grey and "recorded earlier".

+ 4 - 1
src/__tests__/features/condition-map/visit-documents.test.ts

@@ -136,8 +136,11 @@ describe('the Vehicle Condition section on an invoice and a quote', () => {
     const merged = mergeWithDefaults(savedBeforeTheSection())
     const section = merged.sections.find((s) => s.id === 'condition_map')
     expect(section?.visible).toBe(false)
+    // Where a new design has it: last of the appendix, in front of the
+    // signing line.
     const ids = merged.sections.map((s) => s.id)
-    expect(ids.indexOf('condition_map')).toBe(ids.indexOf('bank_account') + 1)
+    expect(ids).toEqual(getDefaultLayout('invoice').sections.map((s) => s.id))
+    expect(ids.indexOf('condition_map')).toBe(ids.indexOf('signature') - 1)
   })
 
   it('goes in front of the signing line wherever a design put it', () => {

+ 156 - 2
src/__tests__/features/inspections/certificate-print.test.ts

@@ -5,7 +5,11 @@
  */
 import { describe, expect, it } from 'vitest'
 import { buildCertificatePrintSpec } from '@/features/inspections/Pdf/buildCertificatePrint'
-import { getDefaultLayout } from '@/features/settings/Schema/invoiceLayoutSchema'
+import {
+  getDefaultLayout,
+  mergeWithDefaults,
+  unmentionedGrandfathered,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
 
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
@@ -126,9 +130,13 @@ describe('the certificate a completed inspection prints', () => {
     expect(t).not.toContain('Horn')
 
     const layout = getDefaultLayout('certificate')
+    // Every kind of graded row; the ungraded ones have a switch of their own.
     layout.sections = layout.sections.map((section) =>
       section.id === 'results_table'
-        ? { ...section, fields: section.fields?.map((f) => ({ ...f, visible: true })) }
+        ? {
+            ...section,
+            fields: section.fields?.map((f) => ({ ...f, visible: f.id !== 'ungraded_checks' })),
+          }
         : section
     )
     const everything = buildCertificatePrintSpec({
@@ -158,3 +166,149 @@ describe('the certificate a completed inspection prints', () => {
     expect(details.some((s) => s.includes('84,120'))).toBe(true)
   })
 })
+
+/**
+ * The two choices the result sections offer: the line a clean car prints
+ * under Defects, and the checks nobody has graded yet in All Results.
+ */
+describe('the certificate result switches', () => {
+  /** The certificate layout with these fields of a section set. */
+  function layoutWith(sectionId: string, switches: Record<string, boolean>) {
+    const layout = getDefaultLayout('certificate')
+    layout.sections = layout.sections.map((section) =>
+      section.id === sectionId
+        ? {
+            ...section,
+            fields: section.fields?.map((f) => ({ ...f, visible: switches[f.id] ?? f.visible })),
+          }
+        : section
+    )
+    return layout
+  }
+  const print = (layoutConfig: any, items = data().items) =>
+    buildCertificatePrintSpec({ data: { ...data(), items } as any, layoutConfig }) as any
+  const block = (spec: any, id: string) => spec.blocks.find((b: any) => b.id === id)?.content
+  const rows = (spec: any) =>
+    ((block(spec, 'results_table')?.children ?? []) as any[])
+      .filter((c) => c.kind === 'table')
+      .flatMap((t) => t.rows.map((r: any) => [r.name, r.grade]))
+
+  const clean = () =>
+    data().items.map((i) => ({
+      ...i,
+      condition: i.condition === 'not_inspected' ? 'not_inspected' : 'pass',
+      notes: null,
+    }))
+  const untouched = () => data().items.map((i) => ({ ...i, condition: 'not_inspected' }))
+
+  it('has the note on and the ungraded checks off in a new design', () => {
+    const layout = getDefaultLayout('certificate')
+    const on = (section: string, field: string) =>
+      layout.sections.find((s) => s.id === section)?.fields?.find((f) => f.id === field)?.visible
+    expect(on('defects', 'no_defects_note')).toBe(true)
+    expect(on('results_table', 'ungraded_checks')).toBe(false)
+  })
+
+  it('says a clean car has no deficiencies, or prints no section when the note is off', () => {
+    const noted = print(getDefaultLayout('certificate'), clean())
+    expect(texts(block(noted, 'defects'))).toEqual([
+      'Deficiencies found',
+      'No deficiencies were recorded.',
+    ])
+    const silent = print(layoutWith('defects', { no_defects_note: false }), clean())
+    expect(block(silent, 'defects')).toBeUndefined()
+    // A defect prints whatever the switch says.
+    const found = print(layoutWith('defects', { no_defects_note: false }))
+    expect(texts(block(found, 'defects')).some((s) => s.includes('Brake hose'))).toBe(true)
+  })
+
+  it('never says "no deficiencies" about a car nobody has looked at', () => {
+    for (const note of [true, false]) {
+      const spec = print(layoutWith('defects', { no_defects_note: note }), untouched())
+      expect(block(spec, 'defects')).toBeUndefined()
+      expect(JSON.stringify(spec)).not.toContain('No deficiencies were recorded.')
+    }
+  })
+
+  it('lists the ungraded checks in their places, grade left empty, when asked', () => {
+    expect(rows(print(getDefaultLayout('certificate'))).map(([name]) => name)).not.toContain('Horn')
+    const asked = print(
+      layoutWith('results_table', {
+        ungraded_checks: true,
+        passed_checks: true,
+        not_applicable_checks: true,
+      })
+    )
+    expect(rows(asked)).toEqual([
+      ['Wipers', '1 — Minor defect'],
+      ['Brake hose', '3 — Dangerous defect'],
+      ['Horn', ''],
+      ['Tow bar', 'Not applicable'],
+      ['Lights', '0 — No defect'],
+    ])
+    // On its own it adds only the ungraded rows to the defects.
+    expect(rows(print(layoutWith('results_table', { ungraded_checks: true })))).toEqual([
+      ['Wipers', '1 — Minor defect'],
+      ['Brake hose', '3 — Dangerous defect'],
+      ['Horn', ''],
+    ])
+  })
+
+  it('prints an inspection nobody has started as the whole checklist when asked', () => {
+    expect(block(print(getDefaultLayout('certificate'), untouched()), 'results_table')).toBe(
+      undefined
+    )
+    const sheet = print(layoutWith('results_table', { ungraded_checks: true }), untouched())
+    expect(rows(sheet)).toEqual([
+      ['Wipers', ''],
+      ['Brake hose', ''],
+      ['Horn', ''],
+      ['Tow bar', ''],
+      ['Lights', ''],
+    ])
+    expect(texts(block(sheet, 'results_table')).some((s) => s.includes('4. Lighting'))).toBe(true)
+  })
+
+  it('prints a design saved before the switches existed exactly as it did', () => {
+    // As the designer stored it: the two sections with the fields they had.
+    const current = getDefaultLayout('certificate')
+    const saved = {
+      ...current,
+      version: 3,
+      sections: current.sections.map((section) =>
+        section.id === 'defects' || section.id === 'results_table'
+          ? {
+              ...section,
+              fields: section.fields?.filter(
+                (f) => f.id !== 'no_defects_note' && f.id !== 'ungraded_checks'
+              ),
+            }
+          : section
+      ),
+    }
+    // The note on, the ungraded checks off: what every certificate printed.
+    expect(texts(block(print(saved, clean()), 'defects'))).toContain(
+      'No deficiencies were recorded.'
+    )
+    expect(rows(print(saved)).map(([name]) => name)).not.toContain('Horn')
+    for (const items of [data().items, clean()]) {
+      expect(print(saved, items)).toEqual(print(current, items))
+    }
+    // Read back, the switches show what the sheet is already doing.
+    const merged = mergeWithDefaults(saved)
+    const on = (section: string, field: string) =>
+      merged.sections.find((s) => s.id === section)?.fields?.find((f) => f.id === field)?.visible
+    expect(on('defects', 'no_defects_note')).toBe(true)
+    expect(on('results_table', 'ungraded_checks')).toBe(false)
+    // And the designer, which resolves a stored list without merging it,
+    // reads the note as on and the ungraded checks as off too.
+    const stored = saved.sections.find((s) => s.id === 'defects')?.fields
+    expect(unmentionedGrandfathered('defects', stored)).toEqual(['no_defects_note'])
+    expect(
+      unmentionedGrandfathered(
+        'results_table',
+        saved.sections.find((s) => s.id === 'results_table')?.fields
+      )
+    ).toEqual([])
+  })
+})

+ 296 - 0
src/__tests__/features/invoice-designer/canvas-first-paint.test.tsx

@@ -0,0 +1,296 @@
+/**
+ * The designer's sheet before anything on it has been measured.
+ *
+ * Where a block goes depends on how tall the blocks above it are, and only a
+ * browser can say. The server cannot, so the page it sends used to carry every
+ * row at the top margin, one over the other, and a reload showed that pile
+ * until hydration measured the blocks and moved them. Now the server sends the
+ * paper with the blocks held back, and they are let through in the same pass
+ * that measures them.
+ *
+ * `render` only returns after that pass, so the state before it is read here
+ * the way a browser gets it: from the server's markup, and then hydrated.
+ */
+import { act, cleanup, render } from '@testing-library/react'
+import { Profiler } from 'react'
+import { hydrateRoot, type Root } from 'react-dom/client'
+import { renderToString } from 'react-dom/server'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { SpecCanvas } from '@/features/invoice-designer/Render/SpecCanvas'
+import { BLOCK_GAP } from '@/features/invoice-designer/Render/layoutEngine'
+import type { Block, DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
+
+const MARGIN = 40
+const PAGE = { width: 595, height: 842 }
+
+function specOf(blocks: Block[]): DocumentSpec {
+  return {
+    page: {
+      ...PAGE,
+      margin: { top: MARGIN, right: MARGIN, bottom: MARGIN, left: MARGIN },
+      background: '#ffffff',
+      text: '#111827',
+      muted: '#6b7280',
+      accent: '#d97706',
+      fontFamily: 'Helvetica',
+      fontSize: 9,
+    },
+    blocks,
+  }
+}
+
+/** A block whose content carries the block's id, as the generators build them. */
+const block = (id: string, placement: Block['placement']): Block => ({
+  id,
+  label: id,
+  placement,
+  content: { kind: 'text', id, text: `${id} text` },
+})
+
+const flow = (id: string, order: number) => block(id, { mode: 'flow', order })
+
+/** Three rows in the flow, one block placed by hand and a pinned footer. */
+const DOCUMENT = specOf([
+  flow('a', 0),
+  flow('b', 1),
+  flow('c', 2),
+  block('stamp', { mode: 'anchored', anchor: { x: 380, y: 600, width: 160, page: 1 } }),
+  block('footer', { mode: 'pinned', edge: 'bottom' }),
+])
+const BLOCK_IDS = DOCUMENT.blocks.map((b) => b.id)
+
+const NONE: ReadonlySet<string> = new Set()
+/** The canvas reports what the pointer did; nothing here is listening. */
+const noop = () => undefined
+
+const canvas = (spec: DocumentSpec, zoom = 1) => (
+  <SpecCanvas
+    spec={spec}
+    selected={null}
+    onSelect={noop}
+    onAnchor={noop}
+    onInsert={noop}
+    onPair={noop}
+    pairable={NONE}
+    zoom={zoom}
+    rulers={false}
+  />
+)
+
+/** The block as drawn on the paper, not its copy in the off-screen measurer. */
+const drawn = (root: ParentNode, id: string) =>
+  root.querySelector<HTMLElement>(`[data-sheet] [data-node-id="${id}"]`)
+
+/** The copies of a block that sit outside every sheet: the ones measured. */
+const measuringCopies = (root: ParentNode, id: string) =>
+  Array.from(root.querySelectorAll<HTMLElement>(`[data-node-id="${id}"]`)).filter(
+    (el) => !el.closest('[data-sheet]')
+  )
+
+const loadingLines = (root: ParentNode) => root.querySelector<HTMLElement>('[data-sheet-loading]')
+
+const pageLabel = (root: HTMLElement) =>
+  Array.from(root.querySelectorAll<HTMLElement>('div')).find(
+    (el) => el.children.length === 0 && /^Page \d+ of \d+$/.test(el.textContent ?? '')
+  )
+
+/** jsdom lays nothing out, so every height reads 0 unless a test says otherwise. */
+const realOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')
+function measureEveryBlockAs(height: number) {
+  Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
+    configurable: true,
+    get: () => height,
+  })
+}
+
+let host: HTMLElement | undefined
+let root: Root | undefined
+
+/** What the server would send, put on the page the way a browser receives it. */
+function serverPage(spec: DocumentSpec) {
+  host = document.createElement('div')
+  host.innerHTML = renderToString(canvas(spec))
+  document.body.appendChild(host)
+  return host
+}
+
+afterEach(async () => {
+  await act(async () => root?.unmount())
+  root = undefined
+  host?.remove()
+  host = undefined
+  cleanup()
+  if (realOffsetHeight) {
+    Object.defineProperty(HTMLElement.prototype, 'offsetHeight', realOffsetHeight)
+  }
+  vi.restoreAllMocks()
+})
+
+describe('the sheet the server sends', () => {
+  it('is the paper at its real size, with nothing placed on it yet', () => {
+    const page = serverPage(DOCUMENT)
+
+    const sheets = page.querySelectorAll<HTMLElement>('[data-sheet]')
+    expect(sheets).toHaveLength(1)
+    expect(sheets[0]).toBeVisible()
+    expect(sheets[0].style.width).toBe(`${PAGE.width}px`)
+    expect(sheets[0].style.height).toBe(`${PAGE.height}px`)
+
+    // Every block is in the markup, and none of them is shown: with no heights
+    // the rows are a block gap apart, which is a pile and not a document.
+    for (const id of BLOCK_IDS) {
+      const shell = drawn(page, id)
+      expect(shell, id).not.toBeNull()
+      expect(shell, id).not.toBeVisible()
+    }
+    // One sheet is all that can be known before measuring, so the count that
+    // would read "of 1" is held back with the blocks.
+    expect(pageLabel(page)).not.toBeVisible()
+  })
+
+  it('shows that the document is on its way, and says so to a screen reader', () => {
+    const page = serverPage(DOCUMENT)
+
+    const lines = loadingLines(page)
+    expect(lines).toBeVisible()
+    expect(lines?.closest('[data-sheet]')).toBe(page.querySelector('[data-sheet="1"]'))
+    // Inside the page margins, where the document will start.
+    expect(lines?.style.top).toBe(`${MARGIN}px`)
+    expect(lines?.style.left).toBe(`${MARGIN}px`)
+    // Decorative, so the announcement is the canvas being busy.
+    expect(lines?.getAttribute('aria-hidden')).toBe('true')
+    expect(lines?.textContent).toBe('')
+    expect(page.querySelector('[aria-busy="true"]')).toContainElement(lines)
+  })
+
+  it('still carries every block off-screen to be measured', () => {
+    // The measuring copy is what the first layout is computed from, so holding
+    // the blocks back must not take it out of the markup. It stays hidden the
+    // way it was: anything looking for the visible copy of a block skips it.
+    const page = serverPage(DOCUMENT)
+    for (const id of BLOCK_IDS) {
+      const copies = measuringCopies(page, id)
+      expect(copies, id).toHaveLength(1)
+      expect(copies[0], id).not.toBeVisible()
+    }
+  })
+})
+
+describe('hydrating that sheet', () => {
+  it('lets the blocks through where the measurement puts them, on the same elements', async () => {
+    const page = serverPage(DOCUMENT)
+    const before = {
+      sheet: page.querySelector('[data-sheet="1"]'),
+      a: drawn(page, 'a'),
+      footer: drawn(page, 'footer'),
+    }
+    const errors = vi.spyOn(console, 'error').mockImplementation(noop)
+    const recovered = vi.fn()
+    measureEveryBlockAs(60)
+
+    await act(async () => {
+      root = hydrateRoot(page, canvas(DOCUMENT), { onRecoverableError: recovered })
+    })
+
+    // The first client render matched the server, so nothing was thrown away.
+    expect(recovered).not.toHaveBeenCalled()
+    expect(errors).not.toHaveBeenCalled()
+    expect(page.querySelector('[data-sheet="1"]')).toBe(before.sheet)
+    expect(drawn(page, 'a')).toBe(before.a)
+    expect(drawn(page, 'footer')).toBe(before.footer)
+
+    for (const id of BLOCK_IDS) expect(drawn(page, id), id).toBeVisible()
+    const tops = ['a', 'b', 'c'].map((id) => drawn(page, id)?.parentElement?.style.top)
+    expect(tops).toEqual([
+      `${MARGIN}px`,
+      `${MARGIN + 60 + BLOCK_GAP}px`,
+      `${MARGIN + (60 + BLOCK_GAP) * 2}px`,
+    ])
+
+    expect(loadingLines(page)).toBeNull()
+    expect(page.querySelector('[aria-busy]')).toBeNull()
+    expect(pageLabel(page)).toBeVisible()
+  })
+
+  it('never shows a block anywhere but its measured place on the way there', async () => {
+    // Letting the blocks through one render before the heights arrive would
+    // put the pile back on screen for a frame. So the page is read after
+    // every commit: the second row is either held back or where it belongs.
+    const page = serverPage(DOCUMENT)
+    measureEveryBlockAs(60)
+    const commits: { shown: boolean; top?: string }[] = []
+    const record = () => {
+      const b = drawn(page, 'b')
+      commits.push({ shown: b?.style.visibility !== 'hidden', top: b?.parentElement?.style.top })
+    }
+
+    await act(async () => {
+      root = hydrateRoot(
+        page,
+        <Profiler id="canvas" onRender={record}>
+          {canvas(DOCUMENT)}
+        </Profiler>
+      )
+    })
+
+    const placed = `${MARGIN + 60 + BLOCK_GAP}px`
+    expect(commits[0].shown).toBe(false)
+    expect(commits.at(-1)).toEqual({ shown: true, top: placed })
+    expect(commits.filter((commit) => commit.shown && commit.top !== placed)).toEqual([])
+  })
+})
+
+describe('the sheet mounted in the browser', () => {
+  it('shows the blocks straight away, with no loading state left behind', () => {
+    measureEveryBlockAs(60)
+    const { container } = render(canvas(DOCUMENT))
+
+    for (const id of BLOCK_IDS) expect(drawn(container, id), id).toBeVisible()
+    expect(drawn(container, 'b')?.parentElement?.style.top).toBe(`${MARGIN + 60 + BLOCK_GAP}px`)
+    expect(loadingLines(container)).toBeNull()
+    expect(container.querySelector('[aria-busy]')).toBeNull()
+    expect(pageLabel(container)).toBeVisible()
+  })
+
+  it('does not wait for a height it may never get', () => {
+    // jsdom measures every block as 0, and a real block can too: an image
+    // that has not arrived, a section with nothing in it. Having measured is
+    // what opens the sheet, not what the measurement came to.
+    const { container } = render(canvas(DOCUMENT))
+
+    for (const id of BLOCK_IDS) expect(drawn(container, id), id).toBeVisible()
+    expect(loadingLines(container)).toBeNull()
+    expect(container.querySelector('[aria-busy]')).toBeNull()
+  })
+
+  it('opens a document with every section switched off', () => {
+    const { container } = render(canvas(specOf([])))
+
+    expect(container.querySelector('[data-sheet="1"]')).toBeVisible()
+    expect(loadingLines(container)).toBeNull()
+    expect(container.querySelector('[aria-busy]')).toBeNull()
+  })
+
+  it('never goes back to loading while the document is edited', () => {
+    const { container, rerender } = render(canvas(DOCUMENT))
+    const a = drawn(container, 'a')
+
+    const edits: [DocumentSpec, number][] = [
+      // A section switched on, one switched off, the sheet zoomed.
+      [specOf([...DOCUMENT.blocks, flow('d', 3)]), 1],
+      [specOf(DOCUMENT.blocks.filter((b) => b.id !== 'b')), 1],
+      [DOCUMENT, 1.5],
+    ]
+    for (const [spec, zoom] of edits) {
+      rerender(canvas(spec, zoom))
+      expect(loadingLines(container)).toBeNull()
+      expect(container.querySelector('[aria-busy]')).toBeNull()
+      // The same element, still shown: an edit moves a block, it does not
+      // take it off the page and put it back.
+      expect(drawn(container, 'a')).toBe(a)
+      expect(a).toBeVisible()
+    }
+    expect(drawn(container, 'd')).toBeNull()
+    expect(drawn(container, 'b')).toBeVisible()
+  })
+})

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

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

+ 9 - 1
src/__tests__/features/invoice-designer/fixed-slot-fields.test.ts

@@ -97,7 +97,7 @@ describe('fields that print in a place of their own', () => {
     )
   })
 
-  it('is the eleven the sheet actually has', () => {
+  it('is the nineteen the sheet actually has', () => {
     // Named, so that gaining or losing one is a decision rather than a drift.
     expect(FIXED_SLOT_FIELDS).toEqual({
       header: ['logo', 'company_name'],
@@ -110,6 +110,14 @@ describe('fields that print in a place of their own', () => {
         'date_line',
         'customer_line',
       ],
+      defects: ['defect_notes', 'defect_photos', 'no_defects_note'],
+      results_table: [
+        'passed_checks',
+        'not_applicable_checks',
+        'check_notes',
+        'combined_table',
+        'ungraded_checks',
+      ],
     })
   })
 

+ 187 - 0
src/__tests__/features/invoice-designer/inspection-results-render.test.tsx

@@ -0,0 +1,187 @@
+// @vitest-environment node
+/**
+ * The inspection's results on an invoice, a quote and a work order, drawn.
+ *
+ * The two result blocks were the certificate's alone; now three other
+ * documents print them, from a real inspection rather than the sample. So the
+ * same thing the certificate is held to is held to here: the sheet a customer
+ * opens and the PDF they download say the same rows, the ungraded checks with
+ * their empty grade and the defect's photograph included.
+ */
+import { Document, renderToBuffer } from '@react-pdf/renderer'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { extractText } from 'unpdf'
+import { describe, expect, it } from 'vitest'
+import '@/features/vehicles/Components/invoice-pdf/fonts'
+import { invoiceLabels } from '@/features/invoice-designer/Lib/invoiceLabels'
+import { workOrderLabels } from '@/features/invoice-designer/Lib/workOrderLabels'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { buildQuotePrintSpec } from '@/features/invoice-designer/Pdf/buildQuotePrint'
+import { buildWorkOrderPrintSpec } from '@/features/invoice-designer/Pdf/buildWorkOrderPrint'
+import { SpecPdfPage } from '@/features/invoice-designer/Pdf/SpecPdf'
+import { SpecSheet } from '@/features/invoice-designer/Render/SpecSheet'
+import type { InspectionResults } from '@/features/inspections/Lib/inspectionResults'
+import {
+  DESIGNER_LAYOUT_VERSION,
+  getDefaultLayout,
+  type LayoutDocumentType,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import pdfEn from '../../../../messages/en/pdf.json'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+const PNG =
+  'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAX+XDSwAAAABJRU5ErkJggg=='
+
+const item = (id: string, name: string, condition: string, over: Record<string, unknown> = {}) => ({
+  id,
+  name,
+  section: 'Brakes',
+  sectionCode: '1',
+  code: null,
+  condition,
+  notes: null,
+  sortOrder: Number(id.slice(1)),
+  ...over,
+})
+
+const results: InspectionResults = {
+  severityScale: 'eu',
+  country: null,
+  items: [
+    item('i1', 'Brake pedal', 'pass'),
+    item('i2', 'Brake hoses', 'fail', { notes: 'Cracked at the front left' }),
+    item('i3', 'Headlamp aim', 'not_inspected', { section: 'Lighting', sectionCode: '4' }),
+  ],
+  itemPhotos: { i2: [{ dataUri: PNG }] },
+}
+
+/** A layout of this document with both result sections and every switch on. */
+function layoutOf(documentType: LayoutDocumentType) {
+  const layout = getDefaultLayout(documentType)
+  return {
+    ...layout,
+    version: DESIGNER_LAYOUT_VERSION,
+    sections: layout.sections.map((s) =>
+      s.id === 'defects' || s.id === 'results_table'
+        ? { ...s, visible: true, fields: s.fields?.map((f) => ({ ...f, visible: true })) }
+        : s
+    ),
+  }
+}
+
+const lines = {
+  partItems: [],
+  laborItems: [{ description: 'Fit', hours: 1, rate: 100, total: 100 }],
+}
+const totals = { subtotal: 100, taxRate: 0, taxAmount: 0, totalAmount: 100, discountValue: 0 }
+
+const SHEETS: [string, () => any][] = [
+  [
+    'an invoice',
+    () =>
+      buildInvoicePrintSpec({
+        data: {
+          id: 'svc_1',
+          title: 'Brakes',
+          type: 'repair',
+          serviceDate: new Date('2026-09-24T09:00:00Z'),
+          cost: 100,
+          invoiceNumber: 'INV-1',
+          customer: null,
+          vehicle: null,
+          ...lines,
+          ...totals,
+        } as any,
+        labels: invoiceLabels(pdfEn as any),
+        template: { layoutConfig: layoutOf('invoice') },
+        inspectionResults: results,
+      }),
+  ],
+  [
+    'a quote',
+    () =>
+      buildQuotePrintSpec({
+        data: {
+          quoteNumber: 'Q-1',
+          title: 'Brakes',
+          description: null,
+          validUntil: null,
+          createdAt: new Date('2026-09-24T09:00:00Z'),
+          discountType: null,
+          discountAmount: 0,
+          notes: null,
+          customer: null,
+          vehicle: null,
+          ...lines,
+          ...totals,
+        } as any,
+        labels: invoiceLabels(pdfEn as any, 'quote'),
+        layoutConfig: layoutOf('invoice'),
+        inspectionResults: results,
+      }),
+  ],
+  [
+    'a work order',
+    () =>
+      buildWorkOrderPrintSpec({
+        data: {
+          id: 'svc_1',
+          title: 'Brakes',
+          type: 'repair',
+          serviceDate: new Date('2026-09-24T09:00:00Z'),
+          cost: 100,
+          customFields: [],
+          findings: [],
+          customer: null,
+          vehicle: null,
+          ...lines,
+          ...totals,
+        } as any,
+        job: {
+          orderNumber: 'WO-1',
+          statusLabel: 'Open',
+          concerns: [],
+          printedAt: new Date('2026-09-25T10:00:00Z'),
+          linkedInspection: results,
+        },
+        labels: workOrderLabels(pdfEn as any),
+        template: { layoutConfig: layoutOf('work_order') } as any,
+      }),
+  ],
+]
+
+describe.each(SHEETS)('the inspection results drawn on %s', (_name, build) => {
+  it('say the same rows on the sheet and in the PDF', async () => {
+    const spec = build()
+    expect(JSON.stringify(spec)).toContain(PNG)
+    const html = renderToStaticMarkup((<SpecSheet spec={spec} />) as any)
+    expect(html).toContain(PNG)
+    const sheet = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')
+    const pdf = await renderToBuffer(
+      (
+        <Document>
+          <SpecPdfPage spec={spec} />
+        </Document>
+      ) as any
+    )
+    const { text } = await extractText(new Uint8Array(pdf), { mergePages: true })
+    for (const needle of [
+      'Deficiencies found',
+      'Major defect',
+      'Brake hoses',
+      'Cracked at the front left',
+      'All results',
+      'Brake pedal',
+      'No defect',
+      // The check nobody has graded, on the sheet with nothing beside it.
+      'Headlamp aim',
+      'Lighting',
+    ]) {
+      // The headings are set in capitals, which the PDF's text carries and
+      // the sheet's markup leaves to its styling.
+      expect(sheet.toLowerCase(), needle).toContain(needle.toLowerCase())
+      expect(text.replace(/\s+/g, ' ').toLowerCase(), needle).toContain(needle.toLowerCase())
+    }
+  })
+})

+ 419 - 0
src/__tests__/features/invoice-designer/invoice-quote-inspection-results.test.ts

@@ -0,0 +1,419 @@
+/**
+ * The inspection's results on an invoice and on a quote.
+ *
+ * Both can print the two result sections the certificate has: an invoice from
+ * the inspection linked to its job, a quote from the one it was raised from.
+ * They are a choice, off in a new design and in every design saved before
+ * they existed, and no word either document already prints changes for them.
+ */
+import { describe, expect, it } from 'vitest'
+import { themeOf } from '@/features/invoice-designer/Components/designTheme'
+import { buildSampleData } from '@/features/invoice-designer/Components/sample'
+import { invoiceLabels } from '@/features/invoice-designer/Lib/invoiceLabels'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { buildQuotePrintSpec } from '@/features/invoice-designer/Pdf/buildQuotePrint'
+import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
+import { buildDocumentSpec } from '@/features/invoice-designer/Spec/buildSpec'
+import type { InspectionResults } from '@/features/inspections/Lib/inspectionResults'
+import {
+  DESIGNER_LAYOUT_VERSION,
+  getDefaultLayout,
+  mergeWithDefaults,
+  type InvoiceLayoutConfig,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import {
+  buildLayoutFromPreset,
+  certificatePresets,
+  layoutPresets,
+} from '@/features/settings/Schema/layoutPresets'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+import pdfEn from '../../../../messages/en/pdf.json'
+import pdfNb from '../../../../messages/nb/pdf.json'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+const invoice: InvoiceData = {
+  id: 'svc_00000001',
+  title: 'Service',
+  description: null,
+  type: 'repair',
+  serviceDate: new Date('2026-09-24T09:00:00Z'),
+  shopName: null,
+  techName: null,
+  mileage: 84120,
+  diagnosticNotes: null,
+  invoiceNotes: null,
+  subtotal: 100,
+  taxRate: 0,
+  taxAmount: 0,
+  totalAmount: 100,
+  cost: 100,
+  invoiceNumber: '2026-0042',
+  partItems: [],
+  laborItems: [{ description: 'Replace front pads', hours: 1, rate: 100, total: 100 }],
+  customer: null,
+  vehicle: null,
+}
+
+const quote = {
+  quoteNumber: 'Q-7',
+  title: 'Brakes',
+  description: null,
+  validUntil: null,
+  createdAt: new Date('2026-09-24T09:00:00Z'),
+  subtotal: 100,
+  taxRate: 0,
+  taxAmount: 0,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  totalAmount: 100,
+  notes: null,
+  partItems: [],
+  laborItems: [{ description: 'Replace front pads', hours: 1, rate: 100, total: 100 }],
+  customer: null,
+  vehicle: null,
+} as any
+
+const item = (id: string, name: string, condition: string, over: Record<string, unknown> = {}) => ({
+  id,
+  name,
+  section: 'Brakes',
+  sectionCode: '1',
+  code: null,
+  condition,
+  notes: null,
+  sortOrder: Number(id.slice(1)),
+  ...over,
+})
+
+const results = (): InspectionResults => ({
+  severityScale: 'eu',
+  country: null,
+  items: [
+    item('i1', 'Brake pedal', 'pass'),
+    item('i2', 'Brake hoses', 'fail', { notes: 'Cracked at the front left' }),
+    item('i3', 'Brake fluid', 'attention'),
+    item('i4', 'Headlamps', 'not_inspected', { section: 'Lighting', sectionCode: '4' }),
+  ],
+  itemPhotos: { i2: [{ dataUri: 'data:image/jpeg;base64,AA==' }] },
+})
+
+/** The invoice and quote layout with these sections on, the graded rows with them. */
+function withSections(ids: string[], layout = getDefaultLayout('invoice')): InvoiceLayoutConfig {
+  return {
+    ...layout,
+    version: DESIGNER_LAYOUT_VERSION,
+    sections: layout.sections.map((s) =>
+      ids.includes(s.id)
+        ? {
+            ...s,
+            visible: true,
+            fields: s.fields?.map((f) => ({ ...f, visible: f.id !== 'ungraded_checks' })),
+          }
+        : s
+    ),
+  }
+}
+
+const printInvoice = (
+  layout: InvoiceLayoutConfig | undefined,
+  inspectionResults: InspectionResults | null | undefined,
+  labels: Record<string, string> = invoiceLabels(pdfEn as any)
+) =>
+  buildInvoicePrintSpec({
+    data: invoice,
+    labels,
+    template: layout ? { layoutConfig: layout } : undefined,
+    inspectionResults,
+  }) as any
+
+const printQuote = (
+  layout: InvoiceLayoutConfig | undefined,
+  inspectionResults: InspectionResults | null | undefined,
+  labels: Record<string, string> = invoiceLabels(pdfEn as any, 'quote')
+) => buildQuotePrintSpec({ data: quote, labels, layoutConfig: layout, inspectionResults }) as any
+
+const DOCUMENTS = [
+  ['an invoice', printInvoice],
+  ['a quote', printQuote],
+] as const
+
+const printed = (spec: any): string[] => spec.blocks.map((b: any) => b.id)
+const blockOf = (spec: any, id: string) =>
+  spec.blocks.find((b: any) => b.id === id)?.content ?? null
+
+function texts(node: any): string[] {
+  const out: string[] = []
+  const walk = (n: any) => {
+    if (!n || typeof n !== 'object') return
+    if (n.kind === 'text' && n.text) out.push(String(n.text))
+    if (n.kind === 'table') {
+      out.push(...n.columns.map((c: any) => c.label).filter(Boolean))
+      for (const row of n.rows ?? []) out.push(...Object.values(row).map(String).filter(Boolean))
+    }
+    for (const child of n.children ?? []) walk(child.node ?? child)
+  }
+  walk(node)
+  return out
+}
+
+describe.each(DOCUMENTS)('the inspection results on %s', (_name, print) => {
+  it('prints both blocks from the inspection when the design asks', () => {
+    const spec = print(withSections(['defects', 'results_table']), results())
+    const ids = printed(spec)
+    // Together as the appendix: after the bill, defects first.
+    expect(ids.slice(ids.indexOf('defects'), ids.indexOf('defects') + 2)).toEqual([
+      'defects',
+      'results_table',
+    ])
+    expect(ids.indexOf('defects')).toBeGreaterThan(ids.indexOf('totals'))
+    expect(ids.indexOf('results_table')).toBeLessThan(ids.indexOf('footer'))
+
+    expect(texts(blockOf(spec, 'defects'))).toEqual([
+      'Deficiencies found',
+      'Major defect',
+      'Brake hoses',
+      'Cracked at the front left',
+      'Minor defect',
+      'Brake fluid',
+    ])
+    expect(JSON.stringify(blockOf(spec, 'defects'))).toContain('data:image/jpeg;base64,AA==')
+    const table = texts(blockOf(spec, 'results_table'))
+    expect(table[0]).toBe('All results')
+    expect(table).toContain('Brake pedal')
+    expect(table).toContain('No defect')
+    expect(table).toContain('Status')
+    // A check nobody has graded is left out until the design asks for those.
+    expect(table).not.toContain('Headlamps')
+  })
+
+  it('sits in front of the condition map when both are on', () => {
+    const ids = getDefaultLayout('invoice').sections.map((s) => s.id)
+    const at = ids.indexOf('defects')
+    expect(ids.slice(at, at + 4)).toEqual([
+      'defects',
+      'results_table',
+      'condition_map',
+      'signature',
+    ])
+  })
+
+  it('prints nothing new at the default, inspection or not', () => {
+    const plain = print(undefined, null)
+    const linked = print(undefined, results())
+    expect(printed(linked)).not.toContain('defects')
+    expect(printed(linked)).not.toContain('results_table')
+    expect(linked).toEqual(plain)
+    // A saved designer layout at its defaults: the same.
+    const saved = { ...getDefaultLayout('invoice'), version: DESIGNER_LAYOUT_VERSION }
+    expect(print(saved, results())).toEqual(print(saved, null))
+  })
+
+  it('prints nothing, not even a heading, without an inspection', () => {
+    const on = withSections(['defects', 'results_table'])
+    const off = { ...getDefaultLayout('invoice'), version: DESIGNER_LAYOUT_VERSION }
+    for (const none of [null, undefined]) {
+      expect(printed(print(on, none))).toEqual(printed(print(off, null)))
+    }
+  })
+
+  it('prints nothing for an inspection nobody has started', () => {
+    const untouched = results()
+    untouched.items = untouched.items.map((i) => ({ ...i, condition: 'not_inspected' }))
+    const spec = print(withSections(['defects', 'results_table']), untouched)
+    expect(printed(spec)).not.toContain('defects')
+    expect(printed(spec)).not.toContain('results_table')
+  })
+
+  it('words the sections in the document language', () => {
+    const type = print === printQuote ? 'quote' : 'invoice'
+    const spec = print(
+      withSections(['defects', 'results_table']),
+      results(),
+      invoiceLabels(pdfNb as any, type)
+    )
+    const nb = (pdfNb as any).inspection
+    expect(texts(blockOf(spec, 'defects'))[0]).toBe(nb.deficiencies)
+    const table = texts(blockOf(spec, 'results_table'))
+    expect(table[0]).toBe(nb.allResults)
+    expect(table).toContain(nb.statusColumn)
+    expect(table).toContain(nb.euPass)
+  })
+})
+
+describe('the invoice and quote labels', () => {
+  it('take the inspection vocabulary without changing a word they had', async () => {
+    for (const pdf of [pdfEn, pdfNb] as any[]) {
+      for (const type of ['invoice', 'quote'] as const) {
+        const before = { ...pdf.invoice, ...(type === 'quote' ? pdf.quote : {}), ...pdf.common }
+        const after = invoiceLabels(pdf, type)
+        for (const key of Object.keys(before)) expect(after[key], key).toBe(before[key])
+        expect(after.allResults).toBe(pdf.inspection.allResults)
+        expect(after.euPass).toBe(pdf.inspection.euPass)
+      }
+    }
+    // The print path reads the same words.
+    for (const locale of ['en', 'nb']) {
+      const pdf: any = locale === 'en' ? pdfEn : pdfNb
+      expect(await loadPrintLabels(locale, {})).toEqual(invoiceLabels(pdf))
+      expect(await loadPrintLabels(locale, {}, 'quote')).toEqual(invoiceLabels(pdf, 'quote'))
+    }
+  })
+
+  it('print nothing new on a default sheet for having them', () => {
+    const bare = { ...(pdfEn as any).invoice, ...(pdfEn as any).common }
+    expect(printInvoice(undefined, null, invoiceLabels(pdfEn as any))).toEqual(
+      printInvoice(undefined, null, bare)
+    )
+    const bareQuote = { ...bare, ...(pdfEn as any).quote, ...(pdfEn as any).common }
+    expect(printQuote(undefined, null, invoiceLabels(pdfEn as any, 'quote'))).toEqual(
+      printQuote(undefined, null, bareQuote)
+    )
+  })
+})
+
+describe('the invoice and quote layout and the result sections', () => {
+  const ordered = (layout: InvoiceLayoutConfig) =>
+    [...layout.sections].sort((a, b) => a.order - b.order).map((s) => s.id)
+  const shown = (layout: InvoiceLayoutConfig) =>
+    [...layout.sections]
+      .filter((s) => s.visible)
+      .sort((a, b) => a.order - b.order)
+      .map((s) => s.id)
+  const without = (layout: InvoiceLayoutConfig, ids: string[]): InvoiceLayoutConfig => ({
+    ...layout,
+    version: DESIGNER_LAYOUT_VERSION,
+    sections: layout.sections
+      .filter((s) => !ids.includes(s.id))
+      .sort((a, b) => a.order - b.order)
+      .map((s, order) => ({ ...s, order })),
+  })
+
+  it('has them off in a new design', () => {
+    const layout = getDefaultLayout('invoice')
+    for (const id of ['defects', 'results_table']) {
+      expect(layout.sections.find((s) => s.id === id)?.visible).toBe(false)
+    }
+  })
+
+  it('gives a design saved before they existed both sections, hidden, in front of the map', () => {
+    const saved = without(getDefaultLayout('invoice'), ['defects', 'results_table'])
+    const merged = mergeWithDefaults(saved)
+    const ids = ordered(merged)
+    const at = ids.indexOf('condition_map')
+    expect(ids.slice(at - 2, at + 1)).toEqual(['defects', 'results_table', 'condition_map'])
+    for (const id of ['defects', 'results_table']) {
+      const section = merged.sections.find((s) => s.id === id)
+      expect(section?.visible).toBe(false)
+      expect(section?.fields?.length).toBeGreaterThan(0)
+    }
+    expect(shown(merged)).toEqual(shown(saved))
+    // And both documents print what they printed.
+    expect(printInvoice(saved, results())).toEqual(printInvoice(saved, null))
+    expect(printQuote(saved, results())).toEqual(printQuote(saved, null))
+  })
+
+  it('follows the condition map wherever a design has put it', () => {
+    // The map moved up under the vehicle, as a workshop might want it.
+    const base = without(getDefaultLayout('invoice'), ['defects', 'results_table'])
+    const ids = ordered(base).filter((id) => id !== 'condition_map')
+    ids.splice(ids.indexOf('vehicle') + 1, 0, 'condition_map')
+    const saved: InvoiceLayoutConfig = {
+      ...base,
+      sections: ids.map((id, order) => ({
+        ...(base.sections.find((s) => s.id === id) as any),
+        order,
+      })),
+    }
+    const merged = ordered(mergeWithDefaults(saved))
+    const at = merged.indexOf('condition_map')
+    expect(merged.slice(at - 3, at + 1)).toEqual([
+      'vehicle',
+      'defects',
+      'results_table',
+      'condition_map',
+    ])
+  })
+
+  it('joins a design older than the condition map with it, in front of the signing line', () => {
+    // Bank details under the signature, as some workshops arrange it.
+    const base = without(getDefaultLayout('invoice'), ['defects', 'results_table', 'condition_map'])
+    const ids = ordered(base).filter((id) => id !== 'bank_account')
+    ids.splice(ids.indexOf('signature') + 1, 0, 'bank_account')
+    const saved: InvoiceLayoutConfig = {
+      ...base,
+      sections: ids.map((id, order) => ({
+        ...(base.sections.find((s) => s.id === id) as any),
+        order,
+      })),
+    }
+    const merged = ordered(mergeWithDefaults(saved))
+    const at = merged.indexOf('signature')
+    expect(merged.slice(at - 3, at + 2)).toEqual([
+      'defects',
+      'results_table',
+      'condition_map',
+      'signature',
+      'bank_account',
+    ])
+  })
+
+  it('leaves a design built from a starting point in the order it was saved in', () => {
+    // A starting point writes its own numbers on the built-in list, and the
+    // designer saves it like that until a section is moved. A section added
+    // later must not put the sheet, or an issued copy of it, back in the
+    // built-in order.
+    const cases: [InvoiceLayoutConfig, string[]][] = [
+      ...layoutPresets.map((p): [InvoiceLayoutConfig, string[]] => [
+        buildLayoutFromPreset(p),
+        ['defects', 'results_table'],
+      ]),
+      ...layoutPresets.map((p): [InvoiceLayoutConfig, string[]] => [
+        buildLayoutFromPreset(p),
+        ['defects', 'results_table', 'condition_map', 'signature'],
+      ]),
+      ...certificatePresets.map((p): [InvoiceLayoutConfig, string[]] => [
+        buildLayoutFromPreset(p),
+        ['signature'],
+      ]),
+    ]
+    for (const [built, missing] of cases) {
+      const saved = {
+        ...built,
+        version: DESIGNER_LAYOUT_VERSION,
+        sections: built.sections.filter((s) => !missing.includes(s.id)),
+      }
+      const merged = mergeWithDefaults(JSON.parse(JSON.stringify(saved)))
+      expect(
+        shown(merged).filter((id) => !missing.includes(id)),
+        `${built.documentType ?? 'invoice'} without ${missing.join(', ')}`
+      ).toEqual(shown(saved))
+    }
+  })
+})
+
+describe('the designer preview of an invoice and a quote', () => {
+  it.each([
+    'invoice',
+    'quote',
+  ] as const)('shows sample results on the %s canvas when on', (type) => {
+    const sample = buildSampleData(
+      { name: 'Shop', address: 'A road', phone: '555', email: 's@example.com', logoUrl: '' } as any,
+      [],
+      ((key: string) => key) as any,
+      invoiceLabels(pdfEn as any, type),
+      type
+    )
+    const off = getDefaultLayout('invoice')
+    const offSpec = buildDocumentSpec(off, themeOf({} as any, off), sample) as any
+    expect(printed(offSpec)).not.toContain('defects')
+    expect(printed(offSpec)).not.toContain('results_table')
+    const on = withSections(['defects', 'results_table'])
+    const spec = buildDocumentSpec(on, themeOf({} as any, on), sample) as any
+    expect(texts(blockOf(spec, 'defects')).join('\n')).toContain('sample.checkBrakeHoses')
+    expect(texts(blockOf(spec, 'results_table'))).toContain('sample.checkBrakePedal')
+    // Still the document it is: its own title, not the certificate's.
+    expect(sample.meta.title).not.toBe((pdfEn as any).inspection.title)
+  })
+})

+ 4 - 2
src/__tests__/features/invoice-designer/marine-labels.test.ts

@@ -39,7 +39,9 @@ describe('invoice print labels', () => {
 
   it('leaves an automotive workshop exactly as the translation file has it', async () => {
     const en = pdfMessages('en')
-    const expected = { ...en.invoice, ...en.common }
+    // The inspection's vocabulary lies underneath, for the result sections a
+    // design can switch on; every word the invoice has of its own wins.
+    const expected = { ...en.inspection, ...en.invoice, ...en.common }
     expect(await loadPrintLabels('en', AUTOMOTIVE)).toEqual(expected)
     // No setting at all is an automotive workshop.
     expect(await loadPrintLabels('en', {})).toEqual(expected)
@@ -77,7 +79,7 @@ describe('quote print labels', () => {
   it('leaves an automotive quote exactly as the translation file has it', async () => {
     const en = pdfMessages('en')
     const labels = await loadPrintLabels('en', AUTOMOTIVE, 'quote')
-    expect(labels).toEqual({ ...en.invoice, ...en.quote, ...en.common })
+    expect(labels).toEqual({ ...en.inspection, ...en.invoice, ...en.quote, ...en.common })
     expect(labels.vehicle).toBe('Vehicle')
     expect(labels.vin).toBe('VIN: {vin}')
     expect(labels.plate).toBe('Plate: {plate}')

+ 87 - 0
src/__tests__/features/invoice-designer/sheet-first-paint.test.tsx

@@ -0,0 +1,87 @@
+/**
+ * The customer's sheet before anything on it has been measured.
+ *
+ * The shared invoice, quote and inspection pages and the portal's invoice
+ * draw the document with SpecSheet, which places blocks by heights only a
+ * browser can measure and fits the paper to a width only a browser knows. The
+ * server's markup used to show every row at the top margin on a full-size
+ * sheet until hydration. Now it shows paper of the right shape, and the sheet
+ * takes its place in the pass that measures it.
+ */
+import { cleanup, render } from '@testing-library/react'
+import { renderToString } from 'react-dom/server'
+import { afterEach, describe, expect, it } from 'vitest'
+import { SpecSheet } from '@/features/invoice-designer/Render/SpecSheet'
+import type { Block, DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
+
+const block = (id: string, order: number): Block => ({
+  id,
+  label: id,
+  placement: { mode: 'flow', order },
+  content: { kind: 'text', id, text: `${id} text` },
+})
+
+const SPEC: DocumentSpec = {
+  page: {
+    width: 595,
+    height: 842,
+    margin: { top: 40, right: 40, bottom: 40, left: 40 },
+    background: '#ffffff',
+    text: '#111827',
+    muted: '#6b7280',
+    accent: '#d97706',
+    fontFamily: 'Helvetica',
+    fontSize: 9,
+  },
+  blocks: [block('a', 0), block('b', 1), block('c', 2)],
+}
+
+/** Whether an element can be seen: neither it nor anything around it is hidden. */
+function shown(element: Element | null): boolean {
+  for (let node = element; node; node = node.parentElement) {
+    if ((node as HTMLElement).style?.visibility === 'hidden') return false
+  }
+  return element !== null
+}
+
+afterEach(cleanup)
+
+describe('the sheet a customer opens', () => {
+  it('is paper of the page shape, with no block to see, as the server sends it', () => {
+    const host = document.createElement('div')
+    host.innerHTML = renderToString(<SpecSheet spec={SPEC} />)
+
+    const paper = host.querySelector<HTMLElement>('[data-sheet-loading]')
+    expect(paper).not.toBeNull()
+    // Sized by CSS alone, so it is right on a phone before any script runs.
+    expect(paper?.style.width).toBe('100%')
+    expect(paper?.style.aspectRatio).toBe('595 / 842')
+
+    const busy = host.querySelector<HTMLElement>('[aria-busy="true"]')
+    expect(busy?.style.visibility).toBe('hidden')
+    // Out of the flow's way: the paper alone gives the page its height.
+    expect(busy?.style.height).toBe('0px')
+    const texts = [...host.querySelectorAll('*')].filter(
+      (el) => el.children.length === 0 && /^[abc] text$/.test(el.textContent ?? '')
+    )
+    expect(texts.length).toBeGreaterThan(0)
+    expect(texts.some(shown)).toBe(false)
+  })
+
+  it('shows the blocks and drops the paper once mounted', () => {
+    const { container } = render(<SpecSheet spec={SPEC} />)
+    expect(container.querySelector('[data-sheet-loading]')).toBeNull()
+    expect(container.querySelector('[aria-busy]')).toBeNull()
+    const texts = [...container.querySelectorAll('*')].filter(
+      (el) => el.children.length === 0 && /^[abc] text$/.test(el.textContent ?? '')
+    )
+    // Each block is drawn twice: once to be measured, once on the sheet.
+    expect(texts.filter(shown).map((el) => el.textContent)).toEqual(['a text', 'b text', 'c text'])
+  })
+
+  it('never brings the paper back when the document changes', () => {
+    const { container, rerender } = render(<SpecSheet spec={SPEC} />)
+    rerender(<SpecSheet spec={{ ...SPEC, blocks: [...SPEC.blocks, block('d', 3)] }} />)
+    expect(container.querySelector('[data-sheet-loading]')).toBeNull()
+  })
+})

+ 500 - 0
src/__tests__/features/invoice-designer/work-order-inspection-results.test.ts

@@ -0,0 +1,500 @@
+/**
+ * The linked inspection's results on the work order.
+ *
+ * The work order can print the two result sections the certificate has, from
+ * the inspection linked to the job. They are a choice: off in a new design
+ * and in every design saved before they existed, and silent when the job has
+ * no inspection or the inspection has nothing graded yet.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { findFirst } = vi.hoisted(() => ({ findFirst: vi.fn() }))
+vi.mock('@/lib/db', () => ({ db: { inspection: { findFirst } } }))
+vi.mock('@/features/inspections/Lib/inspectionPhotos', () => ({
+  loadInspectionPhotos: vi.fn(async () => ({ photos: { i2: [{ dataUri: 'data:photo' }] } })),
+}))
+
+import { loadInspectionPhotos } from '@/features/inspections/Lib/inspectionPhotos'
+import {
+  inspectionResultsFor,
+  loadLinkedInspectionResults,
+} from '@/features/inspections/Lib/linkedInspectionResults.server'
+import { themeOf } from '@/features/invoice-designer/Components/designTheme'
+import { buildSampleData } from '@/features/invoice-designer/Components/sample'
+import { workOrderLabels } from '@/features/invoice-designer/Lib/workOrderLabels'
+import { buildWorkOrderPrintSpec } from '@/features/invoice-designer/Pdf/buildWorkOrderPrint'
+import { buildDocumentSpec } from '@/features/invoice-designer/Spec/buildSpec'
+import {
+  getDefaultLayout,
+  mergeWithDefaults,
+  type InvoiceLayoutConfig,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import { buildLayoutFromPreset, workOrderPresets } from '@/features/settings/Schema/layoutPresets'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+import pdfEn from '../../../../messages/en/pdf.json'
+import pdfNb from '../../../../messages/nb/pdf.json'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+const data = () =>
+  ({
+    id: 'svc_1',
+    title: 'Service',
+    description: null,
+    type: 'repair',
+    serviceDate: new Date('2026-09-24T09:00:00Z'),
+    mileage: 84120,
+    subtotal: 100,
+    taxRate: 0,
+    taxAmount: 0,
+    totalAmount: 100,
+    cost: 100,
+    discountValue: 0,
+    partItems: [],
+    laborItems: [{ description: 'Replace front pads', hours: 1, rate: 100, total: 100 }],
+    customFields: [],
+    findings: [],
+    customer: { name: 'Alex Carter' },
+    vehicle: { make: 'Volvo', model: 'V60', year: 2020, licensePlate: 'AB 12345' },
+  }) as unknown as InvoiceData
+
+const item = (id: string, name: string, condition: string, over: Record<string, unknown> = {}) => ({
+  id,
+  name,
+  section: 'Brakes',
+  sectionCode: '1',
+  code: null,
+  condition,
+  notes: null,
+  sortOrder: Number(id.slice(1)),
+  ...over,
+})
+
+const inspection = () => ({
+  severityScale: 'eu' as const,
+  country: null,
+  items: [
+    item('i1', 'Brake pedal', 'pass'),
+    item('i2', 'Brake hoses', 'fail', {
+      notes: 'Cracked at the front left',
+      imageUrls: ['/api/protected/files/org_1/inspections/hose.jpg'],
+    }),
+    item('i3', 'Brake fluid', 'attention'),
+    item('i4', 'Headlamps', 'not_inspected', { section: 'Lighting', sectionCode: '4' }),
+  ],
+})
+
+/**
+ * The work order layout with these sections switched on and every kind of
+ * graded row with them. The ungraded checks and the "no deficiencies" line
+ * have switches of their own, set here by name.
+ */
+function withSections(
+  ids: string[],
+  switches: { ungraded_checks?: boolean; no_defects_note?: boolean; combined_table?: boolean } = {},
+  layout = getDefaultLayout('work_order')
+) {
+  const on: Record<string, boolean> = { ungraded_checks: false, ...switches }
+  return {
+    ...layout,
+    sections: layout.sections.map((s) =>
+      ids.includes(s.id)
+        ? {
+            ...s,
+            visible: true,
+            fields: s.fields?.map((f) => ({ ...f, visible: on[f.id] ?? true })),
+          }
+        : s
+    ),
+  }
+}
+
+function print(
+  layout: InvoiceLayoutConfig | undefined,
+  linkedInspection: ReturnType<typeof inspection> | null | undefined,
+  labels: Record<string, string> = workOrderLabels(pdfEn as any)
+) {
+  return buildWorkOrderPrintSpec({
+    data: data(),
+    job: {
+      orderNumber: '2026-0042',
+      statusLabel: 'Open',
+      concerns: [],
+      printedAt: new Date('2026-09-25T10:00:00Z'),
+      linkedInspectionId: linkedInspection ? 'insp_1' : null,
+      linkedInspection,
+    },
+    labels,
+    template: layout ? ({ layoutConfig: { ...layout, version: 3 } } as any) : undefined,
+  }) as any
+}
+
+const printed = (spec: any): string[] => spec.blocks.map((b: any) => b.id)
+
+function texts(node: any): string[] {
+  const out: string[] = []
+  const walk = (n: any) => {
+    if (!n || typeof n !== 'object') return
+    if (n.kind === 'text' && n.text) out.push(String(n.text))
+    if (n.kind === 'table') {
+      out.push(...n.columns.map((c: any) => c.label).filter(Boolean))
+      for (const row of n.rows ?? []) out.push(...Object.values(row).map(String).filter(Boolean))
+    }
+    for (const child of n.children ?? []) walk(child.node ?? child)
+  }
+  walk(node)
+  return out
+}
+
+const blockOf = (spec: any, id: string) =>
+  spec.blocks.find((b: any) => b.id === id)?.content ?? null
+
+describe('the inspection results on a work order', () => {
+  it('prints both blocks from the linked inspection when the design asks', () => {
+    const spec = print(withSections(['defects', 'results_table']), inspection())
+    const ids = printed(spec)
+    expect(ids).toContain('defects')
+    expect(ids).toContain('results_table')
+    // After the condition map's place and ahead of the tables of the job.
+    expect(ids.indexOf('defects')).toBeLessThan(ids.indexOf('results_table'))
+    expect(ids.indexOf('results_table')).toBeLessThan(ids.indexOf('labor_table'))
+    expect(ids.indexOf('defects')).toBeGreaterThan(ids.indexOf('job_description'))
+
+    const defects = texts(blockOf(spec, 'defects'))
+    expect(defects[0]).toBe('Deficiencies found')
+    // Worst first, each with its grade and its note.
+    expect(defects).toEqual([
+      'Deficiencies found',
+      expect.stringContaining('Major defect'),
+      'Brake hoses',
+      'Cracked at the front left',
+      expect.stringContaining('Minor defect'),
+      'Brake fluid',
+    ])
+
+    const results = texts(blockOf(spec, 'results_table'))
+    expect(results[0]).toBe('All results')
+    expect(results).toContain('Brake pedal')
+    expect(results).toContain('Brake hoses')
+    expect(results).toContain('No defect')
+    expect(results).toContain('Status')
+  })
+
+  it('leaves out a check nobody has graded yet', () => {
+    const all = texts(
+      blockOf(print(withSections(['results_table']), inspection()), 'results_table')
+    )
+    expect(all).not.toContain('Headlamps')
+    expect(all.join(' ')).not.toContain('Lighting')
+  })
+
+  it('prints nothing new at the default, linked inspection or not', () => {
+    const plain = print(undefined, null)
+    const linked = print(undefined, inspection())
+    expect(printed(linked)).not.toContain('defects')
+    expect(printed(linked)).not.toContain('results_table')
+    expect(linked).toEqual(plain)
+    // The Compact starting point leaves them off too.
+    const compact = buildLayoutFromPreset(workOrderPresets[0])
+    for (const id of ['defects', 'results_table']) {
+      expect(compact.sections.find((s) => s.id === id)?.visible).toBe(false)
+    }
+  })
+
+  it('prints nothing, not even a heading, for a job without an inspection', () => {
+    const on = withSections(['defects', 'results_table'])
+    for (const none of [null, undefined]) {
+      const spec = print(on, none)
+      expect(printed(spec)).not.toContain('defects')
+      expect(printed(spec)).not.toContain('results_table')
+      expect(printed(spec)).toEqual(printed(print(undefined, null)))
+    }
+  })
+
+  it('prints nothing for an inspection nobody has started', () => {
+    const untouched = inspection()
+    untouched.items = untouched.items.map((i) => ({ ...i, condition: 'not_inspected' }))
+    const spec = print(withSections(['defects', 'results_table']), untouched)
+    expect(printed(spec)).not.toContain('defects')
+    expect(printed(spec)).not.toContain('results_table')
+  })
+
+  it('prints the whole checklist, grades left empty, when the design asks for ungraded checks', () => {
+    const untouched = inspection()
+    untouched.items = untouched.items.map((i) => ({ ...i, condition: 'not_inspected' }))
+    const spec = print(
+      withSections(['defects', 'results_table'], { ungraded_checks: true, combined_table: false }),
+      untouched
+    )
+    const table = (blockOf(spec, 'results_table').children as any[]).filter(
+      (c) => c.kind === 'table'
+    )
+    // A table per section, in checklist order, every check on it.
+    expect(table.map((t) => t.rows.map((r: any) => r.name))).toEqual([
+      ['Brake pedal', 'Brake hoses', 'Brake fluid'],
+      ['Headlamps'],
+    ])
+    expect(table.flatMap((t) => t.rows.map((r: any) => r.grade))).toEqual(['', '', '', ''])
+    // And still nothing about deficiencies on a car nobody has looked at.
+    expect(printed(spec)).not.toContain('defects')
+  })
+
+  it('puts an ungraded check in its place among the graded ones', () => {
+    const on = withSections(['results_table'], { ungraded_checks: true })
+    const rows = (blockOf(print(on, inspection()), 'results_table').children as any[])
+      .filter((c) => c.kind === 'table')
+      .flatMap((t) => t.rows.map((r: any) => [r.name, r.grade]))
+    expect(rows).toEqual([
+      ['Brake pedal', 'No defect'],
+      ['Brake hoses', 'Major defect'],
+      ['Brake fluid', 'Minor defect'],
+      ['Headlamps', ''],
+    ])
+    // Each kind of row keeps its own switch: without the passed checks, the
+    // sheet is what was found and what is still to do.
+    const layout = getDefaultLayout('work_order')
+    layout.sections = layout.sections.map((s) =>
+      s.id === 'results_table'
+        ? {
+            ...s,
+            visible: true,
+            fields: s.fields?.map((f) => ({
+              ...f,
+              visible: f.id === 'ungraded_checks' || f.id === 'combined_table',
+            })),
+          }
+        : s
+    )
+    const combined = (blockOf(print(layout, inspection()), 'results_table').children as any[]).find(
+      (c) => c.kind === 'table'
+    )
+    expect(combined.rows.map((r: any) => [r.section, r.name])).toEqual([
+      ['1. Brakes', 'Brake hoses'],
+      ['1. Brakes', 'Brake fluid'],
+      ['4. Lighting', 'Headlamps'],
+    ])
+  })
+
+  it('says a graded car has no deficiencies, unless the design leaves the line out', () => {
+    const clean = inspection()
+    clean.items = clean.items.map((i) => ({
+      ...i,
+      condition: i.condition === 'not_inspected' ? 'not_inspected' : 'pass',
+    }))
+    const noted = print(withSections(['defects']), clean)
+    expect(texts(blockOf(noted, 'defects'))).toEqual([
+      'Deficiencies found',
+      'No deficiencies were recorded.',
+    ])
+    const silent = print(withSections(['defects'], { no_defects_note: false }), clean)
+    expect(printed(silent)).not.toContain('defects')
+    // The switch is about the clean car only: a defect prints either way.
+    const found = print(withSections(['defects'], { no_defects_note: false }), inspection())
+    expect(texts(blockOf(found, 'defects'))).toContain('Brake hoses')
+  })
+
+  it('words the headings, the columns and the grades in the document language', () => {
+    const labels = workOrderLabels(pdfNb as any)
+    const spec = print(withSections(['defects', 'results_table']), inspection(), labels)
+    const nb = (pdfNb as any).inspection
+    expect(texts(blockOf(spec, 'defects'))[0]).toBe(nb.deficiencies)
+    const results = texts(blockOf(spec, 'results_table'))
+    expect(results[0]).toBe(nb.allResults)
+    expect(results).toContain(nb.statusColumn)
+    expect(results).toContain(nb.euPass)
+  })
+
+  it('takes the inspection vocabulary without changing a word the work order had', () => {
+    for (const pdf of [pdfEn, pdfNb] as any[]) {
+      const before = { ...pdf.invoice, ...pdf.common, ...pdf.workOrder }
+      const after = workOrderLabels(pdf)
+      for (const key of Object.keys(before)) {
+        if (['title', 'invoiceNumberLabel', 'billTo'].includes(key)) continue
+        expect(after[key]).toBe(before[key])
+      }
+      expect(after.title).toBe(pdf.workOrder.title)
+      expect(after.allResults).toBe(pdf.inspection.allResults)
+    }
+  })
+})
+
+describe('the work order layout and the result sections', () => {
+  it('has them off by default, after the condition map and before the checklist', () => {
+    const layout = getDefaultLayout('work_order')
+    const ids = layout.sections.map((s) => s.id)
+    const at = ids.indexOf('condition_map')
+    expect(ids.slice(at, at + 4)).toEqual([
+      'condition_map',
+      'defects',
+      'results_table',
+      'work_checklist',
+    ])
+    for (const id of ['defects', 'results_table']) {
+      expect(layout.sections.find((s) => s.id === id)?.visible).toBe(false)
+    }
+  })
+
+  it('gives a design saved before they existed both sections, hidden', () => {
+    // A workshop's own design: everything on, the order its own, saved before
+    // the result sections were offered.
+    const before = getDefaultLayout('work_order')
+    const saved: InvoiceLayoutConfig = {
+      ...before,
+      version: 3,
+      sections: before.sections
+        .filter((s) => s.id !== 'defects' && s.id !== 'results_table')
+        .map((s, order) => ({ ...s, visible: true, order })),
+    }
+    const merged = mergeWithDefaults(saved)
+    const ids = [...merged.sections].sort((a, b) => a.order - b.order).map((s) => s.id)
+    const at = ids.indexOf('condition_map')
+    expect(ids.slice(at, at + 4)).toEqual([
+      'condition_map',
+      'defects',
+      'results_table',
+      'work_checklist',
+    ])
+    for (const id of ['defects', 'results_table']) {
+      const section = merged.sections.find((s) => s.id === id)
+      expect(section?.visible).toBe(false)
+      // With the certificate's own switches, at the certificate's defaults.
+      expect(section?.fields?.length).toBeGreaterThan(0)
+    }
+    // And the sheet it prints is the one it printed before.
+    const spec = print(saved, inspection())
+    expect(printed(spec)).not.toContain('defects')
+    expect(printed(spec)).not.toContain('results_table')
+    expect(printed(spec)).toEqual(printed(print(saved, null)))
+  })
+
+  it('leaves a design built from a starting point in the order it was saved in', () => {
+    // A starting point writes its own numbers on the built-in list and the
+    // designer saves it like that until a section is moved. Joining such a
+    // design must not put the sheet back in the built-in order.
+    const shown = (layout: InvoiceLayoutConfig) =>
+      [...layout.sections]
+        .filter((s) => s.visible)
+        .sort((a, b) => a.order - b.order)
+        .map((s) => s.id)
+    for (const preset of workOrderPresets) {
+      const built = buildLayoutFromPreset(preset)
+      const saved: InvoiceLayoutConfig = {
+        ...built,
+        version: 3,
+        sections: built.sections.filter((s) => s.id !== 'defects' && s.id !== 'results_table'),
+      }
+      const merged = mergeWithDefaults(JSON.parse(JSON.stringify(saved)))
+      expect(shown(merged), preset.id).toEqual(shown(saved))
+      expect(merged.sections.find((s) => s.id === 'defects')?.visible, preset.id).toBe(false)
+      expect(printed(print(saved, inspection())), preset.id).toEqual(printed(print(built, null)))
+    }
+  })
+
+  it('shows sample results on the designer canvas once switched on', () => {
+    const sample = buildSampleData(
+      { name: 'Shop', address: 'A road', phone: '555', email: 's@example.com', logoUrl: '' } as any,
+      [],
+      ((key: string) => key) as any,
+      workOrderLabels(pdfEn as any),
+      'work_order'
+    )
+    const off = getDefaultLayout('work_order')
+    const offSpec = buildDocumentSpec(off, themeOf({} as any, off), sample) as any
+    expect(printed(offSpec)).not.toContain('results_table')
+    const on = withSections(['defects', 'results_table'])
+    const spec = buildDocumentSpec(on, themeOf({} as any, on), sample) as any
+    expect(texts(blockOf(spec, 'defects')).join('\n')).toContain('sample.checkBrakeHoses')
+    expect(texts(blockOf(spec, 'results_table'))).toContain('sample.checkBrakePedal')
+    // The sample has one check still to do, for the switch that lists those.
+    expect(texts(blockOf(spec, 'results_table'))).not.toContain('sample.checkStopLamps')
+    const listed = withSections(['results_table'], { ungraded_checks: true })
+    const full = buildDocumentSpec(listed, themeOf({} as any, listed), sample) as any
+    expect(texts(blockOf(full, 'results_table'))).toContain('sample.checkStopLamps')
+    // Still a work order: its own title, not the certificate's.
+    expect(sample.meta.title).toBe((pdfEn as any).workOrder.title)
+  })
+})
+
+describe('loading the linked inspection', () => {
+  beforeEach(() => {
+    findFirst.mockReset()
+    vi.mocked(loadInspectionPhotos).mockClear()
+  })
+
+  it("asks for the workshop's own inspection on the job's own vehicle, finished or not", async () => {
+    findFirst.mockResolvedValue({
+      severityScale: null,
+      country: null,
+      template: { severityScale: 'basic', country: 'NO' },
+      items: inspection().items,
+    })
+    const loaded = await loadLinkedInspectionResults('org_1', 'veh_1', 'insp_1', { photos: false })
+    expect(findFirst.mock.calls[0][0].where).toEqual({
+      id: 'insp_1',
+      organizationId: 'org_1',
+      vehicleId: 'veh_1',
+    })
+    expect(loaded?.severityScale).toBe('basic')
+    expect(loaded?.country).toBe('NO')
+    expect(loaded?.items).toHaveLength(4)
+    expect(loaded?.itemPhotos).toBeUndefined()
+  })
+
+  it('reads the photographs only when the design shows them', async () => {
+    findFirst.mockResolvedValue({
+      severityScale: 'eu',
+      country: null,
+      template: { severityScale: 'eu', country: null },
+      items: inspection().items,
+    })
+    const loaded = await loadLinkedInspectionResults('org_1', 'veh_1', 'insp_1', { photos: true })
+    expect(loaded?.itemPhotos).toEqual({ i2: [{ dataUri: 'data:photo' }] })
+    // Only the defects that have pictures are decoded: nothing else draws one.
+    expect(vi.mocked(loadInspectionPhotos).mock.calls[0][0].map((i) => i.id)).toEqual(['i2'])
+  })
+
+  it('is not read at all for a design that prints neither section', async () => {
+    findFirst.mockResolvedValue({
+      severityScale: 'eu',
+      country: null,
+      template: { severityScale: 'eu', country: null },
+      items: inspection().items,
+    })
+    // A quote raised from an inspection, its design at the defaults.
+    const quote = { vehicleId: 'veh_1', inspectionId: 'insp_1' }
+    expect(await inspectionResultsFor('org_1', quote, getDefaultLayout('invoice'))).toBeNull()
+    expect(findFirst).not.toHaveBeenCalled()
+
+    const invoice = getDefaultLayout('invoice')
+    const on = {
+      ...invoice,
+      sections: invoice.sections.map((s) =>
+        s.id === 'results_table' ? { ...s, visible: true } : s
+      ),
+    }
+    const loaded = await inspectionResultsFor('org_1', quote, on)
+    expect(findFirst.mock.calls[0][0].where).toEqual({
+      id: 'insp_1',
+      organizationId: 'org_1',
+      vehicleId: 'veh_1',
+    })
+    expect(loaded?.items).toHaveLength(4)
+    // The table draws no photograph, so none is decoded for it.
+    expect(loaded?.itemPhotos).toBeUndefined()
+    expect(loadInspectionPhotos).not.toHaveBeenCalled()
+  })
+
+  it('is nothing without a link, without a vehicle, or with no checks', async () => {
+    expect(await loadLinkedInspectionResults('org_1', 'veh_1', null, { photos: false })).toBeNull()
+    expect(await loadLinkedInspectionResults('org_1', null, 'insp_1', { photos: false })).toBeNull()
+    expect(findFirst).not.toHaveBeenCalled()
+    findFirst.mockResolvedValue(null)
+    expect(
+      await loadLinkedInspectionResults('org_1', 'veh_1', 'other_org', { photos: false })
+    ).toBeNull()
+    findFirst.mockResolvedValue({ severityScale: null, country: null, template: {}, items: [] })
+    expect(
+      await loadLinkedInspectionResults('org_1', 'veh_1', 'insp_1', { photos: false })
+    ).toBeNull()
+  })
+})

+ 437 - 0
src/__tests__/features/invoices/invoice-inspection-results.test.ts

@@ -0,0 +1,437 @@
+// @vitest-environment node
+/**
+ * The inspection's results on an invoice, draft and issued.
+ *
+ * A draft prints the inspection linked to its job as it stands. Issuing
+ * freezes the checks beside everything else the invoice says, so grading,
+ * renaming or deleting a check afterwards cannot rewrite paper a customer
+ * holds. An invoice issued without results has none, and never borrows
+ * today's; and one issued before the field existed still reads as it did.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const db = {
+  serviceRecord: { findUnique: vi.fn() },
+  appSetting: { findMany: vi.fn() },
+  organization: { findUnique: vi.fn() },
+  vehicleFinding: { findMany: vi.fn() },
+  documentDesign: { findFirst: vi.fn() },
+  inspection: { findFirst: vi.fn() },
+}
+vi.mock('@/lib/db', () => ({ db }))
+vi.mock('@/features/custom-fields/Lib/getCustomFieldsForPrint', () => ({
+  getCustomFieldsForPrint: async () => [],
+}))
+vi.mock('@/features/invoice-designer/Lib/designRules.server', () => ({
+  designRuleSubjectOf: () => ({}),
+  findRuleDesign: async () => null,
+}))
+vi.mock('@/features/signatures/Lib/memberSignature.server', () => ({
+  memberSignatureDataUri: async () => undefined,
+}))
+vi.mock('@/features/condition-map/Lib/loadMarks.server', () => ({
+  loadVisitConditionMap: async () => null,
+  loadMarkTypeRows: async () => [],
+}))
+// A stored photograph decodes to a picture named after its file; one whose
+// file has gone decodes to nothing, as the real loader leaves it out.
+const loadInspectionPhotos = vi.fn(async (items: { id: string; imageUrls: string[] }[]) => ({
+  photos: Object.fromEntries(
+    items
+      .map((i) => [
+        i.id,
+        i.imageUrls.filter((u) => !u.includes('gone')).map((u) => ({ dataUri: `data:${u}` })),
+      ])
+      .filter(([, photos]) => (photos as unknown[]).length > 0)
+  ),
+  omitted: 0,
+}))
+vi.mock('@/features/inspections/Lib/inspectionPhotos', () => ({
+  loadInspectionPhotos: (items: { id: string; imageUrls: string[] }[]) =>
+    loadInspectionPhotos(items),
+}))
+
+const { assembleInvoicePrint } = await import('@/features/invoices/Lib/assembleInvoicePrint')
+const { buildIssuedInvoiceData } = await import('@/features/invoices/Lib/issueInvoice')
+const { freezeInspectionResults, readIssuedInvoiceData, thawInspectionResults } = await import(
+  '@/features/invoices/Lib/issuedInvoice'
+)
+const { buildInvoicePrintSpec } = await import('@/features/invoice-designer/Pdf/buildInvoicePrint')
+const { DESIGNER_LAYOUT_VERSION, getDefaultLayout } = await import(
+  '@/features/settings/Schema/invoiceLayoutSchema'
+)
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/** Where two photographs of a defect are stored; the second file has since gone. */
+const HOSE = '/api/protected/files/org-1/services/hose.jpg'
+const GONE = '/api/protected/files/org-1/services/gone.jpg'
+
+/** The invoice design with the two result sections switched on or left off. */
+function design(on: boolean, switches: Record<string, boolean> = {}) {
+  const layout = getDefaultLayout('invoice')
+  return {
+    ...layout,
+    version: DESIGNER_LAYOUT_VERSION,
+    sections: layout.sections.map((s) =>
+      s.id === 'defects' || s.id === 'results_table'
+        ? {
+            ...s,
+            visible: on,
+            fields: s.fields?.map((f) => ({ ...f, visible: switches[f.id] ?? f.visible })),
+          }
+        : s
+    ),
+  }
+}
+
+const item = (id: string, name: string, condition: string, over: Record<string, unknown> = {}) => ({
+  id,
+  name,
+  section: 'Brakes',
+  sectionCode: '1',
+  code: null,
+  condition,
+  notes: null,
+  sortOrder: Number(id.slice(1)),
+  measuredValue: null,
+  unit: null,
+  textValue: null,
+  inputType: 'condition',
+  imageUrls: [] as string[],
+  ...over,
+})
+
+/** The inspection as the job's technician left it when the invoice went out. */
+const inspectionAtIssue = () => ({
+  severityScale: 'eu',
+  country: null,
+  template: { severityScale: 'eu', country: null },
+  items: [
+    item('i1', 'Brake pedal', 'pass', {
+      imageUrls: ['/api/protected/files/org-1/services/pedal.jpg'],
+    }),
+    item('i2', 'Brake hoses', 'fail', {
+      notes: 'Cracked at the front left',
+      imageUrls: [HOSE, GONE],
+    }),
+    item('i3', 'Brake fluid', 'not_inspected'),
+  ],
+})
+
+/** The same inspection after somebody went back to it. */
+const inspectionToday = () => ({
+  ...inspectionAtIssue(),
+  items: [
+    item('i1', 'Brake pedal', 'dangerous', { notes: 'Snapped off' }),
+    item('i2', 'Brake hoses (renamed)', 'pass'),
+    item('i9', 'Horn', 'fail'),
+  ],
+})
+
+const record = (over: Record<string, unknown> = {}) => ({
+  id: 'rec-1',
+  organizationId: 'org-1',
+  vehicleId: 'veh-1',
+  inspectionId: 'insp-1',
+  designId: null,
+  title: 'Brakes',
+  type: 'repair',
+  serviceDate: new Date('2026-09-24T09:00:00Z'),
+  invoiceNumber: '2026-0042',
+  subtotal: 100,
+  taxRate: 0,
+  taxAmount: 0,
+  totalAmount: 100,
+  cost: 100,
+  discountValue: 0,
+  manuallyPaid: false,
+  conditionMapOnInvoice: null,
+  techName: null,
+  issuedAt: null,
+  issuedData: null,
+  editUnlockedAt: null,
+  partItems: [],
+  laborItems: [{ description: 'Replace hoses', hours: 1, rate: 100, total: 100 }],
+  attachments: [],
+  payments: [],
+  technician: null,
+  customer: { name: 'Alex Carter', invoiceDesignId: null },
+  vehicle: {
+    customerId: 'cust-1',
+    make: 'Volvo',
+    model: 'V60',
+    year: 2020,
+    vin: null,
+    licensePlate: 'AB 12345',
+    hsn: null,
+    tsn: null,
+    mileage: 84120,
+    customer: null,
+  },
+  issuedDesignSnapshot: null,
+  issuedLogoSnapshot: null,
+  issuedSignatureSnapshot: null,
+  createdBy: { id: 'user-1', name: 'Kari' },
+  ...over,
+})
+
+/** The workshop's settings with this layout as its invoice design. */
+const settingsWith = (layout: unknown) => [
+  { key: 'invoice.layoutConfig', value: JSON.stringify(layout) },
+]
+
+const printed = (assembly: any) =>
+  (
+    buildInvoicePrintSpec({
+      data: assembly.data,
+      template: assembly.template,
+      inspectionResults: assembly.inspectionResults,
+    }) as any
+  ).blocks
+
+const blockText = (assembly: any, id: string) =>
+  JSON.stringify(printed(assembly).find((b: any) => b.id === id)?.content ?? null)
+
+/** Issues the invoice as it prints now, and returns the record that leaves behind. */
+async function issue(layout: unknown) {
+  db.serviceRecord.findUnique.mockResolvedValue(record())
+  db.appSetting.findMany.mockResolvedValue(settingsWith(layout))
+  db.inspection.findFirst.mockResolvedValue(inspectionAtIssue())
+  const live = await assembleInvoicePrint('rec-1', { mode: 'live', inspectionResults: 'rows' })
+  // Through JSON, as the column stores it.
+  const issuedData = JSON.parse(JSON.stringify(buildIssuedInvoiceData(live!)))
+  return record({
+    issuedAt: new Date('2026-09-25T10:00:00Z'),
+    issuedData,
+    issuedDesignSnapshot: { layout, template: {} },
+  })
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  db.organization.findUnique.mockResolvedValue({ name: 'Shop', portalSlug: null })
+  db.vehicleFinding.findMany.mockResolvedValue([])
+  db.documentDesign.findFirst.mockResolvedValue(null)
+  db.appSetting.findMany.mockResolvedValue([])
+})
+
+describe('a draft invoice', () => {
+  it('prints the linked inspection as it stands, scoped to the workshop and the vehicle', async () => {
+    db.serviceRecord.findUnique.mockResolvedValue(record())
+    db.appSetting.findMany.mockResolvedValue(settingsWith(design(true)))
+    db.inspection.findFirst.mockResolvedValue(inspectionAtIssue())
+
+    const assembly = await assembleInvoicePrint('rec-1')
+    expect(db.inspection.findFirst.mock.calls[0][0].where).toEqual({
+      id: 'insp-1',
+      organizationId: 'org-1',
+      vehicleId: 'veh-1',
+    })
+    expect(assembly?.inspectionResults?.items.map((i) => i.name)).toEqual([
+      'Brake pedal',
+      'Brake hoses',
+      'Brake fluid',
+    ])
+    expect(blockText(assembly, 'defects')).toContain('Brake hoses')
+    // The defect's photographs, and only the defect's; the lost file left out.
+    expect(assembly?.inspectionResults?.itemPhotos).toEqual({
+      i2: [{ dataUri: `data:${HOSE}` }],
+    })
+  })
+
+  it('does not read the inspection for a design that prints neither section', async () => {
+    db.serviceRecord.findUnique.mockResolvedValue(record())
+    db.appSetting.findMany.mockResolvedValue(settingsWith(design(false)))
+    const assembly = await assembleInvoicePrint('rec-1')
+    expect(db.inspection.findFirst).not.toHaveBeenCalled()
+    expect(assembly?.inspectionResults).toBeNull()
+    // Nor for a workshop that never opened the designer.
+    db.appSetting.findMany.mockResolvedValue([])
+    await assembleInvoicePrint('rec-1')
+    expect(db.inspection.findFirst).not.toHaveBeenCalled()
+  })
+
+  it('leaves the inspection to the work order when it prints from the same job', async () => {
+    db.serviceRecord.findUnique.mockResolvedValue(record())
+    db.appSetting.findMany.mockResolvedValue(settingsWith(design(true)))
+    const assembly = await assembleInvoicePrint('rec-1', {
+      mode: 'live',
+      inspectionResults: 'none',
+    })
+    expect(db.inspection.findFirst).not.toHaveBeenCalled()
+    expect(assembly?.inspectionResults).toBeNull()
+  })
+})
+
+describe('issuing an invoice', () => {
+  it('freezes every check, with where the defects photographs are and no picture', async () => {
+    const issued = await issue(design(true))
+    const frozen = (issued.issuedData as any).inspectionResults
+    expect(frozen.items.map((i: any) => [i.name, i.condition])).toEqual([
+      ['Brake pedal', 'pass'],
+      ['Brake hoses', 'fail'],
+      // Ungraded too: a design may print the whole checklist.
+      ['Brake fluid', 'not_inspected'],
+    ])
+    expect(frozen.items.map((i: any) => i.imageUrls)).toEqual([undefined, [HOSE, GONE], undefined])
+    // Issuing decodes no photograph, and none is written into the snapshot.
+    expect(loadInspectionPhotos).not.toHaveBeenCalled()
+    expect(JSON.stringify(issued.issuedData)).not.toContain('data:')
+  })
+
+  it('freezes nothing for a design that prints neither section', async () => {
+    const issued = await issue(design(false))
+    expect((issued.issuedData as any).inspectionResults).toBeNull()
+    expect(db.inspection.findFirst).not.toHaveBeenCalled()
+  })
+})
+
+describe('an issued invoice', () => {
+  it('prints the results as they were when issued, whatever the inspection says now', async () => {
+    const issued = await issue(design(true))
+    vi.clearAllMocks()
+    db.organization.findUnique.mockResolvedValue({ name: 'Shop', portalSlug: null })
+    db.appSetting.findMany.mockResolvedValue(settingsWith(design(true)))
+    db.serviceRecord.findUnique.mockResolvedValue(issued)
+    db.inspection.findFirst.mockResolvedValue(inspectionToday())
+
+    const assembly = await assembleInvoicePrint('rec-1')
+    expect(assembly?.issuedAt).toEqual(issued.issuedAt)
+    // Today's inspection is never asked.
+    expect(db.inspection.findFirst).not.toHaveBeenCalled()
+    expect(assembly?.inspectionResults?.items.map((i) => [i.name, i.condition])).toEqual([
+      ['Brake pedal', 'pass'],
+      ['Brake hoses', 'fail'],
+      ['Brake fluid', 'not_inspected'],
+    ])
+    const defects = blockText(assembly, 'defects')
+    expect(defects).toContain('Brake hoses')
+    expect(defects).toContain('Cracked at the front left')
+    expect(defects).not.toContain('Snapped off')
+    expect(defects).not.toContain('renamed')
+    expect(defects).not.toContain('Horn')
+    // The photographs are read again from where they were, the lost one skipped.
+    expect(defects).toContain(`data:${HOSE}`)
+    expect(defects).not.toContain('gone')
+    expect(loadInspectionPhotos.mock.calls[0][0].map((i) => i.id)).toEqual(['i2'])
+  })
+
+  it('reads a frozen photograph only from the uploads of this workshop', async () => {
+    // A snapshot is stored data: one that names another workshop's file, or
+    // a path that is no upload at all, gets no picture.
+    const issued = await issue(design(true))
+    const theirs = '/api/protected/files/org-2/services/secret.jpg'
+    const items = (issued.issuedData as any).inspectionResults.items
+    items[1].imageUrls = [theirs, '/etc/passwd', '../../org-2/services/secret.jpg', HOSE]
+    db.serviceRecord.findUnique.mockResolvedValue(issued)
+    loadInspectionPhotos.mockClear()
+    const defects = blockText(await assembleInvoicePrint('rec-1'), 'defects')
+    expect(loadInspectionPhotos.mock.calls[0][0][0].imageUrls).toEqual([HOSE])
+    expect(defects).toContain(`data:${HOSE}`)
+    expect(defects).not.toContain('org-2')
+  })
+
+  it('prints the frozen checklist, ungraded checks and all, for a design that lists them', async () => {
+    const layout = design(true, { ungraded_checks: true, passed_checks: true })
+    const issued = await issue(layout)
+    db.serviceRecord.findUnique.mockResolvedValue(issued)
+    db.inspection.findFirst.mockResolvedValue(inspectionToday())
+    const table = blockText(await assembleInvoicePrint('rec-1'), 'results_table')
+    expect(table).toContain('Brake pedal')
+    expect(table).toContain('Brake fluid')
+    expect(table).not.toContain('Horn')
+  })
+
+  it('prints none when it was issued without results, and never borrows the ones of today', async () => {
+    // Issued by a design that printed neither section, or before the invoice
+    // could print them at all; then given a design that does.
+    for (const issuedData of [
+      (await issue(design(false))).issuedData,
+      { version: 1, workshop: { name: 'Shop', address: '', phone: '', email: '' } },
+    ]) {
+      vi.clearAllMocks()
+      db.organization.findUnique.mockResolvedValue({ name: 'Shop', portalSlug: null })
+      db.appSetting.findMany.mockResolvedValue(settingsWith(design(true)))
+      db.inspection.findFirst.mockResolvedValue(inspectionToday())
+      db.serviceRecord.findUnique.mockResolvedValue(
+        record({
+          issuedAt: new Date('2026-09-25T10:00:00Z'),
+          issuedData,
+          issuedDesignSnapshot: { layout: design(true), template: {} },
+        })
+      )
+      const assembly = await assembleInvoicePrint('rec-1')
+      expect(assembly?.issuedAt).not.toBeNull()
+      expect(assembly?.inspectionResults).toBeNull()
+      expect(db.inspection.findFirst).not.toHaveBeenCalled()
+      const ids = printed(assembly).map((b: any) => b.id)
+      expect(ids).not.toContain('defects')
+      expect(ids).not.toContain('results_table')
+    }
+  })
+
+  it('prints live again while an owner has it reopened', async () => {
+    const issued = await issue(design(true))
+    db.serviceRecord.findUnique.mockResolvedValue({
+      ...issued,
+      editUnlockedAt: new Date('2026-09-26T10:00:00Z'),
+    })
+    db.inspection.findFirst.mockResolvedValue(inspectionToday())
+    const assembly = await assembleInvoicePrint('rec-1')
+    expect(assembly?.issuedAt).toBeNull()
+    expect(blockText(assembly, 'defects')).toContain('Snapped off')
+  })
+})
+
+describe('the issued data', () => {
+  const OLD = {
+    version: 1,
+    workshop: { name: 'Shop', address: 'Road 1', phone: '555', email: 's@x.no' },
+    invoiceSettings: { bankAccount: '1234', dueDays: 14 },
+    serviceType: 'automotive',
+    customer: { name: 'Alex Carter', address: 'Old street 1' },
+    vehicle: { make: 'Volvo', model: 'V60', year: 2020 },
+    technicianName: 'Kari',
+    findings: [{ description: 'Worn pads', severity: 'monitor' }],
+    customFields: [],
+    conditionMap: null,
+  }
+
+  it('still reads a snapshot written before the results were kept, word for word', () => {
+    const read = readIssuedInvoiceData(JSON.parse(JSON.stringify(OLD)))
+    expect(read).toEqual(OLD)
+    expect(read?.inspectionResults).toBeUndefined()
+    expect(thawInspectionResults(read?.inspectionResults)).toBeNull()
+  })
+
+  it('reads back what it froze', () => {
+    const results = {
+      severityScale: 'basic' as const,
+      country: 'NO',
+      items: inspectionAtIssue().items,
+    }
+    const stored = JSON.parse(
+      JSON.stringify({ ...OLD, inspectionResults: freezeInspectionResults(results) })
+    )
+    const thawed = thawInspectionResults(readIssuedInvoiceData(stored)?.inspectionResults)
+    expect(thawed?.severityScale).toBe('basic')
+    expect(thawed?.country).toBe('NO')
+    expect(thawed?.items.map((i) => [i.id, i.condition, i.notes, i.imageUrls])).toEqual([
+      ['i1', 'pass', null, []],
+      ['i2', 'fail', 'Cracked at the front left', [HOSE, GONE]],
+      ['i3', 'not_inspected', null, []],
+    ])
+    expect(freezeInspectionResults(null)).toBeNull()
+    expect(freezeInspectionResults({ severityScale: 'eu', country: null, items: [] })).toBeNull()
+  })
+
+  it('loses only the results, not the invoice, when the results cannot be read', () => {
+    // Anything else unreadable means the snapshot is not trusted and the
+    // invoice prints from live rows; a bad results block must not do that.
+    const read = readIssuedInvoiceData({ ...OLD, inspectionResults: { items: 'not a list' } })
+    expect(read).not.toBeNull()
+    expect(read?.customer?.address).toBe('Old street 1')
+    expect(read?.inspectionResults).toBeNull()
+  })
+})

+ 118 - 0
src/__tests__/features/invoices/issued-design-state.test.ts

@@ -0,0 +1,118 @@
+/**
+ * Which design an issued invoice says it was sent with.
+ *
+ * The Invoice design menu ticks the design whose look is the one frozen on
+ * the invoice. A section added to every design after the invoice was issued
+ * must not make an invoice nobody redesigned read as sent with a design the
+ * workshop no longer has.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { contentHash } from '@/features/invoice-designer/Lib/designHash'
+import {
+  designSourceFromStored,
+  materializeDesignSource,
+} from '@/features/invoice-designer/Lib/designSource'
+import { getDefaultLayout } from '@/features/settings/Schema/invoiceLayoutSchema'
+import { buildLayoutFromPreset, layoutPresets } from '@/features/settings/Schema/layoutPresets'
+
+const { serviceRecord, documentDesign, appSetting, currentLook } = vi.hoisted(() => ({
+  serviceRecord: { findFirst: vi.fn() },
+  documentDesign: { findMany: vi.fn() },
+  appSetting: { findMany: vi.fn() },
+  currentLook: vi.fn(),
+}))
+
+vi.mock('@/lib/db', () => ({ db: { serviceRecord, documentDesign, appSetting } }))
+vi.mock('@/features/invoice-designer/Lib/designSnapshots', () => ({
+  ensureAssetSnapshot: vi.fn(),
+  ensureDesignSnapshot: vi.fn(),
+}))
+vi.mock('@/features/invoices/Lib/assembleInvoicePrint', () => ({
+  currentLook: (...args: unknown[]) => currentLook(...args),
+  designLook: vi.fn(),
+}))
+
+const { issuedDesignState } = await import('@/features/invoices/Lib/reapplyDesign')
+
+const TEMPLATE = { primaryColor: '#111827', headerStyle: 'standard' }
+/** Sections that did not exist when the invoice was issued. */
+const ADDED_SINCE = new Set(['defects', 'results_table'])
+
+/**
+ * A design as an invoice froze it before those sections existed: read the way
+ * issuing reads it, every default written out, less what has been added since.
+ */
+function frozenBefore(layout: unknown) {
+  const source = designSourceFromStored(layout, TEMPLATE)
+  if (!source) throw new Error('unreadable design')
+  const frozen = materializeDesignSource(source)
+  const sections = [...(frozen.layout.sections ?? [])]
+    .sort((a, b) => a.order - b.order)
+    .filter((s) => !ADDED_SINCE.has(s.id))
+    .map((s, order) => ({ ...s, order }))
+  return { layout: { ...frozen.layout, sections }, template: frozen.template }
+}
+
+function issuedWith({ layout, template }: ReturnType<typeof frozenBefore>) {
+  serviceRecord.findFirst.mockResolvedValue({
+    id: 'rec-1',
+    designId: null,
+    vehicleId: 'veh-1',
+    customer: null,
+    vehicle: null,
+    issuedAt: new Date('2026-09-01T10:00:00Z'),
+    issuedDesignSnapshot: {
+      hash: contentHash({ layout, template }),
+      layout,
+      template,
+    },
+  })
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  appSetting.findMany.mockResolvedValue([])
+  documentDesign.findMany.mockResolvedValue([])
+  currentLook.mockResolvedValue({ designSource: null })
+})
+
+describe('an invoice issued before a section was added to every design', () => {
+  // A design row saved before the sections existed: a starting point as it
+  // was stored then, built-in list order and all.
+  const preset = buildLayoutFromPreset(layoutPresets[0])
+  const stored = { ...preset, sections: preset.sections.filter((s) => !ADDED_SINCE.has(s.id)) }
+
+  it('still follows the default it was sent with', async () => {
+    const layout = getDefaultLayout('invoice')
+    issuedWith(frozenBefore(layout))
+    currentLook.mockResolvedValue({ designSource: designSourceFromStored(layout, TEMPLATE) })
+
+    const state = await issuedDesignState('org-1', 'rec-1')
+    expect(state).toMatchObject({ followsDefault: true, unknown: false })
+  })
+
+  it('still matches the named design it was sent with', async () => {
+    issuedWith(frozenBefore(stored))
+    documentDesign.findMany.mockResolvedValue([
+      { id: 'design-1', name: 'Ours', layout: stored, template: TEMPLATE },
+    ])
+
+    const state = await issuedDesignState('org-1', 'rec-1')
+    expect(state).toMatchObject({ followsDefault: false, matchedDesignId: 'design-1' })
+  })
+
+  it('says so when the design really has been changed since', async () => {
+    issuedWith(frozenBefore(getDefaultLayout('invoice')))
+    documentDesign.findMany.mockResolvedValue([
+      {
+        id: 'design-1',
+        name: 'Ours',
+        layout: stored,
+        template: { ...TEMPLATE, primaryColor: '#ff0000' },
+      },
+    ])
+
+    const state = await issuedDesignState('org-1', 'rec-1')
+    expect(state).toMatchObject({ followsDefault: false, matchedDesignId: null, unknown: true })
+  })
+})

+ 6 - 1
src/__tests__/features/invoices/reissue-invoice.test.ts

@@ -84,7 +84,12 @@ it("captures today's rows again and dates the issue to now", async () => {
 
   expect(await reissueInvoice('rec-1', 'org-1')).toBe(true)
 
-  expect(assembleInvoicePrint).toHaveBeenCalledWith('rec-1', { mode: 'live' })
+  // Live rows, and the linked inspection's checks without their photographs:
+  // a snapshot keeps where the pictures are, not the pictures.
+  expect(assembleInvoicePrint).toHaveBeenCalledWith('rec-1', {
+    mode: 'live',
+    inspectionResults: 'rows',
+  })
   const data = serviceRecord.update.mock.calls[0]?.[0]?.data
   expect(data.issuedAt.getTime()).toBeGreaterThanOrEqual(before)
   expect(data.issuedDesignSnapshotId).toBe('design-snap')

+ 200 - 0
src/__tests__/features/quotes/add-quote-to-work-order.test.ts

@@ -33,6 +33,7 @@ import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
 import { db } from '@/lib/db'
 import {
   addQuoteToServiceRecord,
+  getQuoteConversionConflicts,
   getWorkOrdersForQuoteConversion,
 } from '@/features/quotes/Actions/quoteActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
@@ -64,6 +65,13 @@ const baseQuote = {
   inspectionId: null,
   tireSetId: null,
   discountAmount: 0,
+  taxRate: 25,
+  taxInclusive: false,
+  taxComponents: null,
+  warrantyStatus: null,
+  warrantyMonths: null,
+  warrantyMileage: null,
+  warrantyNotes: null,
   partItems: [
     {
       name: 'Brake pad',
@@ -98,9 +106,15 @@ const baseJob = {
   tireSetId: null,
   discountType: null,
   discountValue: 0,
+  subtotal: 250,
   taxRate: 25,
   taxInclusive: false,
   taxComponents: null,
+  warrantyStatus: null,
+  warrantyMonths: null,
+  warrantyMileage: null,
+  warrantyNotes: null,
+  serviceDate: new Date('2026-10-01T10:00:00Z'),
   sentAt: null,
   manuallyPaid: false,
   totalAmount: 312.5,
@@ -369,6 +383,192 @@ describe('addQuoteToServiceRecord', () => {
       data: { discountType: 'fixed', discountValue: 18 },
     })
   })
+
+  it("adds the quote's discount on top of a fixed one the job has", async () => {
+    const { recordUpdate } = setup({
+      quote: { discountAmount: 18 },
+      job: { discountType: 'fixed', discountValue: 10 },
+    })
+
+    await addQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID, JOB_ID)
+
+    expect(recordUpdate).toHaveBeenCalledWith({
+      where: { id: JOB_ID },
+      data: { discountType: 'fixed', discountValue: 28 },
+    })
+  })
+
+  it('writes the discount amount with the totals, so the stored amount follows the lines', async () => {
+    const { recordUpdate } = setup({ job: { discountType: 'fixed', discountValue: 1000 } })
+
+    await addQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID, JOB_ID)
+
+    // 1000 off a 430 subtotal is 430 off, not 1000.
+    expect(recordUpdate).toHaveBeenLastCalledWith({
+      where: { id: JOB_ID },
+      data: expect.objectContaining({ subtotal: 430, discountAmount: 430, totalAmount: 0 }),
+    })
+  })
+
+  it('asks for the right to change jobs and quotes, not the right to raise a job', async () => {
+    setup()
+    vi.mocked(getCachedMembership).mockResolvedValue({
+      organizationId: ORG,
+      role: 'member',
+      roleId: 'role-creator',
+      customRole: {
+        isAdmin: false,
+        permissions: [
+          { action: 'create', subject: 'services' },
+          { action: 'read', subject: 'services' },
+          { action: 'read', subject: 'quotes' },
+        ],
+      },
+    } as any)
+
+    const result = await addQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID, JOB_ID)
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/permission/i)
+    expect(db.$transaction).not.toHaveBeenCalled()
+  })
+})
+
+/**
+ * Where the quote and the job disagree on a standing answer, the dialog asks
+ * and the action does nothing until every question has one.
+ */
+describe('addQuoteToServiceRecord with conflicts', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    setupAuth()
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
+  })
+
+  const percentJob = { discountType: 'percentage', discountValue: 10 }
+
+  it('names them for the dialog without changing anything', async () => {
+    setup({
+      quote: { discountAmount: 18, taxRate: 0, warrantyStatus: 'included', warrantyMonths: 12 },
+      job: percentJob,
+    })
+
+    const result = await getQuoteConversionConflicts(QUOTE_ID, JOB_ID)
+
+    expect(result.success).toBe(true)
+    expect(result.data?.map((c) => c.kind)).toEqual(['discount', 'tax', 'warranty'])
+    expect(result.data?.[0]).toEqual({
+      kind: 'discount',
+      job: { percent: 10, amount: 25 },
+      quote: { amount: 18 },
+    })
+    expect(db.$transaction).not.toHaveBeenCalled()
+  })
+
+  it('refuses to add the quote while a conflict is unanswered', async () => {
+    const { quoteUpdateMany } = setup({ quote: { discountAmount: 18 }, job: percentJob })
+
+    const result = await addQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID, JOB_ID)
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/choose what the work order keeps/)
+    expect(quoteUpdateMany).not.toHaveBeenCalled()
+  })
+
+  it('refuses an answer it does not know', async () => {
+    setup({ quote: { discountAmount: 18 }, job: percentJob })
+
+    const result = await addQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID, JOB_ID, {
+      discount: 'halve',
+    })
+
+    expect(result.success).toBe(false)
+    expect(db.quote.findFirst).not.toHaveBeenCalled()
+  })
+
+  it("keeps the job's percentage and drops the quote's discount when told to", async () => {
+    const { recordUpdate } = setup({ quote: { discountAmount: 18 }, job: percentJob })
+
+    const result = await addQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID, JOB_ID, {
+      discount: 'keepJob',
+    })
+
+    expect(result.success).toBe(true)
+    const discountWrites = recordUpdate.mock.calls.filter(
+      ([args]: any[]) => 'discountType' in args.data
+    )
+    expect(discountWrites).toEqual([])
+  })
+
+  it("turns the job's percentage into its amount and adds the quote's when told to", async () => {
+    const { recordUpdate } = setup({ quote: { discountAmount: 18 }, job: percentJob })
+
+    await addQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID, JOB_ID, { discount: 'combine' })
+
+    // 10% of the 250 the job stood at, plus the quote's 18.
+    expect(recordUpdate).toHaveBeenCalledWith({
+      where: { id: JOB_ID },
+      data: { discountType: 'fixed', discountValue: 43 },
+    })
+  })
+
+  it("keeps the job's tax when told to, and the lines come across as quoted", async () => {
+    const { recordUpdate, parts } = setup({ quote: { taxRate: 0, taxInclusive: true } })
+
+    const result = await addQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID, JOB_ID, { tax: 'keepJob' })
+
+    expect(result.success).toBe(true)
+    expect(parts.map((p) => p.total)).toEqual([200, 100])
+    expect(recordUpdate.mock.calls.some(([args]: any[]) => 'taxRate' in args.data)).toBe(false)
+  })
+
+  it("switches the job to the quote's tax when told to, before re-totalling", async () => {
+    const { recordUpdate } = setup({ quote: { taxRate: 0, taxInclusive: true } })
+
+    await addQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID, JOB_ID, { tax: 'useQuote' })
+
+    const taxWrite = recordUpdate.mock.calls.findIndex(([args]: any[]) => 'taxRate' in args.data)
+    const totalsWrite = recordUpdate.mock.calls.findIndex(
+      ([args]: any[]) => 'totalAmount' in args.data
+    )
+    expect(taxWrite).toBeGreaterThanOrEqual(0)
+    expect(recordUpdate.mock.calls[taxWrite][0].data).toMatchObject({
+      taxRate: 0,
+      taxInclusive: true,
+    })
+    expect(totalsWrite).toBeGreaterThan(taxWrite)
+  })
+
+  it("takes the quote's warranty when told to, with its expiry from the job's date", async () => {
+    const { recordUpdate } = setup({
+      quote: { warrantyStatus: 'included', warrantyMonths: 12, warrantyNotes: 'Parts and labor' },
+    })
+
+    await addQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID, JOB_ID, { warranty: 'useQuote' })
+
+    const write = recordUpdate.mock.calls.find(([args]: any[]) => 'warrantyStatus' in args.data)
+    expect(write?.[0].data).toMatchObject({
+      warrantyStatus: 'included',
+      warrantyMonths: 12,
+      warrantyMileage: null,
+      warrantyNotes: 'Parts and labor',
+    })
+    expect(write?.[0].data.warrantyExpiresAt).toBeInstanceOf(Date)
+    expect(write?.[0].data.warrantyExpiresAt.getUTCFullYear()).toBe(2027)
+  })
+
+  it("keeps the job's warranty when told to", async () => {
+    const { recordUpdate } = setup({ quote: { warrantyStatus: 'not_included' } })
+
+    const result = await addQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID, JOB_ID, {
+      warranty: 'keepJob',
+    })
+
+    expect(result.success).toBe(true)
+    expect(recordUpdate.mock.calls.some(([args]: any[]) => 'warrantyStatus' in args.data)).toBe(
+      false
+    )
+  })
 })
 
 describe('getWorkOrdersForQuoteConversion', () => {

+ 168 - 0
src/__tests__/features/quotes/conversion-conflicts.test.ts

@@ -0,0 +1,168 @@
+/**
+ * Where a quote and the open work order it is being added to disagree.
+ *
+ * The lines come across either way. A percentage discount, a different tax
+ * basis and a warranty the customer accepted are standing answers the job
+ * cannot hold twice, so each is named for the dialog to ask about, and the
+ * action refuses to go ahead while one is unanswered.
+ */
+
+import { describe, it, expect } from 'vitest'
+import {
+  conversionConflicts,
+  unresolvedConflicts,
+  resolutionsSchema,
+} from '@/features/quotes/Lib/conversionConflicts'
+
+const quote = {
+  discountAmount: 0,
+  taxRate: 25,
+  taxInclusive: false,
+  taxComponents: null,
+  warrantyStatus: null,
+  warrantyMonths: null,
+  warrantyMileage: null,
+  warrantyNotes: null,
+}
+
+const job = {
+  subtotal: 400,
+  discountType: null as string | null,
+  discountValue: 0,
+  taxRate: 25,
+  taxInclusive: false,
+  taxComponents: null,
+  warrantyStatus: null,
+  warrantyMonths: null,
+  warrantyMileage: null,
+  warrantyNotes: null,
+}
+
+describe('conversionConflicts', () => {
+  it('finds none when the two agree', () => {
+    expect(conversionConflicts(quote, job)).toEqual([])
+  })
+
+  describe('discount', () => {
+    it("is a question only when the job's is a percentage and the quote has one", () => {
+      const pct = { ...job, discountType: 'percentage', discountValue: 10 }
+      expect(conversionConflicts({ ...quote, discountAmount: 20 }, pct)).toEqual([
+        { kind: 'discount', job: { percent: 10, amount: 40 }, quote: { amount: 20 } },
+      ])
+      // A fixed one, or none, takes the quote's amount on top without asking.
+      expect(
+        conversionConflicts(
+          { ...quote, discountAmount: 20 },
+          { ...job, discountType: 'fixed', discountValue: 5 }
+        )
+      ).toEqual([])
+      expect(conversionConflicts({ ...quote, discountAmount: 20 }, job)).toEqual([])
+      // A quote with no discount leaves a percentage job alone.
+      expect(conversionConflicts(quote, pct)).toEqual([])
+    })
+  })
+
+  describe('tax', () => {
+    it('is a question when the rate differs', () => {
+      expect(conversionConflicts({ ...quote, taxRate: 0 }, job)).toEqual([
+        {
+          kind: 'tax',
+          job: { rate: 25, inclusive: false, components: null },
+          quote: { rate: 0, inclusive: false, components: null },
+        },
+      ])
+    })
+
+    it('is a question when one includes tax in the lines and the other adds it', () => {
+      const [conflict] = conversionConflicts({ ...quote, taxInclusive: true }, job)
+      expect(conflict).toMatchObject({ kind: 'tax', quote: { inclusive: true } })
+    })
+
+    it('is a question when the split differs, and not when it is the same split', () => {
+      const split = [
+        { name: 'GST', rate: 5, amount: 0 },
+        { name: 'QST', rate: 9.975, amount: 0 },
+      ]
+      const splitJob = { ...job, taxRate: 14.975, taxComponents: split }
+      expect(
+        conversionConflicts({ ...quote, taxRate: 14.975, taxComponents: split }, splitJob)
+      ).toEqual([])
+      const [conflict] = conversionConflicts({ ...quote, taxRate: 14.975 }, splitJob)
+      expect(conflict).toMatchObject({
+        kind: 'tax',
+        job: {
+          components: [
+            { name: 'GST', rate: 5 },
+            { name: 'QST', rate: 9.975 },
+          ],
+        },
+        quote: { components: null },
+      })
+    })
+  })
+
+  describe('warranty', () => {
+    it('is a question when the quote states one the job does not carry', () => {
+      const offered = { ...quote, warrantyStatus: 'included', warrantyMonths: 12 }
+      expect(conversionConflicts(offered, job)).toEqual([
+        {
+          kind: 'warranty',
+          job: {
+            warrantyStatus: null,
+            warrantyMonths: null,
+            warrantyMileage: null,
+            warrantyNotes: null,
+          },
+          quote: {
+            warrantyStatus: 'included',
+            warrantyMonths: 12,
+            warrantyMileage: null,
+            warrantyNotes: null,
+          },
+        },
+      ])
+      // "Not included" is a statement too, and differs from saying nothing.
+      expect(conversionConflicts({ ...quote, warrantyStatus: 'not_included' }, job)).toHaveLength(1)
+    })
+
+    it('is no question when the quote says nothing, or says the same', () => {
+      const withWarranty = { ...job, warrantyStatus: 'included', warrantyMonths: 12 }
+      expect(conversionConflicts(quote, withWarranty)).toEqual([])
+      expect(
+        conversionConflicts(
+          { ...quote, warrantyStatus: 'included', warrantyMonths: 12 },
+          withWarranty
+        )
+      ).toEqual([])
+    })
+  })
+
+  it('lists every disagreement, discount first', () => {
+    const kinds = conversionConflicts(
+      { ...quote, discountAmount: 20, taxRate: 0, warrantyStatus: 'not_included' },
+      { ...job, discountType: 'percentage', discountValue: 10 }
+    ).map((c) => c.kind)
+    expect(kinds).toEqual(['discount', 'tax', 'warranty'])
+  })
+})
+
+describe('unresolvedConflicts', () => {
+  it('names the conflicts the answers leave out', () => {
+    const conflicts = conversionConflicts(
+      { ...quote, discountAmount: 20, taxRate: 0 },
+      { ...job, discountType: 'percentage', discountValue: 10 }
+    )
+    expect(unresolvedConflicts(conflicts, undefined)).toEqual(['discount', 'tax'])
+    expect(unresolvedConflicts(conflicts, { discount: 'combine' })).toEqual(['tax'])
+    expect(unresolvedConflicts(conflicts, { discount: 'keepJob', tax: 'useQuote' })).toEqual([])
+  })
+})
+
+describe('resolutionsSchema', () => {
+  it('accepts the known answers and refuses made-up ones', () => {
+    expect(resolutionsSchema.safeParse({}).success).toBe(true)
+    expect(resolutionsSchema.safeParse({ discount: 'combine', tax: 'keepJob' }).success).toBe(true)
+    expect(resolutionsSchema.safeParse({ discount: 'drop' }).success).toBe(false)
+    expect(resolutionsSchema.safeParse({ title: 'useQuote' }).success).toBe(false)
+  })
+})

+ 49 - 2
src/__tests__/features/quotes/convert-quote-inventory.test.ts

@@ -85,6 +85,7 @@ function setupConversion(partItems: any[]) {
   vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null as any)
 
   const servicePartCreateMany = vi.fn().mockResolvedValue({ count: partItems.length })
+  const quoteUpdateMany = vi.fn().mockResolvedValue({ count: 1 })
   const stockMovementCreateMany = vi.fn().mockResolvedValue({ count: 1 })
   const queryRaw = vi.fn().mockResolvedValue([{ quantity: 7 }])
 
@@ -101,13 +102,13 @@ function setupConversion(partItems: any[]) {
       servicePart: { createMany: servicePartCreateMany },
       serviceLabor: { createMany: vi.fn() },
       serviceAttachment: { createMany: vi.fn() },
-      quote: { update: vi.fn() },
+      quote: { updateMany: quoteUpdateMany },
       stockMovement: { createMany: stockMovementCreateMany },
       $queryRaw: queryRaw,
     })
   )
 
-  return { servicePartCreateMany, stockMovementCreateMany, queryRaw }
+  return { servicePartCreateMany, stockMovementCreateMany, queryRaw, quoteUpdateMany }
 }
 
 describe('convertQuoteToServiceRecord — inventory', () => {
@@ -221,3 +222,49 @@ describe('convertQuoteToServiceRecord — inventory', () => {
     expect(queryRaw).not.toHaveBeenCalled()
   })
 })
+
+describe('convertQuoteToServiceRecord — once only', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    setupAuth()
+  })
+
+  it('refuses a quote that is already converted', async () => {
+    const { servicePartCreateMany } = setupConversion([])
+    vi.mocked(db.quote.findFirst).mockResolvedValue({ id: QUOTE_ID, status: 'converted' } as any)
+
+    const result = await convertQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID)
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/already converted/)
+    expect(servicePartCreateMany).not.toHaveBeenCalled()
+  })
+
+  it('claims the quote inside the transaction, so a second click raises no second job', async () => {
+    const { quoteUpdateMany, stockMovementCreateMany } = setupConversion([
+      {
+        name: 'Pad',
+        quantity: 1,
+        unitPrice: 10,
+        total: 10,
+        excluded: false,
+        inventoryPartId: 'inv-1',
+      },
+    ])
+    // Somebody else converted it between the read and the claim.
+    quoteUpdateMany.mockResolvedValue({ count: 0 })
+
+    const result = await convertQuoteToServiceRecord(QUOTE_ID, VEHICLE_ID)
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/already converted/)
+    expect(quoteUpdateMany).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: expect.objectContaining({ id: QUOTE_ID, status: { not: 'converted' } }),
+      })
+    )
+    // The transaction threw after the lines were written, so the whole of it
+    // rolls back; the stock movement must not be left standing on its own.
+    expect(stockMovementCreateMany).toHaveBeenCalledTimes(1)
+  })
+})

+ 6 - 0
src/__tests__/features/settings/layout-editor-labels.test.ts

@@ -20,6 +20,8 @@ import {
   BUILTIN_DOCUMENT_TITLE_FIELDS,
   BUILTIN_JOB_DETAILS_FIELDS,
   BUILTIN_SIGNATURE_FIELDS,
+  BUILTIN_DEFECTS_FIELDS,
+  BUILTIN_RESULTS_TABLE_FIELDS,
   WORK_ORDER_SECTIONS,
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 
@@ -35,6 +37,10 @@ const FIELD_IDS = [
   ...BUILTIN_DOCUMENT_TITLE_FIELDS,
   ...BUILTIN_JOB_DETAILS_FIELDS,
   ...BUILTIN_SIGNATURE_FIELDS,
+  // The inspection's two sections, which an invoice, a quote and a work
+  // order can print as well as the certificate.
+  ...BUILTIN_DEFECTS_FIELDS,
+  ...BUILTIN_RESULTS_TABLE_FIELDS,
 ].map((f) => f.id)
 
 const SECTION_IDS = [...new Set([...BUILTIN_SECTIONS, ...WORK_ORDER_SECTIONS].map((s) => s.id))]

+ 32 - 0
src/__tests__/features/settings/preset-no-defects-note.test.ts

@@ -0,0 +1,32 @@
+/**
+ * A clean inspection says so on its certificate.
+ *
+ * "No deficiencies were recorded." printed on every clean certificate before
+ * it had a switch. A starting point that lists the defects section's fields
+ * switches off the ones it leaves out, so each has to name the note or the
+ * default certificate stops printing it.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+  buildLayoutFromPreset,
+  certificatePresets,
+  layoutPresets,
+  workOrderPresets,
+} from '@/features/settings/Schema/layoutPresets'
+
+describe('the no-defects note on the starting points', () => {
+  const withDefects = [...layoutPresets, ...certificatePresets, ...workOrderPresets]
+    .map((preset) => ({ preset, layout: buildLayoutFromPreset(preset) }))
+    .filter(({ layout }) => layout.sections.some((s) => s.id === 'defects' && s.fields))
+
+  it('covers the certificate starting points', () => {
+    expect(withDefects.some(({ preset }) => preset.documentType === 'certificate')).toBe(true)
+  })
+
+  it.each(
+    withDefects.map(({ preset, layout }) => [preset.id, layout] as const)
+  )('%s keeps the note on', (_id, layout) => {
+    const fields = layout.sections.find((s) => s.id === 'defects')?.fields ?? []
+    expect(fields.find((f) => f.id === 'no_defects_note')?.visible).toBe(true)
+  })
+})

+ 58 - 1
src/__tests__/lib/backup-file-urls.test.ts

@@ -14,7 +14,12 @@
 import fs from 'node:fs'
 import path from 'node:path'
 import { describe, expect, it } from 'vitest'
-import { rewriteFileUrl, rewriteFileUrlsWithin, withFileUrls } from '@/lib/backup/file-urls'
+import {
+  rewriteFileUrl,
+  rewriteFileUrlsWithin,
+  withFileUrls,
+  withIssuedDataFileUrls,
+} from '@/lib/backup/file-urls'
 import { FILE_REFERENCES } from '@/lib/files/references'
 
 const OLD = 'orgFromTheBackup'
@@ -138,6 +143,58 @@ describe('rewriteFileUrlsWithin', () => {
  * (the file manager keeps a file while any of them mentions it). Each one the
  * import route restores has to rewrite it.
  */
+describe('withIssuedDataFileUrls', () => {
+  // An issued invoice keeps where the photographs of the linked inspection's
+  // defects are stored. Left naming the backup's workshop they would stop
+  // printing, since a snapshot only ever reads this workshop's own uploads.
+  const issued = () => ({
+    version: 1,
+    workshop: { name: 'Shop', address: '/uploads/are/not/an/address' },
+    customer: { name: 'Alex', address: 'Old street 1' },
+    inspectionResults: {
+      severityScale: 'eu',
+      items: [
+        { id: 'i1', name: 'Brake pedal', condition: 'pass', notes: '/api/files/a/note' },
+        {
+          id: 'i2',
+          name: 'Brake hoses',
+          condition: 'fail',
+          imageUrls: [stored(OLD, 'services', 'hose.jpg'), `/api/files/${OLD}/services/b.jpg`],
+        },
+      ],
+    },
+  })
+
+  it('points the frozen photographs at this workshop and leaves every word alone', () => {
+    const restored = withIssuedDataFileUrls(issued(), NEW) as ReturnType<typeof issued>
+    expect(restored.inspectionResults.items[1].imageUrls).toEqual([
+      stored(NEW, 'services', 'hose.jpg'),
+      stored(NEW, 'services', 'b.jpg'),
+    ])
+    expect({ ...restored, inspectionResults: null }).toEqual({
+      ...issued(),
+      inspectionResults: null,
+    })
+    expect(restored.inspectionResults.items[0]).toEqual(issued().inspectionResults.items[0])
+  })
+
+  it('hands back a snapshot without results exactly as it was', () => {
+    const old = { version: 1, workshop: { name: 'Shop' }, conditionMap: null }
+    expect(withIssuedDataFileUrls(old, NEW)).toBe(old)
+    expect(withIssuedDataFileUrls(null, NEW)).toBeNull()
+    const none = { version: 1, inspectionResults: null }
+    expect(withIssuedDataFileUrls(none, NEW)).toBe(none)
+  })
+
+  it('is what the import route restores issued data through', () => {
+    const route = fs.readFileSync(
+      path.join(process.cwd(), 'src/app/api/protected/backup/import/route.ts'),
+      'utf8'
+    )
+    expect(route).toMatch(/issuedData:[\s\S]{0,200}?withIssuedDataFileUrls\(sr\.issuedData/)
+  })
+})
+
 describe('the import route rewrites every restored file column', () => {
   const source = fs.readFileSync(
     path.join(process.cwd(), 'src/app/api/protected/backup/import/route.ts'),

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

@@ -40,7 +40,7 @@ const NOT_FILES: Record<string, string> = {
   'RecurringInvoice.taxComponents': 'tax lines',
   'ServiceRecord.taxComponents': 'tax lines',
   'ServiceRecord.issuedData':
-    'frozen issuer and customer details; the issued logo is bytes in DocumentAssetSnapshot',
+    'frozen issuer and customer details; the issued logo is bytes in DocumentAssetSnapshot. The linked inspection results in it remember where a defect photo was, as a note and not a use: the photo belongs to the inspection, and a print leaves out one that has gone',
   'ServiceRecord.issuedLogoSnapshotId': 'the id of a row holding the logo bytes',
   'ServiceAttachment.fileName': 'a label',
   'ServiceAttachment.fileType': 'a MIME type',

+ 1 - 0
src/app/(public)/share/invoice/[orgId]/[token]/page.tsx

@@ -141,6 +141,7 @@ export default async function PublicInvoicePage({
     telegramLabel: labels?.telegramConnect,
     labels,
     conditionMap,
+    inspectionResults: assembly.inspectionResults,
   })
 
   const termsOfSaleUrl =

+ 5 - 1
src/app/(public)/share/quote/[orgId]/[token]/page.tsx

@@ -10,6 +10,7 @@ import { buildQuotePrintSpec } from '@/features/invoice-designer/Pdf/buildQuoteP
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { quoteConditionMap } from '@/features/condition-map/Lib/loadMarks.server'
 import { linkedCertificateInspectionId } from '@/features/inspections/Lib/linkedCertificate.server'
+import { inspectionResultsFor } from '@/features/inspections/Lib/linkedInspectionResults.server'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { headers } from 'next/headers'
@@ -209,10 +210,12 @@ export default async function PublicQuotePage({
   const pick = (key: string) => settingsMap[`quote.${key}`] || settingsMap[`invoice.${key}`]
   const acceptLanguage = (await headers()).get('accept-language')
   const locale = await resolveCustomerLocale(orgId, acceptLanguage)
-  const [labels, conditionMap, certificateInspection] = await Promise.all([
+  const [labels, conditionMap, certificateInspection, inspectionResults] = await Promise.all([
     loadPrintLabels(locale, settingsMap, 'quote'),
     quoteConditionMap(orgId, quote, locale),
     linkedCertificateInspectionId(orgId, quote.inspectionId),
+    // The same results the PDF prints, read the same way.
+    inspectionResultsFor(orgId, quote, layoutConfig),
   ])
 
   const torqvoiceLogoDataUri = features.brandingRemoved
@@ -251,6 +254,7 @@ export default async function PublicQuotePage({
     labels,
     layoutConfig,
     conditionMap,
+    inspectionResults,
   })
 
   const appUrl = getAppBaseUrl()

+ 7 - 2
src/app/api/protected/backup/import/route.ts

@@ -6,7 +6,12 @@ import { getAuthContext } from '@/lib/get-auth-context'
 import { db, type TxClient } from '@/lib/db'
 import { isDemoMode } from '@/lib/demo'
 import { clearPlanFor, UPLOAD_CATEGORIES } from '@/lib/backup/manifest'
-import { rewriteFileUrl, rewriteFileUrlsWithin, withFileUrls } from '@/lib/backup/file-urls'
+import {
+  rewriteFileUrl,
+  rewriteFileUrlsWithin,
+  withFileUrls,
+  withIssuedDataFileUrls,
+} from '@/lib/backup/file-urls'
 import { columnsOf } from '@/lib/backup/rows'
 import { toSafeDate } from '@/lib/invoice-utils'
 import { taxComponentsForCopy } from '@/features/settings/Lib/workshopTax'
@@ -182,7 +187,7 @@ async function importServiceRecordTree(
       issuedSignatureSnapshotId: keptReference(sr.issuedSignatureSnapshotId, opts.assetSnapshotIds),
       issuedData:
         sr.issuedData && typeof sr.issuedData === 'object'
-          ? (sr.issuedData as Prisma.InputJsonValue)
+          ? (withIssuedDataFileUrls(sr.issuedData, opts.organizationId) as Prisma.InputJsonValue)
           : undefined,
     },
   })

+ 32 - 27
src/features/condition-map/Actions/conditionMarkActions.ts

@@ -288,8 +288,8 @@ const carrySchema = z
  * the same dent twice and the next visit meets it once.
  *
  * The earlier mark is kept rather than moved, because it is the earlier
- * visit's record: a completed inspection prints its marks as they stood when
- * it was completed, and an issued invoice from its own copy. The new mark
+ * visit's record: it points at the newer mark, so its own sheet goes on
+ * showing it while every other sheet meets the newer one. The new mark
  * keeps the old one's photos by pointing at the same files; the file manager
  * counts both rows, so removing a photo from one leaves the other's in place.
  *
@@ -323,31 +323,36 @@ export async function carryConditionMarks(input: unknown) {
           })
           if (claimed.count === 0) continue
           carried.push(mark)
-          created.push(
-            await tx.conditionMark.create({
-              data: {
-                organizationId,
-                vehicleId: mark.vehicleId,
-                inspectionId: sheet.inspectionId ?? null,
-                inspectionItemId: sheet.inspectionItemId ?? null,
-                serviceRecordId: sheet.serviceRecordId ?? null,
-                bodyType: mark.bodyType,
-                view: mark.view,
-                panel: mark.panel,
-                x: mark.x,
-                y: mark.y,
-                kind: mark.kind,
-                severity: mark.severity,
-                note: mark.note,
-                imageUrls: mark.imageUrls,
-                // Marks are numbered by when they were recorded, so each gets
-                // a moment of its own and they keep the order they had.
-                recordedAt: new Date(now.getTime() + index),
-                recordedById: userId,
-              },
-              select: MARK_SELECT,
-            })
-          )
+          const next = await tx.conditionMark.create({
+            data: {
+              organizationId,
+              vehicleId: mark.vehicleId,
+              inspectionId: sheet.inspectionId ?? null,
+              inspectionItemId: sheet.inspectionItemId ?? null,
+              serviceRecordId: sheet.serviceRecordId ?? null,
+              bodyType: mark.bodyType,
+              view: mark.view,
+              panel: mark.panel,
+              x: mark.x,
+              y: mark.y,
+              kind: mark.kind,
+              severity: mark.severity,
+              note: mark.note,
+              imageUrls: mark.imageUrls,
+              // Marks are numbered by when they were recorded, so each gets
+              // a moment of its own and they keep the order they had.
+              recordedAt: new Date(now.getTime() + index),
+              recordedById: userId,
+            },
+            select: MARK_SELECT,
+          })
+          // Closed because it was superseded, not repaired: the earlier
+          // sheet still prints it as its own record.
+          await tx.conditionMark.update({
+            where: { id: mark.id },
+            data: { carriedToId: next.id },
+          })
+          created.push(next)
         }
         return created
       })

+ 25 - 17
src/features/condition-map/Components/ConditionMapCard.tsx

@@ -91,6 +91,12 @@ export function ConditionMapCard({
   const t = useTranslations('conditionMap')
   const router = useRouter()
   const [marks, setMarks] = useState<ConditionMarkData[]>(initialMarks)
+  // What the server says replaces what a tab remembers whenever the page is
+  // re-read: another tab or another person may have changed the vehicle's
+  // marks since this one was opened.
+  useEffect(() => {
+    setMarks(initialMarks)
+  }, [initialMarks])
   const [body, setBody] = useState<BodyType>(bodyTypeFor(vehicle, { serviceType }))
   const [focusView, setFocusView] = useState<View | null>(null)
   const [editingId, setEditingId] = useState<string | null>(null)
@@ -323,19 +329,13 @@ export function ConditionMapCard({
         toast.error(result.success ? t('saveFailed') : result.error || t('saveFailed'))
         return
       }
-      // What was saved is the answer: a mark somebody else confirmed or
-      // cleared in the meantime was not carried, and stays as it was here.
+      // What was saved is the answer. A mark not carried was taken by
+      // somebody else a moment ago, confirmed on another sheet or cleared, so
+      // this tab's picture of the vehicle is stale: it stays closed here and
+      // the page is re-read rather than guessed at.
       const { marks: saved, carried } = result.data
-      const closed = new Set(carried)
-      setMarks((prev) => [
-        ...prev
-          .filter((m) => !m.id.startsWith(STAND_IN))
-          .map((m) => {
-            if (!earlier.has(m.id)) return m
-            return closed.has(m.id) ? m : (before.find((b) => b.id === m.id) ?? m)
-          }),
-        ...saved,
-      ])
+      setMarks((prev) => [...prev.filter((m) => !m.id.startsWith(STAND_IN)), ...saved])
+      if (carried.length !== earlier.size) router.refresh()
     })
   }
 
@@ -570,14 +570,22 @@ export function ConditionMapCard({
         open={editing !== null}
         readOnly={
           readOnly ||
-          (editing ? !isDrawnOnSheet(editing, scope) || editing.id.startsWith(STAND_IN) : false)
+          (editing
+            ? !isDrawnOnSheet(editing, scope) ||
+              editing.id.startsWith(STAND_IN) ||
+              Boolean(editing.carriedToId)
+            : false)
         }
         readOnlyReason={
-          readOnly || !editing || isDrawnOnSheet(editing, scope)
+          readOnly || !editing
             ? undefined
-            : isOwnMark(editing, scope)
-              ? t('inspectionItemId' in scope ? 'readOnlyOtherCheck' : 'readOnlyLinked')
-              : t('readOnlyEarlier')
+            : editing.carriedToId
+              ? t('readOnlyCarried')
+              : isDrawnOnSheet(editing, scope)
+                ? undefined
+                : isOwnMark(editing, scope)
+                  ? t('inspectionItemId' in scope ? 'readOnlyOtherCheck' : 'readOnlyLinked')
+                  : t('readOnlyEarlier')
         }
         busy={pending}
         onChange={handleChange}

+ 1 - 0
src/features/condition-map/Lib/loadMarks.server.ts

@@ -29,6 +29,7 @@ export const MARK_SELECT = {
   imageUrls: true,
   recordedAt: true,
   resolvedAt: true,
+  carriedToId: true,
 } as const
 
 /** Every mark ever drawn on the vehicle, oldest first; resolved ones included as history. */

+ 10 - 1
src/features/condition-map/Lib/marks.ts

@@ -59,6 +59,12 @@ export interface ConditionMarkData {
   imageUrls: string[]
   recordedAt: Date | string
   resolvedAt: Date | string | null
+  /**
+   * The newer mark a later visit recorded in its place when it confirmed the
+   * damage still there. Set with resolvedAt: closed because it was
+   * superseded, not repaired, so its own sheet still shows it.
+   */
+  carriedToId?: 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
@@ -153,7 +159,10 @@ export function splitMarks(
   marks: ConditionMarkData[],
   scope: MarkScope
 ): { own: ConditionMarkData[]; previous: ConditionMarkData[] } {
-  const open = marks.filter((m) => !m.resolvedAt)
+  // A mark closed only because a later visit recorded it again is still this
+  // visit's record of the car: its own sheet keeps it, every other sheet
+  // meets the newer one instead.
+  const open = marks.filter((m) => !m.resolvedAt || (m.carriedToId && isOwnMark(m, scope)))
   return {
     own: open.filter((m) => isOwnMark(m, scope)),
     previous: open.filter((m) => !isOwnMark(m, scope) && !isLaterVisit(m, scope)),

+ 4 - 3
src/features/condition-map/Lib/print.ts

@@ -137,10 +137,11 @@ export function conditionMapForPrint(input: ConditionMapPrintInput): ConditionMa
   const body: BodyType = isBodyType(input.bodyType) ? input.bodyType : 'sedan'
   // A mark drawn on another body type is still on the car: it is listed,
   // though it has no place on this drawing. The map on screen says so.
-  const open = input.marks.filter((m) => !m.resolvedAt)
+  // What is still open is the scope's to say: a mark a later visit carried
+  // forward is closed on the car but still this sheet's own record.
   const { own, previous } = input.scope
-    ? splitMarks(open, input.scope)
-    : { own: open, previous: [] as ConditionMarkData[] }
+    ? splitMarks(input.marks, input.scope)
+    : { own: input.marks.filter((m) => !m.resolvedAt), previous: [] as ConditionMarkData[] }
   const recorded = numberedMarks([...(input.includePrevious ? previous : []), ...own])
   const nothing = (input.requireOwn && own.length === 0) || recorded.length === 0
   if (nothing && !input.blank) return null

+ 64 - 0
src/features/inspections/Lib/inspectionResults.ts

@@ -0,0 +1,64 @@
+import type { InvoiceLayoutConfig } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { CertificateData } from '@/features/invoice-designer/Spec/certificateData'
+import { type CertificatePrintItem, certificateDataFor } from '../Pdf/buildCertificatePrint'
+
+/**
+ * An inspection's checks as another document prints them: the work order and
+ * the invoice of the job the inspection is linked to, and the quote raised
+ * from it. They borrow the certificate's Defects and All Results sections, so
+ * this is the inspection reduced to what those two read.
+ */
+
+export interface InspectionResultItem extends CertificatePrintItem {
+  /** The check's photographs as they are stored; a print embeds them itself. */
+  imageUrls?: string[]
+}
+
+export interface InspectionResults {
+  severityScale: 'eu' | 'basic'
+  country: string | null
+  /** Every check on the inspection, graded or not. */
+  items: InspectionResultItem[]
+  /** Photos on each check, keyed by check id, already sized for the page. */
+  itemPhotos?: Record<string, { dataUri: string }[]>
+}
+
+/**
+ * Whether a layout prints either result section, and whether the defects
+ * carry their photographs. Asked before the inspection is read, so a design
+ * that shows neither costs no query, and one without photographs no decoding.
+ */
+export function inspectionResultsWanted(layout: Pick<InvoiceLayoutConfig, 'sections'>): {
+  wanted: boolean
+  photos: boolean
+} {
+  const shown = (id: string) => layout.sections.find((s) => s.id === id && s.visible)
+  const defects = shown('defects')
+  return {
+    wanted: Boolean(defects || shown('results_table')),
+    photos: defects?.fields?.find((f) => f.id === 'defect_photos')?.visible === true,
+  }
+}
+
+/**
+ * The results worded for the certificate's blocks, in the document's own
+ * labels, or nothing for a layout that prints neither section and for a
+ * document with no inspection behind it. What each section then makes of an
+ * inspection with no defects, or with nothing graded yet, is the section's
+ * own business.
+ */
+export function inspectionResultsForPrint(
+  layout: Pick<InvoiceLayoutConfig, 'sections'>,
+  results: InspectionResults | null | undefined,
+  labels: Record<string, string>
+): CertificateData | undefined {
+  if (!results || results.items.length === 0) return undefined
+  if (!inspectionResultsWanted(layout).wanted) return undefined
+  return certificateDataFor({
+    items: results.items,
+    scale: results.severityScale,
+    country: results.country,
+    labels,
+    itemPhotos: results.itemPhotos,
+  })
+}

+ 110 - 0
src/features/inspections/Lib/linkedInspectionResults.server.ts

@@ -0,0 +1,110 @@
+import 'server-only'
+import { db } from '@/lib/db'
+import type { InvoiceLayoutConfig } from '@/features/settings/Schema/invoiceLayoutSchema'
+import { isDefect } from './conditions'
+import { loadInspectionPhotos } from './inspectionPhotos'
+import {
+  type InspectionResultItem,
+  type InspectionResults,
+  inspectionResultsWanted,
+} from './inspectionResults'
+
+/** The inspection linked to a job, as the work order's result sections read it. */
+export type LinkedInspectionResults = InspectionResults
+
+/**
+ * The checks of the inspection linked to a job, for the work order's sheet.
+ *
+ * Unlike the certificate that goes with an invoice, this does not wait for the
+ * inspection to be finished: a work order is the job as it stands, so it says
+ * what has been graded so far. The inspection has to be the workshop's own
+ * and on the job's own vehicle; anything else is no inspection at all.
+ */
+export async function loadLinkedInspectionResults(
+  organizationId: string,
+  vehicleId: string | null | undefined,
+  inspectionId: string | null | undefined,
+  options: { photos: boolean }
+): Promise<LinkedInspectionResults | null> {
+  if (!inspectionId || !vehicleId) return null
+  const inspection = await db.inspection.findFirst({
+    where: { id: inspectionId, organizationId, vehicleId },
+    select: {
+      severityScale: true,
+      country: true,
+      template: { select: { severityScale: true, country: true } },
+      items: {
+        orderBy: { sortOrder: 'asc' },
+        select: {
+          id: true,
+          name: true,
+          section: true,
+          sectionCode: true,
+          code: true,
+          condition: true,
+          notes: true,
+          sortOrder: true,
+          measuredValue: true,
+          unit: true,
+          textValue: true,
+          inputType: true,
+          imageUrls: true,
+        },
+      },
+    },
+  })
+  if (!inspection || inspection.items.length === 0) return null
+
+  return {
+    severityScale:
+      (inspection.severityScale ?? inspection.template.severityScale) === 'basic' ? 'basic' : 'eu',
+    country: inspection.country ?? inspection.template.country ?? null,
+    items: inspection.items,
+    itemPhotos: options.photos ? await loadDefectPhotos(inspection.items) : undefined,
+  }
+}
+
+/**
+ * The photographs of the checks that were not OK, sized for the page. Only
+ * those: the defects are the one place a borrowed result section draws a
+ * photo. A file that has gone is left out, and a failure costs the photos
+ * rather than the document they would have been on.
+ */
+export async function loadDefectPhotos(
+  items: InspectionResultItem[]
+): Promise<InspectionResults['itemPhotos']> {
+  const defects = items
+    .filter((item) => isDefect(item.condition) && (item.imageUrls?.length ?? 0) > 0)
+    .map((item) => ({
+      id: item.id,
+      condition: item.condition,
+      sortOrder: item.sortOrder,
+      imageUrls: item.imageUrls ?? [],
+    }))
+  if (defects.length === 0) return undefined
+  try {
+    return (await loadInspectionPhotos(defects)).photos
+  } catch (error) {
+    console.error('[Inspection results] Photo embedding failed, printing without photos:', error)
+    return undefined
+  }
+}
+
+/**
+ * The inspection behind a document, read only for a layout that prints its
+ * results: the one linked to a job for a work order or a draft invoice, the
+ * one a quote was raised from for a quote. Every other document costs no
+ * query more than it did.
+ */
+export async function inspectionResultsFor(
+  organizationId: string,
+  source: { vehicleId: string | null | undefined; inspectionId: string | null | undefined },
+  layout: Pick<InvoiceLayoutConfig, 'sections'>,
+  options: { photos?: boolean } = {}
+): Promise<InspectionResults | null> {
+  const { wanted, photos } = inspectionResultsWanted(layout)
+  if (!wanted) return null
+  return loadLinkedInspectionResults(organizationId, source.vehicleId, source.inspectionId, {
+    photos: photos && options.photos !== false,
+  })
+}

+ 128 - 83
src/features/inspections/Pdf/buildCertificatePrint.ts

@@ -14,7 +14,11 @@ import {
   type DocumentTheme,
 } from '@/features/invoice-designer/Spec/buildSpec'
 import type { DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
-import type { CertificateData } from '@/features/invoice-designer/Spec/certificateData'
+import type {
+  CertificateData,
+  CertificateResultRow,
+  CertificateResultSection,
+} from '@/features/invoice-designer/Spec/certificateData'
 import { type ConditionMapLabels, conditionMapForPrint } from '@/features/condition-map/Lib/print'
 import type { ConditionMarkData } from '@/features/condition-map/Lib/marks'
 import {
@@ -149,6 +153,121 @@ function notesHtml(notes: string | null): string | undefined {
     .join('')
 }
 
+/**
+ * What an inspection found, worded and coloured for the certificate's blocks:
+ * the outcome, the defects worst first, and every graded check by section.
+ * A check nobody has graded yet is left out of all three, so an inspection
+ * still under way says what has been found so far and nothing about the
+ * rest; the whole checklist rides along beside them for a design that asks
+ * for the ungraded checks too.
+ *
+ * Apart from the certificate itself so a work order can print the results of
+ * the inspection linked to its job through the same blocks.
+ */
+export function certificateDataFor(input: {
+  items: CertificatePrintItem[]
+  scale: SeverityScale
+  country: string | null
+  labels: Record<string, string>
+  itemPhotos?: Record<string, { dataUri: string }[]>
+  overviewPhotos?: { dataUri: string; caption: string | null }[]
+}): CertificateData {
+  const { scale, country, labels } = input
+  const L = (key: string, fallback: string) => labels[key] || fallback
+
+  // The grade as the certificate words it, with the national number where
+  // the country has one: "2 — Major defect".
+  const conditionText = (condition: string) => {
+    const suffix = condition.replace(/(^|_)([a-z])/g, (_, __, c: string) => c.toUpperCase())
+    return L(`${scale}${suffix}`, CONDITION_TOKENS[condition as Condition]?.label ?? condition)
+  }
+  const gradeOf = (condition: string) =>
+    gradedConditionLabel(condition, scale, country, conditionText(condition))
+
+  const graded = input.items
+    .filter((item) => item.condition !== 'not_inspected')
+    .sort((a, b) => a.sortOrder - b.sortOrder)
+  const counts = countConditions(graded)
+  const result = deriveTestResult(graded)
+  const resultToken = TEST_RESULT_TOKENS[result]
+
+  const summaryParts: string[] = []
+  for (const [key, count] of [
+    ['pass', counts.pass],
+    ['attention', counts.attention],
+    ['fail', counts.fail],
+    ['dangerous', counts.dangerous],
+    ['not_applicable', counts.notApplicable],
+  ] as const) {
+    if (count > 0) summaryParts.push(`${count} × ${conditionText(key)}`)
+  }
+
+  const itemPhotos = input.itemPhotos ?? {}
+  const readingOf = (item: CertificatePrintItem): string | null => {
+    if (item.measuredValue !== null && item.measuredValue !== undefined) {
+      return `${item.measuredValue}${item.unit ? ` ${item.unit}` : ''}`
+    }
+    return item.textValue?.trim() || null
+  }
+  const noteOf = (item: CertificatePrintItem): string | null => {
+    const reading = readingOf(item)
+    const note = item.notes?.trim() || null
+    return [reading, note].filter(Boolean).join(' · ') || null
+  }
+
+  const rowOf = (item: CertificatePrintItem): CertificateResultRow => ({
+    code: item.code ?? null,
+    name: item.name,
+    // An ungraded check has no grade to print: the cell is left for a pen.
+    grade: item.condition === 'not_inspected' ? '' : gradeOf(item.condition),
+    notes: noteOf(item),
+    kind:
+      item.condition === 'not_inspected'
+        ? 'not_inspected'
+        : isDefect(item.condition)
+          ? 'defect'
+          : item.condition === 'not_applicable'
+            ? 'not_applicable'
+            : 'pass',
+  })
+  const bySection = (items: CertificatePrintItem[]): CertificateResultSection[] => {
+    const order: string[] = []
+    const groups: Record<string, CertificatePrintItem[]> = {}
+    for (const item of items) {
+      if (!groups[item.section]) {
+        groups[item.section] = []
+        order.push(item.section)
+      }
+      groups[item.section].push(item)
+    }
+    return order.map((name) => ({
+      code: groups[name][0]?.sectionCode ?? null,
+      name,
+      rows: groups[name].map(rowOf),
+    }))
+  }
+
+  return {
+    result: {
+      label: L(RESULT_LABEL_KEY[result], resultToken.label),
+      detail: L(RESULT_DETAIL_KEY[result], resultToken.detail),
+      color: resultToken.pdf,
+    },
+    summary: summaryParts.join(' · '),
+    defects: defectsWorstFirst(graded).map((item) => ({
+      code: item.code ?? null,
+      name: item.name,
+      grade: gradeOf(item.condition),
+      color: CONDITION_TOKENS[item.condition as Condition]?.pdf ?? resultToken.pdf,
+      notes: noteOf(item),
+      photos: (itemPhotos[item.id] ?? []).map((photo) => photo.dataUri),
+    })),
+    sections: bySection(graded),
+    checklist: bySection([...input.items].sort((a, b) => a.sortOrder - b.sortOrder)),
+    photos: input.overviewPhotos ?? [],
+  }
+}
+
 export function buildCertificatePrintSpec(input: CertificatePrintInput): DocumentSpec {
   const { data, workshop, template } = input
   const labels = input.labels ?? {}
@@ -169,15 +288,6 @@ export function buildCertificatePrintSpec(input: CertificatePrintInput): Documen
   const inspector = data.inspectorName || data.technician?.name || ''
   const mileage = data.mileage ?? data.vehicle.mileage
 
-  // The grade as the certificate words it, with the national number where
-  // the country has one: "2 — Major defect".
-  const conditionText = (condition: string) => {
-    const suffix = condition.replace(/(^|_)([a-z])/g, (_, __, c: string) => c.toUpperCase())
-    return L(`${scale}${suffix}`, CONDITION_TOKENS[condition as Condition]?.label ?? condition)
-  }
-  const gradeOf = (condition: string) =>
-    gradedConditionLabel(condition, scale, country, conditionText(condition))
-
   const fields: Record<string, string> = {
     customer_name: data.vehicle.customer?.name || '',
     customer_company: data.vehicle.customer?.company || '',
@@ -224,79 +334,14 @@ export function buildCertificatePrintSpec(input: CertificatePrintInput): Documen
     footer_note: fillTemplate(L('footerText', 'Vehicle Inspection — {shopName}'), { shopName }),
   }
 
-  const graded = data.items
-    .filter((item) => item.condition !== 'not_inspected')
-    .sort((a, b) => a.sortOrder - b.sortOrder)
-  const counts = countConditions(graded)
-  const result = deriveTestResult(graded)
-  const resultToken = TEST_RESULT_TOKENS[result]
-
-  const summaryParts: string[] = []
-  for (const [key, count] of [
-    ['pass', counts.pass],
-    ['attention', counts.attention],
-    ['fail', counts.fail],
-    ['dangerous', counts.dangerous],
-    ['not_applicable', counts.notApplicable],
-  ] as const) {
-    if (count > 0) summaryParts.push(`${count} × ${conditionText(key)}`)
-  }
-
-  const itemPhotos = input.itemPhotos ?? {}
-  const readingOf = (item: CertificatePrintItem): string | null => {
-    if (item.measuredValue !== null && item.measuredValue !== undefined) {
-      return `${item.measuredValue}${item.unit ? ` ${item.unit}` : ''}`
-    }
-    return item.textValue?.trim() || null
-  }
-  const noteOf = (item: CertificatePrintItem): string | null => {
-    const reading = readingOf(item)
-    const note = item.notes?.trim() || null
-    return [reading, note].filter(Boolean).join(' · ') || null
-  }
-
-  const sectionOrder: string[] = []
-  const bySection: Record<string, CertificatePrintItem[]> = {}
-  for (const item of graded) {
-    if (!bySection[item.section]) {
-      bySection[item.section] = []
-      sectionOrder.push(item.section)
-    }
-    bySection[item.section].push(item)
-  }
-
-  const certificate: CertificateData = {
-    result: {
-      label: L(RESULT_LABEL_KEY[result], resultToken.label),
-      detail: L(RESULT_DETAIL_KEY[result], resultToken.detail),
-      color: resultToken.pdf,
-    },
-    summary: summaryParts.join(' · '),
-    defects: defectsWorstFirst(graded).map((item) => ({
-      code: item.code ?? null,
-      name: item.name,
-      grade: gradeOf(item.condition),
-      color: CONDITION_TOKENS[item.condition as Condition]?.pdf ?? resultToken.pdf,
-      notes: noteOf(item),
-      photos: (itemPhotos[item.id] ?? []).map((photo) => photo.dataUri),
-    })),
-    sections: sectionOrder.map((name) => ({
-      code: bySection[name][0]?.sectionCode ?? null,
-      name,
-      rows: bySection[name].map((item) => ({
-        code: item.code ?? null,
-        name: item.name,
-        grade: gradeOf(item.condition),
-        notes: noteOf(item),
-        kind: isDefect(item.condition)
-          ? 'defect'
-          : item.condition === 'not_applicable'
-            ? 'not_applicable'
-            : 'pass',
-      })),
-    })),
-    photos: input.overviewPhotos ?? [],
-  }
+  const certificate = certificateDataFor({
+    items: data.items,
+    scale,
+    country,
+    labels,
+    itemPhotos: input.itemPhotos,
+    overviewPhotos: input.overviewPhotos,
+  })
 
   // The map: this inspection's own marks in colour and, when the design
   // asks, the ones still open from earlier visits in grey. Like the work

+ 10 - 0
src/features/invoice-designer/Components/DesignerInspector.tsx

@@ -581,6 +581,16 @@ export function DesignerInspector({
               <p className="text-[11.5px] leading-snug text-[#8a8f97]">
                 {t('conditionMapWidthHint')}
               </p>
+              <Row label={t('conditionMapDense')}>
+                <Toggle
+                  on={style.dense === true}
+                  onChange={(on) => setStyle({ dense: on ? true : undefined })}
+                  testId="condition-map-dense"
+                />
+              </Row>
+              <p className="text-[11.5px] leading-snug text-[#8a8f97]">
+                {t('conditionMapDenseHint')}
+              </p>
             </Group>
           )}
 

+ 2 - 8
src/features/invoice-designer/Components/InvoiceDesigner.tsx

@@ -43,6 +43,7 @@ import {
   saveQuoteLayoutConfig,
   saveWorkOrderLayoutConfig,
 } from '@/features/settings/Actions/invoiceLayoutActions'
+import { invoiceLabels } from '../Lib/invoiceLabels'
 import { workOrderLabels } from '../Lib/workOrderLabels'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
@@ -296,14 +297,7 @@ export function InvoiceDesigner({
     if (docType === 'work_order') {
       return withOrgNumberLabel(workOrderLabels(pdf), workshop.orgNumberLabel)
     }
-    return withOrgNumberLabel(
-      {
-        ...(pdf.invoice ?? {}),
-        ...(docType === 'quote' ? (pdf.quote ?? {}) : {}),
-        ...(pdf.common ?? {}),
-      },
-      workshop.orgNumberLabel
-    )
+    return withOrgNumberLabel(invoiceLabels(pdf, docType), workshop.orgNumberLabel)
   }, [messages, docType, workshop.orgNumberLabel])
   const L = useCallback(
     (key: string, fallback: string) => printLabels[key] || fallback,

+ 2 - 5
src/features/invoice-designer/Components/PresetPreview.tsx

@@ -1,6 +1,7 @@
 'use client'
 
 import { certificateLabels } from '@/features/inspections/Lib/certificateLabels'
+import { invoiceLabels } from '../Lib/invoiceLabels'
 import { workOrderLabels } from '../Lib/workOrderLabels'
 
 import { useMemo } from 'react'
@@ -44,11 +45,7 @@ function useSampleData(docType: DocumentType, workshop?: PreviewWorkshop, logoUr
         ? certificateLabels(pdf)
         : docType === 'work_order'
           ? workOrderLabels(pdf)
-          : {
-              ...(pdf.invoice ?? {}),
-              ...(docType === 'quote' ? (pdf.quote ?? {}) : {}),
-              ...(pdf.common ?? {}),
-            }
+          : invoiceLabels(pdf, docType)
     return buildSampleData(
       {
         name: workshop?.name ?? '',

+ 100 - 63
src/features/invoice-designer/Components/sample.ts

@@ -390,6 +390,11 @@ export function buildSampleData(
           },
     ...(docType === 'certificate' ? sampleCertificate(t, labels, values, sample) : {}),
     ...(docType === 'work_order' ? sampleWorkOrder(t, labels, values, sample) : {}),
+    // An inspection with one minor defect and one check still to do, so the
+    // result sections have something to draw on every document: the
+    // certificate's own, and elsewhere a stand-in for the inspection linked
+    // to a job or the one a quote was raised from.
+    certificate: sampleInspectionResults(t, labels),
     // A car with a dent, a scratch and one mark from an earlier visit, so the
     // condition map section has something to draw on every document. An
     // invoice or a quote prints this visit's marks only.
@@ -555,9 +560,8 @@ function sampleCertificate(
   labels: PrintLabels,
   values: Record<string, string>,
   sample: SampleTables
-): Pick<DocumentData, 'fields' | 'meta' | 'certificate' | 'notes' | 'attachedDocuments'> {
+): Pick<DocumentData, 'fields' | 'meta' | 'notes' | 'attachedDocuments'> {
   const L = (key: string, fallback: string) => labels[key] || fallback
-  const grade = (key: string, fallback: string) => L(key, fallback)
   return {
     fields: {
       ...values,
@@ -584,79 +588,112 @@ function sampleCertificate(
         name: 'signed-inspection-form.pdf',
       }),
     ],
-    certificate: {
-      result: {
-        label: L('resultPassMinor', 'Pass with minor defects'),
-        detail: L(
-          'resultDetailPassMinor',
-          'The vehicle passes. Repair the minor deficiencies without undue delay.'
-        ),
-        color: { bg: '#fef9c3', text: '#713f12' },
-      },
-      summary: `11 × ${grade('euPass', 'No defect')} · 1 × ${grade('euAttention', 'Minor defect')} · 1 × ${grade('euNotApplicable', 'Not applicable')}`,
-      defects: [
+  }
+}
+
+/**
+ * The inspection the sample stands on: a car that passed with one minor
+ * defect, so every result block has something to draw, and one check nobody
+ * has graded yet, so the switch for those has a row to show.
+ */
+function sampleInspectionResults(
+  t: SampleT,
+  labels: PrintLabels
+): NonNullable<DocumentData['certificate']> {
+  const L = (key: string, fallback: string) => labels[key] || fallback
+  const grade = (key: string, fallback: string) => L(key, fallback)
+  const sections: NonNullable<DocumentData['certificate']>['sections'] = [
+    {
+      code: '1',
+      name: t('sample.sectionBrakes'),
+      rows: [
+        {
+          code: '1.1.1',
+          name: t('sample.checkBrakePedal'),
+          grade: grade('euPass', 'No defect'),
+          notes: null,
+          kind: 'pass',
+        },
         {
           code: '1.1.13',
           name: t('sample.checkBrakeHoses'),
           grade: `1 — ${grade('euAttention', 'Minor defect')}`,
-          color: { bg: '#fef9c3', text: '#713f12' },
           notes: t('sample.checkBrakeHosesNote'),
-          photos: [SAMPLE_PHOTO],
+          kind: 'defect',
+        },
+        {
+          code: '1.1.17',
+          name: t('sample.checkBrakeFluid'),
+          grade: grade('euPass', 'No defect'),
+          notes: null,
+          kind: 'pass',
         },
       ],
-      sections: [
+    },
+    {
+      code: '4',
+      name: t('sample.sectionLighting'),
+      rows: [
         {
-          code: '1',
-          name: t('sample.sectionBrakes'),
-          rows: [
-            {
-              code: '1.1.1',
-              name: t('sample.checkBrakePedal'),
-              grade: grade('euPass', 'No defect'),
-              notes: null,
-              kind: 'pass',
-            },
-            {
-              code: '1.1.13',
-              name: t('sample.checkBrakeHoses'),
-              grade: `1 — ${grade('euAttention', 'Minor defect')}`,
-              notes: t('sample.checkBrakeHosesNote'),
-              kind: 'defect',
-            },
-            {
-              code: '1.1.17',
-              name: t('sample.checkBrakeFluid'),
-              grade: grade('euPass', 'No defect'),
-              notes: null,
-              kind: 'pass',
-            },
-          ],
+          code: '4.1.1',
+          name: t('sample.checkHeadlamps'),
+          grade: grade('euPass', 'No defect'),
+          notes: null,
+          kind: 'pass',
         },
         {
-          code: '4',
-          name: t('sample.sectionLighting'),
-          rows: [
-            {
-              code: '4.1.1',
-              name: t('sample.checkHeadlamps'),
-              grade: grade('euPass', 'No defect'),
-              notes: null,
-              kind: 'pass',
-            },
-            {
-              code: '4.5.1',
-              name: t('sample.checkFogLamp'),
-              grade: grade('euNotApplicable', 'Not applicable'),
-              notes: null,
-              kind: 'not_applicable',
-            },
-          ],
+          code: '4.5.1',
+          name: t('sample.checkFogLamp'),
+          grade: grade('euNotApplicable', 'Not applicable'),
+          notes: null,
+          kind: 'not_applicable',
         },
       ],
-      photos: [
-        { dataUri: SAMPLE_PHOTO, caption: t('sample.photoFront') },
-        { dataUri: SAMPLE_PHOTO, caption: t('sample.photoOdometer') },
-      ],
     },
+  ]
+  return {
+    result: {
+      label: L('resultPassMinor', 'Pass with minor defects'),
+      detail: L(
+        'resultDetailPassMinor',
+        'The vehicle passes. Repair the minor deficiencies without undue delay.'
+      ),
+      color: { bg: '#fef9c3', text: '#713f12' },
+    },
+    summary: `11 × ${grade('euPass', 'No defect')} · 1 × ${grade('euAttention', 'Minor defect')} · 1 × ${grade('euNotApplicable', 'Not applicable')}`,
+    defects: [
+      {
+        code: '1.1.13',
+        name: t('sample.checkBrakeHoses'),
+        grade: `1 — ${grade('euAttention', 'Minor defect')}`,
+        color: { bg: '#fef9c3', text: '#713f12' },
+        notes: t('sample.checkBrakeHosesNote'),
+        photos: [SAMPLE_PHOTO],
+      },
+    ],
+    sections,
+    // The same checklist with the stop lamps still to do, in their place.
+    checklist: sections.map((section) =>
+      section.code === '4'
+        ? {
+            ...section,
+            rows: [
+              section.rows[0],
+              {
+                code: '4.3.1',
+                name: t('sample.checkStopLamps'),
+                grade: '',
+                notes: null,
+                kind: 'not_inspected' as const,
+              },
+              ...section.rows.slice(1),
+            ],
+          }
+        : section
+    ),
+    photos: [
+      { dataUri: SAMPLE_PHOTO, caption: t('sample.photoFront') },
+      { dataUri: SAMPLE_PHOTO, caption: t('sample.photoOdometer') },
+    ],
   }
 }

+ 30 - 0
src/features/invoice-designer/Lib/invoiceLabels.ts

@@ -0,0 +1,30 @@
+/**
+ * The words an invoice or a quote prints, as the document builder expects
+ * them, from the whole `pdf.json`.
+ *
+ * A quote sheet is an invoice sheet with different wording in a handful of
+ * places, and the two are drawn by the same builder. Layering the quote over
+ * the invoice means every shared label (column heads, panel titles, warranty)
+ * is translated for a quote too, instead of falling through to English.
+ *
+ * The inspection's vocabulary lies underneath both: the results of the
+ * inspection behind a job or a quote print through the certificate's blocks,
+ * which ask for the certificate's words (the grades, the column heads, "All
+ * results"). Underneath, so no word an invoice or a quote already prints
+ * changes. Used by the print path and the designer's preview alike, so both
+ * say the same thing.
+ */
+
+type Labels = Record<string, string>
+
+export function invoiceLabels(
+  pdfMessages: Record<string, Labels | undefined>,
+  documentType: 'invoice' | 'quote' = 'invoice'
+): Labels {
+  return {
+    ...(pdfMessages.inspection ?? {}),
+    ...(pdfMessages.invoice ?? {}),
+    ...(documentType === 'quote' ? (pdfMessages.quote ?? {}) : {}),
+    ...(pdfMessages.common ?? {}),
+  }
+}

+ 16 - 3
src/features/invoice-designer/Lib/workOrderLabels.ts

@@ -11,10 +11,22 @@
 
 type Labels = Record<string, string>
 
-/** The work order's wording over the invoice's, and the strip's captions renamed. */
-export function withWorkOrderLabels(base: Labels, workOrder: Labels | undefined): Labels {
+/**
+ * The work order's wording over the invoice's, and the strip's captions renamed.
+ *
+ * The inspection's vocabulary lies underneath both: the results of the
+ * inspection linked to a job print through the certificate's blocks, which
+ * ask for the certificate's words (the grades, the column heads, "All
+ * results"). Underneath, so no word a work order already prints changes.
+ */
+export function withWorkOrderLabels(
+  base: Labels,
+  workOrder: Labels | undefined,
+  inspection?: Labels
+): Labels {
   const wo = workOrder ?? {}
   return {
+    ...(inspection ?? {}),
     ...base,
     ...wo,
     title: wo.title || 'WORK ORDER',
@@ -29,6 +41,7 @@ export function workOrderLabels(
 ): Labels {
   return withWorkOrderLabels(
     { ...(pdfMessages.invoice ?? {}), ...(pdfMessages.common ?? {}) },
-    pdfMessages.workOrder
+    pdfMessages.workOrder,
+    pdfMessages.inspection
   )
 }

+ 13 - 0
src/features/invoice-designer/Pdf/buildInvoicePrint.ts

@@ -37,6 +37,10 @@ import {
   type VisitConditionMap,
   visitConditionMapForPrint,
 } from '@/features/condition-map/Lib/print'
+import {
+  type InspectionResults,
+  inspectionResultsForPrint,
+} from '@/features/inspections/Lib/inspectionResults'
 
 /**
  * A real job, expressed as the document the designer edits.
@@ -66,6 +70,11 @@ export interface InvoicePrintInput {
   labels?: Record<string, string>
   /** The car's condition this visit, for a layout with Vehicle Condition on. */
   conditionMap?: VisitConditionMap
+  /**
+   * The inspection linked to the job, for a layout with Defects or All
+   * Results on: as it stands for a draft, as issued for an issued invoice.
+   */
+  inspectionResults?: InspectionResults | null
 }
 
 function fillTemplate(template: string, values: Record<string, string>): string {
@@ -462,6 +471,8 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     }),
     payment,
     conditionMap: visitConditionMapForPrint(input.conditionMap, doc.margin) ?? undefined,
+    // The linked inspection's findings, through the certificate's own blocks.
+    certificate: inspectionResultsForPrint(layout, input.inspectionResults, labels),
     telegramQr: input.telegramQrDataUri
       ? {
           dataUri: input.telegramQrDataUri,
@@ -486,6 +497,8 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
       general: L('customFieldsTitle', 'Additional Information'),
       findings: L('findings', 'Findings'),
       condition_map: L('conditionMapTitle', 'Vehicle condition'),
+      defects: L('deficiencies', 'Deficiencies found'),
+      results_table: L('allResults', 'All results'),
     },
   }
 

+ 13 - 0
src/features/invoice-designer/Pdf/buildQuotePrint.ts

@@ -31,6 +31,10 @@ import {
   type VisitConditionMap,
   visitConditionMapForPrint,
 } from '@/features/condition-map/Lib/print'
+import {
+  type InspectionResults,
+  inspectionResultsForPrint,
+} from '@/features/inspections/Lib/inspectionResults'
 
 /**
  * A quote, expressed as the document the designer edits, the same way the
@@ -120,6 +124,11 @@ export interface QuotePrintInput {
   lineItemsInclTax?: boolean
   /** The car's condition as the inspection the quote came from found it. */
   conditionMap?: VisitConditionMap
+  /**
+   * The inspection the quote was raised from, for a layout with Defects or
+   * All Results on. Read as it stands at every print, like the condition map.
+   */
+  inspectionResults?: InspectionResults | null
 }
 
 function fillTemplate(template: string, values: Record<string, string>): string {
@@ -364,6 +373,8 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
     warranty: warrantyForPrint(data, { labels, unitSystem: input.unitSystem }),
     payment: [],
     conditionMap: visitConditionMapForPrint(input.conditionMap, doc.margin) ?? undefined,
+    // What the inspection found, through the certificate's own blocks.
+    certificate: inspectionResultsForPrint(layout, input.inspectionResults, labels),
     branding: input.torqvoiceLogoDataUri ? { logoDataUri: input.torqvoiceLogoDataUri } : undefined,
     portalUrl: input.portalUrl,
     signature: {
@@ -382,6 +393,8 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
       general: L('customFieldsTitle', 'Additional Information'),
       findings: L('findings', 'Findings'),
       condition_map: L('conditionMapTitle', 'Vehicle condition'),
+      defects: L('deficiencies', 'Deficiencies found'),
+      results_table: L('allResults', 'All results'),
     },
   }
 

+ 16 - 0
src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts

@@ -39,6 +39,10 @@ import { warrantyForPrint } from './warrantyPrint'
 import { type ConditionMapLabels, conditionMapForPrint } from '@/features/condition-map/Lib/print'
 import type { ConditionMarkData } from '@/features/condition-map/Lib/marks'
 import { typeKeyLine } from '@/features/vehicles/Lib/typeKey'
+import {
+  type InspectionResults,
+  inspectionResultsForPrint,
+} from '@/features/inspections/Lib/inspectionResults'
 
 /**
  * A job as the sheet the customer signs and the technician works from.
@@ -76,6 +80,11 @@ export interface WorkOrderJob {
   openedAt?: Date | string | null
   bodyType?: string | null
   conditionMapLabels?: ConditionMapLabels
+  /**
+   * The checks of the inspection linked to the job, for the result sections
+   * a design can switch on. As they stand at this print, graded or not.
+   */
+  linkedInspection?: InspectionResults | null
 }
 
 export interface WorkOrderPrintInput {
@@ -425,8 +434,15 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
       general: L('customFieldsTitle', 'Additional Information'),
       findings: L('findings', 'Findings'),
       condition_map: L('conditionMapTitle', 'Vehicle condition'),
+      defects: L('deficiencies', 'Deficiencies found'),
+      results_table: L('allResults', 'All results'),
     },
     conditionMap: conditionMap ?? undefined,
+    // What the linked inspection has found so far, for the certificate's two
+    // result blocks. Built only for a design that prints one of them; each
+    // block then decides for itself what an inspection with nothing wrong,
+    // or with nothing graded yet, prints.
+    certificate: inspectionResultsForPrint(layout, job.linkedInspection, labels),
     workOrder: {
       concerns: job.concerns.map((concern) => ({
         description: concern.description,

+ 1 - 1
src/features/invoice-designer/Pdf/estimateHeights.ts

@@ -113,7 +113,7 @@ function nodeHeight(node: Node, width: number, inherited: Inherited): number {
 
     case 'table': {
       const headerSize = node.headerStyle?.fontSize ?? inherited.fontSize
-      const headerHeight = headerSize * DEFAULT_LINE_HEIGHT + 12
+      const headerHeight = headerSize * DEFAULT_LINE_HEIGHT + (node.headerPadding ?? 6) * 2
       const rowPadding = node.rowPadding ?? 5
       const inner = width - 16
       const gap = 0

+ 5 - 10
src/features/invoice-designer/Pdf/printLabels.ts

@@ -4,6 +4,7 @@
  * routes have always applied.
  */
 
+import { invoiceLabels } from '../Lib/invoiceLabels'
 import { withOrgNumberLabel } from '../Lib/labelOverrides'
 import { isMarineWorkshop, type PdfMessages, withMarineDocumentLabels } from '../Lib/marineLabels'
 import { withWorkOrderLabels } from '../Lib/workOrderLabels'
@@ -29,21 +30,15 @@ export async function loadPrintLabels(
 ): Promise<Record<string, string>> {
   const pdfMessages = await loadPdfMessages(locale)
   const quote = documentType === 'quote'
-  // A quote sheet is an invoice sheet with different wording in a handful of
-  // places, and the two are drawn by the same builder. Layering the quote over
-  // the invoice means every shared label (column heads, panel titles, warranty)
-  // is translated for a quote too, instead of falling through to English.
-  let labels: Record<string, string> = {
-    ...pdfMessages.invoice,
-    ...(quote ? pdfMessages.quote : {}),
-    ...pdfMessages.common,
-  }
+  // The quote's wording over the invoice's, with the inspection's vocabulary
+  // under both for the result sections a design can switch on.
+  let labels: Record<string, string> = invoiceLabels(pdfMessages, quote ? 'quote' : 'invoice')
 
   if (isMarineWorkshop(settingsMap)) {
     labels = withMarineDocumentLabels(labels, pdfMessages, quote ? 'quote' : 'invoice')
   }
   if (documentType === 'work_order') {
-    labels = withWorkOrderLabels(labels, pdfMessages.workOrder)
+    labels = withWorkOrderLabels(labels, pdfMessages.workOrder, pdfMessages.inspection)
   }
 
   const customTaxLabel = settingsMap['workshop.taxLabel']?.trim()

+ 1 - 1
src/features/invoice-designer/Pdf/renderPdf.tsx

@@ -242,7 +242,7 @@ export function RenderNodePdf({ node, base }: { node: Node; base: TextStyle }):
           <View
             style={{
               flexDirection: 'row',
-              paddingVertical: 6,
+              paddingVertical: node.headerPadding ?? 6,
               paddingHorizontal: 8,
               ...(node.headerStyle?.background
                 ? { backgroundColor: node.headerStyle.background }

+ 59 - 1
src/features/invoice-designer/Render/SpecCanvas.tsx

@@ -35,6 +35,8 @@ const PAIR_ZONE = 44
 const FLOAT_ZONE = 56
 /** Pointer travel, in screen pixels, before a press becomes a drag. */
 const DRAG_THRESHOLD = 4
+/** How wide each pale line is on a sheet whose blocks are not placed yet. */
+const LOADING_LINES = ['34%', '20%', '100%', '93%', '97%', '62%']
 
 export interface Guide {
   axis: 'x' | 'y'
@@ -277,6 +279,13 @@ export function SpecCanvas({
   const pagesRef = useRef<HTMLDivElement>(null)
   const containerRef = useRef<HTMLDivElement>(null)
   const [heights, setHeights] = useState<number[]>([])
+  /**
+   * False until the blocks have been measured once, which takes a browser.
+   * The server has no heights, so its rows all start at the top margin, one
+   * over the other, and its HTML is what a reload shows until hydration. It
+   * never goes back to false: an edit re-measures what is already in place.
+   */
+  const [measured, setMeasured] = useState(false)
   /** Bumped when a late asset arrives, purely to run the measurement again. */
   const [measureTick, setMeasureTick] = useState(0)
   const [drag, setDrag] = useState<Drag | null>(null)
@@ -321,6 +330,9 @@ export function SpecCanvas({
     setHeights((prev) =>
       prev.length === next.length && prev.every((h, i) => h === next[i]) ? prev : next
     )
+    // Set here, with the heights, so the blocks are let through in the render
+    // that places them and the browser never paints the one before it.
+    setMeasured(true)
   })
 
   // A logo or a webfont arriving after first paint changes the heights without
@@ -856,6 +868,9 @@ export function SpecCanvas({
       style={{
         position: 'relative',
         cursor: 'grab',
+        // Held back until there are heights to place it by. Hidden rather
+        // than left out, so it is the same element before and after.
+        visibility: measured ? undefined : 'hidden',
         outline:
           selected === block.id
             ? '2px solid #2563eb'
@@ -1040,6 +1055,40 @@ export function SpecCanvas({
             </>
           )}
 
+          {/* The paper waiting for its blocks: a few pale lines where the
+              document starts. Only a reload shows it, between the server's
+              HTML and hydration, so it has to move by CSS alone. */}
+          {!measured && pageNumber === 1 && (
+            <div
+              aria-hidden
+              data-sheet-loading
+              className="animate-pulse motion-reduce:animate-none"
+              style={{
+                position: 'absolute',
+                top: spec.page.margin.top,
+                left: spec.page.margin.left,
+                right: spec.page.margin.right,
+                display: 'flex',
+                flexDirection: 'column',
+                gap: 9,
+                pointerEvents: 'none',
+              }}
+            >
+              {LOADING_LINES.map((width) => (
+                <div
+                  key={width}
+                  style={{
+                    width,
+                    height: 8,
+                    borderRadius: 2,
+                    background: spec.page.text,
+                    opacity: 0.1,
+                  }}
+                />
+              ))}
+            </div>
+          )}
+
           {placed.map((r) => {
             const row = r.row
             return (
@@ -1181,7 +1230,15 @@ export function SpecCanvas({
               />
             ))}
         </div>
-        <div style={{ textAlign: 'center', fontSize: 12, color: '#8a8f97' }}>
+        <div
+          style={{
+            textAlign: 'center',
+            fontSize: 12,
+            color: '#8a8f97',
+            // How many pages there are is not known before measuring either.
+            visibility: measured ? undefined : 'hidden',
+          }}
+        >
           Page {pageNumber} of {renderLayout.pageCount}
         </div>
       </div>
@@ -1195,6 +1252,7 @@ export function SpecCanvas({
         className="flex flex-1 flex-col items-center overflow-auto bg-[#e4e7eb] px-10 pb-16 pt-9 outline-none"
         tabIndex={0}
         onKeyDown={nudge}
+        aria-busy={measured ? undefined : true}
         style={{ cursor: dragActive ? 'grabbing' : undefined }}
       >
         {/* Laid out but not shown, to measure every block at print width. */}

+ 67 - 5
src/features/invoice-designer/Render/SpecSheet.tsx

@@ -18,6 +18,9 @@ import './documentFonts.css'
  */
 
 const SHADOW_SHADES = ['rgba(0,0,0,0.13)', 'rgba(0,0,0,0.07)', 'rgba(0,0,0,0.03)']
+/** How wide each pale line is on the paper that waits for its blocks. */
+const LOADING_LINES = ['34%', '20%', '100%', '93%', '97%', '62%']
+const PAPER_SHADOW = '0 1px 3px rgba(26,29,33,0.12), 0 10px 28px rgba(26,29,33,0.14)'
 
 export function SpecSheet({ spec }: { spec: DocumentSpec }) {
   const measureRef = useRef<HTMLDivElement>(null)
@@ -25,6 +28,14 @@ export function SpecSheet({ spec }: { spec: DocumentSpec }) {
   const [heights, setHeights] = useState<number[]>([])
   const [, setMeasureTick] = useState(0)
   const [scale, setScale] = useState(1)
+  /**
+   * False until the blocks have been measured and the sheet fitted once,
+   * which takes a browser. The server knows neither, so its HTML has every
+   * row at the top margin, one over the other, on a sheet at full size, and
+   * that is what a customer's phone showed until hydration. It never goes
+   * back to false: later measurements move what is already in place.
+   */
+  const [ready, setReady] = useState(false)
 
   const contentWidth = spec.page.width - spec.page.margin.left - spec.page.margin.right
   const colWidth = (contentWidth - BLOCK_GAP) / 2
@@ -49,6 +60,9 @@ export function SpecSheet({ spec }: { spec: DocumentSpec }) {
     setHeights((prev) =>
       prev.length === next.length && prev.every((h, i) => h === next[i]) ? prev : next
     )
+    // With the heights, so the sheet is let through in the render that
+    // places its blocks and the browser never paints the one before it.
+    setReady(true)
   })
 
   // Late images and webfonts change the heights without causing a render.
@@ -68,7 +82,8 @@ export function SpecSheet({ spec }: { spec: DocumentSpec }) {
 
   // Fit the sheet to the container, like a PDF viewer fits a page: smaller
   // screens shrink it, wider pages grow it, so it always spans the content.
-  useEffect(() => {
+  // Before the first paint, so the sheet is never shown at the wrong size.
+  useLayoutEffect(() => {
     const frame = frameRef.current
     if (!frame) return
     const fit = () => setScale(frame.clientWidth / spec.page.width || 1)
@@ -249,7 +264,7 @@ export function SpecSheet({ spec }: { spec: DocumentSpec }) {
         fontSize: spec.page.fontSize,
         // The sheet is its own paper on the page; it carries its own shadow
         // rather than being set inside a card.
-        boxShadow: '0 1px 3px rgba(26,29,33,0.12), 0 10px 28px rgba(26,29,33,0.14)',
+        boxShadow: PAPER_SHADOW,
         borderRadius: 2,
       }}
     >
@@ -342,10 +357,57 @@ export function SpecSheet({ spec }: { spec: DocumentSpec }) {
         ))}
       </div>
 
+      {/* The paper waiting for its blocks: the first page's size by CSS
+          alone, since nothing else runs before hydration, with a few pale
+          lines where the document starts. The sheet takes its place exactly. */}
+      {!ready && (
+        <div
+          aria-hidden
+          data-sheet-loading
+          style={{
+            width: '100%',
+            aspectRatio: `${spec.page.width} / ${spec.page.height}`,
+            boxSizing: 'border-box',
+            padding: `${(spec.page.margin.top / spec.page.width) * 100}% ${
+              (spec.page.margin.right / spec.page.width) * 100
+            }% 0 ${(spec.page.margin.left / spec.page.width) * 100}%`,
+            background: spec.page.background || '#ffffff',
+            boxShadow: PAPER_SHADOW,
+            borderRadius: 2,
+          }}
+        >
+          <div
+            className="animate-pulse motion-reduce:animate-none"
+            style={{ display: 'flex', flexDirection: 'column', gap: 9 }}
+          >
+            {LOADING_LINES.map((width) => (
+              <div
+                key={width}
+                style={{
+                  width,
+                  height: 8,
+                  borderRadius: 2,
+                  background: spec.page.text,
+                  opacity: 0.1,
+                }}
+              />
+            ))}
+          </div>
+        </div>
+      )}
+
       <div
-        style={{
-          height: layout.pageCount * spec.page.height * scale + (layout.pageCount - 1) * 12 * scale,
-        }}
+        aria-busy={ready ? undefined : true}
+        style={
+          ready
+            ? {
+                height:
+                  layout.pageCount * spec.page.height * scale + (layout.pageCount - 1) * 12 * scale,
+              }
+            : // Kept in the page so it is the same element before and after,
+              // out of sight and taking no room while the paper stands in.
+              { height: 0, overflow: 'hidden', visibility: 'hidden' }
+        }
       >
         <div
           style={{

+ 1 - 1
src/features/invoice-designer/Render/renderHtml.tsx

@@ -277,7 +277,7 @@ function NodeBody({ node }: { node: Node }): ReactNode {
               display: 'flex',
               ...boxCss(node.headerStyle),
               ...textCss(node.headerStyle),
-              padding: '6px 8px',
+              padding: `${node.headerPadding ?? 6}px 8px`,
             }}
           >
             {node.columns.map((column) => (

+ 2 - 0
src/features/invoice-designer/Spec/buildSpec.ts

@@ -225,6 +225,8 @@ export function lookOf(section: InvoiceSection, theme: DocumentTheme) {
     padding: s?.padding,
     /** Banding behind alternate rows; unset follows the sheet's setting. */
     stripes: s?.stripes,
+    /** Rows and column headings set close together. */
+    dense: s?.dense === true,
     fontSize: s?.fontSize,
     /**
      * Resolved against the sheet, not left blank when the section sets none.

+ 25 - 7
src/features/invoice-designer/Spec/certificateBlocks.ts

@@ -16,6 +16,9 @@ import {
  * the sheet's theme and the document's data, like every other builder, and
  * draws nothing when the inspection has nothing for it: a clean car has no
  * defects block, an inspection with no photographs no photo block.
+ *
+ * A work order, an invoice and a quote borrow the defects and the results
+ * table for the inspection linked to them, filling `certificate` the same way.
  */
 
 const PHOTO_WIDTH = 158
@@ -167,7 +170,14 @@ export function testDetailsBlock(
   return panel(section, theme, data, sectionFields(section))
 }
 
-/** Every check that was not OK, worst first, with the note and the photographs. */
+/**
+ * Every check that was not OK, worst first, with the note and the photographs.
+ *
+ * A car with nothing wrong prints the heading over a line saying so, unless
+ * the design switches that line off, and then the section prints nothing. An
+ * inspection with nothing graded yet never prints the line, whatever the
+ * switch says: "no deficiencies" about a car nobody has looked at is false.
+ */
 export function defectsBlock(
   section: InvoiceSection,
   theme: DocumentTheme,
@@ -180,6 +190,8 @@ export function defectsBlock(
   const fields = new Set(sectionFields(section))
   const withNotes = fields.has('defect_notes')
   const withPhotos = fields.has('defect_photos')
+  const clean = certificate.defects.length === 0
+  if (clean && (certificate.sections.length === 0 || !fields.has('no_defects_note'))) return null
 
   const children: Node[] = []
   if (section.heading !== false) {
@@ -189,7 +201,7 @@ export function defectsBlock(
       style: headingStyle(look, size),
     })
   }
-  if (certificate.defects.length === 0) {
+  if (clean) {
     children.push({
       kind: 'text',
       id: 'defects.none',
@@ -270,6 +282,11 @@ export function defectsBlock(
  * Every check with its grade: a table per section, or, when the design asks
  * for one table, all of them together with a column saying which section
  * each belongs to.
+ *
+ * Each kind of row has a switch of its own, and the defects always print. A
+ * design that asks for the checks nobody has graded yet gets them in their
+ * places in the checklist with the grade left empty, so an inspection that
+ * has not started prints as the sheet to fill in.
  */
 export function resultsTableBlock(
   section: InvoiceSection,
@@ -281,10 +298,13 @@ export function resultsTableBlock(
   const look = lookOf(section, theme)
   const size = look.fontSize ?? theme.fontSize
   const fields = new Set(sectionFields(section))
+  const withUngraded = fields.has('ungraded_checks')
   const keep = (row: { kind: string }) =>
     row.kind === 'defect' ||
     (row.kind === 'pass' && fields.has('passed_checks')) ||
-    (row.kind === 'not_applicable' && fields.has('not_applicable_checks'))
+    (row.kind === 'not_applicable' && fields.has('not_applicable_checks')) ||
+    (row.kind === 'not_inspected' && withUngraded)
+  const groups = (withUngraded && certificate.checklist) || certificate.sections
   const withNotes = fields.has('check_notes')
   const combined = fields.has('combined_table')
 
@@ -332,13 +352,11 @@ export function resultsTableBlock(
     })
   }
   if (combined) {
-    const rows = certificate.sections.flatMap((group) =>
-      group.rows.filter(keep).map((row) => rowOf(group, row))
-    )
+    const rows = groups.flatMap((group) => group.rows.filter(keep).map((row) => rowOf(group, row)))
     if (rows.length === 0) return null
     children.push(table(rows, true))
   } else {
-    for (const group of certificate.sections) {
+    for (const group of groups) {
       const rows = group.rows.filter(keep)
       if (rows.length === 0) continue
       children.push({

+ 23 - 4
src/features/invoice-designer/Spec/certificateData.ts

@@ -27,8 +27,17 @@ export interface CertificateResultRow {
   name: string
   grade: string
   notes: string | null
-  /** Which kind of row this is, so a design can leave some out. */
-  kind: 'pass' | 'defect' | 'not_applicable'
+  /**
+   * Which kind of row this is, so a design can leave some out. A check nobody
+   * has graded yet is `not_inspected`, and only ever appears in `checklist`.
+   */
+  kind: 'pass' | 'defect' | 'not_applicable' | 'not_inspected'
+}
+
+export interface CertificateResultSection {
+  code: string | null
+  name: string
+  rows: CertificateResultRow[]
 }
 
 export interface CertificateData {
@@ -40,8 +49,18 @@ export interface CertificateData {
   /** "12 passed · 1 minor defect", already worded. */
   summary: string
   defects: CertificateDefect[]
-  /** Every graded check, section by section, in checklist order. */
-  sections: { code: string | null; name: string; rows: CertificateResultRow[] }[]
+  /**
+   * Every graded check, section by section, in checklist order. Empty means
+   * nobody has graded anything yet, which the blocks read as "say nothing".
+   */
+  sections: CertificateResultSection[]
+  /**
+   * The whole checklist, the ungraded checks in their places with an empty
+   * grade: what the results table prints for a design that asks for them, so
+   * an inspection nobody has started prints as a sheet to fill in. Kept apart
+   * from `sections` so a design that does not ask prints exactly what it did.
+   */
+  checklist?: CertificateResultSection[]
   /** Photographs of the vehicle as a whole, with the captions the desk gave them. */
   photos: { dataUri: string; caption: string | null }[]
 }

Some files were not shown because too many files changed in this diff