Explorar el Código

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 hace 4 semanas
padre
commit
144515b9dc
Se han modificado 32 ficheros con 866 adiciones y 154 borrados
  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",
       "footer_note": "Fußzeilen-Hinweis",
       "org_number": "Organisationsnummer",
+      "payment_terms": "Zahlungsbedingungen",
+      "due_date": "Fälligkeitsdatum",
+      "title": "Titel",
+      "invoice_number": "Rechnungsnummer",
+      "customer_number": "Kundennummer",
+      "date": "Datum",
       "portal_link": "Portal-Link"
     },
     "presetsTitle": "Mit einer Anordnung beginnen",
@@ -1936,6 +1942,7 @@
       "serviceType": "Reparatur",
       "companyName": "Ihre Werkstatt",
       "slogan": "Qualität, auf die Sie sich verlassen können",
+      "paymentTerms": "Zahlbar innerhalb von 14 Tagen",
       "footerNote": "Vielen Dank für Ihren Auftrag",
       "value": "Beispiel"
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Es ist kein Slogan hinterlegt, daher zeigt das Blatt einen Platzhalter, der nicht gedruckt wird.",
     "sloganSetHint": "Ihr Slogan stammt aus Ihren Firmendaten.",
     "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",
     "leaveTitle": "Ohne Speichern verlassen?",
     "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",
       "footer_note": "Footer Note",
       "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"
     },
     "presetsTitle": "Start from an arrangement",
@@ -1936,6 +1942,7 @@
       "serviceType": "Repair",
       "companyName": "Your Workshop",
       "slogan": "Quality service you can trust",
+      "paymentTerms": "Due within 14 days",
       "footerNote": "Thank you for your business",
       "value": "Sample"
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "No slogan is set, so the sheet shows a stand-in that will not print.",
     "sloganSetHint": "Your slogan comes from your company details.",
     "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",
     "leaveTitle": "Leave without saving?",
     "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",
       "footer_note": "Nota de pie",
       "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"
     },
     "presetsTitle": "Empezar con una disposición",
@@ -1936,6 +1942,7 @@
       "serviceType": "Reparación",
       "companyName": "Tu taller",
       "slogan": "Calidad de servicio en la que puedes confiar",
+      "paymentTerms": "Pago en 14 días",
       "footerNote": "Gracias por confiar en nosotros",
       "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á.",
     "sloganSetHint": "Tu eslogan proviene de los datos de tu 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",
     "leaveTitle": "¿Salir sin guardar?",
     "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",
       "footer_note": "Note de bas de page",
       "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"
     },
     "presetsTitle": "Partir d’une disposition",
@@ -1936,6 +1942,7 @@
       "serviceType": "Réparation",
       "companyName": "Votre atelier",
       "slogan": "Un service de qualité en toute confiance",
+      "paymentTerms": "Payable sous 14 jours",
       "footerNote": "Merci de votre confiance",
       "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é.",
     "sloganSetHint": "Votre slogan provient des informations de votre 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",
     "leaveTitle": "Quitter sans enregistrer ?",
     "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",
       "footer_note": "Nota a piè di pagina",
       "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"
     },
     "presetsTitle": "Partire da una disposizione",
@@ -1936,6 +1942,7 @@
       "serviceType": "Riparazione",
       "companyName": "La tua officina",
       "slogan": "Un servizio di qualità di cui fidarsi",
+      "paymentTerms": "Pagamento entro 14 giorni",
       "footerNote": "Grazie per averci scelto",
       "value": "Esempio"
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Nessuno slogan impostato, quindi il foglio mostra un segnaposto che non verrà stampato.",
     "sloganSetHint": "Lo slogan proviene dai dati della tua 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",
     "leaveTitle": "Uscire senza salvare?",
     "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",
       "footer_note": "Poraštės pastaba",
       "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"
     },
     "presetsTitle": "Pradėti nuo išdėstymo",
@@ -1936,6 +1942,7 @@
       "serviceType": "Remontas",
       "companyName": "Jūsų servisas",
       "slogan": "Kokybiškas aptarnavimas, kuriuo galite pasitikėti",
+      "paymentTerms": "Apmokėti per 14 dienų",
       "footerNote": "Ačiū, kad renkatės mus",
       "value": "Pavyzdys"
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Šūkis nenustatytas, todėl lape rodomas pavyzdinis tekstas, kuris nebus spausdinamas.",
     "sloganSetHint": "Jūsų šūkis imamas iš įmonės duomenų.",
     "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",
     "leaveTitle": "Išeiti neišsaugojus?",
     "leaveBody": "Šio dizaino pakeitimai neišsaugoti ir bus prarasti.",

+ 13 - 0
messages/nb/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Bankkonto",
       "footer_note": "Bunntekstnotat",
       "org_number": "Organisasjonsnummer",
+      "payment_terms": "Betalingsbetingelser",
+      "due_date": "Forfallsdato",
+      "title": "Tittel",
+      "invoice_number": "Fakturanr.",
+      "customer_number": "Kundenr.",
+      "date": "Dato",
       "portal_link": "Portallenke"
     },
     "presetsTitle": "Start fra et oppsett",
@@ -1936,6 +1942,7 @@
       "serviceType": "Reparasjon",
       "companyName": "Ditt verksted",
       "slogan": "Kvalitetsservice du kan stole på",
+      "paymentTerms": "Betales innen 14 dager",
       "footerNote": "Takk for handelen",
       "value": "Eksempel"
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Det er ikke satt noe slagord, så arket viser en plassholder som ikke skrives ut.",
     "sloganSetHint": "Slagordet hentes fra firmaopplysningene dine.",
     "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",
     "leaveTitle": "Forlate uten å lagre?",
     "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",
       "footer_note": "Voettekstnotitie",
       "org_number": "Organisatienummer",
+      "payment_terms": "Betalingsvoorwaarden",
+      "due_date": "Vervaldatum",
+      "title": "Titel",
+      "invoice_number": "Factuurnr.",
+      "customer_number": "Klantnr.",
+      "date": "Datum",
       "portal_link": "Portaallink"
     },
     "presetsTitle": "Begin met een indeling",
@@ -1936,6 +1942,7 @@
       "serviceType": "Reparatie",
       "companyName": "Uw werkplaats",
       "slogan": "Kwaliteit waar u op kunt bouwen",
+      "paymentTerms": "Betaling binnen 14 dagen",
       "footerNote": "Bedankt voor uw opdracht",
       "value": "Voorbeeld"
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Er is geen slogan ingesteld, dus het blad toont een voorbeeldtekst die niet wordt afgedrukt.",
     "sloganSetHint": "Je slogan komt uit je bedrijfsgegevens.",
     "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",
     "leaveTitle": "Weggaan zonder opslaan?",
     "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",
       "footer_note": "Notatka w stopce",
       "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"
     },
     "presetsTitle": "Zacznij od układu",
@@ -1936,6 +1942,7 @@
       "serviceType": "Naprawa",
       "companyName": "Twój warsztat",
       "slogan": "Jakość, której możesz zaufać",
+      "paymentTerms": "Płatne w ciągu 14 dni",
       "footerNote": "Dziękujemy za współpracę",
       "value": "Przykład"
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Nie ustawiono sloganu, więc arkusz pokazuje tekst zastępczy, który nie zostanie wydrukowany.",
     "sloganSetHint": "Twój slogan pochodzi z danych 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",
     "leaveTitle": "Wyjść bez zapisywania?",
     "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",
       "footer_note": "Nota de rodapé",
       "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"
     },
     "presetsTitle": "Começar por uma disposição",
@@ -1936,6 +1942,7 @@
       "serviceType": "Reparo",
       "companyName": "Sua oficina",
       "slogan": "Serviço de qualidade em que você pode confiar",
+      "paymentTerms": "Pagamento em 14 dias",
       "footerNote": "Obrigado pela preferência",
       "value": "Exemplo"
     },
@@ -1951,6 +1958,12 @@
     "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.",
     "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",
     "leaveTitle": "Sair sem salvar?",
     "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": "Банковский счёт",
       "footer_note": "Примечание в подвале",
       "org_number": "Номер организации",
+      "payment_terms": "Условия оплаты",
+      "due_date": "Срок оплаты",
+      "title": "Заголовок",
+      "invoice_number": "№ счёта",
+      "customer_number": "№ клиента",
+      "date": "Дата",
       "portal_link": "Ссылка на портал"
     },
     "presetsTitle": "Начать с расположения",
@@ -1936,6 +1942,7 @@
       "serviceType": "Ремонт",
       "companyName": "Ваша мастерская",
       "slogan": "Качественный сервис, которому можно доверять",
+      "paymentTerms": "Оплата в течение 14 дней",
       "footerNote": "Благодарим за обращение",
       "value": "Пример"
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Слоган не задан, поэтому на листе показан образец, который не будет напечатан.",
     "sloganSetHint": "Слоган берётся из данных вашей компании.",
     "sloganLink": "Настройки компании",
+    "paymentTerms": "Условия оплаты",
+    "paymentTermsPlaceholderHint": "Условия оплаты не заданы, поэтому на листе показан образец, который не будет напечатан.",
+    "paymentTermsSetHint": "Условия оплаты берутся из настроек оплаты.",
+    "paymentTermsLink": "Настройки оплаты",
+    "titleText": "Текст заголовка",
+    "titleTextHint": "Как называется этот документ. Если оставить пустым, печатается его собственное название на языке читателя.",
     "placeholder": "Образец",
     "leaveTitle": "Выйти без сохранения?",
     "leaveBody": "Изменения в этом оформлении не сохранены и будут потеряны.",

+ 13 - 0
messages/tr/settings.json

@@ -642,6 +642,12 @@
       "bank_account": "Banka hesabı",
       "footer_note": "Alt bilgi notu",
       "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ı"
     },
     "presetsTitle": "Bir düzenle başlayın",
@@ -1936,6 +1942,7 @@
       "serviceType": "Onarım",
       "companyName": "Servisiniz",
       "slogan": "Güvenebileceğiniz kaliteli hizmet",
+      "paymentTerms": "14 gün içinde ödenir",
       "footerNote": "İş birliğiniz için teşekkürler",
       "value": "Örnek"
     },
@@ -1951,6 +1958,12 @@
     "sloganPlaceholderHint": "Slogan tanımlanmadığı için sayfada yazdırılmayacak bir örnek metin gösteriliyor.",
     "sloganSetHint": "Sloganınız şirket bilgilerinizden gelir.",
     "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",
     "leaveTitle": "Kaydetmeden çıkılsın mı?",
     "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])
     }
     // `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([])
   })
 

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

@@ -1,17 +1,21 @@
 /**
  * @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 { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
 import {
   DESIGNER_LAYOUT_VERSION,
   getDefaultInvoiceLayout,
+  invoiceLayoutConfigSchema,
+  mergeWithDefaults,
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
 
@@ -38,24 +42,32 @@ const data: InvoiceData = {
   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,
   // 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))
   return buildInvoicePrintSpec({ data, template: { layoutConfig: layout } })
 }
 
 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')
     expect(titles).toHaveLength(1)
     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)
+    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 title = spec.blocks.find((b) => b.id === 'document_title')
     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', () => {
     const spec = specWithTitleVisible(true)
     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')
   })
 })
+
+/**
+ * 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', () => {
     const seen = visibility(getDefaultInvoiceLayout())
     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.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.
   vi.mocked(db.appSetting.findMany).mockImplementation((async (args: any) => {
     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)
   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', () => {
   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', () => {
@@ -60,7 +66,10 @@ describe('what counts as a clash', () => {
   it('allows another person in another bay at the same hour', () => {
     // Two jobs at once is the whole point of having two ramps.
     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([])
   })
 
@@ -68,7 +77,10 @@ describe('what counts as a clash', () => {
     // 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) })]
     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([])
   })
 
@@ -84,7 +96,13 @@ describe('what counts as a clash', () => {
 
   it('catches an inspection holding the bay, not only a job', () => {
     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)
     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 && (
-                    <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')}
                     </span>
                   )}
@@ -207,7 +207,7 @@ function TemplateTab({
               )}
             >
               {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')}
                 </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] ?? '',
           slogan: settings[SETTING_KEYS.WORKSHOP_SLOGAN] ?? '',
           orgNumber: settings[SETTING_KEYS.INVOICE_ORG_NUMBER] ?? '',
+          paymentTerms: settings[SETTING_KEYS.INVOICE_PAYMENT_TERMS] ?? '',
           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,
       label: d.label,
       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 !== '')
 }

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

@@ -19,6 +19,7 @@ import {
   fromCustomFieldId,
   getBuiltinFieldName,
   getBuiltinFieldsForSection,
+  unmentionedGrandfathered,
   isCustomFieldId,
   toCustomFieldId,
 } from '@/features/settings/Schema/invoiceLayoutSchema'
@@ -320,6 +321,8 @@ export function DesignerInspector({
   ownLogo,
   onLogo,
   sloganSet,
+  paymentTermsSet,
+  documentTitleDefault,
 }: {
   layout: InvoiceLayoutConfig
   template: DesignerTemplate
@@ -339,6 +342,10 @@ export function DesignerInspector({
   onLogo: (url: string) => void
   /** Whether the workshop has a slogan, or the canvas is showing a stand-in. */
   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 tSection = useTranslations('settings.layoutEditor.sections')
@@ -370,12 +377,15 @@ export function DesignerInspector({
       .filter((f) => isCustomFieldId(f.id) || builtinIds.has(f.id))
     // A field added after this layout was written still needs its switch.
     // Without this a saved design, which is loaded as it was stored, can never
-    // reach anything built since, and the option looks simply missing.
+    // 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[] = [
       ...stored,
       ...builtins
         .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
     // added, so the list only carries what this section actually uses. The
@@ -604,6 +614,26 @@ export function DesignerInspector({
             )}
           </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
               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
@@ -626,6 +656,27 @@ export function DesignerInspector({
             </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' && (
             <Group title={t('logo')}>
               <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 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
   // company logo otherwise. The same fallback the print routes apply, so the
   // 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. */
   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]
   )
 
@@ -996,6 +1007,8 @@ export function InvoiceDesigner({
           logoUrl={workshop.logoUrl}
           ownLogo={!!template.logoUrl}
           sloganSet={!!companyWorkshop.slogan?.trim()}
+          paymentTermsSet={!!companyWorkshop.paymentTerms?.trim()}
+          documentTitleDefault={data.meta.title}
           onLogo={(url) => setTemplate({ logoUrl: url })}
         />
       </div>

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

@@ -44,6 +44,7 @@ function useSampleData(docType: DocumentType, workshop?: PreviewWorkshop, logoUr
         email: workshop?.email ?? '',
         slogan: workshop?.slogan ?? '',
         orgNumber: '',
+        paymentTerms: '',
         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')}`,
       },
       {
+        // 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'),
-        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
     // 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
   slogan: string
   orgNumber: string
+  /** Payment terms from payment settings. Empty prints nothing on the sheet. */
+  paymentTerms: 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')}`)
   }
 
-  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'] = []
   if (invoiceSettings?.bankAccount) {
@@ -391,10 +383,14 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     })
   }
   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) {
-    payment.push({ label: L('dueDateLabel', 'Due Date'), value: dueDate })
+    payment.push({ id: 'due_date', label: L('dueDateLabel', 'Due Date'), value: dueDate })
   }
 
   const documentData: DocumentData = {

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

@@ -10,7 +10,7 @@ import {
   type DocumentLayout,
   type PlacedRect,
 } from './layoutEngine'
-import { RenderNode, textCss } from './renderHtml'
+import { PlaceholderNodes, RenderNode, textCss } from './renderHtml'
 import { fontStack } from '../Components/types'
 
 /**
@@ -267,6 +267,12 @@ export function SpecCanvas({
 }) {
   const t = useTranslations('settings.designer')
   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 pagesRef = useRef<HTMLDivElement>(null)
   const containerRef = useRef<HTMLDivElement>(null)
@@ -1183,47 +1189,49 @@ export function SpecCanvas({
   }
 
   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
-        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
-          ref={pagesRef}
+          ref={measureRef}
+          aria-hidden
           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>
+    </PlaceholderNodes.Provider>
   )
 }

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

@@ -1,6 +1,6 @@
 'use client'
 
-import type { CSSProperties, ReactNode } from 'react'
+import { createContext, useContext, type CSSProperties, type ReactNode } from 'react'
 import { sanitizeHtml } from '@/lib/sanitize-html'
 import type { BoxStyle, Node, TextStyle } from '../Spec/documentSpec'
 import { fontStack } from '../Components/types'
@@ -55,11 +55,58 @@ const JUSTIFY = {
 
 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
  * of the flow before they get here, so this only ever lays out in flow.
  */
 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 } : {}
 
   switch (node.kind) {

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

@@ -2,6 +2,7 @@ import {
   FOOTER_SPECIAL_FIELD_IDS,
   footerColumnsOf,
   getBuiltinFieldsForSection,
+  unmentionedGrandfathered,
   isCustomFieldId,
   type InvoiceLayoutConfig,
   type InvoiceSection,
@@ -175,7 +176,12 @@ function lookOf(section: InvoiceSection, theme: DocumentTheme) {
  */
 export function sectionFields(section: InvoiceSection): string[] {
   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 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 = [
-    [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]
       : 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][]
 
+  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 {
     kind: 'row',
     id: section.id,
     justify: 'between',
     align: 'end',
     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 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 =>
     inline
       ? {
           kind: 'row',
+          id: pair.id,
           gap: 4,
           children: [
             {
@@ -1430,6 +1469,7 @@ function paymentBlock(
         }
       : {
           kind: 'stack',
+          id: pair.id,
           gap: 1,
           children: [
             {
@@ -1710,9 +1750,15 @@ function blockFor(section: InvoiceSection, theme: DocumentTheme, data: DocumentD
 export function buildDocumentSpec(
   layout: InvoiceLayoutConfig,
   theme: DocumentTheme,
-  data: DocumentData
+  input: DocumentData
 ): DocumentSpec {
   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 contentWidth =
     595 - (framed ? FRAMED.padLeft + FRAMED.railWidth + theme.margin : theme.margin * 2)
@@ -1795,16 +1841,6 @@ export function buildDocumentSpec(
     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 {
     page: {
       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.
    */
   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. */
   style: invoiceSectionStyleSchema.optional(),
   /** Controls which fields are shown within this section. */
@@ -144,7 +152,22 @@ export const invoiceLayoutConfigSchema = z.object({
 })
 
 /** 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,
@@ -152,7 +175,7 @@ export const DESIGNER_LAYOUT_VERSION = 2
  * so a deploy never restyles an organization's documents behind its back.
  */
 export function isDesignerLayout(config?: Partial<InvoiceLayoutConfig> | null): boolean {
-  return (config?.version ?? 1) >= DESIGNER_LAYOUT_VERSION
+  return (config?.version ?? 1) >= DESIGNER_ERA_VERSION
 }
 
 // ---------------------------------------------------------------------------
@@ -190,13 +213,15 @@ export function fromCustomFieldId(cfId: string): string {
 
 export const BUILTIN_SECTIONS = [
   { 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,
   // paired and styled like anything else on the sheet.
   { id: 'slogan', name: 'Slogan' },
   { id: 'customer', name: 'Customer' },
   { id: 'vehicle', name: 'Vehicle' },
   { id: 'service', name: 'Service' },
-  { id: 'document_title', name: 'Document Title' },
   { id: 'items_table', name: 'Items Table' },
   { id: 'parts_table', name: 'Parts Table' },
   { id: 'labor_table', name: 'Labor Table' },
@@ -273,11 +298,51 @@ export const BUILTIN_FOOTER_FIELDS = [
   { id: 'company_org_number', name: 'Organization Number' },
 ] 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 = [
   { id: 'bank_account', name: 'Bank Account' },
   { id: 'org_number', name: 'Organization Number' },
+  { id: 'payment_terms', name: 'Payment Terms' },
+  { id: 'due_date', name: 'Due Date' },
 ] 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. */
 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 BuiltinHeaderFieldId = (typeof BUILTIN_HEADER_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']
 
 /** Sections that have configurable fields */
 export const SECTIONS_WITH_FIELDS = new Set<string>([
   'header',
+  'document_title',
   'footer',
   'customer',
   'vehicle',
@@ -375,6 +442,8 @@ function getDefaultFieldsForSection(sectionId: string): InvoiceFieldConfig[] | u
       return BUILTIN_HEADER_FIELDS.map((f) => ({ id: f.id, visible: true }))
     case 'bank_account':
       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':
       // Only the note and the portal link, which is the footer every existing
       // invoice already has.
@@ -396,12 +465,7 @@ function getDefaultFieldsForSection(sectionId: string): InvoiceFieldConfig[] | u
  * rather than joining them, and `document_title` because the standard headers
  * 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 {
   return {
@@ -492,6 +556,8 @@ export function getBuiltinFieldsForSection(
       return BUILTIN_HEADER_FIELDS
     case 'bank_account':
       return BUILTIN_BANK_ACCOUNT_FIELDS
+    case 'document_title':
+      return BUILTIN_DOCUMENT_TITLE_FIELDS
     case 'footer':
       return BUILTIN_FOOTER_FIELDS
     default:
@@ -507,6 +573,7 @@ export function getBuiltinFieldName(fieldId: string): string | undefined {
     ...BUILTIN_SERVICE_FIELDS,
     ...BUILTIN_HEADER_FIELDS,
     ...BUILTIN_BANK_ACCOUNT_FIELDS,
+    ...BUILTIN_DOCUMENT_TITLE_FIELDS,
     ...BUILTIN_FOOTER_FIELDS,
   ]
   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
   const hasAnyColumn = merged.some((s) => s.column)
   if (!hasAnyColumn) {