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

Keep the classic sheet until an organization saves a design (#277)

Deploying the designer restyled every organization's invoices and quotes
without them touching anything: the new spec builder drew the same stored
settings with new defaults. Layouts now carry a version stamp that only the
designer writes, and documents printed for an organization without one keep
the retired renderer's look: the combined letterhead with the number and
dates inside it for all three legacy header styles, tinted primary column
heads, and dark section headings. Saving in the designer stamps the layout
and graduates the organization to the new rendering it was just previewing.
Framed opts into the new pipeline regardless, since it never existed before
the designer.
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
6ec76be660

+ 7 - 2
src/__tests__/features/settings/document-title-placement.test.tsx

@@ -9,7 +9,10 @@
  */
  */
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
 import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
 import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
-import { getDefaultInvoiceLayout } from '@/features/settings/Schema/invoiceLayoutSchema'
+import {
+  DESIGNER_LAYOUT_VERSION,
+  getDefaultInvoiceLayout,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
 import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
 import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
 
 
 const data: InvoiceData = {
 const data: InvoiceData = {
@@ -36,7 +39,9 @@ const data: InvoiceData = {
 }
 }
 
 
 function specWithTitleVisible(visible: boolean) {
 function specWithTitleVisible(visible: boolean) {
-  const layout = getDefaultInvoiceLayout()
+  // A designer-saved layout: an unstamped one keeps the classic letterhead,
+  // which carries the title itself and has no separate block to place.
+  const layout = { ...getDefaultInvoiceLayout(), version: DESIGNER_LAYOUT_VERSION }
   layout.sections = layout.sections.map((s) => (s.id === 'document_title' ? { ...s, visible } : s))
   layout.sections = layout.sections.map((s) => (s.id === 'document_title' ? { ...s, visible } : s))
   return buildInvoicePrintSpec({ data, template: { layoutConfig: layout } })
   return buildInvoicePrintSpec({ data, template: { layoutConfig: layout } })
 }
 }

+ 57 - 2
src/__tests__/features/settings/spec-pdf-print.test.tsx

@@ -13,11 +13,15 @@ import { renderToBuffer, type DocumentProps } from '@react-pdf/renderer'
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
 import '@/features/vehicles/Components/invoice-pdf/fonts'
 import '@/features/vehicles/Components/invoice-pdf/fonts'
 import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
 import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { mixColors } from '@/features/invoice-designer/Spec/buildSpec'
 import { estimateBlockHeights } from '@/features/invoice-designer/Pdf/estimateHeights'
 import { estimateBlockHeights } from '@/features/invoice-designer/Pdf/estimateHeights'
 import { lineCount } from '@/features/invoice-designer/Pdf/measure'
 import { lineCount } from '@/features/invoice-designer/Pdf/measure'
 import { InvoicePDF } from '@/features/vehicles/Components/invoice-pdf/InvoicePDF'
 import { InvoicePDF } from '@/features/vehicles/Components/invoice-pdf/InvoicePDF'
 import { QuotePDF } from '@/features/quotes/Components/QuotePDF'
 import { QuotePDF } from '@/features/quotes/Components/QuotePDF'
-import { getDefaultInvoiceLayout } from '@/features/settings/Schema/invoiceLayoutSchema'
+import {
+  DESIGNER_LAYOUT_VERSION,
+  getDefaultInvoiceLayout,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
 import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
 import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
 
 
 const invoice: InvoiceData = {
 const invoice: InvoiceData = {
@@ -185,8 +189,59 @@ describe('the printed invoice follows the designed layout', () => {
   })
   })
 
 
   it('always prints the number and the date, even with the title section off', () => {
   it('always prints the number and the date, even with the title section off', () => {
-    // The default layout hides the document title; the sheet borrows it.
+    // An organization with no saved layout keeps the classic letterhead,
+    // which carries the number itself, so no title block joins the sheet.
     const spec = buildInvoicePrintSpec({ data: invoice, workshop, invoiceSettings: settings })
     const spec = buildInvoicePrintSpec({ data: invoice, workshop, invoiceSettings: settings })
+    expect(spec.blocks.find((b) => b.id === 'document_title')).toBeUndefined()
+    expect(JSON.stringify(spec.blocks.find((b) => b.id === 'header'))).toContain('INV-2026-0042')
+  })
+
+  it('keeps the classic look for an organization without a designer layout', async () => {
+    // A layout saved before the designer existed (no version stamp) counts
+    // the same as no layout at all: the deploy must not restyle the sheet.
+    for (const layoutConfig of [undefined, getDefaultInvoiceLayout()]) {
+      const spec = buildInvoicePrintSpec({
+        data: invoice,
+        workshop,
+        invoiceSettings: settings,
+        template: { primaryColor: '#d97706', layoutConfig },
+      })
+      // The tinted column heads and darkened head ink of the old sheets.
+      const parts = JSON.stringify(spec.blocks.find((b) => b.id === 'parts_table'))
+      expect(parts).toContain(mixColors('#ffffff', '#d97706', 0.1))
+      expect(parts).toContain(mixColors('#d97706', '#000000', 0.3))
+      // The title and the dates live inside the letterhead.
+      const header = JSON.stringify(spec.blocks.find((b) => b.id === 'header'))
+      expect(header).toContain('INVOICE')
+      expect(header).toContain('INV-2026-0042')
+    }
+    // And every legacy header style still prints a valid document.
+    for (const headerStyle of ['standard', 'compact', 'modern']) {
+      const buffer = await renderToBuffer(
+        (
+          <InvoicePDF
+            data={invoice}
+            workshop={workshop}
+            invoiceSettings={settings}
+            template={{ primaryColor: '#d97706', headerStyle }}
+          />
+        ) as React.ReactElement<DocumentProps>
+      )
+      expect(buffer.subarray(0, 5).toString()).toBe('%PDF-')
+    }
+  })
+
+  it('borrows the title block once a designer layout is saved', () => {
+    // The default designer layout hides the title section; the sheet borrows
+    // it and sets it under the header.
+    const spec = buildInvoicePrintSpec({
+      data: invoice,
+      workshop,
+      invoiceSettings: settings,
+      template: {
+        layoutConfig: { ...getDefaultInvoiceLayout(), version: DESIGNER_LAYOUT_VERSION },
+      },
+    })
     const title = spec.blocks.find((b) => b.id === 'document_title')
     const title = spec.blocks.find((b) => b.id === 'document_title')
     expect(title).toBeDefined()
     expect(title).toBeDefined()
     expect(JSON.stringify(title)).toContain('INV-2026-0042')
     expect(JSON.stringify(title)).toContain('INV-2026-0042')

+ 5 - 1
src/features/invoice-designer/Components/InvoiceDesigner.tsx

@@ -19,6 +19,7 @@ import { Input } from '@/components/ui/input'
 import { buildLayoutFromPreset, layoutPresets } from '@/features/settings/Schema/layoutPresets'
 import { buildLayoutFromPreset, layoutPresets } from '@/features/settings/Schema/layoutPresets'
 import {
 import {
   COLUMN_ELIGIBLE_SECTIONS,
   COLUMN_ELIGIBLE_SECTIONS,
+  DESIGNER_LAYOUT_VERSION,
   getDefaultInvoiceLayout,
   getDefaultInvoiceLayout,
   materializeHiddenSection,
   materializeHiddenSection,
   toCustomFieldId,
   toCustomFieldId,
@@ -580,8 +581,11 @@ export function InvoiceDesigner({
     setSaving(true)
     setSaving(true)
     try {
     try {
       const prefix = docType === 'invoice' ? 'invoice' : 'quote'
       const prefix = docType === 'invoice' ? 'invoice' : 'quote'
+      // The stamp that graduates this organization from the classic
+      // pre-designer rendering to whatever this designer shows.
+      const stamped = { ...layout, version: DESIGNER_LAYOUT_VERSION }
       await Promise.all([
       await Promise.all([
-        docType === 'invoice' ? saveInvoiceLayoutConfig(layout) : saveQuoteLayoutConfig(layout),
+        docType === 'invoice' ? saveInvoiceLayoutConfig(stamped) : saveQuoteLayoutConfig(stamped),
         setSettings({
         setSettings({
           [`${prefix}.primaryColor`]: template.primaryColor,
           [`${prefix}.primaryColor`]: template.primaryColor,
           [`${prefix}.backgroundColor`]: template.backgroundColor,
           [`${prefix}.backgroundColor`]: template.backgroundColor,

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

@@ -4,6 +4,7 @@ import { calculateTotals, netLineTotal } from '@/lib/tax'
 import {
 import {
   getDefaultInvoiceLayout,
   getDefaultInvoiceLayout,
   isCustomFieldId,
   isCustomFieldId,
+  isDesignerLayout,
   mergeWithDefaults,
   mergeWithDefaults,
   toCustomFieldId,
   toCustomFieldId,
   type InvoiceLayoutConfig,
   type InvoiceLayoutConfig,
@@ -467,6 +468,10 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     frameShadow: frameShadowWidth(template?.frameShadow),
     frameShadow: frameShadowWidth(template?.frameShadow),
     frameRadius: template?.frameRadius ?? 0,
     frameRadius: template?.frameRadius ?? 0,
     logoSize: template?.logoSize ?? 100,
     logoSize: template?.logoSize ?? 100,
+    // Until this organization saves a layout in the designer, the sheet keeps
+    // the classic look the retired renderer printed. Framed never existed
+    // before the designer, so choosing it opts into the new rendering.
+    classic: !isDesignerLayout(input.template?.layoutConfig) && headerStyle !== 'framed',
   }
   }
 
 
   return buildDocumentSpec(layout, theme, documentData)
   return buildDocumentSpec(layout, theme, documentData)

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

@@ -4,6 +4,7 @@ import { calculateTotals, netLineTotal } from '@/lib/tax'
 import {
 import {
   getDefaultInvoiceLayout,
   getDefaultInvoiceLayout,
   isCustomFieldId,
   isCustomFieldId,
+  isDesignerLayout,
   mergeWithDefaults,
   mergeWithDefaults,
   toCustomFieldId,
   toCustomFieldId,
   type InvoiceLayoutConfig,
   type InvoiceLayoutConfig,
@@ -361,6 +362,10 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
     frameShadow: frameShadowWidth(template?.frameShadow),
     frameShadow: frameShadowWidth(template?.frameShadow),
     frameRadius: template?.frameRadius ?? 0,
     frameRadius: template?.frameRadius ?? 0,
     logoSize: template?.logoSize ?? 100,
     logoSize: template?.logoSize ?? 100,
+    // Until this organization saves a layout in the designer, the sheet keeps
+    // the classic look the retired renderer printed. Framed never existed
+    // before the designer, so choosing it opts into the new rendering.
+    classic: !isDesignerLayout(input.layoutConfig) && headerStyle !== 'framed',
   }
   }
 
 
   return buildDocumentSpec(layout, theme, documentData)
   return buildDocumentSpec(layout, theme, documentData)

+ 264 - 26
src/features/invoice-designer/Spec/buildSpec.ts

@@ -97,6 +97,13 @@ export interface DocumentTheme {
   /** Rounding where the rail meets the band, in points. */
   /** Rounding where the rail meets the band, in points. */
   frameRadius: number
   frameRadius: number
   logoSize: number
   logoSize: number
+  /**
+   * Draw with the pre-designer defaults: the combined letterhead, tinted
+   * column heads and dark headings the retired renderer printed. Set for
+   * organizations that have never saved a layout in the designer, so a deploy
+   * does not restyle their documents behind their back.
+   */
+  classic?: boolean
 }
 }
 
 
 /**
 /**
@@ -158,6 +165,28 @@ const scale = (base: number, factor: number) => Math.max(5, Math.round(base * fa
 /** A translated string, or the English the sheet has always printed. */
 /** A translated string, or the English the sheet has always printed. */
 const label = (data: DocumentData, key: string, fallback: string) => data.labels[key] || fallback
 const label = (data: DocumentData, key: string, fallback: string) => data.labels[key] || fallback
 
 
+/**
+ * The column heads every table wears. The default is the sheet's ink reversed
+ * out; classic keeps the tinted primary band with darkened primary text the
+ * old sheets printed. A fill the section sets itself wins in both.
+ */
+function tableHead(look: ReturnType<typeof lookOf>, theme: DocumentTheme, size: number) {
+  if (theme.classic && !look.fill) {
+    return {
+      background: mixColors(theme.background || '#ffffff', theme.primary, 0.1),
+      color: mixColors(theme.primary, '#000000', 0.3),
+      fontSize: scale(size, 0.78),
+      bold: true,
+    }
+  }
+  return {
+    background: look.fill || look.text,
+    color: look.fill ? look.label : theme.background || '#ffffff',
+    fontSize: scale(size, 0.78),
+    bold: true,
+  }
+}
+
 /** A labelled panel: the customer, the vehicle, the service, the extras. */
 /** A labelled panel: the customer, the vehicle, the service, the extras. */
 function panel(
 function panel(
   section: InvoiceSection,
   section: InvoiceSection,
@@ -216,7 +245,229 @@ function panel(
   }
   }
 }
 }
 
 
+/**
+ * The letterhead as the retired PDF components drew it, for organizations
+ * that have never saved a layout in the designer. The title, the number and
+ * the dates live inside this header, exactly where they always printed, so
+ * classic sheets never carry a separate document title block.
+ */
+function classicLetterhead(
+  section: InvoiceSection,
+  theme: DocumentTheme,
+  data: DocumentData
+): Node {
+  const look = lookOf(section, theme)
+  const fields = sectionFields(section)
+  const ls = theme.logoSize / 100
+  const compact = theme.headerStyle === 'compact'
+  const modern = theme.headerStyle === 'modern'
+
+  const dueLine = data.meta.due
+    ? data.labels.due
+      ? data.labels.due.replace('{date}', data.meta.due)
+      : `Due: ${data.meta.due}`
+    : ''
+
+  // The company column, field by field in the section's order, each line the
+  // size and ink the old sheets gave it.
+  const bandInk = (soft: string) => (modern ? soft : look.muted)
+  const companyLines = (align: 'left' | 'center'): Node[] =>
+    fields
+      .map((id): Node | null => {
+        const value = data.fields[id]
+        switch (id) {
+          case 'logo':
+            return data.logoUrl
+              ? {
+                  kind: 'image',
+                  id: 'header.logo',
+                  src: data.logoUrl,
+                  maxWidth: (compact ? 40 : modern ? 50 : 150) * ls,
+                  maxHeight: (compact ? 40 : modern ? 50 : 60) * ls,
+                  align,
+                }
+              : null
+          case 'company_name':
+            return value
+              ? {
+                  kind: 'text',
+                  id: 'header.company_name',
+                  text: value,
+                  style: {
+                    color: theme.companyText,
+                    fontSize: compact ? 16 : 22,
+                    bold: true,
+                    align,
+                  },
+                }
+              : null
+          case 'company_address':
+            return value
+              ? {
+                  kind: 'text',
+                  id: 'header.company_address',
+                  text: value,
+                  style: {
+                    color: bandInk('rgba(255,255,255,0.8)'),
+                    fontSize: compact ? 8 : 9,
+                    align,
+                  },
+                }
+              : null
+          case 'company_phone':
+          case 'company_email':
+          case 'company_org_number':
+            return value
+              ? {
+                  kind: 'text',
+                  id: `header.${id}`,
+                  text: value,
+                  style: { color: bandInk('rgba(255,255,255,0.7)'), fontSize: 8, align },
+                }
+              : null
+          default:
+            return null
+        }
+      })
+      .filter(Boolean) as Node[]
+
+  const brandingRow = (justify: 'start' | 'center' | 'end', soft?: string): Node[] =>
+    data.branding
+      ? [
+          {
+            kind: 'row',
+            id: 'header.branding',
+            gap: 3,
+            justify,
+            align: 'center',
+            children: [
+              {
+                node: {
+                  kind: 'image',
+                  src: data.branding.logoDataUri,
+                  maxWidth: 12,
+                  maxHeight: 12,
+                },
+              },
+              {
+                node: {
+                  kind: 'text',
+                  text: 'Torqvoice',
+                  style: { color: soft ?? look.muted, fontSize: 7, bold: true },
+                },
+              },
+            ],
+          },
+        ]
+      : []
+
+  // The right column: the document's own identity, right where a customer's
+  // eye has always found it.
+  const metaColumn = (titleSize: number): Node => ({
+    kind: 'stack',
+    id: 'header.meta',
+    gap: 3,
+    children: [
+      {
+        kind: 'text',
+        id: 'header.title',
+        text: data.meta.title,
+        style: { fontSize: titleSize, bold: true, color: look.text, align: 'right' },
+      },
+      ...[data.meta.number, data.meta.date, dueLine].filter(Boolean).map<Node>((line, i) => ({
+        kind: 'text',
+        id: `header.meta_${i}`,
+        text: line,
+        style: { fontSize: 9, color: look.muted, align: 'right' },
+      })),
+    ],
+  })
+
+  if (modern) {
+    return {
+      kind: 'stack',
+      id: section.id,
+      gap: 12,
+      children: [
+        {
+          kind: 'stack',
+          id: 'header.banner',
+          gap: 3,
+          style: { background: look.fill || theme.primary, padding: 20, radius: 4 },
+          children: [...companyLines('center'), ...brandingRow('center', 'rgba(255,255,255,0.7)')],
+        },
+        {
+          kind: 'row',
+          id: 'header.title_row',
+          justify: 'between',
+          align: 'center',
+          children: [
+            {
+              node: {
+                kind: 'text',
+                id: 'header.title',
+                text: data.meta.title,
+                style: { fontSize: 18, bold: true, color: look.text },
+              },
+            },
+            {
+              node: {
+                kind: 'row',
+                id: 'header.meta',
+                gap: 16,
+                children: [data.meta.number, data.meta.date, dueLine]
+                  .filter(Boolean)
+                  .map((line) => ({
+                    node: {
+                      kind: 'text',
+                      text: line,
+                      style: { fontSize: 9, color: look.muted },
+                    } as Node,
+                  })),
+              },
+            },
+          ],
+        },
+      ],
+    }
+  }
+
+  return {
+    kind: 'stack',
+    id: section.id,
+    gap: 0,
+    children: [
+      {
+        kind: 'row',
+        id: 'header.columns',
+        justify: 'between',
+        children: [
+          {
+            node: {
+              kind: 'stack',
+              id: 'header.company',
+              gap: 2,
+              children: [...companyLines('left'), ...brandingRow('start')],
+            },
+          },
+          { node: metaColumn(compact ? 14 : 18) },
+        ],
+      },
+      { kind: 'spacer', height: compact ? 10 : 15 },
+      // The rule the old sheets closed the letterhead with: a hairline for
+      // compact, a heavy primary stroke for standard.
+      {
+        kind: 'stack',
+        id: 'header.rule',
+        style: { background: compact ? look.border || '#e5e7eb' : theme.primary },
+        children: [{ kind: 'spacer', height: compact ? 1 : 3 }],
+      },
+    ],
+  }
+}
+
 function letterhead(section: InvoiceSection, theme: DocumentTheme, data: DocumentData): Node {
 function letterhead(section: InvoiceSection, theme: DocumentTheme, data: DocumentData): Node {
+  if (theme.classic) return classicLetterhead(section, theme, data)
   const look = lookOf(section, theme)
   const look = lookOf(section, theme)
   const size = look.fontSize ?? theme.fontSize
   const size = look.fontSize ?? theme.fontSize
   const fields = sectionFields(section)
   const fields = sectionFields(section)
@@ -452,12 +703,7 @@ function itemsTable(
       borderColor: look.border || (look.ruleWidth !== undefined ? look.muted : '#eceef1'),
       borderColor: look.border || (look.ruleWidth !== undefined ? look.muted : '#eceef1'),
       borderWidth: look.outerBorder ? (look.ruleWidth ?? 0.75) : 0,
       borderWidth: look.outerBorder ? (look.ruleWidth ?? 0.75) : 0,
     },
     },
-    headerStyle: {
-      background: look.fill || look.text,
-      color: look.fill ? look.label : theme.background || '#ffffff',
-      fontSize: scale(size, 0.78),
-      bold: true,
-    },
+    headerStyle: tableHead(look, theme, size),
     columns: [
     columns: [
       { key: 'n', label: label(data, 'pos', '#'), width: 22 },
       { key: 'n', label: label(data, 'pos', '#'), width: 22 },
       { key: 'qty', label: label(data, 'qty', 'Qty'), width: 48, align: 'right' },
       { key: 'qty', label: label(data, 'qty', 'Qty'), width: 48, align: 'right' },
@@ -496,7 +742,11 @@ function titledTable(
     children.push({
     children.push({
       kind: 'text',
       kind: 'text',
       text: title,
       text: title,
-      style: { color: look.label, bold: true, fontSize: scale(size, 1.05) },
+      // Classic headings print in the sheet's own dark ink, at the size the
+      // old sheets set them.
+      style: theme.classic
+        ? { color: look.text, bold: true, fontSize: scale(size, 1.2) }
+        : { color: look.label, bold: true, fontSize: scale(size, 1.05) },
     })
     })
   }
   }
   if (intro) {
   if (intro) {
@@ -531,12 +781,7 @@ function partsTable(
       borderColor: look.border || (look.ruleWidth !== undefined ? look.muted : '#eceef1'),
       borderColor: look.border || (look.ruleWidth !== undefined ? look.muted : '#eceef1'),
       borderWidth: look.outerBorder ? (look.ruleWidth ?? 0.75) : 0,
       borderWidth: look.outerBorder ? (look.ruleWidth ?? 0.75) : 0,
     },
     },
-    headerStyle: {
-      background: look.fill || look.text,
-      color: look.fill ? look.label : theme.background || '#ffffff',
-      fontSize: scale(size, 0.78),
-      bold: true,
-    },
+    headerStyle: tableHead(look, theme, size),
     columns: [
     columns: [
       { key: 'ref', label: label(data, 'partNumber', 'Part #'), width: 76 },
       { key: 'ref', label: label(data, 'partNumber', 'Part #'), width: 76 },
       { key: 'desc', label: label(data, 'description', 'Description'), width: 'flex' },
       { key: 'desc', label: label(data, 'description', 'Description'), width: 'flex' },
@@ -577,12 +822,7 @@ function laborTable(
       borderColor: look.border || (look.ruleWidth !== undefined ? look.muted : '#eceef1'),
       borderColor: look.border || (look.ruleWidth !== undefined ? look.muted : '#eceef1'),
       borderWidth: look.outerBorder ? (look.ruleWidth ?? 0.75) : 0,
       borderWidth: look.outerBorder ? (look.ruleWidth ?? 0.75) : 0,
     },
     },
-    headerStyle: {
-      background: look.fill || look.text,
-      color: look.fill ? look.label : theme.background || '#ffffff',
-      fontSize: scale(size, 0.78),
-      bold: true,
-    },
+    headerStyle: tableHead(look, theme, size),
     columns: [
     columns: [
       { key: 'desc', label: label(data, 'description', 'Description'), width: 'flex' },
       { key: 'desc', label: label(data, 'description', 'Description'), width: 'flex' },
       { key: 'qty', label: label(data, 'qtyOrHours', 'Qty / Hours'), width: 70, align: 'right' },
       { key: 'qty', label: label(data, 'qtyOrHours', 'Qty / Hours'), width: 70, align: 'right' },
@@ -627,12 +867,7 @@ function findingsBlock(
         borderColor: look.border || (look.ruleWidth !== undefined ? look.muted : '#eceef1'),
         borderColor: look.border || (look.ruleWidth !== undefined ? look.muted : '#eceef1'),
         borderWidth: look.outerBorder ? (look.ruleWidth ?? 0.75) : 0,
         borderWidth: look.outerBorder ? (look.ruleWidth ?? 0.75) : 0,
       },
       },
-      headerStyle: {
-        background: look.fill || look.text,
-        color: look.fill ? look.label : theme.background || '#ffffff',
-        fontSize: scale(size, 0.78),
-        bold: true,
-      },
+      headerStyle: tableHead(look, theme, size),
       columns: [
       columns: [
         { key: 'severity', label: label(data, 'findingSeverityLabel', 'Severity'), width: 80 },
         { key: 'severity', label: label(data, 'findingSeverityLabel', 'Severity'), width: 80 },
         { key: 'description', label: label(data, 'description', 'Description'), width: 'flex' },
         { key: 'description', label: label(data, 'description', 'Description'), width: 'flex' },
@@ -1216,6 +1451,9 @@ export function buildDocumentSpec(
 
 
   for (const section of ordered) {
   for (const section of ordered) {
     if (!section.visible) continue
     if (!section.visible) continue
+    // A classic letterhead already prints the number and the dates, so the
+    // title block would say them twice.
+    if (section.id === 'document_title' && theme.classic) continue
     // The framed letterhead lives on the band the chrome paints, so unless a
     // The framed letterhead lives on the band the chrome paints, so unless a
     // hand placement says otherwise it is anchored there rather than flowed.
     // hand placement says otherwise it is anchored there rather than flowed.
     if (section.id === 'header' && framed && !anchors.header) {
     if (section.id === 'header' && framed && !anchors.header) {
@@ -1238,7 +1476,7 @@ export function buildDocumentSpec(
   // is borrowed and set directly under the header, which is where every
   // is borrowed and set directly under the header, which is where every
   // header used to print it.
   // header used to print it.
   const titleSection = ordered.find((s) => s.id === 'document_title')
   const titleSection = ordered.find((s) => s.id === 'document_title')
-  if (titleSection && !titleSection.visible) {
+  if (titleSection && !titleSection.visible && !theme.classic) {
     const headerOrder = ordered.find((s) => s.id === 'header')?.order ?? 0
     const headerOrder = ordered.find((s) => s.id === 'header')?.order ?? 0
     push({ ...titleSection, visible: true }, { mode: 'flow', order: headerOrder + 0.5 }, true)
     push({ ...titleSection, visible: true }, { mode: 'flow', order: headerOrder + 0.5 }, true)
   }
   }

+ 20 - 1
src/features/settings/Schema/invoiceLayoutSchema.ts

@@ -116,8 +116,26 @@ export const invoiceLayoutConfigSchema = z.object({
   document: invoiceDocumentStyleSchema.optional(),
   document: invoiceDocumentStyleSchema.optional(),
   /** Anything positioned by hand, keyed by node id. */
   /** Anything positioned by hand, keyed by node id. */
   anchors: z.record(z.string(), anchorSchema).optional(),
   anchors: z.record(z.string(), anchorSchema).optional(),
+  /**
+   * Which era saved this layout. Absent means the layout predates the
+   * full-screen designer (or was never saved at all), and the print keeps the
+   * classic look those organizations have always mailed out.
+   */
+  version: z.number().int().optional(),
 })
 })
 
 
+/** Stamped on every layout the designer saves. */
+export const DESIGNER_LAYOUT_VERSION = 2
+
+/**
+ * Whether this layout was saved from the full-screen designer. Anything else,
+ * including no saved layout at all, keeps the classic pre-designer rendering
+ * so a deploy never restyles an organization's documents behind its back.
+ */
+export function isDesignerLayout(config?: Partial<InvoiceLayoutConfig> | null): boolean {
+  return (config?.version ?? 1) >= DESIGNER_LAYOUT_VERSION
+}
+
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // TypeScript types (derived from Zod)
 // TypeScript types (derived from Zod)
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -475,7 +493,7 @@ export function mergeWithDefaults(saved: Partial<InvoiceLayoutConfig>): InvoiceL
   const defaults = getDefaultInvoiceLayout()
   const defaults = getDefaultInvoiceLayout()
 
 
   if (!saved.sections || saved.sections.length === 0) {
   if (!saved.sections || saved.sections.length === 0) {
-    return defaults
+    return saved.version !== undefined ? { ...defaults, version: saved.version } : defaults
   }
   }
 
 
   // Migrate old format: split "info" into customer/vehicle/service
   // Migrate old format: split "info" into customer/vehicle/service
@@ -544,6 +562,7 @@ export function mergeWithDefaults(saved: Partial<InvoiceLayoutConfig>): InvoiceL
     sections: merged,
     sections: merged,
     ...(saved.document ? { document: saved.document } : {}),
     ...(saved.document ? { document: saved.document } : {}),
     ...(saved.anchors ? { anchors: saved.anchors } : {}),
     ...(saved.anchors ? { anchors: saved.anchors } : {}),
+    ...(saved.version !== undefined ? { version: saved.version } : {}),
   }
   }
 }
 }