Преглед изворни кода

Invoice designer: payment terms switches and an editable document title (#306)

* Print payment terms only when the workshop has written some

An invoice with a due date printed a Payment Terms line counted off it
("Net 14 Days") even when the payment settings field was empty, so a
workshop published a term it never agreed to, beside the due date it was
derived from. The line now comes from the setting alone.

The designer canvas shows the real terms when they are set and a marked
stand-in when they are not, the same way it treats a missing slogan, with
an inspector hint linking to payment settings.

* Give the payment panel's terms and due date their own switches

The panel printed four rows and only two of them, bank account and org
number, could be switched off in the designer. Payment terms and due date
now carry field ids, so the section's existing field gate reaches them.

Layouts saved before the switches existed name neither field, and an
unnamed field is off, which would have stripped both rows from every
design in use. Fields listed in GRANDFATHERED_FIELDS read as visible when
a stored layout never mentions them, so a switch arrives showing what the
sheet already does.

The canvas drops the terms row when no terms are written, the way the
sheet does, and the switch goes with it rather than offering to hide a row
that cannot print.

Also raises the "In Use" badge on the templates page above the preview it
was painting behind, matching the z-10 the designer's gallery already
uses.

* Mark the payment terms switch instead of hiding it

A switch that vanishes reads as a missing feature. The row keeps its
switch when no terms are written and wears the same Placeholder mark the
canvas puts on a block standing in for something unwritten, so the
workshop can see the field exists and that nothing prints there yet.

* placeholder

* Mark the stand-in terms row on the sheet, the way the slogan is marked

The mark belongs on the paper, where the workshop is looking, not beside a
switch in the inspector. The payment panel keeps its terms row with a
stand-in when none are written, and that row alone wears the dashed frame
and Placeholder tag; the bank account beside it is real and says so.

The mark is a node-level context the canvas provides and no other reader of
the document model does, so a stand-in cannot pick up a badge on its way to
paper.

* Obey the Document Title switch, and let the strip choose what it says

Hiding the title strip did nothing: the block carries the number and the
dates, so a hidden one was drawn anyway, directly under the header, and the
switch looked broken because the strip stayed exactly where it was.

The strip is now a section like any other, visible by default and placed
under the header where it was already being drawn. Layouts written before
the switch was obeyed are read as showing it there, so honouring the switch
takes the number off nobody's sheet; the designer stamps the new version
when it saves, and a workshop that switches the strip off from then on
keeps it off. The stamp that means "designer era" is now its own constant,
because moving it with the save stamp would drop every organization back to
the classic sheet.

Title, Number, Customer Number, Date and Due Date each got a switch, so a
sheet can carry its number without the big word over it, or drop a date it
has no use for. A strip with nothing left switched on prints nothing.

* Let a workshop write what its documents are called

What a sheet must call itself is local law, not translation: a business
registered for GST in Australia heads its invoices "Tax Invoice", and one
that is not registered must not. Waiting on a release for that word is not
a reasonable answer, so the Document Title section takes the workshop's own
text and prints it in place of the name the app would print. Empty keeps
the translated name.

The words live on the section, with the design, rather than in settings, so
a design saved under one name keeps it.

* Resolve the document's name once, for every block that prints it

The title strip is not the only thing that can carry the name: the
pre-designer letterhead prints it itself, and a workshop still on that sheet
would have typed a name into a box that changed nothing they could see. The
name is now resolved for the whole sheet before any block is built, so the
strip and the letterhead cannot disagree and the box means what it says
wherever the sheet came from.

* Place the migrated title strip by renumbering, so the designer can save again

A layout saved before the Document Title switch was obeyed is read with the
strip shown under the header. It was put there with order 0.5, and the
schema wants whole numbers, so every Save from the designer on such a
layout failed validation. The title text a workshop typed never reached the
database, and the sheet, the preview and the shared page kept the old name.
Bernt Christian Egeland пре 4 недеља
родитељ
комит
144515b9dc
32 измењених фајлова са 866 додато и 154 уклоњено
  1. 13 0
      messages/de/settings.json
  2. 13 0
      messages/en/settings.json
  3. 13 0
      messages/es/settings.json
  4. 13 0
      messages/fr/settings.json
  5. 13 0
      messages/it/settings.json
  6. 13 0
      messages/lt/settings.json
  7. 13 0
      messages/nb/settings.json
  8. 13 0
      messages/nl/settings.json
  9. 13 0
      messages/pl/settings.json
  10. 13 0
      messages/pt-BR/settings.json
  11. 13 0
      messages/ru/settings.json
  12. 13 0
      messages/tr/settings.json
  13. 5 4
      src/__tests__/features/settings/designer-controls.test.ts
  14. 144 10
      src/__tests__/features/settings/document-title-placement.test.tsx
  15. 3 1
      src/__tests__/features/settings/framed-template-preset.test.tsx
  16. 114 0
      src/__tests__/features/settings/payment-terms-print.test.ts
  17. 45 0
      src/__tests__/features/settings/placeholder-node-mark.test.tsx
  18. 1 3
      src/__tests__/features/settings/tax-backfill-respects-lock.test.ts
  19. 22 4
      src/__tests__/features/workboard/availability.test.ts
  20. 2 2
      src/app/(authenticated)/settings/templates/template-settings.tsx
  21. 1 0
      src/app/(designer)/invoice-designer/page.tsx
  22. 1 3
      src/features/custom-fields/Lib/getCustomFieldsForPrint.ts
  23. 53 2
      src/features/invoice-designer/Components/DesignerInspector.tsx
  24. 22 9
      src/features/invoice-designer/Components/InvoiceDesigner.tsx
  25. 1 0
      src/features/invoice-designer/Components/PresetPreview.tsx
  26. 6 2
      src/features/invoice-designer/Components/sample.ts
  27. 2 0
      src/features/invoice-designer/Components/types.ts
  28. 10 14
      src/features/invoice-designer/Pdf/buildInvoicePrint.ts
  29. 43 35
      src/features/invoice-designer/Render/SpecCanvas.tsx
  30. 48 1
      src/features/invoice-designer/Render/renderHtml.tsx
  31. 91 55
      src/features/invoice-designer/Spec/buildSpec.ts
  32. 96 9
      src/features/settings/Schema/invoiceLayoutSchema.ts

+ 13 - 0
messages/de/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Bankkonto",
       "bank_account": "Bankkonto",
       "footer_note": "Fußzeilen-Hinweis",
       "footer_note": "Fußzeilen-Hinweis",
       "org_number": "Organisationsnummer",
       "org_number": "Organisationsnummer",
+      "payment_terms": "Zahlungsbedingungen",
+      "due_date": "Fälligkeitsdatum",
+      "title": "Titel",
+      "invoice_number": "Rechnungsnummer",
+      "customer_number": "Kundennummer",
+      "date": "Datum",
       "portal_link": "Portal-Link"
       "portal_link": "Portal-Link"
     },
     },
     "presetsTitle": "Mit einer Anordnung beginnen",
     "presetsTitle": "Mit einer Anordnung beginnen",
@@ -1936,6 +1942,7 @@
       "serviceType": "Reparatur",
       "serviceType": "Reparatur",
       "companyName": "Ihre Werkstatt",
       "companyName": "Ihre Werkstatt",
       "slogan": "Qualität, auf die Sie sich verlassen können",
       "slogan": "Qualität, auf die Sie sich verlassen können",
+      "paymentTerms": "Zahlbar innerhalb von 14 Tagen",
       "footerNote": "Vielen Dank für Ihren Auftrag",
       "footerNote": "Vielen Dank für Ihren Auftrag",
       "value": "Beispiel"
       "value": "Beispiel"
     },
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Es ist kein Slogan hinterlegt, daher zeigt das Blatt einen Platzhalter, der nicht gedruckt wird.",
     "sloganPlaceholderHint": "Es ist kein Slogan hinterlegt, daher zeigt das Blatt einen Platzhalter, der nicht gedruckt wird.",
     "sloganSetHint": "Ihr Slogan stammt aus Ihren Firmendaten.",
     "sloganSetHint": "Ihr Slogan stammt aus Ihren Firmendaten.",
     "sloganLink": "Firmeneinstellungen",
     "sloganLink": "Firmeneinstellungen",
+    "paymentTerms": "Zahlungsbedingungen",
+    "paymentTermsPlaceholderHint": "Es sind keine Zahlungsbedingungen hinterlegt, daher zeigt das Blatt einen Platzhalter, der nicht gedruckt wird.",
+    "paymentTermsSetHint": "Ihre Zahlungsbedingungen stammen aus den Zahlungseinstellungen.",
+    "paymentTermsLink": "Zahlungseinstellungen",
+    "titleText": "Titeltext",
+    "titleTextHint": "Wie sich dieses Dokument nennt. Leer gelassen, druckt es seinen eigenen Namen in der Sprache des Lesers.",
     "placeholder": "Platzhalter",
     "placeholder": "Platzhalter",
     "leaveTitle": "Ohne Speichern verlassen?",
     "leaveTitle": "Ohne Speichern verlassen?",
     "leaveBody": "Ihre Änderungen an diesem Design wurden nicht gespeichert und gehen verloren.",
     "leaveBody": "Ihre Änderungen an diesem Design wurden nicht gespeichert und gehen verloren.",

+ 13 - 0
messages/en/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Bank Account",
       "bank_account": "Bank Account",
       "footer_note": "Footer Note",
       "footer_note": "Footer Note",
       "org_number": "Organization Number",
       "org_number": "Organization Number",
+      "payment_terms": "Payment Terms",
+      "due_date": "Due Date",
+      "title": "Title",
+      "invoice_number": "Invoice No.",
+      "customer_number": "Customer No.",
+      "date": "Date",
       "portal_link": "Portal Link"
       "portal_link": "Portal Link"
     },
     },
     "presetsTitle": "Start from an arrangement",
     "presetsTitle": "Start from an arrangement",
@@ -1936,6 +1942,7 @@
       "serviceType": "Repair",
       "serviceType": "Repair",
       "companyName": "Your Workshop",
       "companyName": "Your Workshop",
       "slogan": "Quality service you can trust",
       "slogan": "Quality service you can trust",
+      "paymentTerms": "Due within 14 days",
       "footerNote": "Thank you for your business",
       "footerNote": "Thank you for your business",
       "value": "Sample"
       "value": "Sample"
     },
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "No slogan is set, so the sheet shows a stand-in that will not print.",
     "sloganPlaceholderHint": "No slogan is set, so the sheet shows a stand-in that will not print.",
     "sloganSetHint": "Your slogan comes from your company details.",
     "sloganSetHint": "Your slogan comes from your company details.",
     "sloganLink": "Company settings",
     "sloganLink": "Company settings",
+    "paymentTerms": "Payment terms",
+    "paymentTermsPlaceholderHint": "No payment terms are set, so the sheet shows a stand-in that will not print.",
+    "paymentTermsSetHint": "Your payment terms come from your payment settings.",
+    "paymentTermsLink": "Payment settings",
+    "titleText": "Title text",
+    "titleTextHint": "What this document calls itself. Left empty, it prints its own name in the reader's language.",
     "placeholder": "Placeholder",
     "placeholder": "Placeholder",
     "leaveTitle": "Leave without saving?",
     "leaveTitle": "Leave without saving?",
     "leaveBody": "Your changes to this design have not been saved. They will be lost.",
     "leaveBody": "Your changes to this design have not been saved. They will be lost.",

+ 13 - 0
messages/es/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Cuenta bancaria",
       "bank_account": "Cuenta bancaria",
       "footer_note": "Nota de pie",
       "footer_note": "Nota de pie",
       "org_number": "Número de organización",
       "org_number": "Número de organización",
+      "payment_terms": "Condiciones de pago",
+      "due_date": "Fecha de vencimiento",
+      "title": "Título",
+      "invoice_number": "N.º de factura",
+      "customer_number": "N.º de cliente",
+      "date": "Fecha",
       "portal_link": "Enlace al portal"
       "portal_link": "Enlace al portal"
     },
     },
     "presetsTitle": "Empezar con una disposición",
     "presetsTitle": "Empezar con una disposición",
@@ -1936,6 +1942,7 @@
       "serviceType": "Reparación",
       "serviceType": "Reparación",
       "companyName": "Tu taller",
       "companyName": "Tu taller",
       "slogan": "Calidad de servicio en la que puedes confiar",
       "slogan": "Calidad de servicio en la que puedes confiar",
+      "paymentTerms": "Pago en 14 días",
       "footerNote": "Gracias por confiar en nosotros",
       "footerNote": "Gracias por confiar en nosotros",
       "value": "Ejemplo"
       "value": "Ejemplo"
     },
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "No hay ningún eslogan definido, así que la hoja muestra un texto de ejemplo que no se imprimirá.",
     "sloganPlaceholderHint": "No hay ningún eslogan definido, así que la hoja muestra un texto de ejemplo que no se imprimirá.",
     "sloganSetHint": "Tu eslogan proviene de los datos de tu empresa.",
     "sloganSetHint": "Tu eslogan proviene de los datos de tu empresa.",
     "sloganLink": "Ajustes de empresa",
     "sloganLink": "Ajustes de empresa",
+    "paymentTerms": "Condiciones de pago",
+    "paymentTermsPlaceholderHint": "No hay condiciones de pago definidas, así que la hoja muestra un texto de ejemplo que no se imprimirá.",
+    "paymentTermsSetHint": "Tus condiciones de pago provienen de los ajustes de pago.",
+    "paymentTermsLink": "Ajustes de pago",
+    "titleText": "Texto del título",
+    "titleTextHint": "Cómo se llama este documento. Si lo dejas vacío, imprime su propio nombre en el idioma del lector.",
     "placeholder": "Marcador",
     "placeholder": "Marcador",
     "leaveTitle": "¿Salir sin guardar?",
     "leaveTitle": "¿Salir sin guardar?",
     "leaveBody": "Los cambios de este diseño no se han guardado y se perderán.",
     "leaveBody": "Los cambios de este diseño no se han guardado y se perderán.",

+ 13 - 0
messages/fr/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Compte bancaire",
       "bank_account": "Compte bancaire",
       "footer_note": "Note de bas de page",
       "footer_note": "Note de bas de page",
       "org_number": "Numéro d'organisation",
       "org_number": "Numéro d'organisation",
+      "payment_terms": "Conditions de paiement",
+      "due_date": "Date d'échéance",
+      "title": "Titre",
+      "invoice_number": "N° de facture",
+      "customer_number": "N° de client",
+      "date": "Date",
       "portal_link": "Lien du portail"
       "portal_link": "Lien du portail"
     },
     },
     "presetsTitle": "Partir d’une disposition",
     "presetsTitle": "Partir d’une disposition",
@@ -1936,6 +1942,7 @@
       "serviceType": "Réparation",
       "serviceType": "Réparation",
       "companyName": "Votre atelier",
       "companyName": "Votre atelier",
       "slogan": "Un service de qualité en toute confiance",
       "slogan": "Un service de qualité en toute confiance",
+      "paymentTerms": "Payable sous 14 jours",
       "footerNote": "Merci de votre confiance",
       "footerNote": "Merci de votre confiance",
       "value": "Exemple"
       "value": "Exemple"
     },
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Aucun slogan n'est défini, la feuille affiche donc un texte d'exemple qui ne sera pas imprimé.",
     "sloganPlaceholderHint": "Aucun slogan n'est défini, la feuille affiche donc un texte d'exemple qui ne sera pas imprimé.",
     "sloganSetHint": "Votre slogan provient des informations de votre entreprise.",
     "sloganSetHint": "Votre slogan provient des informations de votre entreprise.",
     "sloganLink": "Paramètres de l'entreprise",
     "sloganLink": "Paramètres de l'entreprise",
+    "paymentTerms": "Conditions de paiement",
+    "paymentTermsPlaceholderHint": "Aucune condition de paiement n'est définie, la feuille affiche donc un texte d'exemple qui ne sera pas imprimé.",
+    "paymentTermsSetHint": "Vos conditions de paiement proviennent des paramètres de paiement.",
+    "paymentTermsLink": "Paramètres de paiement",
+    "titleText": "Texte du titre",
+    "titleTextHint": "Le nom que porte ce document. Laissé vide, il imprime son propre nom dans la langue du lecteur.",
     "placeholder": "Exemple",
     "placeholder": "Exemple",
     "leaveTitle": "Quitter sans enregistrer ?",
     "leaveTitle": "Quitter sans enregistrer ?",
     "leaveBody": "Les modifications de cette maquette ne sont pas enregistrées et seront perdues.",
     "leaveBody": "Les modifications de cette maquette ne sont pas enregistrées et seront perdues.",

+ 13 - 0
messages/it/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Conto bancario",
       "bank_account": "Conto bancario",
       "footer_note": "Nota a piè di pagina",
       "footer_note": "Nota a piè di pagina",
       "org_number": "Numero organizzazione",
       "org_number": "Numero organizzazione",
+      "payment_terms": "Condizioni di pagamento",
+      "due_date": "Data di scadenza",
+      "title": "Titolo",
+      "invoice_number": "N. fattura",
+      "customer_number": "N. cliente",
+      "date": "Data",
       "portal_link": "Link al portale"
       "portal_link": "Link al portale"
     },
     },
     "presetsTitle": "Partire da una disposizione",
     "presetsTitle": "Partire da una disposizione",
@@ -1936,6 +1942,7 @@
       "serviceType": "Riparazione",
       "serviceType": "Riparazione",
       "companyName": "La tua officina",
       "companyName": "La tua officina",
       "slogan": "Un servizio di qualità di cui fidarsi",
       "slogan": "Un servizio di qualità di cui fidarsi",
+      "paymentTerms": "Pagamento entro 14 giorni",
       "footerNote": "Grazie per averci scelto",
       "footerNote": "Grazie per averci scelto",
       "value": "Esempio"
       "value": "Esempio"
     },
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Nessuno slogan impostato, quindi il foglio mostra un segnaposto che non verrà stampato.",
     "sloganPlaceholderHint": "Nessuno slogan impostato, quindi il foglio mostra un segnaposto che non verrà stampato.",
     "sloganSetHint": "Lo slogan proviene dai dati della tua azienda.",
     "sloganSetHint": "Lo slogan proviene dai dati della tua azienda.",
     "sloganLink": "Impostazioni azienda",
     "sloganLink": "Impostazioni azienda",
+    "paymentTerms": "Termini di pagamento",
+    "paymentTermsPlaceholderHint": "Nessun termine di pagamento impostato, quindi il foglio mostra un segnaposto che non verrà stampato.",
+    "paymentTermsSetHint": "I termini di pagamento provengono dalle impostazioni di pagamento.",
+    "paymentTermsLink": "Impostazioni di pagamento",
+    "titleText": "Testo del titolo",
+    "titleTextHint": "Come si chiama questo documento. Se lasciato vuoto, stampa il proprio nome nella lingua del lettore.",
     "placeholder": "Segnaposto",
     "placeholder": "Segnaposto",
     "leaveTitle": "Uscire senza salvare?",
     "leaveTitle": "Uscire senza salvare?",
     "leaveBody": "Le modifiche a questo design non sono state salvate e andranno perse.",
     "leaveBody": "Le modifiche a questo design non sono state salvate e andranno perse.",

+ 13 - 0
messages/lt/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Banko sąskaita",
       "bank_account": "Banko sąskaita",
       "footer_note": "Poraštės pastaba",
       "footer_note": "Poraštės pastaba",
       "org_number": "Įmonės kodas",
       "org_number": "Įmonės kodas",
+      "payment_terms": "Mokėjimo sąlygos",
+      "due_date": "Mokėjimo terminas",
+      "title": "Pavadinimas",
+      "invoice_number": "Sąskaitos Nr.",
+      "customer_number": "Kliento Nr.",
+      "date": "Data",
       "portal_link": "Portalo nuoroda"
       "portal_link": "Portalo nuoroda"
     },
     },
     "presetsTitle": "Pradėti nuo išdėstymo",
     "presetsTitle": "Pradėti nuo išdėstymo",
@@ -1936,6 +1942,7 @@
       "serviceType": "Remontas",
       "serviceType": "Remontas",
       "companyName": "Jūsų servisas",
       "companyName": "Jūsų servisas",
       "slogan": "Kokybiškas aptarnavimas, kuriuo galite pasitikėti",
       "slogan": "Kokybiškas aptarnavimas, kuriuo galite pasitikėti",
+      "paymentTerms": "Apmokėti per 14 dienų",
       "footerNote": "Ačiū, kad renkatės mus",
       "footerNote": "Ačiū, kad renkatės mus",
       "value": "Pavyzdys"
       "value": "Pavyzdys"
     },
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Šūkis nenustatytas, todėl lape rodomas pavyzdinis tekstas, kuris nebus spausdinamas.",
     "sloganPlaceholderHint": "Šūkis nenustatytas, todėl lape rodomas pavyzdinis tekstas, kuris nebus spausdinamas.",
     "sloganSetHint": "Jūsų šūkis imamas iš įmonės duomenų.",
     "sloganSetHint": "Jūsų šūkis imamas iš įmonės duomenų.",
     "sloganLink": "Įmonės nustatymai",
     "sloganLink": "Įmonės nustatymai",
+    "paymentTerms": "Mokėjimo sąlygos",
+    "paymentTermsPlaceholderHint": "Mokėjimo sąlygos nenustatytos, todėl lape rodomas pavyzdinis tekstas, kuris nebus spausdinamas.",
+    "paymentTermsSetHint": "Jūsų mokėjimo sąlygos imamos iš mokėjimų nustatymų.",
+    "paymentTermsLink": "Mokėjimų nustatymai",
+    "titleText": "Pavadinimo tekstas",
+    "titleTextHint": "Kaip vadinasi šis dokumentas. Palikus tuščią, spausdinamas jo paties pavadinimas skaitytojo kalba.",
     "placeholder": "Vietaženklis",
     "placeholder": "Vietaženklis",
     "leaveTitle": "Išeiti neišsaugojus?",
     "leaveTitle": "Išeiti neišsaugojus?",
     "leaveBody": "Šio dizaino pakeitimai neišsaugoti ir bus prarasti.",
     "leaveBody": "Šio dizaino pakeitimai neišsaugoti ir bus prarasti.",

+ 13 - 0
messages/nb/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Bankkonto",
       "bank_account": "Bankkonto",
       "footer_note": "Bunntekstnotat",
       "footer_note": "Bunntekstnotat",
       "org_number": "Organisasjonsnummer",
       "org_number": "Organisasjonsnummer",
+      "payment_terms": "Betalingsbetingelser",
+      "due_date": "Forfallsdato",
+      "title": "Tittel",
+      "invoice_number": "Fakturanr.",
+      "customer_number": "Kundenr.",
+      "date": "Dato",
       "portal_link": "Portallenke"
       "portal_link": "Portallenke"
     },
     },
     "presetsTitle": "Start fra et oppsett",
     "presetsTitle": "Start fra et oppsett",
@@ -1936,6 +1942,7 @@
       "serviceType": "Reparasjon",
       "serviceType": "Reparasjon",
       "companyName": "Ditt verksted",
       "companyName": "Ditt verksted",
       "slogan": "Kvalitetsservice du kan stole på",
       "slogan": "Kvalitetsservice du kan stole på",
+      "paymentTerms": "Betales innen 14 dager",
       "footerNote": "Takk for handelen",
       "footerNote": "Takk for handelen",
       "value": "Eksempel"
       "value": "Eksempel"
     },
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Det er ikke satt noe slagord, så arket viser en plassholder som ikke skrives ut.",
     "sloganPlaceholderHint": "Det er ikke satt noe slagord, så arket viser en plassholder som ikke skrives ut.",
     "sloganSetHint": "Slagordet hentes fra firmaopplysningene dine.",
     "sloganSetHint": "Slagordet hentes fra firmaopplysningene dine.",
     "sloganLink": "Firmainnstillinger",
     "sloganLink": "Firmainnstillinger",
+    "paymentTerms": "Betalingsvilkår",
+    "paymentTermsPlaceholderHint": "Det er ikke satt noen betalingsvilkår, så arket viser en plassholder som ikke skrives ut.",
+    "paymentTermsSetHint": "Betalingsvilkårene hentes fra betalingsinnstillingene dine.",
+    "paymentTermsLink": "Betalingsinnstillinger",
+    "titleText": "Titteltekst",
+    "titleTextHint": "Hva dette dokumentet kaller seg. Står feltet tomt, skrives dokumentets eget navn på leserens språk.",
     "placeholder": "Plassholder",
     "placeholder": "Plassholder",
     "leaveTitle": "Forlate uten å lagre?",
     "leaveTitle": "Forlate uten å lagre?",
     "leaveBody": "Endringene i dette designet er ikke lagret. De går tapt.",
     "leaveBody": "Endringene i dette designet er ikke lagret. De går tapt.",

+ 13 - 0
messages/nl/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Bankrekening",
       "bank_account": "Bankrekening",
       "footer_note": "Voettekstnotitie",
       "footer_note": "Voettekstnotitie",
       "org_number": "Organisatienummer",
       "org_number": "Organisatienummer",
+      "payment_terms": "Betalingsvoorwaarden",
+      "due_date": "Vervaldatum",
+      "title": "Titel",
+      "invoice_number": "Factuurnr.",
+      "customer_number": "Klantnr.",
+      "date": "Datum",
       "portal_link": "Portaallink"
       "portal_link": "Portaallink"
     },
     },
     "presetsTitle": "Begin met een indeling",
     "presetsTitle": "Begin met een indeling",
@@ -1936,6 +1942,7 @@
       "serviceType": "Reparatie",
       "serviceType": "Reparatie",
       "companyName": "Uw werkplaats",
       "companyName": "Uw werkplaats",
       "slogan": "Kwaliteit waar u op kunt bouwen",
       "slogan": "Kwaliteit waar u op kunt bouwen",
+      "paymentTerms": "Betaling binnen 14 dagen",
       "footerNote": "Bedankt voor uw opdracht",
       "footerNote": "Bedankt voor uw opdracht",
       "value": "Voorbeeld"
       "value": "Voorbeeld"
     },
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Er is geen slogan ingesteld, dus het blad toont een voorbeeldtekst die niet wordt afgedrukt.",
     "sloganPlaceholderHint": "Er is geen slogan ingesteld, dus het blad toont een voorbeeldtekst die niet wordt afgedrukt.",
     "sloganSetHint": "Je slogan komt uit je bedrijfsgegevens.",
     "sloganSetHint": "Je slogan komt uit je bedrijfsgegevens.",
     "sloganLink": "Bedrijfsinstellingen",
     "sloganLink": "Bedrijfsinstellingen",
+    "paymentTerms": "Betalingsvoorwaarden",
+    "paymentTermsPlaceholderHint": "Er zijn geen betalingsvoorwaarden ingesteld, dus het blad toont een voorbeeldtekst die niet wordt afgedrukt.",
+    "paymentTermsSetHint": "Je betalingsvoorwaarden komen uit je betaalinstellingen.",
+    "paymentTermsLink": "Betaalinstellingen",
+    "titleText": "Titeltekst",
+    "titleTextHint": "Hoe dit document zichzelf noemt. Laat je het leeg, dan drukt het zijn eigen naam af in de taal van de lezer.",
     "placeholder": "Voorbeeld",
     "placeholder": "Voorbeeld",
     "leaveTitle": "Weggaan zonder opslaan?",
     "leaveTitle": "Weggaan zonder opslaan?",
     "leaveBody": "Je wijzigingen aan dit ontwerp zijn niet opgeslagen en gaan verloren.",
     "leaveBody": "Je wijzigingen aan dit ontwerp zijn niet opgeslagen en gaan verloren.",

+ 13 - 0
messages/pl/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Konto bankowe",
       "bank_account": "Konto bankowe",
       "footer_note": "Notatka w stopce",
       "footer_note": "Notatka w stopce",
       "org_number": "Numer organizacji",
       "org_number": "Numer organizacji",
+      "payment_terms": "Warunki płatności",
+      "due_date": "Termin płatności",
+      "title": "Tytuł",
+      "invoice_number": "Nr faktury",
+      "customer_number": "Nr klienta",
+      "date": "Data",
       "portal_link": "Link do portalu"
       "portal_link": "Link do portalu"
     },
     },
     "presetsTitle": "Zacznij od układu",
     "presetsTitle": "Zacznij od układu",
@@ -1936,6 +1942,7 @@
       "serviceType": "Naprawa",
       "serviceType": "Naprawa",
       "companyName": "Twój warsztat",
       "companyName": "Twój warsztat",
       "slogan": "Jakość, której możesz zaufać",
       "slogan": "Jakość, której możesz zaufać",
+      "paymentTerms": "Płatne w ciągu 14 dni",
       "footerNote": "Dziękujemy za współpracę",
       "footerNote": "Dziękujemy za współpracę",
       "value": "Przykład"
       "value": "Przykład"
     },
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Nie ustawiono sloganu, więc arkusz pokazuje tekst zastępczy, który nie zostanie wydrukowany.",
     "sloganPlaceholderHint": "Nie ustawiono sloganu, więc arkusz pokazuje tekst zastępczy, który nie zostanie wydrukowany.",
     "sloganSetHint": "Twój slogan pochodzi z danych firmy.",
     "sloganSetHint": "Twój slogan pochodzi z danych firmy.",
     "sloganLink": "Ustawienia firmy",
     "sloganLink": "Ustawienia firmy",
+    "paymentTerms": "Warunki płatności",
+    "paymentTermsPlaceholderHint": "Nie ustawiono warunków płatności, więc arkusz pokazuje tekst zastępczy, który nie zostanie wydrukowany.",
+    "paymentTermsSetHint": "Twoje warunki płatności pochodzą z ustawień płatności.",
+    "paymentTermsLink": "Ustawienia płatności",
+    "titleText": "Tekst tytułu",
+    "titleTextHint": "Jak nazywa się ten dokument. Pozostawione puste, drukuje własną nazwę w języku odbiorcy.",
     "placeholder": "Symbol zastępczy",
     "placeholder": "Symbol zastępczy",
     "leaveTitle": "Wyjść bez zapisywania?",
     "leaveTitle": "Wyjść bez zapisywania?",
     "leaveBody": "Zmiany w tym projekcie nie zostały zapisane i zostaną utracone.",
     "leaveBody": "Zmiany w tym projekcie nie zostały zapisane i zostaną utracone.",

+ 13 - 0
messages/pt-BR/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Conta bancária",
       "bank_account": "Conta bancária",
       "footer_note": "Nota de rodapé",
       "footer_note": "Nota de rodapé",
       "org_number": "Número da organização",
       "org_number": "Número da organização",
+      "payment_terms": "Condições de pagamento",
+      "due_date": "Data de vencimento",
+      "title": "Título",
+      "invoice_number": "Nº da fatura",
+      "customer_number": "Nº do cliente",
+      "date": "Data",
       "portal_link": "Link do portal"
       "portal_link": "Link do portal"
     },
     },
     "presetsTitle": "Começar por uma disposição",
     "presetsTitle": "Começar por uma disposição",
@@ -1936,6 +1942,7 @@
       "serviceType": "Reparo",
       "serviceType": "Reparo",
       "companyName": "Sua oficina",
       "companyName": "Sua oficina",
       "slogan": "Serviço de qualidade em que você pode confiar",
       "slogan": "Serviço de qualidade em que você pode confiar",
+      "paymentTerms": "Pagamento em 14 dias",
       "footerNote": "Obrigado pela preferência",
       "footerNote": "Obrigado pela preferência",
       "value": "Exemplo"
       "value": "Exemplo"
     },
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Nenhum slogan definido, então a folha mostra um texto de exemplo que não será impresso.",
     "sloganPlaceholderHint": "Nenhum slogan definido, então a folha mostra um texto de exemplo que não será impresso.",
     "sloganSetHint": "Seu slogan vem dos dados da sua empresa.",
     "sloganSetHint": "Seu slogan vem dos dados da sua empresa.",
     "sloganLink": "Configurações da empresa",
     "sloganLink": "Configurações da empresa",
+    "paymentTerms": "Condições de pagamento",
+    "paymentTermsPlaceholderHint": "Nenhuma condição de pagamento definida, então a folha mostra um texto de exemplo que não será impresso.",
+    "paymentTermsSetHint": "Suas condições de pagamento vêm das configurações de pagamento.",
+    "paymentTermsLink": "Configurações de pagamento",
+    "titleText": "Texto do título",
+    "titleTextHint": "Como este documento se chama. Deixado vazio, imprime o próprio nome no idioma do leitor.",
     "placeholder": "Exemplo",
     "placeholder": "Exemplo",
     "leaveTitle": "Sair sem salvar?",
     "leaveTitle": "Sair sem salvar?",
     "leaveBody": "Suas alterações neste design não foram salvas e serão perdidas.",
     "leaveBody": "Suas alterações neste design não foram salvas e serão perdidas.",

+ 13 - 0
messages/ru/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Банковский счёт",
       "bank_account": "Банковский счёт",
       "footer_note": "Примечание в подвале",
       "footer_note": "Примечание в подвале",
       "org_number": "Номер организации",
       "org_number": "Номер организации",
+      "payment_terms": "Условия оплаты",
+      "due_date": "Срок оплаты",
+      "title": "Заголовок",
+      "invoice_number": "№ счёта",
+      "customer_number": "№ клиента",
+      "date": "Дата",
       "portal_link": "Ссылка на портал"
       "portal_link": "Ссылка на портал"
     },
     },
     "presetsTitle": "Начать с расположения",
     "presetsTitle": "Начать с расположения",
@@ -1936,6 +1942,7 @@
       "serviceType": "Ремонт",
       "serviceType": "Ремонт",
       "companyName": "Ваша мастерская",
       "companyName": "Ваша мастерская",
       "slogan": "Качественный сервис, которому можно доверять",
       "slogan": "Качественный сервис, которому можно доверять",
+      "paymentTerms": "Оплата в течение 14 дней",
       "footerNote": "Благодарим за обращение",
       "footerNote": "Благодарим за обращение",
       "value": "Пример"
       "value": "Пример"
     },
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Слоган не задан, поэтому на листе показан образец, который не будет напечатан.",
     "sloganPlaceholderHint": "Слоган не задан, поэтому на листе показан образец, который не будет напечатан.",
     "sloganSetHint": "Слоган берётся из данных вашей компании.",
     "sloganSetHint": "Слоган берётся из данных вашей компании.",
     "sloganLink": "Настройки компании",
     "sloganLink": "Настройки компании",
+    "paymentTerms": "Условия оплаты",
+    "paymentTermsPlaceholderHint": "Условия оплаты не заданы, поэтому на листе показан образец, который не будет напечатан.",
+    "paymentTermsSetHint": "Условия оплаты берутся из настроек оплаты.",
+    "paymentTermsLink": "Настройки оплаты",
+    "titleText": "Текст заголовка",
+    "titleTextHint": "Как называется этот документ. Если оставить пустым, печатается его собственное название на языке читателя.",
     "placeholder": "Образец",
     "placeholder": "Образец",
     "leaveTitle": "Выйти без сохранения?",
     "leaveTitle": "Выйти без сохранения?",
     "leaveBody": "Изменения в этом оформлении не сохранены и будут потеряны.",
     "leaveBody": "Изменения в этом оформлении не сохранены и будут потеряны.",

+ 13 - 0
messages/tr/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Banka hesabı",
       "bank_account": "Banka hesabı",
       "footer_note": "Alt bilgi notu",
       "footer_note": "Alt bilgi notu",
       "org_number": "Kuruluş numarası",
       "org_number": "Kuruluş numarası",
+      "payment_terms": "Ödeme koşulları",
+      "due_date": "Vade tarihi",
+      "title": "Başlık",
+      "invoice_number": "Fatura No",
+      "customer_number": "Müşteri No",
+      "date": "Tarih",
       "portal_link": "Portal bağlantısı"
       "portal_link": "Portal bağlantısı"
     },
     },
     "presetsTitle": "Bir düzenle başlayın",
     "presetsTitle": "Bir düzenle başlayın",
@@ -1936,6 +1942,7 @@
       "serviceType": "Onarım",
       "serviceType": "Onarım",
       "companyName": "Servisiniz",
       "companyName": "Servisiniz",
       "slogan": "Güvenebileceğiniz kaliteli hizmet",
       "slogan": "Güvenebileceğiniz kaliteli hizmet",
+      "paymentTerms": "14 gün içinde ödenir",
       "footerNote": "İş birliğiniz için teşekkürler",
       "footerNote": "İş birliğiniz için teşekkürler",
       "value": "Örnek"
       "value": "Örnek"
     },
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Slogan tanımlanmadığı için sayfada yazdırılmayacak bir örnek metin gösteriliyor.",
     "sloganPlaceholderHint": "Slogan tanımlanmadığı için sayfada yazdırılmayacak bir örnek metin gösteriliyor.",
     "sloganSetHint": "Sloganınız şirket bilgilerinizden gelir.",
     "sloganSetHint": "Sloganınız şirket bilgilerinizden gelir.",
     "sloganLink": "Şirket ayarları",
     "sloganLink": "Şirket ayarları",
+    "paymentTerms": "Ödeme koşulları",
+    "paymentTermsPlaceholderHint": "Ödeme koşulları tanımlanmadığı için sayfada yazdırılmayacak bir örnek metin gösteriliyor.",
+    "paymentTermsSetHint": "Ödeme koşullarınız ödeme ayarlarınızdan gelir.",
+    "paymentTermsLink": "Ödeme ayarları",
+    "titleText": "Başlık metni",
+    "titleTextHint": "Bu belgenin kendine verdiği ad. Boş bırakılırsa kendi adını okuyucunun dilinde yazdırır.",
     "placeholder": "Örnek",
     "placeholder": "Örnek",
     "leaveTitle": "Kaydetmeden çıkılsın mı?",
     "leaveTitle": "Kaydetmeden çıkılsın mı?",
     "leaveBody": "Bu tasarımdaki değişiklikler kaydedilmedi ve kaybolacak.",
     "leaveBody": "Bu tasarımdaki değişiklikler kaydedilmedi ve kaybolacak.",

+ 5 - 4
src/__tests__/features/settings/designer-controls.test.ts

@@ -67,10 +67,11 @@ describe('the generator covers what a layout can express', () => {
       for (const m of block.matchAll(/id: '([a-z_]+)'/g)) ids.add(m[1])
       for (const m of block.matchAll(/id: '([a-z_]+)'/g)) ids.add(m[1])
     }
     }
     // `logo` comes from the workshop's upload and `portal_link` from the
     // `logo` comes from the workshop's upload and `portal_link` from the
-    // sample portal URL, rather than from a sample field string.
-    const missing = [...ids].filter(
-      (id) => id !== 'logo' && id !== 'portal_link' && !SAMPLE.includes(`${id}:`)
-    )
+    // sample portal URL, rather than from a sample field string. The payment
+    // panel's terms and due date are rows of the panel itself, built with the
+    // rest of `payment` and switched by the same ids.
+    const fromElsewhere = new Set(['logo', 'portal_link', 'payment_terms', 'due_date'])
+    const missing = [...ids].filter((id) => !fromElsewhere.has(id) && !SAMPLE.includes(`${id}:`))
     expect(missing).toEqual([])
     expect(missing).toEqual([])
   })
   })
 
 

+ 144 - 10
src/__tests__/features/settings/document-title-placement.test.tsx

@@ -1,17 +1,21 @@
 /**
 /**
  * @vitest-environment node
  * @vitest-environment node
  *
  *
- * The number, the date and the amount a customer quotes back must print
- * exactly once, wherever the layout puts them. A layout with a Document Title
- * section shows it there; a layout without one gets it set directly under the
- * header, which is where every header used to print it. Enforced in the
- * document generator, so the designer and the paper obey the same rule.
+ * The Document Title switch means what it says: on, the strip prints where the
+ * layout puts it, exactly once; off, it does not print. It did not always. The
+ * strip carries the number and the dates, so a hidden one used to be drawn
+ * anyway, under the header, and switching it off in the designer looked broken
+ * because the strip stayed. Layouts saved before the switch was obeyed are read
+ * as showing the strip there, so honouring it takes nothing off a sheet that
+ * never chose to lose it.
  */
  */
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
 import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
 import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
 import {
 import {
   DESIGNER_LAYOUT_VERSION,
   DESIGNER_LAYOUT_VERSION,
   getDefaultInvoiceLayout,
   getDefaultInvoiceLayout,
+  invoiceLayoutConfigSchema,
+  mergeWithDefaults,
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
 import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
 
 
@@ -38,24 +42,32 @@ const data: InvoiceData = {
   vehicle: null,
   vehicle: null,
 }
 }
 
 
-function specWithTitleVisible(visible: boolean) {
+function specWithTitleVisible(visible: boolean, version = DESIGNER_LAYOUT_VERSION) {
   // A designer-saved layout: an unstamped one keeps the classic letterhead,
   // A designer-saved layout: an unstamped one keeps the classic letterhead,
   // which carries the title itself and has no separate block to place.
   // which carries the title itself and has no separate block to place.
-  const layout = { ...getDefaultInvoiceLayout(), version: DESIGNER_LAYOUT_VERSION }
+  const layout = { ...getDefaultInvoiceLayout(), version }
   layout.sections = layout.sections.map((s) => (s.id === 'document_title' ? { ...s, visible } : s))
   layout.sections = layout.sections.map((s) => (s.id === 'document_title' ? { ...s, visible } : s))
   return buildInvoicePrintSpec({ data, template: { layoutConfig: layout } })
   return buildInvoicePrintSpec({ data, template: { layoutConfig: layout } })
 }
 }
 
 
 describe('document title placement', () => {
 describe('document title placement', () => {
-  it.each([true, false])('prints the block exactly once (visible: %s)', (visible) => {
-    const spec = specWithTitleVisible(visible)
+  it('prints the block exactly once when it is on', () => {
+    const spec = specWithTitleVisible(true)
     const titles = spec.blocks.filter((b) => b.id === 'document_title')
     const titles = spec.blocks.filter((b) => b.id === 'document_title')
     expect(titles).toHaveLength(1)
     expect(titles).toHaveLength(1)
     expect(JSON.stringify(titles[0])).toContain('INV-2026-1001')
     expect(JSON.stringify(titles[0])).toContain('INV-2026-1001')
   })
   })
 
 
-  it('sets a borrowed title directly under the header', () => {
+  it('prints nothing at all when it is off', () => {
     const spec = specWithTitleVisible(false)
     const spec = specWithTitleVisible(false)
+    expect(spec.blocks.filter((b) => b.id === 'document_title')).toHaveLength(0)
+    expect(JSON.stringify(spec)).not.toContain('INV-2026-1001')
+  })
+
+  it('keeps the strip a layout from before the switch was drawing', () => {
+    // Version 2 is that era. The strip was drawn under the header however the
+    // switch was set, so reading it as off would take the number off paper.
+    const spec = specWithTitleVisible(false, 2)
     const header = spec.blocks.find((b) => b.id === 'header')
     const header = spec.blocks.find((b) => b.id === 'header')
     const title = spec.blocks.find((b) => b.id === 'document_title')
     const title = spec.blocks.find((b) => b.id === 'document_title')
     expect(header?.placement.mode).toBe('flow')
     expect(header?.placement.mode).toBe('flow')
@@ -74,6 +86,40 @@ describe('document title placement', () => {
     }
     }
   })
   })
 
 
+  it('reads a layout from before the switch as one the designer can save again', () => {
+    // The strip is put under the header by renumbering, not by a fraction:
+    // orders are whole numbers to the schema, and a 0.5 that reached the
+    // designer failed validation on Save, so nothing typed there ever stuck.
+    const layout = { ...getDefaultInvoiceLayout(), version: 2 }
+    layout.sections = layout.sections.map((s) =>
+      s.id === 'document_title' ? { ...s, visible: false, order: 12 } : s
+    )
+    const merged = mergeWithDefaults(layout)
+    const ordered = [...merged.sections].sort((a, b) => a.order - b.order)
+    const header = ordered.findIndex((s) => s.id === 'header')
+    expect(ordered[header + 1]?.id).toBe('document_title')
+    expect(ordered[header + 1]?.visible).toBe(true)
+    expect(merged.sections.every((s) => Number.isInteger(s.order))).toBe(true)
+
+    const named = merged.sections.map((s) =>
+      s.id === 'document_title' ? { ...s, text: 'Tax Invoice' } : s
+    )
+    const saved = invoiceLayoutConfigSchema.safeParse({
+      ...merged,
+      sections: named,
+      version: DESIGNER_LAYOUT_VERSION,
+    })
+    expect(saved.success).toBe(true)
+    if (saved.success) {
+      const spec = buildInvoicePrintSpec({
+        data,
+        template: { headerStyle: 'compact', layoutConfig: saved.data },
+      })
+      expect(JSON.stringify(spec)).toContain('Tax Invoice')
+      expect(JSON.stringify(spec)).not.toContain('"INVOICE"')
+    }
+  })
+
   it('leaves a visible title section at its own place in the order', () => {
   it('leaves a visible title section at its own place in the order', () => {
     const spec = specWithTitleVisible(true)
     const spec = specWithTitleVisible(true)
     const title = spec.blocks.find((b) => b.id === 'document_title')
     const title = spec.blocks.find((b) => b.id === 'document_title')
@@ -90,3 +136,91 @@ describe('document title placement', () => {
     expect(spec.blocks.find((b) => b.id === 'document_title')?.placement.mode).toBe('flow')
     expect(spec.blocks.find((b) => b.id === 'document_title')?.placement.mode).toBe('flow')
   })
   })
 })
 })
+
+/**
+ * What the strip says is the workshop's to choose. A business that must call
+ * the sheet one thing in one country and another elsewhere, or that wants the
+ * number without the big word over it, sets that here rather than living with
+ * whatever the template prints.
+ */
+describe('what the title strip shows', () => {
+  /** The strip, as JSON, with only `visible` field ids switched on. */
+  function stripWith(visible: string[]) {
+    const layout = { ...getDefaultInvoiceLayout(), version: DESIGNER_LAYOUT_VERSION }
+    layout.sections = layout.sections.map((s) =>
+      s.id === 'document_title'
+        ? {
+            ...s,
+            fields: ['title', 'invoice_number', 'customer_number', 'date', 'due_date'].map(
+              (id) => ({ id, visible: visible.includes(id) })
+            ),
+          }
+        : s
+    )
+    const spec = buildInvoicePrintSpec({ data, template: { layoutConfig: layout } })
+    return spec.blocks.find((b) => b.id === 'document_title')
+  }
+
+  it('drops the big word but keeps the cells beside it', () => {
+    const strip = JSON.stringify(stripWith(['invoice_number', 'date']))
+    expect(strip).toContain('INV-2026-1001')
+    expect(strip).not.toContain('document_title.title')
+  })
+
+  it('drops a cell that is switched off', () => {
+    const strip = JSON.stringify(stripWith(['title', 'date']))
+    expect(strip).not.toContain('INV-2026-1001')
+    expect(strip).toContain('document_title.title')
+  })
+
+  it('prints no strip at all when everything in it is off', () => {
+    expect(stripWith([])).toBeUndefined()
+  })
+})
+
+/**
+ * What a sheet must call itself is local law, not translation: a business
+ * registered for GST in Australia heads its invoices "Tax Invoice", and one
+ * that is not registered must not. The word is the workshop's to write, and it
+ * lives with the design, so a sheet sent before they registered keeps the name
+ * it was sent under.
+ */
+describe('the words the title strip prints', () => {
+  /** The strip, as JSON, with `text` set on the Document Title section. */
+  function stripNamed(text?: string) {
+    const layout = { ...getDefaultInvoiceLayout(), version: DESIGNER_LAYOUT_VERSION }
+    layout.sections = layout.sections.map((s) => (s.id === 'document_title' ? { ...s, text } : s))
+    const spec = buildInvoicePrintSpec({ data, template: { layoutConfig: layout } })
+    return JSON.stringify(spec.blocks.find((b) => b.id === 'document_title'))
+  }
+
+  it("prints the workshop's own word for the document", () => {
+    expect(stripNamed('Tax Invoice')).toContain('Tax Invoice')
+  })
+
+  it.each([undefined, '', '   '])('falls back to the printed name (%s)', (text) => {
+    const strip = stripNamed(text)
+    expect(strip).toContain('INVOICE')
+    expect(strip).not.toContain('Tax Invoice')
+  })
+})
+
+/**
+ * A workshop still on the pre-designer sheet has no title strip: its
+ * letterhead prints the name itself. The name it prints is the one they wrote
+ * all the same, because a box that says what the document is called cannot
+ * quietly mean "on some sheets".
+ */
+describe('the classic letterhead', () => {
+  it('prints the name the workshop wrote', () => {
+    const layout = getDefaultInvoiceLayout()
+    layout.sections = layout.sections.map((s) =>
+      s.id === 'document_title' ? { ...s, text: 'Tax Invoice' } : s
+    )
+    // No version stamp is the classic era, which is what these sheets print.
+    const spec = buildInvoicePrintSpec({ data, template: { layoutConfig: layout } })
+    const header = JSON.stringify(spec.blocks.find((b) => b.id === 'header'))
+    expect(header).toContain('Tax Invoice')
+    expect(spec.blocks.find((b) => b.id === 'document_title')).toBeUndefined()
+  })
+})

+ 3 - 1
src/__tests__/features/settings/framed-template-preset.test.tsx

@@ -108,7 +108,9 @@ describe('framed template preset', () => {
   it('is not what a workshop gets by default', () => {
   it('is not what a workshop gets by default', () => {
     const seen = visibility(getDefaultInvoiceLayout())
     const seen = visibility(getDefaultInvoiceLayout())
     expect(seen.items_table).toBe(false)
     expect(seen.items_table).toBe(false)
-    expect(seen.document_title).toBe(false)
+    // The title strip is a block like any other now: shown, under the header,
+    // where it was drawn from behind a switch that claimed it was hidden.
+    expect(seen.document_title).toBe(true)
     expect(seen.parts_table).toBe(true)
     expect(seen.parts_table).toBe(true)
     expect(seen.labor_table).toBe(true)
     expect(seen.labor_table).toBe(true)
   })
   })

+ 114 - 0
src/__tests__/features/settings/payment-terms-print.test.ts

@@ -0,0 +1,114 @@
+/**
+ * @vitest-environment node
+ *
+ * Payment terms are the workshop's own sentence, written in payment settings.
+ * The sheet used to invent one from the due date ("Net 14 Days") whenever a
+ * due date existed, so a workshop that had written nothing still printed a
+ * term it had never agreed to, right next to the due date it was counted off.
+ * An empty field now prints no terms line at all.
+ */
+import { describe, expect, it } from 'vitest'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import {
+  DESIGNER_LAYOUT_VERSION,
+  getDefaultInvoiceLayout,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { InvoiceLayoutConfig } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+
+const data: InvoiceData = {
+  id: 'svc-terms',
+  title: 'Service',
+  description: null,
+  type: 'repair',
+  serviceDate: new Date('2026-08-14'),
+  invoiceDate: new Date('2026-08-14'),
+  invoiceDueDate: new Date('2026-08-28'),
+  shopName: 'Shop',
+  techName: null,
+  mileage: null,
+  diagnosticNotes: null,
+  invoiceNotes: null,
+  subtotal: 100,
+  taxRate: 0,
+  taxAmount: 0,
+  totalAmount: 100,
+  cost: 100,
+  invoiceNumber: 'INV-2026-1001',
+  partItems: [],
+  laborItems: [{ description: 'Work', hours: 1, rate: 100, total: 100 }],
+  customer: null,
+  vehicle: null,
+}
+
+/** The payment panel, as JSON, which is where a terms line would show up. */
+function paymentPanel(paymentTerms?: string, layout?: InvoiceLayoutConfig) {
+  const spec = buildInvoicePrintSpec({
+    data,
+    invoiceSettings: { currencyCode: 'EUR', bankAccount: 'XX00 1234', paymentTerms },
+    ...(layout ? { template: { layoutConfig: layout } } : {}),
+  })
+  const block = spec.blocks.find((b) => b.id === 'bank_account')
+  return JSON.stringify(block ?? {})
+}
+
+/** A designer-saved layout with the payment panel's fields set to `fields`. */
+function layoutWithPaymentFields(fields: { id: string; visible: boolean }[]) {
+  const layout = { ...getDefaultInvoiceLayout(), version: DESIGNER_LAYOUT_VERSION }
+  layout.sections = layout.sections.map((s) => (s.id === 'bank_account' ? { ...s, fields } : s))
+  return layout
+}
+
+describe('payment terms on a printed invoice', () => {
+  it('prints no terms when the setting is empty, due date or not', () => {
+    for (const terms of [undefined, '', '   ']) {
+      const panel = paymentPanel(terms)
+      expect(panel, `terms ${JSON.stringify(terms)}`).not.toContain('Payment Terms')
+      // The date the money is due is its own line and stays.
+      expect(panel).toContain('Due Date')
+    }
+  })
+
+  it("prints the workshop's own terms when they have written some", () => {
+    const panel = paymentPanel('Payable on collection')
+    expect(panel).toContain('Payment Terms')
+    expect(panel).toContain('Payable on collection')
+  })
+})
+
+/**
+ * Payment terms and due date print as rows of the payment panel and only got
+ * their own switches once a workshop asked for them. Every layout saved before
+ * that names neither, and a field a layout does not name is off, so the
+ * switches had to arrive without taking the rows off anybody's paper.
+ */
+describe('the payment panel switches', () => {
+  it('keeps both rows on a layout saved before the switches existed', () => {
+    const panel = paymentPanel(
+      'Payable on collection',
+      layoutWithPaymentFields([
+        { id: 'bank_account', visible: true },
+        { id: 'org_number', visible: true },
+      ])
+    )
+    expect(panel).toContain('Payment Terms')
+    expect(panel).toContain('Due Date')
+  })
+
+  it.each([
+    ['payment_terms', 'Payment Terms', 'Due Date'],
+    ['due_date', 'Due Date', 'Payment Terms'],
+  ])('takes the %s row off when it is switched off', (id, gone, kept) => {
+    const panel = paymentPanel(
+      'Payable on collection',
+      layoutWithPaymentFields([
+        { id: 'bank_account', visible: true },
+        { id: 'org_number', visible: true },
+        { id: 'payment_terms', visible: id !== 'payment_terms' },
+        { id: 'due_date', visible: id !== 'due_date' },
+      ])
+    )
+    expect(panel).not.toContain(gone)
+    expect(panel).toContain(kept)
+  })
+})

+ 45 - 0
src/__tests__/features/settings/placeholder-node-mark.test.tsx

@@ -0,0 +1,45 @@
+/**
+ * A stand-in the workshop has not written yet is marked on the canvas, the way
+ * a missing slogan is: dashed, tagged, and unmistakable against a real line.
+ * The mark belongs to the editor alone. The same nodes are drawn for the sheet
+ * a customer opens, and a mark printed there would dress a line that is not
+ * really on the paper, so it is opt-in through the context the canvas provides
+ * and nothing else does.
+ */
+import { describe, expect, it } from 'vitest'
+import { render } from '@testing-library/react'
+import { PlaceholderNodes, RenderNode } from '@/features/invoice-designer/Render/renderHtml'
+import type { Node } from '@/features/invoice-designer/Spec/documentSpec'
+
+const panel: Node = {
+  kind: 'stack',
+  children: [
+    {
+      kind: 'stack',
+      id: 'payment_terms',
+      children: [{ kind: 'text', text: 'Due within 14 days' }],
+    },
+    { kind: 'stack', id: 'due_date', children: [{ kind: 'text', text: '28.08.2026' }] },
+  ],
+}
+
+describe('a row standing in for something unwritten', () => {
+  it('is marked on the canvas, and only that row', () => {
+    const { container, getAllByText } = render(
+      <PlaceholderNodes.Provider value={{ ids: new Set(['payment_terms']), label: 'Placeholder' }}>
+        <RenderNode node={panel} />
+      </PlaceholderNodes.Provider>
+    )
+    expect(getAllByText('Placeholder')).toHaveLength(1)
+    const marked = container.querySelector('[data-node-id="payment_terms"]')?.parentElement
+    expect(marked?.getAttribute('style')).toContain('dashed')
+    const other = container.querySelector('[data-node-id="due_date"]')?.parentElement
+    expect(other?.getAttribute('style') ?? '').not.toContain('dashed')
+  })
+
+  it('carries no mark where the context is not provided', () => {
+    const { container } = render(<RenderNode node={panel} />)
+    expect(container.textContent).not.toContain('Placeholder')
+    expect(container.innerHTML).not.toContain('dashed')
+  })
+})

+ 1 - 3
src/__tests__/features/settings/tax-backfill-respects-lock.test.ts

@@ -57,9 +57,7 @@ beforeEach(() => {
   // the rows whose keys it asked for.
   // the rows whose keys it asked for.
   vi.mocked(db.appSetting.findMany).mockImplementation((async (args: any) => {
   vi.mocked(db.appSetting.findMany).mockImplementation((async (args: any) => {
     const wanted: string[] = args?.where?.key?.in ?? Object.keys(SETTINGS)
     const wanted: string[] = args?.where?.key?.in ?? Object.keys(SETTINGS)
-    return wanted
-      .filter((key) => key in SETTINGS)
-      .map((key) => ({ key, value: SETTINGS[key] }))
+    return wanted.filter((key) => key in SETTINGS).map((key) => ({ key, value: SETTINGS[key] }))
   }) as any)
   }) as any)
   vi.mocked(db.$transaction).mockImplementation(async (cb: any) => cb(db))
   vi.mocked(db.$transaction).mockImplementation(async (cb: any) => cb(db))
 })
 })

+ 22 - 4
src/__tests__/features/workboard/availability.test.ts

@@ -44,7 +44,13 @@ describe('overlapping in time', () => {
 
 
 describe('what counts as a clash', () => {
 describe('what counts as a clash', () => {
   const existing = [
   const existing = [
-    booking({ id: 'job-1', start: at(1, 9), end: at(1, 11), technicianId: 't1', workBayId: 'bay1' }),
+    booking({
+      id: 'job-1',
+      start: at(1, 9),
+      end: at(1, 11),
+      technicianId: 't1',
+      workBayId: 'bay1',
+    }),
   ]
   ]
 
 
   it('clashes when the same technician is already busy', () => {
   it('clashes when the same technician is already busy', () => {
@@ -60,7 +66,10 @@ describe('what counts as a clash', () => {
   it('allows another person in another bay at the same hour', () => {
   it('allows another person in another bay at the same hour', () => {
     // Two jobs at once is the whole point of having two ramps.
     // Two jobs at once is the whole point of having two ramps.
     expect(
     expect(
-      findConflicts({ start: at(1, 10), end: at(1, 12), technicianId: 't2', workBayId: 'bay2' }, existing)
+      findConflicts(
+        { start: at(1, 10), end: at(1, 12), technicianId: 't2', workBayId: 'bay2' },
+        existing
+      )
     ).toEqual([])
     ).toEqual([])
   })
   })
 
 
@@ -68,7 +77,10 @@ describe('what counts as a clash', () => {
     // Unassigned work is on the list, not in the shop, so nothing collides.
     // Unassigned work is on the list, not in the shop, so nothing collides.
     const loose = [booking({ id: 'job-2', start: at(1, 9), end: at(1, 11) })]
     const loose = [booking({ id: 'job-2', start: at(1, 9), end: at(1, 11) })]
     expect(
     expect(
-      findConflicts({ start: at(1, 10), end: at(1, 12), technicianId: 't1', workBayId: 'bay1' }, loose)
+      findConflicts(
+        { start: at(1, 10), end: at(1, 12), technicianId: 't1', workBayId: 'bay1' },
+        loose
+      )
     ).toEqual([])
     ).toEqual([])
   })
   })
 
 
@@ -84,7 +96,13 @@ describe('what counts as a clash', () => {
 
 
   it('catches an inspection holding the bay, not only a job', () => {
   it('catches an inspection holding the bay, not only a job', () => {
     const inspections = [
     const inspections = [
-      booking({ id: 'insp-1', kind: 'inspection', start: at(1, 9), end: at(1, 10), workBayId: 'bay1' }),
+      booking({
+        id: 'insp-1',
+        kind: 'inspection',
+        start: at(1, 9),
+        end: at(1, 10),
+        workBayId: 'bay1',
+      }),
     ]
     ]
     const c = findConflicts({ start: at(1, 9, 30), end: at(1, 11), workBayId: 'bay1' }, inspections)
     const c = findConflicts({ start: at(1, 9, 30), end: at(1, 11), workBayId: 'bay1' }, inspections)
     expect(c.map((b) => b.kind)).toEqual(['inspection'])
     expect(c.map((b) => b.kind)).toEqual(['inspection'])

+ 2 - 2
src/app/(authenticated)/settings/templates/template-settings.tsx

@@ -172,7 +172,7 @@ function TemplateTab({
                   )}
                   )}
                 >
                 >
                   {active && (
                   {active && (
-                    <span className="absolute right-2 top-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
+                    <span className="absolute right-2 top-2 z-10 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
                       {tDesigner('inUse')}
                       {tDesigner('inUse')}
                     </span>
                     </span>
                   )}
                   )}
@@ -207,7 +207,7 @@ function TemplateTab({
               )}
               )}
             >
             >
               {active && (
               {active && (
-                <span className="absolute right-2 top-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
+                <span className="absolute right-2 top-2 z-10 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
                   {tDesigner('inUse')}
                   {tDesigner('inUse')}
                 </span>
                 </span>
               )}
               )}

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

@@ -123,6 +123,7 @@ export default async function InvoiceDesignerPage({
           email: settings[SETTING_KEYS.WORKSHOP_EMAIL] ?? '',
           email: settings[SETTING_KEYS.WORKSHOP_EMAIL] ?? '',
           slogan: settings[SETTING_KEYS.WORKSHOP_SLOGAN] ?? '',
           slogan: settings[SETTING_KEYS.WORKSHOP_SLOGAN] ?? '',
           orgNumber: settings[SETTING_KEYS.INVOICE_ORG_NUMBER] ?? '',
           orgNumber: settings[SETTING_KEYS.INVOICE_ORG_NUMBER] ?? '',
+          paymentTerms: settings[SETTING_KEYS.INVOICE_PAYMENT_TERMS] ?? '',
           logoUrl: settings[SETTING_KEYS.COMPANY_LOGO] ?? '',
           logoUrl: settings[SETTING_KEYS.COMPANY_LOGO] ?? '',
         }}
         }}
       />
       />

+ 1 - 3
src/features/custom-fields/Lib/getCustomFieldsForPrint.ts

@@ -35,9 +35,7 @@ export async function getCustomFieldsForPrint(
       fieldId: d.id,
       fieldId: d.id,
       label: d.label,
       label: d.label,
       fieldType: d.fieldType,
       fieldType: d.fieldType,
-      value: valueByField.has(d.id)
-        ? (valueByField.get(d.id) as string)
-        : (d.defaultValue ?? ''),
+      value: valueByField.has(d.id) ? (valueByField.get(d.id) as string) : (d.defaultValue ?? ''),
     }))
     }))
     .filter((cf) => cf.value !== '')
     .filter((cf) => cf.value !== '')
 }
 }

+ 53 - 2
src/features/invoice-designer/Components/DesignerInspector.tsx

@@ -19,6 +19,7 @@ import {
   fromCustomFieldId,
   fromCustomFieldId,
   getBuiltinFieldName,
   getBuiltinFieldName,
   getBuiltinFieldsForSection,
   getBuiltinFieldsForSection,
+  unmentionedGrandfathered,
   isCustomFieldId,
   isCustomFieldId,
   toCustomFieldId,
   toCustomFieldId,
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 } from '@/features/settings/Schema/invoiceLayoutSchema'
@@ -320,6 +321,8 @@ export function DesignerInspector({
   ownLogo,
   ownLogo,
   onLogo,
   onLogo,
   sloganSet,
   sloganSet,
+  paymentTermsSet,
+  documentTitleDefault,
 }: {
 }: {
   layout: InvoiceLayoutConfig
   layout: InvoiceLayoutConfig
   template: DesignerTemplate
   template: DesignerTemplate
@@ -339,6 +342,10 @@ export function DesignerInspector({
   onLogo: (url: string) => void
   onLogo: (url: string) => void
   /** Whether the workshop has a slogan, or the canvas is showing a stand-in. */
   /** Whether the workshop has a slogan, or the canvas is showing a stand-in. */
   sloganSet: boolean
   sloganSet: boolean
+  /** Whether payment terms are set, or the panel is showing a stand-in. */
+  paymentTermsSet: boolean
+  /** What the title strip prints when the workshop has not renamed it. */
+  documentTitleDefault: string
 }) {
 }) {
   const t = useTranslations('settings.designer')
   const t = useTranslations('settings.designer')
   const tSection = useTranslations('settings.layoutEditor.sections')
   const tSection = useTranslations('settings.layoutEditor.sections')
@@ -370,12 +377,15 @@ export function DesignerInspector({
       .filter((f) => isCustomFieldId(f.id) || builtinIds.has(f.id))
       .filter((f) => isCustomFieldId(f.id) || builtinIds.has(f.id))
     // A field added after this layout was written still needs its switch.
     // 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
     // Without this a saved design, which is loaded as it was stored, can never
-    // reach anything built since, and the option looks simply missing.
+    // reach anything built since, and the option looks simply missing. It
+    // arrives off, except for the rows that printed before they had a switch:
+    // those show what the sheet is already doing.
+    const grandfathered = new Set(unmentionedGrandfathered(section.id, stored))
     const resolvedFields: InvoiceFieldConfig[] = [
     const resolvedFields: InvoiceFieldConfig[] = [
       ...stored,
       ...stored,
       ...builtins
       ...builtins
         .filter((f) => !stored.some((existing) => existing.id === f.id))
         .filter((f) => !stored.some((existing) => existing.id === f.id))
-        .map((f) => ({ id: f.id, visible: false })),
+        .map((f) => ({ id: f.id, visible: grandfathered.has(f.id) })),
     ]
     ]
     // Workshop-defined fields wait as chips below the list until they are
     // Workshop-defined fields wait as chips below the list until they are
     // added, so the list only carries what this section actually uses. The
     // added, so the list only carries what this section actually uses. The
@@ -604,6 +614,26 @@ export function DesignerInspector({
             )}
             )}
           </Group>
           </Group>
 
 
+          {/* What this document calls itself. It is the workshop's to write,
+              because the right word is a matter of local law and local habit:
+              a business registered for GST in Australia must head the sheet
+              "Tax Invoice" and one that is not registered must not. Kept with
+              the design rather than in settings, so a sheet sent last year
+              keeps the name it was sent under. */}
+          {section.id === 'document_title' && (
+            <Group title={t('titleText')}>
+              <input
+                type="text"
+                maxLength={60}
+                value={section.text ?? ''}
+                placeholder={documentTitleDefault}
+                onChange={(e) => onSection(section.id, { text: e.target.value || undefined })}
+                className="h-7 w-full rounded-md border border-[#e3e5e9] px-2 text-[12px]"
+              />
+              <p className="text-[11.5px] leading-snug text-[#8a8f97]">{t('titleTextHint')}</p>
+            </Group>
+          )}
+
           {/* The slogan is the workshop's own words, kept in company settings
           {/* The slogan is the workshop's own words, kept in company settings
               rather than here, because it is the same line wherever it is
               rather than here, because it is the same line wherever it is
               printed. When there is none the canvas shows a stand-in so the
               printed. When there is none the canvas shows a stand-in so the
@@ -626,6 +656,27 @@ export function DesignerInspector({
             </Group>
             </Group>
           )}
           )}
 
 
+          {/* Payment terms live in payment settings, the same line on every
+              invoice, so they are not edited here. When there is none the
+              panel shows a stand-in so the row can be seen and switched, and
+              that has to say so: it reads as a real term otherwise, and it
+              prints as nothing. */}
+          {section.id === 'bank_account' && (
+            <Group title={t('paymentTerms')}>
+              <p className="text-[11.5px] leading-snug text-[#8a8f97]">
+                {paymentTermsSet ? t('paymentTermsSetHint') : t('paymentTermsPlaceholderHint')}{' '}
+                <a
+                  href="/settings/payment"
+                  target="_blank"
+                  rel="noreferrer"
+                  className="font-medium text-[#2563eb] underline underline-offset-2"
+                >
+                  {t('paymentTermsLink')}
+                </a>
+              </p>
+            </Group>
+          )}
+
           {section.id === 'header' && (
           {section.id === 'header' && (
             <Group title={t('logo')}>
             <Group title={t('logo')}>
               <LogoUpload value={logoUrl} own={ownLogo} onChange={onLogo} />
               <LogoUpload value={logoUrl} own={ownLogo} onChange={onLogo} />

+ 22 - 9
src/features/invoice-designer/Components/InvoiceDesigner.tsx

@@ -199,13 +199,18 @@ export function InvoiceDesigner({
 
 
   const layout = layouts[docType]
   const layout = layouts[docType]
   const template = templates[docType]
   const template = templates[docType]
-  // Blocks the canvas is filling in for the workshop. The slogan is the only
-  // one today: the rest of the sample stands in for a job, which a real sheet
-  // will have, while a slogan nobody has written simply never prints.
-  const placeholderIds = useMemo(
-    () => new Set(companyWorkshop.slogan?.trim() ? [] : ['slogan']),
-    [companyWorkshop.slogan]
-  )
+  // What the canvas is filling in for the workshop: a slogan and payment terms
+  // nobody has written show a stand-in that prints as nothing, and are marked
+  // as such. The rest of the sample stands in for a job, which a real sheet
+  // will have. The slogan is a block of its own; the terms are one row of the
+  // payment panel, and marking the whole panel for them would be a lie about
+  // the bank account beside them.
+  const placeholderIds = useMemo(() => {
+    const ids: string[] = []
+    if (!companyWorkshop.slogan?.trim()) ids.push('slogan')
+    if (!companyWorkshop.paymentTerms?.trim()) ids.push('payment_terms')
+    return new Set(ids)
+  }, [companyWorkshop.slogan, companyWorkshop.paymentTerms])
   // What this document actually prints: its own mark when it has one, the
   // 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
   // company logo otherwise. The same fallback the print routes apply, so the
   // canvas cannot promise a picture the paper will not carry.
   // canvas cannot promise a picture the paper will not carry.
@@ -307,8 +312,14 @@ export function InvoiceDesigner({
 
 
   /** What a saved design would produce, for its card in the gallery. */
   /** What a saved design would produce, for its card in the gallery. */
   const specForDesign = useCallback(
   const specForDesign = useCallback(
-    (design: SavedDesign) =>
-      buildDocumentSpec(design.layout, themeOf(design.template, design.layout), data),
+    (design: SavedDesign) => {
+      // Merged the way applying it merges, so the card is the sheet the click
+      // produces rather than the one the design was saved against.
+      const layout = mergeWithDefaults(
+        JSON.parse(JSON.stringify(design.layout)) as InvoiceLayoutConfig
+      )
+      return buildDocumentSpec(layout, themeOf(design.template, layout), data)
+    },
     [data]
     [data]
   )
   )
 
 
@@ -996,6 +1007,8 @@ export function InvoiceDesigner({
           logoUrl={workshop.logoUrl}
           logoUrl={workshop.logoUrl}
           ownLogo={!!template.logoUrl}
           ownLogo={!!template.logoUrl}
           sloganSet={!!companyWorkshop.slogan?.trim()}
           sloganSet={!!companyWorkshop.slogan?.trim()}
+          paymentTermsSet={!!companyWorkshop.paymentTerms?.trim()}
+          documentTitleDefault={data.meta.title}
           onLogo={(url) => setTemplate({ logoUrl: url })}
           onLogo={(url) => setTemplate({ logoUrl: url })}
         />
         />
       </div>
       </div>

+ 1 - 0
src/features/invoice-designer/Components/PresetPreview.tsx

@@ -44,6 +44,7 @@ function useSampleData(docType: DocumentType, workshop?: PreviewWorkshop, logoUr
         email: workshop?.email ?? '',
         email: workshop?.email ?? '',
         slogan: workshop?.slogan ?? '',
         slogan: workshop?.slogan ?? '',
         orgNumber: '',
         orgNumber: '',
+        paymentTerms: '',
         logoUrl: logoUrl ?? '',
         logoUrl: logoUrl ?? '',
       },
       },
       [],
       [],

+ 6 - 2
src/features/invoice-designer/Components/sample.ts

@@ -274,10 +274,14 @@ export function buildSampleData(
         value: values.org_number || `${L('org', 'Org: {org}').replace('{org}', '123 456 789')}`,
         value: values.org_number || `${L('org', 'Org: {org}').replace('{org}', '123 456 789')}`,
       },
       },
       {
       {
+        // The workshop's own terms, or a stand-in when they have written none,
+        // so the row is something to see and switch rather than an absence.
+        // The stand-in prints as nothing, which is why the canvas marks it.
+        id: 'payment_terms',
         label: L('paymentTermsLabel', 'Payment Terms'),
         label: L('paymentTermsLabel', 'Payment Terms'),
-        value: fillTemplate(L('netDays', '{days} Days'), { days: '10' }),
+        value: workshop.paymentTerms?.trim() || t('sample.paymentTerms'),
       },
       },
-      { label: L('dueDateLabel', 'Due Date'), value: sample.due },
+      { id: 'due_date', label: L('dueDateLabel', 'Due Date'), value: sample.due },
     ],
     ],
     // A stand-in link, so the canvas shows the portal line the printed sheet
     // A stand-in link, so the canvas shows the portal line the printed sheet
     // carries and the footer's switch for it has something to switch.
     // carries and the footer's switch for it has something to switch.

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

@@ -44,6 +44,8 @@ export interface DesignerWorkshop {
   email: string
   email: string
   slogan: string
   slogan: string
   orgNumber: string
   orgNumber: string
+  /** Payment terms from payment settings. Empty prints nothing on the sheet. */
+  paymentTerms: string
   logoUrl: string
   logoUrl: string
 }
 }
 
 

+ 10 - 14
src/features/invoice-designer/Pdf/buildInvoicePrint.ts

@@ -362,18 +362,10 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     warrantyParts.push(`${data.warrantyMileage.toLocaleString()} ${L('km', 'km')}`)
     warrantyParts.push(`${data.warrantyMileage.toLocaleString()} ${L('km', 'km')}`)
   }
   }
 
 
-  const netDays = dueDateRaw
-    ? Math.max(
-        0,
-        Math.ceil((dueDateRaw.getTime() - new Date(effectiveInvoiceDate).getTime()) / 86400000)
-      )
-    : 0
-  const paymentTermsText =
-    netDays > 0
-      ? labels.netDays
-        ? fillTemplate(labels.netDays, { days: String(netDays) })
-        : `Net ${netDays} Days`
-      : invoiceSettings?.paymentTerms || ''
+  // The workshop's own words, from payment settings. An empty field prints
+  // nothing at all: a due date is already its own line below, and terms
+  // counted off it ("Net 14 Days") read as a rule nobody wrote.
+  const paymentTermsText = invoiceSettings?.paymentTerms?.trim() || ''
 
 
   const payment: DocumentData['payment'] = []
   const payment: DocumentData['payment'] = []
   if (invoiceSettings?.bankAccount) {
   if (invoiceSettings?.bankAccount) {
@@ -391,10 +383,14 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     })
     })
   }
   }
   if (paymentTermsText) {
   if (paymentTermsText) {
-    payment.push({ label: L('paymentTermsLabel', 'Payment Terms'), value: paymentTermsText })
+    payment.push({
+      id: 'payment_terms',
+      label: L('paymentTermsLabel', 'Payment Terms'),
+      value: paymentTermsText,
+    })
   }
   }
   if (dueDate) {
   if (dueDate) {
-    payment.push({ label: L('dueDateLabel', 'Due Date'), value: dueDate })
+    payment.push({ id: 'due_date', label: L('dueDateLabel', 'Due Date'), value: dueDate })
   }
   }
 
 
   const documentData: DocumentData = {
   const documentData: DocumentData = {

+ 43 - 35
src/features/invoice-designer/Render/SpecCanvas.tsx

@@ -10,7 +10,7 @@ import {
   type DocumentLayout,
   type DocumentLayout,
   type PlacedRect,
   type PlacedRect,
 } from './layoutEngine'
 } from './layoutEngine'
-import { RenderNode, textCss } from './renderHtml'
+import { PlaceholderNodes, RenderNode, textCss } from './renderHtml'
 import { fontStack } from '../Components/types'
 import { fontStack } from '../Components/types'
 
 
 /**
 /**
@@ -267,6 +267,12 @@ export function SpecCanvas({
 }) {
 }) {
   const t = useTranslations('settings.designer')
   const t = useTranslations('settings.designer')
   const placeholderLabel = t('placeholder')
   const placeholderLabel = t('placeholder')
+  // The same set marks blocks here and rows inside them further down, since a
+  // block id and a node id never name the same thing.
+  const placeholders = useMemo(
+    () => ({ ids: placeholderIds ?? new Set<string>(), label: placeholderLabel }),
+    [placeholderIds, placeholderLabel]
+  )
   const measureRef = useRef<HTMLDivElement>(null)
   const measureRef = useRef<HTMLDivElement>(null)
   const pagesRef = useRef<HTMLDivElement>(null)
   const pagesRef = useRef<HTMLDivElement>(null)
   const containerRef = useRef<HTMLDivElement>(null)
   const containerRef = useRef<HTMLDivElement>(null)
@@ -1183,47 +1189,49 @@ export function SpecCanvas({
   }
   }
 
 
   return (
   return (
-    <div
-      ref={containerRef}
-      className="flex flex-1 flex-col items-center overflow-auto bg-[#e4e7eb] px-10 pb-16 pt-9 outline-none"
-      tabIndex={0}
-      onKeyDown={nudge}
-      style={{ cursor: dragActive ? 'grabbing' : undefined }}
-    >
-      {/* Laid out but not shown, to measure every block at print width. */}
+    <PlaceholderNodes.Provider value={placeholders}>
       <div
       <div
-        ref={measureRef}
-        aria-hidden
-        style={{
-          position: 'absolute',
-          top: 0,
-          left: -99999,
-          visibility: 'hidden',
-          fontFamily: fontStack(spec.page.fontFamily),
-          fontSize: spec.page.fontSize,
-        }}
+        ref={containerRef}
+        className="flex flex-1 flex-col items-center overflow-auto bg-[#e4e7eb] px-10 pb-16 pt-9 outline-none"
+        tabIndex={0}
+        onKeyDown={nudge}
+        style={{ cursor: dragActive ? 'grabbing' : undefined }}
       >
       >
-        {spec.blocks.map((block) => (
-          <div key={block.id} style={{ width: widthFor(block), ...textCss(block.text) }}>
-            <RenderNode node={block.content} />
-          </div>
-        ))}
-      </div>
-
-      <div style={{ width: spec.page.width * zoom }}>
+        {/* Laid out but not shown, to measure every block at print width. */}
         <div
         <div
-          ref={pagesRef}
+          ref={measureRef}
+          aria-hidden
           style={{
           style={{
-            transform: `scale(${zoom})`,
-            transformOrigin: 'top left',
-            display: 'flex',
-            flexDirection: 'column',
-            gap: 10,
+            position: 'absolute',
+            top: 0,
+            left: -99999,
+            visibility: 'hidden',
+            fontFamily: fontStack(spec.page.fontFamily),
+            fontSize: spec.page.fontSize,
           }}
           }}
         >
         >
-          {Array.from({ length: renderLayout.pageCount }, (_, i) => sheet(i + 1))}
+          {spec.blocks.map((block) => (
+            <div key={block.id} style={{ width: widthFor(block), ...textCss(block.text) }}>
+              <RenderNode node={block.content} />
+            </div>
+          ))}
+        </div>
+
+        <div style={{ width: spec.page.width * zoom }}>
+          <div
+            ref={pagesRef}
+            style={{
+              transform: `scale(${zoom})`,
+              transformOrigin: 'top left',
+              display: 'flex',
+              flexDirection: 'column',
+              gap: 10,
+            }}
+          >
+            {Array.from({ length: renderLayout.pageCount }, (_, i) => sheet(i + 1))}
+          </div>
         </div>
         </div>
       </div>
       </div>
-    </div>
+    </PlaceholderNodes.Provider>
   )
   )
 }
 }

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

@@ -1,6 +1,6 @@
 'use client'
 'use client'
 
 
-import type { CSSProperties, ReactNode } from 'react'
+import { createContext, useContext, type CSSProperties, type ReactNode } from 'react'
 import { sanitizeHtml } from '@/lib/sanitize-html'
 import { sanitizeHtml } from '@/lib/sanitize-html'
 import type { BoxStyle, Node, TextStyle } from '../Spec/documentSpec'
 import type { BoxStyle, Node, TextStyle } from '../Spec/documentSpec'
 import { fontStack } from '../Components/types'
 import { fontStack } from '../Components/types'
@@ -55,11 +55,58 @@ const JUSTIFY = {
 
 
 const ALIGN = { start: 'flex-start', center: 'center', end: 'flex-end' } as const
 const ALIGN = { start: 'flex-start', center: 'center', end: 'flex-end' } as const
 
 
+/**
+ * Nodes the designer is standing in for, by id, and what to call them.
+ *
+ * Only the editing canvas provides this. Every other reader of this model
+ * draws the same nodes and must never wear the mark, because a stand-in that
+ * reaches paper is a bug the mark would hide rather than something to print.
+ */
+export const PlaceholderNodes = createContext<{
+  ids: ReadonlySet<string>
+  label: string
+} | null>(null)
+
+/** The dashed frame and tag a marked row wears, matching a marked block's. */
+function Marked({ label, children }: { label: string; children: ReactNode }): ReactNode {
+  return (
+    <div style={{ position: 'relative', outline: '1px dashed #c9a227', outlineOffset: 2 }}>
+      {children}
+      <span
+        style={{
+          position: 'absolute',
+          top: -8,
+          right: -2,
+          background: '#fdf6e3',
+          border: '1px solid #e6d8a8',
+          color: '#8a6d1f',
+          fontFamily: "'IBM Plex Sans', sans-serif",
+          fontSize: 9,
+          fontWeight: 600,
+          padding: '1px 5px',
+          borderRadius: 3,
+          whiteSpace: 'nowrap',
+          pointerEvents: 'none',
+        }}
+      >
+        {label}
+      </span>
+    </div>
+  )
+}
+
 /**
 /**
  * Draw one node. Anchored nodes are handled by the page, which lifts them out
  * Draw one node. Anchored nodes are handled by the page, which lifts them out
  * of the flow before they get here, so this only ever lays out in flow.
  * of the flow before they get here, so this only ever lays out in flow.
  */
  */
 export function RenderNode({ node }: { node: Node }): ReactNode {
 export function RenderNode({ node }: { node: Node }): ReactNode {
+  const placeholders = useContext(PlaceholderNodes)
+  const drawn = <NodeBody node={node} />
+  if (!node.id || !placeholders?.ids.has(node.id)) return drawn
+  return <Marked label={placeholders.label}>{drawn}</Marked>
+}
+
+function NodeBody({ node }: { node: Node }): ReactNode {
   const id = node.id ? { 'data-node-id': node.id } : {}
   const id = node.id ? { 'data-node-id': node.id } : {}
 
 
   switch (node.kind) {
   switch (node.kind) {

+ 91 - 55
src/features/invoice-designer/Spec/buildSpec.ts

@@ -2,6 +2,7 @@ import {
   FOOTER_SPECIAL_FIELD_IDS,
   FOOTER_SPECIAL_FIELD_IDS,
   footerColumnsOf,
   footerColumnsOf,
   getBuiltinFieldsForSection,
   getBuiltinFieldsForSection,
+  unmentionedGrandfathered,
   isCustomFieldId,
   isCustomFieldId,
   type InvoiceLayoutConfig,
   type InvoiceLayoutConfig,
   type InvoiceSection,
   type InvoiceSection,
@@ -175,7 +176,12 @@ function lookOf(section: InvoiceSection, theme: DocumentTheme) {
  */
  */
 export function sectionFields(section: InvoiceSection): string[] {
 export function sectionFields(section: InvoiceSection): string[] {
   if (!section.fields) return getBuiltinFieldsForSection(section.id).map((f) => f.id)
   if (!section.fields) return getBuiltinFieldsForSection(section.id).map((f) => f.id)
-  return section.fields.filter((f) => f.visible).map((f) => f.id)
+  return [
+    ...section.fields.filter((f) => f.visible).map((f) => f.id),
+    // A row that printed before it had a switch keeps printing until somebody
+    // uses the switch, so adding one takes nothing off a saved layout.
+    ...unmentionedGrandfathered(section.id, section.fields),
+  ]
 }
 }
 
 
 /**
 /**
@@ -785,63 +791,93 @@ function sloganBlock(
   }
   }
 }
 }
 
 
-function documentTitle(section: InvoiceSection, theme: DocumentTheme, data: DocumentData): Node {
+function documentTitle(
+  section: InvoiceSection,
+  theme: DocumentTheme,
+  data: DocumentData
+): Node | null {
   const look = lookOf(section, theme)
   const look = lookOf(section, theme)
   const size = look.fontSize ?? theme.fontSize
   const size = look.fontSize ?? theme.fontSize
+  // What the strip is asked to say. A cell also needs something to say: a job
+  // with no customer number has no such cell, switch or no switch.
+  const fields = new Set(sectionFields(section))
   const cells = [
   const cells = [
-    [label(data, 'invoiceNumberLabel', 'Invoice No.'), data.meta.number],
-    data.meta.customerNumber
+    fields.has('invoice_number')
+      ? [label(data, 'invoiceNumberLabel', 'Invoice No.'), data.meta.number]
+      : null,
+    data.meta.customerNumber && fields.has('customer_number')
       ? [label(data, 'customerNumberLabel', 'Customer No.'), data.meta.customerNumber]
       ? [label(data, 'customerNumberLabel', 'Customer No.'), data.meta.customerNumber]
       : null,
       : null,
-    [label(data, 'dateLabel', 'Date'), data.meta.date],
-    data.meta.due ? [label(data, 'dueDateLabel', 'Due'), data.meta.due] : null,
+    fields.has('date') ? [label(data, 'dateLabel', 'Date'), data.meta.date] : null,
+    data.meta.due && fields.has('due_date')
+      ? [label(data, 'dueDateLabel', 'Due'), data.meta.due]
+      : null,
   ].filter(Boolean) as [string, string][]
   ].filter(Boolean) as [string, string][]
 
 
+  const showTitle = fields.has('title')
+  // Everything switched off is a strip with nothing to print, and an empty
+  // block would still take its room and its rule on the sheet.
+  if (!showTitle && !cells.length) return null
+
   return {
   return {
     kind: 'row',
     kind: 'row',
     id: section.id,
     id: section.id,
     justify: 'between',
     justify: 'between',
     align: 'end',
     align: 'end',
     children: [
     children: [
-      {
-        node: {
-          kind: 'text',
-          id: 'document_title.title',
-          text: data.meta.title,
-          style: { fontSize: scale(size, 2.4), bold: true, color: look.text },
-        },
-      },
-      {
-        node: {
-          kind: 'row',
-          id: 'document_title.meta',
-          style: { borderColor: look.border || look.text, borderWidth: 1, background: look.fill },
-          children: cells.map(([cellLabel, value]) => ({
-            node: {
-              kind: 'stack',
-              gap: 0,
-              style: { padding: 5 },
-              children: [
-                {
-                  kind: 'text',
-                  text: cellLabel,
-                  style: { color: look.muted, fontSize: scale(size, 0.62), align: 'center' },
-                },
-                {
-                  kind: 'text',
-                  text: value,
-                  style: {
-                    bold: true,
-                    fontSize: scale(size, 0.95),
-                    align: 'center',
-                    color: look.text,
-                  },
+      ...(showTitle
+        ? [
+            {
+              node: {
+                kind: 'text' as const,
+                id: 'document_title.title',
+                text: data.meta.title,
+                style: { fontSize: scale(size, 2.4), bold: true, color: look.text },
+              },
+            },
+          ]
+        : []),
+      // No cells left means no strip beside the title: an empty framed box is
+      // a box the workshop switched the contents out of.
+      ...(cells.length
+        ? [
+            {
+              node: {
+                kind: 'row' as const,
+                id: 'document_title.meta',
+                style: {
+                  borderColor: look.border || look.text,
+                  borderWidth: 1,
+                  background: look.fill,
                 },
                 },
-              ],
-            } as Node,
-          })),
-        },
-      },
+                children: cells.map(([cellLabel, value]) => ({
+                  node: {
+                    kind: 'stack',
+                    gap: 0,
+                    style: { padding: 5 },
+                    children: [
+                      {
+                        kind: 'text',
+                        text: cellLabel,
+                        style: { color: look.muted, fontSize: scale(size, 0.62), align: 'center' },
+                      },
+                      {
+                        kind: 'text',
+                        text: value,
+                        style: {
+                          bold: true,
+                          fontSize: scale(size, 0.95),
+                          align: 'center',
+                          color: look.text,
+                        },
+                      },
+                    ],
+                  } as Node,
+                })),
+              },
+            },
+          ]
+        : []),
     ],
     ],
   }
   }
 }
 }
@@ -1406,10 +1442,13 @@ function paymentBlock(
   const variant = section.variant || (framed ? 'outline' : 'accent')
   const variant = section.variant || (framed ? 'outline' : 'accent')
 
 
   const inline = variant === 'lines'
   const inline = variant === 'lines'
+  // The pair's own id rides on the node, so the designer can mark one row of
+  // the panel rather than the whole panel.
   const cell = (pair: PaymentPair): Node =>
   const cell = (pair: PaymentPair): Node =>
     inline
     inline
       ? {
       ? {
           kind: 'row',
           kind: 'row',
+          id: pair.id,
           gap: 4,
           gap: 4,
           children: [
           children: [
             {
             {
@@ -1430,6 +1469,7 @@ function paymentBlock(
         }
         }
       : {
       : {
           kind: 'stack',
           kind: 'stack',
+          id: pair.id,
           gap: 1,
           gap: 1,
           children: [
           children: [
             {
             {
@@ -1710,9 +1750,15 @@ function blockFor(section: InvoiceSection, theme: DocumentTheme, data: DocumentD
 export function buildDocumentSpec(
 export function buildDocumentSpec(
   layout: InvoiceLayoutConfig,
   layout: InvoiceLayoutConfig,
   theme: DocumentTheme,
   theme: DocumentTheme,
-  data: DocumentData
+  input: DocumentData
 ): DocumentSpec {
 ): DocumentSpec {
   const framed = theme.headerStyle === 'framed'
   const framed = theme.headerStyle === 'framed'
+  // What this document calls itself, resolved once for the whole sheet: the
+  // workshop's own word when they have written one, the app's translated name
+  // otherwise. Every block that prints the title reads it here, so a sheet
+  // whose letterhead carries the title cannot disagree with the title strip.
+  const titleText = layout.sections.find((s) => s.id === 'document_title')?.text?.trim()
+  const data = titleText ? { ...input, meta: { ...input.meta, title: titleText } } : input
   const anchors = layout.anchors ?? {}
   const anchors = layout.anchors ?? {}
   const contentWidth =
   const contentWidth =
     595 - (framed ? FRAMED.padLeft + FRAMED.railWidth + theme.margin : theme.margin * 2)
     595 - (framed ? FRAMED.padLeft + FRAMED.railWidth + theme.margin : theme.margin * 2)
@@ -1795,16 +1841,6 @@ export function buildDocumentSpec(
     push(section)
     push(section)
   }
   }
 
 
-  // The number, the date and the amount a customer quotes back must print
-  // exactly once. When the layout has no title section of its own, the block
-  // 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 && !theme.classic) {
-    const headerOrder = ordered.find((s) => s.id === 'header')?.order ?? 0
-    push({ ...titleSection, visible: true }, { mode: 'flow', order: headerOrder + 0.5 }, true)
-  }
-
   return {
   return {
     page: {
     page: {
       width: 595,
       width: 595,

+ 96 - 9
src/features/settings/Schema/invoiceLayoutSchema.ts

@@ -85,6 +85,14 @@ export const invoiceSectionSchema = z.object({
    * customer card. Unset means shown, which every layout has always done.
    * customer card. Unset means shown, which every layout has always done.
    */
    */
   heading: z.boolean().optional(),
   heading: z.boolean().optional(),
+  /**
+   * Words this section prints in place of the ones the app would print. The
+   * title strip is the section that has any: what a sheet must call itself is
+   * a matter of local law and local habit, not of translation, and a business
+   * that has to print "Tax Invoice" cannot wait for a release to do it.
+   * Blank or unset prints the document's own name in the reader's language.
+   */
+  text: z.string().max(60).optional(),
   /** Appearance overrides for this section. Unset uses the document's own. */
   /** Appearance overrides for this section. Unset uses the document's own. */
   style: invoiceSectionStyleSchema.optional(),
   style: invoiceSectionStyleSchema.optional(),
   /** Controls which fields are shown within this section. */
   /** Controls which fields are shown within this section. */
@@ -144,7 +152,22 @@ export const invoiceLayoutConfigSchema = z.object({
 })
 })
 
 
 /** Stamped on every layout the designer saves. */
 /** Stamped on every layout the designer saves. */
-export const DESIGNER_LAYOUT_VERSION = 2
+export const DESIGNER_LAYOUT_VERSION = 3
+
+/**
+ * The stamp that means "saved from the full-screen designer". It is not the
+ * version the designer writes: that one moves whenever a layout needs reading
+ * differently, and moving this with it would drop every organization back to
+ * the classic sheet.
+ */
+const DESIGNER_ERA_VERSION = 2
+
+/**
+ * The first version whose Document Title switch is obeyed. Below it, a hidden
+ * title strip was drawn anyway, under the header, so hiding it there was never
+ * a choice to keep.
+ */
+const HONEST_TITLE_SWITCH_VERSION = 3
 
 
 /**
 /**
  * Whether this layout was saved from the full-screen designer. Anything else,
  * Whether this layout was saved from the full-screen designer. Anything else,
@@ -152,7 +175,7 @@ export const DESIGNER_LAYOUT_VERSION = 2
  * so a deploy never restyles an organization's documents behind its back.
  * so a deploy never restyles an organization's documents behind its back.
  */
  */
 export function isDesignerLayout(config?: Partial<InvoiceLayoutConfig> | null): boolean {
 export function isDesignerLayout(config?: Partial<InvoiceLayoutConfig> | null): boolean {
-  return (config?.version ?? 1) >= DESIGNER_LAYOUT_VERSION
+  return (config?.version ?? 1) >= DESIGNER_ERA_VERSION
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -190,13 +213,15 @@ export function fromCustomFieldId(cfId: string): string {
 
 
 export const BUILTIN_SECTIONS = [
 export const BUILTIN_SECTIONS = [
   { id: 'header', name: 'Header' },
   { id: 'header', name: 'Header' },
+  // Directly under the header, which is where the strip carrying the number
+  // and the date has always been drawn.
+  { id: 'document_title', name: 'Document Title' },
   // Its own section, not a line inside the header, so it can be placed,
   // Its own section, not a line inside the header, so it can be placed,
   // paired and styled like anything else on the sheet.
   // paired and styled like anything else on the sheet.
   { id: 'slogan', name: 'Slogan' },
   { id: 'slogan', name: 'Slogan' },
   { id: 'customer', name: 'Customer' },
   { id: 'customer', name: 'Customer' },
   { id: 'vehicle', name: 'Vehicle' },
   { id: 'vehicle', name: 'Vehicle' },
   { id: 'service', name: 'Service' },
   { id: 'service', name: 'Service' },
-  { id: 'document_title', name: 'Document Title' },
   { id: 'items_table', name: 'Items Table' },
   { id: 'items_table', name: 'Items Table' },
   { id: 'parts_table', name: 'Parts Table' },
   { id: 'parts_table', name: 'Parts Table' },
   { id: 'labor_table', name: 'Labor Table' },
   { id: 'labor_table', name: 'Labor Table' },
@@ -273,11 +298,51 @@ export const BUILTIN_FOOTER_FIELDS = [
   { id: 'company_org_number', name: 'Organization Number' },
   { id: 'company_org_number', name: 'Organization Number' },
 ] as const
 ] as const
 
 
+/**
+ * The title strip: the big word, and the cells beside it. Each is a switch,
+ * because what a document must name differs by trade and by country, and the
+ * strip is where a sheet says its number and its dates.
+ */
+export const BUILTIN_DOCUMENT_TITLE_FIELDS = [
+  { id: 'title', name: 'Title' },
+  { id: 'invoice_number', name: 'Number' },
+  { id: 'customer_number', name: 'Customer Number' },
+  { id: 'date', name: 'Date' },
+  { id: 'due_date', name: 'Due Date' },
+] as const
+
 export const BUILTIN_BANK_ACCOUNT_FIELDS = [
 export const BUILTIN_BANK_ACCOUNT_FIELDS = [
   { id: 'bank_account', name: 'Bank Account' },
   { id: 'bank_account', name: 'Bank Account' },
   { id: 'org_number', name: 'Organization Number' },
   { id: 'org_number', name: 'Organization Number' },
+  { id: 'payment_terms', name: 'Payment Terms' },
+  { id: 'due_date', name: 'Due Date' },
 ] as const
 ] as const
 
 
+/**
+ * Fields that printed before they had a switch, by section.
+ *
+ * A field added to a section is off in every layout saved before it existed,
+ * which is right for a line that never printed and wrong for one that always
+ * did: switches arrived for the payment panel's terms and due date long after
+ * the rows themselves, and defaulting them off would have quietly stripped
+ * them from every existing design. A layout that does not mention them shows
+ * them; switching one off is a choice somebody made, and is stored.
+ */
+export const GRANDFATHERED_FIELDS: Record<string, readonly string[]> = {
+  bank_account: ['payment_terms', 'due_date'],
+  document_title: ['title', 'invoice_number', 'customer_number', 'date', 'due_date'],
+}
+
+/** The ids of `sectionId`'s grandfathered fields that `fields` never mentions. */
+export function unmentionedGrandfathered(
+  sectionId: string,
+  fields: ReadonlyArray<{ id: string }> | undefined
+): string[] {
+  const ids = GRANDFATHERED_FIELDS[sectionId]
+  if (!ids || !fields) return []
+  return ids.filter((id) => !fields.some((f) => f.id === id))
+}
+
 /** The footer's rows that are not detail lines: the mark, the portal link and the closing note. */
 /** The footer's rows that are not detail lines: the mark, the portal link and the closing note. */
 export const FOOTER_SPECIAL_FIELD_IDS: Set<string> = new Set(['logo', 'portal_link', 'footer_note'])
 export const FOOTER_SPECIAL_FIELD_IDS: Set<string> = new Set(['logo', 'portal_link', 'footer_note'])
 
 
@@ -303,11 +368,13 @@ export type BuiltinVehicleFieldId = (typeof BUILTIN_VEHICLE_FIELDS)[number]['id'
 export type BuiltinServiceFieldId = (typeof BUILTIN_SERVICE_FIELDS)[number]['id']
 export type BuiltinServiceFieldId = (typeof BUILTIN_SERVICE_FIELDS)[number]['id']
 export type BuiltinHeaderFieldId = (typeof BUILTIN_HEADER_FIELDS)[number]['id']
 export type BuiltinHeaderFieldId = (typeof BUILTIN_HEADER_FIELDS)[number]['id']
 export type BuiltinBankAccountFieldId = (typeof BUILTIN_BANK_ACCOUNT_FIELDS)[number]['id']
 export type BuiltinBankAccountFieldId = (typeof BUILTIN_BANK_ACCOUNT_FIELDS)[number]['id']
+export type BuiltinDocumentTitleFieldId = (typeof BUILTIN_DOCUMENT_TITLE_FIELDS)[number]['id']
 export type BuiltinFooterFieldId = (typeof BUILTIN_FOOTER_FIELDS)[number]['id']
 export type BuiltinFooterFieldId = (typeof BUILTIN_FOOTER_FIELDS)[number]['id']
 
 
 /** Sections that have configurable fields */
 /** Sections that have configurable fields */
 export const SECTIONS_WITH_FIELDS = new Set<string>([
 export const SECTIONS_WITH_FIELDS = new Set<string>([
   'header',
   'header',
+  'document_title',
   'footer',
   'footer',
   'customer',
   'customer',
   'vehicle',
   'vehicle',
@@ -375,6 +442,8 @@ function getDefaultFieldsForSection(sectionId: string): InvoiceFieldConfig[] | u
       return BUILTIN_HEADER_FIELDS.map((f) => ({ id: f.id, visible: true }))
       return BUILTIN_HEADER_FIELDS.map((f) => ({ id: f.id, visible: true }))
     case 'bank_account':
     case 'bank_account':
       return BUILTIN_BANK_ACCOUNT_FIELDS.map((f) => ({ id: f.id, visible: true }))
       return BUILTIN_BANK_ACCOUNT_FIELDS.map((f) => ({ id: f.id, visible: true }))
+    case 'document_title':
+      return BUILTIN_DOCUMENT_TITLE_FIELDS.map((f) => ({ id: f.id, visible: true }))
     case 'footer':
     case 'footer':
       // Only the note and the portal link, which is the footer every existing
       // Only the note and the portal link, which is the footer every existing
       // invoice already has.
       // invoice already has.
@@ -396,12 +465,7 @@ function getDefaultFieldsForSection(sectionId: string): InvoiceFieldConfig[] | u
  * rather than joining them, and `document_title` because the standard headers
  * rather than joining them, and `document_title` because the standard headers
  * already print the title themselves.
  * already print the title themselves.
  */
  */
-const HIDDEN_BY_DEFAULT_SECTIONS = new Set<string>([
-  'general',
-  'telegram_qr',
-  'items_table',
-  'document_title',
-])
+const HIDDEN_BY_DEFAULT_SECTIONS = new Set<string>(['general', 'telegram_qr', 'items_table'])
 
 
 export function getDefaultInvoiceLayout(): InvoiceLayoutConfig {
 export function getDefaultInvoiceLayout(): InvoiceLayoutConfig {
   return {
   return {
@@ -492,6 +556,8 @@ export function getBuiltinFieldsForSection(
       return BUILTIN_HEADER_FIELDS
       return BUILTIN_HEADER_FIELDS
     case 'bank_account':
     case 'bank_account':
       return BUILTIN_BANK_ACCOUNT_FIELDS
       return BUILTIN_BANK_ACCOUNT_FIELDS
+    case 'document_title':
+      return BUILTIN_DOCUMENT_TITLE_FIELDS
     case 'footer':
     case 'footer':
       return BUILTIN_FOOTER_FIELDS
       return BUILTIN_FOOTER_FIELDS
     default:
     default:
@@ -507,6 +573,7 @@ export function getBuiltinFieldName(fieldId: string): string | undefined {
     ...BUILTIN_SERVICE_FIELDS,
     ...BUILTIN_SERVICE_FIELDS,
     ...BUILTIN_HEADER_FIELDS,
     ...BUILTIN_HEADER_FIELDS,
     ...BUILTIN_BANK_ACCOUNT_FIELDS,
     ...BUILTIN_BANK_ACCOUNT_FIELDS,
+    ...BUILTIN_DOCUMENT_TITLE_FIELDS,
     ...BUILTIN_FOOTER_FIELDS,
     ...BUILTIN_FOOTER_FIELDS,
   ]
   ]
   return allFields.find((f) => f.id === fieldId)?.name
   return allFields.find((f) => f.id === fieldId)?.name
@@ -600,6 +667,26 @@ export function mergeWithDefaults(saved: Partial<InvoiceLayoutConfig>): InvoiceL
     }
     }
   }
   }
 
 
+  // Before the switch was obeyed, a hidden title strip was drawn anyway, under
+  // the header. Those layouts are read as showing it there, which is what they
+  // print today: without this, honouring the switch would take the number and
+  // the date off every sheet that never chose to lose them. The designer
+  // stamps the current version when it saves, so a workshop that switches the
+  // strip off from now on keeps it off.
+  // Placed by renumbering the whole run rather than by a fraction after the
+  // header: orders are whole numbers as far as the saved layout's schema is
+  // concerned, and a 0.5 that reached the designer was rejected on Save, which
+  // silently kept every such workshop on the layout they were trying to change.
+  if ((saved.version ?? 1) < HONEST_TITLE_SWITCH_VERSION) {
+    const title = merged.find((s) => s.id === 'document_title')
+    if (title && !title.visible) {
+      const rest = merged.filter((s) => s !== title).sort((a, b) => a.order - b.order)
+      const headerIdx = rest.findIndex((s) => s.id === 'header')
+      rest.splice(headerIdx + 1, 0, { ...title, visible: true })
+      merged.splice(0, merged.length, ...rest.map((s, i) => ({ ...s, order: i })))
+    }
+  }
+
   // Auto-assign column values to column-eligible sections if none have columns
   // Auto-assign column values to column-eligible sections if none have columns
   const hasAnyColumn = merged.some((s) => s.column)
   const hasAnyColumn = merged.some((s) => s.column)
   if (!hasAnyColumn) {
   if (!hasAnyColumn) {