Sfoglia il codice sorgente

Add a printable work order with a customer signature line (#425)

A job prints as a work order while it is open: a fourth designer document
with its own sections, presets and design family, opened from the job page
menu. The default is a compact black-on-white sheet with the customer, the
car, the concerns, the work and parts with prices, and a line for each side
to sign. The Signature section gains a customer line on every document.
Bernt Christian Egeland 6 giorni fa
parent
commit
f9508bc651
70 ha cambiato i file con 2797 aggiunte e 177 eliminazioni
  1. 22 0
      messages/de/pdf.json
  2. 2 1
      messages/de/service.json
  3. 37 6
      messages/de/settings.json
  4. 22 0
      messages/en/pdf.json
  5. 2 1
      messages/en/service.json
  6. 37 6
      messages/en/settings.json
  7. 22 0
      messages/es/pdf.json
  8. 2 1
      messages/es/service.json
  9. 37 6
      messages/es/settings.json
  10. 22 0
      messages/fr/pdf.json
  11. 2 1
      messages/fr/service.json
  12. 37 6
      messages/fr/settings.json
  13. 22 0
      messages/it/pdf.json
  14. 2 1
      messages/it/service.json
  15. 37 6
      messages/it/settings.json
  16. 22 0
      messages/lt/pdf.json
  17. 2 1
      messages/lt/service.json
  18. 37 6
      messages/lt/settings.json
  19. 22 0
      messages/nb/pdf.json
  20. 2 1
      messages/nb/service.json
  21. 38 7
      messages/nb/settings.json
  22. 22 0
      messages/nl/pdf.json
  23. 2 1
      messages/nl/service.json
  24. 37 6
      messages/nl/settings.json
  25. 22 0
      messages/pl/pdf.json
  26. 2 1
      messages/pl/service.json
  27. 38 7
      messages/pl/settings.json
  28. 22 0
      messages/pt-BR/pdf.json
  29. 2 1
      messages/pt-BR/service.json
  30. 37 6
      messages/pt-BR/settings.json
  31. 22 0
      messages/pt-PT/pdf.json
  32. 2 1
      messages/pt-PT/service.json
  33. 37 6
      messages/pt-PT/settings.json
  34. 22 0
      messages/ru/pdf.json
  35. 2 1
      messages/ru/service.json
  36. 37 6
      messages/ru/settings.json
  37. 22 0
      messages/tr/pdf.json
  38. 2 1
      messages/tr/service.json
  39. 37 6
      messages/tr/settings.json
  40. 8 2
      src/__tests__/features/invoice-designer/fixed-slot-fields.test.ts
  41. 344 0
      src/__tests__/features/invoice-designer/work-order-print.test.ts
  42. 5 0
      src/__tests__/features/settings/framed-template-preset.test.tsx
  43. 10 1
      src/__tests__/features/settings/layout-editor-labels.test.ts
  44. 5 0
      src/app/(authenticated)/settings/templates/page.tsx
  45. 46 17
      src/app/(authenticated)/settings/templates/template-settings.tsx
  46. 27 5
      src/app/(designer)/invoice-designer/page.tsx
  47. 50 0
      src/app/api/protected/services/[id]/work-order-pdf/route.ts
  48. 1 1
      src/features/invoice-designer/Actions/documentDesignActions.ts
  49. 25 8
      src/features/invoice-designer/Components/InvoiceDesigner.tsx
  50. 8 5
      src/features/invoice-designer/Components/PresetPreview.tsx
  51. 70 0
      src/features/invoice-designer/Components/sample.ts
  52. 3 2
      src/features/invoice-designer/Components/types.ts
  53. 19 3
      src/features/invoice-designer/Lib/designSource.ts
  54. 4 2
      src/features/invoice-designer/Lib/documentLogo.ts
  55. 34 0
      src/features/invoice-designer/Lib/workOrderLabels.ts
  56. 439 0
      src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts
  57. 10 3
      src/features/invoice-designer/Pdf/printLabels.ts
  58. 264 5
      src/features/invoice-designer/Spec/buildSpec.ts
  59. 30 4
      src/features/invoice-designer/Spec/signatureBlock.ts
  60. 1 0
      src/features/invoices/Lib/assembleInvoicePrint.ts
  61. 99 0
      src/features/invoices/Lib/assembleWorkOrderPrint.ts
  62. 57 0
      src/features/invoices/Pdf/buildWorkOrderPdfBuffer.ts
  63. 48 2
      src/features/settings/Actions/invoiceLayoutActions.ts
  64. 199 22
      src/features/settings/Schema/invoiceLayoutSchema.ts
  65. 142 2
      src/features/settings/Schema/layoutPresets.ts
  66. 3 0
      src/features/settings/Schema/settingsSchema.ts
  67. 21 0
      src/features/vehicles/Components/invoice-pdf/WorkOrderPDF.tsx
  68. 2 0
      src/features/vehicles/Components/invoice-pdf/types.ts
  69. 12 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  70. 16 0
      src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx

+ 22 - 0
messages/de/pdf.json

@@ -209,5 +209,27 @@
     "signature": "Unterschrift",
     "signedBy": "Unterzeichnet von",
     "signatureDate": "Datum"
+  },
+  "workOrder": {
+    "title": "ARBEITSAUFTRAG",
+    "orderNumberLabel": "Auftrags-Nr.",
+    "customer": "Kunde",
+    "plateLabel": "Kennzeichen",
+    "jobDetails": "Auftragsdetails",
+    "status": "Status: {status}",
+    "technician": "Techniker: {tech}",
+    "scheduled": "Geplant: {date}",
+    "promised": "Zugesagt: {date}",
+    "workBay": "Platz: {bay}",
+    "statusPending": "Offen",
+    "statusInProgress": "In Arbeit",
+    "statusWaitingParts": "Wartet auf Teile",
+    "statusCompleted": "Abgeschlossen",
+    "concerns": "Kundenanliegen",
+    "workRequested": "Gewünschte Arbeiten",
+    "checklist": "Arbeiten",
+    "scanToOpen": "Scannen, um diesen Auftrag zu öffnen",
+    "customerSignature": "Unterschrift Kunde",
+    "printedOn": "Gedruckt am {date}"
   }
 }

+ 2 - 1
messages/de/service.json

@@ -55,7 +55,8 @@
     "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"
+    "designPickFailed": "Design konnte nicht geändert werden",
+    "printWorkOrder": "Arbeitsauftrag drucken"
   },
   "basicInfo": {
     "title": "Grundlegende Informationen",

+ 37 - 6
messages/de/settings.json

@@ -708,7 +708,12 @@
       "defects": "Mängel",
       "results_table": "Alle Ergebnisse",
       "inspection_photos": "Fotos",
-      "signature": "Unterschrift"
+      "signature": "Unterschrift",
+      "job_details": "Auftragsdetails",
+      "job_qr": "Auf dem Handy öffnen",
+      "concerns": "Kundenanliegen",
+      "job_description": "Gewünschte Arbeiten",
+      "work_checklist": "Arbeitscheckliste"
     },
     "fields": {
       "customer_name": "Kundenname",
@@ -759,7 +764,13 @@
       "inspector_line": "Unterschriftszeile",
       "inspector_name": "Name des Unterzeichners",
       "date_line": "Datumszeile",
-      "signature_image": "Unterschrift"
+      "signature_image": "Unterschrift",
+      "status": "Status",
+      "technician": "Techniker",
+      "scheduled": "Geplant",
+      "promised": "Zugesagt",
+      "work_bay": "Arbeitsplatz",
+      "customer_line": "Unterschriftszeile Kunde"
     },
     "presetsTitle": "Mit einer Anordnung beginnen",
     "presetsHint": "Ersetzt die Anordnung unten. Nichts wird gespeichert, bis Sie speichern.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Kompakt",
         "description": "Eine Seite für Flotten: kleine Schrift, das Ergebnis neben dem Titel, alle Ergebnisse ohne Anmerkungen, die Fotos, eine Unterschriftszeile"
+      },
+      "work-order-standard": {
+        "name": "Standard",
+        "description": "Die ausführlichere Tresenkopie in Ihrer Farbe: Kunde und Fahrzeug vollständig, der Service, die Befunde und die Garantie neben den Arbeiten und Preisen"
+      },
+      "work-order-job-sheet": {
+        "name": "Arbeitskarte",
+        "description": "Für die Tafel: Kennzeichen groß, die Fakten zum Auftrag, die Arbeiten als Kästchen zum Abhaken, ein Code zum Öffnen am Handy und keine Preise"
+      },
+      "work-order-compact": {
+        "name": "Kompakt",
+        "description": "Schwarz auf Weiß, eine Seite: Name und Telefon des Kunden, das Fahrzeug, der Auftrag, die Arbeiten mit Preisen und beide Unterschriften. Was jeder Arbeitsauftrag druckt, bis Sie ein Design speichern"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Inspektions-\nChecklisten",
       "sms": "SMS",
       "layout": "Layout",
-      "certificates": "Inspektions-\nZertifikate"
+      "certificates": "Inspektions-\nZertifikate",
+      "workOrders": "Arbeits-\naufträge"
     },
     "presets": "Vorlagen-Presets",
     "primaryColor": "Primärfarbe",
@@ -938,7 +962,8 @@
     "openDesigner": "Vorlagen-Designer öffnen",
     "designerIntro": "Wählen Sie einen Ausgangspunkt und öffnen Sie den Designer, um alles zu ändern.",
     "designerHint": "Farben, Schriften, Abstände und alle Abschnitte liegen im Designer.",
-    "certificatesDescription": "Gestalten Sie das Zertifikat, das eine abgeschlossene Inspektion druckt und teilt."
+    "certificatesDescription": "Gestalten Sie das Zertifikat, das eine abgeschlossene Inspektion druckt und teilt.",
+    "workOrdersDescription": "Gestalten Sie das Blatt, das ein Arbeitsauftrag druckt: was der Kunde unterschreibt und was der Techniker von der Tafel nimmt."
   },
   "payment": {
     "readMore": "Mehr erfahren",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "Zustand der Scheinwerfer",
       "checkFogLamp": "Nebelschlussleuchte",
       "photoFront": "Vorne",
-      "photoOdometer": "Kilometerstand"
+      "photoOdometer": "Kilometerstand",
+      "concernNoise": "Schleifgeräusch vorne beim Bremsen",
+      "concernNoiseDetail": "Bremsbeläge und -scheiben vorne verschlissen; ersetzt",
+      "concernWarningLight": "Warnleuchte im Armaturenbrett seit letzter Woche",
+      "workRequested": "Bremsbeläge und -scheiben vorne ersetzen, Serviceanzeige zurücksetzen und Probefahrt.",
+      "checklistTestDrive": "Probefahrt"
     },
     "logoUpload": "Logo hochladen",
     "logoReplace": "Logo ersetzen",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "Standarddesign konnte nicht festgelegt werden",
     "fieldFixedSlot": "Wird immer an derselben Stelle gedruckt und kann nicht verschoben werden.",
     "certificate": "Inspektionszertifikat",
-    "resultWidthHint": "Leer füllt die Zeile. Mit einem Wert wird das Band schmaler und sitzt an der Seite, die die Ausrichtung wählt; in einer Spalte nimmt es die Spaltenbreite."
+    "resultWidthHint": "Leer füllt die Zeile. Mit einem Wert wird das Band schmaler und sitzt an der Seite, die die Ausrichtung wählt; in einer Spalte nimmt es die Spaltenbreite.",
+    "work_order": "Arbeitsauftrag"
   },
   "preview": {
     "title": "Bremsenservice und Ölwechsel",

+ 22 - 0
messages/en/pdf.json

@@ -209,5 +209,27 @@
     "signature": "Signature",
     "signedBy": "Signed by",
     "signatureDate": "Date"
+  },
+  "workOrder": {
+    "title": "WORK ORDER",
+    "orderNumberLabel": "Work order No.",
+    "customer": "Customer",
+    "plateLabel": "Plate",
+    "jobDetails": "Job details",
+    "status": "Status: {status}",
+    "technician": "Technician: {tech}",
+    "scheduled": "Scheduled: {date}",
+    "promised": "Promised: {date}",
+    "workBay": "Bay: {bay}",
+    "statusPending": "Pending",
+    "statusInProgress": "In progress",
+    "statusWaitingParts": "Waiting for parts",
+    "statusCompleted": "Completed",
+    "concerns": "Customer concerns",
+    "workRequested": "Work requested",
+    "checklist": "Work",
+    "scanToOpen": "Scan to open this work order",
+    "customerSignature": "Customer signature",
+    "printedOn": "Printed {date}"
   }
 }

+ 2 - 1
messages/en/service.json

@@ -55,7 +55,8 @@
     "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"
+    "designPickFailed": "Could not change the design",
+    "printWorkOrder": "Print work order"
   },
   "basicInfo": {
     "title": "Basic Information",

+ 37 - 6
messages/en/settings.json

@@ -708,7 +708,12 @@
       "defects": "Defects",
       "results_table": "All results",
       "inspection_photos": "Photos",
-      "signature": "Signature"
+      "signature": "Signature",
+      "job_details": "Job Details",
+      "job_qr": "Open on Phone",
+      "concerns": "Customer Concerns",
+      "job_description": "Work Requested",
+      "work_checklist": "Work Checklist"
     },
     "fields": {
       "customer_name": "Customer Name",
@@ -759,7 +764,13 @@
       "inspector_line": "Signature line",
       "inspector_name": "Signer's name",
       "date_line": "Date line",
-      "signature_image": "Signature"
+      "signature_image": "Signature",
+      "status": "Status",
+      "technician": "Technician",
+      "scheduled": "Scheduled",
+      "promised": "Promised",
+      "work_bay": "Work Bay",
+      "customer_line": "Customer signature line"
     },
     "presetsTitle": "Start from an arrangement",
     "presetsHint": "Replaces the arrangement below. Nothing is saved until you save.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Compact",
         "description": "One page for a fleet: small type, the verdict beside the title, the full results without notes, the photos, a line to sign"
+      },
+      "work-order-standard": {
+        "name": "Standard",
+        "description": "The fuller counter copy in your colour: the whole customer and vehicle, the service, the findings and the warranty beside the work and the prices"
+      },
+      "work-order-job-sheet": {
+        "name": "Job sheet",
+        "description": "For the board: the plate large, the facts of the job, the work as boxes to tick, a code to open it on a phone, and no prices"
+      },
+      "work-order-compact": {
+        "name": "Compact",
+        "description": "Black on white, one page: the customer's name and phone, the car, the job, the work with prices and both signatures. What every work order prints until you save a design"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Inspection\nChecklists",
       "sms": "SMS",
       "layout": "Layout",
-      "certificates": "Inspection\nCertificates"
+      "certificates": "Inspection\nCertificates",
+      "workOrders": "Work\nOrders"
     },
     "presets": "Template Presets",
     "primaryColor": "Primary Color",
@@ -938,7 +962,8 @@
     "openDesigner": "Open Template Designer",
     "designerIntro": "Pick a starting point, then open the designer to change anything.",
     "designerHint": "Colors, fonts, spacing and every section live in the designer.",
-    "certificatesDescription": "Design the certificate a completed inspection prints and shares."
+    "certificatesDescription": "Design the certificate a completed inspection prints and shares.",
+    "workOrdersDescription": "Design the sheet a work order prints: what the customer signs and what the technician takes off the board."
   },
   "payment": {
     "readMore": "Read More",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "Headlamp condition",
       "checkFogLamp": "Rear fog lamp",
       "photoFront": "Front",
-      "photoOdometer": "Odometer"
+      "photoOdometer": "Odometer",
+      "concernNoise": "Grinding noise from the front when braking",
+      "concernNoiseDetail": "Front pads and discs worn; replaced",
+      "concernWarningLight": "Warning light on the dashboard since last week",
+      "workRequested": "Replace front brake pads and discs, reset the service indicator and road test.",
+      "checklistTestDrive": "Road test"
     },
     "logoUpload": "Upload logo",
     "logoReplace": "Replace logo",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "Could not set the default design",
     "fieldFixedSlot": "Always prints in the same place, so it cannot be moved.",
     "certificate": "Inspection certificate",
-    "resultWidthHint": "Empty spans the row. Set points to make the band narrower; it then sits at the side the alignment picks, and in a column it takes the column's width."
+    "resultWidthHint": "Empty spans the row. Set points to make the band narrower; it then sits at the side the alignment picks, and in a column it takes the column's width.",
+    "work_order": "Work order"
   },
   "preview": {
     "title": "Brake Service & Oil Change",

+ 22 - 0
messages/es/pdf.json

@@ -209,5 +209,27 @@
     "signature": "Firma",
     "signedBy": "Firmado por",
     "signatureDate": "Fecha"
+  },
+  "workOrder": {
+    "title": "ORDEN DE TRABAJO",
+    "orderNumberLabel": "N.º de orden",
+    "customer": "Cliente",
+    "plateLabel": "Matrícula",
+    "jobDetails": "Datos del trabajo",
+    "status": "Estado: {status}",
+    "technician": "Técnico: {tech}",
+    "scheduled": "Programado: {date}",
+    "promised": "Prometido: {date}",
+    "workBay": "Puesto: {bay}",
+    "statusPending": "Pendiente",
+    "statusInProgress": "En curso",
+    "statusWaitingParts": "Esperando piezas",
+    "statusCompleted": "Completado",
+    "concerns": "Inquietudes del cliente",
+    "workRequested": "Trabajo solicitado",
+    "checklist": "Trabajos",
+    "scanToOpen": "Escanee para abrir esta orden de trabajo",
+    "customerSignature": "Firma del cliente",
+    "printedOn": "Impreso el {date}"
   }
 }

+ 2 - 1
messages/es/service.json

@@ -55,7 +55,8 @@
     "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"
+    "designPickFailed": "No se pudo cambiar el diseño",
+    "printWorkOrder": "Imprimir orden de trabajo"
   },
   "basicInfo": {
     "title": "Información Básica",

+ 37 - 6
messages/es/settings.json

@@ -708,7 +708,12 @@
       "defects": "Defectos",
       "results_table": "Todos los resultados",
       "inspection_photos": "Fotos",
-      "signature": "Firma"
+      "signature": "Firma",
+      "job_details": "Datos del trabajo",
+      "job_qr": "Abrir en el móvil",
+      "concerns": "Inquietudes del cliente",
+      "job_description": "Trabajo solicitado",
+      "work_checklist": "Lista de trabajos"
     },
     "fields": {
       "customer_name": "Nombre del cliente",
@@ -759,7 +764,13 @@
       "inspector_line": "Línea de firma",
       "inspector_name": "Nombre del firmante",
       "date_line": "Línea de fecha",
-      "signature_image": "Firma"
+      "signature_image": "Firma",
+      "status": "Estado",
+      "technician": "Técnico",
+      "scheduled": "Programado",
+      "promised": "Prometido",
+      "work_bay": "Puesto",
+      "customer_line": "Línea de firma del cliente"
     },
     "presetsTitle": "Empezar con una disposición",
     "presetsHint": "Sustituye la disposición de abajo. No se guarda nada hasta que guardes.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Compacto",
         "description": "Una página para flotas: letra pequeña, el veredicto junto al título, todos los resultados sin notas, las fotos, una línea para firmar"
+      },
+      "work-order-standard": {
+        "name": "Estándar",
+        "description": "La copia de mostrador más completa en su color: el cliente y el vehículo enteros, el servicio, las observaciones y la garantía junto al trabajo y los precios"
+      },
+      "work-order-job-sheet": {
+        "name": "Hoja de taller",
+        "description": "Para el tablero: la matrícula grande, los datos del trabajo, las tareas como casillas para marcar, un código para abrirlo en el móvil y sin precios"
+      },
+      "work-order-compact": {
+        "name": "Compacto",
+        "description": "Negro sobre blanco, una página: nombre y teléfono del cliente, el coche, el trabajo, la mano de obra con precios y ambas firmas. Lo que imprime toda orden de trabajo hasta que guarde un diseño"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Listas de\ninspección",
       "sms": "SMS",
       "layout": "Disposición",
-      "certificates": "Certificados de\ninspección"
+      "certificates": "Certificados de\ninspección",
+      "workOrders": "Órdenes\nde trabajo"
     },
     "presets": "Presets de plantilla",
     "primaryColor": "Color primario",
@@ -938,7 +962,8 @@
     "openDesigner": "Abrir el diseñador",
     "designerIntro": "Elige un punto de partida y abre el diseñador para cambiar lo que quieras.",
     "designerHint": "Los colores, las fuentes, los espacios y todas las secciones están en el diseñador.",
-    "certificatesDescription": "Diseña el certificado que imprime y comparte una inspección completada."
+    "certificatesDescription": "Diseña el certificado que imprime y comparte una inspección completada.",
+    "workOrdersDescription": "Diseñe la hoja que imprime una orden de trabajo: lo que firma el cliente y lo que el técnico recoge del tablero."
   },
   "payment": {
     "readMore": "Leer más",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "Estado de los faros",
       "checkFogLamp": "Luz antiniebla trasera",
       "photoFront": "Frontal",
-      "photoOdometer": "Cuentakilómetros"
+      "photoOdometer": "Cuentakilómetros",
+      "concernNoise": "Ruido de roce delante al frenar",
+      "concernNoiseDetail": "Pastillas y discos delanteros desgastados; sustituidos",
+      "concernWarningLight": "Testigo encendido en el cuadro desde la semana pasada",
+      "workRequested": "Sustituir pastillas y discos delanteros, reiniciar el indicador de servicio y probar en carretera.",
+      "checklistTestDrive": "Prueba en carretera"
     },
     "logoUpload": "Subir logotipo",
     "logoReplace": "Cambiar logotipo",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "No se pudo establecer el diseño predeterminado",
     "fieldFixedSlot": "Siempre se imprime en el mismo lugar, así que no se puede mover.",
     "certificate": "Certificado de inspección",
-    "resultWidthHint": "Vacío ocupa toda la fila. Con un valor la banda se estrecha y se coloca en el lado que marca la alineación; en una columna ocupa el ancho de la columna."
+    "resultWidthHint": "Vacío ocupa toda la fila. Con un valor la banda se estrecha y se coloca en el lado que marca la alineación; en una columna ocupa el ancho de la columna.",
+    "work_order": "Orden de trabajo"
   },
   "preview": {
     "title": "Servicio de frenos y cambio de aceite",

+ 22 - 0
messages/fr/pdf.json

@@ -209,5 +209,27 @@
     "signature": "Signature",
     "signedBy": "Signé par",
     "signatureDate": "Date"
+  },
+  "workOrder": {
+    "title": "ORDRE DE TRAVAIL",
+    "orderNumberLabel": "N° d'ordre",
+    "customer": "Client",
+    "plateLabel": "Plaque",
+    "jobDetails": "Détails du travail",
+    "status": "Statut : {status}",
+    "technician": "Technicien : {tech}",
+    "scheduled": "Planifié : {date}",
+    "promised": "Promis : {date}",
+    "workBay": "Poste : {bay}",
+    "statusPending": "En attente",
+    "statusInProgress": "En cours",
+    "statusWaitingParts": "En attente de pièces",
+    "statusCompleted": "Terminé",
+    "concerns": "Demandes du client",
+    "workRequested": "Travaux demandés",
+    "checklist": "Travaux",
+    "scanToOpen": "Scannez pour ouvrir cet ordre de travail",
+    "customerSignature": "Signature du client",
+    "printedOn": "Imprimé le {date}"
   }
 }

+ 2 - 1
messages/fr/service.json

@@ -55,7 +55,8 @@
     "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"
+    "designPickFailed": "Impossible de changer le design",
+    "printWorkOrder": "Imprimer l'ordre de travail"
   },
   "basicInfo": {
     "title": "Informations de Base",

+ 37 - 6
messages/fr/settings.json

@@ -708,7 +708,12 @@
       "defects": "Défauts",
       "results_table": "Tous les résultats",
       "inspection_photos": "Photos",
-      "signature": "Signature"
+      "signature": "Signature",
+      "job_details": "Détails du travail",
+      "job_qr": "Ouvrir sur le téléphone",
+      "concerns": "Demandes du client",
+      "job_description": "Travaux demandés",
+      "work_checklist": "Liste des travaux"
     },
     "fields": {
       "customer_name": "Nom du client",
@@ -759,7 +764,13 @@
       "inspector_line": "Ligne de signature",
       "inspector_name": "Nom du signataire",
       "date_line": "Ligne de date",
-      "signature_image": "Signature"
+      "signature_image": "Signature",
+      "status": "Statut",
+      "technician": "Technicien",
+      "scheduled": "Planifié",
+      "promised": "Promis",
+      "work_bay": "Poste",
+      "customer_line": "Ligne de signature du client"
     },
     "presetsTitle": "Partir d’une disposition",
     "presetsHint": "Remplace la disposition ci-dessous. Rien n’est enregistré avant votre validation.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Compact",
         "description": "Une page pour une flotte : petits caractères, le verdict à côté du titre, tous les résultats sans remarques, les photos, une ligne de signature"
+      },
+      "work-order-standard": {
+        "name": "Standard",
+        "description": "La copie de comptoir plus complète, à votre couleur : le client et le véhicule en entier, la prestation, les constats et la garantie à côté des travaux et des prix"
+      },
+      "work-order-job-sheet": {
+        "name": "Fiche de travail",
+        "description": "Pour le tableau : la plaque en grand, les faits du travail, les tâches à cocher, un code pour l'ouvrir sur le téléphone et aucun prix"
+      },
+      "work-order-compact": {
+        "name": "Compact",
+        "description": "Noir sur blanc, une page : le nom et le téléphone du client, la voiture, le travail, la main-d'œuvre avec les prix et les deux signatures. Ce que chaque ordre de travail imprime tant que vous n'enregistrez pas de design"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Listes\nd'inspection",
       "sms": "SMS",
       "layout": "Mise en page",
-      "certificates": "Certificats\nd'inspection"
+      "certificates": "Certificats\nd'inspection",
+      "workOrders": "Ordres\nde travail"
     },
     "presets": "Presets de modèle",
     "primaryColor": "Couleur primaire",
@@ -938,7 +962,8 @@
     "openDesigner": "Ouvrir le concepteur",
     "designerIntro": "Choisissez un point de départ, puis ouvrez le concepteur pour tout modifier.",
     "designerHint": "Les couleurs, les polices, les espacements et toutes les sections sont dans le concepteur.",
-    "certificatesDescription": "Concevez le certificat qu'une inspection terminée imprime et partage."
+    "certificatesDescription": "Concevez le certificat qu'une inspection terminée imprime et partage.",
+    "workOrdersDescription": "Concevez la feuille qu'imprime un ordre de travail : ce que le client signe et ce que le technicien prend au tableau."
   },
   "payment": {
     "readMore": "En savoir plus",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "État des projecteurs",
       "checkFogLamp": "Feu de brouillard arrière",
       "photoFront": "Avant",
-      "photoOdometer": "Compteur"
+      "photoOdometer": "Compteur",
+      "concernNoise": "Bruit de frottement à l'avant au freinage",
+      "concernNoiseDetail": "Plaquettes et disques avant usés ; remplacés",
+      "concernWarningLight": "Voyant allumé au tableau de bord depuis la semaine dernière",
+      "workRequested": "Remplacer les plaquettes et disques avant, réinitialiser l'indicateur d'entretien et faire un essai routier.",
+      "checklistTestDrive": "Essai routier"
     },
     "logoUpload": "Importer un logo",
     "logoReplace": "Remplacer le logo",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "Impossible de définir le design par défaut",
     "fieldFixedSlot": "S'imprime toujours au même endroit et ne peut pas être déplacé.",
     "certificate": "Certificat d'inspection",
-    "resultWidthHint": "Vide occupe toute la ligne. Une valeur rétrécit le bandeau, placé du côté choisi par l'alignement ; dans une colonne il prend la largeur de la colonne."
+    "resultWidthHint": "Vide occupe toute la ligne. Une valeur rétrécit le bandeau, placé du côté choisi par l'alignement ; dans une colonne il prend la largeur de la colonne.",
+    "work_order": "Ordre de travail"
   },
   "preview": {
     "title": "Révision des freins et vidange",

+ 22 - 0
messages/it/pdf.json

@@ -209,5 +209,27 @@
     "signature": "Firma",
     "signedBy": "Firmato da",
     "signatureDate": "Data"
+  },
+  "workOrder": {
+    "title": "ORDINE DI LAVORO",
+    "orderNumberLabel": "N. ordine",
+    "customer": "Cliente",
+    "plateLabel": "Targa",
+    "jobDetails": "Dettagli del lavoro",
+    "status": "Stato: {status}",
+    "technician": "Tecnico: {tech}",
+    "scheduled": "Programmato: {date}",
+    "promised": "Promesso: {date}",
+    "workBay": "Postazione: {bay}",
+    "statusPending": "In attesa",
+    "statusInProgress": "In corso",
+    "statusWaitingParts": "In attesa di ricambi",
+    "statusCompleted": "Completato",
+    "concerns": "Richieste del cliente",
+    "workRequested": "Lavoro richiesto",
+    "checklist": "Lavori",
+    "scanToOpen": "Scansiona per aprire questo ordine di lavoro",
+    "customerSignature": "Firma del cliente",
+    "printedOn": "Stampato il {date}"
   }
 }

+ 2 - 1
messages/it/service.json

@@ -55,7 +55,8 @@
     "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"
+    "designPickFailed": "Impossibile cambiare il design",
+    "printWorkOrder": "Stampa ordine di lavoro"
   },
   "basicInfo": {
     "title": "Informazioni di Base",

+ 37 - 6
messages/it/settings.json

@@ -708,7 +708,12 @@
       "defects": "Difetti",
       "results_table": "Tutti gli esiti",
       "inspection_photos": "Foto",
-      "signature": "Firma"
+      "signature": "Firma",
+      "job_details": "Dettagli del lavoro",
+      "job_qr": "Apri sul telefono",
+      "concerns": "Richieste del cliente",
+      "job_description": "Lavoro richiesto",
+      "work_checklist": "Elenco lavori"
     },
     "fields": {
       "customer_name": "Nome cliente",
@@ -759,7 +764,13 @@
       "inspector_line": "Riga della firma",
       "inspector_name": "Nome del firmatario",
       "date_line": "Riga della data",
-      "signature_image": "Firma"
+      "signature_image": "Firma",
+      "status": "Stato",
+      "technician": "Tecnico",
+      "scheduled": "Programmato",
+      "promised": "Promesso",
+      "work_bay": "Postazione",
+      "customer_line": "Riga della firma del cliente"
     },
     "presetsTitle": "Partire da una disposizione",
     "presetsHint": "Sostituisce la disposizione qui sotto. Nulla viene salvato finché non salvi.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Compatto",
         "description": "Una pagina per le flotte: caratteri piccoli, l'esito accanto al titolo, tutti gli esiti senza note, le foto, una riga per la firma"
+      },
+      "work-order-standard": {
+        "name": "Standard",
+        "description": "La copia da banco più completa nel vostro colore: cliente e veicolo per intero, il servizio, i rilievi e la garanzia accanto ai lavori e ai prezzi"
+      },
+      "work-order-job-sheet": {
+        "name": "Scheda di lavoro",
+        "description": "Per la bacheca: la targa in grande, i dati del lavoro, le attività come caselle da spuntare, un codice per aprirla sul telefono e nessun prezzo"
+      },
+      "work-order-compact": {
+        "name": "Compatto",
+        "description": "Nero su bianco, una pagina: nome e telefono del cliente, l'auto, il lavoro, la manodopera con i prezzi ed entrambe le firme. Ciò che ogni ordine di lavoro stampa finché non salvate un design"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Checklist\nispezione",
       "sms": "SMS",
       "layout": "Layout",
-      "certificates": "Certificati\nispezione"
+      "certificates": "Certificati\nispezione",
+      "workOrders": "Ordini\ndi lavoro"
     },
     "presets": "Preset modello",
     "primaryColor": "Colore primario",
@@ -938,7 +962,8 @@
     "openDesigner": "Apri il designer",
     "designerIntro": "Scegli un punto di partenza, poi apri il designer per cambiare tutto.",
     "designerHint": "Colori, caratteri, spaziature e tutte le sezioni sono nel designer.",
-    "certificatesDescription": "Progetta il certificato che un'ispezione completata stampa e condivide."
+    "certificatesDescription": "Progetta il certificato che un'ispezione completata stampa e condivide.",
+    "workOrdersDescription": "Progetta il foglio che stampa un ordine di lavoro: ciò che firma il cliente e ciò che il tecnico prende dalla bacheca."
   },
   "payment": {
     "readMore": "Scopri di più",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "Stato dei proiettori",
       "checkFogLamp": "Retronebbia",
       "photoFront": "Frontale",
-      "photoOdometer": "Contachilometri"
+      "photoOdometer": "Contachilometri",
+      "concernNoise": "Rumore di sfregamento davanti in frenata",
+      "concernNoiseDetail": "Pastiglie e dischi anteriori usurati; sostituiti",
+      "concernWarningLight": "Spia accesa sul cruscotto dalla settimana scorsa",
+      "workRequested": "Sostituire pastiglie e dischi anteriori, azzerare l'indicatore di manutenzione e provare su strada.",
+      "checklistTestDrive": "Prova su strada"
     },
     "logoUpload": "Carica logo",
     "logoReplace": "Sostituisci logo",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "Impossibile impostare il design predefinito",
     "fieldFixedSlot": "Viene sempre stampato nello stesso punto, quindi non può essere spostato.",
     "certificate": "Certificato di ispezione",
-    "resultWidthHint": "Vuoto occupa tutta la riga. Un valore restringe la fascia, che si posiziona sul lato scelto dall'allineamento; in una colonna prende la larghezza della colonna."
+    "resultWidthHint": "Vuoto occupa tutta la riga. Un valore restringe la fascia, che si posiziona sul lato scelto dall'allineamento; in una colonna prende la larghezza della colonna.",
+    "work_order": "Ordine di lavoro"
   },
   "preview": {
     "title": "Servizio freni e cambio olio",

+ 22 - 0
messages/lt/pdf.json

@@ -209,5 +209,27 @@
     "signature": "Parašas",
     "signedBy": "Pasirašė",
     "signatureDate": "Data"
+  },
+  "workOrder": {
+    "title": "DARBO UŽSAKYMAS",
+    "orderNumberLabel": "Užsakymo Nr.",
+    "customer": "Klientas",
+    "plateLabel": "Valst. Nr.",
+    "jobDetails": "Darbo duomenys",
+    "status": "Būsena: {status}",
+    "technician": "Technikas: {tech}",
+    "scheduled": "Suplanuota: {date}",
+    "promised": "Pažadėta: {date}",
+    "workBay": "Vieta: {bay}",
+    "statusPending": "Laukiama",
+    "statusInProgress": "Vykdoma",
+    "statusWaitingParts": "Laukiama dalių",
+    "statusCompleted": "Baigta",
+    "concerns": "Kliento nusiskundimai",
+    "workRequested": "Užsakyti darbai",
+    "checklist": "Darbai",
+    "scanToOpen": "Nuskenuokite, kad atidarytumėte šį darbo užsakymą",
+    "customerSignature": "Kliento parašas",
+    "printedOn": "Išspausdinta {date}"
   }
 }

+ 2 - 1
messages/lt/service.json

@@ -55,7 +55,8 @@
     "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"
+    "designPickFailed": "Nepavyko pakeisti dizaino",
+    "printWorkOrder": "Spausdinti darbo užsakymą"
   },
   "basicInfo": {
     "title": "Pagrindinė informacija",

+ 37 - 6
messages/lt/settings.json

@@ -708,7 +708,12 @@
       "defects": "Defektai",
       "results_table": "Visi rezultatai",
       "inspection_photos": "Nuotraukos",
-      "signature": "Parašas"
+      "signature": "Parašas",
+      "job_details": "Darbo duomenys",
+      "job_qr": "Atidaryti telefone",
+      "concerns": "Kliento nusiskundimai",
+      "job_description": "Užsakyti darbai",
+      "work_checklist": "Darbų sąrašas"
     },
     "fields": {
       "customer_name": "Kliento vardas",
@@ -759,7 +764,13 @@
       "inspector_line": "Parašo eilutė",
       "inspector_name": "Pasirašiusiojo vardas",
       "date_line": "Datos eilutė",
-      "signature_image": "Parašas"
+      "signature_image": "Parašas",
+      "status": "Būsena",
+      "technician": "Technikas",
+      "scheduled": "Suplanuota",
+      "promised": "Pažadėta",
+      "work_bay": "Darbo vieta",
+      "customer_line": "Kliento parašo eilutė"
     },
     "presetsTitle": "Pradėti nuo išdėstymo",
     "presetsHint": "Pakeičia žemiau esantį išdėstymą. Niekas neįrašoma, kol neišsaugote.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Kompaktiškas",
         "description": "Vienas lapas parkui: smulkus šriftas, verdiktas šalia pavadinimo, visi rezultatai be pastabų, nuotraukos, parašo eilutė"
+      },
+      "work-order-standard": {
+        "name": "Standartinis",
+        "description": "Išsamesnė prekystalio kopija jūsų spalva: visas klientas ir transporto priemonė, paslauga, pastebėjimai ir garantija šalia darbų ir kainų"
+      },
+      "work-order-job-sheet": {
+        "name": "Darbo lapas",
+        "description": "Lentai: didelis valstybinis numeris, darbo faktai, darbai kaip langeliai pažymėti, kodas atidaryti telefone ir jokių kainų"
+      },
+      "work-order-compact": {
+        "name": "Kompaktiškas",
+        "description": "Juoda ant balto, vienas puslapis: kliento vardas ir telefonas, automobilis, darbas, darbai su kainomis ir abu parašai. Tai, ką spausdina kiekvienas darbo užsakymas, kol neišsaugosite dizaino"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Apžiūros\nkontroliniai sąrašai",
       "sms": "SMS",
       "layout": "Išdėstymas",
-      "certificates": "Apžiūros\nsertifikatai"
+      "certificates": "Apžiūros\nsertifikatai",
+      "workOrders": "Darbo\nužsakymai"
     },
     "presets": "Šablonų presetai",
     "primaryColor": "Pagrindinė spalva",
@@ -938,7 +962,8 @@
     "openDesigner": "Atverti dizainerį",
     "designerIntro": "Pasirinkite pradžios tašką ir atverkite dizainerį, kad pakeistumėte viską.",
     "designerHint": "Spalvos, šriftai, tarpai ir visos skiltys yra dizaineryje.",
-    "certificatesDescription": "Sukurkite sertifikatą, kurį spausdina ir dalijasi užbaigta apžiūra."
+    "certificatesDescription": "Sukurkite sertifikatą, kurį spausdina ir dalijasi užbaigta apžiūra.",
+    "workOrdersDescription": "Sukurkite lapą, kurį spausdina darbo užsakymas: ką pasirašo klientas ir ką technikas pasiima nuo lentos."
   },
   "payment": {
     "readMore": "Skaityti daugiau",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "Priekinių žibintų būklė",
       "checkFogLamp": "Galinis rūko žibintas",
       "photoFront": "Priekis",
-      "photoOdometer": "Odometras"
+      "photoOdometer": "Odometras",
+      "concernNoise": "Šlifavimo garsas priekyje stabdant",
+      "concernNoiseDetail": "Priekinės kaladėlės ir diskai nusidėvėję; pakeisti",
+      "concernWarningLight": "Įspėjamoji lemputė prietaisų skydelyje nuo praėjusios savaitės",
+      "workRequested": "Pakeisti priekines stabdžių kaladėles ir diskus, atstatyti aptarnavimo indikatorių ir išbandyti kelyje.",
+      "checklistTestDrive": "Bandomasis važiavimas"
     },
     "logoUpload": "Įkelti logotipą",
     "logoReplace": "Pakeisti logotipą",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "Nepavyko nustatyti numatytojo dizaino",
     "fieldFixedSlot": "Visada spausdinama toje pačioje vietoje, todėl negalima perkelti.",
     "certificate": "Apžiūros sertifikatas",
-    "resultWidthHint": "Tuščia užima visą eilutę. Nurodžius taškus juosta susiaurėja ir atsiduria lygiavimo pusėje; stulpelyje ji užima stulpelio plotį."
+    "resultWidthHint": "Tuščia užima visą eilutę. Nurodžius taškus juosta susiaurėja ir atsiduria lygiavimo pusėje; stulpelyje ji užima stulpelio plotį.",
+    "work_order": "Darbo užsakymas"
   },
   "preview": {
     "title": "Stabdžių aptarnavimas ir tepalų keitimas",

+ 22 - 0
messages/nb/pdf.json

@@ -209,5 +209,27 @@
     "signature": "Signatur",
     "signedBy": "Signert av",
     "signatureDate": "Dato"
+  },
+  "workOrder": {
+    "title": "ARBEIDSORDRE",
+    "orderNumberLabel": "Ordrenr.",
+    "customer": "Kunde",
+    "plateLabel": "Reg.nr.",
+    "jobDetails": "Jobbdetaljer",
+    "status": "Status: {status}",
+    "technician": "Tekniker: {tech}",
+    "scheduled": "Planlagt: {date}",
+    "promised": "Lovet: {date}",
+    "workBay": "Plass: {bay}",
+    "statusPending": "Venter",
+    "statusInProgress": "Pågår",
+    "statusWaitingParts": "Venter på deler",
+    "statusCompleted": "Fullført",
+    "concerns": "Kundens henvendelser",
+    "workRequested": "Ønsket arbeid",
+    "checklist": "Arbeid",
+    "scanToOpen": "Skann for å åpne denne arbeidsordren",
+    "customerSignature": "Kundens signatur",
+    "printedOn": "Skrevet ut {date}"
   }
 }

+ 2 - 1
messages/nb/service.json

@@ -55,7 +55,8 @@
     "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"
+    "designPickFailed": "Kunne ikke endre designet",
+    "printWorkOrder": "Skriv ut arbeidsordre"
   },
   "basicInfo": {
     "title": "Grunnleggende informasjon",

+ 38 - 7
messages/nb/settings.json

@@ -708,7 +708,12 @@
       "defects": "Mangler",
       "results_table": "Alle resultater",
       "inspection_photos": "Bilder",
-      "signature": "Signatur"
+      "signature": "Signatur",
+      "job_details": "Jobbdetaljer",
+      "job_qr": "Åpne på telefonen",
+      "concerns": "Kundens henvendelser",
+      "job_description": "Ønsket arbeid",
+      "work_checklist": "Sjekkliste for arbeid"
     },
     "fields": {
       "customer_name": "Kundenavn",
@@ -719,7 +724,7 @@
       "customer_tax_id": "Org.nr / MVA-nr",
       "vehicle_name": "Kjøretøy",
       "vin": "VIN",
-      "license_plate": "Reg.nr",
+      "license_plate": "Registreringsnummer",
       "mileage": "Kilometerstand",
       "service_title": "Tjenestetittel",
       "service_type": "Tjenestetype",
@@ -759,7 +764,13 @@
       "inspector_line": "Signaturlinje",
       "inspector_name": "Underskriverens navn",
       "date_line": "Datolinje",
-      "signature_image": "Signatur"
+      "signature_image": "Signatur",
+      "status": "Status",
+      "technician": "Tekniker",
+      "scheduled": "Planlagt",
+      "promised": "Lovet",
+      "work_bay": "Arbeidsplass",
+      "customer_line": "Signaturlinje for kunde"
     },
     "presetsTitle": "Start fra et oppsett",
     "presetsHint": "Erstatter oppsettet under. Ingenting lagres før du lagrer.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Kompakt",
         "description": "Én side for flåter: liten skrift, resultatet ved siden av tittelen, alle resultater uten merknader, bildene, en signaturlinje"
+      },
+      "work-order-standard": {
+        "name": "Standard",
+        "description": "Den fyldigere diskkopien i din farge: hele kunden og kjøretøyet, servicen, funnene og garantien ved siden av arbeidet og prisene"
+      },
+      "work-order-job-sheet": {
+        "name": "Arbeidsark",
+        "description": "Til tavla: skiltet stort, fakta om jobben, arbeidet som bokser å krysse av, en kode som åpner den på telefonen, og ingen priser"
+      },
+      "work-order-compact": {
+        "name": "Kompakt",
+        "description": "Svart på hvitt, én side: kundens navn og telefon, bilen, jobben, arbeidet med priser og begge signaturene. Det hver arbeidsordre skriver ut til du lagrer et design"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Inspeksjons-\nsjekklister",
       "sms": "SMS",
       "layout": "Oppsett",
-      "certificates": "Inspeksjons-\nsertifikater"
+      "certificates": "Inspeksjons-\nsertifikater",
+      "workOrders": "Arbeids-\nordrer"
     },
     "presets": "Malforhåndsvisninger",
     "primaryColor": "Primærfarge",
@@ -938,7 +962,8 @@
     "openDesigner": "Åpne designeren",
     "designerIntro": "Velg et utgangspunkt, og åpne designeren for å endre alt.",
     "designerHint": "Farger, skrifter, avstander og alle seksjoner ligger i designeren.",
-    "certificatesDescription": "Utform sertifikatet en fullført inspeksjon skriver ut og deler."
+    "certificatesDescription": "Utform sertifikatet en fullført inspeksjon skriver ut og deler.",
+    "workOrdersDescription": "Utform arket en arbeidsordre skriver ut: det kunden signerer og det teknikeren tar med seg fra tavla."
   },
   "payment": {
     "readMore": "Les mer",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "Frontlyktenes tilstand",
       "checkFogLamp": "Tåkelys bak",
       "photoFront": "Front",
-      "photoOdometer": "Kilometerteller"
+      "photoOdometer": "Kilometerteller",
+      "concernNoise": "Skrapelyd foran ved bremsing",
+      "concernNoiseDetail": "Klosser og skiver foran slitt; byttet",
+      "concernWarningLight": "Varsellampe i dashbordet siden forrige uke",
+      "workRequested": "Bytt bremseklosser og skiver foran, nullstill serviceindikatoren og prøvekjør.",
+      "checklistTestDrive": "Prøvekjøring"
     },
     "logoUpload": "Last opp logo",
     "logoReplace": "Bytt logo",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "Kunne ikke sette standarddesignet",
     "fieldFixedSlot": "Skrives alltid ut på samme sted, så den kan ikke flyttes.",
     "certificate": "Inspeksjonssertifikat",
-    "resultWidthHint": "Tomt fyller raden. Angi punkter for et smalere bånd; det legger seg på siden justeringen velger, og i en kolonne tar det kolonnens bredde."
+    "resultWidthHint": "Tomt fyller raden. Angi punkter for et smalere bånd; det legger seg på siden justeringen velger, og i en kolonne tar det kolonnens bredde.",
+    "work_order": "Arbeidsordre"
   },
   "preview": {
     "title": "Bremseservice og oljeskift",

+ 22 - 0
messages/nl/pdf.json

@@ -209,5 +209,27 @@
     "signature": "Handtekening",
     "signedBy": "Ondertekend door",
     "signatureDate": "Datum"
+  },
+  "workOrder": {
+    "title": "WERKORDER",
+    "orderNumberLabel": "Werkordernr.",
+    "customer": "Klant",
+    "plateLabel": "Kenteken",
+    "jobDetails": "Opdrachtgegevens",
+    "status": "Status: {status}",
+    "technician": "Monteur: {tech}",
+    "scheduled": "Gepland: {date}",
+    "promised": "Toegezegd: {date}",
+    "workBay": "Werkplek: {bay}",
+    "statusPending": "In afwachting",
+    "statusInProgress": "In uitvoering",
+    "statusWaitingParts": "Wacht op onderdelen",
+    "statusCompleted": "Afgerond",
+    "concerns": "Klachten van de klant",
+    "workRequested": "Gevraagd werk",
+    "checklist": "Werk",
+    "scanToOpen": "Scan om deze werkorder te openen",
+    "customerSignature": "Handtekening klant",
+    "printedOn": "Afgedrukt op {date}"
   }
 }

+ 2 - 1
messages/nl/service.json

@@ -55,7 +55,8 @@
     "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"
+    "designPickFailed": "Kon het ontwerp niet wijzigen",
+    "printWorkOrder": "Werkorder afdrukken"
   },
   "basicInfo": {
     "title": "Basisinformatie",

+ 37 - 6
messages/nl/settings.json

@@ -708,7 +708,12 @@
       "defects": "Gebreken",
       "results_table": "Alle resultaten",
       "inspection_photos": "Foto's",
-      "signature": "Handtekening"
+      "signature": "Handtekening",
+      "job_details": "Opdrachtgegevens",
+      "job_qr": "Openen op telefoon",
+      "concerns": "Klachten van de klant",
+      "job_description": "Gevraagd werk",
+      "work_checklist": "Werkchecklist"
     },
     "fields": {
       "customer_name": "Klantnaam",
@@ -759,7 +764,13 @@
       "inspector_line": "Handtekeningregel",
       "inspector_name": "Naam van de ondertekenaar",
       "date_line": "Datumregel",
-      "signature_image": "Handtekening"
+      "signature_image": "Handtekening",
+      "status": "Status",
+      "technician": "Monteur",
+      "scheduled": "Gepland",
+      "promised": "Toegezegd",
+      "work_bay": "Werkplek",
+      "customer_line": "Handtekeningregel klant"
     },
     "presetsTitle": "Begin met een indeling",
     "presetsHint": "Vervangt de indeling hieronder. Er wordt niets opgeslagen tot u opslaat.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Compact",
         "description": "Eén pagina voor wagenparken: kleine letters, het oordeel naast de titel, alle resultaten zonder opmerkingen, de foto's, een handtekeningregel"
+      },
+      "work-order-standard": {
+        "name": "Standaard",
+        "description": "De vollere baliekopie in uw kleur: de hele klant en het voertuig, de service, de bevindingen en de garantie naast het werk en de prijzen"
+      },
+      "work-order-job-sheet": {
+        "name": "Werkbon",
+        "description": "Voor het bord: het kenteken groot, de feiten van de opdracht, het werk als vakjes om af te vinken, een code om hem op de telefoon te openen en geen prijzen"
+      },
+      "work-order-compact": {
+        "name": "Compact",
+        "description": "Zwart op wit, één pagina: naam en telefoon van de klant, de auto, de opdracht, het werk met prijzen en beide handtekeningen. Wat elke werkorder afdrukt tot u een ontwerp opslaat"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Inspectie-\nchecklists",
       "sms": "SMS",
       "layout": "Indeling",
-      "certificates": "Inspectie-\ncertificaten"
+      "certificates": "Inspectie-\ncertificaten",
+      "workOrders": "Werk-\norders"
     },
     "presets": "Sjabloonpresets",
     "primaryColor": "Primaire kleur",
@@ -938,7 +962,8 @@
     "openDesigner": "Ontwerper openen",
     "designerIntro": "Kies een startpunt en open de ontwerper om alles aan te passen.",
     "designerHint": "Kleuren, lettertypen, ruimte en alle secties staan in de ontwerper.",
-    "certificatesDescription": "Ontwerp het certificaat dat een afgeronde inspectie afdrukt en deelt."
+    "certificatesDescription": "Ontwerp het certificaat dat een afgeronde inspectie afdrukt en deelt.",
+    "workOrdersDescription": "Ontwerp het blad dat een werkorder afdrukt: wat de klant tekent en wat de monteur van het bord pakt."
   },
   "payment": {
     "readMore": "Lees meer",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "Staat van de koplampen",
       "checkFogLamp": "Mistachterlicht",
       "photoFront": "Voorkant",
-      "photoOdometer": "Kilometerteller"
+      "photoOdometer": "Kilometerteller",
+      "concernNoise": "Schurend geluid voor bij het remmen",
+      "concernNoiseDetail": "Remblokken en -schijven voor versleten; vervangen",
+      "concernWarningLight": "Waarschuwingslampje op het dashboard sinds vorige week",
+      "workRequested": "Remblokken en -schijven voor vervangen, service-indicator resetten en proefrit maken.",
+      "checklistTestDrive": "Proefrit"
     },
     "logoUpload": "Logo uploaden",
     "logoReplace": "Logo vervangen",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "Kon het standaardontwerp niet instellen",
     "fieldFixedSlot": "Wordt altijd op dezelfde plek afgedrukt en kan niet worden verplaatst.",
     "certificate": "Inspectiecertificaat",
-    "resultWidthHint": "Leeg vult de rij. Met een waarde wordt de band smaller en komt aan de kant die de uitlijning kiest; in een kolom neemt hij de kolombreedte."
+    "resultWidthHint": "Leeg vult de rij. Met een waarde wordt de band smaller en komt aan de kant die de uitlijning kiest; in een kolom neemt hij de kolombreedte.",
+    "work_order": "Werkorder"
   },
   "preview": {
     "title": "Remservice en oliewissel",

+ 22 - 0
messages/pl/pdf.json

@@ -209,5 +209,27 @@
     "signature": "Podpis",
     "signedBy": "Podpisał(a)",
     "signatureDate": "Data"
+  },
+  "workOrder": {
+    "title": "ZLECENIE",
+    "orderNumberLabel": "Nr zlecenia",
+    "customer": "Klient",
+    "plateLabel": "Nr rej.",
+    "jobDetails": "Szczegóły zlecenia",
+    "status": "Status: {status}",
+    "technician": "Technik: {tech}",
+    "scheduled": "Zaplanowano: {date}",
+    "promised": "Obiecano: {date}",
+    "workBay": "Stanowisko: {bay}",
+    "statusPending": "Oczekujące",
+    "statusInProgress": "W toku",
+    "statusWaitingParts": "Oczekiwanie na części",
+    "statusCompleted": "Zakończone",
+    "concerns": "Uwagi klienta",
+    "workRequested": "Zlecone prace",
+    "checklist": "Prace",
+    "scanToOpen": "Zeskanuj, aby otworzyć to zlecenie",
+    "customerSignature": "Podpis klienta",
+    "printedOn": "Wydrukowano {date}"
   }
 }

+ 2 - 1
messages/pl/service.json

@@ -55,7 +55,8 @@
     "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"
+    "designPickFailed": "Nie udało się zmienić projektu",
+    "printWorkOrder": "Drukuj zlecenie"
   },
   "basicInfo": {
     "title": "Informacje Podstawowe",

+ 38 - 7
messages/pl/settings.json

@@ -708,7 +708,12 @@
       "defects": "Usterki",
       "results_table": "Wszystkie wyniki",
       "inspection_photos": "Zdjęcia",
-      "signature": "Podpis"
+      "signature": "Podpis",
+      "job_details": "Szczegóły zlecenia",
+      "job_qr": "Otwórz na telefonie",
+      "concerns": "Uwagi klienta",
+      "job_description": "Zlecone prace",
+      "work_checklist": "Lista prac"
     },
     "fields": {
       "customer_name": "Nazwa klienta",
@@ -719,7 +724,7 @@
       "customer_tax_id": "NIP klienta",
       "vehicle_name": "Pojazd",
       "vin": "VIN",
-      "license_plate": "Tablica rejestracyjna",
+      "license_plate": "Numer rejestracyjny",
       "mileage": "Przebieg",
       "service_title": "Tytuł usługi",
       "service_type": "Typ usługi",
@@ -759,7 +764,13 @@
       "inspector_line": "Linia podpisu",
       "inspector_name": "Imię i nazwisko podpisującego",
       "date_line": "Linia daty",
-      "signature_image": "Podpis"
+      "signature_image": "Podpis",
+      "status": "Status",
+      "technician": "Technik",
+      "scheduled": "Zaplanowano",
+      "promised": "Obiecano",
+      "work_bay": "Stanowisko",
+      "customer_line": "Linia podpisu klienta"
     },
     "presetsTitle": "Zacznij od układu",
     "presetsHint": "Zastępuje układ poniżej. Nic nie zostanie zapisane, dopóki nie zapiszesz.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Kompaktowy",
         "description": "Jedna strona dla flot: mała czcionka, werdykt obok tytułu, wszystkie wyniki bez uwag, zdjęcia, miejsce na podpis"
+      },
+      "work-order-standard": {
+        "name": "Standardowy",
+        "description": "Pełniejsza kopia dla lady w twoim kolorze: cały klient i pojazd, usługa, spostrzeżenia i gwarancja obok prac i cen"
+      },
+      "work-order-job-sheet": {
+        "name": "Karta pracy",
+        "description": "Na tablicę: duża rejestracja, fakty o zleceniu, prace jako pola do odhaczenia, kod otwierający je na telefonie i żadnych cen"
+      },
+      "work-order-compact": {
+        "name": "Kompaktowy",
+        "description": "Czarno na białym, jedna strona: imię i telefon klienta, samochód, zlecenie, prace z cenami i oba podpisy. To, co drukuje każde zlecenie, dopóki nie zapiszesz projektu"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Listy kontrolne\nprzeglądów",
       "sms": "SMS",
       "layout": "Układ",
-      "certificates": "Certyfikaty\nprzeglądów"
+      "certificates": "Certyfikaty\nprzeglądów",
+      "workOrders": "Zlecenia"
     },
     "presets": "Gotowe szablony",
     "primaryColor": "Kolor główny",
@@ -938,7 +962,8 @@
     "openDesigner": "Otwórz kreator",
     "designerIntro": "Wybierz punkt wyjścia, a potem otwórz kreator, aby zmienić wszystko.",
     "designerHint": "Kolory, czcionki, odstępy i wszystkie sekcje są w kreatorze.",
-    "certificatesDescription": "Zaprojektuj certyfikat, który drukuje i udostępnia zakończony przegląd."
+    "certificatesDescription": "Zaprojektuj certyfikat, który drukuje i udostępnia zakończony przegląd.",
+    "workOrdersDescription": "Zaprojektuj arkusz drukowany ze zlecenia: to, co podpisuje klient, i to, co technik zdejmuje z tablicy."
   },
   "payment": {
     "readMore": "Czytaj więcej",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "Stan reflektorów",
       "checkFogLamp": "Tylne światło przeciwmgłowe",
       "photoFront": "Przód",
-      "photoOdometer": "Licznik"
+      "photoOdometer": "Licznik",
+      "concernNoise": "Zgrzytanie z przodu przy hamowaniu",
+      "concernNoiseDetail": "Przednie klocki i tarcze zużyte; wymienione",
+      "concernWarningLight": "Kontrolka na desce rozdzielczej od zeszłego tygodnia",
+      "workRequested": "Wymienić przednie klocki i tarcze hamulcowe, zresetować wskaźnik serwisu i wykonać jazdę próbną.",
+      "checklistTestDrive": "Jazda próbna"
     },
     "logoUpload": "Prześlij logo",
     "logoReplace": "Zmień logo",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "Nie udało się ustawić domyślnego projektu",
     "fieldFixedSlot": "Zawsze drukuje się w tym samym miejscu, więc nie można go przenieść.",
     "certificate": "Certyfikat przeglądu",
-    "resultWidthHint": "Puste wypełnia wiersz. Wpisz punkty, by zwęzić pasek; ułoży się po stronie wskazanej przez wyrównanie, a w kolumnie zajmie jej szerokość."
+    "resultWidthHint": "Puste wypełnia wiersz. Wpisz punkty, by zwęzić pasek; ułoży się po stronie wskazanej przez wyrównanie, a w kolumnie zajmie jej szerokość.",
+    "work_order": "Zlecenie"
   },
   "preview": {
     "title": "Serwis hamulców i wymiana oleju",

+ 22 - 0
messages/pt-BR/pdf.json

@@ -209,5 +209,27 @@
     "signature": "Assinatura",
     "signedBy": "Assinado por",
     "signatureDate": "Data"
+  },
+  "workOrder": {
+    "title": "ORDEM DE SERVIÇO",
+    "orderNumberLabel": "Nº da OS",
+    "customer": "Cliente",
+    "plateLabel": "Placa",
+    "jobDetails": "Detalhes do serviço",
+    "status": "Status: {status}",
+    "technician": "Técnico: {tech}",
+    "scheduled": "Agendado: {date}",
+    "promised": "Prometido: {date}",
+    "workBay": "Box: {bay}",
+    "statusPending": "Pendente",
+    "statusInProgress": "Em andamento",
+    "statusWaitingParts": "Aguardando peças",
+    "statusCompleted": "Concluído",
+    "concerns": "Reclamações do cliente",
+    "workRequested": "Serviço solicitado",
+    "checklist": "Serviços",
+    "scanToOpen": "Escaneie para abrir esta ordem de serviço",
+    "customerSignature": "Assinatura do cliente",
+    "printedOn": "Impresso em {date}"
   }
 }

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

@@ -55,7 +55,8 @@
     "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"
+    "designPickFailed": "Não foi possível alterar o design",
+    "printWorkOrder": "Imprimir ordem de serviço"
   },
   "basicInfo": {
     "title": "Informações Básicas",

+ 37 - 6
messages/pt-BR/settings.json

@@ -708,7 +708,12 @@
       "defects": "Defeitos",
       "results_table": "Todos os resultados",
       "inspection_photos": "Fotos",
-      "signature": "Assinatura"
+      "signature": "Assinatura",
+      "job_details": "Detalhes do serviço",
+      "job_qr": "Abrir no celular",
+      "concerns": "Reclamações do cliente",
+      "job_description": "Serviço solicitado",
+      "work_checklist": "Lista de serviços"
     },
     "fields": {
       "customer_name": "Nome do cliente",
@@ -759,7 +764,13 @@
       "inspector_line": "Linha de assinatura",
       "inspector_name": "Nome de quem assina",
       "date_line": "Linha da data",
-      "signature_image": "Assinatura"
+      "signature_image": "Assinatura",
+      "status": "Status",
+      "technician": "Técnico",
+      "scheduled": "Agendado",
+      "promised": "Prometido",
+      "work_bay": "Box",
+      "customer_line": "Linha de assinatura do cliente"
     },
     "presetsTitle": "Começar por uma disposição",
     "presetsHint": "Substitui a disposição abaixo. Nada é salvo até você salvar.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Compacto",
         "description": "Uma página para frotas: letra pequena, o veredito ao lado do título, todos os resultados sem notas, as fotos, uma linha para assinar"
+      },
+      "work-order-standard": {
+        "name": "Padrão",
+        "description": "A cópia de balcão mais completa na sua cor: o cliente e o veículo inteiros, o serviço, as observações e a garantia ao lado do trabalho e dos preços"
+      },
+      "work-order-job-sheet": {
+        "name": "Ficha de serviço",
+        "description": "Para o quadro: a placa em destaque, os dados do serviço, as tarefas como caixas para marcar, um código para abrir no celular e sem preços"
+      },
+      "work-order-compact": {
+        "name": "Compacto",
+        "description": "Preto no branco, uma página: nome e telefone do cliente, o carro, o serviço, a mão de obra com preços e as duas assinaturas. O que toda ordem de serviço imprime até você salvar um design"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Checklists de\ninspeção",
       "sms": "SMS",
       "layout": "Layout",
-      "certificates": "Certificados de\ninspeção"
+      "certificates": "Certificados de\ninspeção",
+      "workOrders": "Ordens\nde serviço"
     },
     "presets": "Presets de modelo",
     "primaryColor": "Cor primária",
@@ -938,7 +962,8 @@
     "openDesigner": "Abrir o designer",
     "designerIntro": "Escolha um ponto de partida e abra o designer para mudar tudo.",
     "designerHint": "Cores, fontes, espaçamento e todas as seções ficam no designer.",
-    "certificatesDescription": "Desenhe o certificado que uma inspeção concluída imprime e compartilha."
+    "certificatesDescription": "Desenhe o certificado que uma inspeção concluída imprime e compartilha.",
+    "workOrdersDescription": "Desenhe a folha que uma ordem de serviço imprime: o que o cliente assina e o que o técnico pega do quadro."
   },
   "payment": {
     "readMore": "Saiba mais",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "Estado dos faróis",
       "checkFogLamp": "Luz de neblina traseira",
       "photoFront": "Frente",
-      "photoOdometer": "Hodômetro"
+      "photoOdometer": "Hodômetro",
+      "concernNoise": "Barulho de raspagem na frente ao frear",
+      "concernNoiseDetail": "Pastilhas e discos dianteiros gastos; substituídos",
+      "concernWarningLight": "Luz de aviso no painel desde a semana passada",
+      "workRequested": "Substituir pastilhas e discos dianteiros, zerar o indicador de revisão e fazer teste de rua.",
+      "checklistTestDrive": "Teste de rua"
     },
     "logoUpload": "Enviar logotipo",
     "logoReplace": "Substituir logotipo",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "Não foi possível definir o design padrão",
     "fieldFixedSlot": "Sempre é impresso no mesmo lugar, portanto não pode ser movido.",
     "certificate": "Certificado de inspeção",
-    "resultWidthHint": "Vazio ocupa a linha inteira. Com um valor a faixa fica mais estreita e se posiciona no lado do alinhamento; em uma coluna ocupa a largura da coluna."
+    "resultWidthHint": "Vazio ocupa a linha inteira. Com um valor a faixa fica mais estreita e se posiciona no lado do alinhamento; em uma coluna ocupa a largura da coluna.",
+    "work_order": "Ordem de serviço"
   },
   "preview": {
     "title": "Serviço de freios e troca de óleo",

+ 22 - 0
messages/pt-PT/pdf.json

@@ -209,5 +209,27 @@
     "signature": "Assinatura",
     "signedBy": "Assinado por",
     "signatureDate": "Data"
+  },
+  "workOrder": {
+    "title": "ORDEM DE TRABALHO",
+    "orderNumberLabel": "N.º da ordem",
+    "customer": "Cliente",
+    "plateLabel": "Matrícula",
+    "jobDetails": "Detalhes do trabalho",
+    "status": "Estado: {status}",
+    "technician": "Técnico: {tech}",
+    "scheduled": "Agendado: {date}",
+    "promised": "Prometido: {date}",
+    "workBay": "Posto: {bay}",
+    "statusPending": "Pendente",
+    "statusInProgress": "Em curso",
+    "statusWaitingParts": "À espera de peças",
+    "statusCompleted": "Concluído",
+    "concerns": "Reclamações do cliente",
+    "workRequested": "Trabalho pedido",
+    "checklist": "Trabalhos",
+    "scanToOpen": "Digitalize para abrir esta ordem de trabalho",
+    "customerSignature": "Assinatura do cliente",
+    "printedOn": "Impresso em {date}"
   }
 }

+ 2 - 1
messages/pt-PT/service.json

@@ -55,7 +55,8 @@
     "designPickConfirmBody": "Só muda o aspeto. Os dados da oficina, o cliente, as condições de pagamento e os valores mantêm-se exatamente como foram enviados. O design atual não é guardado, por isso não é possível anular.",
     "designPickConfirm": "Mudar o design",
     "designPickDone": "A fatura passa a ser impressa com {name}",
-    "designPickFailed": "Não foi possível alterar o design"
+    "designPickFailed": "Não foi possível alterar o design",
+    "printWorkOrder": "Imprimir ordem de trabalho"
   },
   "basicInfo": {
     "title": "Informações básicas",

+ 37 - 6
messages/pt-PT/settings.json

@@ -708,7 +708,12 @@
       "defects": "Defeitos",
       "results_table": "Todos os resultados",
       "inspection_photos": "Fotografias",
-      "signature": "Assinatura"
+      "signature": "Assinatura",
+      "job_details": "Detalhes do trabalho",
+      "job_qr": "Abrir no telemóvel",
+      "concerns": "Reclamações do cliente",
+      "job_description": "Trabalho pedido",
+      "work_checklist": "Lista de trabalhos"
     },
     "fields": {
       "customer_name": "Nome do cliente",
@@ -759,7 +764,13 @@
       "inspector_line": "Linha de assinatura",
       "inspector_name": "Nome de quem assina",
       "date_line": "Linha da data",
-      "signature_image": "Assinatura"
+      "signature_image": "Assinatura",
+      "status": "Estado",
+      "technician": "Técnico",
+      "scheduled": "Agendado",
+      "promised": "Prometido",
+      "work_bay": "Posto",
+      "customer_line": "Linha de assinatura do cliente"
     },
     "presetsTitle": "Começar por uma disposição",
     "presetsHint": "Substitui a disposição abaixo. Nada é guardado até clicar em Guardar.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Compacto",
         "description": "Uma página para frotas: letra pequena, o veredito ao lado do título, todos os resultados sem notas, as fotografias, uma linha para assinar"
+      },
+      "work-order-standard": {
+        "name": "Normal",
+        "description": "A cópia de balcão mais completa na sua cor: o cliente e a viatura inteiros, o serviço, as observações e a garantia ao lado do trabalho e dos preços"
+      },
+      "work-order-job-sheet": {
+        "name": "Folha de obra",
+        "description": "Para o quadro: a matrícula em destaque, os dados do trabalho, as tarefas como caixas para assinalar, um código para abrir no telemóvel e sem preços"
+      },
+      "work-order-compact": {
+        "name": "Compacto",
+        "description": "Preto no branco, uma página: nome e telefone do cliente, a viatura, o trabalho, a mão de obra com preços e as duas assinaturas. O que toda a ordem de trabalho imprime até guardar um design"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Listas de\ninspeção",
       "sms": "SMS",
       "layout": "Layout",
-      "certificates": "Certificados de\ninspeção"
+      "certificates": "Certificados de\ninspeção",
+      "workOrders": "Ordens\nde trabalho"
     },
     "presets": "Modelos predefinidos",
     "primaryColor": "Cor primária",
@@ -938,7 +962,8 @@
     "openDesigner": "Abrir o editor de modelos",
     "designerIntro": "Escolha um ponto de partida e depois abra o editor para alterar o que quiser.",
     "designerHint": "Cores, tipos de letra, espaçamento e todas as secções estão no editor.",
-    "certificatesDescription": "Desenhe o certificado que uma inspeção concluída imprime e partilha."
+    "certificatesDescription": "Desenhe o certificado que uma inspeção concluída imprime e partilha.",
+    "workOrdersDescription": "Desenhe a folha que uma ordem de trabalho imprime: o que o cliente assina e o que o técnico leva do quadro."
   },
   "payment": {
     "readMore": "Saiba mais",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "Estado dos faróis",
       "checkFogLamp": "Farolim de nevoeiro traseiro",
       "photoFront": "Frente",
-      "photoOdometer": "Conta-quilómetros"
+      "photoOdometer": "Conta-quilómetros",
+      "concernNoise": "Ruído de atrito à frente ao travar",
+      "concernNoiseDetail": "Pastilhas e discos dianteiros gastos; substituídos",
+      "concernWarningLight": "Luz de aviso no painel desde a semana passada",
+      "workRequested": "Substituir pastilhas e discos dianteiros, repor o indicador de manutenção e fazer teste de estrada.",
+      "checklistTestDrive": "Teste de estrada"
     },
     "logoUpload": "Carregar logótipo",
     "logoReplace": "Substituir logótipo",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "Não foi possível definir o design predefinido",
     "fieldFixedSlot": "É sempre impresso no mesmo sítio, por isso não pode ser movido.",
     "certificate": "Certificado de inspeção",
-    "resultWidthHint": "Vazio ocupa a linha inteira. Com um valor a faixa fica mais estreita e coloca-se no lado do alinhamento; numa coluna ocupa a largura da coluna."
+    "resultWidthHint": "Vazio ocupa a linha inteira. Com um valor a faixa fica mais estreita e coloca-se no lado do alinhamento; numa coluna ocupa a largura da coluna.",
+    "work_order": "Ordem de trabalho"
   },
   "preview": {
     "title": "Revisão dos travões e mudança de óleo",

+ 22 - 0
messages/ru/pdf.json

@@ -209,5 +209,27 @@
     "signature": "Подпись",
     "signedBy": "Подписал(а)",
     "signatureDate": "Дата"
+  },
+  "workOrder": {
+    "title": "ЗАКАЗ-НАРЯД",
+    "orderNumberLabel": "№ заказа",
+    "customer": "Клиент",
+    "plateLabel": "Госномер",
+    "jobDetails": "Детали заказа",
+    "status": "Статус: {status}",
+    "technician": "Механик: {tech}",
+    "scheduled": "Запланировано: {date}",
+    "promised": "Обещано: {date}",
+    "workBay": "Пост: {bay}",
+    "statusPending": "Ожидает",
+    "statusInProgress": "В работе",
+    "statusWaitingParts": "Ожидание запчастей",
+    "statusCompleted": "Завершён",
+    "concerns": "Обращения клиента",
+    "workRequested": "Запрошенные работы",
+    "checklist": "Работы",
+    "scanToOpen": "Отсканируйте, чтобы открыть этот заказ-наряд",
+    "customerSignature": "Подпись клиента",
+    "printedOn": "Напечатано {date}"
   }
 }

+ 2 - 1
messages/ru/service.json

@@ -55,7 +55,8 @@
     "designPickConfirmBody": "Меняется только внешний вид. Данные сервиса, клиент, условия оплаты и суммы останутся ровно такими, какими были отправлены. Нынешний дизайн не сохраняется, поэтому отменить это не получится.",
     "designPickConfirm": "Изменить дизайн",
     "designPickDone": "Счёт теперь печатается с дизайном {name}",
-    "designPickFailed": "Не удалось изменить дизайн"
+    "designPickFailed": "Не удалось изменить дизайн",
+    "printWorkOrder": "Печать заказ-наряда"
   },
   "basicInfo": {
     "title": "Основная информация",

+ 37 - 6
messages/ru/settings.json

@@ -708,7 +708,12 @@
       "defects": "Дефекты",
       "results_table": "Все результаты",
       "inspection_photos": "Фото",
-      "signature": "Подпись"
+      "signature": "Подпись",
+      "job_details": "Детали заказа",
+      "job_qr": "Открыть на телефоне",
+      "concerns": "Обращения клиента",
+      "job_description": "Запрошенные работы",
+      "work_checklist": "Чек-лист работ"
     },
     "fields": {
       "customer_name": "Имя клиента",
@@ -759,7 +764,13 @@
       "inspector_line": "Строка подписи",
       "inspector_name": "Имя подписавшего",
       "date_line": "Строка даты",
-      "signature_image": "Подпись"
+      "signature_image": "Подпись",
+      "status": "Статус",
+      "technician": "Механик",
+      "scheduled": "Запланировано",
+      "promised": "Обещано",
+      "work_bay": "Пост",
+      "customer_line": "Строка подписи клиента"
     },
     "presetsTitle": "Начать с расположения",
     "presetsHint": "Заменяет расположение ниже. Ничего не сохранится, пока вы не сохраните.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Компактный",
         "description": "Одна страница для автопарка: мелкий шрифт, вердикт рядом с заголовком, все результаты без примечаний, фото, строка для подписи"
+      },
+      "work-order-standard": {
+        "name": "Стандартный",
+        "description": "Более полная копия для стойки в вашем цвете: клиент и автомобиль целиком, услуга, замечания и гарантия рядом с работами и ценами"
+      },
+      "work-order-job-sheet": {
+        "name": "Рабочий лист",
+        "description": "Для доски: крупный госномер, факты о заказе, работы как поля для отметки, код для открытия на телефоне и никаких цен"
+      },
+      "work-order-compact": {
+        "name": "Компактный",
+        "description": "Чёрным по белому, одна страница: имя и телефон клиента, автомобиль, заказ, работы с ценами и обе подписи. То, что печатает каждый заказ-наряд, пока вы не сохраните дизайн"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Чек-листы\nосмотров",
       "sms": "SMS",
       "layout": "Макет",
-      "certificates": "Сертификаты\nосмотров"
+      "certificates": "Сертификаты\nосмотров",
+      "workOrders": "Заказ-\nнаряды"
     },
     "presets": "Предустановки шаблонов",
     "primaryColor": "Основной цвет",
@@ -938,7 +962,8 @@
     "openDesigner": "Открыть дизайнер",
     "designerIntro": "Выберите отправную точку и откройте дизайнер, чтобы изменить всё.",
     "designerHint": "Цвета, шрифты, отступы и все разделы находятся в дизайнере.",
-    "certificatesDescription": "Оформите сертификат, который печатает и отправляет завершённый осмотр."
+    "certificatesDescription": "Оформите сертификат, который печатает и отправляет завершённый осмотр.",
+    "workOrdersDescription": "Оформите лист, который печатает заказ-наряд: то, что подписывает клиент, и то, что механик снимает с доски."
   },
   "payment": {
     "readMore": "Подробнее",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "Состояние фар",
       "checkFogLamp": "Задний противотуманный фонарь",
       "photoFront": "Спереди",
-      "photoOdometer": "Одометр"
+      "photoOdometer": "Одометр",
+      "concernNoise": "Скрежет спереди при торможении",
+      "concernNoiseDetail": "Передние колодки и диски изношены; заменены",
+      "concernWarningLight": "Индикатор на панели горит с прошлой недели",
+      "workRequested": "Заменить передние колодки и диски, сбросить индикатор ТО и провести тест-драйв.",
+      "checklistTestDrive": "Тест-драйв"
     },
     "logoUpload": "Загрузить логотип",
     "logoReplace": "Заменить логотип",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "Не удалось установить дизайн по умолчанию",
     "fieldFixedSlot": "Всегда печатается на одном и том же месте, поэтому переместить нельзя.",
     "certificate": "Сертификат осмотра",
-    "resultWidthHint": "Пусто — на всю строку. Укажите пункты, чтобы сузить полосу; она встанет со стороны, заданной выравниванием, а в колонке займёт её ширину."
+    "resultWidthHint": "Пусто — на всю строку. Укажите пункты, чтобы сузить полосу; она встанет со стороны, заданной выравниванием, а в колонке займёт её ширину.",
+    "work_order": "Заказ-наряд"
   },
   "preview": {
     "title": "Обслуживание тормозов и замена масла",

+ 22 - 0
messages/tr/pdf.json

@@ -209,5 +209,27 @@
     "signature": "İmza",
     "signedBy": "İmzalayan",
     "signatureDate": "Tarih"
+  },
+  "workOrder": {
+    "title": "İŞ EMRİ",
+    "orderNumberLabel": "İş emri No.",
+    "customer": "Müşteri",
+    "plateLabel": "Plaka",
+    "jobDetails": "İş ayrıntıları",
+    "status": "Durum: {status}",
+    "technician": "Teknisyen: {tech}",
+    "scheduled": "Planlanan: {date}",
+    "promised": "Söz verilen: {date}",
+    "workBay": "Alan: {bay}",
+    "statusPending": "Beklemede",
+    "statusInProgress": "Devam ediyor",
+    "statusWaitingParts": "Parça bekleniyor",
+    "statusCompleted": "Tamamlandı",
+    "concerns": "Müşteri şikayetleri",
+    "workRequested": "İstenen iş",
+    "checklist": "İşler",
+    "scanToOpen": "Bu iş emrini açmak için tarayın",
+    "customerSignature": "Müşteri imzası",
+    "printedOn": "Yazdırma tarihi {date}"
   }
 }

+ 2 - 1
messages/tr/service.json

@@ -55,7 +55,8 @@
     "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"
+    "designPickFailed": "Tasarım değiştirilemedi",
+    "printWorkOrder": "İş emrini yazdır"
   },
   "basicInfo": {
     "title": "Temel Bilgiler",

+ 37 - 6
messages/tr/settings.json

@@ -708,7 +708,12 @@
       "defects": "Kusurlar",
       "results_table": "Tüm sonuçlar",
       "inspection_photos": "Fotoğraflar",
-      "signature": "İmza"
+      "signature": "İmza",
+      "job_details": "İş ayrıntıları",
+      "job_qr": "Telefonda aç",
+      "concerns": "Müşteri şikayetleri",
+      "job_description": "İstenen iş",
+      "work_checklist": "İş kontrol listesi"
     },
     "fields": {
       "customer_name": "Müşteri adı",
@@ -759,7 +764,13 @@
       "inspector_line": "İmza satırı",
       "inspector_name": "İmzalayanın adı",
       "date_line": "Tarih satırı",
-      "signature_image": "İmza"
+      "signature_image": "İmza",
+      "status": "Durum",
+      "technician": "Teknisyen",
+      "scheduled": "Planlanan",
+      "promised": "Söz verilen",
+      "work_bay": "Çalışma alanı",
+      "customer_line": "Müşteri imza satırı"
     },
     "presetsTitle": "Bir düzenle başlayın",
     "presetsHint": "Aşağıdaki düzeni değiştirir. Siz kaydetmeden hiçbir şey kaydedilmez.",
@@ -803,6 +814,18 @@
       "certificate-compact": {
         "name": "Kompakt",
         "description": "Filolar için tek sayfa: küçük yazı, başlığın yanında sonuç, notsuz tüm sonuçlar, fotoğraflar, imza satırı"
+      },
+      "work-order-standard": {
+        "name": "Standart",
+        "description": "Kendi renginizde daha dolu tezgâh kopyası: müşteri ve aracın tamamı, servis, bulgular ve garanti, işler ve fiyatların yanında"
+      },
+      "work-order-job-sheet": {
+        "name": "İş kağıdı",
+        "description": "Pano için: büyük plaka, işin bilgileri, işaretlenecek kutular halinde işler, telefonda açmak için bir kod ve fiyat yok"
+      },
+      "work-order-compact": {
+        "name": "Kompakt",
+        "description": "Beyaz üzerine siyah, tek sayfa: müşterinin adı ve telefonu, araç, iş, fiyatlarıyla işçilik ve iki imza. Bir tasarım kaydedene kadar her iş emrinin yazdırdığı sayfa"
       }
     },
     "style": {
@@ -833,7 +856,8 @@
       "inspections": "Muayene\nkontrol listeleri",
       "sms": "SMS",
       "layout": "Düzen",
-      "certificates": "Muayene\nsertifikaları"
+      "certificates": "Muayene\nsertifikaları",
+      "workOrders": "İş\nemirleri"
     },
     "presets": "Şablon Hazır Ayarları",
     "primaryColor": "Ana Renk",
@@ -938,7 +962,8 @@
     "openDesigner": "Tasarımcıyı aç",
     "designerIntro": "Bir başlangıç noktası seçin, sonra her şeyi değiştirmek için tasarımcıyı açın.",
     "designerHint": "Renkler, yazı tipleri, boşluklar ve tüm bölümler tasarımcıda.",
-    "certificatesDescription": "Tamamlanan bir muayenenin yazdırıp paylaştığı sertifikayı tasarlayın."
+    "certificatesDescription": "Tamamlanan bir muayenenin yazdırıp paylaştığı sertifikayı tasarlayın.",
+    "workOrdersDescription": "Bir iş emrinin yazdırdığı sayfayı tasarlayın: müşterinin imzaladığı ve teknisyenin panodan aldığı sayfa."
   },
   "payment": {
     "readMore": "Devamını oku",
@@ -2161,7 +2186,12 @@
       "checkHeadlamps": "Far durumu",
       "checkFogLamp": "Arka sis lambası",
       "photoFront": "Ön",
-      "photoOdometer": "Kilometre sayacı"
+      "photoOdometer": "Kilometre sayacı",
+      "concernNoise": "Frenlerken önden sürtünme sesi",
+      "concernNoiseDetail": "Ön balatalar ve diskler aşınmış; değiştirildi",
+      "concernWarningLight": "Geçen haftadan beri göstergede uyarı ışığı",
+      "workRequested": "Ön fren balatalarını ve disklerini değiştir, servis göstergesini sıfırla ve yol testi yap.",
+      "checklistTestDrive": "Yol testi"
     },
     "logoUpload": "Logo yükle",
     "logoReplace": "Logoyu değiştir",
@@ -2203,7 +2233,8 @@
     "couldNotSetDefault": "Varsayılan tasarım ayarlanamadı",
     "fieldFixedSlot": "Her zaman aynı yerde yazdırılır, bu yüzden taşınamaz.",
     "certificate": "Muayene sertifikası",
-    "resultWidthHint": "Boş bırakılırsa satırı kaplar. Punto girerseniz bant daralır ve hizalamanın seçtiği tarafa yerleşir; sütunda sütun genişliğini alır."
+    "resultWidthHint": "Boş bırakılırsa satırı kaplar. Punto girerseniz bant daralır ve hizalamanın seçtiği tarafa yerleşir; sütunda sütun genişliğini alır.",
+    "work_order": "İş emri"
   },
   "preview": {
     "title": "Fren bakımı ve yağ değişimi",

+ 8 - 2
src/__tests__/features/invoice-designer/fixed-slot-fields.test.ts

@@ -95,13 +95,19 @@ describe('fields that print in a place of their own', () => {
     )
   })
 
-  it('is the ten the sheet actually has', () => {
+  it('is the eleven the sheet actually has', () => {
     // Named, so that gaining or losing one is a decision rather than a drift.
     expect(FIXED_SLOT_FIELDS).toEqual({
       header: ['logo', 'company_name'],
       document_title: ['title'],
       footer: ['footer_note', 'portal_link', 'logo'],
-      signature: ['signature_image', 'inspector_line', 'inspector_name', 'date_line'],
+      signature: [
+        'signature_image',
+        'inspector_line',
+        'inspector_name',
+        'date_line',
+        'customer_line',
+      ],
     })
   })
 

+ 344 - 0
src/__tests__/features/invoice-designer/work-order-print.test.ts

@@ -0,0 +1,344 @@
+/**
+ * A job as the work order sheet words it: the strip says the order number
+ * and the plate, nothing of the bill is on it, the customer's concerns and
+ * the work as a checklist print when the design asks, and the customer gets
+ * a line of their own to sign under their name.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+  buildWorkOrderPrintSpec,
+  workOrderQrWanted,
+} from '@/features/invoice-designer/Pdf/buildWorkOrderPrint'
+import { withWorkOrderLabels } from '@/features/invoice-designer/Lib/workOrderLabels'
+import {
+  getDefaultInvoiceLayout,
+  getDefaultLayout,
+  mergeWithDefaults,
+  WORK_ORDER_SECTIONS,
+  type InvoiceLayoutConfig,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+import { buildLayoutFromPreset, workOrderPresets } from '@/features/settings/Schema/layoutPresets'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+const PNG =
+  'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAX+XDSwAAAABJRU5ErkJggg=='
+
+const data = () =>
+  ({
+    id: 'svc_00000001',
+    title: '2026-0042 - AB 12345',
+    description: 'Customer asks for a road test after the brakes.\n\nCall before extra work.',
+    type: 'repair',
+    serviceDate: new Date('2026-09-24T09:00:00Z'),
+    startDateTime: new Date('2026-09-25T08:30:00Z'),
+    shopName: null,
+    techName: 'Kari',
+    mileage: 84120,
+    diagnosticNotes: 'Internal: check the caliper slide pins.',
+    invoiceNotes: '<p>Please bring the locking wheel nut.</p>',
+    subtotal: 300,
+    taxRate: 25,
+    taxAmount: 75,
+    totalAmount: 375,
+    cost: 375,
+    invoiceNumber: '2026-0042',
+    discountValue: 0,
+    partItems: [
+      { partNumber: 'BP-1', name: 'Front pads', quantity: 1, unitPrice: 100, total: 100 },
+    ],
+    laborItems: [
+      { description: 'Replace front pads', hours: 1.5, rate: 100, total: 150 },
+      { description: 'Road test', hours: 0.5, rate: 100, total: 50 },
+    ],
+    customFields: [],
+    findings: [],
+    customer: {
+      name: 'Alex Carter',
+      email: null,
+      phone: null,
+      address: null,
+      company: null,
+      customerNumber: 'C-7',
+    },
+    vehicle: {
+      make: 'Volvo',
+      model: 'V60',
+      year: 2020,
+      vin: 'YV1',
+      licensePlate: 'AB 12345',
+      mileage: 84120,
+      customer: null,
+    },
+  }) as unknown as InvoiceData
+
+const job = () => ({
+  orderNumber: '2026-0042',
+  statusLabel: 'Waiting for approval',
+  workBay: 'Bay 2',
+  promisedAt: new Date('2026-09-25T16:00:00Z'),
+  concerns: [
+    { description: 'Grinding when braking', correction: 'Pads and discs replaced' },
+    { description: 'Warning light', correction: null },
+  ],
+  qrDataUri: PNG,
+  printedAt: new Date('2026-09-25T10:00:00Z'),
+})
+
+/** Every string a spec prints, in order. */
+function texts(spec: any): string[] {
+  const out: string[] = []
+  const walk = (n: any) => {
+    if (!n || typeof n !== 'object') return
+    if (n.kind === 'text' && n.text) out.push(String(n.text))
+    if (n.kind === 'richtext' && n.html) out.push(String(n.html))
+    if (n.kind === 'table')
+      for (const row of n.rows ?? []) out.push(...Object.values(row).map(String))
+    for (const child of n.children ?? []) walk(child.node ?? child)
+  }
+  for (const block of spec.blocks) walk(block.content)
+  return out
+}
+
+function blockOf(spec: any, id: string) {
+  return spec.blocks.find((block: any) => block.id === id)?.content ?? null
+}
+
+const labels = withWorkOrderLabels(
+  { billTo: 'Bill To', signedBy: 'Signed by', signatureDate: 'Date' },
+  {
+    title: 'WORK ORDER',
+    orderNumberLabel: 'Work order No.',
+    customer: 'Customer',
+    plateLabel: 'Plate',
+    status: 'Status: {status}',
+    workBay: 'Bay: {bay}',
+    customerSignature: 'Customer signature',
+    scanToOpen: 'Scan to open',
+    printedOn: 'Printed {date}',
+  }
+)
+
+function print(layout?: InvoiceLayoutConfig, over: Partial<ReturnType<typeof job>> = {}) {
+  return buildWorkOrderPrintSpec({
+    data: data(),
+    job: { ...job(), ...over },
+    labels,
+    signer: { name: 'Jamie Lee', dataUri: PNG },
+    template: layout ? ({ layoutConfig: { ...layout, version: 3 } } as any) : undefined,
+    invoiceSettings: { currencyCode: 'EUR', dateFormat: 'yyyy-MM-dd' },
+  }) as any
+}
+
+describe('the work order sheet', () => {
+  it('prints from the work order layout even without a saved design', () => {
+    const spec = print()
+    const ids = spec.blocks.map((b: any) => b.id)
+    expect(ids).toContain('concerns')
+    expect(ids).not.toContain('bank_account')
+    // The code to scan and the checklist are the board copy's, off until a
+    // design asks for them.
+    expect(ids).not.toContain('job_qr')
+    expect(ids).not.toContain('work_checklist')
+  })
+
+  it('says the order number, the plate and the title in the strip, and no due date', () => {
+    const strip = texts({ blocks: [{ content: blockOf(print(), 'document_title') }] })
+    expect(strip).toContain('WORK ORDER')
+    expect(strip).toContain('Work order No.')
+    expect(strip).toContain('2026-0042')
+    expect(strip).toContain('Plate')
+    expect(strip).toContain('AB 12345')
+    expect(strip.join(' ')).not.toMatch(/Due/)
+  })
+
+  it('carries nothing of the bill: totals end at the total, no payments, no bank details', () => {
+    const spec = print()
+    const all = texts(spec).join('\n')
+    expect(all).toContain('Total')
+    expect(all).not.toMatch(/PAID|Amount Due|Bank/)
+    expect(blockOf(spec, 'bank_account')).toBeNull()
+  })
+
+  it('words the job: status, technician, scheduled and promised, with the bay a switch', () => {
+    const shown = getDefaultLayout('work_order')
+    shown.sections = shown.sections.map((s) =>
+      s.id === 'job_details' ? { ...s, visible: true } : s
+    )
+    const lines = texts({ blocks: [{ content: blockOf(print(shown), 'job_details') }] })
+    expect(lines).toContain('Status: Waiting for approval')
+    expect(lines).toContain('Technician: Kari')
+    expect(lines.some((l) => l.startsWith('Scheduled: 2026-09-25'))).toBe(true)
+    expect(lines.some((l) => l.startsWith('Promised: 2026-09-25'))).toBe(true)
+    expect(lines).not.toContain('Bay: Bay 2')
+    const layout = getDefaultLayout('work_order')
+    layout.sections = layout.sections.map((s) =>
+      s.id === 'job_details'
+        ? { ...s, visible: true, fields: s.fields?.map((f) => ({ ...f, visible: true })) }
+        : s
+    )
+    expect(texts({ blocks: [{ content: blockOf(print(layout), 'job_details') }] })).toContain(
+      'Bay: Bay 2'
+    )
+  })
+
+  it('starts short and black on white: name and phone, the car, no boxes, no colour', () => {
+    const layout = getDefaultLayout('work_order')
+    const visible = (id: string) => layout.sections.find((s) => s.id === id)?.visible
+    const on = (id: string, field: string) =>
+      layout.sections.find((s) => s.id === id)?.fields?.find((f) => f.id === field)?.visible
+    expect(on('customer', 'customer_name')).toBe(true)
+    expect(on('customer', 'customer_phone')).toBe(true)
+    expect(on('customer', 'customer_address')).toBe(false)
+    expect(on('vehicle', 'vin')).toBe(false)
+    expect(on('vehicle', 'license_plate')).toBe(true)
+    for (const id of [
+      'service',
+      'findings',
+      'warranty',
+      'attached_documents',
+      'work_checklist',
+      'notes',
+      'header',
+      'job_details',
+      'job_qr',
+    ]) {
+      expect(visible(id)).toBe(false)
+    }
+    for (const id of ['customer', 'vehicle', 'job_details', 'signature']) {
+      expect(layout.sections.find((s) => s.id === id)?.boxed).toBe(false)
+    }
+    expect(layout.document?.accentColor).toBe('#111827')
+    expect(layout.document?.stripes).toBe(false)
+    expect(layout.sections.find((s) => s.id === 'customer')?.column).toBe('left')
+    expect(layout.sections.find((s) => s.id === 'vehicle')?.column).toBe('right')
+    // The default and the Compact starting point are the same sheet.
+    const compact = workOrderPresets.find((p) => p.id === 'work-order-compact')
+    const strip = (config: InvoiceLayoutConfig) =>
+      config.sections
+        .filter((s) => s.visible)
+        .sort((a, b) => a.order - b.order)
+        .map((s) => [s.id, s.boxed, s.fields?.filter((f) => f.visible).map((f) => f.id)])
+    expect(strip(buildLayoutFromPreset(compact!))).toEqual(strip(layout))
+    expect(buildLayoutFromPreset(compact!).document).toEqual(layout.document)
+  })
+
+  it('numbers the concerns in order, with what was done under each', () => {
+    const lines = texts({ blocks: [{ content: blockOf(print(), 'concerns') }] })
+    expect(lines).toEqual([
+      'Customer concerns',
+      '1.',
+      'Grinding when braking',
+      'Pads and discs replaced',
+      '2.',
+      'Warning light',
+    ])
+  })
+
+  it('prints the description as paragraphs and keeps internal notes off the sheet', () => {
+    const all = texts(print()).join('\n')
+    expect(all).toContain('<p>Customer asks for a road test after the brakes.</p>')
+    expect(all).not.toContain('caliper slide pins')
+  })
+
+  it('lists the work as boxes to tick, without prices, when the checklist is on', () => {
+    const layout = getDefaultLayout('work_order')
+    layout.sections = layout.sections.map((s) =>
+      s.id === 'work_checklist' ? { ...s, visible: true } : s
+    )
+    const block = blockOf(print(layout), 'work_checklist')
+    const lines = texts({ blocks: [{ content: block }] })
+    expect(lines).toContain('Replace front pads')
+    expect(lines).toContain('1.5 hrs')
+    expect(lines).toContain('Road test')
+    expect(lines.join(' ')).not.toMatch(/€|150/)
+    // One empty box per line: a bordered stack with nothing in it.
+    const boxes: any[] = []
+    const walk = (n: any) => {
+      if (n?.kind === 'stack' && n.style?.borderWidth && n.children?.[0]?.kind === 'spacer')
+        boxes.push(n)
+      for (const child of n?.children ?? []) walk(child.node ?? child)
+    }
+    walk(block)
+    expect(boxes).toHaveLength(2)
+  })
+
+  it('gives the customer a line of their own, under their name, beside the issuer', () => {
+    const block = blockOf(print(), 'signature')
+    const lines = texts({ blocks: [{ content: block }] })
+    expect(lines).toContain('Signed by')
+    expect(lines).toContain('Jamie Lee')
+    expect(lines).toContain('Customer signature')
+    expect(lines).toContain('Alex Carter')
+    // A work order is signed with a pen: the issuer's saved image stays off.
+    const images: string[] = []
+    const walk = (n: any) => {
+      if (n?.kind === 'image') images.push(n.src)
+      for (const child of n?.children ?? []) walk(child.node ?? child)
+    }
+    walk(block)
+    expect(images).toEqual([])
+  })
+
+  it('draws the code that opens the job on a phone only when the layout asks', () => {
+    const withQr = getDefaultLayout('work_order')
+    withQr.sections = withQr.sections.map((s) => (s.id === 'job_qr' ? { ...s, visible: true } : s))
+    expect(blockOf(print(withQr), 'job_qr')).not.toBeNull()
+    expect(blockOf(print(withQr, { qrDataUri: undefined }), 'job_qr')).toBeNull()
+    expect(blockOf(print(), 'job_qr')).toBeNull()
+    expect(workOrderQrWanted(withQr)).toBe(true)
+    expect(workOrderQrWanted(getDefaultLayout('work_order'))).toBe(false)
+    // Switched on, the code sits top right beside the customer and the car.
+    const qr = print(withQr).blocks.find((b: any) => b.id === 'job_qr')
+    expect(qr.placement).toMatchObject({ mode: 'flow', column: 'right' })
+  })
+
+  it('says when it was printed in the footer', () => {
+    const footer = texts({ blocks: [{ content: blockOf(print(), 'footer') }] }).join(' ')
+    expect(footer).toContain('Printed 2026-09-25')
+  })
+})
+
+describe('the work order layout', () => {
+  it('is its own list of sections, read back as one', () => {
+    const merged = mergeWithDefaults({ documentType: 'work_order', sections: [] })
+    expect(merged.documentType).toBe('work_order')
+    expect(merged.sections.map((s) => s.id)).toEqual(WORK_ORDER_SECTIONS.map((s) => s.id))
+  })
+
+  it('starts with both signature lines on and the customer line off elsewhere', () => {
+    const on = (layout: InvoiceLayoutConfig, id: string) =>
+      layout.sections.find((s) => s.id === 'signature')?.fields?.find((f) => f.id === id)?.visible
+    const workOrder = getDefaultLayout('work_order')
+    expect(on(workOrder, 'customer_line')).toBe(true)
+    expect(on(workOrder, 'inspector_line')).toBe(true)
+    expect(on(workOrder, 'signature_image')).toBe(false)
+    expect(workOrder.sections.find((s) => s.id === 'signature')?.visible).toBe(true)
+    const invoice = getDefaultInvoiceLayout()
+    expect(on(invoice, 'customer_line')).toBe(false)
+    expect(on(invoice, 'signature_image')).toBe(true)
+    // A saved invoice layout from before the switch existed gains it switched off.
+    const merged = mergeWithDefaults({
+      sections: [
+        {
+          id: 'signature',
+          visible: true,
+          order: 0,
+          fields: [{ id: 'inspector_line', visible: true }],
+        },
+      ],
+    })
+    expect(on(merged, 'customer_line')).toBe(false)
+  })
+
+  it('keeps the plate cell off every strip but the work order', () => {
+    const cell = (layout: InvoiceLayoutConfig) =>
+      layout.sections
+        .find((s) => s.id === 'document_title')
+        ?.fields?.find((f) => f.id === 'license_plate')?.visible
+    expect(cell(getDefaultLayout('work_order'))).toBe(true)
+    expect(cell(getDefaultInvoiceLayout())).toBe(false)
+    expect(cell(mergeWithDefaults({}))).toBe(false)
+  })
+})

+ 5 - 0
src/__tests__/features/settings/framed-template-preset.test.tsx

@@ -205,8 +205,12 @@ describe('framed template preset', () => {
   it('offers to take the panel off every card that draws one', () => {
     expect([...BOXED_ELIGIBLE_SECTIONS].sort()).toEqual([
       'attached_documents',
+      'concerns',
       'customer',
       'general',
+      'job_description',
+      'job_details',
+      'job_qr',
       'notes',
       'result',
       'service',
@@ -215,6 +219,7 @@ describe('framed template preset', () => {
       'test_details',
       'vehicle',
       'warranty',
+      'work_checklist',
     ])
   })
 

+ 10 - 1
src/__tests__/features/settings/layout-editor-labels.test.ts

@@ -17,6 +17,10 @@ import {
   BUILTIN_HEADER_FIELDS,
   BUILTIN_BANK_ACCOUNT_FIELDS,
   BUILTIN_FOOTER_FIELDS,
+  BUILTIN_DOCUMENT_TITLE_FIELDS,
+  BUILTIN_JOB_DETAILS_FIELDS,
+  BUILTIN_SIGNATURE_FIELDS,
+  WORK_ORDER_SECTIONS,
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 
 const LOCALES = readdirSync('messages')
@@ -28,8 +32,13 @@ const FIELD_IDS = [
   ...BUILTIN_HEADER_FIELDS,
   ...BUILTIN_BANK_ACCOUNT_FIELDS,
   ...BUILTIN_FOOTER_FIELDS,
+  ...BUILTIN_DOCUMENT_TITLE_FIELDS,
+  ...BUILTIN_JOB_DETAILS_FIELDS,
+  ...BUILTIN_SIGNATURE_FIELDS,
 ].map((f) => f.id)
 
+const SECTION_IDS = [...new Set([...BUILTIN_SECTIONS, ...WORK_ORDER_SECTIONS].map((s) => s.id))]
+
 function layoutEditorMessages(locale: string) {
   const json = JSON.parse(readFileSync(`messages/${locale}/settings.json`, 'utf8'))
   return json.layoutEditor ?? {}
@@ -42,7 +51,7 @@ describe('layout editor labels', () => {
 
   it.each(LOCALES)('names every section in %s', (locale) => {
     const named = Object.keys(layoutEditorMessages(locale).sections ?? {})
-    const missing = BUILTIN_SECTIONS.map((s) => s.id).filter((id) => !named.includes(id))
+    const missing = SECTION_IDS.filter((id) => !named.includes(id))
     expect(missing).toEqual([])
   })
 

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

@@ -34,6 +34,7 @@ export default async function TemplatePage() {
     invoiceDesigns,
     quoteDesigns,
     certificateDesigns,
+    workOrderDesigns,
   ] = await Promise.all([
     getSettings([
       SETTING_KEYS.INVOICE_PRIMARY_COLOR,
@@ -59,6 +60,7 @@ export default async function TemplatePage() {
       SETTING_KEYS.INVOICE_ACTIVE_DESIGN,
       SETTING_KEYS.QUOTE_ACTIVE_DESIGN,
       SETTING_KEYS.CERTIFICATE_ACTIVE_DESIGN,
+      SETTING_KEYS.WORK_ORDER_ACTIVE_DESIGN,
       SETTING_KEYS.WORKSHOP_ADDRESS,
       SETTING_KEYS.WORKSHOP_SLOGAN,
       SETTING_KEYS.WORKSHOP_PHONE,
@@ -85,6 +87,7 @@ export default async function TemplatePage() {
     listDocumentDesigns('invoice'),
     listDocumentDesigns('quote'),
     listDocumentDesigns('certificate'),
+    listDocumentDesigns('work_order'),
   ])
 
   const settings = result.success && result.data ? result.data : {}
@@ -104,6 +107,7 @@ export default async function TemplatePage() {
     ...(invoiceDesigns.success && invoiceDesigns.data ? invoiceDesigns.data : []),
     ...(quoteDesigns.success && quoteDesigns.data ? quoteDesigns.data : []),
     ...(certificateDesigns.success && certificateDesigns.data ? certificateDesigns.data : []),
+    ...(workOrderDesigns.success && workOrderDesigns.data ? workOrderDesigns.data : []),
   ]
 
   // The preview is meant to look like this workshop's own paper, so it gets the
@@ -171,6 +175,7 @@ export default async function TemplatePage() {
         invoice: settings[SETTING_KEYS.INVOICE_ACTIVE_DESIGN] || '',
         quote: settings[SETTING_KEYS.QUOTE_ACTIVE_DESIGN] || '',
         certificate: settings[SETTING_KEYS.CERTIFICATE_ACTIVE_DESIGN] || '',
+        work_order: settings[SETTING_KEYS.WORK_ORDER_ACTIVE_DESIGN] || '',
       }}
       invoiceLayoutConfig={invoiceLayoutResult.success ? invoiceLayoutResult.data : undefined}
       quoteLayoutConfig={quoteLayoutResult.success ? quoteLayoutResult.data : undefined}

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

@@ -39,7 +39,7 @@ interface TemplateValues {
   logoSize: number
 }
 
-type TabType = 'invoice' | 'quotation' | 'certificates' | 'inspections' | 'sms'
+type TabType = 'invoice' | 'quotation' | 'workOrders' | 'certificates' | 'inspections' | 'sms'
 
 interface WorkshopPreviewInfo {
   name?: string
@@ -163,7 +163,7 @@ function TemplateTab({
   savedDesigns = [],
   activeDesign = '',
 }: {
-  documentType: 'invoice' | 'quote' | 'certificate'
+  documentType: 'invoice' | 'quote' | 'certificate' | 'work_order'
   workshop?: WorkshopPreviewInfo
   logoUrl?: string
   savedDesigns?: SavedDesign[]
@@ -535,7 +535,7 @@ export function TemplateSettings({
   invoiceLayoutConfig,
   quoteLayoutConfig,
   savedDesigns = [],
-  activeDesigns = { invoice: '', quote: '', certificate: '' },
+  activeDesigns = { invoice: '', quote: '', certificate: '', work_order: '' },
 }: {
   initialInvoiceValues: TemplateValues
   initialQuoteValues: TemplateValues
@@ -549,7 +549,7 @@ export function TemplateSettings({
   invoiceLayoutConfig?: InvoiceLayoutConfig
   quoteLayoutConfig?: InvoiceLayoutConfig
   savedDesigns?: SavedDesign[]
-  activeDesigns?: { invoice: string; quote: string; certificate: string }
+  activeDesigns?: { invoice: string; quote: string; certificate: string; work_order: string }
 }) {
   const router = useRouter()
   const searchParams = useSearchParams()
@@ -625,23 +625,28 @@ export function TemplateSettings({
           <h2 className="text-lg font-semibold">{t('templates.title')}</h2>
           {/* Colors live here and arrangement lives there, which is easy to
               get lost in. Each page says where the other half is. */}
-          {tab !== 'inspections' && tab !== 'sms' && tab !== 'certificates' && (
-            <Link
-              href="/settings/invoice?tab=layout"
-              className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
-            >
-              {t('templates.goToLayout')}
-            </Link>
-          )}
+          {tab !== 'inspections' &&
+            tab !== 'sms' &&
+            tab !== 'certificates' &&
+            tab !== 'workOrders' && (
+              <Link
+                href="/settings/invoice?tab=layout"
+                className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
+              >
+                {t('templates.goToLayout')}
+              </Link>
+            )}
         </div>
         <p className="text-sm text-muted-foreground">
           {tab === 'inspections'
             ? t('templates.inspectionsDescription')
             : tab === 'certificates'
               ? t('templates.certificatesDescription')
-              : tab === 'sms'
-                ? t('templates.smsDescription')
-                : t('templates.invoiceDescription')}
+              : tab === 'workOrders'
+                ? t('templates.workOrdersDescription')
+                : tab === 'sms'
+                  ? t('templates.smsDescription')
+                  : t('templates.invoiceDescription')}
         </p>
       </div>
 
@@ -671,6 +676,18 @@ export function TemplateSettings({
         >
           <TabLabel text={t('templates.tabs.quotation')} />
         </button>
+        <button
+          type="button"
+          onClick={() => setTab('workOrders')}
+          className={cn(
+            'flex flex-1 items-center justify-center rounded-md px-4 py-2 text-center text-sm font-medium transition-colors',
+            tab === 'workOrders'
+              ? 'bg-background text-foreground shadow-sm'
+              : 'text-muted-foreground hover:text-foreground'
+          )}
+        >
+          <TabLabel text={t('templates.tabs.workOrders')} />
+        </button>
         <button
           type="button"
           onClick={() => setTab('certificates')}
@@ -713,6 +730,16 @@ export function TemplateSettings({
 
       {tab === 'inspections' ? (
         <TemplateListClient templates={inspectionTemplates} checklistLanguage={checklistLanguage} />
+      ) : tab === 'workOrders' ? (
+        <ReadOnlyWrapper>
+          <TemplateTab
+            documentType="work_order"
+            workshop={workshop}
+            logoUrl={logoUrl}
+            savedDesigns={savedDesigns.filter((design) => design.documentType === 'work_order')}
+            activeDesign={activeDesigns.work_order}
+          />
+        </ReadOnlyWrapper>
       ) : tab === 'certificates' ? (
         <ReadOnlyWrapper>
           <TemplateTab
@@ -746,7 +773,8 @@ export function TemplateSettings({
                 workshop={workshop}
                 logoUrl={logoUrl}
                 savedDesigns={savedDesigns.filter(
-                  (design) => design.documentType !== 'certificate'
+                  (design) =>
+                    design.documentType !== 'certificate' && design.documentType !== 'work_order'
                 )}
                 activeDesign={activeDesigns.invoice}
               />
@@ -756,7 +784,8 @@ export function TemplateSettings({
                 workshop={workshop}
                 logoUrl={logoUrl}
                 savedDesigns={savedDesigns.filter(
-                  (design) => design.documentType !== 'certificate'
+                  (design) =>
+                    design.documentType !== 'certificate' && design.documentType !== 'work_order'
                 )}
                 activeDesign={activeDesigns.quote}
               />

+ 27 - 5
src/app/(designer)/invoice-designer/page.tsx

@@ -9,6 +9,7 @@ import {
   getCertificateLayoutConfig,
   getInvoiceLayoutConfig,
   getQuoteLayoutConfig,
+  getWorkOrderLayoutConfig,
 } from '@/features/settings/Actions/invoiceLayoutActions'
 import { getFieldDefinitions } from '@/features/custom-fields/Actions/customFieldActions'
 import { listDocumentDesigns } from '@/features/invoice-designer/Actions/documentDesignActions'
@@ -16,6 +17,7 @@ import { InvoiceDesigner } from '@/features/invoice-designer/Components/InvoiceD
 import { DismissOnArrival } from '@/components/feature-hint'
 import { INVOICE_DESIGNER_ANNOUNCEMENT, parseHintIds } from '@/features/settings/Lib/featureHints'
 import type { SavedDesign } from '@/features/invoice-designer/Components/types'
+import { WORK_ORDER_TEMPLATE_DEFAULTS } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { telegramBotLink as botLinkOf } from '@/features/invoices/Lib/telegramQr'
 import { getOrgTelegramBotUsername } from '@/lib/telegram'
 import { memberSignatureDataUri } from '@/features/signatures/Lib/memberSignature.server'
@@ -47,6 +49,8 @@ export default async function InvoiceDesignerPage({
     certificateLayout,
     certificateDesigns,
     signatureUrl,
+    workOrderLayout,
+    workOrderDesigns,
   ] = await Promise.all([
     getSettings(),
     getInvoiceLayoutConfig(),
@@ -71,6 +75,8 @@ export default async function InvoiceDesignerPage({
     getCertificateLayoutConfig(),
     listDocumentDesigns('certificate'),
     memberSignatureDataUri(data.organizationId, data.userId),
+    getWorkOrderLayoutConfig(),
+    listDocumentDesigns('work_order'),
   ])
 
   // Somebody is looking at the designer, so the workshop knows it exists. Only
@@ -89,14 +95,19 @@ export default async function InvoiceDesignerPage({
     // A certificate's sections are its own, so its designs stay a family
     // apart: the gallery shows them only on the certificate canvas.
     ...(certificateDesigns.success && certificateDesigns.data ? certificateDesigns.data : []),
+    // The work order's designs are a family of their own for the same reason.
+    ...(workOrderDesigns.success && workOrderDesigns.data ? workOrderDesigns.data : []),
   ]
   const { doc, view, preset, design } = await searchParams
 
-  const templateFor = (prefix: 'invoice' | 'quote' | 'certificate') => ({
+  const templateFor = (prefix: 'invoice' | 'quote' | 'certificate' | 'work_order') => ({
+    // A work order does not borrow the invoice's colour: it starts black on
+    // white, the way it prints until a design is saved for it.
     primaryColor:
       settings[`${prefix}.primaryColor`] ||
-      settings[SETTING_KEYS.INVOICE_PRIMARY_COLOR] ||
-      '#d97706',
+      (prefix === 'work_order'
+        ? WORK_ORDER_TEMPLATE_DEFAULTS.primaryColor
+        : settings[SETTING_KEYS.INVOICE_PRIMARY_COLOR] || '#d97706'),
     backgroundColor: settings[`${prefix}.backgroundColor`] || '',
     textColor: settings[`${prefix}.textColor`] || '',
     companyTextColor: settings[`${prefix}.companyTextColor`] || '',
@@ -105,7 +116,9 @@ export default async function InvoiceDesignerPage({
     frameSide: settings[`${prefix}.frameSide`] || 'left',
     frameRadius: Number(settings[`${prefix}.frameRadius`]) || 0,
     fontFamily: settings[`${prefix}.fontFamily`] || 'Helvetica',
-    headerStyle: settings[`${prefix}.headerStyle`] || 'standard',
+    headerStyle:
+      settings[`${prefix}.headerStyle`] ||
+      (prefix === 'work_order' ? WORK_ORDER_TEMPLATE_DEFAULTS.headerStyle : 'standard'),
     logoSize: Number(settings[`${prefix}.logoSize`]) || 100,
     logoUrl: settings[`${prefix}.logo`] || '',
   })
@@ -115,7 +128,13 @@ export default async function InvoiceDesignerPage({
       {announcementLive && <DismissOnArrival id={INVOICE_DESIGNER_ANNOUNCEMENT} />}
       <InvoiceDesigner
         initialDocumentType={
-          doc === 'quote' ? 'quote' : doc === 'certificate' ? 'certificate' : 'invoice'
+          doc === 'quote'
+            ? 'quote'
+            : doc === 'certificate'
+              ? 'certificate'
+              : doc === 'work_order'
+                ? 'work_order'
+                : 'invoice'
         }
         initialView={view === 'designer' ? 'designer' : 'gallery'}
         initialPresetId={preset}
@@ -124,13 +143,16 @@ export default async function InvoiceDesignerPage({
           invoice: settings['invoice.activeDesign'] || '',
           quote: settings['quote.activeDesign'] || '',
           certificate: settings['certificate.activeDesign'] || '',
+          work_order: settings['work_order.activeDesign'] || '',
         }}
         invoiceLayout={invoiceLayout.success ? invoiceLayout.data : undefined}
         quoteLayout={quoteLayout.success ? quoteLayout.data : undefined}
         certificateLayout={certificateLayout.success ? certificateLayout.data : undefined}
+        workOrderLayout={workOrderLayout.success ? workOrderLayout.data : undefined}
         invoiceTemplate={templateFor('invoice')}
         quoteTemplate={templateFor('quote')}
         certificateTemplate={templateFor('certificate')}
+        workOrderTemplate={templateFor('work_order')}
         initialSavedDesigns={savedDesigns}
         telegramBotLink={telegramBotUsername ? botLinkOf(telegramBotUsername) : undefined}
         workshop={{

+ 50 - 0
src/app/api/protected/services/[id]/work-order-pdf/route.ts

@@ -0,0 +1,50 @@
+import { NextResponse } from 'next/server'
+import { cookies } from 'next/headers'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { db } from '@/lib/db'
+import { buildWorkOrderPdfBuffer } from '@/features/invoices/Pdf/buildWorkOrderPdfBuffer'
+
+/**
+ * The work order as a PDF: the job as it stands right now, in the
+ * workshop's design for it, with a line for the customer to sign. Never
+ * frozen and never shared; the workshop prints it and files the paper.
+ */
+export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
+  try {
+    const ctx = await getAuthContext()
+    if (!ctx) {
+      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+    }
+
+    const { id } = await params
+    const owned = await db.serviceRecord.findFirst({
+      where: { id, organizationId: ctx.organizationId },
+      select: { id: true },
+    })
+    if (!owned) {
+      return NextResponse.json({ error: 'Record not found' }, { status: 404 })
+    }
+
+    const cookieStore = await cookies()
+    const locale = cookieStore.get('locale')?.value || 'en'
+
+    const result = await buildWorkOrderPdfBuffer(owned.id, locale)
+    if (!result) {
+      return NextResponse.json({ error: 'Record not found' }, { status: 404 })
+    }
+
+    const body = result.buffer.buffer.slice(
+      result.buffer.byteOffset,
+      result.buffer.byteOffset + result.buffer.byteLength
+    ) as ArrayBuffer
+    return new NextResponse(body, {
+      headers: {
+        'Content-Type': 'application/pdf',
+        'Content-Disposition': `inline; filename="${result.filename}"`,
+      },
+    })
+  } catch (error) {
+    console.error('[Work order PDF] Error:', error)
+    return NextResponse.json({ error: 'Failed to generate PDF' }, { status: 500 })
+  }
+}

+ 1 - 1
src/features/invoice-designer/Actions/documentDesignActions.ts

@@ -24,7 +24,7 @@ import { requireFeature } from '@/lib/features'
 import { releaseReplacedSettingFiles, settingValuesBefore } from '@/lib/files/settings'
 import { releaseFiles } from '@/lib/files/manager'
 
-const documentTypeSchema = z.enum(['invoice', 'quote', 'certificate'])
+const documentTypeSchema = z.enum(['invoice', 'quote', 'certificate', 'work_order'])
 
 const saveDesignSchema = z.object({
   id: z.string().optional(),

+ 25 - 8
src/features/invoice-designer/Components/InvoiceDesigner.tsx

@@ -40,7 +40,9 @@ import {
   saveCertificateLayoutConfig,
   saveInvoiceLayoutConfig,
   saveQuoteLayoutConfig,
+  saveWorkOrderLayoutConfig,
 } from '@/features/settings/Actions/invoiceLayoutActions'
+import { workOrderLabels } from '../Lib/workOrderLabels'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import {
@@ -76,9 +78,11 @@ export function InvoiceDesigner({
   invoiceLayout,
   quoteLayout,
   certificateLayout,
+  workOrderLayout,
   invoiceTemplate,
   quoteTemplate,
   certificateTemplate,
+  workOrderTemplate,
   initialSavedDesigns = [],
   initialPresetId,
   initialDesignId,
@@ -93,10 +97,13 @@ export function InvoiceDesigner({
   invoiceLayout?: InvoiceLayoutConfig
   quoteLayout?: InvoiceLayoutConfig
   certificateLayout?: InvoiceLayoutConfig
+  workOrderLayout?: InvoiceLayoutConfig
   invoiceTemplate: DesignerTemplate
   quoteTemplate: DesignerTemplate
   /** The certificate's look; it falls back to the invoice's when never set. */
   certificateTemplate?: DesignerTemplate
+  /** The work order's look; it falls back to the invoice's when never set. */
+  workOrderTemplate?: DesignerTemplate
   initialSavedDesigns?: SavedDesign[]
   /** A preset to arrive with already applied, from settings' starting points. */
   initialPresetId?: string
@@ -139,6 +146,7 @@ export function InvoiceDesigner({
       invoice: invoiceLayout ?? getDefaultInvoiceLayout(),
       quote: quoteLayout ?? getDefaultInvoiceLayout(),
       certificate: certificateLayout ?? getDefaultLayout('certificate'),
+      work_order: workOrderLayout ?? getDefaultLayout('work_order'),
     }
     if (initialDesign) base[initialDocumentType] = mergeWithDefaults(initialDesign.layout)
     else if (initialPreset) base[initialDocumentType] = buildLayoutFromPreset(initialPreset)
@@ -149,6 +157,7 @@ export function InvoiceDesigner({
       invoice: invoiceTemplate,
       quote: quoteTemplate,
       certificate: certificateTemplate ?? invoiceTemplate,
+      work_order: workOrderTemplate ?? invoiceTemplate,
     }
     if (initialDesign) {
       base[initialDocumentType] = { ...initialDesign.template }
@@ -170,6 +179,7 @@ export function InvoiceDesigner({
     invoice: !!arrivedWith && initialDocumentType === 'invoice',
     quote: !!arrivedWith && initialDocumentType === 'quote',
     certificate: !!arrivedWith && initialDocumentType === 'certificate',
+    work_order: !!arrivedWith && initialDocumentType === 'work_order',
   })
   // The preset or design in the URL is a one-shot instruction, consumed
   // above. Left in the address bar it would re-apply itself on every refresh,
@@ -189,7 +199,7 @@ export function InvoiceDesigner({
   // saved layout, which reads as the design changing by itself. The browser
   // asks first, so losing the work is a choice rather than a surprise.
   useEffect(() => {
-    if (!dirty.invoice && !dirty.quote && !dirty.certificate) return
+    if (!dirty.invoice && !dirty.quote && !dirty.certificate && !dirty.work_order) return
     const warn = (event: BeforeUnloadEvent) => {
       event.preventDefault()
       // Chrome still wants the legacy channel to show the dialog.
@@ -205,6 +215,7 @@ export function InvoiceDesigner({
       invoice: initialActiveDesigns?.invoice ?? '',
       quote: initialActiveDesigns?.quote ?? '',
       certificate: initialActiveDesigns?.certificate ?? '',
+      work_order: initialActiveDesigns?.work_order ?? '',
     }
     if (initialDesign) base[initialDocumentType] = `design:${initialDesign.id}`
     else if (initialPreset) base[initialDocumentType] = `preset:${initialPreset.id}`
@@ -212,12 +223,12 @@ export function InvoiceDesigner({
   })
   const [savedDesigns, setSavedDesigns] = useState<SavedDesign[]>(initialSavedDesigns)
   // An invoice design is a starting point for a quote and the other way
-  // round; a certificate's sections are its own, so its designs and the
-  // others' never appear on each other's canvas.
+  // round; a certificate's and a work order's sections are their own, so
+  // their designs and the others' never appear on each other's canvas.
   const galleryDesigns = savedDesigns.filter((design) =>
-    docType === 'certificate'
-      ? design.documentType === 'certificate'
-      : design.documentType !== 'certificate'
+    docType === 'certificate' || docType === 'work_order'
+      ? design.documentType === docType
+      : design.documentType !== 'certificate' && design.documentType !== 'work_order'
   )
   /** Open state and draft name for the save-design dialog. */
   const [namingDesign, setNamingDesign] = useState(false)
@@ -279,6 +290,9 @@ export function InvoiceDesigner({
   const printLabels = useMemo<PrintLabels>(() => {
     const pdf = messages.pdf ?? {}
     if (docType === 'certificate') return certificateLabels(pdf)
+    if (docType === 'work_order') {
+      return withOrgNumberLabel(workOrderLabels(pdf), workshop.orgNumberLabel)
+    }
     return withOrgNumberLabel(
       {
         ...(pdf.invoice ?? {}),
@@ -434,6 +448,7 @@ export function InvoiceDesigner({
         invoice: prev.invoice === `design:${design.id}` ? '' : prev.invoice,
         quote: prev.quote === `design:${design.id}` ? '' : prev.quote,
         certificate: prev.certificate === `design:${design.id}` ? '' : prev.certificate,
+        work_order: prev.work_order === `design:${design.id}` ? '' : prev.work_order,
       }))
       if (designName.trim().toLowerCase() === design.name.trim().toLowerCase()) {
         setDesignName('')
@@ -696,7 +711,9 @@ export function InvoiceDesigner({
           ? saveInvoiceLayoutConfig(stamped)
           : docType === 'quote'
             ? saveQuoteLayoutConfig(stamped)
-            : saveCertificateLayoutConfig(stamped),
+            : docType === 'certificate'
+              ? saveCertificateLayoutConfig(stamped)
+              : saveWorkOrderLayoutConfig(stamped),
         setSettings({
           [`${prefix}.primaryColor`]: template.primaryColor,
           [`${prefix}.backgroundColor`]: template.backgroundColor,
@@ -853,7 +870,7 @@ export function InvoiceDesigner({
         </button>
 
         <div className="flex gap-0.5 rounded-lg bg-[#f0f1f4] p-[3px]">
-          {(['invoice', 'quote', 'certificate'] as DocumentType[]).map((type) => (
+          {(['invoice', 'quote', 'work_order', 'certificate'] as DocumentType[]).map((type) => (
             <button
               key={type}
               type="button"

+ 8 - 5
src/features/invoice-designer/Components/PresetPreview.tsx

@@ -1,6 +1,7 @@
 'use client'
 
 import { certificateLabels } from '@/features/inspections/Lib/certificateLabels'
+import { workOrderLabels } from '../Lib/workOrderLabels'
 
 import { useMemo } from 'react'
 import { useMessages, useTranslations } from 'next-intl'
@@ -39,11 +40,13 @@ function useSampleData(docType: DocumentType, workshop?: PreviewWorkshop, logoUr
     const labels: PrintLabels =
       docType === 'certificate'
         ? certificateLabels(pdf)
-        : {
-            ...(pdf.invoice ?? {}),
-            ...(docType === 'quote' ? (pdf.quote ?? {}) : {}),
-            ...(pdf.common ?? {}),
-          }
+        : docType === 'work_order'
+          ? workOrderLabels(pdf)
+          : {
+              ...(pdf.invoice ?? {}),
+              ...(docType === 'quote' ? (pdf.quote ?? {}) : {}),
+              ...(pdf.common ?? {}),
+            }
     return buildSampleData(
       {
         name: workshop?.name ?? '',

+ 70 - 0
src/features/invoice-designer/Components/sample.ts

@@ -266,6 +266,7 @@ export function buildSampleData(
       customerNumber: sample.customerNumber,
       date: sample.date,
       due: sample.due,
+      plate: 'AB 12345',
     },
     items: sample.items.map((item) => ({
       n: String(item.n),
@@ -352,6 +353,7 @@ export function buildSampleData(
       bank_account: L('paymentInformation', 'Payment Information'),
       general: L('customFieldsTitle', 'Additional Information'),
       findings: L('findings', 'Observations'),
+      job_details: L('jobDetails', 'Job details'),
       test_details: L('testDetails', 'Test details'),
       defects: L('deficiencies', 'Deficiencies found'),
       results_table: L('allResults', 'All results'),
@@ -374,8 +376,76 @@ export function buildSampleData(
             date: sample.date,
             dateCaption: L('signatureDate', 'Date'),
             image: workshop.signatureUrl || SAMPLE_SIGNATURE,
+            // The customer's line has a customer on every document but the
+            // certificate, so the switch for it has something to show.
+            customerName: values.customer_name,
+            customerCaption: L('customerSignature', 'Customer signature'),
           },
     ...(docType === 'certificate' ? sampleCertificate(t, labels, values, sample) : {}),
+    ...(docType === 'work_order' ? sampleWorkOrder(t, labels, values, sample) : {}),
+  }
+}
+
+/**
+ * A code that goes nowhere, drawn as a pattern, so the designer can place
+ * and size the block that the printed sheet fills with a real one.
+ */
+const SAMPLE_QR = `data:image/svg+xml;utf8,${encodeURIComponent(
+  '<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84" viewBox="0 0 21 21" shape-rendering="crispEdges"><rect width="21" height="21" fill="#fff"/><path fill="#111" d="M0 0h7v7H0zM14 0h7v7h-7zM0 14h7v7H0zM2 2h3v3H2zM16 2h3v3h-3zM2 16h3v3H2zM8 0h1v1H8zM10 0h2v1h-2zM8 2h2v1H8zM11 2h1v2h-1zM9 4h1v2H9zM11 5h2v1h-2zM0 8h1v1H0zM2 8h3v1H2zM6 8h2v1H6zM9 8h1v1H9zM12 8h2v1h-2zM15 8h1v1h-1zM18 8h3v1h-3zM1 10h2v1H1zM4 10h1v1H4zM7 10h3v1H7zM11 10h1v1h-1zM13 10h2v1h-2zM16 10h1v1h-1zM19 10h2v1h-2zM0 12h1v1H0zM3 12h2v1H3zM6 12h1v1H6zM8 12h3v1H8zM12 12h1v1h-1zM14 12h3v1h-3zM18 12h1v1h-1zM20 12h1v1h-1zM8 14h1v2H8zM10 14h2v1h-2zM13 14h1v1h-1zM15 14h1v1h-1zM17 14h2v1h-2zM20 14h1v1h-1zM9 16h2v1H9zM12 16h2v1h-2zM15 16h1v1h-1zM17 16h1v1h-1zM19 16h2v1h-2zM8 18h1v1H8zM10 18h1v1h-1zM12 18h1v1h-1zM14 18h3v1h-3zM18 18h1v1h-1zM20 18h1v1h-1zM9 20h3v1H9zM13 20h2v1h-2zM16 20h2v1h-2zM19 20h1v1h-1z"/></svg>'
+)}`
+
+/**
+ * The work order's own part of the sample: a job that is open, booked and
+ * with a technician, two things the customer came in with, the work as a
+ * checklist, and a code to scan.
+ */
+function sampleWorkOrder(
+  t: SampleT,
+  labels: PrintLabels,
+  values: Record<string, string>,
+  sample: SampleTables
+): Pick<DocumentData, 'fields' | 'meta' | 'workOrder' | 'payment' | 'portalUrl'> {
+  const L = (key: string, fallback: string) => labels[key] || fallback
+  return {
+    fields: {
+      ...values,
+      bank_account: '',
+      status: fillTemplate(L('status', 'Status: {status}'), {
+        status: L('statusInProgress', 'In progress'),
+      }),
+      technician: fillTemplate(L('technician', 'Technician: {tech}'), { tech: 'Jamie Lee' }),
+      scheduled: fillTemplate(L('scheduled', 'Scheduled: {date}'), {
+        date: `${sample.date} 08:30`,
+      }),
+      promised: fillTemplate(L('promised', 'Promised: {date}'), { date: `${sample.date} 16:00` }),
+      work_bay: fillTemplate(L('workBay', 'Bay: {bay}'), { bay: '2' }),
+      footer_note: `${t('sample.footerNote')} · ${fillTemplate(L('printedOn', 'Printed {date}'), {
+        date: sample.date,
+      })}`,
+    },
+    meta: {
+      title: L('title', 'WORK ORDER'),
+      number: '2026-0042',
+      customerNumber: sample.customerNumber,
+      date: sample.date,
+      plate: 'AB 12345',
+    },
+    // A job that is still open has no bank details and no portal link to
+    // print; its totals already end at the total, like a quote's.
+    payment: [],
+    portalUrl: undefined,
+    workOrder: {
+      concerns: [
+        { description: t('sample.concernNoise'), detail: t('sample.concernNoiseDetail') },
+        { description: t('sample.concernWarningLight') },
+      ],
+      description: { html: `<p>${t('sample.workRequested')}</p>` },
+      checklist: sample.items
+        .filter((item) => !item.sku)
+        .map((item) => ({ label: item.desc, detail: `${item.qty} ${item.unit}` }))
+        .concat([{ label: t('sample.checklistTestDrive'), detail: '' }]),
+      qr: { dataUri: SAMPLE_QR, label: L('scanToOpen', 'Scan to open this work order') },
+    },
   }
 }
 

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

@@ -5,9 +5,10 @@ import type { DesignAutoRule } from '../Lib/designRules'
 /**
  * The documents the designer draws. A certificate is what a completed
  * inspection prints: the same sheet machinery as an invoice, fed grades
- * instead of prices.
+ * instead of prices. A work order is the job while it is open: the sheet
+ * the customer signs and the technician takes off the board.
  */
-export type DocumentType = 'invoice' | 'quote' | 'certificate'
+export type DocumentType = 'invoice' | 'quote' | 'certificate' | 'work_order'
 
 /** The template settings the designer edits, as strings the way settings store them. */
 export interface DesignerTemplate {

+ 19 - 3
src/features/invoice-designer/Lib/designSource.ts

@@ -5,6 +5,7 @@ import {
   mergeWithDefaults,
   type InvoiceLayoutConfig,
 } from '@/features/settings/Schema/invoiceLayoutSchema'
+import { WORK_ORDER_TEMPLATE_DEFAULTS } from '@/features/settings/Schema/invoiceLayoutSchema'
 import type { TemplateConfig } from '@/features/vehicles/Components/invoice-pdf/types'
 import type { DesignerTemplate, DocumentType, SavedDesign } from '../Components/types'
 import { isDesignAutoRule } from './designRules'
@@ -50,7 +51,12 @@ export const designTemplateSchema = z
   })
   .passthrough()
 
-export const DESIGN_DOCUMENT_TYPES: DocumentType[] = ['invoice', 'quote', 'certificate']
+export const DESIGN_DOCUMENT_TYPES: DocumentType[] = [
+  'invoice',
+  'quote',
+  'certificate',
+  'work_order',
+]
 
 /** A stored layout and template read back as they were written, or null. */
 function parseStoredSource(layout: unknown, template: unknown): DesignSource | null {
@@ -117,11 +123,19 @@ export function designSourceFromSettings(
   documentType: DocumentType
 ): DesignSource {
   const p = documentType
+  // A work order starts black on white with a compact letterhead: a sheet
+  // printed all day long, not one sent to a customer.
+  const defaults =
+    documentType === 'work_order'
+      ? WORK_ORDER_TEMPLATE_DEFAULTS
+      : { primaryColor: '#d97706', headerStyle: 'standard' }
   return {
     layout: parseLayoutSetting(settings[`${p}.layoutConfig`]),
     template: {
       primaryColor:
-        settings[`${p}.primaryColor`] || settings[`${p}.template.primaryColor`] || '#d97706',
+        settings[`${p}.primaryColor`] ||
+        settings[`${p}.template.primaryColor`] ||
+        defaults.primaryColor,
       backgroundColor: settings[`${p}.backgroundColor`] || '',
       textColor: settings[`${p}.textColor`] || '',
       companyTextColor: settings[`${p}.companyTextColor`] || '',
@@ -132,7 +146,9 @@ export function designSourceFromSettings(
       fontFamily:
         settings[`${p}.fontFamily`] || settings[`${p}.template.fontFamily`] || 'Helvetica',
       headerStyle:
-        settings[`${p}.headerStyle`] || settings[`${p}.template.headerStyle`] || 'standard',
+        settings[`${p}.headerStyle`] ||
+        settings[`${p}.template.headerStyle`] ||
+        defaults.headerStyle,
       logoSize: Number(settings[`${p}.logoSize`]) || 100,
       logoUrl: settings[`${p}.logo`] || '',
       showLogo: (settings[`${p}.showLogo`] ?? settings[`${p}.template.showLogo`]) !== 'false',

+ 4 - 2
src/features/invoice-designer/Lib/documentLogo.ts

@@ -14,13 +14,15 @@ import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
  */
 export function documentLogoPath(
   settings: Record<string, string | undefined | null>,
-  documentType: 'invoice' | 'quote' | 'certificate'
+  documentType: 'invoice' | 'quote' | 'certificate' | 'work_order'
 ): string {
   const own =
     documentType === 'quote'
       ? settings[SETTING_KEYS.QUOTE_LOGO]
       : documentType === 'certificate'
         ? settings[SETTING_KEYS.CERTIFICATE_LOGO]
-        : settings[SETTING_KEYS.INVOICE_LOGO]
+        : documentType === 'work_order'
+          ? settings[SETTING_KEYS.WORK_ORDER_LOGO]
+          : settings[SETTING_KEYS.INVOICE_LOGO]
   return own?.trim() || settings[SETTING_KEYS.COMPANY_LOGO]?.trim() || ''
 }

+ 34 - 0
src/features/invoice-designer/Lib/workOrderLabels.ts

@@ -0,0 +1,34 @@
+/**
+ * The words a work order prints, as the document builder expects them.
+ *
+ * The builder is the invoice's, so its title strip asks for
+ * `invoiceNumberLabel` and its customer panel for `billTo`. A work order
+ * answers those with its own words, the way the certificate does, and adds
+ * the vocabulary of its own sections: the job details, the concerns, the
+ * checklist, the customer's signature line. Used by the print path and the
+ * designer's preview alike, so both say the same thing.
+ */
+
+type Labels = Record<string, string>
+
+/** The work order's wording over the invoice's, and the strip's captions renamed. */
+export function withWorkOrderLabels(base: Labels, workOrder: Labels | undefined): Labels {
+  const wo = workOrder ?? {}
+  return {
+    ...base,
+    ...wo,
+    title: wo.title || 'WORK ORDER',
+    invoiceNumberLabel: wo.orderNumberLabel || 'Work order No.',
+    billTo: wo.customer || base.billTo || 'Customer',
+  }
+}
+
+/** The labels a designed work order prints, from the whole `pdf.json`. */
+export function workOrderLabels(
+  pdfMessages: Record<string, Record<string, string> | undefined>
+): Labels {
+  return withWorkOrderLabels(
+    { ...(pdfMessages.invoice ?? {}), ...(pdfMessages.common ?? {}) },
+    pdfMessages.workOrder
+  )
+}

+ 439 - 0
src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts

@@ -0,0 +1,439 @@
+import {
+  DEFAULT_DATE_FORMAT,
+  DEFAULT_TIME_FORMAT,
+  formatCurrency,
+  formatDateForPdf,
+  formatDateTime,
+} from '@/lib/format'
+import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
+import { formatQuantity } from '@/lib/format-quantity'
+import { calculateTotals, netLineTotal } from '@/lib/tax'
+import { parseTaxComponents } from '@/lib/tax-components'
+import { taxLines } from './taxLines'
+import {
+  getDefaultLayout,
+  isCustomFieldId,
+  mergeWithDefaults,
+  toCustomFieldId,
+  type InvoiceLayoutConfig,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import { formatFieldValue } from '@/features/vehicles/Components/invoice-pdf/CustomFields'
+import { BASE_FONT_SIZE } from '@/features/vehicles/Components/invoice-pdf/styles'
+import type {
+  InvoiceData,
+  InvoiceSettingsProps,
+  TemplateConfig,
+  WorkshopInfo,
+} from '@/features/vehicles/Components/invoice-pdf/types'
+import {
+  buildDocumentSpec,
+  frameShadowWidth,
+  mixColors,
+  type DocumentData,
+  type DocumentTheme,
+  type TotalLine,
+} from '../Spec/buildSpec'
+import type { DocumentSpec } from '../Spec/documentSpec'
+import { warrantyForPrint } from './warrantyPrint'
+
+/**
+ * A job as the sheet the customer signs and the technician works from.
+ *
+ * The same record the invoice prints, read while the job is still open: the
+ * customer and the car, what they came in with, the work and the parts with
+ * their prices, and the totals they are agreeing to, with a line for their
+ * pen at the bottom. Nothing of the bill (payments, bank details, a due
+ * date) is here, because none of it exists yet. The sheet is never frozen:
+ * every print says the job as it stands and the date it was printed.
+ */
+
+/** What the work order carries beyond what the invoice's data already says. */
+export interface WorkOrderJob {
+  /** The job's number as the strip prints it. */
+  orderNumber: string
+  /** The status in the reader's words: the workshop's own name for it, or the stage. */
+  statusLabel: string
+  workBay?: string | null
+  promisedAt?: Date | string | null
+  /** What the customer came in with, in the order they said it. */
+  concerns: { description: string; correction?: string | null }[]
+  /** A code that opens this job on a phone, when the layout prints one. */
+  qrDataUri?: string
+  /** When the sheet was printed, which the footer and the signature date say. */
+  printedAt: Date
+}
+
+export interface WorkOrderPrintInput {
+  data: InvoiceData
+  job: WorkOrderJob
+  workshop?: WorkshopInfo
+  invoiceSettings?: InvoiceSettingsProps
+  logoDataUri?: string
+  /** Who signs for the workshop: whoever opened the job, with their saved signature. */
+  signer?: { name: string; dataUri?: string }
+  template?: TemplateConfig
+  torqvoiceLogoDataUri?: string
+  labels?: Record<string, string>
+}
+
+function fillTemplate(template: string, values: Record<string, string>): string {
+  return Object.entries(values).reduce((str, [key, val]) => str.replace(`{${key}}`, val), template)
+}
+
+/** A plain note as the rich-text block expects it: paragraphs, nothing else. */
+export function plainTextHtml(text: string | null | undefined): string | undefined {
+  const trimmed = text?.trim()
+  if (!trimmed) return undefined
+  const escapeText = (value: string) =>
+    value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+  return trimmed
+    .split(/\n{2,}/)
+    .map((paragraph) => `<p>${escapeText(paragraph).replace(/\n/g, '<br/>')}</p>`)
+    .join('')
+}
+
+/**
+ * The layout the print follows: the saved work order design filled out with
+ * the work order's defaults, and any custom field no section claims put in
+ * the extras panel rather than lost.
+ */
+function resolveLayout(input: WorkOrderPrintInput): InvoiceLayoutConfig {
+  const saved = input.template?.layoutConfig
+  const layout = saved
+    ? mergeWithDefaults({ ...saved, documentType: 'work_order' })
+    : getDefaultLayout('work_order')
+
+  let sections = layout.sections
+  const assigned = new Set(
+    sections.flatMap((s) => (s.fields ?? []).filter((f) => isCustomFieldId(f.id)).map((f) => f.id))
+  )
+  const unassigned = (input.data.customFields ?? [])
+    .map((cf) => toCustomFieldId(cf.fieldId))
+    .filter((id) => !assigned.has(id))
+  if (unassigned.length) {
+    sections = sections.map((section) =>
+      section.id === 'general'
+        ? {
+            ...section,
+            fields: [...(section.fields ?? []), ...unassigned.map((id) => ({ id, visible: true }))],
+          }
+        : section
+    )
+  }
+  return { ...layout, sections }
+}
+
+/** Whether the layout prints the code that opens the job on a phone. */
+export function workOrderQrWanted(layout: Pick<InvoiceLayoutConfig, 'sections'>): boolean {
+  return layout.sections.some((s) => s.id === 'job_qr' && s.visible)
+}
+
+export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpec {
+  const { workshop, invoiceSettings, template, job } = input
+  const data = { ...input.data, laborItems: documentLaborLines(input.data.laborItems) }
+  const labels = input.labels ?? {}
+  const L = (key: string, fallback: string) => labels[key] || fallback
+
+  const layout = resolveLayout(input)
+  const doc = layout.document ?? {}
+
+  const cc = invoiceSettings?.currencyCode || 'USD'
+  const cf: 'symbol' | 'code' = invoiceSettings?.currencyFormat === 'code' ? 'code' : 'symbol'
+  const money = (value: number) => formatCurrency(value, cc, cf)
+  const taxRate = data.taxRate
+  const taxInclusive = data.taxInclusive ?? false
+  const net = (value: number) => netLineTotal(value, taxRate, taxInclusive)
+  const linesInclTax = invoiceSettings?.lineItemsInclTax === true && taxRate > 0
+  const gross = (value: number) => (taxInclusive ? value : value * (1 + taxRate / 100))
+  const shown = linesInclTax ? gross : net
+
+  const partsSubtotal = data.partItems.reduce((sum, p) => sum + p.total, 0)
+  const laborSubtotal = data.laborItems.reduce((sum, l) => sum + l.total, 0)
+  const computedSubtotal = partsSubtotal + laborSubtotal
+  const computedDiscount =
+    data.discountType === 'percentage'
+      ? computedSubtotal * ((data.discountValue || 0) / 100)
+      : data.discountType === 'fixed'
+        ? Math.min(data.discountValue || 0, computedSubtotal)
+        : 0
+  const { totalAmount: computedTotal } = calculateTotals({
+    subtotal: computedSubtotal,
+    discountAmount: computedDiscount,
+    taxRate,
+    taxInclusive,
+  })
+  const displayTotal =
+    data.totalAmount > 0 ? data.totalAmount : computedTotal > 0 ? computedTotal : data.cost
+
+  const df = invoiceSettings?.dateFormat || DEFAULT_DATE_FORMAT
+  const tz = invoiceSettings?.timezone || undefined
+  const day = (value: Date | string) => formatDateForPdf(value, df, tz)
+  const timeFormat = invoiceSettings?.timeFormat === '24h' ? '24h' : DEFAULT_TIME_FORMAT
+  const moment = (value: Date | string) => formatDateTime(value, df, timeFormat, tz)
+  const openedOn = day(data.startDateTime ?? data.serviceDate)
+  const printedOn = day(job.printedAt)
+
+  const shopDisplayName = workshop?.name || data.shopName || 'Torqvoice'
+  const customer = data.customer ?? data.vehicle?.customer
+  const vehicleName = data.vehicle
+    ? `${data.vehicle.year} ${data.vehicle.make} ${data.vehicle.model}`
+    : ''
+  const tech = data.technician?.name || data.techName || ''
+  const unit = invoiceSettings?.unitSystem === 'metric' ? L('km', 'km') : L('mi', 'mi')
+
+  const fields: Record<string, string> = {
+    customer_name: customer?.name || '',
+    customer_company: customer?.company || '',
+    customer_address: customer?.address || '',
+    customer_email: customer?.email || '',
+    customer_phone: customer?.phone || '',
+    customer_tax_id: customer?.taxId ? `${L('customerTaxId', 'Tax ID')}: ${customer.taxId}` : '',
+    vehicle_name: vehicleName,
+    vin: data.vehicle?.vin ? fillTemplate(L('vin', 'VIN: {vin}'), { vin: data.vehicle.vin }) : '',
+    license_plate: data.vehicle?.licensePlate
+      ? fillTemplate(L('plate', 'Plate: {plate}'), { plate: data.vehicle.licensePlate })
+      : '',
+    mileage: data.mileage
+      ? `${fillTemplate(L('mileage', 'Mileage: {mileage}'), {
+          mileage: data.mileage.toLocaleString(),
+        })} ${unit}`
+      : '',
+    service_title: data.title || '',
+    service_type: data.vehicle ? fillTemplate(L('type', 'Type: {type}'), { type: data.type }) : '',
+    tech_name: tech ? fillTemplate(L('tech', 'Tech: {tech}'), { tech }) : '',
+    // The facts of the job, each already worded as its own line.
+    status: job.statusLabel
+      ? fillTemplate(L('status', 'Status: {status}'), { status: job.statusLabel })
+      : '',
+    technician: tech ? fillTemplate(L('technician', 'Technician: {tech}'), { tech }) : '',
+    scheduled: data.startDateTime
+      ? fillTemplate(L('scheduled', 'Scheduled: {date}'), { date: moment(data.startDateTime) })
+      : '',
+    promised: job.promisedAt
+      ? fillTemplate(L('promised', 'Promised: {date}'), { date: moment(job.promisedAt) })
+      : '',
+    work_bay: job.workBay ? fillTemplate(L('workBay', 'Bay: {bay}'), { bay: job.workBay }) : '',
+    company_name: shopDisplayName,
+    company_slogan: workshop?.slogan?.trim() || '',
+    company_address: workshop?.address || '',
+    company_phone: workshop?.phone
+      ? fillTemplate(L('tel', 'Tel: {phone}'), { phone: workshop.phone })
+      : '',
+    company_email: workshop?.email || '',
+    company_org_number:
+      invoiceSettings?.showOrgNumber && invoiceSettings?.orgNumber
+        ? fillTemplate(L('org', 'Org: {org}'), { org: invoiceSettings.orgNumber })
+        : '',
+    bank_account: '',
+    // Every print says when it was taken, because the job keeps moving.
+    footer_note: `${invoiceSettings?.footerNote || shopDisplayName} · ${fillTemplate(
+      L('printedOn', 'Printed {date}'),
+      { date: printedOn }
+    )}`,
+  }
+  for (const cfEntry of data.customFields ?? []) {
+    if (cfEntry.value === '' || cfEntry.value == null) continue
+    fields[toCustomFieldId(cfEntry.fieldId)] =
+      `${cfEntry.label}: ${formatFieldValue(cfEntry.value, cfEntry.fieldType)}`
+  }
+
+  const items: DocumentData['items'] = [
+    ...data.laborItems.map((l, i) => ({
+      n: String(i + 1),
+      qty: String(l.hours),
+      unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
+      desc: l.description,
+      price: money(shown(l.rate)),
+      total: money(shown(l.total)),
+    })),
+    ...data.partItems.map((p, i) => ({
+      n: String(data.laborItems.length + i + 1),
+      qty: formatQuantity(p.quantity, p.unit),
+      unit: p.unit || '',
+      desc: p.name,
+      sub: p.partNumber || undefined,
+      price: money(shown(p.unitPrice)),
+      total: money(shown(p.total)),
+    })),
+  ]
+  const parts: DocumentData['parts'] = data.partItems.map((p) => ({
+    ref: p.partNumber || '-',
+    desc: p.name,
+    qty: formatQuantity(p.quantity, p.unit),
+    price: money(shown(p.unitPrice)),
+    total: money(shown(p.total)),
+  }))
+  const labor: DocumentData['labor'] = data.laborItems.map((l) => {
+    const isService = l.pricingType === 'service' || isShopFeeLine(l)
+    return {
+      desc: l.description,
+      qty: isService ? `${l.hours} ${L('unit', 'unit')}` : `${l.hours} ${L('hrs', 'hrs')}`,
+      rate: isService
+        ? money(shown(l.rate))
+        : fillTemplate(L('ratePerHour', '{rate}/hr'), { rate: money(shown(l.rate)) }),
+      total: money(shown(l.total)),
+    }
+  })
+
+  // The board copy's list: the work without its prices, a fee left out
+  // because nobody ticks off a fee.
+  const checklist = data.laborItems
+    .filter((l) => !isShopFeeLine(l))
+    .map((l) => ({
+      label: l.description,
+      detail:
+        l.pricingType === 'service'
+          ? undefined
+          : l.hours
+            ? `${l.hours} ${L('hrs', 'hrs')}`
+            : undefined,
+    }))
+
+  const severityLabels: Record<string, string> = {
+    urgent: L('findingSeverityUrgent', 'Urgent'),
+    needs_work: L('findingSeverityNeedsWork', 'Needs Work'),
+    monitor: L('findingSeverityMonitor', 'Monitor'),
+  }
+  const severityColors: Record<string, string> = {
+    urgent: '#ef4444',
+    needs_work: '#f59e0b',
+    monitor: '#3b82f6',
+  }
+  const findings: DocumentData['findings'] = (data.findings ?? []).map((f) => ({
+    severity: severityLabels[f.severity] || f.severity,
+    color: severityColors[f.severity] || '#666666',
+    description: f.description,
+    notes: f.notes || '',
+  }))
+
+  // The same ladder the invoice prints, without the payments: nothing has
+  // been paid on a job that is still open.
+  const itemsTableVisible = layout.sections.some((s) => s.id === 'items_table' && s.visible)
+  const totals: TotalLine[] = []
+  if (!itemsTableVisible && data.partItems.length > 0) {
+    totals.push({ label: L('parts', 'Parts'), value: money(shown(partsSubtotal)), kind: 'line' })
+  }
+  if (!itemsTableVisible && data.laborItems.length > 0) {
+    totals.push({ label: L('labor', 'Labor'), value: money(shown(laborSubtotal)), kind: 'line' })
+  }
+  const displaySubtotal = shown(data.subtotal)
+  if (displaySubtotal > 0) {
+    totals.push({
+      label: linesInclTax
+        ? L('subtotalInclTax', 'Subtotal (incl. tax)')
+        : L('subtotal', 'Subtotal'),
+      value: money(displaySubtotal),
+      kind: 'line',
+    })
+  }
+  const displayDiscount = shown(data.discountAmount ?? 0)
+  if (displayDiscount > 0) {
+    totals.push({
+      label:
+        data.discountType === 'percentage'
+          ? fillTemplate(L('discountPercent', 'Discount ({percent}%)'), {
+              percent: String(data.discountValue),
+            })
+          : L('discount', 'Discount'),
+      value: money(-displayDiscount),
+      kind: 'discount',
+    })
+  }
+  totals.push(
+    ...taxLines({
+      taxRate,
+      taxAmount: data.taxAmount,
+      components: parseTaxComponents(data.taxComponents),
+      linesInclTax,
+      labels,
+      money,
+    })
+  )
+  totals.push({ label: L('total', 'Total'), value: money(displayTotal), kind: 'total' })
+
+  const documentData: DocumentData = {
+    fields,
+    logoUrl: input.logoDataUri,
+    labels,
+    meta: {
+      title: L('title', 'WORK ORDER'),
+      number: job.orderNumber,
+      customerNumber: customer?.customerNumber ?? undefined,
+      date: openedOn,
+      plate: data.vehicle?.licensePlate ?? undefined,
+    },
+    items,
+    parts,
+    labor,
+    findings,
+    totals,
+    notes: { html: data.invoiceNotes ?? undefined },
+    warranty: warrantyForPrint(data, {
+      labels,
+      unitSystem: invoiceSettings?.unitSystem,
+      expires: data.warrantyExpiresAt ? day(data.warrantyExpiresAt) : undefined,
+    }),
+    payment: [],
+    branding: input.torqvoiceLogoDataUri ? { logoDataUri: input.torqvoiceLogoDataUri } : undefined,
+    sectionLabels: {
+      customer: L('customer', 'Customer'),
+      vehicle: L('vehicle', 'Vehicle'),
+      service: L('service', 'Service'),
+      job_details: L('jobDetails', 'Job details'),
+      general: L('customFieldsTitle', 'Additional Information'),
+      findings: L('findings', 'Findings'),
+    },
+    workOrder: {
+      concerns: job.concerns.map((concern) => ({
+        description: concern.description,
+        detail: concern.correction?.trim() || undefined,
+      })),
+      description: { html: plainTextHtml(data.description) },
+      checklist,
+      qr: job.qrDataUri
+        ? { dataUri: job.qrDataUri, label: L('scanToOpen', 'Scan to open this work order') }
+        : undefined,
+    },
+    signature: {
+      heading: L('signature', 'Signature'),
+      name: input.signer?.name ?? '',
+      nameCaption: L('signedBy', 'Signed by'),
+      date: printedOn,
+      dateCaption: L('signatureDate', 'Date'),
+      image: input.signer?.dataUri,
+      customerName: customer?.name ?? '',
+      customerCaption: L('customerSignature', 'Customer signature'),
+    },
+  }
+
+  const primary = template?.primaryColor || '#d97706'
+  const headerStyle = template?.headerStyle || 'standard'
+  const banded = headerStyle === 'framed' || headerStyle === 'modern'
+  const text = template?.textColor || '#111827'
+  const background = template?.backgroundColor || '#ffffff'
+
+  const theme: DocumentTheme = {
+    primary,
+    background,
+    text,
+    muted: template?.textColor ? mixColors(text, background, 0.42) : '#6b7280',
+    accent: doc.accentColor || primary,
+    companyText: template?.companyTextColor || (banded ? '#ffffff' : primary),
+    fontFamily: doc.fontFamily || template?.fontFamily || 'Helvetica',
+    fontSize: doc.fontSize ?? BASE_FONT_SIZE,
+    margin: doc.margin ?? 40,
+    rowPadding: doc.rowPadding ?? 5,
+    stripes: doc.stripes !== false,
+    stripeColor: doc.stripeColor || mixColors(background, text, 0.045),
+    headerStyle,
+    frameSide: template?.frameSide === 'right' ? 'right' : 'left',
+    frameBorderColor: template?.frameBorderColor || undefined,
+    frameShadow: frameShadowWidth(template?.frameShadow),
+    frameRadius: template?.frameRadius ?? 0,
+    logoSize: template?.logoSize ?? 100,
+    // A work order never printed through the classic renderer.
+    classic: false,
+  }
+
+  return buildDocumentSpec(layout, theme, documentData)
+}

+ 10 - 3
src/features/invoice-designer/Pdf/printLabels.ts

@@ -6,9 +6,13 @@
 
 import { withOrgNumberLabel } from '../Lib/labelOverrides'
 import { isMarineWorkshop, type PdfMessages, withMarineDocumentLabels } from '../Lib/marineLabels'
+import { withWorkOrderLabels } from '../Lib/workOrderLabels'
 
-/** Which document's wording wins where the invoice and the quote differ. */
-export type PrintDocumentType = 'invoice' | 'quote'
+/**
+ * Which document's wording wins where the invoice and the quote differ. A
+ * work order takes the invoice's vocabulary and lays its own on top.
+ */
+export type PrintDocumentType = 'invoice' | 'quote' | 'work_order'
 
 async function loadPdfMessages(locale: string): Promise<PdfMessages> {
   try {
@@ -36,7 +40,10 @@ export async function loadPrintLabels(
   }
 
   if (isMarineWorkshop(settingsMap)) {
-    labels = withMarineDocumentLabels(labels, pdfMessages, documentType)
+    labels = withMarineDocumentLabels(labels, pdfMessages, quote ? 'quote' : 'invoice')
+  }
+  if (documentType === 'work_order') {
+    labels = withWorkOrderLabels(labels, pdfMessages.workOrder)
   }
 
   const customTaxLabel = settingsMap['workshop.taxLabel']?.trim()

+ 264 - 5
src/features/invoice-designer/Spec/buildSpec.ts

@@ -57,6 +57,8 @@ export interface DocumentData {
     customerNumber?: string
     date: string
     due?: string
+    /** The plate, for the strip's own cell of it. */
+    plate?: string
   }
   items: {
     n: string
@@ -101,6 +103,8 @@ export interface DocumentData {
   sectionLabels: Record<string, string>
   /** What a certificate prints beyond the shared fields; absent on any other document. */
   certificate?: CertificateData
+  /** What a work order prints beyond the shared fields; absent on any other document. */
+  workOrder?: WorkOrderData
   /**
    * Who signs the sheet and when, already worded. Absent means the document
    * has no Signature section to fill, and the section draws nothing.
@@ -119,6 +123,28 @@ export interface DocumentSignature {
   dateCaption: string
   /** The signer's saved signature as a PNG or JPEG data URI. */
   image?: string
+  /**
+   * The customer's line: who signs it and what it is called. Absent means the
+   * document has nobody on the other side of the counter, and the customer
+   * line switch draws nothing.
+   */
+  customerName?: string
+  customerCaption?: string
+}
+
+/**
+ * The work order's own part of the sheet. Everything is already worded, the
+ * way the rest of the document is, so the blocks only draw.
+ */
+export interface WorkOrderData {
+  /** What the customer came in with, in the order they said it. */
+  concerns: { description: string; detail?: string }[]
+  /** The job's description, as paragraphs. */
+  description?: { html?: string }
+  /** The work to do, one box each; the detail is hours or a part count. */
+  checklist: { label: string; detail?: string }[]
+  /** A code that opens the job on a phone, with the line under it. */
+  qr?: { dataUri: string; label: string }
 }
 
 export interface DocumentTheme {
@@ -856,6 +882,8 @@ function documentTitle(
         return [label(data, 'dateLabel', 'Date'), data.meta.date]
       case 'due_date':
         return data.meta.due ? [label(data, 'dueDateLabel', 'Due'), data.meta.due] : null
+      case 'license_plate':
+        return data.meta.plate ? [label(data, 'plateLabel', 'Plate'), data.meta.plate] : null
       default:
         return null
     }
@@ -1623,12 +1651,14 @@ function paymentBlock(
   }
 }
 
-function telegramBlock(
+/** A code with its line under it: the Telegram chat, or the job on a phone. */
+function qrBlock(
   section: InvoiceSection,
   theme: DocumentTheme,
-  data: DocumentData
+  qr: { dataUri: string; label: string } | undefined,
+  side: number
 ): Node | null {
-  if (!data.telegramQr) return null
+  if (!qr) return null
   const look = lookOf(section, theme)
   const size = look.fontSize ?? theme.fontSize
   return {
@@ -1640,16 +1670,229 @@ function telegramBlock(
         ? { background: look.fill || '#fafafa', radius: 6, padding: look.padding ?? 10 }
         : { background: look.fill, padding: look.padding ?? 10 },
     children: [
-      { kind: 'image', src: data.telegramQr.dataUri, maxWidth: 56, maxHeight: 56, align: 'center' },
+      { kind: 'image', src: qr.dataUri, maxWidth: side, maxHeight: side, align: 'center' },
       {
         kind: 'text',
-        text: data.telegramQr.label,
+        text: qr.label,
         style: { color: look.muted, fontSize: scale(size, 0.72), align: 'center' },
       },
     ],
   }
 }
 
+function telegramBlock(
+  section: InvoiceSection,
+  theme: DocumentTheme,
+  data: DocumentData
+): Node | null {
+  return qrBlock(section, theme, data.telegramQr, 56)
+}
+
+/**
+ * The code that opens this job on a phone. Larger than the Telegram code,
+ * because it is scanned off a board from arm's length, not off a letter.
+ */
+function jobQrBlock(
+  section: InvoiceSection,
+  theme: DocumentTheme,
+  data: DocumentData
+): Node | null {
+  return qrBlock(section, theme, data.workOrder?.qr, 84)
+}
+
+/** The panel dress the notes wear, for the work order's prose blocks. */
+function proseBlock(
+  section: InvoiceSection,
+  theme: DocumentTheme,
+  heading: string,
+  html: string | undefined
+): Node | null {
+  const look = lookOf(section, theme)
+  const size = look.fontSize ?? theme.fontSize
+  if (!html || html.replace(/<[^>]*>/g, '').trim().length === 0) return null
+  const children: Node[] = []
+  if (section.heading !== false) {
+    children.push({
+      kind: 'text',
+      text: heading,
+      style: { color: look.label, fontSize: scale(size, 0.72), bold: true, uppercase: true },
+    })
+  }
+  children.push({
+    kind: 'richtext',
+    html,
+    style: { color: look.text, fontSize: scale(size, 0.92) },
+  })
+  const boxed = section.boxed !== false
+  return {
+    kind: 'stack',
+    id: section.id,
+    gap: 3,
+    style: {
+      background: look.fill || (boxed ? '#f3f4f6' : undefined),
+      borderColor: look.border,
+      borderWidth: look.border ? (look.ruleWidth ?? 0.75) : 0,
+      radius: boxed ? 3 : 0,
+      padding: look.padding ?? 10,
+    },
+    children,
+  }
+}
+
+/** What the customer came in with, numbered in the order they said it. */
+function concernsBlock(
+  section: InvoiceSection,
+  theme: DocumentTheme,
+  data: DocumentData
+): Node | null {
+  const concerns = data.workOrder?.concerns ?? []
+  if (!concerns.length) return null
+  const look = lookOf(section, theme)
+  const size = look.fontSize ?? theme.fontSize
+  const children: Node[] = []
+  if (section.heading !== false) {
+    children.push({
+      kind: 'text',
+      text: label(data, 'concerns', 'Customer concerns'),
+      style: { color: look.label, fontSize: scale(size, 0.72), bold: true, uppercase: true },
+    })
+  }
+  concerns.forEach((concern, i) => {
+    children.push({
+      kind: 'row',
+      gap: 6,
+      align: 'start',
+      children: [
+        {
+          width: 14,
+          node: {
+            kind: 'text',
+            text: `${i + 1}.`,
+            style: { color: look.muted, fontSize: scale(size, 0.92), bold: true },
+          },
+        },
+        {
+          width: 'flex',
+          node: {
+            kind: 'stack',
+            gap: 1,
+            children: [
+              {
+                kind: 'text',
+                text: concern.description,
+                style: { color: look.text, fontSize: scale(size, 0.92) },
+              },
+              ...(concern.detail
+                ? [
+                    {
+                      kind: 'text' as const,
+                      text: concern.detail,
+                      style: { color: look.muted, fontSize: scale(size, 0.82) },
+                    },
+                  ]
+                : []),
+            ],
+          },
+        },
+      ],
+    })
+  })
+  const boxed = section.boxed !== false
+  return {
+    kind: 'stack',
+    id: section.id,
+    gap: 4,
+    style: {
+      background: look.fill || (boxed ? '#f3f4f6' : undefined),
+      borderColor: look.border,
+      borderWidth: look.border ? (look.ruleWidth ?? 0.75) : 0,
+      radius: boxed ? 3 : 0,
+      padding: look.padding ?? 10,
+    },
+    children,
+  }
+}
+
+/** The side of a tick box, in points. */
+const TICK_BOX = 9
+
+/**
+ * The work, one empty box per line, for the copy that goes on the board: a
+ * technician ticks each job off with a pen. Hours or counts sit at the
+ * right; prices are the tables' business, not this one's.
+ */
+function checklistBlock(
+  section: InvoiceSection,
+  theme: DocumentTheme,
+  data: DocumentData
+): Node | null {
+  const rows = data.workOrder?.checklist ?? []
+  if (!rows.length) return null
+  const look = lookOf(section, theme)
+  const size = look.fontSize ?? theme.fontSize
+  const ink = look.border || look.text
+  const children: Node[] = []
+  if (section.heading !== false) {
+    children.push({
+      kind: 'text',
+      text: label(data, 'checklist', 'Work'),
+      style: theme.classic
+        ? { color: look.text, bold: true, fontSize: scale(size, 1.2) }
+        : { color: look.label, bold: true, fontSize: scale(size, 1.05) },
+    })
+  }
+  for (const row of rows) {
+    children.push({
+      kind: 'row',
+      gap: 7,
+      align: 'center',
+      children: [
+        {
+          width: TICK_BOX,
+          node: {
+            kind: 'stack',
+            style: { borderColor: ink, borderWidth: 0.9, radius: 1.5 },
+            children: [{ kind: 'spacer', height: TICK_BOX - 1.8 }],
+          },
+        },
+        {
+          width: 'flex',
+          node: { kind: 'text', text: row.label, style: { color: look.text, fontSize: size } },
+        },
+        ...(row.detail
+          ? [
+              {
+                width: 70,
+                node: {
+                  kind: 'text' as const,
+                  text: row.detail,
+                  style: { color: look.muted, fontSize: scale(size, 0.9), align: 'right' as const },
+                },
+              },
+            ]
+          : []),
+      ],
+    })
+    children.push({ kind: 'spacer', height: 0.5, color: look.border || '#eceef1' })
+  }
+  const boxed = section.boxed !== false
+  return {
+    kind: 'stack',
+    id: section.id,
+    gap: theme.rowPadding,
+    style: boxed
+      ? {
+          background: look.fill,
+          borderColor: look.border || '#e3e5e9',
+          borderWidth: look.ruleWidth ?? 0.75,
+          radius: 3,
+          padding: look.padding ?? 10,
+        }
+      : { background: look.fill, padding: look.padding ?? 0 },
+    children,
+  }
+}
+
 function footer(section: InvoiceSection, theme: DocumentTheme, data: DocumentData): Node {
   const look = lookOf(section, theme)
   const size = look.fontSize ?? theme.fontSize
@@ -1810,6 +2053,22 @@ function blockFor(section: InvoiceSection, theme: DocumentTheme, data: DocumentD
       return telegramBlock(section, theme, data)
     case 'footer':
       return footer(section, theme, data)
+    // The work order's own sections.
+    case 'job_details':
+      return panel(section, theme, data, sectionFields(section))
+    case 'job_qr':
+      return jobQrBlock(section, theme, data)
+    case 'concerns':
+      return concernsBlock(section, theme, data)
+    case 'job_description':
+      return proseBlock(
+        section,
+        theme,
+        label(data, 'workRequested', 'Work requested'),
+        data.workOrder?.description?.html
+      )
+    case 'work_checklist':
+      return checklistBlock(section, theme, data)
     // The certificate's own sections; see certificateBlocks.ts.
     case 'result':
       return resultBlock(section, theme, data)

+ 30 - 4
src/features/invoice-designer/Spec/signatureBlock.ts

@@ -10,6 +10,9 @@ const SIGNATURE_HEIGHT = 44
 /**
  * Whoever issued the document signs it: a line with their signature drawn on
  * it when they have saved one, their name under it, and the date beside.
+ * Beside those, when the design asks, an empty line for the customer, with
+ * their name under it and a date of its own: the work order a customer
+ * signs at the counter, or a quote they accept on paper.
  *
  * Every piece is a switch, because paper differs: a certificate is often
  * signed by hand, so the line prints empty; an invoice may carry the image
@@ -27,7 +30,9 @@ export function signatureBlock(
   const withImage = fields.has('signature_image') && Boolean(signature.image)
   const withLine = fields.has('inspector_line')
   const withDate = fields.has('date_line')
-  if (!withLine && !withImage && !withDate) return null
+  // A customer line needs a customer side: a certificate has none.
+  const withCustomer = fields.has('customer_line') && signature.customerCaption !== undefined
+  if (!withLine && !withImage && !withDate && !withCustomer) return null
   const look = lookOf(section, theme)
   const size = look.fontSize ?? theme.fontSize
   const boxed = section.boxed === true
@@ -51,10 +56,13 @@ export function signatureBlock(
       ...(withLine || !opts.image
         ? [{ kind: 'spacer' as const, height: 0.75, color: look.border || look.text }]
         : []),
-      // An empty value still takes its line, so the two columns stay level.
+      // An empty value still takes its line, so the columns stay level. A
+      // plain space collapses to nothing on the designer's canvas and the
+      // column's rule then sinks below its neighbours'; a non-breaking one
+      // keeps the line's height in both renderers.
       {
         kind: 'text',
-        text: opts.value || ' ',
+        text: opts.value || '\u00a0',
         style: { color: look.text, fontSize: scale(size, 0.92) },
       },
       {
@@ -76,12 +84,30 @@ export function signatureBlock(
       }),
     })
   }
-  if (withDate) {
+  // The issuer's date only when the issuer has a line; a date beside nothing
+  // reads as a mistake.
+  if (withDate && (withLine || withImage || !withCustomer)) {
     columns.push({
       width: 'flex',
       node: column({ caption: signature.dateCaption, value: signature.date }),
     })
   }
+  if (withCustomer) {
+    columns.push({
+      width: 'flex',
+      node: column({
+        caption: signature.customerCaption ?? '',
+        value: fields.has('inspector_name') ? (signature.customerName ?? '') : '',
+      }),
+    })
+    // The customer's date is theirs to write, so its line prints empty.
+    if (withDate) {
+      columns.push({
+        width: 'flex',
+        node: column({ caption: signature.dateCaption, value: '' }),
+      })
+    }
+  }
   return {
     kind: 'stack',
     id: section.id,

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

@@ -247,6 +247,7 @@ export function liveInvoiceSettings(settingsMap: Record<string, string>): Invoic
     currencyFormat: settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol',
     unitSystem: settingsMap['workshop.unitSystem'] || 'imperial',
     dateFormat: settingsMap['workshop.dateFormat'] || undefined,
+    timeFormat: settingsMap['workshop.timeFormat'] || undefined,
     timezone: settingsMap['workshop.timezone'] || undefined,
   }
 }

+ 99 - 0
src/features/invoices/Lib/assembleWorkOrderPrint.ts

@@ -0,0 +1,99 @@
+import 'server-only'
+
+import { db } from '@/lib/db'
+import { getAppBaseUrl } from '@/lib/app-url'
+import { generateQrDataUri } from '@/lib/qr'
+import {
+  designSourceFromSettings,
+  templateConfigFromSource,
+} from '@/features/invoice-designer/Lib/designSource'
+import { workOrderQrWanted } from '@/features/invoice-designer/Pdf/buildWorkOrderPrint'
+import type { WorkOrderJob } from '@/features/invoice-designer/Pdf/buildWorkOrderPrint'
+import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
+import { assembleInvoicePrint, designLook, type InvoicePrintAssembly } from './assembleInvoicePrint'
+
+/**
+ * A job as the work order sheet prints it.
+ *
+ * The invoice's assembler already reads everything the two sheets share, so
+ * this asks it for the live rows (a work order is never printed from a
+ * snapshot: it is the job as it stands) and adds what only the work order
+ * says: the status in words, the bay, the promise, the customer's concerns,
+ * the code that opens the job on a phone, and the work order's own design.
+ */
+export interface WorkOrderPrintAssembly extends InvoicePrintAssembly {
+  job: Omit<WorkOrderJob, 'statusLabel'> & {
+    /** The workshop's own name for the status, or null for a stage the reader's language names. */
+    customStatusName: string | null
+    status: string
+  }
+}
+
+export async function assembleWorkOrderPrint(
+  recordId: string
+): Promise<WorkOrderPrintAssembly | null> {
+  const base = await assembleInvoicePrint(recordId, { mode: 'live' })
+  if (!base) return null
+  const { record, organizationId, settingsMap } = base
+
+  const [job, concerns] = await Promise.all([
+    db.serviceRecord.findUnique({
+      where: { id: record.id },
+      select: {
+        promisedAt: true,
+        status: true,
+        customStatus: { select: { name: true } },
+        workBay: { select: { name: true } },
+      },
+    }),
+    db.serviceConcern.findMany({
+      where: { serviceRecordId: record.id },
+      select: { description: true, correction: true },
+      orderBy: { sortOrder: 'asc' },
+    }),
+  ])
+
+  // The work order's own design, never the invoice's: what a customer signs
+  // is not what they are billed with. Its default is the work order's, so a
+  // workshop that never opened the designer still gets the right sheet.
+  const source = designSourceFromSettings(settingsMap, 'work_order')
+  source.layout = { ...source.layout, documentType: 'work_order' }
+  // The work order's own logo when it has one, the company's otherwise.
+  const look = await designLook(settingsMap, source)
+  const template = templateConfigFromSource(look.designSource)
+  const layoutConfig = mergeWithDefaults({ ...source.layout, documentType: 'work_order' })
+
+  // The code that opens this job on a phone: the job's own page, which the
+  // web app shows on a phone as it does on the desk.
+  const qrDataUri = workOrderQrWanted(layoutConfig)
+    ? await generateQrDataUri(
+        record.vehicleId
+          ? `${getAppBaseUrl()}/vehicles/${record.vehicleId}/service/${record.id}`
+          : `${getAppBaseUrl()}/sales/${record.id}`,
+        240
+      )
+    : undefined
+
+  return {
+    ...base,
+    template,
+    layoutConfig,
+    logoDataUri: look.logoDataUri,
+    designSource: look.designSource,
+    job: {
+      orderNumber: workOrderNumberOf(record),
+      status: job?.status ?? record.status,
+      customStatusName: job?.customStatus?.name ?? null,
+      workBay: job?.workBay?.name ?? null,
+      promisedAt: job?.promisedAt ?? null,
+      concerns: concerns.filter((concern) => concern.description.trim()),
+      qrDataUri,
+      printedAt: new Date(),
+    },
+  }
+}
+
+/** The job's number as the strip and the file spell it. */
+export function workOrderNumberOf(record: { invoiceNumber: string | null; id: string }): string {
+  return record.invoiceNumber || `WO-${record.id.slice(-8).toUpperCase()}`
+}

+ 57 - 0
src/features/invoices/Pdf/buildWorkOrderPdfBuffer.ts

@@ -0,0 +1,57 @@
+import 'server-only'
+
+import { renderToBuffer } from '@react-pdf/renderer'
+import '@/features/vehicles/Components/invoice-pdf/fonts'
+import React from 'react'
+import { getFeatures } from '@/lib/features'
+import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
+import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
+import { WorkOrderPDF } from '@/features/vehicles/Components/invoice-pdf/WorkOrderPDF'
+import { assembleWorkOrderPrint, workOrderNumberOf } from '../Lib/assembleWorkOrderPrint'
+
+/** The stage names the sheet prints when the workshop has no name of its own for the status. */
+const STAGE_LABEL_KEYS: Record<string, string> = {
+  pending: 'statusPending',
+  'in-progress': 'statusInProgress',
+  'waiting-parts': 'statusWaitingParts',
+  completed: 'statusCompleted',
+}
+
+/**
+ * The one place a work order becomes a PDF, for the preview and the
+ * download alike. The workshop reads it in its own language: it is the
+ * workshop's sheet, signed at its counter.
+ */
+export async function buildWorkOrderPdfBuffer(
+  serviceRecordId: string,
+  locale: string
+): Promise<{ buffer: Uint8Array; filename: string } | null> {
+  const assembly = await assembleWorkOrderPrint(serviceRecordId)
+  if (!assembly) return null
+
+  const labels = await loadPrintLabels(locale, assembly.labelSettings, 'work_order')
+  const features = await getFeatures(assembly.organizationId)
+  const torqvoiceLogoDataUri = features.brandingRemoved
+    ? undefined
+    : await getTorqvoiceLogoDataUri()
+
+  const { job } = assembly
+  const stageKey = STAGE_LABEL_KEYS[job.status]
+  const statusLabel =
+    job.customStatusName || (stageKey ? labels[stageKey] || job.status : job.status)
+
+  const element = React.createElement(WorkOrderPDF, {
+    data: assembly.data,
+    job: { ...job, statusLabel },
+    workshop: assembly.workshop,
+    invoiceSettings: assembly.invoiceSettings,
+    logoDataUri: assembly.logoDataUri,
+    signer: assembly.signer,
+    template: assembly.template,
+    torqvoiceLogoDataUri,
+    labels,
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  }) as any
+  const buffer = await renderToBuffer(element)
+  return { buffer, filename: `${workOrderNumberOf(assembly.record)}-work-order.pdf` }
+}

+ 48 - 2
src/features/settings/Actions/invoiceLayoutActions.ts

@@ -20,6 +20,7 @@ type LayoutSettingKey =
   | typeof SETTING_KEYS.INVOICE_LAYOUT_CONFIG
   | typeof SETTING_KEYS.QUOTE_LAYOUT_CONFIG
   | typeof SETTING_KEYS.CERTIFICATE_LAYOUT_CONFIG
+  | typeof SETTING_KEYS.WORK_ORDER_LAYOUT_CONFIG
 
 async function loadLayoutConfig(
   organizationId: string,
@@ -37,11 +38,19 @@ async function loadLayoutConfig(
   if (!setting?.value) {
     return key === SETTING_KEYS.CERTIFICATE_LAYOUT_CONFIG
       ? getDefaultLayout('certificate')
-      : getDefaultInvoiceLayout()
+      : key === SETTING_KEYS.WORK_ORDER_LAYOUT_CONFIG
+        ? getDefaultLayout('work_order')
+        : getDefaultInvoiceLayout()
   }
 
   const parsed = JSON.parse(setting.value)
-  return mergeWithDefaults(parsed)
+  // A work order layout is read as one whatever the row says, so a row saved
+  // before the stamp existed is still filled in from its own sections.
+  return mergeWithDefaults(
+    key === SETTING_KEYS.WORK_ORDER_LAYOUT_CONFIG
+      ? { ...parsed, documentType: 'work_order' }
+      : parsed
+  )
 }
 
 async function persistLayoutConfig(
@@ -245,3 +254,40 @@ export async function saveCertificateLayoutConfig(config: InvoiceLayoutConfig) {
     }
   )
 }
+
+/** The work order's saved arrangement, or the work order defaults. */
+export async function getWorkOrderLayoutConfig() {
+  return withAuth(
+    async ({ organizationId }) =>
+      loadLayoutConfig(organizationId, SETTING_KEYS.WORK_ORDER_LAYOUT_CONFIG),
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+    }
+  )
+}
+
+export async function saveWorkOrderLayoutConfig(config: InvoiceLayoutConfig) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const validated = await persistLayoutConfig(
+        userId,
+        organizationId,
+        SETTING_KEYS.WORK_ORDER_LAYOUT_CONFIG,
+        { ...config, documentType: 'work_order' }
+      )
+
+      revalidatePath('/settings/templates')
+      return validated
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: () => ({
+        action: 'settings.updateInvoiceLayout',
+        entity: 'AppSetting',
+        details: { key: 'settings_updateInvoiceLayout' },
+      }),
+    }
+  )
+}

+ 199 - 22
src/features/settings/Schema/invoiceLayoutSchema.ts

@@ -154,7 +154,7 @@ export const invoiceLayoutConfigSchema = z.object({
    * share one section list; a certificate has sections of its own, so its
    * layout has to say so to be merged with the right defaults.
    */
-  documentType: z.enum(['invoice', 'quote', 'certificate']).optional(),
+  documentType: z.enum(['invoice', 'quote', 'certificate', 'work_order']).optional(),
 })
 
 /** Stamped on every layout the designer saves. */
@@ -269,13 +269,59 @@ export const CERTIFICATE_SECTIONS = [
   { id: 'footer', name: 'Footer' },
 ] as const
 
-export type LayoutDocumentType = 'invoice' | 'quote' | 'certificate'
+/**
+ * What a work order prints: the sheet a customer signs at the counter and the
+ * technician takes off the board. The invoice's sections, less what belongs
+ * to a bill (bank details, payments), plus the facts of the job, the
+ * customer's concerns, a checklist of the work and a code that opens the
+ * job on a phone.
+ */
+export const WORK_ORDER_SECTIONS = [
+  { id: 'header', name: 'Header' },
+  { id: 'document_title', name: 'Document Title' },
+  { id: 'slogan', name: 'Slogan' },
+  { id: 'customer', name: 'Customer' },
+  { id: 'vehicle', name: 'Vehicle' },
+  { id: 'service', name: 'Service' },
+  { id: 'job_details', name: 'Job Details' },
+  { id: 'job_qr', name: 'Open on Phone' },
+  { id: 'concerns', name: 'Customer Concerns' },
+  { id: 'job_description', name: 'Work Requested' },
+  { id: 'work_checklist', name: 'Work Checklist' },
+  { id: 'items_table', name: 'Items Table' },
+  { id: 'parts_table', name: 'Parts Table' },
+  { id: 'labor_table', name: 'Labor Table' },
+  { id: 'findings', name: 'Findings' },
+  { id: 'totals', name: 'Totals' },
+  { id: 'notes', name: 'Notes' },
+  { id: 'attached_documents', name: 'Attached Documents' },
+  { id: 'warranty', name: 'Warranty' },
+  { id: 'general', name: 'General' },
+  { id: 'signature', name: 'Signature' },
+  { id: 'footer', name: 'Footer' },
+] as const
+
+export type LayoutDocumentType = 'invoice' | 'quote' | 'certificate' | 'work_order'
 
 /** The sections a document is built from. */
 export function sectionsFor(
   documentType: LayoutDocumentType | undefined
 ): ReadonlyArray<{ id: string; name: string }> {
-  return documentType === 'certificate' ? CERTIFICATE_SECTIONS : BUILTIN_SECTIONS
+  if (documentType === 'certificate') return CERTIFICATE_SECTIONS
+  if (documentType === 'work_order') return WORK_ORDER_SECTIONS
+  return BUILTIN_SECTIONS
+}
+
+/**
+ * The document a layout arranges. Absent means an invoice or a quote, which
+ * share one section list; the others have to say so.
+ */
+export function layoutDocumentType(
+  saved: Pick<Partial<InvoiceLayoutConfig>, 'documentType'> | null | undefined
+): LayoutDocumentType {
+  if (saved?.documentType === 'certificate') return 'certificate'
+  if (saved?.documentType === 'work_order') return 'work_order'
+  return 'invoice'
 }
 
 export const BUILTIN_CUSTOMER_FIELDS = [
@@ -349,6 +395,21 @@ export const BUILTIN_DOCUMENT_TITLE_FIELDS = [
   { id: 'customer_number', name: 'Customer Number' },
   { id: 'date', name: 'Date' },
   { id: 'due_date', name: 'Due Date' },
+  // The plate as a cell of its own, large enough to read a work order off a
+  // board. Off everywhere but the work order until a design asks for it.
+  { id: 'license_plate', name: 'License Plate' },
+] as const
+
+/**
+ * The facts of a job that an invoice never prints: where it stands, who has
+ * it, when it is booked and when the customer was promised it.
+ */
+export const BUILTIN_JOB_DETAILS_FIELDS = [
+  { id: 'status', name: 'Status' },
+  { id: 'technician', name: 'Technician' },
+  { id: 'scheduled', name: 'Scheduled' },
+  { id: 'promised', name: 'Promised' },
+  { id: 'work_bay', name: 'Work Bay' },
 ] as const
 
 export const BUILTIN_BANK_ACCOUNT_FIELDS = [
@@ -392,6 +453,9 @@ export const BUILTIN_SIGNATURE_FIELDS = [
   { id: 'inspector_line', name: 'Signature line' },
   { id: 'inspector_name', name: "Signer's name" },
   { id: 'date_line', name: 'Date line' },
+  // A second, empty line for the customer's pen, beside the issuer's. On by
+  // default only on the work order, which is the sheet a customer signs.
+  { id: 'customer_line', name: 'Customer signature line' },
 ] as const
 
 /** Which rows the full results table prints beyond the defects. */
@@ -471,6 +535,7 @@ export const SECTIONS_WITH_FIELDS = new Set<string>([
   'signature',
   'defects',
   'results_table',
+  'job_details',
 ])
 
 /** Sections that print inside a panel and can have it taken away. */
@@ -486,6 +551,11 @@ export const BOXED_ELIGIBLE_SECTIONS = new Set<string>([
   'test_details',
   'signature',
   'result',
+  'job_details',
+  'job_qr',
+  'concerns',
+  'job_description',
+  'work_checklist',
 ])
 
 /** Sections that can be placed in left/right columns */
@@ -508,7 +578,7 @@ export const FIXED_SLOT_FIELDS: Record<string, readonly string[]> = {
   footer: ['footer_note', 'portal_link', 'logo'],
   // The signature sits on its line, the name under it and the date beside:
   // where each goes is the shape of a signature block, not an order.
-  signature: ['signature_image', 'inspector_line', 'inspector_name', 'date_line'],
+  signature: ['signature_image', 'inspector_line', 'inspector_name', 'date_line', 'customer_line'],
 }
 
 /** Whether this field prints where the list puts it, or in a slot of its own. */
@@ -529,6 +599,8 @@ export const COLUMN_ELIGIBLE_SECTIONS = new Set<string>([
   'test_details',
   'signature',
   'result',
+  'job_details',
+  'job_qr',
 ])
 
 /** Sections that MUST be full-width (cannot be in columns) */
@@ -543,6 +615,9 @@ export const FULL_WIDTH_ONLY_SECTIONS = new Set<string>([
   'defects',
   'results_table',
   'inspection_photos',
+  'concerns',
+  'job_description',
+  'work_checklist',
 ])
 
 /** Default column assignment for column-eligible sections */
@@ -551,32 +626,58 @@ const DEFAULT_COLUMN: Record<string, 'left' | 'right'> = {
   vehicle: 'left',
   service: 'right',
   test_details: 'right',
+  job_details: 'right',
+  job_qr: 'right',
 }
 
 // ---------------------------------------------------------------------------
 // Defaults
 // ---------------------------------------------------------------------------
 
-function getDefaultFieldsForSection(sectionId: string): InvoiceFieldConfig[] | undefined {
+function getDefaultFieldsForSection(
+  sectionId: string,
+  documentType: LayoutDocumentType = 'invoice'
+): InvoiceFieldConfig[] | undefined {
+  const workOrder = documentType === 'work_order'
   switch (sectionId) {
+    // A work order says only what the technician and the customer need: who
+    // the car belongs to and how to reach them, which car, and the job.
     case 'customer':
-      return BUILTIN_CUSTOMER_FIELDS.map((f) => ({ id: f.id, visible: true }))
+      return BUILTIN_CUSTOMER_FIELDS.map((f) => ({
+        id: f.id,
+        visible: workOrder ? f.id === 'customer_name' || f.id === 'customer_phone' : true,
+      }))
     case 'vehicle':
-      return BUILTIN_VEHICLE_FIELDS.map((f) => ({ id: f.id, visible: true }))
+      return BUILTIN_VEHICLE_FIELDS.map((f) => ({
+        id: f.id,
+        visible: workOrder ? f.id !== 'vin' : true,
+      }))
     case 'service':
       return BUILTIN_SERVICE_FIELDS.map((f) => ({ id: f.id, visible: true }))
     case 'header':
-      return BUILTIN_HEADER_FIELDS.map((f) => ({ id: f.id, visible: true }))
+      return BUILTIN_HEADER_FIELDS.map((f) => ({
+        id: f.id,
+        visible: workOrder ? f.id !== 'company_email' && f.id !== 'company_org_number' : true,
+      }))
     case 'bank_account':
       return BUILTIN_BANK_ACCOUNT_FIELDS.map((f) => ({ id: f.id, visible: true }))
     case 'document_title':
-      return BUILTIN_DOCUMENT_TITLE_FIELDS.map((f) => ({ id: f.id, visible: true }))
+      // The plate cell is the work order's; every other strip keeps the
+      // cells it has always had.
+      return BUILTIN_DOCUMENT_TITLE_FIELDS.map((f) => ({
+        id: f.id,
+        visible: workOrder
+          ? f.id !== 'customer_number' && f.id !== 'due_date'
+          : f.id !== 'license_plate',
+      }))
+    case 'job_details':
+      return BUILTIN_JOB_DETAILS_FIELDS.map((f) => ({ id: f.id, visible: f.id !== 'work_bay' }))
     case 'footer':
       // Only the note and the portal link, which is the footer every existing
-      // invoice already has.
+      // invoice already has. A work order is not sent, so no portal link.
       return BUILTIN_FOOTER_FIELDS.map((f) => ({
         id: f.id,
-        visible: f.id === 'footer_note' || f.id === 'portal_link',
+        visible: f.id === 'footer_note' || (f.id === 'portal_link' && !workOrder),
       }))
     case 'general':
       return [] // no built-in fields, only custom fields
@@ -585,7 +686,14 @@ function getDefaultFieldsForSection(sectionId: string): InvoiceFieldConfig[] | u
     case 'result':
       return BUILTIN_RESULT_FIELDS.map((f) => ({ id: f.id, visible: true }))
     case 'signature':
-      return BUILTIN_SIGNATURE_FIELDS.map((f) => ({ id: f.id, visible: true }))
+      // A work order is signed with a pen at the counter: the customer's
+      // line is on and the issuer's saved image is off. Everywhere else the
+      // customer line waits to be switched on.
+      return BUILTIN_SIGNATURE_FIELDS.map((f) => ({
+        id: f.id,
+        visible:
+          f.id === 'customer_line' ? workOrder : f.id === 'signature_image' ? !workOrder : true,
+      }))
     case 'defects':
       return BUILTIN_DEFECTS_FIELDS.map((f) => ({ id: f.id, visible: true }))
     case 'results_table':
@@ -617,6 +725,61 @@ const HIDDEN_BY_DEFAULT_SECTIONS = new Set<string>([
 ])
 /** A signature line is a choice; most certificates are issued unsigned. */
 const HIDDEN_BY_DEFAULT_CERTIFICATE_SECTIONS = new Set<string>(['slogan', 'signature'])
+/**
+ * The work order prints as short as it can be: the customer, the car, the
+ * job, the work and parts with their prices, and the lines to sign. Workshops
+ * print these all day, so everything else waits to be switched on.
+ */
+const HIDDEN_BY_DEFAULT_WORK_ORDER_SECTIONS = new Set<string>([
+  'general',
+  'items_table',
+  'work_checklist',
+  'slogan',
+  'service',
+  'findings',
+  'attached_documents',
+  'warranty',
+  // The customer's notes are the invoice's, and the code to scan is the
+  // board copy's; each costs the room that keeps the counter copy on one page.
+  'notes',
+  'job_qr',
+  // Status, technician and times are the board's business, not the counter's.
+  'job_details',
+  // The letterhead: the customer signs at the counter of the shop whose
+  // name is over the door, and the strip already says which sheet this is.
+  'header',
+])
+
+/** The work order's panels print as bare lines: no fills, no boxes, no colour. */
+const PLAIN_WORK_ORDER_SECTIONS = new Set<string>([
+  'customer',
+  'vehicle',
+  'job_details',
+  'job_qr',
+  'concerns',
+  'job_description',
+  'work_checklist',
+  'notes',
+  'signature',
+])
+
+/**
+ * Black on white for a sheet printed all day long: no coloured accent, no
+ * banding, no dark bar over the tables, small type on a narrow margin.
+ */
+export const WORK_ORDER_DOCUMENT_STYLE: InvoiceDocumentStyle = {
+  accentColor: '#111827',
+  fontSize: 9,
+  rowPadding: 3,
+  margin: 32,
+  stripes: false,
+}
+
+/** The colour and header the work order prints with until a design says otherwise. */
+export const WORK_ORDER_TEMPLATE_DEFAULTS = { primaryColor: '#111827', headerStyle: 'compact' }
+
+/** A table's column heads set in plain ink on white, with a hairline under each row. */
+const PLAIN_TABLE_STYLE: InvoiceSectionStyle = { backgroundColor: '#ffffff', borderWidth: 0.5 }
 
 export function getDefaultInvoiceLayout(): InvoiceLayoutConfig {
   return getDefaultLayout('invoice')
@@ -624,21 +787,32 @@ export function getDefaultInvoiceLayout(): InvoiceLayoutConfig {
 
 /** The default arrangement for a document: every section, in its built-in order. */
 export function getDefaultLayout(documentType: LayoutDocumentType): InvoiceLayoutConfig {
-  const certificate = documentType === 'certificate'
-  const hidden = certificate ? HIDDEN_BY_DEFAULT_CERTIFICATE_SECTIONS : HIDDEN_BY_DEFAULT_SECTIONS
+  const hidden =
+    documentType === 'certificate'
+      ? HIDDEN_BY_DEFAULT_CERTIFICATE_SECTIONS
+      : documentType === 'work_order'
+        ? HIDDEN_BY_DEFAULT_WORK_ORDER_SECTIONS
+        : HIDDEN_BY_DEFAULT_SECTIONS
   return {
     sections: sectionsFor(documentType).map((s, index) => {
-      const fields = getDefaultFieldsForSection(s.id)
-      const column = DEFAULT_COLUMN[s.id]
+      const fields = getDefaultFieldsForSection(s.id, documentType)
+      const workOrder = documentType === 'work_order'
+      // The car beside the customer, not under it: two short panels on one row.
+      const column = workOrder && s.id === 'vehicle' ? 'right' : DEFAULT_COLUMN[s.id]
+      const plain = workOrder && PLAIN_WORK_ORDER_SECTIONS.has(s.id)
+      const table = workOrder && (s.id === 'labor_table' || s.id === 'parts_table')
       return {
         id: s.id,
         visible: !hidden.has(s.id),
         order: index,
         ...(column ? { column } : {}),
+        ...(plain ? { boxed: false } : {}),
+        ...(table ? { style: { ...PLAIN_TABLE_STYLE } } : {}),
         ...(fields ? { fields } : {}),
       }
     }),
-    ...(certificate ? { documentType: 'certificate' as const } : {}),
+    ...(documentType === 'work_order' ? { document: { ...WORK_ORDER_DOCUMENT_STYLE } } : {}),
+    ...(documentType === 'certificate' || documentType === 'work_order' ? { documentType } : {}),
   }
 }
 
@@ -729,6 +903,8 @@ export function getBuiltinFieldsForSection(
       return BUILTIN_DEFECTS_FIELDS
     case 'results_table':
       return BUILTIN_RESULTS_TABLE_FIELDS
+    case 'job_details':
+      return BUILTIN_JOB_DETAILS_FIELDS
     default:
       return []
   }
@@ -749,6 +925,7 @@ export function getBuiltinFieldName(fieldId: string): string | undefined {
     ...BUILTIN_SIGNATURE_FIELDS,
     ...BUILTIN_DEFECTS_FIELDS,
     ...BUILTIN_RESULTS_TABLE_FIELDS,
+    ...BUILTIN_JOB_DETAILS_FIELDS,
   ]
   return allFields.find((f) => f.id === fieldId)?.name
 }
@@ -783,9 +960,9 @@ export function materializeHiddenSection(
 // ---------------------------------------------------------------------------
 
 export function mergeWithDefaults(saved: Partial<InvoiceLayoutConfig>): InvoiceLayoutConfig {
-  // A certificate layout says so and is filled in from the certificate's
-  // sections; anything else is an invoice or a quote, which share theirs.
-  const documentType = saved.documentType === 'certificate' ? 'certificate' : 'invoice'
+  // A certificate or a work order layout says so and is filled in from its
+  // own sections; anything else is an invoice or a quote, which share theirs.
+  const documentType = layoutDocumentType(saved)
   const defaults = getDefaultLayout(documentType)
 
   if (!saved.sections || saved.sections.length === 0) {
@@ -803,7 +980,7 @@ export function mergeWithDefaults(saved: Partial<InvoiceLayoutConfig>): InvoiceL
     if (seen.has(section.id)) continue
     seen.add(section.id)
 
-    const defaultFields = getDefaultFieldsForSection(section.id)
+    const defaultFields = getDefaultFieldsForSection(section.id, documentType)
     if (defaultFields) {
       merged.push({
         ...section,
@@ -879,7 +1056,7 @@ export function mergeWithDefaults(saved: Partial<InvoiceLayoutConfig>): InvoiceL
     ...(saved.document ? { document: saved.document } : {}),
     ...(saved.anchors ? { anchors: saved.anchors } : {}),
     ...(saved.version !== undefined ? { version: saved.version } : {}),
-    ...(documentType === 'certificate' ? { documentType } : {}),
+    ...(documentType !== 'invoice' ? { documentType } : {}),
   }
 }
 

+ 142 - 2
src/features/settings/Schema/layoutPresets.ts

@@ -3,8 +3,13 @@ import {
   type InvoiceLayoutConfig,
   type InvoiceSectionStyle,
   type LayoutDocumentType,
+  BUILTIN_CUSTOMER_FIELDS,
   BUILTIN_FOOTER_FIELDS,
   BUILTIN_HEADER_FIELDS,
+  BUILTIN_JOB_DETAILS_FIELDS,
+  BUILTIN_VEHICLE_FIELDS,
+  WORK_ORDER_DOCUMENT_STYLE,
+  WORK_ORDER_TEMPLATE_DEFAULTS,
   getDefaultLayout,
   sectionsFor,
 } from './invoiceLayoutSchema'
@@ -393,9 +398,144 @@ export const certificatePresets: LayoutPreset[] = [
   },
 ]
 
+/**
+ * Starting points for a work order: the sheet a job prints while it is open.
+ * The customer's copy carries the prices and two lines to sign; the board
+ * copy carries the work as a checklist, the code that opens the job on a
+ * phone, and no prices at all.
+ */
+export const workOrderPresets: LayoutPreset[] = [
+  {
+    // What every work order prints until a design says otherwise: black on
+    // white, one page, only what the technician and the customer need. Kept
+    // equal to the built-in default, which the test holds it to.
+    id: 'work-order-compact',
+    documentType: 'work_order',
+    template: {
+      primaryColor: WORK_ORDER_TEMPLATE_DEFAULTS.primaryColor,
+      headerStyle: WORK_ORDER_TEMPLATE_DEFAULTS.headerStyle,
+      fontFamily: 'Helvetica',
+    },
+    document: { ...WORK_ORDER_DOCUMENT_STYLE },
+    order: [
+      'document_title',
+      'customer',
+      'vehicle',
+      'concerns',
+      'job_description',
+      'parts_table',
+      'labor_table',
+      'totals',
+      'signature',
+      'footer',
+    ],
+    columns: { customer: 'left', vehicle: 'right' },
+    plain: [
+      'customer',
+      'vehicle',
+      'job_details',
+      'job_qr',
+      'concerns',
+      'job_description',
+      'notes',
+      'signature',
+    ],
+    fields: {
+      customer: ['customer_name', 'customer_phone'],
+      vehicle: ['vehicle_name', 'license_plate', 'mileage'],
+      document_title: ['title', 'invoice_number', 'date', 'license_plate'],
+      job_details: ['status', 'technician', 'scheduled', 'promised'],
+      signature: ['inspector_line', 'inspector_name', 'date_line', 'customer_line'],
+    },
+    styles: {
+      labor_table: { backgroundColor: '#ffffff', borderWidth: 0.5 },
+      parts_table: { backgroundColor: '#ffffff', borderWidth: 0.5 },
+    },
+    headerFields: ['logo', 'company_name', 'company_address', 'company_phone'],
+    footerFields: ['footer_note'],
+  },
+  {
+    // The fuller counter copy: the whole customer and vehicle, the service,
+    // the findings, the warranty, in the workshop's colour.
+    id: 'work-order-standard',
+    documentType: 'work_order',
+    template: { primaryColor: '#d97706', headerStyle: 'standard', fontFamily: 'Helvetica' },
+    document: { fontSize: 10, rowPadding: 5, margin: 40, stripes: true },
+    order: [
+      'header',
+      'document_title',
+      'customer',
+      'vehicle',
+      'service',
+      'job_details',
+      'job_qr',
+      'concerns',
+      'job_description',
+      'labor_table',
+      'parts_table',
+      'findings',
+      'totals',
+      'notes',
+      'attached_documents',
+      'warranty',
+      'signature',
+      'footer',
+    ],
+    columns: {
+      customer: 'left',
+      vehicle: 'left',
+      service: 'right',
+      job_details: 'right',
+      job_qr: 'right',
+    },
+    fields: {
+      customer: BUILTIN_CUSTOMER_FIELDS.map((f) => f.id as string),
+      vehicle: BUILTIN_VEHICLE_FIELDS.map((f) => f.id as string),
+      job_details: BUILTIN_JOB_DETAILS_FIELDS.map((f) => f.id as string),
+    },
+    styles: { labor_table: {}, parts_table: {} },
+    headerFields: ALL_HEADER_FIELDS,
+    footerFields: ['footer_note', 'portal_link'],
+  },
+  {
+    // The board copy: the plate large in the strip, the job's facts, the
+    // work as boxes to tick, the code to scan, and nothing about money.
+    id: 'work-order-job-sheet',
+    documentType: 'work_order',
+    template: { primaryColor: '#111827', headerStyle: 'compact', fontFamily: 'Helvetica' },
+    document: { accentColor: '#111827', fontSize: 10, rowPadding: 6, stripes: false },
+    order: [
+      'header',
+      'document_title',
+      'vehicle',
+      'customer',
+      'job_details',
+      'job_qr',
+      'concerns',
+      'job_description',
+      'work_checklist',
+      'findings',
+      'notes',
+      'signature',
+      'footer',
+    ],
+    columns: { vehicle: 'left', customer: 'left', job_details: 'right', job_qr: 'right' },
+    plain: ['customer', 'vehicle', 'job_details', 'job_qr', 'concerns', 'job_description', 'notes'],
+    fields: {
+      document_title: ['title', 'invoice_number', 'license_plate', 'date'],
+      job_details: ['status', 'technician', 'scheduled', 'promised', 'work_bay'],
+      signature: ['inspector_line', 'inspector_name', 'date_line'],
+    },
+    headerFields: ['logo', 'company_name', 'company_phone'],
+    footerFields: ['footer_note'],
+  },
+]
+
 /** The starting points for one document. */
 export function presetsFor(documentType: LayoutDocumentType): LayoutPreset[] {
-  return documentType === 'certificate' ? certificatePresets : layoutPresets
+  if (documentType === 'certificate') return certificatePresets
+  if (documentType === 'work_order') return workOrderPresets
+  return layoutPresets
 }
 
 /**
@@ -444,6 +584,6 @@ export function buildLayoutFromPreset(preset: LayoutPreset): InvoiceLayoutConfig
   return {
     sections,
     ...(preset.document ? { document: preset.document } : {}),
-    ...(documentType === 'certificate' ? { documentType } : {}),
+    ...(documentType !== 'invoice' ? { documentType } : {}),
   }
 }

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

@@ -135,6 +135,7 @@ export const SETTING_KEYS = {
   INVOICE_ACTIVE_DESIGN: 'invoice.activeDesign',
   QUOTE_ACTIVE_DESIGN: 'quote.activeDesign',
   CERTIFICATE_ACTIVE_DESIGN: 'certificate.activeDesign',
+  WORK_ORDER_ACTIVE_DESIGN: 'work_order.activeDesign',
   QUOTE_FONT_FAMILY: 'quote.fontFamily',
   QUOTE_HEADER_STYLE: 'quote.headerStyle',
   /// Inspection reminders and the booking link they carry.
@@ -209,11 +210,13 @@ export const SETTING_KEYS = {
   INVOICE_LAYOUT_CONFIG: 'invoice.layoutConfig',
   QUOTE_LAYOUT_CONFIG: 'quote.layoutConfig',
   CERTIFICATE_LAYOUT_CONFIG: 'certificate.layoutConfig',
+  WORK_ORDER_LAYOUT_CONFIG: 'work_order.layoutConfig',
   // A mark for the paperwork alone. Unset means the documents print the
   // company logo, which is what every sheet did before this existed.
   INVOICE_LOGO: 'invoice.logo',
   QUOTE_LOGO: 'quote.logo',
   CERTIFICATE_LOGO: 'certificate.logo',
+  WORK_ORDER_LOGO: 'work_order.logo',
   AI_PROVIDER: 'ai.provider',
   AI_API_KEY: 'ai.apiKey',
   AI_MODEL: 'ai.model',

+ 21 - 0
src/features/vehicles/Components/invoice-pdf/WorkOrderPDF.tsx

@@ -0,0 +1,21 @@
+import { Document } from '@react-pdf/renderer'
+import {
+  buildWorkOrderPrintSpec,
+  type WorkOrderPrintInput,
+} from '@/features/invoice-designer/Pdf/buildWorkOrderPrint'
+import { SpecPdfPage } from '@/features/invoice-designer/Pdf/SpecPdf'
+
+/**
+ * The printed work order: the same document pipeline as the invoice, fed
+ * the job while it is still open. One sheet, no appended files: the
+ * photographs and reports belong to the invoice's copy, and a work order is
+ * for signing at the counter and pinning to the board.
+ */
+export function WorkOrderPDF(input: WorkOrderPrintInput) {
+  const spec = buildWorkOrderPrintSpec(input)
+  return (
+    <Document>
+      <SpecPdfPage spec={spec} />
+    </Document>
+  )
+}

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

@@ -135,6 +135,8 @@ export interface InvoiceSettingsProps {
   currencyFormat?: 'symbol' | 'code'
   unitSystem?: string
   dateFormat?: string
+  /** How a time of day is written where the sheet prints one: '12h' or '24h'. */
+  timeFormat?: string
   timezone?: string
 }
 

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

@@ -289,6 +289,7 @@ export function ServicePageClient({
     completed: SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
   }
   const [showPdfPreview, setShowPdfPreview] = useState(false)
+  const [showWorkOrderPreview, setShowWorkOrderPreview] = useState(false)
   const [showNotifyDialog, setShowNotifyDialog] = useState(false)
   const [notifyMessage, setNotifyMessage] = useState('')
 
@@ -516,6 +517,11 @@ export function ServicePageClient({
       actions.handleDownloadPDF()
     },
     onPreviewPDF: previewInvoice,
+    // The sheet says the job as it stands, so what has been typed goes in first.
+    onPrintWorkOrder: async () => {
+      if (formState.hasUnsavedChanges) await actions.saveNow()
+      setShowWorkOrderPreview(true)
+    },
     onDelete: actions.handleDelete,
     onShowEmail: async () => {
       if (!(await checkDates())) return
@@ -832,6 +838,12 @@ export function ServicePageClient({
           url={`/api/protected/services/${record.id}/pdf`}
         />
 
+        <PdfPreviewDialog
+          open={showWorkOrderPreview}
+          onOpenChange={setShowWorkOrderPreview}
+          url={`/api/protected/services/${record.id}/work-order-pdf`}
+        />
+
         <SendEmailDialog
           open={actions.showEmailDialog}
           onOpenChange={actions.setShowEmailDialog}

+ 16 - 0
src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx

@@ -27,6 +27,7 @@ import {
   Save,
   Trash2,
   Video,
+  Printer,
 } from 'lucide-react'
 import {
   paymentStatusColors,
@@ -60,6 +61,8 @@ interface UnifiedServiceHeaderProps {
   showSaved?: boolean
   onDownloadPDF: () => void
   onPreviewPDF: () => void
+  /** The work order as a sheet to print and sign, opened in the preview. */
+  onPrintWorkOrder?: () => void
   onDelete: () => void
   onShowEmail: () => void
   onShowShare: () => void
@@ -92,6 +95,7 @@ export function UnifiedServiceHeader({
   showSaved = false,
   onDownloadPDF,
   onPreviewPDF,
+  onPrintWorkOrder,
   onDelete,
   onShowEmail,
   onShowShare,
@@ -147,6 +151,7 @@ export function UnifiedServiceHeader({
           showSaved={showSaved}
           onDownloadPDF={onDownloadPDF}
           onPreviewPDF={onPreviewPDF}
+          onPrintWorkOrder={onPrintWorkOrder}
           onDelete={onDelete}
           onShowEmail={onShowEmail}
           onShowShare={onShowShare}
@@ -190,6 +195,7 @@ type ServiceHeaderActionsProps = Pick<
   | 'showSaved'
   | 'onDownloadPDF'
   | 'onPreviewPDF'
+  | 'onPrintWorkOrder'
   | 'onDelete'
   | 'onShowEmail'
   | 'onShowShare'
@@ -218,6 +224,7 @@ export function ServiceHeaderActions({
   showSaved = false,
   onDownloadPDF,
   onPreviewPDF,
+  onPrintWorkOrder,
   onDelete,
   onShowEmail,
   onShowShare,
@@ -299,6 +306,15 @@ export function ServiceHeaderActions({
             <TooltipContent>{t('moreActions')}</TooltipContent>
           </Tooltip>
           <DropdownMenuContent align="end" className="min-w-56">
+            {onPrintWorkOrder && (
+              <>
+                <DropdownMenuItem onClick={onPrintWorkOrder}>
+                  <Printer className="mr-2 size-4" aria-hidden="true" />
+                  {t('printWorkOrder')}
+                </DropdownMenuItem>
+                <DropdownMenuSeparator />
+              </>
+            )}
             {designMenu && (
               <>
                 {designMenu}