Explorar o código

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 hai 1 mes
pai
achega
6ec76be660

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

@@ -9,7 +9,10 @@
  */
 import { describe, expect, it } from 'vitest'
 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'
 
 const data: InvoiceData = {
@@ -36,7 +39,9 @@ const data: InvoiceData = {
 }
 
 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))
   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 '@/features/vehicles/Components/invoice-pdf/fonts'
 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 { lineCount } from '@/features/invoice-designer/Pdf/measure'
 import { InvoicePDF } from '@/features/vehicles/Components/invoice-pdf/InvoicePDF'
 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'
 
 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', () => {
-    // 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 })
+    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')
     expect(title).toBeDefined()
     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 {
   COLUMN_ELIGIBLE_SECTIONS,
+  DESIGNER_LAYOUT_VERSION,
   getDefaultInvoiceLayout,
   materializeHiddenSection,
   toCustomFieldId,
@@ -580,8 +581,11 @@ export function InvoiceDesigner({
     setSaving(true)
     try {
       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([
-        docType === 'invoice' ? saveInvoiceLayoutConfig(layout) : saveQuoteLayoutConfig(layout),
+        docType === 'invoice' ? saveInvoiceLayoutConfig(stamped) : saveQuoteLayoutConfig(stamped),
         setSettings({
           [`${prefix}.primaryColor`]: template.primaryColor,
           [`${prefix}.backgroundColor`]: template.backgroundColor,

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

@@ -4,6 +4,7 @@ import { calculateTotals, netLineTotal } from '@/lib/tax'
 import {
   getDefaultInvoiceLayout,
   isCustomFieldId,
+  isDesignerLayout,
   mergeWithDefaults,
   toCustomFieldId,
   type InvoiceLayoutConfig,
@@ -467,6 +468,10 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     frameShadow: frameShadowWidth(template?.frameShadow),
     frameRadius: template?.frameRadius ?? 0,
     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)

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

@@ -4,6 +4,7 @@ import { calculateTotals, netLineTotal } from '@/lib/tax'
 import {
   getDefaultInvoiceLayout,
   isCustomFieldId,
+  isDesignerLayout,
   mergeWithDefaults,
   toCustomFieldId,
   type InvoiceLayoutConfig,
@@ -361,6 +362,10 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
     frameShadow: frameShadowWidth(template?.frameShadow),
     frameRadius: template?.frameRadius ?? 0,
     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)

+ 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. */
   frameRadius: 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. */
 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. */
 function panel(
   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 {
+  if (theme.classic) return classicLetterhead(section, theme, data)
   const look = lookOf(section, theme)
   const size = look.fontSize ?? theme.fontSize
   const fields = sectionFields(section)
@@ -452,12 +703,7 @@ function itemsTable(
       borderColor: look.border || (look.ruleWidth !== undefined ? look.muted : '#eceef1'),
       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: [
       { key: 'n', label: label(data, 'pos', '#'), width: 22 },
       { key: 'qty', label: label(data, 'qty', 'Qty'), width: 48, align: 'right' },
@@ -496,7 +742,11 @@ function titledTable(
     children.push({
       kind: 'text',
       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) {
@@ -531,12 +781,7 @@ function partsTable(
       borderColor: look.border || (look.ruleWidth !== undefined ? look.muted : '#eceef1'),
       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: [
       { key: 'ref', label: label(data, 'partNumber', 'Part #'), width: 76 },
       { key: 'desc', label: label(data, 'description', 'Description'), width: 'flex' },
@@ -577,12 +822,7 @@ function laborTable(
       borderColor: look.border || (look.ruleWidth !== undefined ? look.muted : '#eceef1'),
       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: [
       { key: 'desc', label: label(data, 'description', 'Description'), width: 'flex' },
       { 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'),
         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: [
         { key: 'severity', label: label(data, 'findingSeverityLabel', 'Severity'), width: 80 },
         { key: 'description', label: label(data, 'description', 'Description'), width: 'flex' },
@@ -1216,6 +1451,9 @@ export function buildDocumentSpec(
 
   for (const section of ordered) {
     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
     // hand placement says otherwise it is anchored there rather than flowed.
     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
   // header used to print it.
   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
     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(),
   /** Anything positioned by hand, keyed by node id. */
   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)
 // ---------------------------------------------------------------------------
@@ -475,7 +493,7 @@ export function mergeWithDefaults(saved: Partial<InvoiceLayoutConfig>): InvoiceL
   const defaults = getDefaultInvoiceLayout()
 
   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
@@ -544,6 +562,7 @@ export function mergeWithDefaults(saved: Partial<InvoiceLayoutConfig>): InvoiceL
     sections: merged,
     ...(saved.document ? { document: saved.document } : {}),
     ...(saved.anchors ? { anchors: saved.anchors } : {}),
+    ...(saved.version !== undefined ? { version: saved.version } : {}),
   }
 }