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

Let a new design reach invoices that were already sent (#400)

A sent invoice prints from the copy it was frozen with, so changing the
design only ever reached invoices sent from then on. Move just the look:
issuedData and issuedAt stay, so the details, terms and totals on paper a
customer holds do not move.

Two ways in. Settings > Invoice re-applies across the archive, cursor
batched, behind a warning and a destructive confirm. An "Invoice design"
submenu on the invoice, in a new overflow menu that also takes Delete out
of the toolbar, says when the invoice went out and which design it prints
with, then offers every saved design. The tick is worked out by hashing,
since nothing on the record names the frozen design.

Also fixes two lines that told people the wrong thing: the design picker
claimed "updated" on an invoice that still prints the old design, and its
hint said unlock-and-resend was the only route.
Bernt Christian Egeland 2 недель назад
Родитель
Сommit
f0380fcc07
46 измененных файлов с 1225 добавлено и 40 удалено
  1. 3 0
      messages/de/audit.json
  2. 16 2
      messages/de/service.json
  3. 10 0
      messages/de/settings.json
  4. 3 0
      messages/en/audit.json
  5. 16 2
      messages/en/service.json
  6. 10 0
      messages/en/settings.json
  7. 3 0
      messages/es/audit.json
  8. 16 2
      messages/es/service.json
  9. 10 0
      messages/es/settings.json
  10. 3 0
      messages/fr/audit.json
  11. 16 2
      messages/fr/service.json
  12. 10 0
      messages/fr/settings.json
  13. 3 0
      messages/it/audit.json
  14. 16 2
      messages/it/service.json
  15. 10 0
      messages/it/settings.json
  16. 3 0
      messages/lt/audit.json
  17. 16 2
      messages/lt/service.json
  18. 10 0
      messages/lt/settings.json
  19. 3 0
      messages/nb/audit.json
  20. 16 2
      messages/nb/service.json
  21. 10 0
      messages/nb/settings.json
  22. 3 0
      messages/nl/audit.json
  23. 16 2
      messages/nl/service.json
  24. 10 0
      messages/nl/settings.json
  25. 3 0
      messages/pl/audit.json
  26. 16 2
      messages/pl/service.json
  27. 10 0
      messages/pl/settings.json
  28. 3 0
      messages/pt-BR/audit.json
  29. 16 2
      messages/pt-BR/service.json
  30. 10 0
      messages/pt-BR/settings.json
  31. 3 0
      messages/ru/audit.json
  32. 16 2
      messages/ru/service.json
  33. 10 0
      messages/ru/settings.json
  34. 3 0
      messages/tr/audit.json
  35. 16 2
      messages/tr/service.json
  36. 10 0
      messages/tr/settings.json
  37. 164 0
      src/__tests__/features/invoices/reapply-design.test.ts
  38. 85 1
      src/app/(authenticated)/settings/invoice/invoice-settings.tsx
  39. 7 1
      src/app/(authenticated)/settings/invoice/page.tsx
  40. 136 0
      src/features/invoices/Actions/invoiceDesignActions.ts
  41. 47 7
      src/features/invoices/Lib/assembleInvoicePrint.ts
  42. 218 0
      src/features/invoices/Lib/reapplyDesign.ts
  43. 4 1
      src/features/vehicles/Components/service-edit/InvoiceDetailsSection.tsx
  44. 158 0
      src/features/vehicles/Components/service-page/InvoiceDesignMenu.tsx
  45. 12 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  46. 46 6
      src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx

+ 3 - 0
messages/de/audit.json

@@ -168,6 +168,8 @@
     "settings_deleteEmailTemplate": "E-Mail-Vorlage gelöscht",
     "settings_applyEmailTemplate": "Standard-E-Mail-Vorlage festgelegt",
     "settings_freezeInvoices": "Ältere Rechnungen gesperrt",
+    "settings_reapplyInvoiceDesign": "Rechnungsdesign erneut angewendet",
+    "invoice_reapplyDesign": "Rechnungsdesign erneut angewendet",
     "inspection_reminders_sent": "HU-Erinnerungen gesendet",
     "inspection_reminders_send": "HU-Erinnerungen gesendet",
     "service_videoCall_send": "Link zum Videoanruf gesendet",
@@ -312,6 +314,7 @@
     "settings_deleteEmailTemplate": "Hat die E-Mail-Vorlage {name} gelöscht",
     "settings_applyEmailTemplate": "Hat {name} als Vorlage für {kind}-E-Mails festgelegt",
     "settings_freezeInvoices": "Hat {count, plural, one {# Rechnung} other {# Rechnungen}} von vor der Sperre mit dem aktuellen Design gesperrt",
+    "settings_reapplyInvoiceDesign": "Hat das aktuelle Design erneut auf {count, plural, one {# bereits versendete Rechnung} other {# bereits versendete Rechnungen}} angewendet",
     "inspection_reminders_sent": "{count} HU-Erinnerungen gesendet",
     "service_videoCall_send": "Link zum Videoanruf per {channels} gesendet",
     "timeEntry_create": "{minutes} Minuten für {technician} auf \"{job}\" hinzugefügt",

+ 16 - 2
messages/de/service.json

@@ -41,7 +41,20 @@
     "unpaid": "Unbezahlt",
     "unsavedChanges": "Nicht gespeicherte Änderungen",
     "saved": "Gespeichert",
-    "joinCall": "Videoanruf beitreten"
+    "joinCall": "Videoanruf beitreten",
+    "moreActions": "Weitere Aktionen",
+    "designMenu": "Rechnungsdesign",
+    "designMenuLoading": "Prüfe, mit welchem Design diese Rechnung gedruckt wird ...",
+    "designMenuExplain": "Am {date} an den Kunden gegangen, daher wird sie weiterhin mit dem damaligen Design gedruckt. Ein anderes zu wählen ändert nur das Aussehen. Was die Rechnung sagt, bleibt wie versendet, und das alte Aussehen wird nicht aufbewahrt.",
+    "designSentWith": "Das Design beim Versand",
+    "designFollow": "Aktueller Standard",
+    "designFollowNamed": "Aktueller Standard ({name})",
+    "designDefaultName": "den aktuellen Standard",
+    "designPickConfirmTitle": "Diese Rechnung auf {name} umstellen?",
+    "designPickConfirmBody": "Nur das Aussehen ändert sich. Werkstattangaben, Kunde, Zahlungsbedingungen und Beträge bleiben genau so, wie sie versendet wurden. Das jetzige Design wird nicht aufbewahrt, das lässt sich also nicht rückgängig machen.",
+    "designPickConfirm": "Design ändern",
+    "designPickDone": "Die Rechnung wird jetzt mit {name} gedruckt",
+    "designPickFailed": "Design konnte nicht geändert werden"
   },
   "basicInfo": {
     "title": "Grundlegende Informationen",
@@ -89,8 +102,9 @@
     "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.",
+    "designPinned": "Diese Rechnung erreichte den Kunden am {date} und behält daher das Design, mit dem sie hinausging. Eine Auswahl hier greift erst, nachdem sie entsperrt und erneut versendet wurde. Ein Inhaber kann das Design jetzt über Rechnungsdesign im ⋮-Menü oben ändern.",
     "designSaved": "Rechnungsdesign aktualisiert",
+    "designSavedPinned": "Gespeichert. Diese Rechnung behält das Design, mit dem sie versendet wurde, bis sie entsperrt und erneut versendet wird.",
     "designFollowingRule": "Standard ({name}, {rule})",
     "designRule": {
       "parts_sale": "Reiner Teileverkauf"

+ 10 - 0
messages/de/settings.json

@@ -600,6 +600,16 @@
     "freezeProgress": "{done} von {total} gesperrt",
     "freezeDone": "{count, plural, one {# Rechnung} other {# Rechnungen}} gesperrt",
     "freezeFailed": "Rechnungen konnten nicht gesperrt werden",
+    "reapplyTitle": "Design auf bereits versendeten Rechnungen",
+    "reapplyDescription": "Das heute verwendete Design auf Rechnungen anwenden, die auf ein älteres festgelegt sind.",
+    "reapplyBody": "{count, plural, one {# Rechnung ist} other {# Rechnungen sind}} auf das Design festgelegt, mit dem sie versendet wurde, daher erreicht ein neues Design nur ab jetzt versendete Rechnungen. Das erneute Anwenden legt das heutige Design auf alle. Nur das Aussehen ändert sich: Werkstattangaben, Kunde, Zahlungsbedingungen und Beträge jeder Rechnung bleiben, wie sie versendet wurden.",
+    "reapplyWarning": "Das lässt sich nicht rückgängig machen. Das zuvor verwendete Design wird nicht aufbewahrt.",
+    "reapplyButton": "Aktuelles Design erneut anwenden",
+    "reapplyConfirmTitle": "Design auf {count, plural, one {# Rechnung} other {# Rechnungen}} erneut anwenden?",
+    "reapplyConfirmBody": "Jede Rechnung, die bereits an einen Kunden ging, wechselt zum aktuellen Design. Nur das Aussehen ändert sich: Angaben, Bedingungen und Beträge jeder Rechnung bleiben, wie sie versendet wurden. Das lässt sich nicht rückgängig machen, und das zuvor verwendete Design kann nicht wiederhergestellt werden.",
+    "reapplyProgress": "{done} von {total} aktualisiert",
+    "reapplyDone": "{count, plural, one {# Rechnung verwendet} other {# Rechnungen verwenden}} jetzt das aktuelle Design",
+    "reapplyFailed": "Design konnte nicht erneut angewendet werden",
     "sectionSending": "Versand",
     "attachPdfLabel": "PDF an E-Mails anhängen",
     "attachPdfHint": "Die Vorgabe für jede Rechnung, jedes Angebot und jede Prüfung, die Sie per E-Mail senden. Ein: Der Kunde erhält das PDF als Anhang. Aus: Er erhält stattdessen einen Link zum Dokument, und der Aufrufzähler zeigt, wann es geöffnet wurde, damit Sie bei den unbeachteten nachfassen können. Für einen einzelnen Versand können Sie das im Teilen-Dialog ändern."

+ 3 - 0
messages/en/audit.json

@@ -168,6 +168,8 @@
     "settings_deleteEmailTemplate": "Deleted Email Template",
     "settings_applyEmailTemplate": "Set Default Email Template",
     "settings_freezeInvoices": "Locked Older Invoices",
+    "settings_reapplyInvoiceDesign": "Re-applied Invoice Design",
+    "invoice_reapplyDesign": "Re-applied Invoice Design",
     "inspection_reminders_sent": "Sent inspection reminders",
     "inspection_reminders_send": "Sent inspection reminders",
     "service_videoCall_send": "Sent video call link",
@@ -312,6 +314,7 @@
     "settings_deleteEmailTemplate": "Deleted the email template {name}",
     "settings_applyEmailTemplate": "Made {name} the template for {kind} emails",
     "settings_freezeInvoices": "Locked {count, plural, one {# invoice} other {# invoices}} from before locking with the current design",
+    "settings_reapplyInvoiceDesign": "Re-applied the current design to {count, plural, one {# invoice} other {# invoices}} already sent",
     "inspection_reminders_sent": "Sent {count} inspection reminders",
     "service_videoCall_send": "Sent the video call link via {channels}",
     "timeEntry_create": "Added {minutes} minutes for {technician} on \"{job}\"",

+ 16 - 2
messages/en/service.json

@@ -41,7 +41,20 @@
     "unpaid": "Unpaid",
     "unsavedChanges": "Unsaved changes",
     "saved": "Saved",
-    "joinCall": "Join video call"
+    "joinCall": "Join video call",
+    "moreActions": "More actions",
+    "designMenu": "Invoice design",
+    "designMenuLoading": "Checking which design this invoice prints with...",
+    "designMenuExplain": "Sent to the customer on {date}, so it still prints with the design it was sent with. Choosing another changes only how it looks. What it says stays as it was sent, and the old look is not kept.",
+    "designSentWith": "The design it was sent with",
+    "designFollow": "Follow the current default",
+    "designFollowNamed": "Follow the current default ({name})",
+    "designDefaultName": "the current default",
+    "designPickConfirmTitle": "Change this invoice to {name}?",
+    "designPickConfirmBody": "Only the look changes. The workshop details, the customer, the payment terms and the totals stay exactly as they were sent. The design it prints with now is not kept, so this cannot be undone.",
+    "designPickConfirm": "Change the design",
+    "designPickDone": "This invoice now prints with {name}",
+    "designPickFailed": "Could not change the design"
   },
   "basicInfo": {
     "title": "Basic Information",
@@ -89,8 +102,9 @@
     "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.",
+    "designPinned": "This invoice reached the customer on {date}, so it keeps the design it went out with. A choice here applies only after it is unlocked and sent again. An owner can change the design now from Invoice design in the ⋮ menu at the top.",
     "designSaved": "Invoice design updated",
+    "designSavedPinned": "Saved. This invoice keeps the design it was sent with until it is unlocked and sent again.",
     "designFollowingRule": "Default ({name}, {rule})",
     "designRule": {
       "parts_sale": "Parts-Only Sale"

+ 10 - 0
messages/en/settings.json

@@ -600,6 +600,16 @@
     "freezeProgress": "Locked {done} of {total}",
     "freezeDone": "{count, plural, one {# invoice} other {# invoices}} locked",
     "freezeFailed": "Could not lock the invoices",
+    "reapplyTitle": "Design on invoices already sent",
+    "reapplyDescription": "Put the design you use today on invoices that were locked to an older one.",
+    "reapplyBody": "{count, plural, one {# invoice is} other {# invoices are}} locked to the design each was sent with, so a new design reaches only invoices sent from now on. Re-applying puts the design in use today on all of them. Only the look changes: the workshop details, customer, payment terms and totals each invoice was sent with stay as they are.",
+    "reapplyWarning": "This cannot be undone. The design each invoice used before is not kept.",
+    "reapplyButton": "Re-apply the current design",
+    "reapplyConfirmTitle": "Re-apply the design to {count, plural, one {# invoice} other {# invoices}}?",
+    "reapplyConfirmBody": "Every invoice that has already gone to a customer switches to the design in use now. Only the look changes: the details, terms and totals each invoice was sent with stay as they are. This cannot be undone, and the design each invoice used before cannot be restored.",
+    "reapplyProgress": "Re-applied {done} of {total}",
+    "reapplyDone": "{count, plural, one {# invoice} other {# invoices}} now use the current design",
+    "reapplyFailed": "Could not re-apply the design",
     "sectionSending": "Sending",
     "attachPdfLabel": "Attach the PDF to emails",
     "attachPdfHint": "The default for every invoice, quote and inspection you send by email. On, the customer gets the PDF attached. Off, they get a link to the document instead, and the view counter tells you when they opened it, so you can chase the ones nobody has looked at. You can change it for a single send in the share dialog."

+ 3 - 0
messages/es/audit.json

@@ -168,6 +168,8 @@
     "settings_deleteEmailTemplate": "Plantilla de correo eliminada",
     "settings_applyEmailTemplate": "Plantilla de correo predeterminada establecida",
     "settings_freezeInvoices": "Facturas anteriores bloqueadas",
+    "settings_reapplyInvoiceDesign": "Diseño de factura vuelto a aplicar",
+    "invoice_reapplyDesign": "Diseño de factura vuelto a aplicar",
     "inspection_reminders_sent": "Envió recordatorios de inspección",
     "inspection_reminders_send": "Envió recordatorios de inspección",
     "service_videoCall_send": "Enlace de videollamada enviado",
@@ -312,6 +314,7 @@
     "settings_deleteEmailTemplate": "Eliminó la plantilla de correo {name}",
     "settings_applyEmailTemplate": "Estableció {name} como plantilla para los correos de {kind}",
     "settings_freezeInvoices": "Bloqueó {count, plural, one {# factura} other {# facturas}} anteriores al bloqueo con el diseño actual",
+    "settings_reapplyInvoiceDesign": "Volvió a aplicar el diseño actual a {count, plural, one {# factura} other {# facturas}} ya enviadas",
     "inspection_reminders_sent": "Envió {count} recordatorios de inspección",
     "service_videoCall_send": "Envió el enlace de la videollamada por {channels}",
     "timeEntry_create": "Se añadieron {minutes} minutos para {technician} en \"{job}\"",

+ 16 - 2
messages/es/service.json

@@ -41,7 +41,20 @@
     "unpaid": "Sin Pagar",
     "unsavedChanges": "Cambios sin guardar",
     "saved": "Guardado",
-    "joinCall": "Unirse a la videollamada"
+    "joinCall": "Unirse a la videollamada",
+    "moreActions": "Más acciones",
+    "designMenu": "Diseño de la factura",
+    "designMenuLoading": "Comprobando con qué diseño se imprime esta factura...",
+    "designMenuExplain": "Se envió al cliente el {date}, así que sigue imprimiéndose con el diseño del envío. Elegir otro solo cambia el aspecto. Lo que dice la factura se mantiene tal como se envió, y el aspecto anterior no se conserva.",
+    "designSentWith": "El diseño con el que se envió",
+    "designFollow": "Seguir el diseño predeterminado",
+    "designFollowNamed": "Seguir el diseño predeterminado ({name})",
+    "designDefaultName": "el diseño predeterminado",
+    "designPickConfirmTitle": "¿Cambiar esta factura a {name}?",
+    "designPickConfirmBody": "Solo cambia el aspecto. Los datos del taller, el cliente, las condiciones de pago y los importes se mantienen tal como se enviaron. El diseño actual no se conserva, así que esto no se puede deshacer.",
+    "designPickConfirm": "Cambiar el diseño",
+    "designPickDone": "La factura ya se imprime con {name}",
+    "designPickFailed": "No se pudo cambiar el diseño"
   },
   "basicInfo": {
     "title": "Información Básica",
@@ -89,8 +102,9 @@
     "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.",
+    "designPinned": "Esta factura llegó al cliente el {date}, así que mantiene el diseño con el que salió. Una elección aquí se aplica solo después de desbloquearla y volver a enviarla. Un propietario puede cambiar el diseño ahora desde Diseño de la factura en el menú ⋮ de arriba.",
     "designSaved": "Diseño de factura actualizado",
+    "designSavedPinned": "Guardado. Esta factura mantiene el diseño con el que se envió hasta que se desbloquee y se vuelva a enviar.",
     "designFollowingRule": "Predeterminado ({name}, {rule})",
     "designRule": {
       "parts_sale": "Venta solo de piezas"

+ 10 - 0
messages/es/settings.json

@@ -600,6 +600,16 @@
     "freezeProgress": "Bloqueadas {done} de {total}",
     "freezeDone": "{count, plural, one {# factura bloqueada} other {# facturas bloqueadas}}",
     "freezeFailed": "No se pudieron bloquear las facturas",
+    "reapplyTitle": "Diseño de las facturas ya enviadas",
+    "reapplyDescription": "Aplica el diseño que usas hoy a las facturas fijadas a uno anterior.",
+    "reapplyBody": "{count, plural, one {# factura está fijada} other {# facturas están fijadas}} al diseño con el que se envió, así que un diseño nuevo solo llega a las facturas que envíes a partir de ahora. Volver a aplicarlo pone el diseño de hoy en todas. Solo cambia el aspecto: los datos del taller, el cliente, las condiciones de pago y los importes de cada factura se mantienen igual.",
+    "reapplyWarning": "Esto no se puede deshacer. El diseño que usaba cada factura no se conserva.",
+    "reapplyButton": "Volver a aplicar el diseño actual",
+    "reapplyConfirmTitle": "¿Volver a aplicar el diseño a {count, plural, one {# factura} other {# facturas}}?",
+    "reapplyConfirmBody": "Todas las facturas que ya fueron a un cliente pasan al diseño en uso ahora. Solo cambia el aspecto: los datos, las condiciones y los importes de cada factura se mantienen igual. No se puede deshacer y el diseño anterior no se podrá recuperar.",
+    "reapplyProgress": "{done} de {total} actualizadas",
+    "reapplyDone": "{count, plural, one {# factura usa} other {# facturas usan}} ahora el diseño actual",
+    "reapplyFailed": "No se pudo volver a aplicar el diseño",
     "sectionSending": "Envío",
     "attachPdfLabel": "Adjuntar el PDF a los correos",
     "attachPdfHint": "El valor predeterminado para cada factura, presupuesto e inspección que envíes por correo. Activado: el cliente recibe el PDF adjunto. Desactivado: recibe un enlace al documento y el contador de visitas te dice cuándo lo abrió, para que puedas insistir con los que nadie ha mirado. Puedes cambiarlo para un envío concreto en el diálogo de compartir."

+ 3 - 0
messages/fr/audit.json

@@ -168,6 +168,8 @@
     "settings_deleteEmailTemplate": "Modèle d'e-mail supprimé",
     "settings_applyEmailTemplate": "Modèle d'e-mail par défaut défini",
     "settings_freezeInvoices": "Anciennes factures verrouillées",
+    "settings_reapplyInvoiceDesign": "Design de facture réappliqué",
+    "invoice_reapplyDesign": "Design de facture réappliqué",
     "inspection_reminders_sent": "Rappels de contrôle envoyés",
     "inspection_reminders_send": "Rappels de contrôle envoyés",
     "service_videoCall_send": "Lien d'appel vidéo envoyé",
@@ -312,6 +314,7 @@
     "settings_deleteEmailTemplate": "A supprimé le modèle d'e-mail {name}",
     "settings_applyEmailTemplate": "A défini {name} comme modèle pour les e-mails {kind}",
     "settings_freezeInvoices": "A verrouillé {count, plural, one {# facture} other {# factures}} antérieures au verrouillage avec le design actuel",
+    "settings_reapplyInvoiceDesign": "A réappliqué le design actuel à {count, plural, one {# facture} other {# factures}} déjà envoyées",
     "inspection_reminders_sent": "{count} rappels de contrôle envoyés",
     "service_videoCall_send": "A envoyé le lien de l'appel vidéo par {channels}",
     "timeEntry_create": "{minutes} minutes ajoutées pour {technician} sur « {job} »",

+ 16 - 2
messages/fr/service.json

@@ -41,7 +41,20 @@
     "unpaid": "Non Payé",
     "unsavedChanges": "Modifications non enregistrées",
     "saved": "Enregistré",
-    "joinCall": "Rejoindre l'appel vidéo"
+    "joinCall": "Rejoindre l'appel vidéo",
+    "moreActions": "Autres actions",
+    "designMenu": "Design de la facture",
+    "designMenuLoading": "Recherche du design avec lequel cette facture s'imprime...",
+    "designMenuExplain": "Partie au client le {date}, elle s'imprime donc toujours avec le design de l'envoi. En choisir un autre ne change que l'apparence. Ce que dit la facture reste tel qu'envoyé, et l'ancienne apparence n'est pas conservée.",
+    "designSentWith": "Le design lors de l'envoi",
+    "designFollow": "Suivre le design par défaut",
+    "designFollowNamed": "Suivre le design par défaut ({name})",
+    "designDefaultName": "le design par défaut",
+    "designPickConfirmTitle": "Passer cette facture à {name} ?",
+    "designPickConfirmBody": "Seule l'apparence change. Les coordonnées de l'atelier, le client, les conditions de paiement et les montants restent exactement tels qu'ils ont été envoyés. Le design actuel n'est pas conservé, l'action est donc irréversible.",
+    "designPickConfirm": "Changer le design",
+    "designPickDone": "La facture s'imprime maintenant avec {name}",
+    "designPickFailed": "Impossible de changer le design"
   },
   "basicInfo": {
     "title": "Informations de Base",
@@ -89,8 +102,9 @@
     "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.",
+    "designPinned": "Cette facture est parvenue au client le {date}, elle conserve donc le design avec lequel elle est partie. Un choix fait ici ne s'applique qu'après déverrouillage et nouvel envoi. Un propriétaire peut changer le design maintenant via Design de la facture dans le menu ⋮ en haut.",
     "designSaved": "Design de facture mis à jour",
+    "designSavedPinned": "Enregistré. Cette facture conserve le design avec lequel elle a été envoyée jusqu'à ce qu'elle soit déverrouillée et renvoyée.",
     "designFollowingRule": "Par défaut ({name}, {rule})",
     "designRule": {
       "parts_sale": "Vente de pièces seule"

+ 10 - 0
messages/fr/settings.json

@@ -600,6 +600,16 @@
     "freezeProgress": "{done} sur {total} verrouillées",
     "freezeDone": "{count, plural, one {# facture verrouillée} other {# factures verrouillées}}",
     "freezeFailed": "Impossible de verrouiller les factures",
+    "reapplyTitle": "Design des factures déjà envoyées",
+    "reapplyDescription": "Appliquer le design utilisé aujourd'hui aux factures verrouillées sur un design plus ancien.",
+    "reapplyBody": "{count, plural, one {# facture est verrouillée} other {# factures sont verrouillées}} sur le design d'envoi, si bien qu'un nouveau design ne touche que les factures envoyées à partir de maintenant. La réapplication pose le design du jour sur toutes. Seule l'apparence change : les coordonnées de l'atelier, le client, les conditions de paiement et les montants de chaque facture restent tels quels.",
+    "reapplyWarning": "Cette action est irréversible. Le design utilisé auparavant n'est pas conservé.",
+    "reapplyButton": "Réappliquer le design actuel",
+    "reapplyConfirmTitle": "Réappliquer le design à {count, plural, one {# facture} other {# factures}} ?",
+    "reapplyConfirmBody": "Chaque facture déjà partie chez un client passe au design utilisé aujourd'hui. Seule l'apparence change : les coordonnées, les conditions et les montants de chaque facture restent tels quels. L'action est irréversible et le design précédent ne pourra pas être rétabli.",
+    "reapplyProgress": "{done} sur {total} mises à jour",
+    "reapplyDone": "{count, plural, one {# facture utilise} other {# factures utilisent}} maintenant le design actuel",
+    "reapplyFailed": "Impossible de réappliquer le design",
     "sectionSending": "Envoi",
     "attachPdfLabel": "Joindre le PDF aux e-mails",
     "attachPdfHint": "La valeur par défaut pour chaque facture, devis et inspection que vous envoyez par e-mail. Activé : le client reçoit le PDF en pièce jointe. Désactivé : il reçoit un lien vers le document, et le compteur de consultations indique quand il l'a ouvert, ce qui permet de relancer ceux que personne n'a regardés. Vous pouvez le changer pour un envoi précis dans la fenêtre de partage."

+ 3 - 0
messages/it/audit.json

@@ -168,6 +168,8 @@
     "settings_deleteEmailTemplate": "Modello email eliminato",
     "settings_applyEmailTemplate": "Modello email predefinito impostato",
     "settings_freezeInvoices": "Fatture precedenti bloccate",
+    "settings_reapplyInvoiceDesign": "Design fattura riapplicato",
+    "invoice_reapplyDesign": "Design fattura riapplicato",
     "inspection_reminders_sent": "Promemoria revisione inviati",
     "inspection_reminders_send": "Promemoria revisione inviati",
     "service_videoCall_send": "Link videochiamata inviato",
@@ -312,6 +314,7 @@
     "settings_deleteEmailTemplate": "Ha eliminato il modello email {name}",
     "settings_applyEmailTemplate": "Ha impostato {name} come modello per le email {kind}",
     "settings_freezeInvoices": "Ha bloccato {count, plural, one {# fattura} other {# fatture}} precedenti al blocco con il design attuale",
+    "settings_reapplyInvoiceDesign": "Ha riapplicato il design attuale a {count, plural, one {# fattura} other {# fatture}} già inviate",
     "inspection_reminders_sent": "Inviati {count} promemoria revisione",
     "service_videoCall_send": "Ha inviato il link della videochiamata via {channels}",
     "timeEntry_create": "Aggiunti {minutes} minuti per {technician} su \"{job}\"",

+ 16 - 2
messages/it/service.json

@@ -41,7 +41,20 @@
     "unpaid": "Non Pagato",
     "unsavedChanges": "Modifiche non salvate",
     "saved": "Salvato",
-    "joinCall": "Partecipa alla videochiamata"
+    "joinCall": "Partecipa alla videochiamata",
+    "moreActions": "Altre azioni",
+    "designMenu": "Design della fattura",
+    "designMenuLoading": "Controllo con quale design viene stampata questa fattura...",
+    "designMenuExplain": "Andata al cliente il {date}, quindi viene ancora stampata con il design di allora. Sceglierne un altro cambia solo l'aspetto. Quello che la fattura dice resta come è stato inviato, e l'aspetto precedente non viene conservato.",
+    "designSentWith": "Il design con cui è stata inviata",
+    "designFollow": "Segui il design predefinito",
+    "designFollowNamed": "Segui il design predefinito ({name})",
+    "designDefaultName": "il design predefinito",
+    "designPickConfirmTitle": "Passare questa fattura a {name}?",
+    "designPickConfirmBody": "Cambia solo l'aspetto. I dati dell'officina, il cliente, le condizioni di pagamento e gli importi restano esattamente come sono stati inviati. Il design attuale non viene conservato, quindi l'operazione non è reversibile.",
+    "designPickConfirm": "Cambia il design",
+    "designPickDone": "La fattura ora viene stampata con {name}",
+    "designPickFailed": "Impossibile cambiare il design"
   },
   "basicInfo": {
     "title": "Informazioni di Base",
@@ -89,8 +102,9 @@
     "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.",
+    "designPinned": "Questa fattura è arrivata al cliente il {date}, quindi mantiene il design con cui è uscita. Una scelta fatta qui vale solo dopo che è stata sbloccata e inviata di nuovo. Un titolare può cambiare il design ora da Design della fattura nel menu ⋮ in alto.",
     "designSaved": "Design della fattura aggiornato",
+    "designSavedPinned": "Salvato. Questa fattura mantiene il design con cui è stata inviata finché non viene sbloccata e inviata di nuovo.",
     "designFollowingRule": "Predefinito ({name}, {rule})",
     "designRule": {
       "parts_sale": "Vendita solo ricambi"

+ 10 - 0
messages/it/settings.json

@@ -600,6 +600,16 @@
     "freezeProgress": "Bloccate {done} di {total}",
     "freezeDone": "{count, plural, one {# fattura bloccata} other {# fatture bloccate}}",
     "freezeFailed": "Impossibile bloccare le fatture",
+    "reapplyTitle": "Design sulle fatture già inviate",
+    "reapplyDescription": "Applica il design che usi oggi alle fatture fissate a uno precedente.",
+    "reapplyBody": "{count, plural, one {# fattura è fissata} other {# fatture sono fissate}} al design con cui è stata inviata, quindi un nuovo design raggiunge solo le fatture inviate d'ora in poi. Riapplicandolo, il design di oggi va su tutte. Cambia solo l'aspetto: i dati dell'officina, il cliente, le condizioni di pagamento e gli importi di ogni fattura restano come sono.",
+    "reapplyWarning": "L'operazione non è reversibile. Il design usato prima non viene conservato.",
+    "reapplyButton": "Riapplica il design attuale",
+    "reapplyConfirmTitle": "Riapplicare il design a {count, plural, one {# fattura} other {# fatture}}?",
+    "reapplyConfirmBody": "Ogni fattura già andata a un cliente passa al design in uso ora. Cambia solo l'aspetto: dati, condizioni e importi di ogni fattura restano come sono. L'operazione non è reversibile e il design precedente non potrà essere ripristinato.",
+    "reapplyProgress": "{done} di {total} aggiornate",
+    "reapplyDone": "{count, plural, one {# fattura usa} other {# fatture usano}} ora il design attuale",
+    "reapplyFailed": "Impossibile riapplicare il design",
     "sectionSending": "Invio",
     "attachPdfLabel": "Allega il PDF alle email",
     "attachPdfHint": "L'impostazione predefinita per ogni fattura, preventivo e controllo che invii via email. Attivo: il cliente riceve il PDF in allegato. Disattivo: riceve invece un link al documento e il contatore delle visualizzazioni indica quando lo ha aperto, così puoi sollecitare quelli che nessuno ha guardato. Puoi cambiarlo per un singolo invio nella finestra di condivisione."

+ 3 - 0
messages/lt/audit.json

@@ -168,6 +168,8 @@
     "settings_deleteEmailTemplate": "Ištrintas el. laiško šablonas",
     "settings_applyEmailTemplate": "Nustatytas numatytasis el. laiško šablonas",
     "settings_freezeInvoices": "Užrakintos senesnės sąskaitos",
+    "settings_reapplyInvoiceDesign": "Sąskaitos dizainas pritaikytas iš naujo",
+    "invoice_reapplyDesign": "Sąskaitos dizainas pritaikytas iš naujo",
     "inspection_reminders_sent": "Išsiųsti apžiūros priminimai",
     "inspection_reminders_send": "Išsiųsti apžiūros priminimai",
     "service_videoCall_send": "Išsiųsta vaizdo skambučio nuoroda",
@@ -312,6 +314,7 @@
     "settings_deleteEmailTemplate": "Ištrynė el. laiško šabloną {name}",
     "settings_applyEmailTemplate": "Nustatė {name} kaip {kind} el. laiškų šabloną",
     "settings_freezeInvoices": "Užrakino {count, plural, one {# sąskaitą} few {# sąskaitas} other {# sąskaitų}} iki užrakinimo su dabartiniu dizainu",
+    "settings_reapplyInvoiceDesign": "Iš naujo pritaikė dabartinį dizainą {count, plural, one {# išsiųstai sąskaitai} few {# išsiųstoms sąskaitoms} other {# išsiųstų sąskaitų}}",
     "inspection_reminders_sent": "Išsiųsta {count} apžiūros priminimų",
     "service_videoCall_send": "Išsiuntė vaizdo skambučio nuorodą per {channels}",
     "timeEntry_create": "Pridėta {minutes} min. technikui {technician} darbe „{job}“",

+ 16 - 2
messages/lt/service.json

@@ -41,7 +41,20 @@
     "unpaid": "Neapmokėta",
     "unsavedChanges": "Neišsaugoti pakeitimai",
     "saved": "Išsaugota",
-    "joinCall": "Prisijungti prie vaizdo skambučio"
+    "joinCall": "Prisijungti prie vaizdo skambučio",
+    "moreActions": "Daugiau veiksmų",
+    "designMenu": "Sąskaitos dizainas",
+    "designMenuLoading": "Tikrinama, su kuriuo dizainu spausdinama ši sąskaita...",
+    "designMenuExplain": "Klientui iškeliavo {date}, todėl vis dar spausdinama su tuo dizainu, su kuriuo buvo išsiųsta. Pasirinkus kitą, keičiasi tik išvaizda. Tai, ką sąskaita sako, lieka kaip išsiųsta, o senoji išvaizda nesaugoma.",
+    "designSentWith": "Dizainas, su kuriuo išsiųsta",
+    "designFollow": "Sekti dabartinį numatytąjį",
+    "designFollowNamed": "Sekti dabartinį numatytąjį ({name})",
+    "designDefaultName": "dabartinį numatytąjį dizainą",
+    "designPickConfirmTitle": "Pakeisti šią sąskaitą į {name}?",
+    "designPickConfirmBody": "Keičiasi tik išvaizda. Serviso duomenys, klientas, mokėjimo sąlygos ir sumos lieka tokie, kokie buvo išsiųsti. Dabartinis dizainas nesaugomas, todėl to atšaukti nepavyks.",
+    "designPickConfirm": "Keisti dizainą",
+    "designPickDone": "Sąskaita dabar spausdinama su {name}",
+    "designPickFailed": "Nepavyko pakeisti dizaino"
   },
   "basicInfo": {
     "title": "Pagrindinė informacija",
@@ -89,8 +102,9 @@
     "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ą.",
+    "designPinned": "Ši sąskaita klientą pasiekė {date}, todėl išlaiko dizainą, su kuriuo iškeliavo. Čia padarytas pasirinkimas suveiks tik atrakinus ir išsiuntus iš naujo. Savininkas gali pakeisti dizainą dabar per Sąskaitos dizainas ⋮ meniu viršuje.",
     "designSaved": "Sąskaitos dizainas atnaujintas",
+    "designSavedPinned": "Išsaugota. Ši sąskaita išlaiko dizainą, su kuriuo buvo išsiųsta, kol nebus atrakinta ir išsiųsta iš naujo.",
     "designFollowingRule": "Numatytasis ({name}, {rule})",
     "designRule": {
       "parts_sale": "Tik dalių pardavimas"

+ 10 - 0
messages/lt/settings.json

@@ -600,6 +600,16 @@
     "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ų",
+    "reapplyTitle": "Jau išsiųstų sąskaitų dizainas",
+    "reapplyDescription": "Pritaikykite šiandien naudojamą dizainą sąskaitoms, pririštoms prie senesnio.",
+    "reapplyBody": "{count, plural, one {# sąskaita pririšta} few {# sąskaitos pririštos} other {# sąskaitų pririšta}} prie dizaino, su kuriuo buvo išsiųsta, todėl naujas dizainas pasiekia tik nuo šiol siunčiamas sąskaitas. Pritaikius iš naujo, šiandienos dizainas atsiduria ant visų. Keičiasi tik išvaizda: kiekvienos sąskaitos serviso duomenys, klientas, mokėjimo sąlygos ir sumos lieka tokie patys.",
+    "reapplyWarning": "To atšaukti nepavyks. Anksčiau naudotas dizainas nesaugomas.",
+    "reapplyButton": "Pritaikyti dabartinį dizainą iš naujo",
+    "reapplyConfirmTitle": "Pritaikyti dizainą iš naujo {count, plural, one {# sąskaitai} few {# sąskaitoms} other {# sąskaitų}}?",
+    "reapplyConfirmBody": "Kiekviena sąskaita, jau iškeliavusi klientui, pereina prie dabar naudojamo dizaino. Keičiasi tik išvaizda: kiekvienos sąskaitos duomenys, sąlygos ir sumos lieka tokie patys. To atšaukti nepavyks, o ankstesnio dizaino atkurti negalėsite.",
+    "reapplyProgress": "Atnaujinta {done} iš {total}",
+    "reapplyDone": "{count, plural, one {# sąskaita dabar naudoja} few {# sąskaitos dabar naudoja} other {# sąskaitų dabar naudoja}} dabartinį dizainą",
+    "reapplyFailed": "Nepavyko pritaikyti dizaino iš naujo",
     "sectionSending": "Siuntimas",
     "attachPdfLabel": "Prie el. laiškų pridėti PDF",
     "attachPdfHint": "Numatytoji nuostata kiekvienai sąskaitai, pasiūlymui ir patikrai, siunčiamai el. paštu. Įjungta: klientas gauna PDF kaip priedą. Išjungta: jis gauna nuorodą į dokumentą, o peržiūrų skaitiklis parodo, kada jis buvo atidarytas, todėl galite priminti tiems, kurie dar nepažiūrėjo. Atskiram siuntimui tai galite pakeisti bendrinimo lange."

+ 3 - 0
messages/nb/audit.json

@@ -168,6 +168,8 @@
     "settings_deleteEmailTemplate": "Slettet e-postmal",
     "settings_applyEmailTemplate": "Satte standard e-postmal",
     "settings_freezeInvoices": "Låste eldre fakturaer",
+    "settings_reapplyInvoiceDesign": "Brukte fakturadesign på nytt",
+    "invoice_reapplyDesign": "Brukte fakturadesign på nytt",
     "inspection_reminders_sent": "Sendte påminnelser om EU-kontroll",
     "inspection_reminders_send": "Sendte påminnelser om EU-kontroll",
     "service_videoCall_send": "Sendte lenke til videosamtale",
@@ -312,6 +314,7 @@
     "settings_deleteEmailTemplate": "Slettet e-postmalen {name}",
     "settings_applyEmailTemplate": "Gjorde {name} til mal for {kind}-e-poster",
     "settings_freezeInvoices": "Låste {count, plural, one {# faktura} other {# fakturaer}} fra før låsing med gjeldende design",
+    "settings_reapplyInvoiceDesign": "Brukte gjeldende design på nytt på {count, plural, one {# faktura} other {# fakturaer}} som allerede var sendt",
     "inspection_reminders_sent": "Sendte {count} påminnelser om EU-kontroll",
     "service_videoCall_send": "Sendte lenken til videosamtalen via {channels}",
     "timeEntry_create": "La til {minutes} minutter for {technician} på «{job}»",

+ 16 - 2
messages/nb/service.json

@@ -41,7 +41,20 @@
     "unpaid": "Ubetalt",
     "unsavedChanges": "Ulagrede endringer",
     "saved": "Lagret",
-    "joinCall": "Bli med i videosamtale"
+    "joinCall": "Bli med i videosamtale",
+    "moreActions": "Flere handlinger",
+    "designMenu": "Fakturadesign",
+    "designMenuLoading": "Sjekker hvilket design denne fakturaen skrives ut med ...",
+    "designMenuExplain": "Sendt til kunden {date}, så den skrives fortsatt ut med designet den ble sendt med. Å velge et annet endrer bare utseendet. Det fakturaen sier står som da den ble sendt, og det gamle utseendet tas ikke vare på.",
+    "designSentWith": "Designet den ble sendt med",
+    "designFollow": "Følg gjeldende standard",
+    "designFollowNamed": "Følg gjeldende standard ({name})",
+    "designDefaultName": "gjeldende standard",
+    "designPickConfirmTitle": "Endre denne fakturaen til {name}?",
+    "designPickConfirmBody": "Bare utseendet endres. Verkstedopplysningene, kunden, betalingsbetingelsene og beløpene står nøyaktig som de ble sendt. Designet den skrives ut med nå tas ikke vare på, så dette kan ikke angres.",
+    "designPickConfirm": "Endre designet",
+    "designPickDone": "Fakturaen skrives nå ut med {name}",
+    "designPickFailed": "Kunne ikke endre designet"
   },
   "basicInfo": {
     "title": "Grunnleggende informasjon",
@@ -89,8 +102,9 @@
     "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.",
+    "designPinned": "Denne fakturaen nådde kunden {date}, så den beholder designet den gikk ut med. Et valg her gjelder først etter at den låses opp og sendes på nytt. En eier kan endre designet nå fra Fakturadesign i ⋮-menyen øverst.",
     "designSaved": "Fakturadesign oppdatert",
+    "designSavedPinned": "Lagret. Denne fakturaen beholder designet den ble sendt med til den låses opp og sendes på nytt.",
     "designFollowingRule": "Standard ({name}, {rule})",
     "designRule": {
       "parts_sale": "Kun delesalg"

+ 10 - 0
messages/nb/settings.json

@@ -600,6 +600,16 @@
     "freezeProgress": "Låst {done} av {total}",
     "freezeDone": "{count, plural, one {# faktura} other {# fakturaer}} låst",
     "freezeFailed": "Kunne ikke låse fakturaene",
+    "reapplyTitle": "Design på fakturaer som er sendt",
+    "reapplyDescription": "Legg designet du bruker i dag på fakturaer som er låst til et eldre.",
+    "reapplyBody": "{count, plural, one {# faktura er} other {# fakturaer er}} låst til designet den ble sendt med, så et nytt design når bare fakturaer som sendes fra nå av. Å bruke designet på nytt legger dagens design på alle sammen. Bare utseendet endres: verkstedopplysningene, kunden, betalingsbetingelsene og beløpene hver faktura ble sendt med står som de er.",
+    "reapplyWarning": "Dette kan ikke angres. Designet hver faktura brukte før tas ikke vare på.",
+    "reapplyButton": "Bruk gjeldende design på nytt",
+    "reapplyConfirmTitle": "Bruke designet på nytt på {count, plural, one {# faktura} other {# fakturaer}}?",
+    "reapplyConfirmBody": "Hver faktura som allerede har gått til en kunde bytter til designet som er i bruk nå. Bare utseendet endres: opplysningene, betingelsene og beløpene hver faktura ble sendt med står som de er. Dette kan ikke angres, og designet hver faktura brukte før kan ikke gjenopprettes.",
+    "reapplyProgress": "Oppdatert {done} av {total}",
+    "reapplyDone": "{count, plural, one {# faktura} other {# fakturaer}} bruker nå gjeldende design",
+    "reapplyFailed": "Kunne ikke bruke designet på nytt",
     "sectionSending": "Utsending",
     "attachPdfLabel": "Legg ved PDF i e-post",
     "attachPdfHint": "Standard for alle fakturaer, tilbud og kontroller du sender på e-post. På: kunden får PDF-en vedlagt. Av: kunden får en lenke til dokumentet i stedet, og visningstelleren viser når det ble åpnet, så du kan følge opp dem ingen har sett på. Du kan endre det for én enkelt utsending i delingsdialogen."

+ 3 - 0
messages/nl/audit.json

@@ -168,6 +168,8 @@
     "settings_deleteEmailTemplate": "E-mailsjabloon verwijderd",
     "settings_applyEmailTemplate": "Standaard e-mailsjabloon ingesteld",
     "settings_freezeInvoices": "Oudere facturen vergrendeld",
+    "settings_reapplyInvoiceDesign": "Factuurontwerp opnieuw toegepast",
+    "invoice_reapplyDesign": "Factuurontwerp opnieuw toegepast",
     "inspection_reminders_sent": "Keuringsherinneringen verstuurd",
     "inspection_reminders_send": "Keuringsherinneringen verstuurd",
     "service_videoCall_send": "Link videogesprek verstuurd",
@@ -312,6 +314,7 @@
     "settings_deleteEmailTemplate": "Verwijderde het e-mailsjabloon {name}",
     "settings_applyEmailTemplate": "Stelde {name} in als sjabloon voor {kind}-e-mails",
     "settings_freezeInvoices": "Vergrendelde {count, plural, one {# factuur} other {# facturen}} van vóór het vergrendelen met het huidige ontwerp",
+    "settings_reapplyInvoiceDesign": "Heeft het huidige ontwerp opnieuw toegepast op {count, plural, one {# al verstuurde factuur} other {# al verstuurde facturen}}",
     "inspection_reminders_sent": "{count} keuringsherinneringen verstuurd",
     "service_videoCall_send": "Heeft de link van het videogesprek verstuurd via {channels}",
     "timeEntry_create": "{minutes} minuten toegevoegd voor {technician} op \"{job}\"",

+ 16 - 2
messages/nl/service.json

@@ -41,7 +41,20 @@
     "unpaid": "Onbetaald",
     "unsavedChanges": "Niet-opgeslagen wijzigingen",
     "saved": "Opgeslagen",
-    "joinCall": "Deelnemen aan videogesprek"
+    "joinCall": "Deelnemen aan videogesprek",
+    "moreActions": "Meer acties",
+    "designMenu": "Factuurontwerp",
+    "designMenuLoading": "Bezig met nagaan met welk ontwerp deze factuur wordt afgedrukt...",
+    "designMenuExplain": "Op {date} naar de klant gegaan, dus ze wordt nog steeds afgedrukt met het ontwerp van toen. Een ander kiezen verandert alleen het uiterlijk. Wat de factuur zegt blijft zoals ze is verstuurd, en het oude uiterlijk wordt niet bewaard.",
+    "designSentWith": "Het ontwerp bij verzending",
+    "designFollow": "Het huidige standaardontwerp volgen",
+    "designFollowNamed": "Het huidige standaardontwerp volgen ({name})",
+    "designDefaultName": "het huidige standaardontwerp",
+    "designPickConfirmTitle": "Deze factuur omzetten naar {name}?",
+    "designPickConfirmBody": "Alleen het uiterlijk verandert. De werkplaatsgegevens, de klant, de betalingsvoorwaarden en de bedragen blijven precies zoals ze zijn verstuurd. Het huidige ontwerp wordt niet bewaard, dus dit kan niet ongedaan worden gemaakt.",
+    "designPickConfirm": "Ontwerp wijzigen",
+    "designPickDone": "De factuur wordt nu afgedrukt met {name}",
+    "designPickFailed": "Kon het ontwerp niet wijzigen"
   },
   "basicInfo": {
     "title": "Basisinformatie",
@@ -89,8 +102,9 @@
     "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.",
+    "designPinned": "Deze factuur bereikte de klant op {date} en houdt dus het ontwerp waarmee ze is uitgegaan. Een keuze hier geldt pas nadat ze is ontgrendeld en opnieuw verstuurd. Een eigenaar kan het ontwerp nu wijzigen via Factuurontwerp in het ⋮-menu bovenaan.",
     "designSaved": "Factuurontwerp bijgewerkt",
+    "designSavedPinned": "Opgeslagen. Deze factuur houdt het ontwerp waarmee ze is verstuurd totdat ze wordt ontgrendeld en opnieuw verstuurd.",
     "designFollowingRule": "Standaard ({name}, {rule})",
     "designRule": {
       "parts_sale": "Alleen onderdelenverkoop"

+ 10 - 0
messages/nl/settings.json

@@ -600,6 +600,16 @@
     "freezeProgress": "{done} van {total} vergrendeld",
     "freezeDone": "{count, plural, one {# factuur} other {# facturen}} vergrendeld",
     "freezeFailed": "Kon de facturen niet vergrendelen",
+    "reapplyTitle": "Ontwerp op al verstuurde facturen",
+    "reapplyDescription": "Zet het ontwerp dat je vandaag gebruikt op facturen die aan een ouder ontwerp vastzitten.",
+    "reapplyBody": "{count, plural, one {# factuur zit} other {# facturen zitten}} vast aan het ontwerp waarmee ze is verstuurd, dus een nieuw ontwerp bereikt alleen facturen die je vanaf nu verstuurt. Opnieuw toepassen zet het ontwerp van vandaag op allemaal. Alleen het uiterlijk verandert: de werkplaatsgegevens, de klant, de betalingsvoorwaarden en de bedragen van elke factuur blijven zoals ze zijn.",
+    "reapplyWarning": "Dit kan niet ongedaan worden gemaakt. Het ontwerp dat elke factuur eerder gebruikte wordt niet bewaard.",
+    "reapplyButton": "Huidig ontwerp opnieuw toepassen",
+    "reapplyConfirmTitle": "Het ontwerp opnieuw toepassen op {count, plural, one {# factuur} other {# facturen}}?",
+    "reapplyConfirmBody": "Elke factuur die al naar een klant is gegaan schakelt over naar het ontwerp dat nu in gebruik is. Alleen het uiterlijk verandert: de gegevens, voorwaarden en bedragen van elke factuur blijven zoals ze zijn. Dit kan niet ongedaan worden gemaakt en het vorige ontwerp kan niet worden hersteld.",
+    "reapplyProgress": "{done} van {total} bijgewerkt",
+    "reapplyDone": "{count, plural, one {# factuur gebruikt} other {# facturen gebruiken}} nu het huidige ontwerp",
+    "reapplyFailed": "Kon het ontwerp niet opnieuw toepassen",
     "sectionSending": "Verzenden",
     "attachPdfLabel": "De pdf aan e-mails toevoegen",
     "attachPdfHint": "De standaard voor elke factuur, offerte en inspectie die je per e-mail verstuurt. Aan: de klant krijgt de pdf als bijlage. Uit: hij krijgt een link naar het document, en de weergaveteller laat zien wanneer het is geopend, zodat je kunt nabellen bij wie nog niet gekeken heeft. Voor één verzending kun je het aanpassen in het deelvenster."

+ 3 - 0
messages/pl/audit.json

@@ -168,6 +168,8 @@
     "settings_deleteEmailTemplate": "Usunięto szablon e-maila",
     "settings_applyEmailTemplate": "Ustawiono domyślny szablon e-maila",
     "settings_freezeInvoices": "Zablokowano starsze faktury",
+    "settings_reapplyInvoiceDesign": "Ponownie nałożono projekt faktury",
+    "invoice_reapplyDesign": "Ponownie nałożono projekt faktury",
     "inspection_reminders_sent": "Wysłano przypomnienia o przeglądzie",
     "inspection_reminders_send": "Wysłano przypomnienia o przeglądzie",
     "service_videoCall_send": "Wysłano link do rozmowy wideo",
@@ -312,6 +314,7 @@
     "settings_deleteEmailTemplate": "Usunął szablon e-maila {name}",
     "settings_applyEmailTemplate": "Ustawił {name} jako szablon dla e-maili {kind}",
     "settings_freezeInvoices": "Zablokował {count, plural, one {# fakturę} few {# faktury} many {# faktur} other {# faktury}} sprzed blokady z bieżącym projektem",
+    "settings_reapplyInvoiceDesign": "Nałożono bieżący projekt ponownie na {count, plural, one {# wysłaną fakturę} few {# wysłane faktury} many {# wysłanych faktur} other {# wysłanej faktury}}",
     "inspection_reminders_sent": "Wysłano {count} przypomnień o przeglądzie",
     "service_videoCall_send": "Wysłano link do rozmowy wideo przez {channels}",
     "timeEntry_create": "Dodano {minutes} min dla {technician} w zleceniu „{job}”",

+ 16 - 2
messages/pl/service.json

@@ -41,7 +41,20 @@
     "unpaid": "Niezapłacone",
     "unsavedChanges": "Niezapisane zmiany",
     "saved": "Zapisano",
-    "joinCall": "Dołącz do rozmowy wideo"
+    "joinCall": "Dołącz do rozmowy wideo",
+    "moreActions": "Więcej działań",
+    "designMenu": "Projekt faktury",
+    "designMenuLoading": "Sprawdzanie, z jakim projektem drukuje się ta faktura...",
+    "designMenuExplain": "Trafiła do klienta {date}, więc nadal drukuje się z projektem z chwili wysyłki. Wybór innego zmienia tylko wygląd. To, co faktura mówi, pozostaje takie jak przy wysyłce, a poprzedni wygląd nie jest zachowywany.",
+    "designSentWith": "Projekt z chwili wysyłki",
+    "designFollow": "Zgodnie z bieżącym domyślnym",
+    "designFollowNamed": "Zgodnie z bieżącym domyślnym ({name})",
+    "designDefaultName": "bieżący domyślny projekt",
+    "designPickConfirmTitle": "Zmienić tę fakturę na {name}?",
+    "designPickConfirmBody": "Zmienia się tylko wygląd. Dane warsztatu, klient, warunki płatności i kwoty pozostają dokładnie takie, jakie zostały wysłane. Obecny projekt nie jest zachowywany, więc tej operacji nie można cofnąć.",
+    "designPickConfirm": "Zmień projekt",
+    "designPickDone": "Faktura drukuje się teraz z projektem {name}",
+    "designPickFailed": "Nie udało się zmienić projektu"
   },
   "basicInfo": {
     "title": "Informacje Podstawowe",
@@ -89,8 +102,9 @@
     "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.",
+    "designPinned": "Ta faktura trafiła do klienta {date}, więc zachowuje projekt, z którym wyszła. Wybór tutaj zadziała dopiero po odblokowaniu i ponownej wysyłce. Właściciel może zmienić projekt teraz przez Projekt faktury w menu ⋮ u góry.",
     "designSaved": "Projekt faktury zaktualizowany",
+    "designSavedPinned": "Zapisano. Ta faktura zachowuje projekt, z którym została wysłana, dopóki nie zostanie odblokowana i wysłana ponownie.",
     "designFollowingRule": "Domyślny ({name}, {rule})",
     "designRule": {
       "parts_sale": "Sprzedaż samych części"

+ 10 - 0
messages/pl/settings.json

@@ -600,6 +600,16 @@
     "freezeProgress": "Zablokowano {done} z {total}",
     "freezeDone": "Zablokowano {count, plural, one {# fakturę} few {# faktury} many {# faktur} other {# faktury}}",
     "freezeFailed": "Nie udało się zablokować faktur",
+    "reapplyTitle": "Projekt na już wysłanych fakturach",
+    "reapplyDescription": "Nałóż projekt używany dziś na faktury przypisane do starszego.",
+    "reapplyBody": "{count, plural, one {# faktura jest przypisana} few {# faktury są przypisane} many {# faktur jest przypisanych} other {# faktury jest przypisanych}} do projektu, z którym została wysłana, więc nowy projekt obejmie tylko faktury wysyłane od teraz. Ponowne nałożenie umieści dzisiejszy projekt na wszystkich. Zmienia się tylko wygląd: dane warsztatu, klient, warunki płatności i kwoty każdej faktury pozostają bez zmian.",
+    "reapplyWarning": "Tej operacji nie można cofnąć. Projekt używany wcześniej nie jest zachowywany.",
+    "reapplyButton": "Nałóż ponownie bieżący projekt",
+    "reapplyConfirmTitle": "Nałożyć projekt ponownie na {count, plural, one {# fakturę} few {# faktury} many {# faktur} other {# faktury}}?",
+    "reapplyConfirmBody": "Każda faktura, która trafiła już do klienta, przechodzi na projekt używany teraz. Zmienia się tylko wygląd: dane, warunki i kwoty każdej faktury pozostają bez zmian. Operacji nie można cofnąć, a poprzedniego projektu nie da się przywrócić.",
+    "reapplyProgress": "Zaktualizowano {done} z {total}",
+    "reapplyDone": "{count, plural, one {# faktura używa} few {# faktury używają} many {# faktur używa} other {# faktury używa}} teraz bieżącego projektu",
+    "reapplyFailed": "Nie udało się ponownie nałożyć projektu",
     "sectionSending": "Wysyłka",
     "attachPdfLabel": "Dołączaj PDF do wiadomości e-mail",
     "attachPdfHint": "Domyślne ustawienie dla każdej faktury, oferty i przeglądu wysyłanych e-mailem. Włączone: klient dostaje PDF w załączniku. Wyłączone: dostaje link do dokumentu, a licznik wyświetleń pokazuje, kiedy go otworzył, więc możesz przypomnieć się tym, którzy jeszcze nie zajrzeli. Dla pojedynczej wysyłki zmienisz to w oknie udostępniania."

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

@@ -168,6 +168,8 @@
     "settings_deleteEmailTemplate": "Modelo de e-mail excluído",
     "settings_applyEmailTemplate": "Modelo de e-mail padrão definido",
     "settings_freezeInvoices": "Faturas anteriores travadas",
+    "settings_reapplyInvoiceDesign": "Design de fatura reaplicado",
+    "invoice_reapplyDesign": "Design de fatura reaplicado",
     "inspection_reminders_sent": "Enviou lembretes de inspeção",
     "inspection_reminders_send": "Enviou lembretes de inspeção",
     "service_videoCall_send": "Link da videochamada enviado",
@@ -312,6 +314,7 @@
     "settings_deleteEmailTemplate": "Excluiu o modelo de e-mail {name}",
     "settings_applyEmailTemplate": "Definiu {name} como modelo para e-mails de {kind}",
     "settings_freezeInvoices": "Travou {count, plural, one {# fatura} other {# faturas}} anteriores ao bloqueio com o design atual",
+    "settings_reapplyInvoiceDesign": "Reaplicou o design atual em {count, plural, one {# fatura} other {# faturas}} já enviadas",
     "inspection_reminders_sent": "Enviou {count} lembretes de inspeção",
     "service_videoCall_send": "Enviou o link da videochamada por {channels}",
     "timeEntry_create": "Adicionados {minutes} minutos para {technician} em \"{job}\"",

+ 16 - 2
messages/pt-BR/service.json

@@ -41,7 +41,20 @@
     "unpaid": "Não Pago",
     "unsavedChanges": "Alterações não salvas",
     "saved": "Salvo",
-    "joinCall": "Entrar na videochamada"
+    "joinCall": "Entrar na videochamada",
+    "moreActions": "Mais ações",
+    "designMenu": "Design da fatura",
+    "designMenuLoading": "Verificando com qual design esta fatura é impressa...",
+    "designMenuExplain": "Foi para o cliente em {date}, então ainda é impressa com o design do envio. Escolher outro muda apenas a aparência. O que a fatura diz continua como foi enviado, e a aparência anterior não é guardada.",
+    "designSentWith": "O design com que foi enviada",
+    "designFollow": "Seguir o design padrão",
+    "designFollowNamed": "Seguir o design padrão ({name})",
+    "designDefaultName": "o design padrão",
+    "designPickConfirmTitle": "Mudar esta fatura para {name}?",
+    "designPickConfirmBody": "Só a aparência muda. Os dados da oficina, o cliente, as condições de pagamento e os valores continuam exatamente como foram enviados. O design atual não é guardado, então isso não pode ser desfeito.",
+    "designPickConfirm": "Mudar o design",
+    "designPickDone": "A fatura agora é impressa com {name}",
+    "designPickFailed": "Não foi possível alterar o design"
   },
   "basicInfo": {
     "title": "Informações Básicas",
@@ -89,8 +102,9 @@
     "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.",
+    "designPinned": "Esta fatura chegou ao cliente em {date}, então mantém o design com que saiu. Uma escolha aqui só vale depois de destravar e enviar de novo. Um proprietário pode mudar o design agora em Design da fatura no menu ⋮ no topo.",
     "designSaved": "Design da fatura atualizado",
+    "designSavedPinned": "Salvo. Esta fatura mantém o design com que foi enviada até ser destravada e enviada de novo.",
     "designFollowingRule": "Padrão ({name}, {rule})",
     "designRule": {
       "parts_sale": "Venda apenas de peças"

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

@@ -600,6 +600,16 @@
     "freezeProgress": "Travadas {done} de {total}",
     "freezeDone": "{count, plural, one {# fatura travada} other {# faturas travadas}}",
     "freezeFailed": "Não foi possível travar as faturas",
+    "reapplyTitle": "Design nas faturas já enviadas",
+    "reapplyDescription": "Coloque o design que você usa hoje nas faturas travadas em um mais antigo.",
+    "reapplyBody": "{count, plural, one {# fatura está travada} other {# faturas estão travadas}} no design com que foi enviada, então um design novo alcança apenas as faturas enviadas de agora em diante. Reaplicar coloca o design de hoje em todas. Só a aparência muda: os dados da oficina, o cliente, as condições de pagamento e os valores de cada fatura continuam como estão.",
+    "reapplyWarning": "Isso não pode ser desfeito. O design que cada fatura usava antes não é guardado.",
+    "reapplyButton": "Reaplicar o design atual",
+    "reapplyConfirmTitle": "Reaplicar o design em {count, plural, one {# fatura} other {# faturas}}?",
+    "reapplyConfirmBody": "Toda fatura que já foi para um cliente passa para o design em uso agora. Só a aparência muda: os dados, as condições e os valores de cada fatura continuam como estão. Isso não pode ser desfeito e o design anterior não poderá ser restaurado.",
+    "reapplyProgress": "{done} de {total} atualizadas",
+    "reapplyDone": "{count, plural, one {# fatura usa} other {# faturas usam}} agora o design atual",
+    "reapplyFailed": "Não foi possível reaplicar o design",
     "sectionSending": "Envio",
     "attachPdfLabel": "Anexar o PDF aos e-mails",
     "attachPdfHint": "O padrão para cada fatura, orçamento e inspeção que você envia por e-mail. Ligado: o cliente recebe o PDF em anexo. Desligado: ele recebe um link para o documento, e o contador de visualizações mostra quando foi aberto, para você cobrar quem ainda não olhou. Você pode mudar isso em um envio específico na janela de compartilhamento."

+ 3 - 0
messages/ru/audit.json

@@ -168,6 +168,8 @@
     "settings_deleteEmailTemplate": "Удалён шаблон письма",
     "settings_applyEmailTemplate": "Задан шаблон письма по умолчанию",
     "settings_freezeInvoices": "Зафиксированы старые счета",
+    "settings_reapplyInvoiceDesign": "Дизайн счёта применён заново",
+    "invoice_reapplyDesign": "Дизайн счёта применён заново",
     "inspection_reminders_sent": "Отправлены напоминания о техосмотре",
     "inspection_reminders_send": "Отправлены напоминания о техосмотре",
     "service_videoCall_send": "Отправлена ссылка на видеозвонок",
@@ -312,6 +314,7 @@
     "settings_deleteEmailTemplate": "Удалил шаблон письма {name}",
     "settings_applyEmailTemplate": "Сделал {name} шаблоном для писем {kind}",
     "settings_freezeInvoices": "Зафиксировал {count, plural, one {# счёт} few {# счёта} many {# счетов} other {# счёта}} до появления блокировки с текущим дизайном",
+    "settings_reapplyInvoiceDesign": "Применил текущий дизайн заново к {count, plural, one {# отправленному счёту} few {# отправленным счетам} many {# отправленным счетам} other {# отправленным счетам}}",
     "inspection_reminders_sent": "Отправлено напоминаний о техосмотре: {count}",
     "service_videoCall_send": "Отправил(а) ссылку на видеозвонок через {channels}",
     "timeEntry_create": "Добавлено {minutes} мин для {technician} по заказу «{job}»",

+ 16 - 2
messages/ru/service.json

@@ -41,7 +41,20 @@
     "unpaid": "Не оплачено",
     "unsavedChanges": "Несохранённые изменения",
     "saved": "Сохранено",
-    "joinCall": "Присоединиться к видеозвонку"
+    "joinCall": "Присоединиться к видеозвонку",
+    "moreActions": "Другие действия",
+    "designMenu": "Дизайн счёта",
+    "designMenuLoading": "Проверяем, с каким дизайном печатается этот счёт...",
+    "designMenuExplain": "Ушёл клиенту {date}, поэтому печатается с тем дизайном, с которым был отправлен. Выбор другого меняет только внешний вид. То, что написано в счёте, останется как при отправке, а прежний вид не сохраняется.",
+    "designSentWith": "Дизайн на момент отправки",
+    "designFollow": "Как текущий по умолчанию",
+    "designFollowNamed": "Как текущий по умолчанию ({name})",
+    "designDefaultName": "текущий дизайн по умолчанию",
+    "designPickConfirmTitle": "Перевести этот счёт на {name}?",
+    "designPickConfirmBody": "Меняется только внешний вид. Данные сервиса, клиент, условия оплаты и суммы останутся ровно такими, какими были отправлены. Нынешний дизайн не сохраняется, поэтому отменить это не получится.",
+    "designPickConfirm": "Изменить дизайн",
+    "designPickDone": "Счёт теперь печатается с дизайном {name}",
+    "designPickFailed": "Не удалось изменить дизайн"
   },
   "basicInfo": {
     "title": "Основная информация",
@@ -89,8 +102,9 @@
     "design": "Дизайн счёта",
     "designDefault": "По умолчанию для мастерской",
     "designFollowing": "По умолчанию ({name})",
-    "designPinned": "Лист зафиксирован при выставлении счёта {date}. Новый выбор применится только после того, как владелец разблокирует счёт и он будет отправлен снова.",
+    "designPinned": "Этот счёт дошёл до клиента {date}, поэтому сохраняет дизайн, с которым ушёл. Выбор здесь сработает только после разблокировки и повторной отправки. Владелец может изменить дизайн сейчас через Дизайн счёта в меню ⋮ наверху.",
     "designSaved": "Дизайн счёта обновлён",
+    "designSavedPinned": "Сохранено. Этот счёт сохранит дизайн, с которым был отправлен, пока его не разблокируют и не отправят снова.",
     "designFollowingRule": "По умолчанию ({name}, {rule})",
     "designRule": {
       "parts_sale": "Продажа только запчастей"

+ 10 - 0
messages/ru/settings.json

@@ -600,6 +600,16 @@
     "freezeProgress": "Зафиксировано {done} из {total}",
     "freezeDone": "Зафиксировано {count, plural, one {# счёт} few {# счёта} many {# счетов} other {# счёта}}",
     "freezeFailed": "Не удалось зафиксировать счета",
+    "reapplyTitle": "Дизайн уже отправленных счетов",
+    "reapplyDescription": "Примените дизайн, который вы используете сегодня, к счетам, привязанным к более старому.",
+    "reapplyBody": "{count, plural, one {# счёт привязан} few {# счёта привязаны} many {# счетов привязаны} other {# счёта привязаны}} к дизайну, с которым был отправлен, поэтому новый дизайн попадает только на счета, отправленные с этого момента. Повторное применение положит сегодняшний дизайн на все. Меняется только внешний вид: данные сервиса, клиент, условия оплаты и суммы каждого счёта остаются как есть.",
+    "reapplyWarning": "Это нельзя отменить. Дизайн, который счёт использовал раньше, не сохраняется.",
+    "reapplyButton": "Применить текущий дизайн заново",
+    "reapplyConfirmTitle": "Применить дизайн заново к {count, plural, one {# счёту} few {# счетам} many {# счетам} other {# счетам}}?",
+    "reapplyConfirmBody": "Каждый счёт, уже ушедший клиенту, перейдёт на дизайн, который используется сейчас. Меняется только внешний вид: данные, условия и суммы каждого счёта остаются как есть. Это нельзя отменить, и прежний дизайн восстановить не получится.",
+    "reapplyProgress": "Обновлено {done} из {total}",
+    "reapplyDone": "{count, plural, one {# счёт теперь использует} few {# счёта теперь используют} many {# счетов теперь используют} other {# счёта теперь используют}} текущий дизайн",
+    "reapplyFailed": "Не удалось применить дизайн заново",
     "sectionSending": "Отправка",
     "attachPdfLabel": "Прикреплять PDF к письмам",
     "attachPdfHint": "Значение по умолчанию для каждого счёта, коммерческого предложения и осмотра, отправляемых по почте. Включено: клиент получает PDF во вложении. Выключено: он получает ссылку на документ, а счётчик просмотров показывает, когда её открыли, чтобы вы могли напомнить тем, кто ещё не посмотрел. Для отдельной отправки это можно изменить в окне общего доступа."

+ 3 - 0
messages/tr/audit.json

@@ -168,6 +168,8 @@
     "settings_deleteEmailTemplate": "E-posta şablonu silindi",
     "settings_applyEmailTemplate": "Varsayılan e-posta şablonu ayarlandı",
     "settings_freezeInvoices": "Eski faturalar kilitlendi",
+    "settings_reapplyInvoiceDesign": "Fatura tasarımı yeniden uygulandı",
+    "invoice_reapplyDesign": "Fatura tasarımı yeniden uygulandı",
     "inspection_reminders_sent": "Muayene hatırlatmaları gönderildi",
     "inspection_reminders_send": "Muayene hatırlatmaları gönderildi",
     "service_videoCall_send": "Görüntülü görüşme bağlantısı gönderildi",
@@ -312,6 +314,7 @@
     "settings_deleteEmailTemplate": "{name} e-posta şablonunu sildi",
     "settings_applyEmailTemplate": "{name} şablonunu {kind} e-postaları için şablon yaptı",
     "settings_freezeInvoices": "Kilitlemeden önceki {count, plural, one {# faturayı} other {# faturayı}} mevcut tasarımla kilitledi",
+    "settings_reapplyInvoiceDesign": "Gönderilmiş {count, plural, one {# faturaya} other {# faturaya}} geçerli tasarımı yeniden uyguladı",
     "inspection_reminders_sent": "{count} muayene hatırlatması gönderildi",
     "service_videoCall_send": "Görüntülü görüşme bağlantısını {channels} ile gönderdi",
     "timeEntry_create": "{technician} için \"{job}\" işine {minutes} dakika eklendi",

+ 16 - 2
messages/tr/service.json

@@ -41,7 +41,20 @@
     "unpaid": "Ödenmedi",
     "unsavedChanges": "Kaydedilmemiş değişiklikler",
     "saved": "Kaydedildi",
-    "joinCall": "Görüntülü görüşmeye katıl"
+    "joinCall": "Görüntülü görüşmeye katıl",
+    "moreActions": "Diğer işlemler",
+    "designMenu": "Fatura tasarımı",
+    "designMenuLoading": "Bu faturanın hangi tasarımla yazdırıldığı kontrol ediliyor...",
+    "designMenuExplain": "{date} tarihinde müşteriye gitti, bu yüzden hâlâ gönderildiği tasarımla yazdırılıyor. Başka birini seçmek yalnızca görünümü değiştirir. Faturanın söyledikleri gönderildiği gibi kalır, eski görünüm ise saklanmaz.",
+    "designSentWith": "Gönderildiği tasarım",
+    "designFollow": "Geçerli varsayılanı izle",
+    "designFollowNamed": "Geçerli varsayılanı izle ({name})",
+    "designDefaultName": "geçerli varsayılan tasarım",
+    "designPickConfirmTitle": "Bu fatura {name} tasarımına geçsin mi?",
+    "designPickConfirmBody": "Yalnızca görünüm değişir. Servis bilgileri, müşteri, ödeme koşulları ve tutarlar gönderildiği gibi kalır. Şu anki tasarım saklanmaz, bu yüzden bu işlem geri alınamaz.",
+    "designPickConfirm": "Tasarımı değiştir",
+    "designPickDone": "Fatura artık {name} tasarımıyla yazdırılıyor",
+    "designPickFailed": "Tasarım değiştirilemedi"
   },
   "basicInfo": {
     "title": "Temel Bilgiler",
@@ -89,8 +102,9 @@
     "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.",
+    "designPinned": "Bu fatura müşteriye {date} tarihinde ulaştı, bu yüzden çıktığı tasarımı korur. Buradaki seçim ancak kilidi açılıp yeniden gönderildikten sonra geçerli olur. Bir sahip tasarımı şimdi üstteki ⋮ menüsündeki Fatura tasarımı ile değiştirebilir.",
     "designSaved": "Fatura tasarımı güncellendi",
+    "designSavedPinned": "Kaydedildi. Bu fatura, kilidi açılıp yeniden gönderilene kadar gönderildiği tasarımı korur.",
     "designFollowingRule": "Varsayılan ({name}, {rule})",
     "designRule": {
       "parts_sale": "Yalnızca parça satışı"

+ 10 - 0
messages/tr/settings.json

@@ -600,6 +600,16 @@
     "freezeProgress": "{total} faturadan {done} tanesi kilitlendi",
     "freezeDone": "{count, plural, one {# fatura} other {# fatura}} kilitlendi",
     "freezeFailed": "Faturalar kilitlenemedi",
+    "reapplyTitle": "Gönderilmiş faturalardaki tasarım",
+    "reapplyDescription": "Bugün kullandığınız tasarımı, daha eski bir tasarıma sabitlenmiş faturalara uygulayın.",
+    "reapplyBody": "{count, plural, one {# fatura} other {# fatura}} gönderildiği tasarıma sabitlenmiş durumda, bu yüzden yeni bir tasarım yalnızca bundan sonra gönderilen faturalara ulaşır. Yeniden uygulamak bugünkü tasarımı hepsine koyar. Yalnızca görünüm değişir: her faturanın servis bilgileri, müşterisi, ödeme koşulları ve tutarları olduğu gibi kalır.",
+    "reapplyWarning": "Bu geri alınamaz. Faturaların daha önce kullandığı tasarım saklanmaz.",
+    "reapplyButton": "Geçerli tasarımı yeniden uygula",
+    "reapplyConfirmTitle": "Tasarım {count, plural, one {# faturaya} other {# faturaya}} yeniden uygulansın mı?",
+    "reapplyConfirmBody": "Müşteriye gitmiş her fatura şu anda kullanılan tasarıma geçer. Yalnızca görünüm değişir: her faturanın bilgileri, koşulları ve tutarları olduğu gibi kalır. Bu geri alınamaz ve önceki tasarım geri getirilemez.",
+    "reapplyProgress": "{total} faturanın {done} tanesi güncellendi",
+    "reapplyDone": "{count, plural, one {# fatura} other {# fatura}} artık geçerli tasarımı kullanıyor",
+    "reapplyFailed": "Tasarım yeniden uygulanamadı",
     "sectionSending": "Gönderim",
     "attachPdfLabel": "E-postalara PDF ekle",
     "attachPdfHint": "E-postayla gönderdiğiniz her fatura, teklif ve kontrol için varsayılan. Açık: müşteri PDF'i ek olarak alır. Kapalı: bunun yerine belgeye bir bağlantı alır ve görüntülenme sayacı ne zaman açtığını gösterir, böylece kimsenin bakmadıklarını takip edebilirsiniz. Tek bir gönderim için bunu paylaşım penceresinde değiştirebilirsiniz."

+ 164 - 0
src/__tests__/features/invoices/reapply-design.test.ts

@@ -0,0 +1,164 @@
+/**
+ * Re-applying the current design to invoices that are already issued.
+ *
+ * The whole value of issuing is that a sent invoice stops following live
+ * settings, so the one thing this must never do is let a design change carry
+ * anything else with it: not the workshop's address, not the payment terms,
+ * not the date the document became the customer's. These pin that down, plus
+ * the memo that keeps a run over a whole archive from resolving the same
+ * design once per invoice.
+ */
+
+import { it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    serviceRecord: { findMany: vi.fn(), update: vi.fn(), findFirst: vi.fn() },
+    appSetting: { findMany: vi.fn() },
+    documentDesign: { findFirst: vi.fn(), findMany: vi.fn() },
+  },
+}))
+vi.mock('@/features/invoices/Lib/assembleInvoicePrint', () => ({
+  currentLook: vi.fn(),
+  designLook: vi.fn(),
+}))
+vi.mock('@/features/invoice-designer/Lib/designSnapshots', () => ({
+  ensureDesignSnapshot: vi.fn(),
+  ensureAssetSnapshot: vi.fn(),
+}))
+
+import { db } from '@/lib/db'
+import { currentLook, designLook } from '@/features/invoices/Lib/assembleInvoicePrint'
+import {
+  ensureAssetSnapshot,
+  ensureDesignSnapshot,
+} from '@/features/invoice-designer/Lib/designSnapshots'
+import { reapplyDesign } from '@/features/invoices/Lib/reapplyDesign'
+
+const ORG = 'org-1'
+
+const record = (id: string, over: Record<string, unknown> = {}) => ({
+  id,
+  designId: null,
+  vehicleId: 'veh-1',
+  customer: null,
+  vehicle: { customer: { invoiceDesignId: null } },
+  ...over,
+})
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([] as never)
+  vi.mocked(db.serviceRecord.update).mockResolvedValue({} as never)
+  vi.mocked(currentLook).mockResolvedValue({
+    designSource: { layout: {}, template: {} } as never,
+    logoDataUri: 'data:image/png;base64,AAA',
+  })
+  vi.mocked(designLook).mockResolvedValue({
+    designSource: { layout: {}, template: {} } as never,
+    logoDataUri: 'data:image/png;base64,BBB',
+  })
+  vi.mocked(ensureDesignSnapshot).mockResolvedValue('design-snap')
+  vi.mocked(ensureAssetSnapshot).mockResolvedValue('logo-snap')
+})
+
+it('moves the look and nothing else', async () => {
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([record('rec-1')] as never)
+
+  expect(await reapplyDesign(ORG, ['rec-1'])).toBe(1)
+  expect(db.serviceRecord.update).toHaveBeenCalledWith({
+    where: { id: 'rec-1' },
+    data: { issuedDesignSnapshotId: 'design-snap', issuedLogoSnapshotId: 'logo-snap' },
+  })
+})
+
+it('only ever looks at issued invoices of this workshop', async () => {
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([] as never)
+
+  await reapplyDesign(ORG, ['rec-1'])
+  const where = vi.mocked(db.serviceRecord.findMany).mock.calls[0]?.[0]?.where
+  expect(where).toMatchObject({
+    organizationId: ORG,
+    issuedAt: { not: null },
+    id: { in: ['rec-1'] },
+  })
+})
+
+it('clears the logo pointer when the design carries no logo', async () => {
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([record('rec-1')] as never)
+  vi.mocked(currentLook).mockResolvedValue({
+    designSource: { layout: {}, template: {} } as never,
+    logoDataUri: undefined,
+  })
+
+  await reapplyDesign(ORG, ['rec-1'])
+  expect(ensureAssetSnapshot).not.toHaveBeenCalled()
+  expect(db.serviceRecord.update).toHaveBeenCalledWith({
+    where: { id: 'rec-1' },
+    data: { issuedDesignSnapshotId: 'design-snap', issuedLogoSnapshotId: null },
+  })
+})
+
+it('resolves the design once for invoices that resolve to the same one', async () => {
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+    record('rec-1'),
+    record('rec-2'),
+    record('rec-3'),
+  ] as never)
+
+  expect(await reapplyDesign(ORG, ['rec-1', 'rec-2', 'rec-3'])).toBe(3)
+  expect(currentLook).toHaveBeenCalledTimes(1)
+  expect(db.serviceRecord.update).toHaveBeenCalledTimes(3)
+})
+
+it('resolves again when an invoice pins its own design', async () => {
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+    record('rec-1'),
+    record('rec-2', { designId: 'design-b' }),
+  ] as never)
+
+  await reapplyDesign(ORG, ['rec-1', 'rec-2'])
+  expect(currentLook).toHaveBeenCalledTimes(2)
+})
+
+it('resolves again for a counter sale, whose design rules differ', async () => {
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+    record('rec-1'),
+    record('rec-2', { vehicleId: null, vehicle: null, customer: { invoiceDesignId: null } }),
+  ] as never)
+
+  await reapplyDesign(ORG, ['rec-1', 'rec-2'])
+  expect(currentLook).toHaveBeenCalledTimes(2)
+})
+
+it('does nothing, and asks the database nothing, for an empty list', async () => {
+  expect(await reapplyDesign(ORG, [])).toBe(0)
+  expect(db.serviceRecord.findMany).not.toHaveBeenCalled()
+})
+
+it('uses one chosen design for every invoice, resolved once', async () => {
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+    record('rec-1'),
+    record('rec-2', { vehicleId: null, vehicle: null, customer: { invoiceDesignId: 'other' } }),
+  ] as never)
+  vi.mocked(db.documentDesign.findFirst).mockResolvedValue({
+    layout: {},
+    template: {},
+  } as never)
+
+  expect(await reapplyDesign(ORG, ['rec-1', 'rec-2'], 'design-x')).toBe(2)
+  // The pick is the look, so what each invoice would have followed is moot.
+  expect(currentLook).not.toHaveBeenCalled()
+  expect(designLook).toHaveBeenCalledTimes(1)
+  expect(db.serviceRecord.update).toHaveBeenCalledTimes(2)
+})
+
+it('refuses a design of another workshop', async () => {
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([record('rec-1')] as never)
+  vi.mocked(db.documentDesign.findFirst).mockResolvedValue(null as never)
+
+  await expect(reapplyDesign(ORG, ['rec-1'], 'design-elsewhere')).rejects.toThrow(
+    'Design not found'
+  )
+  expect(db.serviceRecord.update).not.toHaveBeenCalled()
+})

+ 85 - 1
src/app/(authenticated)/settings/invoice/invoice-settings.tsx

@@ -19,8 +19,19 @@ import { Textarea } from '@/components/ui/textarea'
 import { toast } from 'sonner'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { freezeUnfrozenInvoices } from '@/features/invoices/Actions/legacyInvoiceActions'
+import { reapplyDesignToIssuedInvoices } from '@/features/invoices/Actions/invoiceDesignActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { ChevronDown, ChevronUp, FileText, Hash, Loader2, Lock, Save } from 'lucide-react'
+import {
+  AlertTriangle,
+  ChevronDown,
+  ChevronUp,
+  FileText,
+  Hash,
+  Loader2,
+  Lock,
+  Palette,
+  Save,
+} from 'lucide-react'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 import { cn } from '@/lib/utils'
 import { useConfirm } from '@/components/confirm-dialog'
@@ -51,6 +62,8 @@ interface InvoiceSettingsProps {
   workshop?: { name?: string; address?: string; phone?: string; email?: string; slogan?: string }
   /** Invoices that reached a customer before issuing existed, still unfrozen. */
   unfrozenInvoices?: number
+  /** Invoices locked to the design they were issued with. */
+  issuedInvoices?: number
   initialInvoiceLayout?: InvoiceLayoutConfig
   initialQuoteLayout?: InvoiceLayoutConfig
   customFields: FieldDef[]
@@ -107,6 +120,7 @@ function SwitchRow({
 export function InvoiceSettings({
   settings,
   unfrozenInvoices = 0,
+  issuedInvoices = 0,
   initialInvoiceLayout,
   initialQuoteLayout,
   customFields,
@@ -220,6 +234,41 @@ export function InvoiceSettings({
     }
   }
 
+  // Invoices already sent print from the design they were locked to. A
+  // workshop that rebrands wants the new look on the archive too, so this
+  // walks every issued invoice by cursor and moves only its design and logo.
+  // A cursor rather than a count: a re-applied invoice is still issued, so
+  // the set being walked never shrinks.
+  const [reapplying, setReapplying] = useState<{ done: number; total: number } | null>(null)
+  const handleReapplyDesign = async () => {
+    const total = issuedInvoices
+    const ok = await confirm({
+      title: t('invoice.reapplyConfirmTitle', { count: total }),
+      description: t('invoice.reapplyConfirmBody'),
+      confirmLabel: t('invoice.reapplyButton'),
+      destructive: true,
+    })
+    if (!ok) return
+    setReapplying({ done: 0, total })
+    let done = 0
+    try {
+      let cursor: string | undefined
+      do {
+        const result = await reapplyDesignToIssuedInvoices(cursor)
+        if (!result.success || !result.data) throw new Error(result.success ? '' : result.error)
+        done += result.data.updated
+        cursor = result.data.nextCursor ?? undefined
+        setReapplying({ done, total })
+      } while (cursor)
+      toast.success(t('invoice.reapplyDone', { count: done }))
+      router.refresh()
+    } catch {
+      toast.error(t('invoice.reapplyFailed'))
+    } finally {
+      setReapplying(null)
+    }
+  }
+
   const year = String(new Date().getFullYear())
   const numberHint = (prefix: string, preview: string) =>
     t.rich('invoice.invoiceNumberFormatHint', {
@@ -508,6 +557,41 @@ export function InvoiceSettings({
               )}
             </AppCard>
 
+            {issuedInvoices > 0 && (
+              <AppCard
+                icon={Palette}
+                title={t('invoice.reapplyTitle')}
+                description={t('invoice.reapplyDescription')}
+                contentClassName="space-y-3"
+              >
+                <p className="text-xs leading-relaxed text-muted-foreground">
+                  {t('invoice.reapplyBody', { count: issuedInvoices })}
+                </p>
+                <div className="flex flex-col gap-3 rounded-md border border-dashed p-3 sm:flex-row sm:items-center sm:justify-between">
+                  <p className="flex items-start gap-2 text-xs leading-relaxed text-amber-700 dark:text-amber-400">
+                    <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
+                    {t('invoice.reapplyWarning')}
+                  </p>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    className="shrink-0"
+                    disabled={reapplying !== null}
+                    onClick={handleReapplyDesign}
+                  >
+                    {reapplying && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
+                    {reapplying
+                      ? t('invoice.reapplyProgress', {
+                          done: reapplying.done,
+                          total: reapplying.total,
+                        })
+                      : t('invoice.reapplyButton')}
+                  </Button>
+                </div>
+              </AppCard>
+            )}
+
             <SaveButton>
               <div className="flex items-center gap-3">
                 <Button onClick={handleSaveGeneral} disabled={saving}>

+ 7 - 1
src/app/(authenticated)/settings/invoice/page.tsx

@@ -11,6 +11,7 @@ import { getFieldDefinitions } from '@/features/custom-fields/Actions/customFiel
 import { redirect } from 'next/navigation'
 import { InvoiceSettings } from './invoice-settings'
 import { countUnfrozenInvoices } from '@/features/invoices/Actions/legacyInvoiceActions'
+import { countIssuedInvoices } from '@/features/invoices/Actions/invoiceDesignActions'
 
 export default async function InvoiceSettingsPage() {
   const data = await getLayoutData()
@@ -30,8 +31,12 @@ export default async function InvoiceSettingsPage() {
   const settings = result.success && result.data ? result.data : {}
   const customFields =
     customFieldsResult.success && customFieldsResult.data ? customFieldsResult.data : []
-  const unfrozenResult = await countUnfrozenInvoices()
+  const [unfrozenResult, issuedResult] = await Promise.all([
+    countUnfrozenInvoices(),
+    countIssuedInvoices(),
+  ])
   const unfrozenInvoices = unfrozenResult.success ? (unfrozenResult.data ?? 0) : 0
+  const issuedInvoices = issuedResult.success ? (issuedResult.data ?? 0) : 0
 
   // Check if Telegram is enabled (plan feature + the switch on its integration)
   let telegramEnabled = false
@@ -59,6 +64,7 @@ export default async function InvoiceSettingsPage() {
       settings={settings}
       workshop={workshop}
       unfrozenInvoices={unfrozenInvoices}
+      issuedInvoices={issuedInvoices}
       initialInvoiceLayout={invoiceLayoutResult.success ? invoiceLayoutResult.data : undefined}
       initialQuoteLayout={quoteLayoutResult.success ? quoteLayoutResult.data : undefined}
       customFields={customFields}

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

@@ -5,6 +5,7 @@ import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { assertInvoiceEditable } from '@/lib/document-lock.server'
+import { ISSUED_WHERE, issuedDesignState, reapplyDesign } from '../Lib/reapplyDesign'
 
 /**
  * Chooses which design one invoice prints with. Null goes back to following
@@ -55,3 +56,138 @@ export async function setInvoiceDesign(recordId: string, designId: string | null
     }
   )
 }
+
+/**
+ * Putting the design in use now onto invoices that were issued with an older
+ * one, for the whole archive from invoice settings or for a single invoice
+ * from its own page. What this moves and what it leaves alone is described in
+ * Lib/reapplyDesign.ts.
+ *
+ * Both are written to the audit log: this is a deliberate change to documents
+ * the rules otherwise hold still, and it cannot be undone.
+ *
+ * They are guarded differently on purpose. The archive run sits in invoice
+ * settings beside the one-way lock of older invoices and answers to the same
+ * permission: whoever may edit settings may already change the active design
+ * and lock the whole archive. The single-invoice one sits beside the unlock
+ * on a document a customer holds, so it answers to the same rule as that
+ * unlock and is an owner's or admin's call.
+ */
+
+function requireAdmin(isAdmin: boolean) {
+  if (!isAdmin) {
+    throw new Error('Only an owner or admin can re-apply a design to one issued invoice')
+  }
+}
+
+/** How many invoices the archive run would touch, for the warning it shows. */
+export async function countIssuedInvoices() {
+  return withAuth(
+    async ({ organizationId }) => db.serviceRecord.count({ where: ISSUED_WHERE(organizationId) }),
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+    }
+  )
+}
+
+/** How many invoices one call re-applies, so the request stays short. */
+const BATCH = 100
+
+/**
+ * Re-applies the current design to a batch of issued invoices and hands back
+ * the cursor for the next one. A cursor rather than a shrinking count: unlike
+ * the one-way lock of older invoices, a re-applied invoice still matches the
+ * set being walked, so counting what is left would never reach zero.
+ */
+export async function reapplyDesignToIssuedInvoices(cursorId?: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const batch = await db.serviceRecord.findMany({
+        where: ISSUED_WHERE(organizationId),
+        select: { id: true },
+        orderBy: { id: 'asc' },
+        take: BATCH,
+        ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : {}),
+      })
+      const updated = await reapplyDesign(
+        organizationId,
+        batch.map((r) => r.id)
+      )
+      revalidatePath('/settings/invoice')
+      return {
+        updated,
+        // A short batch is the last one; a full one may still have more.
+        nextCursor: batch.length === BATCH ? (batch[batch.length - 1]?.id ?? null) : null,
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: 'settings.reapplyInvoiceDesign',
+        entity: 'ServiceRecord',
+        details: { key: 'settings_reapplyInvoiceDesign', params: { count: result.updated } },
+        metadata: { updated: result.updated },
+      }),
+    }
+  )
+}
+
+/**
+ * What the menu on one invoice needs to describe itself: when the invoice was
+ * issued, which design it prints with now, and what else it could print with.
+ * Null for an invoice that is not issued, which has no frozen look to change.
+ */
+export async function getIssuedDesignState(recordId: string) {
+  return withAuth(({ organizationId }) => issuedDesignState(organizationId, recordId), {
+    requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }],
+  })
+}
+
+/**
+ * The same re-apply, for the one invoice open on screen, and with a choice:
+ * `designId` names a saved design, null goes back to whichever design the
+ * invoice would follow now.
+ *
+ * The pick is written to `designId` as well as to the snapshot. Otherwise an
+ * owner who unlocks and re-sends the invoice later would silently lose it,
+ * since the re-capture on send reads the live choice.
+ */
+export async function reapplyDesignToInvoice(recordId: string, designId: string | null) {
+  return withAuth(
+    async ({ organizationId, isAdmin }) => {
+      requireAdmin(isAdmin)
+      const record = await db.serviceRecord.findFirst({
+        where: { id: recordId, organizationId },
+        select: { id: true, vehicleId: true, invoiceNumber: true, issuedAt: true },
+      })
+      if (!record) throw new Error('Record not found')
+      if (!record.issuedAt) throw new Error('This invoice is not locked to a design')
+
+      const updated = await reapplyDesign(organizationId, [record.id], designId)
+      await db.serviceRecord.update({
+        where: { id: record.id },
+        data: { designId: designId || null },
+      })
+      revalidatePath(
+        record.vehicleId
+          ? `/vehicles/${record.vehicleId}/service/${recordId}`
+          : `/sales/${recordId}`
+      )
+      return { recordId, updated, designId, reference: record.invoiceNumber || recordId }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+      audit: ({ result }) => ({
+        action: 'invoice.reapplyDesign',
+        entity: 'ServiceRecord',
+        entityId: result.recordId,
+        message: `Changed the design of issued invoice ${result.reference}`,
+        metadata: { serviceRecordId: result.recordId, designId: result.designId },
+      }),
+    }
+  )
+}

+ 47 - 7
src/features/invoices/Lib/assembleInvoicePrint.ts

@@ -141,6 +141,12 @@ async function loadLogoDataUri(logoPath: string): Promise<string | undefined> {
   }
 }
 
+/** The little of an invoice that decides which design it prints with. */
+export interface DesignSubject {
+  designId: string | null
+  vehicleId: string | null
+}
+
 /**
  * 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
@@ -150,7 +156,7 @@ async function loadLogoDataUri(logoPath: string): Promise<string | undefined> {
 async function resolveLiveDesign(
   organizationId: string,
   settingsMap: Record<string, string>,
-  record: InvoiceRecordForPrint,
+  record: DesignSubject,
   customerDesignId: string | null | undefined
 ): Promise<DesignSource> {
   // The invoice's own choice, then its customer's, then whichever design
@@ -170,6 +176,43 @@ async function resolveLiveDesign(
   return designSourceFromSettings(settingsMap, 'invoice')
 }
 
+/** A design together with the logo it prints. */
+export interface DesignLook {
+  designSource: DesignSource
+  logoDataUri?: string
+}
+
+/**
+ * A design plus the logo it points at, which is the design's own if it sets
+ * one and the workshop's otherwise.
+ */
+export async function designLook(
+  settingsMap: Record<string, string>,
+  designSource: DesignSource
+): Promise<DesignLook> {
+  const logoPath =
+    designSource.template.logoUrl?.trim() || settingsMap[SETTING_KEYS.COMPANY_LOGO]?.trim() || ''
+  return { designSource, logoDataUri: await loadLogoDataUri(logoPath) }
+}
+
+/**
+ * The look an invoice would print with right now. Split out of the live
+ * assembly because re-applying a design to an issued invoice needs exactly
+ * this and nothing else, and building a whole sheet per invoice to get it
+ * would make a bulk run crawl.
+ */
+export async function currentLook(
+  organizationId: string,
+  settingsMap: Record<string, string>,
+  record: DesignSubject,
+  customerDesignId: string | null | undefined
+): Promise<DesignLook> {
+  return designLook(
+    settingsMap,
+    await resolveLiveDesign(organizationId, settingsMap, record, customerDesignId)
+  )
+}
+
 function liveInvoiceSettings(settingsMap: Record<string, string>): InvoiceSettingsProps {
   return {
     bankAccount: settingsMap['invoice.bankAccount'] || '',
@@ -267,19 +310,16 @@ async function assembleLive(
   settingsMap: Record<string, string>
 ): Promise<InvoicePrintAssembly> {
   const customerRow = record.customer ?? record.vehicle?.customer ?? null
-  const [findings, customFields, designSource] = await Promise.all([
+  const [findings, customFields, look] = 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),
+    currentLook(organizationId, settingsMap, record, customerRow?.invoiceDesignId),
   ])
-
-  const logoPath =
-    designSource.template.logoUrl?.trim() || settingsMap[SETTING_KEYS.COMPANY_LOGO]?.trim() || ''
-  const logoDataUri = await loadLogoDataUri(logoPath)
+  const { designSource, logoDataUri } = look
 
   const invoiceSettings = liveInvoiceSettings(settingsMap)
   const workshop: WorkshopInfo = {

+ 218 - 0
src/features/invoices/Lib/reapplyDesign.ts

@@ -0,0 +1,218 @@
+/**
+ * Re-freezing the look of an invoice that is already issued.
+ *
+ * An issued invoice prints from the snapshots it was issued with, so the
+ * sheet a customer holds cannot be rewritten by a later change of address,
+ * terms or logo. That is the point of issuing, and it is right for what the
+ * document says. It is not always right for how it looks: a workshop that
+ * rebrands, fixes a crooked logo or corrects a colour usually wants the new
+ * look on the whole archive, not only on invoices sent from today.
+ *
+ * So this moves the two pointers that hold the look, `issuedDesignSnapshotId`
+ * and `issuedLogoSnapshotId`, to a chosen design, or to the one the invoice
+ * would print with if it were drafted now. Everything else an issue captured is left exactly as
+ * it was: `issuedData` keeps the workshop and customer details, the payment
+ * terms, the findings and the custom fields, and `issuedAt` keeps saying when
+ * the document became the customer's. The numbers on the paper do not move.
+ *
+ * The old snapshot rows are not deleted. They are shared by content hash and
+ * an invoice may still reference them, and keeping them costs a row. What
+ * cannot be recovered is which snapshot this invoice used to point at, which
+ * is why both callers warn before they run.
+ */
+
+import { db } from '@/lib/db'
+import {
+  ensureAssetSnapshot,
+  ensureDesignSnapshot,
+} from '@/features/invoice-designer/Lib/designSnapshots'
+import { contentHash } from '@/features/invoice-designer/Lib/designHash'
+import {
+  designSourceFromStored,
+  materializeDesignSource,
+} from '@/features/invoice-designer/Lib/designSource'
+import { currentLook, designLook, type DesignLook } from './assembleInvoicePrint'
+import type { DesignSource } from '@/features/invoice-designer/Lib/designSource'
+
+/** Only invoices that were issued have a look to re-apply; drafts follow the
+ *  live design already. */
+export const ISSUED_WHERE = (organizationId: string) => ({
+  organizationId,
+  issuedAt: { not: null },
+})
+
+const RECORD_SELECT = {
+  id: true,
+  designId: true,
+  vehicleId: true,
+  customer: { select: { invoiceDesignId: true } },
+  vehicle: { select: { customer: { select: { invoiceDesignId: true } } } },
+} as const
+
+type DesignRecord = {
+  id: string
+  designId: string | null
+  vehicleId: string | null
+  customer: { invoiceDesignId: string | null } | null
+  vehicle: { customer: { invoiceDesignId: string | null } | null } | null
+}
+
+/**
+ * Two invoices resolve to the same design whenever these three agree, so a
+ * run over a thousand invoices of one workshop does the resolution a handful
+ * of times rather than a thousand.
+ */
+function lookKey(record: DesignRecord, customerDesignId: string | null): string {
+  return [record.designId ?? '', customerDesignId ?? '', record.vehicleId ? 'v' : '-'].join('|')
+}
+
+interface Snapshots {
+  designSnapshotId: string
+  logoSnapshotId: string | null
+}
+
+async function snapshotsFor(organizationId: string, look: DesignLook): Promise<Snapshots> {
+  const [designSnapshotId, logoSnapshotId] = await Promise.all([
+    ensureDesignSnapshot(organizationId, look.designSource),
+    look.logoDataUri
+      ? ensureAssetSnapshot(organizationId, look.logoDataUri)
+      : Promise.resolve(null),
+  ])
+  return { designSnapshotId, logoSnapshotId }
+}
+
+/**
+ * Points the given issued invoices at a design. `chosenDesignId` names one of
+ * the workshop's saved designs; null means whichever design each invoice
+ * would follow if it were drafted now, which is what the archive run uses.
+ *
+ * Returns how many rows were changed. Records that are not issued are skipped
+ * rather than issued as a side effect: freezing a draft is a different
+ * decision, and invoice settings already offers it.
+ */
+export async function reapplyDesign(
+  organizationId: string,
+  recordIds: string[],
+  chosenDesignId: string | null = null
+): Promise<number> {
+  if (recordIds.length === 0) return 0
+
+  const [records, settings] = await Promise.all([
+    db.serviceRecord.findMany({
+      where: { ...ISSUED_WHERE(organizationId), id: { in: recordIds } },
+      select: RECORD_SELECT,
+    }),
+    db.appSetting.findMany({ where: { organizationId }, select: { key: true, value: true } }),
+  ])
+  const settingsMap: Record<string, string> = {}
+  for (const s of settings) settingsMap[s.key] = s.value
+
+  // One named design is the same look for every invoice, so it is resolved
+  // once. Following the default is resolved per invoice, but two invoices
+  // that agree on the three things the rules read share the answer.
+  const chosen = chosenDesignId
+    ? await snapshotsFor(
+        organizationId,
+        await designLook(settingsMap, await loadDesignSource(organizationId, chosenDesignId))
+      )
+    : null
+  const resolved = new Map<string, Snapshots>()
+  let updated = 0
+
+  for (const record of records) {
+    let snapshots = chosen
+    if (!snapshots) {
+      const customerDesignId =
+        record.customer?.invoiceDesignId ?? record.vehicle?.customer?.invoiceDesignId ?? null
+      const key = lookKey(record, customerDesignId)
+      snapshots = resolved.get(key) ?? null
+      if (!snapshots) {
+        snapshots = await snapshotsFor(
+          organizationId,
+          await currentLook(organizationId, settingsMap, record, customerDesignId)
+        )
+        resolved.set(key, snapshots)
+      }
+    }
+    await db.serviceRecord.update({
+      where: { id: record.id },
+      data: {
+        issuedDesignSnapshotId: snapshots.designSnapshotId,
+        issuedLogoSnapshotId: snapshots.logoSnapshotId,
+      },
+    })
+    updated += 1
+  }
+
+  return updated
+}
+
+/** A saved invoice design of this workshop, refused if it is neither. */
+async function loadDesignSource(organizationId: string, designId: string) {
+  const row = await db.documentDesign.findFirst({
+    where: { id: designId, organizationId, documentType: 'invoice' },
+    select: { layout: true, template: true },
+  })
+  const source = row ? designSourceFromStored(row.layout, row.template) : null
+  if (!source) throw new Error('Design not found')
+  return source
+}
+
+/**
+ * Which design an issued invoice prints with, worked out by content rather
+ * than stored. Nothing on the record names the frozen design: snapshots are
+ * shared by content hash and carry no name, so the only honest answer is to
+ * hash each saved design and see which one matches.
+ *
+ * That also answers it correctly after the design has been edited since the
+ * invoice was sent, where the truthful answer is "none of these any more".
+ * Worked out when the menu opens rather than on every page load, because it
+ * reads every design of the workshop to do it.
+ */
+export async function issuedDesignState(organizationId: string, recordId: string) {
+  const record = await db.serviceRecord.findFirst({
+    where: { id: recordId, organizationId },
+    select: {
+      ...RECORD_SELECT,
+      issuedAt: true,
+      issuedDesignSnapshot: { select: { hash: true } },
+    },
+  })
+  if (!record?.issuedAt) return null
+
+  const [designs, settings] = await Promise.all([
+    db.documentDesign.findMany({
+      where: { organizationId, documentType: 'invoice' },
+      select: { id: true, name: true, layout: true, template: true },
+      orderBy: { name: 'asc' },
+    }),
+    db.appSetting.findMany({ where: { organizationId }, select: { key: true, value: true } }),
+  ])
+  const settingsMap: Record<string, string> = {}
+  for (const s of settings) settingsMap[s.key] = s.value
+
+  const frozenHash = record.issuedDesignSnapshot?.hash ?? null
+  const hashOf = (source: DesignSource | null) =>
+    source ? contentHash(materializeDesignSource(source)) : null
+
+  const customerDesignId =
+    record.customer?.invoiceDesignId ?? record.vehicle?.customer?.invoiceDesignId ?? null
+  const live = await currentLook(organizationId, settingsMap, record, customerDesignId)
+  const followsDefault = frozenHash !== null && hashOf(live.designSource) === frozenHash
+
+  // The default wins a tie: when it resolves to a named design, "follows the
+  // default" says more about the invoice than the design's own name does.
+  const matchedDesignId = followsDefault
+    ? null
+    : (designs.find((d) => hashOf(designSourceFromStored(d.layout, d.template)) === frozenHash)
+        ?.id ?? null)
+
+  return {
+    issuedAt: record.issuedAt.toISOString(),
+    followsDefault,
+    matchedDesignId,
+    /** True when the frozen look is no design the workshop still has. */
+    unknown: !followsDefault && matchedDesignId === null,
+    designs: designs.map((d) => ({ id: d.id, name: d.name })),
+  }
+}

+ 4 - 1
src/features/vehicles/Components/service-edit/InvoiceDetailsSection.tsx

@@ -88,7 +88,10 @@ export function InvoiceDetailsSection({
     const result = await setInvoiceDesign(initialData.id, value === FOLLOW_DEFAULT ? null : value)
     setSavingDesign(false)
     if (result.success) {
-      toast.success(t('designSaved'))
+      // An issued invoice prints from the copy it was sent with, so the choice
+      // is saved but not visible yet. Saying "updated" here sends people off
+      // to email a sheet that still carries the old design.
+      toast.success(designPinnedAt ? t('designSavedPinned') : t('designSaved'))
       router.refresh()
     } else {
       setDesign(previous)

+ 158 - 0
src/features/vehicles/Components/service-page/InvoiceDesignMenu.tsx

@@ -0,0 +1,158 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { Check, Palette } from 'lucide-react'
+import { toast } from 'sonner'
+import {
+  DropdownMenuPortal,
+  DropdownMenuSeparator,
+  DropdownMenuSub,
+  DropdownMenuSubContent,
+  DropdownMenuSubTrigger,
+  DropdownMenuItem,
+} from '@/components/ui/dropdown-menu'
+import { useConfirm } from '@/components/confirm-dialog'
+import {
+  getIssuedDesignState,
+  reapplyDesignToInvoice,
+} from '@/features/invoices/Actions/invoiceDesignActions'
+
+interface DesignState {
+  issuedAt: string
+  followsDefault: boolean
+  matchedDesignId: string | null
+  unknown: boolean
+  designs: { id: string; name: string }[]
+}
+
+interface InvoiceDesignMenuProps {
+  recordId: string
+  /** What "the current default" resolves to for this invoice, if it has a name. */
+  designFollowsName: string | null
+}
+
+/**
+ * Changing the look of an invoice that has already gone to the customer.
+ *
+ * A bare "use the current design" told nobody anything, so this is a submenu
+ * that says what state the invoice is in before it offers to change it: when
+ * it was sent, that it still prints with the design it was sent with, and
+ * that picking another changes the look and nothing the invoice says.
+ *
+ * Which design is in force is not stored anywhere, so it is worked out by
+ * hashing on the server when the submenu opens rather than on every page
+ * load. Until that lands the list is not shown at all: a list with no tick on
+ * it would be a worse answer than a spinner.
+ */
+export function InvoiceDesignMenu({ recordId, designFollowsName }: InvoiceDesignMenuProps) {
+  const t = useTranslations('service.header')
+  const router = useRouter()
+  const confirm = useConfirm()
+  const [state, setState] = useState<DesignState | null>(null)
+  const [busy, setBusy] = useState(false)
+
+  const load = async () => {
+    const result = await getIssuedDesignState(recordId)
+    if (result.success && result.data) setState(result.data as DesignState)
+  }
+
+  const pick = async (designId: string | null, name: string) => {
+    const ok = await confirm({
+      title: t('designPickConfirmTitle', { name }),
+      description: t('designPickConfirmBody'),
+      confirmLabel: t('designPickConfirm'),
+      destructive: true,
+    })
+    if (!ok) return
+    setBusy(true)
+    const result = await reapplyDesignToInvoice(recordId, designId)
+    setBusy(false)
+    if (!result.success) {
+      toast.error(t('designPickFailed'))
+      return
+    }
+    // The tick has moved; the next open re-reads where to.
+    setState(null)
+    toast.success(t('designPickDone', { name }))
+    router.refresh()
+  }
+
+  // The row says what following the default means here; the confirm and the
+  // toast name the design itself, because "now prints with Follow the current
+  // default (Nordic Blue)" is not a sentence.
+  const defaultLabel = designFollowsName
+    ? t('designFollowNamed', { name: designFollowsName })
+    : t('designFollow')
+  const defaultName = designFollowsName ?? t('designDefaultName')
+
+  return (
+    <DropdownMenuSub
+      onOpenChange={(open) => {
+        if (open && !state) void load()
+      }}
+    >
+      <DropdownMenuSubTrigger>
+        <Palette className="mr-2 size-4" aria-hidden="true" />
+        {t('designMenu')}
+      </DropdownMenuSubTrigger>
+      <DropdownMenuPortal>
+        <DropdownMenuSubContent className="max-w-[min(20rem,calc(100vw-2rem))]">
+          <p className="px-2 py-1.5 text-xs leading-relaxed text-muted-foreground whitespace-normal">
+            {state
+              ? t('designMenuExplain', {
+                  date: new Date(state.issuedAt).toLocaleDateString(),
+                })
+              : t('designMenuLoading')}
+          </p>
+          {state && (
+            <>
+              <DropdownMenuSeparator />
+              {state.unknown && <Choice label={t('designSentWith')} current />}
+              <Choice
+                label={defaultLabel}
+                current={state.followsDefault}
+                onPick={busy ? undefined : () => void pick(null, defaultName)}
+              />
+              {state.designs.map((design) => (
+                <Choice
+                  key={design.id}
+                  label={design.name}
+                  current={state.matchedDesignId === design.id}
+                  onPick={busy ? undefined : () => void pick(design.id, design.name)}
+                />
+              ))}
+            </>
+          )}
+        </DropdownMenuSubContent>
+      </DropdownMenuPortal>
+    </DropdownMenuSub>
+  )
+}
+
+/**
+ * One design to choose from. The one in force is ticked and cannot be picked,
+ * so the menu answers "which is it?" and "what else?" in the same list; the
+ * others keep the tick's width so the names line up.
+ */
+function Choice({
+  label,
+  current,
+  onPick,
+}: {
+  label: string
+  current: boolean
+  onPick?: () => void
+}) {
+  return (
+    <DropdownMenuItem disabled={current || !onPick} onClick={onPick}>
+      {current ? (
+        <Check className="size-4 shrink-0" aria-hidden="true" />
+      ) : (
+        <span className="size-4 shrink-0" aria-hidden="true" />
+      )}
+      <span className="truncate">{label}</span>
+    </DropdownMenuItem>
+  )
+}

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

@@ -2,6 +2,7 @@
 
 import { DocumentLockBanner } from '@/components/document-lock-banner'
 import { setInvoiceEditUnlocked } from '@/features/settings/Actions/documentLockActions'
+import { InvoiceDesignMenu } from './InvoiceDesignMenu'
 import { useState, useCallback, useMemo, useRef, useEffect } from 'react'
 import { useRouter } from 'next/navigation'
 import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
@@ -341,6 +342,12 @@ export function ServicePageClient({
     }
   }, [router, confirmComplete, formState, record.id, t])
 
+  // An issued invoice prints from the design it was frozen with. An owner or
+  // admin can move it to another, which changes the look and nothing the
+  // invoice says. Offered only while the sheet is actually frozen: a reopened
+  // one already follows the live design, so there is nothing to change.
+  const canChangeIssuedDesign = canUnlock && designPinnedAt !== null
+
   useSaveShortcut(() => {
     if (formState.hasUnsavedChanges) return actions.saveNow()
   })
@@ -390,6 +397,11 @@ export function ServicePageClient({
         }}
         onNotifyCustomer={handleNotifyCustomer}
         hasCustomer={!!customer}
+        designMenu={
+          canChangeIssuedDesign ? (
+            <InvoiceDesignMenu recordId={record.id} designFollowsName={designFollowsName} />
+          ) : undefined
+        }
       />
 
       {(lockState.locked || lockState.unlockedAt) && (

+ 46 - 6
src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx

@@ -5,6 +5,15 @@ import Link from 'next/link'
 import { Badge } from '@/components/ui/badge'
 import { ButtonGroup } from '@/components/ui/button-group'
 import { IconActionButton } from '@/components/icon-action-button'
+import { Button } from '@/components/ui/button'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 import { cn } from '@/lib/utils'
 import {
   ArrowLeft,
@@ -13,6 +22,7 @@ import {
   Globe,
   Mail,
   MessageSquare,
+  MoreVertical,
   Save,
   Trash2,
   Video,
@@ -53,6 +63,8 @@ interface UnifiedServiceHeaderProps {
   onShowShare: () => void
   onNotifyCustomer?: () => void
   hasCustomer?: boolean
+  /** The invoice-design submenu, when this invoice has a frozen look to change. */
+  designMenu?: React.ReactNode
   /** Video call link from a connected calendar, when one exists. */
   meetingUrl?: string | null
 }
@@ -77,6 +89,7 @@ export function UnifiedServiceHeader({
   onShowShare,
   onNotifyCustomer,
   hasCustomer = false,
+  designMenu,
   meetingUrl = null,
 }: UnifiedServiceHeaderProps) {
   const t = useTranslations('service.header')
@@ -162,12 +175,39 @@ export function UnifiedServiceHeader({
                 onClick={onNotifyCustomer}
               />
             )}
-            <IconActionButton
-              label={t('delete')}
-              icon={Trash2}
-              className="text-destructive hover:bg-destructive/10"
-              onClick={onDelete}
-            />
+            {/* The row already carries six actions in twelve languages. New
+                ones go in here rather than widening it, and Delete moved in
+                with them: a destructive button one pixel from Share is a
+                misclick waiting to happen. */}
+            <DropdownMenu>
+              <Tooltip>
+                <TooltipTrigger asChild>
+                  <DropdownMenuTrigger asChild>
+                    <Button
+                      type="button"
+                      variant="outline"
+                      size="icon-sm"
+                      aria-label={t('moreActions')}
+                    >
+                      <MoreVertical className="size-4" aria-hidden="true" />
+                    </Button>
+                  </DropdownMenuTrigger>
+                </TooltipTrigger>
+                <TooltipContent>{t('moreActions')}</TooltipContent>
+              </Tooltip>
+              <DropdownMenuContent align="end" className="min-w-56">
+                {designMenu && (
+                  <>
+                    {designMenu}
+                    <DropdownMenuSeparator />
+                  </>
+                )}
+                <DropdownMenuItem className="text-destructive" onClick={onDelete}>
+                  <Trash2 className="mr-2 size-4" aria-hidden="true" />
+                  {t('delete')}
+                </DropdownMenuItem>
+              </DropdownMenuContent>
+            </DropdownMenu>
           </ButtonGroup>
         </div>
       </div>