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

Preview the invoice and email designers in the workshop's own currency and date format (#518)

* Preview the invoice and email designers in the workshop's own currency and date format

* Read the workshop's currency and time zone for the email previews, and keep sample dates on their day

* Keep the workshop time zone rule in the pure time zone module, so email previews need no database
Bernt Christian Egeland 1 день назад
Родитель
Сommit
51ce723f59

+ 45 - 0
src/__tests__/features/email/email-template-engine.test.ts

@@ -3,6 +3,8 @@ import en from '../../../../messages/en/email.json'
 import { buildEmailSpec } from '@/features/email/Lib/buildEmailSpec'
 import { buildEmailSpec } from '@/features/email/Lib/buildEmailSpec'
 import {
 import {
   sampleContextFor,
   sampleContextFor,
+  sampleFormatFrom,
+  sampleSummaryRows,
   sampleValuesFor,
   sampleValuesFor,
   summaryRowsFor,
   summaryRowsFor,
   tagValuesFor,
   tagValuesFor,
@@ -346,6 +348,49 @@ describe('emailContext', () => {
     expect(sampleValuesFor('invoice_sent', {}).workshop_name).toBeTruthy()
     expect(sampleValuesFor('invoice_sent', {}).workshop_name).toBeTruthy()
   })
   })
 
 
+  it("previews amounts and dates in the workshop's own currency and date format", () => {
+    // A workshop in dollars used to preview "1 250,00" and a European date.
+    const format = sampleFormatFrom({
+      'workshop.currencyCode': 'USD',
+      'workshop.dateFormat': 'MM/dd/yyyy',
+    })
+    const rows = Object.fromEntries(
+      sampleSummaryRows('invoice_sent', messages.summary, format).map((r) => [r.key, r.value])
+    )
+    expect(rows.total).toBe('$1,250.00')
+    expect(rows.balance).toBe('$250.00')
+    expect(rows.due).toBe('09/30/2026')
+    const values = sampleValuesFor('invoice_sent', {}, format)
+    expect(values.total).toBe('$1,250.00')
+    expect(values.due_date).toBe('09/30/2026')
+    const test = sampleContextFor('invoice_sent', null, format.currency) as {
+      document?: { currencyCode?: string }
+    }
+    expect(test.document?.currencyCode).toBe('USD')
+  })
+
+  it('previews the same day a test mail shows, in the time zone the workshop was detected in', () => {
+    // On automatic, a send uses the detected zone; the preview must too, or a
+    // workshop in Auckland sees the 30th in the preview and the 1st in its inbox.
+    const format = sampleFormatFrom({
+      'workshop.dateFormat': 'dd.MM.yyyy',
+      'workshop.timezoneDetected': 'Pacific/Auckland',
+    })
+    const due = Object.fromEntries(
+      sampleSummaryRows('invoice_sent', messages.summary, format).map((r) => [r.key, r.value])
+    ).due
+    const sent = summaryRowsFor(
+      'invoice_sent',
+      sampleContextFor('invoice_sent', null, format.currency),
+      messages.summary,
+      format.date
+    ).find((r) => r.key === 'due')?.value
+    expect(due).toBe('01.10.2026')
+    expect(due).toBe(sent)
+    // The invitation's expiry is written the workshop's way as well.
+    expect(sampleValuesFor('team_invitation', {}, format).invite_expires).toBe('17.09.2026')
+  })
+
   it.each(EMAIL_KINDS)('%s renders its preset from sample data without a literal tag', (kind) => {
   it.each(EMAIL_KINDS)('%s renders its preset from sample data without a literal tag', (kind) => {
     const context = sampleContextFor(kind)
     const context = sampleContextFor(kind)
     const spec = buildEmailSpec(preset(kind), {
     const spec = buildEmailSpec(preset(kind), {

+ 3 - 3
src/__tests__/features/invoice-designer/grouped-lines.test.tsx

@@ -117,8 +117,8 @@ describe('a parts table grouped by category', () => {
       .filter((r: any) => r[table.emphasisKey])
       .filter((r: any) => r[table.emphasisKey])
       .map((r: any) => [r.desc, r.total])
       .map((r: any) => [r.desc, r.total])
     expect(subtotals).toEqual([
     expect(subtotals).toEqual([
-      [`${BRAKES} subtotal`, '€ 245.50'],
-      [`${CONSUMABLES} subtotal`, '€ 12.00'],
+      [`${BRAKES} subtotal`, '$245.50'],
+      [`${CONSUMABLES} subtotal`, '$12.00'],
     ])
     ])
     // A heading and a subtotal per group around the three sample parts.
     // A heading and a subtotal per group around the three sample parts.
     expect(table.rows).toHaveLength(3 + 4)
     expect(table.rows).toHaveLength(3 + 4)
@@ -159,7 +159,7 @@ describe('the combined items table grouped by category', () => {
     const numbers = table.rows.filter((r: any) => r.n).map((r: any) => r.n)
     const numbers = table.rows.filter((r: any) => r.n).map((r: any) => r.n)
     expect(numbers).toEqual(['1', '2', '3', '4'])
     expect(numbers).toEqual(['1', '2', '3', '4'])
     const laborSubtotal = table.rows.find((r: any) => r.desc === 'Labor subtotal')
     const laborSubtotal = table.rows.find((r: any) => r.desc === 'Labor subtotal')
-    expect(laborSubtotal.total).toBe('€ 222.50')
+    expect(laborSubtotal.total).toBe('$222.50')
   })
   })
 })
 })
 
 

+ 46 - 0
src/__tests__/features/invoice-designer/sample-currency.test.ts

@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import { buildSampleData } from '@/features/invoice-designer/Components/sample'
+import type { DesignerWorkshop } from '@/features/invoice-designer/Components/types'
+import { formatCurrency, formatDateForPdf } from '@/lib/format'
+
+/**
+ * The designer's sample sheet is written in the workshop's own money and date
+ * format, the way its printed sheets are. A workshop in dollars used to
+ * preview "€ 149.00" and a European date.
+ */
+
+const t = (key: string) => key
+const labels = { subtotal: 'Subtotal', tax: 'Tax ({rate}%)', total: 'Total' }
+const workshop: DesignerWorkshop = {
+  name: 'Fjord Bil',
+  address: '',
+  phone: '',
+  email: '',
+  slogan: '',
+  orgNumber: '',
+  paymentTerms: '',
+  logoUrl: '',
+}
+
+describe('the designer sample in the workshop currency', () => {
+  it('writes every amount and date the way the workshop prints them', () => {
+    const nok = {
+      money: (amount: number) => formatCurrency(amount, 'NOK', 'symbol'),
+      date: (date: Date) => formatDateForPdf(date, 'dd.MM.yyyy'),
+    }
+    const data = buildSampleData(workshop, [], t, labels, 'invoice', nok)
+    const totals = Object.fromEntries(data.totals.map((line) => [line.label, line.value]))
+    expect(totals.Subtotal).toBe(formatCurrency(480, 'NOK', 'symbol'))
+    expect(totals.Total).toBe(formatCurrency(600, 'NOK', 'symbol'))
+    expect(data.meta.date).toBe('14.08.2026')
+    expect(data.meta.due).toBe('28.08.2026')
+    expect(JSON.stringify(data)).not.toContain('€')
+  })
+
+  it('falls back to the app default currency, not euros', () => {
+    const data = buildSampleData(workshop, [], t, labels, 'invoice')
+    const totals = Object.fromEntries(data.totals.map((line) => [line.label, line.value]))
+    expect(totals.Total).toBe('$600.00')
+    expect(JSON.stringify(data)).not.toContain('€')
+  })
+})

+ 6 - 6
src/__tests__/features/invoice-designer/sample-split-tax.test.ts

@@ -48,10 +48,10 @@ describe('the designer sample and a split tax', () => {
     // 480.00 net: GST 24.00, QST 47.88, total 551.88
     // 480.00 net: GST 24.00, QST 47.88, total 551.88
     expect(totals).toEqual(
     expect(totals).toEqual(
       expect.arrayContaining([
       expect.arrayContaining([
-        ['Subtotal', '€ 480.00'],
-        ['GST (5%)', '€ 24.00'],
-        ['QST (9.975%)', '€ 47.88'],
-        ['Total', '€ 551.88'],
+        ['Subtotal', '$480.00'],
+        ['GST (5%)', '$24.00'],
+        ['QST (9.975%)', '$47.88'],
+        ['Total', '$551.88'],
       ])
       ])
     )
     )
     expect(totals.some(([label]) => label.startsWith('Tax ('))).toBe(false)
     expect(totals.some(([label]) => label.startsWith('Tax ('))).toBe(false)
@@ -87,8 +87,8 @@ describe('the designer sample and a split tax', () => {
     const data = buildSampleData(workshop, [], t, labels, 'invoice')
     const data = buildSampleData(workshop, [], t, labels, 'invoice')
     expect(data.totals.map((line) => [line.label, line.value])).toEqual(
     expect(data.totals.map((line) => [line.label, line.value])).toEqual(
       expect.arrayContaining([
       expect.arrayContaining([
-        ['Tax (25%)', '€ 120.00'],
-        ['Total', '€ 600.00'],
+        ['Tax (25%)', '$120.00'],
+        ['Total', '$600.00'],
       ])
       ])
     )
     )
     expect(data.payment.some((pair) => pair.label.endsWith('No.'))).toBe(false)
     expect(data.payment.some((pair) => pair.label.endsWith('No.'))).toBe(false)

+ 11 - 3
src/app/(authenticated)/settings/email-templates/page.tsx

@@ -10,7 +10,12 @@ import {
   listEmailTemplates,
   listEmailTemplates,
 } from '@/features/email/Actions/emailTemplateActions'
 } from '@/features/email/Actions/emailTemplateActions'
 import type { EmailSampleData } from '@/features/email/Components/EmailThumbnail'
 import type { EmailSampleData } from '@/features/email/Components/EmailThumbnail'
-import { sampleSummaryRows, sampleValuesFor } from '@/features/email/Lib/emailContext'
+import {
+  SAMPLE_FORMAT_KEYS,
+  sampleFormatFrom,
+  sampleSummaryRows,
+  sampleValuesFor,
+} from '@/features/email/Lib/emailContext'
 import { EMAIL_KINDS, type EmailKind } from '@/features/email/Lib/emailKinds'
 import { EMAIL_KINDS, type EmailKind } from '@/features/email/Lib/emailKinds'
 import { loadEmailMessages } from '@/features/email/Lib/emailMessages.server'
 import { loadEmailMessages } from '@/features/email/Lib/emailMessages.server'
 import { presetTemplate } from '@/features/email/Lib/emailPresets'
 import { presetTemplate } from '@/features/email/Lib/emailPresets'
@@ -41,6 +46,7 @@ export default async function EmailTemplatesPage() {
         SETTING_KEYS.WORKSHOP_PHONE,
         SETTING_KEYS.WORKSHOP_PHONE,
         SETTING_KEYS.WORKSHOP_EMAIL,
         SETTING_KEYS.WORKSHOP_EMAIL,
         SETTING_KEYS.WORKSHOP_ADDRESS,
         SETTING_KEYS.WORKSHOP_ADDRESS,
+        ...SAMPLE_FORMAT_KEYS,
       ]),
       ]),
       db.organization.findUnique({
       db.organization.findUnique({
         where: { id: data.organizationId },
         where: { id: data.organizationId },
@@ -59,14 +65,16 @@ export default async function EmailTemplatesPage() {
     email: settings[SETTING_KEYS.WORKSHOP_EMAIL],
     email: settings[SETTING_KEYS.WORKSHOP_EMAIL],
     address: settings[SETTING_KEYS.WORKSHOP_ADDRESS],
     address: settings[SETTING_KEYS.WORKSHOP_ADDRESS],
   }
   }
+  // The stand-in amounts and dates, written the way this workshop's mail writes them.
+  const sampleFormat = sampleFormatFrom(settings)
 
 
   const presets = {} as Record<EmailKind, EmailTemplate>
   const presets = {} as Record<EmailKind, EmailTemplate>
   const samples = {} as Record<EmailKind, EmailSampleData>
   const samples = {} as Record<EmailKind, EmailSampleData>
   for (const kind of EMAIL_KINDS) {
   for (const kind of EMAIL_KINDS) {
     presets[kind] = presetTemplate(kind, messages)
     presets[kind] = presetTemplate(kind, messages)
     samples[kind] = {
     samples[kind] = {
-      values: sampleValuesFor(kind, workshop),
-      summary: sampleSummaryRows(kind, messages.summary),
+      values: sampleValuesFor(kind, workshop, sampleFormat),
+      summary: sampleSummaryRows(kind, messages.summary, sampleFormat),
     }
     }
   }
   }
 
 

+ 11 - 3
src/app/(designer)/email-designer/page.tsx

@@ -10,7 +10,12 @@ import {
   listEmailTemplates,
   listEmailTemplates,
 } from '@/features/email/Actions/emailTemplateActions'
 } from '@/features/email/Actions/emailTemplateActions'
 import { EmailDesigner } from '@/features/email/Components/EmailDesigner'
 import { EmailDesigner } from '@/features/email/Components/EmailDesigner'
-import { sampleSummaryRows, sampleValuesFor } from '@/features/email/Lib/emailContext'
+import {
+  SAMPLE_FORMAT_KEYS,
+  sampleFormatFrom,
+  sampleSummaryRows,
+  sampleValuesFor,
+} from '@/features/email/Lib/emailContext'
 import { isEmailKind } from '@/features/email/Lib/emailKinds'
 import { isEmailKind } from '@/features/email/Lib/emailKinds'
 import { loadEmailMessages } from '@/features/email/Lib/emailMessages.server'
 import { loadEmailMessages } from '@/features/email/Lib/emailMessages.server'
 import { presetTemplate } from '@/features/email/Lib/emailPresets'
 import { presetTemplate } from '@/features/email/Lib/emailPresets'
@@ -47,6 +52,7 @@ export default async function EmailDesignerPage({
         SETTING_KEYS.WORKSHOP_PHONE,
         SETTING_KEYS.WORKSHOP_PHONE,
         SETTING_KEYS.WORKSHOP_EMAIL,
         SETTING_KEYS.WORKSHOP_EMAIL,
         SETTING_KEYS.WORKSHOP_ADDRESS,
         SETTING_KEYS.WORKSHOP_ADDRESS,
+        ...SAMPLE_FORMAT_KEYS,
       ]),
       ]),
       db.organization.findUnique({
       db.organization.findUnique({
         where: { id: data.organizationId },
         where: { id: data.organizationId },
@@ -79,6 +85,8 @@ export default async function EmailDesignerPage({
     email: settings[SETTING_KEYS.WORKSHOP_EMAIL],
     email: settings[SETTING_KEYS.WORKSHOP_EMAIL],
     address: settings[SETTING_KEYS.WORKSHOP_ADDRESS],
     address: settings[SETTING_KEYS.WORKSHOP_ADDRESS],
   }
   }
+  // The stand-in amounts and dates, written the way this workshop's mail writes them.
+  const sampleFormat = sampleFormatFrom(settings)
 
 
   const saved = (
   const saved = (
     templatesResult.success && templatesResult.data ? templatesResult.data : []
     templatesResult.success && templatesResult.data ? templatesResult.data : []
@@ -102,8 +110,8 @@ export default async function EmailDesignerPage({
         savedNames={saved.map((template) => ({ id: template.id, name: template.name }))}
         savedNames={saved.map((template) => ({ id: template.id, name: template.name }))}
         activeId={active}
         activeId={active}
         sample={{
         sample={{
-          values: sampleValuesFor(kind, workshop),
-          summary: sampleSummaryRows(kind, messages.summary),
+          values: sampleValuesFor(kind, workshop, sampleFormat),
+          summary: sampleSummaryRows(kind, messages.summary, sampleFormat),
         }}
         }}
         userEmail={user?.email ?? ''}
         userEmail={user?.email ?? ''}
       />
       />

+ 19 - 4
src/app/(designer)/layout.tsx

@@ -6,6 +6,8 @@ import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permis
 import { ServiceTypeProvider } from '@/components/service-type-context'
 import { ServiceTypeProvider } from '@/components/service-type-context'
 import { TypeKeyProvider } from '@/components/type-key-context'
 import { TypeKeyProvider } from '@/components/type-key-context'
 import { ConfirmProvider } from '@/components/confirm-dialog'
 import { ConfirmProvider } from '@/components/confirm-dialog'
+import { CurrencySettingsProvider } from '@/components/currency-settings-context'
+import { DateSettingsProvider } from '@/components/date-settings-context'
 // The faces the PDFs embed, so the canvas measures what the paper prints.
 // The faces the PDFs embed, so the canvas measures what the paper prints.
 import '@/features/invoice-designer/Render/documentFonts.css'
 import '@/features/invoice-designer/Render/documentFonts.css'
 
 
@@ -43,10 +45,23 @@ export default async function DesignerLayout({ children }: { children: React.Rea
   return (
   return (
     <ServiceTypeProvider serviceType={data.serviceType ?? 'automotive'}>
     <ServiceTypeProvider serviceType={data.serviceType ?? 'automotive'}>
       <TypeKeyProvider enabled={data.typeKeyEnabled}>
       <TypeKeyProvider enabled={data.typeKeyEnabled}>
-        <ConfirmProvider>
-          <SaveShortcutListener />
-          <div className="h-screen overflow-hidden bg-[#eceef1] text-[#1a1d21]">{children}</div>
-        </ConfirmProvider>
+        {/* The workshop's money and dates, which the sample sheet is written in. */}
+        <DateSettingsProvider
+          dateFormat={data.dateFormat}
+          timeFormat={data.timeFormat}
+          timezone={data.timezone}
+          weekStartDay={data.weekStartDay}
+        >
+          <CurrencySettingsProvider
+            currencyCode={data.currencyCode}
+            currencyFormat={data.currencyFormat}
+          >
+            <ConfirmProvider>
+              <SaveShortcutListener />
+              <div className="h-screen overflow-hidden bg-[#eceef1] text-[#1a1d21]">{children}</div>
+            </ConfirmProvider>
+          </CurrencySettingsProvider>
+        </DateSettingsProvider>
       </TypeKeyProvider>
       </TypeKeyProvider>
     </ServiceTypeProvider>
     </ServiceTypeProvider>
   )
   )

+ 7 - 1
src/features/email/Actions/emailTemplateActions.ts

@@ -20,6 +20,7 @@ import { getOrgFromAddress, sendOrgMail } from '@/lib/email'
 import { missingTags, unknownTags } from '../Lib/tags'
 import { missingTags, unknownTags } from '../Lib/tags'
 import { buildTemplatedMail } from '../Lib/sendTemplatedMail'
 import { buildTemplatedMail } from '../Lib/sendTemplatedMail'
 import { sampleContextFor } from '../Lib/emailContext'
 import { sampleContextFor } from '../Lib/emailContext'
+import { workshopCurrencySettings } from '@/lib/workshop-currency'
 import { missingAssets, sweepEmailAssets } from '../Lib/emailAssets.server'
 import { missingAssets, sweepEmailAssets } from '../Lib/emailAssets.server'
 import { requireFeature } from '@/lib/features'
 import { requireFeature } from '@/lib/features'
 import { requireEmailSending, withPlatformMailAllowance } from '@/features/email/Lib/emailAllowance'
 import { requireEmailSending, withPlatformMailAllowance } from '@/features/email/Lib/emailAllowance'
@@ -322,7 +323,12 @@ export async function sendTestEmail(input: SaveEmailTemplateInput, recipientEmai
       const mail = await buildTemplatedMail(organizationId, {
       const mail = await buildTemplatedMail(organizationId, {
         kind: data.kind,
         kind: data.kind,
         to,
         to,
-        context: sampleContextFor(data.kind, sender?.name),
+        // In the workshop's own currency, as the designer previews it.
+        context: sampleContextFor(
+          data.kind,
+          sender?.name,
+          await workshopCurrencySettings(organizationId)
+        ),
         template: data,
         template: data,
         attached: kindSpec(data.kind).hasAttachment,
         attached: kindSpec(data.kind).hasAttachment,
       })
       })

+ 91 - 14
src/features/email/Lib/emailContext.ts

@@ -1,5 +1,11 @@
-import { resolveCurrencyCode } from '@/lib/currencies'
-import { formatCurrency, type CurrencyFormat } from '@/lib/format'
+import {
+  type CurrencySettings,
+  resolveCurrencyCode,
+  resolveCurrencySettings,
+} from '@/lib/currencies'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { formatCurrency, formatDate, type CurrencyFormat } from '@/lib/format'
+import { resolveWorkshopTimeZone } from '@/lib/timezone'
 import type { EmailKind } from './emailKinds'
 import type { EmailKind } from './emailKinds'
 import { kindSpec } from './emailKinds'
 import { kindSpec } from './emailKinds'
 import type { SummaryRowKey } from './emailTemplate'
 import type { SummaryRowKey } from './emailTemplate'
@@ -215,8 +221,69 @@ export function summaryRowsFor<K extends EmailKind>(
  * because seeing your own name in the footer is what tells you it is right,
  * because seeing your own name in the footer is what tells you it is right,
  * and an invented customer and job for everything else.
  * and an invented customer and job for everything else.
  */
  */
-export function sampleValuesFor(kind: EmailKind, workshop: WorkshopContext): TagValues {
-  const samples = sampleTagValues()
+/**
+ * The workshop's currency and date format, for the stand-in amounts and dates.
+ * A sent mail writes them the workshop's way, so the preview does too: a
+ * workshop in dollars should not see euros in its designer.
+ */
+export interface SampleFormat {
+  currency: CurrencySettings
+  date: DateFormatter
+}
+
+/**
+ * The sample format from a workshop's settings, as `getSettings` returns them:
+ * the caller asks for SAMPLE_FORMAT_KEYS. The time zone is resolved the way a
+ * send resolves it (the one set, else the one detected, else UTC), so the
+ * preview and a test mail show the same day.
+ */
+export function sampleFormatFrom(settings: Record<string, string | undefined>): SampleFormat {
+  const dateFormat = settings[SETTING_KEYS.DATE_FORMAT] || undefined
+  const timezone = resolveWorkshopTimeZone(
+    settings[SETTING_KEYS.TIMEZONE],
+    settings[SETTING_KEYS.TIMEZONE_DETECTED]
+  )
+  return {
+    currency: resolveCurrencySettings(settings),
+    date: (date) => formatDate(date, dateFormat, timezone),
+  }
+}
+
+/** The settings `sampleFormatFrom` reads, for the page that loads them. */
+export const SAMPLE_FORMAT_KEYS = [
+  SETTING_KEYS.CURRENCY_CODE,
+  SETTING_KEYS.CURRENCY_FORMAT,
+  SETTING_KEYS.DATE_FORMAT,
+  SETTING_KEYS.TIMEZONE,
+  SETTING_KEYS.TIMEZONE_DETECTED,
+]
+
+const DEFAULT_SAMPLE_FORMAT = sampleFormatFrom({})
+
+/** The stand-in invoice: what it comes to, what is paid, and when the rest is due. */
+const SAMPLE_TOTAL = 1250
+const SAMPLE_PAID = 1000
+const SAMPLE_DUE = new Date('2026-09-30T12:00:00Z')
+/** When the stand-in team invitation runs out. */
+const SAMPLE_INVITE_EXPIRES = new Date('2026-09-16T12:00:00Z')
+
+function sampleMoney(format: SampleFormat): (amount: number) => string {
+  return (amount) =>
+    formatCurrency(amount, format.currency.currencyCode, format.currency.currencyFormat)
+}
+
+export function sampleValuesFor(
+  kind: EmailKind,
+  workshop: WorkshopContext,
+  format: SampleFormat = DEFAULT_SAMPLE_FORMAT
+): TagValues {
+  const money = sampleMoney(format)
+  const samples = sampleTagValues({
+    total: money(SAMPLE_TOTAL),
+    balance_due: money(SAMPLE_TOTAL - SAMPLE_PAID),
+    due_date: format.date(SAMPLE_DUE),
+    invite_expires: format.date(SAMPLE_INVITE_EXPIRES),
+  })
   // The workshop's real details replace the samples wholesale: a missing
   // The workshop's real details replace the samples wholesale: a missing
   // phone shows as no phone, because that is what the customer would see.
   // phone shows as no phone, because that is what the customer would see.
   samples.workshop_name = text(workshop.name) ?? samples.workshop_name
   samples.workshop_name = text(workshop.name) ?? samples.workshop_name
@@ -231,14 +298,19 @@ export function sampleValuesFor(kind: EmailKind, workshop: WorkshopContext): Tag
   return values
   return values
 }
 }
 
 
-export function sampleSummaryRows(kind: EmailKind, labels: SummaryLabels): SummaryRow[] {
+export function sampleSummaryRows(
+  kind: EmailKind,
+  labels: SummaryLabels,
+  format: SampleFormat = DEFAULT_SAMPLE_FORMAT
+): SummaryRow[] {
   if (!kindSpec(kind).hasSummary) return []
   if (!kindSpec(kind).hasSummary) return []
+  const money = sampleMoney(format)
   return [
   return [
     { key: 'reference', label: labels.reference, value: 'INV-1042' },
     { key: 'reference', label: labels.reference, value: 'INV-1042' },
     { key: 'vehicle', label: labels.vehicle, value: '2019 Volvo V70' },
     { key: 'vehicle', label: labels.vehicle, value: '2019 Volvo V70' },
-    { key: 'total', label: labels.total, value: '1 250,00' },
-    { key: 'balance', label: labels.balance, value: '250,00' },
-    { key: 'due', label: labels.due, value: '30 Sep 2026' },
+    { key: 'total', label: labels.total, value: money(SAMPLE_TOTAL) },
+    { key: 'balance', label: labels.balance, value: money(SAMPLE_TOTAL - SAMPLE_PAID) },
+    { key: 'due', label: labels.due, value: format.date(SAMPLE_DUE) },
   ]
   ]
 }
 }
 
 
@@ -246,7 +318,11 @@ export function sampleSummaryRows(kind: EmailKind, labels: SummaryLabels): Summa
  * A context with the same stand-in data the preview shows, for a test send
  * A context with the same stand-in data the preview shows, for a test send
  * through the real pipeline.
  * through the real pipeline.
  */
  */
-export function sampleContextFor(kind: EmailKind, currentUser?: string | null): EmailContext {
+export function sampleContextFor(
+  kind: EmailKind,
+  currentUser?: string | null,
+  currency: CurrencySettings = DEFAULT_SAMPLE_FORMAT.currency
+): EmailContext {
   const base: BaseContext = {
   const base: BaseContext = {
     customerName: TAGS.customer_name.sample,
     customerName: TAGS.customer_name.sample,
     vehicle: { year: 2019, make: 'Volvo', model: 'V70', licensePlate: 'AB 12345', mileage: 92000 },
     vehicle: { year: 2019, make: 'Volvo', model: 'V70', licensePlate: 'AB 12345', mileage: 92000 },
@@ -261,7 +337,7 @@ export function sampleContextFor(kind: EmailKind, currentUser?: string | null):
         ...base,
         ...base,
         inviteLink: TAGS.invite_link.sample,
         inviteLink: TAGS.invite_link.sample,
         role: TAGS.role.sample,
         role: TAGS.role.sample,
-        expiresAt: new Date('2026-09-16T12:00:00Z'),
+        expiresAt: SAMPLE_INVITE_EXPIRES,
       }
       }
     case 'message':
     case 'message':
       return { ...base, message: TAGS.message.sample, portalLink: TAGS.portal_link.sample }
       return { ...base, message: TAGS.message.sample, portalLink: TAGS.portal_link.sample }
@@ -271,10 +347,11 @@ export function sampleContextFor(kind: EmailKind, currentUser?: string | null):
         document: {
         document: {
           number: TAGS.document_number.sample,
           number: TAGS.document_number.sample,
           title: TAGS.document_title.sample,
           title: TAGS.document_title.sample,
-          total: 1250,
-          paid: 1000,
-          dueDate: new Date('2026-09-30T12:00:00Z'),
-          currencyCode: 'EUR',
+          total: SAMPLE_TOTAL,
+          paid: SAMPLE_PAID,
+          dueDate: SAMPLE_DUE,
+          currencyCode: currency.currencyCode,
+          currencyFormat: currency.currencyFormat,
         },
         },
         shareLink: TAGS.share_link.sample,
         shareLink: TAGS.share_link.sample,
       }
       }

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

@@ -56,6 +56,7 @@ import { SpecCanvas } from '../Render/SpecCanvas'
 import { SpecThumbnail } from '../Render/SpecThumbnail'
 import { SpecThumbnail } from '../Render/SpecThumbnail'
 import { buildDocumentSpec, type DocumentData } from '../Spec/buildSpec'
 import { buildDocumentSpec, type DocumentData } from '../Spec/buildSpec'
 import { buildSampleData, type PrintLabels } from './sample'
 import { buildSampleData, type PrintLabels } from './sample'
+import { useSampleFormat } from './useSampleFormat'
 import { useTypeKeyEnabled } from '@/components/type-key-context'
 import { useTypeKeyEnabled } from '@/components/type-key-context'
 import { specForPreset } from './presetSpec'
 import { specForPreset } from './presetSpec'
 import { themeOf } from './designTheme'
 import { themeOf } from './designTheme'
@@ -346,9 +347,17 @@ export function InvoiceDesigner({
   }, [telegramOn, telegramBotLink])
   }, [telegramOn, telegramBotLink])
 
 
   /** What a workshop's own sheet says, with the sample standing in for a job. */
   /** What a workshop's own sheet says, with the sample standing in for a job. */
+  const sampleFormat = useSampleFormat()
   const data: DocumentData = useMemo(
   const data: DocumentData = useMemo(
     () => ({
     () => ({
-      ...buildSampleData({ ...workshop, typeKeyEnabled }, customFields, t, printLabels, docType),
+      ...buildSampleData(
+        { ...workshop, typeKeyEnabled },
+        customFields,
+        t,
+        printLabels,
+        docType,
+        sampleFormat
+      ),
       telegramQr:
       telegramQr:
         telegramOn && telegramQrDataUri
         telegramOn && telegramQrDataUri
           ? { dataUri: telegramQrDataUri, label: L('telegramConnect', 'Chat with us on Telegram') }
           ? { dataUri: telegramQrDataUri, label: L('telegramConnect', 'Chat with us on Telegram') }
@@ -361,6 +370,7 @@ export function InvoiceDesigner({
       t,
       t,
       printLabels,
       printLabels,
       docType,
       docType,
+      sampleFormat,
       telegramOn,
       telegramOn,
       telegramQrDataUri,
       telegramQrDataUri,
       L,
       L,

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

@@ -11,6 +11,7 @@ import type { LayoutPreset } from '@/features/settings/Schema/layoutPresets'
 import type { TaxComponentDefinition } from '@/lib/tax'
 import type { TaxComponentDefinition } from '@/lib/tax'
 import { SpecThumbnail } from '../Render/SpecThumbnail'
 import { SpecThumbnail } from '../Render/SpecThumbnail'
 import { buildSampleData, type PrintLabels } from './sample'
 import { buildSampleData, type PrintLabels } from './sample'
+import { useSampleFormat } from './useSampleFormat'
 import { specForDesign, specForPreset } from './presetSpec'
 import { specForDesign, specForPreset } from './presetSpec'
 import type { DocumentType, SavedDesign } from './types'
 import type { DocumentType, SavedDesign } from './types'
 
 
@@ -35,6 +36,7 @@ function useSampleData(docType: DocumentType, workshop?: PreviewWorkshop, logoUr
   const t = useTranslations('settings.designer')
   const t = useTranslations('settings.designer')
   const typeKeyEnabled = useTypeKeyEnabled()
   const typeKeyEnabled = useTypeKeyEnabled()
   const messages = useMessages() as { pdf?: Record<string, Record<string, string>> }
   const messages = useMessages() as { pdf?: Record<string, Record<string, string>> }
+  const sampleFormat = useSampleFormat()
 
 
   return useMemo(() => {
   return useMemo(() => {
     const pdf = messages.pdf ?? {}
     const pdf = messages.pdf ?? {}
@@ -62,9 +64,10 @@ function useSampleData(docType: DocumentType, workshop?: PreviewWorkshop, logoUr
       [],
       [],
       t,
       t,
       labels,
       labels,
-      docType
+      docType,
+      sampleFormat
     )
     )
-  }, [docType, workshop, logoUrl, t, messages, typeKeyEnabled])
+  }, [docType, workshop, logoUrl, t, messages, typeKeyEnabled, sampleFormat])
 }
 }
 
 
 export function PresetPreview({
 export function PresetPreview({

+ 64 - 31
src/features/invoice-designer/Components/sample.ts

@@ -1,4 +1,5 @@
 import { toCustomFieldId } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { toCustomFieldId } from '@/features/settings/Schema/invoiceLayoutSchema'
+import { formatCurrency, formatDateForPdf } from '@/lib/format'
 import { calculateTotals } from '@/lib/tax'
 import { calculateTotals } from '@/lib/tax'
 import { taxComponentLabel } from '@/lib/tax-components'
 import { taxComponentLabel } from '@/lib/tax-components'
 import type { DocumentData, LineGroup, PaymentPair, TotalLine } from '../Spec/buildSpec'
 import type { DocumentData, LineGroup, PaymentPair, TotalLine } from '../Spec/buildSpec'
@@ -36,6 +37,25 @@ export function fillTemplate(template: string, values: Record<string, string>):
   return Object.entries(values).reduce((str, [key, val]) => str.replace(`{${key}}`, val), template)
   return Object.entries(values).reduce((str, [key, val]) => str.replace(`{${key}}`, val), template)
 }
 }
 
 
+/**
+ * How the sample writes its money and dates: the workshop's own currency and
+ * date format, the way its printed sheets do, so a workshop in dollars does
+ * not preview euros. Left out, the app's defaults apply.
+ */
+export interface SampleFormat {
+  money: (amount: number) => string
+  date: (date: Date) => string
+}
+
+export const DEFAULT_SAMPLE_FORMAT: SampleFormat = {
+  money: (amount) => formatCurrency(amount),
+  date: (date) => formatDateForPdf(date, undefined, 'UTC'),
+}
+
+/** The sample job's dates, at noon so no time zone moves them to another day. */
+const SAMPLE_DATE = new Date('2026-08-14T12:00:00Z')
+const SAMPLE_DUE = new Date('2026-08-28T12:00:00Z')
+
 export interface SampleTables {
 export interface SampleTables {
   items: {
   items: {
     n: number
     n: number
@@ -47,9 +67,13 @@ export interface SampleTables {
     category?: string
     category?: string
     price: string
     price: string
     total: string
     total: string
+    /** The line total as a number, for the sums built from it. */
+    amount: number
   }[]
   }[]
   findings: { severity: string; color: string; description: string; notes: string }[]
   findings: { severity: string; color: string; description: string; notes: string }[]
   subtotal: string
   subtotal: string
+  /** The subtotal as a number, for a split tax worked out from it. */
+  subtotalAmount: number
   tax: string
   tax: string
   total: string
   total: string
   number: string
   number: string
@@ -61,8 +85,13 @@ export interface SampleTables {
   title: string
   title: string
 }
 }
 
 
-export function sampleTables(t: SampleT, labels: PrintLabels): SampleTables {
+export function sampleTables(
+  t: SampleT,
+  labels: PrintLabels,
+  format: SampleFormat = DEFAULT_SAMPLE_FORMAT
+): SampleTables {
   const L = (key: string, fallback: string) => labels[key] || fallback
   const L = (key: string, fallback: string) => labels[key] || fallback
+  const m = format.money
   return {
   return {
     items: [
     items: [
       {
       {
@@ -70,8 +99,9 @@ export function sampleTables(t: SampleT, labels: PrintLabels): SampleTables {
         qty: '2.5',
         qty: '2.5',
         unit: L('hrs', 'hrs'),
         unit: L('hrs', 'hrs'),
         desc: t('sample.itemLabor'),
         desc: t('sample.itemLabor'),
-        price: '€ 89.00',
-        total: '€ 222.50',
+        price: m(89),
+        total: m(222.5),
+        amount: 222.5,
       },
       },
       {
       {
         n: 2,
         n: 2,
@@ -80,8 +110,9 @@ export function sampleTables(t: SampleT, labels: PrintLabels): SampleTables {
         desc: t('sample.itemDisc'),
         desc: t('sample.itemDisc'),
         sku: 'BD-1042',
         sku: 'BD-1042',
         category: t('sample.categoryBrakes'),
         category: t('sample.categoryBrakes'),
-        price: '€ 149.00',
-        total: '€ 149.00',
+        price: m(149),
+        total: m(149),
+        amount: 149,
       },
       },
       {
       {
         n: 3,
         n: 3,
@@ -90,8 +121,9 @@ export function sampleTables(t: SampleT, labels: PrintLabels): SampleTables {
         desc: t('sample.itemPads'),
         desc: t('sample.itemPads'),
         sku: 'BP-2210',
         sku: 'BP-2210',
         category: t('sample.categoryBrakes'),
         category: t('sample.categoryBrakes'),
-        price: '€ 96.50',
-        total: '€ 96.50',
+        price: m(96.5),
+        total: m(96.5),
+        amount: 96.5,
       },
       },
       {
       {
         n: 4,
         n: 4,
@@ -102,8 +134,9 @@ export function sampleTables(t: SampleT, labels: PrintLabels): SampleTables {
         // category previews two groups rather than one.
         // category previews two groups rather than one.
         sku: 'WS-0001',
         sku: 'WS-0001',
         category: t('sample.categoryConsumables'),
         category: t('sample.categoryConsumables'),
-        price: '€ 12.00',
-        total: '€ 12.00',
+        price: m(12),
+        total: m(12),
+        amount: 12,
       },
       },
     ],
     ],
     findings: [
     findings: [
@@ -120,12 +153,13 @@ export function sampleTables(t: SampleT, labels: PrintLabels): SampleTables {
         notes: '',
         notes: '',
       },
       },
     ],
     ],
-    subtotal: '€ 480.00',
-    tax: '€ 120.00',
-    total: '€ 600.00',
+    subtotal: m(480),
+    subtotalAmount: 480,
+    tax: m(120),
+    total: m(600),
     number: 'INV-2026-0042',
     number: 'INV-2026-0042',
-    date: '14.08.2026',
-    due: '28.08.2026',
+    date: format.date(SAMPLE_DATE),
+    due: format.date(SAMPLE_DUE),
     customerNumber: 'C-0117',
     customerNumber: 'C-0117',
     notes: t('sample.notes'),
     notes: t('sample.notes'),
     warranty: t('sample.warranty'),
     warranty: t('sample.warranty'),
@@ -191,11 +225,6 @@ export function fieldValues(
   }
   }
 }
 }
 
 
-/** The sample's money, written the way its other figures are. */
-function sampleMoney(value: number): string {
-  return `€ ${value.toFixed(2)}`
-}
-
 /**
 /**
  * The tax rows of the sample, and the registration numbers they bring: the
  * The tax rows of the sample, and the registration numbers they bring: the
  * workshop's own split when it has one, taxed on the sample's net subtotal
  * workshop's own split when it has one, taxed on the sample's net subtotal
@@ -205,7 +234,8 @@ function sampleMoney(value: number): string {
 function sampleTax(
 function sampleTax(
   workshop: DesignerWorkshop,
   workshop: DesignerWorkshop,
   sample: SampleTables,
   sample: SampleTables,
-  labels: PrintLabels
+  labels: PrintLabels,
+  format: SampleFormat
 ): { lines: TotalLine[]; total: string; registrations: PaymentPair[] } {
 ): { lines: TotalLine[]; total: string; registrations: PaymentPair[] } {
   const L = (key: string, fallback: string) => labels[key] || fallback
   const L = (key: string, fallback: string) => labels[key] || fallback
   const components = workshop.taxComponents ?? null
   const components = workshop.taxComponents ?? null
@@ -223,9 +253,8 @@ function sampleTax(
       registrations: [],
       registrations: [],
     }
     }
   }
   }
-  const subtotal = Number(sample.subtotal.replace(/[^\d.]/g, ''))
   const totals = calculateTotals({
   const totals = calculateTotals({
-    subtotal,
+    subtotal: sample.subtotalAmount,
     discountAmount: 0,
     discountAmount: 0,
     taxRate: 0,
     taxRate: 0,
     taxInclusive: false,
     taxInclusive: false,
@@ -234,10 +263,10 @@ function sampleTax(
   return {
   return {
     lines: (totals.components ?? []).map((component) => ({
     lines: (totals.components ?? []).map((component) => ({
       label: taxComponentLabel(component),
       label: taxComponentLabel(component),
-      value: sampleMoney(component.amount),
+      value: format.money(component.amount),
       kind: 'line' as const,
       kind: 'line' as const,
     })),
     })),
-    total: sampleMoney(totals.totalAmount),
+    total: format.money(totals.totalAmount),
     registrations: components
     registrations: components
       .filter((component) => component.registrationNumber)
       .filter((component) => component.registrationNumber)
       .map((component) => ({
       .map((component) => ({
@@ -253,11 +282,14 @@ function sampleTax(
  * template cards in settings preview exactly the same paper.
  * template cards in settings preview exactly the same paper.
  */
  */
 /** The sample's line groups, summed from its own rows. */
 /** The sample's line groups, summed from its own rows. */
-function sampleLineGroups(sample: SampleTables, labels: PrintLabels): LineGroup[] {
+function sampleLineGroups(
+  sample: SampleTables,
+  labels: PrintLabels,
+  format: SampleFormat
+): LineGroup[] {
   const L = (key: string, fallback: string) => labels[key] || fallback
   const L = (key: string, fallback: string) => labels[key] || fallback
-  const amount = (total: string) => Number(total.replace(/[^\d.]/g, ''))
   const sum = (rows: SampleTables['items']) =>
   const sum = (rows: SampleTables['items']) =>
-    `€ ${rows.reduce((acc, row) => acc + amount(row.total), 0).toFixed(2)}`
+    format.money(rows.reduce((acc, row) => acc + row.amount, 0))
   const group = (key: string, title: string, rows: SampleTables['items']) => ({
   const group = (key: string, title: string, rows: SampleTables['items']) => ({
     key,
     key,
     title,
     title,
@@ -286,12 +318,13 @@ export function buildSampleData(
   customFields: { id: string; label?: string | null; name: string; isActive: boolean }[],
   customFields: { id: string; label?: string | null; name: string; isActive: boolean }[],
   t: SampleT,
   t: SampleT,
   labels: PrintLabels,
   labels: PrintLabels,
-  docType: DocumentType
+  docType: DocumentType,
+  format: SampleFormat = DEFAULT_SAMPLE_FORMAT
 ): DocumentData {
 ): DocumentData {
   const L = (key: string, fallback: string) => labels[key] || fallback
   const L = (key: string, fallback: string) => labels[key] || fallback
-  const sample = sampleTables(t, labels)
+  const sample = sampleTables(t, labels, format)
   const values = fieldValues(workshop, t, labels)
   const values = fieldValues(workshop, t, labels)
-  const tax = sampleTax(workshop, sample, labels)
+  const tax = sampleTax(workshop, sample, labels, format)
   return {
   return {
     fields: {
     fields: {
       ...values,
       ...values,
@@ -335,7 +368,7 @@ export function buildSampleData(
       })),
       })),
     // The groups a table divided by category shows: the brake parts added
     // The groups a table divided by category shows: the brake parts added
     // up on their own, the consumables after them, the labor first.
     // up on their own, the consumables after them, the labor first.
-    lineGroups: sampleLineGroups(sample, labels),
+    lineGroups: sampleLineGroups(sample, labels, format),
     labor: sample.items
     labor: sample.items
       .filter((item) => !item.sku)
       .filter((item) => !item.sku)
       .map((item) => ({
       .map((item) => ({

+ 25 - 0
src/features/invoice-designer/Components/useSampleFormat.ts

@@ -0,0 +1,25 @@
+'use client'
+
+import { useMemo } from 'react'
+import { useFormatCurrency } from '@/components/currency-settings-context'
+import { useDateSettings } from '@/components/date-settings-context'
+import { formatDateForPdf } from '@/lib/format'
+import type { SampleFormat } from './sample'
+
+/**
+ * The workshop's currency and date format, for the sample sheet, so the
+ * designer previews the money and dates its printed sheets will carry.
+ */
+export function useSampleFormat(): SampleFormat {
+  const formatMoney = useFormatCurrency()
+  const { dateFormat } = useDateSettings()
+  return useMemo(
+    () => ({
+      money: (amount: number) => formatMoney(amount),
+      // The sample's dates are calendar dates, set at noon UTC: read in UTC they
+      // are the same day on the server and in every browser, wherever it is.
+      date: (date: Date) => formatDateForPdf(date, dateFormat, 'UTC'),
+    }),
+    [formatMoney, dateFormat]
+  )
+}

+ 11 - 0
src/lib/timezone.ts

@@ -121,6 +121,17 @@ export function isZonedWeekend(date: Date, timeZone: string): boolean {
   return w === 0 || w === 6
   return w === 0 || w === 6
 }
 }
 
 
+/**
+ * The zone a workshop's times are read in: the one it chose, else the one its
+ * browser was detected in when it saved its settings, else UTC.
+ */
+export function resolveWorkshopTimeZone(
+  explicit: string | null | undefined,
+  detected: string | null | undefined
+): string {
+  return safeTimeZone(explicit?.trim() || detected?.trim() || null)
+}
+
 /** A zone the runtime accepts, or the fallback when the setting is empty or misspelt. */
 /** A zone the runtime accepts, or the fallback when the setting is empty or misspelt. */
 export function safeTimeZone(value: string | null | undefined, fallback = 'UTC'): string {
 export function safeTimeZone(value: string | null | undefined, fallback = 'UTC'): string {
   if (!value) return fallback
   if (!value) return fallback

+ 3 - 8
src/lib/workshop-timezone.ts

@@ -1,6 +1,8 @@
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
-import { safeTimeZone } from '@/lib/timezone'
+import { resolveWorkshopTimeZone } from '@/lib/timezone'
+
+export { resolveWorkshopTimeZone }
 
 
 /**
 /**
  * The zone server code reads workshop times in.
  * The zone server code reads workshop times in.
@@ -10,13 +12,6 @@ import { safeTimeZone } from '@/lib/timezone'
  * so a workshop that never chose a zone still gets the right one here rather
  * so a workshop that never chose a zone still gets the right one here rather
  * than UTC. An explicit choice always wins.
  * than UTC. An explicit choice always wins.
  */
  */
-export function resolveWorkshopTimeZone(
-  explicit: string | null | undefined,
-  detected: string | null | undefined
-): string {
-  return safeTimeZone(explicit?.trim() || detected?.trim() || null)
-}
-
 export async function workshopTimeZone(organizationId: string): Promise<string> {
 export async function workshopTimeZone(organizationId: string): Promise<string> {
   const rows = await db.appSetting.findMany({
   const rows = await db.appSetting.findMany({
     where: { organizationId, key: { in: [SETTING_KEYS.TIMEZONE, SETTING_KEYS.TIMEZONE_DETECTED] } },
     where: { organizationId, key: { in: [SETTING_KEYS.TIMEZONE, SETTING_KEYS.TIMEZONE_DETECTED] } },