Ver Fonte

Custom fields: live list, default values, and real invoice placement (#297)

* Give custom fields default values, document placement, and a live list

- Fix the settings list not showing a new, edited, or deleted field until
  a hard reload (frozen useState over refreshed server props)
- Add an optional default value per field, validated per type, pre-filled
  on forms and printed whenever no value has been entered
- Let the create/edit dialog choose which invoice/quote section a field
  prints in (header, footer, bank account, ..., or not at all), synced
  through a new setCustomFieldPlacement action
- Move custom fields between sections from the designer inspector, and
  let the quote layout place quote-entity fields
- Mount the custom fields form on the quote page so quote fields can
  finally be filled in
- Consolidate the seven print/share queries into getCustomFieldsForPrint

* Offer every custom field in every designer section, and render it there

- The letterhead, footer, and bank account builders drew only their fixed
  field ids, so a custom field assigned to them was invisible on canvas
  and print; they now fold the workshop's own fields in
- Every section with a field list offers all custom fields with a toggle,
  so one field can print in several places; the move dropdown and the
  unassigned chips are gone
- A field switched off is only kept in a section it was stored in, so
  unplaced fields keep their additional-information fallback
- The dialog's placement select reports the first section a field is
  switched on in

* Add custom fields from dashed chips again, and drag field rows to reorder

- Custom fields wait as dashed chips under the field list; clicking one
  adds it to the section switched on, and its x hands it back
- Field rows drag to a new order, sliding live as the pointer passes;
  the stored order is what the sheet prints
- The list carries only what the section uses again, instead of every
  custom field with a toggle

* Print custom fields as plain lines, never as a panel or column lead

A panel bolds its first line the way the customer's name heads its card,
and each footer column bolds its lead; a workshop-defined field standing
first was taking that weight. Custom lines now keep regular weight
everywhere; the bank block's values stay bold like their built-in
neighbours.

* Let the layout choose which field lines print bold

Each field row in the designer carries a B control. Until a section has
a choice, the automatic emphasis stands (a panel's first line, a footer
column's head); the first explicit choice hands the whole section over
to the choices, stored per field in the layout config. The bank block
keeps its all-bold values and the letterhead's built-in lines keep
their own typography.

* Flow the footer's details in the stored field order

The footer drew its columns from a hardcoded matrix, so dragging its
field list changed nothing. Its detail lines now flow top-to-bottom
into up to three columns in the stored order, shared with the
inspector so the B marks match the printed heads; the default order
reproduces the columns the footer has always printed. The note and
the logo keep their own rows.

* Put the portal link under the footer's control, and show it on the canvas

The printed footer carried the customer's portal link unconditionally
while the designer's sample data had no URL, so the line existed only
on paper. It is now a footer field: on by default (merged layouts keep
printing it), switchable off, and the canvas shows it over a stand-in
link so the sheet on screen matches the sheet in hand.
Bernt Christian Egeland há 4 semanas atrás
pai
commit
98d7ca504d
64 ficheiros alterados com 1438 adições e 327 exclusões
  1. 4 2
      messages/de/audit.json
  2. 3 1
      messages/de/quotes.json
  3. 14 3
      messages/de/settings.json
  4. 4 2
      messages/en/audit.json
  5. 3 1
      messages/en/quotes.json
  6. 14 3
      messages/en/settings.json
  7. 4 2
      messages/es/audit.json
  8. 3 1
      messages/es/quotes.json
  9. 14 3
      messages/es/settings.json
  10. 4 2
      messages/fr/audit.json
  11. 3 1
      messages/fr/quotes.json
  12. 14 3
      messages/fr/settings.json
  13. 4 2
      messages/it/audit.json
  14. 3 1
      messages/it/quotes.json
  15. 14 3
      messages/it/settings.json
  16. 4 2
      messages/lt/audit.json
  17. 3 1
      messages/lt/quotes.json
  18. 14 3
      messages/lt/settings.json
  19. 4 2
      messages/nb/audit.json
  20. 3 1
      messages/nb/quotes.json
  21. 14 3
      messages/nb/settings.json
  22. 4 2
      messages/nl/audit.json
  23. 3 1
      messages/nl/quotes.json
  24. 14 3
      messages/nl/settings.json
  25. 4 2
      messages/pl/audit.json
  26. 3 1
      messages/pl/quotes.json
  27. 14 3
      messages/pl/settings.json
  28. 4 2
      messages/pt-BR/audit.json
  29. 3 1
      messages/pt-BR/quotes.json
  30. 14 3
      messages/pt-BR/settings.json
  31. 4 2
      messages/ru/audit.json
  32. 3 1
      messages/ru/quotes.json
  33. 14 3
      messages/ru/settings.json
  34. 4 2
      messages/tr/audit.json
  35. 3 1
      messages/tr/quotes.json
  36. 14 3
      messages/tr/settings.json
  37. 2 0
      prisma/migrations/20260901090000_custom_field_default_value/migration.sql
  38. 12 11
      prisma/schema.prisma
  39. 266 0
      src/__tests__/features/settings/custom-field-defaults-placement.test.ts
  40. 5 2
      src/__tests__/features/settings/designer-controls.test.ts
  41. 2 2
      src/__tests__/features/settings/framed-template-preset.test.tsx
  42. 164 0
      src/__tests__/features/settings/spec-pdf-print.test.tsx
  43. 16 2
      src/app/(authenticated)/settings/custom-fields/page.tsx
  44. 2 2
      src/app/(authenticated)/settings/invoice/invoice-settings.tsx
  45. 7 0
      src/app/(designer)/invoice-designer/page.tsx
  46. 2 18
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  47. 2 18
      src/app/(public)/share/quote/[orgId]/[token]/page.tsx
  48. 2 16
      src/app/api/protected/quotes/[id]/pdf/route.ts
  49. 7 17
      src/app/api/protected/services/[id]/pdf/route.ts
  50. 2 16
      src/app/api/public/share/quote/[orgId]/[token]/pdf/route.ts
  51. 2 1
      src/features/custom-fields/Actions/customFieldActions.ts
  52. 5 1
      src/features/custom-fields/Components/CustomFieldsForm.tsx
  53. 171 16
      src/features/custom-fields/Components/CustomFieldsManager.tsx
  54. 43 0
      src/features/custom-fields/Lib/getCustomFieldsForPrint.ts
  55. 47 4
      src/features/custom-fields/Schema/customFieldSchema.ts
  56. 99 12
      src/features/invoice-designer/Components/DesignerInspector.tsx
  57. 5 1
      src/features/invoice-designer/Components/InvoiceDesigner.tsx
  58. 3 0
      src/features/invoice-designer/Components/sample.ts
  59. 116 28
      src/features/invoice-designer/Spec/buildSpec.ts
  60. 3 17
      src/features/invoices/Pdf/buildInvoicePdfBuffer.ts
  61. 9 0
      src/features/quotes/Components/QuoteRightColumn.tsx
  62. 24 0
      src/features/quotes/Components/useQuoteFormState.ts
  63. 135 69
      src/features/settings/Actions/invoiceLayoutActions.ts
  64. 33 2
      src/features/settings/Schema/invoiceLayoutSchema.ts

+ 4 - 2
messages/de/audit.json

@@ -150,7 +150,8 @@
     "settings_whatsappDisconnected": "WhatsApp getrennt",
     "settings_whatsappDisconnected": "WhatsApp getrennt",
     "settings_whatsappNumberRegistered": "WhatsApp-Nummer registriert",
     "settings_whatsappNumberRegistered": "WhatsApp-Nummer registriert",
     "team_giveTechnicianTheApp": "Board-Techniker ein Konto gegeben",
     "team_giveTechnicianTheApp": "Board-Techniker ein Konto gegeben",
-    "team_createDefaultRoles": "Standardrollen angelegt"
+    "team_createDefaultRoles": "Standardrollen angelegt",
+    "settings_setCustomFieldPlacement": "Position eines benutzerdefinierten Felds geändert"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
@@ -273,6 +274,7 @@
     "whatsapp_send": "WhatsApp-Nachricht an {name} gesendet",
     "whatsapp_send": "WhatsApp-Nachricht an {name} gesendet",
     "work_bay_create": "Arbeitsplatz „{name}“ angelegt",
     "work_bay_create": "Arbeitsplatz „{name}“ angelegt",
     "work_bay_update": "Arbeitsplatz „{name}“ aktualisiert",
     "work_bay_update": "Arbeitsplatz „{name}“ aktualisiert",
-    "work_bay_delete": "Arbeitsplatz {id} gelöscht"
+    "work_bay_delete": "Arbeitsplatz {id} gelöscht",
+    "settings_setCustomFieldPlacement": "Hat geändert, wo ein benutzerdefiniertes Feld gedruckt wird"
   }
   }
 }
 }

+ 3 - 1
messages/de/quotes.json

@@ -48,7 +48,9 @@
       "details": "Details",
       "details": "Details",
       "images": "Bilder",
       "images": "Bilder",
       "documents": "Dokumente"
       "documents": "Dokumente"
-    }
+    },
+    "customFieldsInvalid": "Bitte alle erforderlichen benutzerdefinierten Felder ausfüllen",
+    "customFieldsSaveFailed": "Benutzerdefinierte Felder konnten nicht gespeichert werden"
   },
   },
   "form": {
   "form": {
     "newQuote": "Neues Angebot",
     "newQuote": "Neues Angebot",

+ 14 - 3
messages/de/settings.json

@@ -633,7 +633,8 @@
       "company_org_number": "Organisationsnummer",
       "company_org_number": "Organisationsnummer",
       "bank_account": "Bankkonto",
       "bank_account": "Bankkonto",
       "footer_note": "Fußzeilen-Hinweis",
       "footer_note": "Fußzeilen-Hinweis",
-      "org_number": "Organisationsnummer"
+      "org_number": "Organisationsnummer",
+      "portal_link": "Portal-Link"
     },
     },
     "presetsTitle": "Mit einer Anordnung beginnen",
     "presetsTitle": "Mit einer Anordnung beginnen",
     "presetsHint": "Ersetzt die Anordnung unten. Nichts wird gespeichert, bis Sie speichern.",
     "presetsHint": "Ersetzt die Anordnung unten. Nichts wird gespeichert, bis Sie speichern.",
@@ -1006,7 +1007,16 @@
     "failedSave": "Feld konnte nicht gespeichert werden",
     "failedSave": "Feld konnte nicht gespeichert werden",
     "failedDelete": "Konnte nicht gelöscht werden",
     "failedDelete": "Konnte nicht gelöscht werden",
     "edit": "Edit",
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "unassigned": "Nicht zugewiesen",
+    "defaultValue": "Standardwert",
+    "defaultValueHint": "Wird bei neuen Einträgen vorausgefüllt und gedruckt, solange kein Wert eingegeben wurde.",
+    "defaultValueNone": "Kein",
+    "defaultValueCheckedHint": "Standardmäßig aktiviert",
+    "placement": "Position auf dem Dokument",
+    "placementHidden": "Wird nicht gedruckt",
+    "placementHint": "In welchem Abschnitt der Rechnung oder des Angebots dieses Feld gedruckt wird. Im Designer kann es zusätzlich in weiteren Abschnitten aktiviert werden.",
+    "failedSavePlacement": "Feldposition konnte nicht gespeichert werden"
   },
   },
   "workshop": {
   "workshop": {
     "title": "Werkstatt-Standards",
     "title": "Werkstatt-Standards",
@@ -1916,7 +1926,8 @@
     "placeholder": "Platzhalter",
     "placeholder": "Platzhalter",
     "leaveTitle": "Ohne Speichern verlassen?",
     "leaveTitle": "Ohne Speichern verlassen?",
     "leaveBody": "Ihre Änderungen an diesem Design wurden nicht gespeichert und gehen verloren.",
     "leaveBody": "Ihre Änderungen an diesem Design wurden nicht gespeichert und gehen verloren.",
-    "leaveConfirm": "Verlassen"
+    "leaveConfirm": "Verlassen",
+    "boldField": "Fett"
   },
   },
   "preview": {
   "preview": {
     "title": "Bremsenservice und Ölwechsel",
     "title": "Bremsenservice und Ölwechsel",

+ 4 - 2
messages/en/audit.json

@@ -150,7 +150,8 @@
     "settings_whatsappDisconnected": "Disconnected WhatsApp",
     "settings_whatsappDisconnected": "Disconnected WhatsApp",
     "settings_whatsappNumberRegistered": "Registered a WhatsApp number",
     "settings_whatsappNumberRegistered": "Registered a WhatsApp number",
     "team_giveTechnicianTheApp": "Gave a board-only technician an account",
     "team_giveTechnicianTheApp": "Gave a board-only technician an account",
-    "team_createDefaultRoles": "Created the standard roles"
+    "team_createDefaultRoles": "Created the standard roles",
+    "settings_setCustomFieldPlacement": "Updated Custom Field Placement"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Created custom field \"{name}\"",
     "customField_create": "Created custom field \"{name}\"",
@@ -273,6 +274,7 @@
     "whatsapp_send": "Sent a WhatsApp message to {name}",
     "whatsapp_send": "Sent a WhatsApp message to {name}",
     "work_bay_create": "Created work bay \"{name}\"",
     "work_bay_create": "Created work bay \"{name}\"",
     "work_bay_update": "Updated work bay \"{name}\"",
     "work_bay_update": "Updated work bay \"{name}\"",
-    "work_bay_delete": "Deleted work bay {id}"
+    "work_bay_delete": "Deleted work bay {id}",
+    "settings_setCustomFieldPlacement": "Changed where a custom field prints"
   }
   }
 }
 }

+ 3 - 1
messages/en/quotes.json

@@ -48,7 +48,9 @@
       "details": "Details",
       "details": "Details",
       "images": "Images",
       "images": "Images",
       "documents": "Documents"
       "documents": "Documents"
-    }
+    },
+    "customFieldsInvalid": "Please fill in all required custom fields",
+    "customFieldsSaveFailed": "Failed to save custom fields"
   },
   },
   "form": {
   "form": {
     "newQuote": "New Quote",
     "newQuote": "New Quote",

+ 14 - 3
messages/en/settings.json

@@ -633,7 +633,8 @@
       "company_org_number": "Organization Number",
       "company_org_number": "Organization Number",
       "bank_account": "Bank Account",
       "bank_account": "Bank Account",
       "footer_note": "Footer Note",
       "footer_note": "Footer Note",
-      "org_number": "Organization Number"
+      "org_number": "Organization Number",
+      "portal_link": "Portal Link"
     },
     },
     "presetsTitle": "Start from an arrangement",
     "presetsTitle": "Start from an arrangement",
     "presetsHint": "Replaces the arrangement below. Nothing is saved until you save.",
     "presetsHint": "Replaces the arrangement below. Nothing is saved until you save.",
@@ -1006,7 +1007,16 @@
     "failedSave": "Failed to save field",
     "failedSave": "Failed to save field",
     "failedDelete": "Failed to delete",
     "failedDelete": "Failed to delete",
     "edit": "Edit",
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "unassigned": "Unassigned",
+    "defaultValue": "Default value",
+    "defaultValueHint": "Pre-filled on new records and printed when no value has been entered.",
+    "defaultValueNone": "None",
+    "defaultValueCheckedHint": "Checked by default",
+    "placement": "Placement on document",
+    "placementHidden": "Not printed",
+    "placementHint": "Which section of the invoice or quote this field prints in. In the designer you can also switch it on in more sections.",
+    "failedSavePlacement": "Failed to save field placement"
   },
   },
   "workshop": {
   "workshop": {
     "title": "Workshop Defaults",
     "title": "Workshop Defaults",
@@ -1916,7 +1926,8 @@
     "placeholder": "Placeholder",
     "placeholder": "Placeholder",
     "leaveTitle": "Leave without saving?",
     "leaveTitle": "Leave without saving?",
     "leaveBody": "Your changes to this design have not been saved. They will be lost.",
     "leaveBody": "Your changes to this design have not been saved. They will be lost.",
-    "leaveConfirm": "Leave"
+    "leaveConfirm": "Leave",
+    "boldField": "Bold"
   },
   },
   "preview": {
   "preview": {
     "title": "Brake Service & Oil Change",
     "title": "Brake Service & Oil Change",

+ 4 - 2
messages/es/audit.json

@@ -150,7 +150,8 @@
     "settings_whatsappDisconnected": "WhatsApp desconectado",
     "settings_whatsappDisconnected": "WhatsApp desconectado",
     "settings_whatsappNumberRegistered": "Número de WhatsApp registrado",
     "settings_whatsappNumberRegistered": "Número de WhatsApp registrado",
     "team_giveTechnicianTheApp": "Cuenta dada a un técnico solo de tablero",
     "team_giveTechnicianTheApp": "Cuenta dada a un técnico solo de tablero",
-    "team_createDefaultRoles": "Roles estándar creados"
+    "team_createDefaultRoles": "Roles estándar creados",
+    "settings_setCustomFieldPlacement": "Ubicación de campo personalizado actualizada"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",
     "customField_create": "Campo personalizado «{name}» creado",
@@ -273,6 +274,7 @@
     "whatsapp_send": "Envió un mensaje de WhatsApp a {name}",
     "whatsapp_send": "Envió un mensaje de WhatsApp a {name}",
     "work_bay_create": "Puesto de trabajo «{name}» creado",
     "work_bay_create": "Puesto de trabajo «{name}» creado",
     "work_bay_update": "Puesto de trabajo «{name}» actualizado",
     "work_bay_update": "Puesto de trabajo «{name}» actualizado",
-    "work_bay_delete": "Puesto de trabajo {id} eliminado"
+    "work_bay_delete": "Puesto de trabajo {id} eliminado",
+    "settings_setCustomFieldPlacement": "Cambió dónde se imprime un campo personalizado"
   }
   }
 }
 }

+ 3 - 1
messages/es/quotes.json

@@ -48,7 +48,9 @@
       "details": "Detalles",
       "details": "Detalles",
       "images": "Imágenes",
       "images": "Imágenes",
       "documents": "Documentos"
       "documents": "Documentos"
-    }
+    },
+    "customFieldsInvalid": "Completa todos los campos personalizados obligatorios",
+    "customFieldsSaveFailed": "No se pudieron guardar los campos personalizados"
   },
   },
   "form": {
   "form": {
     "newQuote": "Nuevo presupuesto",
     "newQuote": "Nuevo presupuesto",

+ 14 - 3
messages/es/settings.json

@@ -633,7 +633,8 @@
       "company_org_number": "Número de organización",
       "company_org_number": "Número de organización",
       "bank_account": "Cuenta bancaria",
       "bank_account": "Cuenta bancaria",
       "footer_note": "Nota de pie",
       "footer_note": "Nota de pie",
-      "org_number": "Número de organización"
+      "org_number": "Número de organización",
+      "portal_link": "Enlace al portal"
     },
     },
     "presetsTitle": "Empezar con una disposición",
     "presetsTitle": "Empezar con una disposición",
     "presetsHint": "Sustituye la disposición de abajo. No se guarda nada hasta que guardes.",
     "presetsHint": "Sustituye la disposición de abajo. No se guarda nada hasta que guardes.",
@@ -1006,7 +1007,16 @@
     "failedSave": "No se pudo guardar el campo",
     "failedSave": "No se pudo guardar el campo",
     "failedDelete": "No se pudo eliminar",
     "failedDelete": "No se pudo eliminar",
     "edit": "Edit",
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "unassigned": "Sin asignar",
+    "defaultValue": "Valor predeterminado",
+    "defaultValueHint": "Se rellena automáticamente en registros nuevos y se imprime mientras no se haya introducido un valor.",
+    "defaultValueNone": "Ninguno",
+    "defaultValueCheckedHint": "Marcado por defecto",
+    "placement": "Ubicación en el documento",
+    "placementHidden": "No se imprime",
+    "placementHint": "En qué sección de la factura o del presupuesto se imprime este campo. En el diseñador también puedes activarlo en más secciones.",
+    "failedSavePlacement": "No se pudo guardar la ubicación del campo"
   },
   },
   "workshop": {
   "workshop": {
     "title": "Valores predeterminados del taller",
     "title": "Valores predeterminados del taller",
@@ -1916,7 +1926,8 @@
     "placeholder": "Marcador",
     "placeholder": "Marcador",
     "leaveTitle": "¿Salir sin guardar?",
     "leaveTitle": "¿Salir sin guardar?",
     "leaveBody": "Los cambios de este diseño no se han guardado y se perderán.",
     "leaveBody": "Los cambios de este diseño no se han guardado y se perderán.",
-    "leaveConfirm": "Salir"
+    "leaveConfirm": "Salir",
+    "boldField": "Negrita"
   },
   },
   "preview": {
   "preview": {
     "title": "Servicio de frenos y cambio de aceite",
     "title": "Servicio de frenos y cambio de aceite",

+ 4 - 2
messages/fr/audit.json

@@ -150,7 +150,8 @@
     "settings_whatsappDisconnected": "WhatsApp déconnecté",
     "settings_whatsappDisconnected": "WhatsApp déconnecté",
     "settings_whatsappNumberRegistered": "Numéro WhatsApp enregistré",
     "settings_whatsappNumberRegistered": "Numéro WhatsApp enregistré",
     "team_giveTechnicianTheApp": "Compte donné à un technicien du tableau",
     "team_giveTechnicianTheApp": "Compte donné à un technicien du tableau",
-    "team_createDefaultRoles": "Rôles standard créés"
+    "team_createDefaultRoles": "Rôles standard créés",
+    "settings_setCustomFieldPlacement": "Emplacement de champ personnalisé mis à jour"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",
     "customField_create": "Champ personnalisé « {name} » créé",
@@ -273,6 +274,7 @@
     "whatsapp_send": "Message WhatsApp envoyé à {name}",
     "whatsapp_send": "Message WhatsApp envoyé à {name}",
     "work_bay_create": "Poste de travail « {name} » créé",
     "work_bay_create": "Poste de travail « {name} » créé",
     "work_bay_update": "Poste de travail « {name} » mis à jour",
     "work_bay_update": "Poste de travail « {name} » mis à jour",
-    "work_bay_delete": "Poste de travail {id} supprimé"
+    "work_bay_delete": "Poste de travail {id} supprimé",
+    "settings_setCustomFieldPlacement": "A modifié l'emplacement d'impression d'un champ personnalisé"
   }
   }
 }
 }

+ 3 - 1
messages/fr/quotes.json

@@ -48,7 +48,9 @@
       "details": "Détails",
       "details": "Détails",
       "images": "Images",
       "images": "Images",
       "documents": "Documents"
       "documents": "Documents"
-    }
+    },
+    "customFieldsInvalid": "Veuillez remplir tous les champs personnalisés obligatoires",
+    "customFieldsSaveFailed": "Impossible d'enregistrer les champs personnalisés"
   },
   },
   "form": {
   "form": {
     "newQuote": "Nouveau devis",
     "newQuote": "Nouveau devis",

+ 14 - 3
messages/fr/settings.json

@@ -633,7 +633,8 @@
       "company_org_number": "Numéro d'organisation",
       "company_org_number": "Numéro d'organisation",
       "bank_account": "Compte bancaire",
       "bank_account": "Compte bancaire",
       "footer_note": "Note de bas de page",
       "footer_note": "Note de bas de page",
-      "org_number": "Numéro d'organisation"
+      "org_number": "Numéro d'organisation",
+      "portal_link": "Lien du portail"
     },
     },
     "presetsTitle": "Partir d’une disposition",
     "presetsTitle": "Partir d’une disposition",
     "presetsHint": "Remplace la disposition ci-dessous. Rien n’est enregistré avant votre validation.",
     "presetsHint": "Remplace la disposition ci-dessous. Rien n’est enregistré avant votre validation.",
@@ -1006,7 +1007,16 @@
     "failedSave": "Impossible d'enregistrer le champ",
     "failedSave": "Impossible d'enregistrer le champ",
     "failedDelete": "Impossible de supprimer",
     "failedDelete": "Impossible de supprimer",
     "edit": "Edit",
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "unassigned": "Non assigné",
+    "defaultValue": "Valeur par défaut",
+    "defaultValueHint": "Préremplie sur les nouvelles fiches et imprimée tant qu'aucune valeur n'a été saisie.",
+    "defaultValueNone": "Aucune",
+    "defaultValueCheckedHint": "Cochée par défaut",
+    "placement": "Emplacement sur le document",
+    "placementHidden": "Non imprimé",
+    "placementHint": "Dans quelle section de la facture ou du devis ce champ est imprimé. Dans le designer, vous pouvez aussi l'activer dans d'autres sections.",
+    "failedSavePlacement": "Impossible d'enregistrer l'emplacement du champ"
   },
   },
   "workshop": {
   "workshop": {
     "title": "Valeurs par défaut de l'atelier",
     "title": "Valeurs par défaut de l'atelier",
@@ -1916,7 +1926,8 @@
     "placeholder": "Exemple",
     "placeholder": "Exemple",
     "leaveTitle": "Quitter sans enregistrer ?",
     "leaveTitle": "Quitter sans enregistrer ?",
     "leaveBody": "Les modifications de cette maquette ne sont pas enregistrées et seront perdues.",
     "leaveBody": "Les modifications de cette maquette ne sont pas enregistrées et seront perdues.",
-    "leaveConfirm": "Quitter"
+    "leaveConfirm": "Quitter",
+    "boldField": "Gras"
   },
   },
   "preview": {
   "preview": {
     "title": "Révision des freins et vidange",
     "title": "Révision des freins et vidange",

+ 4 - 2
messages/it/audit.json

@@ -150,7 +150,8 @@
     "settings_whatsappDisconnected": "WhatsApp disconnesso",
     "settings_whatsappDisconnected": "WhatsApp disconnesso",
     "settings_whatsappNumberRegistered": "Numero WhatsApp registrato",
     "settings_whatsappNumberRegistered": "Numero WhatsApp registrato",
     "team_giveTechnicianTheApp": "Account dato a un tecnico solo da lavagna",
     "team_giveTechnicianTheApp": "Account dato a un tecnico solo da lavagna",
-    "team_createDefaultRoles": "Ruoli standard creati"
+    "team_createDefaultRoles": "Ruoli standard creati",
+    "settings_setCustomFieldPlacement": "Posizione del campo personalizzato aggiornata"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",
     "customField_create": "Campo personalizzato «{name}» creato",
@@ -273,6 +274,7 @@
     "whatsapp_send": "Inviato un messaggio WhatsApp a {name}",
     "whatsapp_send": "Inviato un messaggio WhatsApp a {name}",
     "work_bay_create": "Postazione «{name}» creata",
     "work_bay_create": "Postazione «{name}» creata",
     "work_bay_update": "Postazione «{name}» aggiornata",
     "work_bay_update": "Postazione «{name}» aggiornata",
-    "work_bay_delete": "Postazione {id} eliminata"
+    "work_bay_delete": "Postazione {id} eliminata",
+    "settings_setCustomFieldPlacement": "Ha modificato dove viene stampato un campo personalizzato"
   }
   }
 }
 }

+ 3 - 1
messages/it/quotes.json

@@ -48,7 +48,9 @@
       "details": "Dettagli",
       "details": "Dettagli",
       "images": "Immagini",
       "images": "Immagini",
       "documents": "Documenti"
       "documents": "Documenti"
-    }
+    },
+    "customFieldsInvalid": "Compila tutti i campi personalizzati obbligatori",
+    "customFieldsSaveFailed": "Impossibile salvare i campi personalizzati"
   },
   },
   "form": {
   "form": {
     "newQuote": "Nuovo preventivo",
     "newQuote": "Nuovo preventivo",

+ 14 - 3
messages/it/settings.json

@@ -633,7 +633,8 @@
       "company_org_number": "Numero organizzazione",
       "company_org_number": "Numero organizzazione",
       "bank_account": "Conto bancario",
       "bank_account": "Conto bancario",
       "footer_note": "Nota a piè di pagina",
       "footer_note": "Nota a piè di pagina",
-      "org_number": "Numero organizzazione"
+      "org_number": "Numero organizzazione",
+      "portal_link": "Link al portale"
     },
     },
     "presetsTitle": "Partire da una disposizione",
     "presetsTitle": "Partire da una disposizione",
     "presetsHint": "Sostituisce la disposizione qui sotto. Nulla viene salvato finché non salvi.",
     "presetsHint": "Sostituisce la disposizione qui sotto. Nulla viene salvato finché non salvi.",
@@ -1006,7 +1007,16 @@
     "failedSave": "Impossibile salvare il campo",
     "failedSave": "Impossibile salvare il campo",
     "failedDelete": "Impossibile eliminare",
     "failedDelete": "Impossibile eliminare",
     "edit": "Edit",
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "unassigned": "Non assegnato",
+    "defaultValue": "Valore predefinito",
+    "defaultValueHint": "Precompilato nei nuovi record e stampato finché non viene inserito un valore.",
+    "defaultValueNone": "Nessuno",
+    "defaultValueCheckedHint": "Selezionata per impostazione predefinita",
+    "placement": "Posizione sul documento",
+    "placementHidden": "Non stampato",
+    "placementHint": "In quale sezione della fattura o del preventivo viene stampato questo campo. Nel designer puoi attivarlo anche in altre sezioni.",
+    "failedSavePlacement": "Impossibile salvare la posizione del campo"
   },
   },
   "workshop": {
   "workshop": {
     "title": "Predefiniti officina",
     "title": "Predefiniti officina",
@@ -1916,7 +1926,8 @@
     "placeholder": "Segnaposto",
     "placeholder": "Segnaposto",
     "leaveTitle": "Uscire senza salvare?",
     "leaveTitle": "Uscire senza salvare?",
     "leaveBody": "Le modifiche a questo design non sono state salvate e andranno perse.",
     "leaveBody": "Le modifiche a questo design non sono state salvate e andranno perse.",
-    "leaveConfirm": "Esci"
+    "leaveConfirm": "Esci",
+    "boldField": "Grassetto"
   },
   },
   "preview": {
   "preview": {
     "title": "Servizio freni e cambio olio",
     "title": "Servizio freni e cambio olio",

+ 4 - 2
messages/lt/audit.json

@@ -150,7 +150,8 @@
     "settings_whatsappDisconnected": "Atjungtas „WhatsApp“",
     "settings_whatsappDisconnected": "Atjungtas „WhatsApp“",
     "settings_whatsappNumberRegistered": "Užregistruotas „WhatsApp“ numeris",
     "settings_whatsappNumberRegistered": "Užregistruotas „WhatsApp“ numeris",
     "team_giveTechnicianTheApp": "Lentos technikui suteikta paskyra",
     "team_giveTechnicianTheApp": "Lentos technikui suteikta paskyra",
-    "team_createDefaultRoles": "Sukurtos standartinės rolės"
+    "team_createDefaultRoles": "Sukurtos standartinės rolės",
+    "settings_setCustomFieldPlacement": "Atnaujinta pasirinktinio lauko vieta"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
@@ -273,6 +274,7 @@
     "whatsapp_send": "Klientui {name} išsiųsta „WhatsApp“ žinutė",
     "whatsapp_send": "Klientui {name} išsiųsta „WhatsApp“ žinutė",
     "work_bay_create": "Sukurta darbo vieta „{name}“",
     "work_bay_create": "Sukurta darbo vieta „{name}“",
     "work_bay_update": "Atnaujinta darbo vieta „{name}“",
     "work_bay_update": "Atnaujinta darbo vieta „{name}“",
-    "work_bay_delete": "Ištrinta darbo vieta {id}"
+    "work_bay_delete": "Ištrinta darbo vieta {id}",
+    "settings_setCustomFieldPlacement": "Pakeitė, kur spausdinamas pasirinktinis laukas"
   }
   }
 }
 }

+ 3 - 1
messages/lt/quotes.json

@@ -48,7 +48,9 @@
       "details": "Informacija",
       "details": "Informacija",
       "images": "Vaizdai",
       "images": "Vaizdai",
       "documents": "Dokumentai"
       "documents": "Dokumentai"
-    }
+    },
+    "customFieldsInvalid": "Užpildykite visus privalomus pasirinktinius laukus",
+    "customFieldsSaveFailed": "Nepavyko išsaugoti pasirinktinių laukų"
   },
   },
   "form": {
   "form": {
     "newQuote": "Naujas pasiūlymas",
     "newQuote": "Naujas pasiūlymas",

+ 14 - 3
messages/lt/settings.json

@@ -633,7 +633,8 @@
       "company_org_number": "Įmonės kodas",
       "company_org_number": "Įmonės kodas",
       "bank_account": "Banko sąskaita",
       "bank_account": "Banko sąskaita",
       "footer_note": "Poraštės pastaba",
       "footer_note": "Poraštės pastaba",
-      "org_number": "Įmonės kodas"
+      "org_number": "Įmonės kodas",
+      "portal_link": "Portalo nuoroda"
     },
     },
     "presetsTitle": "Pradėti nuo išdėstymo",
     "presetsTitle": "Pradėti nuo išdėstymo",
     "presetsHint": "Pakeičia žemiau esantį išdėstymą. Niekas neįrašoma, kol neišsaugote.",
     "presetsHint": "Pakeičia žemiau esantį išdėstymą. Niekas neįrašoma, kol neišsaugote.",
@@ -1006,7 +1007,16 @@
     "failedSave": "Nepavyko išsaugoti lauko",
     "failedSave": "Nepavyko išsaugoti lauko",
     "failedDelete": "Nepavyko ištrinti",
     "failedDelete": "Nepavyko ištrinti",
     "edit": "Edit",
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "unassigned": "Nepriskirta",
+    "defaultValue": "Numatytoji reikšmė",
+    "defaultValueHint": "Automatiškai užpildoma naujuose įrašuose ir spausdinama, kol neįvesta reikšmė.",
+    "defaultValueNone": "Nėra",
+    "defaultValueCheckedHint": "Pažymėta pagal numatytuosius nustatymus",
+    "placement": "Vieta dokumente",
+    "placementHidden": "Nespausdinama",
+    "placementHint": "Kurioje sąskaitos arba pasiūlymo dalyje spausdinamas šis laukas. Dizaineryje jį galima įjungti ir kitose dalyse.",
+    "failedSavePlacement": "Nepavyko išsaugoti lauko vietos"
   },
   },
   "workshop": {
   "workshop": {
     "title": "Dirbtuvių numatytosios reikšmės",
     "title": "Dirbtuvių numatytosios reikšmės",
@@ -1916,7 +1926,8 @@
     "placeholder": "Vietaženklis",
     "placeholder": "Vietaženklis",
     "leaveTitle": "Išeiti neišsaugojus?",
     "leaveTitle": "Išeiti neišsaugojus?",
     "leaveBody": "Šio dizaino pakeitimai neišsaugoti ir bus prarasti.",
     "leaveBody": "Šio dizaino pakeitimai neišsaugoti ir bus prarasti.",
-    "leaveConfirm": "Išeiti"
+    "leaveConfirm": "Išeiti",
+    "boldField": "Paryškinta"
   },
   },
   "preview": {
   "preview": {
     "title": "Stabdžių aptarnavimas ir tepalų keitimas",
     "title": "Stabdžių aptarnavimas ir tepalų keitimas",

+ 4 - 2
messages/nb/audit.json

@@ -150,7 +150,8 @@
     "settings_whatsappDisconnected": "Koblet fra WhatsApp",
     "settings_whatsappDisconnected": "Koblet fra WhatsApp",
     "settings_whatsappNumberRegistered": "Registrerte et WhatsApp-nummer",
     "settings_whatsappNumberRegistered": "Registrerte et WhatsApp-nummer",
     "team_giveTechnicianTheApp": "Ga en tavle-tekniker en konto",
     "team_giveTechnicianTheApp": "Ga en tavle-tekniker en konto",
-    "team_createDefaultRoles": "Opprettet standardrollene"
+    "team_createDefaultRoles": "Opprettet standardrollene",
+    "settings_setCustomFieldPlacement": "Oppdaterte plassering av egendefinert felt"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",
     "customField_create": "Opprettet egendefinert felt «{name}»",
@@ -273,6 +274,7 @@
     "whatsapp_send": "Sendte en WhatsApp-melding til {name}",
     "whatsapp_send": "Sendte en WhatsApp-melding til {name}",
     "work_bay_create": "Opprettet arbeidsplass «{name}»",
     "work_bay_create": "Opprettet arbeidsplass «{name}»",
     "work_bay_update": "Oppdaterte arbeidsplass «{name}»",
     "work_bay_update": "Oppdaterte arbeidsplass «{name}»",
-    "work_bay_delete": "Slettet arbeidsplass {id}"
+    "work_bay_delete": "Slettet arbeidsplass {id}",
+    "settings_setCustomFieldPlacement": "Endret hvor et egendefinert felt skrives ut"
   }
   }
 }
 }

+ 3 - 1
messages/nb/quotes.json

@@ -48,7 +48,9 @@
       "details": "Detaljer",
       "details": "Detaljer",
       "images": "Bilder",
       "images": "Bilder",
       "documents": "Dokumenter"
       "documents": "Dokumenter"
-    }
+    },
+    "customFieldsInvalid": "Fyll ut alle obligatoriske egendefinerte felt",
+    "customFieldsSaveFailed": "Kunne ikke lagre egendefinerte felt"
   },
   },
   "form": {
   "form": {
     "newQuote": "Nytt tilbud",
     "newQuote": "Nytt tilbud",

+ 14 - 3
messages/nb/settings.json

@@ -633,7 +633,8 @@
       "company_org_number": "Organisasjonsnummer",
       "company_org_number": "Organisasjonsnummer",
       "bank_account": "Bankkonto",
       "bank_account": "Bankkonto",
       "footer_note": "Bunntekstnotat",
       "footer_note": "Bunntekstnotat",
-      "org_number": "Organisasjonsnummer"
+      "org_number": "Organisasjonsnummer",
+      "portal_link": "Portallenke"
     },
     },
     "presetsTitle": "Start fra et oppsett",
     "presetsTitle": "Start fra et oppsett",
     "presetsHint": "Erstatter oppsettet under. Ingenting lagres før du lagrer.",
     "presetsHint": "Erstatter oppsettet under. Ingenting lagres før du lagrer.",
@@ -1006,7 +1007,16 @@
     "failedSave": "Kunne ikke lagre felt",
     "failedSave": "Kunne ikke lagre felt",
     "failedDelete": "Kunne ikke slette",
     "failedDelete": "Kunne ikke slette",
     "edit": "Edit",
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "unassigned": "Ikke plassert",
+    "defaultValue": "Standardverdi",
+    "defaultValueHint": "Fylles ut automatisk på nye oppføringer og skrives ut så lenge ingen verdi er angitt.",
+    "defaultValueNone": "Ingen",
+    "defaultValueCheckedHint": "Avkrysset som standard",
+    "placement": "Plassering på dokumentet",
+    "placementHidden": "Skrives ikke ut",
+    "placementHint": "Hvilken del av fakturaen eller tilbudet dette feltet skrives ut i. I designeren kan du også slå det på i flere seksjoner.",
+    "failedSavePlacement": "Kunne ikke lagre feltplasseringen"
   },
   },
   "workshop": {
   "workshop": {
     "title": "Verkstedstandarder",
     "title": "Verkstedstandarder",
@@ -1916,7 +1926,8 @@
     "placeholder": "Plassholder",
     "placeholder": "Plassholder",
     "leaveTitle": "Forlate uten å lagre?",
     "leaveTitle": "Forlate uten å lagre?",
     "leaveBody": "Endringene i dette designet er ikke lagret. De går tapt.",
     "leaveBody": "Endringene i dette designet er ikke lagret. De går tapt.",
-    "leaveConfirm": "Forlat"
+    "leaveConfirm": "Forlat",
+    "boldField": "Fet"
   },
   },
   "preview": {
   "preview": {
     "title": "Bremseservice og oljeskift",
     "title": "Bremseservice og oljeskift",

+ 4 - 2
messages/nl/audit.json

@@ -150,7 +150,8 @@
     "settings_whatsappDisconnected": "WhatsApp losgekoppeld",
     "settings_whatsappDisconnected": "WhatsApp losgekoppeld",
     "settings_whatsappNumberRegistered": "WhatsApp-nummer geregistreerd",
     "settings_whatsappNumberRegistered": "WhatsApp-nummer geregistreerd",
     "team_giveTechnicianTheApp": "Bordmonteur een account gegeven",
     "team_giveTechnicianTheApp": "Bordmonteur een account gegeven",
-    "team_createDefaultRoles": "Standaardrollen aangemaakt"
+    "team_createDefaultRoles": "Standaardrollen aangemaakt",
+    "settings_setCustomFieldPlacement": "Plaatsing van aangepast veld bijgewerkt"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
@@ -273,6 +274,7 @@
     "whatsapp_send": "WhatsApp-bericht verzonden naar {name}",
     "whatsapp_send": "WhatsApp-bericht verzonden naar {name}",
     "work_bay_create": "Werkplek \"{name}\" aangemaakt",
     "work_bay_create": "Werkplek \"{name}\" aangemaakt",
     "work_bay_update": "Werkplek \"{name}\" bijgewerkt",
     "work_bay_update": "Werkplek \"{name}\" bijgewerkt",
-    "work_bay_delete": "Werkplek {id} verwijderd"
+    "work_bay_delete": "Werkplek {id} verwijderd",
+    "settings_setCustomFieldPlacement": "Wijzigde waar een aangepast veld wordt afgedrukt"
   }
   }
 }
 }

+ 3 - 1
messages/nl/quotes.json

@@ -48,7 +48,9 @@
       "details": "Details",
       "details": "Details",
       "images": "Afbeeldingen",
       "images": "Afbeeldingen",
       "documents": "Documenten"
       "documents": "Documenten"
-    }
+    },
+    "customFieldsInvalid": "Vul alle verplichte aangepaste velden in",
+    "customFieldsSaveFailed": "Kan aangepaste velden niet opslaan"
   },
   },
   "form": {
   "form": {
     "newQuote": "Nieuwe offerte",
     "newQuote": "Nieuwe offerte",

+ 14 - 3
messages/nl/settings.json

@@ -633,7 +633,8 @@
       "company_org_number": "Organisatienummer",
       "company_org_number": "Organisatienummer",
       "bank_account": "Bankrekening",
       "bank_account": "Bankrekening",
       "footer_note": "Voettekstnotitie",
       "footer_note": "Voettekstnotitie",
-      "org_number": "Organisatienummer"
+      "org_number": "Organisatienummer",
+      "portal_link": "Portaallink"
     },
     },
     "presetsTitle": "Begin met een indeling",
     "presetsTitle": "Begin met een indeling",
     "presetsHint": "Vervangt de indeling hieronder. Er wordt niets opgeslagen tot u opslaat.",
     "presetsHint": "Vervangt de indeling hieronder. Er wordt niets opgeslagen tot u opslaat.",
@@ -1006,7 +1007,16 @@
     "failedSave": "Kan veld niet opslaan",
     "failedSave": "Kan veld niet opslaan",
     "failedDelete": "Kan niet verwijderen",
     "failedDelete": "Kan niet verwijderen",
     "edit": "Edit",
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "unassigned": "Niet toegewezen",
+    "defaultValue": "Standaardwaarde",
+    "defaultValueHint": "Wordt vooraf ingevuld bij nieuwe records en afgedrukt zolang er geen waarde is ingevoerd.",
+    "defaultValueNone": "Geen",
+    "defaultValueCheckedHint": "Standaard aangevinkt",
+    "placement": "Plaats op het document",
+    "placementHidden": "Wordt niet afgedrukt",
+    "placementHint": "In welke sectie van de factuur of offerte dit veld wordt afgedrukt. In de designer kun je het ook in meer secties inschakelen.",
+    "failedSavePlacement": "Kan de veldplaatsing niet opslaan"
   },
   },
   "workshop": {
   "workshop": {
     "title": "Werkplaatsstandaarden",
     "title": "Werkplaatsstandaarden",
@@ -1916,7 +1926,8 @@
     "placeholder": "Voorbeeld",
     "placeholder": "Voorbeeld",
     "leaveTitle": "Weggaan zonder opslaan?",
     "leaveTitle": "Weggaan zonder opslaan?",
     "leaveBody": "Je wijzigingen aan dit ontwerp zijn niet opgeslagen en gaan verloren.",
     "leaveBody": "Je wijzigingen aan dit ontwerp zijn niet opgeslagen en gaan verloren.",
-    "leaveConfirm": "Weggaan"
+    "leaveConfirm": "Weggaan",
+    "boldField": "Vet"
   },
   },
   "preview": {
   "preview": {
     "title": "Remservice en oliewissel",
     "title": "Remservice en oliewissel",

+ 4 - 2
messages/pl/audit.json

@@ -150,7 +150,8 @@
     "settings_whatsappDisconnected": "Odłączono WhatsApp",
     "settings_whatsappDisconnected": "Odłączono WhatsApp",
     "settings_whatsappNumberRegistered": "Zarejestrowano numer WhatsApp",
     "settings_whatsappNumberRegistered": "Zarejestrowano numer WhatsApp",
     "team_giveTechnicianTheApp": "Nadano konto technikowi z tablicy",
     "team_giveTechnicianTheApp": "Nadano konto technikowi z tablicy",
-    "team_createDefaultRoles": "Utworzono role standardowe"
+    "team_createDefaultRoles": "Utworzono role standardowe",
+    "settings_setCustomFieldPlacement": "Zaktualizowano miejsce pola niestandardowego"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",
     "customField_create": "Utworzono pole niestandardowe „{name}”",
@@ -273,6 +274,7 @@
     "whatsapp_send": "Wysłano wiadomość WhatsApp do {name}",
     "whatsapp_send": "Wysłano wiadomość WhatsApp do {name}",
     "work_bay_create": "Utworzono stanowisko „{name}”",
     "work_bay_create": "Utworzono stanowisko „{name}”",
     "work_bay_update": "Zaktualizowano stanowisko „{name}”",
     "work_bay_update": "Zaktualizowano stanowisko „{name}”",
-    "work_bay_delete": "Usunięto stanowisko {id}"
+    "work_bay_delete": "Usunięto stanowisko {id}",
+    "settings_setCustomFieldPlacement": "Zmieniono miejsce drukowania pola niestandardowego"
   }
   }
 }
 }

+ 3 - 1
messages/pl/quotes.json

@@ -48,7 +48,9 @@
       "details": "Szczegóły",
       "details": "Szczegóły",
       "images": "Zdjęcia",
       "images": "Zdjęcia",
       "documents": "Dokumenty"
       "documents": "Dokumenty"
-    }
+    },
+    "customFieldsInvalid": "Wypełnij wszystkie wymagane pola niestandardowe",
+    "customFieldsSaveFailed": "Nie udało się zapisać pól niestandardowych"
   },
   },
   "form": {
   "form": {
     "newQuote": "Nowa wycena",
     "newQuote": "Nowa wycena",

+ 14 - 3
messages/pl/settings.json

@@ -633,7 +633,8 @@
       "company_org_number": "Numer organizacji",
       "company_org_number": "Numer organizacji",
       "bank_account": "Konto bankowe",
       "bank_account": "Konto bankowe",
       "footer_note": "Notatka w stopce",
       "footer_note": "Notatka w stopce",
-      "org_number": "Numer organizacji"
+      "org_number": "Numer organizacji",
+      "portal_link": "Link do portalu"
     },
     },
     "presetsTitle": "Zacznij od układu",
     "presetsTitle": "Zacznij od układu",
     "presetsHint": "Zastępuje układ poniżej. Nic nie zostanie zapisane, dopóki nie zapiszesz.",
     "presetsHint": "Zastępuje układ poniżej. Nic nie zostanie zapisane, dopóki nie zapiszesz.",
@@ -1006,7 +1007,16 @@
     "failedSave": "Nie udało się zapisać pola",
     "failedSave": "Nie udało się zapisać pola",
     "failedDelete": "Nie udało się usunąć",
     "failedDelete": "Nie udało się usunąć",
     "edit": "Edit",
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "unassigned": "Nieprzypisane",
+    "defaultValue": "Wartość domyślna",
+    "defaultValueHint": "Wypełniana automatycznie w nowych wpisach i drukowana, dopóki nie wprowadzono wartości.",
+    "defaultValueNone": "Brak",
+    "defaultValueCheckedHint": "Domyślnie zaznaczone",
+    "placement": "Miejsce na dokumencie",
+    "placementHidden": "Nie jest drukowane",
+    "placementHint": "W której sekcji faktury lub wyceny drukowane jest to pole. W projektancie można je włączyć także w innych sekcjach.",
+    "failedSavePlacement": "Nie udało się zapisać miejsca pola"
   },
   },
   "workshop": {
   "workshop": {
     "title": "Domyślne warsztatu",
     "title": "Domyślne warsztatu",
@@ -1916,7 +1926,8 @@
     "placeholder": "Symbol zastępczy",
     "placeholder": "Symbol zastępczy",
     "leaveTitle": "Wyjść bez zapisywania?",
     "leaveTitle": "Wyjść bez zapisywania?",
     "leaveBody": "Zmiany w tym projekcie nie zostały zapisane i zostaną utracone.",
     "leaveBody": "Zmiany w tym projekcie nie zostały zapisane i zostaną utracone.",
-    "leaveConfirm": "Wyjdź"
+    "leaveConfirm": "Wyjdź",
+    "boldField": "Pogrubienie"
   },
   },
   "preview": {
   "preview": {
     "title": "Serwis hamulców i wymiana oleju",
     "title": "Serwis hamulców i wymiana oleju",

+ 4 - 2
messages/pt-BR/audit.json

@@ -150,7 +150,8 @@
     "settings_whatsappDisconnected": "WhatsApp desconectado",
     "settings_whatsappDisconnected": "WhatsApp desconectado",
     "settings_whatsappNumberRegistered": "Número do WhatsApp registrado",
     "settings_whatsappNumberRegistered": "Número do WhatsApp registrado",
     "team_giveTechnicianTheApp": "Conta dada a um técnico só do quadro",
     "team_giveTechnicianTheApp": "Conta dada a um técnico só do quadro",
-    "team_createDefaultRoles": "Funções padrão criadas"
+    "team_createDefaultRoles": "Funções padrão criadas",
+    "settings_setCustomFieldPlacement": "Posição de campo personalizado atualizada"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
     "customField_create": "Campo personalizado \"{name}\" criado",
@@ -273,6 +274,7 @@
     "whatsapp_send": "Enviou uma mensagem de WhatsApp para {name}",
     "whatsapp_send": "Enviou uma mensagem de WhatsApp para {name}",
     "work_bay_create": "Baia \"{name}\" criada",
     "work_bay_create": "Baia \"{name}\" criada",
     "work_bay_update": "Baia \"{name}\" atualizada",
     "work_bay_update": "Baia \"{name}\" atualizada",
-    "work_bay_delete": "Baia {id} excluída"
+    "work_bay_delete": "Baia {id} excluída",
+    "settings_setCustomFieldPlacement": "Alterou onde um campo personalizado é impresso"
   }
   }
 }
 }

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

@@ -48,7 +48,9 @@
       "details": "Detalhes",
       "details": "Detalhes",
       "images": "Imagens",
       "images": "Imagens",
       "documents": "Documentos"
       "documents": "Documentos"
-    }
+    },
+    "customFieldsInvalid": "Preencha todos os campos personalizados obrigatórios",
+    "customFieldsSaveFailed": "Não foi possível salvar os campos personalizados"
   },
   },
   "form": {
   "form": {
     "newQuote": "Novo orçamento",
     "newQuote": "Novo orçamento",

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

@@ -633,7 +633,8 @@
       "company_org_number": "Número da organização",
       "company_org_number": "Número da organização",
       "bank_account": "Conta bancária",
       "bank_account": "Conta bancária",
       "footer_note": "Nota de rodapé",
       "footer_note": "Nota de rodapé",
-      "org_number": "Número da organização"
+      "org_number": "Número da organização",
+      "portal_link": "Link do portal"
     },
     },
     "presetsTitle": "Começar por uma disposição",
     "presetsTitle": "Começar por uma disposição",
     "presetsHint": "Substitui a disposição abaixo. Nada é salvo até você salvar.",
     "presetsHint": "Substitui a disposição abaixo. Nada é salvo até você salvar.",
@@ -1006,7 +1007,16 @@
     "failedSave": "Falha ao salvar campo",
     "failedSave": "Falha ao salvar campo",
     "failedDelete": "Falha ao excluir",
     "failedDelete": "Falha ao excluir",
     "edit": "Edit",
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "unassigned": "Não atribuído",
+    "defaultValue": "Valor padrão",
+    "defaultValueHint": "Preenchido automaticamente em novos registros e impresso enquanto nenhum valor for informado.",
+    "defaultValueNone": "Nenhum",
+    "defaultValueCheckedHint": "Marcado por padrão",
+    "placement": "Posição no documento",
+    "placementHidden": "Não é impresso",
+    "placementHint": "Em qual seção da fatura ou do orçamento este campo é impresso. No designer você também pode ativá-lo em mais seções.",
+    "failedSavePlacement": "Não foi possível salvar a posição do campo"
   },
   },
   "workshop": {
   "workshop": {
     "title": "Padroes da oficina",
     "title": "Padroes da oficina",
@@ -1916,7 +1926,8 @@
     "placeholder": "Exemplo",
     "placeholder": "Exemplo",
     "leaveTitle": "Sair sem salvar?",
     "leaveTitle": "Sair sem salvar?",
     "leaveBody": "Suas alterações neste design não foram salvas e serão perdidas.",
     "leaveBody": "Suas alterações neste design não foram salvas e serão perdidas.",
-    "leaveConfirm": "Sair"
+    "leaveConfirm": "Sair",
+    "boldField": "Negrito"
   },
   },
   "preview": {
   "preview": {
     "title": "Serviço de freios e troca de óleo",
     "title": "Serviço de freios e troca de óleo",

+ 4 - 2
messages/ru/audit.json

@@ -150,7 +150,8 @@
     "settings_whatsappDisconnected": "WhatsApp отключён",
     "settings_whatsappDisconnected": "WhatsApp отключён",
     "settings_whatsappNumberRegistered": "Зарегистрирован номер WhatsApp",
     "settings_whatsappNumberRegistered": "Зарегистрирован номер WhatsApp",
     "team_giveTechnicianTheApp": "Механику с доски выдана учётная запись",
     "team_giveTechnicianTheApp": "Механику с доски выдана учётная запись",
-    "team_createDefaultRoles": "Созданы стандартные роли"
+    "team_createDefaultRoles": "Созданы стандартные роли",
+    "settings_setCustomFieldPlacement": "Обновлено расположение пользовательского поля"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",
     "customField_create": "Создано настраиваемое поле «{name}»",
@@ -273,6 +274,7 @@
     "whatsapp_send": "Отправлено сообщение WhatsApp клиенту {name}",
     "whatsapp_send": "Отправлено сообщение WhatsApp клиенту {name}",
     "work_bay_create": "Создан пост «{name}»",
     "work_bay_create": "Создан пост «{name}»",
     "work_bay_update": "Обновлён пост «{name}»",
     "work_bay_update": "Обновлён пост «{name}»",
-    "work_bay_delete": "Удалён пост {id}"
+    "work_bay_delete": "Удалён пост {id}",
+    "settings_setCustomFieldPlacement": "Изменено место печати пользовательского поля"
   }
   }
 }
 }

+ 3 - 1
messages/ru/quotes.json

@@ -48,7 +48,9 @@
       "details": "Детали",
       "details": "Детали",
       "images": "Изображения",
       "images": "Изображения",
       "documents": "Документы"
       "documents": "Документы"
-    }
+    },
+    "customFieldsInvalid": "Заполните все обязательные пользовательские поля",
+    "customFieldsSaveFailed": "Не удалось сохранить пользовательские поля"
   },
   },
   "form": {
   "form": {
     "newQuote": "Новое предложение",
     "newQuote": "Новое предложение",

+ 14 - 3
messages/ru/settings.json

@@ -633,7 +633,8 @@
       "company_org_number": "Номер организации",
       "company_org_number": "Номер организации",
       "bank_account": "Банковский счёт",
       "bank_account": "Банковский счёт",
       "footer_note": "Примечание в подвале",
       "footer_note": "Примечание в подвале",
-      "org_number": "Номер организации"
+      "org_number": "Номер организации",
+      "portal_link": "Ссылка на портал"
     },
     },
     "presetsTitle": "Начать с расположения",
     "presetsTitle": "Начать с расположения",
     "presetsHint": "Заменяет расположение ниже. Ничего не сохранится, пока вы не сохраните.",
     "presetsHint": "Заменяет расположение ниже. Ничего не сохранится, пока вы не сохраните.",
@@ -1006,7 +1007,16 @@
     "failedSave": "Не удалось сохранить поле",
     "failedSave": "Не удалось сохранить поле",
     "failedDelete": "Не удалось удалить",
     "failedDelete": "Не удалось удалить",
     "edit": "Edit",
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "unassigned": "Не назначено",
+    "defaultValue": "Значение по умолчанию",
+    "defaultValueHint": "Автоматически подставляется в новые записи и печатается, пока значение не введено.",
+    "defaultValueNone": "Нет",
+    "defaultValueCheckedHint": "Отмечено по умолчанию",
+    "placement": "Расположение в документе",
+    "placementHidden": "Не печатается",
+    "placementHint": "В каком разделе счёта или сметы печатается это поле. В дизайнере его можно включить и в других разделах.",
+    "failedSavePlacement": "Не удалось сохранить расположение поля"
   },
   },
   "workshop": {
   "workshop": {
     "title": "Настройки мастерской",
     "title": "Настройки мастерской",
@@ -1916,7 +1926,8 @@
     "placeholder": "Образец",
     "placeholder": "Образец",
     "leaveTitle": "Выйти без сохранения?",
     "leaveTitle": "Выйти без сохранения?",
     "leaveBody": "Изменения в этом оформлении не сохранены и будут потеряны.",
     "leaveBody": "Изменения в этом оформлении не сохранены и будут потеряны.",
-    "leaveConfirm": "Выйти"
+    "leaveConfirm": "Выйти",
+    "boldField": "Жирный"
   },
   },
   "preview": {
   "preview": {
     "title": "Обслуживание тормозов и замена масла",
     "title": "Обслуживание тормозов и замена масла",

+ 4 - 2
messages/tr/audit.json

@@ -150,7 +150,8 @@
     "settings_whatsappDisconnected": "WhatsApp bağlantısı kesildi",
     "settings_whatsappDisconnected": "WhatsApp bağlantısı kesildi",
     "settings_whatsappNumberRegistered": "WhatsApp numarası kaydedildi",
     "settings_whatsappNumberRegistered": "WhatsApp numarası kaydedildi",
     "team_giveTechnicianTheApp": "Pano teknisyenine hesap verildi",
     "team_giveTechnicianTheApp": "Pano teknisyenine hesap verildi",
-    "team_createDefaultRoles": "Standart roller oluşturuldu"
+    "team_createDefaultRoles": "Standart roller oluşturuldu",
+    "settings_setCustomFieldPlacement": "Özel alan konumu güncellendi"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
@@ -273,6 +274,7 @@
     "whatsapp_send": "{name} kişisine WhatsApp mesajı gönderildi",
     "whatsapp_send": "{name} kişisine WhatsApp mesajı gönderildi",
     "work_bay_create": "\"{name}\" çalışma alanı oluşturuldu",
     "work_bay_create": "\"{name}\" çalışma alanı oluşturuldu",
     "work_bay_update": "\"{name}\" çalışma alanı güncellendi",
     "work_bay_update": "\"{name}\" çalışma alanı güncellendi",
-    "work_bay_delete": "{id} çalışma alanı silindi"
+    "work_bay_delete": "{id} çalışma alanı silindi",
+    "settings_setCustomFieldPlacement": "Bir özel alanın yazdırıldığı yer değiştirildi"
   }
   }
 }
 }

+ 3 - 1
messages/tr/quotes.json

@@ -48,7 +48,9 @@
       "details": "Detaylar",
       "details": "Detaylar",
       "images": "Görseller",
       "images": "Görseller",
       "documents": "Belgeler"
       "documents": "Belgeler"
-    }
+    },
+    "customFieldsInvalid": "Lütfen tüm zorunlu özel alanları doldurun",
+    "customFieldsSaveFailed": "Özel alanlar kaydedilemedi"
   },
   },
   "form": {
   "form": {
     "newQuote": "Yeni Teklif",
     "newQuote": "Yeni Teklif",

+ 14 - 3
messages/tr/settings.json

@@ -633,7 +633,8 @@
       "company_org_number": "Kuruluş numarası",
       "company_org_number": "Kuruluş numarası",
       "bank_account": "Banka hesabı",
       "bank_account": "Banka hesabı",
       "footer_note": "Alt bilgi notu",
       "footer_note": "Alt bilgi notu",
-      "org_number": "Kuruluş numarası"
+      "org_number": "Kuruluş numarası",
+      "portal_link": "Portal bağlantısı"
     },
     },
     "presetsTitle": "Bir düzenle başlayın",
     "presetsTitle": "Bir düzenle başlayın",
     "presetsHint": "Aşağıdaki düzeni değiştirir. Siz kaydetmeden hiçbir şey kaydedilmez.",
     "presetsHint": "Aşağıdaki düzeni değiştirir. Siz kaydetmeden hiçbir şey kaydedilmez.",
@@ -1006,7 +1007,16 @@
     "failedSave": "Alan kaydedilemedi",
     "failedSave": "Alan kaydedilemedi",
     "failedDelete": "Silinemedi",
     "failedDelete": "Silinemedi",
     "edit": "Edit",
     "edit": "Edit",
-    "delete": "Delete"
+    "delete": "Delete",
+    "unassigned": "Atanmadı",
+    "defaultValue": "Varsayılan değer",
+    "defaultValueHint": "Yeni kayıtlarda otomatik doldurulur ve bir değer girilene kadar yazdırılır.",
+    "defaultValueNone": "Yok",
+    "defaultValueCheckedHint": "Varsayılan olarak işaretli",
+    "placement": "Belgedeki konum",
+    "placementHidden": "Yazdırılmaz",
+    "placementHint": "Bu alanın faturanın veya teklifin hangi bölümünde yazdırılacağı. Tasarımcıda başka bölümlerde de açabilirsiniz.",
+    "failedSavePlacement": "Alan konumu kaydedilemedi"
   },
   },
   "workshop": {
   "workshop": {
     "title": "Atölye Varsayılanları",
     "title": "Atölye Varsayılanları",
@@ -1916,7 +1926,8 @@
     "placeholder": "Örnek",
     "placeholder": "Örnek",
     "leaveTitle": "Kaydetmeden çıkılsın mı?",
     "leaveTitle": "Kaydetmeden çıkılsın mı?",
     "leaveBody": "Bu tasarımdaki değişiklikler kaydedilmedi ve kaybolacak.",
     "leaveBody": "Bu tasarımdaki değişiklikler kaydedilmedi ve kaybolacak.",
-    "leaveConfirm": "Çık"
+    "leaveConfirm": "Çık",
+    "boldField": "Kalın"
   },
   },
   "preview": {
   "preview": {
     "title": "Fren bakımı ve yağ değişimi",
     "title": "Fren bakımı ve yağ değişimi",

+ 2 - 0
prisma/migrations/20260901090000_custom_field_default_value/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "custom_field_definitions" ADD COLUMN "defaultValue" TEXT;

+ 12 - 11
prisma/schema.prisma

@@ -851,17 +851,18 @@ model QuoteLabor {
 }
 }
 
 
 model CustomFieldDefinition {
 model CustomFieldDefinition {
-  id         String   @id @default(cuid())
-  name       String
-  label      String
-  fieldType  String   @default("text")
-  options    String?
-  required   Boolean  @default(false)
-  entityType String
-  sortOrder  Int      @default(0)
-  isActive   Boolean  @default(true)
-  createdAt  DateTime @default(now())
-  updatedAt  DateTime @updatedAt
+  id           String   @id @default(cuid())
+  name         String
+  label        String
+  fieldType    String   @default("text")
+  options      String?
+  defaultValue String?
+  required     Boolean  @default(false)
+  entityType   String
+  sortOrder    Int      @default(0)
+  isActive     Boolean  @default(true)
+  createdAt    DateTime @default(now())
+  updatedAt    DateTime @updatedAt
 
 
   userId String
   userId String
   user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
   user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)

+ 266 - 0
src/__tests__/features/settings/custom-field-defaults-placement.test.ts

@@ -0,0 +1,266 @@
+/**
+ * Custom field default values and invoice placement.
+ *
+ * Covers the defaultValue validation rules, the definitions-first print
+ * fallback (no stored row -> default, stored '' -> stays empty), and the
+ * setCustomFieldPlacement action's layout-config rewriting.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    appSetting: { findUnique: vi.fn(), upsert: vi.fn() },
+    auditLog: { create: vi.fn() },
+    customFieldDefinition: { findFirst: vi.fn(), findMany: vi.fn() },
+    customFieldValue: { findMany: vi.fn() },
+  },
+}))
+
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import {
+  createFieldDefinitionSchema,
+  updateFieldDefinitionSchema,
+} from '@/features/custom-fields/Schema/customFieldSchema'
+import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
+import { setCustomFieldPlacement } from '@/features/settings/Actions/invoiceLayoutActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+const ORG = 'org-1'
+
+function setupOwner() {
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-1', email: 'owner@example.com' },
+  } as any)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as any)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  setupOwner()
+})
+
+const baseField = {
+  name: 'bank_name',
+  label: 'Bank name',
+  fieldType: 'text',
+  entityType: 'service_record',
+} as const
+
+describe('defaultValue validation', () => {
+  it('accepts a plain text default', () => {
+    const parsed = createFieldDefinitionSchema.parse({ ...baseField, defaultValue: 'My Bank' })
+    expect(parsed.defaultValue).toBe('My Bank')
+  })
+
+  it('rejects a select default that is not one of the options', () => {
+    const result = createFieldDefinitionSchema.safeParse({
+      ...baseField,
+      fieldType: 'select',
+      options: 'A, B, C',
+      defaultValue: 'D',
+    })
+    expect(result.success).toBe(false)
+  })
+
+  it('accepts a select default from the options list', () => {
+    const result = createFieldDefinitionSchema.safeParse({
+      ...baseField,
+      fieldType: 'select',
+      options: 'A, B, C',
+      defaultValue: 'B',
+    })
+    expect(result.success).toBe(true)
+  })
+
+  it('rejects a checkbox default that is not true/false', () => {
+    const result = createFieldDefinitionSchema.safeParse({
+      ...baseField,
+      fieldType: 'checkbox',
+      defaultValue: 'yes',
+    })
+    expect(result.success).toBe(false)
+  })
+
+  it('rejects a non-numeric number default', () => {
+    const result = createFieldDefinitionSchema.safeParse({
+      ...baseField,
+      fieldType: 'number',
+      defaultValue: 'abc',
+    })
+    expect(result.success).toBe(false)
+  })
+
+  it('enforces the same rules on update (extend must not drop the refinement)', () => {
+    const result = updateFieldDefinitionSchema.safeParse({
+      id: 'def-1',
+      ...baseField,
+      fieldType: 'checkbox',
+      defaultValue: 'yes',
+    })
+    expect(result.success).toBe(false)
+  })
+})
+
+describe('getCustomFieldsForPrint', () => {
+  const definitions = [
+    { id: 'f1', label: 'Bank name', fieldType: 'text', defaultValue: 'My Bank' },
+    { id: 'f2', label: 'Reference', fieldType: 'text', defaultValue: null },
+    { id: 'f3', label: 'Cleared', fieldType: 'text', defaultValue: 'fallback' },
+  ]
+
+  it('falls back to the default when no value row exists, keeps a cleared value empty', async () => {
+    vi.mocked(db.customFieldDefinition.findMany).mockResolvedValue(definitions as any)
+    vi.mocked(db.customFieldValue.findMany).mockResolvedValue([
+      { fieldId: 'f2', value: 'entered' },
+      { fieldId: 'f3', value: '' }, // user cleared it: the default must not resurrect
+    ] as any)
+
+    const fields = await getCustomFieldsForPrint(ORG, 'rec-1', 'service_record')
+
+    expect(fields).toEqual([
+      { fieldId: 'f1', label: 'Bank name', fieldType: 'text', value: 'My Bank' },
+      { fieldId: 'f2', label: 'Reference', fieldType: 'text', value: 'entered' },
+    ])
+  })
+
+  it('returns nothing when there are no definitions', async () => {
+    vi.mocked(db.customFieldDefinition.findMany).mockResolvedValue([] as any)
+    const fields = await getCustomFieldsForPrint(ORG, 'rec-1', 'service_record')
+    expect(fields).toEqual([])
+    expect(db.customFieldValue.findMany).not.toHaveBeenCalled()
+  })
+})
+
+describe('setCustomFieldPlacement', () => {
+  const DEF_ID = 'def-1'
+  const CF_ID = `cf_${DEF_ID}`
+
+  function setupClassicLayout(sections?: unknown[]) {
+    vi.mocked(db.customFieldDefinition.findFirst).mockResolvedValue({
+      id: DEF_ID,
+      organizationId: ORG,
+      entityType: 'service_record',
+    } as any)
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(
+      sections === undefined ? (null as any) : ({ value: JSON.stringify({ sections }) } as any)
+    )
+    vi.mocked(db.appSetting.upsert).mockResolvedValue({} as any)
+  }
+
+  function savedConfig() {
+    const call = vi.mocked(db.appSetting.upsert).mock.calls[0][0] as any
+    return JSON.parse(call.update.value)
+  }
+
+  it('places the field in the requested section and removes it everywhere else', async () => {
+    setupClassicLayout([
+      { id: 'header', visible: true, order: 0, fields: [] },
+      { id: 'bank_account', visible: true, order: 1, fields: [] },
+      { id: 'general', visible: false, order: 2, fields: [{ id: CF_ID, visible: true }] },
+    ])
+
+    const result = await setCustomFieldPlacement({
+      definitionId: DEF_ID,
+      entityType: 'service_record',
+      placement: 'bank_account',
+    })
+
+    expect(result.success).toBe(true)
+    const config = savedConfig()
+    const bank = config.sections.find((s: any) => s.id === 'bank_account')
+    const general = config.sections.find((s: any) => s.id === 'general')
+    expect(bank.fields.some((f: any) => f.id === CF_ID && f.visible)).toBe(true)
+    expect(general.fields.some((f: any) => f.id === CF_ID)).toBe(false)
+  })
+
+  it('stores "hidden" as an invisible entry in general', async () => {
+    setupClassicLayout([
+      { id: 'footer', visible: true, order: 0, fields: [{ id: CF_ID, visible: true }] },
+    ])
+
+    const result = await setCustomFieldPlacement({
+      definitionId: DEF_ID,
+      entityType: 'service_record',
+      placement: 'hidden',
+    })
+
+    expect(result.success).toBe(true)
+    const config = savedConfig()
+    const general = config.sections.find((s: any) => s.id === 'general')
+    const footer = config.sections.find((s: any) => s.id === 'footer')
+    expect(general.fields).toContainEqual({ id: CF_ID, visible: false })
+    expect(footer.fields.some((f: any) => f.id === CF_ID)).toBe(false)
+    // Hiding a field must not switch the general panel on.
+    expect(general.visible).toBe(false)
+  })
+
+  it('switches the general section visible when placing a field there', async () => {
+    setupClassicLayout()
+
+    const result = await setCustomFieldPlacement({
+      definitionId: DEF_ID,
+      entityType: 'service_record',
+      placement: 'general',
+    })
+
+    expect(result.success).toBe(true)
+    const config = savedConfig()
+    const general = config.sections.find((s: any) => s.id === 'general')
+    expect(general.visible).toBe(true)
+    expect(general.fields).toContainEqual({ id: CF_ID, visible: true })
+  })
+
+  it('does not stamp a version onto a classic layout', async () => {
+    setupClassicLayout([{ id: 'header', visible: true, order: 0, fields: [] }])
+
+    await setCustomFieldPlacement({
+      definitionId: DEF_ID,
+      entityType: 'service_record',
+      placement: 'header',
+    })
+
+    expect(savedConfig().version).toBeUndefined()
+  })
+
+  it('writes the quote layout for quote fields', async () => {
+    vi.mocked(db.customFieldDefinition.findFirst).mockResolvedValue({
+      id: DEF_ID,
+      organizationId: ORG,
+      entityType: 'quote',
+    } as any)
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null as any)
+    vi.mocked(db.appSetting.upsert).mockResolvedValue({} as any)
+
+    await setCustomFieldPlacement({
+      definitionId: DEF_ID,
+      entityType: 'quote',
+      placement: 'footer',
+    })
+
+    const call = vi.mocked(db.appSetting.upsert).mock.calls[0][0] as any
+    expect(call.where.organizationId_key.key).toBe(SETTING_KEYS.QUOTE_LAYOUT_CONFIG)
+  })
+
+  it('rejects an unknown placement', async () => {
+    setupClassicLayout()
+    const result = await setCustomFieldPlacement({
+      definitionId: DEF_ID,
+      entityType: 'service_record',
+      placement: 'totals',
+    })
+    expect(result.success).toBe(false)
+  })
+})

+ 5 - 2
src/__tests__/features/settings/designer-controls.test.ts

@@ -66,8 +66,11 @@ describe('the generator covers what a layout can express', () => {
       const block = SCHEMA.slice(start, SCHEMA.indexOf('] as const', start))
       const block = SCHEMA.slice(start, SCHEMA.indexOf('] as const', start))
       for (const m of block.matchAll(/id: '([a-z_]+)'/g)) ids.add(m[1])
       for (const m of block.matchAll(/id: '([a-z_]+)'/g)) ids.add(m[1])
     }
     }
-    // `logo` comes from the workshop's upload rather than a sample string.
-    const missing = [...ids].filter((id) => id !== 'logo' && !SAMPLE.includes(`${id}:`))
+    // `logo` comes from the workshop's upload and `portal_link` from the
+    // sample portal URL, rather than from a sample field string.
+    const missing = [...ids].filter(
+      (id) => id !== 'logo' && id !== 'portal_link' && !SAMPLE.includes(`${id}:`)
+    )
     expect(missing).toEqual([])
     expect(missing).toEqual([])
   })
   })
 
 

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

@@ -176,10 +176,10 @@ describe('framed template preset', () => {
     expect(getLetterheadMark(withLetterheadMark(named, 'logo'))).toBe('logo')
     expect(getLetterheadMark(withLetterheadMark(named, 'logo'))).toBe('logo')
   })
   })
 
 
-  it('leaves the footer the one note line it has always been by default', () => {
+  it('leaves the footer the note and portal line it has always been by default', () => {
     const footer = getDefaultInvoiceLayout().sections.find((s) => s.id === 'footer')
     const footer = getDefaultInvoiceLayout().sections.find((s) => s.id === 'footer')
     const visible = footer?.fields?.filter((f) => f.visible).map((f) => f.id)
     const visible = footer?.fields?.filter((f) => f.visible).map((f) => f.id)
-    expect(visible).toEqual(['footer_note'])
+    expect(visible).toEqual(['footer_note', 'portal_link'])
   })
   })
 
 
   it('moves the company details down to the footer', () => {
   it('moves the company details down to the footer', () => {

+ 164 - 0
src/__tests__/features/settings/spec-pdf-print.test.tsx

@@ -306,6 +306,170 @@ describe('the printed invoice follows the designed layout', () => {
     expect(withFooterLogo(true)).toContain('footer.logo')
     expect(withFooterLogo(true)).toContain('footer.logo')
   })
   })
 
 
+  it('prints a custom field in whichever bespoke section switches it on', () => {
+    // The letterhead, footer and payment blocks draw from fixed field ids, so
+    // each must fold the workshop's own fields in; the panels already do.
+    const blockWithField = (sectionId: string, visible: boolean) => {
+      const layout = getDefaultInvoiceLayout()
+      layout.sections = layout.sections.map((section) =>
+        section.id === sectionId
+          ? { ...section, fields: [...(section.fields ?? []), { id: 'cf_cfdef1', visible }] }
+          : section
+      )
+      const spec = buildInvoicePrintSpec({
+        data: invoice,
+        workshop,
+        invoiceSettings: settings,
+        template: { layoutConfig: { ...layout, version: DESIGNER_LAYOUT_VERSION } },
+      })
+      return JSON.stringify(spec.blocks.find((b) => b.id === sectionId))
+    }
+
+    for (const sectionId of ['footer', 'header', 'bank_account']) {
+      expect(blockWithField(sectionId, true)).toContain('INS-991')
+      expect(blockWithField(sectionId, false)).not.toContain('INS-991')
+    }
+  })
+
+  it('prints a custom field as a plain line, never as a bold lead', () => {
+    // A workshop's field is a detail, not a heading: first in a panel or
+    // alone in its footer column, it must not take the lead line's weight.
+    const layout = getDefaultInvoiceLayout()
+    layout.sections = layout.sections.map((section) =>
+      section.id === 'footer' || section.id === 'general'
+        ? {
+            ...section,
+            visible: true,
+            fields: [...(section.fields ?? []), { id: 'cf_cfdef1', visible: true }],
+          }
+        : section
+    )
+    const spec = buildInvoicePrintSpec({
+      data: invoice,
+      workshop,
+      invoiceSettings: settings,
+      template: { layoutConfig: { ...layout, version: DESIGNER_LAYOUT_VERSION } },
+    })
+
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const textNodes: any[] = []
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const walk = (node: any) => {
+      if (!node || typeof node !== 'object') return
+      if (node.text !== undefined) textNodes.push(node)
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      for (const child of node.children ?? []) walk((child as any).node ?? child)
+    }
+    for (const block of spec.blocks) walk(block.content)
+
+    const customLines = textNodes.filter((n) => String(n.text).includes('INS-991'))
+    expect(customLines.length).toBeGreaterThanOrEqual(2)
+    for (const line of customLines) expect(line.style?.bold).toBeFalsy()
+  })
+
+  it('flows the footer details in the order the layout stores', () => {
+    const footerWith = (order: string[]) => {
+      const layout = getDefaultInvoiceLayout()
+      layout.sections = layout.sections.map((section) =>
+        section.id === 'footer'
+          ? {
+              ...section,
+              fields: [
+                { id: 'footer_note', visible: false },
+                ...order.map((id) => ({ id, visible: true })),
+              ],
+            }
+          : section
+      )
+      const spec = buildInvoicePrintSpec({
+        data: invoice,
+        workshop,
+        invoiceSettings: settings,
+        template: { layoutConfig: { ...layout, version: DESIGNER_LAYOUT_VERSION } },
+      })
+      return JSON.stringify(spec.blocks.find((b) => b.id === 'footer'))
+    }
+
+    const bankFirst = footerWith(['bank_account', 'company_name'])
+    expect(bankFirst).toContain('XX00 1234 5678')
+    expect(bankFirst).toContain('Testshop')
+    expect(bankFirst.indexOf('XX00 1234 5678')).toBeLessThan(bankFirst.indexOf('Testshop'))
+
+    const nameFirst = footerWith(['company_name', 'bank_account'])
+    expect(nameFirst.indexOf('Testshop')).toBeLessThan(nameFirst.indexOf('XX00 1234 5678'))
+  })
+
+  it('prints the portal link only while the footer field asks for it', () => {
+    const footerWithPortal = (
+      mutate?: (fields: { id: string; visible: boolean }[]) => { id: string; visible: boolean }[]
+    ) => {
+      const layout = getDefaultInvoiceLayout()
+      if (mutate) {
+        layout.sections = layout.sections.map((section) =>
+          section.id === 'footer' ? { ...section, fields: mutate(section.fields ?? []) } : section
+        )
+      }
+      const spec = buildInvoicePrintSpec({
+        data: invoice,
+        workshop,
+        invoiceSettings: settings,
+        portalUrl: 'https://portal.example/abc',
+        template: { layoutConfig: { ...layout, version: DESIGNER_LAYOUT_VERSION } },
+      })
+      return JSON.stringify(spec.blocks.find((b) => b.id === 'footer'))
+    }
+
+    expect(footerWithPortal()).toContain('portal.example')
+    expect(
+      footerWithPortal((fields) =>
+        fields.map((f) => (f.id === 'portal_link' ? { ...f, visible: false } : f))
+      )
+    ).not.toContain('portal.example')
+    // A layout saved before the switch existed keeps its portal line: the
+    // merge fills the missing field in as visible.
+    expect(footerWithPortal((fields) => fields.filter((f) => f.id !== 'portal_link'))).toContain(
+      'portal.example'
+    )
+  })
+
+  it('hands the emphasis to the layout once it chooses weights itself', () => {
+    // The customer's name leads its panel bold by default; an explicit choice
+    // anywhere in the section replaces the automatic lead with the choices.
+    const layout = getDefaultInvoiceLayout()
+    layout.sections = layout.sections.map((section) =>
+      section.id === 'customer'
+        ? {
+            ...section,
+            fields: (section.fields ?? []).map((f) =>
+              f.id === 'customer_email' ? { ...f, bold: true } : f
+            ),
+          }
+        : section
+    )
+    const spec = buildInvoicePrintSpec({
+      data: invoice,
+      workshop,
+      invoiceSettings: settings,
+      template: { layoutConfig: { ...layout, version: DESIGNER_LAYOUT_VERSION } },
+    })
+
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const textNodes: any[] = []
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const walk = (node: any) => {
+      if (!node || typeof node !== 'object') return
+      if (node.text !== undefined) textNodes.push(node)
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      for (const child of node.children ?? []) walk((child as any).node ?? child)
+    }
+    walk(spec.blocks.find((b) => b.id === 'customer')?.content)
+
+    const name = textNodes.find((n) => String(n.text).includes('Alex Carter'))
+    const email = textNodes.find((n) => String(n.text).includes('alex@example.com'))
+    expect(email?.style?.bold).toBe(true)
+    expect(name?.style?.bold).toBeFalsy()
+  })
+
   it("carries the sheet's typeface into every line of a panel", () => {
   it("carries the sheet's typeface into every line of a panel", () => {
     // A node that mentions a font at all overrides what it inherits, because
     // A node that mentions a font at all overrides what it inherits, because
     // the PDF merges the two and an explicit undefined wins. That reset a
     // the PDF merges the two and an explicit undefined wins. That reset a

+ 16 - 2
src/app/(authenticated)/settings/custom-fields/page.tsx

@@ -1,4 +1,8 @@
 import { getFieldDefinitions } from '@/features/custom-fields/Actions/customFieldActions'
 import { getFieldDefinitions } from '@/features/custom-fields/Actions/customFieldActions'
+import {
+  getInvoiceLayoutConfig,
+  getQuoteLayoutConfig,
+} from '@/features/settings/Actions/invoiceLayoutActions'
 import { getLayoutData } from '@/lib/get-layout-data'
 import { getLayoutData } from '@/lib/get-layout-data'
 import { getFeatures, isCloudMode } from '@/lib/features'
 import { getFeatures, isCloudMode } from '@/lib/features'
 import { CustomFieldsManager } from '@/features/custom-fields/Components/CustomFieldsManager'
 import { CustomFieldsManager } from '@/features/custom-fields/Components/CustomFieldsManager'
@@ -23,8 +27,18 @@ export default async function CustomFieldsPage() {
     )
     )
   }
   }
 
 
-  const result = await getFieldDefinitions()
+  const [result, invoiceLayoutResult, quoteLayoutResult] = await Promise.all([
+    getFieldDefinitions(),
+    getInvoiceLayoutConfig(),
+    getQuoteLayoutConfig(),
+  ])
   const fields = result.success && result.data ? result.data : []
   const fields = result.success && result.data ? result.data : []
 
 
-  return <CustomFieldsManager initialFields={fields} />
+  return (
+    <CustomFieldsManager
+      initialFields={fields}
+      layoutConfig={invoiceLayoutResult.success ? invoiceLayoutResult.data : undefined}
+      quoteLayoutConfig={quoteLayoutResult.success ? quoteLayoutResult.data : undefined}
+    />
+  )
 }
 }

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

@@ -40,6 +40,7 @@ interface FieldDef {
   fieldType: string
   fieldType: string
   entityType: string
   entityType: string
   options: string | null
   options: string | null
+  defaultValue: string | null
   required: boolean
   required: boolean
   sortOrder: number
   sortOrder: number
   isActive: boolean
   isActive: boolean
@@ -487,9 +488,8 @@ export function InvoiceSettings({
       ) : (
       ) : (
         <CustomFieldsManager
         <CustomFieldsManager
           initialFields={customFields}
           initialFields={customFields}
-          // Which sections a field is placed in is a property of the invoice
-          // layout; the designer owns editing it, this only reads it.
           layoutConfig={initialInvoiceLayout ?? getDefaultInvoiceLayout()}
           layoutConfig={initialInvoiceLayout ?? getDefaultInvoiceLayout()}
+          quoteLayoutConfig={initialQuoteLayout ?? getDefaultInvoiceLayout()}
         />
         />
       )}
       )}
     </div>
     </div>

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

@@ -109,6 +109,13 @@ export default async function InvoiceDesignerPage({
                 .map((f) => ({ id: f.id, label: f.label, name: f.name, isActive: f.isActive }))
                 .map((f) => ({ id: f.id, label: f.label, name: f.name, isActive: f.isActive }))
             : []
             : []
         }
         }
+        quoteCustomFields={
+          customFieldsResult.success && customFieldsResult.data
+            ? customFieldsResult.data
+                .filter((f) => f.entityType === 'quote')
+                .map((f) => ({ id: f.id, label: f.label, name: f.name, isActive: f.isActive }))
+            : []
+        }
         workshop={{
         workshop={{
           name: organization?.name ?? '',
           name: organization?.name ?? '',
           address: settings[SETTING_KEYS.WORKSHOP_ADDRESS] ?? '',
           address: settings[SETTING_KEYS.WORKSHOP_ADDRESS] ?? '',

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

@@ -11,6 +11,7 @@ import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { headers } from 'next/headers'
 import { headers } from 'next/headers'
 import type { Metadata } from 'next'
 import type { Metadata } from 'next'
+import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
 
 
 /** Rewrites /api/protected/files/[orgId]/[category]/[filename] to /api/public/files/[token]/[category]/[filename] */
 /** Rewrites /api/protected/files/[orgId]/[category]/[filename] to /api/public/files/[token]/[category]/[filename] */
 function toPublicFileUrl(fileUrl: string, token: string): string {
 function toPublicFileUrl(fileUrl: string, token: string): string {
@@ -163,24 +164,7 @@ export default async function PublicInvoicePage({
   })
   })
 
 
   // Fetch custom field values for this service record
   // Fetch custom field values for this service record
-  const customFieldValues = await db.customFieldValue.findMany({
-    where: { entityId: record.id, entityType: 'service_record' },
-    include: {
-      field: {
-        select: { id: true, label: true, fieldType: true, isActive: true, sortOrder: true },
-      },
-    },
-    orderBy: { field: { sortOrder: 'asc' } },
-  })
-
-  const customFields = customFieldValues
-    .filter((v) => v.field.isActive && v.value)
-    .map((v) => ({
-      label: v.field.label,
-      value: v.value,
-      fieldType: v.field.fieldType,
-      fieldId: v.field.id,
-    }))
+  const customFields = await getCustomFieldsForPrint(orgId, record.id, 'service_record')
 
 
   const settingsMap: Record<string, string> = {}
   const settingsMap: Record<string, string> = {}
   for (const s of settings) settingsMap[s.key] = s.value
   for (const s of settings) settingsMap[s.key] = s.value

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

@@ -11,6 +11,7 @@ import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { headers } from 'next/headers'
 import { headers } from 'next/headers'
 import type { Metadata } from 'next'
 import type { Metadata } from 'next'
+import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
 
 
 export const revalidate = 60
 export const revalidate = 60
 
 
@@ -127,24 +128,7 @@ export default async function PublicQuotePage({
   ])
   ])
 
 
   // Fetch custom field values for this quote
   // Fetch custom field values for this quote
-  const customFieldValues = await db.customFieldValue.findMany({
-    where: { entityId: quote.id, entityType: 'quote' },
-    include: {
-      field: {
-        select: { id: true, label: true, fieldType: true, isActive: true, sortOrder: true },
-      },
-    },
-    orderBy: { field: { sortOrder: 'asc' } },
-  })
-
-  const customFields = customFieldValues
-    .filter((v) => v.field.isActive && v.value)
-    .map((v) => ({
-      label: v.field.label,
-      value: v.value,
-      fieldType: v.field.fieldType,
-      fieldId: v.field.id,
-    }))
+  const customFields = await getCustomFieldsForPrint(orgId, quote.id, 'quote')
 
 
   const settingsMap: Record<string, string> = {}
   const settingsMap: Record<string, string> = {}
   for (const s of settings) settingsMap[s.key] = s.value
   for (const s of settings) settingsMap[s.key] = s.value

+ 2 - 16
src/app/api/protected/quotes/[id]/pdf/route.ts

@@ -13,6 +13,7 @@ import { resolveUploadPath } from '@/lib/resolve-upload-path'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
+import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
 
 
 export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
 export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
   try {
   try {
@@ -154,22 +155,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
     }
     }
 
 
     // Fetch custom field values for the quote
     // Fetch custom field values for the quote
-    const customFieldValues = await db.customFieldValue.findMany({
-      where: { entityId: quote.id, entityType: 'quote' },
-      include: {
-        field: { select: { label: true, fieldType: true, isActive: true, sortOrder: true } },
-      },
-      orderBy: { field: { sortOrder: 'asc' } },
-    })
-
-    const customFields = customFieldValues
-      .filter((v) => v.field.isActive && v.value)
-      .map((v) => ({
-        fieldId: v.fieldId,
-        label: v.field.label,
-        value: v.value,
-        fieldType: v.field.fieldType,
-      }))
+    const customFields = await getCustomFieldsForPrint(ctx.organizationId, quote.id, 'quote')
 
 
     // Check if Torqvoice branding should be shown
     // Check if Torqvoice branding should be shown
     const features = await getFeatures(ctx.organizationId)
     const features = await getFeatures(ctx.organizationId)

+ 7 - 17
src/app/api/protected/services/[id]/pdf/route.ts

@@ -15,6 +15,7 @@ import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { formatDateForPdf } from '@/lib/format'
 import { formatDateForPdf } from '@/lib/format'
 import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { markInvoiceIssued } from '@/features/onboarding/Lib/markInvoiceIssued'
 import { markInvoiceIssued } from '@/features/onboarding/Lib/markInvoiceIssued'
+import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
 
 
 export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
 export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
   try {
   try {
@@ -111,23 +112,12 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
       orderBy: { createdAt: 'desc' },
       orderBy: { createdAt: 'desc' },
     })
     })
 
 
-    // Fetch custom field values for this service record
-    const customFieldValues = await db.customFieldValue.findMany({
-      where: { entityId: record.id, entityType: 'service_record' },
-      include: {
-        field: { select: { label: true, fieldType: true, isActive: true, sortOrder: true } },
-      },
-      orderBy: { field: { sortOrder: 'asc' } },
-    })
-
-    const customFields = customFieldValues
-      .filter((v) => v.field.isActive && v.value)
-      .map((v) => ({
-        fieldId: v.fieldId,
-        label: v.field.label,
-        value: v.value,
-        fieldType: v.field.fieldType,
-      }))
+    // Custom fields for this service record (definition defaults included)
+    const customFields = await getCustomFieldsForPrint(
+      ctx.organizationId,
+      record.id,
+      'service_record'
+    )
 
 
     // Build settings map
     // Build settings map
     const settingsMap: Record<string, string> = {}
     const settingsMap: Record<string, string> = {}

+ 2 - 16
src/app/api/public/share/quote/[orgId]/[token]/pdf/route.ts

@@ -14,6 +14,7 @@ import { resolvePortalOrg } from '@/lib/portal-slug'
 import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
+import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
 
 
 export async function GET(
 export async function GET(
   _request: Request,
   _request: Request,
@@ -158,22 +159,7 @@ export async function GET(
     }
     }
 
 
     // Fetch custom field values for the quote
     // Fetch custom field values for the quote
-    const customFieldValues = await db.customFieldValue.findMany({
-      where: { entityId: quote.id, entityType: 'quote' },
-      include: {
-        field: { select: { label: true, fieldType: true, isActive: true, sortOrder: true } },
-      },
-      orderBy: { field: { sortOrder: 'asc' } },
-    })
-
-    const customFields = customFieldValues
-      .filter((v) => v.field.isActive && v.value)
-      .map((v) => ({
-        fieldId: v.fieldId,
-        label: v.field.label,
-        value: v.value,
-        fieldType: v.field.fieldType,
-      }))
+    const customFields = await getCustomFieldsForPrint(orgId, quote.id, 'quote')
 
 
     const features = await getFeatures(orgId)
     const features = await getFeatures(orgId)
     let torqvoiceLogoDataUri: string | undefined
     let torqvoiceLogoDataUri: string | undefined

+ 2 - 1
src/features/custom-fields/Actions/customFieldActions.ts

@@ -148,9 +148,10 @@ export async function getCustomFieldValues(entityId: string, entityType: string)
         valuesMap[v.fieldId] = v.value
         valuesMap[v.fieldId] = v.value
       }
       }
 
 
+      // No stored row -> definition default; a stored '' means the user cleared it.
       return definitions.map((def) => ({
       return definitions.map((def) => ({
         ...def,
         ...def,
-        value: valuesMap[def.id] || '',
+        value: valuesMap[def.id] !== undefined ? valuesMap[def.id] : (def.defaultValue ?? ''),
       }))
       }))
     },
     },
     {
     {

+ 5 - 1
src/features/custom-fields/Components/CustomFieldsForm.tsx

@@ -63,7 +63,11 @@ export function CustomFieldsForm({
       } else {
       } else {
         const result = await getFieldDefinitions(entityType)
         const result = await getFieldDefinitions(entityType)
         if (result.success && result.data) {
         if (result.success && result.data) {
-          setFields(result.data.filter((d) => d.isActive).map((d) => ({ ...d, value: '' })))
+          setFields(
+            result.data
+              .filter((d) => d.isActive)
+              .map((d) => ({ ...d, value: d.defaultValue ?? '' }))
+          )
         }
         }
       }
       }
       setLoading(false)
       setLoading(false)

+ 171 - 16
src/features/custom-fields/Components/CustomFieldsManager.tsx

@@ -18,6 +18,9 @@ import {
   SelectValue,
   SelectValue,
 } from '@/components/ui/select'
 } from '@/components/ui/select'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Textarea } from '@/components/ui/textarea'
+import { Checkbox } from '@/components/ui/checkbox'
+import { DateInput } from '@/components/ui/date-input'
 import { DocsLink } from '@/components/docs-link'
 import { DocsLink } from '@/components/docs-link'
 import { useGlassModal } from '@/components/glass-modal'
 import { useGlassModal } from '@/components/glass-modal'
 import { useConfirm } from '@/components/confirm-dialog'
 import { useConfirm } from '@/components/confirm-dialog'
@@ -26,11 +29,13 @@ import {
   updateFieldDefinition,
   updateFieldDefinition,
   deleteFieldDefinition,
   deleteFieldDefinition,
 } from '@/features/custom-fields/Actions/customFieldActions'
 } from '@/features/custom-fields/Actions/customFieldActions'
+import { setCustomFieldPlacement } from '@/features/settings/Actions/invoiceLayoutActions'
 import type { FieldType, EntityType } from '@/features/custom-fields/Schema/customFieldSchema'
 import type { FieldType, EntityType } from '@/features/custom-fields/Schema/customFieldSchema'
 import {
 import {
   type InvoiceLayoutConfig,
   type InvoiceLayoutConfig,
   BUILTIN_SECTIONS,
   BUILTIN_SECTIONS,
   CUSTOM_FIELD_PREFIX,
   CUSTOM_FIELD_PREFIX,
+  SECTIONS_WITH_FIELDS,
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { Loader2, Pencil, Plus, Trash2 } from 'lucide-react'
 import { Loader2, Pencil, Plus, Trash2 } from 'lucide-react'
 
 
@@ -40,43 +45,55 @@ interface FieldDef {
   label: string
   label: string
   fieldType: string
   fieldType: string
   options: string | null
   options: string | null
+  defaultValue: string | null
   required: boolean
   required: boolean
   entityType: string
   entityType: string
   sortOrder: number
   sortOrder: number
   isActive: boolean
   isActive: boolean
 }
 }
 
 
+/** Section ids a custom field can be placed into, in document order. */
+const PLACEMENT_SECTIONS = BUILTIN_SECTIONS.map((s) => s.id).filter((id) =>
+  SECTIONS_WITH_FIELDS.has(id)
+)
+
 /**
 /**
- * Find the layout section a custom field definition is assigned to.
- * Returns the human-readable section name, or null if not assigned.
+ * Find where a custom field definition is placed in a layout config. A field
+ * can be switched on in several sections from the designer; the first visible
+ * one (in document order) stands for it here. Returns that section id,
+ * 'hidden' when it is stored but switched on nowhere, or null if unassigned.
  */
  */
-function getSectionForField(
+function getPlacementForField(
   definitionId: string,
   definitionId: string,
   layoutConfig?: InvoiceLayoutConfig
   layoutConfig?: InvoiceLayoutConfig
 ): string | null {
 ): string | null {
   if (!layoutConfig) return null
   if (!layoutConfig) return null
   const cfId = `${CUSTOM_FIELD_PREFIX}${definitionId}`
   const cfId = `${CUSTOM_FIELD_PREFIX}${definitionId}`
+  let stored = false
   for (const section of layoutConfig.sections) {
   for (const section of layoutConfig.sections) {
-    if (section.fields?.some((f) => f.id === cfId)) {
-      const builtin = BUILTIN_SECTIONS.find((s) => s.id === section.id)
-      return builtin?.name ?? section.id
+    const entry = section.fields?.find((f) => f.id === cfId)
+    if (entry) {
+      if (entry.visible) return section.id
+      stored = true
     }
     }
   }
   }
-  return null
+  return stored ? 'hidden' : null
 }
 }
 
 
 export function CustomFieldsManager({
 export function CustomFieldsManager({
   initialFields = [],
   initialFields = [],
   layoutConfig,
   layoutConfig,
+  quoteLayoutConfig,
 }: {
 }: {
   initialFields?: FieldDef[]
   initialFields?: FieldDef[]
   layoutConfig?: InvoiceLayoutConfig
   layoutConfig?: InvoiceLayoutConfig
+  quoteLayoutConfig?: InvoiceLayoutConfig
 }) {
 }) {
   const router = useRouter()
   const router = useRouter()
   const t = useTranslations('settings')
   const t = useTranslations('settings')
   const modal = useGlassModal()
   const modal = useGlassModal()
   const confirm = useConfirm()
   const confirm = useConfirm()
-  const [fields] = useState(initialFields)
+  const fields = initialFields
   const [showDialog, setShowDialog] = useState(false)
   const [showDialog, setShowDialog] = useState(false)
   const [editing, setEditing] = useState<FieldDef | null>(null)
   const [editing, setEditing] = useState<FieldDef | null>(null)
   const [loading, setLoading] = useState(false)
   const [loading, setLoading] = useState(false)
@@ -87,11 +104,18 @@ export function CustomFieldsManager({
     label: '',
     label: '',
     fieldType: 'text' as FieldType,
     fieldType: 'text' as FieldType,
     options: '',
     options: '',
+    defaultValue: '',
     required: false,
     required: false,
     entityType: 'service_record' as EntityType,
     entityType: 'service_record' as EntityType,
     sortOrder: 0,
     sortOrder: 0,
     isActive: true,
     isActive: true,
   })
   })
+  // Where the field prints on the document; null = derived placement unknown.
+  const [placement, setPlacement] = useState<string>('general')
+  const [initialPlacement, setInitialPlacement] = useState<string | null>(null)
+
+  const configForEntity = (entityType: string) =>
+    entityType === 'quote' ? quoteLayoutConfig : layoutConfig
 
 
   const fieldTypeLabels: Record<string, string> = {
   const fieldTypeLabels: Record<string, string> = {
     text: t('customFields.fieldTypes.text'),
     text: t('customFields.fieldTypes.text'),
@@ -113,11 +137,14 @@ export function CustomFieldsManager({
       label: '',
       label: '',
       fieldType: 'text',
       fieldType: 'text',
       options: '',
       options: '',
+      defaultValue: '',
       required: false,
       required: false,
       entityType: 'service_record',
       entityType: 'service_record',
       sortOrder: 0,
       sortOrder: 0,
       isActive: true,
       isActive: true,
     })
     })
+    setPlacement('general')
+    setInitialPlacement(null)
     setEditing(null)
     setEditing(null)
   }
   }
 
 
@@ -133,11 +160,15 @@ export function CustomFieldsManager({
       label: field.label,
       label: field.label,
       fieldType: field.fieldType as FieldType,
       fieldType: field.fieldType as FieldType,
       options: field.options || '',
       options: field.options || '',
+      defaultValue: field.defaultValue || '',
       required: field.required,
       required: field.required,
       entityType: field.entityType as EntityType,
       entityType: field.entityType as EntityType,
       sortOrder: field.sortOrder,
       sortOrder: field.sortOrder,
       isActive: field.isActive,
       isActive: field.isActive,
     })
     })
+    const current = getPlacementForField(field.id, configForEntity(field.entityType)) ?? 'general'
+    setPlacement(current)
+    setInitialPlacement(current)
     setShowDialog(true)
     setShowDialog(true)
   }
   }
 
 
@@ -146,6 +177,7 @@ export function CustomFieldsManager({
     const payload = {
     const payload = {
       ...formData,
       ...formData,
       options: formData.fieldType === 'select' ? formData.options : undefined,
       options: formData.fieldType === 'select' ? formData.options : undefined,
+      defaultValue: formData.defaultValue || undefined,
     }
     }
 
 
     const result = editing
     const result = editing
@@ -153,6 +185,21 @@ export function CustomFieldsManager({
       : await createFieldDefinition(payload)
       : await createFieldDefinition(payload)
 
 
     if (result.success) {
     if (result.success) {
+      const definitionId = editing ? editing.id : result.data?.id
+      if (definitionId && (!editing || placement !== initialPlacement)) {
+        const placementResult = await setCustomFieldPlacement({
+          definitionId,
+          entityType: formData.entityType,
+          placement,
+        })
+        if (!placementResult.success) {
+          modal.open(
+            'error',
+            'Error',
+            placementResult.error || t('customFields.failedSavePlacement')
+          )
+        }
+      }
       toast.success(editing ? t('customFields.fieldUpdated') : t('customFields.fieldCreated'))
       toast.success(editing ? t('customFields.fieldUpdated') : t('customFields.fieldCreated'))
       setShowDialog(false)
       setShowDialog(false)
       resetForm()
       resetForm()
@@ -218,7 +265,8 @@ export function CustomFieldsManager({
       ) : (
       ) : (
         <div className="rounded-lg border divide-y">
         <div className="rounded-lg border divide-y">
           {filtered.map((field) => {
           {filtered.map((field) => {
-            const sectionName = layoutConfig ? getSectionForField(field.id, layoutConfig) : null
+            const fieldConfig = configForEntity(field.entityType)
+            const fieldPlacement = fieldConfig ? getPlacementForField(field.id, fieldConfig) : null
             return (
             return (
               <div
               <div
                 key={field.id}
                 key={field.id}
@@ -247,14 +295,20 @@ export function CustomFieldsManager({
                     <span className="text-[11px] text-muted-foreground">
                     <span className="text-[11px] text-muted-foreground">
                       {entityTypeLabels[field.entityType]}
                       {entityTypeLabels[field.entityType]}
                     </span>
                     </span>
-                    {layoutConfig && (
+                    {fieldConfig && (
                       <>
                       <>
                         <span className="text-[11px] text-muted-foreground">·</span>
                         <span className="text-[11px] text-muted-foreground">·</span>
-                        {sectionName ? (
-                          <span className="text-[11px] text-blue-600">{sectionName}</span>
+                        {fieldPlacement === 'hidden' ? (
+                          <span className="text-[11px] text-muted-foreground">
+                            {t('customFields.placementHidden')}
+                          </span>
+                        ) : fieldPlacement ? (
+                          <span className="text-[11px] text-blue-600">
+                            {t(`layoutEditor.sections.${fieldPlacement}`)}
+                          </span>
                         ) : (
                         ) : (
                           <span className="text-[11px] text-muted-foreground italic">
                           <span className="text-[11px] text-muted-foreground italic">
-                            unassigned
+                            {t('customFields.unassigned')}
                           </span>
                           </span>
                         )}
                         )}
                       </>
                       </>
@@ -298,7 +352,7 @@ export function CustomFieldsManager({
           if (!open) resetForm()
           if (!open) resetForm()
         }}
         }}
       >
       >
-        <DialogContent className="max-w-md">
+        <DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
           <DialogHeader>
           <DialogHeader>
             <DialogTitle>
             <DialogTitle>
               {editing ? t('customFields.editField') : t('customFields.newField')}
               {editing ? t('customFields.editField') : t('customFields.newField')}
@@ -347,7 +401,9 @@ export function CustomFieldsManager({
                 <Label>{t('customFields.fieldType')}</Label>
                 <Label>{t('customFields.fieldType')}</Label>
                 <Select
                 <Select
                   value={formData.fieldType}
                   value={formData.fieldType}
-                  onValueChange={(v) => setFormData({ ...formData, fieldType: v as FieldType })}
+                  onValueChange={(v) =>
+                    setFormData({ ...formData, fieldType: v as FieldType, defaultValue: '' })
+                  }
                 >
                 >
                   <SelectTrigger>
                   <SelectTrigger>
                     <SelectValue />
                     <SelectValue />
@@ -393,11 +449,110 @@ export function CustomFieldsManager({
                 <Input
                 <Input
                   placeholder={t('customFields.optionsPlaceholder')}
                   placeholder={t('customFields.optionsPlaceholder')}
                   value={formData.options}
                   value={formData.options}
-                  onChange={(e) => setFormData({ ...formData, options: e.target.value })}
+                  onChange={(e) => {
+                    const options = e.target.value
+                    const opts = options
+                      .split(',')
+                      .map((o) => o.trim())
+                      .filter(Boolean)
+                    setFormData({
+                      ...formData,
+                      options,
+                      defaultValue: opts.includes(formData.defaultValue)
+                        ? formData.defaultValue
+                        : '',
+                    })
+                  }}
                 />
                 />
               </div>
               </div>
             )}
             )}
 
 
+            <div className="space-y-2">
+              <Label>{t('customFields.defaultValue')}</Label>
+              {formData.fieldType === 'text' && (
+                <Input
+                  value={formData.defaultValue}
+                  onChange={(e) => setFormData({ ...formData, defaultValue: e.target.value })}
+                />
+              )}
+              {formData.fieldType === 'number' && (
+                <Input
+                  type="number"
+                  value={formData.defaultValue}
+                  onChange={(e) => setFormData({ ...formData, defaultValue: e.target.value })}
+                />
+              )}
+              {formData.fieldType === 'date' && (
+                <DateInput
+                  value={formData.defaultValue}
+                  onChange={(v) => setFormData({ ...formData, defaultValue: v })}
+                />
+              )}
+              {formData.fieldType === 'textarea' && (
+                <Textarea
+                  value={formData.defaultValue}
+                  onChange={(e) => setFormData({ ...formData, defaultValue: e.target.value })}
+                  rows={2}
+                />
+              )}
+              {formData.fieldType === 'checkbox' && (
+                <div className="flex items-center gap-2 pt-1">
+                  <Checkbox
+                    checked={formData.defaultValue === 'true'}
+                    onCheckedChange={(checked) =>
+                      setFormData({ ...formData, defaultValue: String(checked === true) })
+                    }
+                  />
+                  <span className="text-sm text-muted-foreground">
+                    {t('customFields.defaultValueCheckedHint')}
+                  </span>
+                </div>
+              )}
+              {formData.fieldType === 'select' && (
+                <Select
+                  value={formData.defaultValue || 'none'}
+                  onValueChange={(v) =>
+                    setFormData({ ...formData, defaultValue: v === 'none' ? '' : v })
+                  }
+                >
+                  <SelectTrigger>
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="none">{t('customFields.defaultValueNone')}</SelectItem>
+                    {formData.options
+                      .split(',')
+                      .map((o) => o.trim())
+                      .filter(Boolean)
+                      .map((option) => (
+                        <SelectItem key={option} value={option}>
+                          {option}
+                        </SelectItem>
+                      ))}
+                  </SelectContent>
+                </Select>
+              )}
+              <p className="text-xs text-muted-foreground">{t('customFields.defaultValueHint')}</p>
+            </div>
+
+            <div className="space-y-2">
+              <Label>{t('customFields.placement')}</Label>
+              <Select value={placement} onValueChange={setPlacement}>
+                <SelectTrigger>
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  {PLACEMENT_SECTIONS.map((id) => (
+                    <SelectItem key={id} value={id}>
+                      {t(`layoutEditor.sections.${id}`)}
+                    </SelectItem>
+                  ))}
+                  <SelectItem value="hidden">{t('customFields.placementHidden')}</SelectItem>
+                </SelectContent>
+              </Select>
+              <p className="text-xs text-muted-foreground">{t('customFields.placementHint')}</p>
+            </div>
+
             <div className="flex items-center justify-between">
             <div className="flex items-center justify-between">
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 <Switch
                 <Switch

+ 43 - 0
src/features/custom-fields/Lib/getCustomFieldsForPrint.ts

@@ -0,0 +1,43 @@
+import { db } from '@/lib/db'
+
+export interface PrintCustomField {
+  fieldId: string
+  label: string
+  value: string
+  fieldType: string
+}
+
+/**
+ * Custom fields for a printed/shared document, definitions-first so a field
+ * with a default value prints even when no value row was ever saved.
+ * A stored '' row means the user cleared the value, so it stays empty.
+ */
+export async function getCustomFieldsForPrint(
+  organizationId: string,
+  entityId: string,
+  entityType: 'service_record' | 'quote'
+): Promise<PrintCustomField[]> {
+  const definitions = await db.customFieldDefinition.findMany({
+    where: { organizationId, entityType, isActive: true },
+    select: { id: true, label: true, fieldType: true, defaultValue: true },
+    orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
+  })
+  if (definitions.length === 0) return []
+
+  const rows = await db.customFieldValue.findMany({
+    where: { entityId, entityType, fieldId: { in: definitions.map((d) => d.id) } },
+    select: { fieldId: true, value: true },
+  })
+  const valueByField = new Map(rows.map((r) => [r.fieldId, r.value]))
+
+  return definitions
+    .map((d) => ({
+      fieldId: d.id,
+      label: d.label,
+      fieldType: d.fieldType,
+      value: valueByField.has(d.id)
+        ? (valueByField.get(d.id) as string)
+        : (d.defaultValue ?? ''),
+    }))
+    .filter((cf) => cf.value !== '')
+}

+ 47 - 4
src/features/custom-fields/Schema/customFieldSchema.ts

@@ -6,7 +6,7 @@ export type FieldType = (typeof fieldTypes)[number]
 export const entityTypes = ['service_record', 'quote'] as const
 export const entityTypes = ['service_record', 'quote'] as const
 export type EntityType = (typeof entityTypes)[number]
 export type EntityType = (typeof entityTypes)[number]
 
 
-export const createFieldDefinitionSchema = z.object({
+const baseFieldDefinitionSchema = z.object({
   name: z
   name: z
     .string()
     .string()
     .min(1)
     .min(1)
@@ -15,15 +15,58 @@ export const createFieldDefinitionSchema = z.object({
   label: z.string().min(1).max(100),
   label: z.string().min(1).max(100),
   fieldType: z.enum(fieldTypes),
   fieldType: z.enum(fieldTypes),
   options: z.string().optional(),
   options: z.string().optional(),
+  defaultValue: z.string().max(500).optional(),
   required: z.boolean().default(false),
   required: z.boolean().default(false),
   entityType: z.enum(entityTypes),
   entityType: z.enum(entityTypes),
   sortOrder: z.number().int().default(0),
   sortOrder: z.number().int().default(0),
   isActive: z.boolean().default(true),
   isActive: z.boolean().default(true),
 })
 })
 
 
-export const updateFieldDefinitionSchema = createFieldDefinitionSchema.extend({
-  id: z.string(),
-})
+// Attached to both create and update so .extend can't drop the rules.
+const defaultValueRule = (
+  data: { fieldType: FieldType; options?: string; defaultValue?: string },
+  ctx: z.RefinementCtx
+) => {
+  const def = data.defaultValue
+  if (!def) return
+  if (data.fieldType === 'select') {
+    const opts = (data.options || '')
+      .split(',')
+      .map((o) => o.trim())
+      .filter(Boolean)
+    if (!opts.includes(def)) {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['defaultValue'],
+        message: 'Default value must be one of the options',
+      })
+    }
+  } else if (data.fieldType === 'checkbox') {
+    if (def !== 'true' && def !== 'false') {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['defaultValue'],
+        message: 'Default value must be true or false',
+      })
+    }
+  } else if (data.fieldType === 'number') {
+    if (!Number.isFinite(Number(def))) {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['defaultValue'],
+        message: 'Default value must be a number',
+      })
+    }
+  }
+}
+
+export const createFieldDefinitionSchema = baseFieldDefinitionSchema.superRefine(defaultValueRule)
+
+export const updateFieldDefinitionSchema = baseFieldDefinitionSchema
+  .extend({
+    id: z.string(),
+  })
+  .superRefine(defaultValueRule)
 
 
 export type CreateFieldDefinitionInput = z.infer<typeof createFieldDefinitionSchema>
 export type CreateFieldDefinitionInput = z.infer<typeof createFieldDefinitionSchema>
 export type UpdateFieldDefinitionInput = z.infer<typeof updateFieldDefinitionSchema>
 export type UpdateFieldDefinitionInput = z.infer<typeof updateFieldDefinitionSchema>

+ 99 - 12
src/features/invoice-designer/Components/DesignerInspector.tsx

@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import type {
 import type {
   InvoiceDocumentStyle,
   InvoiceDocumentStyle,
+  InvoiceFieldConfig,
   InvoiceLayoutConfig,
   InvoiceLayoutConfig,
   InvoiceSection,
   InvoiceSection,
   InvoiceSectionStyle,
   InvoiceSectionStyle,
@@ -12,7 +13,9 @@ import type {
 import {
 import {
   BOXED_ELIGIBLE_SECTIONS,
   BOXED_ELIGIBLE_SECTIONS,
   COLUMN_ELIGIBLE_SECTIONS,
   COLUMN_ELIGIBLE_SECTIONS,
+  FOOTER_SPECIAL_FIELD_IDS,
   SECTIONS_WITH_FIELDS,
   SECTIONS_WITH_FIELDS,
+  footerColumnsOf,
   fromCustomFieldId,
   fromCustomFieldId,
   getBuiltinFieldName,
   getBuiltinFieldName,
   getBuiltinFieldsForSection,
   getBuiltinFieldsForSection,
@@ -350,17 +353,13 @@ export function DesignerInspector({
   }
   }
   /** A section's name, or its id spaced out when nothing has named it. */
   /** A section's name, or its id spaced out when nothing has named it. */
   const sectionName = (id: string) => (tSection.has(id) ? tSection(id) : id.replace(/_/g, ' '))
   const sectionName = (id: string) => (tSection.has(id) ? tSection(id) : id.replace(/_/g, ' '))
+  /** The field row being dragged to a new spot in the list, if any. */
+  const [dragFieldId, setDragFieldId] = useState<string | null>(null)
   const section = selected ? layout.sections.find((s) => s.id === selected) : undefined
   const section = selected ? layout.sections.find((s) => s.id === selected) : undefined
   const doc = layout.document ?? {}
   const doc = layout.document ?? {}
 
 
   if (section) {
   if (section) {
     const style = section.style ?? {}
     const style = section.style ?? {}
-    const assigned = new Set(
-      layout.sections.flatMap((s) =>
-        (s.fields ?? []).filter((f) => isCustomFieldId(f.id)).map((f) => fromCustomFieldId(f.id))
-      )
-    )
-    const unassignedCustomFields = customFields.filter((f) => f.isActive && !assigned.has(f.id))
     /**
     /**
      * The fields this section shows, resolved the way the generator resolves
      * The fields this section shows, resolved the way the generator resolves
      * them: no list of its own means every built-in field, visible.
      * them: no list of its own means every built-in field, visible.
@@ -372,17 +371,61 @@ export function DesignerInspector({
     // A field added after this layout was written still needs its switch.
     // A field added after this layout was written still needs its switch.
     // Without this a saved design, which is loaded as it was stored, can never
     // Without this a saved design, which is loaded as it was stored, can never
     // reach anything built since, and the option looks simply missing.
     // reach anything built since, and the option looks simply missing.
-    const resolvedFields = [
+    const resolvedFields: InvoiceFieldConfig[] = [
       ...stored,
       ...stored,
       ...builtins
       ...builtins
         .filter((f) => !stored.some((existing) => existing.id === f.id))
         .filter((f) => !stored.some((existing) => existing.id === f.id))
         .map((f) => ({ id: f.id, visible: false })),
         .map((f) => ({ id: f.id, visible: false })),
     ]
     ]
+    // Workshop-defined fields wait as chips below the list until they are
+    // added, so the list only carries what this section actually uses. The
+    // same field can still be added to several sections.
+    const availableCustomFields = customFields.filter(
+      (f) => f.isActive && !stored.some((existing) => existing.id === toCustomFieldId(f.id))
+    )
     // The footer prints its mark only when the field is switched on, and the
     // The footer prints its mark only when the field is switched on, and the
     // controls that dress that mark follow it.
     // controls that dress that mark follow it.
     const footerLogoOn = resolvedFields.some((f) => f.id === 'logo' && f.visible)
     const footerLogoOn = resolvedFields.some((f) => f.id === 'logo' && f.visible)
-    const setFields = (fields: { id: string; visible: boolean }[]) =>
-      onSection(section.id, { fields })
+    const setFields = (fields: InvoiceFieldConfig[]) => onSection(section.id, { fields })
+    // Which rows can take an explicit weight: the payment block sets all its
+    // values bold by design, and the letterhead's built-in lines are display
+    // typography of their own.
+    const boldable = (id: string) =>
+      section.id !== 'bank_account' && (section.id !== 'header' || isCustomFieldId(id))
+    // Until the workshop chooses weights, the automatic leads show as bold:
+    // a panel's first line, a footer column's head. One choice anywhere in
+    // the section hands the whole section over to the choices.
+    const autoBold = !resolvedFields.some((f) => f.bold !== undefined)
+    const defaultBoldIds: Set<string> = (() => {
+      if (!autoBold) return new Set<string>()
+      if (section.id === 'footer') {
+        // The same column flow the sheet prints, so the B marks the same
+        // lines the footer sets bold.
+        const detailIds = resolvedFields
+          .filter((f) => f.visible && !FOOTER_SPECIAL_FIELD_IDS.has(f.id))
+          .map((f) => f.id)
+        const leads = footerColumnsOf(detailIds)
+          .map((column) => column[0])
+          .filter((id): id is string => Boolean(id) && !isCustomFieldId(id))
+        return new Set(leads)
+      }
+      if (['customer', 'vehicle', 'service', 'general'].includes(section.id)) {
+        const first = resolvedFields.find((f) => f.visible)
+        if (first && !isCustomFieldId(first.id)) return new Set([first.id])
+      }
+      return new Set<string>()
+    })()
+    /** Slides the dragged row to the spot the pointer is over, live. */
+    const dragFieldOver = (overId: string) => {
+      if (!dragFieldId || dragFieldId === overId) return
+      const from = resolvedFields.findIndex((f) => f.id === dragFieldId)
+      const to = resolvedFields.findIndex((f) => f.id === overId)
+      if (from === -1 || to === -1) return
+      const next = [...resolvedFields]
+      const [moved] = next.splice(from, 1)
+      next.splice(to, 0, moved)
+      setFields(next)
+    }
     const setStyle = (patch: InvoiceSectionStyle) => {
     const setStyle = (patch: InvoiceSectionStyle) => {
       const next = { ...style, ...patch }
       const next = { ...style, ...patch }
       const kept = Object.fromEntries(
       const kept = Object.fromEntries(
@@ -653,10 +696,54 @@ export function DesignerInspector({
           {SECTIONS_WITH_FIELDS.has(section.id) && (
           {SECTIONS_WITH_FIELDS.has(section.id) && (
             <Group title={t('fields')}>
             <Group title={t('fields')}>
               {resolvedFields.map((field) => (
               {resolvedFields.map((field) => (
-                <div key={field.id} className="flex items-center justify-between gap-2">
+                <div
+                  key={field.id}
+                  draggable
+                  onDragStart={(e) => {
+                    e.dataTransfer.effectAllowed = 'move'
+                    setDragFieldId(field.id)
+                  }}
+                  onDragEnd={() => setDragFieldId(null)}
+                  onDragOver={(e) => e.preventDefault()}
+                  onDragEnter={() => dragFieldOver(field.id)}
+                  onDrop={(e) => {
+                    e.preventDefault()
+                    setDragFieldId(null)
+                  }}
+                  className={`flex items-center justify-between gap-2 ${
+                    dragFieldId === field.id ? 'opacity-50' : ''
+                  }`}
+                >
+                  <span className="cursor-grab select-none text-[13px] leading-none text-[#c3c7cd]">
+                    ⠿
+                  </span>
                   <span className="min-w-0 flex-1 truncate text-[13px] font-medium">
                   <span className="min-w-0 flex-1 truncate text-[13px] font-medium">
                     {fieldName(field.id)}
                     {fieldName(field.id)}
                   </span>
                   </span>
+                  {boldable(field.id) &&
+                    (() => {
+                      const boldOn = field.bold ?? defaultBoldIds.has(field.id)
+                      return (
+                        <button
+                          type="button"
+                          onClick={() =>
+                            setFields(
+                              resolvedFields.map((f) =>
+                                f.id === field.id ? { ...f, bold: !boldOn } : f
+                              )
+                            )
+                          }
+                          className={`h-5 w-5 shrink-0 rounded text-[12px] font-bold leading-none transition-colors ${
+                            boldOn
+                              ? 'bg-[#e8edf9] text-[#2563eb]'
+                              : 'text-[#c3c7cd] hover:text-[#5b6068]'
+                          }`}
+                          title={t('boldField')}
+                        >
+                          B
+                        </button>
+                      )
+                    })()}
                   {isCustomFieldId(field.id) && (
                   {isCustomFieldId(field.id) && (
                     <button
                     <button
                       type="button"
                       type="button"
@@ -677,11 +764,11 @@ export function DesignerInspector({
                   />
                   />
                 </div>
                 </div>
               ))}
               ))}
-              {unassignedCustomFields.length > 0 && (
+              {availableCustomFields.length > 0 && (
                 <div className="pt-1">
                 <div className="pt-1">
                   <div className="pb-1.5 text-[11.5px] text-[#8a8f97]">{t('yourCustomFields')}</div>
                   <div className="pb-1.5 text-[11.5px] text-[#8a8f97]">{t('yourCustomFields')}</div>
                   <div className="flex flex-wrap gap-1.5">
                   <div className="flex flex-wrap gap-1.5">
-                    {unassignedCustomFields.map((field) => (
+                    {availableCustomFields.map((field) => (
                       <button
                       <button
                         key={field.id}
                         key={field.id}
                         type="button"
                         type="button"

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

@@ -80,7 +80,8 @@ export function InvoiceDesigner({
   initialDesignId,
   initialDesignId,
   initialActiveDesigns,
   initialActiveDesigns,
   workshop: companyWorkshop,
   workshop: companyWorkshop,
-  customFields,
+  customFields: serviceCustomFields,
+  quoteCustomFields = [],
 }: {
 }: {
   initialDocumentType: DocumentType
   initialDocumentType: DocumentType
   initialView: 'gallery' | 'designer'
   initialView: 'gallery' | 'designer'
@@ -97,6 +98,7 @@ export function InvoiceDesigner({
   initialActiveDesigns?: Record<DocumentType, string>
   initialActiveDesigns?: Record<DocumentType, string>
   workshop: DesignerWorkshop
   workshop: DesignerWorkshop
   customFields: DesignerFieldDef[]
   customFields: DesignerFieldDef[]
+  quoteCustomFields?: DesignerFieldDef[]
 }) {
 }) {
   const router = useRouter()
   const router = useRouter()
   const t = useTranslations('settings.designer')
   const t = useTranslations('settings.designer')
@@ -118,6 +120,8 @@ export function InvoiceDesigner({
   const arrivedWith = initialDesign ?? initialPreset
   const arrivedWith = initialDesign ?? initialPreset
   const [view, setView] = useState<'gallery' | 'designer'>(arrivedWith ? 'designer' : initialView)
   const [view, setView] = useState<'gallery' | 'designer'>(arrivedWith ? 'designer' : initialView)
   const [docType, setDocType] = useState<DocumentType>(initialDocumentType)
   const [docType, setDocType] = useState<DocumentType>(initialDocumentType)
+  // Each document places its own entity's fields: quotes carry quote fields.
+  const customFields = docType === 'quote' ? quoteCustomFields : serviceCustomFields
   const [layouts, setLayouts] = useState<Record<DocumentType, InvoiceLayoutConfig>>(() => {
   const [layouts, setLayouts] = useState<Record<DocumentType, InvoiceLayoutConfig>>(() => {
     const base = {
     const base = {
       invoice: invoiceLayout ?? getDefaultInvoiceLayout(),
       invoice: invoiceLayout ?? getDefaultInvoiceLayout(),

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

@@ -279,6 +279,9 @@ export function buildSampleData(
       },
       },
       { label: L('dueDateLabel', 'Due Date'), value: sample.due },
       { label: L('dueDateLabel', 'Due Date'), value: sample.due },
     ],
     ],
+    // A stand-in link, so the canvas shows the portal line the printed sheet
+    // carries and the footer's switch for it has something to switch.
+    portalUrl: 'https://example.com/portal/a1b2c3',
     sectionLabels: {
     sectionLabels: {
       customer: L('billTo', 'Bill To'),
       customer: L('billTo', 'Bill To'),
       vehicle: L('vehicle', 'Vehicle'),
       vehicle: L('vehicle', 'Vehicle'),

+ 116 - 28
src/features/invoice-designer/Spec/buildSpec.ts

@@ -1,5 +1,8 @@
 import {
 import {
+  FOOTER_SPECIAL_FIELD_IDS,
+  footerColumnsOf,
   getBuiltinFieldsForSection,
   getBuiltinFieldsForSection,
+  isCustomFieldId,
   type InvoiceLayoutConfig,
   type InvoiceLayoutConfig,
   type InvoiceSection,
   type InvoiceSection,
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 } from '@/features/settings/Schema/invoiceLayoutSchema'
@@ -175,6 +178,37 @@ export function sectionFields(section: InvoiceSection): string[] {
   return section.fields.filter((f) => f.visible).map((f) => f.id)
   return section.fields.filter((f) => f.visible).map((f) => f.id)
 }
 }
 
 
+/**
+ * The workshop-defined lines a section carries, already worded ("Label:
+ * value") and non-empty. The bespoke builders (letterhead, footer, payment)
+ * draw from fixed field ids, so without this a custom field assigned to them
+ * would be silently invisible; the panels resolve every id and need no help.
+ */
+function customFieldEntries(
+  section: InvoiceSection,
+  data: DocumentData
+): { id: string; value: string }[] {
+  return sectionFields(section)
+    .filter((id) => isCustomFieldId(id))
+    .map((id) => ({ id, value: data.fields[id] }))
+    .filter((entry) => Boolean(entry.value))
+}
+
+/** The section's explicit weight for a field, or nothing when never chosen. */
+function explicitBold(section: InvoiceSection, id: string): boolean | undefined {
+  return section.fields?.find((f) => f.id === id)?.bold
+}
+
+/**
+ * Whether the section still emphasizes its own leads. The automatic bold
+ * (a panel's first line, a footer column's head) stands until the workshop
+ * chooses weights itself; one explicit choice anywhere in the section hands
+ * the whole section over to the choices.
+ */
+function autoEmphasis(section: InvoiceSection): boolean {
+  return !section.fields?.some((f) => f.bold !== undefined)
+}
+
 const scale = (base: number, factor: number) => Math.max(5, Math.round(base * factor * 10) / 10)
 const scale = (base: number, factor: number) => Math.max(5, Math.round(base * factor * 10) / 10)
 
 
 /** A translated string, or the English the sheet has always printed. */
 /** A translated string, or the English the sheet has always printed. */
@@ -247,17 +281,27 @@ function panel(
               style: heading,
               style: heading,
             } as Node,
             } as Node,
           ]),
           ]),
-      ...lines.map<Node>((line, i) => ({
-        kind: 'text',
-        id: `${section.id}.${line.id}`,
-        text: line.value,
-        style: {
-          color: i === 0 ? look.text : look.muted,
-          bold: i === 0,
-          fontSize: i === 0 ? size : scale(size, 0.92),
-          fontFamily: look.fontFamily,
-        },
-      })),
+      ...lines.map<Node>((line, i) => {
+        // The first line leads the panel, set bold and a step larger, the way
+        // the customer's name has always headed its card. A workshop-defined
+        // field is a detail, not a heading, so it prints as a plain line even
+        // when it happens to stand first. A weight the layout chooses for the
+        // line wins over all of that.
+        const lead =
+          explicitBold(section, line.id) ??
+          (autoEmphasis(section) && i === 0 && !isCustomFieldId(line.id))
+        return {
+          kind: 'text',
+          id: `${section.id}.${line.id}`,
+          text: line.value,
+          style: {
+            color: lead ? look.text : look.muted,
+            bold: lead,
+            fontSize: lead ? size : scale(size, 0.92),
+            fontFamily: look.fontFamily,
+          },
+        }
+      }),
     ],
     ],
   }
   }
 }
 }
@@ -309,6 +353,8 @@ function framedBandHeight(
     .filter((id) => fields.includes(id))
     .filter((id) => fields.includes(id))
     .some((id) => data.fields[id])
     .some((id) => data.fields[id])
   if (hasStrapline) height += 6 + line(scale(size, 0.9))
   if (hasStrapline) height += 6 + line(scale(size, 0.9))
+  const customCount = customFieldEntries(section, data).length
+  if (customCount) height += 6 + customCount * line(scale(size, 0.9))
   if (data.branding) height += 6 + Math.max(MARK.icon, line(scale(size, MARK.word)))
   if (data.branding) height += 6 + Math.max(MARK.icon, line(scale(size, MARK.word)))
 
 
   return Math.max(FRAMED.bandHeight, Math.ceil(FRAMED_HEADER_TOP + height + FRAMED_HEADER_PAD))
   return Math.max(FRAMED.bandHeight, Math.ceil(FRAMED_HEADER_TOP + height + FRAMED_HEADER_PAD))
@@ -395,7 +441,19 @@ function classicLetterhead(
                 }
                 }
               : null
               : null
           default:
           default:
-            return null
+            // Workshop-defined fields print like the contact lines above.
+            return isCustomFieldId(id) && value
+              ? {
+                  kind: 'text',
+                  text: value,
+                  style: {
+                    color: bandInk('rgba(255,255,255,0.7)'),
+                    fontSize: 8,
+                    align,
+                    bold: explicitBold(section, id) === true,
+                  },
+                }
+              : null
         }
         }
       })
       })
       .filter(Boolean) as Node[]
       .filter(Boolean) as Node[]
@@ -627,6 +685,20 @@ function letterhead(section: InvoiceSection, theme: DocumentTheme, data: Documen
       style: { color: mutedOnBand, fontSize: scale(size, compact ? 0.8 : 0.9), align },
       style: { color: mutedOnBand, fontSize: scale(size, compact ? 0.8 : 0.9), align },
     })
     })
   }
   }
+  // Workshop-defined fields assigned to the header, each on its own line so a
+  // long value does not crowd the strapline.
+  for (const entry of customFieldEntries(section, data)) {
+    below.push({
+      kind: 'text',
+      text: entry.value,
+      style: {
+        color: mutedOnBand,
+        fontSize: scale(size, compact ? 0.8 : 0.9),
+        align,
+        bold: explicitBold(section, entry.id) === true,
+      },
+    })
+  }
   if (compact) {
   if (compact) {
     // The rule that separates a compact letterhead from the document.
     // The rule that separates a compact letterhead from the document.
     below.push({
     below.push({
@@ -1314,6 +1386,16 @@ function paymentBlock(
 ): Node | null {
 ): Node | null {
   const fields = new Set(sectionFields(section))
   const fields = new Set(sectionFields(section))
   const pairs = data.payment.filter((pair) => (pair.id ? fields.has(pair.id) : true))
   const pairs = data.payment.filter((pair) => (pair.id ? fields.has(pair.id) : true))
+  // Workshop-defined fields assigned here join as label-over-value pairs.
+  // Their lines arrive worded as "Label: value", so split at the first colon.
+  for (const entry of customFieldEntries(section, data)) {
+    const at = entry.value.indexOf(': ')
+    pairs.push(
+      at === -1
+        ? { label: '', value: entry.value }
+        : { label: entry.value.slice(0, at), value: entry.value.slice(at + 2) }
+    )
+  }
   if (!pairs.length) return null
   if (!pairs.length) return null
 
 
   const look = lookOf(section, theme)
   const look = lookOf(section, theme)
@@ -1468,18 +1550,16 @@ function footer(section: InvoiceSection, theme: DocumentTheme, data: DocumentDat
   // left against the margin while the note and the portal link stay centered
   // left against the margin while the note and the portal link stay centered
   // where a printed footer has always put them.
   // where a printed footer has always put them.
   const logoAlign = section.style?.logoAlign ?? ('center' as const)
   const logoAlign = section.style?.logoAlign ?? ('center' as const)
-  const columns = [
-    ['company_name', 'company_address'],
-    ['company_phone', 'company_email'],
-    ['bank_account', 'company_org_number'],
-  ]
-    .map((column) =>
-      column
-        .filter((id) => fields.includes(id))
-        .map((id) => data.fields[id])
-        .filter(Boolean)
-    )
-    .filter((column) => column.length > 0)
+  // The detail lines flow into columns in the stored field order, so dragging
+  // the list in the designer rearranges the printed footer. The default order
+  // reproduces the columns the footer has always printed: name over address,
+  // phone over email, bank over org number.
+  const columns = footerColumnsOf(
+    fields
+      .filter((id) => !FOOTER_SPECIAL_FIELD_IDS.has(id))
+      .map((id) => ({ id, value: data.fields[id] }))
+      .filter((entry) => Boolean(entry.value))
+  )
 
 
   const children: Node[] = []
   const children: Node[] = []
   // A shop that wants its mark along the bottom as well as the top, the way
   // A shop that wants its mark along the bottom as well as the top, the way
@@ -1495,7 +1575,7 @@ function footer(section: InvoiceSection, theme: DocumentTheme, data: DocumentDat
       align: logoAlign,
       align: logoAlign,
     })
     })
   }
   }
-  if (data.portalUrl) {
+  if (fields.includes('portal_link') && data.portalUrl) {
     children.push({
     children.push({
       kind: 'text',
       kind: 'text',
       id: 'footer.portal',
       id: 'footer.portal',
@@ -1515,10 +1595,18 @@ function footer(section: InvoiceSection, theme: DocumentTheme, data: DocumentDat
         node: {
         node: {
           kind: 'stack',
           kind: 'stack',
           gap: 1,
           gap: 1,
-          children: column.map<Node>((value, i) => ({
+          children: column.map<Node>((entry, i) => ({
             kind: 'text',
             kind: 'text',
-            text: value,
-            style: { color: look.muted, fontSize: scale(size, 0.72), bold: i === 0 },
+            text: entry.value,
+            style: {
+              color: look.muted,
+              fontSize: scale(size, 0.72),
+              // A column's head prints bold until the section chooses weights
+              // itself; a workshop-defined line is a detail, never a head.
+              bold:
+                explicitBold(section, entry.id) ??
+                (autoEmphasis(section) && i === 0 && !isCustomFieldId(entry.id)),
+            },
           })),
           })),
         },
         },
       })),
       })),

+ 3 - 17
src/features/invoices/Pdf/buildInvoicePdfBuffer.ts

@@ -35,6 +35,7 @@ import { formatDateForPdf } from '@/lib/format'
 import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
+import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
 
 
 type PdfMessages = Record<string, Record<string, string>>
 type PdfMessages = Record<string, Record<string, string>>
 
 
@@ -136,23 +137,8 @@ export async function buildInvoicePdfBuffer(
     orderBy: { createdAt: 'desc' },
     orderBy: { createdAt: 'desc' },
   })
   })
 
 
-  // Fetch custom field values for this service record
-  const customFieldValues = await db.customFieldValue.findMany({
-    where: { entityId: record.id, entityType: 'service_record' },
-    include: {
-      field: { select: { label: true, fieldType: true, isActive: true, sortOrder: true } },
-    },
-    orderBy: { field: { sortOrder: 'asc' } },
-  })
-
-  const customFields = customFieldValues
-    .filter((v) => v.field.isActive && v.value)
-    .map((v) => ({
-      fieldId: v.fieldId,
-      label: v.field.label,
-      value: v.value,
-      fieldType: v.field.fieldType,
-    }))
+  // Custom fields for this service record (definition defaults included)
+  const customFields = await getCustomFieldsForPrint(orgId, record.id, 'service_record')
 
 
   const [settings, org] = await Promise.all([
   const [settings, org] = await Promise.all([
     db.appSetting.findMany({
     db.appSetting.findMany({

+ 9 - 0
src/features/quotes/Components/QuoteRightColumn.tsx

@@ -26,6 +26,7 @@ import {
 import { SharedLinkCard } from '@/components/shared-link-card'
 import { SharedLinkCard } from '@/components/shared-link-card'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { netLineTotal } from '@/lib/tax'
 import { netLineTotal } from '@/lib/tax'
+import { CustomFieldsForm } from '@/features/custom-fields/Components/CustomFieldsForm'
 import type { QuoteFormState } from './useQuoteFormState'
 import type { QuoteFormState } from './useQuoteFormState'
 import type { QuoteRecord } from './quote-page-types'
 import type { QuoteRecord } from './quote-page-types'
 import { VehicleCombobox } from './VehicleCombobox'
 import { VehicleCombobox } from './VehicleCombobox'
@@ -257,6 +258,14 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
         )}
         )}
       </div>
       </div>
 
 
+      {/* Custom Fields */}
+      <CustomFieldsForm
+        entityId={quote.id}
+        entityType="quote"
+        onValuesReady={state.onCustomFieldsReady}
+        onChange={state.markDirty}
+      />
+
       {/* Customer Response */}
       {/* Customer Response */}
       {quote.customerMessage &&
       {quote.customerMessage &&
         (state.status === 'changes_requested' || state.status === 'accepted') && (
         (state.status === 'changes_requested' || state.status === 'accepted') && (

+ 24 - 0
src/features/quotes/Components/useQuoteFormState.ts

@@ -145,6 +145,13 @@ export function useQuoteFormState({
   const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
   const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
   const isSavingRef = useRef(false)
   const isSavingRef = useRef(false)
 
 
+  // Custom fields save callback ref
+  const customFieldsSaveRef = useRef<(() => Promise<{ valid: boolean }>) | null>(null)
+
+  const onCustomFieldsReady = useCallback((save: () => Promise<{ valid: boolean }>) => {
+    customFieldsSaveRef.current = save
+  }, [])
+
   const markDirty = useCallback(() => {
   const markDirty = useCallback(() => {
     // A locked quote cannot be saved, so nothing may queue an autosave that
     // A locked quote cannot be saved, so nothing may queue an autosave that
     // the server will only refuse five seconds later.
     // the server will only refuse five seconds later.
@@ -324,6 +331,22 @@ export function useQuoteFormState({
     e.preventDefault()
     e.preventDefault()
     if (isSavingRef.current) return
     if (isSavingRef.current) return
     if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
     if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
+
+    // Validate and save custom fields before saving; a locked quote saves nothing.
+    if (!locked && customFieldsSaveRef.current) {
+      try {
+        const { valid } = await customFieldsSaveRef.current()
+        if (!valid) {
+          toast.error(t('page.customFieldsInvalid'))
+          return
+        }
+      } catch (err) {
+        console.error('Custom fields save error:', err)
+        toast.error(t('page.customFieldsSaveFailed'))
+        return
+      }
+    }
+
     isSavingRef.current = true
     isSavingRef.current = true
     setSaving(true)
     setSaving(true)
     const formData = new FormData(e.currentTarget)
     const formData = new FormData(e.currentTarget)
@@ -495,6 +518,7 @@ export function useQuoteFormState({
     setSelectedCustomer,
     setSelectedCustomer,
     // Callbacks
     // Callbacks
     markDirty,
     markDirty,
+    onCustomFieldsReady,
     updatePart,
     updatePart,
     updateLabor,
     updateLabor,
     addPart,
     addPart,

+ 135 - 69
src/features/settings/Actions/invoiceLayoutActions.ts

@@ -10,27 +10,68 @@ import {
   invoiceLayoutConfigSchema,
   invoiceLayoutConfigSchema,
   mergeWithDefaults,
   mergeWithDefaults,
   getDefaultInvoiceLayout,
   getDefaultInvoiceLayout,
+  SECTIONS_WITH_FIELDS,
+  toCustomFieldId,
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { EntityType } from '@/features/custom-fields/Schema/customFieldSchema'
 
 
-export async function getInvoiceLayoutConfig() {
-  return withAuth(
-    async ({ organizationId }) => {
-      const setting = await db.appSetting.findUnique({
-        where: {
-          organizationId_key: {
-            organizationId,
-            key: SETTING_KEYS.INVOICE_LAYOUT_CONFIG,
-          },
-        },
-      })
+type LayoutSettingKey =
+  | typeof SETTING_KEYS.INVOICE_LAYOUT_CONFIG
+  | typeof SETTING_KEYS.QUOTE_LAYOUT_CONFIG
 
 
-      if (!setting?.value) {
-        return getDefaultInvoiceLayout()
-      }
+async function loadLayoutConfig(
+  organizationId: string,
+  key: LayoutSettingKey
+): Promise<InvoiceLayoutConfig> {
+  const setting = await db.appSetting.findUnique({
+    where: {
+      organizationId_key: {
+        organizationId,
+        key,
+      },
+    },
+  })
+
+  if (!setting?.value) {
+    return getDefaultInvoiceLayout()
+  }
 
 
-      const parsed = JSON.parse(setting.value)
-      return mergeWithDefaults(parsed)
+  const parsed = JSON.parse(setting.value)
+  return mergeWithDefaults(parsed)
+}
+
+async function persistLayoutConfig(
+  userId: string,
+  organizationId: string,
+  key: LayoutSettingKey,
+  config: InvoiceLayoutConfig
+): Promise<InvoiceLayoutConfig> {
+  const validated = invoiceLayoutConfigSchema.parse(config)
+  const value = JSON.stringify(validated)
+
+  await db.appSetting.upsert({
+    where: {
+      organizationId_key: {
+        organizationId,
+        key,
+      },
     },
     },
+    update: { value },
+    create: {
+      userId,
+      organizationId,
+      key,
+      value,
+    },
+  })
+
+  return validated
+}
+
+export async function getInvoiceLayoutConfig() {
+  return withAuth(
+    async ({ organizationId }) =>
+      loadLayoutConfig(organizationId, SETTING_KEYS.INVOICE_LAYOUT_CONFIG),
     {
     {
       requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
       requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
     }
     }
@@ -40,24 +81,12 @@ export async function getInvoiceLayoutConfig() {
 export async function saveInvoiceLayoutConfig(config: InvoiceLayoutConfig) {
 export async function saveInvoiceLayoutConfig(config: InvoiceLayoutConfig) {
   return withAuth(
   return withAuth(
     async ({ userId, organizationId }) => {
     async ({ userId, organizationId }) => {
-      const validated = invoiceLayoutConfigSchema.parse(config)
-      const value = JSON.stringify(validated)
-
-      await db.appSetting.upsert({
-        where: {
-          organizationId_key: {
-            organizationId,
-            key: SETTING_KEYS.INVOICE_LAYOUT_CONFIG,
-          },
-        },
-        update: { value },
-        create: {
-          userId,
-          organizationId,
-          key: SETTING_KEYS.INVOICE_LAYOUT_CONFIG,
-          value,
-        },
-      })
+      const validated = await persistLayoutConfig(
+        userId,
+        organizationId,
+        SETTING_KEYS.INVOICE_LAYOUT_CONFIG,
+        config
+      )
 
 
       revalidatePath('/settings/templates')
       revalidatePath('/settings/templates')
       revalidatePath('/settings/invoice')
       revalidatePath('/settings/invoice')
@@ -78,23 +107,8 @@ export async function saveInvoiceLayoutConfig(config: InvoiceLayoutConfig) {
 
 
 export async function getQuoteLayoutConfig() {
 export async function getQuoteLayoutConfig() {
   return withAuth(
   return withAuth(
-    async ({ organizationId }) => {
-      const setting = await db.appSetting.findUnique({
-        where: {
-          organizationId_key: {
-            organizationId,
-            key: SETTING_KEYS.QUOTE_LAYOUT_CONFIG,
-          },
-        },
-      })
-
-      if (!setting?.value) {
-        return getDefaultInvoiceLayout()
-      }
-
-      const parsed = JSON.parse(setting.value)
-      return mergeWithDefaults(parsed)
-    },
+    async ({ organizationId }) =>
+      loadLayoutConfig(organizationId, SETTING_KEYS.QUOTE_LAYOUT_CONFIG),
     {
     {
       requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
       requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
     }
     }
@@ -104,24 +118,12 @@ export async function getQuoteLayoutConfig() {
 export async function saveQuoteLayoutConfig(config: InvoiceLayoutConfig) {
 export async function saveQuoteLayoutConfig(config: InvoiceLayoutConfig) {
   return withAuth(
   return withAuth(
     async ({ userId, organizationId }) => {
     async ({ userId, organizationId }) => {
-      const validated = invoiceLayoutConfigSchema.parse(config)
-      const value = JSON.stringify(validated)
-
-      await db.appSetting.upsert({
-        where: {
-          organizationId_key: {
-            organizationId,
-            key: SETTING_KEYS.QUOTE_LAYOUT_CONFIG,
-          },
-        },
-        update: { value },
-        create: {
-          userId,
-          organizationId,
-          key: SETTING_KEYS.QUOTE_LAYOUT_CONFIG,
-          value,
-        },
-      })
+      const validated = await persistLayoutConfig(
+        userId,
+        organizationId,
+        SETTING_KEYS.QUOTE_LAYOUT_CONFIG,
+        config
+      )
 
 
       revalidatePath('/settings/templates')
       revalidatePath('/settings/templates')
       return validated
       return validated
@@ -138,3 +140,67 @@ export async function saveQuoteLayoutConfig(config: InvoiceLayoutConfig) {
     }
     }
   )
   )
 }
 }
+
+/** Where a custom field prints: a section id, or hidden from the document entirely. */
+export type CustomFieldPlacement = string
+
+export async function setCustomFieldPlacement(input: {
+  definitionId: string
+  entityType: EntityType
+  placement: CustomFieldPlacement
+}) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const { definitionId, entityType, placement } = input
+
+      if (placement !== 'hidden' && !SECTIONS_WITH_FIELDS.has(placement)) {
+        throw new Error('Invalid placement')
+      }
+
+      const definition = await db.customFieldDefinition.findFirst({
+        where: { id: definitionId, organizationId, entityType },
+      })
+      if (!definition) throw new Error('Field not found')
+
+      const key =
+        entityType === 'quote'
+          ? SETTING_KEYS.QUOTE_LAYOUT_CONFIG
+          : SETTING_KEYS.INVOICE_LAYOUT_CONFIG
+      const config = await loadLayoutConfig(organizationId, key)
+      const cfId = toCustomFieldId(definitionId)
+      // "hidden" is stored as an invisible entry in "general": still assigned,
+      // so the print builders' auto-append of unassigned fields skips it.
+      const targetId = placement === 'hidden' ? 'general' : placement
+
+      const sections = config.sections.map((section) => {
+        const fields = (section.fields ?? []).filter((f) => f.id !== cfId)
+        if (section.id !== targetId) {
+          return section.fields ? { ...section, fields } : section
+        }
+        fields.push({ id: cfId, visible: placement !== 'hidden' })
+        // "general" is hidden by default; placing a field there must switch it on.
+        const visible = placement === 'general' ? true : section.visible
+        return { ...section, fields, visible }
+      })
+
+      // Deliberately no version stamp: persisting the merged config must not
+      // graduate a classic layout to designer rendering (isDesignerLayout).
+      await persistLayoutConfig(userId, organizationId, key, { ...config, sections })
+
+      revalidatePath('/settings/custom-fields')
+      revalidatePath('/settings/invoice')
+      return { definitionId, placement }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: 'settings.setCustomFieldPlacement',
+        entity: 'AppSetting',
+        details: { key: 'settings_setCustomFieldPlacement' },
+        metadata: { fieldId: result.definitionId, placement: result.placement },
+      }),
+    }
+  )
+}

+ 33 - 2
src/features/settings/Schema/invoiceLayoutSchema.ts

@@ -7,6 +7,12 @@ import { z } from 'zod'
 export const invoiceFieldConfigSchema = z.object({
 export const invoiceFieldConfigSchema = z.object({
   id: z.string(),
   id: z.string(),
   visible: z.boolean(),
   visible: z.boolean(),
+  /**
+   * An explicit weight for this line. Unset keeps the section's automatic
+   * emphasis (the first line of a panel, a footer column's lead); the moment
+   * any field in a section carries a choice, only the choices apply.
+   */
+  bold: z.boolean().optional(),
 })
 })
 
 
 /**
 /**
@@ -253,6 +259,9 @@ export const BUILTIN_HEADER_FIELDS = [
  */
  */
 export const BUILTIN_FOOTER_FIELDS = [
 export const BUILTIN_FOOTER_FIELDS = [
   { id: 'footer_note', name: 'Footer Note' },
   { id: 'footer_note', name: 'Footer Note' },
+  // The customer's link to their portal. On by default because the sheet has
+  // always printed it when a portal link exists; now the footer can decline.
+  { id: 'portal_link', name: 'Portal Link' },
   // Off unless asked for, the way the rest of the footer details are: a shop
   // Off unless asked for, the way the rest of the footer details are: a shop
   // that wants its mark at the foot of the page as well as the top can say so.
   // that wants its mark at the foot of the page as well as the top can say so.
   { id: 'logo', name: 'Logo' },
   { id: 'logo', name: 'Logo' },
@@ -269,6 +278,24 @@ export const BUILTIN_BANK_ACCOUNT_FIELDS = [
   { id: 'org_number', name: 'Organization Number' },
   { id: 'org_number', name: 'Organization Number' },
 ] as const
 ] as const
 
 
+/** The footer's rows that are not detail lines: the mark, the portal link and the closing note. */
+export const FOOTER_SPECIAL_FIELD_IDS: Set<string> = new Set(['logo', 'portal_link', 'footer_note'])
+
+/**
+ * The footer's detail lines flowed top-to-bottom into up to three columns, in
+ * the order given, so the stored field order is the order the sheet shows.
+ * The generator and the designer's field list both read this, so the drag
+ * order and the print agree on which line heads which column.
+ */
+export function footerColumnsOf<T>(entries: T[]): T[][] {
+  const colCount = Math.min(3, entries.length)
+  if (!colCount) return []
+  const rows = Math.ceil(entries.length / colCount)
+  return Array.from({ length: colCount }, (_, c) => entries.slice(c * rows, (c + 1) * rows)).filter(
+    (column) => column.length > 0
+  )
+}
+
 export type BuiltinSectionId = (typeof BUILTIN_SECTIONS)[number]['id']
 export type BuiltinSectionId = (typeof BUILTIN_SECTIONS)[number]['id']
 export type BuiltinInfoFieldId = (typeof BUILTIN_INFO_FIELDS)[number]['id']
 export type BuiltinInfoFieldId = (typeof BUILTIN_INFO_FIELDS)[number]['id']
 export type BuiltinCustomerFieldId = (typeof BUILTIN_CUSTOMER_FIELDS)[number]['id']
 export type BuiltinCustomerFieldId = (typeof BUILTIN_CUSTOMER_FIELDS)[number]['id']
@@ -349,8 +376,12 @@ function getDefaultFieldsForSection(sectionId: string): InvoiceFieldConfig[] | u
     case 'bank_account':
     case 'bank_account':
       return BUILTIN_BANK_ACCOUNT_FIELDS.map((f) => ({ id: f.id, visible: true }))
       return BUILTIN_BANK_ACCOUNT_FIELDS.map((f) => ({ id: f.id, visible: true }))
     case 'footer':
     case 'footer':
-      // Only the note, which is the footer every existing invoice already has.
-      return BUILTIN_FOOTER_FIELDS.map((f) => ({ id: f.id, visible: f.id === 'footer_note' }))
+      // Only the note and the portal link, which is the footer every existing
+      // invoice already has.
+      return BUILTIN_FOOTER_FIELDS.map((f) => ({
+        id: f.id,
+        visible: f.id === 'footer_note' || f.id === 'portal_link',
+      }))
     case 'general':
     case 'general':
       return [] // no built-in fields, only custom fields
       return [] // no built-in fields, only custom fields
     default:
     default: