Pārlūkot izejas kodu

Invoice designs as rows, issued invoices frozen, and a design for parts-only sales (#307)

* Print payment terms only when the workshop has written some

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

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

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

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

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

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

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

* Mark the payment terms switch instead of hiding it

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

* placeholder

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

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

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

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

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

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

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

* Let a workshop write what its documents are called

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

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

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

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

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

A layout saved before the Document Title switch was obeyed is read with the
strip shown under the header. It was put there with order 0.5, and the
schema wants whole numbers, so every Save from the designer on such a
layout failed validation. The title text a workshop typed never reached the
database, and the sheet, the preview and the shared page kept the old name.

* Freeze an invoice when it is issued, and let a workshop keep several designs

An invoice read everything but its own lines live at print time: the
workshop's address, bank account and payment terms, the customer's address,
the vehicle, the technician's name, the open findings, the custom field
definitions, the logo file and the whole design. A customer who moved house,
a finding that was resolved, a new logo or a title changed from "Invoice"
to "Tax Invoice" rewrote every invoice ever sent. Bookkeeping law in the
markets the app sells into says an issued invoice is not to change.

An invoice is issued now the moment it becomes the customer's document:
sent by email or link, paid, marked paid, or on the first reprint of one
that went out before this existed. Issuing captures the design and the logo
into content-hashed snapshot rows, shared by every invoice issued with the
same look, and everything else the record does not own into a JSON block on
the record. From then on the sheet prints from those, through one assembler
that replaces the four hand-rolled copies in the PDF routes, the share page
and the email. Sending again while the invoice is not locked re-issues it,
so the snapshot is always the last copy that went out; an unlock reopens it
until it is sent again.

The designs a workshop saved move from a settings blob into rows, so an
invoice or a customer can point at one and the database keeps the reference
honest. The invoice details box gets a design picker, the customer form gets
one, and the templates page can make a design the default without opening
the designer. Deleting a design says how many drafts and customers fall back
to the default; issued invoices are not affected and are not mentioned.

The migration moves the blob row by row in PL/pgSQL, skipping anything that
is not a list of designs, so an organization that never opened the designer
or holds a corrupt blob contributes nothing rather than failing it. It was
run against a copy of the local database seeded with every bad shape.
scripts/backfill-issued-invoices.ts issues the invoices that went out before
this existed, so a change made after the deploy cannot reach them.

* Leave older invoices alone, and let the workshop freeze them from settings

Only invoices issued after this version is deployed freeze on their own.
The invoices that reached a customer before that keep printing from live
rows, as they always have, until they are sent again or the workshop asks.
Nothing is captured behind anyone's back on a read any more, and the
one-off backfill script is gone with it.

Invoice settings counts those older invoices, says they went out before
freezing existed and still follow today's details, logo and design, and
offers to freeze them with the design in use right now. The freeze runs in
batches of a hundred, one request each, with progress on the button, so a
workshop with thousands of them never hits a request limit.

* Never re-issue an invoice on send; only an unlock reopens it

An issued invoice was captured again every time it was sent while the lock
setting was off, on the reasoning that an unlocked document might have been
edited. With the lock off, which is the default, that meant changing the
default design and emailing an already sent invoice quietly froze the new
look over the old one: the preview showed the copy the customer held, the
email carried a different sheet.

What was issued now stays issued. Sending an issued invoice again renders
and sends the same snapshot the preview shows, whatever the workshop has
changed since. The one route to a fresh capture is an owner's unlock
followed by a send, which is the corrected copy going out. The lock setting
no longer plays any part in the decision, so a workshop that never turned
locking on gets the same guarantee.

* Call it locking, the word the manual and the workshop use

* parts only invoice

* parts only
Bernt Christian Egeland 3 nedēļas atpakaļ
vecāks
revīzija
723efb6212
97 mainītis faili ar 3582 papildinājumiem un 943 dzēšanām
  1. 12 0
      messages/de/audit.json
  2. 5 0
      messages/de/customers.json
  3. 11 1
      messages/de/service.json
  4. 24 2
      messages/de/settings.json
  5. 12 0
      messages/en/audit.json
  6. 5 0
      messages/en/customers.json
  7. 11 1
      messages/en/service.json
  8. 24 2
      messages/en/settings.json
  9. 12 0
      messages/es/audit.json
  10. 5 0
      messages/es/customers.json
  11. 11 1
      messages/es/service.json
  12. 24 2
      messages/es/settings.json
  13. 12 0
      messages/fr/audit.json
  14. 5 0
      messages/fr/customers.json
  15. 11 1
      messages/fr/service.json
  16. 24 2
      messages/fr/settings.json
  17. 12 0
      messages/it/audit.json
  18. 5 0
      messages/it/customers.json
  19. 11 1
      messages/it/service.json
  20. 24 2
      messages/it/settings.json
  21. 12 0
      messages/lt/audit.json
  22. 5 0
      messages/lt/customers.json
  23. 11 1
      messages/lt/service.json
  24. 24 2
      messages/lt/settings.json
  25. 12 0
      messages/nb/audit.json
  26. 5 0
      messages/nb/customers.json
  27. 11 1
      messages/nb/service.json
  28. 24 2
      messages/nb/settings.json
  29. 12 0
      messages/nl/audit.json
  30. 5 0
      messages/nl/customers.json
  31. 11 1
      messages/nl/service.json
  32. 24 2
      messages/nl/settings.json
  33. 12 0
      messages/pl/audit.json
  34. 5 0
      messages/pl/customers.json
  35. 11 1
      messages/pl/service.json
  36. 24 2
      messages/pl/settings.json
  37. 12 0
      messages/pt-BR/audit.json
  38. 5 0
      messages/pt-BR/customers.json
  39. 11 1
      messages/pt-BR/service.json
  40. 24 2
      messages/pt-BR/settings.json
  41. 12 0
      messages/ru/audit.json
  42. 5 0
      messages/ru/customers.json
  43. 11 1
      messages/ru/service.json
  44. 24 2
      messages/ru/settings.json
  45. 12 0
      messages/tr/audit.json
  46. 5 0
      messages/tr/customers.json
  47. 11 1
      messages/tr/service.json
  48. 24 2
      messages/tr/settings.json
  49. 169 0
      prisma/migrations/20260903120000_document_designs_and_issued_invoices/migration.sql
  50. 10 0
      prisma/migrations/20260903150000_document_design_auto_rules/migration.sql
  51. 8 0
      prisma/schema/customers.prisma
  52. 76 0
      prisma/schema/document-designs.prisma
  53. 3 0
      prisma/schema/organization.prisma
  54. 29 0
      prisma/schema/service-records.prisma
  55. 43 0
      src/__tests__/features/invoice-designer/design-rules.test.ts
  56. 121 0
      src/__tests__/features/invoices/issued-invoice-rules.test.ts
  57. 107 0
      src/__tests__/features/settings/design-source.test.ts
  58. 5 0
      src/__tests__/features/workorders/document-lock-enforcement.test.ts
  59. 20 1
      src/app/(authenticated)/customers/[id]/customer-detail-client.tsx
  60. 70 0
      src/app/(authenticated)/settings/invoice/invoice-settings.tsx
  61. 4 0
      src/app/(authenticated)/settings/invoice/page.tsx
  62. 64 58
      src/app/(authenticated)/settings/templates/page.tsx
  63. 104 16
      src/app/(authenticated)/settings/templates/template-settings.tsx
  64. 48 39
      src/app/(designer)/invoice-designer/page.tsx
  65. 61 211
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  66. 30 0
      src/app/api/protected/backup/export/route.ts
  67. 93 0
      src/app/api/protected/backup/import/route.ts
  68. 25 223
      src/app/api/protected/services/[id]/pdf/route.ts
  69. 18 0
      src/features/customers/Actions/customerActions.ts
  70. 50 1
      src/features/customers/Components/CustomerForm.tsx
  71. 2 0
      src/features/customers/Schema/customerSchema.ts
  72. 27 76
      src/features/email/Actions/emailActions.ts
  73. 304 0
      src/features/invoice-designer/Actions/documentDesignActions.ts
  74. 42 40
      src/features/invoice-designer/Components/InvoiceDesigner.tsx
  75. 3 0
      src/features/invoice-designer/Components/types.ts
  76. 33 0
      src/features/invoice-designer/Lib/designHash.ts
  77. 21 0
      src/features/invoice-designer/Lib/designRules.server.ts
  78. 54 0
      src/features/invoice-designer/Lib/designRules.ts
  79. 93 0
      src/features/invoice-designer/Lib/designSnapshots.ts
  80. 163 0
      src/features/invoice-designer/Lib/designSource.ts
  81. 57 0
      src/features/invoices/Actions/invoiceDesignActions.ts
  82. 72 0
      src/features/invoices/Actions/legacyInvoiceActions.ts
  83. 394 0
      src/features/invoices/Lib/assembleInvoicePrint.ts
  84. 105 0
      src/features/invoices/Lib/issueInvoice.ts
  85. 192 0
      src/features/invoices/Lib/issuedInvoice.ts
  86. 21 237
      src/features/invoices/Pdf/buildInvoicePdfBuffer.ts
  87. 4 0
      src/features/payments/Actions/paymentActions.ts
  88. 0 3
      src/features/settings/Schema/settingsSchema.ts
  89. 10 0
      src/features/vehicles/Actions/serviceActions.ts
  90. 5 0
      src/features/vehicles/Components/service-detail/types.ts
  91. 84 0
      src/features/vehicles/Components/service-edit/InvoiceDetailsSection.tsx
  92. 15 1
      src/features/vehicles/Components/service-page/DetailsRightColumn.tsx
  93. 8 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  94. 39 0
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  95. 9 0
      src/features/vehicles/Components/service-page/service-page-types.ts
  96. 25 0
      src/lib/backup/manifest.ts
  97. 18 1
      src/lib/document-lock.server.ts

+ 12 - 0
messages/de/audit.json

@@ -156,6 +156,12 @@
     "import_undo": "Import rückgängig gemacht",
     "integration_connect": "Integration verbunden",
     "integration_disconnect": "Integration getrennt",
+    "service_setInvoiceDesign": "Rechnungsdesign geändert",
+    "settings_saveDocumentDesign": "Dokumentdesign gespeichert",
+    "settings_deleteDocumentDesign": "Dokumentdesign gelöscht",
+    "settings_applyDocumentDesign": "Standard-Dokumentdesign festgelegt",
+    "settings_setDocumentDesignRule": "Verwendung eines Designs geändert",
+    "settings_freezeInvoices": "Ältere Rechnungen gesperrt",
     "inspection_reminders_sent": "HU-Erinnerungen gesendet",
     "inspection_reminders_send": "HU-Erinnerungen gesendet"
   },
@@ -286,6 +292,12 @@
     "import_undo": "Tabellenimport rückgängig gemacht: {records} Datensätze entfernt",
     "integration_connect": "{name} verbunden",
     "integration_disconnect": "{name} getrennt",
+    "service_setInvoiceDesign": "Hat geändert, mit welchem Design eine Rechnung gedruckt wird",
+    "settings_saveDocumentDesign": "Hat das Dokumentdesign {name} gespeichert",
+    "settings_deleteDocumentDesign": "Hat das Dokumentdesign {name} gelöscht",
+    "settings_applyDocumentDesign": "Hat {name} zum Standard-Dokumentdesign gemacht",
+    "settings_setDocumentDesignRule": "Verwendung des Dokumentdesigns {name} geändert",
+    "settings_freezeInvoices": "Hat {count, plural, one {# Rechnung} other {# Rechnungen}} von vor der Sperre mit dem aktuellen Design gesperrt",
     "inspection_reminders_sent": "{count} HU-Erinnerungen gesendet"
   }
 }

+ 5 - 0
messages/de/customers.json

@@ -33,6 +33,8 @@
     "phoneCopied": "Telefonnummer in die Zwischenablage kopiert",
     "taxIdLabel": "USt-IdNr.",
     "taxExemptBadge": "Steuerbefreit",
+    "invoiceDesignBadge": "Rechnungsdesign: {name}",
+    "invoiceDesignBadgeHint": "Rechnungen für diesen Kunden werden mit diesem Design statt dem Werkstatt-Standard gedruckt. Im Designer öffnen.",
     "tabs": {
       "vehicles": "Fahrzeuge ({count})",
       "invoices": "Rechnungen ({count})",
@@ -93,6 +95,9 @@
     "sectionContact": "Kontakt",
     "sectionBilling": "Rechnung",
     "addScannedVehicle": "Fahrzeug {vehicle} anlegen",
+    "invoiceDesign": "Rechnungsdesign",
+    "invoiceDesignDefault": "Werkstatt-Standard",
+    "invoiceDesignHint": "Rechnungen für diesen Kunden werden mit diesem Design statt dem Werkstatt-Standard gedruckt. Eine Rechnung kann trotzdem ein eigenes wählen.",
     "reminderOptOut": "Keine Erinnerungen",
     "reminderOptOutHint": "Diesen Kunden nicht in HU-Erinnerungen oder andere Kampagnen aufnehmen."
   },

+ 11 - 1
messages/de/service.json

@@ -79,7 +79,17 @@
       "typePrompt": "Geben Sie einen Namen ein...",
       "boardTechnicians": "Board-Techniker",
       "teamMembers": "Teamitglieder"
-    }
+    },
+    "design": "Rechnungsdesign",
+    "designDefault": "Werkstatt-Standard",
+    "designFollowing": "Standard ({name})",
+    "designPinned": "Das Blatt wurde beim Ausstellen der Rechnung am {date} eingefroren. Eine neue Wahl gilt erst, wenn ein Inhaber die Rechnung entsperrt und sie erneut gesendet wird.",
+    "designSaved": "Rechnungsdesign aktualisiert",
+    "designFollowingRule": "Standard ({name}, {rule})",
+    "designRule": {
+      "parts_sale": "Reiner Teileverkauf"
+    },
+    "designSaveFailed": "Rechnungsdesign konnte nicht geändert werden"
   },
   "parts": {
     "title": "Teile",

+ 24 - 2
messages/de/settings.json

@@ -580,7 +580,16 @@
     "quoteLockTriggerSent": "Sobald das Angebot versendet ist",
     "quoteLockTriggerAccepted": "Sobald das Angebot angenommen ist",
     "quoteLockTriggerSentHint": "Sperrt, sobald das Angebot zum Kunden geht. Nur wählen, wenn Sie Angebote nie nachverhandeln.",
-    "quoteLockTriggerAcceptedHint": "Sperrt, sobald der Kunde das Angebot angenommen hat oder es in einen Auftrag umgewandelt wurde. Versendete Angebote bleiben bearbeitbar."
+    "quoteLockTriggerAcceptedHint": "Sperrt, sobald der Kunde das Angebot angenommen hat oder es in einen Auftrag umgewandelt wurde. Versendete Angebote bleiben bearbeitbar.",
+    "freezeTitle": "Rechnungen von vor der Sperre",
+    "freezeBody": "{count, plural, one {# Rechnung} other {# Rechnungen}} erreichte einen Kunden, bevor diese Version sperren konnte, was eine Rechnung druckt. Sie werden weiterhin mit Ihren aktuellen Angaben, Logo und Design gedruckt, sodass eine Änderung von heute auch sie erreicht. Sperren bewahrt jede so, wie sie jetzt gedruckt wird.",
+    "freezeNone": "Jede Rechnung, die einen Kunden erreicht hat, ist so gesperrt, wie sie versandt wurde.",
+    "freezeButton": "Mit dem aktuellen Design sperren",
+    "freezeConfirmTitle": "{count, plural, one {# Rechnung} other {# Rechnungen}} sperren?",
+    "freezeConfirmBody": "Jede behält das Design, das Logo, die Werkstattangaben, den Kunden, das Fahrzeug und die Befunde, mit denen sie jetzt gedruckt wird. Das kann nicht rückgängig gemacht werden; die Rechnungen bleiben genau so bearbeitbar oder für die Bearbeitung gesperrt wie heute.",
+    "freezeProgress": "{done} von {total} gesperrt",
+    "freezeDone": "{count, plural, one {# Rechnung} other {# Rechnungen}} gesperrt",
+    "freezeFailed": "Rechnungen konnten nicht gesperrt werden"
   },
   "layoutEditor": {
     "helpText": "Ziehen zum Neuordnen. Verwenden Sie den Breiten-Umschalter, um Abschnitte nebeneinander zu platzieren. Aufeinanderfolgende Halbbreite-Abschnitte (L/R) werden in zwei Spalten dargestellt.",
@@ -1783,6 +1792,14 @@
     "galleryTitle": "Mit einer Vorlage beginnen",
     "galleryHint": "Wählen Sie einen Ausgangspunkt. Im Designer lässt sich alles ändern.",
     "yourDesigns": "Ihre Designs",
+    "autoRuleHint": "Verkäufe ohne Fahrzeug werden mit dem dafür markierten Design gedruckt, sofern der Verkauf oder der Kunde kein anderes gewählt hat.",
+    "useForPartsSale": "Für reine Teileverkäufe verwenden",
+    "usedForPartsSale": "Wird für reine Teileverkäufe verwendet",
+    "autoRule": {
+      "parts_sale": "Reiner Teileverkauf"
+    },
+    "autoRuleSaved": "Verwendung von „{name}“ aktualisiert",
+    "couldNotSaveRule": "Regel konnte nicht aktualisiert werden",
     "templates": "Vorlagen",
     "inUse": "In Verwendung",
     "savedOn": "Gespeichert am {date}",
@@ -1968,7 +1985,12 @@
     "leaveTitle": "Ohne Speichern verlassen?",
     "leaveBody": "Ihre Änderungen an diesem Design wurden nicht gespeichert und gehen verloren.",
     "leaveConfirm": "Verlassen",
-    "boldField": "Fett"
+    "boldField": "Fett",
+    "deleteDesignDrafts": "{count, plural, one {# Rechnungsentwurf} other {# Rechnungsentwürfe}} mit diesem Design fallen auf das Standarddesign zurück.",
+    "deleteDesignCustomers": "{count, plural, one {# Kunde} other {# Kunden}} mit diesem Design fallen auf das Standarddesign zurück.",
+    "setDefault": "Als Standard festlegen",
+    "defaultSet": "„{name}“ wird jetzt verwendet",
+    "couldNotSetDefault": "Standarddesign konnte nicht festgelegt werden"
   },
   "preview": {
     "title": "Bremsenservice und Ölwechsel",

+ 12 - 0
messages/en/audit.json

@@ -156,6 +156,12 @@
     "import_undo": "Undid Import",
     "integration_connect": "Connected Integration",
     "integration_disconnect": "Disconnected Integration",
+    "service_setInvoiceDesign": "Changed Invoice Design",
+    "settings_saveDocumentDesign": "Saved Document Design",
+    "settings_deleteDocumentDesign": "Deleted Document Design",
+    "settings_applyDocumentDesign": "Set Default Document Design",
+    "settings_setDocumentDesignRule": "Changed When a Design Is Used",
+    "settings_freezeInvoices": "Locked Older Invoices",
     "inspection_reminders_sent": "Sent inspection reminders",
     "inspection_reminders_send": "Sent inspection reminders"
   },
@@ -286,6 +292,12 @@
     "import_undo": "Undid a spreadsheet import: {records} records removed",
     "integration_connect": "Connected {name}",
     "integration_disconnect": "Disconnected {name}",
+    "service_setInvoiceDesign": "Changed which design an invoice prints with",
+    "settings_saveDocumentDesign": "Saved the document design {name}",
+    "settings_deleteDocumentDesign": "Deleted the document design {name}",
+    "settings_applyDocumentDesign": "Made {name} the default document design",
+    "settings_setDocumentDesignRule": "Changed when the document design {name} is used",
+    "settings_freezeInvoices": "Locked {count, plural, one {# invoice} other {# invoices}} from before locking with the current design",
     "inspection_reminders_sent": "Sent {count} inspection reminders"
   }
 }

+ 5 - 0
messages/en/customers.json

@@ -33,6 +33,8 @@
     "phoneCopied": "Phone number copied to clipboard",
     "taxIdLabel": "Tax ID",
     "taxExemptBadge": "Tax exempt",
+    "invoiceDesignBadge": "Invoice design: {name}",
+    "invoiceDesignBadgeHint": "Invoices for this customer print with this design instead of the workshop default. Open it in the designer.",
     "tabs": {
       "vehicles": "Vehicles ({count})",
       "invoices": "Invoices ({count})",
@@ -93,6 +95,9 @@
     "sectionContact": "Contact",
     "sectionBilling": "Billing",
     "addScannedVehicle": "Add vehicle {vehicle}",
+    "invoiceDesign": "Invoice design",
+    "invoiceDesignDefault": "Workshop default",
+    "invoiceDesignHint": "Invoices for this customer print with this design instead of the workshop default. An invoice can still choose its own.",
     "reminderOptOut": "No reminders",
     "reminderOptOutHint": "Do not include this customer in inspection reminders or other campaigns."
   },

+ 11 - 1
messages/en/service.json

@@ -79,7 +79,17 @@
       "typePrompt": "Type a name...",
       "boardTechnicians": "Board Technicians",
       "teamMembers": "Team Members"
-    }
+    },
+    "design": "Invoice design",
+    "designDefault": "Workshop default",
+    "designFollowing": "Default ({name})",
+    "designPinned": "The sheet was frozen when the invoice was issued on {date}. A new choice applies only after an owner unlocks the invoice and it is sent again.",
+    "designSaved": "Invoice design updated",
+    "designFollowingRule": "Default ({name}, {rule})",
+    "designRule": {
+      "parts_sale": "Parts-Only Sale"
+    },
+    "designSaveFailed": "Could not change the invoice design"
   },
   "parts": {
     "title": "Parts",

+ 24 - 2
messages/en/settings.json

@@ -580,7 +580,16 @@
     "quoteLockTriggerSent": "Once the quote is sent",
     "quoteLockTriggerAccepted": "Once the quote is accepted",
     "quoteLockTriggerSentHint": "Locks as soon as the quote goes to the customer. Choose this only if you never revise a quote during negotiation.",
-    "quoteLockTriggerAcceptedHint": "Locks once the customer has accepted the quote, or it has been converted to a job. Sent quotes stay editable."
+    "quoteLockTriggerAcceptedHint": "Locks once the customer has accepted the quote, or it has been converted to a job. Sent quotes stay editable.",
+    "freezeTitle": "Invoices from before locking",
+    "freezeBody": "{count, plural, one {# invoice} other {# invoices}} reached a customer before this version could lock what an invoice prints. They still print from your current details, logo and design, so a change you make today reaches them too. Locking them keeps each one as it prints right now.",
+    "freezeNone": "Every invoice that reached a customer is locked as it was sent.",
+    "freezeButton": "Lock them with the current design",
+    "freezeConfirmTitle": "Lock {count, plural, one {# invoice} other {# invoices}}?",
+    "freezeConfirmBody": "Each one keeps the design, logo, workshop details, customer, vehicle and findings it prints with right now. This cannot be undone, and the invoices stay editable or locked for editing exactly as they are today.",
+    "freezeProgress": "Locked {done} of {total}",
+    "freezeDone": "{count, plural, one {# invoice} other {# invoices}} locked",
+    "freezeFailed": "Could not lock the invoices"
   },
   "layoutEditor": {
     "helpText": "Drag to reorder. Use the width toggle to place sections side-by-side. Consecutive half-width sections (L/R) will render in two columns.",
@@ -1783,6 +1792,14 @@
     "galleryTitle": "Start from a template",
     "galleryHint": "Pick a starting point. You can change everything in the designer.",
     "yourDesigns": "Your designs",
+    "autoRuleHint": "Sales made without a vehicle print with the design marked for them, unless the sale or its customer chose another.",
+    "useForPartsSale": "Use for parts-only sales",
+    "usedForPartsSale": "Used for parts-only sales",
+    "autoRule": {
+      "parts_sale": "Parts-Only Sale"
+    },
+    "autoRuleSaved": "Updated when \"{name}\" is used",
+    "couldNotSaveRule": "Could not update the rule",
     "templates": "Templates",
     "inUse": "In use",
     "savedOn": "Saved {date}",
@@ -1968,7 +1985,12 @@
     "leaveTitle": "Leave without saving?",
     "leaveBody": "Your changes to this design have not been saved. They will be lost.",
     "leaveConfirm": "Leave",
-    "boldField": "Bold"
+    "boldField": "Bold",
+    "deleteDesignDrafts": "{count, plural, one {# draft invoice} other {# draft invoices}} using it will go back to the default design.",
+    "deleteDesignCustomers": "{count, plural, one {# customer} other {# customers}} set to it will go back to the default design.",
+    "setDefault": "Set as default",
+    "defaultSet": "\"{name}\" is now in use",
+    "couldNotSetDefault": "Could not set the default design"
   },
   "preview": {
     "title": "Brake Service & Oil Change",

+ 12 - 0
messages/es/audit.json

@@ -156,6 +156,12 @@
     "import_undo": "Importación deshecha",
     "integration_connect": "Integración conectada",
     "integration_disconnect": "Integración desconectada",
+    "service_setInvoiceDesign": "Diseño de factura cambiado",
+    "settings_saveDocumentDesign": "Diseño de documento guardado",
+    "settings_deleteDocumentDesign": "Diseño de documento eliminado",
+    "settings_applyDocumentDesign": "Diseño de documento predeterminado establecido",
+    "settings_setDocumentDesignRule": "Cambió cuándo se usa un diseño",
+    "settings_freezeInvoices": "Facturas anteriores bloqueadas",
     "inspection_reminders_sent": "Envió recordatorios de inspección",
     "inspection_reminders_send": "Envió recordatorios de inspección"
   },
@@ -286,6 +292,12 @@
     "import_undo": "Deshizo una importación de hoja de cálculo: {records} registros eliminados",
     "integration_connect": "{name} conectado",
     "integration_disconnect": "{name} desconectado",
+    "service_setInvoiceDesign": "Cambió con qué diseño se imprime una factura",
+    "settings_saveDocumentDesign": "Guardó el diseño de documento {name}",
+    "settings_deleteDocumentDesign": "Eliminó el diseño de documento {name}",
+    "settings_applyDocumentDesign": "Estableció {name} como diseño de documento predeterminado",
+    "settings_setDocumentDesignRule": "Cambió cuándo se usa el diseño de documento {name}",
+    "settings_freezeInvoices": "Bloqueó {count, plural, one {# factura} other {# facturas}} anteriores al bloqueo con el diseño actual",
     "inspection_reminders_sent": "Envió {count} recordatorios de inspección"
   }
 }

+ 5 - 0
messages/es/customers.json

@@ -33,6 +33,8 @@
     "phoneCopied": "Número de teléfono copiado al portapapeles",
     "taxIdLabel": "NIF",
     "taxExemptBadge": "Exento de impuestos",
+    "invoiceDesignBadge": "Diseño de factura: {name}",
+    "invoiceDesignBadgeHint": "Las facturas de este cliente se imprimen con este diseño en lugar del predeterminado del taller. Ábrelo en el diseñador.",
     "tabs": {
       "vehicles": "Vehículos ({count})",
       "invoices": "Facturas ({count})",
@@ -93,6 +95,9 @@
     "sectionContact": "Contacto",
     "sectionBilling": "Facturación",
     "addScannedVehicle": "Añadir el vehículo {vehicle}",
+    "invoiceDesign": "Diseño de factura",
+    "invoiceDesignDefault": "Predeterminado del taller",
+    "invoiceDesignHint": "Las facturas de este cliente se imprimen con este diseño en lugar del predeterminado del taller. Una factura puede elegir el suyo.",
     "reminderOptOut": "Sin recordatorios",
     "reminderOptOutHint": "No incluir a este cliente en recordatorios de inspección ni otras campañas."
   },

+ 11 - 1
messages/es/service.json

@@ -79,7 +79,17 @@
       "typePrompt": "Escriba un nombre...",
       "boardTechnicians": "Técnicos del Tablero",
       "teamMembers": "Miembros del Equipo"
-    }
+    },
+    "design": "Diseño de factura",
+    "designDefault": "Predeterminado del taller",
+    "designFollowing": "Predeterminado ({name})",
+    "designPinned": "La hoja se congeló al emitir la factura el {date}. Una nueva elección se aplica solo después de que un propietario desbloquee la factura y se envíe de nuevo.",
+    "designSaved": "Diseño de factura actualizado",
+    "designFollowingRule": "Predeterminado ({name}, {rule})",
+    "designRule": {
+      "parts_sale": "Venta solo de piezas"
+    },
+    "designSaveFailed": "No se pudo cambiar el diseño de la factura"
   },
   "parts": {
     "title": "Piezas",

+ 24 - 2
messages/es/settings.json

@@ -580,7 +580,16 @@
     "quoteLockTriggerSent": "Cuando se envía el presupuesto",
     "quoteLockTriggerAccepted": "Cuando se acepta el presupuesto",
     "quoteLockTriggerSentHint": "Se bloquea en cuanto el presupuesto llega al cliente. Elija esta opción solo si nunca revisa un presupuesto durante la negociación.",
-    "quoteLockTriggerAcceptedHint": "Se bloquea cuando el cliente acepta el presupuesto o este se convierte en un trabajo. Los presupuestos enviados siguen siendo editables."
+    "quoteLockTriggerAcceptedHint": "Se bloquea cuando el cliente acepta el presupuesto o este se convierte en un trabajo. Los presupuestos enviados siguen siendo editables.",
+    "freezeTitle": "Facturas anteriores al bloqueo",
+    "freezeBody": "{count, plural, one {# factura} other {# facturas}} llegaron a un cliente antes de que esta versión pudiera bloquear lo que imprime una factura. Siguen imprimiéndose con sus datos, logotipo y diseño actuales, así que un cambio de hoy también les afecta. Bloquearlas conserva cada una tal como se imprime ahora.",
+    "freezeNone": "Todas las facturas que llegaron a un cliente están bloqueadas tal como se enviaron.",
+    "freezeButton": "Bloquearlas con el diseño actual",
+    "freezeConfirmTitle": "¿Bloquear {count, plural, one {# factura} other {# facturas}}?",
+    "freezeConfirmBody": "Cada una conserva el diseño, el logotipo, los datos del taller, el cliente, el vehículo y los hallazgos con los que se imprime ahora. No se puede deshacer, y las facturas siguen editables o bloqueadas para edición exactamente como hoy.",
+    "freezeProgress": "Bloqueadas {done} de {total}",
+    "freezeDone": "{count, plural, one {# factura bloqueada} other {# facturas bloqueadas}}",
+    "freezeFailed": "No se pudieron bloquear las facturas"
   },
   "layoutEditor": {
     "helpText": "Arrastra para reordenar. Usa el selector de ancho para colocar secciones lado a lado. Las secciones consecutivas de medio ancho (I/D) se mostrarán en dos columnas.",
@@ -1783,6 +1792,14 @@
     "galleryTitle": "Empieza con una plantilla",
     "galleryHint": "Elige un punto de partida. Puedes cambiarlo todo en el diseñador.",
     "yourDesigns": "Tus diseños",
+    "autoRuleHint": "Las ventas sin vehículo se imprimen con el diseño marcado para ellas, salvo que la venta o su cliente hayan elegido otro.",
+    "useForPartsSale": "Usar para ventas solo de piezas",
+    "usedForPartsSale": "Se usa para ventas solo de piezas",
+    "autoRule": {
+      "parts_sale": "Venta solo de piezas"
+    },
+    "autoRuleSaved": "Uso de «{name}» actualizado",
+    "couldNotSaveRule": "No se pudo actualizar la regla",
     "templates": "Plantillas",
     "inUse": "En uso",
     "savedOn": "Guardado el {date}",
@@ -1968,7 +1985,12 @@
     "leaveTitle": "¿Salir sin guardar?",
     "leaveBody": "Los cambios de este diseño no se han guardado y se perderán.",
     "leaveConfirm": "Salir",
-    "boldField": "Negrita"
+    "boldField": "Negrita",
+    "deleteDesignDrafts": "{count, plural, one {# borrador de factura} other {# borradores de factura}} que lo usan volverán al diseño predeterminado.",
+    "deleteDesignCustomers": "{count, plural, one {# cliente} other {# clientes}} asignados a él volverán al diseño predeterminado.",
+    "setDefault": "Establecer como predeterminado",
+    "defaultSet": "«{name}» está ahora en uso",
+    "couldNotSetDefault": "No se pudo establecer el diseño predeterminado"
   },
   "preview": {
     "title": "Servicio de frenos y cambio de aceite",

+ 12 - 0
messages/fr/audit.json

@@ -156,6 +156,12 @@
     "import_undo": "Import annulé",
     "integration_connect": "Intégration connectée",
     "integration_disconnect": "Intégration déconnectée",
+    "service_setInvoiceDesign": "Design de facture modifié",
+    "settings_saveDocumentDesign": "Design de document enregistré",
+    "settings_deleteDocumentDesign": "Design de document supprimé",
+    "settings_applyDocumentDesign": "Design de document par défaut défini",
+    "settings_setDocumentDesignRule": "A modifié quand un design est utilisé",
+    "settings_freezeInvoices": "Anciennes factures verrouillées",
     "inspection_reminders_sent": "Rappels de contrôle envoyés",
     "inspection_reminders_send": "Rappels de contrôle envoyés"
   },
@@ -286,6 +292,12 @@
     "import_undo": "Import de feuille de calcul annulé : {records} enregistrements supprimés",
     "integration_connect": "{name} connecté",
     "integration_disconnect": "{name} déconnecté",
+    "service_setInvoiceDesign": "A changé le design avec lequel une facture est imprimée",
+    "settings_saveDocumentDesign": "A enregistré le design de document {name}",
+    "settings_deleteDocumentDesign": "A supprimé le design de document {name}",
+    "settings_applyDocumentDesign": "A défini {name} comme design de document par défaut",
+    "settings_setDocumentDesignRule": "A modifié quand le design de document {name} est utilisé",
+    "settings_freezeInvoices": "A verrouillé {count, plural, one {# facture} other {# factures}} antérieures au verrouillage avec le design actuel",
     "inspection_reminders_sent": "{count} rappels de contrôle envoyés"
   }
 }

+ 5 - 0
messages/fr/customers.json

@@ -33,6 +33,8 @@
     "phoneCopied": "Numéro de téléphone copié dans le presse-papiers",
     "taxIdLabel": "N° TVA",
     "taxExemptBadge": "Exonéré de taxe",
+    "invoiceDesignBadge": "Design de facture : {name}",
+    "invoiceDesignBadgeHint": "Les factures de ce client s'impriment avec ce design au lieu de celui par défaut de l'atelier. Ouvrir dans le designer.",
     "tabs": {
       "vehicles": "Véhicules ({count})",
       "invoices": "Factures ({count})",
@@ -93,6 +95,9 @@
     "sectionContact": "Contact",
     "sectionBilling": "Facturation",
     "addScannedVehicle": "Ajouter le véhicule {vehicle}",
+    "invoiceDesign": "Design de facture",
+    "invoiceDesignDefault": "Défaut de l'atelier",
+    "invoiceDesignHint": "Les factures de ce client sont imprimées avec ce design au lieu du défaut de l'atelier. Une facture peut toujours choisir le sien.",
     "reminderOptOut": "Pas de rappels",
     "reminderOptOutHint": "Ne pas inclure ce client dans les rappels de contrôle ni les autres campagnes."
   },

+ 11 - 1
messages/fr/service.json

@@ -79,7 +79,17 @@
       "typePrompt": "Tapez un nom...",
       "boardTechnicians": "Techniciens du Tableau",
       "teamMembers": "Membres de l'Équipe"
-    }
+    },
+    "design": "Design de facture",
+    "designDefault": "Défaut de l'atelier",
+    "designFollowing": "Défaut ({name})",
+    "designPinned": "La feuille a été figée à l'émission de la facture le {date}. Un nouveau choix ne s'applique qu'après qu'un propriétaire déverrouille la facture et qu'elle soit renvoyée.",
+    "designSaved": "Design de facture mis à jour",
+    "designFollowingRule": "Par défaut ({name}, {rule})",
+    "designRule": {
+      "parts_sale": "Vente de pièces seule"
+    },
+    "designSaveFailed": "Impossible de changer le design de la facture"
   },
   "parts": {
     "title": "Pièces",

+ 24 - 2
messages/fr/settings.json

@@ -580,7 +580,16 @@
     "quoteLockTriggerSent": "Une fois le devis envoyé",
     "quoteLockTriggerAccepted": "Une fois le devis accepté",
     "quoteLockTriggerSentHint": "Verrouille dès que le devis part chez le client. À choisir uniquement si vous ne révisez jamais un devis en cours de négociation.",
-    "quoteLockTriggerAcceptedHint": "Verrouille une fois le devis accepté par le client ou converti en intervention. Les devis envoyés restent modifiables."
+    "quoteLockTriggerAcceptedHint": "Verrouille une fois le devis accepté par le client ou converti en intervention. Les devis envoyés restent modifiables.",
+    "freezeTitle": "Factures antérieures au verrouillage",
+    "freezeBody": "{count, plural, one {# facture a atteint} other {# factures ont atteint}} un client avant que cette version puisse verrouiller ce qu'une facture imprime. Elles s'impriment toujours avec vos coordonnées, logo et design actuels, donc un changement fait aujourd'hui les atteint aussi. Les verrouiller conserve chacune telle qu'elle s'imprime maintenant.",
+    "freezeNone": "Chaque facture ayant atteint un client est verrouillée telle qu'elle a été envoyée.",
+    "freezeButton": "Les verrouiller avec le design actuel",
+    "freezeConfirmTitle": "Verrouiller {count, plural, one {# facture} other {# factures}} ?",
+    "freezeConfirmBody": "Chacune conserve le design, le logo, les coordonnées de l'atelier, le client, le véhicule et les constats avec lesquels elle s'imprime maintenant. Irréversible ; les factures restent modifiables ou verrouillées en modification exactement comme aujourd'hui.",
+    "freezeProgress": "{done} sur {total} verrouillées",
+    "freezeDone": "{count, plural, one {# facture verrouillée} other {# factures verrouillées}}",
+    "freezeFailed": "Impossible de verrouiller les factures"
   },
   "layoutEditor": {
     "helpText": "Glissez pour réorganiser. Utilisez le sélecteur de largeur pour placer les sections côte à côte. Les sections consécutives en demi-largeur (G/D) s'afficheront en deux colonnes.",
@@ -1783,6 +1792,14 @@
     "galleryTitle": "Partir d'un modèle",
     "galleryHint": "Choisissez un point de départ. Vous pourrez tout modifier dans le concepteur.",
     "yourDesigns": "Vos maquettes",
+    "autoRuleHint": "Les ventes sans véhicule s'impriment avec le design qui leur est attribué, sauf si la vente ou son client en a choisi un autre.",
+    "useForPartsSale": "Utiliser pour les ventes de pièces seules",
+    "usedForPartsSale": "Utilisé pour les ventes de pièces seules",
+    "autoRule": {
+      "parts_sale": "Vente de pièces seule"
+    },
+    "autoRuleSaved": "Utilisation de « {name} » mise à jour",
+    "couldNotSaveRule": "Impossible de mettre à jour la règle",
     "templates": "Modèles",
     "inUse": "Utilisée",
     "savedOn": "Enregistrée le {date}",
@@ -1968,7 +1985,12 @@
     "leaveTitle": "Quitter sans enregistrer ?",
     "leaveBody": "Les modifications de cette maquette ne sont pas enregistrées et seront perdues.",
     "leaveConfirm": "Quitter",
-    "boldField": "Gras"
+    "boldField": "Gras",
+    "deleteDesignDrafts": "{count, plural, one {# brouillon de facture} other {# brouillons de facture}} qui l'utilisent reviendront au design par défaut.",
+    "deleteDesignCustomers": "{count, plural, one {# client} other {# clients}} qui y sont associés reviendront au design par défaut.",
+    "setDefault": "Définir par défaut",
+    "defaultSet": "« {name} » est maintenant utilisé",
+    "couldNotSetDefault": "Impossible de définir le design par défaut"
   },
   "preview": {
     "title": "Révision des freins et vidange",

+ 12 - 0
messages/it/audit.json

@@ -156,6 +156,12 @@
     "import_undo": "Importazione annullata",
     "integration_connect": "Integrazione collegata",
     "integration_disconnect": "Integrazione scollegata",
+    "service_setInvoiceDesign": "Design della fattura cambiato",
+    "settings_saveDocumentDesign": "Design del documento salvato",
+    "settings_deleteDocumentDesign": "Design del documento eliminato",
+    "settings_applyDocumentDesign": "Design del documento predefinito impostato",
+    "settings_setDocumentDesignRule": "Modificato quando viene usato un design",
+    "settings_freezeInvoices": "Fatture precedenti bloccate",
     "inspection_reminders_sent": "Promemoria revisione inviati",
     "inspection_reminders_send": "Promemoria revisione inviati"
   },
@@ -286,6 +292,12 @@
     "import_undo": "Importazione da foglio di calcolo annullata: {records} record rimossi",
     "integration_connect": "{name} collegato",
     "integration_disconnect": "{name} scollegato",
+    "service_setInvoiceDesign": "Ha cambiato il design con cui viene stampata una fattura",
+    "settings_saveDocumentDesign": "Ha salvato il design del documento {name}",
+    "settings_deleteDocumentDesign": "Ha eliminato il design del documento {name}",
+    "settings_applyDocumentDesign": "Ha impostato {name} come design del documento predefinito",
+    "settings_setDocumentDesignRule": "Modificato quando viene usato il design del documento {name}",
+    "settings_freezeInvoices": "Ha bloccato {count, plural, one {# fattura} other {# fatture}} precedenti al blocco con il design attuale",
     "inspection_reminders_sent": "Inviati {count} promemoria revisione"
   }
 }

+ 5 - 0
messages/it/customers.json

@@ -33,6 +33,8 @@
     "phoneCopied": "Numero di telefono copiato negli appunti",
     "taxIdLabel": "P. IVA",
     "taxExemptBadge": "Esente da imposta",
+    "invoiceDesignBadge": "Design fattura: {name}",
+    "invoiceDesignBadgeHint": "Le fatture di questo cliente si stampano con questo design invece di quello predefinito dell'officina. Aprilo nel designer.",
     "tabs": {
       "vehicles": "Veicoli ({count})",
       "invoices": "Fatture ({count})",
@@ -93,6 +95,9 @@
     "sectionContact": "Contatto",
     "sectionBilling": "Fatturazione",
     "addScannedVehicle": "Aggiungi il veicolo {vehicle}",
+    "invoiceDesign": "Design della fattura",
+    "invoiceDesignDefault": "Predefinito dell'officina",
+    "invoiceDesignHint": "Le fatture di questo cliente vengono stampate con questo design invece del predefinito dell'officina. Una fattura può comunque sceglierne uno proprio.",
     "reminderOptOut": "Nessun promemoria",
     "reminderOptOutHint": "Non includere questo cliente nei promemoria revisione o in altre campagne."
   },

+ 11 - 1
messages/it/service.json

@@ -79,7 +79,17 @@
       "typePrompt": "Digita un nome...",
       "boardTechnicians": "Tecnici della Lavagna",
       "teamMembers": "Membri del Team"
-    }
+    },
+    "design": "Design della fattura",
+    "designDefault": "Predefinito dell'officina",
+    "designFollowing": "Predefinito ({name})",
+    "designPinned": "Il foglio è stato congelato all'emissione della fattura il {date}. Una nuova scelta vale solo dopo che un titolare sblocca la fattura e questa viene inviata di nuovo.",
+    "designSaved": "Design della fattura aggiornato",
+    "designFollowingRule": "Predefinito ({name}, {rule})",
+    "designRule": {
+      "parts_sale": "Vendita solo ricambi"
+    },
+    "designSaveFailed": "Impossibile cambiare il design della fattura"
   },
   "parts": {
     "title": "Pezzi",

+ 24 - 2
messages/it/settings.json

@@ -580,7 +580,16 @@
     "quoteLockTriggerSent": "Quando il preventivo è inviato",
     "quoteLockTriggerAccepted": "Quando il preventivo è accettato",
     "quoteLockTriggerSentHint": "Si blocca appena il preventivo raggiunge il cliente. Da scegliere solo se non rivedi mai un preventivo durante la trattativa.",
-    "quoteLockTriggerAcceptedHint": "Si blocca quando il cliente accetta il preventivo o questo viene convertito in un lavoro. I preventivi inviati restano modificabili."
+    "quoteLockTriggerAcceptedHint": "Si blocca quando il cliente accetta il preventivo o questo viene convertito in un lavoro. I preventivi inviati restano modificabili.",
+    "freezeTitle": "Fatture precedenti al blocco",
+    "freezeBody": "{count, plural, one {# fattura ha raggiunto} other {# fatture hanno raggiunto}} un cliente prima che questa versione potesse bloccare ciò che una fattura stampa. Si stampano ancora con dati, logo e design attuali, quindi una modifica di oggi le raggiunge. Bloccarle conserva ciascuna come si stampa adesso.",
+    "freezeNone": "Ogni fattura che ha raggiunto un cliente è bloccata come è stata inviata.",
+    "freezeButton": "Bloccale con il design attuale",
+    "freezeConfirmTitle": "Bloccare {count, plural, one {# fattura} other {# fatture}}?",
+    "freezeConfirmBody": "Ciascuna conserva il design, il logo, i dati dell'officina, il cliente, il veicolo e i rilievi con cui si stampa adesso. Non si può annullare, e le fatture restano modificabili o bloccate in modifica esattamente come oggi.",
+    "freezeProgress": "Bloccate {done} di {total}",
+    "freezeDone": "{count, plural, one {# fattura bloccata} other {# fatture bloccate}}",
+    "freezeFailed": "Impossibile bloccare le fatture"
   },
   "layoutEditor": {
     "helpText": "Trascina per riordinare. Usa il selettore di larghezza per posizionare le sezioni affiancate. Le sezioni consecutive a mezza larghezza (S/D) verranno visualizzate in due colonne.",
@@ -1783,6 +1792,14 @@
     "galleryTitle": "Parti da un modello",
     "galleryHint": "Scegli un punto di partenza. Nel designer puoi cambiare tutto.",
     "yourDesigns": "I tuoi design",
+    "autoRuleHint": "Le vendite senza veicolo si stampano con il design contrassegnato per loro, salvo che la vendita o il cliente ne abbiano scelto un altro.",
+    "useForPartsSale": "Usa per le vendite di soli ricambi",
+    "usedForPartsSale": "Usato per le vendite di soli ricambi",
+    "autoRule": {
+      "parts_sale": "Vendita solo ricambi"
+    },
+    "autoRuleSaved": "Uso di \"{name}\" aggiornato",
+    "couldNotSaveRule": "Impossibile aggiornare la regola",
     "templates": "Modelli",
     "inUse": "In uso",
     "savedOn": "Salvato il {date}",
@@ -1968,7 +1985,12 @@
     "leaveTitle": "Uscire senza salvare?",
     "leaveBody": "Le modifiche a questo design non sono state salvate e andranno perse.",
     "leaveConfirm": "Esci",
-    "boldField": "Grassetto"
+    "boldField": "Grassetto",
+    "deleteDesignDrafts": "{count, plural, one {# bozza di fattura} other {# bozze di fattura}} che lo usano torneranno al design predefinito.",
+    "deleteDesignCustomers": "{count, plural, one {# cliente} other {# clienti}} impostati su di esso torneranno al design predefinito.",
+    "setDefault": "Imposta come predefinito",
+    "defaultSet": "«{name}» è ora in uso",
+    "couldNotSetDefault": "Impossibile impostare il design predefinito"
   },
   "preview": {
     "title": "Servizio freni e cambio olio",

+ 12 - 0
messages/lt/audit.json

@@ -156,6 +156,12 @@
     "import_undo": "Atšauktas importas",
     "integration_connect": "Prijungta integracija",
     "integration_disconnect": "Atjungta integracija",
+    "service_setInvoiceDesign": "Pakeistas sąskaitos dizainas",
+    "settings_saveDocumentDesign": "Išsaugotas dokumento dizainas",
+    "settings_deleteDocumentDesign": "Ištrintas dokumento dizainas",
+    "settings_applyDocumentDesign": "Nustatytas numatytasis dokumento dizainas",
+    "settings_setDocumentDesignRule": "Pakeista, kada naudojamas dizainas",
+    "settings_freezeInvoices": "Užrakintos senesnės sąskaitos",
     "inspection_reminders_sent": "Išsiųsti apžiūros priminimai",
     "inspection_reminders_send": "Išsiųsti apžiūros priminimai"
   },
@@ -286,6 +292,12 @@
     "import_undo": "Atšauktas skaičiuoklės importas: pašalinta įrašų: {records}",
     "integration_connect": "Prijungta {name}",
     "integration_disconnect": "Atjungta {name}",
+    "service_setInvoiceDesign": "Pakeitė, kokiu dizainu spausdinama sąskaita",
+    "settings_saveDocumentDesign": "Išsaugojo dokumento dizainą {name}",
+    "settings_deleteDocumentDesign": "Ištrynė dokumento dizainą {name}",
+    "settings_applyDocumentDesign": "Nustatė {name} kaip numatytąjį dokumento dizainą",
+    "settings_setDocumentDesignRule": "Pakeista, kada naudojamas dokumento dizainas {name}",
+    "settings_freezeInvoices": "Užrakino {count, plural, one {# sąskaitą} few {# sąskaitas} other {# sąskaitų}} iki užrakinimo su dabartiniu dizainu",
     "inspection_reminders_sent": "Išsiųsta {count} apžiūros priminimų"
   }
 }

+ 5 - 0
messages/lt/customers.json

@@ -33,6 +33,8 @@
     "phoneCopied": "Telefono numeris nukopijuotas į iškarpinę",
     "taxIdLabel": "PVM kodas",
     "taxExemptBadge": "Atleistas nuo mokesčio",
+    "invoiceDesignBadge": "Sąskaitos dizainas: {name}",
+    "invoiceDesignBadgeHint": "Šio kliento sąskaitos spausdinamos šiuo dizainu vietoj dirbtuvių numatytojo. Atidaryti dizaino įrankyje.",
     "tabs": {
       "vehicles": "Transporto priemonės ({count})",
       "invoices": "Sąskaitos ({count})",
@@ -93,6 +95,9 @@
     "sectionContact": "Kontaktai",
     "sectionBilling": "Atsiskaitymas",
     "addScannedVehicle": "Pridėti transporto priemonę {vehicle}",
+    "invoiceDesign": "Sąskaitos dizainas",
+    "invoiceDesignDefault": "Dirbtuvės numatytasis",
+    "invoiceDesignHint": "Šio kliento sąskaitos spausdinamos šiuo dizainu vietoj dirbtuvės numatytojo. Sąskaita vis tiek gali pasirinkti savo.",
     "reminderOptOut": "Be priminimų",
     "reminderOptOutHint": "Neįtraukti šio kliento į apžiūros priminimus ar kitas kampanijas."
   },

+ 11 - 1
messages/lt/service.json

@@ -79,7 +79,17 @@
       "typePrompt": "Įveskite vardą...",
       "boardTechnicians": "Lentos technikai",
       "teamMembers": "Komandos nariai"
-    }
+    },
+    "design": "Sąskaitos dizainas",
+    "designDefault": "Dirbtuvės numatytasis",
+    "designFollowing": "Numatytasis ({name})",
+    "designPinned": "Lapas užfiksuotas išrašant sąskaitą {date}. Naujas pasirinkimas galios tik po to, kai savininkas atrakins sąskaitą ir ji bus išsiųsta dar kartą.",
+    "designSaved": "Sąskaitos dizainas atnaujintas",
+    "designFollowingRule": "Numatytasis ({name}, {rule})",
+    "designRule": {
+      "parts_sale": "Tik dalių pardavimas"
+    },
+    "designSaveFailed": "Nepavyko pakeisti sąskaitos dizaino"
   },
   "parts": {
     "title": "Dalys",

+ 24 - 2
messages/lt/settings.json

@@ -580,7 +580,16 @@
     "quoteLockTriggerSent": "Kai pasiūlymas išsiųstas",
     "quoteLockTriggerAccepted": "Kai pasiūlymas priimtas",
     "quoteLockTriggerSentHint": "Užrakina, kai tik pasiūlymas išsiunčiamas klientui. Rinkitės tik jei niekada nekeičiate pasiūlymo derybų metu.",
-    "quoteLockTriggerAcceptedHint": "Užrakina, kai klientas priima pasiūlymą arba jis paverčiamas darbu. Išsiųstus pasiūlymus galima redaguoti."
+    "quoteLockTriggerAcceptedHint": "Užrakina, kai klientas priima pasiūlymą arba jis paverčiamas darbu. Išsiųstus pasiūlymus galima redaguoti.",
+    "freezeTitle": "Sąskaitos iki užrakinimo",
+    "freezeBody": "{count, plural, one {# sąskaita pasiekė} few {# sąskaitos pasiekė} other {# sąskaitų pasiekė}} klientą anksčiau, nei ši versija galėjo užrakinti, ką sąskaita spausdina. Jos vis dar spausdinamos su dabartiniais duomenimis, logotipu ir dizainu, tad šiandienos pakeitimas pasiekia ir jas. Užrakinus, kiekviena lieka tokia, kokia spausdinama dabar.",
+    "freezeNone": "Kiekviena klientą pasiekusi sąskaita užrakinta tokia, kokia buvo išsiųsta.",
+    "freezeButton": "Užrakinti su dabartiniu dizainu",
+    "freezeConfirmTitle": "Užrakinti {count, plural, one {# sąskaitą} few {# sąskaitas} other {# sąskaitų}}?",
+    "freezeConfirmBody": "Kiekviena išlaiko dizainą, logotipą, dirbtuvės duomenis, klientą, transporto priemonę ir pastebėjimus, su kuriais spausdinama dabar. To atšaukti negalima; sąskaitos lieka redaguojamos arba užrakintos redagavimui lygiai kaip šiandien.",
+    "freezeProgress": "Užrakinta {done} iš {total}",
+    "freezeDone": "Užrakinta {count, plural, one {# sąskaita} few {# sąskaitos} other {# sąskaitų}}",
+    "freezeFailed": "Nepavyko užrakinti sąskaitų"
   },
   "layoutEditor": {
     "helpText": "Vilkite, kad pertvarkytumėte. Naudokite pločio perjungiklį, kad išdėstytumėte sekcijas greta. Iš eilės einančios pusės pločio sekcijos (K/D) bus vaizduojamos dviejuose stulpeliuose.",
@@ -1783,6 +1792,14 @@
     "galleryTitle": "Pradėkite nuo šablono",
     "galleryHint": "Pasirinkite atspirties tašką. Kūrimo įrankyje galėsite pakeisti viską.",
     "yourDesigns": "Jūsų dizainai",
+    "autoRuleHint": "Pardavimai be transporto priemonės spausdinami jiems pažymėtu dizainu, nebent pardavimas ar jo klientas pasirinko kitą.",
+    "useForPartsSale": "Naudoti tik dalių pardavimams",
+    "usedForPartsSale": "Naudojamas tik dalių pardavimams",
+    "autoRule": {
+      "parts_sale": "Tik dalių pardavimas"
+    },
+    "autoRuleSaved": "„{name}“ naudojimas atnaujintas",
+    "couldNotSaveRule": "Nepavyko atnaujinti taisyklės",
     "templates": "Šablonai",
     "inUse": "Naudojamas",
     "savedOn": "Išsaugota {date}",
@@ -1968,7 +1985,12 @@
     "leaveTitle": "Išeiti neišsaugojus?",
     "leaveBody": "Šio dizaino pakeitimai neišsaugoti ir bus prarasti.",
     "leaveConfirm": "Išeiti",
-    "boldField": "Paryškinta"
+    "boldField": "Paryškinta",
+    "deleteDesignDrafts": "{count, plural, one {# sąskaitos juodraštis} few {# sąskaitų juodraščiai} other {# sąskaitų juodraščių}}, kurie jį naudoja, grįš prie numatytojo dizaino.",
+    "deleteDesignCustomers": "{count, plural, one {# klientas} few {# klientai} other {# klientų}}, kuriems jis priskirtas, grįš prie numatytojo dizaino.",
+    "setDefault": "Nustatyti kaip numatytąjį",
+    "defaultSet": "„{name}“ dabar naudojamas",
+    "couldNotSetDefault": "Nepavyko nustatyti numatytojo dizaino"
   },
   "preview": {
     "title": "Stabdžių aptarnavimas ir tepalų keitimas",

+ 12 - 0
messages/nb/audit.json

@@ -156,6 +156,12 @@
     "import_undo": "Angret import",
     "integration_connect": "Koblet til en integrasjon",
     "integration_disconnect": "Koblet fra en integrasjon",
+    "service_setInvoiceDesign": "Endret fakturadesign",
+    "settings_saveDocumentDesign": "Lagret dokumentdesign",
+    "settings_deleteDocumentDesign": "Slettet dokumentdesign",
+    "settings_applyDocumentDesign": "Satte standard dokumentdesign",
+    "settings_setDocumentDesignRule": "Endret når et design brukes",
+    "settings_freezeInvoices": "Låste eldre fakturaer",
     "inspection_reminders_sent": "Sendte påminnelser om EU-kontroll",
     "inspection_reminders_send": "Sendte påminnelser om EU-kontroll"
   },
@@ -286,6 +292,12 @@
     "import_undo": "Angret en regnearkimport: {records} poster fjernet",
     "integration_connect": "Koblet til {name}",
     "integration_disconnect": "Koblet fra {name}",
+    "service_setInvoiceDesign": "Endret hvilket design en faktura skrives ut med",
+    "settings_saveDocumentDesign": "Lagret dokumentdesignet {name}",
+    "settings_deleteDocumentDesign": "Slettet dokumentdesignet {name}",
+    "settings_applyDocumentDesign": "Gjorde {name} til standard dokumentdesign",
+    "settings_setDocumentDesignRule": "Endret når dokumentdesignet {name} brukes",
+    "settings_freezeInvoices": "Låste {count, plural, one {# faktura} other {# fakturaer}} fra før låsing med gjeldende design",
     "inspection_reminders_sent": "Sendte {count} påminnelser om EU-kontroll"
   }
 }

+ 5 - 0
messages/nb/customers.json

@@ -33,6 +33,8 @@
     "phoneCopied": "Telefonnummer kopiert til utklippstavlen",
     "taxIdLabel": "Org.nr",
     "taxExemptBadge": "Avgiftsfritatt",
+    "invoiceDesignBadge": "Fakturadesign: {name}",
+    "invoiceDesignBadgeHint": "Fakturaer til denne kunden skrives ut med dette designet i stedet for verkstedets standard. Åpne det i designeren.",
     "tabs": {
       "vehicles": "Kjøretøy ({count})",
       "invoices": "Fakturaer ({count})",
@@ -93,6 +95,9 @@
     "sectionContact": "Kontakt",
     "sectionBilling": "Fakturering",
     "addScannedVehicle": "Legg til kjøretøyet {vehicle}",
+    "invoiceDesign": "Fakturadesign",
+    "invoiceDesignDefault": "Verkstedets standard",
+    "invoiceDesignHint": "Fakturaer til denne kunden skrives ut med dette designet i stedet for verkstedets standard. En faktura kan fortsatt velge sitt eget.",
     "reminderOptOut": "Ingen påminnelser",
     "reminderOptOutHint": "Ikke ta med denne kunden i påminnelser om EU-kontroll eller andre utsendinger."
   },

+ 11 - 1
messages/nb/service.json

@@ -79,7 +79,17 @@
       "typePrompt": "Skriv navn...",
       "boardTechnicians": "Tavleteknikere",
       "teamMembers": "Lagmedlemmer"
-    }
+    },
+    "design": "Fakturadesign",
+    "designDefault": "Verkstedets standard",
+    "designFollowing": "Standard ({name})",
+    "designPinned": "Arket ble fryst da fakturaen ble utstedt {date}. Et nytt valg gjelder først etter at en eier låser opp fakturaen og den sendes igjen.",
+    "designSaved": "Fakturadesign oppdatert",
+    "designFollowingRule": "Standard ({name}, {rule})",
+    "designRule": {
+      "parts_sale": "Kun delesalg"
+    },
+    "designSaveFailed": "Kunne ikke endre fakturadesignet"
   },
   "parts": {
     "title": "Deler",

+ 24 - 2
messages/nb/settings.json

@@ -580,7 +580,16 @@
     "quoteLockTriggerSent": "Når tilbudet er sendt",
     "quoteLockTriggerAccepted": "Når tilbudet er akseptert",
     "quoteLockTriggerSentHint": "Låses så snart tilbudet går til kunden. Velg dette bare hvis du aldri endrer et tilbud underveis i forhandlingen.",
-    "quoteLockTriggerAcceptedHint": "Låses når kunden har akseptert tilbudet, eller det er gjort om til en jobb. Sendte tilbud kan fortsatt endres."
+    "quoteLockTriggerAcceptedHint": "Låses når kunden har akseptert tilbudet, eller det er gjort om til en jobb. Sendte tilbud kan fortsatt endres.",
+    "freezeTitle": "Fakturaer fra før låsing",
+    "freezeBody": "{count, plural, one {# faktura} other {# fakturaer}} nådde en kunde før denne versjonen kunne låse det en faktura skriver ut. De skrives fortsatt ut med dagens opplysninger, logo og design, så en endring du gjør i dag når også dem. Låser du dem, beholder hver av dem det den skriver ut akkurat nå.",
+    "freezeNone": "Alle fakturaer som har nådd en kunde er låst slik de ble sendt.",
+    "freezeButton": "Lås dem med gjeldende design",
+    "freezeConfirmTitle": "Låse {count, plural, one {# faktura} other {# fakturaer}}?",
+    "freezeConfirmBody": "Hver av dem beholder designet, logoen, verkstedopplysningene, kunden, kjøretøyet og funnene den skrives ut med akkurat nå. Dette kan ikke angres, og fakturaene forblir redigerbare eller låst for redigering nøyaktig som i dag.",
+    "freezeProgress": "Låst {done} av {total}",
+    "freezeDone": "{count, plural, one {# faktura} other {# fakturaer}} låst",
+    "freezeFailed": "Kunne ikke låse fakturaene"
   },
   "layoutEditor": {
     "helpText": "Dra for å sortere. Bruk bredde-veksler for å plassere seksjoner side om side. Påfølgende halvbredde-seksjoner (V/H) vises i to kolonner.",
@@ -1783,6 +1792,14 @@
     "galleryTitle": "Start fra en mal",
     "galleryHint": "Velg et utgangspunkt. Du kan endre alt i designeren.",
     "yourDesigns": "Dine design",
+    "autoRuleHint": "Salg uten kjøretøy skrives ut med designet som er merket for dem, med mindre salget eller kunden har valgt et annet.",
+    "useForPartsSale": "Bruk for rene delesalg",
+    "usedForPartsSale": "Brukes for rene delesalg",
+    "autoRule": {
+      "parts_sale": "Kun delesalg"
+    },
+    "autoRuleSaved": "Oppdaterte når «{name}» brukes",
+    "couldNotSaveRule": "Kunne ikke oppdatere regelen",
     "templates": "Maler",
     "inUse": "I bruk",
     "savedOn": "Lagret {date}",
@@ -1968,7 +1985,12 @@
     "leaveTitle": "Forlate uten å lagre?",
     "leaveBody": "Endringene i dette designet er ikke lagret. De går tapt.",
     "leaveConfirm": "Forlat",
-    "boldField": "Fet"
+    "boldField": "Fet",
+    "deleteDesignDrafts": "{count, plural, one {# fakturautkast} other {# fakturautkast}} som bruker det, går tilbake til standarddesignet.",
+    "deleteDesignCustomers": "{count, plural, one {# kunde} other {# kunder}} som er satt til det, går tilbake til standarddesignet.",
+    "setDefault": "Bruk som standard",
+    "defaultSet": "«{name}» er nå i bruk",
+    "couldNotSetDefault": "Kunne ikke sette standarddesignet"
   },
   "preview": {
     "title": "Bremseservice og oljeskift",

+ 12 - 0
messages/nl/audit.json

@@ -156,6 +156,12 @@
     "import_undo": "Import ongedaan gemaakt",
     "integration_connect": "Integratie gekoppeld",
     "integration_disconnect": "Integratie losgekoppeld",
+    "service_setInvoiceDesign": "Factuurontwerp gewijzigd",
+    "settings_saveDocumentDesign": "Documentontwerp opgeslagen",
+    "settings_deleteDocumentDesign": "Documentontwerp verwijderd",
+    "settings_applyDocumentDesign": "Standaard documentontwerp ingesteld",
+    "settings_setDocumentDesignRule": "Gewijzigd wanneer een ontwerp wordt gebruikt",
+    "settings_freezeInvoices": "Oudere facturen vergrendeld",
     "inspection_reminders_sent": "Keuringsherinneringen verstuurd",
     "inspection_reminders_send": "Keuringsherinneringen verstuurd"
   },
@@ -286,6 +292,12 @@
     "import_undo": "Spreadsheetimport ongedaan gemaakt: {records} records verwijderd",
     "integration_connect": "{name} gekoppeld",
     "integration_disconnect": "{name} losgekoppeld",
+    "service_setInvoiceDesign": "Wijzigde met welk ontwerp een factuur wordt afgedrukt",
+    "settings_saveDocumentDesign": "Sloeg het documentontwerp {name} op",
+    "settings_deleteDocumentDesign": "Verwijderde het documentontwerp {name}",
+    "settings_applyDocumentDesign": "Maakte {name} het standaard documentontwerp",
+    "settings_setDocumentDesignRule": "Gewijzigd wanneer het documentontwerp {name} wordt gebruikt",
+    "settings_freezeInvoices": "Vergrendelde {count, plural, one {# factuur} other {# facturen}} van vóór het vergrendelen met het huidige ontwerp",
     "inspection_reminders_sent": "{count} keuringsherinneringen verstuurd"
   }
 }

+ 5 - 0
messages/nl/customers.json

@@ -33,6 +33,8 @@
     "phoneCopied": "Telefoonnummer gekopieerd naar klembord",
     "taxIdLabel": "Btw-nr.",
     "taxExemptBadge": "Belastingvrijgesteld",
+    "invoiceDesignBadge": "Factuurontwerp: {name}",
+    "invoiceDesignBadgeHint": "Facturen voor deze klant worden met dit ontwerp afgedrukt in plaats van de werkplaatsstandaard. Open het in de ontwerper.",
     "tabs": {
       "vehicles": "Voertuigen ({count})",
       "invoices": "Facturen ({count})",
@@ -93,6 +95,9 @@
     "sectionContact": "Contact",
     "sectionBilling": "Facturatie",
     "addScannedVehicle": "Voertuig {vehicle} toevoegen",
+    "invoiceDesign": "Factuurontwerp",
+    "invoiceDesignDefault": "Standaard van de werkplaats",
+    "invoiceDesignHint": "Facturen voor deze klant worden met dit ontwerp afgedrukt in plaats van de standaard van de werkplaats. Een factuur kan nog steeds een eigen ontwerp kiezen.",
     "reminderOptOut": "Geen herinneringen",
     "reminderOptOutHint": "Deze klant niet opnemen in keuringsherinneringen of andere campagnes."
   },

+ 11 - 1
messages/nl/service.json

@@ -79,7 +79,17 @@
       "typePrompt": "Typ een naam...",
       "boardTechnicians": "Werkbordtechnici",
       "teamMembers": "Teamleden"
-    }
+    },
+    "design": "Factuurontwerp",
+    "designDefault": "Standaard van de werkplaats",
+    "designFollowing": "Standaard ({name})",
+    "designPinned": "Het blad is bevroren toen de factuur op {date} werd uitgegeven. Een nieuwe keuze geldt pas nadat een eigenaar de factuur ontgrendelt en ze opnieuw wordt verzonden.",
+    "designSaved": "Factuurontwerp bijgewerkt",
+    "designFollowingRule": "Standaard ({name}, {rule})",
+    "designRule": {
+      "parts_sale": "Alleen onderdelenverkoop"
+    },
+    "designSaveFailed": "Kon het factuurontwerp niet wijzigen"
   },
   "parts": {
     "title": "Onderdelen",

+ 24 - 2
messages/nl/settings.json

@@ -580,7 +580,16 @@
     "quoteLockTriggerSent": "Zodra de offerte is verstuurd",
     "quoteLockTriggerAccepted": "Zodra de offerte is geaccepteerd",
     "quoteLockTriggerSentHint": "Vergrendelt zodra de offerte naar de klant gaat. Kies dit alleen als je een offerte nooit aanpast tijdens de onderhandeling.",
-    "quoteLockTriggerAcceptedHint": "Vergrendelt zodra de klant de offerte heeft geaccepteerd of die is omgezet naar een opdracht. Verstuurde offertes blijven bewerkbaar."
+    "quoteLockTriggerAcceptedHint": "Vergrendelt zodra de klant de offerte heeft geaccepteerd of die is omgezet naar een opdracht. Verstuurde offertes blijven bewerkbaar.",
+    "freezeTitle": "Facturen van vóór het vergrendelen",
+    "freezeBody": "{count, plural, one {# factuur} other {# facturen}} bereikte een klant voordat deze versie kon vergrendelen wat een factuur afdrukt. Ze worden nog met uw huidige gegevens, logo en ontwerp afgedrukt, dus een wijziging van vandaag bereikt ze ook. Vergrendelen houdt elke factuur zoals ze nu wordt afgedrukt.",
+    "freezeNone": "Elke factuur die een klant bereikte is vergrendeld zoals ze is verstuurd.",
+    "freezeButton": "Vergrendelen met het huidige ontwerp",
+    "freezeConfirmTitle": "{count, plural, one {# factuur} other {# facturen}} vergrendelen?",
+    "freezeConfirmBody": "Elke factuur behoudt het ontwerp, het logo, de werkplaatsgegevens, de klant, het voertuig en de bevindingen waarmee ze nu wordt afgedrukt. Dit kan niet ongedaan worden gemaakt; de facturen blijven precies zo bewerkbaar of voor bewerking vergrendeld als vandaag.",
+    "freezeProgress": "{done} van {total} vergrendeld",
+    "freezeDone": "{count, plural, one {# factuur} other {# facturen}} vergrendeld",
+    "freezeFailed": "Kon de facturen niet vergrendelen"
   },
   "layoutEditor": {
     "helpText": "Sleep om te herordenen. Gebruik de breedte-schakelaar om secties naast elkaar te plaatsen. Opeenvolgende halve breedte secties (L/R) worden in twee kolommen weergegeven.",
@@ -1783,6 +1792,14 @@
     "galleryTitle": "Begin met een sjabloon",
     "galleryHint": "Kies een startpunt. In de ontwerper kunt u alles wijzigen.",
     "yourDesigns": "Uw ontwerpen",
+    "autoRuleHint": "Verkopen zonder voertuig worden afgedrukt met het daarvoor gemarkeerde ontwerp, tenzij de verkoop of de klant een ander koos.",
+    "useForPartsSale": "Gebruiken voor verkoop van alleen onderdelen",
+    "usedForPartsSale": "Gebruikt voor verkoop van alleen onderdelen",
+    "autoRule": {
+      "parts_sale": "Alleen onderdelenverkoop"
+    },
+    "autoRuleSaved": "Gebruik van \"{name}\" bijgewerkt",
+    "couldNotSaveRule": "Kon de regel niet bijwerken",
     "templates": "Sjablonen",
     "inUse": "In gebruik",
     "savedOn": "Opgeslagen {date}",
@@ -1968,7 +1985,12 @@
     "leaveTitle": "Weggaan zonder opslaan?",
     "leaveBody": "Je wijzigingen aan dit ontwerp zijn niet opgeslagen en gaan verloren.",
     "leaveConfirm": "Weggaan",
-    "boldField": "Vet"
+    "boldField": "Vet",
+    "deleteDesignDrafts": "{count, plural, one {# conceptfactuur} other {# conceptfacturen}} die het gebruiken, vallen terug op het standaardontwerp.",
+    "deleteDesignCustomers": "{count, plural, one {# klant} other {# klanten}} die erop zijn ingesteld, vallen terug op het standaardontwerp.",
+    "setDefault": "Als standaard instellen",
+    "defaultSet": "\"{name}\" is nu in gebruik",
+    "couldNotSetDefault": "Kon het standaardontwerp niet instellen"
   },
   "preview": {
     "title": "Remservice en oliewissel",

+ 12 - 0
messages/pl/audit.json

@@ -156,6 +156,12 @@
     "import_undo": "Cofnięto import",
     "integration_connect": "Połączono integrację",
     "integration_disconnect": "Rozłączono integrację",
+    "service_setInvoiceDesign": "Zmieniono projekt faktury",
+    "settings_saveDocumentDesign": "Zapisano projekt dokumentu",
+    "settings_deleteDocumentDesign": "Usunięto projekt dokumentu",
+    "settings_applyDocumentDesign": "Ustawiono domyślny projekt dokumentu",
+    "settings_setDocumentDesignRule": "Zmieniono, kiedy używany jest projekt",
+    "settings_freezeInvoices": "Zablokowano starsze faktury",
     "inspection_reminders_sent": "Wysłano przypomnienia o przeglądzie",
     "inspection_reminders_send": "Wysłano przypomnienia o przeglądzie"
   },
@@ -286,6 +292,12 @@
     "import_undo": "Cofnięto import arkusza: usunięto {records} rekordów",
     "integration_connect": "Połączono {name}",
     "integration_disconnect": "Rozłączono {name}",
+    "service_setInvoiceDesign": "Zmienił projekt, z którym drukowana jest faktura",
+    "settings_saveDocumentDesign": "Zapisał projekt dokumentu {name}",
+    "settings_deleteDocumentDesign": "Usunął projekt dokumentu {name}",
+    "settings_applyDocumentDesign": "Ustawił {name} jako domyślny projekt dokumentu",
+    "settings_setDocumentDesignRule": "Zmieniono, kiedy używany jest projekt dokumentu {name}",
+    "settings_freezeInvoices": "Zablokował {count, plural, one {# fakturę} few {# faktury} many {# faktur} other {# faktury}} sprzed blokady z bieżącym projektem",
     "inspection_reminders_sent": "Wysłano {count} przypomnień o przeglądzie"
   }
 }

+ 5 - 0
messages/pl/customers.json

@@ -33,6 +33,8 @@
     "phoneCopied": "Numer telefonu skopiowany do schowka",
     "taxIdLabel": "NIP",
     "taxExemptBadge": "Zwolniony z podatku",
+    "invoiceDesignBadge": "Projekt faktury: {name}",
+    "invoiceDesignBadgeHint": "Faktury tego klienta są drukowane z tym projektem zamiast domyślnego warsztatu. Otwórz w projektancie.",
     "tabs": {
       "vehicles": "Pojazdy ({count})",
       "invoices": "Faktury ({count})",
@@ -93,6 +95,9 @@
     "sectionContact": "Kontakt",
     "sectionBilling": "Rozliczenia",
     "addScannedVehicle": "Dodaj pojazd {vehicle}",
+    "invoiceDesign": "Projekt faktury",
+    "invoiceDesignDefault": "Domyślny warsztatu",
+    "invoiceDesignHint": "Faktury dla tego klienta są drukowane z tym projektem zamiast domyślnego projektu warsztatu. Faktura nadal może wybrać własny.",
     "reminderOptOut": "Bez przypomnień",
     "reminderOptOutHint": "Nie uwzględniaj tego klienta w przypomnieniach o przeglądzie ani innych kampaniach."
   },

+ 11 - 1
messages/pl/service.json

@@ -79,7 +79,17 @@
       "typePrompt": "Wpisz nazwę...",
       "boardTechnicians": "Technicy z Tablicy",
       "teamMembers": "Członkowie Zespołu"
-    }
+    },
+    "design": "Projekt faktury",
+    "designDefault": "Domyślny warsztatu",
+    "designFollowing": "Domyślny ({name})",
+    "designPinned": "Arkusz został zamrożony przy wystawieniu faktury {date}. Nowy wybór zadziała dopiero, gdy właściciel odblokuje fakturę i zostanie ona wysłana ponownie.",
+    "designSaved": "Projekt faktury zaktualizowany",
+    "designFollowingRule": "Domyślny ({name}, {rule})",
+    "designRule": {
+      "parts_sale": "Sprzedaż samych części"
+    },
+    "designSaveFailed": "Nie udało się zmienić projektu faktury"
   },
   "parts": {
     "title": "Części",

+ 24 - 2
messages/pl/settings.json

@@ -580,7 +580,16 @@
     "quoteLockTriggerSent": "Po wysłaniu oferty",
     "quoteLockTriggerAccepted": "Po zaakceptowaniu oferty",
     "quoteLockTriggerSentHint": "Blokuje, gdy oferta trafi do klienta. Wybierz tylko wtedy, gdy nigdy nie zmieniasz oferty w trakcie negocjacji.",
-    "quoteLockTriggerAcceptedHint": "Blokuje po zaakceptowaniu oferty przez klienta lub przekształceniu jej w zlecenie. Wysłane oferty pozostają edytowalne."
+    "quoteLockTriggerAcceptedHint": "Blokuje po zaakceptowaniu oferty przez klienta lub przekształceniu jej w zlecenie. Wysłane oferty pozostają edytowalne.",
+    "freezeTitle": "Faktury sprzed blokady",
+    "freezeBody": "{count, plural, one {# faktura dotarła} few {# faktury dotarły} many {# faktur dotarło} other {# faktury dotarło}} do klienta, zanim ta wersja mogła zablokować to, co drukuje faktura. Nadal drukują się z bieżącymi danymi, logo i projektem, więc dzisiejsza zmiana dotrze też do nich. Zablokowanie zachowuje każdą taką, jaka drukuje się teraz.",
+    "freezeNone": "Każda faktura, która dotarła do klienta, jest zablokowana tak, jak została wysłana.",
+    "freezeButton": "Zablokuj je z bieżącym projektem",
+    "freezeConfirmTitle": "Zablokować {count, plural, one {# fakturę} few {# faktury} many {# faktur} other {# faktury}}?",
+    "freezeConfirmBody": "Każda zachowa projekt, logo, dane warsztatu, klienta, pojazd i ustalenia, z którymi drukuje się teraz. Nie da się tego cofnąć; faktury pozostaną edytowalne lub zablokowane do edycji dokładnie tak jak dziś.",
+    "freezeProgress": "Zablokowano {done} z {total}",
+    "freezeDone": "Zablokowano {count, plural, one {# fakturę} few {# faktury} many {# faktur} other {# faktury}}",
+    "freezeFailed": "Nie udało się zablokować faktur"
   },
   "layoutEditor": {
     "helpText": "Przeciągnij, aby zmienić kolejność. Użyj przełącznika szerokości, aby umieścić sekcje obok siebie. Kolejne sekcje o połowie szerokości (L/P) będą wyświetlane w dwóch kolumnach.",
@@ -1783,6 +1792,14 @@
     "galleryTitle": "Zacznij od szablonu",
     "galleryHint": "Wybierz punkt wyjścia. W kreatorze zmienisz wszystko.",
     "yourDesigns": "Twoje projekty",
+    "autoRuleHint": "Sprzedaże bez pojazdu drukują się z oznaczonym dla nich projektem, chyba że sprzedaż lub jej klient wybrali inny.",
+    "useForPartsSale": "Używaj do sprzedaży samych części",
+    "usedForPartsSale": "Używany do sprzedaży samych części",
+    "autoRule": {
+      "parts_sale": "Sprzedaż samych części"
+    },
+    "autoRuleSaved": "Zaktualizowano, kiedy używany jest „{name}”",
+    "couldNotSaveRule": "Nie udało się zaktualizować reguły",
     "templates": "Szablony",
     "inUse": "W użyciu",
     "savedOn": "Zapisano {date}",
@@ -1968,7 +1985,12 @@
     "leaveTitle": "Wyjść bez zapisywania?",
     "leaveBody": "Zmiany w tym projekcie nie zostały zapisane i zostaną utracone.",
     "leaveConfirm": "Wyjdź",
-    "boldField": "Pogrubienie"
+    "boldField": "Pogrubienie",
+    "deleteDesignDrafts": "{count, plural, one {# szkic faktury} few {# szkice faktur} many {# szkiców faktur} other {# szkicu faktury}} z tym projektem wróci do projektu domyślnego.",
+    "deleteDesignCustomers": "{count, plural, one {# klient} few {# klientów} many {# klientów} other {# klienta}} z tym projektem wróci do projektu domyślnego.",
+    "setDefault": "Ustaw jako domyślny",
+    "defaultSet": "„{name}” jest teraz w użyciu",
+    "couldNotSetDefault": "Nie udało się ustawić domyślnego projektu"
   },
   "preview": {
     "title": "Serwis hamulców i wymiana oleju",

+ 12 - 0
messages/pt-BR/audit.json

@@ -156,6 +156,12 @@
     "import_undo": "Importação desfeita",
     "integration_connect": "Integração conectada",
     "integration_disconnect": "Integração desconectada",
+    "service_setInvoiceDesign": "Design da fatura alterado",
+    "settings_saveDocumentDesign": "Design de documento salvo",
+    "settings_deleteDocumentDesign": "Design de documento excluído",
+    "settings_applyDocumentDesign": "Design de documento padrão definido",
+    "settings_setDocumentDesignRule": "Alterou quando um design é usado",
+    "settings_freezeInvoices": "Faturas anteriores travadas",
     "inspection_reminders_sent": "Enviou lembretes de inspeção",
     "inspection_reminders_send": "Enviou lembretes de inspeção"
   },
@@ -286,6 +292,12 @@
     "import_undo": "Desfez uma importação de planilha: {records} registros removidos",
     "integration_connect": "{name} conectado",
     "integration_disconnect": "{name} desconectado",
+    "service_setInvoiceDesign": "Alterou com qual design uma fatura é impressa",
+    "settings_saveDocumentDesign": "Salvou o design de documento {name}",
+    "settings_deleteDocumentDesign": "Excluiu o design de documento {name}",
+    "settings_applyDocumentDesign": "Definiu {name} como design de documento padrão",
+    "settings_setDocumentDesignRule": "Alterou quando o design de documento {name} é usado",
+    "settings_freezeInvoices": "Travou {count, plural, one {# fatura} other {# faturas}} anteriores ao bloqueio com o design atual",
     "inspection_reminders_sent": "Enviou {count} lembretes de inspeção"
   }
 }

+ 5 - 0
messages/pt-BR/customers.json

@@ -33,6 +33,8 @@
     "phoneCopied": "Número de telefone copiado para a área de transferência",
     "taxIdLabel": "CNPJ",
     "taxExemptBadge": "Isento de imposto",
+    "invoiceDesignBadge": "Design da fatura: {name}",
+    "invoiceDesignBadgeHint": "As faturas deste cliente são impressas com este design em vez do padrão da oficina. Abra no designer.",
     "tabs": {
       "vehicles": "Veículos ({count})",
       "invoices": "Faturas ({count})",
@@ -93,6 +95,9 @@
     "sectionContact": "Contato",
     "sectionBilling": "Faturamento",
     "addScannedVehicle": "Adicionar o veículo {vehicle}",
+    "invoiceDesign": "Design da fatura",
+    "invoiceDesignDefault": "Padrão da oficina",
+    "invoiceDesignHint": "As faturas deste cliente são impressas com este design em vez do padrão da oficina. Uma fatura ainda pode escolher o seu próprio.",
     "reminderOptOut": "Sem lembretes",
     "reminderOptOutHint": "Não incluir este cliente em lembretes de inspeção ou outras campanhas."
   },

+ 11 - 1
messages/pt-BR/service.json

@@ -79,7 +79,17 @@
       "typePrompt": "Digite um nome...",
       "boardTechnicians": "Técnicos do Quadro",
       "teamMembers": "Membros do Time"
-    }
+    },
+    "design": "Design da fatura",
+    "designDefault": "Padrão da oficina",
+    "designFollowing": "Padrão ({name})",
+    "designPinned": "A folha foi congelada quando a fatura foi emitida em {date}. Uma nova escolha só vale depois que um proprietário desbloquear a fatura e ela for enviada de novo.",
+    "designSaved": "Design da fatura atualizado",
+    "designFollowingRule": "Padrão ({name}, {rule})",
+    "designRule": {
+      "parts_sale": "Venda apenas de peças"
+    },
+    "designSaveFailed": "Não foi possível alterar o design da fatura"
   },
   "parts": {
     "title": "Peças",

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

@@ -580,7 +580,16 @@
     "quoteLockTriggerSent": "Assim que o orçamento for enviado",
     "quoteLockTriggerAccepted": "Assim que o orçamento for aceito",
     "quoteLockTriggerSentHint": "Bloqueia assim que o orçamento chega ao cliente. Escolha apenas se você nunca revisa um orçamento durante a negociação.",
-    "quoteLockTriggerAcceptedHint": "Bloqueia quando o cliente aceita o orçamento ou ele é convertido em serviço. Orçamentos enviados continuam editáveis."
+    "quoteLockTriggerAcceptedHint": "Bloqueia quando o cliente aceita o orçamento ou ele é convertido em serviço. Orçamentos enviados continuam editáveis.",
+    "freezeTitle": "Faturas anteriores ao bloqueio",
+    "freezeBody": "{count, plural, one {# fatura chegou} other {# faturas chegaram}} a um cliente antes que esta versão pudesse travar o que uma fatura imprime. Elas ainda são impressas com seus dados, logo e design atuais, então uma mudança feita hoje também as alcança. Travá-las mantém cada uma como é impressa agora.",
+    "freezeNone": "Toda fatura que chegou a um cliente está travada como foi enviada.",
+    "freezeButton": "Travar com o design atual",
+    "freezeConfirmTitle": "Travar {count, plural, one {# fatura} other {# faturas}}?",
+    "freezeConfirmBody": "Cada uma mantém o design, o logo, os dados da oficina, o cliente, o veículo e as constatações com que é impressa agora. Não pode ser desfeito, e as faturas continuam editáveis ou travadas para edição exatamente como hoje.",
+    "freezeProgress": "Travadas {done} de {total}",
+    "freezeDone": "{count, plural, one {# fatura travada} other {# faturas travadas}}",
+    "freezeFailed": "Não foi possível travar as faturas"
   },
   "layoutEditor": {
     "helpText": "Arraste para reordenar. Use o alternador de largura para posicionar seções lado a lado. Seções consecutivas de meia largura (E/D) serão exibidas em duas colunas.",
@@ -1783,6 +1792,14 @@
     "galleryTitle": "Comece por um modelo",
     "galleryHint": "Escolha um ponto de partida. Você pode mudar tudo no designer.",
     "yourDesigns": "Seus designs",
+    "autoRuleHint": "Vendas sem veículo são impressas com o design marcado para elas, a menos que a venda ou seu cliente tenha escolhido outro.",
+    "useForPartsSale": "Usar para vendas só de peças",
+    "usedForPartsSale": "Usado para vendas só de peças",
+    "autoRule": {
+      "parts_sale": "Venda apenas de peças"
+    },
+    "autoRuleSaved": "Uso de \"{name}\" atualizado",
+    "couldNotSaveRule": "Não foi possível atualizar a regra",
     "templates": "Modelos",
     "inUse": "Em uso",
     "savedOn": "Salvo em {date}",
@@ -1968,7 +1985,12 @@
     "leaveTitle": "Sair sem salvar?",
     "leaveBody": "Suas alterações neste design não foram salvas e serão perdidas.",
     "leaveConfirm": "Sair",
-    "boldField": "Negrito"
+    "boldField": "Negrito",
+    "deleteDesignDrafts": "{count, plural, one {# rascunho de fatura} other {# rascunhos de fatura}} que o usam voltarão ao design padrão.",
+    "deleteDesignCustomers": "{count, plural, one {# cliente} other {# clientes}} definidos com ele voltarão ao design padrão.",
+    "setDefault": "Definir como padrão",
+    "defaultSet": "\"{name}\" está em uso agora",
+    "couldNotSetDefault": "Não foi possível definir o design padrão"
   },
   "preview": {
     "title": "Serviço de freios e troca de óleo",

+ 12 - 0
messages/ru/audit.json

@@ -156,6 +156,12 @@
     "import_undo": "Отменён импорт",
     "integration_connect": "Подключена интеграция",
     "integration_disconnect": "Отключена интеграция",
+    "service_setInvoiceDesign": "Изменён дизайн счёта",
+    "settings_saveDocumentDesign": "Сохранён дизайн документа",
+    "settings_deleteDocumentDesign": "Удалён дизайн документа",
+    "settings_applyDocumentDesign": "Установлен дизайн документа по умолчанию",
+    "settings_setDocumentDesignRule": "Изменено, когда используется дизайн",
+    "settings_freezeInvoices": "Зафиксированы старые счета",
     "inspection_reminders_sent": "Отправлены напоминания о техосмотре",
     "inspection_reminders_send": "Отправлены напоминания о техосмотре"
   },
@@ -286,6 +292,12 @@
     "import_undo": "Отменён импорт таблицы, удалено записей: {records}",
     "integration_connect": "Подключена интеграция {name}",
     "integration_disconnect": "Отключена интеграция {name}",
+    "service_setInvoiceDesign": "Изменил, с каким дизайном печатается счёт",
+    "settings_saveDocumentDesign": "Сохранил дизайн документа {name}",
+    "settings_deleteDocumentDesign": "Удалил дизайн документа {name}",
+    "settings_applyDocumentDesign": "Сделал {name} дизайном документа по умолчанию",
+    "settings_setDocumentDesignRule": "Изменено, когда используется дизайн документа {name}",
+    "settings_freezeInvoices": "Зафиксировал {count, plural, one {# счёт} few {# счёта} many {# счетов} other {# счёта}} до появления блокировки с текущим дизайном",
     "inspection_reminders_sent": "Отправлено напоминаний о техосмотре: {count}"
   }
 }

+ 5 - 0
messages/ru/customers.json

@@ -33,6 +33,8 @@
     "phoneCopied": "Номер телефона скопирован в буфер обмена",
     "taxIdLabel": "ИНН",
     "taxExemptBadge": "Освобожден от налога",
+    "invoiceDesignBadge": "Дизайн счёта: {name}",
+    "invoiceDesignBadgeHint": "Счета этого клиента печатаются с этим дизайном вместо стандартного для мастерской. Открыть в дизайнере.",
     "tabs": {
       "vehicles": "Транспортные средства ({count})",
       "invoices": "Счета ({count})",
@@ -93,6 +95,9 @@
     "sectionContact": "Контакты",
     "sectionBilling": "Выставление счетов",
     "addScannedVehicle": "Добавить автомобиль {vehicle}",
+    "invoiceDesign": "Дизайн счёта",
+    "invoiceDesignDefault": "По умолчанию для мастерской",
+    "invoiceDesignHint": "Счета этого клиента печатаются с этим дизайном вместо дизайна мастерской по умолчанию. Счёт по-прежнему может выбрать свой.",
     "reminderOptOut": "Без напоминаний",
     "reminderOptOutHint": "Не включать этого клиента в напоминания о техосмотре и другие рассылки."
   },

+ 11 - 1
messages/ru/service.json

@@ -79,7 +79,17 @@
       "typePrompt": "Введите имя...",
       "boardTechnicians": "Механики доски",
       "teamMembers": "Участники команды"
-    }
+    },
+    "design": "Дизайн счёта",
+    "designDefault": "По умолчанию для мастерской",
+    "designFollowing": "По умолчанию ({name})",
+    "designPinned": "Лист зафиксирован при выставлении счёта {date}. Новый выбор применится только после того, как владелец разблокирует счёт и он будет отправлен снова.",
+    "designSaved": "Дизайн счёта обновлён",
+    "designFollowingRule": "По умолчанию ({name}, {rule})",
+    "designRule": {
+      "parts_sale": "Продажа только запчастей"
+    },
+    "designSaveFailed": "Не удалось изменить дизайн счёта"
   },
   "parts": {
     "title": "Запчасти",

+ 24 - 2
messages/ru/settings.json

@@ -580,7 +580,16 @@
     "quoteLockTriggerSent": "После отправки предложения",
     "quoteLockTriggerAccepted": "После принятия предложения",
     "quoteLockTriggerSentHint": "Блокирует, как только предложение уходит клиенту. Выбирайте, только если вы никогда не пересматриваете предложение в ходе переговоров.",
-    "quoteLockTriggerAcceptedHint": "Блокирует после принятия предложения клиентом или его преобразования в заказ. Отправленные предложения остаются редактируемыми."
+    "quoteLockTriggerAcceptedHint": "Блокирует после принятия предложения клиентом или его преобразования в заказ. Отправленные предложения остаются редактируемыми.",
+    "freezeTitle": "Счета до появления блокировки",
+    "freezeBody": "{count, plural, one {# счёт дошёл} few {# счёта дошли} many {# счетов дошли} other {# счёта дошли}} до клиента раньше, чем эта версия могла зафиксировать содержимое счёта. Они по-прежнему печатаются с текущими реквизитами, логотипом и дизайном, поэтому сегодняшнее изменение затронет и их. Фиксация сохранит каждый таким, каким он печатается сейчас.",
+    "freezeNone": "Каждый счёт, дошедший до клиента, зафиксирован таким, каким был отправлен.",
+    "freezeButton": "Зафиксировать с текущим дизайном",
+    "freezeConfirmTitle": "Зафиксировать {count, plural, one {# счёт} few {# счёта} many {# счетов} other {# счёта}}?",
+    "freezeConfirmBody": "Каждый сохранит дизайн, логотип, реквизиты мастерской, клиента, автомобиль и замечания, с которыми печатается сейчас. Отменить нельзя; счета останутся редактируемыми или заблокированными для правки ровно как сегодня.",
+    "freezeProgress": "Зафиксировано {done} из {total}",
+    "freezeDone": "Зафиксировано {count, plural, one {# счёт} few {# счёта} many {# счетов} other {# счёта}}",
+    "freezeFailed": "Не удалось зафиксировать счета"
   },
   "layoutEditor": {
     "helpText": "Перетаскивайте для изменения порядка. Используйте переключатель ширины для размещения разделов бок о бок. Последовательные разделы половинной ширины (Л/П) будут отображаться в два столбца.",
@@ -1783,6 +1792,14 @@
     "galleryTitle": "Начните с шаблона",
     "galleryHint": "Выберите отправную точку. В конструкторе можно изменить всё.",
     "yourDesigns": "Ваши макеты",
+    "autoRuleHint": "Продажи без автомобиля печатаются с отмеченным для них дизайном, если продажа или её клиент не выбрали другой.",
+    "useForPartsSale": "Использовать для продаж только запчастей",
+    "usedForPartsSale": "Используется для продаж только запчастей",
+    "autoRule": {
+      "parts_sale": "Продажа только запчастей"
+    },
+    "autoRuleSaved": "Обновлено, когда используется «{name}»",
+    "couldNotSaveRule": "Не удалось обновить правило",
     "templates": "Шаблоны",
     "inUse": "Используется",
     "savedOn": "Сохранено {date}",
@@ -1968,7 +1985,12 @@
     "leaveTitle": "Выйти без сохранения?",
     "leaveBody": "Изменения в этом оформлении не сохранены и будут потеряны.",
     "leaveConfirm": "Выйти",
-    "boldField": "Жирный"
+    "boldField": "Жирный",
+    "deleteDesignDrafts": "{count, plural, one {# черновик счёта} few {# черновика счетов} many {# черновиков счетов} other {# черновика счетов}} с этим дизайном вернутся к дизайну по умолчанию.",
+    "deleteDesignCustomers": "{count, plural, one {# клиент} few {# клиента} many {# клиентов} other {# клиента}} с этим дизайном вернутся к дизайну по умолчанию.",
+    "setDefault": "Сделать основным",
+    "defaultSet": "«{name}» теперь используется",
+    "couldNotSetDefault": "Не удалось установить дизайн по умолчанию"
   },
   "preview": {
     "title": "Обслуживание тормозов и замена масла",

+ 12 - 0
messages/tr/audit.json

@@ -156,6 +156,12 @@
     "import_undo": "İçe Aktarma Geri Alındı",
     "integration_connect": "Entegrasyon bağlandı",
     "integration_disconnect": "Entegrasyon bağlantısı kesildi",
+    "service_setInvoiceDesign": "Fatura tasarımı değiştirildi",
+    "settings_saveDocumentDesign": "Belge tasarımı kaydedildi",
+    "settings_deleteDocumentDesign": "Belge tasarımı silindi",
+    "settings_applyDocumentDesign": "Varsayılan belge tasarımı ayarlandı",
+    "settings_setDocumentDesignRule": "Bir tasarımın ne zaman kullanılacağı değiştirildi",
+    "settings_freezeInvoices": "Eski faturalar kilitlendi",
     "inspection_reminders_sent": "Muayene hatırlatmaları gönderildi",
     "inspection_reminders_send": "Muayene hatırlatmaları gönderildi"
   },
@@ -286,6 +292,12 @@
     "import_undo": "Bir hesap tablosu içe aktarması geri alındı: {records} kayıt kaldırıldı",
     "integration_connect": "{name} bağlandı",
     "integration_disconnect": "{name} bağlantısı kesildi",
+    "service_setInvoiceDesign": "Bir faturanın hangi tasarımla yazdırılacağını değiştirdi",
+    "settings_saveDocumentDesign": "{name} belge tasarımını kaydetti",
+    "settings_deleteDocumentDesign": "{name} belge tasarımını sildi",
+    "settings_applyDocumentDesign": "{name} tasarımını varsayılan belge tasarımı yaptı",
+    "settings_setDocumentDesignRule": "{name} belge tasarımının ne zaman kullanılacağı değiştirildi",
+    "settings_freezeInvoices": "Kilitlemeden önceki {count, plural, one {# faturayı} other {# faturayı}} mevcut tasarımla kilitledi",
     "inspection_reminders_sent": "{count} muayene hatırlatması gönderildi"
   }
 }

+ 5 - 0
messages/tr/customers.json

@@ -33,6 +33,8 @@
     "phoneCopied": "Telefon numarası panoya kopyalandı",
     "taxIdLabel": "Vergi no",
     "taxExemptBadge": "Vergiden muaf",
+    "invoiceDesignBadge": "Fatura tasarımı: {name}",
+    "invoiceDesignBadgeHint": "Bu müşterinin faturaları atölye varsayılanı yerine bu tasarımla yazdırılır. Tasarımcıda açın.",
     "tabs": {
       "vehicles": "Araçlar ({count})",
       "invoices": "Faturalar ({count})",
@@ -93,6 +95,9 @@
     "sectionContact": "İletişim",
     "sectionBilling": "Faturalama",
     "addScannedVehicle": "{vehicle} aracını ekle",
+    "invoiceDesign": "Fatura tasarımı",
+    "invoiceDesignDefault": "Atölye varsayılanı",
+    "invoiceDesignHint": "Bu müşterinin faturaları atölye varsayılanı yerine bu tasarımla yazdırılır. Bir fatura yine de kendi tasarımını seçebilir.",
     "reminderOptOut": "Hatırlatma yok",
     "reminderOptOutHint": "Bu müşteriyi muayene hatırlatmalarına veya diğer kampanyalara dahil etme."
   },

+ 11 - 1
messages/tr/service.json

@@ -79,7 +79,17 @@
       "typePrompt": "Adını yazın...",
       "boardTechnicians": "Pano Teknisyenleri",
       "teamMembers": "Takım Üyeleri"
-    }
+    },
+    "design": "Fatura tasarımı",
+    "designDefault": "Atölye varsayılanı",
+    "designFollowing": "Varsayılan ({name})",
+    "designPinned": "Sayfa, fatura {date} tarihinde düzenlendiğinde donduruldu. Yeni seçim yalnızca bir sahip faturanın kilidini açıp yeniden gönderildikten sonra uygulanır.",
+    "designSaved": "Fatura tasarımı güncellendi",
+    "designFollowingRule": "Varsayılan ({name}, {rule})",
+    "designRule": {
+      "parts_sale": "Yalnızca parça satışı"
+    },
+    "designSaveFailed": "Fatura tasarımı değiştirilemedi"
   },
   "parts": {
     "title": "Parçalar",

+ 24 - 2
messages/tr/settings.json

@@ -580,7 +580,16 @@
     "quoteLockTriggerSent": "Teklif gönderildiğinde",
     "quoteLockTriggerAccepted": "Teklif kabul edildiğinde",
     "quoteLockTriggerSentHint": "Teklif müşteriye ulaşır ulaşmaz kilitlenir. Yalnızca pazarlık sırasında teklifi hiç revize etmiyorsanız seçin.",
-    "quoteLockTriggerAcceptedHint": "Müşteri teklifi kabul ettiğinde veya teklif bir işe dönüştürüldüğünde kilitlenir. Gönderilmiş teklifler düzenlenebilir kalır."
+    "quoteLockTriggerAcceptedHint": "Müşteri teklifi kabul ettiğinde veya teklif bir işe dönüştürüldüğünde kilitlenir. Gönderilmiş teklifler düzenlenebilir kalır.",
+    "freezeTitle": "Kilitlemeden önceki faturalar",
+    "freezeBody": "{count, plural, one {# fatura} other {# fatura}}, bu sürüm bir faturanın yazdırdığını kilitleyemeden önce bir müşteriye ulaştı. Hâlâ mevcut bilgileriniz, logonuz ve tasarımınızla yazdırılıyorlar; bugün yaptığınız bir değişiklik onlara da ulaşır. Kilitlemek her birini şu anda yazdırıldığı gibi korur.",
+    "freezeNone": "Müşteriye ulaşan her fatura gönderildiği haliyle kilitlenmiş.",
+    "freezeButton": "Mevcut tasarımla kilitle",
+    "freezeConfirmTitle": "{count, plural, one {# fatura} other {# fatura}} kilitlensin mi?",
+    "freezeConfirmBody": "Her biri şu anda yazdırıldığı tasarımı, logoyu, atölye bilgilerini, müşteriyi, aracı ve bulguları korur. Geri alınamaz; faturalar bugünkü gibi düzenlenebilir ya da düzenlemeye kilitli kalır.",
+    "freezeProgress": "{total} faturadan {done} tanesi kilitlendi",
+    "freezeDone": "{count, plural, one {# fatura} other {# fatura}} kilitlendi",
+    "freezeFailed": "Faturalar kilitlenemedi"
   },
   "layoutEditor": {
     "helpText": "Sıralamak için sürükleyin. Bölümleri yan yana yerleştirmek için genişlik değiştiriciyi kullanın. Ardışık yarım genişlik bölümleri (S/S) iki sütun halinde görüntülenir.",
@@ -1783,6 +1792,14 @@
     "galleryTitle": "Bir şablonla başlayın",
     "galleryHint": "Bir başlangıç noktası seçin. Tasarımcıda her şeyi değiştirebilirsiniz.",
     "yourDesigns": "Tasarımlarınız",
+    "autoRuleHint": "Araçsız satışlar, satış veya müşterisi başka birini seçmedikçe, kendileri için işaretlenen tasarımla yazdırılır.",
+    "useForPartsSale": "Yalnızca parça satışları için kullan",
+    "usedForPartsSale": "Yalnızca parça satışları için kullanılıyor",
+    "autoRule": {
+      "parts_sale": "Yalnızca parça satışı"
+    },
+    "autoRuleSaved": "\"{name}\" kullanımı güncellendi",
+    "couldNotSaveRule": "Kural güncellenemedi",
     "templates": "Şablonlar",
     "inUse": "Kullanımda",
     "savedOn": "{date} tarihinde kaydedildi",
@@ -1968,7 +1985,12 @@
     "leaveTitle": "Kaydetmeden çıkılsın mı?",
     "leaveBody": "Bu tasarımdaki değişiklikler kaydedilmedi ve kaybolacak.",
     "leaveConfirm": "Çık",
-    "boldField": "Kalın"
+    "boldField": "Kalın",
+    "deleteDesignDrafts": "Bunu kullanan {count, plural, one {# taslak fatura} other {# taslak fatura}} varsayılan tasarıma döner.",
+    "deleteDesignCustomers": "Buna ayarlı {count, plural, one {# müşteri} other {# müşteri}} varsayılan tasarıma döner.",
+    "setDefault": "Varsayılan yap",
+    "defaultSet": "\"{name}\" artık kullanımda",
+    "couldNotSetDefault": "Varsayılan tasarım ayarlanamadı"
   },
   "preview": {
     "title": "Fren bakımı ve yağ değişimi",

+ 169 - 0
prisma/migrations/20260903120000_document_designs_and_issued_invoices/migration.sql

@@ -0,0 +1,169 @@
+-- Named designs become rows, and an issued invoice keeps what it was issued
+-- with. Prisma does not wrap a migration in a transaction; this one moves
+-- data, so it wraps itself.
+BEGIN;
+
+-- CreateTable
+CREATE TABLE "document_designs" (
+    "id" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "documentType" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "layout" JSONB NOT NULL,
+    "template" JSONB NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "document_designs_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "document_design_snapshots" (
+    "id" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "hash" TEXT NOT NULL,
+    "layout" JSONB NOT NULL,
+    "template" JSONB NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "document_design_snapshots_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "document_asset_snapshots" (
+    "id" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "hash" TEXT NOT NULL,
+    "mimeType" TEXT NOT NULL,
+    "data" BYTEA NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "document_asset_snapshots_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "document_designs_organizationId_documentType_idx" ON "document_designs"("organizationId", "documentType");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "document_design_snapshots_organizationId_hash_key" ON "document_design_snapshots"("organizationId", "hash");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "document_asset_snapshots_organizationId_hash_key" ON "document_asset_snapshots"("organizationId", "hash");
+
+-- AddForeignKey
+ALTER TABLE "document_designs" ADD CONSTRAINT "document_designs_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "document_design_snapshots" ADD CONSTRAINT "document_design_snapshots_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "document_asset_snapshots" ADD CONSTRAINT "document_asset_snapshots_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN "designId" TEXT,
+ADD COLUMN "issuedAt" TIMESTAMP(3),
+ADD COLUMN "issuedDesignSnapshotId" TEXT,
+ADD COLUMN "issuedLogoSnapshotId" TEXT,
+ADD COLUMN "issuedData" JSONB;
+
+-- AlterTable
+ALTER TABLE "customers" ADD COLUMN "invoiceDesignId" TEXT;
+
+-- CreateIndex
+CREATE INDEX "service_records_designId_idx" ON "service_records"("designId");
+
+-- CreateIndex
+CREATE INDEX "service_records_issuedDesignSnapshotId_idx" ON "service_records"("issuedDesignSnapshotId");
+
+-- CreateIndex
+CREATE INDEX "service_records_issuedLogoSnapshotId_idx" ON "service_records"("issuedLogoSnapshotId");
+
+-- CreateIndex
+CREATE INDEX "customers_invoiceDesignId_idx" ON "customers"("invoiceDesignId");
+
+-- AddForeignKey
+ALTER TABLE "service_records" ADD CONSTRAINT "service_records_designId_fkey" FOREIGN KEY ("designId") REFERENCES "document_designs"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "service_records" ADD CONSTRAINT "service_records_issuedDesignSnapshotId_fkey" FOREIGN KEY ("issuedDesignSnapshotId") REFERENCES "document_design_snapshots"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "service_records" ADD CONSTRAINT "service_records_issuedLogoSnapshotId_fkey" FOREIGN KEY ("issuedLogoSnapshotId") REFERENCES "document_asset_snapshots"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "customers" ADD CONSTRAINT "customers_invoiceDesignId_fkey" FOREIGN KEY ("invoiceDesignId") REFERENCES "document_designs"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- Data: the designs a workshop saved in the designer, out of the settings
+-- blob and into rows. Ids are kept, so "invoice.activeDesign" pointers of
+-- the form design:<id> keep meaning the same design.
+--
+-- Row by row in PL/pgSQL rather than one INSERT ... SELECT, because this has
+-- to land on every organization there is: most have no blob at all, some
+-- have an empty list, and a blob written by an older build could hold
+-- anything. A value that is not JSON, not an array, or an entry without an
+-- id, a name or a layout object is skipped, never fatal. Designs were saved
+-- for whichever document was on the canvas and the blob did not record
+-- which; every entry becomes an invoice design, which is what the gallery
+-- has always shown them as.
+DO $$
+DECLARE
+  setting RECORD;
+  entry RECORD;
+  designs jsonb;
+  saved_at timestamptz;
+  layout jsonb;
+  template jsonb;
+BEGIN
+  FOR setting IN
+    SELECT "organizationId", "value"
+    FROM "app_settings"
+    WHERE "key" = 'designer.savedDesigns' AND "organizationId" IS NOT NULL
+  LOOP
+    BEGIN
+      designs := setting."value"::jsonb;
+    EXCEPTION WHEN OTHERS THEN
+      CONTINUE;
+    END;
+    IF designs IS NULL OR jsonb_typeof(designs) <> 'array' THEN
+      CONTINUE;
+    END IF;
+
+    FOR entry IN SELECT value FROM jsonb_array_elements(designs) LOOP
+      IF jsonb_typeof(entry.value) <> 'object' THEN CONTINUE; END IF;
+      IF COALESCE(entry.value->>'id', '') = '' OR COALESCE(entry.value->>'name', '') = '' THEN
+        CONTINUE;
+      END IF;
+      layout := entry.value->'layout';
+      IF layout IS NULL OR jsonb_typeof(layout) <> 'object' THEN CONTINUE; END IF;
+      template := entry.value->'template';
+      IF template IS NULL OR jsonb_typeof(template) <> 'object' THEN
+        template := '{}'::jsonb;
+      END IF;
+      BEGIN
+        saved_at := NULLIF(entry.value->>'savedAt', '')::timestamptz;
+      EXCEPTION WHEN OTHERS THEN
+        saved_at := NULL;
+      END;
+
+      INSERT INTO "document_designs"
+        ("id", "organizationId", "documentType", "name", "layout", "template", "createdAt", "updatedAt")
+      VALUES (
+        entry.value->>'id',
+        setting."organizationId",
+        'invoice',
+        left(entry.value->>'name', 60),
+        layout,
+        template,
+        COALESCE(saved_at, CURRENT_TIMESTAMP),
+        COALESCE(saved_at, CURRENT_TIMESTAMP)
+      )
+      ON CONFLICT ("id") DO NOTHING;
+    END LOOP;
+  END LOOP;
+END $$;
+
+-- The blob is not read any more; leaving it would let an old build write
+-- over designs the new one has since edited.
+DELETE FROM "app_settings" WHERE "key" = 'designer.savedDesigns';
+
+COMMIT;

+ 10 - 0
prisma/migrations/20260903150000_document_design_auto_rules/migration.sql

@@ -0,0 +1,10 @@
+-- A design can volunteer itself for parts-only sales, the jobs made without
+-- a vehicle. Nullable, so every existing design
+-- keeps printing only where it was chosen. One design per rule per workshop
+-- and document; Postgres treats nulls as distinct, so designs without a rule
+-- do not collide.
+
+ALTER TABLE "document_designs" ADD COLUMN "autoRule" TEXT;
+
+CREATE UNIQUE INDEX "document_designs_organizationId_documentType_autoRule_key"
+  ON "document_designs"("organizationId", "documentType", "autoRule");

+ 8 - 0
prisma/schema/customers.prisma

@@ -38,12 +38,20 @@ model Customer {
   reminders      Reminder[]
   tireSets       TireSet[]
 
+  /// The invoice design this customer's invoices use in place of the
+  /// workshop's default: a fleet account that wants its own paper. An
+  /// invoice's own choice wins over it. SetNull: a deleted design falls
+  /// back to the default.
+  invoiceDesignId String?
+  invoiceDesign   DocumentDesign? @relation(fields: [invoiceDesignId], references: [id], onDelete: SetNull)
+
   importBatchId String?
   importBatch   ImportBatch? @relation(fields: [importBatchId], references: [id], onDelete: SetNull)
 
   @@unique([organizationId, customerNumber])
   @@index([organizationId])
   @@index([importBatchId])
+  @@index([invoiceDesignId])
   @@map("customers")
 }
 

+ 76 - 0
prisma/schema/document-designs.prisma

@@ -0,0 +1,76 @@
+/// A look for a document, saved under a name: the arrangement the designer
+/// edits and the colours and type around it. A workshop keeps several, one
+/// of them in use by default, and points an invoice or a customer at another
+/// when the default is wrong for them: a business that becomes GST registered
+/// keeps "Invoice" for its old paper and prints "Tax Invoice" from now on.
+///
+/// Rows used to live as one JSON list in a settings row. They are rows now
+/// because invoices and customers reference them, and a reference into a
+/// blob cannot be kept honest by the database.
+model DocumentDesign {
+  id             String       @id @default(cuid())
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  /// invoice | quote
+  documentType   String
+  name           String
+  /// InvoiceLayoutConfig as saved, before defaults are merged in.
+  layout         Json
+  /// DesignerTemplate: the colours, type, header style and logo.
+  template       Json
+  /// Picks this design by itself for one kind of job, when neither the
+  /// invoice nor its customer chose one. Today only parts_sale, a job with
+  /// no vehicle; vehicle jobs are what the default already covers. Null
+  /// means only when chosen. One design per rule, which the unique index
+  /// below keeps honest; see designRules.ts.
+  autoRule       String?
+  createdAt      DateTime     @default(now())
+  updatedAt      DateTime     @updatedAt
+
+  serviceRecords ServiceRecord[]
+  customers      Customer[]
+
+  @@unique([organizationId, documentType, autoRule])
+  @@index([organizationId, documentType])
+  @@map("document_designs")
+}
+
+/// A design exactly as it was when a document was issued, kept once per
+/// distinct content and shared by every document issued with it. An issued
+/// invoice reads its look from here and never from the live design, so
+/// editing or deleting a design cannot change paper a customer already holds.
+/// The hash is over the canonical JSON of layout and template, so the same
+/// design issued a thousand times costs one row.
+model DocumentDesignSnapshot {
+  id             String       @id @default(cuid())
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  hash           String
+  layout         Json
+  template       Json
+  createdAt      DateTime     @default(now())
+
+  serviceRecords ServiceRecord[]
+
+  @@unique([organizationId, hash])
+  @@map("document_design_snapshots")
+}
+
+/// A file as it was when a document was issued. Today that is the logo: the
+/// upload route deletes the previous file when a new one arrives, so a path
+/// alone would leave old invoices printing the new mark, or none. Keyed by
+/// content hash for the same reason as the design snapshots.
+model DocumentAssetSnapshot {
+  id             String       @id @default(cuid())
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  hash           String
+  mimeType       String
+  data           Bytes
+  createdAt      DateTime     @default(now())
+
+  serviceRecords ServiceRecord[]
+
+  @@unique([organizationId, hash])
+  @@map("document_asset_snapshots")
+}

+ 3 - 0
prisma/schema/organization.prisma

@@ -79,6 +79,9 @@ model Organization {
   vehicleInspectionStatuses   VehicleInspectionStatus[]
   inspectionReminderCampaigns InspectionReminderCampaign[]
   inspectionReminderSends     InspectionReminderSend[]
+  documentDesigns             DocumentDesign[]
+  documentDesignSnapshots     DocumentDesignSnapshot[]
+  documentAssetSnapshots      DocumentAssetSnapshot[]
 
   @@map("organizations")
 }

+ 29 - 0
prisma/schema/service-records.prisma

@@ -46,6 +46,32 @@ model ServiceRecord {
   /// the link is revoked; sending is not undone by withdrawing the link.
   sentAt DateTime?
 
+  /// The design this invoice prints with while it is still being written.
+  /// Null follows the customer's design, then the workshop's default. SetNull:
+  /// a deleted design sends its drafts back to the default rather than
+  /// taking them with it.
+  designId String?
+  design   DocumentDesign? @relation(fields: [designId], references: [id], onDelete: SetNull)
+
+  /// When the invoice became the customer's document: first send, share or
+  /// payment. From then on the sheet prints from the three snapshots below,
+  /// never from live settings, so a changed address, logo, payment terms or
+  /// title cannot rewrite paper already issued, and a preview and a re-send
+  /// always show the same sheet. Only an owner's unlock followed by a send
+  /// captures a fresh copy: the corrected one.
+  issuedAt               DateTime?
+  /// The look at issue. Restrict: a snapshot referenced by an invoice is a
+  /// record and cannot be removed.
+  issuedDesignSnapshotId String?
+  issuedDesignSnapshot   DocumentDesignSnapshot? @relation(fields: [issuedDesignSnapshotId], references: [id], onDelete: Restrict)
+  issuedLogoSnapshotId   String?
+  issuedLogoSnapshot     DocumentAssetSnapshot?  @relation(fields: [issuedLogoSnapshotId], references: [id], onDelete: Restrict)
+  /// Everything else the sheet printed that the record does not own: the
+  /// workshop's details and payment settings, the customer, the vehicle, the
+  /// technician's name, the findings and the custom fields. Shape in
+  /// issuedInvoice.ts; read leniently so old snapshots render on new code.
+  issuedData             Json?
+
   /// Set when an owner or admin deliberately reopens a locked document.
   /// Nullable rather than a boolean so the audit trail can show when, and the
   /// screen can say the document was reopened rather than never locked.
@@ -111,6 +137,9 @@ model ServiceRecord {
   @@index([vehicleId])
   @@index([technicianId])
   @@index([workBayId])
+  @@index([designId])
+  @@index([issuedDesignSnapshotId])
+  @@index([issuedLogoSnapshotId])
   @@index([organizationId])
   @@index([customerId])
   @@index([inspectionId])

+ 43 - 0
src/__tests__/features/invoice-designer/design-rules.test.ts

@@ -0,0 +1,43 @@
+/**
+ * When a design volunteers itself for an invoice. The rule reads only
+ * whether a vehicle is attached, which is the same split the job picker
+ * makes between a vehicle and a parts-only sale.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+  designRuleMatches,
+  isDesignAutoRule,
+  pickDesignByRule,
+} from '@/features/invoice-designer/Lib/designRules'
+
+const partsSale = { hasVehicle: false }
+const vehicleJob = { hasVehicle: true }
+
+describe('designRuleMatches', () => {
+  it('claims a sale without a vehicle and nothing else', () => {
+    expect(designRuleMatches('parts_sale', partsSale)).toBe(true)
+    expect(designRuleMatches('parts_sale', vehicleJob)).toBe(false)
+  })
+})
+
+describe('pickDesignByRule', () => {
+  const sale = { id: 'sale', autoRule: 'parts_sale' }
+  const plain = { id: 'plain', autoRule: null }
+
+  it('picks the sale design for a sale and leaves a vehicle job to the default', () => {
+    expect(pickDesignByRule([plain, sale], partsSale)).toBe(sale)
+    expect(pickDesignByRule([plain, sale], vehicleJob)).toBeNull()
+  })
+
+  it('never picks a design without a rule, or with one it does not know', () => {
+    expect(pickDesignByRule([plain, { id: 'odd', autoRule: 'weekends' }], vehicleJob)).toBeNull()
+  })
+})
+
+describe('isDesignAutoRule', () => {
+  it('accepts the known rules and nothing else', () => {
+    expect(isDesignAutoRule('parts_sale')).toBe(true)
+    expect(isDesignAutoRule('parts_only')).toBe(false)
+    expect(isDesignAutoRule(null)).toBe(false)
+  })
+})

+ 121 - 0
src/__tests__/features/invoices/issued-invoice-rules.test.ts

@@ -0,0 +1,121 @@
+/**
+ * When an invoice prints from what it was issued with, and when issuing it
+ * again captures a fresh copy. These two rules are the whole promise that a
+ * changed address, logo or title cannot rewrite paper a customer holds, so
+ * they are pinned down case by case.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+  readIssuedInvoiceData,
+  rendersFromIssue,
+  shouldIssue,
+  wasIssuedBeforeTracking,
+} from '@/features/invoices/Lib/issuedInvoice'
+
+const t0 = new Date('2026-09-01T10:00:00Z')
+const t1 = new Date('2026-09-02T10:00:00Z')
+
+describe('rendersFromIssue', () => {
+  it('prints a draft from live rows', () => {
+    expect(rendersFromIssue({ issuedAt: null, issuedData: null })).toBe(false)
+  })
+
+  it('prints an issued invoice from its snapshot', () => {
+    expect(rendersFromIssue({ issuedAt: t0, issuedData: { version: 1 } })).toBe(true)
+  })
+
+  it('falls back to live rows when the snapshot is missing', () => {
+    expect(rendersFromIssue({ issuedAt: t0, issuedData: null })).toBe(false)
+  })
+
+  it('reopens the document while an unlock is newer than the issue', () => {
+    expect(rendersFromIssue({ issuedAt: t0, editUnlockedAt: t1, issuedData: { version: 1 } })).toBe(
+      false
+    )
+  })
+
+  it('keeps the snapshot once the issue is newer than the unlock', () => {
+    expect(rendersFromIssue({ issuedAt: t1, editUnlockedAt: t0, issuedData: { version: 1 } })).toBe(
+      true
+    )
+  })
+})
+
+describe('shouldIssue', () => {
+  it('captures a never-issued invoice for any reason', () => {
+    for (const reason of ['sent', 'paid', 'backfill'] as const) {
+      expect(shouldIssue({ issuedAt: null }, reason)).toBe(true)
+    }
+  })
+
+  it('keeps the snapshot when an issued invoice is sent again', () => {
+    // The design, the address or the terms may all have changed since. The
+    // customer holds the first copy, and the preview shows it, so the email
+    // must carry that same copy. This is the case that went wrong once.
+    expect(shouldIssue({ issuedAt: t0 }, 'sent')).toBe(false)
+    expect(shouldIssue({ issuedAt: t1, editUnlockedAt: t0 }, 'sent')).toBe(false)
+  })
+
+  it('re-captures on send after an owner unlocked the invoice', () => {
+    expect(shouldIssue({ issuedAt: t0, editUnlockedAt: t1 }, 'sent')).toBe(true)
+  })
+
+  it('never overwrites an issue on payment or backfill, even when unlocked', () => {
+    expect(shouldIssue({ issuedAt: t0, editUnlockedAt: t1 }, 'paid')).toBe(false)
+    expect(shouldIssue({ issuedAt: t0, editUnlockedAt: t1 }, 'backfill')).toBe(false)
+  })
+})
+
+describe('wasIssuedBeforeTracking', () => {
+  it('is true for an invoice that went out before issuing existed', () => {
+    expect(
+      wasIssuedBeforeTracking({ issuedAt: null, sentAt: t0, manuallyPaid: false, payments: [] })
+    ).toBe(true)
+    expect(
+      wasIssuedBeforeTracking({ issuedAt: null, sentAt: null, manuallyPaid: true, payments: [] })
+    ).toBe(true)
+    expect(
+      wasIssuedBeforeTracking({
+        issuedAt: null,
+        sentAt: null,
+        manuallyPaid: false,
+        payments: [{ amount: 10 }],
+      })
+    ).toBe(true)
+  })
+
+  it('is false for a draft and for anything already issued', () => {
+    expect(
+      wasIssuedBeforeTracking({ issuedAt: null, sentAt: null, manuallyPaid: false, payments: [] })
+    ).toBe(false)
+    expect(
+      wasIssuedBeforeTracking({ issuedAt: t0, sentAt: t0, manuallyPaid: true, payments: [] })
+    ).toBe(false)
+  })
+})
+
+describe('readIssuedInvoiceData', () => {
+  it('reads a snapshot with fields this version never heard of', () => {
+    const data = readIssuedInvoiceData({
+      version: 7,
+      workshop: { name: 'Shop', address: 'Road 1', phone: '', email: '', futureField: 'x' },
+      invoiceSettings: { paymentTerms: 'Net 14', somethingNew: true },
+      customer: { name: 'Ann', loyaltyTier: 'gold' },
+    })
+    expect(data?.workshop.name).toBe('Shop')
+    expect(data?.invoiceSettings.paymentTerms).toBe('Net 14')
+    expect(data?.customer?.name).toBe('Ann')
+  })
+
+  it('fills the blocks every renderer reads when a snapshot lacks them', () => {
+    const data = readIssuedInvoiceData({ version: 1 })
+    expect(data?.workshop).toEqual({ name: '', address: '', phone: '', email: '' })
+    expect(data?.invoiceSettings).toEqual({})
+  })
+
+  it('refuses what is not a snapshot at all', () => {
+    expect(readIssuedInvoiceData(null)).toBeNull()
+    expect(readIssuedInvoiceData('nope')).toBeNull()
+    expect(readIssuedInvoiceData({ workshop: {} })).toBeNull()
+  })
+})

+ 107 - 0
src/__tests__/features/settings/design-source.test.ts

@@ -0,0 +1,107 @@
+/**
+ * A design is one shape wherever it came from: a saved row, the workshop's
+ * settings, or a snapshot. The hash over that shape is what lets a thousand
+ * invoices issued with the same look share one stored copy.
+ */
+import { describe, expect, it } from 'vitest'
+import { canonicalJson, contentHash } from '@/features/invoice-designer/Lib/designHash'
+import {
+  designSourceFromSettings,
+  designSourceFromStored,
+  materializeDesignSource,
+  savedDesignFromRow,
+  templateConfigFromSource,
+} from '@/features/invoice-designer/Lib/designSource'
+
+describe('canonicalJson', () => {
+  it('orders keys at every depth and drops undefined', () => {
+    const a = canonicalJson({ b: 1, a: { d: undefined, c: [{ z: 1, y: 2 }] } })
+    const b = canonicalJson({ a: { c: [{ y: 2, z: 1 }] }, b: 1 })
+    expect(a).toBe(b)
+    expect(a).toBe('{"a":{"c":[{"y":2,"z":1}]},"b":1}')
+  })
+
+  it('hashes the same design the same however its keys were written', () => {
+    const one = contentHash({ template: { primaryColor: '#000', fontFamily: 'Lato' } })
+    const two = contentHash({ template: { fontFamily: 'Lato', primaryColor: '#000' } })
+    expect(one).toBe(two)
+    expect(one).toMatch(/^[0-9a-f]{64}$/)
+  })
+})
+
+describe('designSourceFromSettings', () => {
+  it('reads the keys every renderer read, with the legacy fallbacks', () => {
+    const source = designSourceFromSettings(
+      {
+        'invoice.template.primaryColor': '#123456',
+        'invoice.frameSide': 'right',
+        'invoice.logo': '/uploads/x.png',
+        'invoice.showLogo': 'false',
+        'invoice.layoutConfig': '{"version":3,"sections":[]}',
+      },
+      'invoice'
+    )
+    expect(source.template.primaryColor).toBe('#123456')
+    expect(source.template.frameSide).toBe('right')
+    expect(source.template.logoUrl).toBe('/uploads/x.png')
+    expect(source.template.showLogo).toBe(false)
+    expect(source.template.showCompanyName).toBe(true)
+    expect(source.layout.version).toBe(3)
+  })
+
+  it('shrugs off a layout that is not JSON', () => {
+    const source = designSourceFromSettings({ 'invoice.layoutConfig': '{oops' }, 'invoice')
+    expect(source.layout).toEqual({})
+  })
+})
+
+describe('designSourceFromStored', () => {
+  it('fills a sparse template with the defaults the designer would', () => {
+    const source = designSourceFromStored({}, { primaryColor: '#abc', extra: 'kept' })
+    expect(source?.template.primaryColor).toBe('#abc')
+    expect(source?.template.fontFamily).toBe('Helvetica')
+    expect((source?.template as unknown as Record<string, unknown>).extra).toBe('kept')
+  })
+
+  it('refuses a template that is not an object', () => {
+    expect(designSourceFromStored({}, 'nope')).toBeNull()
+  })
+})
+
+describe('templateConfigFromSource', () => {
+  it('turns blank colours into undefined and merges the layout', () => {
+    const source = designSourceFromStored({}, { backgroundColor: '', textColor: '#111' })
+    const config = templateConfigFromSource(source!)
+    expect(config.backgroundColor).toBeUndefined()
+    expect(config.textColor).toBe('#111')
+    expect(config.layoutConfig?.sections.length).toBeGreaterThan(0)
+  })
+})
+
+describe('materializeDesignSource', () => {
+  it('writes every default out so a later default cannot reach in', () => {
+    const frozen = materializeDesignSource({
+      layout: { sections: [] },
+      template: designSourceFromStored({}, {})!.template,
+    })
+    expect(frozen.layout.sections?.length).toBeGreaterThan(0)
+    // Materializing again changes nothing, so the hash is stable.
+    expect(contentHash(materializeDesignSource(frozen))).toBe(contentHash(frozen))
+  })
+})
+
+describe('savedDesignFromRow', () => {
+  it('maps a row to what the gallery shows', () => {
+    const saved = savedDesignFromRow({
+      id: 'd1',
+      name: 'Tax Invoice',
+      updatedAt: new Date('2026-09-03T00:00:00Z'),
+      layout: { sections: [] },
+      template: { primaryColor: '#000' },
+    })
+    expect(saved?.id).toBe('d1')
+    expect(saved?.savedAt).toBe('2026-09-03T00:00:00.000Z')
+    expect(saved?.template.primaryColor).toBe('#000')
+    expect(saved?.layout.sections.length).toBeGreaterThan(0)
+  })
+})

+ 5 - 0
src/__tests__/features/workorders/document-lock-enforcement.test.ts

@@ -25,6 +25,11 @@ vi.mock('@/lib/notification-bus', () => ({
   notificationBus: { publish: vi.fn(), emit: vi.fn() },
 }))
 vi.mock('@/lib/audit', () => ({ logAudit: vi.fn() }))
+// Sending and paying freeze what the invoice prints. That capture reads the
+// whole record and the workshop's files; here only the lock is under test.
+vi.mock('@/features/invoices/Lib/issueInvoice', () => ({
+  issueInvoice: vi.fn().mockResolvedValue(false),
+}))
 
 vi.mock('@/lib/db', () => ({
   db: {

+ 20 - 1
src/app/(authenticated)/customers/[id]/customer-detail-client.tsx

@@ -55,6 +55,7 @@ import {
   Users,
   Wrench,
   X,
+  Palette,
 } from 'lucide-react'
 import { Badge } from '@/components/ui/badge'
 import { Textarea } from '@/components/ui/textarea'
@@ -117,6 +118,9 @@ interface CustomerDetail {
   taxId: string | null
   taxExempt: boolean
   notes: string | null
+  invoiceDesignId?: string | null
+  /** The design the customer's invoices print with, when one was chosen. */
+  invoiceDesign?: { id: string; name: string } | null
   vehicles: {
     id: string
     make: string
@@ -284,7 +288,8 @@ export function CustomerDetailClient({
     customer.address ||
     customer.company ||
     customer.taxId ||
-    customer.notes
+    customer.notes ||
+    customer.invoiceDesign
 
   return (
     <div className="space-y-6">
@@ -362,6 +367,20 @@ export function CustomerDetailClient({
                     {t('taxExemptBadge')}
                   </span>
                 )}
+                {/* A customer on another design is easy to forget about when
+                    the workshop redesigns its paper, so it is said up front. */}
+                {customer.invoiceDesign && (
+                  <Link
+                    href={`/invoice-designer?doc=invoice&design=${customer.invoiceDesign.id}`}
+                    target="_blank"
+                    rel="noopener"
+                    title={t('invoiceDesignBadgeHint')}
+                    className="flex items-center gap-1.5 rounded-full border border-primary/40 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary hover:bg-primary/20"
+                  >
+                    <Palette className="h-3 w-3" />
+                    {t('invoiceDesignBadge', { name: customer.invoiceDesign.name })}
+                  </Link>
+                )}
               </div>
             )}
             {customer.notes && (

+ 70 - 0
src/app/(authenticated)/settings/invoice/invoice-settings.tsx

@@ -20,6 +20,7 @@ import { Textarea } from '@/components/ui/textarea'
 import { toast } from 'sonner'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { backfillCustomerNumbers } from '@/features/customers/Actions/customerActions'
+import { freezeUnfrozenInvoices } from '@/features/invoices/Actions/legacyInvoiceActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { FileText, Loader2, Lock, Save } from 'lucide-react'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
@@ -51,6 +52,8 @@ interface InvoiceSettingsProps {
   /** Fills the layout preview with this workshop's own letterhead. */
   workshop?: { name?: string; address?: string; phone?: string; email?: string; slogan?: string }
   unnumberedCustomers?: number
+  /** Invoices that reached a customer before issuing existed, still unfrozen. */
+  unfrozenInvoices?: number
   initialInvoiceLayout?: InvoiceLayoutConfig
   initialQuoteLayout?: InvoiceLayoutConfig
   customFields: FieldDef[]
@@ -62,6 +65,7 @@ export function InvoiceSettings({
   settings,
   workshop,
   unnumberedCustomers = 0,
+  unfrozenInvoices = 0,
   initialInvoiceLayout,
   initialQuoteLayout,
   customFields,
@@ -144,6 +148,43 @@ export function InvoiceSettings({
   const confirm = useConfirm()
   const [assigning, setAssigning] = useState(false)
   const [unnumbered, setUnnumbered] = useState(unnumberedCustomers)
+
+  // Invoices sent before this version could lock what they print. Locked in
+  // batches, one request each, so a workshop with thousands of them sees
+  // progress rather than a timeout.
+  const [unfrozen, setUnfrozen] = useState(unfrozenInvoices)
+  const [freezing, setFreezing] = useState<{ done: number; total: number } | null>(null)
+  const handleFreezeInvoices = async () => {
+    const total = unfrozen
+    const ok = await confirm({
+      title: t('invoice.freezeConfirmTitle', { count: total }),
+      description: t('invoice.freezeConfirmBody'),
+      confirmLabel: t('invoice.freezeButton'),
+    })
+    if (!ok) return
+    setFreezing({ done: 0, total })
+    let done = 0
+    try {
+      let remaining = total
+      while (remaining > 0) {
+        const result = await freezeUnfrozenInvoices()
+        if (!result.success || !result.data) throw new Error(result.success ? '' : result.error)
+        done += result.data.frozen
+        remaining = result.data.remaining
+        setFreezing({ done, total })
+        // A batch that locked nothing yet left some behind would loop forever.
+        if (result.data.frozen === 0) break
+      }
+      setUnfrozen(remaining)
+      toast.success(t('invoice.freezeDone', { count: done }))
+      router.refresh()
+    } catch {
+      setUnfrozen(Math.max(0, total - done))
+      toast.error(t('invoice.freezeFailed'))
+    } finally {
+      setFreezing(null)
+    }
+  }
   const handleAssignCustomerNumbers = async () => {
     const ok = await confirm({
       title: t('invoice.assignCustomerNumbersConfirmTitle'),
@@ -423,6 +464,35 @@ export function InvoiceSettings({
                   </p>
                 </div>
               </div>
+
+              <div className="rounded-md border p-3">
+                <h4 className="text-sm font-medium">{t('invoice.freezeTitle')}</h4>
+                <div className="mt-1.5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
+                  <p className="text-xs text-muted-foreground">
+                    {unfrozen > 0
+                      ? t('invoice.freezeBody', { count: unfrozen })
+                      : t('invoice.freezeNone')}
+                  </p>
+                  {unfrozen > 0 && (
+                    <Button
+                      type="button"
+                      variant="outline"
+                      size="sm"
+                      className="shrink-0"
+                      disabled={freezing !== null}
+                      onClick={handleFreezeInvoices}
+                    >
+                      {freezing && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
+                      {freezing
+                        ? t('invoice.freezeProgress', {
+                            done: freezing.done,
+                            total: freezing.total,
+                          })
+                        : t('invoice.freezeButton')}
+                    </Button>
+                  )}
+                </div>
+              </div>
             </div>
 
             <Separator />

+ 4 - 0
src/app/(authenticated)/settings/invoice/page.tsx

@@ -10,6 +10,7 @@ import {
 import { getFieldDefinitions } from '@/features/custom-fields/Actions/customFieldActions'
 import { redirect } from 'next/navigation'
 import { InvoiceSettings } from './invoice-settings'
+import { countUnfrozenInvoices } from '@/features/invoices/Actions/legacyInvoiceActions'
 
 export default async function InvoiceSettingsPage() {
   const data = await getLayoutData()
@@ -32,6 +33,8 @@ export default async function InvoiceSettingsPage() {
   })
   const customFields =
     customFieldsResult.success && customFieldsResult.data ? customFieldsResult.data : []
+  const unfrozenResult = await countUnfrozenInvoices()
+  const unfrozenInvoices = unfrozenResult.success ? (unfrozenResult.data ?? 0) : 0
 
   // Check if Telegram is enabled (plan feature + the switch on its integration)
   let telegramEnabled = false
@@ -59,6 +62,7 @@ export default async function InvoiceSettingsPage() {
       settings={settings}
       workshop={workshop}
       unnumberedCustomers={unnumberedCustomers}
+      unfrozenInvoices={unfrozenInvoices}
       initialInvoiceLayout={invoiceLayoutResult.success ? invoiceLayoutResult.data : undefined}
       initialQuoteLayout={quoteLayoutResult.success ? quoteLayoutResult.data : undefined}
       customFields={customFields}

+ 64 - 58
src/app/(authenticated)/settings/templates/page.tsx

@@ -13,6 +13,7 @@ import {
   getQuoteLayoutConfig,
 } from '@/features/settings/Actions/invoiceLayoutActions'
 import type { SavedDesign } from '@/features/invoice-designer/Components/types'
+import { listDocumentDesigns } from '@/features/invoice-designer/Actions/documentDesignActions'
 export default async function TemplatePage() {
   const data = await getLayoutData()
 
@@ -31,55 +32,63 @@ export default async function TemplatePage() {
     )
   }
 
-  const [result, inspectionTemplatesResult, invoiceLayoutResult, quoteLayoutResult, organization] =
-    await Promise.all([
-      getSettings([
-        SETTING_KEYS.INVOICE_PRIMARY_COLOR,
-        SETTING_KEYS.INVOICE_BACKGROUND_COLOR,
-        SETTING_KEYS.INVOICE_TEXT_COLOR,
-        SETTING_KEYS.INVOICE_COMPANY_TEXT_COLOR,
-        SETTING_KEYS.INVOICE_FRAME_BORDER_COLOR,
-        SETTING_KEYS.INVOICE_FRAME_SHADOW,
-        SETTING_KEYS.INVOICE_FONT_FAMILY,
-        SETTING_KEYS.INVOICE_HEADER_STYLE,
-        SETTING_KEYS.INVOICE_LOGO_SIZE,
-        SETTING_KEYS.QUOTE_PRIMARY_COLOR,
-        SETTING_KEYS.QUOTE_BACKGROUND_COLOR,
-        SETTING_KEYS.QUOTE_TEXT_COLOR,
-        SETTING_KEYS.QUOTE_COMPANY_TEXT_COLOR,
-        SETTING_KEYS.QUOTE_FRAME_BORDER_COLOR,
-        SETTING_KEYS.QUOTE_FRAME_SHADOW,
-        SETTING_KEYS.QUOTE_FONT_FAMILY,
-        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,
-        SETTING_KEYS.WORKSHOP_EMAIL,
-        SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY,
-        SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY,
-        SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
-        SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
-        SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
-        SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
-        SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED,
-        SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED,
-      ]),
-      getTemplates(),
-      getInvoiceLayoutConfig(),
-      getQuoteLayoutConfig(),
-      // Read straight off the organization, the way the real PDF does. Going
-      // through the membership list would miss anyone viewing a workshop they
-      // are not a member of.
-      db.organization.findUnique({
-        where: { id: data.organizationId },
-        select: { name: true },
-      }),
-    ])
+  const [
+    result,
+    inspectionTemplatesResult,
+    invoiceLayoutResult,
+    quoteLayoutResult,
+    organization,
+    invoiceDesigns,
+    quoteDesigns,
+  ] = await Promise.all([
+    getSettings([
+      SETTING_KEYS.INVOICE_PRIMARY_COLOR,
+      SETTING_KEYS.INVOICE_BACKGROUND_COLOR,
+      SETTING_KEYS.INVOICE_TEXT_COLOR,
+      SETTING_KEYS.INVOICE_COMPANY_TEXT_COLOR,
+      SETTING_KEYS.INVOICE_FRAME_BORDER_COLOR,
+      SETTING_KEYS.INVOICE_FRAME_SHADOW,
+      SETTING_KEYS.INVOICE_FONT_FAMILY,
+      SETTING_KEYS.INVOICE_HEADER_STYLE,
+      SETTING_KEYS.INVOICE_LOGO_SIZE,
+      SETTING_KEYS.QUOTE_PRIMARY_COLOR,
+      SETTING_KEYS.QUOTE_BACKGROUND_COLOR,
+      SETTING_KEYS.QUOTE_TEXT_COLOR,
+      SETTING_KEYS.QUOTE_COMPANY_TEXT_COLOR,
+      SETTING_KEYS.QUOTE_FRAME_BORDER_COLOR,
+      SETTING_KEYS.QUOTE_FRAME_SHADOW,
+      SETTING_KEYS.QUOTE_FONT_FAMILY,
+      SETTING_KEYS.QUOTE_HEADER_STYLE,
+      SETTING_KEYS.QUOTE_LOGO_SIZE,
+      SETTING_KEYS.COMPANY_LOGO,
+      SETTING_KEYS.INVOICE_ACTIVE_DESIGN,
+      SETTING_KEYS.QUOTE_ACTIVE_DESIGN,
+      SETTING_KEYS.WORKSHOP_ADDRESS,
+      SETTING_KEYS.WORKSHOP_SLOGAN,
+      SETTING_KEYS.WORKSHOP_PHONE,
+      SETTING_KEYS.WORKSHOP_EMAIL,
+      SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY,
+      SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY,
+      SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
+      SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
+      SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
+      SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
+      SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED,
+      SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED,
+    ]),
+    getTemplates(),
+    getInvoiceLayoutConfig(),
+    getQuoteLayoutConfig(),
+    // Read straight off the organization, the way the real PDF does. Going
+    // through the membership list would miss anyone viewing a workshop they
+    // are not a member of.
+    db.organization.findUnique({
+      where: { id: data.organizationId },
+      select: { name: true },
+    }),
+    listDocumentDesigns('invoice'),
+    listDocumentDesigns('quote'),
+  ])
 
   const settings = result.success && result.data ? result.data : {}
   const inspectionTemplates =
@@ -90,15 +99,12 @@ 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 = []
-  }
+  // Both documents' designs on both tabs, the way the designer's gallery
+  // shows them: a look saved for one is a starting point for the other.
+  const savedDesigns: SavedDesign[] = [
+    ...(invoiceDesigns.success && invoiceDesigns.data ? invoiceDesigns.data : []),
+    ...(quoteDesigns.success && quoteDesigns.data ? quoteDesigns.data : []),
+  ]
 
   // 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.

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

@@ -12,12 +12,16 @@ import { toast } from 'sonner'
 import { setSetting } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { layoutPresets } from '@/features/settings/Schema/layoutPresets'
-import { Loader2, Palette, MessageSquare, RotateCcw } from 'lucide-react'
+import { Check, 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 {
+  applyDocumentDesign,
+  setDocumentDesignRule,
+} from '@/features/invoice-designer/Actions/documentDesignActions'
 import { Textarea } from '@/components/ui/textarea'
 import { type InvoiceLayoutConfig } from '@/features/settings/Schema/invoiceLayoutSchema'
 
@@ -147,6 +151,45 @@ function TemplateTab({
 }) {
   const t = useTranslations('settings')
   const tDesigner = useTranslations('settings.designer')
+  const router = useRouter()
+  const [applying, setApplying] = useState<string | null>(null)
+  const [ruling, setRuling] = useState<string | null>(null)
+
+  // Only invoices are printed through the design resolution that reads
+  // rules; a quote design has nowhere to volunteer itself yet.
+  const rulesApply = documentType === 'invoice'
+  // One rule exists today, so the card offers it as a button that toggles,
+  // beside "Set as default", rather than a menu of one.
+  const togglePartsSale = async (design: SavedDesign) => {
+    setRuling(design.id)
+    try {
+      const next = design.autoRule === 'parts_sale' ? null : 'parts_sale'
+      const result = await setDocumentDesignRule(design.id, next)
+      if (!result.success) throw new Error(result.error)
+      toast.success(tDesigner('autoRuleSaved', { name: design.name }))
+      router.refresh()
+    } catch {
+      toast.error(tDesigner('couldNotSaveRule'))
+    } finally {
+      setRuling(null)
+    }
+  }
+
+  // Makes a saved design the one this document prints with, without a trip
+  // through the designer: the same writes its Save makes.
+  const setDefault = async (design: SavedDesign) => {
+    setApplying(design.id)
+    try {
+      const result = await applyDocumentDesign(design.id)
+      if (!result.success) throw new Error(result.error)
+      toast.success(tDesigner('defaultSet', { name: design.name }))
+      router.refresh()
+    } catch {
+      toast.error(tDesigner('couldNotSetDefault'))
+    } finally {
+      setApplying(null)
+    }
+  }
 
   return (
     <AppCard icon={Palette} title={t('templates.presets')} contentClassName="space-y-4">
@@ -157,17 +200,17 @@ function TemplateTab({
       {savedDesigns.length > 0 && (
         <div>
           <h3 className="mb-2 text-sm font-medium">{tDesigner('yourDesigns')}</h3>
+          {rulesApply && (
+            <p className="mb-3 text-xs text-muted-foreground">{tDesigner('autoRuleHint')}</p>
+          )}
           <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
+                <div
                   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',
+                    'relative flex flex-col rounded-lg border p-3 text-left transition-colors hover:bg-muted',
                     active && 'border-primary'
                   )}
                 >
@@ -176,16 +219,61 @@ function TemplateTab({
                       {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>
+                  {rulesApply && design.autoRule && (
+                    <span className="absolute left-2 top-2 z-10 rounded-full border border-primary/40 bg-background px-2 py-0.5 text-[10px] font-semibold text-primary">
+                      {tDesigner(`autoRule.${design.autoRule}`)}
+                    </span>
+                  )}
+                  <Link
+                    href={`/invoice-designer?doc=${documentType}&design=${design.id}`}
+                    target="_blank"
+                    rel="noopener"
+                    className="block"
+                  >
+                    <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>
+                  {!active && (
+                    <Button
+                      type="button"
+                      variant="outline"
+                      size="sm"
+                      className="mt-2 h-7 text-xs"
+                      disabled={applying === design.id}
+                      onClick={() => void setDefault(design)}
+                    >
+                      {applying === design.id && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
+                      {tDesigner('setDefault')}
+                    </Button>
+                  )}
+                  {rulesApply && (design.autoRule === 'parts_sale' || !active) && (
+                    <Button
+                      type="button"
+                      variant={design.autoRule === 'parts_sale' ? 'secondary' : 'outline'}
+                      size="sm"
+                      className="mt-2 h-7 text-xs"
+                      disabled={ruling === design.id}
+                      aria-pressed={design.autoRule === 'parts_sale'}
+                      onClick={() => void togglePartsSale(design)}
+                    >
+                      {ruling === design.id ? (
+                        <Loader2 className="mr-1 h-3 w-3 animate-spin" />
+                      ) : (
+                        design.autoRule === 'parts_sale' && <Check className="mr-1 h-3 w-3" />
+                      )}
+                      {design.autoRule === 'parts_sale'
+                        ? tDesigner('usedForPartsSale')
+                        : tDesigner('useForPartsSale')}
+                    </Button>
+                  )}
+                </div>
               )
             })}
           </div>

+ 48 - 39
src/app/(designer)/invoice-designer/page.tsx

@@ -9,6 +9,7 @@ import {
   getQuoteLayoutConfig,
 } from '@/features/settings/Actions/invoiceLayoutActions'
 import { getFieldDefinitions } from '@/features/custom-fields/Actions/customFieldActions'
+import { listDocumentDesigns } from '@/features/invoice-designer/Actions/documentDesignActions'
 import { InvoiceDesigner } from '@/features/invoice-designer/Components/InvoiceDesigner'
 import { DismissOnArrival } from '@/components/feature-hint'
 import { INVOICE_DESIGNER_ANNOUNCEMENT, parseHintIds } from '@/features/settings/Lib/featureHints'
@@ -28,26 +29,36 @@ export default async function InvoiceDesignerPage({
   // gate the settings page used applies here.
   if (!features.customTemplates) redirect('/settings/templates')
 
-  const [settingsResult, invoiceLayout, quoteLayout, organization, customFieldsResult, seenRow] =
-    await Promise.all([
-      getSettings(),
-      getInvoiceLayoutConfig(),
-      getQuoteLayoutConfig(),
-      db.organization.findUnique({
-        where: { id: data.organizationId },
-        select: { name: true },
-      }),
-      features.customFields ? getFieldDefinitions() : Promise.resolve({ success: true, data: [] }),
-      db.appSetting.findUnique({
-        where: {
-          organizationId_key: {
-            organizationId: data.organizationId,
-            key: SETTING_KEYS.FEATURE_HINTS_SEEN,
-          },
+  const [
+    settingsResult,
+    invoiceLayout,
+    quoteLayout,
+    organization,
+    customFieldsResult,
+    seenRow,
+    invoiceDesigns,
+    quoteDesigns,
+  ] = await Promise.all([
+    getSettings(),
+    getInvoiceLayoutConfig(),
+    getQuoteLayoutConfig(),
+    db.organization.findUnique({
+      where: { id: data.organizationId },
+      select: { name: true },
+    }),
+    features.customFields ? getFieldDefinitions() : Promise.resolve({ success: true, data: [] }),
+    db.appSetting.findUnique({
+      where: {
+        organizationId_key: {
+          organizationId: data.organizationId,
+          key: SETTING_KEYS.FEATURE_HINTS_SEEN,
         },
-        select: { value: true },
-      }),
-    ])
+      },
+      select: { value: true },
+    }),
+    listDocumentDesigns('invoice'),
+    listDocumentDesigns('quote'),
+  ])
 
   // Somebody is looking at the designer, so the workshop knows it exists. Only
   // written when the card is still outstanding, to keep a settled announcement
@@ -56,15 +67,13 @@ export default async function InvoiceDesignerPage({
 
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
 
-  let savedDesigns: unknown = []
-  try {
-    savedDesigns = settings[SETTING_KEYS.DESIGNER_SAVED_DESIGNS]
-      ? JSON.parse(settings[SETTING_KEYS.DESIGNER_SAVED_DESIGNS])
-      : []
-    if (!Array.isArray(savedDesigns)) savedDesigns = []
-  } catch {
-    savedDesigns = []
-  }
+  // One gallery for both documents, as before: a design saved on the quote
+  // canvas is as much a starting point for the invoice as the other way
+  // round. Invoice designs first, because that is what most of them are.
+  const savedDesigns: SavedDesign[] = [
+    ...(invoiceDesigns.success && invoiceDesigns.data ? invoiceDesigns.data : []),
+    ...(quoteDesigns.success && quoteDesigns.data ? quoteDesigns.data : []),
+  ]
   const { doc, view, preset, design } = await searchParams
 
   const templateFor = (prefix: 'invoice' | 'quote') => ({
@@ -101,7 +110,17 @@ export default async function InvoiceDesignerPage({
         quoteLayout={quoteLayout.success ? quoteLayout.data : undefined}
         invoiceTemplate={templateFor('invoice')}
         quoteTemplate={templateFor('quote')}
-        initialSavedDesigns={savedDesigns as SavedDesign[]}
+        initialSavedDesigns={savedDesigns}
+        workshop={{
+          name: organization?.name || '',
+          address: settings[SETTING_KEYS.WORKSHOP_ADDRESS] || '',
+          phone: settings[SETTING_KEYS.WORKSHOP_PHONE] || '',
+          email: settings[SETTING_KEYS.WORKSHOP_EMAIL] || '',
+          slogan: settings[SETTING_KEYS.WORKSHOP_SLOGAN] || '',
+          orgNumber: settings[SETTING_KEYS.INVOICE_ORG_NUMBER] || '',
+          paymentTerms: settings[SETTING_KEYS.INVOICE_PAYMENT_TERMS] || '',
+          logoUrl: settings[SETTING_KEYS.COMPANY_LOGO] || '',
+        }}
         customFields={
           customFieldsResult.success && customFieldsResult.data
             ? customFieldsResult.data
@@ -116,16 +135,6 @@ export default async function InvoiceDesignerPage({
                 .map((f) => ({ id: f.id, label: f.label, name: f.name, isActive: f.isActive }))
             : []
         }
-        workshop={{
-          name: organization?.name ?? '',
-          address: settings[SETTING_KEYS.WORKSHOP_ADDRESS] ?? '',
-          phone: settings[SETTING_KEYS.WORKSHOP_PHONE] ?? '',
-          email: settings[SETTING_KEYS.WORKSHOP_EMAIL] ?? '',
-          slogan: settings[SETTING_KEYS.WORKSHOP_SLOGAN] ?? '',
-          orgNumber: settings[SETTING_KEYS.INVOICE_ORG_NUMBER] ?? '',
-          paymentTerms: settings[SETTING_KEYS.INVOICE_PAYMENT_TERMS] ?? '',
-          logoUrl: settings[SETTING_KEYS.COMPANY_LOGO] ?? '',
-        }}
       />
     </>
   )

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

@@ -1,17 +1,15 @@
-import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { db } from '@/lib/db'
 import { notFound } from 'next/navigation'
 import { InvoiceView } from './invoice-view'
 import { getFeatures } from '@/lib/features'
 import { resolvePortalOrg } from '@/lib/portal-slug'
-import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
+import { assembleInvoicePrint } from '@/features/invoices/Lib/assembleInvoicePrint'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { headers } from 'next/headers'
 import type { Metadata } from 'next'
-import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
 import { getOrgTelegramBotUsername } from '@/lib/telegram'
 
 /** Rewrites /api/protected/files/[orgId]/[category]/[filename] to /api/public/files/[token]/[category]/[filename] */
@@ -39,163 +37,44 @@ export default async function PublicInvoicePage({
   const resolvedOrg = await resolvePortalOrg(orgParam)
   const orgId = resolvedOrg?.id ?? orgParam
 
-  const record = await db.serviceRecord.findUnique({
+  const shared = await db.serviceRecord.findUnique({
     where: { publicToken: token },
-    include: {
-      // Explicit select: internal unitCost/markupPercent must never reach
-      // the public customer-facing payload
-      partItems: {
-        select: {
-          id: true,
-          partNumber: true,
-          name: true,
-          quantity: true,
-          unit: true,
-          unitPrice: true,
-          total: true,
-        },
-      },
-      laborItems: true,
-      technician: { select: { name: true } },
-      attachments: true,
-      payments: { orderBy: { date: 'desc' } },
-      customer: {
-        select: {
-          name: true,
-          email: true,
-          phone: true,
-          address: true,
-          company: true,
-          taxId: true,
-        },
-      },
-      vehicle: {
-        select: {
-          make: true,
-          model: true,
-          year: true,
-          vin: true,
-          licensePlate: true,
-          mileage: true,
-          userId: true,
-          organizationId: true,
-          customer: {
-            select: {
-              name: true,
-              email: true,
-              phone: true,
-              address: true,
-              company: true,
-              taxId: true,
-            },
-          },
-        },
-      },
-    },
+    select: { id: true, organizationId: true },
   })
-
-  if (!record || record.organizationId !== orgId) {
+  if (!shared || shared.organizationId !== orgId) {
     notFound()
   }
 
-  // Fetch workshop settings and features
-  const [settings, org, features] = await Promise.all([
-    db.appSetting.findMany({
-      where: {
-        organizationId: record.organizationId,
-        key: {
-          in: [
-            'workshop.address',
-            'workshop.phone',
-            'workshop.email',
-            'workshop.slogan',
-            'workshop.logo',
-            'invoice.logo',
-            'workshop.unitSystem',
-            'workshop.currencyCode',
-            'workshop.currencyFormat',
-            'invoice.bankAccount',
-            'invoice.orgNumber',
-            'invoice.paymentTerms',
-            'invoice.footerNote',
-            'invoice.showBankAccount',
-            'invoice.showOrgNumber',
-            'invoice.dueDays',
-            'invoice.showLogo',
-            'invoice.showCompanyName',
-            'invoice.primaryColor',
-            'invoice.backgroundColor',
-            'invoice.textColor',
-            'invoice.companyTextColor',
-            'invoice.frameBorderColor',
-            'invoice.frameShadow',
-            'invoice.frameRadius',
-            'invoice.frameSide',
-            'invoice.fontFamily',
-            'invoice.headerStyle',
-            'invoice.logoSize',
-            'payment.providersEnabled',
-            'payment.termsOfSale',
-            'payment.termsOfSaleUrl',
-            'workshop.dateFormat',
-            'workshop.timezone',
-            'workshop.serviceType',
-            'workshop.taxLabel',
-            'portal.enabled',
-            'invoice.layoutConfig',
-            'telegram.botUsername',
-          ],
-        },
-      },
-    }),
-    record.organizationId
-      ? db.organization.findUnique({
-          where: { id: record.organizationId },
-          select: { name: true, portalSlug: true },
-        })
-      : null,
-    getFeatures(orgId),
-  ])
-
-  // Fetch findings for this service record (open ones to show on invoice)
-  const findings = await db.vehicleFinding.findMany({
-    where: { serviceRecordId: record.id, status: { not: 'resolved' } },
-    select: { description: true, severity: true, notes: true },
-    orderBy: { createdAt: 'desc' },
-  })
-
-  // Fetch custom field values for this service record
-  const customFields = await getCustomFieldsForPrint(orgId, record.id, 'service_record')
+  // The document as it was issued, or the live draft: one assembler decides,
+  // and the page a customer opens is the sheet the PDF prints.
+  const assembly = await assembleInvoicePrint(shared.id)
+  if (!assembly) notFound()
+  const { record, settingsMap, org } = assembly
 
-  const settingsMap: Record<string, string> = {}
-  for (const s of settings) settingsMap[s.key] = s.value
+  const features = await getFeatures(orgId)
 
   const workshop = {
-    name: org?.name || '',
-    address: settingsMap['workshop.address'] || '',
-    phone: settingsMap['workshop.phone'] || '',
-    email: settingsMap['workshop.email'] || '',
-    slogan: settingsMap['workshop.slogan'] || undefined,
+    name: assembly.workshop.name,
+    address: assembly.workshop.address,
+    phone: assembly.workshop.phone,
+    email: assembly.workshop.email,
   }
 
-  const currencyCode = settingsMap['workshop.currencyCode'] || 'USD'
-  const currencyFormat: 'symbol' | 'code' =
-    settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol'
+  const currencyCode = assembly.invoiceSettings.currencyCode || 'USD'
+  const currencyFormat: 'symbol' | 'code' = assembly.invoiceSettings.currencyFormat || 'symbol'
 
   const invoiceSettings = {
-    bankAccount: settingsMap['invoice.bankAccount'] || '',
-    orgNumber: settingsMap['invoice.orgNumber'] || '',
-    paymentTerms: settingsMap['invoice.paymentTerms'] || '',
-    footerNote: settingsMap['invoice.footerNote'] || '',
-    showBankAccount: settingsMap['invoice.showBankAccount'] !== 'false',
-    showOrgNumber: settingsMap['invoice.showOrgNumber'] !== 'false',
-    dueDays: Number(settingsMap['invoice.dueDays']) || 0,
+    bankAccount: assembly.invoiceSettings.bankAccount || '',
+    orgNumber: assembly.invoiceSettings.orgNumber || '',
+    paymentTerms: assembly.invoiceSettings.paymentTerms || '',
+    footerNote: assembly.invoiceSettings.footerNote || '',
+    showBankAccount: assembly.invoiceSettings.showBankAccount !== false,
+    showOrgNumber: assembly.invoiceSettings.showOrgNumber !== false,
+    dueDays: assembly.invoiceSettings.dueDays || 0,
   }
 
-  const showLogo = settingsMap['invoice.showLogo'] !== 'false'
-  const showCompanyName = settingsMap['invoice.showCompanyName'] !== 'false'
-  const rawLogoUrl = documentLogoPath(settingsMap, 'invoice')
-  const logoUrl = rawLogoUrl ? toPublicFileUrl(rawLogoUrl, token) : ''
+  const showLogo = assembly.template.showLogo !== false
+  const showCompanyName = assembly.template.showCompanyName !== false
 
   // Determine which online payment providers are enabled for this org
   const enabledProvidersRaw = settingsMap['payment.providersEnabled'] || ''
@@ -204,10 +83,24 @@ export default async function PublicInvoicePage({
     .map((s) => s.trim())
     .filter(Boolean)
 
-  // Rewrite attachment file URLs to use the public file route (no auth required)
-  // Only include attachments marked for invoice display
+  // What reaches the browser: internal unitCost/markupPercent must never be
+  // in the customer-facing payload, attachment URLs go through the public
+  // file route, and the customer, vehicle and technician are the ones the
+  // sheet prints, frozen at issue or live for a draft.
   const publicRecord = {
     ...record,
+    techName: assembly.data.techName,
+    customer: assembly.data.customer,
+    vehicle: assembly.data.vehicle,
+    partItems: record.partItems.map((p) => ({
+      id: p.id,
+      partNumber: p.partNumber,
+      name: p.name,
+      quantity: p.quantity,
+      unit: p.unit,
+      unitPrice: p.unitPrice,
+      total: p.total,
+    })),
     attachments: record.attachments
       .filter((att) => att.includeInInvoice !== false)
       .map((att) => ({
@@ -216,64 +109,21 @@ export default async function PublicInvoicePage({
       })),
   }
 
-  // Parse layout config
-  const layoutConfig = mergeWithDefaults(
-    settingsMap['invoice.layoutConfig'] ? JSON.parse(settingsMap['invoice.layoutConfig']) : {}
-  )
-
-  // The document the workshop designed, built here from the real job so the
-  // page a customer opens is the sheet the designer shows and the PDF prints.
   const acceptLanguage = (await headers()).get('accept-language')
-  const locale = await resolveCustomerLocale(record.organizationId, acceptLanguage)
-  const labels = await loadPrintLabels(locale, settingsMap)
-
-  const paidFromPayments = record.payments.reduce((sum, p) => sum + p.amount, 0)
-  const effectiveTotal = record.totalAmount > 0 ? record.totalAmount : record.cost
-  const paymentSummary =
-    record.payments.length > 0 || record.manuallyPaid
-      ? {
-          totalPaid: record.manuallyPaid ? effectiveTotal : paidFromPayments,
-          payments: record.payments.map((p) => ({
-            amount: p.amount,
-            date: p.date.toLocaleDateString(),
-            method: p.method,
-          })),
-        }
-      : undefined
+  const locale = await resolveCustomerLocale(orgId, acceptLanguage)
+  const labels = await loadPrintLabels(locale, assembly.labelSettings)
 
   const torqvoiceLogoDataUri = features.brandingRemoved
     ? undefined
     : await getTorqvoiceLogoDataUri()
 
   const spec = buildInvoicePrintSpec({
-    data: { ...record, customFields, findings },
-    workshop,
-    invoiceSettings: {
-      ...invoiceSettings,
-      currencyCode,
-      currencyFormat,
-      unitSystem: settingsMap['workshop.unitSystem'] || undefined,
-      dateFormat: settingsMap['workshop.dateFormat'] || undefined,
-      timezone: settingsMap['workshop.timezone'] || undefined,
-    },
-    paymentSummary,
-    logoDataUri: logoUrl || undefined,
-    template: {
-      primaryColor: settingsMap['invoice.primaryColor'] || '#d97706',
-      backgroundColor: settingsMap['invoice.backgroundColor'] || undefined,
-      textColor: settingsMap['invoice.textColor'] || undefined,
-      companyTextColor: settingsMap['invoice.companyTextColor'] || undefined,
-      frameBorderColor: settingsMap['invoice.frameBorderColor'] || undefined,
-      frameShadow: settingsMap['invoice.frameShadow'],
-      frameRadius: Number(settingsMap['invoice.frameRadius']) || 0,
-      frameSide: settingsMap['invoice.frameSide'] === 'right' ? 'right' : 'left',
-      fontFamily: settingsMap['invoice.fontFamily'] || 'Helvetica',
-      showLogo,
-      showCompanyName,
-      headerStyle: settingsMap['invoice.headerStyle'] || 'standard',
-      logoSize: Number(settingsMap['invoice.logoSize']) || 100,
-      layoutConfig,
-    },
+    data: assembly.data,
+    workshop: assembly.workshop,
+    invoiceSettings: assembly.invoiceSettings,
+    paymentSummary: assembly.paymentSummary,
+    logoDataUri: assembly.logoDataUri,
+    template: assembly.template,
     torqvoiceLogoDataUri,
     labels,
   })
@@ -291,7 +141,7 @@ export default async function PublicInvoicePage({
 
   // The bot follows the Telegram integration, whichever side of the move it
   // was connected on.
-  const telegramBotUsername = (await getOrgTelegramBotUsername(record.organizationId)) || ''
+  const telegramBotUsername = (await getOrgTelegramBotUsername(orgId)) || ''
   const telegramBotLink = telegramBotUsername ? `https://t.me/${telegramBotUsername}` : undefined
 
   return (
@@ -305,23 +155,23 @@ export default async function PublicInvoicePage({
       token={token}
       enabledProviders={enabledProviders}
       invoiceSettings={invoiceSettings}
-      logoUrl={logoUrl}
+      logoUrl={assembly.logoDataUri || ''}
       showLogo={showLogo}
       showCompanyName={showCompanyName}
       showTorqvoiceBranding={!features.brandingRemoved}
-      dateFormat={settingsMap['workshop.dateFormat'] || undefined}
-      timezone={settingsMap['workshop.timezone'] || undefined}
+      dateFormat={assembly.invoiceSettings.dateFormat}
+      timezone={assembly.invoiceSettings.timezone}
       termsOfSaleUrl={termsOfSaleUrl}
-      primaryColor={settingsMap['invoice.primaryColor'] || '#d97706'}
-      headerStyle={settingsMap['invoice.headerStyle'] || 'standard'}
-      logoSize={Number(settingsMap['invoice.logoSize']) || 100}
+      primaryColor={assembly.template.primaryColor || '#d97706'}
+      headerStyle={assembly.template.headerStyle || 'standard'}
+      logoSize={assembly.template.logoSize || 100}
       portalUrl={portalUrl}
-      layoutConfig={layoutConfig}
-      customFields={customFields}
-      findings={findings}
+      layoutConfig={assembly.layoutConfig}
+      customFields={assembly.data.customFields}
+      findings={assembly.data.findings}
       telegramBotLink={telegramBotLink}
-      serviceType={(settingsMap['workshop.serviceType'] || 'automotive') as 'automotive' | 'marine'}
-      taxLabel={settingsMap['workshop.taxLabel']?.trim() || undefined}
+      serviceType={assembly.serviceType}
+      taxLabel={assembly.taxLabel}
     />
   )
 }

+ 30 - 0
src/app/api/protected/backup/export/route.ts

@@ -82,6 +82,16 @@ export async function POST(request: NextRequest) {
     )
   }
 
+  if (options.settings) {
+    queries.push(
+      db.documentDesign
+        .findMany({ where: { organizationId: ctx.organizationId } })
+        .then((result) => {
+          data.documentDesigns = result
+        })
+    )
+  }
+
   if (options.customers) {
     queries.push(
       db.customer.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {
@@ -90,6 +100,26 @@ export async function POST(request: NextRequest) {
     )
   }
 
+  if (options.vehicles) {
+    // What issued invoices were issued with. The logo bytes travel as base64,
+    // since the file is JSON.
+    queries.push(
+      db.documentDesignSnapshot
+        .findMany({ where: { organizationId: ctx.organizationId } })
+        .then((result) => {
+          data.documentDesignSnapshots = result
+        }),
+      db.documentAssetSnapshot
+        .findMany({ where: { organizationId: ctx.organizationId } })
+        .then((result) => {
+          data.documentAssetSnapshots = result.map((row) => ({
+            ...row,
+            data: Buffer.from(row.data).toString('base64'),
+          }))
+        })
+    )
+  }
+
   if (options.customFields) {
     queries.push(
       db.customFieldDefinition

+ 93 - 0
src/app/api/protected/backup/import/route.ts

@@ -54,6 +54,11 @@ async function parseBackup(
   return { backup, files: null }
 }
 
+/** A foreign key from the backup, kept only when this import restored its target. */
+function keptReference(id: unknown, restored: ReadonlySet<string> | undefined): string | null {
+  return typeof id === 'string' && id && restored?.has(id) ? id : null
+}
+
 /**
  * Restores one service record with its nested parts/labor/attachments/payments.
  * Used for both vehicle-linked records (nested under vehicles in the backup)
@@ -69,6 +74,14 @@ async function importServiceRecordTree(
     workDayStartTime: string
     /** Technicians restored by this import. See the time entries below. */
     technicianIds: ReadonlySet<string>
+    /**
+     * Designs and snapshots restored by this import. A reference to one the
+     * backup did not carry is dropped rather than left dangling: the record
+     * then prints from the default, as any invoice without a design does.
+     */
+    designIds?: ReadonlySet<string>
+    designSnapshotIds?: ReadonlySet<string>
+    assetSnapshotIds?: ReadonlySet<string>
   }
 ) {
   // Derive startDateTime/endDateTime from backup or fall back to serviceDate + work day start
@@ -127,6 +140,14 @@ async function importServiceRecordTree(
       updatedAt: toSafeDate(sr.updatedAt as string),
       vehicleId: opts.vehicleId,
       customerId: opts.customerId,
+      designId: keptReference(sr.designId, opts.designIds),
+      issuedAt: sr.issuedAt ? toSafeDate(sr.issuedAt as string) : null,
+      issuedDesignSnapshotId: keptReference(sr.issuedDesignSnapshotId, opts.designSnapshotIds),
+      issuedLogoSnapshotId: keptReference(sr.issuedLogoSnapshotId, opts.assetSnapshotIds),
+      issuedData:
+        sr.issuedData && typeof sr.issuedData === 'object'
+          ? (sr.issuedData as Prisma.InputJsonValue)
+          : undefined,
     },
   })
 
@@ -414,6 +435,11 @@ export async function POST(request: NextRequest) {
         Webhook: () => tx.webhook.deleteMany({ where: { organizationId } }),
         ReportSchedule: () => tx.reportSchedule.deleteMany({ where: { organizationId } }),
         AppSetting: () => tx.appSetting.deleteMany({ where: { organizationId } }),
+        DocumentDesign: () => tx.documentDesign.deleteMany({ where: { organizationId } }),
+        DocumentDesignSnapshot: () =>
+          tx.documentDesignSnapshot.deleteMany({ where: { organizationId } }),
+        DocumentAssetSnapshot: () =>
+          tx.documentAssetSnapshot.deleteMany({ where: { organizationId } }),
       }
 
       for (const model of clearPlanFor(Object.keys(data))) {
@@ -441,6 +467,66 @@ export async function POST(request: NextRequest) {
         })
       }
 
+      // 2b. Named designs, and what issued invoices were issued with. Before
+      // customers and service records, which point at them. Backups from
+      // before these existed have none of the keys and restore as before.
+      const designIds = new Set<string>()
+      if (data.documentDesigns?.length) {
+        const rows = (data.documentDesigns as Record<string, unknown>[]).filter(
+          (d) => typeof d.id === 'string' && typeof d.name === 'string'
+        )
+        await tx.documentDesign.createMany({
+          data: rows.map((d) => ({
+            id: d.id as string,
+            organizationId: ctx.organizationId,
+            documentType: (d.documentType as string) || 'invoice',
+            name: d.name as string,
+            layout: (d.layout ?? {}) as Prisma.InputJsonValue,
+            template: (d.template ?? {}) as Prisma.InputJsonValue,
+            createdAt: toSafeDate(d.createdAt as string),
+            updatedAt: toSafeDate(d.updatedAt as string),
+          })),
+        })
+        for (const d of rows) designIds.add(d.id as string)
+      }
+      const designSnapshotIds = new Set<string>()
+      if (data.documentDesignSnapshots?.length) {
+        const rows = (data.documentDesignSnapshots as Record<string, unknown>[]).filter(
+          (d) => typeof d.id === 'string' && typeof d.hash === 'string'
+        )
+        await tx.documentDesignSnapshot.createMany({
+          data: rows.map((d) => ({
+            id: d.id as string,
+            organizationId: ctx.organizationId,
+            hash: d.hash as string,
+            layout: (d.layout ?? {}) as Prisma.InputJsonValue,
+            template: (d.template ?? {}) as Prisma.InputJsonValue,
+            createdAt: toSafeDate(d.createdAt as string),
+          })),
+          skipDuplicates: true,
+        })
+        for (const d of rows) designSnapshotIds.add(d.id as string)
+      }
+      const assetSnapshotIds = new Set<string>()
+      if (data.documentAssetSnapshots?.length) {
+        const rows = (data.documentAssetSnapshots as Record<string, unknown>[]).filter(
+          (d) =>
+            typeof d.id === 'string' && typeof d.hash === 'string' && typeof d.data === 'string'
+        )
+        await tx.documentAssetSnapshot.createMany({
+          data: rows.map((d) => ({
+            id: d.id as string,
+            organizationId: ctx.organizationId,
+            hash: d.hash as string,
+            mimeType: (d.mimeType as string) || 'image/png',
+            data: new Uint8Array(Buffer.from(d.data as string, 'base64')),
+            createdAt: toSafeDate(d.createdAt as string),
+          })),
+          skipDuplicates: true,
+        })
+        for (const d of rows) assetSnapshotIds.add(d.id as string)
+      }
+
       // 3. Insert customers
       if (data.customers?.length) {
         await tx.customer.createMany({
@@ -452,6 +538,7 @@ export async function POST(request: NextRequest) {
             address: (c.address as string) || null,
             company: (c.company as string) || null,
             notes: (c.notes as string) || null,
+            invoiceDesignId: keptReference(c.invoiceDesignId, designIds),
             createdAt: toSafeDate(c.createdAt as string),
             updatedAt: toSafeDate(c.updatedAt as string),
             userId: ctx.userId,
@@ -690,6 +777,9 @@ export async function POST(request: NextRequest) {
                 customerId: null,
                 workDayStartTime,
                 technicianIds,
+                designIds,
+                designSnapshotIds,
+                assetSnapshotIds,
               })
             }
           }
@@ -771,6 +861,9 @@ export async function POST(request: NextRequest) {
             customerId: (sr.customerId as string) || null,
             workDayStartTime,
             technicianIds,
+            designIds,
+            designSnapshotIds,
+            assetSnapshotIds,
           })
         }
       }

+ 25 - 223
src/app/api/protected/services/[id]/pdf/route.ts

@@ -11,12 +11,10 @@ import { PDFDocument } from 'pdf-lib'
 import { resolveUploadPath } from '@/lib/resolve-upload-path'
 import { getFeatures } from '@/lib/features'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
-import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
-import { formatDateForPdf } from '@/lib/format'
-import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { markInvoiceIssued } from '@/features/onboarding/Lib/markInvoiceIssued'
-import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
 import { getOrgTelegramBotUsername } from '@/lib/telegram'
+import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
+import { assembleInvoicePrint, invoiceNumberOf } from '@/features/invoices/Lib/assembleInvoicePrint'
 
 export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
   try {
@@ -26,121 +24,32 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
       return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
     }
 
-    // Load locale-based PDF translations
-    const cookieStore = await cookies()
-    const locale = cookieStore.get('locale')?.value || 'en'
-    let pdfMessages: Record<string, Record<string, string>>
-    try {
-      pdfMessages = (await import(`../../../../../../../messages/${locale}/pdf.json`)).default
-    } catch {
-      pdfMessages = (await import(`../../../../../../../messages/en/pdf.json`)).default
-    }
-    const labels = {
-      ...pdfMessages.invoice,
-      ...pdfMessages.common,
-    }
-
     const { id } = await params
 
-    const [record, settings, org] = await Promise.all([
-      db.serviceRecord.findFirst({
-        where: { id, organizationId: ctx.organizationId },
-        include: {
-          partItems: true,
-          laborItems: true,
-          attachments: true,
-          payments: { orderBy: { date: 'desc' } },
-          // Pull the linked technician's current name as the source of truth.
-          // The denormalized `techName` field can drift out of sync; the FK
-          // relation is always correct. The PDF prefers technician.name and
-          // falls back to techName for legacy records that have no FK.
-          technician: { select: { name: true } },
-          customer: {
-            select: {
-              name: true,
-              email: true,
-              phone: true,
-              address: true,
-              company: true,
-              taxId: true,
-              customerNumber: true,
-            },
-          },
-          vehicle: {
-            select: {
-              make: true,
-              model: true,
-              year: true,
-              vin: true,
-              licensePlate: true,
-              mileage: true,
-              customer: {
-                select: {
-                  name: true,
-                  email: true,
-                  phone: true,
-                  address: true,
-                  company: true,
-                  taxId: true,
-                  customerNumber: true,
-                },
-              },
-            },
-          },
-        },
-      }),
-      db.appSetting.findMany({
-        where: { organizationId: ctx.organizationId },
-      }),
-      db.organization.findUnique({
-        where: { id: ctx.organizationId },
-        select: { name: true, portalSlug: true },
-      }),
-    ])
+    const owned = await db.serviceRecord.findFirst({
+      where: { id, organizationId: ctx.organizationId },
+      select: { id: true },
+    })
+    if (!owned) {
+      return NextResponse.json({ error: 'Record not found' }, { status: 404 })
+    }
 
-    if (!record) {
+    // What the sheet says: an issued invoice from its snapshots, a draft
+    // from live rows. Everything below adds what is not part of the document.
+    const assembly = await assembleInvoicePrint(owned.id)
+    if (!assembly) {
       return NextResponse.json({ error: 'Record not found' }, { status: 404 })
     }
+    const { record, settingsMap, org, layoutConfig } = assembly
 
     // Getting-started checklist: a downloaded invoice leaves no other trace
     // in the data, so record it here. Best-effort, never blocks the PDF.
     void markInvoiceIssued(ctx.organizationId, ctx.userId, record.id)
 
-    // Fetch findings for this service record (open ones to show on invoice)
-    const findings = await db.vehicleFinding.findMany({
-      where: { serviceRecordId: record.id, status: { not: 'resolved' } },
-      select: { description: true, severity: true, notes: true },
-      orderBy: { createdAt: 'desc' },
-    })
-
-    // Custom fields for this service record (definition defaults included)
-    const customFields = await getCustomFieldsForPrint(
-      ctx.organizationId,
-      record.id,
-      'service_record'
-    )
-
-    // Build settings map
-    const settingsMap: Record<string, string> = {}
-    for (const s of settings) settingsMap[s.key] = s.value
-
-    // Override labels for marine service type
-    const serviceType = settingsMap['workshop.serviceType'] || 'automotive'
-    if (serviceType === 'marine') {
-      if (pdfMessages.invoice.mileageMarine) labels.mileage = pdfMessages.invoice.mileageMarine
-      if (pdfMessages.invoice.vinMarine) labels.vin = pdfMessages.invoice.vinMarine
-      if (pdfMessages.invoice.plateMarine) labels.plate = pdfMessages.invoice.plateMarine
-      if (pdfMessages.invoice.vehicleMarine) labels.vehicle = pdfMessages.invoice.vehicleMarine
-      // Override unit labels for engine hours
-      labels.km = 'hrs'
-      labels.mi = 'hrs'
-    }
-
-    // Custom tax label override (e.g. "VAT", "MVA", "GST", "MwSt.")
-    const customTaxLabel = settingsMap['workshop.taxLabel']?.trim()
-    if (customTaxLabel) {
-      labels.tax = `${customTaxLabel} ({rate}%)`
-    }
+    // Load locale-based PDF translations
+    const cookieStore = await cookies()
+    const locale = cookieStore.get('locale')?.value || 'en'
+    const labels = await loadPrintLabels(locale, assembly.labelSettings)
 
     // Load image attachments as base64 data URIs for PDF embedding
     const imageAttachments: { fileName: string; dataUri: string; description?: string }[] = []
@@ -185,107 +94,6 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
       }
     }
 
-    // The document's own mark when it has one, the company logo otherwise.
-    let logoDataUri: string | undefined
-    const logoPath = documentLogoPath(settingsMap, 'invoice')
-    if (logoPath) {
-      try {
-        const fullPath = resolveUploadPath(logoPath)
-        const logoBuffer = await readFile(fullPath)
-        const ext = logoPath.split('.').pop()?.toLowerCase() || 'png'
-        const mimeMap: Record<string, string> = {
-          png: 'image/png',
-          jpg: 'image/jpeg',
-          jpeg: 'image/jpeg',
-          webp: 'image/webp',
-          svg: 'image/svg+xml',
-        }
-        const mime = mimeMap[ext] || 'image/png'
-        logoDataUri = `data:${mime};base64,${logoBuffer.toString('base64')}`
-      } catch {
-        // Logo file not found, skip
-      }
-    }
-
-    const invoiceSettings = {
-      bankAccount: settingsMap['invoice.bankAccount'] || '',
-      orgNumber: settingsMap['invoice.orgNumber'] || '',
-      paymentTerms: settingsMap['invoice.paymentTerms'] || '',
-      footerNote: settingsMap['invoice.footerNote'] || '',
-      showBankAccount: settingsMap['invoice.showBankAccount'] === 'true',
-      showOrgNumber: settingsMap['invoice.showOrgNumber'] === 'true',
-      dueDays: Number(settingsMap['invoice.dueDays']) || 0,
-      currencyCode: settingsMap['workshop.currencyCode'] || 'USD',
-      currencyFormat: (settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol') as
-        | 'symbol'
-        | 'code',
-      unitSystem: settingsMap['workshop.unitSystem'] || 'imperial',
-      dateFormat: settingsMap['workshop.dateFormat'] || undefined,
-      timezone: settingsMap['workshop.timezone'] || undefined,
-    }
-
-    // Build payment summary
-    const pdfDateFormat = settingsMap['workshop.dateFormat'] || undefined
-    const pdfTimezone = settingsMap['workshop.timezone'] || undefined
-
-    const paidFromPayments =
-      record.payments?.reduce((sum: number, p: { amount: number }) => sum + p.amount, 0) ?? 0
-    const effectiveTotal = record.totalAmount > 0 ? record.totalAmount : record.cost
-    const totalPaidForPdf = record.manuallyPaid ? effectiveTotal : paidFromPayments
-
-    const paymentSummary =
-      (record.payments && record.payments.length > 0) || record.manuallyPaid
-        ? {
-            totalPaid: totalPaidForPdf,
-            payments: (record.payments || []).map(
-              (p: { amount: number; date: Date; method: string }) => ({
-                amount: p.amount,
-                date: formatDateForPdf(p.date, pdfDateFormat, pdfTimezone),
-                method: p.method,
-              })
-            ),
-          }
-        : undefined
-
-    // Fetch layout config
-    const layoutConfigSetting = await db.appSetting.findUnique({
-      where: {
-        organizationId_key: { organizationId: ctx.organizationId, key: 'invoice.layoutConfig' },
-      },
-    })
-    const layoutConfig = mergeWithDefaults(
-      layoutConfigSetting?.value ? JSON.parse(layoutConfigSetting.value) : {}
-    )
-
-    const template = {
-      primaryColor:
-        settingsMap['invoice.primaryColor'] ||
-        settingsMap['invoice.template.primaryColor'] ||
-        '#d97706',
-      backgroundColor: settingsMap['invoice.backgroundColor'] || undefined,
-      textColor: settingsMap['invoice.textColor'] || undefined,
-      companyTextColor: settingsMap['invoice.companyTextColor'] || undefined,
-      frameBorderColor: settingsMap['invoice.frameBorderColor'] || undefined,
-      frameShadow: settingsMap['invoice.frameShadow'],
-      frameRadius: Number(settingsMap['invoice.frameRadius']) || 0,
-      frameSide: (settingsMap['invoice.frameSide'] === 'right' ? 'right' : 'left') as
-        | 'left'
-        | 'right',
-      fontFamily:
-        settingsMap['invoice.fontFamily'] ||
-        settingsMap['invoice.template.fontFamily'] ||
-        'Helvetica',
-      showLogo:
-        (settingsMap['invoice.showLogo'] ?? settingsMap['invoice.template.showLogo']) !== 'false',
-      showCompanyName: settingsMap['invoice.showCompanyName'] !== 'false',
-      headerStyle:
-        settingsMap['invoice.headerStyle'] ||
-        settingsMap['invoice.template.headerStyle'] ||
-        'standard',
-      logoSize: Number(settingsMap['invoice.logoSize']) || 100,
-      layoutConfig,
-    }
-
     // Check if Torqvoice branding should be shown
     const features = await getFeatures(ctx.organizationId)
     let torqvoiceLogoDataUri: string | undefined
@@ -312,21 +120,15 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
     }
 
     const element = React.createElement(InvoicePDF, {
-      data: { ...record, customFields, findings },
-      workshop: {
-        name: org?.name || '',
-        address: settingsMap['workshop.address'] || '',
-        phone: settingsMap['workshop.phone'] || '',
-        email: settingsMap['workshop.email'] || '',
-        slogan: settingsMap['workshop.slogan'] || undefined,
-      },
-      invoiceSettings,
-      paymentSummary,
+      data: assembly.data,
+      workshop: assembly.workshop,
+      invoiceSettings: assembly.invoiceSettings,
+      paymentSummary: assembly.paymentSummary,
       imageAttachments,
       otherAttachments,
       pdfAttachmentNames: pdfAttachments.map((a) => a.fileName),
-      logoDataUri,
-      template,
+      logoDataUri: assembly.logoDataUri,
+      template: assembly.template,
       torqvoiceLogoDataUri,
       portalUrl,
       telegramQrDataUri,
@@ -336,7 +138,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
     }) as any
     const invoiceBuffer = await renderToBuffer(element)
 
-    const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`
+    const invoiceNum = invoiceNumberOf(record)
 
     // Merge attached PDF diagnostic reports into the invoice
     let finalBuffer: ArrayBuffer

+ 18 - 0
src/features/customers/Actions/customerActions.ts

@@ -10,6 +10,19 @@ import { createDraftServiceRecord } from '@/features/vehicles/Actions/createDraf
 import { claimWhatsappMessagesForCustomer } from '@/lib/whatsapp'
 import { serviceDateOrderBy } from '@/lib/date-sort'
 
+/**
+ * A design a customer is being pointed at has to be this workshop's and an
+ * invoice design; anything else is refused rather than stored.
+ */
+async function assertInvoiceDesign(organizationId: string, designId: string | null | undefined) {
+  if (!designId) return
+  const design = await db.documentDesign.findFirst({
+    where: { id: designId, organizationId, documentType: 'invoice' },
+    select: { id: true },
+  })
+  if (!design) throw new Error('Design not found')
+}
+
 export async function getCustomers() {
   return withAuth(
     async ({ userId, organizationId }) => {
@@ -35,6 +48,9 @@ export async function getCustomer(customerId: string) {
       const customer = await db.customer.findFirst({
         where: { id: customerId, organizationId },
         include: {
+          // The name, so the page can say which design this customer's
+          // invoices print with rather than only that one was chosen.
+          invoiceDesign: { select: { id: true, name: true } },
           vehicles: {
             where: { isArchived: false },
             include: {
@@ -77,6 +93,7 @@ export async function createCustomer(input: unknown) {
       }
 
       const data = createCustomerSchema.parse(input)
+      await assertInvoiceDesign(organizationId, data.invoiceDesignId)
 
       // Auto-assign the next sequential number when none was provided; the
       // per-org unique index guards against races and manual duplicates.
@@ -135,6 +152,7 @@ export async function updateCustomer(input: unknown) {
   return withAuth(
     async ({ userId, organizationId }) => {
       const { id, ...data } = updateCustomerSchema.parse(input)
+      await assertInvoiceDesign(organizationId, data.invoiceDesignId)
       let result
       try {
         result = await db.customer.updateMany({

+ 50 - 1
src/features/customers/Components/CustomerForm.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { Button } from '@/components/ui/button'
@@ -9,6 +9,14 @@ import { Label } from '@/components/ui/label'
 import { Switch } from '@/components/ui/switch'
 import { Checkbox } from '@/components/ui/checkbox'
 import { Textarea } from '@/components/ui/textarea'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { listDesignOptions } from '@/features/invoice-designer/Actions/documentDesignActions'
 import {
   Dialog,
   DialogContent,
@@ -40,6 +48,7 @@ interface CustomerFormProps {
     taxExempt?: boolean
     reminderOptOut?: boolean
     notes?: string | null
+    invoiceDesignId?: string | null
   }
   /**
    * Prefills a new customer, e.g. with the keeper read off a scanned
@@ -63,6 +72,25 @@ export function CustomerForm({
   const tv = useTranslations('vehicles.form')
   const [loading, setLoading] = useState(false)
   const [taxExempt, setTaxExempt] = useState(customer?.taxExempt ?? false)
+  // The workshop's saved invoice designs, fetched when the dialog opens so
+  // the three places this form is used need not each carry the list. Radix
+  // reserves "" as a value, so "follow the default" is a sentinel.
+  const FOLLOW_DEFAULT = '__default__'
+  const [designOptions, setDesignOptions] = useState<{ id: string; name: string }[]>([])
+  const [invoiceDesignId, setInvoiceDesignId] = useState(
+    customer?.invoiceDesignId ?? FOLLOW_DEFAULT
+  )
+  useEffect(() => {
+    if (!open) return
+    let cancelled = false
+    listDesignOptions('invoice').then((result) => {
+      if (!cancelled && result.success && result.data) setDesignOptions(result.data)
+    })
+    return () => {
+      cancelled = true
+    }
+  }, [open])
+
   const [reminderOptOut, setReminderOptOut] = useState(customer?.reminderOptOut ?? false)
   const formRef = useRef<HTMLFormElement>(null)
   /** Vehicle details from a scanned document, offered once the customer exists. */
@@ -108,6 +136,7 @@ export function CustomerForm({
       taxExempt,
       reminderOptOut,
       notes: (formData.get('notes') as string) || undefined,
+      invoiceDesignId: invoiceDesignId === FOLLOW_DEFAULT ? null : invoiceDesignId,
     }
 
     const result = customer
@@ -287,6 +316,26 @@ export function CustomerForm({
               <Switch checked={taxExempt} onCheckedChange={setTaxExempt} />
             </div>
 
+            {designOptions.length > 0 && (
+              <div className="space-y-2">
+                <Label>{t('invoiceDesign')}</Label>
+                <Select value={invoiceDesignId} onValueChange={setInvoiceDesignId}>
+                  <SelectTrigger className="w-full">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value={FOLLOW_DEFAULT}>{t('invoiceDesignDefault')}</SelectItem>
+                    {designOptions.map((option) => (
+                      <SelectItem key={option.id} value={option.id}>
+                        {option.name}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+                <p className="text-xs text-muted-foreground">{t('invoiceDesignHint')}</p>
+              </div>
+            )}
+
             <div className="flex items-center justify-between gap-4 rounded-lg border p-3">
               <div className="space-y-0.5">
                 <Label>{t('reminderOptOut')}</Label>

+ 2 - 0
src/features/customers/Schema/customerSchema.ts

@@ -11,6 +11,8 @@ export const createCustomerSchema = z.object({
   taxExempt: z.boolean().optional(),
   reminderOptOut: z.boolean().optional(),
   notes: z.string().optional(),
+  /** An invoice design for this customer's invoices; null follows the default. */
+  invoiceDesignId: z.string().nullable().optional(),
 })
 
 export const updateCustomerSchema = createCustomerSchema.partial().extend({

+ 27 - 76
src/features/email/Actions/emailActions.ts

@@ -22,6 +22,10 @@ import {
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { requireFeature } from '@/lib/features'
 import { demoGuard } from '@/lib/demo'
+import { issueInvoice } from '@/features/invoices/Lib/issueInvoice'
+import { assembleInvoicePrint, invoiceNumberOf } from '@/features/invoices/Lib/assembleInvoicePrint'
+import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
+import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 
 async function getWorkshopSettings(organizationId: string) {
   const [settings, org] = await Promise.all([
@@ -278,97 +282,44 @@ export async function sendInvoiceEmail(input: {
 
       const { serviceRecordId, recipientEmail, message } = input
 
-      const record = await db.serviceRecord.findFirst({
+      const owned = await db.serviceRecord.findFirst({
         where: { id: serviceRecordId, organizationId },
-        include: {
-          partItems: true,
-          laborItems: true,
-          customer: {
-            select: {
-              name: true,
-              email: true,
-              phone: true,
-              address: true,
-              company: true,
-              customerNumber: true,
-            },
-          },
-          vehicle: {
-            include: {
-              customer: {
-                select: {
-                  name: true,
-                  email: true,
-                  phone: true,
-                  address: true,
-                  company: true,
-                  customerNumber: true,
-                },
-              },
-            },
-          },
-        },
+        select: { id: true, publicToken: true },
       })
-      if (!record) throw new Error('Service record not found')
+      if (!owned) throw new Error('Service record not found')
 
       const settings = await getWorkshopSettings(organizationId)
       if (settings['workshop.emailEnabled'] === 'false') {
         throw new Error('Email sending is disabled. Enable it in Settings.')
       }
 
-      const logoDataUri = await loadLogoDataUri(documentLogoPath(settings, 'invoice'))
-      const currencyCode = settings['workshop.currencyCode'] || 'USD'
-      const currencyFormat: 'symbol' | 'code' =
-        settings['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol'
-      const fromName = settings['workshop.emailFromName'] || settings['workshop.name'] || 'Workshop'
+      // Issued before it is rendered, so the copy that goes out and the copy
+      // the workshop can print in five years are the same one.
+      await issueInvoice(serviceRecordId, organizationId, 'sent')
+      const assembly = await assembleInvoicePrint(serviceRecordId)
+      if (!assembly) throw new Error('Service record not found')
+      const { record } = assembly
 
-      const invoiceTemplate = {
-        primaryColor: settings['invoice.primaryColor'] || '#d97706',
-        backgroundColor: settings['invoice.backgroundColor'] || undefined,
-        textColor: settings['invoice.textColor'] || undefined,
-        companyTextColor: settings['invoice.companyTextColor'] || undefined,
-        frameBorderColor: settings['invoice.frameBorderColor'] || undefined,
-        frameShadow: settings['invoice.frameShadow'],
-        frameRadius: Number(settings['invoice.frameRadius']) || 0,
-        frameSide: (settings['invoice.frameSide'] === 'right' ? 'right' : 'left') as
-          | 'left'
-          | 'right',
-        fontFamily: settings['invoice.fontFamily'] || 'Helvetica',
-        showLogo: settings['invoice.showLogo'] !== 'false',
-        showCompanyName: settings['invoice.showCompanyName'] !== 'false',
-        headerStyle: settings['invoice.headerStyle'] || 'standard',
-        logoSize: Number(settings['invoice.logoSize']) || undefined,
-        layoutConfig: (() => {
-          try {
-            return settings['invoice.layoutConfig']
-              ? mergeWithDefaults(JSON.parse(settings['invoice.layoutConfig']))
-              : undefined
-          } catch {
-            return undefined
-          }
-        })(),
-      }
+      const locale = await resolveCustomerLocale(organizationId, null)
+      const labels = await loadPrintLabels(locale, assembly.labelSettings)
+      const fromName = settings['workshop.emailFromName'] || settings['workshop.name'] || 'Workshop'
 
       // Generate PDF
       const element = React.createElement(InvoicePDF, {
-        data: record,
-        workshop: {
-          name: settings['workshop.name'] || '',
-          address: settings['workshop.address'] || '',
-          phone: settings['workshop.phone'] || '',
-          email: settings['workshop.email'] || '',
-          slogan: settings['workshop.slogan'] || undefined,
-        },
-        invoiceSettings: { currencyCode, currencyFormat },
-        logoDataUri,
-        template: invoiceTemplate,
+        data: assembly.data,
+        workshop: assembly.workshop,
+        invoiceSettings: assembly.invoiceSettings,
+        paymentSummary: assembly.paymentSummary,
+        logoDataUri: assembly.logoDataUri,
+        template: assembly.template,
+        labels,
       }) as any // eslint-disable-line @typescript-eslint/no-explicit-any
       const pdfBuffer = await renderToBuffer(element)
-      const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`
+      const invoiceNum = invoiceNumberOf(record)
 
       // Build public invoice link if token exists
-      const publicLink = record.publicToken
-        ? `${process.env.NEXT_PUBLIC_APP_URL || process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000'}/share/invoice/${organizationId}/${record.publicToken}`
+      const publicLink = owned.publicToken
+        ? `${process.env.NEXT_PUBLIC_APP_URL || process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000'}/share/invoice/${organizationId}/${owned.publicToken}`
         : null
 
       const from = await getOrgFromAddress(organizationId)
@@ -397,7 +348,7 @@ export async function sendInvoiceEmail(input: {
         ],
       })
 
-      await markInvoiceSent(serviceRecordId, organizationId)
+      await markInvoiceSent(serviceRecordId, organizationId, { alreadyIssued: true })
 
       return { sent: true, serviceRecordId, recipientEmail }
     },

+ 304 - 0
src/features/invoice-designer/Actions/documentDesignActions.ts

@@ -0,0 +1,304 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import type { Prisma } from '@/generated/prisma/client'
+import {
+  DESIGNER_LAYOUT_VERSION,
+  invoiceLayoutConfigSchema,
+  mergeWithDefaults,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { DocumentType, SavedDesign } from '../Components/types'
+import {
+  DESIGN_DOCUMENT_TYPES,
+  designSourceFromStored,
+  designTemplateSchema,
+  savedDesignFromRow,
+} from '../Lib/designSource'
+import { DESIGN_AUTO_RULES } from '../Lib/designRules'
+
+const documentTypeSchema = z.enum(['invoice', 'quote'])
+
+const saveDesignSchema = z.object({
+  id: z.string().optional(),
+  documentType: documentTypeSchema,
+  name: z.string().trim().min(1).max(60),
+  layout: invoiceLayoutConfigSchema.partial(),
+  template: designTemplateSchema,
+})
+
+export type SaveDesignInput = z.input<typeof saveDesignSchema>
+
+/** Every design the workshop saved for one document, newest first. */
+export async function listDocumentDesigns(documentType: DocumentType) {
+  return withAuth(
+    async ({ organizationId }): Promise<SavedDesign[]> => {
+      const rows = await db.documentDesign.findMany({
+        where: { organizationId, documentType: documentTypeSchema.parse(documentType) },
+        orderBy: { updatedAt: 'desc' },
+      })
+      return rows.map(savedDesignFromRow).filter((d): d is SavedDesign => d !== null)
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+    }
+  )
+}
+
+/**
+ * The names alone, for a picker on an invoice or a customer. Anyone who may
+ * edit those may see what designs exist; the layouts themselves stay behind
+ * the settings permission.
+ */
+export async function listDesignOptions(documentType: DocumentType) {
+  return withAuth(
+    async ({ organizationId }) => {
+      return db.documentDesign.findMany({
+        where: { organizationId, documentType: documentTypeSchema.parse(documentType) },
+        select: { id: true, name: true },
+        orderBy: { name: 'asc' },
+      })
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }],
+    }
+  )
+}
+
+/**
+ * Saves a design under its name. Without an id, the same name means the
+ * same design: saving again updates it in place rather than filling the
+ * gallery with near-copies, which is what the designer has always done.
+ */
+export async function saveDocumentDesign(input: SaveDesignInput) {
+  return withAuth(
+    async ({ organizationId }): Promise<SavedDesign> => {
+      const data = saveDesignSchema.parse(input)
+      const layout = data.layout as Prisma.InputJsonValue
+      const template = data.template as Prisma.InputJsonValue
+
+      let target = data.id
+        ? await db.documentDesign.findFirst({
+            where: { id: data.id, organizationId, documentType: data.documentType },
+            select: { id: true },
+          })
+        : null
+      if (!target) {
+        target = await db.documentDesign.findFirst({
+          where: {
+            organizationId,
+            documentType: data.documentType,
+            name: { equals: data.name, mode: 'insensitive' },
+          },
+          select: { id: true },
+        })
+      }
+
+      const row = target
+        ? await db.documentDesign.update({
+            where: { id: target.id },
+            data: { name: data.name, layout, template },
+          })
+        : await db.documentDesign.create({
+            data: {
+              organizationId,
+              documentType: data.documentType,
+              name: data.name,
+              layout,
+              template,
+            },
+          })
+
+      const saved = savedDesignFromRow(row)
+      if (!saved) throw new Error('Design could not be read back')
+      revalidatePath('/settings/templates')
+      return saved
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: 'settings.saveDocumentDesign',
+        entity: 'DocumentDesign',
+        entityId: result.id,
+        details: { key: 'settings_saveDocumentDesign', params: { name: result.name } },
+      }),
+    }
+  )
+}
+
+/**
+ * What still points at a design: invoices not yet issued, and customers.
+ * Issued invoices are counted for the record but are never affected, since
+ * they print from their own snapshot.
+ */
+export async function getDocumentDesignUsage(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const design = await db.documentDesign.findFirst({
+        where: { id, organizationId },
+        select: { id: true },
+      })
+      if (!design) throw new Error('Design not found')
+      const [drafts, issued, customers] = await Promise.all([
+        db.serviceRecord.count({ where: { designId: id, issuedAt: null } }),
+        db.serviceRecord.count({ where: { designId: id, issuedAt: { not: null } } }),
+        db.customer.count({ where: { invoiceDesignId: id } }),
+      ])
+      return { drafts, issued, customers }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+    }
+  )
+}
+
+/**
+ * Removes a design. Drafts and customers that chose it fall back to the
+ * default through the SetNull foreign keys; issued invoices keep their
+ * snapshot and do not notice.
+ */
+export async function deleteDocumentDesign(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const design = await db.documentDesign.findFirst({
+        where: { id, organizationId },
+        select: { id: true, name: true },
+      })
+      if (!design) throw new Error('Design not found')
+      await db.documentDesign.delete({ where: { id: design.id } })
+      revalidatePath('/settings/templates')
+      return { id: design.id, name: design.name }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: 'settings.deleteDocumentDesign',
+        entity: 'DocumentDesign',
+        entityId: result.id,
+        details: { key: 'settings_deleteDocumentDesign', params: { name: result.name } },
+      }),
+    }
+  )
+}
+
+/**
+ * Makes a saved design the one the document prints with by default: the
+ * same writes the designer's Save makes, without opening the designer. The
+ * settings keys stay the source of the default, because the classic
+ * template page and every quote renderer still read them.
+ */
+export async function applyDocumentDesign(id: string) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const row = await db.documentDesign.findFirst({ where: { id, organizationId } })
+      if (!row) throw new Error('Design not found')
+      const documentType = DESIGN_DOCUMENT_TYPES.includes(row.documentType as DocumentType)
+        ? (row.documentType as DocumentType)
+        : 'invoice'
+      const source = designSourceFromStored(row.layout, row.template)
+      if (!source) throw new Error('Design could not be read')
+
+      const prefix = documentType
+      const layout = { ...mergeWithDefaults(source.layout), version: DESIGNER_LAYOUT_VERSION }
+      const t = source.template
+      const entries: Record<string, string> = {
+        [`${prefix}.layoutConfig`]: JSON.stringify(invoiceLayoutConfigSchema.parse(layout)),
+        [`${prefix}.primaryColor`]: t.primaryColor,
+        [`${prefix}.backgroundColor`]: t.backgroundColor,
+        [`${prefix}.textColor`]: t.textColor,
+        [`${prefix}.companyTextColor`]: t.companyTextColor,
+        [`${prefix}.frameBorderColor`]: t.frameBorderColor,
+        [`${prefix}.frameShadow`]: t.frameShadow,
+        [`${prefix}.frameRadius`]: String(t.frameRadius),
+        [`${prefix}.frameSide`]: t.frameSide,
+        [`${prefix}.fontFamily`]: t.fontFamily,
+        [`${prefix}.headerStyle`]: t.headerStyle,
+        [`${prefix}.logoSize`]: String(t.logoSize),
+        [`${prefix}.logo`]: t.logoUrl,
+        [`${prefix}.activeDesign`]: `design:${row.id}`,
+      }
+      await db.$transaction(
+        Object.entries(entries).map(([key, value]) =>
+          db.appSetting.upsert({
+            where: { organizationId_key: { organizationId, key } },
+            update: { value },
+            create: { userId, organizationId, key, value },
+          })
+        )
+      )
+      revalidatePath('/settings/templates')
+      revalidatePath('/settings/invoice')
+      return { id: row.id, name: row.name, documentType }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: 'settings.applyDocumentDesign',
+        entity: 'DocumentDesign',
+        entityId: result.id,
+        details: { key: 'settings_applyDocumentDesign', params: { name: result.name } },
+      }),
+    }
+  )
+}
+
+const autoRuleSchema = z.enum(DESIGN_AUTO_RULES).nullable()
+
+/**
+ * Makes a design volunteer itself for one kind of invoice, or stop doing so.
+ * A rule belongs to one design at a time: giving it to this one takes it
+ * from whichever had it, in the same transaction, so the unique index never
+ * refuses the move. Issued invoices are unaffected, as ever: they print from
+ * their snapshot, and a rule only ever decides a draft's look.
+ */
+export async function setDocumentDesignRule(id: string, rule: string | null) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const autoRule = autoRuleSchema.parse(rule)
+      const design = await db.documentDesign.findFirst({
+        where: { id, organizationId },
+        select: { id: true, name: true, documentType: true },
+      })
+      if (!design) throw new Error('Design not found')
+      await db.$transaction(async (tx) => {
+        if (autoRule) {
+          await tx.documentDesign.updateMany({
+            where: {
+              organizationId,
+              documentType: design.documentType,
+              autoRule,
+              id: { not: design.id },
+            },
+            data: { autoRule: null },
+          })
+        }
+        await tx.documentDesign.update({ where: { id: design.id }, data: { autoRule } })
+      })
+      revalidatePath('/settings/templates')
+      return { id: design.id, name: design.name, autoRule }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: 'settings.setDocumentDesignRule',
+        entity: 'DocumentDesign',
+        entityId: result.id,
+        details: {
+          key: 'settings_setDocumentDesignRule',
+          params: { name: result.name, rule: result.autoRule ?? 'none' },
+        },
+      }),
+    }
+  )
+}

+ 42 - 40
src/features/invoice-designer/Components/InvoiceDesigner.tsx

@@ -34,6 +34,11 @@ import {
   saveQuoteLayoutConfig,
 } from '@/features/settings/Actions/invoiceLayoutActions'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
+import {
+  deleteDocumentDesign,
+  getDocumentDesignUsage,
+  saveDocumentDesign,
+} from '../Actions/documentDesignActions'
 import { SpecCanvas } from '../Render/SpecCanvas'
 import { SpecThumbnail } from '../Render/SpecThumbnail'
 import { buildDocumentSpec, type DocumentData } from '../Spec/buildSpec'
@@ -56,18 +61,6 @@ function presetTemplatePatch(preset: (typeof layoutPresets)[number]) {
   }
 }
 
-/**
- * An id for a saved design. crypto.randomUUID only exists in secure contexts,
- * and a dev server reached over a LAN address is not one, so it gets a plain
- * random fallback rather than a crash.
- */
-function designId(): string {
-  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
-    return crypto.randomUUID()
-  }
-  return `design-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
-}
-
 export function InvoiceDesigner({
   initialDocumentType,
   initialView,
@@ -284,14 +277,6 @@ export function InvoiceDesigner({
     [docType, setLayout, setTemplate]
   )
 
-  /** Keep the named designs, in state and in settings, in one move. */
-  const persistDesigns = useCallback((next: SavedDesign[]) => {
-    setSavedDesigns(next)
-    setSettings({ 'designer.savedDesigns': JSON.stringify(next) }).catch(() => {
-      toast.error(t('couldNotSaveDesigns'))
-    })
-  }, [])
-
   /** Bring a saved design back, onto whichever document is being edited. */
   const applyDesign = useCallback(
     (design: SavedDesign) => {
@@ -330,14 +315,33 @@ export function InvoiceDesigner({
    */
   const deleteDesign = useCallback(
     async (design: SavedDesign) => {
+      // What the delete costs, so the dialog can say it: drafts and
+      // customers pointing at this design go back to the default. Issued
+      // invoices keep what they were issued with and are not mentioned.
+      const usage = await getDocumentDesignUsage(design.id)
+      const drafts = usage.success && usage.data ? usage.data.drafts : 0
+      const customers = usage.success && usage.data ? usage.data.customers : 0
+      const consequences = [
+        drafts > 0 ? t('deleteDesignDrafts', { count: drafts }) : '',
+        customers > 0 ? t('deleteDesignCustomers', { count: customers }) : '',
+      ]
+        .filter(Boolean)
+        .join(' ')
       const ok = await confirm({
         title: t('deleteDesignTitle'),
-        description: t('deleteDesignBody', { name: design.name }),
+        description: [t('deleteDesignBody', { name: design.name }), consequences]
+          .filter(Boolean)
+          .join(' '),
         confirmLabel: t('delete'),
         destructive: true,
       })
       if (!ok) return false
-      persistDesigns(savedDesigns.filter((d) => d.id !== design.id))
+      const result = await deleteDocumentDesign(design.id)
+      if (!result.success) {
+        toast.error(t('couldNotSaveDesigns'))
+        return false
+      }
+      setSavedDesigns((prev) => prev.filter((d) => d.id !== design.id))
       setActiveDesigns((prev) => ({
         invoice: prev.invoice === `design:${design.id}` ? '' : prev.invoice,
         quote: prev.quote === `design:${design.id}` ? '' : prev.quote,
@@ -347,7 +351,7 @@ export function InvoiceDesigner({
       }
       return true
     },
-    [confirm, t, persistDesigns, savedDesigns, designName]
+    [confirm, t, designName]
   )
 
   /** The saved design this document is based on, when it is based on one. */
@@ -570,27 +574,24 @@ export function InvoiceDesigner({
     try {
       const prefix = docType === 'invoice' ? 'invoice' : 'quote'
 
-      const snapshot = {
-        savedAt: new Date().toISOString(),
+      // The row is the design: saved on the server under its name, which is
+      // also what makes the same name update in place rather than fill the
+      // gallery with near-copies.
+      const savedResult = await saveDocumentDesign({
+        id: existing?.id,
+        documentType: docType,
+        name,
         layout: JSON.parse(JSON.stringify(layout)) as InvoiceLayoutConfig,
         template: { ...template },
+      })
+      if (!savedResult.success || !savedResult.data) {
+        throw new Error(savedResult.success ? 'No design returned' : savedResult.error)
       }
-      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)
-        )
+      const saved = savedResult.data
+      setSavedDesigns((prev) => [saved, ...prev.filter((d) => d.id !== saved.id)])
+      if (!existing) {
         setDesignName(name)
-        active = `design:${id}`
+        active = `design:${saved.id}`
         setActiveDesigns((prev) => ({ ...prev, [docType]: active }))
       }
 
@@ -687,6 +688,7 @@ export function InvoiceDesigner({
                         <div className="mt-2.5 truncate text-sm font-semibold">{design.name}</div>
                         <div className="text-xs leading-snug text-[#71767e]">
                           {t('savedOn', { date: new Date(design.savedAt).toLocaleDateString() })}
+                          {design.autoRule && ` · ${t(`autoRule.${design.autoRule}`)}`}
                         </div>
                       </button>
                       <button

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

@@ -1,4 +1,5 @@
 import type { InvoiceLayoutConfig } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { DesignAutoRule } from '../Lib/designRules'
 
 export type DocumentType = 'invoice' | 'quote'
 
@@ -35,6 +36,8 @@ export interface SavedDesign {
   savedAt: string
   layout: InvoiceLayoutConfig
   template: DesignerTemplate
+  /** The kind of invoice this design prints by itself, or null for only when chosen. */
+  autoRule: DesignAutoRule | null
 }
 
 export interface DesignerWorkshop {

+ 33 - 0
src/features/invoice-designer/Lib/designHash.ts

@@ -0,0 +1,33 @@
+import { createHash } from 'node:crypto'
+
+/**
+ * JSON with object keys in a fixed order at every depth, so two values that
+ * mean the same thing hash the same whatever order their keys were written
+ * in. Undefined members are dropped the way JSON.stringify drops them.
+ */
+export function canonicalJson(value: unknown): string {
+  return JSON.stringify(sortKeys(value))
+}
+
+function sortKeys(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(sortKeys)
+  if (value && typeof value === 'object') {
+    const out: Record<string, unknown> = {}
+    for (const key of Object.keys(value as Record<string, unknown>).sort()) {
+      const member = (value as Record<string, unknown>)[key]
+      if (member !== undefined) out[key] = sortKeys(member)
+    }
+    return out
+  }
+  return value
+}
+
+/** SHA-256 of the canonical JSON, hex. The identity of a snapshot. */
+export function contentHash(value: unknown): string {
+  return createHash('sha256').update(canonicalJson(value)).digest('hex')
+}
+
+/** SHA-256 of raw bytes, hex. The identity of a stored file. */
+export function bytesHash(bytes: Uint8Array): string {
+  return createHash('sha256').update(bytes).digest('hex')
+}

+ 21 - 0
src/features/invoice-designer/Lib/designRules.server.ts

@@ -0,0 +1,21 @@
+import { db } from '@/lib/db'
+import { type DesignRuleSubject, isDesignAutoRule, pickDesignByRule } from './designRules'
+
+/** What the rules read off an invoice, from the row the print already loads. */
+export function designRuleSubjectOf(record: { vehicleId: string | null }): DesignRuleSubject {
+  return { hasVehicle: record.vehicleId !== null }
+}
+
+/**
+ * The design that volunteers for this invoice, or null. Only invoice
+ * designs carry rules, and only the few rows that have one are read.
+ */
+export async function findRuleDesign(organizationId: string, subject: DesignRuleSubject) {
+  const candidates = await db.documentDesign.findMany({
+    where: { organizationId, documentType: 'invoice', autoRule: { not: null } },
+    select: { id: true, name: true, autoRule: true, layout: true, template: true },
+  })
+  const picked = pickDesignByRule(candidates, subject)
+  if (!picked || !isDesignAutoRule(picked.autoRule)) return null
+  return { ...picked, autoRule: picked.autoRule }
+}

+ 54 - 0
src/features/invoice-designer/Lib/designRules.ts

@@ -0,0 +1,54 @@
+/**
+ * Which design an invoice prints with when nobody chose one for it.
+ *
+ * The app makes two kinds of job: one from a vehicle, and a parts-only sale
+ * from the sales page, with no vehicle at all. A workshop often wants the
+ * second kind on a different sheet: no vehicle block, a shorter table, maybe
+ * another title. Choosing the design by hand on each sale is the kind of
+ * step that gets forgotten, so a design can volunteer itself for them. The
+ * rule is read from the invoice at print time and never stored on it, so
+ * attaching a vehicle to a sale moves it back to the default by itself.
+ *
+ * There is no rule for vehicle jobs, because the workshop default already
+ * is that: everything a rule does not claim. The name follows the picker
+ * the workshop sees when it creates a job, which offers a vehicle or a
+ * "Parts-Only Sale". Everything here is pure; the lookup that turns a
+ * matched rule into a design row lives beside the print assembly.
+ */
+
+export const DESIGN_AUTO_RULES = ['parts_sale'] as const
+
+export type DesignAutoRule = (typeof DESIGN_AUTO_RULES)[number]
+
+/** The one fact about an invoice the rules look at. */
+export interface DesignRuleSubject {
+  hasVehicle: boolean
+}
+
+export function isDesignAutoRule(value: unknown): value is DesignAutoRule {
+  return typeof value === 'string' && (DESIGN_AUTO_RULES as readonly string[]).includes(value)
+}
+
+export function designRuleMatches(rule: DesignAutoRule, subject: DesignRuleSubject): boolean {
+  switch (rule) {
+    case 'parts_sale':
+      return !subject.hasVehicle
+  }
+}
+
+/**
+ * Of the designs that carry a rule, the one this invoice should print with,
+ * or null when none applies. Takes the rows already loaded so the caller
+ * decides how they are fetched and this stays testable without a database.
+ */
+export function pickDesignByRule<T extends { autoRule: string | null }>(
+  designs: T[],
+  subject: DesignRuleSubject
+): T | null {
+  for (const rule of DESIGN_AUTO_RULES) {
+    if (!designRuleMatches(rule, subject)) continue
+    const design = designs.find((d) => d.autoRule === rule)
+    if (design) return design
+  }
+  return null
+}

+ 93 - 0
src/features/invoice-designer/Lib/designSnapshots.ts

@@ -0,0 +1,93 @@
+import { db } from '@/lib/db'
+import type { Prisma } from '@/generated/prisma/client'
+import { bytesHash, contentHash } from './designHash'
+import { materializeDesignSource, type DesignSource } from './designSource'
+
+/**
+ * Snapshots are kept once per distinct content: an invoice issued with the
+ * same look as a thousand before it points at the row they share. The
+ * unique index on (organization, hash) is the guard, and the create is
+ * retried as a lookup when two issues race for the same new row.
+ */
+
+function isUniqueViolation(err: unknown): boolean {
+  return Boolean(err && typeof err === 'object' && (err as { code?: string }).code === 'P2002')
+}
+
+/** The id of the snapshot holding this design, created if it is new. */
+export async function ensureDesignSnapshot(
+  organizationId: string,
+  source: DesignSource
+): Promise<string> {
+  const frozen = materializeDesignSource(source)
+  const hash = contentHash(frozen)
+  const existing = await db.documentDesignSnapshot.findUnique({
+    where: { organizationId_hash: { organizationId, hash } },
+    select: { id: true },
+  })
+  if (existing) return existing.id
+  try {
+    const created = await db.documentDesignSnapshot.create({
+      data: {
+        organizationId,
+        hash,
+        layout: frozen.layout as unknown as Prisma.InputJsonValue,
+        template: frozen.template as unknown as Prisma.InputJsonValue,
+      },
+      select: { id: true },
+    })
+    return created.id
+  } catch (err) {
+    if (!isUniqueViolation(err)) throw err
+    const raced = await db.documentDesignSnapshot.findUnique({
+      where: { organizationId_hash: { organizationId, hash } },
+      select: { id: true },
+    })
+    if (!raced) throw err
+    return raced.id
+  }
+}
+
+const DATA_URI = /^data:([^;,]+);base64,([\s\S]*)$/
+
+/**
+ * The id of the stored copy of a file given as a data URI, created if it is
+ * new. Null for anything that is not a base64 data URI, which is the only
+ * form the print path hands a logo around in.
+ */
+export async function ensureAssetSnapshot(
+  organizationId: string,
+  dataUri: string
+): Promise<string | null> {
+  const match = DATA_URI.exec(dataUri)
+  if (!match) return null
+  const mimeType = match[1]
+  const bytes = new Uint8Array(Buffer.from(match[2], 'base64'))
+  if (bytes.length === 0) return null
+  const hash = bytesHash(bytes)
+  const existing = await db.documentAssetSnapshot.findUnique({
+    where: { organizationId_hash: { organizationId, hash } },
+    select: { id: true },
+  })
+  if (existing) return existing.id
+  try {
+    const created = await db.documentAssetSnapshot.create({
+      data: { organizationId, hash, mimeType, data: bytes },
+      select: { id: true },
+    })
+    return created.id
+  } catch (err) {
+    if (!isUniqueViolation(err)) throw err
+    const raced = await db.documentAssetSnapshot.findUnique({
+      where: { organizationId_hash: { organizationId, hash } },
+      select: { id: true },
+    })
+    if (!raced) throw err
+    return raced.id
+  }
+}
+
+/** A stored file back in the form the renderers take. */
+export function assetDataUri(asset: { mimeType: string; data: Uint8Array }): string {
+  return `data:${asset.mimeType};base64,${Buffer.from(asset.data).toString('base64')}`
+}

+ 163 - 0
src/features/invoice-designer/Lib/designSource.ts

@@ -0,0 +1,163 @@
+import { z } from 'zod'
+import {
+  invoiceLayoutConfigSchema,
+  mergeWithDefaults,
+  type InvoiceLayoutConfig,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { TemplateConfig } from '@/features/vehicles/Components/invoice-pdf/types'
+import type { DesignerTemplate, DocumentType, SavedDesign } from '../Components/types'
+import { isDesignAutoRule } from './designRules'
+
+/**
+ * A design as something that can be printed from: the layout and the
+ * template around it. Every design row, every snapshot and the workshop's
+ * live settings reduce to this one shape, so the renderers never learn where
+ * a look came from.
+ *
+ * The two letterhead switches ride along for layouts that predate the
+ * designer, where the logo and the company name were settings rather than
+ * header fields. The print builder still honours them when no layout was
+ * saved, so they have to survive into a snapshot.
+ */
+export interface DesignSource {
+  layout: Partial<InvoiceLayoutConfig>
+  template: DesignerTemplate & { showLogo?: boolean; showCompanyName?: boolean }
+}
+
+/**
+ * What a stored template must look like to be trusted. Lenient on purpose:
+ * unknown keys pass through, so a field added to the designer later does not
+ * make every earlier design unreadable, and each colour that was left blank
+ * reads as blank rather than failing.
+ */
+export const designTemplateSchema = z
+  .object({
+    primaryColor: z.string().default('#d97706'),
+    backgroundColor: z.string().default(''),
+    textColor: z.string().default(''),
+    companyTextColor: z.string().default(''),
+    frameBorderColor: z.string().default(''),
+    frameShadow: z.string().default('true'),
+    frameSide: z.string().default('left'),
+    frameRadius: z.number().default(0),
+    fontFamily: z.string().default('Helvetica'),
+    headerStyle: z.string().default('standard'),
+    logoSize: z.number().default(100),
+    logoUrl: z.string().default(''),
+    showLogo: z.boolean().optional(),
+    showCompanyName: z.boolean().optional(),
+  })
+  .passthrough()
+
+export const DESIGN_DOCUMENT_TYPES: DocumentType[] = ['invoice', 'quote']
+
+/** Reads a stored design row's JSON columns back into a source, or null. */
+export function designSourceFromStored(layout: unknown, template: unknown): DesignSource | null {
+  const parsedTemplate = designTemplateSchema.safeParse(template ?? {})
+  if (!parsedTemplate.success) return null
+  const parsedLayout = invoiceLayoutConfigSchema.partial().safeParse(layout ?? {})
+  if (!parsedLayout.success) return null
+  return { layout: parsedLayout.data, template: parsedTemplate.data }
+}
+
+function parseLayoutSetting(value: string | undefined): Partial<InvoiceLayoutConfig> {
+  if (!value) return {}
+  try {
+    const parsed = JSON.parse(value)
+    return parsed && typeof parsed === 'object' ? (parsed as Partial<InvoiceLayoutConfig>) : {}
+  } catch {
+    return {}
+  }
+}
+
+/**
+ * The look the workshop's settings describe for one document: what every
+ * renderer read before designs became rows, key for key, including the two
+ * legacy `invoice.template.*` spellings the protected PDF route still fell
+ * back to.
+ */
+export function designSourceFromSettings(
+  settings: Record<string, string | undefined>,
+  documentType: DocumentType
+): DesignSource {
+  const p = documentType
+  return {
+    layout: parseLayoutSetting(settings[`${p}.layoutConfig`]),
+    template: {
+      primaryColor:
+        settings[`${p}.primaryColor`] || settings[`${p}.template.primaryColor`] || '#d97706',
+      backgroundColor: settings[`${p}.backgroundColor`] || '',
+      textColor: settings[`${p}.textColor`] || '',
+      companyTextColor: settings[`${p}.companyTextColor`] || '',
+      frameBorderColor: settings[`${p}.frameBorderColor`] || '',
+      frameShadow: settings[`${p}.frameShadow`] || 'true',
+      frameSide: settings[`${p}.frameSide`] === 'right' ? 'right' : 'left',
+      frameRadius: Number(settings[`${p}.frameRadius`]) || 0,
+      fontFamily:
+        settings[`${p}.fontFamily`] || settings[`${p}.template.fontFamily`] || 'Helvetica',
+      headerStyle:
+        settings[`${p}.headerStyle`] || settings[`${p}.template.headerStyle`] || 'standard',
+      logoSize: Number(settings[`${p}.logoSize`]) || 100,
+      logoUrl: settings[`${p}.logo`] || '',
+      showLogo: (settings[`${p}.showLogo`] ?? settings[`${p}.template.showLogo`]) !== 'false',
+      showCompanyName: settings[`${p}.showCompanyName`] !== 'false',
+    },
+  }
+}
+
+/**
+ * The template the print builders take, with the layout filled out with
+ * defaults. Blank colours become undefined, which is how the builders spell
+ * "use the default", rather than empty strings they would try to draw with.
+ */
+export function templateConfigFromSource(source: DesignSource): TemplateConfig {
+  const t = source.template
+  return {
+    primaryColor: t.primaryColor || '#d97706',
+    backgroundColor: t.backgroundColor || undefined,
+    textColor: t.textColor || undefined,
+    companyTextColor: t.companyTextColor || undefined,
+    frameBorderColor: t.frameBorderColor || undefined,
+    frameShadow: t.frameShadow,
+    frameRadius: Number(t.frameRadius) || 0,
+    frameSide: t.frameSide === 'right' ? 'right' : 'left',
+    fontFamily: t.fontFamily || 'Helvetica',
+    showLogo: t.showLogo !== false,
+    showCompanyName: t.showCompanyName !== false,
+    headerStyle: t.headerStyle || 'standard',
+    logoSize: Number(t.logoSize) || 100,
+    layoutConfig: mergeWithDefaults(source.layout),
+  }
+}
+
+/**
+ * The source as it should be frozen: the layout with every default written
+ * out, so a default that changes later cannot reach into an issued document.
+ */
+export function materializeDesignSource(source: DesignSource): DesignSource {
+  return { layout: mergeWithDefaults(source.layout), template: { ...source.template } }
+}
+
+/**
+ * A design row as the designer and the gallery hold it. Null for a row whose
+ * JSON cannot be read, which the callers drop rather than crash on.
+ */
+export function savedDesignFromRow(row: {
+  id: string
+  name: string
+  updatedAt: Date
+  layout: unknown
+  template: unknown
+  autoRule?: string | null
+}): SavedDesign | null {
+  const source = designSourceFromStored(row.layout, row.template)
+  if (!source) return null
+  return {
+    id: row.id,
+    name: row.name,
+    savedAt: row.updatedAt.toISOString(),
+    layout: mergeWithDefaults(source.layout),
+    template: source.template,
+    autoRule: isDesignAutoRule(row.autoRule) ? row.autoRule : null,
+  }
+}

+ 57 - 0
src/features/invoices/Actions/invoiceDesignActions.ts

@@ -0,0 +1,57 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { assertInvoiceEditable } from '@/lib/document-lock.server'
+
+/**
+ * Chooses which design one invoice prints with. Null goes back to following
+ * the customer's design and the workshop's default. Refused on a locked
+ * invoice like any other edit: what a locked invoice prints is settled.
+ */
+export async function setInvoiceDesign(recordId: string, designId: string | null) {
+  return withAuth(
+    async ({ organizationId }) => {
+      await assertInvoiceEditable(recordId, organizationId)
+      const record = await db.serviceRecord.findFirst({
+        where: { id: recordId, organizationId },
+        select: { id: true, vehicleId: true },
+      })
+      if (!record) throw new Error('Record not found')
+
+      if (designId) {
+        const design = await db.documentDesign.findFirst({
+          where: { id: designId, organizationId, documentType: 'invoice' },
+          select: { id: true },
+        })
+        if (!design) throw new Error('Design not found')
+      }
+
+      await db.serviceRecord.update({
+        where: { id: recordId },
+        data: { designId: designId || null },
+      })
+
+      revalidatePath(
+        record.vehicleId
+          ? `/vehicles/${record.vehicleId}/service/${recordId}`
+          : `/sales/${recordId}`
+      )
+      return { recordId, designId: designId || null }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+      audit: ({ result }) => ({
+        action: 'service.setInvoiceDesign',
+        entity: 'ServiceRecord',
+        entityId: result.recordId,
+        details: { key: 'service_setInvoiceDesign' },
+        metadata: { designId: result.designId },
+      }),
+    }
+  )
+}

+ 72 - 0
src/features/invoices/Actions/legacyInvoiceActions.ts

@@ -0,0 +1,72 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { issueInvoice } from '../Lib/issueInvoice'
+
+/**
+ * Invoices that reached a customer before issuing existed: sent, shared,
+ * paid or marked paid, and never issued. They print from live rows, so a
+ * changed address, logo, terms or title still reaches them. Nothing captures
+ * them behind the workshop's back; invoice settings shows the count and
+ * offers to lock them with the design and details in use right now.
+ *
+ * The screen says "lock", which is the word a workshop uses for a document
+ * that must stop changing, and the same word the manual uses. In the code the
+ * act is called issuing, because it is the same capture that a send performs.
+ */
+const LEGACY_WHERE = (organizationId: string) => ({
+  organizationId,
+  issuedAt: null,
+  OR: [{ sentAt: { not: null } }, { manuallyPaid: true }, { payments: { some: {} } }],
+})
+
+export async function countUnfrozenInvoices() {
+  return withAuth(
+    async ({ organizationId }) => db.serviceRecord.count({ where: LEGACY_WHERE(organizationId) }),
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+    }
+  )
+}
+
+/** How many invoices one call locks, so the request stays short. */
+const FREEZE_BATCH = 100
+
+/**
+ * Locks a batch of those invoices with the current design and details, and
+ * says how many are left. The page calls it until nothing remains, which
+ * keeps a workshop with thousands of old invoices inside request limits.
+ */
+export async function freezeUnfrozenInvoices() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const batch = await db.serviceRecord.findMany({
+        where: LEGACY_WHERE(organizationId),
+        select: { id: true },
+        orderBy: { createdAt: 'asc' },
+        take: FREEZE_BATCH,
+      })
+      let frozen = 0
+      for (const record of batch) {
+        if (await issueInvoice(record.id, organizationId, 'backfill')) frozen += 1
+      }
+      const remaining = await db.serviceRecord.count({ where: LEGACY_WHERE(organizationId) })
+      revalidatePath('/settings/invoice')
+      return { frozen, remaining }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: 'settings.freezeInvoices',
+        entity: 'ServiceRecord',
+        details: { key: 'settings_freezeInvoices', params: { count: result.frozen } },
+        metadata: { frozen: result.frozen, remaining: result.remaining },
+      }),
+    }
+  )
+}

+ 394 - 0
src/features/invoices/Lib/assembleInvoicePrint.ts

@@ -0,0 +1,394 @@
+/**
+ * One place that turns an invoice into what the renderers take.
+ *
+ * Four routes used to assemble this by hand from live rows: the protected
+ * PDF, the public share page, the shared PDF builder behind the customer
+ * portal and the share link, and the email. Each read the workshop's
+ * settings, the customer, the vehicle and the layout as they were at that
+ * moment, so a customer who moved house rewrote every invoice ever sent to
+ * them. Now there is one assembler, and it has two sources: live rows for a
+ * draft, and the snapshots an invoice was issued with for everything else.
+ *
+ * Invoices sent before issuing existed keep printing from live rows until
+ * they are sent again or frozen from invoice settings; nothing is captured
+ * behind the workshop's back on a read.
+ *
+ * The result is the print builders' input, with the design as a source the
+ * issue step can freeze. What a caller adds on top is whatever is not part
+ * of the document: portal links, the Telegram code, Torqvoice branding, and
+ * the reader's translations.
+ */
+
+import { readFile } from 'fs/promises'
+import { db } from '@/lib/db'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { resolveUploadPath } from '@/lib/resolve-upload-path'
+import { formatDateForPdf } from '@/lib/format'
+import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
+import type { InvoiceLayoutConfig } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type {
+  InvoiceData,
+  InvoiceSettingsProps,
+  PaymentSummary,
+  TemplateConfig,
+  WorkshopInfo,
+} from '@/features/vehicles/Components/invoice-pdf/types'
+import {
+  designSourceFromSettings,
+  designSourceFromStored,
+  templateConfigFromSource,
+  type DesignSource,
+} from '@/features/invoice-designer/Lib/designSource'
+import { assetDataUri } from '@/features/invoice-designer/Lib/designSnapshots'
+import {
+  designRuleSubjectOf,
+  findRuleDesign,
+} from '@/features/invoice-designer/Lib/designRules.server'
+import { readIssuedInvoiceData, rendersFromIssue, type IssuedInvoiceData } from './issuedInvoice'
+
+const PARTY_SELECT = {
+  name: true,
+  email: true,
+  phone: true,
+  address: true,
+  company: true,
+  taxId: true,
+  customerNumber: true,
+  invoiceDesignId: true,
+} as const
+
+const RECORD_INCLUDE = {
+  partItems: true,
+  laborItems: true,
+  attachments: true,
+  payments: { orderBy: { date: 'desc' as const } },
+  // The linked technician's current name is the source of truth; the stored
+  // techName string is only a fallback for records without the link.
+  technician: { select: { name: true } },
+  customer: { select: PARTY_SELECT },
+  vehicle: {
+    select: {
+      make: true,
+      model: true,
+      year: true,
+      vin: true,
+      licensePlate: true,
+      mileage: true,
+      customer: { select: PARTY_SELECT },
+    },
+  },
+  issuedDesignSnapshot: true,
+  issuedLogoSnapshot: true,
+}
+
+export type InvoiceRecordForPrint = NonNullable<Awaited<ReturnType<typeof loadRecord>>>
+
+async function loadRecord(recordId: string) {
+  return db.serviceRecord.findUnique({ where: { id: recordId }, include: RECORD_INCLUDE })
+}
+
+export type PrintMode = 'auto' | 'live'
+
+export interface AssembleOptions {
+  /**
+   * `auto` prints an issued invoice from its snapshots and a draft from live
+   * rows. `live` always reads the rows, which is what issuing itself needs.
+   */
+  mode?: PrintMode
+}
+
+export interface InvoicePrintAssembly {
+  record: InvoiceRecordForPrint
+  organizationId: string
+  org: { name: string; portalSlug: string | null } | null
+  /** The workshop's live settings, for what is not part of the document. */
+  settingsMap: Record<string, string>
+  /** Set when the sheet comes from what was issued rather than live rows. */
+  issuedAt: Date | null
+  data: InvoiceData
+  workshop: WorkshopInfo
+  invoiceSettings: InvoiceSettingsProps
+  serviceType: 'automotive' | 'marine'
+  taxLabel?: string
+  template: TemplateConfig
+  layoutConfig: InvoiceLayoutConfig
+  logoDataUri?: string
+  paymentSummary?: PaymentSummary
+  /** The look in the shape a snapshot stores. */
+  designSource: DesignSource
+  /** What the print labels derive from: the frozen service type and tax label. */
+  labelSettings: Record<string, string>
+}
+
+const MIME_BY_EXT: Record<string, string> = {
+  png: 'image/png',
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  webp: 'image/webp',
+  svg: 'image/svg+xml',
+}
+
+async function loadLogoDataUri(logoPath: string): Promise<string | undefined> {
+  if (!logoPath) return undefined
+  try {
+    const buffer = await readFile(resolveUploadPath(logoPath))
+    const ext = logoPath.split('.').pop()?.toLowerCase() || 'png'
+    return `data:${MIME_BY_EXT[ext] || 'image/png'};base64,${buffer.toString('base64')}`
+  } catch {
+    return undefined
+  }
+}
+
+/**
+ * The design a draft prints with: the invoice's own choice, then the
+ * customer's, then whatever the settings describe. A choice that points at
+ * a design of another workshop or of the wrong document type is ignored,
+ * not trusted.
+ */
+async function resolveLiveDesign(
+  organizationId: string,
+  settingsMap: Record<string, string>,
+  record: InvoiceRecordForPrint,
+  customerDesignId: string | null | undefined
+): Promise<DesignSource> {
+  // The invoice's own choice, then its customer's, then whichever design
+  // volunteers for this kind of invoice, then the workshop default.
+  for (const id of [record.designId, customerDesignId]) {
+    if (!id) continue
+    const row = await db.documentDesign.findFirst({
+      where: { id, organizationId, documentType: 'invoice' },
+      select: { layout: true, template: true },
+    })
+    const source = row ? designSourceFromStored(row.layout, row.template) : null
+    if (source) return source
+  }
+  const byRule = await findRuleDesign(organizationId, designRuleSubjectOf(record))
+  const ruled = byRule ? designSourceFromStored(byRule.layout, byRule.template) : null
+  if (ruled) return ruled
+  return designSourceFromSettings(settingsMap, 'invoice')
+}
+
+function liveInvoiceSettings(settingsMap: Record<string, string>): InvoiceSettingsProps {
+  return {
+    bankAccount: settingsMap['invoice.bankAccount'] || '',
+    orgNumber: settingsMap['invoice.orgNumber'] || '',
+    paymentTerms: settingsMap['invoice.paymentTerms'] || '',
+    footerNote: settingsMap['invoice.footerNote'] || '',
+    showBankAccount: settingsMap['invoice.showBankAccount'] === 'true',
+    showOrgNumber: settingsMap['invoice.showOrgNumber'] === 'true',
+    dueDays: Number(settingsMap['invoice.dueDays']) || 0,
+    currencyCode: settingsMap['workshop.currencyCode'] || 'USD',
+    currencyFormat: settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol',
+    unitSystem: settingsMap['workshop.unitSystem'] || 'imperial',
+    dateFormat: settingsMap['workshop.dateFormat'] || undefined,
+    timezone: settingsMap['workshop.timezone'] || undefined,
+  }
+}
+
+function paymentSummaryOf(
+  record: InvoiceRecordForPrint,
+  dateFormat: string | undefined,
+  timezone: string | undefined
+): PaymentSummary | undefined {
+  if (record.payments.length === 0 && !record.manuallyPaid) return undefined
+  const paidFromPayments = record.payments.reduce((sum, p) => sum + p.amount, 0)
+  const effectiveTotal = record.totalAmount > 0 ? record.totalAmount : record.cost
+  return {
+    totalPaid: record.manuallyPaid ? effectiveTotal : paidFromPayments,
+    payments: record.payments.map((p) => ({
+      amount: p.amount,
+      date: formatDateForPdf(p.date, dateFormat, timezone),
+      method: p.method,
+    })),
+  }
+}
+
+type Party = NonNullable<InvoiceData['customer']>
+
+function partyOf(
+  row:
+    | {
+        name: string
+        email?: string | null
+        phone?: string | null
+        address?: string | null
+        company?: string | null
+        taxId?: string | null
+        customerNumber?: string | null
+      }
+    | null
+    | undefined
+): Party | null {
+  if (!row) return null
+  return {
+    name: row.name,
+    email: row.email ?? null,
+    phone: row.phone ?? null,
+    address: row.address ?? null,
+    company: row.company ?? null,
+    taxId: row.taxId ?? null,
+    customerNumber: row.customerNumber ?? null,
+  }
+}
+
+export async function assembleInvoicePrint(
+  recordId: string,
+  options: AssembleOptions = {}
+): Promise<InvoicePrintAssembly | null> {
+  const mode = options.mode ?? 'auto'
+  const record = await loadRecord(recordId)
+  if (!record?.organizationId) return null
+  const organizationId = record.organizationId
+
+  const [settings, org] = await Promise.all([
+    db.appSetting.findMany({ where: { organizationId }, select: { key: true, value: true } }),
+    db.organization.findUnique({
+      where: { id: organizationId },
+      select: { name: true, portalSlug: true },
+    }),
+  ])
+  const settingsMap: Record<string, string> = {}
+  for (const s of settings) settingsMap[s.key] = s.value
+
+  const frozen: IssuedInvoiceData | null =
+    mode === 'auto' && rendersFromIssue(record) ? readIssuedInvoiceData(record.issuedData) : null
+
+  if (frozen) return assembleFrozen(record, organizationId, org, settingsMap, frozen)
+  return assembleLive(record, organizationId, org, settingsMap)
+}
+
+async function assembleLive(
+  record: InvoiceRecordForPrint,
+  organizationId: string,
+  org: InvoicePrintAssembly['org'],
+  settingsMap: Record<string, string>
+): Promise<InvoicePrintAssembly> {
+  const customerRow = record.customer ?? record.vehicle?.customer ?? null
+  const [findings, customFields, designSource] = await Promise.all([
+    db.vehicleFinding.findMany({
+      where: { serviceRecordId: record.id, status: { not: 'resolved' } },
+      select: { description: true, severity: true, notes: true },
+      orderBy: { createdAt: 'desc' },
+    }),
+    getCustomFieldsForPrint(organizationId, record.id, 'service_record'),
+    resolveLiveDesign(organizationId, settingsMap, record, customerRow?.invoiceDesignId),
+  ])
+
+  const logoPath =
+    designSource.template.logoUrl?.trim() || settingsMap[SETTING_KEYS.COMPANY_LOGO]?.trim() || ''
+  const logoDataUri = await loadLogoDataUri(logoPath)
+
+  const invoiceSettings = liveInvoiceSettings(settingsMap)
+  const workshop: WorkshopInfo = {
+    name: org?.name || '',
+    address: settingsMap['workshop.address'] || '',
+    phone: settingsMap['workshop.phone'] || '',
+    email: settingsMap['workshop.email'] || '',
+    slogan: settingsMap['workshop.slogan'] || undefined,
+  }
+  const serviceType = settingsMap['workshop.serviceType'] === 'marine' ? 'marine' : 'automotive'
+  const taxLabel = settingsMap['workshop.taxLabel']?.trim() || undefined
+  const template = templateConfigFromSource(designSource)
+
+  const data: InvoiceData = {
+    ...record,
+    customer: partyOf(record.customer),
+    vehicle: record.vehicle
+      ? { ...record.vehicle, customer: partyOf(record.vehicle.customer) }
+      : null,
+    customFields,
+    findings,
+  }
+
+  return {
+    record,
+    organizationId,
+    org,
+    settingsMap,
+    issuedAt: null,
+    data,
+    workshop,
+    invoiceSettings,
+    serviceType,
+    taxLabel,
+    template,
+    layoutConfig: template.layoutConfig!,
+    logoDataUri,
+    paymentSummary: paymentSummaryOf(record, invoiceSettings.dateFormat, invoiceSettings.timezone),
+    designSource,
+    labelSettings: { 'workshop.serviceType': serviceType, 'workshop.taxLabel': taxLabel ?? '' },
+  }
+}
+
+function assembleFrozen(
+  record: InvoiceRecordForPrint,
+  organizationId: string,
+  org: InvoicePrintAssembly['org'],
+  settingsMap: Record<string, string>,
+  frozen: IssuedInvoiceData
+): InvoicePrintAssembly {
+  const snapshot = record.issuedDesignSnapshot
+  // A snapshot that cannot be read falls back to the live look rather than
+  // to nothing: the words on the sheet are still the frozen ones.
+  const designSource =
+    (snapshot && designSourceFromStored(snapshot.layout, snapshot.template)) ||
+    designSourceFromSettings(settingsMap, 'invoice')
+  const template = templateConfigFromSource(designSource)
+  const logoDataUri = record.issuedLogoSnapshot
+    ? assetDataUri(record.issuedLogoSnapshot)
+    : undefined
+
+  const invoiceSettings: InvoiceSettingsProps = {
+    ...frozen.invoiceSettings,
+    currencyCode: frozen.invoiceSettings.currencyCode || 'USD',
+    currencyFormat: frozen.invoiceSettings.currencyFormat || 'symbol',
+  }
+  const serviceType = frozen.serviceType === 'marine' ? 'marine' : 'automotive'
+  const taxLabel = frozen.taxLabel?.trim() || undefined
+  const customer = frozen.customer ? partyOf(frozen.customer) : null
+  const technicianName = frozen.technicianName ?? null
+
+  const data: InvoiceData = {
+    ...record,
+    techName: technicianName ?? record.techName,
+    technician: technicianName ? { name: technicianName } : null,
+    customer,
+    vehicle: frozen.vehicle
+      ? {
+          make: frozen.vehicle.make,
+          model: frozen.vehicle.model,
+          year: frozen.vehicle.year,
+          vin: frozen.vehicle.vin ?? null,
+          licensePlate: frozen.vehicle.licensePlate ?? null,
+          mileage: frozen.vehicle.mileage ?? record.vehicle?.mileage ?? 0,
+          customer: null,
+        }
+      : null,
+    customFields: frozen.customFields ?? [],
+    findings: (frozen.findings ?? []).map((f) => ({ ...f, notes: f.notes ?? null })),
+  }
+
+  return {
+    record,
+    organizationId,
+    org,
+    settingsMap,
+    issuedAt: record.issuedAt,
+    data,
+    workshop: { ...frozen.workshop, slogan: frozen.workshop.slogan || undefined },
+    invoiceSettings,
+    serviceType,
+    taxLabel,
+    template,
+    layoutConfig: template.layoutConfig!,
+    logoDataUri,
+    paymentSummary: paymentSummaryOf(record, invoiceSettings.dateFormat, invoiceSettings.timezone),
+    designSource,
+    labelSettings: { 'workshop.serviceType': serviceType, 'workshop.taxLabel': taxLabel ?? '' },
+  }
+}
+
+/** The document's number as the file and the subject line spell it. */
+export function invoiceNumberOf(record: { invoiceNumber: string | null; id: string }): string {
+  return record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`
+}

+ 105 - 0
src/features/invoices/Lib/issueInvoice.ts

@@ -0,0 +1,105 @@
+import { db } from '@/lib/db'
+import type { Prisma } from '@/generated/prisma/client'
+import {
+  ensureAssetSnapshot,
+  ensureDesignSnapshot,
+} from '@/features/invoice-designer/Lib/designSnapshots'
+import { assembleInvoicePrint, type InvoicePrintAssembly } from './assembleInvoicePrint'
+import {
+  ISSUED_INVOICE_VERSION,
+  shouldIssue,
+  type IssueReason,
+  type IssuedInvoiceData,
+} from './issuedInvoice'
+
+/** The part of an assembly the record does not own, as the snapshot keeps it. */
+export function buildIssuedInvoiceData(a: InvoicePrintAssembly): IssuedInvoiceData {
+  const customer = a.data.customer ?? a.data.vehicle?.customer ?? null
+  return {
+    version: ISSUED_INVOICE_VERSION,
+    workshop: { ...a.workshop },
+    invoiceSettings: { ...a.invoiceSettings },
+    serviceType: a.serviceType,
+    taxLabel: a.taxLabel,
+    customer: customer
+      ? {
+          name: customer.name,
+          email: customer.email ?? null,
+          phone: customer.phone ?? null,
+          address: customer.address ?? null,
+          company: customer.company ?? null,
+          taxId: customer.taxId ?? null,
+          customerNumber: customer.customerNumber ?? null,
+        }
+      : null,
+    vehicle: a.data.vehicle
+      ? {
+          make: a.data.vehicle.make,
+          model: a.data.vehicle.model,
+          year: a.data.vehicle.year,
+          vin: a.data.vehicle.vin,
+          licensePlate: a.data.vehicle.licensePlate,
+          mileage: a.data.vehicle.mileage,
+        }
+      : null,
+    technicianName: a.data.technician?.name || a.data.techName || null,
+    findings: (a.data.findings ?? []).map((f) => ({
+      description: f.description,
+      severity: f.severity,
+      notes: f.notes ?? null,
+    })),
+    customFields: (a.data.customFields ?? []).map((cf) => ({
+      fieldId: cf.fieldId,
+      label: cf.label,
+      value: cf.value,
+      fieldType: cf.fieldType,
+    })),
+  }
+}
+
+/**
+ * Freezes what an invoice prints, if this occasion calls for it.
+ *
+ * Called by every path that makes the invoice the customer's document:
+ * sending by email or link, recording a payment, marking it paid, and the
+ * freeze of older invoices a workshop asks for in invoice settings. The
+ * decision of whether to capture is in shouldIssue; this does the capture.
+ * An invoice already issued is left exactly as it is unless an owner has
+ * unlocked it since.
+ * Returns whether a snapshot was taken.
+ */
+export async function issueInvoice(
+  recordId: string,
+  organizationId: string,
+  reason: IssueReason
+): Promise<boolean> {
+  const record = await db.serviceRecord.findFirst({
+    where: { id: recordId, organizationId },
+    select: { id: true, issuedAt: true, editUnlockedAt: true, sentAt: true },
+  })
+  if (!record) return false
+  if (!shouldIssue(record, reason)) return false
+
+  const assembly = await assembleInvoicePrint(recordId, { mode: 'live' })
+  if (!assembly) return false
+
+  const [issuedDesignSnapshotId, issuedLogoSnapshotId] = await Promise.all([
+    ensureDesignSnapshot(organizationId, assembly.designSource),
+    assembly.logoDataUri
+      ? ensureAssetSnapshot(organizationId, assembly.logoDataUri)
+      : Promise.resolve(null),
+  ])
+
+  await db.serviceRecord.update({
+    where: { id: recordId },
+    data: {
+      // A backfilled invoice is dated to when it was sent, which is the
+      // nearest thing to when it was issued that the record knows.
+      issuedAt: reason === 'backfill' ? (record.sentAt ?? new Date()) : new Date(),
+      issuedDesignSnapshotId,
+      issuedLogoSnapshotId,
+      issuedData: buildIssuedInvoiceData(assembly) as unknown as Prisma.InputJsonValue,
+    },
+  })
+  return true
+}

+ 192 - 0
src/features/invoices/Lib/issuedInvoice.ts

@@ -0,0 +1,192 @@
+import { z } from 'zod'
+
+/**
+ * What an issued invoice carries with it, beyond its own rows.
+ *
+ * An invoice already owns its lines, amounts, dates and notes. Everything
+ * else it prints was read live from somewhere that keeps changing: the
+ * workshop's address and bank account, the customer's address, the vehicle,
+ * the technician's name, the open findings, the custom field definitions.
+ * Issuing the invoice copies those here, so the sheet the customer holds is
+ * the sheet the workshop can print again in five years.
+ *
+ * The shape is read leniently: unknown keys pass, and everything but the
+ * version is optional. A snapshot written today must still render on the
+ * code of several releases from now, which will have fields this one never
+ * heard of, and must never fail because one of them is missing.
+ */
+
+export const ISSUED_INVOICE_VERSION = 1
+
+const partySchema = z
+  .object({
+    name: z.string(),
+    email: z.string().nullable().optional(),
+    phone: z.string().nullable().optional(),
+    address: z.string().nullable().optional(),
+    company: z.string().nullable().optional(),
+    taxId: z.string().nullable().optional(),
+    customerNumber: z.string().nullable().optional(),
+  })
+  .passthrough()
+
+export const issuedInvoiceDataSchema = z
+  .object({
+    version: z.number().int(),
+    workshop: z
+      .object({
+        name: z.string().default(''),
+        address: z.string().default(''),
+        phone: z.string().default(''),
+        email: z.string().default(''),
+        slogan: z.string().optional(),
+      })
+      .passthrough()
+      .optional(),
+    invoiceSettings: z
+      .object({
+        bankAccount: z.string().optional(),
+        orgNumber: z.string().optional(),
+        paymentTerms: z.string().optional(),
+        footerNote: z.string().optional(),
+        showBankAccount: z.boolean().optional(),
+        showOrgNumber: z.boolean().optional(),
+        dueDays: z.number().optional(),
+        currencyCode: z.string().optional(),
+        currencyFormat: z.enum(['symbol', 'code']).optional(),
+        unitSystem: z.string().optional(),
+        dateFormat: z.string().optional(),
+        timezone: z.string().optional(),
+      })
+      .passthrough()
+      .optional(),
+    serviceType: z.string().optional(),
+    taxLabel: z.string().optional(),
+    customer: partySchema.nullable().optional(),
+    vehicle: z
+      .object({
+        make: z.string(),
+        model: z.string(),
+        year: z.number(),
+        vin: z.string().nullable().optional(),
+        licensePlate: z.string().nullable().optional(),
+        mileage: z.number().optional(),
+      })
+      .passthrough()
+      .nullable()
+      .optional(),
+    technicianName: z.string().nullable().optional(),
+    findings: z
+      .array(
+        z
+          .object({
+            description: z.string(),
+            severity: z.string(),
+            notes: z.string().nullable().optional(),
+          })
+          .passthrough()
+      )
+      .optional(),
+    customFields: z
+      .array(
+        z
+          .object({
+            fieldId: z.string(),
+            label: z.string(),
+            value: z.string(),
+            fieldType: z.string(),
+          })
+          .passthrough()
+      )
+      .optional(),
+  })
+  .passthrough()
+
+type ParsedIssuedInvoiceData = z.infer<typeof issuedInvoiceDataSchema>
+
+export type IssuedInvoiceData = ParsedIssuedInvoiceData & {
+  workshop: NonNullable<ParsedIssuedInvoiceData['workshop']>
+  invoiceSettings: NonNullable<ParsedIssuedInvoiceData['invoiceSettings']>
+}
+
+/**
+ * The stored JSON as data, or null when it cannot be trusted at all. The
+ * two blocks every renderer reads are filled in when a snapshot lacks them,
+ * so callers never branch on their absence.
+ */
+export function readIssuedInvoiceData(raw: unknown): IssuedInvoiceData | null {
+  if (!raw || typeof raw !== 'object') return null
+  const parsed = issuedInvoiceDataSchema.safeParse(raw)
+  if (!parsed.success) return null
+  return {
+    ...parsed.data,
+    workshop: parsed.data.workshop ?? { name: '', address: '', phone: '', email: '' },
+    invoiceSettings: parsed.data.invoiceSettings ?? {},
+  }
+}
+
+export interface IssueMarks {
+  issuedAt: Date | null
+  editUnlockedAt?: Date | null
+}
+
+/**
+ * Whether an owner has reopened the document since it was issued. An unlock
+ * is the one deliberate act that says "this copy is being corrected"; nothing
+ * else, and in particular not sending, reopens an issued invoice.
+ */
+export function reopenedSinceIssue(record: IssueMarks): boolean {
+  return Boolean(
+    record.issuedAt &&
+      record.editUnlockedAt &&
+      record.editUnlockedAt.getTime() > record.issuedAt.getTime()
+  )
+}
+
+/**
+ * Whether the invoice prints from what was issued rather than from live
+ * data. While an unlock is newer than the issue the owner is correcting the
+ * document, and the sheet has to show the correction as they work; sending
+ * it again then issues the corrected copy and freezes it once more.
+ */
+export function rendersFromIssue(record: IssueMarks & { issuedData?: unknown }): boolean {
+  if (!record.issuedAt || record.issuedData == null) return false
+  return !reopenedSinceIssue(record)
+}
+
+/**
+ * Why an invoice is being issued. Sending issues a document that was never
+ * issued, or one an owner reopened; payment, and the freeze a workshop asks
+ * for on invoices that went out before issuing existed, only ever fill an
+ * empty slot.
+ */
+export type IssueReason = 'sent' | 'paid' | 'backfill'
+
+/**
+ * Whether to capture now. An issued invoice keeps its snapshot through every
+ * later send: the customer already holds that copy, and a preview and a
+ * re-send must show the same sheet whatever the workshop has changed since.
+ * The one way to a fresh capture is an owner's unlock followed by a send,
+ * which is the corrected copy going out. The lock setting plays no part, so
+ * a workshop that never turned locking on gets the same guarantee.
+ */
+export function shouldIssue(record: IssueMarks, reason: IssueReason): boolean {
+  if (!record.issuedAt) return true
+  if (reason === 'sent') return reopenedSinceIssue(record)
+  return false
+}
+
+/**
+ * Whether an invoice that predates issuing has already reached the
+ * customer, and so prints from live rows that may since have changed. These
+ * are what invoice settings counts and offers to freeze.
+ */
+export function wasIssuedBeforeTracking(record: {
+  issuedAt: Date | null
+  sentAt: Date | null
+  manuallyPaid: boolean
+  payments?: { amount: number }[]
+}): boolean {
+  if (record.issuedAt) return false
+  return Boolean(record.sentAt || record.manuallyPaid || (record.payments?.length ?? 0) > 0)
+}

+ 21 - 237
src/features/invoices/Pdf/buildInvoicePdfBuffer.ts

@@ -1,245 +1,37 @@
 /**
  * Shared invoice PDF builder.
  *
- * This helper renders an invoice PDF for a given service record id. It is used
- * by both the public share-token PDF route and the customer-portal session PDF
- * route. The caller is responsible for verifying that the requester is allowed
- * to access the record — this helper trusts the caller and only validates that
- * the record exists.
+ * Renders an invoice PDF for a given service record id. Used by the public
+ * share-token PDF route and the customer-portal session PDF route. The
+ * caller is responsible for verifying that the requester is allowed to see
+ * the record; this helper trusts the caller and only checks that it exists.
  *
- * The file is intentionally long because building the PDF requires:
- *   - loading the service record with all related items
- *   - loading findings and custom fields
- *   - loading workshop settings, organization info and the layout config
- *   - resolving the customer locale and PDF translations
- *   - reading the document logo from disk and converting it to a data URI
- *   - optionally generating a Telegram QR code
- *   - resolving Torqvoice branding visibility
- *   - constructing the InvoicePDF React element and rendering it to a buffer
- *
- * Splitting these into separate modules would just spread the same logic
- * across multiple files without making it easier to read, so they live here
- * with a couple of small internal helpers.
+ * What the sheet says comes from assembleInvoicePrint, which reads an issued
+ * invoice from its snapshots and a draft from live rows. What is added here
+ * is not part of the document: the reader's translations, the portal link,
+ * the Telegram code and the Torqvoice mark.
  */
 
 import { renderToBuffer } from '@react-pdf/renderer'
 import '@/features/vehicles/Components/invoice-pdf/fonts'
 import React from 'react'
-import { readFile } from 'fs/promises'
-import { db } from '@/lib/db'
 import { InvoicePDF } from '@/features/vehicles/Components/InvoicePDF'
-import { resolveUploadPath } from '@/lib/resolve-upload-path'
 import { getFeatures } from '@/lib/features'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
-import { formatDateForPdf } from '@/lib/format'
-import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
-import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
-import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
-
-type PdfMessages = Record<string, Record<string, string>>
-
-async function loadPdfMessages(locale: string): Promise<PdfMessages> {
-  try {
-    return (await import(`../../../../messages/${locale}/pdf.json`)).default
-  } catch {
-    return (await import(`../../../../messages/en/pdf.json`)).default
-  }
-}
-
-async function loadLogoDataUri(logoPath: string | undefined): Promise<string | undefined> {
-  if (!logoPath) return undefined
-  try {
-    const fullPath = resolveUploadPath(logoPath)
-    const logoBuffer = await readFile(fullPath)
-    const ext = logoPath.split('.').pop()?.toLowerCase() || 'png'
-    const mimeMap: Record<string, string> = {
-      png: 'image/png',
-      jpg: 'image/jpeg',
-      jpeg: 'image/jpeg',
-      webp: 'image/webp',
-      svg: 'image/svg+xml',
-    }
-    const mime = mimeMap[ext] || 'image/png'
-    return `data:${mime};base64,${logoBuffer.toString('base64')}`
-  } catch {
-    return undefined
-  }
-}
+import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
+import { assembleInvoicePrint, invoiceNumberOf } from '../Lib/assembleInvoicePrint'
 
 export async function buildInvoicePdfBuffer(
   serviceRecordId: string,
   acceptLanguageHeader: string | null
 ): Promise<{ buffer: Uint8Array; filename: string } | null> {
-  const record = await db.serviceRecord.findUnique({
-    where: { id: serviceRecordId },
-    include: {
-      partItems: true,
-      laborItems: true,
-      payments: { orderBy: { date: 'desc' } },
-      // Linked technician (FK) is the source of truth for the tech name;
-      // the legacy `techName` string is only a fallback for old records.
-      technician: { select: { name: true } },
-      customer: {
-        select: {
-          name: true,
-          email: true,
-          phone: true,
-          address: true,
-          company: true,
-          taxId: true,
-          customerNumber: true,
-        },
-      },
-      vehicle: {
-        select: {
-          make: true,
-          model: true,
-          year: true,
-          vin: true,
-          licensePlate: true,
-          mileage: true,
-          userId: true,
-          organizationId: true,
-          customer: {
-            select: {
-              name: true,
-              email: true,
-              phone: true,
-              address: true,
-              company: true,
-              taxId: true,
-              customerNumber: true,
-            },
-          },
-        },
-      },
-    },
-  })
+  const assembly = await assembleInvoicePrint(serviceRecordId)
+  if (!assembly) return null
+  const { record, organizationId: orgId, org, settingsMap, layoutConfig } = assembly
 
-  if (!record) return null
-
-  const orgId = record.organizationId
-  if (!orgId) return null
-
-  // Resolve locale + load PDF translations
   const locale = await resolveCustomerLocale(orgId, acceptLanguageHeader)
-  const pdfMessages = await loadPdfMessages(locale)
-  const labels = {
-    ...pdfMessages.invoice,
-    ...pdfMessages.common,
-  }
-
-  // Fetch findings for this service record (open ones to show on invoice)
-  const findings = await db.vehicleFinding.findMany({
-    where: { serviceRecordId: record.id, status: { not: 'resolved' } },
-    select: { description: true, severity: true, notes: true },
-    orderBy: { createdAt: 'desc' },
-  })
-
-  // Custom fields for this service record (definition defaults included)
-  const customFields = await getCustomFieldsForPrint(orgId, record.id, 'service_record')
-
-  const [settings, org] = await Promise.all([
-    db.appSetting.findMany({
-      where: { organizationId: orgId },
-    }),
-    orgId
-      ? db.organization.findUnique({
-          where: { id: orgId },
-          select: { name: true, portalSlug: true },
-        })
-      : null,
-  ])
-
-  const settingsMap: Record<string, string> = {}
-  for (const s of settings) settingsMap[s.key] = s.value
-
-  // Override labels for marine service type
-  const serviceType = settingsMap['workshop.serviceType'] || 'automotive'
-  if (serviceType === 'marine') {
-    if (pdfMessages.invoice.mileageMarine) labels.mileage = pdfMessages.invoice.mileageMarine
-    if (pdfMessages.invoice.vinMarine) labels.vin = pdfMessages.invoice.vinMarine
-    if (pdfMessages.invoice.plateMarine) labels.plate = pdfMessages.invoice.plateMarine
-    if (pdfMessages.invoice.vehicleMarine) labels.vehicle = pdfMessages.invoice.vehicleMarine
-    // Override unit labels for engine hours
-    labels.km = 'hrs'
-    labels.mi = 'hrs'
-  }
-
-  // Custom tax label override (e.g. "VAT", "MVA", "GST", "MwSt.")
-  const customTaxLabel = settingsMap['workshop.taxLabel']?.trim()
-  if (customTaxLabel) {
-    labels.tax = `${customTaxLabel} ({rate}%)`
-  }
-
-  const logoDataUri = await loadLogoDataUri(documentLogoPath(settingsMap, 'invoice'))
-
-  const invoiceSettings = {
-    bankAccount: settingsMap['invoice.bankAccount'] || '',
-    orgNumber: settingsMap['invoice.orgNumber'] || '',
-    paymentTerms: settingsMap['invoice.paymentTerms'] || '',
-    footerNote: settingsMap['invoice.footerNote'] || '',
-    showBankAccount: settingsMap['invoice.showBankAccount'] === 'true',
-    showOrgNumber: settingsMap['invoice.showOrgNumber'] === 'true',
-    dueDays: Number(settingsMap['invoice.dueDays']) || 0,
-    currencyCode: settingsMap['workshop.currencyCode'] || 'USD',
-    currencyFormat: (settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol') as
-      | 'symbol'
-      | 'code',
-    unitSystem: settingsMap['workshop.unitSystem'] || 'imperial',
-    dateFormat: settingsMap['workshop.dateFormat'] || undefined,
-    timezone: settingsMap['workshop.timezone'] || undefined,
-  }
-
-  const pdfDateFormat = settingsMap['workshop.dateFormat'] || undefined
-  const pdfTimezone = settingsMap['workshop.timezone'] || undefined
-
-  const paidFromPayments = record.payments.reduce(
-    (sum: number, p: { amount: number }) => sum + p.amount,
-    0
-  )
-  const effectiveTotal = record.totalAmount > 0 ? record.totalAmount : record.cost
-  const totalPaidForPdf = record.manuallyPaid ? effectiveTotal : paidFromPayments
-
-  const paymentSummary =
-    record.payments.length > 0 || record.manuallyPaid
-      ? {
-          totalPaid: totalPaidForPdf,
-          payments: record.payments.map((p: { amount: number; date: Date; method: string }) => ({
-            amount: p.amount,
-            date: formatDateForPdf(p.date, pdfDateFormat, pdfTimezone),
-            method: p.method,
-          })),
-        }
-      : undefined
-
-  // Fetch layout config
-  const layoutConfigSetting = await db.appSetting.findUnique({
-    where: { organizationId_key: { organizationId: orgId, key: 'invoice.layoutConfig' } },
-  })
-  const layoutConfig = mergeWithDefaults(
-    layoutConfigSetting?.value ? JSON.parse(layoutConfigSetting.value) : {}
-  )
-
-  const template = {
-    primaryColor: settingsMap['invoice.primaryColor'] || '#d97706',
-    backgroundColor: settingsMap['invoice.backgroundColor'] || undefined,
-    textColor: settingsMap['invoice.textColor'] || undefined,
-    companyTextColor: settingsMap['invoice.companyTextColor'] || undefined,
-    frameBorderColor: settingsMap['invoice.frameBorderColor'] || undefined,
-    frameShadow: settingsMap['invoice.frameShadow'],
-    frameRadius: Number(settingsMap['invoice.frameRadius']) || 0,
-    frameSide: (settingsMap['invoice.frameSide'] === 'right' ? 'right' : 'left') as
-      | 'left'
-      | 'right',
-    fontFamily: settingsMap['invoice.fontFamily'] || 'Helvetica',
-    showLogo: settingsMap['invoice.showLogo'] !== 'false',
-    showCompanyName: settingsMap['invoice.showCompanyName'] !== 'false',
-    headerStyle: settingsMap['invoice.headerStyle'] || 'standard',
-    logoSize: Number(settingsMap['invoice.logoSize']) || 100,
-    layoutConfig,
-  }
+  const labels = await loadPrintLabels(locale, assembly.labelSettings)
 
   // Check if Torqvoice branding should be shown
   const features = await getFeatures(orgId)
@@ -267,18 +59,12 @@ export async function buildInvoicePdfBuffer(
   }
 
   const element = React.createElement(InvoicePDF, {
-    data: { ...record, customFields, findings },
-    workshop: {
-      name: org?.name || '',
-      address: settingsMap['workshop.address'] || '',
-      phone: settingsMap['workshop.phone'] || '',
-      email: settingsMap['workshop.email'] || '',
-      slogan: settingsMap['workshop.slogan'] || undefined,
-    },
-    invoiceSettings,
-    paymentSummary,
-    logoDataUri,
-    template,
+    data: assembly.data,
+    workshop: assembly.workshop,
+    invoiceSettings: assembly.invoiceSettings,
+    paymentSummary: assembly.paymentSummary,
+    logoDataUri: assembly.logoDataUri,
+    template: assembly.template,
     torqvoiceLogoDataUri,
     portalUrl,
     telegramQrDataUri,
@@ -288,10 +74,8 @@ export async function buildInvoicePdfBuffer(
   }) as any
   const buffer = await renderToBuffer(element)
 
-  const invoiceNum = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`
-
   return {
     buffer,
-    filename: `${invoiceNum}.pdf`,
+    filename: `${invoiceNumberOf(record)}.pdf`,
   }
 }

+ 4 - 0
src/features/payments/Actions/paymentActions.ts

@@ -2,6 +2,7 @@
 
 import { toSafeDate } from '@/lib/invoice-utils'
 import { db } from '@/lib/db'
+import { issueInvoice } from '@/features/invoices/Lib/issueInvoice'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { createPaymentSchema } from '../Schema/paymentSchema'
@@ -30,6 +31,9 @@ export async function createPayment(input: unknown) {
           note: data.note || null,
         },
       })
+      // Money against it makes the invoice the customer's document, even one
+      // handed over on paper and never sent through the app.
+      await issueInvoice(data.serviceRecordId, organizationId, 'paid')
 
       revalidatePath(
         serviceRecord.vehicleId

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

@@ -94,9 +94,6 @@ export const SETTING_KEYS = {
   QUOTE_FRAME_SHADOW: 'quote.frameShadow',
   QUOTE_FRAME_SIDE: 'quote.frameSide',
   QUOTE_FRAME_RADIUS: 'quote.frameRadius',
-  /// The workshop's own saved designs: a JSON list of named layout+template
-  /// snapshots the designer can bring back after trying something else.
-  DESIGNER_SAVED_DESIGNS: 'designer.savedDesigns',
   /// What the current design is based on: "preset:<id>" or "design:<id>".
   INVOICE_ACTIVE_DESIGN: 'invoice.activeDesign',
   QUOTE_ACTIVE_DESIGN: 'quote.activeDesign',

+ 10 - 0
src/features/vehicles/Actions/serviceActions.ts

@@ -1,6 +1,7 @@
 'use server'
 
 import { db } from '@/lib/db'
+import { issueInvoice } from '@/features/invoices/Lib/issueInvoice'
 import { withAuth } from '@/lib/with-auth'
 import { createServiceSchema, updateServiceSchema } from '../Schema/serviceSchema'
 import { revalidatePath } from 'next/cache'
@@ -244,6 +245,7 @@ export async function getServiceRecord(recordId: string) {
               address: true,
               company: true,
               telegramChatId: true,
+              invoiceDesignId: true,
             },
           },
           vehicle: {
@@ -264,6 +266,7 @@ export async function getServiceRecord(recordId: string) {
                   address: true,
                   company: true,
                   telegramChatId: true,
+                  invoiceDesignId: true,
                 },
               },
             },
@@ -905,6 +908,9 @@ export async function toggleManuallyPaid(recordId: string) {
         where: { id: recordId },
         data: { manuallyPaid: !record.manuallyPaid },
       })
+      // A paid invoice is the customer's document whether or not it was ever
+      // sent through the app.
+      if (!record.manuallyPaid) await issueInvoice(recordId, organizationId, 'paid')
 
       revalidatePath('/')
       revalidatePath('/services')
@@ -1142,6 +1148,10 @@ export async function generatePublicLink(serviceRecordId: string) {
       })
       if (!record) throw new Error('Record not found')
 
+      // The link is a way of sending the invoice, so what it shows is frozen
+      // now, before the stamp below locks it.
+      await issueInvoice(serviceRecordId, organizationId, 'sent')
+
       const token = randomUUID()
       await db.serviceRecord.update({
         where: { id: serviceRecordId },

+ 5 - 0
src/features/vehicles/Components/service-detail/types.ts

@@ -60,6 +60,10 @@ export interface ServiceDetail {
   // Counter sales (parts-only) have no vehicle and link a customer directly.
   vehicle: Vehicle | null
   customer: Vehicle['customer']
+  /** The design this invoice chose; null follows the default. */
+  designId?: string | null
+  /** When the invoice was issued and its sheet frozen. */
+  issuedAt?: Date | null
 }
 
 export interface PartItem {
@@ -116,6 +120,7 @@ export interface Vehicle {
     address: string | null
     company: string | null
     telegramChatId: string | null
+    invoiceDesignId?: string | null
   } | null
 }
 

+ 84 - 0
src/features/vehicles/Components/service-edit/InvoiceDetailsSection.tsx

@@ -1,6 +1,7 @@
 'use client'
 
 import { useEffect, useState } from 'react'
+import type { DesignAutoRule } from '@/features/invoice-designer/Lib/designRules'
 import { useTranslations } from 'next-intl'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
@@ -14,9 +15,20 @@ import {
   SelectValue,
 } from '@/components/ui/select'
 import { Check, Loader2 } from 'lucide-react'
+import { useRouter } from 'next/navigation'
+import { toast } from 'sonner'
 import { cn } from '@/lib/utils'
+import { setInvoiceDesign } from '@/features/invoices/Actions/invoiceDesignActions'
 import type { InitialData } from './form-types'
 
+export interface DesignOption {
+  id: string
+  name: string
+}
+
+/** The value the picker uses for "follow the default": Radix reserves "". */
+const FOLLOW_DEFAULT = '__default__'
+
 interface InvoiceDetailsSectionProps {
   initialData: InitialData
   type: string
@@ -29,6 +41,16 @@ interface InvoiceDetailsSectionProps {
   paymentStatus: string
   onTogglePaid: () => void
   paymentLoading?: boolean
+  /** The workshop's saved invoice designs. No picker when there are none. */
+  designOptions?: DesignOption[]
+  /** The design this invoice chose, or null to follow the default. */
+  designId?: string | null
+  /** What "default" resolves to for this invoice, when it has a name. */
+  designFollowsName?: string | null
+  /** When the sheet was frozen, ISO. Set only while it prints from that copy. */
+  designPinnedAt?: string | null
+  /** Set when a design rule, not the customer or the default, is what "default" means here. */
+  designFollowsRule?: DesignAutoRule | null
 }
 
 export function InvoiceDetailsSection({
@@ -42,8 +64,37 @@ export function InvoiceDetailsSection({
   paymentStatus,
   onTogglePaid,
   paymentLoading,
+  designOptions = [],
+  designId = null,
+  designFollowsName = null,
+  designPinnedAt = null,
+  designFollowsRule = null,
 }: InvoiceDetailsSectionProps) {
   const t = useTranslations('service.basicInfo')
+  const router = useRouter()
+  // Saved on change rather than with the form: the choice is its own edit,
+  // the way the schedule card's technician is, and must not wait for a save
+  // of lines it has nothing to do with.
+  const [design, setDesign] = useState(designId ?? FOLLOW_DEFAULT)
+  const [savingDesign, setSavingDesign] = useState(false)
+  useEffect(() => {
+    setDesign(designId ?? FOLLOW_DEFAULT)
+  }, [designId])
+
+  const changeDesign = async (value: string) => {
+    const previous = design
+    setDesign(value)
+    setSavingDesign(true)
+    const result = await setInvoiceDesign(initialData.id, value === FOLLOW_DEFAULT ? null : value)
+    setSavingDesign(false)
+    if (result.success) {
+      toast.success(t('designSaved'))
+      router.refresh()
+    } else {
+      setDesign(previous)
+      toast.error(t('designSaveFailed'))
+    }
+  }
   // ISO YYYY-MM-DD strings, matching the native date input's value format
   const [invoiceDate, setInvoiceDate] = useState(initialData.invoiceDate || '')
   const [invoiceDueDate, setInvoiceDueDate] = useState(initialData.invoiceDueDate || '')
@@ -179,6 +230,39 @@ export function InvoiceDetailsSection({
           />
         </div>
       </div>
+
+      {designOptions.length > 0 && (
+        <div className="space-y-1">
+          <Label className="text-xs">{t('design')}</Label>
+          <Select value={design} onValueChange={(v) => void changeDesign(v)}>
+            <SelectTrigger className="w-full" disabled={savingDesign}>
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value={FOLLOW_DEFAULT}>
+                {designFollowsName && designFollowsRule
+                  ? t('designFollowingRule', {
+                      name: designFollowsName,
+                      rule: t(`designRule.${designFollowsRule}`),
+                    })
+                  : designFollowsName
+                    ? t('designFollowing', { name: designFollowsName })
+                    : t('designDefault')}
+              </SelectItem>
+              {designOptions.map((option) => (
+                <SelectItem key={option.id} value={option.id}>
+                  {option.name}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+          {designPinnedAt && (
+            <p className="text-xs text-muted-foreground">
+              {t('designPinned', { date: new Date(designPinnedAt).toLocaleDateString() })}
+            </p>
+          )}
+        </div>
+      )}
     </div>
   )
 }

+ 15 - 1
src/features/vehicles/Components/service-page/DetailsRightColumn.tsx

@@ -1,8 +1,9 @@
 import { useRouter } from 'next/navigation'
+import type { DesignAutoRule } from '@/features/invoice-designer/Lib/designRules'
 import { useTranslations } from 'next-intl'
 import { MessageSquare } from 'lucide-react'
 import { SharedLinkCard } from '@/components/shared-link-card'
-import { InvoiceDetailsSection } from '../service-edit/InvoiceDetailsSection'
+import { InvoiceDetailsSection, type DesignOption } from '../service-edit/InvoiceDetailsSection'
 import { BasicInfoSection } from '../service-edit/BasicInfoSection'
 import { ScheduleTimesSection } from '../service-edit/ScheduleTimesSection'
 import { TotalsSection } from '../service-edit/TotalsSection'
@@ -43,6 +44,10 @@ interface DetailsRightColumnProps {
     toNumber: string
   }[]
   videoCall?: ServiceVideoCall
+  designOptions?: DesignOption[]
+  designFollowsName?: string | null
+  designPinnedAt?: string | null
+  designFollowsRule?: DesignAutoRule | null
 }
 
 export function DetailsRightColumn({
@@ -59,6 +64,10 @@ export function DetailsRightColumn({
   orgMembers,
   notificationHistory = [],
   videoCall,
+  designOptions = [],
+  designFollowsName = null,
+  designPinnedAt = null,
+  designFollowsRule = null,
 }: DetailsRightColumnProps) {
   const router = useRouter()
 
@@ -89,6 +98,11 @@ export function DetailsRightColumn({
         paymentStatus={formState.paymentStatus}
         onTogglePaid={actions.handleTogglePaid}
         paymentLoading={actions.paymentLoading}
+        designOptions={designOptions}
+        designId={record.designId ?? null}
+        designFollowsName={designFollowsName}
+        designPinnedAt={designPinnedAt}
+        designFollowsRule={designFollowsRule}
       />
       <BasicInfoSection
         initialData={formState.initialData}

+ 8 - 0
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -96,6 +96,10 @@ export function ServicePageClient({
   findings = [],
   openObservations = [],
   notificationHistory = [],
+  designOptions = [],
+  designFollowsName = null,
+  designPinnedAt = null,
+  designFollowsRule = null,
 }: ServicePageClientProps) {
   const t = useTranslations('service')
   const router = useRouter()
@@ -455,6 +459,10 @@ export function ServicePageClient({
                     workBays={workBays}
                     orgMembers={orgMembers}
                     notificationHistory={notificationHistory}
+                    designOptions={designOptions}
+                    designFollowsName={designFollowsName}
+                    designPinnedAt={designPinnedAt}
+                    designFollowsRule={designFollowsRule}
                   />
                 }
               />

+ 39 - 0
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -10,6 +10,10 @@ import { getLaborPresetsList } from '@/features/labor-presets/Actions/laborPrese
 import { getTechnicians, getOrgMembers } from '@/features/workboard/Actions/technicianActions'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getInvoiceLockState } from '@/lib/document-lock.server'
+import {
+  designRuleSubjectOf,
+  findRuleDesign,
+} from '@/features/invoice-designer/Lib/designRules.server'
 import { getFeatures } from '@/lib/features'
 import { getTireHotelSettings } from '@/features/tire-hotel/Lib/tireHotelSettings'
 import { getStatusReportsForService } from '@/features/status-reports/Actions/getStatusReportsForService'
@@ -17,6 +21,8 @@ import { getServiceFindings } from '@/features/vehicles/Actions/findingActions'
 import { db } from '@/lib/db'
 import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
 import { ServicePageClient } from '@/features/vehicles/Components/service-page/ServicePageClient'
+import { listDesignOptions } from '@/features/invoice-designer/Actions/documentDesignActions'
+import { rendersFromIssue } from '@/features/invoices/Lib/issuedInvoice'
 import { PageHeader } from '@/components/page-header'
 import { getTranslations } from 'next-intl/server'
 
@@ -45,6 +51,7 @@ export async function ServiceRecordPage({
     findingsResult,
     workBaysResult,
     videoCallResult,
+    designOptionsResult,
   ] = await Promise.all([
     getServiceRecord(serviceId),
     getSettings([
@@ -56,6 +63,7 @@ export async function ServiceRecordPage({
       SETTING_KEYS.INVOICE_DUE_DAYS,
       SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT,
       SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY,
+      SETTING_KEYS.INVOICE_ACTIVE_DESIGN,
     ]),
     getInventoryPartsList(),
     getTechnicians(),
@@ -67,6 +75,7 @@ export async function ServiceRecordPage({
     getServiceFindings(serviceId),
     getWorkBays(),
     getServiceVideoCall(serviceId),
+    listDesignOptions('invoice'),
   ])
 
   if (!result.success || !result.data) {
@@ -144,6 +153,32 @@ export async function ServiceRecordPage({
   ])
 
   const currentUserName = currentUser?.name || ''
+
+  // The picker on the invoice: the saved designs, what "default" means for
+  // this invoice (the customer's design, else the one in use), and whether
+  // the sheet is frozen at an issue.
+  const designOptions =
+    designOptionsResult.success && designOptionsResult.data ? designOptionsResult.data : []
+  const customerDesignId =
+    record.customer?.invoiceDesignId ?? record.vehicle?.customer?.invoiceDesignId ?? null
+  const activeDesign = settings[SETTING_KEYS.INVOICE_ACTIVE_DESIGN] || ''
+  const activeDesignId = activeDesign.startsWith('design:')
+    ? activeDesign.slice('design:'.length)
+    : null
+  // What "default" means for this invoice: the customer's design, else the
+  // design that volunteers for this kind of invoice, else the one in use.
+  const customerDesignName = designOptions.find((d) => d.id === customerDesignId)?.name ?? null
+  const ruleDesign =
+    !customerDesignName && organizationId
+      ? await findRuleDesign(organizationId, designRuleSubjectOf(record))
+      : null
+  const designFollowsName =
+    customerDesignName ??
+    ruleDesign?.name ??
+    designOptions.find((d) => d.id === activeDesignId)?.name ??
+    null
+  const designFollowsRule = ruleDesign?.autoRule ?? null
+  const designPinnedAt = rendersFromIssue(record) ? (record.issuedAt?.toISOString() ?? null) : null
   const aiSettingsMap = Object.fromEntries(aiSettings.map((s) => [s.key, s.value]))
   const aiEnabled =
     features?.ai === true &&
@@ -334,6 +369,10 @@ export async function ServiceRecordPage({
           ...n,
           createdAt: n.createdAt.toISOString(),
         }))}
+        designOptions={designOptions}
+        designFollowsName={designFollowsName}
+        designFollowsRule={designFollowsRule}
+        designPinnedAt={designPinnedAt}
       />
     </div>
   )

+ 9 - 0
src/features/vehicles/Components/service-page/service-page-types.ts

@@ -1,3 +1,4 @@
+import type { DesignAutoRule } from '@/features/invoice-designer/Lib/designRules'
 import type { ServiceVideoCall } from '@/features/integrations/Actions/integrationActions'
 import type { LockState } from '@/lib/document-lock'
 import type { ServicePartInput, ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
@@ -100,6 +101,14 @@ export interface ServicePageClientProps {
     sentAt: string | null
   }[]
   initialTab?: string
+  /** The workshop's saved invoice designs, for the picker on the invoice. */
+  designOptions?: { id: string; name: string }[]
+  /** What the invoice's "default" design resolves to, when it has a name. */
+  designFollowsName?: string | null
+  /** When the sheet was frozen, ISO, while it prints from that copy. */
+  designPinnedAt?: string | null
+  /** The rule that picked the default design, when a rule did. */
+  designFollowsRule?: DesignAutoRule | null
   findings?: {
     id: string
     description: string

+ 25 - 0
src/lib/backup/manifest.ts

@@ -52,6 +52,31 @@ export interface BackupEntity {
 
 export const BACKUP_ENTITIES: readonly BackupEntity[] = [
   { model: 'AppSetting', key: 'settings', option: 'settings', restore: 'replace', clearOrder: 90 },
+  // Named designs go with settings, which is where their default lives.
+  // Cleared before settings and after everything that points at them.
+  {
+    model: 'DocumentDesign',
+    key: 'documentDesigns',
+    option: 'settings',
+    restore: 'replace',
+    clearOrder: 88,
+  },
+  // What issued invoices were issued with. Service records point at these
+  // with Restrict, so they clear only after every service record has.
+  {
+    model: 'DocumentDesignSnapshot',
+    key: 'documentDesignSnapshots',
+    option: 'vehicles',
+    restore: 'replace',
+    clearOrder: 55,
+  },
+  {
+    model: 'DocumentAssetSnapshot',
+    key: 'documentAssetSnapshots',
+    option: 'vehicles',
+    restore: 'replace',
+    clearOrder: 56,
+  },
   { model: 'Customer', key: 'customers', option: 'customers', restore: 'replace', clearOrder: 80 },
   {
     model: 'CustomFieldDefinition',

+ 18 - 1
src/lib/document-lock.server.ts

@@ -98,7 +98,24 @@ export async function assertQuoteEditable(quoteId: string, organizationId: strin
  * wrong for everyone who only uses that channel. Every send counts, not just
  * the first, so re-issuing after an unlock locks the corrected copy too.
  */
-export async function markInvoiceSent(recordId: string, organizationId: string) {
+export async function markInvoiceSent(
+  recordId: string,
+  organizationId: string,
+  options: {
+    /**
+     * The caller already issued the invoice, to render the copy it sent from
+     * the same snapshot. Skips the second, identical capture.
+     */
+    alreadyIssued?: boolean
+  } = {}
+) {
+  // Issued before the stamp, so what the customer receives and what is kept
+  // are the same copy. An invoice already issued is left as it is, unless an
+  // owner unlocked it since; see shouldIssue.
+  if (!options.alreadyIssued) {
+    const { issueInvoice } = await import('@/features/invoices/Lib/issueInvoice')
+    await issueInvoice(recordId, organizationId, 'sent')
+  }
   await db.serviceRecord.updateMany({
     where: { id: recordId, organizationId },
     data: { sentAt: new Date() },