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

Set a document's logo from the designer, and let it differ from the app's (#280)

* Swap the logo where the logo is, and offer one in the footer

Changing a logo meant leaving the designer for the company settings page and
losing whatever layout was part way through, which is the wrong moment to be
sent away: somebody adjusting the letterhead can see the file is wrong
precisely because it is in front of them. The header's Logo group now
uploads and clears through the same endpoint and the same setting that page
uses, so the two stay one picture rather than two.

The upload is written straight away rather than waiting on Save. The logo
belongs to the workshop, not to this layout, and somebody who swaps one and
closes the tab has still swapped their logo.

The footer can print it too, for shops whose stationery carries the mark
along the bottom as well as the top. Off by default like the rest of the
footer's details, so no existing sheet grows a second logo on deploy, and
the footer offers the same swap rather than sending somebody back up to the
header to change a picture they are looking at down here.

* Show a field the layout was written before

The footer's new Logo switch was missing from the very panel that told
people to use it. Two paths put a layout in the editor and only one of them
accounted for fields added since it was written: a stored layout is merged
with the defaults on load, but a saved design is applied exactly as it was
saved, so anything built after that design was named had no switch and read
as simply absent.

Saved designs are now merged the same way stored layouts are, which also
gets them sections added since, not just fields. The inspector separately
lists any builtin field the layout does not carry, switched off, so a field
can never again be unreachable because of how the layout happened to arrive.

* Let the paperwork carry its own logo

Uploading a logo in the designer changed the badge in the sidebar, because
there was only ever one logo. A workshop's paperwork does not always want
what the app wears: a wider version for a letterhead, or one with the
address set into it.

Invoices and quotes may now each carry their own mark, kept apart because
everything else about their appearance already is, and a document with none
prints the company logo exactly as every sheet did before the two could
differ. One resolver answers that for the PDF routes, the shared links and
the emails alike, so none of them can drift into printing a different
picture from the designer's preview.

Clearing the mark in the designer returns the document to the company logo
rather than deleting anything: the company logo belongs to the app, not to
a sheet, and is only editable where it always was.

* Keep the use server directive at the top of the mail actions

A directive has to be the first thing in the file, and the import added for
the logo resolver landed above it, which stopped the module compiling at
all.
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
7cba3e39bf
28 измененных файлов с 465 добавлено и 30 удалено
  1. 9 1
      messages/de/settings.json
  2. 9 1
      messages/en/settings.json
  3. 10 2
      messages/es/settings.json
  4. 9 1
      messages/fr/settings.json
  5. 9 1
      messages/it/settings.json
  6. 9 1
      messages/lt/settings.json
  7. 9 1
      messages/nb/settings.json
  8. 9 1
      messages/nl/settings.json
  9. 9 1
      messages/pl/settings.json
  10. 10 2
      messages/pt-BR/settings.json
  11. 9 1
      messages/ru/settings.json
  12. 9 1
      messages/tr/settings.json
  13. 43 1
      src/__tests__/features/settings/designer-flow.test.ts
  14. 68 0
      src/__tests__/features/settings/document-logo.test.ts
  15. 27 0
      src/__tests__/features/settings/spec-pdf-print.test.tsx
  16. 1 0
      src/app/(designer)/invoice-designer/page.tsx
  17. 3 1
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  18. 3 1
      src/app/(public)/share/quote/[orgId]/[token]/page.tsx
  19. 2 1
      src/app/api/protected/quotes/[id]/pdf/route.ts
  20. 3 2
      src/app/api/protected/services/[id]/pdf/route.ts
  21. 5 2
      src/features/email/Actions/emailActions.ts
  22. 133 6
      src/features/invoice-designer/Components/DesignerInspector.tsx
  23. 17 2
      src/features/invoice-designer/Components/InvoiceDesigner.tsx
  24. 6 0
      src/features/invoice-designer/Components/types.ts
  25. 24 0
      src/features/invoice-designer/Lib/documentLogo.ts
  26. 13 0
      src/features/invoice-designer/Spec/buildSpec.ts
  27. 3 0
      src/features/settings/Schema/invoiceLayoutSchema.ts
  28. 4 0
      src/features/settings/Schema/settingsSchema.ts

+ 9 - 1
messages/de/settings.json

@@ -1868,7 +1868,15 @@
       "slogan": "Qualität, auf die Sie sich verlassen können",
       "footerNote": "Vielen Dank für Ihren Auftrag",
       "value": "Beispiel"
-    }
+    },
+    "logoUpload": "Logo hochladen",
+    "logoReplace": "Logo ersetzen",
+    "logoUploading": "Wird hochgeladen…",
+    "logoFailed": "Das Bild konnte nicht hochgeladen werden",
+    "footerLogoHint": "Schalten Sie das Logo unter Felder ein, um es auch am Seitenfuß zu drucken.",
+    "logoReset": "Firmenlogo verwenden",
+    "logoOwn": "Wird nur auf diesem Dokument gedruckt. Das Firmenlogo bleibt unverändert.",
+    "logoInherited": "Es wird das Firmenlogo verwendet. Laden Sie hier eines hoch, um nur auf diesem Dokument ein anderes zu verwenden."
   },
   "preview": {
     "title": "Bremsenservice und Ölwechsel",

+ 9 - 1
messages/en/settings.json

@@ -1868,7 +1868,15 @@
       "slogan": "Quality service you can trust",
       "footerNote": "Thank you for your business",
       "value": "Sample"
-    }
+    },
+    "logoUpload": "Upload logo",
+    "logoReplace": "Replace logo",
+    "logoUploading": "Uploading…",
+    "logoFailed": "Could not upload that image",
+    "footerLogoHint": "Switch the logo on under Fields to print it at the foot of the page too.",
+    "logoReset": "Use the company logo",
+    "logoOwn": "Printed on this document only. The company logo is unchanged.",
+    "logoInherited": "Using the company logo. Upload one here to use a different mark on this document only."
   },
   "preview": {
     "title": "Brake Service & Oil Change",

+ 10 - 2
messages/es/settings.json

@@ -608,7 +608,7 @@
       "service_title": "Título del servicio",
       "service_type": "Tipo de servicio",
       "tech_name": "Técnico",
-      "logo": "Logo",
+      "logo": "Logotipo",
       "company_name": "Nombre de empresa",
       "company_slogan": "Eslogan",
       "company_address": "Dirección",
@@ -1868,7 +1868,15 @@
       "slogan": "Calidad de servicio en la que puedes confiar",
       "footerNote": "Gracias por confiar en nosotros",
       "value": "Ejemplo"
-    }
+    },
+    "logoUpload": "Subir logotipo",
+    "logoReplace": "Cambiar logotipo",
+    "logoUploading": "Subiendo…",
+    "logoFailed": "No se pudo subir la imagen",
+    "footerLogoHint": "Activa el logotipo en Campos para imprimirlo también al pie de la página.",
+    "logoReset": "Usar el logotipo de la empresa",
+    "logoOwn": "Se imprime solo en este documento. El logotipo de la empresa no cambia.",
+    "logoInherited": "Se usa el logotipo de la empresa. Sube uno aquí para usar otro solo en este documento."
   },
   "preview": {
     "title": "Servicio de frenos y cambio de aceite",

+ 9 - 1
messages/fr/settings.json

@@ -1868,7 +1868,15 @@
       "slogan": "Un service de qualité en toute confiance",
       "footerNote": "Merci de votre confiance",
       "value": "Exemple"
-    }
+    },
+    "logoUpload": "Importer un logo",
+    "logoReplace": "Remplacer le logo",
+    "logoUploading": "Import en cours…",
+    "logoFailed": "Impossible d'importer cette image",
+    "footerLogoHint": "Activez le logo dans Champs pour l'imprimer aussi en bas de page.",
+    "logoReset": "Utiliser le logo de l'entreprise",
+    "logoOwn": "Imprimé sur ce document uniquement. Le logo de l'entreprise reste inchangé.",
+    "logoInherited": "Le logo de l'entreprise est utilisé. Importez-en un ici pour en utiliser un autre sur ce document seulement."
   },
   "preview": {
     "title": "Révision des freins et vidange",

+ 9 - 1
messages/it/settings.json

@@ -1868,7 +1868,15 @@
       "slogan": "Un servizio di qualità di cui fidarsi",
       "footerNote": "Grazie per averci scelto",
       "value": "Esempio"
-    }
+    },
+    "logoUpload": "Carica logo",
+    "logoReplace": "Sostituisci logo",
+    "logoUploading": "Caricamento…",
+    "logoFailed": "Impossibile caricare l'immagine",
+    "footerLogoHint": "Attiva il logo in Campi per stamparlo anche a piè di pagina.",
+    "logoReset": "Usa il logo aziendale",
+    "logoOwn": "Stampato solo su questo documento. Il logo aziendale resta invariato.",
+    "logoInherited": "Viene usato il logo aziendale. Caricane uno qui per usarne un altro solo su questo documento."
   },
   "preview": {
     "title": "Servizio freni e cambio olio",

+ 9 - 1
messages/lt/settings.json

@@ -1868,7 +1868,15 @@
       "slogan": "Kokybiškas aptarnavimas, kuriuo galite pasitikėti",
       "footerNote": "Ačiū, kad renkatės mus",
       "value": "Pavyzdys"
-    }
+    },
+    "logoUpload": "Įkelti logotipą",
+    "logoReplace": "Pakeisti logotipą",
+    "logoUploading": "Įkeliama…",
+    "logoFailed": "Nepavyko įkelti vaizdo",
+    "footerLogoHint": "Įjunkite logotipą skiltyje Laukai, kad jis būtų spausdinamas ir puslapio apačioje.",
+    "logoReset": "Naudoti įmonės logotipą",
+    "logoOwn": "Spausdinamas tik šiame dokumente. Įmonės logotipas nekeičiamas.",
+    "logoInherited": "Naudojamas įmonės logotipas. Įkelkite kitą čia, kad jis būtų naudojamas tik šiame dokumente."
   },
   "preview": {
     "title": "Stabdžių aptarnavimas ir tepalų keitimas",

+ 9 - 1
messages/nb/settings.json

@@ -1868,7 +1868,15 @@
       "slogan": "Kvalitetsservice du kan stole på",
       "footerNote": "Takk for handelen",
       "value": "Eksempel"
-    }
+    },
+    "logoUpload": "Last opp logo",
+    "logoReplace": "Bytt logo",
+    "logoUploading": "Laster opp…",
+    "logoFailed": "Kunne ikke laste opp bildet",
+    "footerLogoHint": "Slå på logoen under Felter for å skrive den ut nederst på siden også.",
+    "logoReset": "Bruk firmalogoen",
+    "logoOwn": "Skrives bare ut på dette dokumentet. Firmalogoen er uendret.",
+    "logoInherited": "Bruker firmalogoen. Last opp en her for å bruke en annen logo bare på dette dokumentet."
   },
   "preview": {
     "title": "Bremseservice og oljeskift",

+ 9 - 1
messages/nl/settings.json

@@ -1868,7 +1868,15 @@
       "slogan": "Kwaliteit waar u op kunt bouwen",
       "footerNote": "Bedankt voor uw opdracht",
       "value": "Voorbeeld"
-    }
+    },
+    "logoUpload": "Logo uploaden",
+    "logoReplace": "Logo vervangen",
+    "logoUploading": "Uploaden…",
+    "logoFailed": "Kon die afbeelding niet uploaden",
+    "footerLogoHint": "Zet het logo aan bij Velden om het ook onderaan de pagina af te drukken.",
+    "logoReset": "Bedrijfslogo gebruiken",
+    "logoOwn": "Wordt alleen op dit document afgedrukt. Het bedrijfslogo blijft ongewijzigd.",
+    "logoInherited": "Het bedrijfslogo wordt gebruikt. Upload er hier een om alleen op dit document een ander te gebruiken."
   },
   "preview": {
     "title": "Remservice en oliewissel",

+ 9 - 1
messages/pl/settings.json

@@ -1868,7 +1868,15 @@
       "slogan": "Jakość, której możesz zaufać",
       "footerNote": "Dziękujemy za współpracę",
       "value": "Przykład"
-    }
+    },
+    "logoUpload": "Prześlij logo",
+    "logoReplace": "Zmień logo",
+    "logoUploading": "Przesyłanie…",
+    "logoFailed": "Nie udało się przesłać obrazu",
+    "footerLogoHint": "Włącz logo w sekcji Pola, aby drukować je także na dole strony.",
+    "logoReset": "Użyj logo firmy",
+    "logoOwn": "Drukowane tylko na tym dokumencie. Logo firmy pozostaje bez zmian.",
+    "logoInherited": "Używane jest logo firmy. Prześlij tutaj inne, aby użyć go tylko na tym dokumencie."
   },
   "preview": {
     "title": "Serwis hamulców i wymiana oleju",

+ 10 - 2
messages/pt-BR/settings.json

@@ -608,7 +608,7 @@
       "service_title": "Título do serviço",
       "service_type": "Tipo de serviço",
       "tech_name": "Técnico",
-      "logo": "Logo",
+      "logo": "Logotipo",
       "company_name": "Nome da empresa",
       "company_slogan": "Slogan",
       "company_address": "Endereço",
@@ -1868,7 +1868,15 @@
       "slogan": "Serviço de qualidade em que você pode confiar",
       "footerNote": "Obrigado pela preferência",
       "value": "Exemplo"
-    }
+    },
+    "logoUpload": "Enviar logotipo",
+    "logoReplace": "Substituir logotipo",
+    "logoUploading": "Enviando…",
+    "logoFailed": "Não foi possível enviar a imagem",
+    "footerLogoHint": "Ative o logotipo em Campos para imprimi-lo também no rodapé.",
+    "logoReset": "Usar o logotipo da empresa",
+    "logoOwn": "Impresso somente neste documento. O logotipo da empresa não muda.",
+    "logoInherited": "Usando o logotipo da empresa. Envie um aqui para usar outro somente neste documento."
   },
   "preview": {
     "title": "Serviço de freios e troca de óleo",

+ 9 - 1
messages/ru/settings.json

@@ -1868,7 +1868,15 @@
       "slogan": "Качественный сервис, которому можно доверять",
       "footerNote": "Благодарим за обращение",
       "value": "Пример"
-    }
+    },
+    "logoUpload": "Загрузить логотип",
+    "logoReplace": "Заменить логотип",
+    "logoUploading": "Загрузка…",
+    "logoFailed": "Не удалось загрузить изображение",
+    "footerLogoHint": "Включите логотип в разделе «Поля», чтобы печатать его и внизу страницы.",
+    "logoReset": "Использовать логотип компании",
+    "logoOwn": "Печатается только в этом документе. Логотип компании не меняется.",
+    "logoInherited": "Используется логотип компании. Загрузите другой, чтобы применить его только к этому документу."
   },
   "preview": {
     "title": "Обслуживание тормозов и замена масла",

+ 9 - 1
messages/tr/settings.json

@@ -1868,7 +1868,15 @@
       "slogan": "Güvenebileceğiniz kaliteli hizmet",
       "footerNote": "İş birliğiniz için teşekkürler",
       "value": "Örnek"
-    }
+    },
+    "logoUpload": "Logo yükle",
+    "logoReplace": "Logoyu değiştir",
+    "logoUploading": "Yükleniyor…",
+    "logoFailed": "Görsel yüklenemedi",
+    "footerLogoHint": "Sayfanın altında da yazdırmak için Alanlar bölümünden logoyu açın.",
+    "logoReset": "Şirket logosunu kullan",
+    "logoOwn": "Yalnızca bu belgede yazdırılır. Şirket logosu değişmez.",
+    "logoInherited": "Şirket logosu kullanılıyor. Yalnızca bu belgede farklı bir logo kullanmak için buraya yükleyin."
   },
   "preview": {
     "title": "Fren bakımı ve yağ değişimi",

+ 43 - 1
src/__tests__/features/settings/designer-flow.test.ts

@@ -4,7 +4,11 @@
  */
 import { describe, expect, it } from 'vitest'
 import { groupFlowBlocks, type Block } from '@/features/invoice-designer/Spec/documentSpec'
-import { materializeHiddenSection } from '@/features/settings/Schema/invoiceLayoutSchema'
+import {
+  getBuiltinFieldsForSection,
+  materializeHiddenSection,
+  mergeWithDefaults,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
 
 const block = (id: string, order: number, column?: 'left' | 'right'): Block => ({
   id,
@@ -108,3 +112,41 @@ describe('flow rows', () => {
     ).toEqual(['customer'])
   })
 })
+
+/**
+ * A layout written before a field existed.
+ *
+ * Both ways a layout reaches the editor have to account for it: stored layouts
+ * are merged on load, but a saved design is applied exactly as it was written,
+ * so a field added since would have no switch and would read as missing.
+ */
+describe('fields added after a layout was written', () => {
+  it('offers every builtin field a switch, whatever the layout carries', () => {
+    const builtins = getBuiltinFieldsForSection('footer').map((f) => f.id)
+    // A footer as it was stored before the logo field was added.
+    const stored = [
+      { id: 'footer_note', visible: true },
+      { id: 'company_name', visible: false },
+    ]
+    const builtinIds = new Set(builtins)
+    const kept = stored.filter((f) => builtinIds.has(f.id))
+    const resolved = [
+      ...kept,
+      ...builtins
+        .filter((id) => !kept.some((f) => f.id === id))
+        .map((id) => ({ id, visible: false })),
+    ]
+    expect(resolved.map((f) => f.id)).toEqual(expect.arrayContaining(builtins))
+    expect(resolved.find((f) => f.id === 'logo')?.visible).toBe(false)
+  })
+
+  it('merges an old saved design so it gains what shipped since', () => {
+    const design = {
+      sections: [
+        { id: 'footer', visible: true, order: 12, fields: [{ id: 'footer_note', visible: true }] },
+      ],
+    }
+    const footer = mergeWithDefaults(design as never).sections.find((s) => s.id === 'footer')
+    expect(footer?.fields?.some((f) => f.id === 'logo')).toBe(true)
+  })
+})

+ 68 - 0
src/__tests__/features/settings/document-logo.test.ts

@@ -0,0 +1,68 @@
+/**
+ * Which logo a document prints.
+ *
+ * A workshop's paperwork does not always want the badge the app wears, so a
+ * document may carry its own. The failure this guards is the one that started
+ * it: uploading a letterhead in the designer and finding the logo in the
+ * sidebar had changed too.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+const COMPANY = '/uploads/company.png'
+const INVOICE = '/uploads/invoice.png'
+const QUOTE = '/uploads/quote.png'
+
+describe('resolving a document logo', () => {
+  it('falls back to the company logo when the document has none', () => {
+    const settings = { [SETTING_KEYS.COMPANY_LOGO]: COMPANY }
+    expect(documentLogoPath(settings, 'invoice')).toBe(COMPANY)
+    expect(documentLogoPath(settings, 'quote')).toBe(COMPANY)
+  })
+
+  it('prefers the document’s own mark when it has one', () => {
+    expect(
+      documentLogoPath(
+        { [SETTING_KEYS.COMPANY_LOGO]: COMPANY, [SETTING_KEYS.INVOICE_LOGO]: INVOICE },
+        'invoice'
+      )
+    ).toBe(INVOICE)
+  })
+
+  it('keeps invoices and quotes apart', () => {
+    // Everything else about their appearance already is separate.
+    const settings = {
+      [SETTING_KEYS.COMPANY_LOGO]: COMPANY,
+      [SETTING_KEYS.INVOICE_LOGO]: INVOICE,
+      [SETTING_KEYS.QUOTE_LOGO]: QUOTE,
+    }
+    expect(documentLogoPath(settings, 'invoice')).toBe(INVOICE)
+    expect(documentLogoPath(settings, 'quote')).toBe(QUOTE)
+  })
+
+  it('does not let one document’s mark leak onto the other', () => {
+    const settings = {
+      [SETTING_KEYS.COMPANY_LOGO]: COMPANY,
+      [SETTING_KEYS.INVOICE_LOGO]: INVOICE,
+    }
+    expect(documentLogoPath(settings, 'quote')).toBe(COMPANY)
+  })
+
+  it('treats a blank or whitespace value as unset', () => {
+    // A cleared field stores an empty string rather than removing the row, and
+    // an empty logo path would print nothing at all instead of falling back.
+    expect(
+      documentLogoPath(
+        { [SETTING_KEYS.COMPANY_LOGO]: COMPANY, [SETTING_KEYS.INVOICE_LOGO]: '  ' },
+        'invoice'
+      )
+    ).toBe(COMPANY)
+  })
+
+  it('returns nothing when neither exists, rather than a broken path', () => {
+    expect(documentLogoPath({}, 'invoice')).toBe('')
+    expect(documentLogoPath({ [SETTING_KEYS.COMPANY_LOGO]: null }, 'quote')).toBe('')
+  })
+})

+ 27 - 0
src/__tests__/features/settings/spec-pdf-print.test.tsx

@@ -279,6 +279,33 @@ describe('the printed invoice follows the designed layout', () => {
     expect(loaded.page.margin.top).toBeGreaterThan(loaded.frame?.bandHeight ?? 0)
   })
 
+  it('prints the logo at the foot only when the footer asks for it', () => {
+    // Off by default, like the rest of the footer's details: a sheet that has
+    // always shown one line must not grow a second logo on deploy.
+    const withFooterLogo = (visible: boolean) => {
+      const layout = getDefaultInvoiceLayout()
+      layout.sections = layout.sections.map((section) =>
+        section.id === 'footer'
+          ? {
+              ...section,
+              fields: (section.fields ?? []).map((f) => (f.id === 'logo' ? { ...f, visible } : f)),
+            }
+          : section
+      )
+      const spec = buildInvoicePrintSpec({
+        data: invoice,
+        workshop,
+        invoiceSettings: settings,
+        logoDataUri: 'data:image/png;base64,AAAA',
+        template: { layoutConfig: { ...layout, version: DESIGNER_LAYOUT_VERSION } },
+      })
+      return JSON.stringify(spec.blocks.find((b) => b.id === 'footer'))
+    }
+
+    expect(withFooterLogo(false)).not.toContain('footer.logo')
+    expect(withFooterLogo(true)).toContain('footer.logo')
+  })
+
   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.

+ 1 - 0
src/app/(designer)/invoice-designer/page.tsx

@@ -82,6 +82,7 @@ export default async function InvoiceDesignerPage({
     fontFamily: settings[`${prefix}.fontFamily`] || 'Helvetica',
     headerStyle: settings[`${prefix}.headerStyle`] || 'standard',
     logoSize: Number(settings[`${prefix}.logoSize`]) || 100,
+    logoUrl: settings[`${prefix}.logo`] || '',
   })
 
   return (

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

@@ -1,3 +1,4 @@
+import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { db } from '@/lib/db'
 import { notFound } from 'next/navigation'
 import { InvoiceView } from './invoice-view'
@@ -107,6 +108,7 @@ export default async function PublicInvoicePage({
             'workshop.email',
             'workshop.slogan',
             'workshop.logo',
+            'invoice.logo',
             'workshop.unitSystem',
             'workshop.currencyCode',
             'workshop.currencyFormat',
@@ -207,7 +209,7 @@ export default async function PublicInvoicePage({
 
   const showLogo = settingsMap['invoice.showLogo'] !== 'false'
   const showCompanyName = settingsMap['invoice.showCompanyName'] !== 'false'
-  const rawLogoUrl = settingsMap['workshop.logo'] || ''
+  const rawLogoUrl = documentLogoPath(settingsMap, 'invoice')
   const logoUrl = rawLogoUrl ? toPublicFileUrl(rawLogoUrl, token) : ''
 
   // Determine which online payment providers are enabled for this org

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

@@ -1,3 +1,4 @@
+import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { db } from '@/lib/db'
 import { notFound } from 'next/navigation'
 import { QuoteView } from './quote-view'
@@ -84,6 +85,7 @@ export default async function PublicQuotePage({
             'workshop.email',
             'workshop.slogan',
             'workshop.logo',
+            'quote.logo',
             'workshop.currencyCode',
             'workshop.currencyFormat',
             'workshop.dateFormat',
@@ -160,7 +162,7 @@ export default async function PublicQuotePage({
     settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol'
 
   // Rewrite logo URL for public access
-  const rawLogoUrl = settingsMap['workshop.logo'] || ''
+  const rawLogoUrl = documentLogoPath(settingsMap, 'quote')
   let logoUrl = ''
   if (rawLogoUrl) {
     const match = rawLogoUrl.match(/^\/api\/files\/[^/]+\/(.+)$/)

+ 2 - 1
src/app/api/protected/quotes/[id]/pdf/route.ts

@@ -1,3 +1,4 @@
+import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { NextResponse } from 'next/server'
 import { renderToBuffer } from '@react-pdf/renderer'
 import '@/features/vehicles/Components/invoice-pdf/fonts'
@@ -91,7 +92,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
     }
 
     let logoDataUri: string | undefined
-    const logoPath = settingsMap['workshop.logo']
+    const logoPath = documentLogoPath(settingsMap, 'quote')
     if (logoPath) {
       try {
         const fullPath = resolveUploadPath(logoPath)

+ 3 - 2
src/app/api/protected/services/[id]/pdf/route.ts

@@ -11,6 +11,7 @@ import { PDFDocument } from 'pdf-lib'
 import { resolveUploadPath } from '@/lib/resolve-upload-path'
 import { getFeatures } from '@/lib/features'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
+import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { formatDateForPdf } from '@/lib/format'
 import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { markInvoiceIssued } from '@/features/onboarding/Lib/markInvoiceIssued'
@@ -193,9 +194,9 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
       }
     }
 
-    // Load company logo as base64 data URI
+    // The document's own mark when it has one, the company logo otherwise.
     let logoDataUri: string | undefined
-    const logoPath = settingsMap['workshop.logo']
+    const logoPath = documentLogoPath(settingsMap, 'invoice')
     if (logoPath) {
       try {
         const fullPath = resolveUploadPath(logoPath)

+ 5 - 2
src/features/email/Actions/emailActions.ts

@@ -1,5 +1,6 @@
 'use server'
 
+import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { db } from '@/lib/db'
 import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
 import { withAuth } from '@/lib/with-auth'
@@ -33,6 +34,8 @@ async function getWorkshopSettings(organizationId: string) {
             'workshop.email',
             'workshop.slogan',
             'workshop.logo',
+            'invoice.logo',
+            'quote.logo',
             'workshop.currencyCode',
             'workshop.currencyFormat',
             'workshop.emailFromName',
@@ -132,7 +135,7 @@ export async function sendQuoteEmail(input: {
         throw new Error('Email sending is disabled. Enable it in Settings.')
       }
 
-      const logoDataUri = await loadLogoDataUri(settings['workshop.logo'])
+      const logoDataUri = await loadLogoDataUri(documentLogoPath(settings, 'quote'))
       const currencyCode = settings['workshop.currencyCode'] || 'USD'
       const currencyFormat: 'symbol' | 'code' =
         settings['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol'
@@ -316,7 +319,7 @@ export async function sendInvoiceEmail(input: {
         throw new Error('Email sending is disabled. Enable it in Settings.')
       }
 
-      const logoDataUri = await loadLogoDataUri(settings['workshop.logo'])
+      const logoDataUri = await loadLogoDataUri(documentLogoPath(settings, 'invoice'))
       const currencyCode = settings['workshop.currencyCode'] || 'USD'
       const currencyFormat: 'symbol' | 'code' =
         settings['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol'

+ 133 - 6
src/features/invoice-designer/Components/DesignerInspector.tsx

@@ -1,7 +1,8 @@
 'use client'
 
-import type { ReactNode } from 'react'
+import { useRef, useState, type ReactNode } from 'react'
 import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
 import type {
   InvoiceDocumentStyle,
   InvoiceLayoutConfig,
@@ -125,6 +126,106 @@ function Color({
   )
 }
 
+/**
+ * Give this document a mark of its own, without leaving the designer.
+ *
+ * A workshop's paperwork does not always want the badge the app wears: a
+ * wider version for a letterhead, or one with the address set into it. So
+ * what is uploaded here belongs to this document, and the company logo is
+ * what prints until something is. Changing it here never touches the picture
+ * in the sidebar, which is the surprise this exists to avoid.
+ *
+ * Put where the logo is being looked at, because somebody adjusting the
+ * letterhead can see the file is wrong precisely because it is in front of
+ * them, and sending them to another screen loses the layout in progress.
+ */
+function LogoUpload({
+  value,
+  own,
+  onChange,
+}: {
+  value: string
+  own: boolean
+  onChange: (url: string) => void
+}) {
+  const t = useTranslations('settings.designer')
+  const input = useRef<HTMLInputElement>(null)
+  const [busy, setBusy] = useState(false)
+
+  const upload = async (file: File) => {
+    setBusy(true)
+    try {
+      const body = new FormData()
+      body.append('file', file)
+      const res = await fetch('/api/protected/upload/logo', { method: 'POST', body })
+      if (!res.ok) {
+        const error = await res.json().catch(() => ({}))
+        toast.error(error.error || t('logoFailed'))
+        return
+      }
+      const { url } = await res.json()
+      onChange(url)
+    } catch {
+      toast.error(t('logoFailed'))
+    } finally {
+      setBusy(false)
+      // Cleared so choosing the same file again still fires a change.
+      if (input.current) input.current.value = ''
+    }
+  }
+
+  return (
+    <div className="space-y-2">
+      <div className="flex items-center gap-2.5">
+        <div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-[6px] border border-[#e3e5e9] bg-white">
+          {value ? (
+            // Not next/image: an uploaded URL the loader cannot size.
+            <img src={value} alt="" className="max-h-full max-w-full object-contain" />
+          ) : (
+            <span className="text-[15px] text-[#c3c7cd]">◫</span>
+          )}
+        </div>
+        <div className="flex min-w-0 flex-1 gap-1.5">
+          <button
+            type="button"
+            disabled={busy}
+            onClick={() => input.current?.click()}
+            className="flex-1 rounded-[6px] border border-[#d7dade] bg-white px-2 py-1.5 text-[12.5px] font-medium hover:bg-[#f6f7f8] disabled:opacity-60"
+          >
+            {busy ? t('logoUploading') : value ? t('logoReplace') : t('logoUpload')}
+          </button>
+          {/* Clearing this document's own mark returns it to the company
+              logo; it never deletes the company logo itself, which belongs
+              to the app rather than to any sheet. */}
+          {own && (
+            <button
+              type="button"
+              disabled={busy}
+              onClick={() => onChange('')}
+              className="rounded-[6px] border border-[#d7dade] bg-white px-2 py-1.5 text-[12.5px] text-[#8a8f97] hover:text-[#dc2626] disabled:opacity-60"
+            >
+              {t('logoReset')}
+            </button>
+          )}
+        </div>
+      </div>
+      <p className="text-[11.5px] leading-snug text-[#8a8f97]">
+        {own ? t('logoOwn') : t('logoInherited')}
+      </p>
+      <input
+        ref={input}
+        type="file"
+        accept="image/*"
+        className="hidden"
+        onChange={(event) => {
+          const file = event.target.files?.[0]
+          if (file) void upload(file)
+        }}
+      />
+    </div>
+  )
+}
+
 function Slider({
   label,
   value,
@@ -211,6 +312,9 @@ export function DesignerInspector({
   onSectionStyle,
   onDocument,
   onTemplate,
+  logoUrl,
+  ownLogo,
+  onLogo,
 }: {
   layout: InvoiceLayoutConfig
   template: DesignerTemplate
@@ -223,6 +327,11 @@ export function DesignerInspector({
   onSectionStyle: (id: string, style: InvoiceSectionStyle | undefined) => void
   onDocument: (patch: InvoiceDocumentStyle) => void
   onTemplate: (patch: Partial<DesignerTemplate>) => void
+  /** The picture this document will print, whichever it comes from. */
+  logoUrl: string
+  /** Whether that picture is this document's own rather than the company's. */
+  ownLogo: boolean
+  onLogo: (url: string) => void
 }) {
   const t = useTranslations('settings.designer')
   const tSection = useTranslations('settings.layoutEditor.sections')
@@ -252,12 +361,19 @@ export function DesignerInspector({
      * The fields this section shows, resolved the way the generator resolves
      * them: no list of its own means every built-in field, visible.
      */
-    const builtinIds = new Set(getBuiltinFieldsForSection(section.id).map((f) => f.id))
-    const resolvedFields = (
-      section.fields ??
-      getBuiltinFieldsForSection(section.id).map((f) => ({ id: f.id, visible: true }))
-    ) // A stored id no builtin list carries any more is a leftover, not a field.
+    const builtins = getBuiltinFieldsForSection(section.id)
+    const builtinIds = new Set(builtins.map((f) => f.id))
+    const stored = (section.fields ?? builtins.map((f) => ({ id: f.id, visible: true }))) // A stored id no builtin list carries any more is a leftover, not a field.
       .filter((f) => isCustomFieldId(f.id) || builtinIds.has(f.id))
+    // A field added after this layout was written still needs its switch.
+    // Without this a saved design, which is loaded as it was stored, can never
+    // reach anything built since, and the option looks simply missing.
+    const resolvedFields = [
+      ...stored,
+      ...builtins
+        .filter((f) => !stored.some((existing) => existing.id === f.id))
+        .map((f) => ({ id: f.id, visible: false })),
+    ]
     const setFields = (fields: { id: string; visible: boolean }[]) =>
       onSection(section.id, { fields })
     const setStyle = (patch: InvoiceSectionStyle) => {
@@ -388,6 +504,7 @@ export function DesignerInspector({
 
           {section.id === 'header' && (
             <Group title={t('logo')}>
+              <LogoUpload value={logoUrl} own={ownLogo} onChange={onLogo} />
               {/* The logo is printed by the header, so its size is set where
                   the header is rather than in a list of sheet properties. */}
               <Slider
@@ -402,6 +519,16 @@ export function DesignerInspector({
             </Group>
           )}
 
+          {/* The footer prints the same logo when it is switched on below, so
+              it offers the same swap rather than sending somebody to the
+              header to change a picture they are looking at down here. */}
+          {section.id === 'footer' && (
+            <Group title={t('logo')}>
+              <LogoUpload value={logoUrl} own={ownLogo} onChange={onLogo} />
+              <p className="text-[11.5px] leading-snug text-[#8a8f97]">{t('footerLogoHint')}</p>
+            </Group>
+          )}
+
           {SECTIONS_WITH_FIELDS.has(section.id) && (
             <Group title={t('fields')}>
               {resolvedFields.map((field) => (

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

@@ -22,6 +22,7 @@ import {
   DESIGNER_LAYOUT_VERSION,
   getDefaultInvoiceLayout,
   materializeHiddenSection,
+  mergeWithDefaults,
   toCustomFieldId,
   type InvoiceDocumentStyle,
   type InvoiceLayoutConfig,
@@ -116,7 +117,7 @@ export function InvoiceDesigner({
   initialSavedDesigns = [],
   initialPresetId,
   initialActiveDesigns,
-  workshop,
+  workshop: companyWorkshop,
   customFields,
 }: {
   initialDocumentType: DocumentType
@@ -198,6 +199,13 @@ export function InvoiceDesigner({
 
   const layout = layouts[docType]
   const template = templates[docType]
+  // What this document actually prints: its own mark when it has one, the
+  // company logo otherwise. The same fallback the print routes apply, so the
+  // canvas cannot promise a picture the paper will not carry.
+  const workshop = useMemo(
+    () => ({ ...companyWorkshop, logoUrl: template.logoUrl || companyWorkshop.logoUrl || '' }),
+    [companyWorkshop, template.logoUrl]
+  )
 
   /**
    * The labels the printed sheet uses, resolved from the same `pdf.json` the
@@ -379,7 +387,10 @@ export function InvoiceDesigner({
   /** Bring a saved design back, onto whichever document is being edited. */
   const applyDesign = useCallback(
     (design: SavedDesign) => {
-      setLayout(JSON.parse(JSON.stringify(design.layout)) as InvoiceLayoutConfig)
+      // Merged, not taken as written: a design saved a year ago predates every
+      // section and field added since, and loading it verbatim would hide them
+      // with no way to switch them back on.
+      setLayout(mergeWithDefaults(JSON.parse(JSON.stringify(design.layout)) as InvoiceLayoutConfig))
       setTemplates((prev) => ({ ...prev, [docType]: { ...design.template } }))
       // Its name becomes the working name, so the next save updates it.
       setDesignName(design.name)
@@ -599,6 +610,7 @@ export function InvoiceDesigner({
           [`${prefix}.fontFamily`]: template.fontFamily,
           [`${prefix}.headerStyle`]: template.headerStyle,
           [`${prefix}.logoSize`]: String(template.logoSize),
+          [`${prefix}.logo`]: template.logoUrl,
         }),
       ])
       setDirty(false)
@@ -948,6 +960,9 @@ export function InvoiceDesigner({
           onSectionStyle={patchSectionStyle}
           onDocument={patchDocument}
           onTemplate={setTemplate}
+          logoUrl={workshop.logoUrl}
+          ownLogo={!!template.logoUrl}
+          onLogo={(url) => setTemplate({ logoUrl: url })}
         />
       </div>
 

+ 6 - 0
src/features/invoice-designer/Components/types.ts

@@ -17,6 +17,12 @@ export interface DesignerTemplate {
   fontFamily: string
   headerStyle: string
   logoSize: number
+  /**
+   * A mark for this document alone. Empty means the sheet prints the company
+   * logo, so a workshop that wants one picture everywhere sets it once in
+   * company settings and never thinks about this.
+   */
+  logoUrl: string
 }
 
 /**

+ 24 - 0
src/features/invoice-designer/Lib/documentLogo.ts

@@ -0,0 +1,24 @@
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+/**
+ * Which logo a printed or shared document uses.
+ *
+ * A workshop's letterhead is not always the mark the app wears: a shop may
+ * want a wider version on paper, or one with the postal address set into it,
+ * without changing the badge in their own sidebar. So the documents may carry
+ * their own, and fall back to the company logo when they do not, which is
+ * what every sheet printed before the two could differ.
+ *
+ * Invoices and quotes are kept apart because everything else about their
+ * appearance already is.
+ */
+export function documentLogoPath(
+  settings: Record<string, string | undefined | null>,
+  documentType: 'invoice' | 'quote'
+): string {
+  const own =
+    documentType === 'quote'
+      ? settings[SETTING_KEYS.QUOTE_LOGO]
+      : settings[SETTING_KEYS.INVOICE_LOGO]
+  return own?.trim() || settings[SETTING_KEYS.COMPANY_LOGO]?.trim() || ''
+}

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

@@ -1353,6 +1353,19 @@ function footer(section: InvoiceSection, theme: DocumentTheme, data: DocumentDat
     .filter((column) => column.length > 0)
 
   const children: Node[] = []
+  // A shop that wants its mark along the bottom as well as the top, the way
+  // printed stationery often carries it. Sized against the footer's own text
+  // rather than the header's slider, which belongs to the letterhead.
+  if (fields.includes('logo') && data.logoUrl) {
+    children.push({
+      kind: 'image',
+      id: 'footer.logo',
+      src: data.logoUrl,
+      maxWidth: 120,
+      maxHeight: scale(size, 3.2),
+      align: 'center',
+    })
+  }
   if (data.portalUrl) {
     children.push({
       kind: 'text',

+ 3 - 0
src/features/settings/Schema/invoiceLayoutSchema.ts

@@ -237,6 +237,9 @@ export const BUILTIN_HEADER_FIELDS = [
  */
 export const BUILTIN_FOOTER_FIELDS = [
   { id: 'footer_note', name: 'Footer Note' },
+  // Off unless asked for, the way the rest of the footer details are: a shop
+  // that wants its mark at the foot of the page as well as the top can say so.
+  { id: 'logo', name: 'Logo' },
   { id: 'company_name', name: 'Company Name' },
   { id: 'company_address', name: 'Address' },
   { id: 'company_phone', name: 'Phone' },

+ 4 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -154,6 +154,10 @@ export const SETTING_KEYS = {
   WORKBOARD_WORK_DAY_END: 'workboard.workDayEnd',
   INVOICE_LAYOUT_CONFIG: 'invoice.layoutConfig',
   QUOTE_LAYOUT_CONFIG: 'quote.layoutConfig',
+  // A mark for the paperwork alone. Unset means the documents print the
+  // company logo, which is what every sheet did before this existed.
+  INVOICE_LOGO: 'invoice.logo',
+  QUOTE_LOGO: 'quote.logo',
   AI_PROVIDER: 'ai.provider',
   AI_API_KEY: 'ai.apiKey',
   AI_MODEL: 'ai.model',