فهرست منبع

Designer round two: totals styles, attached documents, template hygiene and one save flow (#286)

* Give the totals block its looks: classic lines, box, panel and accent, a width, and a paid stamp

* Let the totals span the row when placed Full, and make the classic lines its default look

* Make Attached Documents its own section, and let the letterhead choose its side

* Hold a panel's inner room when its box comes off, and add the Minimal template

* Ask before a reload throws away the sheet being designed

* Show templates in settings as the sheets they actually produce

* Pair the table toggles, and say on the sheet which arrangement is which

* Default every template to the split tables, and let the items list explain itself

* Put the workshop's own designs on the templates page, and save template edits as designs

* One Save button: name a new design in the dialog, update the one in use, and let a design be deleted cleanly

* Reset returns to what the sheet is based on, not to the generic default

* Rename Reset to Discard changes, grey it out when clean, and keep the delete cross visible

* Let a custom design be deleted from the editor too, behind an unmissable trash button

* Land in the gallery after deleting the design being edited

* Serve tire-hotel, inspection and document files over share links
Bernt Christian Egeland 1 ماه پیش
والد
کامیت
6ced97c5a7
31فایلهای تغییر یافته به همراه1211 افزوده شده و 372 حذف شده
  1. 19 3
      messages/de/settings.json
  2. 19 3
      messages/en/settings.json
  3. 19 3
      messages/es/settings.json
  4. 19 3
      messages/fr/settings.json
  5. 19 3
      messages/it/settings.json
  6. 19 3
      messages/lt/settings.json
  7. 19 3
      messages/nb/settings.json
  8. 19 3
      messages/nl/settings.json
  9. 19 3
      messages/pl/settings.json
  10. 19 3
      messages/pt-BR/settings.json
  11. 19 3
      messages/ru/settings.json
  12. 19 3
      messages/tr/settings.json
  13. 7 4
      src/__tests__/features/settings/framed-template-preset.test.tsx
  14. 2 1
      src/__tests__/features/settings/layout-presets.test.ts
  15. 20 0
      src/app/(authenticated)/settings/templates/page.tsx
  16. 111 27
      src/app/(authenticated)/settings/templates/template-settings.tsx
  17. 3 2
      src/app/(designer)/invoice-designer/page.tsx
  18. 72 0
      src/features/invoice-designer/Components/DesignerInspector.tsx
  19. 204 231
      src/features/invoice-designer/Components/InvoiceDesigner.tsx
  20. 91 0
      src/features/invoice-designer/Components/PresetPreview.tsx
  21. 44 0
      src/features/invoice-designer/Components/designTheme.ts
  22. 53 0
      src/features/invoice-designer/Components/presetSpec.ts
  23. 122 1
      src/features/invoice-designer/Components/sample.ts
  24. 2 4
      src/features/invoice-designer/Pdf/buildInvoicePrint.ts
  25. 2 4
      src/features/invoice-designer/Pdf/buildQuotePrint.ts
  26. 5 1
      src/features/invoice-designer/Pdf/renderPdf.tsx
  27. 1 1
      src/features/invoice-designer/Render/renderHtml.tsx
  28. 173 56
      src/features/invoice-designer/Spec/buildSpec.ts
  29. 2 1
      src/features/invoice-designer/Spec/documentSpec.ts
  30. 14 0
      src/features/settings/Schema/invoiceLayoutSchema.ts
  31. 55 3
      src/features/settings/Schema/layoutPresets.ts

+ 19 - 3
messages/de/settings.json

@@ -586,6 +586,7 @@
       "labor_table": "Arbeit",
       "totals": "Summen",
       "notes": "Notizen",
+      "attached_documents": "Beigefügte Dokumente",
       "diagnostic_notes": "Diagnosenotizen",
       "findings": "Beobachtungen",
       "warranty": "Garantie",
@@ -628,11 +629,15 @@
       },
       "letterhead": {
         "name": "Briefkopf",
-        "description": "Kontaktdaten unten, Titel unter den Adressen, eine nummerierte Liste"
+        "description": "Kontaktdaten unten, Titel unter den Adressen"
       },
       "compact": {
         "name": "Kompakt",
-        "description": "Ohne Kästen, eine nummerierte Liste, nur das Nötigste"
+        "description": "Ohne Kästen, nur das Nötigste"
+      },
+      "minimal": {
+        "name": "Minimal",
+        "description": "Reine Typografie auf Weiß: keine Kästen, keine Bänder, nur Linien und Weißraum"
       },
       "itemized": {
         "name": "Positionsliste",
@@ -1766,8 +1771,19 @@
       "panel": "Panel",
       "outline": "Kontur",
       "lines": "Linien",
-      "hint": "Akzent tönt die Karte in der Primärfarbe. Panel entspricht den anderen Detailkarten. Kontur zeichnet nur einen Rahmen, und Linien druckt ohne Kasten."
+      "hint": "Akzent tönt die Karte in der Primärfarbe. Panel entspricht den anderen Detailkarten. Kontur zeichnet nur einen Rahmen, und Linien druckt ohne Kasten.",
+      "classic": "Klassisch",
+      "box": "Kasten",
+      "totalsHint": "Klassisch druckt reine Zeilen mit einer Linie über der Summe. Kasten rahmt sie ein. Panel und Akzent setzen sie auf eine Karte."
     },
+    "alignment": "Ausrichtung",
+    "alignCenter": "Mitte",
+    "itemsTableHint": "Eine nummerierte Liste aller Positionen des Auftrags, erst Arbeit, dann Teile, anstelle der getrennten Teile- und Arbeitstabellen.",
+    "discardChanges": "Änderungen verwerfen",
+    "tablesExclusiveHint": "Das Blatt druckt eine Anordnung: eine nummerierte Liste oder getrennte Teile- und Arbeitstabellen. Wird die eine eingeschaltet, geht die andere aus.",
+    "innerPadding": "Innenabstand",
+    "totalsWidth": "Breite",
+    "totalsWidthHint": "Leer füllt die Zeile. Ein Wert in Punkt hängt den Kasten rechts an; in einer Spalte nimmt er deren Breite.",
     "placement": "Platzierung",
     "visible": "Sichtbar",
     "columnFull": "Voll",

+ 19 - 3
messages/en/settings.json

@@ -586,6 +586,7 @@
       "labor_table": "Labor Table",
       "totals": "Totals",
       "notes": "Notes",
+      "attached_documents": "Attached Documents",
       "diagnostic_notes": "Diagnostic Notes",
       "findings": "Observations",
       "warranty": "Warranty",
@@ -628,11 +629,15 @@
       },
       "letterhead": {
         "name": "Letterhead",
-        "description": "Contact details along the bottom, title below the addresses, one numbered list"
+        "description": "Contact details along the bottom, title below the addresses"
       },
       "compact": {
         "name": "Compact",
-        "description": "No panels, one numbered list, only the essentials"
+        "description": "No panels, only the essentials"
+      },
+      "minimal": {
+        "name": "Minimal",
+        "description": "Bare type on white: no boxes, no bands, just rules and whitespace"
       },
       "itemized": {
         "name": "Itemized",
@@ -1766,8 +1771,19 @@
       "panel": "Panel",
       "outline": "Outline",
       "lines": "Lines",
-      "hint": "Accent tints the card with the primary color. Panel matches the other detail cards. Outline draws only a border, and Lines prints without a box."
+      "hint": "Accent tints the card with the primary color. Panel matches the other detail cards. Outline draws only a border, and Lines prints without a box.",
+      "classic": "Classic",
+      "box": "Boxed",
+      "totalsHint": "Classic prints bare lines with a rule above the total. Boxed frames them. Panel and Accent set them on a card."
     },
+    "alignment": "Alignment",
+    "alignCenter": "Center",
+    "itemsTableHint": "One numbered list of every line on the job, labor first and parts after, in place of the separate parts and labor tables.",
+    "discardChanges": "Discard changes",
+    "tablesExclusiveHint": "The sheet prints one arrangement: a single numbered list, or separate parts and labor tables. Switching one on switches the other off.",
+    "innerPadding": "Inner spacing",
+    "totalsWidth": "Width",
+    "totalsWidthHint": "Empty spans the row. Set points to hang the box on the right; in a column it takes the column's width.",
     "placement": "Placement",
     "visible": "Visible",
     "columnFull": "Full",

+ 19 - 3
messages/es/settings.json

@@ -586,6 +586,7 @@
       "labor_table": "Mano de obra",
       "totals": "Totales",
       "notes": "Notas",
+      "attached_documents": "Documentos adjuntos",
       "diagnostic_notes": "Notas de diagnóstico",
       "findings": "Observaciones",
       "warranty": "Garantía",
@@ -628,11 +629,15 @@
       },
       "letterhead": {
         "name": "Membrete",
-        "description": "Datos de contacto abajo, título bajo las direcciones, una lista numerada"
+        "description": "Datos de contacto abajo, título bajo las direcciones"
       },
       "compact": {
         "name": "Compacta",
-        "description": "Sin paneles, una lista numerada, solo lo esencial"
+        "description": "Sin paneles, solo lo esencial"
+      },
+      "minimal": {
+        "name": "Minimalista",
+        "description": "Solo tipografía sobre blanco: sin recuadros ni bandas, solo líneas y espacio"
       },
       "itemized": {
         "name": "Detallado",
@@ -1766,8 +1771,19 @@
       "panel": "Panel",
       "outline": "Contorno",
       "lines": "Líneas",
-      "hint": "Acento tiñe la tarjeta con el color principal. Panel iguala a las demás tarjetas de detalle. Contorno dibuja solo un borde, y Líneas imprime sin recuadro."
+      "hint": "Acento tiñe la tarjeta con el color principal. Panel iguala a las demás tarjetas de detalle. Contorno dibuja solo un borde, y Líneas imprime sin recuadro.",
+      "classic": "Clásico",
+      "box": "Recuadro",
+      "totalsHint": "Clásico imprime líneas sueltas con una regla sobre el total. Recuadro las enmarca. Panel y Acento las colocan en una tarjeta."
     },
+    "alignment": "Alineación",
+    "alignCenter": "Centro",
+    "itemsTableHint": "Una lista numerada de todas las líneas del trabajo, primero mano de obra y luego piezas, en lugar de las tablas separadas.",
+    "discardChanges": "Descartar cambios",
+    "tablesExclusiveHint": "La hoja imprime una disposición: una lista numerada única o tablas separadas de piezas y mano de obra. Activar una desactiva la otra.",
+    "innerPadding": "Espaciado interno",
+    "totalsWidth": "Ancho",
+    "totalsWidthHint": "Vacío ocupa toda la fila. Con un valor en puntos, el bloque cuelga a la derecha; en una columna toma el ancho de la columna.",
     "placement": "Colocación",
     "visible": "Visible",
     "columnFull": "Completa",

+ 19 - 3
messages/fr/settings.json

@@ -586,6 +586,7 @@
       "labor_table": "Main-d'œuvre",
       "totals": "Totaux",
       "notes": "Notes",
+      "attached_documents": "Documents joints",
       "diagnostic_notes": "Notes de diagnostic",
       "findings": "Observations",
       "warranty": "Garantie",
@@ -628,11 +629,15 @@
       },
       "letterhead": {
         "name": "En-tête",
-        "description": "Coordonnées en bas, titre sous les adresses, une liste numérotée"
+        "description": "Coordonnées en bas, titre sous les adresses"
       },
       "compact": {
         "name": "Compacte",
-        "description": "Sans cadres, une liste numérotée, l’essentiel seulement"
+        "description": "Sans cadres, l’essentiel seulement"
+      },
+      "minimal": {
+        "name": "Minimal",
+        "description": "Typographie nue sur blanc : sans encadrés ni bandeaux, seulement des filets et de l'espace"
       },
       "itemized": {
         "name": "Détaillé",
@@ -1766,8 +1771,19 @@
       "panel": "Panneau",
       "outline": "Contour",
       "lines": "Lignes",
-      "hint": "Accent teinte la carte avec la couleur principale. Panneau s'aligne sur les autres cartes de détail. Contour ne trace qu'une bordure, et Lignes imprime sans encadré."
+      "hint": "Accent teinte la carte avec la couleur principale. Panneau s'aligne sur les autres cartes de détail. Contour ne trace qu'une bordure, et Lignes imprime sans encadré.",
+      "classic": "Classique",
+      "box": "Encadré",
+      "totalsHint": "Classique imprime des lignes nues avec un filet au-dessus du total. Encadré les entoure d'un cadre. Panneau et Accent les posent sur une carte."
     },
+    "alignment": "Alignement",
+    "alignCenter": "Centre",
+    "itemsTableHint": "Une liste numérotée de toutes les lignes du travail, main-d'œuvre d'abord puis pièces, à la place des tableaux séparés.",
+    "discardChanges": "Annuler les modifications",
+    "tablesExclusiveHint": "La feuille imprime une disposition : une liste numérotée unique, ou des tableaux séparés pour pièces et main-d'œuvre. Activer l'une désactive l'autre.",
+    "innerPadding": "Marge intérieure",
+    "totalsWidth": "Largeur",
+    "totalsWidthHint": "Vide, le bloc occupe toute la ligne. Une valeur en points l'accroche à droite ; dans une colonne, il prend la largeur de la colonne.",
     "placement": "Placement",
     "visible": "Visible",
     "columnFull": "Pleine",

+ 19 - 3
messages/it/settings.json

@@ -586,6 +586,7 @@
       "labor_table": "Manodopera",
       "totals": "Totali",
       "notes": "Note",
+      "attached_documents": "Documenti allegati",
       "diagnostic_notes": "Note diagnostiche",
       "findings": "Osservazioni",
       "warranty": "Garanzia",
@@ -628,11 +629,15 @@
       },
       "letterhead": {
         "name": "Carta intestata",
-        "description": "Contatti in fondo, titolo sotto gli indirizzi, un elenco numerato"
+        "description": "Contatti in fondo, titolo sotto gli indirizzi"
       },
       "compact": {
         "name": "Compatta",
-        "description": "Senza riquadri, un elenco numerato, solo l’essenziale"
+        "description": "Senza riquadri, solo l’essenziale"
+      },
+      "minimal": {
+        "name": "Minimale",
+        "description": "Solo tipografia su bianco: niente riquadri né fasce, solo filetti e spazio"
       },
       "itemized": {
         "name": "Dettagliato",
@@ -1766,8 +1771,19 @@
       "panel": "Pannello",
       "outline": "Contorno",
       "lines": "Linee",
-      "hint": "Accento colora la scheda con il colore principale. Pannello la allinea alle altre schede di dettaglio. Contorno disegna solo un bordo, e Linee stampa senza riquadro."
+      "hint": "Accento colora la scheda con il colore principale. Pannello la allinea alle altre schede di dettaglio. Contorno disegna solo un bordo, e Linee stampa senza riquadro.",
+      "classic": "Classico",
+      "box": "Riquadro",
+      "totalsHint": "Classico stampa righe semplici con un filetto sopra il totale. Riquadro le incornicia. Pannello e Accento le mettono su una scheda."
     },
+    "alignment": "Allineamento",
+    "alignCenter": "Centro",
+    "itemsTableHint": "Un elenco numerato di tutte le righe del lavoro, prima la manodopera e poi i ricambi, al posto delle tabelle separate.",
+    "discardChanges": "Scarta le modifiche",
+    "tablesExclusiveHint": "Il foglio stampa una disposizione: un unico elenco numerato oppure tabelle separate per ricambi e manodopera. Attivarne una disattiva l'altra.",
+    "innerPadding": "Spaziatura interna",
+    "totalsWidth": "Larghezza",
+    "totalsWidthHint": "Vuoto occupa tutta la riga. Un valore in punti appende il blocco a destra; in una colonna prende la larghezza della colonna.",
     "placement": "Posizione",
     "visible": "Visibile",
     "columnFull": "Intera",

+ 19 - 3
messages/lt/settings.json

@@ -586,6 +586,7 @@
       "labor_table": "Darbų lentelė",
       "totals": "Sumos",
       "notes": "Pastabos",
+      "attached_documents": "Pridėti dokumentai",
       "diagnostic_notes": "Diagnostikos pastabos",
       "findings": "Pastebėjimai",
       "warranty": "Garantija",
@@ -628,11 +629,15 @@
       },
       "letterhead": {
         "name": "Firminis blankas",
-        "description": "Kontaktai apačioje, antraštė po adresais, vienas numeruotas sąrašas"
+        "description": "Kontaktai apačioje, antraštė po adresais"
       },
       "compact": {
         "name": "Kompaktiškas",
-        "description": "Be rėmelių, vienas numeruotas sąrašas, tik būtiniausia"
+        "description": "Be rėmelių, tik būtiniausia"
+      },
+      "minimal": {
+        "name": "Minimalus",
+        "description": "Tik tekstas baltame fone: be rėmelių ir juostų, tik linijos ir erdvė"
       },
       "itemized": {
         "name": "Detalizuotas",
@@ -1766,8 +1771,19 @@
       "panel": "Skydelis",
       "outline": "Kontūras",
       "lines": "Linijos",
-      "hint": "Akcentas nuspalvina kortelę pagrindine spalva. Skydelis suvienodina ją su kitomis detalių kortelėmis. Kontūras piešia tik rėmelį, o Linijos spausdina be rėmelio."
+      "hint": "Akcentas nuspalvina kortelę pagrindine spalva. Skydelis suvienodina ją su kitomis detalių kortelėmis. Kontūras piešia tik rėmelį, o Linijos spausdina be rėmelio.",
+      "classic": "Klasikinis",
+      "box": "Rėmelis",
+      "totalsHint": "Klasikinis spausdina paprastas eilutes su linija virš sumos. Rėmelis jas įrėmina. Skydelis ir Akcentas deda jas ant kortelės."
     },
+    "alignment": "Lygiavimas",
+    "alignCenter": "Centre",
+    "itemsTableHint": "Vienas numeruotas visų darbo eilučių sąrašas, pirmiausia darbai, paskui dalys, vietoj atskirų lentelių.",
+    "discardChanges": "Atmesti pakeitimus",
+    "tablesExclusiveHint": "Lape spausdinamas vienas išdėstymas: vienas numeruotas sąrašas arba atskiros dalių ir darbų lentelės. Įjungus vieną, kitas išsijungia.",
+    "innerPadding": "Vidinis tarpas",
+    "totalsWidth": "Plotis",
+    "totalsWidthHint": "Tuščias užima visą eilutę. Nurodžius taškus, blokas kabinamas dešinėje; stulpelyje jis užima stulpelio plotį.",
     "placement": "Išdėstymas",
     "visible": "Matoma",
     "columnFull": "Visas plotis",

+ 19 - 3
messages/nb/settings.json

@@ -586,6 +586,7 @@
       "labor_table": "Arbeid",
       "totals": "Totaler",
       "notes": "Notater",
+      "attached_documents": "Vedlagte dokumenter",
       "diagnostic_notes": "Diagnostiske notater",
       "findings": "Observasjoner",
       "warranty": "Garanti",
@@ -628,11 +629,15 @@
       },
       "letterhead": {
         "name": "Brevhode",
-        "description": "Kontaktinfo nederst, tittel under adressene, én nummerert liste"
+        "description": "Kontaktinfo nederst, tittel under adressene"
       },
       "compact": {
         "name": "Kompakt",
-        "description": "Uten rammer, én nummerert liste, bare det nødvendige"
+        "description": "Uten rammer, bare det nødvendige"
+      },
+      "minimal": {
+        "name": "Minimal",
+        "description": "Ren typografi på hvitt: ingen bokser, ingen bånd, bare streker og luft"
       },
       "itemized": {
         "name": "Spesifisert",
@@ -1766,8 +1771,19 @@
       "panel": "Panel",
       "outline": "Kontur",
       "lines": "Linjer",
-      "hint": "Aksent toner kortet med primærfargen. Panel matcher de andre detaljkortene. Kontur tegner bare en ramme, og Linjer skriver ut uten boks."
+      "hint": "Aksent toner kortet med primærfargen. Panel matcher de andre detaljkortene. Kontur tegner bare en ramme, og Linjer skriver ut uten boks.",
+      "classic": "Klassisk",
+      "box": "Ramme",
+      "totalsHint": "Klassisk skriver ut rene linjer med en strek over totalen. Ramme setter en ramme rundt dem. Panel og Aksent setter dem på et kort."
     },
+    "alignment": "Justering",
+    "alignCenter": "Midt",
+    "itemsTableHint": "Én nummerert liste over alle linjene i jobben, arbeid først og deler etterpå, i stedet for de separate tabellene.",
+    "discardChanges": "Forkast endringer",
+    "tablesExclusiveHint": "Arket skriver ut én ordning: én nummerert liste, eller separate tabeller for deler og arbeid. Slår du på den ene, slås den andre av.",
+    "innerPadding": "Innvendig avstand",
+    "totalsWidth": "Bredde",
+    "totalsWidthHint": "Tom fyller hele raden. En verdi i punkter henger boksen til høyre; i en kolonne tar den kolonnens bredde.",
     "placement": "Plassering",
     "visible": "Synlig",
     "columnFull": "Full",

+ 19 - 3
messages/nl/settings.json

@@ -586,6 +586,7 @@
       "labor_table": "Arbeid",
       "totals": "Totalen",
       "notes": "Notities",
+      "attached_documents": "Bijgevoegde documenten",
       "diagnostic_notes": "Diagnostische notities",
       "findings": "Observaties",
       "warranty": "Garantie",
@@ -628,11 +629,15 @@
       },
       "letterhead": {
         "name": "Briefhoofd",
-        "description": "Contactgegevens onderaan, titel onder de adressen, één genummerde lijst"
+        "description": "Contactgegevens onderaan, titel onder de adressen"
       },
       "compact": {
         "name": "Compact",
-        "description": "Zonder kaders, één genummerde lijst, alleen het nodige"
+        "description": "Zonder kaders, alleen het nodige"
+      },
+      "minimal": {
+        "name": "Minimaal",
+        "description": "Kale typografie op wit: geen kaders of banden, alleen lijnen en witruimte"
       },
       "itemized": {
         "name": "Gespecificeerd",
@@ -1766,8 +1771,19 @@
       "panel": "Paneel",
       "outline": "Omlijning",
       "lines": "Lijnen",
-      "hint": "Accent kleurt de kaart met de primaire kleur. Paneel sluit aan bij de andere detailkaarten. Omlijning tekent alleen een rand, en Lijnen drukt af zonder kader."
+      "hint": "Accent kleurt de kaart met de primaire kleur. Paneel sluit aan bij de andere detailkaarten. Omlijning tekent alleen een rand, en Lijnen drukt af zonder kader.",
+      "classic": "Klassiek",
+      "box": "Kader",
+      "totalsHint": "Klassiek drukt kale regels af met een lijn boven het totaal. Kader zet er een kader omheen. Paneel en Accent zetten ze op een kaart."
     },
+    "alignment": "Uitlijning",
+    "alignCenter": "Midden",
+    "itemsTableHint": "Eén genummerde lijst van alle regels van de klus, eerst arbeid en dan onderdelen, in plaats van de aparte tabellen.",
+    "discardChanges": "Wijzigingen verwerpen",
+    "tablesExclusiveHint": "Het blad drukt één opstelling af: één genummerde lijst, of aparte tabellen voor onderdelen en arbeid. De ene inschakelen schakelt de andere uit.",
+    "innerPadding": "Binnenruimte",
+    "totalsWidth": "Breedte",
+    "totalsWidthHint": "Leeg vult de hele rij. Met een waarde in punten hangt het blok rechts; in een kolom neemt het de breedte van de kolom.",
     "placement": "Plaatsing",
     "visible": "Zichtbaar",
     "columnFull": "Vol",

+ 19 - 3
messages/pl/settings.json

@@ -586,6 +586,7 @@
       "labor_table": "Robocizna",
       "totals": "Podsumowanie",
       "notes": "Notatki",
+      "attached_documents": "Załączone dokumenty",
       "diagnostic_notes": "Notatki diagnostyczne",
       "findings": "Obserwacje",
       "warranty": "Gwarancja",
@@ -628,11 +629,15 @@
       },
       "letterhead": {
         "name": "Papier firmowy",
-        "description": "Kontakt na dole, tytuł pod adresami, jedna numerowana lista"
+        "description": "Kontakt na dole, tytuł pod adresami"
       },
       "compact": {
         "name": "Kompaktowy",
-        "description": "Bez ramek, jedna numerowana lista, tylko to, co konieczne"
+        "description": "Bez ramek, tylko to, co konieczne"
+      },
+      "minimal": {
+        "name": "Minimalny",
+        "description": "Sama typografia na bieli: bez ramek i pasów, tylko linie i światło"
       },
       "itemized": {
         "name": "Pozycjami",
@@ -1766,8 +1771,19 @@
       "panel": "Panel",
       "outline": "Obrys",
       "lines": "Linie",
-      "hint": "Akcent barwi kartę kolorem głównym. Panel dopasowuje ją do pozostałych kart ze szczegółami. Obrys rysuje samą ramkę, a Linie drukują bez ramki."
+      "hint": "Akcent barwi kartę kolorem głównym. Panel dopasowuje ją do pozostałych kart ze szczegółami. Obrys rysuje samą ramkę, a Linie drukują bez ramki.",
+      "classic": "Klasyczny",
+      "box": "Ramka",
+      "totalsHint": "Klasyczny drukuje same wiersze z linią nad sumą. Ramka je obramowuje. Panel i Akcent umieszczają je na karcie."
     },
+    "alignment": "Wyrównanie",
+    "alignCenter": "Środek",
+    "itemsTableHint": "Jedna numerowana lista wszystkich pozycji zlecenia, najpierw robocizna, potem części, zamiast osobnych tabel.",
+    "discardChanges": "Odrzuć zmiany",
+    "tablesExclusiveHint": "Arkusz drukuje jeden układ: jedną numerowaną listę albo osobne tabele części i robocizny. Włączenie jednego wyłącza drugi.",
+    "innerPadding": "Odstęp wewnętrzny",
+    "totalsWidth": "Szerokość",
+    "totalsWidthHint": "Puste wypełnia cały wiersz. Wartość w punktach zawiesza blok po prawej; w kolumnie przyjmuje szerokość kolumny.",
     "placement": "Rozmieszczenie",
     "visible": "Widoczna",
     "columnFull": "Pełna",

+ 19 - 3
messages/pt-BR/settings.json

@@ -586,6 +586,7 @@
       "labor_table": "Mão de obra",
       "totals": "Totais",
       "notes": "Notas",
+      "attached_documents": "Documentos anexados",
       "diagnostic_notes": "Notas de diagnóstico",
       "findings": "Observações",
       "warranty": "Garantia",
@@ -628,11 +629,15 @@
       },
       "letterhead": {
         "name": "Timbre",
-        "description": "Contatos no rodapé, título abaixo dos endereços, uma lista numerada"
+        "description": "Contatos no rodapé, título abaixo dos endereços"
       },
       "compact": {
         "name": "Compacta",
-        "description": "Sem painéis, uma lista numerada, só o essencial"
+        "description": "Sem painéis, só o essencial"
+      },
+      "minimal": {
+        "name": "Minimalista",
+        "description": "Tipografia pura sobre branco: sem caixas nem faixas, só linhas e espaço"
       },
       "itemized": {
         "name": "Discriminado",
@@ -1766,8 +1771,19 @@
       "panel": "Painel",
       "outline": "Contorno",
       "lines": "Linhas",
-      "hint": "Destaque tinge o cartão com a cor principal. Painel combina com os outros cartões de detalhe. Contorno desenha apenas uma borda, e Linhas imprime sem caixa."
+      "hint": "Destaque tinge o cartão com a cor principal. Painel combina com os outros cartões de detalhe. Contorno desenha apenas uma borda, e Linhas imprime sem caixa.",
+      "classic": "Clássico",
+      "box": "Moldura",
+      "totalsHint": "Clássico imprime linhas simples com um traço acima do total. Moldura as emoldura. Painel e Destaque as colocam em um cartão."
     },
+    "alignment": "Alinhamento",
+    "alignCenter": "Centro",
+    "itemsTableHint": "Uma lista numerada de todas as linhas do serviço, primeiro mão de obra e depois peças, no lugar das tabelas separadas.",
+    "discardChanges": "Descartar alterações",
+    "tablesExclusiveHint": "A folha imprime uma disposição: uma única lista numerada ou tabelas separadas de peças e mão de obra. Ativar uma desativa a outra.",
+    "innerPadding": "Espaçamento interno",
+    "totalsWidth": "Largura",
+    "totalsWidthHint": "Vazio ocupa a linha inteira. Um valor em pontos pendura o bloco à direita; em uma coluna, ele usa a largura da coluna.",
     "placement": "Posicionamento",
     "visible": "Visível",
     "columnFull": "Inteira",

+ 19 - 3
messages/ru/settings.json

@@ -586,6 +586,7 @@
       "labor_table": "Таблица работ",
       "totals": "Итоги",
       "notes": "Заметки",
+      "attached_documents": "Прикреплённые документы",
       "diagnostic_notes": "Диагностические заметки",
       "findings": "Наблюдения",
       "warranty": "Гарантия",
@@ -628,11 +629,15 @@
       },
       "letterhead": {
         "name": "Фирменный бланк",
-        "description": "Контакты внизу, заголовок под адресами, один нумерованный список"
+        "description": "Контакты внизу, заголовок под адресами"
       },
       "compact": {
         "name": "Компактное",
-        "description": "Без рамок, один нумерованный список, только необходимое"
+        "description": "Без рамок, только необходимое"
+      },
+      "minimal": {
+        "name": "Минимализм",
+        "description": "Чистая типографика на белом: без рамок и полос, только линии и воздух"
       },
       "itemized": {
         "name": "По позициям",
@@ -1766,8 +1771,19 @@
       "panel": "Панель",
       "outline": "Контур",
       "lines": "Линии",
-      "hint": "Акцент подкрашивает карточку основным цветом. Панель приводит её к виду остальных карточек с деталями. Контур рисует только рамку, а Линии печатают без рамки."
+      "hint": "Акцент подкрашивает карточку основным цветом. Панель приводит её к виду остальных карточек с деталями. Контур рисует только рамку, а Линии печатают без рамки.",
+      "classic": "Классический",
+      "box": "Рамка",
+      "totalsHint": "Классический печатает простые строки с линией над итогом. Рамка обрамляет их. Панель и Акцент помещают их на карточку."
     },
+    "alignment": "Выравнивание",
+    "alignCenter": "По центру",
+    "itemsTableHint": "Единый нумерованный список всех строк заказа: сначала работы, затем запчасти, вместо отдельных таблиц.",
+    "discardChanges": "Отменить изменения",
+    "tablesExclusiveHint": "Лист печатает один вариант: единый нумерованный список или отдельные таблицы запчастей и работ. Включение одного выключает другой.",
+    "innerPadding": "Внутренний отступ",
+    "totalsWidth": "Ширина",
+    "totalsWidthHint": "Пусто — блок занимает всю строку. Значение в пунктах вешает его справа; в колонке он занимает её ширину.",
     "placement": "Размещение",
     "visible": "Виден",
     "columnFull": "Вся ширина",

+ 19 - 3
messages/tr/settings.json

@@ -586,6 +586,7 @@
       "labor_table": "İşçilik",
       "totals": "Toplamlar",
       "notes": "Notlar",
+      "attached_documents": "Ekli Belgeler",
       "diagnostic_notes": "Teşhis notları",
       "findings": "Gözlemler",
       "warranty": "Garanti",
@@ -628,11 +629,15 @@
       },
       "letterhead": {
         "name": "Antet",
-        "description": "İletişim bilgileri altta, başlık adreslerin altında, tek numaralı liste"
+        "description": "İletişim bilgileri altta, başlık adreslerin altında"
       },
       "compact": {
         "name": "Kompakt",
-        "description": "Çerçevesiz, tek numaralı liste, yalnızca gerekli olanlar"
+        "description": "Çerçevesiz, yalnızca gerekli olanlar"
+      },
+      "minimal": {
+        "name": "Minimal",
+        "description": "Beyaz üzerine yalın tipografi: kutu ve bant yok, yalnızca çizgiler ve boşluk"
       },
       "itemized": {
         "name": "Kalem kalem",
@@ -1766,8 +1771,19 @@
       "panel": "Panel",
       "outline": "Çerçeve",
       "lines": "Çizgiler",
-      "hint": "Vurgu, kartı ana renkle tonlar. Panel, diğer ayrıntı kartlarıyla aynı görünür. Çerçeve yalnızca bir kenarlık çizer, Çizgiler ise kutusuz yazdırır."
+      "hint": "Vurgu, kartı ana renkle tonlar. Panel, diğer ayrıntı kartlarıyla aynı görünür. Çerçeve yalnızca bir kenarlık çizer, Çizgiler ise kutusuz yazdırır.",
+      "classic": "Klasik",
+      "box": "Kutulu",
+      "totalsHint": "Klasik, toplamın üzerinde bir çizgiyle sade satırlar yazdırır. Kutulu onları çerçeveler. Panel ve Vurgu onları bir karta yerleştirir."
     },
+    "alignment": "Hizalama",
+    "alignCenter": "Orta",
+    "itemsTableHint": "İşin tüm satırlarının tek numaralı listesi, önce işçilik sonra parçalar, ayrı tabloların yerine.",
+    "discardChanges": "Değişiklikleri yoksay",
+    "tablesExclusiveHint": "Sayfa tek bir düzen yazdırır: tek numaralı liste ya da ayrı parça ve işçilik tabloları. Birini açmak diğerini kapatır.",
+    "innerPadding": "İç boşluk",
+    "totalsWidth": "Genişlik",
+    "totalsWidthHint": "Boş bırakılırsa satırın tamamını kaplar. Punto değeri girilirse blok sağa asılır; bir sütunda sütunun genişliğini alır.",
     "placement": "Yerleşim",
     "visible": "Görünür",
     "columnFull": "Tam",

+ 7 - 4
src/__tests__/features/settings/framed-template-preset.test.tsx

@@ -113,12 +113,14 @@ describe('framed template preset', () => {
     expect(seen.labor_table).toBe(true)
   })
 
-  it('swaps the two tables for the combined one and moves the title down', () => {
+  it('keeps the two tables apart and moves the title down', () => {
+    // The combined list is the itemized template's own arrangement; every
+    // other template shows the parts and labor tables the user expects.
     const seen = visibility(framedLayout)
-    expect(seen.items_table).toBe(true)
+    expect(seen.items_table).toBe(false)
     expect(seen.document_title).toBe(true)
-    expect(seen.parts_table).toBe(false)
-    expect(seen.labor_table).toBe(false)
+    expect(seen.parts_table).toBe(true)
+    expect(seen.labor_table).toBe(true)
   })
 
   it('stands the vehicle opposite the customer', () => {
@@ -200,6 +202,7 @@ describe('framed template preset', () => {
 
   it('offers to take the panel off every card that draws one', () => {
     expect([...BOXED_ELIGIBLE_SECTIONS].sort()).toEqual([
+      'attached_documents',
       'customer',
       'general',
       'notes',

+ 2 - 1
src/__tests__/features/settings/layout-presets.test.ts

@@ -14,11 +14,12 @@ import {
 const LOCALES = readdirSync('messages')
 
 describe('layout presets', () => {
-  it('offers five arrangements', () => {
+  it('offers six arrangements', () => {
     expect(layoutPresets.map((p) => p.id)).toEqual([
       'classic',
       'letterhead',
       'compact',
+      'minimal',
       'itemized',
       'detailed',
     ])

+ 20 - 0
src/app/(authenticated)/settings/templates/page.tsx

@@ -12,6 +12,7 @@ import {
   getInvoiceLayoutConfig,
   getQuoteLayoutConfig,
 } from '@/features/settings/Actions/invoiceLayoutActions'
+import type { SavedDesign } from '@/features/invoice-designer/Components/types'
 export default async function TemplatePage() {
   const data = await getLayoutData()
 
@@ -52,6 +53,9 @@ export default async function TemplatePage() {
         SETTING_KEYS.QUOTE_HEADER_STYLE,
         SETTING_KEYS.QUOTE_LOGO_SIZE,
         SETTING_KEYS.COMPANY_LOGO,
+        SETTING_KEYS.DESIGNER_SAVED_DESIGNS,
+        SETTING_KEYS.INVOICE_ACTIVE_DESIGN,
+        SETTING_KEYS.QUOTE_ACTIVE_DESIGN,
         SETTING_KEYS.WORKSHOP_ADDRESS,
         SETTING_KEYS.WORKSHOP_SLOGAN,
         SETTING_KEYS.WORKSHOP_PHONE,
@@ -85,6 +89,17 @@ export default async function TemplatePage() {
 
   const t = await getTranslations('settings')
 
+  // The workshop's own saved designs, for cards beside the built-in templates.
+  let savedDesigns: SavedDesign[] = []
+  try {
+    const parsed = settings[SETTING_KEYS.DESIGNER_SAVED_DESIGNS]
+      ? JSON.parse(settings[SETTING_KEYS.DESIGNER_SAVED_DESIGNS])
+      : []
+    if (Array.isArray(parsed)) savedDesigns = parsed
+  } catch {
+    savedDesigns = []
+  }
+
   // The preview is meant to look like this workshop's own paper, so it gets the
   // real company details rather than the sample shop's.
   const workshop = {
@@ -142,6 +157,11 @@ export default async function TemplatePage() {
       initialSmsTemplates={smsTemplates}
       logoUrl={settings[SETTING_KEYS.COMPANY_LOGO] || undefined}
       workshop={workshop}
+      savedDesigns={savedDesigns}
+      activeDesigns={{
+        invoice: settings[SETTING_KEYS.INVOICE_ACTIVE_DESIGN] || '',
+        quote: settings[SETTING_KEYS.QUOTE_ACTIVE_DESIGN] || '',
+      }}
       invoiceLayoutConfig={invoiceLayoutResult.success ? invoiceLayoutResult.data : undefined}
       quoteLayoutConfig={quoteLayoutResult.success ? quoteLayoutResult.data : undefined}
     />

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

@@ -16,6 +16,8 @@ import { Loader2, Palette, MessageSquare, RotateCcw } from 'lucide-react'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 import { cn } from '@/lib/utils'
 import { TemplateListClient } from '@/features/inspections/Components/TemplateListClient'
+import { DesignPreview, PresetPreview } from '@/features/invoice-designer/Components/PresetPreview'
+import type { SavedDesign } from '@/features/invoice-designer/Components/types'
 import { Textarea } from '@/components/ui/textarea'
 import { type InvoiceLayoutConfig } from '@/features/settings/Schema/invoiceLayoutSchema'
 
@@ -130,38 +132,104 @@ function ColorRow({
  * with the sheet in front of you, rather than split across a colour form here
  * and an arrangement editor two clicks away.
  */
-function TemplateTab({ documentType }: { documentType: 'invoice' | 'quote' }) {
+function TemplateTab({
+  documentType,
+  workshop,
+  logoUrl,
+  savedDesigns = [],
+  activeDesign = '',
+}: {
+  documentType: 'invoice' | 'quote'
+  workshop?: WorkshopPreviewInfo
+  logoUrl?: string
+  savedDesigns?: SavedDesign[]
+  activeDesign?: string
+}) {
   const t = useTranslations('settings')
+  const tDesigner = useTranslations('settings.designer')
 
   return (
     <AppCard icon={Palette} title={t('templates.presets')} contentClassName="space-y-4">
       <p className="text-sm text-muted-foreground">{t('templates.designerIntro')}</p>
 
-      <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
-        {layoutPresets.map((preset) => (
-          <Link
-            key={preset.id}
-            href={`/invoice-designer?doc=${documentType}&preset=${preset.id}`}
-            target="_blank"
-            rel="noopener"
-            className="rounded-lg border p-3 text-left transition-colors hover:bg-muted"
-          >
-            <div className="overflow-hidden rounded border bg-background">
-              <div className="h-4 bg-primary/80" />
-              <div className="space-y-1 p-2">
-                <div className="h-1 w-3/5 rounded-sm bg-muted-foreground/30" />
-                <div className="h-1 w-4/5 rounded-sm bg-muted-foreground/20" />
-                <div className="h-2 w-full rounded-sm bg-primary/20" />
+      {/* The workshop's own saved designs, above the built-in starting
+          points: the sheets they actually mail out come first. */}
+      {savedDesigns.length > 0 && (
+        <div>
+          <h3 className="mb-2 text-sm font-medium">{tDesigner('yourDesigns')}</h3>
+          <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
+            {savedDesigns.map((design) => {
+              const active = activeDesign === `design:${design.id}`
+              return (
+                <Link
+                  key={design.id}
+                  href={`/invoice-designer?doc=${documentType}&design=${design.id}`}
+                  target="_blank"
+                  rel="noopener"
+                  className={cn(
+                    'relative rounded-lg border p-3 text-left transition-colors hover:bg-muted',
+                    active && 'border-primary'
+                  )}
+                >
+                  {active && (
+                    <span className="absolute right-2 top-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
+                      {tDesigner('inUse')}
+                    </span>
+                  )}
+                  <div className="flex justify-center">
+                    <DesignPreview
+                      design={design}
+                      docType={documentType}
+                      workshop={workshop}
+                      logoUrl={logoUrl}
+                    />
+                  </div>
+                  <p className="mt-2 truncate text-xs font-medium">{design.name}</p>
+                </Link>
+              )
+            })}
+          </div>
+        </div>
+      )}
+
+      <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
+        {layoutPresets.map((preset) => {
+          const active = activeDesign === `preset:${preset.id}`
+          return (
+            <Link
+              key={preset.id}
+              href={`/invoice-designer?doc=${documentType}&preset=${preset.id}`}
+              target="_blank"
+              rel="noopener"
+              className={cn(
+                'relative rounded-lg border p-3 text-left transition-colors hover:bg-muted',
+                active && 'border-primary'
+              )}
+            >
+              {active && (
+                <span className="absolute right-2 top-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
+                  {tDesigner('inUse')}
+                </span>
+              )}
+              {/* The sheet this template actually produces, on this workshop's
+                  own details — the same picture the designer's gallery draws. */}
+              <div className="flex justify-center">
+                <PresetPreview
+                  preset={preset}
+                  docType={documentType}
+                  workshop={workshop}
+                  logoUrl={logoUrl}
+                />
               </div>
-            </div>
-            <p className="mt-2 text-xs font-medium">
-              {t(`layoutEditor.presets.${preset.id}.name` as Parameters<typeof t>[0])}
-            </p>
-            <p className="text-[11px] leading-tight text-muted-foreground">
-              {t(`layoutEditor.presets.${preset.id}.description` as Parameters<typeof t>[0])}
-            </p>
-          </Link>
-        ))}
+              <p className="mt-2 text-xs font-medium">
+                {t(`layoutEditor.presets.${preset.id}.name` as Parameters<typeof t>[0])}
+              </p>
+              <p className="text-[11px] leading-tight text-muted-foreground">
+                {t(`layoutEditor.presets.${preset.id}.description` as Parameters<typeof t>[0])}
+              </p>
+            </Link>
+          )
+        })}
       </div>
 
       <div className="flex items-center justify-between gap-4 border-t pt-4">
@@ -361,6 +429,8 @@ export function TemplateSettings({
   workshop,
   invoiceLayoutConfig,
   quoteLayoutConfig,
+  savedDesigns = [],
+  activeDesigns = { invoice: '', quote: '' },
 }: {
   initialInvoiceValues: TemplateValues
   initialQuoteValues: TemplateValues
@@ -371,6 +441,8 @@ export function TemplateSettings({
   workshop?: WorkshopPreviewInfo
   invoiceLayoutConfig?: InvoiceLayoutConfig
   quoteLayoutConfig?: InvoiceLayoutConfig
+  savedDesigns?: SavedDesign[]
+  activeDesigns?: { invoice: string; quote: string }
 }) {
   const router = useRouter()
   const searchParams = useSearchParams()
@@ -538,9 +610,21 @@ export function TemplateSettings({
         <>
           <ReadOnlyWrapper>
             {tab === 'invoice' ? (
-              <TemplateTab documentType="invoice" />
+              <TemplateTab
+                documentType="invoice"
+                workshop={workshop}
+                logoUrl={logoUrl}
+                savedDesigns={savedDesigns}
+                activeDesign={activeDesigns.invoice}
+              />
             ) : (
-              <TemplateTab documentType="quote" />
+              <TemplateTab
+                documentType="quote"
+                workshop={workshop}
+                logoUrl={logoUrl}
+                savedDesigns={savedDesigns}
+                activeDesign={activeDesigns.quote}
+              />
             )}
           </ReadOnlyWrapper>
 

+ 3 - 2
src/app/(designer)/invoice-designer/page.tsx

@@ -17,7 +17,7 @@ import type { SavedDesign } from '@/features/invoice-designer/Components/types'
 export default async function InvoiceDesignerPage({
   searchParams,
 }: {
-  searchParams: Promise<{ doc?: string; view?: string; preset?: string }>
+  searchParams: Promise<{ doc?: string; view?: string; preset?: string; design?: string }>
 }) {
   const data = await getLayoutData()
   if (data.status === 'unauthenticated') redirect('/auth/sign-in')
@@ -65,7 +65,7 @@ export default async function InvoiceDesignerPage({
   } catch {
     savedDesigns = []
   }
-  const { doc, view, preset } = await searchParams
+  const { doc, view, preset, design } = await searchParams
 
   const templateFor = (prefix: 'invoice' | 'quote') => ({
     primaryColor:
@@ -92,6 +92,7 @@ export default async function InvoiceDesignerPage({
         initialDocumentType={doc === 'quote' ? 'quote' : 'invoice'}
         initialView={view === 'designer' ? 'designer' : 'gallery'}
         initialPresetId={preset}
+        initialDesignId={design}
         initialActiveDesigns={{
           invoice: settings['invoice.activeDesign'] || '',
           quote: settings['quote.activeDesign'] || '',

+ 72 - 0
src/features/invoice-designer/Components/DesignerInspector.tsx

@@ -33,6 +33,7 @@ const HEADED_SECTIONS = new Set([
   'service',
   'general',
   'notes',
+  'attached_documents',
   'warranty',
   'bank_account',
   'parts_table',
@@ -427,6 +428,36 @@ export function DesignerInspector({
             </Group>
           )}
 
+          {section.id === 'totals' && (
+            <Group title={t('panelStyle')}>
+              <Choice
+                value={section.variant ?? 'classic'}
+                options={[
+                  { value: 'classic', label: t('variant.classic') },
+                  { value: 'box', label: t('variant.box') },
+                  { value: 'panel', label: t('variant.panel') },
+                  { value: 'accent', label: t('variant.accent') },
+                ]}
+                onChange={(variant) => onSection(section.id, { variant })}
+              />
+              <p className="text-[11.5px] leading-snug text-[#8a8f97]">{t('variant.totalsHint')}</p>
+              <Row label={t('totalsWidth')}>
+                <input
+                  type="number"
+                  min={140}
+                  max={515}
+                  value={style.width ?? ''}
+                  placeholder={t('auto')}
+                  onChange={(e) =>
+                    setStyle({ width: e.target.value ? Number(e.target.value) : undefined })
+                  }
+                  className="h-7 w-20 rounded-md border border-[#e3e5e9] px-2 text-[12px]"
+                />
+              </Row>
+              <p className="text-[11.5px] leading-snug text-[#8a8f97]">{t('totalsWidthHint')}</p>
+            </Group>
+          )}
+
           <Group title={t('placement')}>
             <Row label={t('visible')}>
               <Toggle
@@ -434,6 +465,13 @@ export function DesignerInspector({
                 onChange={(visible) => onSection(section.id, { visible })}
               />
             </Row>
+            {(section.id === 'items_table' ||
+              section.id === 'parts_table' ||
+              section.id === 'labor_table') && (
+              <p className="text-[11.5px] leading-snug text-[#8a8f97]">
+                {section.id === 'items_table' ? t('itemsTableHint') : t('tablesExclusiveHint')}
+              </p>
+            )}
             {COLUMN_ELIGIBLE_SECTIONS.has(section.id) && (
               <Choice
                 value={section.column ?? 'full'}
@@ -503,6 +541,21 @@ export function DesignerInspector({
               ))}
             </div>
             <p className="text-[11.5px] leading-snug text-[#8a8f97]">{t('spacingHint')}</p>
+            {(BOXED_ELIGIBLE_SECTIONS.has(section.id) ||
+              section.id === 'bank_account' ||
+              section.id === 'totals') && (
+              <Slider
+                label={t('innerPadding')}
+                value={
+                  style.padding ??
+                  (section.id === 'totals' ? 0 : section.id === 'bank_account' ? 12 : 10)
+                }
+                min={0}
+                max={40}
+                suffix="pt"
+                onChange={(padding) => setStyle({ padding })}
+              />
+            )}
           </Group>
 
           {/* The slogan is the workshop's own words, kept in company settings
@@ -540,6 +593,25 @@ export function DesignerInspector({
                 suffix="%"
                 onChange={(logoSize) => onTemplate({ logoSize })}
               />
+              <div>
+                <div className="mb-1.5 text-[13px] font-medium">{t('alignment')}</div>
+                <Choice
+                  value={
+                    style.align ??
+                    (template.headerStyle === 'modern'
+                      ? 'center'
+                      : template.headerStyle === 'compact'
+                        ? 'left'
+                        : 'right')
+                  }
+                  options={[
+                    { value: 'left', label: t('columnLeft') },
+                    { value: 'center', label: t('alignCenter') },
+                    { value: 'right', label: t('columnRight') },
+                  ]}
+                  onChange={(align) => setStyle({ align: align as 'left' | 'center' | 'right' })}
+                />
+              </div>
               <p className="text-[11.5px] leading-snug text-[#8a8f97]">{t('logoHint')}</p>
             </Group>
           )}

+ 204 - 231
src/features/invoice-designer/Components/InvoiceDesigner.tsx

@@ -1,7 +1,7 @@
 'use client'
 
 import { useCallback, useEffect, useMemo, useState } from 'react'
-import { Eye, EyeOff } from 'lucide-react'
+import { Eye, EyeOff, Trash2 } from 'lucide-react'
 import { useMessages, useTranslations } from 'next-intl'
 import { useRouter } from 'next/navigation'
 import { toast } from 'sonner'
@@ -24,7 +24,6 @@ import {
   getDefaultInvoiceLayout,
   materializeHiddenSection,
   mergeWithDefaults,
-  toCustomFieldId,
   type InvoiceDocumentStyle,
   type InvoiceLayoutConfig,
   type InvoiceSection,
@@ -39,23 +38,13 @@ import { BASE_FONT_SIZE } from '@/features/vehicles/Components/invoice-pdf/style
 import { SpecCanvas } from '../Render/SpecCanvas'
 import { SpecThumbnail } from '../Render/SpecThumbnail'
 import { buildDocumentSpec, frameShadowWidth, type DocumentData } from '../Spec/buildSpec'
-import { fieldValues, fillTemplate, sampleTables, type PrintLabels } from './sample'
+import { buildSampleData, type PrintLabels } from './sample'
+import { specForPreset } from './presetSpec'
+import { themeOf } from './designTheme'
 import type { InvoiceAnchor } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { DesignerInspector, type DesignerFieldDef } from './DesignerInspector'
 import type { DesignerTemplate, DesignerWorkshop, DocumentType, SavedDesign } from './types'
 
-/** Blend two hex colors, used to derive the secondary tone the PDF derives. */
-function mix(from: string, to: string, amount: number) {
-  const parse = (hex: string) => {
-    const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
-    return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [17, 24, 39]
-  }
-  const a = parse(from)
-  const b = parse(to)
-  const at = (i: number) => Math.round(a[i] + (b[i] - a[i]) * amount)
-  return `rgb(${at(0)}, ${at(1)}, ${at(2)})`
-}
-
 /** The template fields a preset carries, over whatever is already set. */
 function presetTemplatePatch(preset: (typeof layoutPresets)[number]) {
   return {
@@ -80,34 +69,6 @@ function designId(): string {
   return `design-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
 }
 
-/** The theme a designer template and layout resolve to, for the generator. */
-function themeOf(template: DesignerTemplate, layout: InvoiceLayoutConfig) {
-  const doc = layout.document ?? {}
-  const text = template.textColor || '#111827'
-  const background = template.backgroundColor || '#ffffff'
-  const banded = template.headerStyle === 'framed' || template.headerStyle === 'modern'
-  return {
-    primary: template.primaryColor,
-    background,
-    text,
-    muted: template.textColor ? mix(text, background, 0.42) : '#6b7280',
-    accent: doc.accentColor || template.primaryColor,
-    companyText: template.companyTextColor || (banded ? '#ffffff' : template.primaryColor),
-    fontFamily: doc.fontFamily || template.fontFamily,
-    fontSize: doc.fontSize ?? BASE_FONT_SIZE,
-    margin: doc.margin ?? 40,
-    rowPadding: doc.rowPadding ?? 5,
-    stripes: doc.stripes !== false,
-    stripeColor: doc.stripeColor || mix(background, text, 0.045),
-    headerStyle: template.headerStyle,
-    frameSide: template.frameSide === 'right' ? ('right' as const) : ('left' as const),
-    frameBorderColor: template.frameBorderColor || undefined,
-    frameShadow: frameShadowWidth(template.frameShadow),
-    frameRadius: template.frameRadius,
-    logoSize: template.logoSize,
-  }
-}
-
 export function InvoiceDesigner({
   initialDocumentType,
   initialView,
@@ -117,6 +78,7 @@ export function InvoiceDesigner({
   quoteTemplate,
   initialSavedDesigns = [],
   initialPresetId,
+  initialDesignId,
   initialActiveDesigns,
   workshop: companyWorkshop,
   customFields,
@@ -130,6 +92,8 @@ export function InvoiceDesigner({
   initialSavedDesigns?: SavedDesign[]
   /** A preset to arrive with already applied, from settings' starting points. */
   initialPresetId?: string
+  /** A saved design to arrive with already applied, from settings' cards. */
+  initialDesignId?: string
   /** What each document's design is based on: "preset:<id>" or "design:<id>". */
   initialActiveDesigns?: Record<DocumentType, string>
   workshop: DesignerWorkshop
@@ -142,24 +106,33 @@ export function InvoiceDesigner({
   const messages = useMessages() as {
     pdf?: Record<string, Record<string, string>>
   }
-  // Arriving with ?preset= means a starting point was picked in settings:
-  // land in the designer with it applied, as unsaved work.
-  const initialPreset = initialPresetId
-    ? layoutPresets.find((p) => p.id === initialPresetId)
+  // Arriving with ?preset= or ?design= means a starting point was picked in
+  // settings: land in the designer with it applied, as unsaved work. A named
+  // design wins over a preset when a link somehow carries both.
+  const initialDesign = initialDesignId
+    ? initialSavedDesigns.find((d) => d.id === initialDesignId)
     : undefined
-  const [view, setView] = useState<'gallery' | 'designer'>(initialPreset ? 'designer' : initialView)
+  const initialPreset =
+    !initialDesign && initialPresetId
+      ? layoutPresets.find((p) => p.id === initialPresetId)
+      : undefined
+  const arrivedWith = initialDesign ?? initialPreset
+  const [view, setView] = useState<'gallery' | 'designer'>(arrivedWith ? 'designer' : initialView)
   const [docType, setDocType] = useState<DocumentType>(initialDocumentType)
   const [layouts, setLayouts] = useState<Record<DocumentType, InvoiceLayoutConfig>>(() => {
     const base = {
       invoice: invoiceLayout ?? getDefaultInvoiceLayout(),
       quote: quoteLayout ?? getDefaultInvoiceLayout(),
     }
-    if (initialPreset) base[initialDocumentType] = buildLayoutFromPreset(initialPreset)
+    if (initialDesign) base[initialDocumentType] = mergeWithDefaults(initialDesign.layout)
+    else if (initialPreset) base[initialDocumentType] = buildLayoutFromPreset(initialPreset)
     return base
   })
   const [templates, setTemplates] = useState<Record<DocumentType, DesignerTemplate>>(() => {
     const base = { invoice: invoiceTemplate, quote: quoteTemplate }
-    if (initialPreset) {
+    if (initialDesign) {
+      base[initialDocumentType] = { ...initialDesign.template }
+    } else if (initialPreset) {
       base[initialDocumentType] = {
         ...base[initialDocumentType],
         ...presetTemplatePatch(initialPreset),
@@ -174,19 +147,36 @@ export function InvoiceDesigner({
   // Per document, because Save only writes the sheet being looked at: one
   // shared flag let saving the quote clear the invoice's unsaved edits.
   const [dirty, setDirty] = useState<Record<DocumentType, boolean>>({
-    invoice: !!initialPreset && initialDocumentType === 'invoice',
-    quote: !!initialPreset && initialDocumentType === 'quote',
+    invoice: !!arrivedWith && initialDocumentType === 'invoice',
+    quote: !!arrivedWith && initialDocumentType === 'quote',
   })
-  // The preset in the URL is a one-shot instruction, consumed above. Left in
-  // the address bar it would re-apply itself on every refresh, overriding
-  // whatever the user picked since, so it is stripped once acted on.
+  // The preset or design in the URL is a one-shot instruction, consumed
+  // above. Left in the address bar it would re-apply itself on every refresh,
+  // overriding whatever the user picked since, so it is stripped once acted
+  // on.
   useEffect(() => {
-    if (!initialPresetId) return
+    if (!initialPresetId && !initialDesignId) return
     const url = new URL(window.location.href)
     url.searchParams.delete('preset')
+    url.searchParams.delete('design')
     url.searchParams.set('view', 'designer')
     window.history.replaceState(null, '', url.toString())
-  }, [initialPresetId])
+  }, [initialPresetId, initialDesignId])
+
+  // A reload or a closed tab throws away everything since the last Save —
+  // including a template just carried in from settings — and then shows the
+  // saved layout, which reads as the design changing by itself. The browser
+  // asks first, so losing the work is a choice rather than a surprise.
+  useEffect(() => {
+    if (!dirty.invoice && !dirty.quote) return
+    const warn = (event: BeforeUnloadEvent) => {
+      event.preventDefault()
+      // Chrome still wants the legacy channel to show the dialog.
+      event.returnValue = ''
+    }
+    window.addEventListener('beforeunload', warn)
+    return () => window.removeEventListener('beforeunload', warn)
+  }, [dirty])
 
   /** What the sheet being edited is based on, per document, for the gallery. */
   const [activeDesigns, setActiveDesigns] = useState<Record<DocumentType, string>>(() => {
@@ -194,13 +184,14 @@ export function InvoiceDesigner({
       invoice: initialActiveDesigns?.invoice ?? '',
       quote: initialActiveDesigns?.quote ?? '',
     }
-    if (initialPreset) base[initialDocumentType] = `preset:${initialPreset.id}`
+    if (initialDesign) base[initialDocumentType] = `design:${initialDesign.id}`
+    else if (initialPreset) base[initialDocumentType] = `preset:${initialPreset.id}`
     return base
   })
   const [savedDesigns, setSavedDesigns] = useState<SavedDesign[]>(initialSavedDesigns)
   /** Open state and draft name for the save-design dialog. */
   const [namingDesign, setNamingDesign] = useState(false)
-  const [designName, setDesignName] = useState('')
+  const [designName, setDesignName] = useState(initialDesign?.name ?? '')
   const confirm = useConfirm()
 
   const layout = layouts[docType]
@@ -261,91 +252,10 @@ export function InvoiceDesigner({
   )
 
   /** What a workshop's own sheet says, with the sample standing in for a job. */
-  const data: DocumentData = useMemo(() => {
-    const sample = sampleTables(t, printLabels)
-    const values = fieldValues(workshop, t, printLabels)
-    return {
-      fields: {
-        ...values,
-        // A custom field prints whatever the job carries; here it shows its
-        // own name so the workshop can see where it will sit.
-        ...Object.fromEntries(
-          customFields
-            .filter((f) => f.isActive)
-            .map((f) => [toCustomFieldId(f.id), `${f.label || f.name}: ${t('sample.value')}`])
-        ),
-      },
-      logoUrl: workshop.logoUrl || undefined,
-      labels: printLabels,
-      meta: {
-        title: sample.title,
-        number: sample.number,
-        customerNumber: sample.customerNumber,
-        date: sample.date,
-        due: sample.due,
-      },
-      items: sample.items.map((item) => ({
-        n: String(item.n),
-        qty: item.qty,
-        unit: item.unit,
-        desc: item.desc,
-        sub: item.sku,
-        price: item.price,
-        total: item.total,
-      })),
-      parts: sample.items
-        .filter((item) => item.sku)
-        .map((item) => ({
-          ref: item.sku as string,
-          desc: item.desc,
-          qty: item.qty,
-          price: item.price,
-          total: item.total,
-        })),
-      labor: sample.items
-        .filter((item) => !item.sku)
-        .map((item) => ({
-          desc: item.desc,
-          qty: `${item.qty} ${item.unit}`,
-          rate: item.price,
-          total: item.total,
-        })),
-      findings: sample.findings,
-      totals: [
-        { label: L('subtotal', 'Subtotal'), value: sample.subtotal, kind: 'line' as const },
-        {
-          // The tax label carries the rate, the way the printed sheet does.
-          label: fillTemplate(L('tax', 'Tax ({rate}%)'), { rate: '25' }),
-          value: sample.tax,
-          kind: 'line' as const,
-        },
-        { label: L('total', 'Total'), value: sample.total, kind: 'total' as const },
-      ],
-      notes: { html: sample.notes },
-      warranty: { duration: sample.warranty },
-      payment: [
-        { id: 'bank_account', label: L('bankAccount', 'Bank Account'), value: values.bank_account },
-        {
-          id: 'org_number',
-          label: L('orgNumberLabel', 'Org. Number'),
-          value: values.org_number || `${L('org', 'Org: {org}').replace('{org}', '123 456 789')}`,
-        },
-        {
-          label: L('paymentTermsLabel', 'Payment Terms'),
-          value: fillTemplate(L('netDays', '{days} Days'), { days: '10' }),
-        },
-        { label: L('dueDateLabel', 'Due Date'), value: sample.due },
-      ],
-      sectionLabels: {
-        customer: L('billTo', 'Bill To'),
-        vehicle: L('vehicle', 'Vehicle'),
-        service: L('service', 'Service'),
-        bank_account: L('paymentInformation', 'Payment Information'),
-        general: L('customFieldsTitle', 'Additional Information'),
-        findings: L('findings', 'Observations'),
-      },
-    }
-  }, [workshop, customFields, t, printLabels, L])
+  const data: DocumentData = useMemo(
+    () => buildSampleData(workshop, customFields, t, printLabels, docType),
+    [workshop, customFields, t, printLabels, docType]
+  )
 
   const spec = useMemo(
     () => buildDocumentSpec(layout, themeOf(template, layout), data),
@@ -358,6 +268,9 @@ export function InvoiceDesigner({
       setLayout(buildLayoutFromPreset(preset))
       setTemplate(presetTemplatePatch(preset))
       setActiveDesigns((prev) => ({ ...prev, [docType]: `preset:${preset.id}` }))
+      // The sheet is no longer the design that name belonged to; keeping it
+      // would let Save quietly overwrite that design with this template.
+      setDesignName('')
       setView('designer')
     },
     [docType, setLayout, setTemplate]
@@ -371,32 +284,6 @@ export function InvoiceDesigner({
     })
   }, [])
 
-  /** Snapshot the current design under the name typed into the dialog. */
-  const saveDesignAs = useCallback(() => {
-    const name = designName.trim()
-    if (!name) return
-    const snapshot = {
-      name,
-      savedAt: new Date().toISOString(),
-      layout: JSON.parse(JSON.stringify(layout)) as InvoiceLayoutConfig,
-      template: { ...template },
-    }
-    // The same name means the same design: saving again updates it in place
-    // rather than filling the gallery with near-copies.
-    const existing = savedDesigns.find((d) => d.name.trim().toLowerCase() === name.toLowerCase())
-    if (existing) {
-      persistDesigns(savedDesigns.map((d) => (d.id === existing.id ? { ...d, ...snapshot } : d)))
-      setActiveDesigns((prev) => ({ ...prev, [docType]: `design:${existing.id}` }))
-      toast.success(t('designUpdated', { name }))
-    } else {
-      const id = designId()
-      persistDesigns([{ id, ...snapshot }, ...savedDesigns].slice(0, 24))
-      setActiveDesigns((prev) => ({ ...prev, [docType]: `design:${id}` }))
-      toast.success(t('designSaved', { name }))
-    }
-    setNamingDesign(false)
-  }, [designName, docType, layout, template, savedDesigns, persistDesigns])
-
   /** Bring a saved design back, onto whichever document is being edited. */
   const applyDesign = useCallback(
     (design: SavedDesign) => {
@@ -422,35 +309,44 @@ export function InvoiceDesigner({
     [data]
   )
 
+  /**
+   * Take one saved design out of the gallery, after asking. The sheet on the
+   * canvas keeps whatever it shows; only the saved copy goes, and nothing can
+   * claim to be "in use" or quietly save over the dead name afterwards.
+   */
+  const deleteDesign = useCallback(
+    async (design: SavedDesign) => {
+      const ok = await confirm({
+        title: t('deleteDesignTitle'),
+        description: t('deleteDesignBody', { name: design.name }),
+        confirmLabel: t('delete'),
+        destructive: true,
+      })
+      if (!ok) return false
+      persistDesigns(savedDesigns.filter((d) => d.id !== design.id))
+      setActiveDesigns((prev) => ({
+        invoice: prev.invoice === `design:${design.id}` ? '' : prev.invoice,
+        quote: prev.quote === `design:${design.id}` ? '' : prev.quote,
+      }))
+      if (designName.trim().toLowerCase() === design.name.trim().toLowerCase()) {
+        setDesignName('')
+      }
+      return true
+    },
+    [confirm, t, persistDesigns, savedDesigns, designName]
+  )
+
+  /** The saved design this document is based on, when it is based on one. */
+  const currentDesign = useMemo(() => {
+    const active = activeDesigns[docType] ?? ''
+    return active.startsWith('design:')
+      ? savedDesigns.find((d) => d.id === active.slice('design:'.length))
+      : undefined
+  }, [activeDesigns, docType, savedDesigns])
+
   /** What a template would produce, for its card in the gallery. */
   const specFor = useCallback(
-    (preset: (typeof layoutPresets)[number]) =>
-      buildDocumentSpec(
-        buildLayoutFromPreset(preset),
-        {
-          primary: preset.template.primaryColor,
-          background: preset.template.backgroundColor || '#ffffff',
-          text: preset.template.textColor || '#111827',
-          muted: '#6b7280',
-          accent: preset.document?.accentColor || preset.template.primaryColor,
-          companyText:
-            preset.template.headerStyle === 'framed' || preset.template.headerStyle === 'modern'
-              ? '#ffffff'
-              : preset.template.primaryColor,
-          fontFamily: preset.template.fontFamily,
-          fontSize: preset.document?.fontSize ?? BASE_FONT_SIZE,
-          margin: preset.document?.margin ?? 40,
-          rowPadding: preset.document?.rowPadding ?? 5,
-          stripes: preset.document?.stripes !== false,
-          stripeColor: preset.document?.stripeColor || '#f3f4f6',
-          headerStyle: preset.template.headerStyle,
-          frameSide: preset.template.frameSide ?? 'left',
-          frameShadow: frameShadowWidth(undefined),
-          frameRadius: 0,
-          logoSize: 100,
-        },
-        data
-      ),
+    (preset: (typeof layoutPresets)[number]) => specForPreset(preset, data),
     [data]
   )
 
@@ -549,10 +445,21 @@ export function InvoiceDesigner({
 
   const patchSection = useCallback(
     (id: string, patch: Partial<InvoiceSection>) => {
-      setLayout({
-        ...layout,
-        sections: layout.sections.map((s) => (s.id === id ? { ...s, ...patch } : s)),
-      })
+      let sections = layout.sections.map((s) => (s.id === id ? { ...s, ...patch } : s))
+      // The numbered items list REPLACES the separate parts and labor tables;
+      // it never joins them. Toggling either arrangement on turns the other
+      // off, and turning the list off brings the pair back, so the sheet
+      // always says what the job cost and never says it twice.
+      if (patch.visible !== undefined) {
+        if (id === 'items_table') {
+          sections = sections.map((s) =>
+            s.id === 'parts_table' || s.id === 'labor_table' ? { ...s, visible: !patch.visible } : s
+          )
+        } else if ((id === 'parts_table' || id === 'labor_table') && patch.visible) {
+          sections = sections.map((s) => (s.id === 'items_table' ? { ...s, visible: false } : s))
+        }
+      }
+      setLayout({ ...layout, sections })
     },
     [layout, setLayout]
   )
@@ -620,10 +527,59 @@ export function InvoiceDesigner({
     router.push('/settings/templates')
   }
 
-  const save = async () => {
+  const save = async (nameOverride?: string) => {
+    // What is saved always lands in the gallery as the workshop's own design.
+    // A sheet based on a template becomes a new named design, so the
+    // templates themselves always stay what they came as; one already based
+    // on a design updates that design in place, so its card keeps showing
+    // what is actually in use.
+    let active = activeDesigns[docType] ?? ''
+    const activeId = active.startsWith('design:') ? active.slice('design:'.length) : null
+    const existing = activeId ? savedDesigns.find((d) => d.id === activeId) : undefined
+
+    // A new design needs its name before anything is written, so Save opens
+    // the naming dialog instead of inventing one; the dialog calls back here.
+    const name = existing ? existing.name : (nameOverride ?? '').trim()
+    if (!existing && !name) {
+      const presetId = active.startsWith('preset:') ? active.slice('preset:'.length) : null
+      setDesignName(
+        designName.trim() ||
+          (presetId && tPreset.has(`${presetId}.name`)
+            ? tPreset(`${presetId}.name`)
+            : t('defaultDesignName', { doc: t(docType) }))
+      )
+      setNamingDesign(true)
+      return
+    }
+
     setSaving(true)
     try {
       const prefix = docType === 'invoice' ? 'invoice' : 'quote'
+
+      const snapshot = {
+        savedAt: new Date().toISOString(),
+        layout: JSON.parse(JSON.stringify(layout)) as InvoiceLayoutConfig,
+        template: { ...template },
+      }
+      if (existing) {
+        persistDesigns(savedDesigns.map((d) => (d.id === existing.id ? { ...d, ...snapshot } : d)))
+      } else {
+        // The same name means the same design: saving again updates it in
+        // place rather than filling the gallery with near-copies.
+        const sameName = savedDesigns.find(
+          (d) => d.name.trim().toLowerCase() === name.toLowerCase()
+        )
+        const id = sameName?.id ?? designId()
+        persistDesigns(
+          sameName
+            ? savedDesigns.map((d) => (d.id === id ? { ...d, name, ...snapshot } : d))
+            : [{ id, name, ...snapshot }, ...savedDesigns].slice(0, 24)
+        )
+        setDesignName(name)
+        active = `design:${id}`
+        setActiveDesigns((prev) => ({ ...prev, [docType]: active }))
+      }
+
       // The stamp that graduates this organization from the classic
       // pre-designer rendering to whatever this designer shows.
       const stamped = { ...layout, version: DESIGNER_LAYOUT_VERSION }
@@ -637,7 +593,7 @@ export function InvoiceDesigner({
           [`${prefix}.frameBorderColor`]: template.frameBorderColor,
           [`${prefix}.frameShadow`]: template.frameShadow,
           [`${prefix}.frameRadius`]: String(template.frameRadius),
-          [`${prefix}.activeDesign`]: activeDesigns[docType] ?? '',
+          [`${prefix}.activeDesign`]: active,
           [`${prefix}.frameSide`]: template.frameSide,
           [`${prefix}.fontFamily`]: template.fontFamily,
           [`${prefix}.headerStyle`]: template.headerStyle,
@@ -721,19 +677,11 @@ export function InvoiceDesigner({
                       </button>
                       <button
                         type="button"
-                        onClick={async () => {
-                          const ok = await confirm({
-                            title: t('deleteDesignTitle'),
-                            description: t('deleteDesignBody', { name: design.name }),
-                            confirmLabel: t('delete'),
-                            destructive: true,
-                          })
-                          if (ok) persistDesigns(savedDesigns.filter((d) => d.id !== design.id))
-                        }}
+                        onClick={() => void deleteDesign(design)}
                         title={t('deleteDesign')}
-                        className="absolute right-2 top-2 z-10 hidden h-6 w-6 items-center justify-center rounded-full bg-white/90 text-[13px] text-[#8a8f97] shadow group-hover:flex hover:text-[#dc2626]"
+                        className="absolute right-2 top-2 z-10 flex h-7 w-7 items-center justify-center rounded-full border border-[#fca5a5] bg-white text-[#dc2626] shadow hover:bg-[#dc2626] hover:text-white"
                       >
-                        ✕
+                        <Trash2 size={13} />
                       </button>
                     </div>
                   )
@@ -878,36 +826,59 @@ export function InvoiceDesigner({
             inside it, rather than being something to go and look for. */}
         <DocsLink href="/docs/configuration/invoice-designer" variant="header" />
 
-        <button
-          type="button"
-          onClick={() => {
-            setDesignName((prev) => prev.trim() || t('defaultDesignName', { doc: t(docType) }))
-            setNamingDesign(true)
-          }}
-          title={t('saveDesignHint')}
-          className="rounded-[7px] border border-[#e3e5e9] px-3 py-1.5 text-[13px] font-medium hover:bg-[#f4f5f7]"
-        >
-          ♡ {t('saveDesign')}
-        </button>
+        {/* Only a design of the workshop's own can be deleted; the built-in
+            templates are fixed starting points and always stay. */}
+        {currentDesign && (
+          <button
+            type="button"
+            onClick={async () => {
+              // The sheet on the canvas was that design; with it gone, the
+              // gallery is the natural place to land and pick the next one.
+              if (await deleteDesign(currentDesign)) {
+                setSelected(null)
+                setDirty((prev) => ({ ...prev, [docType]: false }))
+                setView('gallery')
+              }
+            }}
+            title={t('deleteDesign')}
+            className="flex items-center gap-1.5 rounded-[7px] border border-[#e3e5e9] px-3 py-1.5 text-[13px] font-medium text-[#dc2626] hover:border-[#fca5a5] hover:bg-[#fef2f2]"
+          >
+            <Trash2 size={13} /> {t('delete')}
+          </button>
+        )}
 
         <button
           type="button"
           onClick={() => {
-            setLayout(getDefaultInvoiceLayout())
+            // Back to what this sheet is based on: the template as it came,
+            // the design as it was last saved. Only a sheet based on nothing
+            // falls back to the default arrangement — resetting "Detailed" to
+            // the generic default just moved things nobody had touched.
+            const active = activeDesigns[docType] ?? ''
+            const basisPreset = active.startsWith('preset:')
+              ? layoutPresets.find((p) => p.id === active.slice('preset:'.length))
+              : undefined
+            const basisDesign = active.startsWith('design:')
+              ? savedDesigns.find((d) => d.id === active.slice('design:'.length))
+              : undefined
+            if (basisDesign) applyDesign(basisDesign)
+            else if (basisPreset) applyPreset(basisPreset)
+            else setLayout(getDefaultInvoiceLayout())
             setSelected(null)
           }}
-          className="rounded-[7px] border border-[#e3e5e9] px-3 py-1.5 text-[13px] font-medium hover:bg-[#f4f5f7]"
+          disabled={!dirty[docType]}
+          className="rounded-[7px] border border-[#e3e5e9] px-3 py-1.5 text-[13px] font-medium hover:bg-[#f4f5f7] disabled:opacity-50"
         >
-          {t('reset')}
+          {t('discardChanges')}
         </button>
 
         <button
           type="button"
-          onClick={save}
+          onClick={() => void save()}
           disabled={saving || !dirty[docType]}
           className="rounded-[7px] bg-[#2563eb] px-4 py-[7px] text-[13px] font-semibold text-white hover:bg-[#1d4ed8] disabled:opacity-50"
         >
-          {saving ? t('saving') : dirty[docType] ? t('save') : t('saved')}
+          {saving ? t('saving') : dirty[docType] ? `♡ ${t('saveDesign')}` : t('saved')}
         </button>
       </div>
 
@@ -1035,7 +1006,9 @@ export function InvoiceDesigner({
           <form
             onSubmit={(e) => {
               e.preventDefault()
-              saveDesignAs()
+              if (!designName.trim()) return
+              setNamingDesign(false)
+              void save(designName)
             }}
           >
             <Input

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

@@ -0,0 +1,91 @@
+'use client'
+
+import { useMemo } from 'react'
+import { useMessages, useTranslations } from 'next-intl'
+import type { LayoutPreset } from '@/features/settings/Schema/layoutPresets'
+import { SpecThumbnail } from '../Render/SpecThumbnail'
+import { buildSampleData, type PrintLabels } from './sample'
+import { specForDesign, specForPreset } from './presetSpec'
+import type { DocumentType, SavedDesign } from './types'
+
+/**
+ * The template and design cards' pictures: the sheet each one actually
+ * produces, drawn from the same spec the designer's gallery renders. Made for
+ * the settings page, where the cards used to be identical mock-ups.
+ */
+
+interface PreviewWorkshop {
+  name?: string
+  address?: string
+  phone?: string
+  email?: string
+  slogan?: string
+}
+
+/** The sample document on this workshop's own details, for a card's picture. */
+function useSampleData(docType: DocumentType, workshop?: PreviewWorkshop, logoUrl?: string) {
+  const t = useTranslations('settings.designer')
+  const messages = useMessages() as { pdf?: Record<string, Record<string, string>> }
+
+  return useMemo(() => {
+    const pdf = messages.pdf ?? {}
+    // The same label resolution the print path applies: quote wording over the
+    // invoice's where the two differ.
+    const labels: PrintLabels = {
+      ...(pdf.invoice ?? {}),
+      ...(docType === 'quote' ? (pdf.quote ?? {}) : {}),
+      ...(pdf.common ?? {}),
+    }
+    return buildSampleData(
+      {
+        name: workshop?.name ?? '',
+        address: workshop?.address ?? '',
+        phone: workshop?.phone ?? '',
+        email: workshop?.email ?? '',
+        slogan: workshop?.slogan ?? '',
+        orgNumber: '',
+        logoUrl: logoUrl ?? '',
+      },
+      [],
+      t,
+      labels,
+      docType
+    )
+  }, [docType, workshop, logoUrl, t, messages])
+}
+
+export function PresetPreview({
+  preset,
+  docType,
+  workshop,
+  logoUrl,
+  height = 170,
+}: {
+  preset: LayoutPreset
+  docType: DocumentType
+  workshop?: PreviewWorkshop
+  logoUrl?: string
+  height?: number
+}) {
+  const data = useSampleData(docType, workshop, logoUrl)
+  const spec = useMemo(() => specForPreset(preset, data), [preset, data])
+  return <SpecThumbnail spec={spec} height={height} />
+}
+
+export function DesignPreview({
+  design,
+  docType,
+  workshop,
+  logoUrl,
+  height = 170,
+}: {
+  design: SavedDesign
+  docType: DocumentType
+  workshop?: PreviewWorkshop
+  logoUrl?: string
+  height?: number
+}) {
+  const data = useSampleData(docType, workshop, logoUrl)
+  const spec = useMemo(() => specForDesign(design, data), [design, data])
+  return <SpecThumbnail spec={spec} height={height} />
+}

+ 44 - 0
src/features/invoice-designer/Components/designTheme.ts

@@ -0,0 +1,44 @@
+import { BASE_FONT_SIZE } from '@/features/vehicles/Components/invoice-pdf/styles'
+import type { InvoiceLayoutConfig } from '@/features/settings/Schema/invoiceLayoutSchema'
+import { frameShadowWidth } from '../Spec/buildSpec'
+import type { DesignerTemplate } from './types'
+
+/** Blend two hex colors, used to derive the secondary tone the PDF derives. */
+export function mix(from: string, to: string, amount: number) {
+  const parse = (hex: string) => {
+    const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
+    return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [17, 24, 39]
+  }
+  const a = parse(from)
+  const b = parse(to)
+  const at = (i: number) => Math.round(a[i] + (b[i] - a[i]) * amount)
+  return `rgb(${at(0)}, ${at(1)}, ${at(2)})`
+}
+
+/** The theme a designer template and layout resolve to, for the generator. */
+export function themeOf(template: DesignerTemplate, layout: InvoiceLayoutConfig) {
+  const doc = layout.document ?? {}
+  const text = template.textColor || '#111827'
+  const background = template.backgroundColor || '#ffffff'
+  const banded = template.headerStyle === 'framed' || template.headerStyle === 'modern'
+  return {
+    primary: template.primaryColor,
+    background,
+    text,
+    muted: template.textColor ? mix(text, background, 0.42) : '#6b7280',
+    accent: doc.accentColor || template.primaryColor,
+    companyText: template.companyTextColor || (banded ? '#ffffff' : template.primaryColor),
+    fontFamily: doc.fontFamily || template.fontFamily,
+    fontSize: doc.fontSize ?? BASE_FONT_SIZE,
+    margin: doc.margin ?? 40,
+    rowPadding: doc.rowPadding ?? 5,
+    stripes: doc.stripes !== false,
+    stripeColor: doc.stripeColor || mix(background, text, 0.045),
+    headerStyle: template.headerStyle,
+    frameSide: template.frameSide === 'right' ? ('right' as const) : ('left' as const),
+    frameBorderColor: template.frameBorderColor || undefined,
+    frameShadow: frameShadowWidth(template.frameShadow),
+    frameRadius: template.frameRadius,
+    logoSize: template.logoSize,
+  }
+}

+ 53 - 0
src/features/invoice-designer/Components/presetSpec.ts

@@ -0,0 +1,53 @@
+import { buildLayoutFromPreset, type LayoutPreset } from '@/features/settings/Schema/layoutPresets'
+import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
+import { BASE_FONT_SIZE } from '@/features/vehicles/Components/invoice-pdf/styles'
+import { buildDocumentSpec, frameShadowWidth, type DocumentData } from '../Spec/buildSpec'
+import type { DocumentSpec } from '../Spec/documentSpec'
+import { themeOf } from './designTheme'
+import type { SavedDesign } from './types'
+
+/**
+ * What a gallery template would print, as a document spec.
+ *
+ * One resolver for every card that shows a template: the designer's gallery
+ * and the template cards in settings draw from the same spec, so a template
+ * cannot look like one thing in settings and another in the designer.
+ */
+export function specForPreset(preset: LayoutPreset, data: DocumentData): DocumentSpec {
+  return buildDocumentSpec(
+    buildLayoutFromPreset(preset),
+    {
+      primary: preset.template.primaryColor,
+      background: preset.template.backgroundColor || '#ffffff',
+      text: preset.template.textColor || '#111827',
+      muted: '#6b7280',
+      accent: preset.document?.accentColor || preset.template.primaryColor,
+      companyText:
+        preset.template.headerStyle === 'framed' || preset.template.headerStyle === 'modern'
+          ? '#ffffff'
+          : preset.template.primaryColor,
+      fontFamily: preset.template.fontFamily,
+      fontSize: preset.document?.fontSize ?? BASE_FONT_SIZE,
+      margin: preset.document?.margin ?? 40,
+      rowPadding: preset.document?.rowPadding ?? 5,
+      stripes: preset.document?.stripes !== false,
+      stripeColor: preset.document?.stripeColor || '#f3f4f6',
+      headerStyle: preset.template.headerStyle,
+      frameSide: preset.template.frameSide ?? 'left',
+      frameShadow: frameShadowWidth(undefined),
+      frameRadius: 0,
+      logoSize: 100,
+    },
+    data
+  )
+}
+
+/**
+ * What one of the workshop's own saved designs would print. The stored layout
+ * is merged with today's defaults first, so a design saved before a section
+ * existed still previews the way it will print.
+ */
+export function specForDesign(design: SavedDesign, data: DocumentData): DocumentSpec {
+  const layout = mergeWithDefaults(design.layout)
+  return buildDocumentSpec(layout, themeOf(design.template, layout), data)
+}

+ 122 - 1
src/features/invoice-designer/Components/sample.ts

@@ -1,4 +1,6 @@
-import type { DesignerWorkshop } from './types'
+import { toCustomFieldId } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { DocumentData } from '../Spec/buildSpec'
+import type { DesignerWorkshop, DocumentType } from './types'
 
 /**
  * What the canvas prints for each field the layout can show.
@@ -168,3 +170,122 @@ export function fieldValues(
       : '',
   }
 }
+
+/**
+ * The whole sample document: what a workshop's own sheet says, with the
+ * sample standing in for a job. One builder, so the designer's canvas and the
+ * template cards in settings preview exactly the same paper.
+ */
+export function buildSampleData(
+  workshop: DesignerWorkshop,
+  customFields: { id: string; label?: string | null; name: string; isActive: boolean }[],
+  t: SampleT,
+  labels: PrintLabels,
+  docType: DocumentType
+): DocumentData {
+  const L = (key: string, fallback: string) => labels[key] || fallback
+  const sample = sampleTables(t, labels)
+  const values = fieldValues(workshop, t, labels)
+  return {
+    fields: {
+      ...values,
+      // A custom field prints whatever the job carries; here it shows its
+      // own name so the workshop can see where it will sit.
+      ...Object.fromEntries(
+        customFields
+          .filter((f) => f.isActive)
+          .map((f) => [toCustomFieldId(f.id), `${f.label || f.name}: ${t('sample.value')}`])
+      ),
+    },
+    logoUrl: workshop.logoUrl || undefined,
+    labels,
+    meta: {
+      title: sample.title,
+      number: sample.number,
+      customerNumber: sample.customerNumber,
+      date: sample.date,
+      due: sample.due,
+    },
+    items: sample.items.map((item) => ({
+      n: String(item.n),
+      qty: item.qty,
+      unit: item.unit,
+      desc: item.desc,
+      sub: item.sku,
+      price: item.price,
+      total: item.total,
+    })),
+    parts: sample.items
+      .filter((item) => item.sku)
+      .map((item) => ({
+        ref: item.sku as string,
+        desc: item.desc,
+        qty: item.qty,
+        price: item.price,
+        total: item.total,
+      })),
+    labor: sample.items
+      .filter((item) => !item.sku)
+      .map((item) => ({
+        desc: item.desc,
+        qty: `${item.qty} ${item.unit}`,
+        rate: item.price,
+        total: item.total,
+      })),
+    findings: sample.findings,
+    totals: [
+      { label: L('subtotal', 'Subtotal'), value: sample.subtotal, kind: 'line' as const },
+      {
+        // The tax label carries the rate, the way the printed sheet does.
+        label: fillTemplate(L('tax', 'Tax ({rate}%)'), { rate: '25' }),
+        value: sample.tax,
+        kind: 'line' as const,
+      },
+      { label: L('total', 'Total'), value: sample.total, kind: 'total' as const },
+      // A settled invoice, so the payment line and the paid stamp can be
+      // seen and styled. Quotes never carry payments, so theirs ends at the
+      // total.
+      ...(docType === 'invoice'
+        ? [
+            {
+              label: `${sample.date} (Visa)`,
+              value: `-${sample.total}`,
+              kind: 'payment' as const,
+            },
+            { label: L('paidInFull', 'PAID IN FULL'), value: '', kind: 'paid' as const },
+          ]
+        : []),
+    ],
+    notes: { html: sample.notes },
+    // Stand-ins for files a job carries, so the block can be found, placed
+    // and styled. Filenames need no translating.
+    attachedDocuments: [
+      fillTemplate(L('seeAppendedPages', '{name} (see appended pages)'), {
+        name: 'inspection-report.pdf',
+      }),
+      'tire-photos.jpg',
+    ],
+    warranty: { duration: sample.warranty },
+    payment: [
+      { id: 'bank_account', label: L('bankAccount', 'Bank Account'), value: values.bank_account },
+      {
+        id: 'org_number',
+        label: L('orgNumberLabel', 'Org. Number'),
+        value: values.org_number || `${L('org', 'Org: {org}').replace('{org}', '123 456 789')}`,
+      },
+      {
+        label: L('paymentTermsLabel', 'Payment Terms'),
+        value: fillTemplate(L('netDays', '{days} Days'), { days: '10' }),
+      },
+      { label: L('dueDateLabel', 'Due Date'), value: sample.due },
+    ],
+    sectionLabels: {
+      customer: L('billTo', 'Bill To'),
+      vehicle: L('vehicle', 'Vehicle'),
+      service: L('service', 'Service'),
+      bank_account: L('paymentInformation', 'Payment Information'),
+      general: L('customFieldsTitle', 'Additional Information'),
+      findings: L('findings', 'Observations'),
+    },
+  }
+}

+ 2 - 4
src/features/invoice-designer/Pdf/buildInvoicePrint.ts

@@ -413,10 +413,8 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     labor,
     findings,
     totals,
-    notes: {
-      html: data.invoiceNotes ?? undefined,
-      attachedDocuments: attachedDocuments.length ? attachedDocuments : undefined,
-    },
+    notes: { html: data.invoiceNotes ?? undefined },
+    attachedDocuments: attachedDocuments.length ? attachedDocuments : undefined,
     warranty: {
       duration: warrantyParts.length ? warrantyParts.join(' / ') : undefined,
       expires: data.warrantyExpiresAt

+ 2 - 4
src/features/invoice-designer/Pdf/buildQuotePrint.ts

@@ -319,10 +319,8 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
     labor,
     findings: [],
     totals,
-    notes: {
-      html: data.description ?? undefined,
-      attachedDocuments: attachedDocuments.length ? attachedDocuments : undefined,
-    },
+    notes: { html: data.description ?? undefined },
+    attachedDocuments: attachedDocuments.length ? attachedDocuments : undefined,
     warranty: {},
     payment: [],
     branding: input.torqvoiceLogoDataUri ? { logoDataUri: input.torqvoiceLogoDataUri } : undefined,

+ 5 - 1
src/features/invoice-designer/Pdf/renderPdf.tsx

@@ -266,7 +266,11 @@ export function RenderNodePdf({ node, base }: { node: Node; base: TextStyle }):
     }
 
     case 'spacer':
-      return <View style={{ height: node.height }} />
+      return (
+        <View
+          style={{ height: node.height, ...(node.color ? { backgroundColor: node.color } : {}) }}
+        />
+      )
 
     default:
       return null

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

@@ -221,7 +221,7 @@ export function RenderNode({ node }: { node: Node }): ReactNode {
     }
 
     case 'spacer':
-      return <div {...id} style={{ height: node.height }} />
+      return <div {...id} style={{ height: node.height, background: node.color }} />
 
     default:
       return null

+ 173 - 56
src/features/invoice-designer/Spec/buildSpec.ts

@@ -67,7 +67,9 @@ export interface DocumentData {
   labor: { desc: string; qty: string; rate: string; total: string; excluded?: boolean }[]
   findings: { severity: string; color: string; description: string; notes: string }[]
   totals: TotalLine[]
-  notes: { html?: string; attachedDocuments?: string[] }
+  notes: { html?: string }
+  /** Names of the files riding along with the document, already worded. */
+  attachedDocuments?: string[]
   warranty: { duration?: string; expires?: string; terms?: string }
   payment: PaymentPair[]
   telegramQr?: { dataUri: string; label: string }
@@ -144,6 +146,8 @@ function lookOf(section: InvoiceSection, theme: DocumentTheme) {
     ruleWidth: s?.borderWidth,
     /** A border around the whole table, not only rules between rows. */
     outerBorder: s?.outerBorder === true,
+    /** Room inside the section's panel; unset keeps each panel's default. */
+    padding: s?.padding,
     /** Banding behind alternate rows; unset follows the sheet's setting. */
     stripes: s?.stripes,
     fontSize: s?.fontSize,
@@ -229,7 +233,9 @@ function panel(
         look.border || (look.ruleWidth !== undefined ? look.muted : boxed ? '#e3e5e9' : undefined),
       borderWidth: boxed || look.border ? (look.ruleWidth ?? 0.75) : 0,
       radius: boxed ? 3 : 0,
-      padding: boxed || look.fill ? 10 : 0,
+      // The room stays when the box goes: taking the panel off should strip
+      // its chrome, not cram its lines against the neighbours.
+      padding: look.padding ?? 10,
     },
     children: [
       ...(section.heading === false
@@ -554,8 +560,11 @@ function letterhead(section: InvoiceSection, theme: DocumentTheme, data: Documen
   const compact = theme.headerStyle === 'compact'
   const banded = framed || modern
   // Compact reads like a form's letterhead: small mark on the left with a
-  // rule beneath. Standard sets the mark large on the right.
-  const align = modern ? ('center' as const) : compact ? ('left' as const) : ('right' as const)
+  // rule beneath. Standard sets the mark large on the right. A side the
+  // layout chooses itself wins over all of that.
+  const align =
+    section.style?.align ??
+    (modern ? ('center' as const) : compact ? ('left' as const) : ('right' as const))
 
   const showLogo = fields.includes('logo') && !!data.logoUrl
   // The company name follows the section's own ink when the layout sets one,
@@ -632,7 +641,7 @@ function letterhead(section: InvoiceSection, theme: DocumentTheme, data: Documen
       kind: 'row',
       id: 'header.branding',
       gap: 3,
-      justify: modern ? 'center' : 'end',
+      justify: align === 'center' ? 'center' : align === 'left' ? 'start' : 'end',
       align: 'center',
       children: [
         {
@@ -971,28 +980,57 @@ function totals(section: InvoiceSection, theme: DocumentTheme, data: DocumentDat
   if (!data.totals.length) return null
   const look = lookOf(section, theme)
   const size = look.fontSize ?? theme.fontSize
+  // The looks the block can be set to. Unset means the classic bare lines,
+  // which is what every sheet printed before the box existed.
+  const variant = section.variant || 'classic'
+  const bare = variant === 'classic'
+
+  // The settled state as a stamp rather than a line with nothing in its value
+  // column: one centered green bar that reads at arm's length.
+  const paidBadge = (entry: TotalLine): Node => ({
+    kind: 'row',
+    justify: 'center',
+    style: {
+      background: mixColors(theme.background, '#059669', 0.1),
+      radius: variant === 'box' ? 0 : 3,
+      padding: 6,
+    },
+    children: [
+      {
+        node: {
+          kind: 'text',
+          text: entry.label,
+          style: {
+            color: mixColors('#059669', look.text, 0.25),
+            bold: true,
+            uppercase: true,
+            letterSpacing: 0.8,
+            fontSize: scale(size, 0.95),
+          },
+        },
+      },
+    ],
+  })
 
   const line = (entry: TotalLine): Node => {
-    const emphasized = entry.kind === 'total' || entry.kind === 'due' || entry.kind === 'paid'
+    if (entry.kind === 'paid') return paidBadge(entry)
+    const emphasized = entry.kind === 'total' || entry.kind === 'due'
     const valueColor =
       entry.kind === 'discount'
         ? '#dc2626'
         : entry.kind === 'payment'
           ? '#059669'
-          : entry.kind === 'paid'
-            ? '#059669'
-            : emphasized
-              ? theme.accent
-              : look.text
+          : emphasized
+            ? theme.accent
+            : look.text
     return {
       kind: 'row',
       justify: 'between',
+      align: 'center',
       style: {
-        padding: 6,
+        padding: bare ? { top: 4, right: 0, bottom: 4, left: 0 } : 6,
         background:
-          entry.kind === 'due' || entry.kind === 'paid'
-            ? mixColors(theme.background, entry.kind === 'paid' ? '#059669' : theme.accent, 0.08)
-            : undefined,
+          entry.kind === 'due' ? mixColors(theme.background, theme.accent, 0.08) : undefined,
       },
       children: [
         {
@@ -1002,7 +1040,12 @@ function totals(section: InvoiceSection, theme: DocumentTheme, data: DocumentDat
             style: {
               color: emphasized ? look.text : look.muted,
               bold: emphasized,
-              fontSize: entry.kind === 'payment' ? scale(size, 0.85) : size,
+              fontSize:
+                entry.kind === 'payment'
+                  ? scale(size, 0.85)
+                  : bare && emphasized
+                    ? scale(size, 1.15)
+                    : size,
             },
           },
         },
@@ -1012,7 +1055,7 @@ function totals(section: InvoiceSection, theme: DocumentTheme, data: DocumentDat
             text: entry.value,
             style: {
               bold: true,
-              fontSize: emphasized ? scale(size, 1.15) : size,
+              fontSize: emphasized ? scale(size, bare ? 1.25 : 1.15) : size,
               color: valueColor,
             },
           },
@@ -1021,25 +1064,65 @@ function totals(section: InvoiceSection, theme: DocumentTheme, data: DocumentDat
     }
   }
 
+  // The bare look announces the total with a rule above it, the way the old
+  // sheets did, instead of a frame around everything.
+  const rows: Node[] = []
+  for (const entry of data.totals) {
+    if (bare && entry.kind === 'total') {
+      rows.push({
+        kind: 'spacer',
+        height: look.ruleWidth ?? 1.6,
+        color: look.border || theme.accent,
+      })
+    }
+    rows.push(line(entry))
+  }
+
+  const boxStyle =
+    variant === 'accent'
+      ? {
+          background: look.fill || mixColors(theme.background, theme.primary, 0.08),
+          borderColor: look.border || theme.accent,
+          borderWidth: look.ruleWidth ?? 1,
+          radius: 4,
+          padding: look.padding ?? 4,
+        }
+      : variant === 'panel'
+        ? {
+            background: look.fill || mixColors(theme.background, look.text, 0.045),
+            borderColor: look.border || '#e3e5e9',
+            borderWidth: look.ruleWidth ?? 0,
+            radius: 4,
+            padding: look.padding ?? 4,
+          }
+        : variant === 'box'
+          ? {
+              borderColor: look.border || look.text,
+              borderWidth: look.ruleWidth ?? 1,
+              background: look.fill,
+              padding: look.padding,
+            }
+          : // 'classic': nothing but the lines.
+            { background: look.fill, padding: look.padding }
+
   const box: Node = {
     kind: 'stack',
     gap: 0,
-    style: {
-      borderColor: look.border || look.text,
-      borderWidth: look.ruleWidth ?? 1,
-      background: look.fill,
-    },
-    children: data.totals.map(line),
+    style: boxStyle,
+    children: rows,
   }
-  // Full width, the box keeps to the right the way a sum column reads; in a
-  // half-width lane it takes the lane.
+  // Placed Full, the block spans the row the way every other section does; a
+  // set width hangs the box on the right the way a sum column reads. In a
+  // half-width lane it takes the lane. Classic sheets keep the hung box their
+  // organizations have always mailed out.
+  const width = section.style?.width ?? (theme.classic ? 250 : undefined)
   const children: Node[] = [
-    section.column
+    section.column || !width
       ? box
       : {
           kind: 'row',
           justify: 'end',
-          children: [{ width: 250, node: box }],
+          children: [{ width, node: box }],
         },
   ]
 
@@ -1086,37 +1169,65 @@ function notesBlock(
   const look = lookOf(section, theme)
   const size = look.fontSize ?? theme.fontSize
   const hasNotes = !!data.notes.html && data.notes.html.replace(/<[^>]*>/g, '').trim().length > 0
-  const attached = data.notes.attachedDocuments ?? []
-  if (!hasNotes && !attached.length) return null
+  if (!hasNotes) return null
 
   const children: Node[] = []
-  if (hasNotes) {
-    if (section.heading !== false) {
-      children.push({
-        kind: 'text',
-        text: label(data, 'notes', 'Notes'),
-        style: { color: look.label, fontSize: scale(size, 0.72), bold: true, uppercase: true },
-      })
-    }
+  if (section.heading !== false) {
     children.push({
-      kind: 'richtext',
-      html: data.notes.html as string,
-      style: { color: look.muted, fontSize: scale(size, 0.9) },
+      kind: 'text',
+      text: label(data, 'notes', 'Notes'),
+      style: { color: look.label, fontSize: scale(size, 0.72), bold: true, uppercase: true },
     })
   }
-  if (attached.length) {
+  children.push({
+    kind: 'richtext',
+    html: data.notes.html as string,
+    style: { color: look.muted, fontSize: scale(size, 0.9) },
+  })
+
+  const boxed = section.boxed !== false
+  return {
+    kind: 'stack',
+    id: section.id,
+    gap: 3,
+    style: {
+      background: look.fill || (boxed ? '#f3f4f6' : undefined),
+      borderColor: look.border,
+      borderWidth: look.border ? (look.ruleWidth ?? 0.75) : 0,
+      radius: boxed ? 3 : 0,
+      // The room stays when the box goes: taking the panel off should strip
+      // its chrome, not cram its lines against the neighbours.
+      padding: look.padding ?? 10,
+    },
+    children,
+  }
+}
+
+/** The files riding along with the document, in the notes' panel dress. */
+function attachedDocumentsBlock(
+  section: InvoiceSection,
+  theme: DocumentTheme,
+  data: DocumentData
+): Node | null {
+  const attached = data.attachedDocuments ?? []
+  if (!attached.length) return null
+  const look = lookOf(section, theme)
+  const size = look.fontSize ?? theme.fontSize
+
+  const children: Node[] = []
+  if (section.heading !== false) {
     children.push({
       kind: 'text',
       text: label(data, 'attachedDocuments', 'Attached Documents'),
       style: { color: look.label, fontSize: scale(size, 0.72), bold: true, uppercase: true },
     })
-    for (const name of attached) {
-      children.push({
-        kind: 'text',
-        text: name,
-        style: { color: look.muted, fontSize: scale(size, 0.9) },
-      })
-    }
+  }
+  for (const name of attached) {
+    children.push({
+      kind: 'text',
+      text: name,
+      style: { color: look.muted, fontSize: scale(size, 0.9) },
+    })
   }
 
   const boxed = section.boxed !== false
@@ -1129,7 +1240,9 @@ function notesBlock(
       borderColor: look.border,
       borderWidth: look.border ? (look.ruleWidth ?? 0.75) : 0,
       radius: boxed ? 3 : 0,
-      padding: boxed || look.fill ? 10 : 0,
+      // The room stays when the box goes: taking the panel off should strip
+      // its chrome, not cram its lines against the neighbours.
+      padding: look.padding ?? 10,
     },
     children,
   }
@@ -1185,7 +1298,9 @@ function warrantyBlock(
       borderColor: look.border,
       borderWidth: look.border ? (look.ruleWidth ?? 0.75) : 0,
       radius: boxed ? 3 : 0,
-      padding: boxed || look.fill ? 10 : 0,
+      // The room stays when the box goes: taking the panel off should strip
+      // its chrome, not cram its lines against the neighbours.
+      padding: look.padding ?? 10,
     },
     children,
   }
@@ -1270,7 +1385,7 @@ function paymentBlock(
           borderColor: look.border || theme.primary,
           borderWidth: 1,
           radius: 4,
-          padding: 12,
+          padding: look.padding ?? 12,
         }
       : variant === 'panel'
         ? {
@@ -1278,17 +1393,17 @@ function paymentBlock(
             borderColor: look.border || '#e3e5e9',
             borderWidth: 0.75,
             radius: 3,
-            padding: 10,
+            padding: look.padding ?? 10,
           }
         : variant === 'outline'
           ? {
               background: look.fill,
               borderColor: look.border || look.text,
               borderWidth: 0.5,
-              padding: 10,
+              padding: look.padding ?? 10,
             }
           : // 'lines': nothing but the type.
-            { background: look.fill }
+            { background: look.fill, padding: look.padding }
 
   return {
     kind: 'stack',
@@ -1332,8 +1447,8 @@ function telegramBlock(
     gap: 4,
     style:
       section.boxed !== false
-        ? { background: look.fill || '#fafafa', radius: 6, padding: 10 }
-        : { background: look.fill },
+        ? { background: look.fill || '#fafafa', radius: 6, padding: look.padding ?? 10 }
+        : { background: look.fill, padding: look.padding ?? 10 },
     children: [
       { kind: 'image', src: data.telegramQr.dataUri, maxWidth: 56, maxHeight: 56, align: 'center' },
       {
@@ -1485,6 +1600,8 @@ function blockFor(section: InvoiceSection, theme: DocumentTheme, data: DocumentD
       return totals(section, theme, data)
     case 'notes':
       return notesBlock(section, theme, data)
+    case 'attached_documents':
+      return attachedDocumentsBlock(section, theme, data)
     case 'warranty':
       return warrantyBlock(section, theme, data)
     case 'bank_account':

+ 2 - 1
src/features/invoice-designer/Spec/documentSpec.ts

@@ -106,7 +106,8 @@ export type Node =
       stripe?: string
       anchor?: Anchor
     }
-  | { kind: 'spacer'; id?: string; height: number }
+  /** Empty room, or a filled rule when it carries a color. */
+  | { kind: 'spacer'; id?: string; height: number; color?: string }
 
 /** How a block finds its place on the sheet. */
 export type Placement =

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

@@ -37,6 +37,15 @@ export const invoiceSectionStyleSchema = z.object({
   fontSize: z.number().min(5).max(24).optional(),
   /** Typeface for this section, from the families the app embeds. */
   fontFamily: z.string().optional(),
+  /** Preferred width in points for a block that hangs to one side of the
+   *  sheet, like the totals box. Ignored in a column, which sets the width. */
+  width: z.number().min(140).max(515).optional(),
+  /** Where the section sets its mark and lines. Unset follows the header
+   *  style: compact leans left, standard right, modern centers. */
+  align: z.enum(['left', 'center', 'right']).optional(),
+  /** Room inside the section's panel, in points. Unset keeps each panel's
+   *  own default, box or no box. */
+  padding: z.number().min(0).max(40).optional(),
   /** Extra room around the section in the flow, in points per edge. */
   marginTop: z.number().min(0).max(120).optional(),
   marginBottom: z.number().min(0).max(120).optional(),
@@ -184,6 +193,9 @@ export const BUILTIN_SECTIONS = [
   { id: 'findings', name: 'Findings' },
   { id: 'totals', name: 'Totals' },
   { id: 'notes', name: 'Notes' },
+  // Its own section rather than a tail on the notes, so the list of what
+  // rides along with the document can be placed and styled like anything else.
+  { id: 'attached_documents', name: 'Attached Documents' },
   { id: 'warranty', name: 'Warranty' },
   { id: 'bank_account', name: 'Bank Account' },
   { id: 'footer', name: 'Footer' },
@@ -280,6 +292,7 @@ export const BOXED_ELIGIBLE_SECTIONS = new Set<string>([
   'service',
   'general',
   'notes',
+  'attached_documents',
   'warranty',
   'telegram_qr',
 ])
@@ -293,6 +306,7 @@ export const COLUMN_ELIGIBLE_SECTIONS = new Set<string>([
   'service',
   'general',
   'notes',
+  'attached_documents',
   'bank_account',
 ])
 

+ 55 - 3
src/features/settings/Schema/layoutPresets.ts

@@ -70,6 +70,7 @@ export const layoutPresets: LayoutPreset[] = [
       'findings',
       'totals',
       'notes',
+      'attached_documents',
       'warranty',
       'bank_account',
       'footer',
@@ -95,10 +96,12 @@ export const layoutPresets: LayoutPreset[] = [
       'vehicle',
       'service',
       'document_title',
-      'items_table',
+      'parts_table',
+      'labor_table',
       'findings',
       'totals',
       'notes',
+      'attached_documents',
       'warranty',
       'bank_account',
       'footer',
@@ -125,13 +128,60 @@ export const layoutPresets: LayoutPreset[] = [
       'customer',
       'vehicle',
       'service',
-      'items_table',
+      'parts_table',
+      'labor_table',
+      'totals',
+      'notes',
+      'attached_documents',
+      'footer',
+    ],
+    columns: { customer: 'left', vehicle: 'right', service: 'right' },
+    // Every panel prints bare, including the ones switched on later: compact
+    // means no boxes anywhere.
+    plain: [
+      'customer',
+      'vehicle',
+      'service',
+      'general',
+      'notes',
+      'attached_documents',
+      'warranty',
+      'telegram_qr',
+    ],
+    headerFields: ALL_HEADER_FIELDS,
+    footerFields: ['footer_note'],
+  },
+  {
+    // Type, rules and whitespace only: no panels, no bands, no banding. The
+    // stripped sheet, at reading size rather than compact's density.
+    id: 'minimal',
+    template: { primaryColor: '#111827', headerStyle: 'compact', fontFamily: 'Helvetica' },
+    document: { stripes: false, margin: 48 },
+    order: [
+      'header',
+      'slogan',
+      'customer',
+      'vehicle',
+      'service',
+      'parts_table',
+      'labor_table',
       'totals',
       'notes',
+      'attached_documents',
+      'warranty',
       'footer',
     ],
     columns: { customer: 'left', vehicle: 'right', service: 'right' },
-    plain: ['customer', 'vehicle', 'service'],
+    plain: [
+      'customer',
+      'vehicle',
+      'service',
+      'general',
+      'notes',
+      'attached_documents',
+      'warranty',
+      'telegram_qr',
+    ],
     headerFields: ALL_HEADER_FIELDS,
     footerFields: ['footer_note'],
   },
@@ -154,6 +204,7 @@ export const layoutPresets: LayoutPreset[] = [
       'findings',
       'totals',
       'notes',
+      'attached_documents',
       'warranty',
       'bank_account',
       'footer',
@@ -184,6 +235,7 @@ export const layoutPresets: LayoutPreset[] = [
       'findings',
       'totals',
       'notes',
+      'attached_documents',
       'warranty',
       'bank_account',
       'footer',