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

Add personal signatures to invoices, quotes and inspection certificates (#423)

Each member draws or uploads a signature in account settings. The document
designer's Signature section now exists for invoices and quotes as well,
prints the issuer's signature on the line, and issued invoices and completed
inspections keep the signature they went out with.
Bernt Christian Egeland 1 неделя назад
Родитель
Сommit
014387b2c8
84 измененных файлов с 1606 добавлено и 201 удалено
  1. 3 1
      messages/de/audit.json
  2. 4 1
      messages/de/pdf.json
  3. 31 4
      messages/de/settings.json
  4. 3 1
      messages/en/audit.json
  5. 4 1
      messages/en/pdf.json
  6. 31 4
      messages/en/settings.json
  7. 3 1
      messages/es/audit.json
  8. 4 1
      messages/es/pdf.json
  9. 31 4
      messages/es/settings.json
  10. 3 1
      messages/fr/audit.json
  11. 4 1
      messages/fr/pdf.json
  12. 31 4
      messages/fr/settings.json
  13. 3 1
      messages/it/audit.json
  14. 4 1
      messages/it/pdf.json
  15. 31 4
      messages/it/settings.json
  16. 3 1
      messages/lt/audit.json
  17. 4 1
      messages/lt/pdf.json
  18. 31 4
      messages/lt/settings.json
  19. 3 1
      messages/nb/audit.json
  20. 4 1
      messages/nb/pdf.json
  21. 31 4
      messages/nb/settings.json
  22. 3 1
      messages/nl/audit.json
  23. 4 1
      messages/nl/pdf.json
  24. 31 4
      messages/nl/settings.json
  25. 3 1
      messages/pl/audit.json
  26. 4 1
      messages/pl/pdf.json
  27. 31 4
      messages/pl/settings.json
  28. 3 1
      messages/pt-BR/audit.json
  29. 4 1
      messages/pt-BR/pdf.json
  30. 31 4
      messages/pt-BR/settings.json
  31. 3 1
      messages/pt-PT/audit.json
  32. 4 1
      messages/pt-PT/pdf.json
  33. 31 4
      messages/pt-PT/settings.json
  34. 3 1
      messages/ru/audit.json
  35. 4 1
      messages/ru/pdf.json
  36. 31 4
      messages/ru/settings.json
  37. 3 1
      messages/tr/audit.json
  38. 4 1
      messages/tr/pdf.json
  39. 31 4
      messages/tr/settings.json
  40. 36 0
      prisma/migrations/20260924150000_member_signatures/migration.sql
  41. 8 5
      prisma/schema/document-designs.prisma
  42. 7 2
      prisma/schema/inspections.prisma
  43. 23 0
      prisma/schema/organization.prisma
  44. 14 9
      prisma/schema/service-records.prisma
  45. 9 2
      src/__tests__/features/invoice-designer/every-block.test.ts
  46. 9 2
      src/__tests__/features/invoice-designer/field-order.test.ts
  47. 2 1
      src/__tests__/features/invoice-designer/fixed-slot-fields.test.ts
  48. 141 0
      src/__tests__/features/signatures/signature-block.test.ts
  49. 2 0
      src/__tests__/multitenancy/inspection-isolation.test.ts
  50. 2 0
      src/__tests__/multitenancy/quote-inspection-isolation.test.ts
  51. 6 0
      src/app/(authenticated)/settings/account/account-settings.tsx
  52. 4 1
      src/app/(authenticated)/settings/account/page.tsx
  53. 4 0
      src/app/(designer)/invoice-designer/page.tsx
  54. 2 0
      src/app/(public)/share/inspection/[orgId]/[token]/page.tsx
  55. 1 0
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  56. 5 0
      src/app/(public)/share/quote/[orgId]/[token]/page.tsx
  57. 2 0
      src/app/api/protected/backup/import/route.ts
  58. 2 0
      src/features/ai/tools/workshop-columns.ts
  59. 18 1
      src/features/inspections/Actions/inspectionActions.ts
  60. 4 1
      src/features/inspections/Pdf/buildCertificatePdfBuffer.ts
  61. 10 2
      src/features/inspections/Pdf/buildCertificatePrint.ts
  62. 22 0
      src/features/invoice-designer/Components/DesignerInspector.tsx
  63. 8 1
      src/features/invoice-designer/Components/InvoiceDesigner.tsx
  64. 23 2
      src/features/invoice-designer/Components/sample.ts
  65. 5 0
      src/features/invoice-designer/Components/types.ts
  66. 10 0
      src/features/invoice-designer/Pdf/buildInvoicePrint.ts
  67. 10 0
      src/features/invoice-designer/Pdf/buildQuotePrint.ts
  68. 20 1
      src/features/invoice-designer/Spec/buildSpec.ts
  69. 1 79
      src/features/invoice-designer/Spec/certificateBlocks.ts
  70. 0 1
      src/features/invoice-designer/Spec/certificateData.ts
  71. 111 0
      src/features/invoice-designer/Spec/signatureBlock.ts
  72. 22 1
      src/features/invoices/Lib/assembleInvoicePrint.ts
  73. 12 6
      src/features/invoices/Lib/issueInvoice.ts
  74. 2 0
      src/features/invoices/Lib/issuedInvoice.ts
  75. 1 0
      src/features/invoices/Pdf/buildInvoicePdfBuffer.ts
  76. 1 0
      src/features/portal/Actions/portalActions.ts
  77. 3 0
      src/features/quotes/Components/QuotePDF.tsx
  78. 5 1
      src/features/quotes/Pdf/buildQuotePdfBuffer.ts
  79. 23 5
      src/features/settings/Schema/invoiceLayoutSchema.ts
  80. 74 0
      src/features/signatures/Actions/signatureActions.ts
  81. 336 0
      src/features/signatures/Components/SignatureCard.tsx
  82. 81 0
      src/features/signatures/Lib/memberSignature.server.ts
  83. 28 0
      src/features/signatures/Lib/signatureImage.ts
  84. 3 0
      src/features/vehicles/Components/invoice-pdf/InvoicePDF.tsx

+ 3 - 1
messages/de/audit.json

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "Auftragsstatus geändert",
     "settings_workOrderStatus_archive": "Auftragsstatus entfernt",
     "inspection_photo_added": "Prüfungsfoto hinzugefügt",
-    "inspection_file_added": "Prüfungsdatei hinzugefügt"
+    "inspection_file_added": "Prüfungsdatei hinzugefügt",
+    "signature_save": "Unterschrift gespeichert",
+    "signature_remove": "Unterschrift entfernt"
   },
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",

+ 4 - 1
messages/de/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Motorstunden"
   },
   "common": {
-    "poweredBy": "Bereitgestellt von"
+    "poweredBy": "Bereitgestellt von",
+    "signature": "Unterschrift",
+    "signedBy": "Unterzeichnet von",
+    "signatureDate": "Datum"
   }
 }

+ 31 - 4
messages/de/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Werkstatt gelöscht",
     "deleteWorkshopFailed": "Werkstatt konnte nicht gelöscht werden",
     "deleteAccountMoved": "Möchten Sie Ihr Konto löschen?",
-    "deleteAccountMovedLink": "Das können Sie in den Dateneinstellungen tun."
+    "deleteAccountMovedLink": "Das können Sie in den Dateneinstellungen tun.",
+    "signatureTitle": "Unterschrift",
+    "signatureDescription": "Wird auf den Rechnungen, Angeboten und Prüfbescheinigungen gedruckt, die Sie für diese Werkstatt ausstellen.",
+    "signatureDraw": "Zeichnen",
+    "signatureUpload": "Hochladen",
+    "signatureDrawHint": "Hier unterschreiben",
+    "signatureUploadHint": "Wählen Sie ein Foto oder einen Scan Ihrer Unterschrift",
+    "signatureDrawTip": "Verwenden Sie einen Finger, einen Eingabestift oder die Maus.",
+    "signatureUploadTip": "Unterschreiben Sie auf weißem Papier und fotografieren Sie es. Das Papier wird entfernt, nur die Tinte bleibt erhalten.",
+    "signatureChooseFile": "Bild auswählen",
+    "signatureClear": "Leeren",
+    "signatureSave": "Unterschrift speichern",
+    "signatureCancel": "Abbrechen",
+    "signatureReplace": "Ersetzen",
+    "signatureRemove": "Entfernen",
+    "signatureSaved": "Unterschrift gespeichert",
+    "signatureRemoved": "Unterschrift entfernt. Bereits ausgestellte Dokumente behalten ihre.",
+    "signatureSaveFailed": "Die Unterschrift konnte nicht gespeichert werden",
+    "signatureEmpty": "Zeichnen Sie zuerst eine Unterschrift oder laden Sie eine hoch",
+    "signatureNotImage": "Diese Datei ist kein Bild",
+    "signatureNoInk": "In diesem Bild wurde keine Unterschrift gefunden. Versuchen Sie einen dunkleren Stift auf weißem Papier.",
+    "signatureCanvasLabel": "Unterschriftenfeld",
+    "signatureDesignerHint": "Sie erscheint dort, wo in einem Design der Abschnitt Unterschrift aktiviert ist. Aktivieren Sie ihn im <link>Vorlagen-Designer</link>."
   },
   "team": {
     "title": "Team",
@@ -734,9 +756,10 @@
       "result_detail": "Erläuterung",
       "result_summary": "Anzahl je Bewertung",
       "combined_table": "Eine Tabelle für alle Abschnitte",
-      "inspector_line": "Zeile für den Prüfer",
-      "inspector_name": "Namen des Prüfers drucken",
-      "date_line": "Datumszeile"
+      "inspector_line": "Unterschriftszeile",
+      "inspector_name": "Name des Unterzeichners",
+      "date_line": "Datumszeile",
+      "signature_image": "Unterschrift"
     },
     "presetsTitle": "Mit einer Anordnung beginnen",
     "presetsHint": "Ersetzt die Anordnung unten. Nichts wird gespeichert, bis Sie speichern.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "Druckt einen Code, der {link} öffnet, Ihren verbundenen Telegram-Bot. Er erscheint nur auf Rechnungen, die nach dem Einschalten des Blocks ausgestellt wurden.",
     "telegramQrPlaceholderHint": "Kein Telegram-Bot ist verbunden, daher zeigt das Blatt einen Beispielcode, der nicht gedruckt wird.",
     "telegramQrLink": "Integrationen",
+    "signatureWhose": "Wessen Unterschrift",
+    "signatureOwnHint": "Jedes Dokument druckt die Unterschrift der Person, die es ausgestellt hat: wer den Auftrag angelegt, das Angebot erstellt oder die Prüfung durchgeführt hat. Das Blatt zeigt Ihre.",
+    "signaturePlaceholderHint": "Jedes Dokument druckt die Unterschrift der Person, die es ausgestellt hat: wer den Auftrag angelegt, das Angebot erstellt oder die Prüfung durchgeführt hat. Sie haben noch keine gespeichert, daher zeigt das Blatt ein Beispiel.",
+    "signatureLink": "Unterschrift festlegen",
     "titleText": "Titeltext",
     "titleTextHint": "Wie sich dieses Dokument nennt. Leer gelassen, druckt es seinen eigenen Namen in der Sprache des Lesers.",
     "orgNumberLabel": "Bezeichnung der Organisationsnummer",

+ 3 - 1
messages/en/audit.json

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "Changed Work Order Status",
     "settings_workOrderStatus_archive": "Removed Work Order Status",
     "inspection_photo_added": "Added Inspection Photo",
-    "inspection_file_added": "Added Inspection File"
+    "inspection_file_added": "Added Inspection File",
+    "signature_save": "Saved Signature",
+    "signature_remove": "Removed Signature"
   },
   "summary": {
     "customField_create": "Created custom field \"{name}\"",

+ 4 - 1
messages/en/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Engine Hrs"
   },
   "common": {
-    "poweredBy": "Powered by"
+    "poweredBy": "Powered by",
+    "signature": "Signature",
+    "signedBy": "Signed by",
+    "signatureDate": "Date"
   }
 }

+ 31 - 4
messages/en/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Workshop deleted",
     "deleteWorkshopFailed": "Failed to delete the workshop",
     "deleteAccountMoved": "Want to delete your account?",
-    "deleteAccountMovedLink": "You can do that in Data settings."
+    "deleteAccountMovedLink": "You can do that in Data settings.",
+    "signatureTitle": "Signature",
+    "signatureDescription": "Printed on the invoices, quotes and certificates you issue for this workshop.",
+    "signatureDraw": "Draw",
+    "signatureUpload": "Upload",
+    "signatureDrawHint": "Sign here",
+    "signatureUploadHint": "Choose a photo or scan of your signature",
+    "signatureDrawTip": "Use a finger, a stylus or the mouse.",
+    "signatureUploadTip": "Sign on white paper and take a photo. The paper is removed and only the ink is kept.",
+    "signatureChooseFile": "Choose image",
+    "signatureClear": "Clear",
+    "signatureSave": "Save signature",
+    "signatureCancel": "Cancel",
+    "signatureReplace": "Replace",
+    "signatureRemove": "Remove",
+    "signatureSaved": "Signature saved",
+    "signatureRemoved": "Signature removed. Documents already issued keep theirs.",
+    "signatureSaveFailed": "Could not save the signature",
+    "signatureEmpty": "Draw or upload a signature first",
+    "signatureNotImage": "That file is not an image",
+    "signatureNoInk": "No signature found in that image. Try a darker pen on white paper.",
+    "signatureCanvasLabel": "Signature pad",
+    "signatureDesignerHint": "It appears where a design has the Signature section switched on. Turn it on in the <link>document designer</link>."
   },
   "team": {
     "title": "Team",
@@ -734,9 +756,10 @@
       "result_detail": "Explanation",
       "result_summary": "Counts per grade",
       "combined_table": "One table for all sections",
-      "inspector_line": "Inspector line",
-      "inspector_name": "Print the inspector's name",
-      "date_line": "Date line"
+      "inspector_line": "Signature line",
+      "inspector_name": "Signer's name",
+      "date_line": "Date line",
+      "signature_image": "Signature"
     },
     "presetsTitle": "Start from an arrangement",
     "presetsHint": "Replaces the arrangement below. Nothing is saved until you save.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "Prints a code that opens {link}, your connected Telegram bot. It only prints on invoices issued after the block was switched on.",
     "telegramQrPlaceholderHint": "No Telegram bot is connected, so the sheet shows a sample code that will not print.",
     "telegramQrLink": "Integrations",
+    "signatureWhose": "Whose signature",
+    "signatureOwnHint": "Each document prints the signature of whoever issued it: the person who opened the work order, wrote the quote or did the inspection. The canvas shows yours.",
+    "signaturePlaceholderHint": "Each document prints the signature of whoever issued it: the person who opened the work order, wrote the quote or did the inspection. You have not saved one, so the canvas shows a sample.",
+    "signatureLink": "Set your signature",
     "titleText": "Title text",
     "titleTextHint": "What this document calls itself. Left empty, it prints its own name in the reader's language.",
     "orgNumberLabel": "Organization number label",

+ 3 - 1
messages/es/audit.json

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "Estado de orden modificado",
     "settings_workOrderStatus_archive": "Estado de orden quitado",
     "inspection_photo_added": "Añadió foto de inspección",
-    "inspection_file_added": "Añadió archivo de inspección"
+    "inspection_file_added": "Añadió archivo de inspección",
+    "signature_save": "Guardó la firma",
+    "signature_remove": "Eliminó la firma"
   },
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",

+ 4 - 1
messages/es/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Horas Motor"
   },
   "common": {
-    "poweredBy": "Desarrollado por"
+    "poweredBy": "Desarrollado por",
+    "signature": "Firma",
+    "signedBy": "Firmado por",
+    "signatureDate": "Fecha"
   }
 }

+ 31 - 4
messages/es/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Taller eliminado",
     "deleteWorkshopFailed": "No se pudo eliminar el taller",
     "deleteAccountMoved": "¿Quiere eliminar su cuenta?",
-    "deleteAccountMovedLink": "Puede hacerlo en la configuración de datos."
+    "deleteAccountMovedLink": "Puede hacerlo en la configuración de datos.",
+    "signatureTitle": "Firma",
+    "signatureDescription": "Se imprime en las facturas, presupuestos y certificados que emita para este taller.",
+    "signatureDraw": "Dibujar",
+    "signatureUpload": "Subir",
+    "signatureDrawHint": "Firme aquí",
+    "signatureUploadHint": "Elija una foto o un escaneo de su firma",
+    "signatureDrawTip": "Use un dedo, un lápiz táctil o el ratón.",
+    "signatureUploadTip": "Firme en un papel blanco y hágale una foto. Se elimina el papel y solo se conserva la tinta.",
+    "signatureChooseFile": "Elegir imagen",
+    "signatureClear": "Borrar",
+    "signatureSave": "Guardar firma",
+    "signatureCancel": "Cancelar",
+    "signatureReplace": "Reemplazar",
+    "signatureRemove": "Eliminar",
+    "signatureSaved": "Firma guardada",
+    "signatureRemoved": "Firma eliminada. Los documentos ya emitidos conservan la suya.",
+    "signatureSaveFailed": "No se pudo guardar la firma",
+    "signatureEmpty": "Primero dibuje o suba una firma",
+    "signatureNotImage": "Ese archivo no es una imagen",
+    "signatureNoInk": "No se encontró ninguna firma en esa imagen. Pruebe con un bolígrafo más oscuro sobre papel blanco.",
+    "signatureCanvasLabel": "Panel de firma",
+    "signatureDesignerHint": "Aparece donde un diseño tiene activada la sección Firma. Actívela en el <link>diseñador de documentos</link>."
   },
   "team": {
     "title": "Equipo",
@@ -734,9 +756,10 @@
       "result_detail": "Explicación",
       "result_summary": "Recuento por valoración",
       "combined_table": "Una sola tabla para todas las secciones",
-      "inspector_line": "Línea del inspector",
-      "inspector_name": "Imprimir el nombre del inspector",
-      "date_line": "Línea de fecha"
+      "inspector_line": "Línea de firma",
+      "inspector_name": "Nombre del firmante",
+      "date_line": "Línea de fecha",
+      "signature_image": "Firma"
     },
     "presetsTitle": "Empezar con una disposición",
     "presetsHint": "Sustituye la disposición de abajo. No se guarda nada hasta que guardes.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "Imprime un código que abre {link}, tu bot de Telegram conectado. Solo aparece en facturas emitidas después de activar el bloque.",
     "telegramQrPlaceholderHint": "No hay ningún bot de Telegram conectado, así que la hoja muestra un código de ejemplo que no se imprimirá.",
     "telegramQrLink": "Integraciones",
+    "signatureWhose": "De quién es la firma",
+    "signatureOwnHint": "Cada documento imprime la firma de quien lo emitió: la persona que abrió la orden de trabajo, redactó el presupuesto o hizo la inspección. La hoja muestra la tuya.",
+    "signaturePlaceholderHint": "Cada documento imprime la firma de quien lo emitió: la persona que abrió la orden de trabajo, redactó el presupuesto o hizo la inspección. Aún no has guardado ninguna, así que la hoja muestra un ejemplo.",
+    "signatureLink": "Configurar tu firma",
     "titleText": "Texto del título",
     "titleTextHint": "Cómo se llama este documento. Si lo dejas vacío, imprime su propio nombre en el idioma del lector.",
     "orgNumberLabel": "Etiqueta del número de organización",

+ 3 - 1
messages/fr/audit.json

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "Statut d’ordre modifié",
     "settings_workOrderStatus_archive": "Statut d’ordre retiré",
     "inspection_photo_added": "Photo d'inspection ajoutée",
-    "inspection_file_added": "Fichier d'inspection ajouté"
+    "inspection_file_added": "Fichier d'inspection ajouté",
+    "signature_save": "Signature enregistrée",
+    "signature_remove": "Signature supprimée"
   },
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",

+ 4 - 1
messages/fr/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Heures Moteur"
   },
   "common": {
-    "poweredBy": "Propulsé par"
+    "poweredBy": "Propulsé par",
+    "signature": "Signature",
+    "signedBy": "Signé par",
+    "signatureDate": "Date"
   }
 }

+ 31 - 4
messages/fr/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Atelier supprimé",
     "deleteWorkshopFailed": "Échec de la suppression de l'atelier",
     "deleteAccountMoved": "Vous souhaitez supprimer votre compte ?",
-    "deleteAccountMovedLink": "Vous pouvez le faire dans les paramètres des données."
+    "deleteAccountMovedLink": "Vous pouvez le faire dans les paramètres des données.",
+    "signatureTitle": "Signature",
+    "signatureDescription": "Imprimée sur les factures, devis et certificats que vous émettez pour cet atelier.",
+    "signatureDraw": "Dessiner",
+    "signatureUpload": "Importer",
+    "signatureDrawHint": "Signez ici",
+    "signatureUploadHint": "Choisissez une photo ou un scan de votre signature",
+    "signatureDrawTip": "Utilisez un doigt, un stylet ou la souris.",
+    "signatureUploadTip": "Signez sur du papier blanc et prenez une photo. Le papier est retiré et seule l'encre est conservée.",
+    "signatureChooseFile": "Choisir une image",
+    "signatureClear": "Effacer",
+    "signatureSave": "Enregistrer la signature",
+    "signatureCancel": "Annuler",
+    "signatureReplace": "Remplacer",
+    "signatureRemove": "Supprimer",
+    "signatureSaved": "Signature enregistrée",
+    "signatureRemoved": "Signature supprimée. Les documents déjà émis conservent la leur.",
+    "signatureSaveFailed": "Impossible d'enregistrer la signature",
+    "signatureEmpty": "Dessinez ou importez d'abord une signature",
+    "signatureNotImage": "Ce fichier n'est pas une image",
+    "signatureNoInk": "Aucune signature trouvée dans cette image. Essayez un stylo plus foncé sur du papier blanc.",
+    "signatureCanvasLabel": "Zone de signature",
+    "signatureDesignerHint": "Elle apparaît là où la section Signature est activée dans un design. Activez-la dans le <link>concepteur de documents</link>."
   },
   "team": {
     "title": "Équipe",
@@ -734,9 +756,10 @@
       "result_detail": "Explication",
       "result_summary": "Nombre par note",
       "combined_table": "Un seul tableau pour toutes les sections",
-      "inspector_line": "Ligne du contrôleur",
-      "inspector_name": "Imprimer le nom du contrôleur",
-      "date_line": "Ligne de date"
+      "inspector_line": "Ligne de signature",
+      "inspector_name": "Nom du signataire",
+      "date_line": "Ligne de date",
+      "signature_image": "Signature"
     },
     "presetsTitle": "Partir d’une disposition",
     "presetsHint": "Remplace la disposition ci-dessous. Rien n’est enregistré avant votre validation.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "Imprime un code qui ouvre {link}, votre bot Telegram connecté. Il n'apparaît que sur les factures émises après l'activation du bloc.",
     "telegramQrPlaceholderHint": "Aucun bot Telegram n'est connecté, la feuille affiche donc un code d'exemple qui ne sera pas imprimé.",
     "telegramQrLink": "Intégrations",
+    "signatureWhose": "Signature de qui",
+    "signatureOwnHint": "Chaque document imprime la signature de la personne qui l'a émis : celle qui a ouvert l'ordre de travail, rédigé le devis ou effectué le contrôle. La feuille affiche la vôtre.",
+    "signaturePlaceholderHint": "Chaque document imprime la signature de la personne qui l'a émis : celle qui a ouvert l'ordre de travail, rédigé le devis ou effectué le contrôle. Vous n'en avez pas encore enregistré, la feuille affiche donc un exemple.",
+    "signatureLink": "Définir votre signature",
     "titleText": "Texte du titre",
     "titleTextHint": "Le nom que porte ce document. Laissé vide, il imprime son propre nom dans la langue du lecteur.",
     "orgNumberLabel": "Libellé du numéro d'organisation",

+ 3 - 1
messages/it/audit.json

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "Stato ordine modificato",
     "settings_workOrderStatus_archive": "Stato ordine rimosso",
     "inspection_photo_added": "Foto di ispezione aggiunta",
-    "inspection_file_added": "File di ispezione aggiunto"
+    "inspection_file_added": "File di ispezione aggiunto",
+    "signature_save": "Firma salvata",
+    "signature_remove": "Firma rimossa"
   },
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",

+ 4 - 1
messages/it/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Ore Motore"
   },
   "common": {
-    "poweredBy": "Realizzato con"
+    "poweredBy": "Realizzato con",
+    "signature": "Firma",
+    "signedBy": "Firmato da",
+    "signatureDate": "Data"
   }
 }

+ 31 - 4
messages/it/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Officina eliminata",
     "deleteWorkshopFailed": "Impossibile eliminare l'officina",
     "deleteAccountMoved": "Vuoi eliminare il tuo account?",
-    "deleteAccountMovedLink": "Puoi farlo nelle impostazioni dei dati."
+    "deleteAccountMovedLink": "Puoi farlo nelle impostazioni dei dati.",
+    "signatureTitle": "Firma",
+    "signatureDescription": "Stampata su fatture, preventivi e certificati che emetti per questa officina.",
+    "signatureDraw": "Disegna",
+    "signatureUpload": "Carica",
+    "signatureDrawHint": "Firma qui",
+    "signatureUploadHint": "Scegli una foto o una scansione della tua firma",
+    "signatureDrawTip": "Usa un dito, un pennino o il mouse.",
+    "signatureUploadTip": "Firma su carta bianca e scatta una foto. La carta viene rimossa e resta solo l'inchiostro.",
+    "signatureChooseFile": "Scegli immagine",
+    "signatureClear": "Cancella",
+    "signatureSave": "Salva firma",
+    "signatureCancel": "Annulla",
+    "signatureReplace": "Sostituisci",
+    "signatureRemove": "Rimuovi",
+    "signatureSaved": "Firma salvata",
+    "signatureRemoved": "Firma rimossa. I documenti già emessi mantengono la loro.",
+    "signatureSaveFailed": "Impossibile salvare la firma",
+    "signatureEmpty": "Prima disegna o carica una firma",
+    "signatureNotImage": "Il file non è un'immagine",
+    "signatureNoInk": "Nessuna firma trovata nell'immagine. Prova con una penna più scura su carta bianca.",
+    "signatureCanvasLabel": "Area della firma",
+    "signatureDesignerHint": "Compare dove un design ha attiva la sezione Firma. Attivala nel <link>designer dei documenti</link>."
   },
   "team": {
     "title": "Team",
@@ -734,9 +756,10 @@
       "result_detail": "Spiegazione",
       "result_summary": "Conteggio per esito",
       "combined_table": "Una sola tabella per tutte le sezioni",
-      "inspector_line": "Riga dell'ispettore",
-      "inspector_name": "Stampa il nome dell'ispettore",
-      "date_line": "Riga della data"
+      "inspector_line": "Riga della firma",
+      "inspector_name": "Nome del firmatario",
+      "date_line": "Riga della data",
+      "signature_image": "Firma"
     },
     "presetsTitle": "Partire da una disposizione",
     "presetsHint": "Sostituisce la disposizione qui sotto. Nulla viene salvato finché non salvi.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "Stampa un codice che apre {link}, il tuo bot Telegram collegato. Compare solo sulle fatture emesse dopo l'attivazione del blocco.",
     "telegramQrPlaceholderHint": "Nessun bot Telegram è collegato, quindi il foglio mostra un codice di esempio che non verrà stampato.",
     "telegramQrLink": "Integrazioni",
+    "signatureWhose": "Di chi è la firma",
+    "signatureOwnHint": "Ogni documento stampa la firma di chi lo ha emesso: la persona che ha aperto l'ordine di lavoro, redatto il preventivo o eseguito l'ispezione. Il foglio mostra la tua.",
+    "signaturePlaceholderHint": "Ogni documento stampa la firma di chi lo ha emesso: la persona che ha aperto l'ordine di lavoro, redatto il preventivo o eseguito l'ispezione. Non ne hai ancora salvata una, quindi il foglio mostra un esempio.",
+    "signatureLink": "Imposta la tua firma",
     "titleText": "Testo del titolo",
     "titleTextHint": "Come si chiama questo documento. Se lasciato vuoto, stampa il proprio nome nella lingua del lettore.",
     "orgNumberLabel": "Etichetta del numero di organizzazione",

+ 3 - 1
messages/lt/audit.json

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "Pakeista užsakymo būsena",
     "settings_workOrderStatus_archive": "Pašalinta užsakymo būsena",
     "inspection_photo_added": "Pridėta apžiūros nuotrauka",
-    "inspection_file_added": "Pridėtas apžiūros failas"
+    "inspection_file_added": "Pridėtas apžiūros failas",
+    "signature_save": "Išsaugotas parašas",
+    "signature_remove": "Pašalintas parašas"
   },
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",

+ 4 - 1
messages/lt/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Variklio val."
   },
   "common": {
-    "poweredBy": "Sukurta su"
+    "poweredBy": "Sukurta su",
+    "signature": "Parašas",
+    "signedBy": "Pasirašė",
+    "signatureDate": "Data"
   }
 }

+ 31 - 4
messages/lt/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Dirbtuvės ištrintos",
     "deleteWorkshopFailed": "Nepavyko ištrinti dirbtuvių",
     "deleteAccountMoved": "Norite ištrinti savo paskyrą?",
-    "deleteAccountMovedLink": "Tai galite padaryti duomenų nustatymuose."
+    "deleteAccountMovedLink": "Tai galite padaryti duomenų nustatymuose.",
+    "signatureTitle": "Parašas",
+    "signatureDescription": "Spausdinamas ant sąskaitų, pasiūlymų ir pažymų, kurias išrašote šioms dirbtuvėms.",
+    "signatureDraw": "Piešti",
+    "signatureUpload": "Įkelti",
+    "signatureDrawHint": "Pasirašykite čia",
+    "signatureUploadHint": "Pasirinkite savo parašo nuotrauką arba skenuotą vaizdą",
+    "signatureDrawTip": "Naudokite pirštą, rašiklį arba pelę.",
+    "signatureUploadTip": "Pasirašykite ant balto popieriaus ir nufotografuokite. Popierius pašalinamas, paliekamas tik rašalas.",
+    "signatureChooseFile": "Pasirinkti vaizdą",
+    "signatureClear": "Išvalyti",
+    "signatureSave": "Išsaugoti parašą",
+    "signatureCancel": "Atšaukti",
+    "signatureReplace": "Pakeisti",
+    "signatureRemove": "Pašalinti",
+    "signatureSaved": "Parašas išsaugotas",
+    "signatureRemoved": "Parašas pašalintas. Jau išrašyti dokumentai išlaiko savo parašą.",
+    "signatureSaveFailed": "Nepavyko išsaugoti parašo",
+    "signatureEmpty": "Pirmiausia nupieškite arba įkelkite parašą",
+    "signatureNotImage": "Šis failas nėra vaizdas",
+    "signatureNoInk": "Šiame vaizde parašo nerasta. Pabandykite tamsesniu rašikliu ant balto popieriaus.",
+    "signatureCanvasLabel": "Parašo laukas",
+    "signatureDesignerHint": "Jis rodomas ten, kur dizaine įjungta skiltis Parašas. Įjunkite ją <link>dokumentų dizaineryje</link>."
   },
   "team": {
     "title": "Komanda",
@@ -734,9 +756,10 @@
       "result_detail": "Paaiškinimas",
       "result_summary": "Kiekis pagal įvertinimą",
       "combined_table": "Viena lentelė visiems skyriams",
-      "inspector_line": "Tikrintojo eilutė",
-      "inspector_name": "Spausdinti tikrintojo vardą",
-      "date_line": "Datos eilutė"
+      "inspector_line": "Parašo eilutė",
+      "inspector_name": "Pasirašiusiojo vardas",
+      "date_line": "Datos eilutė",
+      "signature_image": "Parašas"
     },
     "presetsTitle": "Pradėti nuo išdėstymo",
     "presetsHint": "Pakeičia žemiau esantį išdėstymą. Niekas neįrašoma, kol neišsaugote.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "Spausdina kodą, atidarantį {link}, jūsų prijungtą Telegram botą. Jis rodomas tik sąskaitose, išrašytose įjungus bloką.",
     "telegramQrPlaceholderHint": "Telegram botas neprijungtas, todėl lape rodomas pavyzdinis kodas, kuris nebus spausdinamas.",
     "telegramQrLink": "Integracijos",
+    "signatureWhose": "Kieno parašas",
+    "signatureOwnHint": "Kiekviename dokumente spausdinamas jį išrašiusio asmens parašas: to, kuris atidarė užsakymą, parengė pasiūlymą ar atliko apžiūrą. Lape rodomas jūsų parašas.",
+    "signaturePlaceholderHint": "Kiekviename dokumente spausdinamas jį išrašiusio asmens parašas: to, kuris atidarė užsakymą, parengė pasiūlymą ar atliko apžiūrą. Dar neišsaugojote parašo, todėl lape rodomas pavyzdys.",
+    "signatureLink": "Nustatyti savo parašą",
     "titleText": "Pavadinimo tekstas",
     "titleTextHint": "Kaip vadinasi šis dokumentas. Palikus tuščią, spausdinamas jo paties pavadinimas skaitytojo kalba.",
     "orgNumberLabel": "Organizacijos numerio pavadinimas",

+ 3 - 1
messages/nb/audit.json

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "Endret status for arbeidsordre",
     "settings_workOrderStatus_archive": "Fjernet status for arbeidsordre",
     "inspection_photo_added": "La til kontrollbilde",
-    "inspection_file_added": "La til kontrollfil"
+    "inspection_file_added": "La til kontrollfil",
+    "signature_save": "Lagret signatur",
+    "signature_remove": "Fjernet signatur"
   },
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",

+ 4 - 1
messages/nb/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Motortimer"
   },
   "common": {
-    "poweredBy": "Drevet av"
+    "poweredBy": "Drevet av",
+    "signature": "Signatur",
+    "signedBy": "Signert av",
+    "signatureDate": "Dato"
   }
 }

+ 31 - 4
messages/nb/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Verksted slettet",
     "deleteWorkshopFailed": "Kunne ikke slette verkstedet",
     "deleteAccountMoved": "Vil du slette kontoen din?",
-    "deleteAccountMovedLink": "Det kan du gjøre i datainnstillingene."
+    "deleteAccountMovedLink": "Det kan du gjøre i datainnstillingene.",
+    "signatureTitle": "Signatur",
+    "signatureDescription": "Skrives ut på fakturaene, tilbudene og attestene du utsteder for dette verkstedet.",
+    "signatureDraw": "Tegn",
+    "signatureUpload": "Last opp",
+    "signatureDrawHint": "Signer her",
+    "signatureUploadHint": "Velg et bilde eller en skanning av signaturen din",
+    "signatureDrawTip": "Bruk en finger, en pekepenn eller musen.",
+    "signatureUploadTip": "Signer på hvitt papir og ta et bilde. Papiret fjernes og bare blekket beholdes.",
+    "signatureChooseFile": "Velg bilde",
+    "signatureClear": "Tøm",
+    "signatureSave": "Lagre signatur",
+    "signatureCancel": "Avbryt",
+    "signatureReplace": "Bytt ut",
+    "signatureRemove": "Fjern",
+    "signatureSaved": "Signatur lagret",
+    "signatureRemoved": "Signaturen er fjernet. Dokumenter som allerede er utstedt, beholder sin.",
+    "signatureSaveFailed": "Kunne ikke lagre signaturen",
+    "signatureEmpty": "Tegn eller last opp en signatur først",
+    "signatureNotImage": "Filen er ikke et bilde",
+    "signatureNoInk": "Fant ingen signatur i bildet. Prøv en mørkere penn på hvitt papir.",
+    "signatureCanvasLabel": "Signaturfelt",
+    "signatureDesignerHint": "Den vises der et design har seksjonen Signatur slått på. Slå den på i <link>dokumentdesigneren</link>."
   },
   "team": {
     "title": "Team",
@@ -734,9 +756,10 @@
       "result_detail": "Forklaring",
       "result_summary": "Antall per vurdering",
       "combined_table": "Én tabell for alle seksjoner",
-      "inspector_line": "Linje for kontrollør",
-      "inspector_name": "Skriv ut kontrollørens navn",
-      "date_line": "Datolinje"
+      "inspector_line": "Signaturlinje",
+      "inspector_name": "Underskriverens navn",
+      "date_line": "Datolinje",
+      "signature_image": "Signatur"
     },
     "presetsTitle": "Start fra et oppsett",
     "presetsHint": "Erstatter oppsettet under. Ingenting lagres før du lagrer.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "Skriver ut en kode som åpner {link}, Telegram-boten din. Den vises bare på fakturaer utstedt etter at blokken ble slått på.",
     "telegramQrPlaceholderHint": "Ingen Telegram-bot er koblet til, så arket viser en eksempelkode som ikke blir skrevet ut.",
     "telegramQrLink": "Integrasjoner",
+    "signatureWhose": "Hvem sin signatur",
+    "signatureOwnHint": "Hvert dokument skriver ut signaturen til den som utstedte det: den som opprettet arbeidsordren, skrev tilbudet eller utførte kontrollen. Arket viser din.",
+    "signaturePlaceholderHint": "Hvert dokument skriver ut signaturen til den som utstedte det: den som opprettet arbeidsordren, skrev tilbudet eller utførte kontrollen. Du har ikke lagret en ennå, så arket viser et eksempel.",
+    "signatureLink": "Legg inn signaturen din",
     "titleText": "Titteltekst",
     "titleTextHint": "Hva dette dokumentet kaller seg. Står feltet tomt, skrives dokumentets eget navn på leserens språk.",
     "orgNumberLabel": "Betegnelse på organisasjonsnummer",

+ 3 - 1
messages/nl/audit.json

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "Werkorderstatus gewijzigd",
     "settings_workOrderStatus_archive": "Werkorderstatus verwijderd",
     "inspection_photo_added": "Keuringsfoto toegevoegd",
-    "inspection_file_added": "Keuringsbestand toegevoegd"
+    "inspection_file_added": "Keuringsbestand toegevoegd",
+    "signature_save": "Handtekening opgeslagen",
+    "signature_remove": "Handtekening verwijderd"
   },
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",

+ 4 - 1
messages/nl/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Motoruren"
   },
   "common": {
-    "poweredBy": "Mogelijk gemaakt door"
+    "poweredBy": "Mogelijk gemaakt door",
+    "signature": "Handtekening",
+    "signedBy": "Ondertekend door",
+    "signatureDate": "Datum"
   }
 }

+ 31 - 4
messages/nl/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Werkplaats verwijderd",
     "deleteWorkshopFailed": "Kan de werkplaats niet verwijderen",
     "deleteAccountMoved": "Wilt u uw account verwijderen?",
-    "deleteAccountMovedLink": "Dat kan in de gegevensinstellingen."
+    "deleteAccountMovedLink": "Dat kan in de gegevensinstellingen.",
+    "signatureTitle": "Handtekening",
+    "signatureDescription": "Wordt afgedrukt op de facturen, offertes en certificaten die u voor deze werkplaats uitgeeft.",
+    "signatureDraw": "Tekenen",
+    "signatureUpload": "Uploaden",
+    "signatureDrawHint": "Hier tekenen",
+    "signatureUploadHint": "Kies een foto of scan van uw handtekening",
+    "signatureDrawTip": "Gebruik een vinger, een stylus of de muis.",
+    "signatureUploadTip": "Zet uw handtekening op wit papier en maak een foto. Het papier wordt verwijderd en alleen de inkt blijft over.",
+    "signatureChooseFile": "Afbeelding kiezen",
+    "signatureClear": "Wissen",
+    "signatureSave": "Handtekening opslaan",
+    "signatureCancel": "Annuleren",
+    "signatureReplace": "Vervangen",
+    "signatureRemove": "Verwijderen",
+    "signatureSaved": "Handtekening opgeslagen",
+    "signatureRemoved": "Handtekening verwijderd. Al uitgegeven documenten behouden de hunne.",
+    "signatureSaveFailed": "Kan de handtekening niet opslaan",
+    "signatureEmpty": "Teken of upload eerst een handtekening",
+    "signatureNotImage": "Dat bestand is geen afbeelding",
+    "signatureNoInk": "Geen handtekening gevonden in die afbeelding. Probeer een donkerdere pen op wit papier.",
+    "signatureCanvasLabel": "Handtekeningveld",
+    "signatureDesignerHint": "Deze verschijnt waar een ontwerp de sectie Handtekening heeft ingeschakeld. Schakel die in via de <link>documentontwerper</link>."
   },
   "team": {
     "title": "Team",
@@ -734,9 +756,10 @@
       "result_detail": "Toelichting",
       "result_summary": "Aantal per beoordeling",
       "combined_table": "Eén tabel voor alle secties",
-      "inspector_line": "Regel keurmeester",
-      "inspector_name": "Naam van de keurmeester afdrukken",
-      "date_line": "Datumregel"
+      "inspector_line": "Handtekeningregel",
+      "inspector_name": "Naam van de ondertekenaar",
+      "date_line": "Datumregel",
+      "signature_image": "Handtekening"
     },
     "presetsTitle": "Begin met een indeling",
     "presetsHint": "Vervangt de indeling hieronder. Er wordt niets opgeslagen tot u opslaat.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "Drukt een code af die {link} opent, uw gekoppelde Telegram-bot. Hij verschijnt alleen op facturen die zijn uitgegeven nadat het blok is ingeschakeld.",
     "telegramQrPlaceholderHint": "Er is geen Telegram-bot gekoppeld, dus het blad toont een voorbeeldcode die niet wordt afgedrukt.",
     "telegramQrLink": "Integraties",
+    "signatureWhose": "Wiens handtekening",
+    "signatureOwnHint": "Elk document drukt de handtekening af van wie het heeft uitgegeven: degene die de werkorder opende, de offerte opstelde of de keuring uitvoerde. Het blad toont de uwe.",
+    "signaturePlaceholderHint": "Elk document drukt de handtekening af van wie het heeft uitgegeven: degene die de werkorder opende, de offerte opstelde of de keuring uitvoerde. U hebt er nog geen opgeslagen, dus het blad toont een voorbeeld.",
+    "signatureLink": "Uw handtekening instellen",
     "titleText": "Titeltekst",
     "titleTextHint": "Hoe dit document zichzelf noemt. Laat je het leeg, dan drukt het zijn eigen naam af in de taal van de lezer.",
     "orgNumberLabel": "Naam van het organisatienummer",

+ 3 - 1
messages/pl/audit.json

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "Zmieniono status zlecenia",
     "settings_workOrderStatus_archive": "Usunięto status zlecenia",
     "inspection_photo_added": "Dodano zdjęcie przeglądu",
-    "inspection_file_added": "Dodano plik przeglądu"
+    "inspection_file_added": "Dodano plik przeglądu",
+    "signature_save": "Zapisano podpis",
+    "signature_remove": "Usunięto podpis"
   },
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",

+ 4 - 1
messages/pl/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Godziny Silnika"
   },
   "common": {
-    "poweredBy": "Obsługiwane przez"
+    "poweredBy": "Obsługiwane przez",
+    "signature": "Podpis",
+    "signedBy": "Podpisał(a)",
+    "signatureDate": "Data"
   }
 }

+ 31 - 4
messages/pl/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Warsztat usunięty",
     "deleteWorkshopFailed": "Nie udało się usunąć warsztatu",
     "deleteAccountMoved": "Chcesz usunąć swoje konto?",
-    "deleteAccountMovedLink": "Możesz to zrobić w ustawieniach danych."
+    "deleteAccountMovedLink": "Możesz to zrobić w ustawieniach danych.",
+    "signatureTitle": "Podpis",
+    "signatureDescription": "Drukowany na fakturach, ofertach i świadectwach wystawianych przez Ciebie dla tego warsztatu.",
+    "signatureDraw": "Narysuj",
+    "signatureUpload": "Prześlij",
+    "signatureDrawHint": "Podpisz tutaj",
+    "signatureUploadHint": "Wybierz zdjęcie lub skan swojego podpisu",
+    "signatureDrawTip": "Użyj palca, rysika lub myszy.",
+    "signatureUploadTip": "Podpisz się na białej kartce i zrób zdjęcie. Tło kartki zostanie usunięte, a zostanie tylko tusz.",
+    "signatureChooseFile": "Wybierz obraz",
+    "signatureClear": "Wyczyść",
+    "signatureSave": "Zapisz podpis",
+    "signatureCancel": "Anuluj",
+    "signatureReplace": "Zastąp",
+    "signatureRemove": "Usuń",
+    "signatureSaved": "Podpis zapisany",
+    "signatureRemoved": "Podpis usunięty. Już wystawione dokumenty zachowują swój podpis.",
+    "signatureSaveFailed": "Nie udało się zapisać podpisu",
+    "signatureEmpty": "Najpierw narysuj lub prześlij podpis",
+    "signatureNotImage": "Ten plik nie jest obrazem",
+    "signatureNoInk": "Nie znaleziono podpisu na tym obrazie. Spróbuj ciemniejszym długopisem na białej kartce.",
+    "signatureCanvasLabel": "Pole podpisu",
+    "signatureDesignerHint": "Pojawia się tam, gdzie w projekcie włączona jest sekcja Podpis. Włącz ją w <link>kreatorze dokumentów</link>."
   },
   "team": {
     "title": "Zespol",
@@ -734,9 +756,10 @@
       "result_detail": "Objaśnienie",
       "result_summary": "Liczba według oceny",
       "combined_table": "Jedna tabela dla wszystkich sekcji",
-      "inspector_line": "Linia diagnosty",
-      "inspector_name": "Drukuj nazwisko diagnosty",
-      "date_line": "Linia daty"
+      "inspector_line": "Linia podpisu",
+      "inspector_name": "Imię i nazwisko podpisującego",
+      "date_line": "Linia daty",
+      "signature_image": "Podpis"
     },
     "presetsTitle": "Zacznij od układu",
     "presetsHint": "Zastępuje układ poniżej. Nic nie zostanie zapisane, dopóki nie zapiszesz.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "Drukuje kod otwierający {link}, Twojego podłączonego bota Telegram. Pojawia się tylko na fakturach wystawionych po włączeniu bloku.",
     "telegramQrPlaceholderHint": "Żaden bot Telegram nie jest podłączony, więc arkusz pokazuje przykładowy kod, który nie zostanie wydrukowany.",
     "telegramQrLink": "Integracje",
+    "signatureWhose": "Czyj podpis",
+    "signatureOwnHint": "Każdy dokument drukuje podpis osoby, która go wystawiła: tej, która otworzyła zlecenie, przygotowała ofertę lub wykonała przegląd. Arkusz pokazuje Twój.",
+    "signaturePlaceholderHint": "Każdy dokument drukuje podpis osoby, która go wystawiła: tej, która otworzyła zlecenie, przygotowała ofertę lub wykonała przegląd. Nie masz jeszcze zapisanego podpisu, więc arkusz pokazuje przykład.",
+    "signatureLink": "Ustaw swój podpis",
     "titleText": "Tekst tytułu",
     "titleTextHint": "Jak nazywa się ten dokument. Pozostawione puste, drukuje własną nazwę w języku odbiorcy.",
     "orgNumberLabel": "Nazwa numeru organizacji",

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

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "Status de ordem alterado",
     "settings_workOrderStatus_archive": "Status de ordem removido",
     "inspection_photo_added": "Adicionou foto de inspeção",
-    "inspection_file_added": "Adicionou arquivo de inspeção"
+    "inspection_file_added": "Adicionou arquivo de inspeção",
+    "signature_save": "Salvou a assinatura",
+    "signature_remove": "Removeu a assinatura"
   },
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",

+ 4 - 1
messages/pt-BR/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Horas Motor"
   },
   "common": {
-    "poweredBy": "Desenvolvido por"
+    "poweredBy": "Desenvolvido por",
+    "signature": "Assinatura",
+    "signedBy": "Assinado por",
+    "signatureDate": "Data"
   }
 }

+ 31 - 4
messages/pt-BR/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Oficina excluída",
     "deleteWorkshopFailed": "Falha ao excluir a oficina",
     "deleteAccountMoved": "Quer excluir sua conta?",
-    "deleteAccountMovedLink": "Você pode fazer isso nas configurações de dados."
+    "deleteAccountMovedLink": "Você pode fazer isso nas configurações de dados.",
+    "signatureTitle": "Assinatura",
+    "signatureDescription": "Impressa nas faturas, orçamentos e certificados que você emite para esta oficina.",
+    "signatureDraw": "Desenhar",
+    "signatureUpload": "Enviar",
+    "signatureDrawHint": "Assine aqui",
+    "signatureUploadHint": "Escolha uma foto ou digitalização da sua assinatura",
+    "signatureDrawTip": "Use o dedo, uma caneta stylus ou o mouse.",
+    "signatureUploadTip": "Assine em papel branco e tire uma foto. O papel é removido e só a tinta é mantida.",
+    "signatureChooseFile": "Escolher imagem",
+    "signatureClear": "Limpar",
+    "signatureSave": "Salvar assinatura",
+    "signatureCancel": "Cancelar",
+    "signatureReplace": "Substituir",
+    "signatureRemove": "Remover",
+    "signatureSaved": "Assinatura salva",
+    "signatureRemoved": "Assinatura removida. Os documentos já emitidos mantêm a deles.",
+    "signatureSaveFailed": "Não foi possível salvar a assinatura",
+    "signatureEmpty": "Desenhe ou envie uma assinatura primeiro",
+    "signatureNotImage": "Esse arquivo não é uma imagem",
+    "signatureNoInk": "Nenhuma assinatura encontrada nessa imagem. Tente uma caneta mais escura em papel branco.",
+    "signatureCanvasLabel": "Área de assinatura",
+    "signatureDesignerHint": "Ela aparece onde um design tem a seção Assinatura ativada. Ative-a no <link>designer de documentos</link>."
   },
   "team": {
     "title": "Equipe",
@@ -734,9 +756,10 @@
       "result_detail": "Explicação",
       "result_summary": "Contagem por avaliação",
       "combined_table": "Uma tabela para todas as seções",
-      "inspector_line": "Linha do inspetor",
-      "inspector_name": "Imprimir o nome do inspetor",
-      "date_line": "Linha da data"
+      "inspector_line": "Linha de assinatura",
+      "inspector_name": "Nome de quem assina",
+      "date_line": "Linha da data",
+      "signature_image": "Assinatura"
     },
     "presetsTitle": "Começar por uma disposição",
     "presetsHint": "Substitui a disposição abaixo. Nada é salvo até você salvar.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "Imprime um código que abre {link}, seu bot do Telegram conectado. Aparece apenas em faturas emitidas depois que o bloco foi ativado.",
     "telegramQrPlaceholderHint": "Nenhum bot do Telegram está conectado, então a folha mostra um código de exemplo que não será impresso.",
     "telegramQrLink": "Integrações",
+    "signatureWhose": "De quem é a assinatura",
+    "signatureOwnHint": "Cada documento imprime a assinatura de quem o emitiu: a pessoa que abriu a ordem de serviço, fez o orçamento ou realizou a inspeção. A folha mostra a sua.",
+    "signaturePlaceholderHint": "Cada documento imprime a assinatura de quem o emitiu: a pessoa que abriu a ordem de serviço, fez o orçamento ou realizou a inspeção. Você ainda não salvou uma, então a folha mostra um exemplo.",
+    "signatureLink": "Definir sua assinatura",
     "titleText": "Texto do título",
     "titleTextHint": "Como este documento se chama. Deixado vazio, imprime o próprio nome no idioma do leitor.",
     "orgNumberLabel": "Rótulo do número da organização",

+ 3 - 1
messages/pt-PT/audit.json

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "Estado de ordem alterado",
     "settings_workOrderStatus_archive": "Estado de ordem removido",
     "inspection_photo_added": "Adicionou fotografia de inspeção",
-    "inspection_file_added": "Adicionou ficheiro de inspeção"
+    "inspection_file_added": "Adicionou ficheiro de inspeção",
+    "signature_save": "Guardou a assinatura",
+    "signature_remove": "Removeu a assinatura"
   },
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",

+ 4 - 1
messages/pt-PT/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Horas Motor"
   },
   "common": {
-    "poweredBy": "Desenvolvido por"
+    "poweredBy": "Desenvolvido por",
+    "signature": "Assinatura",
+    "signedBy": "Assinado por",
+    "signatureDate": "Data"
   }
 }

+ 31 - 4
messages/pt-PT/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Oficina eliminada",
     "deleteWorkshopFailed": "Não foi possível eliminar a oficina",
     "deleteAccountMoved": "Quer eliminar a sua conta?",
-    "deleteAccountMovedLink": "Pode fazê-lo nas definições de Dados."
+    "deleteAccountMovedLink": "Pode fazê-lo nas definições de Dados.",
+    "signatureTitle": "Assinatura",
+    "signatureDescription": "Impressa nas faturas, orçamentos e certificados que emite para esta oficina.",
+    "signatureDraw": "Desenhar",
+    "signatureUpload": "Carregar",
+    "signatureDrawHint": "Assine aqui",
+    "signatureUploadHint": "Escolha uma fotografia ou digitalização da sua assinatura",
+    "signatureDrawTip": "Use o dedo, uma caneta digital ou o rato.",
+    "signatureUploadTip": "Assine em papel branco e tire uma fotografia. O papel é removido e só a tinta é mantida.",
+    "signatureChooseFile": "Escolher imagem",
+    "signatureClear": "Limpar",
+    "signatureSave": "Guardar assinatura",
+    "signatureCancel": "Cancelar",
+    "signatureReplace": "Substituir",
+    "signatureRemove": "Remover",
+    "signatureSaved": "Assinatura guardada",
+    "signatureRemoved": "Assinatura removida. Os documentos já emitidos mantêm a sua.",
+    "signatureSaveFailed": "Não foi possível guardar a assinatura",
+    "signatureEmpty": "Desenhe ou carregue primeiro uma assinatura",
+    "signatureNotImage": "Esse ficheiro não é uma imagem",
+    "signatureNoInk": "Não foi encontrada nenhuma assinatura nessa imagem. Experimente uma caneta mais escura em papel branco.",
+    "signatureCanvasLabel": "Área de assinatura",
+    "signatureDesignerHint": "Aparece onde um design tem a secção Assinatura ativada. Ative-a no <link>editor de modelos</link>."
   },
   "team": {
     "title": "Equipa",
@@ -734,9 +756,10 @@
       "result_detail": "Explicação",
       "result_summary": "Contagem por avaliação",
       "combined_table": "Uma tabela para todas as secções",
-      "inspector_line": "Linha do inspetor",
-      "inspector_name": "Imprimir o nome do inspetor",
-      "date_line": "Linha da data"
+      "inspector_line": "Linha de assinatura",
+      "inspector_name": "Nome de quem assina",
+      "date_line": "Linha da data",
+      "signature_image": "Assinatura"
     },
     "presetsTitle": "Começar por uma disposição",
     "presetsHint": "Substitui a disposição abaixo. Nada é guardado até clicar em Guardar.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "Imprime um código que abre {link}, o seu bot do Telegram ligado. Só é impresso em faturas emitidas depois de o bloco ter sido ativado.",
     "telegramQrPlaceholderHint": "Não há nenhum bot do Telegram ligado, por isso a folha mostra um código de exemplo que não será impresso.",
     "telegramQrLink": "Integrações",
+    "signatureWhose": "De quem é a assinatura",
+    "signatureOwnHint": "Cada documento imprime a assinatura de quem o emitiu: a pessoa que abriu a ordem de trabalho, fez o orçamento ou realizou a inspeção. A folha mostra a sua.",
+    "signaturePlaceholderHint": "Cada documento imprime a assinatura de quem o emitiu: a pessoa que abriu a ordem de trabalho, fez o orçamento ou realizou a inspeção. Ainda não guardou nenhuma, por isso a folha mostra um exemplo.",
+    "signatureLink": "Definir a sua assinatura",
     "titleText": "Texto do título",
     "titleTextHint": "O nome que este documento dá a si próprio. Se ficar vazio, imprime o seu próprio nome no idioma do leitor.",
     "orgNumberLabel": "Etiqueta do NIF",

+ 3 - 1
messages/ru/audit.json

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "Изменён статус заказ-наряда",
     "settings_workOrderStatus_archive": "Удалён статус заказ-наряда",
     "inspection_photo_added": "Добавлено фото осмотра",
-    "inspection_file_added": "Добавлен файл осмотра"
+    "inspection_file_added": "Добавлен файл осмотра",
+    "signature_save": "Сохранена подпись",
+    "signature_remove": "Удалена подпись"
   },
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",

+ 4 - 1
messages/ru/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Моточасы"
   },
   "common": {
-    "poweredBy": "Работает на"
+    "poweredBy": "Работает на",
+    "signature": "Подпись",
+    "signedBy": "Подписал(а)",
+    "signatureDate": "Дата"
   }
 }

+ 31 - 4
messages/ru/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Мастерская удалена",
     "deleteWorkshopFailed": "Не удалось удалить мастерскую",
     "deleteAccountMoved": "Хотите удалить свою учётную запись?",
-    "deleteAccountMovedLink": "Это можно сделать в настройках данных."
+    "deleteAccountMovedLink": "Это можно сделать в настройках данных.",
+    "signatureTitle": "Подпись",
+    "signatureDescription": "Печатается на счетах, сметах и сертификатах, которые вы выставляете для этой мастерской.",
+    "signatureDraw": "Нарисовать",
+    "signatureUpload": "Загрузить",
+    "signatureDrawHint": "Распишитесь здесь",
+    "signatureUploadHint": "Выберите фото или скан вашей подписи",
+    "signatureDrawTip": "Используйте палец, стилус или мышь.",
+    "signatureUploadTip": "Распишитесь на белой бумаге и сфотографируйте. Бумага удаляется, остаются только чернила.",
+    "signatureChooseFile": "Выбрать изображение",
+    "signatureClear": "Очистить",
+    "signatureSave": "Сохранить подпись",
+    "signatureCancel": "Отмена",
+    "signatureReplace": "Заменить",
+    "signatureRemove": "Удалить",
+    "signatureSaved": "Подпись сохранена",
+    "signatureRemoved": "Подпись удалена. Уже выставленные документы сохраняют свою.",
+    "signatureSaveFailed": "Не удалось сохранить подпись",
+    "signatureEmpty": "Сначала нарисуйте или загрузите подпись",
+    "signatureNotImage": "Этот файл не является изображением",
+    "signatureNoInk": "На изображении не найдена подпись. Попробуйте ручку потемнее на белой бумаге.",
+    "signatureCanvasLabel": "Поле для подписи",
+    "signatureDesignerHint": "Она появляется там, где в дизайне включён раздел «Подпись». Включите его в <link>дизайнере документов</link>."
   },
   "team": {
     "title": "Команда",
@@ -734,9 +756,10 @@
       "result_detail": "Пояснение",
       "result_summary": "Количество по оценкам",
       "combined_table": "Одна таблица для всех разделов",
-      "inspector_line": "Строка инспектора",
-      "inspector_name": "Печатать имя инспектора",
-      "date_line": "Строка даты"
+      "inspector_line": "Строка подписи",
+      "inspector_name": "Имя подписавшего",
+      "date_line": "Строка даты",
+      "signature_image": "Подпись"
     },
     "presetsTitle": "Начать с расположения",
     "presetsHint": "Заменяет расположение ниже. Ничего не сохранится, пока вы не сохраните.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "Печатает код, открывающий {link}, ваш подключённый бот Telegram. Появляется только на счетах, выставленных после включения блока.",
     "telegramQrPlaceholderHint": "Бот Telegram не подключён, поэтому на листе показан образец кода, который не будет напечатан.",
     "telegramQrLink": "Интеграции",
+    "signatureWhose": "Чья подпись",
+    "signatureOwnHint": "Каждый документ печатается с подписью того, кто его выставил: кто открыл заказ-наряд, составил смету или провёл осмотр. На листе показана ваша.",
+    "signaturePlaceholderHint": "Каждый документ печатается с подписью того, кто его выставил: кто открыл заказ-наряд, составил смету или провёл осмотр. Вы ещё не сохранили подпись, поэтому на листе показан образец.",
+    "signatureLink": "Задать свою подпись",
     "titleText": "Текст заголовка",
     "titleTextHint": "Как называется этот документ. Если оставить пустым, печатается его собственное название на языке читателя.",
     "orgNumberLabel": "Название номера организации",

+ 3 - 1
messages/tr/audit.json

@@ -182,7 +182,9 @@
     "settings_workOrderStatus_update": "İş emri durumu değiştirildi",
     "settings_workOrderStatus_archive": "İş emri durumu kaldırıldı",
     "inspection_photo_added": "Muayene fotoğrafı eklendi",
-    "inspection_file_added": "Muayene dosyası eklendi"
+    "inspection_file_added": "Muayene dosyası eklendi",
+    "signature_save": "İmza kaydedildi",
+    "signature_remove": "İmza kaldırıldı"
   },
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",

+ 4 - 1
messages/tr/pdf.json

@@ -205,6 +205,9 @@
     "mileageColMarine": "Motor Saati"
   },
   "common": {
-    "poweredBy": "Destekleyen"
+    "poweredBy": "Destekleyen",
+    "signature": "İmza",
+    "signedBy": "İmzalayan",
+    "signatureDate": "Tarih"
   }
 }

+ 31 - 4
messages/tr/settings.json

@@ -348,7 +348,29 @@
     "deleteWorkshopSuccess": "Atölye silindi",
     "deleteWorkshopFailed": "Atölye silinemedi",
     "deleteAccountMoved": "Hesabınızı silmek mi istiyorsunuz?",
-    "deleteAccountMovedLink": "Bunu veri ayarlarından yapabilirsiniz."
+    "deleteAccountMovedLink": "Bunu veri ayarlarından yapabilirsiniz.",
+    "signatureTitle": "İmza",
+    "signatureDescription": "Bu atölye için düzenlediğiniz faturalara, tekliflere ve sertifikalara basılır.",
+    "signatureDraw": "Çiz",
+    "signatureUpload": "Yükle",
+    "signatureDrawHint": "Buraya imzalayın",
+    "signatureUploadHint": "İmzanızın bir fotoğrafını veya taramasını seçin",
+    "signatureDrawTip": "Parmağınızı, bir kalemi veya fareyi kullanın.",
+    "signatureUploadTip": "Beyaz kağıda imza atın ve fotoğrafını çekin. Kağıt kaldırılır, yalnızca mürekkep kalır.",
+    "signatureChooseFile": "Görsel seç",
+    "signatureClear": "Temizle",
+    "signatureSave": "İmzayı kaydet",
+    "signatureCancel": "İptal",
+    "signatureReplace": "Değiştir",
+    "signatureRemove": "Kaldır",
+    "signatureSaved": "İmza kaydedildi",
+    "signatureRemoved": "İmza kaldırıldı. Daha önce düzenlenmiş belgeler kendi imzalarını korur.",
+    "signatureSaveFailed": "İmza kaydedilemedi",
+    "signatureEmpty": "Önce bir imza çizin veya yükleyin",
+    "signatureNotImage": "Bu dosya bir görsel değil",
+    "signatureNoInk": "Bu görselde imza bulunamadı. Beyaz kağıt üzerinde daha koyu bir kalem deneyin.",
+    "signatureCanvasLabel": "İmza alanı",
+    "signatureDesignerHint": "Bir tasarımda İmza bölümü açık olan yerlerde görünür. Bunu <link>belge tasarımcısında</link> açın."
   },
   "team": {
     "title": "Ekip",
@@ -734,9 +756,10 @@
       "result_detail": "Açıklama",
       "result_summary": "Nota göre sayılar",
       "combined_table": "Tüm bölümler için tek tablo",
-      "inspector_line": "Görevli satırı",
-      "inspector_name": "Görevlinin adını yazdır",
-      "date_line": "Tarih satırı"
+      "inspector_line": "İmza satırı",
+      "inspector_name": "İmzalayanın adı",
+      "date_line": "Tarih satırı",
+      "signature_image": "İmza"
     },
     "presetsTitle": "Bir düzenle başlayın",
     "presetsHint": "Aşağıdaki düzeni değiştirir. Siz kaydetmeden hiçbir şey kaydedilmez.",
@@ -2160,6 +2183,10 @@
     "telegramQrConnectedHint": "{link} adresini açan bir kod basar; bu bağlı Telegram botunuzdur. Yalnızca blok açıldıktan sonra kesilen faturalarda görünür.",
     "telegramQrPlaceholderHint": "Bağlı bir Telegram botu yok, bu yüzden sayfa basılmayacak bir örnek kod gösterir.",
     "telegramQrLink": "Entegrasyonlar",
+    "signatureWhose": "Kimin imzası",
+    "signatureOwnHint": "Her belge, onu düzenleyen kişinin imzasını basar: iş emrini açan, teklifi hazırlayan veya muayeneyi yapan kişi. Sayfada sizinki görünür.",
+    "signaturePlaceholderHint": "Her belge, onu düzenleyen kişinin imzasını basar: iş emrini açan, teklifi hazırlayan veya muayeneyi yapan kişi. Henüz bir imza kaydetmediniz, bu yüzden sayfada örnek bir imza görünür.",
+    "signatureLink": "İmzanızı ayarlayın",
     "titleText": "Başlık metni",
     "titleTextHint": "Bu belgenin kendine verdiği ad. Boş bırakılırsa kendi adını okuyucunun dilinde yazdırır.",
     "orgNumberLabel": "Organizasyon numarası etiketi",

+ 36 - 0
prisma/migrations/20260924150000_member_signatures/migration.sql

@@ -0,0 +1,36 @@
+-- AlterTable
+ALTER TABLE "inspections" ADD COLUMN     "signatureSnapshotId" TEXT;
+
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN     "issuedSignatureSnapshotId" TEXT;
+
+-- CreateTable
+CREATE TABLE "member_signatures" (
+    "id" TEXT NOT NULL,
+    "memberId" TEXT NOT NULL,
+    "mimeType" TEXT NOT NULL,
+    "data" BYTEA NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "member_signatures_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "member_signatures_memberId_key" ON "member_signatures"("memberId");
+
+-- CreateIndex
+CREATE INDEX "inspections_signatureSnapshotId_idx" ON "inspections"("signatureSnapshotId");
+
+-- CreateIndex
+CREATE INDEX "service_records_issuedSignatureSnapshotId_idx" ON "service_records"("issuedSignatureSnapshotId");
+
+-- AddForeignKey
+ALTER TABLE "inspections" ADD CONSTRAINT "inspections_signatureSnapshotId_fkey" FOREIGN KEY ("signatureSnapshotId") REFERENCES "document_asset_snapshots"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "member_signatures" ADD CONSTRAINT "member_signatures_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "organization_members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "service_records" ADD CONSTRAINT "service_records_issuedSignatureSnapshotId_fkey" FOREIGN KEY ("issuedSignatureSnapshotId") REFERENCES "document_asset_snapshots"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+

+ 8 - 5
prisma/schema/document-designs.prisma

@@ -57,10 +57,11 @@ model DocumentDesignSnapshot {
   @@map("document_design_snapshots")
 }
 
-/// A file as it was when a document was issued. Today that is the logo: the
-/// upload route deletes the previous file when a new one arrives, so a path
-/// alone would leave old invoices printing the new mark, or none. Keyed by
-/// content hash for the same reason as the design snapshots.
+/// A file as it was when a document was issued: the logo, and the signature
+/// of whoever issued it. The upload route deletes the previous logo when a
+/// new one arrives and a member can redraw their signature, so a reference to
+/// the live one would leave old paper printing the new mark, or none. Keyed
+/// by content hash for the same reason as the design snapshots.
 model DocumentAssetSnapshot {
   id             String       @id @default(cuid())
   organizationId String
@@ -70,7 +71,9 @@ model DocumentAssetSnapshot {
   data           Bytes
   createdAt      DateTime     @default(now())
 
-  serviceRecords ServiceRecord[]
+  serviceRecords       ServiceRecord[] @relation("IssuedLogoSnapshot")
+  signedServiceRecords ServiceRecord[] @relation("IssuedSignatureSnapshot")
+  signedInspections    Inspection[]
 
   @@unique([organizationId, hash])
   @@map("document_asset_snapshots")

+ 7 - 2
prisma/schema/inspections.prisma

@@ -134,8 +134,12 @@ model Inspection {
   /// design edited later never changes a certificate a customer already has.
   /// Null on inspections completed before designs existed, or by a workshop
   /// that has never designed one; those print the built-in sheet.
-  designSnapshotId String?
-  designSnapshot   DocumentDesignSnapshot? @relation(fields: [designSnapshotId], references: [id], onDelete: SetNull)
+  designSnapshotId    String?
+  designSnapshot      DocumentDesignSnapshot? @relation(fields: [designSnapshotId], references: [id], onDelete: SetNull)
+  /// The inspector's signature as it was at completion, for the same reason:
+  /// a redrawn signature never changes a certificate already issued.
+  signatureSnapshotId String?
+  signatureSnapshot   DocumentAssetSnapshot?  @relation(fields: [signatureSnapshotId], references: [id], onDelete: SetNull)
 
   organizationId String
 
@@ -151,6 +155,7 @@ model Inspection {
 
   @@index([vehicleId])
   @@index([designSnapshotId])
+  @@index([signatureSnapshotId])
   @@index([organizationId])
   @@index([technicianId])
   @@index([workBayId])

+ 23 - 0
prisma/schema/organization.prisma

@@ -100,10 +100,33 @@ model OrganizationMember {
   roleId     String?
   customRole Role?   @relation(fields: [roleId], references: [id], onDelete: SetNull)
 
+  signature MemberSignature?
+
   @@unique([userId, organizationId])
   @@map("organization_members")
 }
 
+/// A member's handwritten signature, drawn or uploaded in account settings,
+/// for the Signature section of the documents they issue.
+///
+/// Kept per workshop rather than per person: a mechanic who works for two
+/// shops signs each one's paper, and leaving a workshop takes the signature
+/// with the membership. The image is held in the row (a PNG or JPEG of a few
+/// kilobytes) so a document can print it without the upload folder, and so
+/// replacing it never touches a file an issued invoice still needs; issued
+/// documents keep their own copy in DocumentAssetSnapshot.
+model MemberSignature {
+  id        String             @id @default(cuid())
+  memberId  String             @unique
+  member    OrganizationMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
+  mimeType  String
+  data      Bytes
+  createdAt DateTime           @default(now())
+  updatedAt DateTime           @updatedAt
+
+  @@map("member_signatures")
+}
+
 model Role {
   id        String   @id @default(cuid())
   name      String

+ 14 - 9
prisma/schema/service-records.prisma

@@ -80,18 +80,22 @@ model ServiceRecord {
   /// title cannot rewrite paper already issued, and a preview and a re-send
   /// always show the same sheet. Only an owner's unlock followed by a send
   /// captures a fresh copy: the corrected one.
-  issuedAt               DateTime?
+  issuedAt                  DateTime?
   /// The look at issue. Restrict: a snapshot referenced by an invoice is a
   /// record and cannot be removed.
-  issuedDesignSnapshotId String?
-  issuedDesignSnapshot   DocumentDesignSnapshot? @relation(fields: [issuedDesignSnapshotId], references: [id], onDelete: Restrict)
-  issuedLogoSnapshotId   String?
-  issuedLogoSnapshot     DocumentAssetSnapshot?  @relation(fields: [issuedLogoSnapshotId], references: [id], onDelete: Restrict)
+  issuedDesignSnapshotId    String?
+  issuedDesignSnapshot      DocumentDesignSnapshot? @relation(fields: [issuedDesignSnapshotId], references: [id], onDelete: Restrict)
+  issuedLogoSnapshotId      String?
+  issuedLogoSnapshot        DocumentAssetSnapshot?  @relation("IssuedLogoSnapshot", fields: [issuedLogoSnapshotId], references: [id], onDelete: Restrict)
+  /// The signature of whoever opened the job, as it was at issue. Null when
+  /// they had none, or the invoice was issued before signatures existed.
+  issuedSignatureSnapshotId String?
+  issuedSignatureSnapshot   DocumentAssetSnapshot?  @relation("IssuedSignatureSnapshot", fields: [issuedSignatureSnapshotId], references: [id], onDelete: Restrict)
   /// Everything else the sheet printed that the record does not own: the
   /// workshop's details and payment settings, the customer, the vehicle, the
   /// technician's name, the findings and the custom fields. Shape in
   /// issuedInvoice.ts; read leniently so old snapshots render on new code.
-  issuedData             Json?
+  issuedData                Json?
 
   /// Set when an owner or admin deliberately reopens a locked document.
   /// Nullable rather than a boolean so the audit trail can show when, and the
@@ -171,6 +175,7 @@ model ServiceRecord {
   @@index([designId])
   @@index([issuedDesignSnapshotId])
   @@index([issuedLogoSnapshotId])
+  @@index([issuedSignatureSnapshotId])
   @@index([organizationId])
   @@index([customerId])
   @@index([inspectionId])
@@ -203,11 +208,11 @@ model ServiceConcern {
   /// The rest of the story this concern starts: condition, cause, correction,
   /// confirmation. `description` is the condition. The cause is the sentence
   /// the customer is told; the structured findings hang off `findings` below.
-  cause        String?
+  cause         String?
   /// What was done about it.
-  correction   String?
+  correction    String?
   /// How the fix was checked: the test drive, the re-scan, the symptom gone.
-  confirmation String?
+  confirmation  String?
   /// Set when somebody ticks the concern as confirmed, cleared when unticked.
   /// Stamped by the server from the session, never taken from the client.
   confirmedAt   DateTime?

+ 9 - 2
src/__tests__/features/invoice-designer/every-block.test.ts

@@ -96,9 +96,9 @@ describe('the sections a default sheet prints', () => {
   it('accounts for every section the designer offers', () => {
     // The guard on the two lists above: a section added to the schema
     // tomorrow is either on the default sheet, and so covered by the
-    // hides-it test, or one of the three known to start off. Neither, and
+    // hides-it test, or one of the four known to start off. Neither, and
     // this fails rather than quietly leaving a block untested.
-    const offByDefault = ['items_table', 'telegram_qr', 'general']
+    const offByDefault = ['items_table', 'telegram_qr', 'general', 'signature']
     expect([...DEFAULT_SHEET, ...offByDefault].sort()).toEqual(
       BUILTIN_SECTIONS.map((section) => section.id).sort()
     )
@@ -114,6 +114,13 @@ describe('the sections a default sheet prints', () => {
     expect(printed(spec)).toContain('items_table')
   })
 
+  it('prints the signature block when it is switched on', () => {
+    // Off by default because most invoices and quotes go out unsigned.
+    expect(DEFAULT_SHEET).not.toContain('signature')
+    const spec = specWith((section) => (section.id === 'signature' ? { visible: true } : undefined))
+    expect(printed(spec)).toContain('signature')
+  })
+
   it.each(['telegram_qr', 'general'])('leaves %s off the sheet with nothing to print', (id) => {
     // Visible and empty is a real state — a QR code with no bot connected, a
     // custom-fields block with no fields — and the designer's rail says

+ 9 - 2
src/__tests__/features/invoice-designer/field-order.test.ts

@@ -11,7 +11,10 @@
  */
 import { describe, expect, it } from 'vitest'
 import { buildSampleData } from '@/features/invoice-designer/Components/sample'
-import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
+import {
+  fieldHasFixedSlot,
+  mergeWithDefaults,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
 import { buildDocumentSpec } from '@/features/invoice-designer/Spec/buildSpec'
 import { themeOf } from '@/features/invoice-designer/Components/designTheme'
 import type { InvoiceFieldConfig } from '@/features/settings/Schema/invoiceLayoutSchema'
@@ -126,8 +129,12 @@ describe('document title strip order', () => {
  * depend on the sample data's wording.
  */
 describe('every section with a field list follows its order', () => {
+  // A section whose every field has a slot of its own (the signature block)
+  // has no order to follow; fixed-slot-fields.test.ts holds it to that.
   const sectionsWithFields = mergeWithDefaults({}).sections.filter(
-    (section) => (section.fields ?? []).length > 1
+    (section) =>
+      (section.fields ?? []).length > 1 &&
+      !(section.fields ?? []).every((field) => fieldHasFixedSlot(section.id, field.id))
   )
 
   /**

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

@@ -95,12 +95,13 @@ describe('fields that print in a place of their own', () => {
     )
   })
 
-  it('is the six the sheet actually has', () => {
+  it('is the ten 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'],
     })
   })
 

+ 141 - 0
src/__tests__/features/signatures/signature-block.test.ts

@@ -0,0 +1,141 @@
+import { describe, expect, it } from 'vitest'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import {
+  getDefaultInvoiceLayout,
+  mergeWithDefaults,
+  type InvoiceFieldConfig,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import { parseSignatureDataUri } from '@/features/signatures/Lib/signatureImage'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/** A real one-pixel PNG, which is all the checks need to see. */
+const PNG =
+  'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAX+XDSwAAAABJRU5ErkJggg=='
+
+const invoice = {
+  id: 'svc_1',
+  title: 'Brakes',
+  type: 'repair',
+  serviceDate: new Date('2026-08-14'),
+  invoiceDate: new Date('2026-08-14'),
+  subtotal: 100,
+  taxRate: 0,
+  taxAmount: 0,
+  totalAmount: 100,
+  cost: 100,
+  invoiceNumber: 'INV-1',
+  discountValue: 0,
+  partItems: [],
+  laborItems: [{ description: 'Fit', hours: 1, rate: 100, total: 100 }],
+  customFields: [],
+  findings: [],
+  customer: { name: 'Alex' },
+  vehicle: null,
+} as unknown as InvoiceData
+
+/** The invoice's signature block with these fields on, or null when it prints nothing. */
+function signatureBlock(
+  opts: { fields?: InvoiceFieldConfig[]; visible?: boolean; signer?: any } = {}
+): any {
+  const layout = getDefaultInvoiceLayout()
+  layout.sections = layout.sections.map((section) =>
+    section.id === 'signature'
+      ? {
+          ...section,
+          visible: opts.visible ?? true,
+          ...(opts.fields ? { fields: opts.fields } : {}),
+        }
+      : section
+  )
+  const spec: any = buildInvoicePrintSpec({
+    data: invoice,
+    template: { layoutConfig: { ...layout, version: 3 } } as any,
+    signer: 'signer' in opts ? opts.signer : { name: 'Jamie Lee', dataUri: PNG },
+  })
+  return spec.blocks.find((block: any) => block.id === 'signature')?.content ?? null
+}
+
+function walk(node: any, visit: (n: any) => void) {
+  if (!node || typeof node !== 'object') return
+  visit(node)
+  for (const child of node.children ?? []) walk(child.node ?? child, visit)
+}
+const textsOf = (node: any) => {
+  const out: string[] = []
+  walk(node, (n) => n.kind === 'text' && n.text && out.push(String(n.text)))
+  return out
+}
+const imagesOf = (node: any) => {
+  const out: string[] = []
+  walk(node, (n) => n.kind === 'image' && out.push(n.src))
+  return out
+}
+
+describe('the signature on an invoice', () => {
+  it('is off until a workshop switches it on', () => {
+    expect(getDefaultInvoiceLayout().sections.find((s) => s.id === 'signature')?.visible).toBe(
+      false
+    )
+    expect(signatureBlock({ visible: false })).toBeNull()
+  })
+
+  it('stays off on a layout saved before the section existed', () => {
+    const saved = getDefaultInvoiceLayout()
+    saved.sections = saved.sections.filter((section) => section.id !== 'signature')
+    const merged = mergeWithDefaults(saved)
+    expect(merged.sections.find((s) => s.id === 'signature')?.visible).toBe(false)
+  })
+
+  it('draws the signer’s signature on the line, with their name and the date', () => {
+    const block = signatureBlock()
+    expect(imagesOf(block)).toEqual([PNG])
+    expect(textsOf(block)).toEqual(
+      expect.arrayContaining(['Signature', 'Jamie Lee', 'Signed by', 'Date'])
+    )
+  })
+
+  it('sets its columns on one baseline, so the signature and date lines meet', () => {
+    // The signature prints at whatever height its image has; the date column
+    // cannot know it, so the row lines the two up from the bottom.
+    const row = signatureBlock().children.find((child: any) => child.kind === 'row')
+    expect(row.align).toBe('end')
+    expect(row.children).toHaveLength(2)
+  })
+
+  it('leaves the image off when the field is switched off', () => {
+    const block = signatureBlock({
+      fields: [
+        { id: 'signature_image', visible: false },
+        { id: 'inspector_line', visible: true },
+        { id: 'inspector_name', visible: true },
+        { id: 'date_line', visible: true },
+      ],
+    })
+    expect(imagesOf(block)).toEqual([])
+    expect(textsOf(block)).toContain('Jamie Lee')
+  })
+
+  it('prints an empty line for a pen when nobody has a signature', () => {
+    const block = signatureBlock({ signer: { name: '' } })
+    expect(block).not.toBeNull()
+    expect(imagesOf(block)).toEqual([])
+    expect(textsOf(block)).toContain('Signed by')
+  })
+})
+
+describe('what a signature image may be', () => {
+  it('takes a PNG', () => {
+    expect(parseSignatureDataUri(PNG)?.mimeType).toBe('image/png')
+  })
+
+  it('refuses what the renderer cannot draw or was not made by the app', () => {
+    expect(parseSignatureDataUri('data:image/svg+xml;base64,PHN2Zy8+')).toBeNull()
+    expect(parseSignatureDataUri('data:image/png;base64,AAAA')).toBeNull()
+    expect(parseSignatureDataUri('/api/files/x/logos/a.png')).toBeNull()
+    expect(parseSignatureDataUri(42)).toBeNull()
+    const huge = `data:image/png;base64,${Buffer.alloc(400 * 1024).toString('base64')}`
+    expect(parseSignatureDataUri(huge)).toBeNull()
+  })
+})

+ 2 - 0
src/__tests__/multitenancy/inspection-isolation.test.ts

@@ -46,6 +46,8 @@ vi.mock('@/lib/db', () => ({
     },
     // Completion reads the certificate design settings to freeze them.
     appSetting: { findMany: vi.fn() },
+    // Completing freezes the inspector's signature; nobody here has one.
+    memberSignature: { findFirst: vi.fn().mockResolvedValue(null) },
   },
 }))
 

+ 2 - 0
src/__tests__/multitenancy/quote-inspection-isolation.test.ts

@@ -38,6 +38,8 @@ vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },
     appSetting: { findMany: vi.fn() },
+    // Completing freezes the inspector's signature; nobody here has one.
+    memberSignature: { findFirst: vi.fn().mockResolvedValue(null) },
     quote: {
       findFirst: vi.fn(),
       findMany: vi.fn(),

+ 6 - 0
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -2,6 +2,7 @@
 
 import { AppCard } from '@/components/app-card'
 import { SignedInDevices } from './signed-in-devices'
+import { SignatureCard } from '@/features/signatures/Components/SignatureCard'
 import type { SignedInDevice } from '@/features/settings/Actions/sessionActions'
 import { useState, useEffect } from 'react'
 import { useSession } from '@/lib/auth-client'
@@ -44,11 +45,14 @@ export function AccountSettings({
   emailVerified: initialEmailVerified,
   emailVerificationRequired,
   devices,
+  signature,
 }: {
   twoFactorEnabled: boolean
   emailVerified: boolean
   emailVerificationRequired: boolean
   devices: SignedInDevice[]
+  /** The caller's saved signature for this workshop, as a data URI. */
+  signature: string | null
 }) {
   const { data: session } = useSession()
   const router = useRouter()
@@ -357,6 +361,8 @@ export function AccountSettings({
         </div>
       </AppCard>
 
+      <SignatureCard initial={signature} />
+
       {/* Change Password */}
       <AppCard
         icon={KeyRound}

+ 4 - 1
src/app/(authenticated)/settings/account/page.tsx

@@ -4,12 +4,13 @@ import { db } from '@/lib/db'
 import { redirect } from 'next/navigation'
 import { AccountSettings } from './account-settings'
 import { listMyDevices } from '@/features/settings/Actions/sessionActions'
+import { getMySignature } from '@/features/signatures/Actions/signatureActions'
 
 export default async function AccountSettingsPage() {
   const session = await auth.api.getSession({ headers: await headers() })
   if (!session) redirect('/auth/sign-in')
 
-  const [user, verificationSetting, devices] = await Promise.all([
+  const [user, verificationSetting, devices, signature] = await Promise.all([
     db.user.findUnique({
       where: { id: session.user.id },
       select: { twoFactorEnabled: true, emailVerified: true },
@@ -19,6 +20,7 @@ export default async function AccountSettingsPage() {
       select: { value: true },
     }),
     listMyDevices(),
+    getMySignature(),
   ])
 
   return (
@@ -27,6 +29,7 @@ export default async function AccountSettingsPage() {
       emailVerified={user?.emailVerified ?? false}
       emailVerificationRequired={verificationSetting?.value === 'true'}
       devices={devices}
+      signature={signature.success ? (signature.data ?? null) : null}
     />
   )
 }

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

@@ -18,6 +18,7 @@ import { INVOICE_DESIGNER_ANNOUNCEMENT, parseHintIds } from '@/features/settings
 import type { SavedDesign } from '@/features/invoice-designer/Components/types'
 import { telegramBotLink as botLinkOf } from '@/features/invoices/Lib/telegramQr'
 import { getOrgTelegramBotUsername } from '@/lib/telegram'
+import { memberSignatureDataUri } from '@/features/signatures/Lib/memberSignature.server'
 
 export default async function InvoiceDesignerPage({
   searchParams,
@@ -45,6 +46,7 @@ export default async function InvoiceDesignerPage({
     telegramBotUsername,
     certificateLayout,
     certificateDesigns,
+    signatureUrl,
   ] = await Promise.all([
     getSettings(),
     getInvoiceLayoutConfig(),
@@ -68,6 +70,7 @@ export default async function InvoiceDesignerPage({
     getOrgTelegramBotUsername(data.organizationId),
     getCertificateLayoutConfig(),
     listDocumentDesigns('certificate'),
+    memberSignatureDataUri(data.organizationId, data.userId),
   ])
 
   // Somebody is looking at the designer, so the workshop knows it exists. Only
@@ -140,6 +143,7 @@ export default async function InvoiceDesignerPage({
           orgNumberLabel: settings[SETTING_KEYS.ORG_NUMBER_LABEL] || '',
           paymentTerms: settings[SETTING_KEYS.INVOICE_PAYMENT_TERMS] || '',
           logoUrl: settings[SETTING_KEYS.COMPANY_LOGO] || '',
+          signatureUrl,
           taxComponents: readWorkshopTax(settings).components,
         }}
         customFields={

+ 2 - 0
src/app/(public)/share/inspection/[orgId]/[token]/page.tsx

@@ -12,6 +12,7 @@ import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { templateConfigFromSource } from '@/features/invoice-designer/Lib/designSource'
 import type { DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
 import { buildCertificatePrintSpec } from '@/features/inspections/Pdf/buildCertificatePrint'
+import { certificateSignatureDataUri } from '@/features/signatures/Lib/memberSignature.server'
 import {
   certificateDesignSource,
   loadCertificateLabels,
@@ -165,6 +166,7 @@ export default async function PublicInspectionPage({
       workshop: { ...workshop, slogan: settingsMap['workshop.slogan'] || undefined },
       labels,
       logoDataUri: toPublic(documentLogoPath(settingsMap, 'certificate')) || undefined,
+      signatureDataUri: await certificateSignatureDataUri(orgId, inspection),
       torqvoiceLogoDataUri: features.brandingRemoved ? undefined : await getTorqvoiceLogoDataUri(),
       dateFormat: settingsMap['workshop.dateFormat'] || undefined,
       timezone: settingsMap['workshop.timezone'] || undefined,

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

@@ -125,6 +125,7 @@ export default async function PublicInvoicePage({
     invoiceSettings: assembly.invoiceSettings,
     paymentSummary: assembly.paymentSummary,
     logoDataUri: assembly.logoDataUri,
+    signer: assembly.signer,
     template: assembly.template,
     torqvoiceLogoDataUri,
     telegramQrDataUri: telegramQr?.dataUri,

+ 5 - 0
src/app/(public)/share/quote/[orgId]/[token]/page.tsx

@@ -13,6 +13,7 @@ import { headers } from 'next/headers'
 import type { Metadata } from 'next'
 import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
 import { getAppBaseUrl } from '@/lib/app-url'
+import { documentSigner } from '@/features/signatures/Lib/memberSignature.server'
 
 export const revalidate = 60
 
@@ -200,6 +201,9 @@ export default async function PublicQuotePage({
     ? undefined
     : await getTorqvoiceLogoDataUri()
 
+  // Whoever wrote the quote signs it.
+  const signer = await documentSigner(orgId, quote.userId)
+
   const spec = buildQuotePrintSpec({
     data: quote,
     lineItemsInclTax: settingsMap['invoice.lineItemsInclTax'] === 'true',
@@ -207,6 +211,7 @@ export default async function PublicQuotePage({
     currencyCode,
     currencyFormat,
     logoDataUri: logoUrl || undefined,
+    signer,
     torqvoiceLogoDataUri,
     dateFormat: settingsMap['workshop.dateFormat'] || undefined,
     timezone: settingsMap['workshop.timezone'] || undefined,

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

@@ -179,6 +179,7 @@ async function importServiceRecordTree(
       issuedAt: sr.issuedAt ? toSafeDate(sr.issuedAt as string) : null,
       issuedDesignSnapshotId: keptReference(sr.issuedDesignSnapshotId, opts.designSnapshotIds),
       issuedLogoSnapshotId: keptReference(sr.issuedLogoSnapshotId, opts.assetSnapshotIds),
+      issuedSignatureSnapshotId: keptReference(sr.issuedSignatureSnapshotId, opts.assetSnapshotIds),
       issuedData:
         sr.issuedData && typeof sr.issuedData === 'object'
           ? (sr.issuedData as Prisma.InputJsonValue)
@@ -1261,6 +1262,7 @@ export async function POST(request: NextRequest) {
               importBatchId: null,
               // The frozen certificate design, when the snapshot came back too.
               designSnapshotId: keptReference(insp.designSnapshotId, designSnapshotIds),
+              signatureSnapshotId: keptReference(insp.signatureSnapshotId, assetSnapshotIds),
               createdAt: toSafeDate(insp.createdAt as string),
               updatedAt: toSafeDate(insp.updatedAt as string),
               vehicleId: insp.vehicleId as string,

+ 2 - 0
src/features/ai/tools/workshop-columns.ts

@@ -102,6 +102,7 @@ export const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = {
     'issuedAt',
     'issuedDesignSnapshotId',
     'issuedLogoSnapshotId',
+    'issuedSignatureSnapshotId',
     'bookingSource',
     'editUnlockedAt',
     'tireSetId',
@@ -340,6 +341,7 @@ export const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = {
     'organizationId',
     'importBatchId',
     'designSnapshotId',
+    'signatureSnapshotId',
   ],
   inspection_items: [
     'id',

+ 18 - 1
src/features/inspections/Actions/inspectionActions.ts

@@ -4,7 +4,14 @@ import { createDraftRecord } from '@/features/vehicles/Lib/createDraftRecord'
 import { createQuoteRecord } from '@/features/quotes/Lib/createQuoteRecord'
 import { createQuoteSchema } from '@/features/quotes/Schema/quoteSchema'
 import { getTranslations } from 'next-intl/server'
-import { ensureDesignSnapshot } from '@/features/invoice-designer/Lib/designSnapshots'
+import {
+  ensureAssetSnapshot,
+  ensureDesignSnapshot,
+} from '@/features/invoice-designer/Lib/designSnapshots'
+import {
+  inspectorUserId,
+  memberSignatureDataUri,
+} from '@/features/signatures/Lib/memberSignature.server'
 import { liveCertificateDesign } from '../Pdf/certificateDesign'
 import { retotalServiceRecord } from '@/features/vehicles/Lib/retotalServiceRecord'
 import { OPEN_SERVICE_STATUSES } from '@/lib/service-record'
@@ -478,12 +485,22 @@ export async function completeInspection(id: string) {
         ? await ensureDesignSnapshot(organizationId, liveDesign)
         : null
 
+      // The inspector's signature, frozen with the design for the same reason.
+      const signatureDataUri = await memberSignatureDataUri(
+        organizationId,
+        await inspectorUserId(inspection, userId)
+      )
+      const signatureSnapshotId = signatureDataUri
+        ? await ensureAssetSnapshot(organizationId, signatureDataUri)
+        : null
+
       await db.inspection.updateMany({
         where: { id, organizationId },
         data: {
           status: 'completed',
           completedAt: new Date(),
           designSnapshotId,
+          signatureSnapshotId,
           ...(inspection.inspectorName ? {} : { inspectorName }),
         },
       })

+ 4 - 1
src/features/inspections/Pdf/buildCertificatePdfBuffer.ts

@@ -10,6 +10,7 @@ import { resolveUploadPath } from '@/lib/resolve-upload-path'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { templateConfigFromSource } from '@/features/invoice-designer/Lib/designSource'
 import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
+import { certificateSignatureDataUri } from '@/features/signatures/Lib/memberSignature.server'
 import { CertificatePDF } from '../Components/CertificatePDF'
 import { appendCertificateDocuments, certificateDocuments } from '../Lib/certificateDocuments'
 import { loadInspectionOverviewPhotos, loadInspectionPhotos } from '../Lib/inspectionPhotos'
@@ -101,9 +102,10 @@ export async function buildCertificatePdfBuffer({
 
   const labels = await loadCertificateLabels(locale, settingsMap)
 
-  const [logoDataUri, features] = await Promise.all([
+  const [logoDataUri, features, signatureDataUri] = await Promise.all([
     logoDataUriFor(settingsMap),
     getFeatures(organizationId),
+    certificateSignatureDataUri(organizationId, inspection),
   ])
 
   // Photos are an enhancement; the certificate is the document.
@@ -128,6 +130,7 @@ export async function buildCertificatePdfBuffer({
     },
     labels,
     logoDataUri,
+    signatureDataUri,
     torqvoiceLogoDataUri: features.brandingRemoved ? undefined : await getTorqvoiceLogoDataUri(),
     dateFormat: settingsMap['workshop.dateFormat'] || undefined,
     timezone: settingsMap['workshop.timezone'] || undefined,

+ 10 - 2
src/features/inspections/Pdf/buildCertificatePrint.ts

@@ -87,6 +87,8 @@ export interface CertificatePrintInput {
   workshop?: { name: string; address: string; phone: string; email: string; slogan?: string }
   labels?: Record<string, string>
   logoDataUri?: string
+  /** The inspector's saved signature, drawn on the signature line. */
+  signatureDataUri?: string
   torqvoiceLogoDataUri?: string
   dateFormat?: string
   timezone?: string
@@ -279,7 +281,6 @@ export function buildCertificatePrintSpec(input: CertificatePrintInput): Documen
       })),
     })),
     photos: input.overviewPhotos ?? [],
-    signature: { inspector, date: testDate },
   }
 
   const documentData: DocumentData = {
@@ -312,9 +313,16 @@ export function buildCertificatePrintSpec(input: CertificatePrintInput): Documen
       defects: L('deficiencies', 'Deficiencies found'),
       results_table: L('allResults', 'All results'),
       inspection_photos: L('photos', 'Photos'),
-      signature: L('signature', 'Signature'),
     },
     certificate,
+    signature: {
+      heading: L('signature', 'Signature'),
+      name: inspector,
+      nameCaption: L('inspector', 'Inspector'),
+      date: testDate,
+      dateCaption: L('testDate', 'Date of test'),
+      image: input.signatureDataUri,
+    },
   }
 
   const primary = template?.primaryColor || '#d97706'

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

@@ -343,6 +343,7 @@ export function DesignerInspector({
   orgNumberLabelDefault,
   onOrgNumberLabel,
   telegramBotLink,
+  signatureSet,
 }: {
   layout: InvoiceLayoutConfig
   template: DesignerTemplate
@@ -373,6 +374,8 @@ export function DesignerInspector({
   onOrgNumberLabel: (value: string) => void
   /** The connected Telegram bot's link; the block is a stand-in without one. */
   telegramBotLink?: string
+  /** Whether the person designing has saved a signature, or the canvas shows a stand-in. */
+  signatureSet: boolean
 }) {
   const t = useTranslations('settings.designer')
   const tSection = useTranslations('settings.layoutEditor.sections')
@@ -782,6 +785,25 @@ export function DesignerInspector({
             </Group>
           )}
 
+          {/* Whose hand it is depends on the document, so nothing here sets
+              it: each person saves their own, and the sheet prints the
+              signature of whoever issued it. */}
+          {section.id === 'signature' && (
+            <Group title={t('signatureWhose')}>
+              <p className="text-[11.5px] leading-snug text-[#8a8f97]">
+                {signatureSet ? t('signatureOwnHint') : t('signaturePlaceholderHint')}{' '}
+                <a
+                  href="/settings/account"
+                  target="_blank"
+                  rel="noreferrer"
+                  className="font-medium text-[#2563eb] underline underline-offset-2"
+                >
+                  {t('signatureLink')}
+                </a>
+              </p>
+            </Group>
+          )}
+
           {section.id === 'header' && (
             <Group title={t('logo')}>
               <LogoUpload value={logoUrl} own={ownLogo} onChange={onLogo} />

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

@@ -237,8 +237,14 @@ export function InvoiceDesigner({
     if (!companyWorkshop.slogan?.trim()) ids.push('slogan')
     if (!companyWorkshop.paymentTerms?.trim()) ids.push('payment_terms')
     if (!telegramBotLink) ids.push('telegram_qr')
+    if (!companyWorkshop.signatureUrl) ids.push('signature')
     return new Set(ids)
-  }, [companyWorkshop.slogan, companyWorkshop.paymentTerms, telegramBotLink])
+  }, [
+    companyWorkshop.slogan,
+    companyWorkshop.paymentTerms,
+    companyWorkshop.signatureUrl,
+    telegramBotLink,
+  ])
   // What this document actually prints: its own mark when it has one, the
   // company logo otherwise. The same fallback the print routes apply, so the
   // canvas cannot promise a picture the paper will not carry.
@@ -1113,6 +1119,7 @@ export function InvoiceDesigner({
           orgNumberLabelDefault={messages.pdf?.invoice?.orgNumberLabel ?? 'Org. Number'}
           onOrgNumberLabel={setOrgNumberLabel}
           telegramBotLink={telegramBotLink}
+          signatureSet={Boolean(companyWorkshop.signatureUrl)}
           onLogo={(url) => setTemplate({ logoUrl: url })}
         />
       </div>

+ 23 - 2
src/features/invoice-designer/Components/sample.ts

@@ -356,12 +356,34 @@ export function buildSampleData(
       defects: L('deficiencies', 'Deficiencies found'),
       results_table: L('allResults', 'All results'),
       inspection_photos: L('photos', 'Photos'),
-      signature: L('signature', 'Signature'),
     },
+    signature:
+      docType === 'certificate'
+        ? {
+            heading: L('signature', 'Signature'),
+            name: 'Jamie Lee',
+            nameCaption: L('inspector', 'Inspector'),
+            date: sample.date,
+            dateCaption: L('testDate', 'Date of test'),
+            image: workshop.signatureUrl || SAMPLE_SIGNATURE,
+          }
+        : {
+            heading: L('signature', 'Signature'),
+            name: 'Jamie Lee',
+            nameCaption: L('signedBy', 'Signed by'),
+            date: sample.date,
+            dateCaption: L('signatureDate', 'Date'),
+            image: workshop.signatureUrl || SAMPLE_SIGNATURE,
+          },
     ...(docType === 'certificate' ? sampleCertificate(t, labels, values, sample) : {}),
   }
 }
 
+/** A stroke of a pen, for a designer whose user has not saved a signature yet. */
+const SAMPLE_SIGNATURE = `data:image/svg+xml;utf8,${encodeURIComponent(
+  '<svg xmlns="http://www.w3.org/2000/svg" width="170" height="44" viewBox="0 0 170 44"><path d="M6 32c8-14 14-24 18-22s-6 22-2 24 10-18 16-18-2 16 4 16 8-12 14-12 0 10 6 10 10-14 16-14-4 12 2 12 12-8 20-10 20 2 34-2" fill="none" stroke="#1e3a8a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'
+)}`
+
 /** A small grey square, so the photo blocks have something to place. */
 const SAMPLE_PHOTO = `data:image/svg+xml;utf8,${encodeURIComponent(
   '<svg xmlns="http://www.w3.org/2000/svg" width="158" height="119"><rect width="158" height="119" fill="#e5e7eb"/><path d="M40 84l28-32 22 26 14-16 24 22H40z" fill="#9ca3af"/><circle cx="112" cy="40" r="9" fill="#9ca3af"/></svg>'
@@ -479,7 +501,6 @@ function sampleCertificate(
         { dataUri: SAMPLE_PHOTO, caption: t('sample.photoFront') },
         { dataUri: SAMPLE_PHOTO, caption: t('sample.photoOdometer') },
       ],
-      signature: { inspector: 'Jamie Lee', date: sample.date },
     },
   }
 }

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

@@ -60,6 +60,11 @@ export interface DesignerWorkshop {
   /** Payment terms from payment settings. Empty prints nothing on the sheet. */
   paymentTerms: string
   logoUrl: string
+  /**
+   * The signature of whoever has the designer open, so the Signature section
+   * previews with their own hand. Unset shows a stand-in.
+   */
+  signatureUrl?: string
   /**
    * The taxes the workshop splits its tax into, when it does (GST and QST).
    * The sample job is then taxed the workshop's way, one line per tax, with

+ 10 - 0
src/features/invoice-designer/Pdf/buildInvoicePrint.ts

@@ -50,6 +50,8 @@ export interface InvoicePrintInput {
   pdfAttachmentNames?: string[]
   otherAttachmentNames?: string[]
   logoDataUri?: string
+  /** Who signs the sheet, for a layout with the Signature section on. */
+  signer?: { name: string; dataUri?: string }
   template?: TemplateConfig
   torqvoiceLogoDataUri?: string
   portalUrl?: string
@@ -449,6 +451,14 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
       : undefined,
     branding: input.torqvoiceLogoDataUri ? { logoDataUri: input.torqvoiceLogoDataUri } : undefined,
     portalUrl: input.portalUrl,
+    signature: {
+      heading: L('signature', 'Signature'),
+      name: input.signer?.name ?? '',
+      nameCaption: L('signedBy', 'Signed by'),
+      date: serviceDate,
+      dateCaption: L('signatureDate', 'Date'),
+      image: input.signer?.dataUri,
+    },
     sectionLabels: {
       customer: L('billTo', 'Bill To'),
       vehicle: L('vehicle', 'Vehicle'),

+ 10 - 0
src/features/invoice-designer/Pdf/buildQuotePrint.ts

@@ -94,6 +94,8 @@ export interface QuotePrintInput {
   currencyCode?: string
   currencyFormat?: 'symbol' | 'code'
   logoDataUri?: string
+  /** Who signs the sheet, for a layout with the Signature section on. */
+  signer?: { name: string; dataUri?: string }
   torqvoiceLogoDataUri?: string
   dateFormat?: string
   timezone?: string
@@ -357,6 +359,14 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
     payment: [],
     branding: input.torqvoiceLogoDataUri ? { logoDataUri: input.torqvoiceLogoDataUri } : undefined,
     portalUrl: input.portalUrl,
+    signature: {
+      heading: L('signature', 'Signature'),
+      name: input.signer?.name ?? '',
+      nameCaption: L('signedBy', 'Signed by'),
+      date: createdDate,
+      dateCaption: L('signatureDate', 'Date'),
+      image: input.signer?.dataUri,
+    },
     sectionLabels: {
       customer: L('to', 'To'),
       vehicle: L('vehicle', 'Vehicle'),

+ 20 - 1
src/features/invoice-designer/Spec/buildSpec.ts

@@ -16,9 +16,9 @@ import {
   inspectionPhotosBlock,
   resultBlock,
   resultsTableBlock,
-  signatureBlock,
   testDetailsBlock,
 } from './certificateBlocks'
+import { signatureBlock } from './signatureBlock'
 
 /**
  * The single description of a document.
@@ -101,6 +101,24 @@ export interface DocumentData {
   sectionLabels: Record<string, string>
   /** What a certificate prints beyond the shared fields; absent on any other document. */
   certificate?: CertificateData
+  /**
+   * Who signs the sheet and when, already worded. Absent means the document
+   * has no Signature section to fill, and the section draws nothing.
+   */
+  signature?: DocumentSignature
+}
+
+export interface DocumentSignature {
+  /** The section's heading. */
+  heading: string
+  /** The signer's name, printed under the line when the layout asks for it. */
+  name: string
+  /** What the line is for: "Inspector", "Authorised signature". */
+  nameCaption: string
+  date: string
+  dateCaption: string
+  /** The signer's saved signature as a PNG or JPEG data URI. */
+  image?: string
 }
 
 export interface DocumentTheme {
@@ -1803,6 +1821,7 @@ function blockFor(section: InvoiceSection, theme: DocumentTheme, data: DocumentD
       return resultsTableBlock(section, theme, data)
     case 'inspection_photos':
       return inspectionPhotosBlock(section, theme, data)
+    // Every document's; see signatureBlock.ts.
     case 'signature':
       return signatureBlock(section, theme, data)
     default:

+ 1 - 79
src/features/invoice-designer/Spec/certificateBlocks.ts

@@ -23,7 +23,7 @@ const PHOTO_HEIGHT = 119
 /** Photographs per row on an A4 sheet at the default margin. */
 const PHOTOS_PER_ROW = 3
 
-function headingStyle(look: ReturnType<typeof lookOf>, size: number): TextStyle {
+export function headingStyle(look: ReturnType<typeof lookOf>, size: number): TextStyle {
   return {
     color: look.label,
     fontSize: scale(size, 0.72),
@@ -379,81 +379,3 @@ export function inspectionPhotosBlock(
   children.push(...photoRows(certificate.photos, look.muted, size))
   return { kind: 'stack', id: section.id, gap: 6, children }
 }
-
-/**
- * A line for the inspector to sign, and the date beside it. Either line can
- * be left off, and the inspector's name under the line can be withheld for
- * a certificate that is signed by hand.
- */
-export function signatureBlock(
-  section: InvoiceSection,
-  theme: DocumentTheme,
-  data: DocumentData
-): Node | null {
-  const certificate = data.certificate
-  if (!certificate) return null
-  const fields = new Set(sectionFields(section))
-  const withInspector = fields.has('inspector_line')
-  const withDate = fields.has('date_line')
-  if (!withInspector && !withDate) return null
-  const look = lookOf(section, theme)
-  const size = look.fontSize ?? theme.fontSize
-  const boxed = section.boxed === true
-  const line = (caption: string, value: string): Node => ({
-    kind: 'stack',
-    gap: 3,
-    children: [
-      { kind: 'spacer', height: 22 },
-      { kind: 'spacer', height: 0.75, color: look.border || look.text },
-      // An empty value still takes its line, so the two columns stay level.
-      {
-        kind: 'text',
-        text: value || ' ',
-        style: { color: look.text, fontSize: scale(size, 0.92) },
-      },
-      { kind: 'text', text: caption, style: { color: look.muted, fontSize: scale(size, 0.72) } },
-    ],
-  })
-  const lines: { width: 'flex'; node: Node }[] = []
-  if (withInspector) {
-    lines.push({
-      width: 'flex',
-      node: line(
-        label(data, 'inspector', 'Inspector'),
-        fields.has('inspector_name') ? certificate.signature.inspector : ''
-      ),
-    })
-  }
-  if (withDate) {
-    lines.push({
-      width: 'flex',
-      node: line(label(data, 'testDate', 'Date of test'), certificate.signature.date),
-    })
-  }
-  return {
-    kind: 'stack',
-    id: section.id,
-    gap: 6,
-    style: boxed
-      ? {
-          background: look.fill || '#f3f4f6',
-          borderColor: look.border,
-          borderWidth: look.border ? (look.ruleWidth ?? 0.75) : 0,
-          radius: 3,
-          padding: look.padding ?? 10,
-        }
-      : undefined,
-    children: [
-      ...(section.heading !== false
-        ? [
-            {
-              kind: 'text' as const,
-              text: data.sectionLabels.signature ?? label(data, 'signature', 'Signature'),
-              style: headingStyle(look, size),
-            },
-          ]
-        : []),
-      { kind: 'row', gap: 24, children: lines },
-    ],
-  }
-}

+ 0 - 1
src/features/invoice-designer/Spec/certificateData.ts

@@ -44,5 +44,4 @@ export interface CertificateData {
   sections: { code: string | null; name: string; rows: CertificateResultRow[] }[]
   /** Photographs of the vehicle as a whole, with the captions the desk gave them. */
   photos: { dataUri: string; caption: string | null }[]
-  signature: { inspector: string; date: string }
 }

+ 111 - 0
src/features/invoice-designer/Spec/signatureBlock.ts

@@ -0,0 +1,111 @@
+import type { InvoiceSection } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { Node } from './documentSpec'
+import { type DocumentData, type DocumentTheme, lookOf, scale, sectionFields } from './buildSpec'
+import { headingStyle } from './certificateBlocks'
+
+/** Room a signature takes above its line, in points. */
+const SIGNATURE_WIDTH = 170
+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.
+ *
+ * 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
+ * alone. A document without a signer (a job the system opened) still prints
+ * the line, which is what a sheet meant for a pen needs.
+ */
+export function signatureBlock(
+  section: InvoiceSection,
+  theme: DocumentTheme,
+  data: DocumentData
+): Node | null {
+  const signature = data.signature
+  if (!signature) return null
+  const fields = new Set(sectionFields(section))
+  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
+  const look = lookOf(section, theme)
+  const size = look.fontSize ?? theme.fontSize
+  const boxed = section.boxed === true
+
+  const column = (opts: { caption: string; value: string; image?: string }): Node => ({
+    kind: 'stack',
+    gap: 3,
+    children: [
+      // The signature sits on the line; a line without one keeps room for a
+      // pen. The row aligns its columns at the bottom, so the lines meet
+      // whatever height the signature happens to print at.
+      opts.image
+        ? {
+            kind: 'image',
+            src: opts.image,
+            maxWidth: SIGNATURE_WIDTH,
+            maxHeight: SIGNATURE_HEIGHT,
+            align: 'left',
+          }
+        : { kind: 'spacer', height: 22 },
+      ...(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.
+      {
+        kind: 'text',
+        text: opts.value || ' ',
+        style: { color: look.text, fontSize: scale(size, 0.92) },
+      },
+      {
+        kind: 'text',
+        text: opts.caption,
+        style: { color: look.muted, fontSize: scale(size, 0.72) },
+      },
+    ],
+  })
+
+  const columns: { width: 'flex'; node: Node }[] = []
+  if (withLine || withImage) {
+    columns.push({
+      width: 'flex',
+      node: column({
+        caption: signature.nameCaption,
+        value: fields.has('inspector_name') ? signature.name : '',
+        image: withImage ? signature.image : undefined,
+      }),
+    })
+  }
+  if (withDate) {
+    columns.push({
+      width: 'flex',
+      node: column({ caption: signature.dateCaption, value: signature.date }),
+    })
+  }
+  return {
+    kind: 'stack',
+    id: section.id,
+    gap: 6,
+    style: boxed
+      ? {
+          background: look.fill || '#f3f4f6',
+          borderColor: look.border,
+          borderWidth: look.border ? (look.ruleWidth ?? 0.75) : 0,
+          radius: 3,
+          padding: look.padding ?? 10,
+        }
+      : undefined,
+    children: [
+      ...(section.heading !== false
+        ? [
+            {
+              kind: 'text' as const,
+              text: signature.heading,
+              style: headingStyle(look, size),
+            },
+          ]
+        : []),
+      { kind: 'row', gap: 24, align: 'end', children: columns },
+    ],
+  }
+}

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

@@ -45,6 +45,7 @@ import {
   designRuleSubjectOf,
   findRuleDesign,
 } from '@/features/invoice-designer/Lib/designRules.server'
+import { memberSignatureDataUri } from '@/features/signatures/Lib/memberSignature.server'
 import { readIssuedInvoiceData, rendersFromIssue, type IssuedInvoiceData } from './issuedInvoice'
 
 const PARTY_SELECT = {
@@ -82,6 +83,9 @@ const RECORD_INCLUDE = {
   },
   issuedDesignSnapshot: true,
   issuedLogoSnapshot: true,
+  issuedSignatureSnapshot: true,
+  // Whoever opened the job signs it.
+  createdBy: { select: { id: true, name: true } },
 }
 
 export type InvoiceRecordForPrint = NonNullable<Awaited<ReturnType<typeof loadRecord>>>
@@ -116,6 +120,8 @@ export interface InvoicePrintAssembly {
   template: TemplateConfig
   layoutConfig: InvoiceLayoutConfig
   logoDataUri?: string
+  /** Who signs the sheet: whoever opened the job, with their saved signature. */
+  signer: PrintSigner
   paymentSummary?: PaymentSummary
   /** The look in the shape a snapshot stores. */
   designSource: DesignSource
@@ -123,6 +129,11 @@ export interface InvoicePrintAssembly {
   labelSettings: Record<string, string>
 }
 
+export interface PrintSigner {
+  name: string
+  dataUri?: string
+}
+
 const MIME_BY_EXT: Record<string, string> = {
   png: 'image/png',
   jpg: 'image/jpeg',
@@ -319,7 +330,7 @@ async function assembleLive(
   settingsMap: Record<string, string>
 ): Promise<InvoicePrintAssembly> {
   const customerRow = record.customer ?? record.vehicle?.customer ?? null
-  const [findings, customFields, look] = await Promise.all([
+  const [findings, customFields, look, signatureDataUri] = await Promise.all([
     db.vehicleFinding.findMany({
       where: { serviceRecordId: record.id, status: { not: 'resolved' } },
       select: { description: true, severity: true, notes: true },
@@ -327,6 +338,7 @@ async function assembleLive(
     }),
     getCustomFieldsForPrint(organizationId, record.id, 'service_record'),
     currentLook(organizationId, settingsMap, record, customerRow?.invoiceDesignId),
+    memberSignatureDataUri(organizationId, record.createdBy?.id),
   ])
   const { designSource, logoDataUri } = look
 
@@ -366,6 +378,7 @@ async function assembleLive(
     template,
     layoutConfig: template.layoutConfig!,
     logoDataUri,
+    signer: { name: record.createdBy?.name ?? '', dataUri: signatureDataUri },
     paymentSummary: paymentSummaryOf(record, invoiceSettings.dateFormat, invoiceSettings.timezone),
     designSource,
     labelSettings: {
@@ -438,6 +451,14 @@ function assembleFrozen(
     template,
     layoutConfig: template.layoutConfig!,
     logoDataUri,
+    // Invoices issued before signatures existed froze no signer, and print
+    // the line unsigned rather than borrowing today's signature.
+    signer: {
+      name: frozen.signerName ?? '',
+      dataUri: record.issuedSignatureSnapshot
+        ? assetDataUri(record.issuedSignatureSnapshot)
+        : undefined,
+    },
     paymentSummary: paymentSummaryOf(record, invoiceSettings.dateFormat, invoiceSettings.timezone),
     designSource,
     labelSettings: {

+ 12 - 6
src/features/invoices/Lib/issueInvoice.ts

@@ -43,6 +43,7 @@ export function buildIssuedInvoiceData(a: InvoicePrintAssembly): IssuedInvoiceDa
         }
       : null,
     technicianName: a.data.technician?.name || a.data.techName || null,
+    signerName: a.signer.name || null,
     findings: (a.data.findings ?? []).map((f) => ({
       description: f.description,
       severity: f.severity,
@@ -83,12 +84,16 @@ export async function issueInvoice(
   const assembly = await assembleInvoicePrint(recordId, { mode: 'live' })
   if (!assembly) return false
 
-  const [issuedDesignSnapshotId, issuedLogoSnapshotId] = await Promise.all([
-    ensureDesignSnapshot(organizationId, assembly.designSource),
-    assembly.logoDataUri
-      ? ensureAssetSnapshot(organizationId, assembly.logoDataUri)
-      : Promise.resolve(null),
-  ])
+  const [issuedDesignSnapshotId, issuedLogoSnapshotId, issuedSignatureSnapshotId] =
+    await Promise.all([
+      ensureDesignSnapshot(organizationId, assembly.designSource),
+      assembly.logoDataUri
+        ? ensureAssetSnapshot(organizationId, assembly.logoDataUri)
+        : Promise.resolve(null),
+      assembly.signer.dataUri
+        ? ensureAssetSnapshot(organizationId, assembly.signer.dataUri)
+        : Promise.resolve(null),
+    ])
 
   await db.serviceRecord.update({
     where: { id: recordId },
@@ -98,6 +103,7 @@ export async function issueInvoice(
       issuedAt: reason === 'backfill' ? (record.sentAt ?? new Date()) : new Date(),
       issuedDesignSnapshotId,
       issuedLogoSnapshotId,
+      issuedSignatureSnapshotId,
       issuedData: buildIssuedInvoiceData(assembly) as unknown as Prisma.InputJsonValue,
     },
   })

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

@@ -76,6 +76,8 @@ export const issuedInvoiceDataSchema = z
       .nullable()
       .optional(),
     technicianName: z.string().nullable().optional(),
+    /** Who signed the sheet; the signature itself is issuedSignatureSnapshot. */
+    signerName: z.string().nullable().optional(),
     findings: z
       .array(
         z

+ 1 - 0
src/features/invoices/Pdf/buildInvoicePdfBuffer.ts

@@ -79,6 +79,7 @@ export async function renderInvoicePdf(
     invoiceSettings: assembly.invoiceSettings,
     paymentSummary: assembly.paymentSummary,
     logoDataUri: assembly.logoDataUri,
+    signer: assembly.signer,
     template: assembly.template,
     torqvoiceLogoDataUri,
     portalUrl,

+ 1 - 0
src/features/portal/Actions/portalActions.ts

@@ -323,6 +323,7 @@ export async function getPortalInvoiceSheet(invoiceId: string) {
       invoiceSettings: assembly.invoiceSettings,
       paymentSummary: assembly.paymentSummary,
       logoDataUri: assembly.logoDataUri,
+      signer: assembly.signer,
       template: assembly.template,
       torqvoiceLogoDataUri,
       telegramQrDataUri: telegramQr?.dataUri,

+ 3 - 0
src/features/quotes/Components/QuotePDF.tsx

@@ -30,6 +30,7 @@ export function QuotePDF({
   currencyCode = 'USD',
   currencyFormat = 'symbol',
   logoDataUri,
+  signer,
   torqvoiceLogoDataUri,
   dateFormat,
   timezone,
@@ -49,6 +50,7 @@ export function QuotePDF({
   currencyCode?: string
   currencyFormat?: 'symbol' | 'code'
   logoDataUri?: string
+  signer?: { name: string; dataUri?: string }
   torqvoiceLogoDataUri?: string
   dateFormat?: string
   timezone?: string
@@ -70,6 +72,7 @@ export function QuotePDF({
     currencyCode,
     currencyFormat,
     logoDataUri,
+    signer,
     torqvoiceLogoDataUri,
     dateFormat,
     timezone,

+ 5 - 1
src/features/quotes/Pdf/buildQuotePdfBuffer.ts

@@ -21,6 +21,7 @@ import '@/features/vehicles/Components/invoice-pdf/fonts'
 import { PDFDocument } from 'pdf-lib'
 import React from 'react'
 import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
+import { documentSigner } from '@/features/signatures/Lib/memberSignature.server'
 import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { QuotePDF } from '@/features/quotes/Components/QuotePDF'
@@ -140,7 +141,7 @@ export async function buildQuotePdfBuffer(
     }
   }
 
-  const [labels, logoDataUri, features, customFields, layoutRow] = await Promise.all([
+  const [labels, logoDataUri, features, customFields, layoutRow, signer] = await Promise.all([
     // The same words the invoice and the public quote page use, marine
     // vocabulary and the workshop's tax and registration captions included.
     loadPrintLabels(locale, settingsMap, 'quote'),
@@ -150,6 +151,8 @@ export async function buildQuotePdfBuffer(
     db.appSetting.findUnique({
       where: { organizationId_key: { organizationId, key: 'quote.layoutConfig' } },
     }),
+    // Whoever wrote the quote signs it.
+    documentSigner(organizationId, quote.userId),
   ])
 
   const element = React.createElement(QuotePDF, {
@@ -167,6 +170,7 @@ export async function buildQuotePdfBuffer(
       | 'symbol'
       | 'code',
     logoDataUri,
+    signer,
     // The mark comes off for the plans that paid to remove it.
     torqvoiceLogoDataUri: features.brandingRemoved ? undefined : await getTorqvoiceLogoDataUri(),
     dateFormat: settingsMap['workshop.dateFormat'] || undefined,

+ 23 - 5
src/features/settings/Schema/invoiceLayoutSchema.ts

@@ -239,6 +239,9 @@ export const BUILTIN_SECTIONS = [
   { id: 'attached_documents', name: 'Attached Documents' },
   { id: 'warranty', name: 'Warranty' },
   { id: 'bank_account', name: 'Bank Account' },
+  // Above the footer, where a signature goes on paper. Off until a workshop
+  // switches it on, so no sheet gains a signature line by a deploy.
+  { id: 'signature', name: 'Signature' },
   { id: 'footer', name: 'Footer' },
   { id: 'telegram_qr', name: 'Telegram QR' },
   { id: 'general', name: 'General' },
@@ -378,10 +381,16 @@ export const BUILTIN_DEFECTS_FIELDS = [
   { id: 'defect_photos', name: 'Photos' },
 ] as const
 
-/** What the signature block draws: the lines, and whether the name is printed. */
+/**
+ * What the signature block draws: the signer's saved signature, the line,
+ * whether the name is printed under it, and the date. The line and name ids
+ * are from when only certificates were signed, by an inspector; they are kept
+ * so saved layouts still mean what they did.
+ */
 export const BUILTIN_SIGNATURE_FIELDS = [
-  { id: 'inspector_line', name: 'Inspector line' },
-  { id: 'inspector_name', name: "Inspector's name" },
+  { id: 'signature_image', name: 'Signature' },
+  { id: 'inspector_line', name: 'Signature line' },
+  { id: 'inspector_name', name: "Signer's name" },
   { id: 'date_line', name: 'Date line' },
 ] as const
 
@@ -497,6 +506,9 @@ export const FIXED_SLOT_FIELDS: Record<string, readonly string[]> = {
   header: ['logo', 'company_name'],
   document_title: ['title'],
   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'],
 }
 
 /** Whether this field prints where the list puts it, or in a slot of its own. */
@@ -594,9 +606,15 @@ function getDefaultFieldsForSection(sectionId: string): InvoiceFieldConfig[] | u
  *
  * `items_table` is one because it replaces the separate parts and labor tables
  * rather than joining them, and `document_title` because the standard headers
- * already print the title themselves.
+ * already print the title themselves. `signature` because most invoices and
+ * quotes go out unsigned.
  */
-const HIDDEN_BY_DEFAULT_SECTIONS = new Set<string>(['general', 'telegram_qr', 'items_table'])
+const HIDDEN_BY_DEFAULT_SECTIONS = new Set<string>([
+  'general',
+  'telegram_qr',
+  'items_table',
+  'signature',
+])
 /** A signature line is a choice; most certificates are issued unsigned. */
 const HIDDEN_BY_DEFAULT_CERTIFICATE_SECTIONS = new Set<string>(['slogan', 'signature'])
 

+ 74 - 0
src/features/signatures/Actions/signatureActions.ts

@@ -0,0 +1,74 @@
+'use server'
+
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { demoGuard } from '@/lib/demo'
+import { memberSignatureDataUri } from '../Lib/memberSignature.server'
+import { parseSignatureDataUri } from '../Lib/signatureImage'
+
+/*
+ * A signature is the member's own, so these ask for no permission beyond
+ * belonging to the workshop: whoever can issue a document can sign it, and
+ * nobody can read or change anyone else's.
+ */
+
+async function membershipId(organizationId: string, userId: string): Promise<string> {
+  const member = await db.organizationMember.findUnique({
+    where: { userId_organizationId: { userId, organizationId } },
+    select: { id: true },
+  })
+  if (!member) throw new Error('No organization found')
+  return member.id
+}
+
+/** The caller's signature for this workshop, as a data URI, or null. */
+export async function getMySignature() {
+  return withAuth(async ({ organizationId, userId }) => {
+    return (await memberSignatureDataUri(organizationId, userId)) ?? null
+  })
+}
+
+/** Keep a drawn or uploaded signature, replacing the one before. */
+export async function saveMySignature(dataUri: string) {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      demoGuard()
+      const image = parseSignatureDataUri(dataUri)
+      if (!image) throw new Error('The signature must be a PNG or JPEG image under 300 KB')
+      const memberId = await membershipId(organizationId, userId)
+      const data = new Uint8Array(image.bytes)
+      await db.memberSignature.upsert({
+        where: { memberId },
+        update: { mimeType: image.mimeType, data },
+        create: { memberId, mimeType: image.mimeType, data },
+      })
+      return { saved: true }
+    },
+    {
+      audit: ({ ctx }) => ({
+        action: 'signature.save',
+        entity: 'MemberSignature',
+        entityId: ctx.userId,
+      }),
+    }
+  )
+}
+
+/** Take the caller's signature away. Documents already issued keep their copy. */
+export async function removeMySignature() {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      demoGuard()
+      const memberId = await membershipId(organizationId, userId)
+      await db.memberSignature.deleteMany({ where: { memberId } })
+      return { removed: true }
+    },
+    {
+      audit: ({ ctx }) => ({
+        action: 'signature.remove',
+        entity: 'MemberSignature',
+        entityId: ctx.userId,
+      }),
+    }
+  )
+}

+ 336 - 0
src/features/signatures/Components/SignatureCard.tsx

@@ -0,0 +1,336 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { Eraser, ImageUp, Loader2, PenLine, Save, Trash2 } from 'lucide-react'
+import { toast } from 'sonner'
+import { AppCard } from '@/components/app-card'
+import { Button } from '@/components/ui/button'
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { removeMySignature, saveMySignature } from '../Actions/signatureActions'
+
+/** The widest a saved signature is kept, in pixels: sharp on paper, a few KB on disk. */
+const MAX_WIDTH = 600
+/** Room left around the ink when the image is cropped to it. */
+const PADDING = 6
+/** Anything lighter than this on an uploaded photo is paper, not ink. */
+const PAPER_LUMINANCE = 215
+
+type Mode = 'draw' | 'upload'
+
+/**
+ * The member's own signature for this workshop: drawn with a finger, a pen or
+ * a mouse, or taken from a photo or scan. Either way it is saved as a PNG
+ * cropped to the ink with a transparent background, so it sits on the
+ * signature line of any design whatever its colours.
+ */
+export function SignatureCard({ initial }: { initial: string | null }) {
+  const t = useTranslations('settings.account')
+  const [saved, setSaved] = useState<string | null>(initial)
+  const [editing, setEditing] = useState(initial === null)
+  const [mode, setMode] = useState<Mode>('draw')
+  const [hasInk, setHasInk] = useState(false)
+  const [busy, setBusy] = useState(false)
+  const canvasRef = useRef<HTMLCanvasElement>(null)
+  const fileRef = useRef<HTMLInputElement>(null)
+  const drawing = useRef<{ x: number; y: number } | null>(null)
+
+  /** Size the backing store to the element, so strokes are crisp on any screen. */
+  const resetCanvas = useCallback(() => {
+    const canvas = canvasRef.current
+    if (!canvas) return
+    const ratio = window.devicePixelRatio || 1
+    const { width, height } = canvas.getBoundingClientRect()
+    canvas.width = Math.round(width * ratio)
+    canvas.height = Math.round(height * ratio)
+    const ctx = canvas.getContext('2d')
+    if (!ctx) return
+    ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
+    ctx.lineCap = 'round'
+    ctx.lineJoin = 'round'
+    ctx.lineWidth = 2.2
+    ctx.strokeStyle = '#111827'
+    setHasInk(false)
+  }, [])
+
+  useEffect(() => {
+    if (editing) resetCanvas()
+  }, [editing, mode, resetCanvas])
+
+  const pointOf = (event: React.PointerEvent<HTMLCanvasElement>) => {
+    const rect = event.currentTarget.getBoundingClientRect()
+    return { x: event.clientX - rect.left, y: event.clientY - rect.top }
+  }
+
+  const onPointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
+    if (mode !== 'draw') return
+    event.currentTarget.setPointerCapture(event.pointerId)
+    const point = pointOf(event)
+    drawing.current = point
+    const ctx = event.currentTarget.getContext('2d')
+    if (!ctx) return
+    // A tap leaves a dot, the way a pen would.
+    ctx.beginPath()
+    ctx.arc(point.x, point.y, ctx.lineWidth / 2, 0, Math.PI * 2)
+    ctx.fillStyle = ctx.strokeStyle
+    ctx.fill()
+    setHasInk(true)
+  }
+
+  const onPointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
+    const last = drawing.current
+    if (!last) return
+    const ctx = event.currentTarget.getContext('2d')
+    if (!ctx) return
+    const point = pointOf(event)
+    // Through the midpoint, so fast strokes curve instead of turning corners.
+    const mid = { x: (last.x + point.x) / 2, y: (last.y + point.y) / 2 }
+    ctx.beginPath()
+    ctx.moveTo(last.x, last.y)
+    ctx.quadraticCurveTo(last.x, last.y, mid.x, mid.y)
+    ctx.lineTo(point.x, point.y)
+    ctx.stroke()
+    drawing.current = point
+  }
+
+  const onPointerUp = () => {
+    drawing.current = null
+  }
+
+  /** Draw a picked image onto the canvas with the paper taken out. */
+  const onFile = (file: File | undefined) => {
+    if (!file) return
+    if (!file.type.startsWith('image/')) {
+      toast.error(t('signatureNotImage'))
+      return
+    }
+    const url = URL.createObjectURL(file)
+    const image = new Image()
+    image.onload = () => {
+      URL.revokeObjectURL(url)
+      const canvas = canvasRef.current
+      const ctx = canvas?.getContext('2d')
+      if (!canvas || !ctx) return
+      resetCanvas()
+      const ratio = window.devicePixelRatio || 1
+      const boxW = canvas.width / ratio
+      const boxH = canvas.height / ratio
+      const scale = Math.min(boxW / image.width, boxH / image.height, 1)
+      const w = image.width * scale
+      const h = image.height * scale
+      ctx.drawImage(image, (boxW - w) / 2, (boxH - h) / 2, w, h)
+      const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height)
+      const data = pixels.data
+      let ink = false
+      for (let i = 0; i < data.length; i += 4) {
+        const luminance = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
+        if (luminance >= PAPER_LUMINANCE) {
+          data[i + 3] = 0
+        } else {
+          // Fade the edges of the ink into the paper rather than cutting them.
+          const alpha = Math.min(1, (PAPER_LUMINANCE - luminance) / 60)
+          data[i + 3] = Math.round(data[i + 3] * alpha)
+          if (data[i + 3] > 0) ink = true
+        }
+      }
+      ctx.putImageData(pixels, 0, 0)
+      setHasInk(ink)
+      if (!ink) toast.error(t('signatureNoInk'))
+    }
+    image.onerror = () => {
+      URL.revokeObjectURL(url)
+      toast.error(t('signatureNotImage'))
+    }
+    image.src = url
+  }
+
+  /** The canvas cropped to its ink and scaled to size, as a PNG data URI. */
+  const exportInk = (): string | null => {
+    const canvas = canvasRef.current
+    const ctx = canvas?.getContext('2d')
+    if (!canvas || !ctx) return null
+    const { data, width, height } = ctx.getImageData(0, 0, canvas.width, canvas.height)
+    let minX = width
+    let minY = height
+    let maxX = -1
+    let maxY = -1
+    for (let y = 0; y < height; y++) {
+      for (let x = 0; x < width; x++) {
+        if (data[(y * width + x) * 4 + 3] > 8) {
+          if (x < minX) minX = x
+          if (x > maxX) maxX = x
+          if (y < minY) minY = y
+          if (y > maxY) maxY = y
+        }
+      }
+    }
+    if (maxX < 0) return null
+    const cropW = maxX - minX + 1
+    const cropH = maxY - minY + 1
+    const scale = Math.min(1, (MAX_WIDTH - PADDING * 2) / cropW)
+    const out = document.createElement('canvas')
+    out.width = Math.round(cropW * scale) + PADDING * 2
+    out.height = Math.round(cropH * scale) + PADDING * 2
+    const outCtx = out.getContext('2d')
+    if (!outCtx) return null
+    outCtx.imageSmoothingQuality = 'high'
+    outCtx.drawImage(
+      canvas,
+      minX,
+      minY,
+      cropW,
+      cropH,
+      PADDING,
+      PADDING,
+      out.width - PADDING * 2,
+      out.height - PADDING * 2
+    )
+    return out.toDataURL('image/png')
+  }
+
+  const onSave = async () => {
+    const dataUri = exportInk()
+    if (!dataUri) {
+      toast.error(t('signatureEmpty'))
+      return
+    }
+    setBusy(true)
+    const result = await saveMySignature(dataUri)
+    setBusy(false)
+    if (!result.success) {
+      toast.error(result.error || t('signatureSaveFailed'))
+      return
+    }
+    setSaved(dataUri)
+    setEditing(false)
+    toast.success(t('signatureSaved'))
+  }
+
+  const onRemove = async () => {
+    setBusy(true)
+    const result = await removeMySignature()
+    setBusy(false)
+    if (!result.success) {
+      toast.error(result.error || t('signatureSaveFailed'))
+      return
+    }
+    setSaved(null)
+    setEditing(true)
+    toast.success(t('signatureRemoved'))
+  }
+
+  return (
+    <AppCard
+      icon={PenLine}
+      title={t('signatureTitle')}
+      description={t('signatureDescription')}
+      contentClassName="space-y-4"
+    >
+      {!editing && saved ? (
+        <>
+          <div className="flex h-36 items-center justify-center rounded-md border bg-white p-4">
+            {/* eslint-disable-next-line @next/next/no-img-element */}
+            <img src={saved} alt={t('signatureTitle')} className="max-h-full max-w-full" />
+          </div>
+          <div className="flex flex-wrap gap-2">
+            <Button variant="outline" onClick={() => setEditing(true)} disabled={busy}>
+              <PenLine className="mr-2 h-4 w-4" />
+              {t('signatureReplace')}
+            </Button>
+            <Button variant="outline" onClick={onRemove} disabled={busy}>
+              {busy ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Trash2 className="mr-2 h-4 w-4" />
+              )}
+              {t('signatureRemove')}
+            </Button>
+          </div>
+        </>
+      ) : (
+        <>
+          <Tabs value={mode} onValueChange={(value) => setMode(value as Mode)}>
+            <TabsList>
+              <TabsTrigger value="draw">
+                <PenLine className="mr-1.5 h-4 w-4" />
+                {t('signatureDraw')}
+              </TabsTrigger>
+              <TabsTrigger value="upload">
+                <ImageUp className="mr-1.5 h-4 w-4" />
+                {t('signatureUpload')}
+              </TabsTrigger>
+            </TabsList>
+          </Tabs>
+          <div className="relative">
+            <canvas
+              ref={canvasRef}
+              className={`h-40 w-full touch-none rounded-md border bg-white ${
+                mode === 'draw' ? 'cursor-crosshair' : ''
+              }`}
+              onPointerDown={onPointerDown}
+              onPointerMove={onPointerMove}
+              onPointerUp={onPointerUp}
+              onPointerCancel={onPointerUp}
+              aria-label={t('signatureCanvasLabel')}
+            />
+            {/* The line a signature is written on, as on paper. */}
+            <div className="pointer-events-none absolute inset-x-6 bottom-9 border-b border-dashed border-gray-300" />
+            {!hasInk && (
+              <div className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm text-gray-400">
+                {mode === 'draw' ? t('signatureDrawHint') : t('signatureUploadHint')}
+              </div>
+            )}
+          </div>
+          <input
+            ref={fileRef}
+            type="file"
+            accept="image/png,image/jpeg,image/webp"
+            className="hidden"
+            onChange={(event) => {
+              onFile(event.target.files?.[0])
+              event.target.value = ''
+            }}
+          />
+          <p className="text-xs text-muted-foreground">
+            {mode === 'draw' ? t('signatureDrawTip') : t('signatureUploadTip')}
+          </p>
+          <div className="flex flex-wrap gap-2">
+            <Button onClick={onSave} disabled={busy || !hasInk}>
+              {busy ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Save className="mr-2 h-4 w-4" />
+              )}
+              {t('signatureSave')}
+            </Button>
+            {mode === 'upload' && (
+              <Button variant="outline" onClick={() => fileRef.current?.click()} disabled={busy}>
+                <ImageUp className="mr-2 h-4 w-4" />
+                {t('signatureChooseFile')}
+              </Button>
+            )}
+            <Button variant="outline" onClick={resetCanvas} disabled={busy || !hasInk}>
+              <Eraser className="mr-2 h-4 w-4" />
+              {t('signatureClear')}
+            </Button>
+            {saved && (
+              <Button variant="ghost" onClick={() => setEditing(false)} disabled={busy}>
+                {t('signatureCancel')}
+              </Button>
+            )}
+          </div>
+        </>
+      )}
+      <p className="text-xs text-muted-foreground">
+        {t.rich('signatureDesignerHint', {
+          link: (chunks) => (
+            <Link href="/invoice-designer" className="underline underline-offset-2">
+              {chunks}
+            </Link>
+          ),
+        })}
+      </p>
+    </AppCard>
+  )
+}

+ 81 - 0
src/features/signatures/Lib/memberSignature.server.ts

@@ -0,0 +1,81 @@
+import 'server-only'
+
+import { db } from '@/lib/db'
+import { assetDataUri } from '@/features/invoice-designer/Lib/designSnapshots'
+
+/**
+ * The signature a member of this workshop has saved, as a data URI the
+ * renderers take, or undefined when they have none (or are nobody: a job the
+ * system opened has no one to sign it).
+ */
+export async function memberSignatureDataUri(
+  organizationId: string,
+  userId: string | null | undefined
+): Promise<string | undefined> {
+  if (!userId) return undefined
+  const row = await db.memberSignature.findFirst({
+    where: { member: { organizationId, userId } },
+    select: { mimeType: true, data: true },
+  })
+  return row ? assetDataUri(row) : undefined
+}
+
+/** Who signs a document: their name, and their saved signature when they have one. */
+export async function documentSigner(
+  organizationId: string,
+  userId: string | null | undefined
+): Promise<{ name: string; dataUri?: string }> {
+  if (!userId) return { name: '' }
+  const [user, dataUri] = await Promise.all([
+    db.user.findUnique({ where: { id: userId }, select: { name: true } }),
+    memberSignatureDataUri(organizationId, userId),
+  ])
+  return { name: user?.name ?? '', dataUri }
+}
+
+/**
+ * Whose signature an inspection carries: its technician's, when they have an
+ * account to have saved one on; with no technician, whoever is completing it,
+ * the same person the certificate then names.
+ */
+export async function inspectorUserId(
+  inspection: { technicianId: string | null },
+  completingUserId: string
+): Promise<string | null> {
+  if (!inspection.technicianId) return completingUserId
+  const technician = await db.technician.findUnique({
+    where: { id: inspection.technicianId },
+    select: { userId: true },
+  })
+  return technician?.userId ?? null
+}
+
+/**
+ * The signature a certificate prints. A completed inspection prints the one
+ * frozen at completion and nothing else, so a redrawn signature never changes
+ * a certificate already issued, and one completed before signatures existed
+ * stays unsigned. An open one previews its technician's current signature.
+ */
+export async function certificateSignatureDataUri(
+  organizationId: string,
+  inspection: {
+    completedAt: Date | null
+    signatureSnapshotId: string | null
+    technicianId: string | null
+  }
+): Promise<string | undefined> {
+  if (inspection.completedAt) {
+    if (!inspection.signatureSnapshotId) return undefined
+    const snapshot = await db.documentAssetSnapshot.findFirst({
+      where: { id: inspection.signatureSnapshotId, organizationId },
+      select: { mimeType: true, data: true },
+    })
+    return snapshot ? assetDataUri(snapshot) : undefined
+  }
+  if (!inspection.technicianId) return undefined
+  const technician = await db.technician.findUnique({
+    where: { id: inspection.technicianId },
+    select: { userId: true },
+  })
+  return memberSignatureDataUri(organizationId, technician?.userId)
+}

+ 28 - 0
src/features/signatures/Lib/signatureImage.ts

@@ -0,0 +1,28 @@
+import { isPrintableImage } from '@/features/invoices/Lib/printableImage'
+
+/**
+ * What a signature image may be: a PNG or JPEG the PDF renderer can draw, and
+ * small. The settings card flattens whatever is drawn or picked to a PNG no
+ * wider than a signature line needs, so anything near the limit was not made
+ * by the app.
+ */
+export const SIGNATURE_MAX_BYTES = 300 * 1024
+
+const DATA_URI = /^data:(image\/(?:png|jpeg));base64,([A-Za-z0-9+/=]+)$/
+
+export interface SignatureImage {
+  mimeType: 'image/png' | 'image/jpeg'
+  bytes: Buffer
+}
+
+/** The image a data URI carries, or null when it is not one a sheet can print. */
+export function parseSignatureDataUri(value: unknown): SignatureImage | null {
+  if (typeof value !== 'string') return null
+  const match = DATA_URI.exec(value.trim())
+  if (!match) return null
+  const mimeType = match[1] as SignatureImage['mimeType']
+  const bytes = Buffer.from(match[2], 'base64')
+  if (bytes.length === 0 || bytes.length > SIGNATURE_MAX_BYTES) return null
+  if (!isPrintableImage(bytes, mimeType)) return null
+  return { mimeType, bytes }
+}

+ 3 - 0
src/features/vehicles/Components/invoice-pdf/InvoicePDF.tsx

@@ -30,6 +30,7 @@ export function InvoicePDF({
   otherAttachments = [],
   pdfAttachmentNames = [],
   logoDataUri,
+  signer,
   template,
   torqvoiceLogoDataUri,
   portalUrl,
@@ -45,6 +46,7 @@ export function InvoicePDF({
   otherAttachments?: OtherAttachment[]
   pdfAttachmentNames?: string[]
   logoDataUri?: string
+  signer?: { name: string; dataUri?: string }
   template?: TemplateConfig
   torqvoiceLogoDataUri?: string
   portalUrl?: string
@@ -60,6 +62,7 @@ export function InvoicePDF({
     pdfAttachmentNames,
     otherAttachmentNames: otherAttachments.map((att) => att.fileName),
     logoDataUri,
+    signer,
     template,
     torqvoiceLogoDataUri,
     portalUrl,