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

Add a shop fee to new work orders and quotes, with where it applies as a setting (#421)

Bernt Christian Egeland 1 неделя назад
Родитель
Сommit
5cdea7898a
60 измененных файлов с 2097 добавлено и 768 удалено
  1. 5 1
      messages/de/service.json
  2. 25 1
      messages/de/settings.json
  3. 5 1
      messages/en/service.json
  4. 25 1
      messages/en/settings.json
  5. 5 1
      messages/es/service.json
  6. 25 1
      messages/es/settings.json
  7. 5 1
      messages/fr/service.json
  8. 25 1
      messages/fr/settings.json
  9. 5 1
      messages/it/service.json
  10. 25 1
      messages/it/settings.json
  11. 5 1
      messages/lt/service.json
  12. 25 1
      messages/lt/settings.json
  13. 5 1
      messages/nb/service.json
  14. 25 1
      messages/nb/settings.json
  15. 5 1
      messages/nl/service.json
  16. 25 1
      messages/nl/settings.json
  17. 5 1
      messages/pl/service.json
  18. 25 1
      messages/pl/settings.json
  19. 5 1
      messages/pt-BR/service.json
  20. 25 1
      messages/pt-BR/settings.json
  21. 5 1
      messages/pt-PT/service.json
  22. 25 1
      messages/pt-PT/settings.json
  23. 5 1
      messages/ru/service.json
  24. 25 1
      messages/ru/settings.json
  25. 5 1
      messages/tr/service.json
  26. 25 1
      messages/tr/settings.json
  27. 2 0
      src/__tests__/api/tech-labor-live.test.ts
  28. 98 0
      src/__tests__/features/quotes/create-quote-shop-fee.test.ts
  29. 153 0
      src/__tests__/features/settings/shop-fee.test.ts
  30. 2 1
      src/__tests__/features/workorders/add-part.test.ts
  31. 47 0
      src/__tests__/features/workorders/create-workorder.test.ts
  32. 3 0
      src/app/(authenticated)/quotes/[id]/page.tsx
  33. 140 0
      src/app/(authenticated)/settings/invoice/invoice-settings.tsx
  34. 3 0
      src/app/api/v1/tech/jobs/[id]/labor/route.ts
  35. 7 3
      src/features/invoice-designer/Pdf/buildInvoicePrint.ts
  36. 7 3
      src/features/invoice-designer/Pdf/buildQuotePrint.ts
  37. 24 1
      src/features/quotes/Actions/quoteActions.ts
  38. 24 14
      src/features/quotes/Components/QuoteLaborEditor.tsx
  39. 288 280
      src/features/quotes/Components/QuotePageClient.tsx
  40. 21 3
      src/features/quotes/Components/useQuoteFormState.ts
  41. 41 0
      src/features/quotes/Lib/createQuoteRecord.ts
  42. 1 1
      src/features/quotes/Schema/quoteSchema.ts
  43. 17 9
      src/features/reports/Actions/reportActions.ts
  44. 16 0
      src/features/settings/Components/ShopFeeContext.tsx
  45. 32 0
      src/features/settings/Components/ShopFeeTag.tsx
  46. 2 0
      src/features/settings/Lib/memberReadableSettings.ts
  47. 185 0
      src/features/settings/Lib/shopFee.ts
  48. 21 0
      src/features/settings/Schema/settingsSchema.ts
  49. 27 17
      src/features/vehicles/Components/service-edit/LaborEditor.tsx
  50. 406 401
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  51. 10 2
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  52. 3 0
      src/features/vehicles/Components/service-page/service-page-types.ts
  53. 25 2
      src/features/vehicles/Components/service-page/useServiceFormState.ts
  54. 3 0
      src/features/vehicles/Lib/addPart.ts
  55. 28 0
      src/features/vehicles/Lib/createDraftRecord.ts
  56. 2 0
      src/features/vehicles/Lib/laborLines.ts
  57. 5 0
      src/features/vehicles/Lib/retotalServiceRecord.ts
  58. 52 0
      src/features/vehicles/Lib/shopFeeLines.ts
  59. 1 1
      src/features/vehicles/Schema/serviceSchema.ts
  60. 11 4
      src/lib/cron/report-schedules.ts

+ 5 - 1
messages/de/service.json

@@ -161,7 +161,11 @@
     "hourlyHint": "Stundenbasiert: nach geleisteten Stunden berechnet (Stunden × Satz/Std.). Zum Wechseln klicken.",
     "serviceHint": "Pauschale: als Festpreis pro Einheit berechnet. Zum Wechseln klicken.",
     "deleteRow": "Zeile löschen",
-    "descriptionMissingHint": "Zeilen ohne Beschreibung werden nicht gespeichert."
+    "descriptionMissingHint": "Zeilen ohne Beschreibung werden nicht gespeichert.",
+    "shopFeeTag": "Pauschale",
+    "shopFeeHintFlat": "Die Werkstattpauschale aus den Einstellungen als fester Betrag. Ändern Sie den Preis für diesen Auftrag oder löschen Sie die Position.",
+    "shopFeeHintPercentLabor": "Die Werkstattpauschale aus den Einstellungen: {percent} % der Arbeit dieses Auftrags. Löschen Sie die Position, um sie wegzulassen.",
+    "shopFeeHintPercentAll": "Die Werkstattpauschale aus den Einstellungen: {percent} % von Arbeit und Teilen dieses Auftrags. Löschen Sie die Position, um sie wegzulassen."
   },
   "liveLabor": {
     "title": "{count, plural, one {Ein Techniker hat Arbeit zu diesem Auftrag hinzugefügt} other {Ein Techniker hat # Arbeitspositionen zu diesem Auftrag hinzugefügt}}",

+ 25 - 1
messages/de/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "Design konnte nicht erneut angewendet werden",
     "sectionSending": "Versand",
     "attachPdfLabel": "PDF an E-Mails anhängen",
-    "attachPdfHint": "Die Vorgabe für jede Rechnung, jedes Angebot und jede Prüfung, die Sie per E-Mail senden. Ein: Der Kunde erhält das PDF als Anhang. Aus: Er erhält stattdessen einen Link zum Dokument, und der Aufrufzähler zeigt, wann es geöffnet wurde, damit Sie bei den unbeachteten nachfassen können. Für einen einzelnen Versand können Sie das im Teilen-Dialog ändern."
+    "attachPdfHint": "Die Vorgabe für jede Rechnung, jedes Angebot und jede Prüfung, die Sie per E-Mail senden. Ein: Der Kunde erhält das PDF als Anhang. Aus: Er erhält stattdessen einen Link zum Dokument, und der Aufrufzähler zeigt, wann es geöffnet wurde, damit Sie bei den unbeachteten nachfassen können. Für einen einzelnen Versand können Sie das im Teilen-Dialog ändern.",
+    "shopFee": {
+      "title": "Werkstattpauschale",
+      "description": "Eine Pauschale für Verbrauchsmaterial wie Putztücher, Reiniger, Schmierstoffe und Entsorgung, die jedem neuen Auftrag und Angebot als eigene Position hinzugefügt wird.",
+      "enabled": "Werkstattpauschale berechnen",
+      "enabledHint": "Neue Aufträge und Angebote erhalten die Pauschale als Position. Sie kann bei einzelnen Aufträgen entfernt werden. Bestehende Aufträge bleiben unverändert.",
+      "defaultLabel": "Kleinmaterial",
+      "label": "Bezeichnung auf der Rechnung",
+      "appliesTo": "Gilt für",
+      "appliesToBoth": "Aufträge und Angebote",
+      "appliesToWorkOrders": "Nur Aufträge",
+      "appliesToQuotes": "Nur Angebote",
+      "mode": "Berechnung",
+      "modeFlat": "Fester Betrag",
+      "modePercent": "Prozentsatz",
+      "amount": "Betrag",
+      "amountHint": "Derselbe Betrag bei jedem Auftrag. Er kann bei einzelnen Aufträgen geändert werden.",
+      "percent": "Prozentsatz (%)",
+      "base": "Prozent von",
+      "baseLabor": "Arbeit",
+      "baseLaborParts": "Arbeit und Teile",
+      "cap": "Höchstbetrag",
+      "capHint": "Die Pauschale wird nie höher als dieser Betrag. Leer lassen für keinen Höchstbetrag.",
+      "capPlaceholder": "Kein Höchstbetrag"
+    }
   },
   "layoutEditor": {
     "helpText": "Ziehen zum Neuordnen. Verwenden Sie den Breiten-Umschalter, um Abschnitte nebeneinander zu platzieren. Aufeinanderfolgende Halbbreite-Abschnitte (L/R) werden in zwei Spalten dargestellt.",

+ 5 - 1
messages/en/service.json

@@ -161,7 +161,11 @@
     "hourlyHint": "Hourly: priced by hours worked (hours × rate/hr). Click to switch.",
     "serviceHint": "Service: priced as a flat fee per unit. Click to switch.",
     "deleteRow": "Delete row",
-    "descriptionMissingHint": "Rows without a description are not saved."
+    "descriptionMissingHint": "Rows without a description are not saved.",
+    "shopFeeTag": "Fee",
+    "shopFeeHintFlat": "The shop fee from Settings, as a flat amount. Change the price for this job, or delete the line to leave it off.",
+    "shopFeeHintPercentLabor": "The shop fee from Settings: {percent} % of the labor on this job. Delete the line to leave it off.",
+    "shopFeeHintPercentAll": "The shop fee from Settings: {percent} % of the labor and parts on this job. Delete the line to leave it off."
   },
   "liveLabor": {
     "title": "{count, plural, one {A technician added labor to this job} other {A technician added # labor lines to this job}}",

+ 25 - 1
messages/en/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "Could not re-apply the design",
     "sectionSending": "Sending",
     "attachPdfLabel": "Attach the PDF to emails",
-    "attachPdfHint": "The default for every invoice, quote and inspection you send by email. On, the customer gets the PDF attached. Off, they get a link to the document instead, and the view counter tells you when they opened it, so you can chase the ones nobody has looked at. You can change it for a single send in the share dialog."
+    "attachPdfHint": "The default for every invoice, quote and inspection you send by email. On, the customer gets the PDF attached. Off, they get a link to the document instead, and the view counter tells you when they opened it, so you can chase the ones nobody has looked at. You can change it for a single send in the share dialog.",
+    "shopFee": {
+      "title": "Shop fee",
+      "description": "A charge for shop supplies, such as rags, cleaners, lubricants and disposal, added to every new work order and quote as its own line.",
+      "enabled": "Charge a shop fee",
+      "enabledHint": "New work orders and quotes get the fee as a line. It can be removed from a single job. Existing jobs are not changed.",
+      "defaultLabel": "Shop supplies",
+      "label": "Name on the invoice",
+      "appliesTo": "Applies to",
+      "appliesToBoth": "Work orders and quotes",
+      "appliesToWorkOrders": "Work orders only",
+      "appliesToQuotes": "Quotes only",
+      "mode": "How it is charged",
+      "modeFlat": "Flat amount",
+      "modePercent": "Percentage",
+      "amount": "Amount",
+      "amountHint": "The same amount on every job. It can be changed on a single job.",
+      "percent": "Percentage (%)",
+      "base": "Percentage of",
+      "baseLabor": "Labor",
+      "baseLaborParts": "Labor and parts",
+      "cap": "Maximum fee",
+      "capHint": "The fee never comes to more than this. Leave empty for no maximum.",
+      "capPlaceholder": "No maximum"
+    }
   },
   "layoutEditor": {
     "helpText": "Drag to reorder. Use the width toggle to place sections side-by-side. Consecutive half-width sections (L/R) will render in two columns.",

+ 5 - 1
messages/es/service.json

@@ -161,7 +161,11 @@
     "hourlyHint": "Por horas: se calcula por las horas trabajadas (horas × tarifa/h). Haz clic para cambiar.",
     "serviceHint": "Servicio: se cobra como tarifa fija por unidad. Haz clic para cambiar.",
     "deleteRow": "Eliminar fila",
-    "descriptionMissingHint": "Las filas sin descripción no se guardan."
+    "descriptionMissingHint": "Las filas sin descripción no se guardan.",
+    "shopFeeTag": "Cargo",
+    "shopFeeHintFlat": "El cargo de taller de Ajustes, como importe fijo. Cambia el precio para este trabajo o borra la línea para quitarlo.",
+    "shopFeeHintPercentLabor": "El cargo de taller de Ajustes: {percent} % de la mano de obra de este trabajo. Borra la línea para quitarlo.",
+    "shopFeeHintPercentAll": "El cargo de taller de Ajustes: {percent} % de la mano de obra y las piezas de este trabajo. Borra la línea para quitarlo."
   },
   "liveLabor": {
     "title": "{count, plural, one {Un técnico añadió mano de obra a este trabajo} other {Un técnico añadió # líneas de mano de obra a este trabajo}}",

+ 25 - 1
messages/es/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "No se pudo volver a aplicar el diseño",
     "sectionSending": "Envío",
     "attachPdfLabel": "Adjuntar el PDF a los correos",
-    "attachPdfHint": "El valor predeterminado para cada factura, presupuesto e inspección que envíes por correo. Activado: el cliente recibe el PDF adjunto. Desactivado: recibe un enlace al documento y el contador de visitas te dice cuándo lo abrió, para que puedas insistir con los que nadie ha mirado. Puedes cambiarlo para un envío concreto en el diálogo de compartir."
+    "attachPdfHint": "El valor predeterminado para cada factura, presupuesto e inspección que envíes por correo. Activado: el cliente recibe el PDF adjunto. Desactivado: recibe un enlace al documento y el contador de visitas te dice cuándo lo abrió, para que puedas insistir con los que nadie ha mirado. Puedes cambiarlo para un envío concreto en el diálogo de compartir.",
+    "shopFee": {
+      "title": "Cargo de taller",
+      "description": "Un cargo por consumibles del taller, como trapos, limpiadores, lubricantes y gestión de residuos, que se añade a cada nueva orden de trabajo y presupuesto como una línea propia.",
+      "enabled": "Aplicar cargo de taller",
+      "enabledHint": "Las nuevas órdenes de trabajo y presupuestos reciben el cargo como una línea. Se puede quitar de un trabajo concreto. Los trabajos existentes no cambian.",
+      "defaultLabel": "Consumibles de taller",
+      "label": "Nombre en la factura",
+      "appliesTo": "Se aplica a",
+      "appliesToBoth": "Órdenes de trabajo y presupuestos",
+      "appliesToWorkOrders": "Solo órdenes de trabajo",
+      "appliesToQuotes": "Solo presupuestos",
+      "mode": "Cómo se calcula",
+      "modeFlat": "Importe fijo",
+      "modePercent": "Porcentaje",
+      "amount": "Importe",
+      "amountHint": "El mismo importe en cada trabajo. Se puede cambiar en un trabajo concreto.",
+      "percent": "Porcentaje (%)",
+      "base": "Porcentaje de",
+      "baseLabor": "Mano de obra",
+      "baseLaborParts": "Mano de obra y piezas",
+      "cap": "Cargo máximo",
+      "capHint": "El cargo nunca supera este importe. Déjalo vacío para no fijar un máximo.",
+      "capPlaceholder": "Sin máximo"
+    }
   },
   "layoutEditor": {
     "helpText": "Arrastra para reordenar. Usa el selector de ancho para colocar secciones lado a lado. Las secciones consecutivas de medio ancho (I/D) se mostrarán en dos columnas.",

+ 5 - 1
messages/fr/service.json

@@ -161,7 +161,11 @@
     "hourlyHint": "À l'heure : facturé selon les heures travaillées (heures × taux/h). Cliquez pour changer.",
     "serviceHint": "Forfait : facturé au prix fixe par unité. Cliquez pour changer.",
     "deleteRow": "Supprimer la ligne",
-    "descriptionMissingHint": "Les lignes sans description ne sont pas enregistrées."
+    "descriptionMissingHint": "Les lignes sans description ne sont pas enregistrées.",
+    "shopFeeTag": "Frais",
+    "shopFeeHintFlat": "Les frais d'atelier des Paramètres, en montant fixe. Modifiez le prix pour cette intervention ou supprimez la ligne pour les retirer.",
+    "shopFeeHintPercentLabor": "Les frais d'atelier des Paramètres : {percent} % de la main-d'œuvre de cette intervention. Supprimez la ligne pour les retirer.",
+    "shopFeeHintPercentAll": "Les frais d'atelier des Paramètres : {percent} % de la main-d'œuvre et des pièces de cette intervention. Supprimez la ligne pour les retirer."
   },
   "liveLabor": {
     "title": "{count, plural, one {Un technicien a ajouté de la main-d’œuvre à cette intervention} other {Un technicien a ajouté # lignes de main-d’œuvre à cette intervention}}",

+ 25 - 1
messages/fr/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "Impossible de réappliquer le design",
     "sectionSending": "Envoi",
     "attachPdfLabel": "Joindre le PDF aux e-mails",
-    "attachPdfHint": "La valeur par défaut pour chaque facture, devis et inspection que vous envoyez par e-mail. Activé : le client reçoit le PDF en pièce jointe. Désactivé : il reçoit un lien vers le document, et le compteur de consultations indique quand il l'a ouvert, ce qui permet de relancer ceux que personne n'a regardés. Vous pouvez le changer pour un envoi précis dans la fenêtre de partage."
+    "attachPdfHint": "La valeur par défaut pour chaque facture, devis et inspection que vous envoyez par e-mail. Activé : le client reçoit le PDF en pièce jointe. Désactivé : il reçoit un lien vers le document, et le compteur de consultations indique quand il l'a ouvert, ce qui permet de relancer ceux que personne n'a regardés. Vous pouvez le changer pour un envoi précis dans la fenêtre de partage.",
+    "shopFee": {
+      "title": "Frais d'atelier",
+      "description": "Des frais pour les consommables de l'atelier, comme les chiffons, nettoyants, lubrifiants et le traitement des déchets, ajoutés à chaque nouvel ordre de réparation et devis sur une ligne à part.",
+      "enabled": "Facturer des frais d'atelier",
+      "enabledHint": "Les nouveaux ordres de réparation et devis reçoivent les frais sur une ligne. Ils peuvent être retirés d'une intervention. Les interventions existantes ne changent pas.",
+      "defaultLabel": "Fournitures d'atelier",
+      "label": "Libellé sur la facture",
+      "appliesTo": "S'applique aux",
+      "appliesToBoth": "Ordres de réparation et devis",
+      "appliesToWorkOrders": "Ordres de réparation seulement",
+      "appliesToQuotes": "Devis seulement",
+      "mode": "Mode de calcul",
+      "modeFlat": "Montant fixe",
+      "modePercent": "Pourcentage",
+      "amount": "Montant",
+      "amountHint": "Le même montant sur chaque intervention. Il peut être modifié sur une intervention.",
+      "percent": "Pourcentage (%)",
+      "base": "Pourcentage de",
+      "baseLabor": "Main-d'œuvre",
+      "baseLaborParts": "Main-d'œuvre et pièces",
+      "cap": "Frais maximum",
+      "capHint": "Les frais ne dépassent jamais ce montant. Laissez vide pour ne fixer aucun maximum.",
+      "capPlaceholder": "Aucun maximum"
+    }
   },
   "layoutEditor": {
     "helpText": "Glissez pour réorganiser. Utilisez le sélecteur de largeur pour placer les sections côte à côte. Les sections consécutives en demi-largeur (G/D) s'afficheront en deux colonnes.",

+ 5 - 1
messages/it/service.json

@@ -161,7 +161,11 @@
     "hourlyHint": "A ore: calcolato sulle ore lavorate (ore × tariffa/h). Clicca per cambiare.",
     "serviceHint": "Servizio: addebitato come importo fisso per unità. Clicca per cambiare.",
     "deleteRow": "Elimina riga",
-    "descriptionMissingHint": "Le righe senza descrizione non vengono salvate."
+    "descriptionMissingHint": "Le righe senza descrizione non vengono salvate.",
+    "shopFeeTag": "Contributo",
+    "shopFeeHintFlat": "Il contributo officina dalle Impostazioni, come importo fisso. Cambia il prezzo per questo lavoro o elimina la riga per toglierlo.",
+    "shopFeeHintPercentLabor": "Il contributo officina dalle Impostazioni: {percent} % della manodopera di questo lavoro. Elimina la riga per toglierlo.",
+    "shopFeeHintPercentAll": "Il contributo officina dalle Impostazioni: {percent} % di manodopera e ricambi di questo lavoro. Elimina la riga per toglierlo."
   },
   "liveLabor": {
     "title": "{count, plural, one {Un tecnico ha aggiunto manodopera a questo lavoro} other {Un tecnico ha aggiunto # righe di manodopera a questo lavoro}}",

+ 25 - 1
messages/it/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "Impossibile riapplicare il design",
     "sectionSending": "Invio",
     "attachPdfLabel": "Allega il PDF alle email",
-    "attachPdfHint": "L'impostazione predefinita per ogni fattura, preventivo e controllo che invii via email. Attivo: il cliente riceve il PDF in allegato. Disattivo: riceve invece un link al documento e il contatore delle visualizzazioni indica quando lo ha aperto, così puoi sollecitare quelli che nessuno ha guardato. Puoi cambiarlo per un singolo invio nella finestra di condivisione."
+    "attachPdfHint": "L'impostazione predefinita per ogni fattura, preventivo e controllo che invii via email. Attivo: il cliente riceve il PDF in allegato. Disattivo: riceve invece un link al documento e il contatore delle visualizzazioni indica quando lo ha aperto, così puoi sollecitare quelli che nessuno ha guardato. Puoi cambiarlo per un singolo invio nella finestra di condivisione.",
+    "shopFee": {
+      "title": "Contributo officina",
+      "description": "Un contributo per i materiali di consumo dell'officina, come stracci, detergenti, lubrificanti e smaltimento, aggiunto a ogni nuovo ordine di lavoro e preventivo come riga a sé.",
+      "enabled": "Applica il contributo officina",
+      "enabledHint": "I nuovi ordini di lavoro e preventivi ricevono il contributo come riga. Si può togliere da un singolo lavoro. I lavori esistenti non cambiano.",
+      "defaultLabel": "Materiali di consumo",
+      "label": "Nome in fattura",
+      "appliesTo": "Si applica a",
+      "appliesToBoth": "Ordini di lavoro e preventivi",
+      "appliesToWorkOrders": "Solo ordini di lavoro",
+      "appliesToQuotes": "Solo preventivi",
+      "mode": "Come viene calcolato",
+      "modeFlat": "Importo fisso",
+      "modePercent": "Percentuale",
+      "amount": "Importo",
+      "amountHint": "Lo stesso importo su ogni lavoro. Si può modificare su un singolo lavoro.",
+      "percent": "Percentuale (%)",
+      "base": "Percentuale di",
+      "baseLabor": "Manodopera",
+      "baseLaborParts": "Manodopera e ricambi",
+      "cap": "Contributo massimo",
+      "capHint": "Il contributo non supera mai questo importo. Lascia vuoto per nessun massimo.",
+      "capPlaceholder": "Nessun massimo"
+    }
   },
   "layoutEditor": {
     "helpText": "Trascina per riordinare. Usa il selettore di larghezza per posizionare le sezioni affiancate. Le sezioni consecutive a mezza larghezza (S/D) verranno visualizzate in due colonne.",

+ 5 - 1
messages/lt/service.json

@@ -161,7 +161,11 @@
     "hourlyHint": "Valandinis: skaičiuojama pagal darbo valandas (valandos × įkainis/val.). Spustelėkite, kad pakeistumėte.",
     "serviceHint": "Paslauga: apmokestinama fiksuota suma už vienetą. Spustelėkite, kad pakeistumėte.",
     "deleteRow": "Ištrinti eilutę",
-    "descriptionMissingHint": "Eilutės be aprašymo neišsaugomos."
+    "descriptionMissingHint": "Eilutės be aprašymo neišsaugomos.",
+    "shopFeeTag": "Mokestis",
+    "shopFeeHintFlat": "Dirbtuvių mokestis iš nustatymų, fiksuota suma. Pakeiskite kainą šiam darbui arba ištrinkite eilutę, kad jo neliktų.",
+    "shopFeeHintPercentLabor": "Dirbtuvių mokestis iš nustatymų: {percent} % šio darbo darbų sumos. Ištrinkite eilutę, kad jo neliktų.",
+    "shopFeeHintPercentAll": "Dirbtuvių mokestis iš nustatymų: {percent} % šio darbo darbų ir dalių sumos. Ištrinkite eilutę, kad jo neliktų."
   },
   "liveLabor": {
     "title": "{count, plural, one {Mechanikas pridėjo # darbo eilutę prie šio užsakymo} few {Mechanikas pridėjo # darbo eilutes prie šio užsakymo} many {Mechanikas pridėjo # darbo eilutės prie šio užsakymo} other {Mechanikas pridėjo # darbo eilučių prie šio užsakymo}}",

+ 25 - 1
messages/lt/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "Nepavyko pritaikyti dizaino iš naujo",
     "sectionSending": "Siuntimas",
     "attachPdfLabel": "Prie el. laiškų pridėti PDF",
-    "attachPdfHint": "Numatytoji nuostata kiekvienai sąskaitai, pasiūlymui ir patikrai, siunčiamai el. paštu. Įjungta: klientas gauna PDF kaip priedą. Išjungta: jis gauna nuorodą į dokumentą, o peržiūrų skaitiklis parodo, kada jis buvo atidarytas, todėl galite priminti tiems, kurie dar nepažiūrėjo. Atskiram siuntimui tai galite pakeisti bendrinimo lange."
+    "attachPdfHint": "Numatytoji nuostata kiekvienai sąskaitai, pasiūlymui ir patikrai, siunčiamai el. paštu. Įjungta: klientas gauna PDF kaip priedą. Išjungta: jis gauna nuorodą į dokumentą, o peržiūrų skaitiklis parodo, kada jis buvo atidarytas, todėl galite priminti tiems, kurie dar nepažiūrėjo. Atskiram siuntimui tai galite pakeisti bendrinimo lange.",
+    "shopFee": {
+      "title": "Dirbtuvių mokestis",
+      "description": "Mokestis už dirbtuvių eksploatacines medžiagas, pavyzdžiui, šluostes, valiklius, tepalus ir atliekų tvarkymą, pridedamas prie kiekvieno naujo darbo užsakymo ir pasiūlymo atskira eilute.",
+      "enabled": "Taikyti dirbtuvių mokestį",
+      "enabledHint": "Nauji darbo užsakymai ir pasiūlymai gauna mokestį atskira eilute. Jį galima pašalinti iš atskiro darbo. Esami darbai nekeičiami.",
+      "defaultLabel": "Eksploatacinės medžiagos",
+      "label": "Pavadinimas sąskaitoje",
+      "appliesTo": "Taikoma",
+      "appliesToBoth": "Darbo užsakymams ir pasiūlymams",
+      "appliesToWorkOrders": "Tik darbo užsakymams",
+      "appliesToQuotes": "Tik pasiūlymams",
+      "mode": "Kaip skaičiuojamas",
+      "modeFlat": "Fiksuota suma",
+      "modePercent": "Procentas",
+      "amount": "Suma",
+      "amountHint": "Ta pati suma kiekvienam darbui. Ją galima pakeisti atskiram darbui.",
+      "percent": "Procentas (%)",
+      "base": "Procentas nuo",
+      "baseLabor": "Darbo",
+      "baseLaborParts": "Darbo ir dalių",
+      "cap": "Didžiausias mokestis",
+      "capHint": "Mokestis niekada neviršija šios sumos. Palikite tuščią, jei ribos nėra.",
+      "capPlaceholder": "Be ribos"
+    }
   },
   "layoutEditor": {
     "helpText": "Vilkite, kad pertvarkytumėte. Naudokite pločio perjungiklį, kad išdėstytumėte sekcijas greta. Iš eilės einančios pusės pločio sekcijos (K/D) bus vaizduojamos dviejuose stulpeliuose.",

+ 5 - 1
messages/nb/service.json

@@ -161,7 +161,11 @@
     "hourlyHint": "Timepris: beregnes etter arbeidede timer (timer × sats/t). Klikk for å bytte.",
     "serviceHint": "Fastpris: beregnes som en fast sum per enhet. Klikk for å bytte.",
     "deleteRow": "Slett rad",
-    "descriptionMissingHint": "Rader uten beskrivelse lagres ikke."
+    "descriptionMissingHint": "Rader uten beskrivelse lagres ikke.",
+    "shopFeeTag": "Gebyr",
+    "shopFeeHintFlat": "Verkstedgebyret fra Innstillinger, som et fast beløp. Endre prisen for denne jobben, eller slett linjen for å fjerne det.",
+    "shopFeeHintPercentLabor": "Verkstedgebyret fra Innstillinger: {percent} % av arbeidet på denne jobben. Slett linjen for å fjerne det.",
+    "shopFeeHintPercentAll": "Verkstedgebyret fra Innstillinger: {percent} % av arbeid og deler på denne jobben. Slett linjen for å fjerne det."
   },
   "liveLabor": {
     "title": "{count, plural, one {En mekaniker la til arbeid på denne jobben} other {En mekaniker la til # arbeidslinjer på denne jobben}}",

+ 25 - 1
messages/nb/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "Kunne ikke bruke designet på nytt",
     "sectionSending": "Utsending",
     "attachPdfLabel": "Legg ved PDF i e-post",
-    "attachPdfHint": "Standard for alle fakturaer, tilbud og kontroller du sender på e-post. På: kunden får PDF-en vedlagt. Av: kunden får en lenke til dokumentet i stedet, og visningstelleren viser når det ble åpnet, så du kan følge opp dem ingen har sett på. Du kan endre det for én enkelt utsending i delingsdialogen."
+    "attachPdfHint": "Standard for alle fakturaer, tilbud og kontroller du sender på e-post. På: kunden får PDF-en vedlagt. Av: kunden får en lenke til dokumentet i stedet, og visningstelleren viser når det ble åpnet, så du kan følge opp dem ingen har sett på. Du kan endre det for én enkelt utsending i delingsdialogen.",
+    "shopFee": {
+      "title": "Verkstedgebyr",
+      "description": "Et gebyr for forbruksmateriell som filler, rensemidler, smøremidler og avfallshåndtering, lagt til hver nye arbeidsordre og hvert nye tilbud som en egen linje.",
+      "enabled": "Ta verkstedgebyr",
+      "enabledHint": "Nye arbeidsordrer og tilbud får gebyret som en linje. Det kan fjernes fra en enkelt jobb. Eksisterende jobber endres ikke.",
+      "defaultLabel": "Forbruksmateriell",
+      "label": "Navn på fakturaen",
+      "appliesTo": "Gjelder for",
+      "appliesToBoth": "Arbeidsordrer og tilbud",
+      "appliesToWorkOrders": "Bare arbeidsordrer",
+      "appliesToQuotes": "Bare tilbud",
+      "mode": "Hvordan det beregnes",
+      "modeFlat": "Fast beløp",
+      "modePercent": "Prosent",
+      "amount": "Beløp",
+      "amountHint": "Samme beløp på hver jobb. Det kan endres på en enkelt jobb.",
+      "percent": "Prosent (%)",
+      "base": "Prosent av",
+      "baseLabor": "Arbeid",
+      "baseLaborParts": "Arbeid og deler",
+      "cap": "Høyeste gebyr",
+      "capHint": "Gebyret blir aldri høyere enn dette. La feltet stå tomt for ingen øvre grense.",
+      "capPlaceholder": "Ingen øvre grense"
+    }
   },
   "layoutEditor": {
     "helpText": "Dra for å sortere. Bruk bredde-veksler for å plassere seksjoner side om side. Påfølgende halvbredde-seksjoner (V/H) vises i to kolonner.",

+ 5 - 1
messages/nl/service.json

@@ -161,7 +161,11 @@
     "hourlyHint": "Per uur: berekend op gewerkte uren (uren × tarief/uur). Klik om te wisselen.",
     "serviceHint": "Service: berekend als vast bedrag per eenheid. Klik om te wisselen.",
     "deleteRow": "Rij verwijderen",
-    "descriptionMissingHint": "Regels zonder omschrijving worden niet opgeslagen."
+    "descriptionMissingHint": "Regels zonder omschrijving worden niet opgeslagen.",
+    "shopFeeTag": "Toeslag",
+    "shopFeeHintFlat": "De werkplaatstoeslag uit Instellingen, als vast bedrag. Pas de prijs voor deze klus aan of verwijder de regel om hem weg te laten.",
+    "shopFeeHintPercentLabor": "De werkplaatstoeslag uit Instellingen: {percent} % van de arbeid op deze klus. Verwijder de regel om hem weg te laten.",
+    "shopFeeHintPercentAll": "De werkplaatstoeslag uit Instellingen: {percent} % van de arbeid en onderdelen op deze klus. Verwijder de regel om hem weg te laten."
   },
   "liveLabor": {
     "title": "{count, plural, one {Een monteur heeft arbeid aan deze order toegevoegd} other {Een monteur heeft # arbeidsregels aan deze order toegevoegd}}",

+ 25 - 1
messages/nl/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "Kon het ontwerp niet opnieuw toepassen",
     "sectionSending": "Verzenden",
     "attachPdfLabel": "De pdf aan e-mails toevoegen",
-    "attachPdfHint": "De standaard voor elke factuur, offerte en inspectie die je per e-mail verstuurt. Aan: de klant krijgt de pdf als bijlage. Uit: hij krijgt een link naar het document, en de weergaveteller laat zien wanneer het is geopend, zodat je kunt nabellen bij wie nog niet gekeken heeft. Voor één verzending kun je het aanpassen in het deelvenster."
+    "attachPdfHint": "De standaard voor elke factuur, offerte en inspectie die je per e-mail verstuurt. Aan: de klant krijgt de pdf als bijlage. Uit: hij krijgt een link naar het document, en de weergaveteller laat zien wanneer het is geopend, zodat je kunt nabellen bij wie nog niet gekeken heeft. Voor één verzending kun je het aanpassen in het deelvenster.",
+    "shopFee": {
+      "title": "Werkplaatstoeslag",
+      "description": "Een toeslag voor verbruiksmaterialen zoals poetsdoeken, reinigers, smeermiddelen en afvalverwerking, als eigen regel toegevoegd aan elke nieuwe werkorder en offerte.",
+      "enabled": "Werkplaatstoeslag rekenen",
+      "enabledHint": "Nieuwe werkorders en offertes krijgen de toeslag als regel. Hij kan per klus worden verwijderd. Bestaande klussen veranderen niet.",
+      "defaultLabel": "Klein materiaal",
+      "label": "Naam op de factuur",
+      "appliesTo": "Geldt voor",
+      "appliesToBoth": "Werkorders en offertes",
+      "appliesToWorkOrders": "Alleen werkorders",
+      "appliesToQuotes": "Alleen offertes",
+      "mode": "Berekening",
+      "modeFlat": "Vast bedrag",
+      "modePercent": "Percentage",
+      "amount": "Bedrag",
+      "amountHint": "Hetzelfde bedrag op elke klus. Het kan per klus worden aangepast.",
+      "percent": "Percentage (%)",
+      "base": "Percentage van",
+      "baseLabor": "Arbeid",
+      "baseLaborParts": "Arbeid en onderdelen",
+      "cap": "Maximale toeslag",
+      "capHint": "De toeslag wordt nooit hoger dan dit bedrag. Laat leeg voor geen maximum.",
+      "capPlaceholder": "Geen maximum"
+    }
   },
   "layoutEditor": {
     "helpText": "Sleep om te herordenen. Gebruik de breedte-schakelaar om secties naast elkaar te plaatsen. Opeenvolgende halve breedte secties (L/R) worden in twee kolommen weergegeven.",

+ 5 - 1
messages/pl/service.json

@@ -161,7 +161,11 @@
     "hourlyHint": "Godzinowo: liczone według przepracowanych godzin (godziny × stawka/godz.). Kliknij, aby zmienić.",
     "serviceHint": "Usługa: rozliczana jako stała opłata za jednostkę. Kliknij, aby zmienić.",
     "deleteRow": "Usuń wiersz",
-    "descriptionMissingHint": "Wiersze bez opisu nie są zapisywane."
+    "descriptionMissingHint": "Wiersze bez opisu nie są zapisywane.",
+    "shopFeeTag": "Opłata",
+    "shopFeeHintFlat": "Opłata warsztatowa z Ustawień jako stała kwota. Zmień cenę dla tego zlecenia lub usuń pozycję, aby ją pominąć.",
+    "shopFeeHintPercentLabor": "Opłata warsztatowa z Ustawień: {percent} % robocizny w tym zleceniu. Usuń pozycję, aby ją pominąć.",
+    "shopFeeHintPercentAll": "Opłata warsztatowa z Ustawień: {percent} % robocizny i części w tym zleceniu. Usuń pozycję, aby ją pominąć."
   },
   "liveLabor": {
     "title": "{count, plural, one {Mechanik dodał pozycję robocizny do tego zlecenia} few {Mechanik dodał # pozycje robocizny do tego zlecenia} many {Mechanik dodał # pozycji robocizny do tego zlecenia} other {Mechanik dodał # pozycji robocizny do tego zlecenia}}",

+ 25 - 1
messages/pl/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "Nie udało się ponownie nałożyć projektu",
     "sectionSending": "Wysyłka",
     "attachPdfLabel": "Dołączaj PDF do wiadomości e-mail",
-    "attachPdfHint": "Domyślne ustawienie dla każdej faktury, oferty i przeglądu wysyłanych e-mailem. Włączone: klient dostaje PDF w załączniku. Wyłączone: dostaje link do dokumentu, a licznik wyświetleń pokazuje, kiedy go otworzył, więc możesz przypomnieć się tym, którzy jeszcze nie zajrzeli. Dla pojedynczej wysyłki zmienisz to w oknie udostępniania."
+    "attachPdfHint": "Domyślne ustawienie dla każdej faktury, oferty i przeglądu wysyłanych e-mailem. Włączone: klient dostaje PDF w załączniku. Wyłączone: dostaje link do dokumentu, a licznik wyświetleń pokazuje, kiedy go otworzył, więc możesz przypomnieć się tym, którzy jeszcze nie zajrzeli. Dla pojedynczej wysyłki zmienisz to w oknie udostępniania.",
+    "shopFee": {
+      "title": "Opłata warsztatowa",
+      "description": "Opłata za materiały eksploatacyjne warsztatu, takie jak czyściwo, środki czyszczące, smary i utylizacja, dodawana do każdego nowego zlecenia i oferty jako osobna pozycja.",
+      "enabled": "Naliczaj opłatę warsztatową",
+      "enabledHint": "Nowe zlecenia i oferty otrzymują opłatę jako pozycję. Można ją usunąć z pojedynczego zlecenia. Istniejące zlecenia się nie zmieniają.",
+      "defaultLabel": "Materiały warsztatowe",
+      "label": "Nazwa na fakturze",
+      "appliesTo": "Dotyczy",
+      "appliesToBoth": "Zleceń i ofert",
+      "appliesToWorkOrders": "Tylko zleceń",
+      "appliesToQuotes": "Tylko ofert",
+      "mode": "Sposób naliczania",
+      "modeFlat": "Stała kwota",
+      "modePercent": "Procent",
+      "amount": "Kwota",
+      "amountHint": "Ta sama kwota przy każdym zleceniu. Można ją zmienić w pojedynczym zleceniu.",
+      "percent": "Procent (%)",
+      "base": "Procent od",
+      "baseLabor": "Robocizny",
+      "baseLaborParts": "Robocizny i części",
+      "cap": "Maksymalna opłata",
+      "capHint": "Opłata nigdy nie przekroczy tej kwoty. Zostaw puste, aby nie ustalać maksimum.",
+      "capPlaceholder": "Bez maksimum"
+    }
   },
   "layoutEditor": {
     "helpText": "Przeciągnij, aby zmienić kolejność. Użyj przełącznika szerokości, aby umieścić sekcje obok siebie. Kolejne sekcje o połowie szerokości (L/P) będą wyświetlane w dwóch kolumnach.",

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

@@ -161,7 +161,11 @@
     "hourlyHint": "Por hora: calculado pelas horas trabalhadas (horas × taxa/h). Clique para alterar.",
     "serviceHint": "Serviço: cobrado como valor fixo por unidade. Clique para alterar.",
     "deleteRow": "Excluir linha",
-    "descriptionMissingHint": "Linhas sem descrição não são salvas."
+    "descriptionMissingHint": "Linhas sem descrição não são salvas.",
+    "shopFeeTag": "Taxa",
+    "shopFeeHintFlat": "A taxa de oficina das Configurações, como valor fixo. Altere o preço para este serviço ou exclua a linha para removê-la.",
+    "shopFeeHintPercentLabor": "A taxa de oficina das Configurações: {percent} % da mão de obra deste serviço. Exclua a linha para removê-la.",
+    "shopFeeHintPercentAll": "A taxa de oficina das Configurações: {percent} % da mão de obra e das peças deste serviço. Exclua a linha para removê-la."
   },
   "liveLabor": {
     "title": "{count, plural, one {Um técnico adicionou mão de obra a esta ordem} other {Um técnico adicionou # linhas de mão de obra a esta ordem}}",

+ 25 - 1
messages/pt-BR/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "Não foi possível reaplicar o design",
     "sectionSending": "Envio",
     "attachPdfLabel": "Anexar o PDF aos e-mails",
-    "attachPdfHint": "O padrão para cada fatura, orçamento e inspeção que você envia por e-mail. Ligado: o cliente recebe o PDF em anexo. Desligado: ele recebe um link para o documento, e o contador de visualizações mostra quando foi aberto, para você cobrar quem ainda não olhou. Você pode mudar isso em um envio específico na janela de compartilhamento."
+    "attachPdfHint": "O padrão para cada fatura, orçamento e inspeção que você envia por e-mail. Ligado: o cliente recebe o PDF em anexo. Desligado: ele recebe um link para o documento, e o contador de visualizações mostra quando foi aberto, para você cobrar quem ainda não olhou. Você pode mudar isso em um envio específico na janela de compartilhamento.",
+    "shopFee": {
+      "title": "Taxa de oficina",
+      "description": "Uma taxa para materiais de consumo da oficina, como panos, produtos de limpeza, lubrificantes e descarte, adicionada a cada nova ordem de serviço e orçamento como uma linha própria.",
+      "enabled": "Cobrar taxa de oficina",
+      "enabledHint": "Novas ordens de serviço e orçamentos recebem a taxa como uma linha. Ela pode ser removida de um serviço específico. Serviços existentes não mudam.",
+      "defaultLabel": "Materiais de consumo",
+      "label": "Nome na fatura",
+      "appliesTo": "Aplica-se a",
+      "appliesToBoth": "Ordens de serviço e orçamentos",
+      "appliesToWorkOrders": "Somente ordens de serviço",
+      "appliesToQuotes": "Somente orçamentos",
+      "mode": "Como é cobrada",
+      "modeFlat": "Valor fixo",
+      "modePercent": "Porcentagem",
+      "amount": "Valor",
+      "amountHint": "O mesmo valor em todo serviço. Pode ser alterado em um serviço específico.",
+      "percent": "Porcentagem (%)",
+      "base": "Porcentagem de",
+      "baseLabor": "Mão de obra",
+      "baseLaborParts": "Mão de obra e peças",
+      "cap": "Taxa máxima",
+      "capHint": "A taxa nunca passa deste valor. Deixe em branco para não ter máximo.",
+      "capPlaceholder": "Sem máximo"
+    }
   },
   "layoutEditor": {
     "helpText": "Arraste para reordenar. Use o alternador de largura para posicionar seções lado a lado. Seções consecutivas de meia largura (E/D) serão exibidas em duas colunas.",

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

@@ -161,7 +161,11 @@
     "hourlyHint": "À hora: calculado pelas horas trabalhadas (horas × taxa/h). Clique para alterar.",
     "serviceHint": "Serviço: cobrado como valor fixo por unidade. Clique para alterar.",
     "deleteRow": "Eliminar linha",
-    "descriptionMissingHint": "As linhas sem descrição não são guardadas."
+    "descriptionMissingHint": "As linhas sem descrição não são guardadas.",
+    "shopFeeTag": "Taxa",
+    "shopFeeHintFlat": "A taxa de oficina das Definições, como valor fixo. Altere o preço para este trabalho ou elimine a linha para a retirar.",
+    "shopFeeHintPercentLabor": "A taxa de oficina das Definições: {percent} % da mão de obra deste trabalho. Elimine a linha para a retirar.",
+    "shopFeeHintPercentAll": "A taxa de oficina das Definições: {percent} % da mão de obra e das peças deste trabalho. Elimine a linha para a retirar."
   },
   "liveLabor": {
     "title": "{count, plural, one {Um técnico adicionou mão de obra a esta folha de obra} other {Um técnico adicionou # linhas de mão de obra a esta folha de obra}}",

+ 25 - 1
messages/pt-PT/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "Não foi possível voltar a aplicar o design",
     "sectionSending": "Envio",
     "attachPdfLabel": "Anexar o PDF aos e-mails",
-    "attachPdfHint": "A predefinição para cada fatura, orçamento e inspeção que envia por e-mail. Ativada: o cliente recebe o PDF em anexo. Desativada: recebe um link para o documento, e o contador de visualizações indica quando o abriu, para poder insistir com quem ainda não o viu. Pode alterar isto num envio específico na janela de partilha."
+    "attachPdfHint": "A predefinição para cada fatura, orçamento e inspeção que envia por e-mail. Ativada: o cliente recebe o PDF em anexo. Desativada: recebe um link para o documento, e o contador de visualizações indica quando o abriu, para poder insistir com quem ainda não o viu. Pode alterar isto num envio específico na janela de partilha.",
+    "shopFee": {
+      "title": "Taxa de oficina",
+      "description": "Uma taxa para consumíveis da oficina, como panos, produtos de limpeza, lubrificantes e tratamento de resíduos, acrescentada a cada nova ordem de reparação e orçamento numa linha própria.",
+      "enabled": "Cobrar taxa de oficina",
+      "enabledHint": "As novas ordens de reparação e orçamentos recebem a taxa numa linha. Pode ser retirada de um trabalho. Os trabalhos existentes não são alterados.",
+      "defaultLabel": "Consumíveis de oficina",
+      "label": "Nome na fatura",
+      "appliesTo": "Aplica-se a",
+      "appliesToBoth": "Ordens de reparação e orçamentos",
+      "appliesToWorkOrders": "Apenas ordens de reparação",
+      "appliesToQuotes": "Apenas orçamentos",
+      "mode": "Forma de cálculo",
+      "modeFlat": "Valor fixo",
+      "modePercent": "Percentagem",
+      "amount": "Valor",
+      "amountHint": "O mesmo valor em cada trabalho. Pode ser alterado num trabalho.",
+      "percent": "Percentagem (%)",
+      "base": "Percentagem de",
+      "baseLabor": "Mão de obra",
+      "baseLaborParts": "Mão de obra e peças",
+      "cap": "Taxa máxima",
+      "capHint": "A taxa nunca ultrapassa este valor. Deixe em branco para não ter máximo.",
+      "capPlaceholder": "Sem máximo"
+    }
   },
   "layoutEditor": {
     "helpText": "Arraste para reordenar. Use o seletor de largura para colocar secções lado a lado. Secções consecutivas de meia largura (E/D) são apresentadas em duas colunas.",

+ 5 - 1
messages/ru/service.json

@@ -161,7 +161,11 @@
     "hourlyHint": "Почасовая: оценка по нормо-часам (часы × ставка/час). Нажмите для изменения.",
     "serviceHint": "Услуга: фиксированная цена за единицу. Нажмите для изменения.",
     "deleteRow": "Удалить строку",
-    "descriptionMissingHint": "Строки без описания не сохраняются."
+    "descriptionMissingHint": "Строки без описания не сохраняются.",
+    "shopFeeTag": "Сбор",
+    "shopFeeHintFlat": "Сбор мастерской из настроек, фиксированной суммой. Измените цену для этой работы или удалите строку, чтобы убрать его.",
+    "shopFeeHintPercentLabor": "Сбор мастерской из настроек: {percent} % от работ по этому заказу. Удалите строку, чтобы убрать его.",
+    "shopFeeHintPercentAll": "Сбор мастерской из настроек: {percent} % от работ и запчастей по этому заказу. Удалите строку, чтобы убрать его."
   },
   "liveLabor": {
     "title": "{count, plural, one {Механик добавил # строку работ в этот заказ} few {Механик добавил # строки работ в этот заказ} many {Механик добавил # строк работ в этот заказ} other {Механик добавил # строк работ в этот заказ}}",

+ 25 - 1
messages/ru/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "Не удалось применить дизайн заново",
     "sectionSending": "Отправка",
     "attachPdfLabel": "Прикреплять PDF к письмам",
-    "attachPdfHint": "Значение по умолчанию для каждого счёта, коммерческого предложения и осмотра, отправляемых по почте. Включено: клиент получает PDF во вложении. Выключено: он получает ссылку на документ, а счётчик просмотров показывает, когда её открыли, чтобы вы могли напомнить тем, кто ещё не посмотрел. Для отдельной отправки это можно изменить в окне общего доступа."
+    "attachPdfHint": "Значение по умолчанию для каждого счёта, коммерческого предложения и осмотра, отправляемых по почте. Включено: клиент получает PDF во вложении. Выключено: он получает ссылку на документ, а счётчик просмотров показывает, когда её открыли, чтобы вы могли напомнить тем, кто ещё не посмотрел. Для отдельной отправки это можно изменить в окне общего доступа.",
+    "shopFee": {
+      "title": "Сбор мастерской",
+      "description": "Сбор за расходные материалы мастерской, например ветошь, очистители, смазки и утилизацию, добавляемый отдельной строкой в каждый новый заказ-наряд и смету.",
+      "enabled": "Взимать сбор мастерской",
+      "enabledHint": "Новые заказ-наряды и сметы получают сбор отдельной строкой. Его можно удалить из отдельной работы. Существующие работы не меняются.",
+      "defaultLabel": "Расходные материалы",
+      "label": "Название в счёте",
+      "appliesTo": "Применяется к",
+      "appliesToBoth": "Заказ-нарядам и сметам",
+      "appliesToWorkOrders": "Только заказ-нарядам",
+      "appliesToQuotes": "Только сметам",
+      "mode": "Способ расчёта",
+      "modeFlat": "Фиксированная сумма",
+      "modePercent": "Процент",
+      "amount": "Сумма",
+      "amountHint": "Одна и та же сумма в каждой работе. Её можно изменить в отдельной работе.",
+      "percent": "Процент (%)",
+      "base": "Процент от",
+      "baseLabor": "Работы",
+      "baseLaborParts": "Работы и запчастей",
+      "cap": "Максимальный сбор",
+      "capHint": "Сбор никогда не превышает эту сумму. Оставьте пустым, чтобы не ограничивать.",
+      "capPlaceholder": "Без ограничения"
+    }
   },
   "layoutEditor": {
     "helpText": "Перетаскивайте для изменения порядка. Используйте переключатель ширины для размещения разделов бок о бок. Последовательные разделы половинной ширины (Л/П) будут отображаться в два столбца.",

+ 5 - 1
messages/tr/service.json

@@ -161,7 +161,11 @@
     "hourlyHint": "Saatlik: çalışılan saate göre hesaplanır (saat × ücret/saat). Değiştirmek için tıklayın.",
     "serviceHint": "Hizmet: birim başına sabit ücret olarak hesaplanır. Değiştirmek için tıklayın.",
     "deleteRow": "Satırı sil",
-    "descriptionMissingHint": "Açıklaması olmayan satırlar kaydedilmez."
+    "descriptionMissingHint": "Açıklaması olmayan satırlar kaydedilmez.",
+    "shopFeeTag": "Ücret",
+    "shopFeeHintFlat": "Ayarlar'daki atölye ücreti, sabit tutar olarak. Bu iş için fiyatı değiştirin veya kaldırmak için satırı silin.",
+    "shopFeeHintPercentLabor": "Ayarlar'daki atölye ücreti: bu işteki işçiliğin % {percent} kadarı. Kaldırmak için satırı silin.",
+    "shopFeeHintPercentAll": "Ayarlar'daki atölye ücreti: bu işteki işçilik ve parçaların % {percent} kadarı. Kaldırmak için satırı silin."
   },
   "liveLabor": {
     "title": "{count, plural, one {Bir teknisyen bu işe işçilik ekledi} other {Bir teknisyen bu işe # işçilik satırı ekledi}}",

+ 25 - 1
messages/tr/settings.json

@@ -620,7 +620,31 @@
     "reapplyFailed": "Tasarım yeniden uygulanamadı",
     "sectionSending": "Gönderim",
     "attachPdfLabel": "E-postalara PDF ekle",
-    "attachPdfHint": "E-postayla gönderdiğiniz her fatura, teklif ve kontrol için varsayılan. Açık: müşteri PDF'i ek olarak alır. Kapalı: bunun yerine belgeye bir bağlantı alır ve görüntülenme sayacı ne zaman açtığını gösterir, böylece kimsenin bakmadıklarını takip edebilirsiniz. Tek bir gönderim için bunu paylaşım penceresinde değiştirebilirsiniz."
+    "attachPdfHint": "E-postayla gönderdiğiniz her fatura, teklif ve kontrol için varsayılan. Açık: müşteri PDF'i ek olarak alır. Kapalı: bunun yerine belgeye bir bağlantı alır ve görüntülenme sayacı ne zaman açtığını gösterir, böylece kimsenin bakmadıklarını takip edebilirsiniz. Tek bir gönderim için bunu paylaşım penceresinde değiştirebilirsiniz.",
+    "shopFee": {
+      "title": "Atölye ücreti",
+      "description": "Bez, temizleyici, yağlayıcı ve atık bertarafı gibi atölye sarf malzemeleri için her yeni iş emrine ve teklife ayrı bir satır olarak eklenen ücret.",
+      "enabled": "Atölye ücreti al",
+      "enabledHint": "Yeni iş emirleri ve teklifler ücreti bir satır olarak alır. Tek bir işten kaldırılabilir. Mevcut işler değişmez.",
+      "defaultLabel": "Sarf malzemeleri",
+      "label": "Faturadaki adı",
+      "appliesTo": "Uygulanacağı yer",
+      "appliesToBoth": "İş emirleri ve teklifler",
+      "appliesToWorkOrders": "Yalnızca iş emirleri",
+      "appliesToQuotes": "Yalnızca teklifler",
+      "mode": "Hesaplama şekli",
+      "modeFlat": "Sabit tutar",
+      "modePercent": "Yüzde",
+      "amount": "Tutar",
+      "amountHint": "Her işte aynı tutar. Tek bir işte değiştirilebilir.",
+      "percent": "Yüzde (%)",
+      "base": "Yüzdesi alınacak",
+      "baseLabor": "İşçilik",
+      "baseLaborParts": "İşçilik ve parçalar",
+      "cap": "En yüksek ücret",
+      "capHint": "Ücret hiçbir zaman bu tutarı geçmez. Üst sınır olmaması için boş bırakın.",
+      "capPlaceholder": "Üst sınır yok"
+    }
   },
   "layoutEditor": {
     "helpText": "Sıralamak için sürükleyin. Bölümleri yan yana yerleştirmek için genişlik değiştiriciyi kullanın. Ardışık yarım genişlik bölümleri (S/S) iki sütun halinde görüntülenir.",

+ 2 - 0
src/__tests__/api/tech-labor-live.test.ts

@@ -33,6 +33,8 @@ vi.mock('@/lib/db', () => {
     serviceLabor: {
       create: vi.fn(async () => state.labor),
       aggregate: vi.fn(async () => ({ _sum: { total: 1350 } })),
+      // A percentage shop fee is re-priced before the job is re-totalled.
+      findMany: vi.fn(async () => []),
     },
     servicePart: { aggregate: vi.fn(async () => ({ _sum: { total: 0 } })) },
     serviceRecord: { update: vi.fn(async () => ({})) },

+ 98 - 0
src/__tests__/features/quotes/create-quote-shop-fee.test.ts

@@ -0,0 +1,98 @@
+/**
+ * The shop fee on a new quote. A quote almost always starts empty, so a
+ * percentage fee starts at nothing; the line is still written, as on a work
+ * order, so the editor has something to re-price as the quote fills in.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { db } = vi.hoisted(() => {
+  const tx = {
+    quote: { create: vi.fn(async () => ({ id: 'quote-1' })) },
+    quotePart: { createMany: vi.fn() },
+    quoteLabor: { createMany: vi.fn() },
+  }
+  return {
+    db: {
+      appSetting: { findMany: vi.fn() },
+      inspection: { findFirst: vi.fn() },
+      customer: { findFirst: vi.fn() },
+      quote: { findFirst: vi.fn(async () => null) },
+      $transaction: vi.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
+      tx,
+    },
+  }
+})
+vi.mock('@/lib/db', () => ({ db }))
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: async () => 'UTC' }))
+
+import { createQuoteRecord } from '@/features/quotes/Lib/createQuoteRecord'
+import { createQuoteSchema } from '@/features/quotes/Schema/quoteSchema'
+
+const CTX = { organizationId: 'org', userId: 'user' }
+const EMPTY = createQuoteSchema.parse({ title: 'Brakes' })
+
+const setting = (key: string, value: string) => ({ key, value })
+const PERCENT = [
+  setting('invoice.shopFeeEnabled', 'true'),
+  setting('invoice.shopFeeLabel', 'Forbruksmateriell'),
+  setting('invoice.shopFeeMode', 'percent'),
+  setting('invoice.shopFeePercent', '10'),
+  setting('invoice.shopFeeBase', 'laborParts'),
+]
+const FLAT = [
+  setting('invoice.shopFeeEnabled', 'true'),
+  setting('invoice.shopFeeMode', 'flat'),
+  setting('invoice.shopFeeAmount', '150'),
+]
+
+const laborWritten = () =>
+  (
+    db.tx.quoteLabor.createMany.mock.calls[0]?.[0] as
+      | { data: Record<string, unknown>[] }
+      | undefined
+  )?.data
+const quoteWritten = () =>
+  (db.tx.quote.create.mock.calls as unknown as [{ data: Record<string, unknown> }][])[0][0].data
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  db.quote.findFirst.mockResolvedValue(null)
+})
+
+describe('the shop fee on a new quote', () => {
+  it('writes a percentage fee line at nothing on an empty quote, to be re-priced', async () => {
+    db.appSetting.findMany.mockResolvedValue(PERCENT)
+    await createQuoteRecord(CTX, { ...EMPTY })
+
+    expect(laborWritten()).toMatchObject([
+      { description: 'Forbruksmateriell', pricingType: 'shopFee', hours: 1, rate: 0, total: 0 },
+    ])
+    expect(quoteWritten().subtotal).toBe(0)
+    expect(quoteWritten().totalAmount).toBe(0)
+  })
+
+  it('writes a flat fee priced and totalled', async () => {
+    db.appSetting.findMany.mockResolvedValue(FLAT)
+    await createQuoteRecord(CTX, { ...EMPTY })
+
+    expect(laborWritten()).toMatchObject([{ pricingType: 'shopFee', rate: 150, total: 150 }])
+    expect(quoteWritten().subtotal).toBe(150)
+    expect(quoteWritten().totalAmount).toBe(150)
+  })
+
+  it('writes no fee line when the fee is for work orders only', async () => {
+    db.appSetting.findMany.mockResolvedValue([
+      ...FLAT,
+      setting('invoice.shopFeeAppliesTo', 'workOrders'),
+    ])
+    await createQuoteRecord(CTX, { ...EMPTY })
+    expect(laborWritten()).toBeUndefined()
+    expect(quoteWritten().subtotal).toBe(0)
+  })
+
+  it('writes no fee line when the workshop charges none', async () => {
+    db.appSetting.findMany.mockResolvedValue([])
+    await createQuoteRecord(CTX, { ...EMPTY })
+    expect(laborWritten()).toBeUndefined()
+  })
+})

+ 153 - 0
src/__tests__/features/settings/shop-fee.test.ts

@@ -0,0 +1,153 @@
+import { describe, it, expect } from 'vitest'
+import {
+  documentLaborLines,
+  newShopFeeLine,
+  readShopFee,
+  recalculateShopFeeLines,
+  shopFeeAmount,
+  shopFeeFor,
+  shopFeeLinesLast,
+  type ShopFeeConfig,
+} from '@/features/settings/Lib/shopFee'
+import { addedLaborLines } from '@/features/vehicles/Lib/laborLines'
+import type { ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
+
+const percent = (over: Partial<ShopFeeConfig> = {}): ShopFeeConfig => ({
+  label: 'Shop supplies',
+  mode: 'percent',
+  amount: 0,
+  percent: 10,
+  base: 'labor',
+  cap: null,
+  appliesTo: 'both',
+  ...over,
+})
+
+describe('reading the shop fee settings', () => {
+  it('is off unless switched on', () => {
+    expect(readShopFee({ 'invoice.shopFeeAmount': '10' })).toBeNull()
+  })
+
+  it('treats a fee of nothing as no fee', () => {
+    expect(
+      readShopFee({ 'invoice.shopFeeEnabled': 'true', 'invoice.shopFeeMode': 'flat' })
+    ).toBeNull()
+  })
+
+  it('reads a percentage fee with its base and cap', () => {
+    expect(
+      readShopFee({
+        'invoice.shopFeeEnabled': 'true',
+        'invoice.shopFeeMode': 'percent',
+        'invoice.shopFeePercent': '8',
+        'invoice.shopFeeBase': 'laborParts',
+        'invoice.shopFeeCap': '40',
+        'invoice.shopFeeLabel': 'Supplies',
+      })
+    ).toEqual({
+      label: 'Supplies',
+      mode: 'percent',
+      amount: 0,
+      percent: 8,
+      base: 'laborParts',
+      cap: 40,
+      appliesTo: 'both',
+    })
+  })
+
+  it('is written onto both kinds of document unless told otherwise', () => {
+    expect(shopFeeFor(percent(), 'quote')).not.toBeNull()
+    expect(shopFeeFor(percent({ appliesTo: 'workOrders' }), 'quote')).toBeNull()
+    expect(shopFeeFor(percent({ appliesTo: 'workOrders' }), 'workOrder')).not.toBeNull()
+    expect(shopFeeFor(percent({ appliesTo: 'quotes' }), 'workOrder')).toBeNull()
+    expect(shopFeeFor(null, 'quote')).toBeNull()
+  })
+})
+
+describe('the fee on a job', () => {
+  it('is a percentage of labour, or of labour and parts', () => {
+    expect(shopFeeAmount(percent(), { labor: 300, parts: 200 })).toBe(30)
+    expect(shopFeeAmount(percent({ base: 'laborParts' }), { labor: 300, parts: 200 })).toBe(50)
+  })
+
+  it('never comes to more than the cap', () => {
+    expect(shopFeeAmount(percent({ cap: 25 }), { labor: 1000, parts: 0 })).toBe(25)
+  })
+
+  it('is the flat amount whatever the job holds', () => {
+    const flat = percent({ mode: 'flat', amount: 15 })
+    expect(shopFeeAmount(flat, { labor: 1000, parts: 500 })).toBe(15)
+    expect(newShopFeeLine(flat)).toEqual({
+      description: 'Shop supplies',
+      hours: 1,
+      rate: 15,
+      total: 15,
+      pricingType: 'shopFee',
+    })
+  })
+})
+
+describe('keeping a percentage fee in step with the job', () => {
+  const lines = [
+    { description: 'Brakes', pricingType: 'hourly', hours: 2, rate: 100, total: 200 },
+    { description: 'Shop supplies', pricingType: 'shopFee', hours: 1, rate: 0, total: 0 },
+  ]
+
+  it('re-prices the fee from the other lines, never from itself', () => {
+    const next = recalculateShopFeeLines(lines, 0, percent())
+    expect(next[1].total).toBe(20)
+    expect(recalculateShopFeeLines(next, 0, percent())).toBe(next)
+  })
+
+  it('leaves a flat fee alone, so a price typed on the job stays', () => {
+    const typed = [lines[0], { ...lines[1], rate: 7, total: 7 }]
+    expect(recalculateShopFeeLines(typed, 0, percent({ mode: 'flat', amount: 15 }))).toBe(typed)
+  })
+
+  it('does not charge on quote lines the customer left out', () => {
+    const quote = [{ ...lines[0], excluded: true }, lines[1]]
+    expect(recalculateShopFeeLines(quote, 0, percent())[1].total).toBe(0)
+  })
+})
+
+describe('the fee on the job and on the document', () => {
+  const fee = (total: number): ServiceLaborInput => ({
+    description: 'Shop supplies',
+    pricingType: 'shopFee',
+    hours: 1,
+    rate: total,
+    total,
+  })
+  const brakes: ServiceLaborInput = {
+    description: 'Brakes',
+    pricingType: 'hourly',
+    hours: 2,
+    rate: 100,
+    total: 200,
+  }
+  const diag: ServiceLaborInput = {
+    description: 'Diagnosis',
+    pricingType: 'hourly',
+    hours: 1,
+    rate: 100,
+    total: 100,
+  }
+
+  it('is never a line a technician added, even re-priced', () => {
+    // The desk is mid-edit, the phone adds a line, the server re-prices the
+    // percentage fee. Only the technician's line is new.
+    expect(addedLaborLines([fee(20), brakes], [fee(30), brakes, diag])).toEqual([diag])
+  })
+
+  it('goes last on the job, wherever it was', () => {
+    const lines = [fee(20), brakes, diag]
+    expect(shopFeeLinesLast(lines)).toEqual([brakes, diag, fee(20)])
+    const ordered = [brakes, fee(20)]
+    expect(shopFeeLinesLast(ordered)).toBe(ordered)
+  })
+
+  it('prints last, and not at all when it came to nothing', () => {
+    expect(documentLaborLines([fee(0), brakes])).toEqual([brakes])
+    expect(documentLaborLines([fee(20), brakes])).toEqual([brakes, fee(20)])
+  })
+})

+ 2 - 1
src/__tests__/features/workorders/add-part.test.ts

@@ -23,7 +23,8 @@ vi.mock('@/lib/db', () => {
     appSetting: { findMany: vi.fn() },
     serviceRecord: { findFirst: vi.fn(), update: vi.fn() },
     servicePart: { create: vi.fn(), aggregate: vi.fn() },
-    serviceLabor: { aggregate: vi.fn() },
+    // findMany: a percentage shop fee is re-priced before the job is re-totalled.
+    serviceLabor: { aggregate: vi.fn(), findMany: vi.fn(async () => []) },
     inventoryPart: { update: vi.fn(), updateMany: vi.fn() },
   }
   // Run the transaction callback against the same mock db (tx === db).

+ 47 - 0
src/__tests__/features/workorders/create-workorder.test.ts

@@ -948,3 +948,50 @@ describe('createDraftServiceRecord — tax injection', () => {
     )
   })
 })
+
+describe('createDraftServiceRecord — shop fee', () => {
+  const FEE_SETTINGS = [
+    { key: 'invoice.shopFeeEnabled', value: 'true' },
+    { key: 'invoice.shopFeeLabel', value: 'Shop supplies' },
+    { key: 'invoice.shopFeeMode', value: 'flat' },
+    { key: 'invoice.shopFeeAmount', value: '12.5' },
+    { key: 'workshop.defaultTaxRate', value: '25' },
+  ]
+
+  function setupDraft(settings: { key: string; value: string }[]) {
+    setupAuth()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(VEHICLE as any)
+    vi.mocked(db.appSetting.findMany).mockResolvedValue(settings as any)
+    vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Shop' } as any)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    vi.mocked(db.serviceRecord.create).mockResolvedValue({ id: 'sr-fee' } as any)
+  }
+
+  it('starts a new work order with the flat fee as its own line, totalled', async () => {
+    setupDraft(FEE_SETTINGS)
+    await createDraftServiceRecord(VEHICLE_ID, new Date(2026, 2, 8, 9, 0, 0))
+
+    const data = vi.mocked(db.serviceRecord.create).mock.calls[0][0].data as any
+    expect(data.laborItems.create).toEqual([
+      { description: 'Shop supplies', hours: 1, rate: 12.5, total: 12.5, pricingType: 'shopFee' },
+    ])
+    expect(data.subtotal).toBe(12.5)
+    expect(data.totalAmount).toBeCloseTo(15.625, 3)
+  })
+
+  it('adds nothing when the fee is for quotes only', async () => {
+    setupDraft([...FEE_SETTINGS, { key: 'invoice.shopFeeAppliesTo', value: 'quotes' }])
+    await createDraftServiceRecord(VEHICLE_ID, new Date(2026, 2, 8, 9, 0, 0))
+
+    const data = vi.mocked(db.serviceRecord.create).mock.calls[0][0].data as any
+    expect(data.laborItems).toBeUndefined()
+  })
+
+  it('adds nothing when the workshop charges no fee', async () => {
+    setupDraft([])
+    await createDraftServiceRecord(VEHICLE_ID, new Date(2026, 2, 8, 9, 0, 0))
+
+    const data = vi.mocked(db.serviceRecord.create).mock.calls[0][0].data as any
+    expect(data.laborItems).toBeUndefined()
+  })
+})

+ 3 - 0
src/app/(authenticated)/quotes/[id]/page.tsx

@@ -1,6 +1,7 @@
 import { getQuote } from '@/features/quotes/Actions/quoteActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { readShopFee, SHOP_FEE_SETTING_KEYS } from '@/features/settings/Lib/shopFee'
 import {
   readWarrantyDefaults,
   WARRANTY_SETTING_KEYS,
@@ -25,6 +26,7 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
       SETTING_KEYS.DEFAULT_LABOR_RATE,
       SETTING_KEYS.UNIT_SYSTEM,
       ...WARRANTY_SETTING_KEYS,
+      ...SHOP_FEE_SETTING_KEYS,
     ]),
     getLaborPresetsList(),
     getInventoryPartsList(),
@@ -81,6 +83,7 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
         defaultTaxRate={defaultTaxRate}
         taxEnabled={taxEnabled}
         defaultLaborRate={defaultLaborRate}
+        shopFee={readShopFee(settings)}
         warrantyTexts={warrantyTextsOf(readWarrantyDefaults(settings))}
         distanceUnit={settings[SETTING_KEYS.UNIT_SYSTEM] === 'metric' ? 'km' : 'mi'}
         laborPresets={laborPresets}

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

@@ -30,6 +30,7 @@ import {
   Loader2,
   Lock,
   Palette,
+  Receipt,
   Save,
 } from 'lucide-react'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
@@ -171,6 +172,27 @@ export function InvoiceSettings({
     settings[SETTING_KEYS.QUOTE_LOCK_TRIGGER] || 'accepted'
   )
   const [attachPdf, setAttachPdf] = useState(settings[SETTING_KEYS.EMAIL_ATTACH_PDF] !== 'false')
+  const [shopFeeEnabled, setShopFeeEnabled] = useState(
+    settings[SETTING_KEYS.SHOP_FEE_ENABLED] === 'true'
+  )
+  const [shopFeeLabel, setShopFeeLabel] = useState(
+    settings[SETTING_KEYS.SHOP_FEE_LABEL] || t('invoice.shopFee.defaultLabel')
+  )
+  const [shopFeeMode, setShopFeeMode] = useState(
+    settings[SETTING_KEYS.SHOP_FEE_MODE] === 'percent' ? 'percent' : 'flat'
+  )
+  const [shopFeeAmount, setShopFeeAmount] = useState(settings[SETTING_KEYS.SHOP_FEE_AMOUNT] || '')
+  const [shopFeePercent, setShopFeePercent] = useState(
+    settings[SETTING_KEYS.SHOP_FEE_PERCENT] || ''
+  )
+  const [shopFeeBase, setShopFeeBase] = useState(
+    settings[SETTING_KEYS.SHOP_FEE_BASE] === 'laborParts' ? 'laborParts' : 'labor'
+  )
+  const [shopFeeCap, setShopFeeCap] = useState(settings[SETTING_KEYS.SHOP_FEE_CAP] || '')
+  const [shopFeeAppliesTo, setShopFeeAppliesTo] = useState(() => {
+    const v = settings[SETTING_KEYS.SHOP_FEE_APPLIES_TO]
+    return v === 'workOrders' || v === 'quotes' ? v : 'both'
+  })
   // The three paragraphs on what a lock freezes are worth reading once, not
   // every time somebody comes to change a due date. Folded away by default.
   const [lockDetailsOpen, setLockDetailsOpen] = useState(false)
@@ -189,6 +211,14 @@ export function InvoiceSettings({
       [SETTING_KEYS.QUOTE_LOCK_ENABLED]: quoteLockEnabled ? 'true' : 'false',
       [SETTING_KEYS.QUOTE_LOCK_TRIGGER]: quoteLockTrigger,
       [SETTING_KEYS.EMAIL_ATTACH_PDF]: attachPdf ? 'true' : 'false',
+      [SETTING_KEYS.SHOP_FEE_ENABLED]: shopFeeEnabled ? 'true' : 'false',
+      [SETTING_KEYS.SHOP_FEE_LABEL]: shopFeeLabel.trim(),
+      [SETTING_KEYS.SHOP_FEE_MODE]: shopFeeMode,
+      [SETTING_KEYS.SHOP_FEE_AMOUNT]: shopFeeAmount,
+      [SETTING_KEYS.SHOP_FEE_PERCENT]: shopFeePercent,
+      [SETTING_KEYS.SHOP_FEE_BASE]: shopFeeBase,
+      [SETTING_KEYS.SHOP_FEE_CAP]: shopFeeCap,
+      [SETTING_KEYS.SHOP_FEE_APPLIES_TO]: shopFeeAppliesTo,
     })
     setSaving(false)
     router.refresh()
@@ -431,6 +461,116 @@ export function InvoiceSettings({
               />
             </AppCard>
 
+            <AppCard
+              icon={Receipt}
+              title={t('invoice.shopFee.title')}
+              description={t('invoice.shopFee.description')}
+              contentClassName="space-y-5"
+            >
+              <SwitchRow
+                id="shopFeeEnabled"
+                label={t('invoice.shopFee.enabled')}
+                hint={t('invoice.shopFee.enabledHint')}
+                checked={shopFeeEnabled}
+                onCheckedChange={setShopFeeEnabled}
+              />
+              {shopFeeEnabled && (
+                <div className="grid gap-4 sm:grid-cols-2">
+                  <Field id="shopFeeLabel" label={t('invoice.shopFee.label')}>
+                    <Input
+                      id="shopFeeLabel"
+                      value={shopFeeLabel}
+                      onChange={(e) => setShopFeeLabel(e.target.value)}
+                    />
+                  </Field>
+                  <Field id="shopFeeAppliesTo" label={t('invoice.shopFee.appliesTo')}>
+                    <Select value={shopFeeAppliesTo} onValueChange={setShopFeeAppliesTo}>
+                      <SelectTrigger id="shopFeeAppliesTo">
+                        <SelectValue />
+                      </SelectTrigger>
+                      <SelectContent>
+                        <SelectItem value="both">{t('invoice.shopFee.appliesToBoth')}</SelectItem>
+                        <SelectItem value="workOrders">
+                          {t('invoice.shopFee.appliesToWorkOrders')}
+                        </SelectItem>
+                        <SelectItem value="quotes">
+                          {t('invoice.shopFee.appliesToQuotes')}
+                        </SelectItem>
+                      </SelectContent>
+                    </Select>
+                  </Field>
+                  <Field id="shopFeeMode" label={t('invoice.shopFee.mode')}>
+                    <Select value={shopFeeMode} onValueChange={setShopFeeMode}>
+                      <SelectTrigger id="shopFeeMode">
+                        <SelectValue />
+                      </SelectTrigger>
+                      <SelectContent>
+                        <SelectItem value="flat">{t('invoice.shopFee.modeFlat')}</SelectItem>
+                        <SelectItem value="percent">{t('invoice.shopFee.modePercent')}</SelectItem>
+                      </SelectContent>
+                    </Select>
+                  </Field>
+                  {shopFeeMode === 'flat' ? (
+                    <Field
+                      id="shopFeeAmount"
+                      label={t('invoice.shopFee.amount')}
+                      hint={t('invoice.shopFee.amountHint')}
+                    >
+                      <Input
+                        id="shopFeeAmount"
+                        type="number"
+                        min="0"
+                        step="0.01"
+                        value={shopFeeAmount}
+                        onChange={(e) => setShopFeeAmount(e.target.value)}
+                      />
+                    </Field>
+                  ) : (
+                    <>
+                      <Field id="shopFeePercent" label={t('invoice.shopFee.percent')}>
+                        <Input
+                          id="shopFeePercent"
+                          type="number"
+                          min="0"
+                          step="0.1"
+                          value={shopFeePercent}
+                          onChange={(e) => setShopFeePercent(e.target.value)}
+                        />
+                      </Field>
+                      <Field id="shopFeeBase" label={t('invoice.shopFee.base')}>
+                        <Select value={shopFeeBase} onValueChange={setShopFeeBase}>
+                          <SelectTrigger id="shopFeeBase">
+                            <SelectValue />
+                          </SelectTrigger>
+                          <SelectContent>
+                            <SelectItem value="labor">{t('invoice.shopFee.baseLabor')}</SelectItem>
+                            <SelectItem value="laborParts">
+                              {t('invoice.shopFee.baseLaborParts')}
+                            </SelectItem>
+                          </SelectContent>
+                        </Select>
+                      </Field>
+                      <Field
+                        id="shopFeeCap"
+                        label={t('invoice.shopFee.cap')}
+                        hint={t('invoice.shopFee.capHint')}
+                      >
+                        <Input
+                          id="shopFeeCap"
+                          type="number"
+                          min="0"
+                          step="0.01"
+                          placeholder={t('invoice.shopFee.capPlaceholder')}
+                          value={shopFeeCap}
+                          onChange={(e) => setShopFeeCap(e.target.value)}
+                        />
+                      </Field>
+                    </>
+                  )}
+                </div>
+              )}
+            </AppCard>
+
             <AppCard
               icon={Lock}
               title={t('invoice.lockTitle')}

+ 3 - 0
src/app/api/v1/tech/jobs/[id]/labor/route.ts

@@ -7,6 +7,7 @@ import { roundMoney } from '@/features/inventory/Lib/partPricing'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { assertInvoiceEditable } from '@/lib/document-lock.server'
 import { announceLaborAdded } from '@/features/vehicles/Lib/jobEvents'
+import { refreshShopFeeLines } from '@/features/vehicles/Lib/shopFeeLines'
 
 /**
  * Adds a line of work to the job.
@@ -81,6 +82,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
 
         // Same recalculation the parts path does, in the same transaction, so
         // the job's totals can never disagree with its lines.
+        await refreshShopFeeLines(tx, job.id, ctx.organizationId)
+
         const [partsAgg, laborAgg] = await Promise.all([
           tx.servicePart.aggregate({ where: { serviceRecordId: job.id }, _sum: { total: true } }),
           tx.serviceLabor.aggregate({ where: { serviceRecordId: job.id }, _sum: { total: true } }),

+ 7 - 3
src/features/invoice-designer/Pdf/buildInvoicePrint.ts

@@ -1,4 +1,5 @@
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
+import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { formatQuantity } from '@/lib/format-quantity'
 import { calculateTotals, netLineTotal } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
@@ -110,7 +111,9 @@ function resolveLayout(input: InvoicePrintInput): InvoiceLayoutConfig {
 }
 
 export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
-  const { data, workshop, invoiceSettings, paymentSummary, template } = input
+  const { workshop, invoiceSettings, paymentSummary, template } = input
+  // The fee under the work, and a fee of nothing left out.
+  const data = { ...input.data, laborItems: documentLaborLines(input.data.laborItems) }
   const labels = input.labels ?? {}
   const L = (key: string, fallback: string) => labels[key] || fallback
 
@@ -240,7 +243,7 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     ...data.laborItems.map((l, i) => ({
       n: String(i + 1),
       qty: String(l.hours),
-      unit: l.pricingType === 'service' ? L('unit', 'unit') : L('hrs', 'hrs'),
+      unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       desc: l.description,
       price: money(shown(l.rate)),
       total: money(shown(l.total)),
@@ -265,7 +268,8 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
   }))
 
   const labor: DocumentData['labor'] = data.laborItems.map((l) => {
-    const isService = l.pricingType === 'service'
+    // A shop fee prints as one unit at its price, like a service line.
+    const isService = l.pricingType === 'service' || isShopFeeLine(l)
     return {
       desc: l.description,
       qty: isService ? `${l.hours} ${L('unit', 'unit')}` : `${l.hours} ${L('hrs', 'hrs')}`,

+ 7 - 3
src/features/invoice-designer/Pdf/buildQuotePrint.ts

@@ -1,4 +1,5 @@
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
+import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { formatQuantity } from '@/lib/format-quantity'
 import { calculateTotals, netLineTotal } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
@@ -114,7 +115,9 @@ function fillTemplate(template: string, values: Record<string, string>): string
 }
 
 export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
-  const { data, workshop, template } = input
+  const { workshop, template } = input
+  // The fee under the work, and a fee of nothing left out.
+  const data = { ...input.data, laborItems: documentLaborLines(input.data.laborItems) }
   const labels = input.labels ?? {}
   const L = (key: string, fallback: string) => labels[key] || fallback
 
@@ -220,7 +223,7 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
     ...data.laborItems.map((l, i) => ({
       n: String(i + 1),
       qty: String(l.hours),
-      unit: l.pricingType === 'service' ? L('unit', 'unit') : L('hrs', 'hrs'),
+      unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       desc: l.description,
       price: money(shown(l.rate)),
       total: money(shown(l.total)),
@@ -248,7 +251,8 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
   }))
 
   const labor: DocumentData['labor'] = data.laborItems.map((l) => {
-    const isService = l.pricingType === 'service'
+    // A shop fee prints as one unit at its price, like a service line.
+    const isService = l.pricingType === 'service' || isShopFeeLine(l)
     return {
       desc: l.description,
       qty: isService ? `${l.hours} ${L('unit', 'unit')}` : `${l.hours} ${L('hrs', 'hrs')}`,

+ 24 - 1
src/features/quotes/Actions/quoteActions.ts

@@ -6,6 +6,14 @@ import { DocumentLockedError, quoteLockState } from '@/lib/document-lock'
 import { db } from '@/lib/db'
 import { parseTaxComponentDefinitions } from '@/lib/tax-components'
 import { documentTotals, taxComponentsForCopy } from '@/features/settings/Lib/workshopTax'
+import {
+  isShopFeeLine,
+  newShopFeeLine,
+  readShopFee,
+  SHOP_FEE_SETTING_KEYS,
+  shopFeeFor,
+} from '@/features/settings/Lib/shopFee'
+import { retotalServiceRecord } from '@/features/vehicles/Lib/retotalServiceRecord'
 import {
   readWarrantyDefaults,
   WARRANTY_SETTING_KEYS,
@@ -424,7 +432,9 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
         db.appSetting.findMany({
           where: {
             organizationId,
-            key: { in: ['workshop.invoicePrefix', ...WARRANTY_SETTING_KEYS] },
+            key: {
+              in: ['workshop.invoicePrefix', ...WARRANTY_SETTING_KEYS, ...SHOP_FEE_SETTING_KEYS],
+            },
           },
         }),
         db.organization.findUnique({
@@ -536,6 +546,19 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
           })
         }
 
+        // A fee the workshop charges on work orders but not on quotes goes on
+        // here, priced for what the customer accepted, and the job re-totalled.
+        // A quote that carried the fee already brought it across above.
+        const jobFee = shopFeeFor(readShopFee(settingsMap), 'workOrder')
+        if (jobFee && !includedLabor.some(isShopFeeLine)) {
+          const feeLine = newShopFeeLine(jobFee, {
+            labor: includedLabor.reduce((sum, l) => sum + l.total, 0),
+            parts: includedParts.reduce((sum, p) => sum + p.total, 0),
+          })
+          await tx.serviceLabor.create({ data: { ...feeLine, serviceRecordId: created.id } })
+          await retotalServiceRecord(created.id, tx)
+        }
+
         // Copy attachments from quote to service record
         if (quote.attachments.length > 0) {
           const quotesDir = path.join(uploadsRoot(), organizationId, 'quotes')

+ 24 - 14
src/features/quotes/Components/QuoteLaborEditor.tsx

@@ -8,6 +8,8 @@ import { Layers, Plus, Trash2, Wrench } from 'lucide-react'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
 import type { QuoteLaborInput } from './quote-page-types'
+import { isShopFeeLine } from '@/features/settings/Lib/shopFee'
+import { ShopFeeTag, useShopFeeLocked } from '@/features/settings/Components/ShopFeeTag'
 
 const QuoteLaborRow = memo(function QuoteLaborRow({
   labor,
@@ -42,6 +44,8 @@ const QuoteLaborRow = memo(function QuoteLaborRow({
   tExcludeFromTotal: string
 }) {
   const isService = labor.pricingType === 'service'
+  const isFee = isShopFeeLine(labor)
+  const feeLocked = useShopFeeLocked()
   const formatCurrency = useFormatCurrency()
   return (
     <div
@@ -55,25 +59,30 @@ const QuoteLaborRow = memo(function QuoteLaborRow({
           rows={1}
           className="min-h-9 flex-1 resize-none"
         />
-        <button
-          type="button"
-          className={`shrink-0 rounded-md border px-2 text-[10px] font-medium transition-all ${
-            isService
-              ? 'border-blue-500/30 bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 hover:border-blue-500/50'
-              : 'border-muted text-muted-foreground hover:bg-muted hover:text-foreground hover:border-foreground/20'
-          }`}
-          onClick={() => onUpdate(index, 'pricingType', isService ? 'hourly' : 'service')}
-          title={isService ? tServiceHint : tHourlyHint}
-        >
-          {isService ? tServiceTag : tHourlyTag}
-        </button>
+        {isFee ? (
+          <ShopFeeTag />
+        ) : (
+          <button
+            type="button"
+            className={`shrink-0 rounded-md border px-2 text-[10px] font-medium transition-all ${
+              isService
+                ? 'border-blue-500/30 bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 hover:border-blue-500/50'
+                : 'border-muted text-muted-foreground hover:bg-muted hover:text-foreground hover:border-foreground/20'
+            }`}
+            onClick={() => onUpdate(index, 'pricingType', isService ? 'hourly' : 'service')}
+            title={isService ? tServiceHint : tHourlyHint}
+          >
+            {isService ? tServiceTag : tHourlyTag}
+          </button>
+        )}
       </div>
       <Input
         type="number"
         min="0"
-        step={isService ? '1' : '0.1'}
-        placeholder={isService ? tQty : tHours}
+        step={isService || isFee ? '1' : '0.1'}
+        placeholder={isService || isFee ? tQty : tHours}
         value={labor.hours}
+        disabled={isFee}
         onChange={(e) => onUpdate(index, 'hours', e.target.value)}
       />
       <Input
@@ -81,6 +90,7 @@ const QuoteLaborRow = memo(function QuoteLaborRow({
         min="0"
         step="0.01"
         value={labor.rate}
+        disabled={isFee && feeLocked}
         onChange={(e) => onUpdate(index, 'rate', e.target.value)}
       />
       <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">

+ 288 - 280
src/features/quotes/Components/QuotePageClient.tsx

@@ -1,5 +1,7 @@
 'use client'
 
+import type { ShopFeeConfig } from '@/features/settings/Lib/shopFee'
+import { ShopFeeProvider } from '@/features/settings/Components/ShopFeeContext'
 import { DocumentLockBanner } from '@/components/document-lock-banner'
 import { setQuoteEditUnlocked } from '@/features/settings/Actions/documentLockActions'
 import type { LockState } from '@/lib/document-lock'
@@ -75,6 +77,7 @@ export function QuotePageClient({
   defaultTaxRate = 0,
   taxEnabled = true,
   defaultLaborRate = 0,
+  shopFee = null,
   warrantyTexts,
   distanceUnit,
   laborPresets = [],
@@ -98,6 +101,8 @@ export function QuotePageClient({
   defaultTaxRate?: number
   taxEnabled?: boolean
   defaultLaborRate?: number
+  /** The workshop's shop fee, for re-pricing a percentage one as lines change. */
+  shopFee?: ShopFeeConfig | null
   /** The workshop's stock warranty texts, for the warranty panel to fill in. */
   warrantyTexts: WarrantyTexts
   /** 'km' or 'mi', as the workshop measures distance. */
@@ -125,6 +130,7 @@ export function QuotePageClient({
     taxEnabled,
     defaultLaborRate,
     locked: lockState.locked,
+    shopFee,
     t,
   })
 
@@ -255,318 +261,320 @@ export function QuotePageClient({
   )
 
   return (
-    <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
-      {/* Header */}
-      <div className="shrink-0 border-b bg-background px-4 py-2">
-        <div className="flex items-center justify-between">
-          <Link
-            href="/quotes"
-            className="flex min-w-0 items-center gap-3 text-foreground transition-colors hover:text-muted-foreground"
-          >
-            <ArrowLeft className="h-4 w-4 shrink-0" />
-            <div className="min-w-0">
-              <div className="flex items-center gap-2">
-                <Badge
-                  variant="outline"
-                  className={`shrink-0 text-xs ${statusColors[state.status] || ''}`}
-                >
-                  {quoteStatusLabel(state.status, tStatus)}
-                </Badge>
-                <h1 className="truncate text-lg font-semibold leading-tight">{quote.title}</h1>
-              </div>
-              <p className="truncate text-xs text-muted-foreground">
-                {quote.quoteNumber || t('page.quote')}
-                {quote.customer ? ` · ${quote.customer.name}` : ''}
-              </p>
-            </div>
-          </Link>
-          <div className="flex shrink-0 items-center gap-2">
-            {state.hasUnsavedChanges && (
-              <span className="text-xs font-medium text-amber-600 dark:text-amber-400">
-                {t('page.unsavedChanges')}
-              </span>
-            )}
-            {state.showSaved && !state.hasUnsavedChanges && (
-              <span className="text-xs font-medium text-green-600 dark:text-green-400">
-                {t('page.saved')}
-              </span>
-            )}
-            <Button
-              type="submit"
-              form="quote-form"
-              size="sm"
-              disabled={state.saving}
-              variant={state.hasUnsavedChanges ? 'default' : 'outline'}
-              className={state.hasUnsavedChanges ? 'animate-pulse' : ''}
+    <ShopFeeProvider value={shopFee}>
+      <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
+        {/* Header */}
+        <div className="shrink-0 border-b bg-background px-4 py-2">
+          <div className="flex items-center justify-between">
+            <Link
+              href="/quotes"
+              className="flex min-w-0 items-center gap-3 text-foreground transition-colors hover:text-muted-foreground"
             >
-              {state.saving ? (
-                <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
-              ) : (
-                <Save className="mr-1 h-3.5 w-3.5" />
+              <ArrowLeft className="h-4 w-4 shrink-0" />
+              <div className="min-w-0">
+                <div className="flex items-center gap-2">
+                  <Badge
+                    variant="outline"
+                    className={`shrink-0 text-xs ${statusColors[state.status] || ''}`}
+                  >
+                    {quoteStatusLabel(state.status, tStatus)}
+                  </Badge>
+                  <h1 className="truncate text-lg font-semibold leading-tight">{quote.title}</h1>
+                </div>
+                <p className="truncate text-xs text-muted-foreground">
+                  {quote.quoteNumber || t('page.quote')}
+                  {quote.customer ? ` · ${quote.customer.name}` : ''}
+                </p>
+              </div>
+            </Link>
+            <div className="flex shrink-0 items-center gap-2">
+              {state.hasUnsavedChanges && (
+                <span className="text-xs font-medium text-amber-600 dark:text-amber-400">
+                  {t('page.unsavedChanges')}
+                </span>
+              )}
+              {state.showSaved && !state.hasUnsavedChanges && (
+                <span className="text-xs font-medium text-green-600 dark:text-green-400">
+                  {t('page.saved')}
+                </span>
               )}
-              {t('page.save')}
-            </Button>
-            <ButtonGroup>
-              <Button
-                variant="outline"
-                size="sm"
-                disabled={state.saving}
-                onClick={async () => {
-                  if (state.saving) return
-                  if (state.hasUnsavedChanges) await state.saveNow()
-                  setShowPdfPreview(true)
-                }}
-              >
-                <Eye className="mr-1 h-3.5 w-3.5" />
-                {tPreview('preview')}
-              </Button>
-              <Button
-                variant="outline"
-                size="sm"
-                onClick={state.handleDownloadPDF}
-                disabled={state.downloading}
-              >
-                {state.downloading ? (
-                  <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
-                ) : (
-                  <Download className="mr-1 h-3.5 w-3.5" />
-                )}
-                {t('page.pdf')}
-              </Button>
-              <Button
-                variant="outline"
-                size="sm"
-                disabled={state.saving}
-                onClick={async () => {
-                  if (state.saving) return
-                  if (state.hasUnsavedChanges) await state.saveNow()
-                  state.setShowEmailDialog(true)
-                }}
-              >
-                {state.saving ? (
-                  <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
-                ) : (
-                  <Mail className="mr-1 h-3.5 w-3.5" />
-                )}
-                {t('page.email')}
-              </Button>
               <Button
-                variant="outline"
+                type="submit"
+                form="quote-form"
                 size="sm"
                 disabled={state.saving}
-                onClick={async () => {
-                  if (state.saving) return
-                  if (state.hasUnsavedChanges) await state.saveNow()
-                  state.setShowShareDialog(true)
-                }}
+                variant={state.hasUnsavedChanges ? 'default' : 'outline'}
+                className={state.hasUnsavedChanges ? 'animate-pulse' : ''}
               >
                 {state.saving ? (
                   <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
                 ) : (
-                  <Globe className="mr-1 h-3.5 w-3.5" />
+                  <Save className="mr-1 h-3.5 w-3.5" />
                 )}
-                {t('page.share')}
-              </Button>
-              <Button
-                variant="outline"
-                size="sm"
-                className="text-destructive hover:bg-destructive/10"
-                onClick={state.handleDelete}
-              >
-                <Trash2 className="mr-1 h-3.5 w-3.5" />
-                {t('page.delete')}
+                {t('page.save')}
               </Button>
-            </ButtonGroup>
+              <ButtonGroup>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  disabled={state.saving}
+                  onClick={async () => {
+                    if (state.saving) return
+                    if (state.hasUnsavedChanges) await state.saveNow()
+                    setShowPdfPreview(true)
+                  }}
+                >
+                  <Eye className="mr-1 h-3.5 w-3.5" />
+                  {tPreview('preview')}
+                </Button>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  onClick={state.handleDownloadPDF}
+                  disabled={state.downloading}
+                >
+                  {state.downloading ? (
+                    <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                  ) : (
+                    <Download className="mr-1 h-3.5 w-3.5" />
+                  )}
+                  {t('page.pdf')}
+                </Button>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  disabled={state.saving}
+                  onClick={async () => {
+                    if (state.saving) return
+                    if (state.hasUnsavedChanges) await state.saveNow()
+                    state.setShowEmailDialog(true)
+                  }}
+                >
+                  {state.saving ? (
+                    <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                  ) : (
+                    <Mail className="mr-1 h-3.5 w-3.5" />
+                  )}
+                  {t('page.email')}
+                </Button>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  disabled={state.saving}
+                  onClick={async () => {
+                    if (state.saving) return
+                    if (state.hasUnsavedChanges) await state.saveNow()
+                    state.setShowShareDialog(true)
+                  }}
+                >
+                  {state.saving ? (
+                    <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                  ) : (
+                    <Globe className="mr-1 h-3.5 w-3.5" />
+                  )}
+                  {t('page.share')}
+                </Button>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  className="text-destructive hover:bg-destructive/10"
+                  onClick={state.handleDelete}
+                >
+                  <Trash2 className="mr-1 h-3.5 w-3.5" />
+                  {t('page.delete')}
+                </Button>
+              </ButtonGroup>
+            </div>
           </div>
         </div>
-      </div>
 
-      {/* Tabs */}
-      <div className="shrink-0 border-b bg-background px-4">
-        <div className="flex gap-1">
-          {[
-            { key: 'details' as TabType, label: t('page.tabs.details'), icon: FileText },
-            { key: 'images' as TabType, label: t('page.tabs.images'), icon: Camera },
-            { key: 'documents' as TabType, label: t('page.tabs.documents'), icon: Paperclip },
-          ].map(({ key, label, icon: Icon }) => (
-            <button
-              key={key}
-              type="button"
-              onClick={() => state.setActiveTab(key)}
-              className={`flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors ${
-                state.activeTab === key
-                  ? 'border-primary text-primary'
-                  : 'border-transparent text-muted-foreground hover:text-foreground'
-              }`}
-            >
-              <Icon className="h-3.5 w-3.5" />
-              {label}
-            </button>
-          ))}
+        {/* Tabs */}
+        <div className="shrink-0 border-b bg-background px-4">
+          <div className="flex gap-1">
+            {[
+              { key: 'details' as TabType, label: t('page.tabs.details'), icon: FileText },
+              { key: 'images' as TabType, label: t('page.tabs.images'), icon: Camera },
+              { key: 'documents' as TabType, label: t('page.tabs.documents'), icon: Paperclip },
+            ].map(({ key, label, icon: Icon }) => (
+              <button
+                key={key}
+                type="button"
+                onClick={() => state.setActiveTab(key)}
+                className={`flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors ${
+                  state.activeTab === key
+                    ? 'border-primary text-primary'
+                    : 'border-transparent text-muted-foreground hover:text-foreground'
+                }`}
+              >
+                <Icon className="h-3.5 w-3.5" />
+                {label}
+              </button>
+            ))}
+          </div>
         </div>
-      </div>
 
-      {/* Above everything else: it is usually why the quote was opened. */}
-      {quote.customerMessage &&
-        !quote.responseDismissedAt &&
-        (state.status === 'changes_requested' || state.status === 'accepted') && (
+        {/* Above everything else: it is usually why the quote was opened. */}
+        {quote.customerMessage &&
+          !quote.responseDismissedAt &&
+          (state.status === 'changes_requested' || state.status === 'accepted') && (
+            <div className="shrink-0 px-4 pt-3">
+              <QuoteCustomerResponse
+                status={state.status}
+                message={quote.customerMessage}
+                respondedAt={quote.updatedAt}
+                resolving={state.resolving}
+                onResolve={state.handleResolveResponse}
+                t={t}
+              />
+            </div>
+          )}
+
+        {(lockState.locked || lockState.unlockedAt) && (
           <div className="shrink-0 px-4 pt-3">
-            <QuoteCustomerResponse
-              status={state.status}
-              message={quote.customerMessage}
-              respondedAt={quote.updatedAt}
-              resolving={state.resolving}
-              onResolve={state.handleResolveResponse}
-              t={t}
+            <DocumentLockBanner
+              state={lockState}
+              kind="quote"
+              canUnlock={canUnlock}
+              onSetUnlocked={(unlocked) => setQuoteEditUnlocked(quote.id, unlocked)}
             />
           </div>
         )}
 
-      {(lockState.locked || lockState.unlockedAt) && (
-        <div className="shrink-0 px-4 pt-3">
-          <DocumentLockBanner
-            state={lockState}
-            kind="quote"
-            canUnlock={canUnlock}
-            onSetUnlocked={(unlocked) => setQuoteEditUnlocked(quote.id, unlocked)}
-          />
-        </div>
-      )}
-
-      {/* Tab Content */}
-      {state.activeTab === 'details' && (
-        <form
-          id="quote-form"
-          ref={state.formRef}
-          onSubmit={state.handleSubmit}
-          className="flex min-h-0 flex-1 flex-col overflow-hidden"
-        >
-          {isLarge ? (
-            <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
-              <ResizablePanel defaultSize={75} minSize={40}>
-                <div className="h-full overflow-y-auto overscroll-contain p-4 pr-2">
-                  <div className="space-y-3 pb-40">{leftColumn}</div>
-                </div>
-              </ResizablePanel>
-              <ResizableHandle withHandle />
-              <ResizablePanel defaultSize={25} minSize={15}>
-                <div className="h-full overflow-y-auto overscroll-contain p-4 pl-2">
-                  <div className="space-y-3 pb-40">{rightColumn}</div>
+        {/* Tab Content */}
+        {state.activeTab === 'details' && (
+          <form
+            id="quote-form"
+            ref={state.formRef}
+            onSubmit={state.handleSubmit}
+            className="flex min-h-0 flex-1 flex-col overflow-hidden"
+          >
+            {isLarge ? (
+              <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
+                <ResizablePanel defaultSize={75} minSize={40}>
+                  <div className="h-full overflow-y-auto overscroll-contain p-4 pr-2">
+                    <div className="space-y-3 pb-40">{leftColumn}</div>
+                  </div>
+                </ResizablePanel>
+                <ResizableHandle withHandle />
+                <ResizablePanel defaultSize={25} minSize={15}>
+                  <div className="h-full overflow-y-auto overscroll-contain p-4 pl-2">
+                    <div className="space-y-3 pb-40">{rightColumn}</div>
+                  </div>
+                </ResizablePanel>
+              </ResizablePanelGroup>
+            ) : (
+              <div className="flex-1 overflow-y-auto overscroll-contain p-4">
+                <div className="space-y-3 pb-40">
+                  {leftColumn}
+                  {rightColumn}
                 </div>
-              </ResizablePanel>
-            </ResizablePanelGroup>
-          ) : (
-            <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-              <div className="space-y-3 pb-40">
-                {leftColumn}
-                {rightColumn}
               </div>
-            </div>
-          )}
-        </form>
-      )}
+            )}
+          </form>
+        )}
 
-      {state.activeTab === 'images' && (
-        <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-          <div className="mx-auto max-w-4xl pb-40">
-            <QuoteImagesManager
-              quoteId={quote.id}
-              initialImages={imageAttachments}
-              maxImages={maxImages}
-            />
+        {state.activeTab === 'images' && (
+          <div className="flex-1 overflow-y-auto overscroll-contain p-4">
+            <div className="mx-auto max-w-4xl pb-40">
+              <QuoteImagesManager
+                quoteId={quote.id}
+                initialImages={imageAttachments}
+                maxImages={maxImages}
+              />
+            </div>
           </div>
-        </div>
-      )}
+        )}
 
-      {state.activeTab === 'documents' && (
-        <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-          <div className="mx-auto max-w-4xl pb-40">
-            <QuoteDocumentsManager
-              quoteId={quote.id}
-              initialDocuments={documentAttachments}
-              maxDocuments={maxDocuments}
-            />
+        {state.activeTab === 'documents' && (
+          <div className="flex-1 overflow-y-auto overscroll-contain p-4">
+            <div className="mx-auto max-w-4xl pb-40">
+              <QuoteDocumentsManager
+                quoteId={quote.id}
+                initialDocuments={documentAttachments}
+                maxDocuments={maxDocuments}
+              />
+            </div>
           </div>
-        </div>
-      )}
+        )}
 
-      <LaborPresetPickerDialog
-        open={showPresetPicker}
-        onOpenChange={setShowPresetPicker}
-        laborPresets={laborPresets}
-        onSelectPreset={handleSelectPreset}
-      />
+        <LaborPresetPickerDialog
+          open={showPresetPicker}
+          onOpenChange={setShowPresetPicker}
+          laborPresets={laborPresets}
+          onSelectPreset={handleSelectPreset}
+        />
 
-      {/* Dialogs */}
-      <PdfPreviewDialog
-        open={showPdfPreview}
-        onOpenChange={setShowPdfPreview}
-        url={`/api/protected/quotes/${quote.id}/pdf`}
-      />
+        {/* Dialogs */}
+        <PdfPreviewDialog
+          open={showPdfPreview}
+          onOpenChange={setShowPdfPreview}
+          url={`/api/protected/quotes/${quote.id}/pdf`}
+        />
 
-      <SendEmailDialog
-        open={state.showEmailDialog}
-        onOpenChange={state.setShowEmailDialog}
-        defaultEmail={quote.customer?.email || ''}
-        entityLabel={t('page.entityLabel')}
-        onSend={async (email, message, attachPdf) => {
-          const result = await sendQuoteEmail({
-            quoteId: quote.id,
-            recipientEmail: email,
-            message,
-            attachPdf,
-          })
-          if (result.success) handleQuoteSent()
-          return result
-        }}
-      />
+        <SendEmailDialog
+          open={state.showEmailDialog}
+          onOpenChange={state.setShowEmailDialog}
+          defaultEmail={quote.customer?.email || ''}
+          entityLabel={t('page.entityLabel')}
+          onSend={async (email, message, attachPdf) => {
+            const result = await sendQuoteEmail({
+              quoteId: quote.id,
+              recipientEmail: email,
+              message,
+              attachPdf,
+            })
+            if (result.success) handleQuoteSent()
+            return result
+          }}
+        />
 
-      <QuoteShareDialog
-        open={state.showShareDialog}
-        onOpenChange={state.setShowShareDialog}
-        quoteId={quote.id}
-        organizationId={organizationId}
-        initialToken={quote.publicToken}
-        onSent={handleQuoteSent}
-        customer={quote.customer}
-        smsEnabled={smsEnabled}
-        emailEnabled={emailEnabled}
-      />
+        <QuoteShareDialog
+          open={state.showShareDialog}
+          onOpenChange={state.setShowShareDialog}
+          quoteId={quote.id}
+          organizationId={organizationId}
+          initialToken={quote.publicToken}
+          onSent={handleQuoteSent}
+          customer={quote.customer}
+          smsEnabled={smsEnabled}
+          emailEnabled={emailEnabled}
+        />
 
-      <Dialog open={state.showConvertDialog} onOpenChange={state.setShowConvertDialog}>
-        <DialogContent className="max-w-md">
-          <DialogHeader>
-            <DialogTitle>{t('page.convertTitle')}</DialogTitle>
-          </DialogHeader>
-          <div className="space-y-4">
-            <p className="text-sm text-muted-foreground">{t('page.convertDescription')}</p>
-            <VehicleCombobox
-              value={state.convertVehicleId}
-              initialVehicle={state.selectedVehicle}
-              placeholder={t('details.selectVehicle')}
-              noneLabel={t('details.none')}
-              onChange={(id) => state.setConvertVehicleId(id)}
-            />
-            <div className="flex gap-2">
-              <Button
-                type="button"
-                onClick={state.handleConvert}
-                disabled={state.converting || !state.convertVehicleId}
-              >
-                {state.converting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                {t('page.convert')}
-              </Button>
-              <Button
-                type="button"
-                variant="ghost"
-                onClick={() => state.setShowConvertDialog(false)}
-              >
-                {t('page.cancel')}
-              </Button>
+        <Dialog open={state.showConvertDialog} onOpenChange={state.setShowConvertDialog}>
+          <DialogContent className="max-w-md">
+            <DialogHeader>
+              <DialogTitle>{t('page.convertTitle')}</DialogTitle>
+            </DialogHeader>
+            <div className="space-y-4">
+              <p className="text-sm text-muted-foreground">{t('page.convertDescription')}</p>
+              <VehicleCombobox
+                value={state.convertVehicleId}
+                initialVehicle={state.selectedVehicle}
+                placeholder={t('details.selectVehicle')}
+                noneLabel={t('details.none')}
+                onChange={(id) => state.setConvertVehicleId(id)}
+              />
+              <div className="flex gap-2">
+                <Button
+                  type="button"
+                  onClick={state.handleConvert}
+                  disabled={state.converting || !state.convertVehicleId}
+                >
+                  {state.converting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                  {t('page.convert')}
+                </Button>
+                <Button
+                  type="button"
+                  variant="ghost"
+                  onClick={() => state.setShowConvertDialog(false)}
+                >
+                  {t('page.cancel')}
+                </Button>
+              </div>
             </div>
-          </div>
-        </DialogContent>
-      </Dialog>
-    </div>
+          </DialogContent>
+        </Dialog>
+      </div>
+    </ShopFeeProvider>
   )
 }

+ 21 - 3
src/features/quotes/Components/useQuoteFormState.ts

@@ -18,6 +18,11 @@ import { zonedDateInput } from '@/lib/timezone'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { normalizeWarranty, WARRANTY_NONE, type WarrantyFields } from '@/lib/warranty'
 import { useDeferredCommit } from '@/hooks/use-deferred-commit'
+import {
+  recalculateShopFeeLines,
+  shopFeeLinesLast,
+  type ShopFeeConfig,
+} from '@/features/settings/Lib/shopFee'
 import { isPriceOverridden, lineTotal, repricePartRow } from '@/features/inventory/Lib/partPricing'
 import type { QuoteRecord, QuotePartInput, QuoteLaborInput } from './quote-page-types'
 import { emptyPart, makeEmptyLabor, makeEmptyService } from './quote-page-types'
@@ -45,6 +50,7 @@ export function useQuoteFormState({
   taxEnabled,
   defaultLaborRate,
   locked = false,
+  shopFee = null,
   t,
 }: {
   quote: QuoteRecord
@@ -54,6 +60,8 @@ export function useQuoteFormState({
   defaultLaborRate: number
   /** A locked quote refuses saves, so it must not queue one. */
   locked?: boolean
+  /** The workshop's shop fee; a percentage one is re-priced as lines change. */
+  shopFee?: ShopFeeConfig | null
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   t: (key: string, values?: any) => string
 }) {
@@ -99,12 +107,12 @@ export function useQuoteFormState({
   )
   const { schedule: scheduleCommit, cancel: cancelCommit } = useDeferredCommit()
   const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(
-    quote.laborItems.map((l) => ({
+    shopFeeLinesLast(quote.laborItems).map((l) => ({
       description: l.description,
       hours: l.hours,
       rate: l.rate,
       total: l.total,
-      pricingType: (l.pricingType as 'hourly' | 'service') || 'hourly',
+      pricingType: (l.pricingType as 'hourly' | 'service' | 'shopFee') || 'hourly',
       excluded: l.excluded ?? false,
     }))
   )
@@ -256,6 +264,16 @@ export function useQuoteFormState({
     () => laborItems.reduce((sum, l) => (l.excluded ? sum : sum + l.total), 0),
     [laborItems]
   )
+  // A percentage shop fee follows the lines the customer is being quoted for.
+  // A fee that moved is a change to save, or the PDF would print the old one.
+  useEffect(() => {
+    if (locked) return
+    const next = recalculateShopFeeLines(laborItems, partsSubtotal, shopFee)
+    if (next === laborItems) return
+    setLaborItems(next)
+    markDirty()
+  }, [laborItems, partsSubtotal, shopFee, locked, markDirty])
+
   const subtotal = partsSubtotal + laborSubtotal
   const discountAmount =
     discountType === 'percentage'
@@ -427,7 +445,7 @@ export function useQuoteFormState({
         vehicleId,
         notes,
         partItems: partItems.filter((p) => p.name),
-        laborItems: laborItems.filter((l) => l.description),
+        laborItems: shopFeeLinesLast(laborItems.filter((l) => l.description)),
         subtotal,
         taxRate,
         taxInclusive,

+ 41 - 0
src/features/quotes/Lib/createQuoteRecord.ts

@@ -1,6 +1,13 @@
 import 'server-only'
 
 import { db } from '@/lib/db'
+import {
+  isShopFeeLine,
+  newShopFeeLine,
+  readShopFee,
+  SHOP_FEE_SETTING_KEYS,
+  shopFeeFor,
+} from '@/features/settings/Lib/shopFee'
 import {
   documentTotals,
   readWorkshopTax,
@@ -48,6 +55,7 @@ export async function createQuoteRecord(
           'workshop.quoteValidDays',
           ...WORKSHOP_TAX_SETTING_KEYS,
           ...WARRANTY_SETTING_KEYS,
+          ...SHOP_FEE_SETTING_KEYS,
         ],
       },
     },
@@ -67,6 +75,27 @@ export async function createQuoteRecord(
     if (!inspection) throw new Error('Inspection not found')
   }
 
+  // The workshop's shop fee, priced for the lines the quote starts with. A
+  // caller that already carries one (a copied quote) keeps its own. Most
+  // quotes start empty, so a percentage fee starts at nothing; the line is
+  // written all the same, as on a work order, and the editor re-prices it as
+  // lines are added. Without the line there is nothing to re-price.
+  let feeAdded = false
+  const shopFee = shopFeeFor(readShopFee(settingsMap), 'quote')
+  if (shopFee && !(data.laborItems ?? []).some(isShopFeeLine)) {
+    const labor = (data.laborItems ?? []).reduce((sum, l) => (l.excluded ? sum : sum + l.total), 0)
+    const parts = (data.partItems ?? []).reduce((sum, p) => sum + p.total, 0)
+    const feeLine = newShopFeeLine(shopFee, { labor, parts })
+    data.laborItems = [...(data.laborItems ?? []), { ...feeLine, excluded: false }]
+    if (feeLine.total > 0) {
+      data.subtotal += feeLine.total
+      if (data.discountType === 'percentage') {
+        data.discountAmount = data.subtotal * (data.discountValue / 100)
+      }
+      feeAdded = true
+    }
+  }
+
   // Apply default tax rate from settings when the caller hasn't set one.
   // All current call sites send taxRate: 0 at creation, so 0 means "unset".
   const workshopTax = readWorkshopTax(settingsMap)
@@ -92,6 +121,18 @@ export async function createQuoteRecord(
   // split-tax workshop's quote prints GST and QST apart from the start.
   // A rate the caller set by hand is one figure and stays one.
   const taxRate = data.taxRate > 0 ? data.taxRate : defaultTaxRate
+  // The fee moved the subtotal, so the caller's tax and total are recomputed.
+  if (feeAdded) {
+    const totals = documentTotals({
+      subtotal: data.subtotal,
+      discountAmount: data.discountAmount,
+      taxRate,
+      taxInclusive,
+      taxComponents: null,
+    })
+    data.taxAmount = totals.taxAmount
+    data.totalAmount = totals.totalAmount
+  }
   const splitTax =
     taxRate > 0 && taxRate === workshopTax.rate && !customerExempt
       ? documentTotals({

+ 1 - 1
src/features/quotes/Schema/quoteSchema.ts

@@ -30,7 +30,7 @@ export const quoteLaborSchema = z.object({
   hours: z.coerce.number().min(0).default(0),
   rate: z.coerce.number().min(0).default(0),
   total: z.coerce.number().min(0).default(0),
-  pricingType: z.enum(['hourly', 'service']).default('hourly'),
+  pricingType: z.enum(['hourly', 'service', 'shopFee']).default('hourly'),
   excluded: z.boolean().optional().default(false),
 })
 

+ 17 - 9
src/features/reports/Actions/reportActions.ts

@@ -8,8 +8,13 @@ import { netLineTotal } from '@/lib/tax'
 import { TaxByRateTable } from '../Lib/taxByRate'
 import { zonedDate, zonedDayKey, zonedParts } from '@/lib/timezone'
 import { workshopDayRange, workshopMonthKey } from '@/lib/workshop-datetime'
+import { isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 
+/** Hours a labour line stands for. A shop fee line is one unit of a charge, not an hour. */
+const laborHours = (line: { hours: number; pricingType: string }) =>
+  isShopFeeLine(line) ? 0 : line.hours
+
 /**
  * The report window on the workshop's calendar. The picker sends bare
  * YYYY-MM-DD keys, so "to 31 Dec" must cover the whole of that day in the
@@ -248,7 +253,7 @@ export async function getTechnicianReport(params: { startDate?: string; endDate?
           technician: { select: { name: true } },
           totalAmount: true,
           cost: true,
-          laborItems: { select: { hours: true } },
+          laborItems: { select: { hours: true, pricingType: true } },
         },
       })
 
@@ -265,7 +270,7 @@ export async function getTechnicianReport(params: { startDate?: string; endDate?
           byTech[key] = { techName: displayName, jobCount: 0, totalRevenue: 0, totalLaborHours: 0 }
         byTech[key].jobCount += 1
         byTech[key].totalRevenue += r.totalAmount > 0 ? r.totalAmount : r.cost
-        byTech[key].totalLaborHours += r.laborItems.reduce((s, l) => s + l.hours, 0)
+        byTech[key].totalLaborHours += r.laborItems.reduce((s, l) => s + laborHours(l), 0)
       }
 
       const technicians = Object.values(byTech)
@@ -313,7 +318,10 @@ export async function getTechnicianTimeReport(params: { startDate?: string; endD
             startDateTime: { gte: start, lt: end },
             technicianId: { not: null },
           },
-          select: { technicianId: true, laborItems: { select: { hours: true } } },
+          select: {
+            technicianId: true,
+            laborItems: { select: { hours: true, pricingType: true } },
+          },
         }),
         db.technician.findMany({
           where: { organizationId },
@@ -346,7 +354,7 @@ export async function getTechnicianTimeReport(params: { startDate?: string; endD
       }
       for (const rec of records) {
         if (!rec.technicianId) continue
-        row(rec.technicianId).billedHours += rec.laborItems.reduce((s, l) => s + l.hours, 0)
+        row(rec.technicianId).billedHours += rec.laborItems.reduce((s, l) => s + laborHours(l), 0)
       }
 
       const names = new Map(
@@ -489,7 +497,7 @@ export async function getJobAnalyticsReport(params: { startDate?: string; endDat
           cost: true,
           serviceDate: true,
           startDateTime: true,
-          laborItems: { select: { hours: true } },
+          laborItems: { select: { hours: true, pricingType: true } },
         },
       })
 
@@ -506,7 +514,7 @@ export async function getJobAnalyticsReport(params: { startDate?: string; endDat
         if (!byType[r.type]) byType[r.type] = { count: 0, totalValue: 0, totalHours: 0 }
         byType[r.type].count += 1
         byType[r.type].totalValue += r.totalAmount > 0 ? r.totalAmount : r.cost
-        byType[r.type].totalHours += r.laborItems.reduce((s, l) => s + l.hours, 0)
+        byType[r.type].totalHours += r.laborItems.reduce((s, l) => s + laborHours(l), 0)
       }
 
       const topServiceTypes = Object.entries(byType)
@@ -853,7 +861,7 @@ export async function getVehicleReport(params: {
               total: true,
             },
           },
-          laborItems: { select: { hours: true, total: true } },
+          laborItems: { select: { hours: true, total: true, pricingType: true } },
         },
         orderBy: [{ startDateTime: { sort: 'desc', nulls: 'last' } }, { serviceDate: 'desc' }],
       })
@@ -883,7 +891,7 @@ export async function getVehicleReport(params: {
           (s, l) => s + netLineTotal(l.total, r.taxRate, r.taxInclusive),
           0
         )
-        const laborHrs = r.laborItems.reduce((s, l) => s + l.hours, 0)
+        const laborHrs = r.laborItems.reduce((s, l) => s + laborHours(l), 0)
         totalPartsUsed += r.partItems.reduce((s, p) => s + p.quantity, 0)
         totalLaborHours += laborHrs
 
@@ -955,7 +963,7 @@ export async function getVehicleReport(params: {
           date: zonedDayKey(r.startDateTime ?? r.serviceDate, tz),
           totalAmount: r.totalAmount > 0 ? r.totalAmount : r.cost,
           partsCount: r.partItems.reduce((s, p) => s + p.quantity, 0),
-          laborHours: r.laborItems.reduce((s, l) => s + l.hours, 0),
+          laborHours: r.laborItems.reduce((s, l) => s + laborHours(l), 0),
           techName: r.technician?.name ?? r.techName ?? null,
         })),
       }

+ 16 - 0
src/features/settings/Components/ShopFeeContext.tsx

@@ -0,0 +1,16 @@
+'use client'
+
+import { createContext, useContext } from 'react'
+import type { ShopFeeConfig } from '../Lib/shopFee'
+
+/**
+ * The workshop's shop fee, for the line editors: a percentage fee's line is
+ * priced from the job and cannot be typed over, a flat one can.
+ */
+const ShopFeeContext = createContext<ShopFeeConfig | null>(null)
+
+export const ShopFeeProvider = ShopFeeContext.Provider
+
+export function useShopFeeConfig(): ShopFeeConfig | null {
+  return useContext(ShopFeeContext)
+}

+ 32 - 0
src/features/settings/Components/ShopFeeTag.tsx

@@ -0,0 +1,32 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { useShopFeeConfig } from './ShopFeeContext'
+
+/**
+ * Stands where the hourly/service switch is on a shop fee line: the line is
+ * neither, and switching it would turn the fee into ordinary labour.
+ */
+export function ShopFeeTag() {
+  const t = useTranslations('service.labor')
+  const config = useShopFeeConfig()
+  const hint =
+    config?.mode === 'percent'
+      ? t(config.base === 'laborParts' ? 'shopFeeHintPercentAll' : 'shopFeeHintPercentLabor', {
+          percent: config.percent,
+        })
+      : t('shopFeeHintFlat')
+  return (
+    <span
+      className="inline-flex shrink-0 items-center rounded-md border border-amber-500/30 bg-amber-500/10 px-2 text-[10px] font-medium text-amber-700 dark:text-amber-400"
+      title={hint}
+    >
+      {t('shopFeeTag')}
+    </span>
+  )
+}
+
+/** Whether a fee line's amount is priced from the job rather than typed. */
+export function useShopFeeLocked(): boolean {
+  return useShopFeeConfig()?.mode === 'percent'
+}

+ 2 - 0
src/features/settings/Lib/memberReadableSettings.ts

@@ -1,4 +1,5 @@
 import { SETTING_KEYS, type SettingKey } from '../Schema/settingsSchema'
+import { SHOP_FEE_SETTING_KEYS } from './shopFee'
 import { WARRANTY_SETTING_KEYS } from './warrantyDefaults'
 
 /**
@@ -33,6 +34,7 @@ export const MEMBER_READABLE_SETTINGS: ReadonlySet<SettingKey> = new Set<Setting
   SETTING_KEYS.DEFAULT_TAX_RATE,
   SETTING_KEYS.DEFAULT_LABOR_RATE,
   SETTING_KEYS.INVOICE_DUE_DAYS,
+  ...SHOP_FEE_SETTING_KEYS,
   SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT,
   SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY,
   SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,

+ 185 - 0
src/features/settings/Lib/shopFee.ts

@@ -0,0 +1,185 @@
+import { SETTING_KEYS } from '../Schema/settingsSchema'
+
+/**
+ * The workshop's shop fee ("shop supplies"): a charge added to every new work
+ * order and quote for the consumables no line itemises, such as rags,
+ * cleaner, lubricants and disposal.
+ *
+ * It is written onto the job as a line of its own, `pricingType: 'shopFee'`,
+ * priced as one unit. A line rather than a hidden total, so it prints, syncs to
+ * accounting and exports like any other charge, and the technician can remove
+ * it from a job that should not carry it.
+ *
+ * A flat fee is written once and then belongs to the job like any line. A
+ * percentage fee follows the job: it is recalculated from the lines it is a
+ * percentage of whenever the job is re-totalled, and capped when the workshop
+ * set a cap.
+ */
+
+export const SHOP_FEE_PRICING = 'shopFee' as const
+
+export const SHOP_FEE_SETTING_KEYS = [
+  SETTING_KEYS.SHOP_FEE_ENABLED,
+  SETTING_KEYS.SHOP_FEE_LABEL,
+  SETTING_KEYS.SHOP_FEE_MODE,
+  SETTING_KEYS.SHOP_FEE_AMOUNT,
+  SETTING_KEYS.SHOP_FEE_PERCENT,
+  SETTING_KEYS.SHOP_FEE_BASE,
+  SETTING_KEYS.SHOP_FEE_CAP,
+  SETTING_KEYS.SHOP_FEE_APPLIES_TO,
+] as const
+
+export type ShopFeeMode = 'flat' | 'percent'
+/** What a percentage fee is a percentage of. */
+export type ShopFeeBase = 'labor' | 'laborParts'
+/** Which new documents are written with the fee. */
+export type ShopFeeAppliesTo = 'both' | 'workOrders' | 'quotes'
+/** A document the fee may be written onto. */
+export type ShopFeeTarget = 'workOrder' | 'quote'
+
+export interface ShopFeeConfig {
+  label: string
+  mode: ShopFeeMode
+  /** The flat amount. */
+  amount: number
+  /** The percentage, as 10 for 10 %. */
+  percent: number
+  base: ShopFeeBase
+  /** Largest a percentage fee may come to; null for no cap. */
+  cap: number | null
+  appliesTo: ShopFeeAppliesTo
+}
+
+const positive = (value: string | undefined): number => {
+  const n = Number(value)
+  return Number.isFinite(n) && n > 0 ? n : 0
+}
+
+/** The fee as settings describe it, or null when the workshop charges none. */
+export function readShopFee(
+  settings: Record<string, string | undefined>,
+  fallbackLabel = 'Shop supplies'
+): ShopFeeConfig | null {
+  if (settings[SETTING_KEYS.SHOP_FEE_ENABLED] !== 'true') return null
+  const mode: ShopFeeMode = settings[SETTING_KEYS.SHOP_FEE_MODE] === 'percent' ? 'percent' : 'flat'
+  const config: ShopFeeConfig = {
+    label: settings[SETTING_KEYS.SHOP_FEE_LABEL]?.trim() || fallbackLabel,
+    mode,
+    amount: positive(settings[SETTING_KEYS.SHOP_FEE_AMOUNT]),
+    percent: positive(settings[SETTING_KEYS.SHOP_FEE_PERCENT]),
+    base: settings[SETTING_KEYS.SHOP_FEE_BASE] === 'laborParts' ? 'laborParts' : 'labor',
+    cap: positive(settings[SETTING_KEYS.SHOP_FEE_CAP]) || null,
+    appliesTo: readAppliesTo(settings[SETTING_KEYS.SHOP_FEE_APPLIES_TO]),
+  }
+  // A fee of nothing is no fee; it would only add an empty line to every job.
+  if (mode === 'flat' ? config.amount === 0 : config.percent === 0) return null
+  return config
+}
+
+const readAppliesTo = (value: string | undefined): ShopFeeAppliesTo =>
+  value === 'workOrders' || value === 'quotes' ? value : 'both'
+
+/**
+ * The fee if it is written onto this kind of document, else null. Governs
+ * only where a new document gets the line; a fee already on a job follows it
+ * whatever this says, since the customer was quoted it.
+ */
+export function shopFeeFor(
+  config: ShopFeeConfig | null,
+  target: ShopFeeTarget
+): ShopFeeConfig | null {
+  if (!config) return null
+  if (config.appliesTo === 'both') return config
+  return config.appliesTo === (target === 'quote' ? 'quotes' : 'workOrders') ? config : null
+}
+
+const round = (value: number) => Math.round(value * 100) / 100
+
+export const isShopFeeLine = (line: { pricingType?: string | null }) =>
+  line.pricingType === SHOP_FEE_PRICING
+
+/**
+ * The lines with the fee last. A new job is created with the fee as its only
+ * line and everything else lands after it, so left alone the fee would print
+ * as line 1; a customer expects it under the work. Returns the same array
+ * when nothing moved.
+ */
+export function shopFeeLinesLast<T extends { pricingType?: string | null }>(lines: T[]): T[] {
+  const fees = lines.filter(isShopFeeLine)
+  if (fees.length === 0) return lines
+  const rest = lines.filter((line) => !isShopFeeLine(line))
+  const next = [...rest, ...fees]
+  return next.every((line, i) => line === lines[i]) ? lines : next
+}
+
+/**
+ * The lines as a customer document shows them: the fee last, and a fee that
+ * came to nothing left out. A percentage fee on a job with none of what it
+ * is a percentage of is a line reading 0.00, which says nothing to the
+ * customer; the line itself stays on the job so it can be priced later.
+ */
+export function documentLaborLines<T extends { pricingType?: string | null; total: number }>(
+  lines: T[]
+): T[] {
+  return shopFeeLinesLast(
+    lines.filter((line) => !isShopFeeLine(line) || Math.abs(line.total) >= 0.005)
+  )
+}
+
+/** The fee for a job with these line totals. Labor excludes fee lines. */
+export function shopFeeAmount(
+  config: ShopFeeConfig,
+  totals: { labor: number; parts: number }
+): number {
+  if (config.mode === 'flat') return round(config.amount)
+  const base = config.base === 'laborParts' ? totals.labor + totals.parts : totals.labor
+  const fee = round((Math.max(base, 0) * config.percent) / 100)
+  return config.cap !== null ? Math.min(fee, round(config.cap)) : fee
+}
+
+/** The line a new job starts with, priced for the lines it starts with. */
+export function newShopFeeLine(
+  config: ShopFeeConfig,
+  totals: { labor: number; parts: number } = { labor: 0, parts: 0 }
+) {
+  const fee = shopFeeAmount(config, totals)
+  return {
+    description: config.label,
+    hours: 1,
+    rate: fee,
+    total: fee,
+    pricingType: SHOP_FEE_PRICING,
+  }
+}
+
+/**
+ * Brings a percentage fee's lines up to date with the rest of the job.
+ * Returns the same array when nothing changed, so a caller holding it in
+ * React state can compare by reference. A flat fee, or no fee configured,
+ * leaves the lines alone: the job keeps what it was given.
+ */
+export function recalculateShopFeeLines<
+  T extends {
+    pricingType?: string | null
+    hours: number
+    rate: number
+    total: number
+    /** A quote line the customer left out, which the fee is not charged on. */
+    excluded?: boolean
+  },
+>(labor: T[], partsTotal: number, config: ShopFeeConfig | null): T[] {
+  if (!config || config.mode !== 'percent') return labor
+  if (!labor.some(isShopFeeLine)) return labor
+  const laborTotal = labor
+    .filter((line) => !isShopFeeLine(line) && !line.excluded)
+    .reduce((sum, line) => sum + (Number(line.total) || 0), 0)
+  const fee = shopFeeAmount(config, { labor: laborTotal, parts: partsTotal })
+  let changed = false
+  const next = labor.map((line) => {
+    if (!isShopFeeLine(line)) return line
+    if (line.total === fee && line.rate === fee && line.hours === 1) return line
+    changed = true
+    return { ...line, hours: 1, rate: fee, total: fee }
+  })
+  return changed ? next : labor
+}

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

@@ -34,6 +34,19 @@ export const SETTING_KEYS = {
   INVOICE_SHOW_ORG_NUMBER: 'invoice.showOrgNumber',
   INVOICE_LINE_ITEMS_INCL_TAX: 'invoice.lineItemsInclTax',
   INVOICE_DUE_DAYS: 'invoice.dueDays',
+  /** See src/features/settings/Lib/shopFee.ts. */
+  SHOP_FEE_ENABLED: 'invoice.shopFeeEnabled',
+  SHOP_FEE_LABEL: 'invoice.shopFeeLabel',
+  /** 'flat' | 'percent' */
+  SHOP_FEE_MODE: 'invoice.shopFeeMode',
+  SHOP_FEE_AMOUNT: 'invoice.shopFeeAmount',
+  SHOP_FEE_PERCENT: 'invoice.shopFeePercent',
+  /** 'labor' | 'laborParts': what a percentage fee is a percentage of. */
+  SHOP_FEE_BASE: 'invoice.shopFeeBase',
+  /** Largest a percentage fee may come to; empty for no cap. */
+  SHOP_FEE_CAP: 'invoice.shopFeeCap',
+  /** 'both' | 'workOrders' | 'quotes': which new documents get the fee. */
+  SHOP_FEE_APPLIES_TO: 'invoice.shopFeeAppliesTo',
   /** See src/lib/document-lock.ts for what these freeze and when. */
   INVOICE_LOCK_ENABLED: 'invoice.lockEnabled',
   INVOICE_LOCK_TRIGGER: 'invoice.lockTrigger',
@@ -288,6 +301,14 @@ export const invoiceSettingsSchema = z.object({
   [SETTING_KEYS.INVOICE_SHOW_ORG_NUMBER]: z.string().optional(),
   [SETTING_KEYS.INVOICE_LINE_ITEMS_INCL_TAX]: z.string().optional(),
   [SETTING_KEYS.INVOICE_DUE_DAYS]: z.string().optional(),
+  [SETTING_KEYS.SHOP_FEE_ENABLED]: z.string().optional(),
+  [SETTING_KEYS.SHOP_FEE_LABEL]: z.string().optional(),
+  [SETTING_KEYS.SHOP_FEE_MODE]: z.string().optional(),
+  [SETTING_KEYS.SHOP_FEE_AMOUNT]: z.string().optional(),
+  [SETTING_KEYS.SHOP_FEE_PERCENT]: z.string().optional(),
+  [SETTING_KEYS.SHOP_FEE_BASE]: z.string().optional(),
+  [SETTING_KEYS.SHOP_FEE_CAP]: z.string().optional(),
+  [SETTING_KEYS.SHOP_FEE_APPLIES_TO]: z.string().optional(),
   [SETTING_KEYS.INVOICE_LOCK_ENABLED]: z.string().optional(),
   [SETTING_KEYS.INVOICE_LOCK_TRIGGER]: z.string().optional(),
   [SETTING_KEYS.QUOTE_LOCK_ENABLED]: z.string().optional(),

+ 27 - 17
src/features/vehicles/Components/service-edit/LaborEditor.tsx

@@ -1,5 +1,7 @@
 'use client'
 
+import { isShopFeeLine } from '@/features/settings/Lib/shopFee'
+import { ShopFeeTag, useShopFeeLocked } from '@/features/settings/Components/ShopFeeTag'
 import { useRef, useCallback, useState, useEffect } from 'react'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
@@ -93,6 +95,8 @@ function SortableLaborRow({
     : undefined
 
   const isService = labor.pricingType === 'service'
+  const isFee = isShopFeeLine(labor)
+  const feeLocked = useShopFeeLocked()
 
   return (
     <div
@@ -126,28 +130,33 @@ function SortableLaborRow({
               descriptionMissing && 'border-destructive focus-visible:ring-destructive'
             )}
           />
-          <button
-            type="button"
-            className={`shrink-0 rounded-md border px-2 text-[10px] font-medium transition-all ${
-              isService
-                ? 'border-blue-500/30 bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 hover:border-blue-500/50'
-                : 'border-muted text-muted-foreground hover:bg-muted hover:text-foreground hover:border-foreground/20'
-            }`}
-            onClick={() => updateLabor(index, 'pricingType', isService ? 'hourly' : 'service')}
-            // Says what the line is now, then that a click changes it. It used to
-            // describe the other mode, so hovering SVC read "Hourly".
-            title={isService ? t('serviceHint') : t('hourlyHint')}
-          >
-            {isService ? t('serviceTag') : t('hourlyTag')}
-          </button>
+          {isFee ? (
+            <ShopFeeTag />
+          ) : (
+            <button
+              type="button"
+              className={`shrink-0 rounded-md border px-2 text-[10px] font-medium transition-all ${
+                isService
+                  ? 'border-blue-500/30 bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 hover:border-blue-500/50'
+                  : 'border-muted text-muted-foreground hover:bg-muted hover:text-foreground hover:border-foreground/20'
+              }`}
+              onClick={() => updateLabor(index, 'pricingType', isService ? 'hourly' : 'service')}
+              // Says what the line is now, then that a click changes it. It used to
+              // describe the other mode, so hovering SVC read "Hourly".
+              title={isService ? t('serviceHint') : t('hourlyHint')}
+            >
+              {isService ? t('serviceTag') : t('hourlyTag')}
+            </button>
+          )}
         </div>
-        <FieldRow label={isService ? t('qty') : t('hours')}>
+        <FieldRow label={isService || isFee ? t('qty') : t('hours')}>
           <Input
             type="number"
             min="0"
-            step={isService ? '1' : 'any'}
-            placeholder={isService ? t('qty') : t('hours')}
+            step={isService || isFee ? '1' : 'any'}
+            placeholder={isService || isFee ? t('qty') : t('hours')}
             value={labor.hours}
+            disabled={isFee}
             onChange={(e) => updateLabor(index, 'hours', e.target.value)}
           />
         </FieldRow>
@@ -157,6 +166,7 @@ function SortableLaborRow({
             min="0"
             step="0.01"
             value={labor.rate}
+            disabled={isFee && feeLocked}
             onChange={(e) => updateLabor(index, 'rate', e.target.value)}
           />
         </FieldRow>

+ 406 - 401
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { ShopFeeProvider } from '@/features/settings/Components/ShopFeeContext'
 import { DocumentLockBanner } from '@/components/document-lock-banner'
 import { setInvoiceEditUnlocked } from '@/features/settings/Actions/documentLockActions'
 import { InvoiceDesignMenu } from './InvoiceDesignMenu'
@@ -88,6 +89,7 @@ export function ServicePageClient({
   defaultTaxRate,
   taxEnabled,
   defaultLaborRate,
+  shopFee = null,
   initialData,
   inventoryParts,
   initialVehicle,
@@ -212,6 +214,7 @@ export function ServicePageClient({
     currentUserName,
     record,
     locked: lockState.locked,
+    shopFee,
   })
 
   // Anything that happens to this job somewhere else: a technician billing
@@ -598,432 +601,340 @@ export function ServicePageClient({
   }
 
   return (
-    <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
-      {modern ? (
-        <ModernHero
-          record={record}
-          status={formState.status}
-          paymentStatus={formState.paymentStatus}
-          warranty={formState.warranty}
-          actions={
-            <>
-              <PresenceChips kind="serviceRecord" id={record.id} className="mr-1" />
-              <ServiceHeaderActions showSave {...headerActionProps} />
-            </>
-          }
-          title={title}
-          onTitleChange={(next) => {
-            setTitle(next)
-            // Saved the moment the field is left, on its own, not with the
-            // form's autosave five seconds later: a rename followed by the
-            // back arrow inside those seconds was lost. Only a refused save
-            // marks the form dirty, so the ordinary path can try again.
-            void updateServiceRecordTitle(record.id, next).then((result) => {
-              if (result.success) {
-                formState.flashSaved()
-              } else {
-                toast.error(result.error || t('page.failedUpdate'))
-                formState.markDirty()
-              }
-            })
-          }}
-          type={record.vehicle ? formState.type : undefined}
-          onTypeChange={formState.dirtySetType}
-          locked={lockState.locked}
-        />
-      ) : (
-        <UnifiedServiceHeader
-          presence={<PresenceChips kind="serviceRecord" id={record.id} />}
-          vehicleId={vehicleId}
-          vehicleName={formState.vehicleName}
-          title={record.title}
-          status={formState.status}
-          paymentStatus={formState.paymentStatus}
-          activeTab={activeTab}
-          onTabChange={handleTabChange}
-          tabCounts={{
-            images: imageAttachmentsForManager.length,
-            video: videoAttachments.length,
-            documents: documentAttachments.length,
-            statusReports: statusReports.length,
-          }}
-          {...headerActionProps}
-        />
-      )}
-
-      {!modern && <TryNewLayoutBanner onTry={() => void switchLayout('modern', 'banner')} />}
+    <ShopFeeProvider value={shopFee}>
+      <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
+        {modern ? (
+          <ModernHero
+            record={record}
+            status={formState.status}
+            paymentStatus={formState.paymentStatus}
+            warranty={formState.warranty}
+            actions={
+              <>
+                <PresenceChips kind="serviceRecord" id={record.id} className="mr-1" />
+                <ServiceHeaderActions showSave {...headerActionProps} />
+              </>
+            }
+            title={title}
+            onTitleChange={(next) => {
+              setTitle(next)
+              // Saved the moment the field is left, on its own, not with the
+              // form's autosave five seconds later: a rename followed by the
+              // back arrow inside those seconds was lost. Only a refused save
+              // marks the form dirty, so the ordinary path can try again.
+              void updateServiceRecordTitle(record.id, next).then((result) => {
+                if (result.success) {
+                  formState.flashSaved()
+                } else {
+                  toast.error(result.error || t('page.failedUpdate'))
+                  formState.markDirty()
+                }
+              })
+            }}
+            type={record.vehicle ? formState.type : undefined}
+            onTypeChange={formState.dirtySetType}
+            locked={lockState.locked}
+          />
+        ) : (
+          <UnifiedServiceHeader
+            presence={<PresenceChips kind="serviceRecord" id={record.id} />}
+            vehicleId={vehicleId}
+            vehicleName={formState.vehicleName}
+            title={record.title}
+            status={formState.status}
+            paymentStatus={formState.paymentStatus}
+            activeTab={activeTab}
+            onTabChange={handleTabChange}
+            tabCounts={{
+              images: imageAttachmentsForManager.length,
+              video: videoAttachments.length,
+              documents: documentAttachments.length,
+              statusReports: statusReports.length,
+            }}
+            {...headerActionProps}
+          />
+        )}
 
-      <LaborAddedBanner
-        count={formState.laborAddedElsewhere.length}
-        onShow={formState.applyLaborAddedElsewhere}
-      />
+        {!modern && <TryNewLayoutBanner onTry={() => void switchLayout('modern', 'banner')} />}
 
-      {(lockState.locked || lockState.unlockedAt) && (
-        <div className="shrink-0 px-4 pt-3">
-          <DocumentLockBanner
-            state={lockState}
-            kind="invoice"
-            canUnlock={canUnlock}
-            onSetUnlocked={(unlocked) => setInvoiceEditUnlocked(record.id, unlocked)}
-          />
-        </div>
-      )}
+        <LaborAddedBanner
+          count={formState.laborAddedElsewhere.length}
+          onShow={formState.applyLaborAddedElsewhere}
+        />
 
-      {activeTab === 'details' && (
-        <WorkOrderLayoutProvider value={layout}>
-          {modern ? (
-            <ModernDetails
-              {...leftColumnProps}
-              {...rightColumnProps}
-              workOrderStatuses={workOrderStatuses}
-              onNotifyForStatus={handleNotifyForStatus}
-              locked={lockState.locked}
-              lockedLabel={t('invoice.lockedFieldsetLabel')}
-              form={{
-                id: 'service-record-form',
-                ref: formState.formRef,
-                onSubmit: actions.handleSubmit,
-                onInput: formState.markDirty,
-              }}
-              files={{
-                images: imageAttachmentsForManager,
-                dropoff: dropoffAttachments,
-                videos: videoAttachments,
-                documents: documentAttachments,
-                maxImages: maxImagesPerService,
-                maxDiagnostics: maxDiagnosticsPerService,
-                maxDocuments: maxDocumentsPerService,
-                // A link to one of the classic tabs opens that tab here.
-                initialTab: isFileTab || storedTab === 'statusReports' ? storedTab : undefined,
-                statusReports: { count: statusReports.length, list: statusReportList },
-              }}
-              onPreviewInvoice={previewInvoice}
-              onSendToCustomer={shareInvoice}
-              onBackToClassic={() => void switchLayout('classic', 'footer')}
-              title={title}
-              aiTranscription={aiTranscription}
-              dictationMode={dictationMode}
-              onAddFindingForConcern={(concernId) =>
-                obsControlsRef.current?.onAddFinding(concernId)
-              }
-              scrollToFiles={isFileTab || storedTab === 'statusReports'}
+        {(lockState.locked || lockState.unlockedAt) && (
+          <div className="shrink-0 px-4 pt-3">
+            <DocumentLockBanner
+              state={lockState}
+              kind="invoice"
+              canUnlock={canUnlock}
+              onSetUnlocked={(unlocked) => setInvoiceEditUnlocked(record.id, unlocked)}
             />
-          ) : (
-            // noValidate, and the rules checked in handleSubmit instead. An
-            // autosave submits through requestSubmit, which native validation
-            // stops with no message and no request; and the rules that matter
-            // most here — a priced part with no name, labour with hours and no
-            // description — are not ones a `required` attribute can state.
-            <form
-              id="service-record-form"
-              ref={formState.formRef}
-              onSubmit={actions.handleSubmit}
-              onInput={formState.markDirty}
-              className="flex min-h-0 flex-1 flex-col"
-              noValidate
-            >
-              {/* A locked invoice offers no editing at all, rather than letting
+          </div>
+        )}
+
+        {activeTab === 'details' && (
+          <WorkOrderLayoutProvider value={layout}>
+            {modern ? (
+              <ModernDetails
+                {...leftColumnProps}
+                {...rightColumnProps}
+                workOrderStatuses={workOrderStatuses}
+                onNotifyForStatus={handleNotifyForStatus}
+                locked={lockState.locked}
+                lockedLabel={t('invoice.lockedFieldsetLabel')}
+                form={{
+                  id: 'service-record-form',
+                  ref: formState.formRef,
+                  onSubmit: actions.handleSubmit,
+                  onInput: formState.markDirty,
+                }}
+                files={{
+                  images: imageAttachmentsForManager,
+                  dropoff: dropoffAttachments,
+                  videos: videoAttachments,
+                  documents: documentAttachments,
+                  maxImages: maxImagesPerService,
+                  maxDiagnostics: maxDiagnosticsPerService,
+                  maxDocuments: maxDocumentsPerService,
+                  // A link to one of the classic tabs opens that tab here.
+                  initialTab: isFileTab || storedTab === 'statusReports' ? storedTab : undefined,
+                  statusReports: { count: statusReports.length, list: statusReportList },
+                }}
+                onPreviewInvoice={previewInvoice}
+                onSendToCustomer={shareInvoice}
+                onBackToClassic={() => void switchLayout('classic', 'footer')}
+                title={title}
+                aiTranscription={aiTranscription}
+                dictationMode={dictationMode}
+                onAddFindingForConcern={(concernId) =>
+                  obsControlsRef.current?.onAddFinding(concernId)
+                }
+                scrollToFiles={isFileTab || storedTab === 'statusReports'}
+              />
+            ) : (
+              // noValidate, and the rules checked in handleSubmit instead. An
+              // autosave submits through requestSubmit, which native validation
+              // stops with no message and no request; and the rules that matter
+              // most here — a priced part with no name, labour with hours and no
+              // description — are not ones a `required` attribute can state.
+              <form
+                id="service-record-form"
+                ref={formState.formRef}
+                onSubmit={actions.handleSubmit}
+                onInput={formState.markDirty}
+                className="flex min-h-0 flex-1 flex-col"
+                noValidate
+              >
+                {/* A locked invoice offers no editing at all, rather than letting
                   someone retype a line and meet the refusal on save. The
                   fieldset disables every control inside it natively;
                   display:contents keeps the layout exactly as it was. */}
-              <fieldset
-                disabled={lockState.locked}
-                className="contents"
-                aria-label={lockState.locked ? t('invoice.lockedFieldsetLabel') : undefined}
-              >
-                <ServiceDetailContent
-                  leftColumn={<DetailsLeftColumn {...leftColumnProps} />}
-                  rightColumn={<DetailsRightColumn {...rightColumnProps} />}
-                />
-              </fieldset>
-            </form>
-          )}
-          {vehicleId && (
-            <ObservationsManager
-              vehicleId={vehicleId}
+                <fieldset
+                  disabled={lockState.locked}
+                  className="contents"
+                  aria-label={lockState.locked ? t('invoice.lockedFieldsetLabel') : undefined}
+                >
+                  <ServiceDetailContent
+                    leftColumn={<DetailsLeftColumn {...leftColumnProps} />}
+                    rightColumn={<DetailsRightColumn {...rightColumnProps} />}
+                  />
+                </fieldset>
+              </form>
+            )}
+            {vehicleId && (
+              <ObservationsManager
+                vehicleId={vehicleId}
+                serviceRecordId={record.id}
+                openObservations={openObservations}
+                onAddObservations={handleAddObservationsToWorkOrder}
+                addingObservations={addingObservations}
+                // Saved concerns only: a row still being typed has no id yet, so
+                // there is nothing a finding could point at.
+                concerns={formState.concerns.filter(
+                  (c): c is { id: string; description: string; sortOrder: number } =>
+                    Boolean(c.id) && Boolean(c.description.trim())
+                )}
+                onControlsReady={(c) => {
+                  obsControlsRef.current = c
+                }}
+              />
+            )}
+          </WorkOrderLayoutProvider>
+        )}
+
+        {activeTab === 'images' && (
+          <div className="flex-1 overflow-y-auto overscroll-contain p-4">
+            <ServiceImagesManager
               serviceRecordId={record.id}
-              openObservations={openObservations}
-              onAddObservations={handleAddObservationsToWorkOrder}
-              addingObservations={addingObservations}
-              // Saved concerns only: a row still being typed has no id yet, so
-              // there is nothing a finding could point at.
-              concerns={formState.concerns.filter(
-                (c): c is { id: string; description: string; sortOrder: number } =>
-                  Boolean(c.id) && Boolean(c.description.trim())
-              )}
-              onControlsReady={(c) => {
-                obsControlsRef.current = c
-              }}
+              initialImages={imageAttachmentsForManager}
+              maxImages={maxImagesPerService}
+              customerId={customer?.id}
             />
-          )}
-        </WorkOrderLayoutProvider>
-      )}
-
-      {activeTab === 'images' && (
-        <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-          <ServiceImagesManager
-            serviceRecordId={record.id}
-            initialImages={imageAttachmentsForManager}
-            maxImages={maxImagesPerService}
-            customerId={customer?.id}
-          />
-        </div>
-      )}
-
-      {activeTab === 'video' && (
-        <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-          <ServiceVideoManager serviceRecordId={record.id} initialVideos={videoAttachments} />
-        </div>
-      )}
-
-      {activeTab === 'documents' && (
-        <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-          <ServiceDocumentsManager
-            serviceRecordId={record.id}
-            initialDocuments={documentAttachments}
-            maxDiagnostics={maxDiagnosticsPerService}
-            maxDocuments={maxDocumentsPerService}
-          />
-        </div>
-      )}
+          </div>
+        )}
 
-      {activeTab === 'statusReports' && (
-        <div className="flex-1 overflow-y-auto overscroll-contain p-4">{statusReportList}</div>
-      )}
+        {activeTab === 'video' && (
+          <div className="flex-1 overflow-y-auto overscroll-contain p-4">
+            <ServiceVideoManager serviceRecordId={record.id} initialVideos={videoAttachments} />
+          </div>
+        )}
 
-      <InventoryPickerDialog
-        open={formState.showInventoryPicker}
-        onOpenChange={formState.setShowInventoryPicker}
-        inventoryParts={inventoryParts}
-        currencyCode={currencyCode}
-        onSelectPart={(part) => formState.dirtySetPartItems((prev) => [part, ...prev])}
-        defaultMarkupPercent={defaultMarkupPercent}
-        markupAppliesToInventory={markupAppliesToInventory}
-      />
+        {activeTab === 'documents' && (
+          <div className="flex-1 overflow-y-auto overscroll-contain p-4">
+            <ServiceDocumentsManager
+              serviceRecordId={record.id}
+              initialDocuments={documentAttachments}
+              maxDiagnostics={maxDiagnosticsPerService}
+              maxDocuments={maxDocumentsPerService}
+            />
+          </div>
+        )}
 
-      <LaborPresetPickerDialog
-        open={formState.showPresetPicker}
-        onOpenChange={formState.setShowPresetPicker}
-        laborPresets={laborPresets}
-        onSelectPreset={handleSelectPreset}
-      />
+        {activeTab === 'statusReports' && (
+          <div className="flex-1 overflow-y-auto overscroll-contain p-4">{statusReportList}</div>
+        )}
 
-      <BarcodeScannerDialog
-        open={formState.showBarcodeScanner}
-        onOpenChange={formState.setShowBarcodeScanner}
-        onScan={handleBarcodeScan}
-        title={t('parts.scanTitle')}
-      />
+        <InventoryPickerDialog
+          open={formState.showInventoryPicker}
+          onOpenChange={formState.setShowInventoryPicker}
+          inventoryParts={inventoryParts}
+          currencyCode={currencyCode}
+          onSelectPart={(part) => formState.dirtySetPartItems((prev) => [part, ...prev])}
+          defaultMarkupPercent={defaultMarkupPercent}
+          markupAppliesToInventory={markupAppliesToInventory}
+        />
 
-      <ImageCarousel
-        images={formState.imageAttachments}
-        currentIndex={actions.carouselIndex}
-        onClose={() => actions.setCarouselIndex(null)}
-        onChangeIndex={actions.setCarouselIndex}
-      />
+        <LaborPresetPickerDialog
+          open={formState.showPresetPicker}
+          onOpenChange={formState.setShowPresetPicker}
+          laborPresets={laborPresets}
+          onSelectPreset={handleSelectPreset}
+        />
 
-      <PdfPreviewDialog
-        open={showPdfPreview}
-        onOpenChange={setShowPdfPreview}
-        url={`/api/protected/services/${record.id}/pdf`}
-      />
+        <BarcodeScannerDialog
+          open={formState.showBarcodeScanner}
+          onOpenChange={formState.setShowBarcodeScanner}
+          onScan={handleBarcodeScan}
+          title={t('parts.scanTitle')}
+        />
 
-      <SendEmailDialog
-        open={actions.showEmailDialog}
-        onOpenChange={actions.setShowEmailDialog}
-        defaultEmail={customer?.email || ''}
-        entityLabel={t('invoice.entityLabel')}
-        onSend={async (email, message, attachPdf) => {
-          const result = await sendInvoiceEmail({
-            serviceRecordId: record.id,
-            recipientEmail: email,
-            message,
-            attachPdf,
-          })
-          // Deliberately not awaited: the email dialog should show "sent" the
-          // moment it is, not sit spinning behind the "mark completed"
-          // confirmation that handleInvoiceSent may raise.
-          if (result.success) void handleInvoiceSent()
-          return result
-        }}
-      />
+        <ImageCarousel
+          images={formState.imageAttachments}
+          currentIndex={actions.carouselIndex}
+          onClose={() => actions.setCarouselIndex(null)}
+          onChangeIndex={actions.setCarouselIndex}
+        />
 
-      <ShareDialog
-        open={actions.showShareDialog}
-        onOpenChange={actions.setShowShareDialog}
-        recordId={record.id}
-        organizationId={organizationId}
-        initialToken={record.publicToken}
-        onSent={handleInvoiceSent}
-        customer={customer}
-        smsEnabled={smsEnabled}
-        emailEnabled={emailEnabled}
-      />
+        <PdfPreviewDialog
+          open={showPdfPreview}
+          onOpenChange={setShowPdfPreview}
+          url={`/api/protected/services/${record.id}/pdf`}
+        />
 
-      {customer && (
-        <>
-          <NotifyCustomerDialog
-            open={actions.showPaymentNotifyDialog}
-            onOpenChange={actions.setShowPaymentNotifyDialog}
-            customer={customer}
-            vehicle={record.vehicle}
-            defaultMessage={actions.paymentNotifyMessage}
-            emailSubject={t('invoice.emailSubject')}
-            smsEnabled={smsEnabled}
-            emailEnabled={emailEnabled}
-            relatedEntityType="service-record"
-            relatedEntityId={record.id}
-          />
-          <NotifyCustomerDialog
-            open={showNotifyDialog}
-            onOpenChange={setShowNotifyDialog}
-            customer={customer}
-            vehicle={record.vehicle}
-            defaultMessage={notifyMessage}
-            emailSubject={t('invoice.statusEmailSubject')}
-            smsEnabled={smsEnabled}
-            emailEnabled={emailEnabled}
-            relatedEntityType="service-record"
-            relatedEntityId={record.id}
-          />
-        </>
-      )}
+        <SendEmailDialog
+          open={actions.showEmailDialog}
+          onOpenChange={actions.setShowEmailDialog}
+          defaultEmail={customer?.email || ''}
+          entityLabel={t('invoice.entityLabel')}
+          onSend={async (email, message, attachPdf) => {
+            const result = await sendInvoiceEmail({
+              serviceRecordId: record.id,
+              recipientEmail: email,
+              message,
+              attachPdf,
+            })
+            // Deliberately not awaited: the email dialog should show "sent" the
+            // moment it is, not sit spinning behind the "mark completed"
+            // confirmation that handleInvoiceSent may raise.
+            if (result.success) void handleInvoiceSent()
+            return result
+          }}
+        />
 
-      {/* Expired dates check dialog */}
-      <Dialog
-        open={showDateCheck}
-        onOpenChange={(open) => {
-          if (!open) {
-            dateCheckResolveRef.current?.(false)
-            dateCheckResolveRef.current = null
-            setCustomizingDates(false)
-          }
-          setShowDateCheck(open)
-        }}
-      >
-        <DialogContent className="sm:max-w-sm">
-          <DialogHeader>
-            <DialogTitle className="flex items-center gap-2">
-              <AlertTriangle className="h-5 w-5 text-amber-500" />
-              {t('page.datesExpiredTitle')}
-            </DialogTitle>
-            <DialogDescription>{t('page.datesExpiredDescription')}</DialogDescription>
-          </DialogHeader>
+        <ShareDialog
+          open={actions.showShareDialog}
+          onOpenChange={actions.setShowShareDialog}
+          recordId={record.id}
+          organizationId={organizationId}
+          initialToken={record.publicToken}
+          onSent={handleInvoiceSent}
+          customer={customer}
+          smsEnabled={smsEnabled}
+          emailEnabled={emailEnabled}
+        />
 
-          {!customizingDates ? (
-            <div className="flex flex-col gap-2 pt-2">
-              <Button
-                disabled={updatingDates}
-                onClick={async () => {
-                  if (updatingDates) return
-                  const now = new Date(new Date().toISOString().split('T')[0])
-                  const due =
-                    defaultDueDays > 0
-                      ? new Date(now.getTime() + defaultDueDays * 86400000)
-                      : new Date(now.getTime() + 14 * 86400000)
-                  setPendingInvoiceDate(now)
-                  setPendingDueDate(due)
-                  setUpdatingDates(true)
-                  try {
-                    await updateServiceRecord({
-                      id: record.id,
-                      invoiceDate: toISODate(now),
-                      invoiceDueDate: toISODate(due),
-                    })
-                    setShowDateCheck(false)
-                    dateCheckResolveRef.current?.(true)
-                    dateCheckResolveRef.current = null
-                    router.refresh()
-                  } finally {
-                    setUpdatingDates(false)
-                  }
-                }}
-              >
-                {updatingDates && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                {t('page.datesExpiredUseToday')}
-              </Button>
-              <Button
-                variant="outline"
-                disabled={updatingDates}
-                onClick={() => {
-                  setShowDateCheck(false)
-                  dateCheckResolveRef.current?.(true)
-                  dateCheckResolveRef.current = null
-                }}
-              >
-                {t('page.datesExpiredProceed')}
-              </Button>
-              <Button
-                variant="ghost"
-                size="sm"
-                disabled={updatingDates}
-                onClick={() => setCustomizingDates(true)}
-              >
-                {t('page.datesExpiredCustomize')}
-              </Button>
-            </div>
-          ) : (
-            <>
-              <div className="space-y-3">
-                <div className="space-y-1">
-                  <Label className="text-xs">{t('basicInfo.invoiceDate')}</Label>
-                  <Popover>
-                    <PopoverTrigger asChild>
-                      <Button
-                        variant="outline"
-                        className="w-full justify-start text-left font-normal h-9 text-sm"
-                      >
-                        <CalendarIcon className="mr-2 h-3.5 w-3.5" />
-                        <span suppressHydrationWarning>{formatDate(pendingInvoiceDate)}</span>
-                      </Button>
-                    </PopoverTrigger>
-                    <PopoverContent className="w-auto p-0" align="start">
-                      <Calendar
-                        mode="single"
-                        selected={pendingInvoiceDate}
-                        onSelect={(d) => d && setPendingInvoiceDate(d)}
-                      />
-                    </PopoverContent>
-                  </Popover>
-                </div>
+        {customer && (
+          <>
+            <NotifyCustomerDialog
+              open={actions.showPaymentNotifyDialog}
+              onOpenChange={actions.setShowPaymentNotifyDialog}
+              customer={customer}
+              vehicle={record.vehicle}
+              defaultMessage={actions.paymentNotifyMessage}
+              emailSubject={t('invoice.emailSubject')}
+              smsEnabled={smsEnabled}
+              emailEnabled={emailEnabled}
+              relatedEntityType="service-record"
+              relatedEntityId={record.id}
+            />
+            <NotifyCustomerDialog
+              open={showNotifyDialog}
+              onOpenChange={setShowNotifyDialog}
+              customer={customer}
+              vehicle={record.vehicle}
+              defaultMessage={notifyMessage}
+              emailSubject={t('invoice.statusEmailSubject')}
+              smsEnabled={smsEnabled}
+              emailEnabled={emailEnabled}
+              relatedEntityType="service-record"
+              relatedEntityId={record.id}
+            />
+          </>
+        )}
 
-                <div className="space-y-1">
-                  <Label className="text-xs">{t('basicInfo.invoiceDueDate')}</Label>
-                  <Popover>
-                    <PopoverTrigger asChild>
-                      <Button
-                        variant="outline"
-                        className="w-full justify-start text-left font-normal h-9 text-sm"
-                      >
-                        <CalendarIcon className="mr-2 h-3.5 w-3.5" />
-                        <span suppressHydrationWarning>{formatDate(pendingDueDate)}</span>
-                      </Button>
-                    </PopoverTrigger>
-                    <PopoverContent className="w-auto p-0" align="start">
-                      <Calendar
-                        mode="single"
-                        selected={pendingDueDate}
-                        onSelect={(d) => d && setPendingDueDate(d)}
-                      />
-                    </PopoverContent>
-                  </Popover>
-                </div>
-              </div>
+        {/* Expired dates check dialog */}
+        <Dialog
+          open={showDateCheck}
+          onOpenChange={(open) => {
+            if (!open) {
+              dateCheckResolveRef.current?.(false)
+              dateCheckResolveRef.current = null
+              setCustomizingDates(false)
+            }
+            setShowDateCheck(open)
+          }}
+        >
+          <DialogContent className="sm:max-w-sm">
+            <DialogHeader>
+              <DialogTitle className="flex items-center gap-2">
+                <AlertTriangle className="h-5 w-5 text-amber-500" />
+                {t('page.datesExpiredTitle')}
+              </DialogTitle>
+              <DialogDescription>{t('page.datesExpiredDescription')}</DialogDescription>
+            </DialogHeader>
 
+            {!customizingDates ? (
               <div className="flex flex-col gap-2 pt-2">
                 <Button
                   disabled={updatingDates}
                   onClick={async () => {
                     if (updatingDates) return
+                    const now = new Date(new Date().toISOString().split('T')[0])
+                    const due =
+                      defaultDueDays > 0
+                        ? new Date(now.getTime() + defaultDueDays * 86400000)
+                        : new Date(now.getTime() + 14 * 86400000)
+                    setPendingInvoiceDate(now)
+                    setPendingDueDate(due)
                     setUpdatingDates(true)
                     try {
                       await updateServiceRecord({
                         id: record.id,
-                        invoiceDate: toISODate(pendingInvoiceDate),
-                        invoiceDueDate: toISODate(pendingDueDate),
+                        invoiceDate: toISODate(now),
+                        invoiceDueDate: toISODate(due),
                       })
                       setShowDateCheck(false)
-                      setCustomizingDates(false)
                       dateCheckResolveRef.current?.(true)
                       dateCheckResolveRef.current = null
                       router.refresh()
@@ -1033,20 +944,114 @@ export function ServicePageClient({
                   }}
                 >
                   {updatingDates && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                  {t('page.datesExpiredUpdate')}
+                  {t('page.datesExpiredUseToday')}
                 </Button>
                 <Button
                   variant="outline"
                   disabled={updatingDates}
-                  onClick={() => setCustomizingDates(false)}
+                  onClick={() => {
+                    setShowDateCheck(false)
+                    dateCheckResolveRef.current?.(true)
+                    dateCheckResolveRef.current = null
+                  }}
+                >
+                  {t('page.datesExpiredProceed')}
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  disabled={updatingDates}
+                  onClick={() => setCustomizingDates(true)}
                 >
-                  {t('page.datesExpiredBack')}
+                  {t('page.datesExpiredCustomize')}
                 </Button>
               </div>
-            </>
-          )}
-        </DialogContent>
-      </Dialog>
-    </div>
+            ) : (
+              <>
+                <div className="space-y-3">
+                  <div className="space-y-1">
+                    <Label className="text-xs">{t('basicInfo.invoiceDate')}</Label>
+                    <Popover>
+                      <PopoverTrigger asChild>
+                        <Button
+                          variant="outline"
+                          className="w-full justify-start text-left font-normal h-9 text-sm"
+                        >
+                          <CalendarIcon className="mr-2 h-3.5 w-3.5" />
+                          <span suppressHydrationWarning>{formatDate(pendingInvoiceDate)}</span>
+                        </Button>
+                      </PopoverTrigger>
+                      <PopoverContent className="w-auto p-0" align="start">
+                        <Calendar
+                          mode="single"
+                          selected={pendingInvoiceDate}
+                          onSelect={(d) => d && setPendingInvoiceDate(d)}
+                        />
+                      </PopoverContent>
+                    </Popover>
+                  </div>
+
+                  <div className="space-y-1">
+                    <Label className="text-xs">{t('basicInfo.invoiceDueDate')}</Label>
+                    <Popover>
+                      <PopoverTrigger asChild>
+                        <Button
+                          variant="outline"
+                          className="w-full justify-start text-left font-normal h-9 text-sm"
+                        >
+                          <CalendarIcon className="mr-2 h-3.5 w-3.5" />
+                          <span suppressHydrationWarning>{formatDate(pendingDueDate)}</span>
+                        </Button>
+                      </PopoverTrigger>
+                      <PopoverContent className="w-auto p-0" align="start">
+                        <Calendar
+                          mode="single"
+                          selected={pendingDueDate}
+                          onSelect={(d) => d && setPendingDueDate(d)}
+                        />
+                      </PopoverContent>
+                    </Popover>
+                  </div>
+                </div>
+
+                <div className="flex flex-col gap-2 pt-2">
+                  <Button
+                    disabled={updatingDates}
+                    onClick={async () => {
+                      if (updatingDates) return
+                      setUpdatingDates(true)
+                      try {
+                        await updateServiceRecord({
+                          id: record.id,
+                          invoiceDate: toISODate(pendingInvoiceDate),
+                          invoiceDueDate: toISODate(pendingDueDate),
+                        })
+                        setShowDateCheck(false)
+                        setCustomizingDates(false)
+                        dateCheckResolveRef.current?.(true)
+                        dateCheckResolveRef.current = null
+                        router.refresh()
+                      } finally {
+                        setUpdatingDates(false)
+                      }
+                    }}
+                  >
+                    {updatingDates && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                    {t('page.datesExpiredUpdate')}
+                  </Button>
+                  <Button
+                    variant="outline"
+                    disabled={updatingDates}
+                    onClick={() => setCustomizingDates(false)}
+                  >
+                    {t('page.datesExpiredBack')}
+                  </Button>
+                </div>
+              </>
+            )}
+          </DialogContent>
+        </Dialog>
+      </div>
+    </ShopFeeProvider>
   )
 }

+ 10 - 2
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -4,6 +4,11 @@ import { getServiceVideoCall } from '@/features/integrations/Actions/integration
 import { getWorkBays } from '@/features/workboard/Actions/workBayActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import {
+  readShopFee,
+  SHOP_FEE_SETTING_KEYS,
+  shopFeeLinesLast,
+} from '@/features/settings/Lib/shopFee'
 import {
   readWarrantyDefaults,
   WARRANTY_SETTING_KEYS,
@@ -81,6 +86,7 @@ export async function ServiceRecordPage({
       SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT,
       SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY,
       SETTING_KEYS.INVOICE_ACTIVE_DESIGN,
+      ...SHOP_FEE_SETTING_KEYS,
       ...WARRANTY_SETTING_KEYS,
     ]),
     getInventoryPartsList(),
@@ -120,6 +126,7 @@ export async function ServiceRecordPage({
   const taxEnabled = settings[SETTING_KEYS.TAX_ENABLED] !== 'false'
   const defaultTaxRate = taxEnabled ? Number(settings[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0 : 0
   const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0
+  const shopFee = readShopFee(settings)
   const defaultDueDays = Number(settings[SETTING_KEYS.INVOICE_DUE_DAYS]) || 0
   const { defaultMarkupPercent, markupAppliesToInventory } = readPartsPricingSettings(settings, {
     defaultMarkupPercent: SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT,
@@ -273,12 +280,12 @@ export async function ServiceRecordPage({
       // parts and inventory reconciliation restocks the "removed" part.
       inventoryPartId: p.inventoryPartId ?? undefined,
     })),
-    laborItems: record.laborItems.map((l) => ({
+    laborItems: shopFeeLinesLast(record.laborItems).map((l) => ({
       description: l.description,
       hours: l.hours,
       rate: l.rate,
       total: l.total,
-      pricingType: (l.pricingType as 'hourly' | 'service') || 'hourly',
+      pricingType: (l.pricingType as 'hourly' | 'service' | 'shopFee') || 'hourly',
     })),
     attachments: [],
     subtotal: record.subtotal,
@@ -372,6 +379,7 @@ export async function ServiceRecordPage({
         defaultTaxRate={defaultTaxRate}
         taxEnabled={taxEnabled}
         defaultLaborRate={defaultLaborRate}
+        shopFee={shopFee}
         initialData={initialData}
         inventoryParts={inventoryParts}
         laborPresets={laborPresets}

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

@@ -1,3 +1,4 @@
+import type { ShopFeeConfig } from '@/features/settings/Lib/shopFee'
 import type { WorkOrderStatusOption } from '@/features/work-order-statuses/Lib/stages'
 import type { WorkOrderLayout } from '@/lib/work-order-layout'
 import type { DesignAutoRule } from '@/features/invoice-designer/Lib/designRules'
@@ -64,6 +65,8 @@ export interface ServicePageClientProps {
   defaultTaxRate: number
   taxEnabled: boolean
   defaultLaborRate: number
+  /** The workshop's shop fee, for re-pricing a percentage one as lines change. */
+  shopFee?: ShopFeeConfig | null
   initialData: InitialData
   inventoryParts: InventoryPartOption[]
   initialVehicle: {

+ 25 - 2
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -5,6 +5,11 @@ import { useDeferredCommit } from '@/hooks/use-deferred-commit'
 import { lineTotal, repricePartRow } from '@/features/inventory/Lib/partPricing'
 import { reconcileConcernRows } from '@/features/vehicles/Lib/concernStory'
 import { addedLaborLines } from '@/features/vehicles/Lib/laborLines'
+import {
+  recalculateShopFeeLines,
+  shopFeeLinesLast,
+  type ShopFeeConfig,
+} from '@/features/settings/Lib/shopFee'
 import type { ConcernRow } from '../service-edit/form-types'
 import type { ServicePartInput, ServiceLaborInput, InitialData } from './service-page-types'
 import type { ServiceDetail } from '../service-detail/types'
@@ -16,12 +21,15 @@ export function useServiceFormState({
   currentUserName,
   record,
   locked = false,
+  shopFee = null,
 }: {
   vehicleId: string | null
   initialData: InitialData
   defaultTaxRate: number
   currentUserName: string
   record: ServiceDetail
+  /** The workshop's shop fee; a percentage one is re-priced as lines change. */
+  shopFee?: ShopFeeConfig | null
   /** A locked invoice refuses saves, so it must not queue one. */
   locked?: boolean
 }) {
@@ -244,6 +252,20 @@ export function useServiceFormState({
 
   // Computed totals
   const partsSubtotal = partItems.reduce((sum, p) => sum + p.total, 0)
+
+  // A percentage shop fee follows the lines it is a percentage of. Returns the
+  // same array when the fee is already right, so this settles after one pass.
+  // A fee that did move is a change to save: the workshop's percentage may
+  // have changed since the job was last saved, and the PDF prints the saved
+  // figure, so the screen must not show one fee and the invoice another.
+  useEffect(() => {
+    if (locked) return
+    const next = recalculateShopFeeLines(laborItems, partsSubtotal, shopFee)
+    if (next === laborItems) return
+    setLaborItems(next)
+    markDirty()
+  }, [laborItems, partsSubtotal, shopFee, locked, markDirty])
+
   // Internal-only: what the parts cost the workshop before markup. Shown in
   // the totals card for the mechanic, never on the PDF or share views.
   const partsCostSubtotal = partItems.reduce(
@@ -388,9 +410,10 @@ export function useServiceFormState({
   /**
    * What a save has to write: the list on screen plus anything added
    * elsewhere that has not been shown yet. A save replaces every labour line
-   * of the job, so a line left out of this is a line deleted.
+   * of the job, so a line left out of this is a line deleted. The shop fee
+   * goes last, under the work it is charged on.
    */
-  const laborItemsForSave = [...laborItems, ...laborAddedElsewhere]
+  const laborItemsForSave = shopFeeLinesLast([...laborItems, ...laborAddedElsewhere])
 
   const dirtySetDiscountType = useCallback(
     (v: string) => {

+ 3 - 0
src/features/vehicles/Lib/addPart.ts

@@ -1,4 +1,5 @@
 import { db } from '@/lib/db'
+import { refreshShopFeeLines } from './shopFeeLines'
 import { documentTotals } from '@/features/settings/Lib/workshopTax'
 import { reconcileInventoryForParts } from '@/features/inventory/Lib/reconcileStock'
 import { assertInvoiceEditable } from '@/lib/document-lock.server'
@@ -80,6 +81,8 @@ export async function addPart(args: {
       },
     })
 
+    await refreshShopFeeLines(tx, record.id, organizationId)
+
     const [partsAgg, laborAgg] = await Promise.all([
       tx.servicePart.aggregate({
         where: { serviceRecordId: record.id },

+ 28 - 0
src/features/vehicles/Lib/createDraftRecord.ts

@@ -1,9 +1,16 @@
 import { db, type TxClient } from '@/lib/db'
 import {
+  documentTotals,
   readWorkshopTax,
   taxFieldsForNewDocument,
   WORKSHOP_TAX_SETTING_KEYS,
 } from '@/features/settings/Lib/workshopTax'
+import {
+  newShopFeeLine,
+  readShopFee,
+  SHOP_FEE_SETTING_KEYS,
+  shopFeeFor,
+} from '@/features/settings/Lib/shopFee'
 import {
   readWarrantyDefaults,
   WARRANTY_SETTING_KEYS,
@@ -67,6 +74,7 @@ export async function createDraftRecord(
             SETTING_KEYS.WORK_ORDER_TITLE_TEMPLATE,
             ...WORKSHOP_TAX_SETTING_KEYS,
             ...WARRANTY_SETTING_KEYS,
+            ...SHOP_FEE_SETTING_KEYS,
             'workboard.workDayStart',
           ],
         },
@@ -233,6 +241,24 @@ export async function createDraftRecord(
     ? warrantyFieldsForNewDocument(readWarrantyDefaults(settingsMap), 'workOrder')
     : EMPTY_WARRANTY
 
+  // The workshop's shop fee, on work done to a vehicle. A counter sale is
+  // parts over the desk and uses none of the supplies the fee pays for.
+  const shopFee = isShopWork ? shopFeeFor(readShopFee(settingsMap), 'workOrder') : null
+  const feeLine = shopFee ? newShopFeeLine(shopFee) : null
+  const feeTotals =
+    feeLine && feeLine.total > 0
+      ? {
+          subtotal: feeLine.total,
+          ...documentTotals({
+            subtotal: feeLine.total,
+            discountAmount: 0,
+            taxRate: taxFields.taxRate,
+            taxInclusive: taxFields.taxInclusive,
+            taxComponents: taxFields.taxComponents,
+          }),
+        }
+      : null
+
   return writer.serviceRecord.create({
     data: {
       organizationId,
@@ -248,6 +274,8 @@ export async function createDraftRecord(
       workBayId: opts.workBayId || undefined,
       invoiceNumber,
       ...taxFields,
+      ...(feeTotals ?? {}),
+      ...(feeLine ? { laborItems: { create: [feeLine] } } : {}),
       ...warranty,
       warrantyExpiresAt: warrantyExpiryFor(warranty, serviceDate, timeZone),
       serviceDate,

+ 2 - 0
src/features/vehicles/Lib/laborLines.ts

@@ -1,4 +1,5 @@
 import type { ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
+import { isShopFeeLine } from '@/features/settings/Lib/shopFee'
 
 /**
  * What a job gained in lines of work while a page was open.
@@ -23,6 +24,7 @@ export function addedLaborLines(
   }
   const added: ServiceLaborInput[] = []
   for (const line of after) {
+    if (isShopFeeLine(line)) continue
     const k = key(line)
     const left = remaining.get(k) ?? 0
     if (left > 0) remaining.set(k, left - 1)

+ 5 - 0
src/features/vehicles/Lib/retotalServiceRecord.ts

@@ -1,5 +1,6 @@
 import { db, type TxClient } from '@/lib/db'
 import { documentTotals } from '@/features/settings/Lib/workshopTax'
+import { refreshShopFeeLines } from './shopFeeLines'
 
 /**
  * Re-totals a service record from its line items.
@@ -19,6 +20,7 @@ export async function retotalServiceRecord(
   const record = await tx.serviceRecord.findUnique({
     where: { id: serviceRecordId },
     select: {
+      organizationId: true,
       discountType: true,
       discountValue: true,
       taxRate: true,
@@ -28,6 +30,9 @@ export async function retotalServiceRecord(
   })
   if (!record) return
 
+  // A percentage shop fee follows the lines it is a percentage of.
+  if (record.organizationId) await refreshShopFeeLines(tx, serviceRecordId, record.organizationId)
+
   const [parts, labor] = await Promise.all([
     tx.servicePart.aggregate({ where: { serviceRecordId }, _sum: { total: true } }),
     tx.serviceLabor.aggregate({ where: { serviceRecordId }, _sum: { total: true } }),

+ 52 - 0
src/features/vehicles/Lib/shopFeeLines.ts

@@ -0,0 +1,52 @@
+import { db, type TxClient } from '@/lib/db'
+import {
+  isShopFeeLine,
+  readShopFee,
+  recalculateShopFeeLines,
+  SHOP_FEE_SETTING_KEYS,
+  type ShopFeeConfig,
+} from '@/features/settings/Lib/shopFee'
+
+/** The workshop's shop fee, read from its settings. */
+export async function loadShopFee(organizationId: string): Promise<ShopFeeConfig | null> {
+  const rows = await db.appSetting.findMany({
+    where: { organizationId, key: { in: [...SHOP_FEE_SETTING_KEYS] } },
+    select: { key: true, value: true },
+  })
+  return readShopFee(Object.fromEntries(rows.map((r) => [r.key, r.value])))
+}
+
+/**
+ * Re-prices a work order's percentage shop fee from its current lines.
+ *
+ * Runs before anything that re-totals a job from outside the editor (a part
+ * added from the phone, a labour line, a tire job), so the fee on the job
+ * follows the work it is a percentage of. A flat fee is left as it is.
+ */
+export async function refreshShopFeeLines(
+  tx: TxClient | typeof db,
+  serviceRecordId: string,
+  organizationId: string
+): Promise<void> {
+  const labor = await tx.serviceLabor.findMany({
+    where: { serviceRecordId },
+    select: { id: true, pricingType: true, hours: true, rate: true, total: true },
+  })
+  if (!labor.some(isShopFeeLine)) return
+  const config = await loadShopFee(organizationId)
+  if (config?.mode !== 'percent') return
+
+  const parts = await tx.servicePart.aggregate({
+    where: { serviceRecordId },
+    _sum: { total: true },
+  })
+  const next = recalculateShopFeeLines(labor, parts._sum.total || 0, config)
+  if (next === labor) return
+  for (const line of next) {
+    if (!isShopFeeLine(line)) continue
+    await tx.serviceLabor.update({
+      where: { id: line.id },
+      data: { hours: line.hours, rate: line.rate, total: line.total },
+    })
+  }
+}

+ 1 - 1
src/features/vehicles/Schema/serviceSchema.ts

@@ -25,7 +25,7 @@ export const serviceLaborSchema = z.object({
   hours: z.coerce.number().min(0).default(0),
   rate: z.coerce.number().min(0).default(0),
   total: z.coerce.number().min(0).default(0),
-  pricingType: z.enum(['hourly', 'service']).default('hourly'),
+  pricingType: z.enum(['hourly', 'service', 'shopFee']).default('hourly'),
 })
 
 export const serviceAttachmentSchema = z.object({

+ 11 - 4
src/lib/cron/report-schedules.ts

@@ -17,6 +17,7 @@ import {
   zonedParts,
 } from '@/lib/timezone'
 import { shiftWorkshopTime, workshopMonthKey } from '@/lib/workshop-datetime'
+import { isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 
 // --------------- date helpers ---------------
@@ -282,7 +283,7 @@ async function fetchTechnicians(orgId: string, start: Date, end: Date) {
       technician: { select: { name: true } },
       totalAmount: true,
       cost: true,
-      laborItems: { select: { hours: true } },
+      laborItems: { select: { hours: true, pricingType: true } },
     },
   })
   const byTech: Record<
@@ -296,7 +297,10 @@ async function fetchTechnicians(orgId: string, start: Date, end: Date) {
       byTech[key] = { techName: name, jobCount: 0, totalRevenue: 0, totalLaborHours: 0 }
     byTech[key].jobCount += 1
     byTech[key].totalRevenue += r.totalAmount > 0 ? r.totalAmount : r.cost
-    byTech[key].totalLaborHours += r.laborItems.reduce((s, l) => s + l.hours, 0)
+    byTech[key].totalLaborHours += r.laborItems.reduce(
+      (s, l) => s + (isShopFeeLine(l) ? 0 : l.hours),
+      0
+    )
   }
   const technicians = Object.values(byTech)
     .map((d) => ({
@@ -383,7 +387,7 @@ async function fetchJobAnalytics(orgId: string, start: Date, end: Date, timeZone
       cost: true,
       serviceDate: true,
       startDateTime: true,
-      laborItems: { select: { hours: true } },
+      laborItems: { select: { hours: true, pricingType: true } },
     },
   })
   const totalValue = records.reduce((s, r) => s + (r.totalAmount > 0 ? r.totalAmount : r.cost), 0)
@@ -394,7 +398,10 @@ async function fetchJobAnalytics(orgId: string, start: Date, end: Date, timeZone
     if (!byType[r.type]) byType[r.type] = { count: 0, totalValue: 0, totalHours: 0 }
     byType[r.type].count += 1
     byType[r.type].totalValue += r.totalAmount > 0 ? r.totalAmount : r.cost
-    byType[r.type].totalHours += r.laborItems.reduce((s, l) => s + l.hours, 0)
+    byType[r.type].totalHours += r.laborItems.reduce(
+      (s, l) => s + (isShopFeeLine(l) ? 0 : l.hours),
+      0
+    )
     const _d = r.startDateTime ?? r.serviceDate
     dayCount[zonedParts(_d, timeZone).weekday] += 1
     const month = workshopMonthKey(_d, timeZone)