Forráskód Böngészése

Group invoice and quote lines by part category (#448)

Bernt Christian Egeland 3 hete
szülő
commit
aa5a70c20f
100 módosított fájl, 832 hozzáadás és 75 törlés
  1. 2 0
      messages/de/pdf.json
  2. 1 0
      messages/de/quotes.json
  3. 1 0
      messages/de/service.json
  4. 6 0
      messages/de/settings.json
  5. 2 0
      messages/en/pdf.json
  6. 1 0
      messages/en/quotes.json
  7. 1 0
      messages/en/service.json
  8. 6 0
      messages/en/settings.json
  9. 2 0
      messages/es/pdf.json
  10. 1 0
      messages/es/quotes.json
  11. 1 0
      messages/es/service.json
  12. 6 0
      messages/es/settings.json
  13. 2 0
      messages/fr/pdf.json
  14. 1 0
      messages/fr/quotes.json
  15. 1 0
      messages/fr/service.json
  16. 6 0
      messages/fr/settings.json
  17. 2 0
      messages/it/pdf.json
  18. 1 0
      messages/it/quotes.json
  19. 1 0
      messages/it/service.json
  20. 6 0
      messages/it/settings.json
  21. 2 0
      messages/lt/pdf.json
  22. 1 0
      messages/lt/quotes.json
  23. 1 0
      messages/lt/service.json
  24. 6 0
      messages/lt/settings.json
  25. 2 0
      messages/nb/pdf.json
  26. 1 0
      messages/nb/quotes.json
  27. 1 0
      messages/nb/service.json
  28. 6 0
      messages/nb/settings.json
  29. 2 0
      messages/nl/pdf.json
  30. 1 0
      messages/nl/quotes.json
  31. 1 0
      messages/nl/service.json
  32. 6 0
      messages/nl/settings.json
  33. 2 0
      messages/pl/pdf.json
  34. 1 0
      messages/pl/quotes.json
  35. 1 0
      messages/pl/service.json
  36. 6 0
      messages/pl/settings.json
  37. 2 0
      messages/pt-BR/pdf.json
  38. 1 0
      messages/pt-BR/quotes.json
  39. 1 0
      messages/pt-BR/service.json
  40. 6 0
      messages/pt-BR/settings.json
  41. 2 0
      messages/pt-PT/pdf.json
  42. 1 0
      messages/pt-PT/quotes.json
  43. 1 0
      messages/pt-PT/service.json
  44. 6 0
      messages/pt-PT/settings.json
  45. 2 0
      messages/ru/pdf.json
  46. 1 0
      messages/ru/quotes.json
  47. 1 0
      messages/ru/service.json
  48. 6 0
      messages/ru/settings.json
  49. 2 0
      messages/tr/pdf.json
  50. 1 0
      messages/tr/quotes.json
  51. 1 0
      messages/tr/service.json
  52. 6 0
      messages/tr/settings.json
  53. 8 0
      prisma/migrations/20261007200000_part_line_category/migration.sql
  54. 3 0
      prisma/schema/inventory.prisma
  55. 3 0
      prisma/schema/quotes.prisma
  56. 4 0
      prisma/schema/service-records.prisma
  57. 191 0
      src/__tests__/features/invoice-designer/grouped-lines.test.tsx
  58. 1 0
      src/app/(authenticated)/labor-presets/page.tsx
  59. 1 0
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  60. 1 0
      src/app/(public)/share/quote/[orgId]/[token]/page.tsx
  61. 2 0
      src/app/api/protected/backup/import/route.ts
  62. 2 0
      src/app/api/v1/tech/jobs/[id]/parts/route.ts
  63. 8 1
      src/app/api/v1/tech/jobs/[id]/route.ts
  64. 2 0
      src/features/ai/tools/workshop-columns.ts
  65. 2 2
      src/features/ai/tools/workshop-tools.ts
  66. 24 0
      src/features/invoice-designer/Components/DesignerInspector.tsx
  67. 44 1
      src/features/invoice-designer/Components/sample.ts
  68. 13 0
      src/features/invoice-designer/Pdf/buildInvoicePrint.ts
  69. 15 0
      src/features/invoice-designer/Pdf/buildQuotePrint.ts
  70. 3 0
      src/features/invoice-designer/Pdf/estimateHeights.ts
  71. 73 0
      src/features/invoice-designer/Pdf/lineGroups.ts
  72. 37 24
      src/features/invoice-designer/Pdf/renderPdf.tsx
  73. 29 17
      src/features/invoice-designer/Render/renderHtml.tsx
  74. 97 18
      src/features/invoice-designer/Spec/buildSpec.ts
  75. 7 0
      src/features/invoice-designer/Spec/documentSpec.ts
  76. 3 0
      src/features/labor-presets/Actions/laborPresetActions.ts
  77. 1 0
      src/features/labor-presets/Components/InventorySearchDialog.tsx
  78. 5 0
      src/features/labor-presets/Components/LaborPresetForm.tsx
  79. 1 0
      src/features/labor-presets/Components/LaborPresetPickerDialog.tsx
  80. 3 0
      src/features/labor-presets/Components/PresetPartsEditor.tsx
  81. 1 0
      src/features/labor-presets/Schema/laborPresetSchema.ts
  82. 1 0
      src/features/quotes/Components/QuotePageClient.tsx
  83. 43 6
      src/features/quotes/Components/QuotePartsEditor.tsx
  84. 2 0
      src/features/quotes/Components/quote-page-types.ts
  85. 1 0
      src/features/quotes/Components/useQuoteFormState.ts
  86. 1 0
      src/features/quotes/Lib/copyQuoteToJob.ts
  87. 1 0
      src/features/quotes/Schema/quoteSchema.ts
  88. 8 0
      src/features/settings/Schema/invoiceLayoutSchema.ts
  89. 6 0
      src/features/tire-hotel/Actions/tireJobActions.ts
  90. 2 0
      src/features/tire-hotel/Lib/addTireLine.ts
  91. 2 0
      src/features/vehicles/Actions/serviceActions.ts
  92. 1 0
      src/features/vehicles/Components/MyActiveJobs.tsx
  93. 2 0
      src/features/vehicles/Components/invoice-pdf/types.ts
  94. 1 0
      src/features/vehicles/Components/service-detail/types.ts
  95. 1 0
      src/features/vehicles/Components/service-edit/InventoryPickerDialog.tsx
  96. 40 6
      src/features/vehicles/Components/service-edit/PartsEditor.tsx
  97. 2 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  98. 1 0
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  99. 2 0
      src/features/vehicles/Lib/addPart.ts
  100. 1 0
      src/features/vehicles/Schema/serviceSchema.ts

+ 2 - 0
messages/de/pdf.json

@@ -20,6 +20,8 @@
     "type": "Typ: {type}",
     "tech": "Techniker: {tech}",
     "parts": "Teile",
+    "otherParts": "Sonstige Teile",
+    "groupSubtotal": "Zwischensumme {group}",
     "partNumber": "Teilenr.",
     "description": "Beschreibung",
     "qty": "Menge",

+ 1 - 0
messages/de/quotes.json

@@ -107,6 +107,7 @@
     "title": "Teile",
     "addPart": "Teil hinzufügen",
     "partNumber": "Teilenr.",
+    "category": "Kategorie",
     "name": "Name",
     "namePlaceholder": "Name *",
     "qty": "Menge",

+ 1 - 0
messages/de/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Aus Bestand",
     "addPart": "Teil hinzufügen",
     "partNumber": "Teil #",
+    "category": "Kategorie",
     "name": "Name",
     "qty": "Menge",
     "unitCost": "Kosten",

+ 6 - 0
messages/de/settings.json

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "Eine nummerierte Liste aller Positionen des Auftrags, erst Arbeit, dann Teile, anstelle der getrennten Teile- und Arbeitstabellen.",
     "discardChanges": "Änderungen verwerfen",
     "tablesExclusiveHint": "Das Blatt druckt eine Anordnung: eine nummerierte Liste oder getrennte Teile- und Arbeitstabellen. Wird die eine eingeschaltet, geht die andere aus.",
+    "groupLines": "Zeilen gruppieren",
+    "groupLinesOff": "Aus",
+    "groupLinesCategory": "Nach Kategorie",
+    "groupLinesHint": "Nach Kategorie druckt die Teile unter einer Überschrift je Kategorie mit eigener Zwischensumme, in der gemeinsamen Liste zuerst die Arbeit. Teile ohne Kategorie werden als „Sonstige Teile“ zusammengefasst.",
     "innerPadding": "Innenabstand",
     "totalsWidth": "Breite",
     "totalsWidthHint": "Leer füllt die Zeile. Ein Wert in Punkt hängt den Kasten rechts an; in einer Spalte nimmt er deren Breite.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Vordere Bremsen erneuert",
       "itemDisc": "Bremsscheibe, vorne",
       "itemPads": "Bremsbelagsatz, Vorderachse",
+      "categoryBrakes": "Bremsen",
+      "categoryConsumables": "Werkstattbedarf",
       "itemConsumables": "Verbrauchsmaterial",
       "unitPcs": "Stk",
       "unitSet": "Satz",

+ 2 - 0
messages/en/pdf.json

@@ -20,6 +20,8 @@
     "type": "Type: {type}",
     "tech": "Tech: {tech}",
     "parts": "Parts",
+    "otherParts": "Other parts",
+    "groupSubtotal": "{group} subtotal",
     "partNumber": "Part #",
     "description": "Description",
     "qty": "Qty",

+ 1 - 0
messages/en/quotes.json

@@ -107,6 +107,7 @@
     "title": "Parts",
     "addPart": "Add Part",
     "partNumber": "Part #",
+    "category": "Category",
     "name": "Name",
     "namePlaceholder": "Name *",
     "qty": "Qty",

+ 1 - 0
messages/en/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "From Inventory",
     "addPart": "Add Part",
     "partNumber": "Part #",
+    "category": "Category",
     "name": "Name",
     "qty": "Qty",
     "unitCost": "Cost",

+ 6 - 0
messages/en/settings.json

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "One numbered list of every line on the job, labor first and parts after, in place of the separate parts and labor tables.",
     "discardChanges": "Discard changes",
     "tablesExclusiveHint": "The sheet prints one arrangement: a single numbered list, or separate parts and labor tables. Switching one on switches the other off.",
+    "groupLines": "Group lines",
+    "groupLinesOff": "Off",
+    "groupLinesCategory": "By category",
+    "groupLinesHint": "By category prints the parts under a heading per category with a subtotal for each, labor first in the combined list. Parts without a category are added up as \"Other parts\".",
     "innerPadding": "Inner spacing",
     "totalsWidth": "Width",
     "totalsWidthHint": "Empty spans the row. Set points to hang the box on the right; in a column it takes the column's width.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Front brakes replaced",
       "itemDisc": "Brake disc, front",
       "itemPads": "Brake pad set, front axle",
+      "categoryBrakes": "Brakes",
+      "categoryConsumables": "Shop supplies",
       "itemConsumables": "Consumables",
       "unitPcs": "pcs",
       "unitSet": "set",

+ 2 - 0
messages/es/pdf.json

@@ -20,6 +20,8 @@
     "type": "Tipo: {type}",
     "tech": "Técnico: {tech}",
     "parts": "Piezas",
+    "otherParts": "Otras piezas",
+    "groupSubtotal": "Subtotal {group}",
     "partNumber": "N.º de pieza",
     "description": "Descripción",
     "qty": "Cant.",

+ 1 - 0
messages/es/quotes.json

@@ -107,6 +107,7 @@
     "title": "Repuestos",
     "addPart": "Agregar repuesto",
     "partNumber": "N.º de repuesto",
+    "category": "Categoría",
     "name": "Nombre",
     "namePlaceholder": "Nombre *",
     "qty": "Cant.",

+ 1 - 0
messages/es/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Del Inventario",
     "addPart": "Agregar Pieza",
     "partNumber": "N° de Pieza",
+    "category": "Categoría",
     "name": "Nombre",
     "qty": "Cantidad",
     "unitCost": "Costo",

+ 6 - 0
messages/es/settings.json

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "Una lista numerada de todas las líneas del trabajo, primero mano de obra y luego piezas, en lugar de las tablas separadas.",
     "discardChanges": "Descartar cambios",
     "tablesExclusiveHint": "La hoja imprime una disposición: una lista numerada única o tablas separadas de piezas y mano de obra. Activar una desactiva la otra.",
+    "groupLines": "Agrupar líneas",
+    "groupLinesOff": "No",
+    "groupLinesCategory": "Por categoría",
+    "groupLinesHint": "Por categoría imprime las piezas bajo un encabezado por categoría con un subtotal para cada una, con la mano de obra primero en la lista combinada. Las piezas sin categoría se suman como «Otras piezas».",
     "innerPadding": "Espaciado interno",
     "totalsWidth": "Ancho",
     "totalsWidthHint": "Vacío ocupa toda la fila. Con un valor en puntos, el bloque cuelga a la derecha; en una columna toma el ancho de la columna.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Frenos delanteros sustituidos",
       "itemDisc": "Disco de freno, delantero",
       "itemPads": "Juego de pastillas, eje delantero",
+      "categoryBrakes": "Frenos",
+      "categoryConsumables": "Consumibles de taller",
       "itemConsumables": "Consumibles",
       "unitPcs": "uds",
       "unitSet": "juego",

+ 2 - 0
messages/fr/pdf.json

@@ -20,6 +20,8 @@
     "type": "Type : {type}",
     "tech": "Technicien : {tech}",
     "parts": "Pièces",
+    "otherParts": "Autres pièces",
+    "groupSubtotal": "Sous-total {group}",
     "partNumber": "N° de pièce",
     "description": "Description",
     "qty": "Qté",

+ 1 - 0
messages/fr/quotes.json

@@ -107,6 +107,7 @@
     "title": "Pièces",
     "addPart": "Ajouter une pièce",
     "partNumber": "N° de pièce",
+    "category": "Catégorie",
     "name": "Nom",
     "namePlaceholder": "Nom *",
     "qty": "Qté",

+ 1 - 0
messages/fr/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "De l'Inventaire",
     "addPart": "Ajouter une Pièce",
     "partNumber": "Nº de Pièce",
+    "category": "Catégorie",
     "name": "Nom",
     "qty": "Qté",
     "unitCost": "Coût",

+ 6 - 0
messages/fr/settings.json

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "Une liste numérotée de toutes les lignes du travail, main-d'œuvre d'abord puis pièces, à la place des tableaux séparés.",
     "discardChanges": "Annuler les modifications",
     "tablesExclusiveHint": "La feuille imprime une disposition : une liste numérotée unique, ou des tableaux séparés pour pièces et main-d'œuvre. Activer l'une désactive l'autre.",
+    "groupLines": "Grouper les lignes",
+    "groupLinesOff": "Non",
+    "groupLinesCategory": "Par catégorie",
+    "groupLinesHint": "Par catégorie imprime les pièces sous un titre par catégorie avec un sous-total pour chacune, la main-d’œuvre en premier dans la liste combinée. Les pièces sans catégorie sont additionnées sous « Autres pièces ».",
     "innerPadding": "Marge intérieure",
     "totalsWidth": "Largeur",
     "totalsWidthHint": "Vide, le bloc occupe toute la ligne. Une valeur en points l'accroche à droite ; dans une colonne, il prend la largeur de la colonne.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Freins avant remplacés",
       "itemDisc": "Disque de frein, avant",
       "itemPads": "Jeu de plaquettes, essieu avant",
+      "categoryBrakes": "Freins",
+      "categoryConsumables": "Fournitures d’atelier",
       "itemConsumables": "Consommables",
       "unitPcs": "pcs",
       "unitSet": "jeu",

+ 2 - 0
messages/it/pdf.json

@@ -20,6 +20,8 @@
     "type": "Tipo: {type}",
     "tech": "Tecnico: {tech}",
     "parts": "Ricambi",
+    "otherParts": "Altri ricambi",
+    "groupSubtotal": "Subtotale {group}",
     "partNumber": "N. pezzo",
     "description": "Descrizione",
     "qty": "Qtà",

+ 1 - 0
messages/it/quotes.json

@@ -107,6 +107,7 @@
     "title": "Ricambi",
     "addPart": "Aggiungi ricambio",
     "partNumber": "N. ricambio",
+    "category": "Categoria",
     "name": "Nome",
     "namePlaceholder": "Nome *",
     "qty": "Qtà",

+ 1 - 0
messages/it/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Da Inventario",
     "addPart": "Aggiungi Pezzo",
     "partNumber": "Numero Pezzo",
+    "category": "Categoria",
     "name": "Nome",
     "qty": "Qtà",
     "unitCost": "Costo",

+ 6 - 0
messages/it/settings.json

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "Un elenco numerato di tutte le righe del lavoro, prima la manodopera e poi i ricambi, al posto delle tabelle separate.",
     "discardChanges": "Scarta le modifiche",
     "tablesExclusiveHint": "Il foglio stampa una disposizione: un unico elenco numerato oppure tabelle separate per ricambi e manodopera. Attivarne una disattiva l'altra.",
+    "groupLines": "Raggruppa righe",
+    "groupLinesOff": "No",
+    "groupLinesCategory": "Per categoria",
+    "groupLinesHint": "Per categoria stampa i ricambi sotto un’intestazione per categoria con un subtotale per ciascuna, con la manodopera per prima nell’elenco combinato. I ricambi senza categoria sono sommati come «Altri ricambi».",
     "innerPadding": "Spaziatura interna",
     "totalsWidth": "Larghezza",
     "totalsWidthHint": "Vuoto occupa tutta la riga. Un valore in punti appende il blocco a destra; in una colonna prende la larghezza della colonna.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Freni anteriori sostituiti",
       "itemDisc": "Disco freno, anteriore",
       "itemPads": "Kit pastiglie, asse anteriore",
+      "categoryBrakes": "Freni",
+      "categoryConsumables": "Materiali di consumo",
       "itemConsumables": "Materiali di consumo",
       "unitPcs": "pz",
       "unitSet": "kit",

+ 2 - 0
messages/lt/pdf.json

@@ -20,6 +20,8 @@
     "type": "Tipas: {type}",
     "tech": "Technikas: {tech}",
     "parts": "Dalys",
+    "otherParts": "Kitos dalys",
+    "groupSubtotal": "{group} – tarpinė suma",
     "partNumber": "Dalies Nr.",
     "description": "Aprašymas",
     "qty": "Kiekis",

+ 1 - 0
messages/lt/quotes.json

@@ -107,6 +107,7 @@
     "title": "Dalys",
     "addPart": "Pridėti dalį",
     "partNumber": "Dalies Nr.",
+    "category": "Kategorija",
     "name": "Pavadinimas",
     "namePlaceholder": "Pavadinimas *",
     "qty": "Kiekis",

+ 1 - 0
messages/lt/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Iš inventoriaus",
     "addPart": "Pridėti dalį",
     "partNumber": "Dalies Nr.",
+    "category": "Kategorija",
     "name": "Pavadinimas",
     "qty": "Kiekis",
     "unitCost": "Savikaina",

+ 6 - 0
messages/lt/settings.json

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "Vienas numeruotas visų darbo eilučių sąrašas, pirmiausia darbai, paskui dalys, vietoj atskirų lentelių.",
     "discardChanges": "Atmesti pakeitimus",
     "tablesExclusiveHint": "Lape spausdinamas vienas išdėstymas: vienas numeruotas sąrašas arba atskiros dalių ir darbų lentelės. Įjungus vieną, kitas išsijungia.",
+    "groupLines": "Grupuoti eilutes",
+    "groupLinesOff": "Išjungta",
+    "groupLinesCategory": "Pagal kategoriją",
+    "groupLinesHint": "Pagal kategoriją spausdina dalis po atskira kiekvienos kategorijos antrašte su tarpine suma, bendrame sąraše pirmiausia darbus. Dalys be kategorijos sumuojamos kaip „Kitos dalys“.",
     "innerPadding": "Vidinis tarpas",
     "totalsWidth": "Plotis",
     "totalsWidthHint": "Tuščias užima visą eilutę. Nurodžius taškus, blokas kabinamas dešinėje; stulpelyje jis užima stulpelio plotį.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Pakeisti priekiniai stabdžiai",
       "itemDisc": "Stabdžių diskas, priekinis",
       "itemPads": "Kaladėlių komplektas, priekinė ašis",
+      "categoryBrakes": "Stabdžiai",
+      "categoryConsumables": "Dirbtuvės medžiagos",
       "itemConsumables": "Eksploatacinės medžiagos",
       "unitPcs": "vnt.",
       "unitSet": "kompl.",

+ 2 - 0
messages/nb/pdf.json

@@ -20,6 +20,8 @@
     "type": "Type: {type}",
     "tech": "Tekniker: {tech}",
     "parts": "Deler",
+    "otherParts": "Andre deler",
+    "groupSubtotal": "Delsum {group}",
     "partNumber": "Del #",
     "description": "Beskrivelse",
     "qty": "Ant.",

+ 1 - 0
messages/nb/quotes.json

@@ -107,6 +107,7 @@
     "title": "Deler",
     "addPart": "Legg til del",
     "partNumber": "Delnr.",
+    "category": "Kategori",
     "name": "Navn",
     "namePlaceholder": "Navn *",
     "qty": "Ant.",

+ 1 - 0
messages/nb/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Fra beholdning",
     "addPart": "Legg til del",
     "partNumber": "Del #",
+    "category": "Kategori",
     "name": "Navn",
     "qty": "Antall",
     "unitCost": "Kostnad",

+ 6 - 0
messages/nb/settings.json

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "Én nummerert liste over alle linjene i jobben, arbeid først og deler etterpå, i stedet for de separate tabellene.",
     "discardChanges": "Forkast endringer",
     "tablesExclusiveHint": "Arket skriver ut én ordning: én nummerert liste, eller separate tabeller for deler og arbeid. Slår du på den ene, slås den andre av.",
+    "groupLines": "Grupper linjer",
+    "groupLinesOff": "Av",
+    "groupLinesCategory": "Etter kategori",
+    "groupLinesHint": "Etter kategori skriver delene under en overskrift per kategori med egen delsum, med arbeid først i den samlede listen. Deler uten kategori summeres som «Andre deler».",
     "innerPadding": "Innvendig avstand",
     "totalsWidth": "Bredde",
     "totalsWidthHint": "Tom fyller hele raden. En verdi i punkter henger boksen til høyre; i en kolonne tar den kolonnens bredde.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Bremser foran skiftet",
       "itemDisc": "Bremseskive, foran",
       "itemPads": "Bremseklosser, foraksel",
+      "categoryBrakes": "Bremser",
+      "categoryConsumables": "Forbruksmateriell",
       "itemConsumables": "Forbruksmateriell",
       "unitPcs": "stk",
       "unitSet": "sett",

+ 2 - 0
messages/nl/pdf.json

@@ -20,6 +20,8 @@
     "type": "Type: {type}",
     "tech": "Monteur: {tech}",
     "parts": "Onderdelen",
+    "otherParts": "Overige onderdelen",
+    "groupSubtotal": "Subtotaal {group}",
     "partNumber": "Onderdeelnr.",
     "description": "Omschrijving",
     "qty": "Aantal",

+ 1 - 0
messages/nl/quotes.json

@@ -107,6 +107,7 @@
     "title": "Onderdelen",
     "addPart": "Onderdeel toevoegen",
     "partNumber": "Onderdeelnr.",
+    "category": "Categorie",
     "name": "Naam",
     "namePlaceholder": "Naam *",
     "qty": "Aantal",

+ 1 - 0
messages/nl/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Uit voorraad",
     "addPart": "Onderdeel toevoegen",
     "partNumber": "Onderdeel #",
+    "category": "Categorie",
     "name": "Naam",
     "qty": "Hoeveelheid",
     "unitCost": "Inkoop",

+ 6 - 0
messages/nl/settings.json

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "Eén genummerde lijst van alle regels van de klus, eerst arbeid en dan onderdelen, in plaats van de aparte tabellen.",
     "discardChanges": "Wijzigingen verwerpen",
     "tablesExclusiveHint": "Het blad drukt één opstelling af: één genummerde lijst, of aparte tabellen voor onderdelen en arbeid. De ene inschakelen schakelt de andere uit.",
+    "groupLines": "Regels groeperen",
+    "groupLinesOff": "Uit",
+    "groupLinesCategory": "Per categorie",
+    "groupLinesHint": "Per categorie drukt de onderdelen af onder een kop per categorie met een subtotaal per groep, met arbeid eerst in de gecombineerde lijst. Onderdelen zonder categorie worden opgeteld als \"Overige onderdelen\".",
     "innerPadding": "Binnenruimte",
     "totalsWidth": "Breedte",
     "totalsWidthHint": "Leeg vult de hele rij. Met een waarde in punten hangt het blok rechts; in een kolom neemt het de breedte van de kolom.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Voorremmen vervangen",
       "itemDisc": "Remschijf, voor",
       "itemPads": "Remblokset, vooras",
+      "categoryBrakes": "Remmen",
+      "categoryConsumables": "Werkplaatsbenodigdheden",
       "itemConsumables": "Verbruiksmateriaal",
       "unitPcs": "st",
       "unitSet": "set",

+ 2 - 0
messages/pl/pdf.json

@@ -20,6 +20,8 @@
     "type": "Typ: {type}",
     "tech": "Technik: {tech}",
     "parts": "Części",
+    "otherParts": "Inne części",
+    "groupSubtotal": "Suma częściowa: {group}",
     "partNumber": "Nr części",
     "description": "Opis",
     "qty": "Ilość",

+ 1 - 0
messages/pl/quotes.json

@@ -107,6 +107,7 @@
     "title": "Części",
     "addPart": "Dodaj część",
     "partNumber": "Nr części",
+    "category": "Kategoria",
     "name": "Nazwa",
     "namePlaceholder": "Nazwa *",
     "qty": "Ilość",

+ 1 - 0
messages/pl/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Z Magazynu",
     "addPart": "Dodaj Część",
     "partNumber": "Nr Części",
+    "category": "Kategoria",
     "name": "Nazwa",
     "qty": "Ilość",
     "unitCost": "Koszt",

+ 6 - 0
messages/pl/settings.json

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "Jedna numerowana lista wszystkich pozycji zlecenia, najpierw robocizna, potem części, zamiast osobnych tabel.",
     "discardChanges": "Odrzuć zmiany",
     "tablesExclusiveHint": "Arkusz drukuje jeden układ: jedną numerowaną listę albo osobne tabele części i robocizny. Włączenie jednego wyłącza drugi.",
+    "groupLines": "Grupuj pozycje",
+    "groupLinesOff": "Wył.",
+    "groupLinesCategory": "Według kategorii",
+    "groupLinesHint": "Według kategorii drukuje części pod nagłówkiem każdej kategorii z osobną sumą częściową, a w połączonej liście najpierw robociznę. Części bez kategorii są sumowane jako „Inne części”.",
     "innerPadding": "Odstęp wewnętrzny",
     "totalsWidth": "Szerokość",
     "totalsWidthHint": "Puste wypełnia cały wiersz. Wartość w punktach zawiesza blok po prawej; w kolumnie przyjmuje szerokość kolumny.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Wymiana hamulców przednich",
       "itemDisc": "Tarcza hamulcowa, przód",
       "itemPads": "Komplet klocków, oś przednia",
+      "categoryBrakes": "Hamulce",
+      "categoryConsumables": "Materiały warsztatowe",
       "itemConsumables": "Materiały eksploatacyjne",
       "unitPcs": "szt.",
       "unitSet": "kpl.",

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

@@ -20,6 +20,8 @@
     "type": "Tipo: {type}",
     "tech": "Técnico: {tech}",
     "parts": "Peças",
+    "otherParts": "Outras peças",
+    "groupSubtotal": "Subtotal {group}",
     "partNumber": "Peça #",
     "description": "Descrição",
     "qty": "Qtd",

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

@@ -107,6 +107,7 @@
     "title": "Peças",
     "addPart": "Adicionar peça",
     "partNumber": "N.º da peça",
+    "category": "Categoria",
     "name": "Nome",
     "namePlaceholder": "Nome *",
     "qty": "Qtd.",

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

@@ -122,6 +122,7 @@
     "fromInventory": "Do Inventário",
     "addPart": "Adicionar Peça",
     "partNumber": "Nº da Peça",
+    "category": "Categoria",
     "name": "Nome",
     "qty": "Qtd",
     "unitCost": "Custo",

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

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "Uma lista numerada de todas as linhas do serviço, primeiro mão de obra e depois peças, no lugar das tabelas separadas.",
     "discardChanges": "Descartar alterações",
     "tablesExclusiveHint": "A folha imprime uma disposição: uma única lista numerada ou tabelas separadas de peças e mão de obra. Ativar uma desativa a outra.",
+    "groupLines": "Agrupar linhas",
+    "groupLinesOff": "Não",
+    "groupLinesCategory": "Por categoria",
+    "groupLinesHint": "Por categoria imprime as peças sob um título por categoria com um subtotal para cada uma, com a mão de obra primeiro na lista combinada. Peças sem categoria são somadas como \"Outras peças\".",
     "innerPadding": "Espaçamento interno",
     "totalsWidth": "Largura",
     "totalsWidthHint": "Vazio ocupa a linha inteira. Um valor em pontos pendura o bloco à direita; em uma coluna, ele usa a largura da coluna.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Freios dianteiros substituídos",
       "itemDisc": "Disco de freio, dianteiro",
       "itemPads": "Jogo de pastilhas, eixo dianteiro",
+      "categoryBrakes": "Freios",
+      "categoryConsumables": "Insumos de oficina",
       "itemConsumables": "Materiais de consumo",
       "unitPcs": "un",
       "unitSet": "jogo",

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

@@ -20,6 +20,8 @@
     "type": "Tipo: {type}",
     "tech": "Técnico: {tech}",
     "parts": "Peças",
+    "otherParts": "Outras peças",
+    "groupSubtotal": "Subtotal {group}",
     "partNumber": "Ref. Peça",
     "description": "Descrição",
     "qty": "Qtd.",

+ 1 - 0
messages/pt-PT/quotes.json

@@ -107,6 +107,7 @@
     "title": "Peças",
     "addPart": "Adicionar peça",
     "partNumber": "Ref. da peça",
+    "category": "Categoria",
     "name": "Nome",
     "namePlaceholder": "Nome *",
     "qty": "Qtd.",

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

@@ -122,6 +122,7 @@
     "fromInventory": "Do stock",
     "addPart": "Adicionar peça",
     "partNumber": "Ref. peça",
+    "category": "Categoria",
     "name": "Nome",
     "qty": "Qtd.",
     "unitCost": "Custo",

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

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "Uma lista numerada de todas as linhas do trabalho, primeiro a mão de obra e depois as peças, em vez das tabelas separadas de peças e mão de obra.",
     "discardChanges": "Descartar alterações",
     "tablesExclusiveHint": "A folha imprime uma disposição: uma única lista numerada ou tabelas separadas de peças e mão de obra. Ativar uma desativa a outra.",
+    "groupLines": "Agrupar linhas",
+    "groupLinesOff": "Não",
+    "groupLinesCategory": "Por categoria",
+    "groupLinesHint": "Por categoria imprime as peças sob um título por categoria com um subtotal para cada uma, com a mão de obra primeiro na lista combinada. Peças sem categoria são somadas como «Outras peças».",
     "innerPadding": "Espaçamento interno",
     "totalsWidth": "Largura",
     "totalsWidthHint": "Vazio ocupa a linha toda. Defina pontos para encostar o bloco à direita; numa coluna, usa a largura da coluna.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Substituição dos travões dianteiros",
       "itemDisc": "Disco de travão, dianteiro",
       "itemPads": "Jogo de pastilhas, eixo dianteiro",
+      "categoryBrakes": "Travões",
+      "categoryConsumables": "Consumíveis de oficina",
       "itemConsumables": "Consumíveis",
       "unitPcs": "un",
       "unitSet": "jogo",

+ 2 - 0
messages/ru/pdf.json

@@ -20,6 +20,8 @@
     "type": "Тип: {type}",
     "tech": "Механик: {tech}",
     "parts": "Запчасти",
+    "otherParts": "Прочие запчасти",
+    "groupSubtotal": "Итого: {group}",
     "partNumber": "Арт. №",
     "description": "Описание",
     "qty": "Кол-во",

+ 1 - 0
messages/ru/quotes.json

@@ -107,6 +107,7 @@
     "title": "Запчасти",
     "addPart": "Добавить запчасть",
     "partNumber": "Арт. №",
+    "category": "Категория",
     "name": "Название",
     "namePlaceholder": "Название *",
     "qty": "Кол-во",

+ 1 - 0
messages/ru/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Со склада",
     "addPart": "Добавить запчасть",
     "partNumber": "Арт. №",
+    "category": "Категория",
     "name": "Название",
     "qty": "Кол-во",
     "unitCost": "Себестоимость",

+ 6 - 0
messages/ru/settings.json

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "Единый нумерованный список всех строк заказа: сначала работы, затем запчасти, вместо отдельных таблиц.",
     "discardChanges": "Отменить изменения",
     "tablesExclusiveHint": "Лист печатает один вариант: единый нумерованный список или отдельные таблицы запчастей и работ. Включение одного выключает другой.",
+    "groupLines": "Группировать строки",
+    "groupLinesOff": "Выкл.",
+    "groupLinesCategory": "По категории",
+    "groupLinesHint": "По категории печатает запчасти под заголовком каждой категории с промежуточной суммой, а в общем списке сначала работы. Запчасти без категории суммируются как «Прочие запчасти».",
     "innerPadding": "Внутренний отступ",
     "totalsWidth": "Ширина",
     "totalsWidthHint": "Пусто — блок занимает всю строку. Значение в пунктах вешает его справа; в колонке он занимает её ширину.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Замена передних тормозов",
       "itemDisc": "Тормозной диск, передний",
       "itemPads": "Комплект колодок, передняя ось",
+      "categoryBrakes": "Тормоза",
+      "categoryConsumables": "Расходные материалы",
       "itemConsumables": "Расходные материалы",
       "unitPcs": "шт.",
       "unitSet": "компл.",

+ 2 - 0
messages/tr/pdf.json

@@ -20,6 +20,8 @@
     "type": "Tür: {type}",
     "tech": "Teknisyen: {tech}",
     "parts": "Parçalar",
+    "otherParts": "Diğer parçalar",
+    "groupSubtotal": "{group} ara toplamı",
     "partNumber": "Parça #",
     "description": "Açıklama",
     "qty": "Adet",

+ 1 - 0
messages/tr/quotes.json

@@ -107,6 +107,7 @@
     "title": "Parçalar",
     "addPart": "Parça Ekle",
     "partNumber": "Parça No.",
+    "category": "Kategori",
     "name": "Ad",
     "namePlaceholder": "Ad *",
     "qty": "Adet",

+ 1 - 0
messages/tr/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Stoktan",
     "addPart": "Parça Ekle",
     "partNumber": "Parça #",
+    "category": "Kategori",
     "name": "Ad",
     "qty": "Mik.",
     "unitCost": "Maliyet",

+ 6 - 0
messages/tr/settings.json

@@ -2034,6 +2034,10 @@
     "itemsTableHint": "İşin tüm satırlarının tek numaralı listesi, önce işçilik sonra parçalar, ayrı tabloların yerine.",
     "discardChanges": "Değişiklikleri yoksay",
     "tablesExclusiveHint": "Sayfa tek bir düzen yazdırır: tek numaralı liste ya da ayrı parça ve işçilik tabloları. Birini açmak diğerini kapatır.",
+    "groupLines": "Satırları grupla",
+    "groupLinesOff": "Kapalı",
+    "groupLinesCategory": "Kategoriye göre",
+    "groupLinesHint": "Kategoriye göre, parçaları her kategori için bir başlık ve ara toplamla yazdırır; birleşik listede önce işçilik gelir. Kategorisi olmayan parçalar \"Diğer parçalar\" olarak toplanır.",
     "innerPadding": "İç boşluk",
     "totalsWidth": "Genişlik",
     "totalsWidthHint": "Boş bırakılırsa satırın tamamını kaplar. Punto değeri girilirse blok sağa asılır; bir sütunda sütunun genişliğini alır.",
@@ -2122,6 +2126,8 @@
       "itemLabor": "Ön frenler değiştirildi",
       "itemDisc": "Fren diski, ön",
       "itemPads": "Fren balata takımı, ön aks",
+      "categoryBrakes": "Frenler",
+      "categoryConsumables": "Atölye sarf malzemeleri",
       "itemConsumables": "Sarf malzemeleri",
       "unitPcs": "adet",
       "unitSet": "takım",

+ 8 - 0
prisma/migrations/20261007200000_part_line_category/migration.sql

@@ -0,0 +1,8 @@
+-- AlterTable
+ALTER TABLE "service_parts" ADD COLUMN     "category" TEXT;
+
+-- AlterTable
+ALTER TABLE "quote_parts" ADD COLUMN     "category" TEXT;
+
+-- AlterTable
+ALTER TABLE "labor_preset_parts" ADD COLUMN     "category" TEXT;

+ 3 - 0
prisma/schema/inventory.prisma

@@ -36,6 +36,9 @@ model LaborPresetPart {
   quantity        Float   @default(1)
   /// Unit of measure snapshotted from InventoryPart at pick time.
   unit            String?
+  /// Category snapshotted from InventoryPart at pick time; copied onto the
+  /// part line when the preset is applied.
+  category        String?
   unitPrice       Float   @default(0)
   inventoryPartId String?
   sortOrder       Int     @default(0)

+ 3 - 0
prisma/schema/quotes.prisma

@@ -104,6 +104,9 @@ model QuotePart {
   /// Unit of measure snapshotted from InventoryPart at pick time; null for
   /// free-text lines and legacy rows.
   unit          String?
+  /// Category snapshotted from InventoryPart at pick time, or typed on a
+  /// free-text line; carried into ServicePart on conversion. See there.
+  category      String?
   /// Internal purchase cost — never shown to customers (quote PDF/share
   /// render unitPrice only)
   unitCost      Float   @default(0)

+ 4 - 0
prisma/schema/service-records.prisma

@@ -296,6 +296,10 @@ model ServicePart {
   /// Unit of measure snapshotted from InventoryPart at pick time; null for
   /// free-text lines and legacy rows.
   unit            String?
+  /// Category snapshotted from InventoryPart at pick time, or typed on a
+  /// free-text line, so an invoice designed to group its parts by category
+  /// keeps printing the same groups after the stock item is renamed or gone.
+  category        String?
   unitPrice       Float   @default(0)
   total           Float   @default(0)
   unitCost        Float   @default(0)

+ 191 - 0
src/__tests__/features/invoice-designer/grouped-lines.test.tsx

@@ -0,0 +1,191 @@
+// @vitest-environment node
+/**
+ * A table of lines divided by the parts' category.
+ *
+ * The layout says "group by category"; the print builder works out the groups
+ * and their subtotals; the spec builder lays the rows out under headings; and
+ * the three renderers draw heading and subtotal rows that no ordinary row
+ * could produce. Each hand-off is checked here, and the two renderers are
+ * asked whether they printed the same headings.
+ */
+import { Document, renderToBuffer } from '@react-pdf/renderer'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { extractText } from 'unpdf'
+import { describe, expect, it } from 'vitest'
+import '@/features/vehicles/Components/invoice-pdf/fonts'
+import { themeOf } from '@/features/invoice-designer/Components/designTheme'
+import { buildSampleData } from '@/features/invoice-designer/Components/sample'
+import { estimateBlockHeights } from '@/features/invoice-designer/Pdf/estimateHeights'
+import { lineGroupsFor } from '@/features/invoice-designer/Pdf/lineGroups'
+import { SpecPdfPage } from '@/features/invoice-designer/Pdf/SpecPdf'
+import { SpecSheet } from '@/features/invoice-designer/Render/SpecSheet'
+import { buildDocumentSpec } from '@/features/invoice-designer/Spec/buildSpec'
+import {
+  type InvoiceLayoutConfig,
+  type InvoiceSection,
+  mergeWithDefaults,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+const sample = () =>
+  buildSampleData(
+    {
+      name: 'Shop',
+      address: 'A road',
+      phone: '555',
+      email: 'shop@example.com',
+      logoUrl: null,
+    } as any,
+    [],
+    ((key: string) => key) as any,
+    {},
+    'invoice'
+  )
+
+/**
+ * The sample's parts carry the sample translation keys as their categories:
+ * two brake parts and one line of consumables. Every part has one, so no
+ * "Other parts" group appears in the preview.
+ */
+const BRAKES = 'sample.categoryBrakes'
+const CONSUMABLES = 'sample.categoryConsumables'
+
+function specWith(patch: (section: InvoiceSection) => Partial<InvoiceSection> | undefined) {
+  const layout = mergeWithDefaults({}) as InvoiceLayoutConfig
+  layout.sections = layout.sections.map((section) => ({ ...section, ...patch(section) }))
+  return { spec: buildDocumentSpec(layout, themeOf({} as any, layout), sample()) as any, layout }
+}
+
+function tableOf(spec: any, id: string): any {
+  const content = spec.blocks.find((block: any) => block.content?.id === id)?.content
+  if (!content) return undefined
+  if (content.kind === 'table') return content
+  return content.children?.find((child: any) => child.kind === 'table')
+}
+
+describe('the groups a document divides its lines into', () => {
+  const money = (n: number) => `€ ${n.toFixed(2)}`
+  const shown = (n: number) => n
+  const labels = { labor: 'Work', otherParts: 'Misc', groupSubtotal: 'Sum for {group}' }
+
+  it('are nothing when no part has a category, so the sheet prints flat', () => {
+    const groups = lineGroupsFor({
+      parts: [{ total: 10 }, { total: 5, category: '  ' }],
+      labor: [{ total: 100 }],
+      labels,
+      shown,
+      money,
+    })
+    expect(groups).toBeUndefined()
+  })
+
+  it('run labor first, then the categories as they first appear, then the rest', () => {
+    const groups = lineGroupsFor({
+      parts: [
+        { total: 10, category: 'Brakes' },
+        { total: 7 },
+        { total: 20, category: 'Service' },
+        { total: 5, category: 'Brakes' },
+        { total: 3, category: 'Service', excluded: true },
+      ],
+      labor: [{ total: 100 }, { total: 50, excluded: true }],
+      labels,
+      shown,
+      money,
+    })
+    expect(groups?.map((g) => [g.title, g.subtotal, g.subtotalLabel])).toEqual([
+      ['Work', '€ 100.00', 'Sum for Work'],
+      ['Brakes', '€ 15.00', 'Sum for Brakes'],
+      ['Service', '€ 20.00', 'Sum for Service'],
+      ['Misc', '€ 7.00', 'Sum for Misc'],
+    ])
+  })
+})
+
+describe('a parts table grouped by category', () => {
+  it('prints a heading and a subtotal row per group, and no labor group', () => {
+    const { spec } = specWith((s) => (s.id === 'parts_table' ? { groupBy: 'category' } : undefined))
+    const table = tableOf(spec, 'parts_table')
+    expect(table.groupKey).toBeDefined()
+    expect(table.emphasisKey).toBeDefined()
+    const headings = table.rows
+      .filter((r: any) => r[table.groupKey])
+      .map((r: any) => r[table.groupKey])
+    expect(headings).toEqual([BRAKES, CONSUMABLES])
+    const subtotals = table.rows
+      .filter((r: any) => r[table.emphasisKey])
+      .map((r: any) => [r.desc, r.total])
+    expect(subtotals).toEqual([
+      [`${BRAKES} subtotal`, '€ 245.50'],
+      [`${CONSUMABLES} subtotal`, '€ 12.00'],
+    ])
+    // A heading and a subtotal per group around the three sample parts.
+    expect(table.rows).toHaveLength(3 + 4)
+  })
+
+  it('prints flat when the layout does not ask for groups', () => {
+    const { spec } = specWith(() => undefined)
+    const table = tableOf(spec, 'parts_table')
+    expect(table.groupKey).toBeUndefined()
+    expect(table.rows).toHaveLength(3)
+  })
+
+  it('measures taller than the flat table it replaces', () => {
+    const flat = specWith(() => undefined).spec
+    const grouped = specWith((s) =>
+      s.id === 'parts_table' ? { groupBy: 'category' } : undefined
+    ).spec
+    const flatHeight = estimateBlockHeights(flat).get('parts_table') ?? 0
+    const groupedHeight = estimateBlockHeights(grouped).get('parts_table') ?? 0
+    expect(groupedHeight).toBeGreaterThan(flatHeight)
+  })
+})
+
+describe('the combined items table grouped by category', () => {
+  it('puts the labor first under its own heading and numbers the lines in print order', () => {
+    const { spec } = specWith((s) =>
+      s.id === 'items_table'
+        ? { visible: true, groupBy: 'category' }
+        : s.id === 'parts_table' || s.id === 'labor_table'
+          ? { visible: false }
+          : undefined
+    )
+    const table = tableOf(spec, 'items_table')
+    const headings = table.rows
+      .filter((r: any) => r[table.groupKey])
+      .map((r: any) => r[table.groupKey])
+    expect(headings).toEqual(['Labor', BRAKES, CONSUMABLES])
+    const numbers = table.rows.filter((r: any) => r.n).map((r: any) => r.n)
+    expect(numbers).toEqual(['1', '2', '3', '4'])
+    const laborSubtotal = table.rows.find((r: any) => r.desc === 'Labor subtotal')
+    expect(laborSubtotal.total).toBe('€ 222.50')
+  })
+})
+
+describe('the share sheet and the PDF print the grouped table alike', () => {
+  it('both show the headings and subtotals', async () => {
+    const { spec } = specWith((s) => (s.id === 'parts_table' ? { groupBy: 'category' } : undefined))
+    const html = renderToStaticMarkup((<SpecSheet spec={spec} />) as any)
+      .replace(/<[^>]+>/g, ' ')
+      .replace(/\s+/g, ' ')
+      .toLowerCase()
+    const pdf = await renderToBuffer(
+      (
+        <Document>
+          <SpecPdfPage spec={spec} />
+        </Document>
+      ) as any
+    )
+    const { text } = await extractText(new Uint8Array(pdf), { mergePages: true })
+    const pdfText = String(text).replace(/\s+/g, ' ').toLowerCase()
+    for (const needle of [
+      `${BRAKES.toLowerCase()} subtotal`,
+      `${CONSUMABLES.toLowerCase()} subtotal`,
+      '245.50',
+    ]) {
+      expect(html).toContain(needle)
+      expect(pdfText).toContain(needle)
+    }
+  }, 30000)
+})

+ 1 - 0
src/app/(authenticated)/labor-presets/page.tsx

@@ -57,6 +57,7 @@ export default async function LaborPresetsPage({
           name: p.name,
           partNumber: p.partNumber,
           unit: p.unit,
+          category: p.category,
           sellPrice: p.sellPrice,
           unitCost: p.unitCost,
           quantity: p.quantity,

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

@@ -102,6 +102,7 @@ export default async function PublicInvoicePage({
       name: p.name,
       quantity: p.quantity,
       unit: p.unit,
+      category: p.category,
       unitPrice: p.unitPrice,
       total: p.total,
     })),

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

@@ -50,6 +50,7 @@ export default async function PublicQuotePage({
           name: true,
           quantity: true,
           unit: true,
+          category: true,
           unitPrice: true,
           total: true,
           excluded: true,

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

@@ -202,6 +202,7 @@ async function importServiceRecordTree(
         name: p.name as string,
         quantity: (p.quantity as number) || 1,
         unit: (p.unit as string) || null,
+        category: (p.category as string) || null,
         unitPrice: (p.unitPrice as number) || 0,
         total: (p.total as number) || 0,
         unitCost: (p.unitCost as number) || 0,
@@ -1132,6 +1133,7 @@ export async function POST(request: NextRequest) {
                 name: p.name as string,
                 quantity: (p.quantity as number) || 1,
                 unit: (p.unit as string) || null,
+                category: (p.category as string) || null,
                 unitPrice: (p.unitPrice as number) || 0,
                 total: (p.total as number) || 0,
                 unitCost: (p.unitCost as number) || 0,

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

@@ -62,6 +62,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
             name: true,
             partNumber: true,
             unit: true,
+            category: true,
             unitCost: true,
             sellPrice: true,
           },
@@ -99,6 +100,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
           name: stock.name,
           partNumber: stock.partNumber,
           unit: stock.unit,
+          category: stock.category,
           quantity: body.quantity,
           unitPrice,
           unitCost: stock.unitCost,

+ 8 - 1
src/app/api/v1/tech/jobs/[id]/route.ts

@@ -63,7 +63,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
           },
           customer: { select: { id: true, name: true, phone: true, email: true } },
           partItems: {
-            select: { id: true, name: true, quantity: true, unit: true, partNumber: true },
+            select: {
+              id: true,
+              name: true,
+              quantity: true,
+              unit: true,
+              category: true,
+              partNumber: true,
+            },
             orderBy: { id: 'asc' },
           },
           laborItems: {

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

@@ -125,6 +125,7 @@ export const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = {
     'name',
     'quantity',
     'unit',
+    'category',
     'unitPrice',
     'total',
     'unitCost',
@@ -237,6 +238,7 @@ export const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = {
     'name',
     'quantity',
     'unit',
+    'category',
     'unitCost',
     'markupPercent',
     'unitPrice',

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

@@ -674,7 +674,7 @@ vehicles (id, make, model, year, vin, "licensePlate", color, mileage, "fuelType"
 
 service_records (id, title, description, type, status, cost, mileage, "serviceDate", "startDateTime", "endDateTime", "shopName", "techName", parts, "laborHours", "diagnosticNotes", "invoiceNotes", subtotal, "taxRate", "taxAmount", "totalAmount", "invoiceNumber", "discountType", "discountValue", "discountAmount", "manuallyPaid", "createdAt", "updatedAt", "vehicleId", "customerId", "technicianId", "sortOrder") -- "vehicleId" is NULL for parts-only counter sales; those link "customerId" directly
 
-service_parts (id, "partNumber", name, quantity, unit, "unitPrice", total, "serviceRecordId")
+service_parts (id, "partNumber", name, quantity, unit, category, "unitPrice", total, "serviceRecordId")
 
 service_labor (id, description, hours, rate, total, "serviceRecordId")
 
@@ -686,7 +686,7 @@ reminders (id, title, description, "dueDate", "dueMileage", "isCompleted", "crea
 
 quotes (id, "quoteNumber", title, description, status, "validUntil", subtotal, "taxRate", "taxAmount", "discountType", "discountValue", "discountAmount", "totalAmount", notes, "customerMessage", "convertedToId", "createdAt", "updatedAt", "customerId", "vehicleId", "inspectionId")
 
-quote_parts (id, "partNumber", name, quantity, unit, "unitPrice", total, excluded, "quoteId")
+quote_parts (id, "partNumber", name, quantity, unit, category, "unitPrice", total, excluded, "quoteId")
 
 quote_labor (id, description, hours, rate, total, excluded, "quoteId")
 

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

@@ -34,6 +34,12 @@ import type { DesignerTemplate } from './types'
 /** Sections whose body is a table, and so offer line controls. */
 const TABLE_SECTIONS = new Set(['items_table', 'parts_table', 'labor_table', 'findings'])
 
+/**
+ * Tables of lines that can be divided by the parts' category. The labor table
+ * is not one: labor lines carry no category.
+ */
+const GROUPABLE_SECTIONS = new Set(['items_table', 'parts_table'])
+
 /** Sections that print a small heading of their own, which can be hidden. */
 const HEADED_SECTIONS = new Set([
   'customer',
@@ -670,6 +676,24 @@ export function DesignerInspector({
                 {section.id === 'items_table' ? t('itemsTableHint') : t('tablesExclusiveHint')}
               </p>
             )}
+            {GROUPABLE_SECTIONS.has(section.id) && (
+              <div>
+                <div className="mb-1.5 text-[13px] font-medium">{t('groupLines')}</div>
+                <Choice
+                  value={section.groupBy ?? 'none'}
+                  options={[
+                    { value: 'none', label: t('groupLinesOff') },
+                    { value: 'category', label: t('groupLinesCategory') },
+                  ]}
+                  onChange={(v) =>
+                    onSection(section.id, { groupBy: v === 'category' ? 'category' : undefined })
+                  }
+                />
+                <p className="mt-1.5 text-[11.5px] leading-snug text-[#8a8f97]">
+                  {t('groupLinesHint')}
+                </p>
+              </div>
+            )}
             {COLUMN_ELIGIBLE_SECTIONS.has(section.id) && (
               <Choice
                 value={section.column ?? 'full'}

+ 44 - 1
src/features/invoice-designer/Components/sample.ts

@@ -1,7 +1,8 @@
 import { toCustomFieldId } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { calculateTotals } from '@/lib/tax'
 import { taxComponentLabel } from '@/lib/tax-components'
-import type { DocumentData, PaymentPair, TotalLine } from '../Spec/buildSpec'
+import type { DocumentData, LineGroup, PaymentPair, TotalLine } from '../Spec/buildSpec'
+import { LABOR_GROUP, OTHER_PARTS_GROUP, partGroupKey } from '../Pdf/lineGroups'
 import type { DesignerWorkshop, DocumentType } from './types'
 import { conditionMapForPrint } from '@/features/condition-map/Lib/print'
 import type { ConditionMarkData } from '@/features/condition-map/Lib/marks'
@@ -42,6 +43,8 @@ export interface SampleTables {
     unit: string
     desc: string
     sku?: string
+    /** A stocked part's category, for the grouped tables. */
+    category?: string
     price: string
     total: string
   }[]
@@ -76,6 +79,7 @@ export function sampleTables(t: SampleT, labels: PrintLabels): SampleTables {
         unit: t('sample.unitPcs'),
         desc: t('sample.itemDisc'),
         sku: 'BD-1042',
+        category: t('sample.categoryBrakes'),
         price: '€ 149.00',
         total: '€ 149.00',
       },
@@ -85,6 +89,7 @@ export function sampleTables(t: SampleT, labels: PrintLabels): SampleTables {
         unit: t('sample.unitSet'),
         desc: t('sample.itemPads'),
         sku: 'BP-2210',
+        category: t('sample.categoryBrakes'),
         price: '€ 96.50',
         total: '€ 96.50',
       },
@@ -93,6 +98,10 @@ export function sampleTables(t: SampleT, labels: PrintLabels): SampleTables {
         qty: '1',
         unit: t('sample.unitEach'),
         desc: t('sample.itemConsumables'),
+        // A stocked part in a category of its own, so a sheet grouped by
+        // category previews two groups rather than one.
+        sku: 'WS-0001',
+        category: t('sample.categoryConsumables'),
         price: '€ 12.00',
         total: '€ 12.00',
       },
@@ -243,6 +252,35 @@ function sampleTax(
  * sample standing in for a job. One builder, so the designer's canvas and the
  * template cards in settings preview exactly the same paper.
  */
+/** The sample's line groups, summed from its own rows. */
+function sampleLineGroups(sample: SampleTables, labels: PrintLabels): LineGroup[] {
+  const L = (key: string, fallback: string) => labels[key] || fallback
+  const amount = (total: string) => Number(total.replace(/[^\d.]/g, ''))
+  const sum = (rows: SampleTables['items']) =>
+    `€ ${rows.reduce((acc, row) => acc + amount(row.total), 0).toFixed(2)}`
+  const group = (key: string, title: string, rows: SampleTables['items']) => ({
+    key,
+    title,
+    subtotalLabel: fillTemplate(L('groupSubtotal', '{group} subtotal'), { group: title }),
+    subtotal: sum(rows),
+  })
+  const labor = sample.items.filter((item) => !item.sku)
+  const parts = sample.items.filter((item) => item.sku)
+  const categories = [...new Set(parts.map((item) => item.category).filter(Boolean))] as string[]
+  const other = parts.filter((item) => !item.category)
+  return [
+    group(LABOR_GROUP, L('labor', 'Labor'), labor),
+    ...categories.map((category) =>
+      group(
+        partGroupKey(category),
+        category,
+        parts.filter((item) => item.category === category)
+      )
+    ),
+    ...(other.length ? [group(OTHER_PARTS_GROUP, L('otherParts', 'Other parts'), other)] : []),
+  ]
+}
+
 export function buildSampleData(
   workshop: DesignerWorkshop,
   customFields: { id: string; label?: string | null; name: string; isActive: boolean }[],
@@ -283,6 +321,7 @@ export function buildSampleData(
       sub: item.sku,
       price: item.price,
       total: item.total,
+      group: item.sku ? partGroupKey(item.category) : LABOR_GROUP,
     })),
     parts: sample.items
       .filter((item) => item.sku)
@@ -292,7 +331,11 @@ export function buildSampleData(
         qty: item.qty,
         price: item.price,
         total: item.total,
+        group: partGroupKey(item.category),
       })),
+    // The groups a table divided by category shows: the brake parts added
+    // up on their own, the consumables after them, the labor first.
+    lineGroups: sampleLineGroups(sample, labels),
     labor: sample.items
       .filter((item) => !item.sku)
       .map((item) => ({

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

@@ -5,6 +5,7 @@ import { formatQuantity } from '@/lib/format-quantity'
 import { calculateTotals, netLineTotal, discountAmountFor } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
+import { LABOR_GROUP, lineGroupsFor, partGroupKey } from './lineGroups'
 import {
   getDefaultInvoiceLayout,
   isCustomFieldId,
@@ -276,6 +277,7 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
       desc: l.description,
       price: money(shown(l.rate)),
       total: money(shown(l.total)),
+      group: LABOR_GROUP,
     })),
     ...data.partItems.map((p, i) => ({
       n: String(data.laborItems.length + i + 1),
@@ -285,6 +287,7 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
       sub: p.partNumber || undefined,
       price: money(shown(p.unitPrice)),
       total: money(shown(p.total)),
+      group: partGroupKey(p.category),
     })),
   ]
 
@@ -294,8 +297,17 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     qty: formatQuantity(p.quantity, p.unit),
     price: money(shown(p.unitPrice)),
     total: money(shown(p.total)),
+    group: partGroupKey(p.category),
   }))
 
+  const lineGroups = lineGroupsFor({
+    parts: data.partItems,
+    labor: data.laborItems,
+    labels,
+    shown,
+    money,
+  })
+
   const labor: DocumentData['labor'] = data.laborItems.map((l) => {
     // A shop fee prints as one unit at its price, like a service line.
     const isService = l.pricingType === 'service' || isShopFeeLine(l)
@@ -458,6 +470,7 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     items,
     parts,
     labor,
+    lineGroups,
     findings,
     totals,
     notes: { html: data.invoiceNotes ?? undefined },

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

@@ -5,6 +5,7 @@ import { formatQuantity } from '@/lib/format-quantity'
 import { calculateTotals, netLineTotal, discountAmountFor } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
+import { LABOR_GROUP, lineGroupsFor, partGroupKey } from './lineGroups'
 import {
   getDefaultInvoiceLayout,
   isCustomFieldId,
@@ -69,6 +70,8 @@ export interface QuotePrintData {
     name: string
     quantity: number
     unit?: string | null
+    /** For a layout that groups the lines by category. */
+    category?: string | null
     unitPrice: number
     total: number
     excluded?: boolean
@@ -250,6 +253,7 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
       price: money(shown(l.rate)),
       total: money(shown(l.total)),
       excluded: l.excluded,
+      group: LABOR_GROUP,
     })),
     ...data.partItems.map((p, i) => ({
       n: String(data.laborItems.length + i + 1),
@@ -260,6 +264,7 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
       price: money(shown(p.unitPrice)),
       total: money(shown(p.total)),
       excluded: p.excluded,
+      group: partGroupKey(p.category),
     })),
   ]
 
@@ -270,8 +275,17 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
     price: money(shown(p.unitPrice)),
     total: money(shown(p.total)),
     excluded: p.excluded,
+    group: partGroupKey(p.category),
   }))
 
+  const lineGroups = lineGroupsFor({
+    parts: data.partItems,
+    labor: data.laborItems,
+    labels,
+    shown,
+    money,
+  })
+
   const labor: DocumentData['labor'] = data.laborItems.map((l) => {
     // A shop fee prints as one unit at its price, like a service line.
     const isService = l.pricingType === 'service' || isShopFeeLine(l)
@@ -366,6 +380,7 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
     items,
     parts,
     labor,
+    lineGroups,
     findings: [],
     totals,
     notes: { html: data.description ?? undefined },

+ 3 - 0
src/features/invoice-designer/Pdf/estimateHeights.ts

@@ -128,6 +128,9 @@ function nodeHeight(node: Node, width: number, inherited: Inherited): number {
       let body = 0
       for (const row of node.rows) {
         let lines = 1
+        // A group heading is one run of text across the whole row.
+        const heading = node.groupKey ? row[node.groupKey] : undefined
+        if (heading) lines = lineCount(heading, inner, cellStyle, inherited.fontSize)
         for (const column of node.columns) {
           const value = row[column.key]
           if (!value) continue

+ 73 - 0
src/features/invoice-designer/Pdf/lineGroups.ts

@@ -0,0 +1,73 @@
+/**
+ * The groups a sheet divides its lines into when a table groups by category.
+ *
+ * Computed once by each print builder from the real lines and handed to the
+ * spec as worded strings, like everything else the document prints; the
+ * spec builder only orders rows under the headings it is given. A line's
+ * `group` names its entry here, so the parts table and the combined items
+ * table read the same division.
+ */
+
+import type { LineGroup } from '../Spec/buildSpec'
+
+/** The group a part line belongs to: its category, or the catch-all. */
+export function partGroupKey(category: string | null | undefined): string {
+  const name = category?.trim()
+  return name ? `category:${name}` : OTHER_PARTS_GROUP
+}
+
+export const OTHER_PARTS_GROUP = 'other'
+export const LABOR_GROUP = 'labor'
+
+interface GroupInput {
+  parts: { category?: string | null; total: number; excluded?: boolean }[]
+  labor: { total: number; excluded?: boolean }[]
+  labels: Record<string, string>
+  /** A line total as the sheet shows it, before and after tax alike. */
+  shown: (amount: number) => number
+  money: (amount: number) => string
+}
+
+function fill(template: string, values: Record<string, string>): string {
+  return Object.entries(values).reduce((str, [key, val]) => str.replace(`{${key}}`, val), template)
+}
+
+/**
+ * Labor first, as the flat list prints it, then one group per category in
+ * the order the categories first appear, then the parts with no category.
+ * Nothing at all when no part has a category: a sheet asked to group then
+ * prints flat rather than under a single heading that says nothing.
+ */
+export function lineGroupsFor(input: GroupInput): LineGroup[] | undefined {
+  const { labels, shown, money } = input
+  const L = (key: string, fallback: string) => labels[key] || fallback
+  if (!input.parts.some((part) => part.category?.trim())) return undefined
+
+  const subtotalOf = (lines: { total: number; excluded?: boolean }[]) =>
+    money(shown(lines.reduce((sum, line) => (line.excluded ? sum : sum + line.total), 0)))
+  const group = (key: string, title: string, lines: { total: number; excluded?: boolean }[]) => ({
+    key,
+    title,
+    subtotalLabel: fill(L('groupSubtotal', '{group} subtotal'), { group: title }),
+    subtotal: subtotalOf(lines),
+  })
+
+  const groups: LineGroup[] = []
+  if (input.labor.length) groups.push(group(LABOR_GROUP, L('labor', 'Labor'), input.labor))
+
+  const byCategory = new Map<string, { title: string; lines: GroupInput['parts'] }>()
+  const other: GroupInput['parts'] = []
+  for (const part of input.parts) {
+    const key = partGroupKey(part.category)
+    if (key === OTHER_PARTS_GROUP) {
+      other.push(part)
+      continue
+    }
+    const entry = byCategory.get(key) ?? { title: part.category?.trim() ?? '', lines: [] }
+    entry.lines.push(part)
+    byCategory.set(key, entry)
+  }
+  for (const [key, entry] of byCategory) groups.push(group(key, entry.title, entry.lines))
+  if (other.length) groups.push(group(OTHER_PARTS_GROUP, L('otherParts', 'Other parts'), other))
+  return groups
+}

+ 37 - 24
src/features/invoice-designer/Pdf/renderPdf.tsx

@@ -237,6 +237,7 @@ export function RenderNodePdf({ node, base }: { node: Node; base: TextStyle }):
       const cell = (width: number | 'flex'): Style => (width === 'flex' ? { flex: 1 } : { width })
       const headerText = textStylePdf(node.headerStyle, base)
       const borderColor = node.style?.borderColor ?? '#eceef1'
+      let band = 0
       return (
         <View style={boxStylePdf(node.style)}>
           <View
@@ -260,6 +261,11 @@ export function RenderNodePdf({ node, base }: { node: Node; base: TextStyle }):
           </View>
           {node.rows.flatMap((row, i) => {
             const struck = node.strikeKey ? !!row[node.strikeKey] : false
+            const heading = node.groupKey ? row[node.groupKey] : undefined
+            const emphasis = node.emphasisKey ? !!row[node.emphasisKey] : false
+            // Banding counts the lines, not the headings between them, so a
+            // group never starts on a band because of how many came before.
+            const banded = !!node.stripe && !heading && band++ % 2 === 1
             // Rules are their own elements between rows, and they overlap both
             // neighbours by a hair: PDF viewers leave antialiasing seams
             // between rectangles that merely touch, which showed as white
@@ -293,7 +299,7 @@ export function RenderNodePdf({ node, base }: { node: Node; base: TextStyle }):
                   alignItems: 'flex-start',
                   paddingVertical: node.rowPadding ?? 5,
                   paddingHorizontal: 8,
-                  ...(node.stripe && i % 2 === 1
+                  ...(banded
                     ? { backgroundColor: node.stripe }
                     : node.rowBackground
                       ? { backgroundColor: node.rowBackground }
@@ -301,30 +307,37 @@ export function RenderNodePdf({ node, base }: { node: Node; base: TextStyle }):
                   ...(struck ? { opacity: 0.5 } : {}),
                 }}
               >
-                {node.columns.map((column) => (
-                  <View key={column.key} style={cell(column.width)}>
-                    <Text
-                      style={{
-                        ...textStylePdf(undefined, base),
-                        textAlign: column.align,
-                        ...(struck ? { textDecoration: 'line-through' as const } : {}),
-                      }}
-                    >
-                      {row[column.key]}
-                    </Text>
-                    {node.subKey && column.width === 'flex' && row[node.subKey] ? (
-                      <Text
-                        style={{
-                          ...textStylePdf(undefined, base),
-                          fontSize: (base.fontSize ?? 9) * 0.85,
-                          opacity: 0.6,
-                        }}
-                      >
-                        {row[node.subKey]}
-                      </Text>
-                    ) : null}
+                {heading ? (
+                  <View style={{ flex: 1 }}>
+                    <Text style={{ ...textStylePdf({ bold: true }, base) }}>{heading}</Text>
                   </View>
-                ))}
+                ) : null}
+                {heading
+                  ? null
+                  : node.columns.map((column) => (
+                      <View key={column.key} style={cell(column.width)}>
+                        <Text
+                          style={{
+                            ...textStylePdf(emphasis ? { bold: true } : undefined, base),
+                            textAlign: column.align,
+                            ...(struck ? { textDecoration: 'line-through' as const } : {}),
+                          }}
+                        >
+                          {row[column.key]}
+                        </Text>
+                        {node.subKey && column.width === 'flex' && row[node.subKey] ? (
+                          <Text
+                            style={{
+                              ...textStylePdf(undefined, base),
+                              fontSize: (base.fontSize ?? 9) * 0.85,
+                              opacity: 0.6,
+                            }}
+                          >
+                            {row[node.subKey]}
+                          </Text>
+                        ) : null}
+                      </View>
+                    ))}
               </View>
             )
             return rule ? [body, rule] : [body]

+ 29 - 17
src/features/invoice-designer/Render/renderHtml.tsx

@@ -270,6 +270,7 @@ function NodeBody({ node }: { node: Node }): ReactNode {
     case 'table': {
       const cell = (width: number | 'flex'): CSSProperties =>
         width === 'flex' ? { flex: 1, minWidth: 0 } : { width, flex: 'none' }
+      let band = 0
       return (
         <div {...id} style={boxCss(node.style)}>
           <div
@@ -288,6 +289,11 @@ function NodeBody({ node }: { node: Node }): ReactNode {
           </div>
           {node.rows.flatMap((row, i) => {
             const struck = node.strikeKey ? !!row[node.strikeKey] : false
+            const heading = node.groupKey ? row[node.groupKey] : undefined
+            const emphasis = node.emphasisKey ? !!row[node.emphasisKey] : false
+            // Banding counts the lines, not the headings between them, so a
+            // group never starts on a band because of how many came before.
+            const banded = !!node.stripe && !heading && band++ % 2 === 1
             // Rules are their own elements between rows, overlapping both
             // neighbours by a hair so scaled rendering cannot leave seams;
             // a banded background paints after the rule and covers the
@@ -313,28 +319,34 @@ function NodeBody({ node }: { node: Node }): ReactNode {
                   display: 'flex',
                   alignItems: 'flex-start',
                   padding: `${node.rowPadding ?? 5}px 8px`,
-                  background: node.stripe && i % 2 === 1 ? node.stripe : node.rowBackground,
+                  background: banded ? node.stripe : node.rowBackground,
                   opacity: struck ? 0.5 : undefined,
                   textDecoration: struck ? 'line-through' : undefined,
+                  fontWeight: emphasis ? 700 : undefined,
                 }}
               >
-                {node.columns.map((column) => (
-                  <span
-                    key={column.key}
-                    style={{
-                      ...cell(column.width),
-                      textAlign: column.align,
-                      whiteSpace: 'pre-line',
-                    }}
-                  >
-                    {row[column.key]}
-                    {node.subKey && column.width === 'flex' && row[node.subKey] ? (
-                      <span style={{ display: 'block', opacity: 0.6, fontSize: '0.85em' }}>
-                        {row[node.subKey]}
+                {heading ? (
+                  <span style={{ flex: 1, minWidth: 0, fontWeight: 700 }}>{heading}</span>
+                ) : null}
+                {heading
+                  ? null
+                  : node.columns.map((column) => (
+                      <span
+                        key={column.key}
+                        style={{
+                          ...cell(column.width),
+                          textAlign: column.align,
+                          whiteSpace: 'pre-line',
+                        }}
+                      >
+                        {row[column.key]}
+                        {node.subKey && column.width === 'flex' && row[node.subKey] ? (
+                          <span style={{ display: 'block', opacity: 0.6, fontSize: '0.85em' }}>
+                            {row[node.subKey]}
+                          </span>
+                        ) : null}
                       </span>
-                    ) : null}
-                  </span>
-                ))}
+                    ))}
               </div>
             )
             return rule ? [body, rule] : [body]

+ 97 - 18
src/features/invoice-designer/Spec/buildSpec.ts

@@ -47,6 +47,15 @@ export interface PaymentPair {
   value: string
 }
 
+/** One division of a grouped table: its heading and the sum of its lines. */
+export interface LineGroup {
+  key: string
+  title: string
+  /** The subtotal row's wording, e.g. "Brakes subtotal". */
+  subtotalLabel: string
+  subtotal: string
+}
+
 export interface DocumentData {
   /** Field id to the value it prints, covering every field a layout can show. */
   fields: Record<string, string>
@@ -72,6 +81,8 @@ export interface DocumentData {
     total: string
     /** Lines the customer opted out of print struck through. */
     excluded?: boolean
+    /** The key of the line's group in `lineGroups`, for a table grouping by category. */
+    group?: string
   }[]
   parts: {
     ref: string
@@ -80,8 +91,17 @@ export interface DocumentData {
     price: string
     total: string
     excluded?: boolean
+    group?: string
   }[]
   labor: { desc: string; qty: string; rate: string; total: string; excluded?: boolean }[]
+  /**
+   * The groups a table divides its lines into when its layout asks to group
+   * by category, in print order: one per part category, one for parts without
+   * a category and one for the labor lines, each with its subtotal already
+   * worded. Absent or empty when no part line has a category, and a grouped
+   * table prints flat.
+   */
+  lineGroups?: LineGroup[]
   findings: { severity: string; color: string; description: string; notes: string }[]
   totals: TotalLine[]
   notes: { html?: string }
@@ -970,6 +990,47 @@ function documentTitle(
   }
 }
 
+/** Row keys a grouped table reads its heading and subtotal rows by. */
+const GROUP_TITLE_KEY = 'groupTitle'
+const GROUP_SUBTOTAL_KEY = 'groupSubtotal'
+
+/**
+ * The rows of a table of lines, divided the way the section asks.
+ *
+ * Flat unless the layout groups by category and the document has groups to
+ * show; then each group prints a heading row, its lines and a subtotal row,
+ * in the order `lineGroups` gives, and a line in no known group closes the
+ * table on its own. `toRow` gets the line's place in print order, so a
+ * numbered list counts the way the reader sees it.
+ */
+function groupedRows<L extends { group?: string }>(
+  section: InvoiceSection,
+  data: DocumentData,
+  lines: L[],
+  toRow: (line: L, index: number) => Record<string, string>,
+  labelKey: string
+): { rows: Record<string, string>[]; grouped: boolean } {
+  const groups = data.lineGroups ?? []
+  if (section.groupBy !== 'category' || groups.length === 0) {
+    return { rows: lines.map(toRow), grouped: false }
+  }
+  const rows: Record<string, string>[] = []
+  const placed = new Set<L>()
+  let index = 0
+  for (const group of groups) {
+    const members = lines.filter((line) => line.group === group.key)
+    if (!members.length) continue
+    rows.push({ [GROUP_TITLE_KEY]: group.title })
+    for (const line of members) {
+      rows.push(toRow(line, index++))
+      placed.add(line)
+    }
+    rows.push({ [labelKey]: group.subtotalLabel, total: group.subtotal, [GROUP_SUBTOTAL_KEY]: '1' })
+  }
+  for (const line of lines) if (!placed.has(line)) rows.push(toRow(line, index++))
+  return { rows, grouped: true }
+}
+
 /** The one numbered list of everything on the job. */
 function itemsTable(
   section: InvoiceSection,
@@ -979,6 +1040,24 @@ function itemsTable(
   if (!data.items.length) return null
   const look = lookOf(section, theme)
   const size = look.fontSize ?? theme.fontSize
+  // Grouped, the list is renumbered in print order; flat, it keeps the
+  // numbers the document gave its lines.
+  const items = groupedRows(
+    section,
+    data,
+    data.items,
+    (item, index) => ({
+      n: section.groupBy === 'category' ? String(index + 1) : item.n,
+      qty: item.qty,
+      unit: item.unit,
+      desc: item.desc,
+      sub: item.sub ?? '',
+      price: item.price,
+      total: item.total,
+      struck: item.excluded ? '1' : '',
+    }),
+    'desc'
+  )
   return {
     kind: 'table',
     id: section.id,
@@ -1004,16 +1083,8 @@ function itemsTable(
     ],
     subKey: 'sub',
     strikeKey: 'struck',
-    rows: data.items.map((item) => ({
-      n: item.n,
-      qty: item.qty,
-      unit: item.unit,
-      desc: item.desc,
-      sub: item.sub ?? '',
-      price: item.price,
-      total: item.total,
-      struck: item.excluded ? '1' : '',
-    })),
+    ...(items.grouped ? { groupKey: GROUP_TITLE_KEY, emphasisKey: GROUP_SUBTOTAL_KEY } : {}),
+    rows: items.rows,
   }
 }
 
@@ -1058,6 +1129,20 @@ function partsTable(
   if (!data.parts.length) return null
   const look = lookOf(section, theme)
   const size = look.fontSize ?? theme.fontSize
+  const parts = groupedRows(
+    section,
+    data,
+    data.parts,
+    (p) => ({
+      ref: p.ref,
+      desc: p.desc,
+      qty: p.qty,
+      price: p.price,
+      total: p.total,
+      struck: p.excluded ? '1' : '',
+    }),
+    'desc'
+  )
   return titledTable(section, theme, label(data, 'parts', 'Parts'), undefined, {
     kind: 'table',
     rowPadding: theme.rowPadding,
@@ -1080,14 +1165,8 @@ function partsTable(
       { key: 'total', label: label(data, 'total', 'Total'), width: 74, align: 'right' },
     ],
     strikeKey: 'struck',
-    rows: data.parts.map((p) => ({
-      ref: p.ref,
-      desc: p.desc,
-      qty: p.qty,
-      price: p.price,
-      total: p.total,
-      struck: p.excluded ? '1' : '',
-    })),
+    ...(parts.grouped ? { groupKey: GROUP_TITLE_KEY, emphasisKey: GROUP_SUBTOTAL_KEY } : {}),
+    rows: parts.rows,
   })
 }
 

+ 7 - 0
src/features/invoice-designer/Spec/documentSpec.ts

@@ -98,6 +98,13 @@ export type Node =
       subKey?: string
       /** Rows whose value here is truthy print struck through and dimmed. */
       strikeKey?: string
+      /**
+       * Rows whose value here is set are group headings: that value prints
+       * bold across the whole row, and the row is never banded.
+       */
+      groupKey?: string
+      /** Rows whose value here is truthy print bold, for a group's subtotal. */
+      emphasisKey?: string
       /** Thickness of the rule under each row, in points. */
       ruleWidth?: number
       /**

+ 3 - 0
src/features/labor-presets/Actions/laborPresetActions.ts

@@ -122,6 +122,7 @@ export async function createLaborPreset(input: unknown) {
                   partNumber: part.partNumber || null,
                   quantity: part.quantity,
                   unit: part.unit ?? null,
+                  category: part.category ?? null,
                   unitPrice: part.unitPrice,
                   inventoryPartId: part.inventoryPartId || null,
                   sortOrder: index,
@@ -194,6 +195,7 @@ export async function updateLaborPreset(input: unknown) {
                     partNumber: part.partNumber || null,
                     quantity: part.quantity,
                     unit: part.unit ?? null,
+                    category: part.category ?? null,
                     unitPrice: part.unitPrice,
                     inventoryPartId: part.inventoryPartId || null,
                     sortOrder: index,
@@ -273,6 +275,7 @@ export async function getLaborPresetsList() {
               partNumber: true,
               quantity: true,
               unit: true,
+              category: true,
               unitPrice: true,
               inventoryPartId: true,
               sortOrder: true,

+ 1 - 0
src/features/labor-presets/Components/InventorySearchDialog.tsx

@@ -11,6 +11,7 @@ export interface InventoryPartOption {
   name: string
   partNumber: string | null
   unit?: string | null
+  category?: string | null
   sellPrice: number
   unitCost: number
   /** On-hand stock, shown by the inline name suggestions. */

+ 5 - 0
src/features/labor-presets/Components/LaborPresetForm.tsx

@@ -49,6 +49,7 @@ interface LaborPresetData {
     partNumber: string | null
     quantity: number
     unit: string | null
+    category?: string | null
     unitPrice: number
     inventoryPartId: string | null
     sortOrder: number
@@ -67,6 +68,7 @@ interface LaborPresetFormProps {
     name: string
     partNumber: string | null
     unit: string | null
+    category?: string | null
     sellPrice: number
     unitCost: number
     quantity: number
@@ -154,6 +156,7 @@ export function LaborPresetForm({
       partNumber: p.partNumber || '',
       quantity: p.quantity,
       unit: p.unit ?? null,
+      category: p.category ?? null,
       unitPrice: p.unitPrice,
       inventoryPartId: p.inventoryPartId || '',
     })) ?? []
@@ -194,6 +197,7 @@ export function LaborPresetForm({
           partNumber: p.partNumber || '',
           quantity: p.quantity,
           unit: p.unit ?? null,
+          category: p.category ?? null,
           unitPrice: p.unitPrice,
           inventoryPartId: p.inventoryPartId || '',
         })) ?? []
@@ -254,6 +258,7 @@ export function LaborPresetForm({
         partNumber: part.partNumber || undefined,
         quantity: Number(part.quantity) || 1,
         unit: part.unit ?? undefined,
+        category: part.category ?? undefined,
         unitPrice: Number(part.unitPrice) || 0,
         inventoryPartId: part.inventoryPartId || undefined,
         sortOrder: index,

+ 1 - 0
src/features/labor-presets/Components/LaborPresetPickerDialog.tsx

@@ -22,6 +22,7 @@ export interface LaborPresetOption {
     partNumber: string | null
     quantity: number
     unit: string | null
+    category?: string | null
     unitPrice: number
     inventoryPartId: string | null
     sortOrder: number

+ 3 - 0
src/features/labor-presets/Components/PresetPartsEditor.tsx

@@ -20,6 +20,7 @@ export interface PresetPartItem {
   quantity: number
   /** Unit of measure snapshotted from the picked inventory part. */
   unit: string | null
+  category?: string | null
   unitPrice: number
   inventoryPartId: string
 }
@@ -70,6 +71,7 @@ export function PresetPartsEditor({
               name: picked.name,
               partNumber: picked.partNumber ?? '',
               unit: picked.unit ?? null,
+              category: picked.category ?? null,
               unitPrice,
               inventoryPartId: picked.id,
             }
@@ -87,6 +89,7 @@ export function PresetPartsEditor({
         partNumber: ip.partNumber || '',
         quantity: 1,
         unit: ip.unit ?? null,
+        category: ip.category ?? null,
         unitPrice: price,
         inventoryPartId: ip.id,
       },

+ 1 - 0
src/features/labor-presets/Schema/laborPresetSchema.ts

@@ -14,6 +14,7 @@ export const laborPresetPartSchema = z.object({
   quantity: z.coerce.number().min(0, 'Quantity must be 0 or more').default(1),
   /** Unit of measure snapshotted from the picked inventory part. */
   unit: z.string().nullish(),
+  category: z.string().nullish(),
   unitPrice: z.coerce.number().min(0, 'Price must be 0 or more').default(0),
   inventoryPartId: z.string().optional(),
   sortOrder: z.coerce.number().int().min(0).default(0),

+ 1 - 0
src/features/quotes/Components/QuotePageClient.tsx

@@ -183,6 +183,7 @@ export function QuotePageClient({
           partNumber: part.partNumber || '',
           quantity: part.quantity,
           unit: part.unit ?? null,
+          category: part.category ?? null,
           unitCost: 0,
           markupPercent: 0,
           unitPrice: part.unitPrice,

+ 43 - 6
src/features/quotes/Components/QuotePartsEditor.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { memo, useCallback, useState } from 'react'
+import { memo, useCallback, useId, useMemo, useState } from 'react'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Textarea } from '@/components/ui/textarea'
@@ -28,12 +28,17 @@ const QuotePartRow = memo(function QuotePartRow({
   onUpdate,
   onDelete,
   tPartNumber,
+  tCategory,
   tNamePlaceholder,
   tDeleteRow,
   tExcludeFromTotal,
   inventoryParts,
   onSelectSuggestion,
+  categoryListId,
 }: {
+  /** The datalist of categories the workshop's stock already uses. */
+  categoryListId: string
+  tCategory: string
   part: QuotePartInput
   index: number
   currencyCode: string
@@ -66,11 +71,24 @@ const QuotePartRow = memo(function QuotePartRow({
     <div
       className={`grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.6fr_0.9fr_0.7fr_0.9fr_0.9fr_auto] ${part.excluded ? 'line-through opacity-50' : ''}`}
     >
-      <Input
-        placeholder={tPartNumber}
-        value={part.partNumber ?? ''}
-        onChange={(e) => onUpdate(index, 'partNumber', e.target.value)}
-      />
+      <div className="flex flex-col gap-1">
+        <Input
+          placeholder={tPartNumber}
+          value={part.partNumber ?? ''}
+          onChange={(e) => onUpdate(index, 'partNumber', e.target.value)}
+        />
+        {/* The category decides which group the line prints under on a sheet
+            that groups its parts. A stocked part brings its own; a typed line
+            can be given one here. */}
+        <Input
+          list={categoryListId}
+          placeholder={tCategory}
+          aria-label={tCategory}
+          value={part.category ?? ''}
+          onChange={(e) => onUpdate(index, 'category', e.target.value)}
+          className="h-7 text-xs"
+        />
+      </div>
       <div className="relative">
         {/* A textarea, as on the invoice: a part often needs a second line,
             and it grows with what is typed rather than starting tall. */}
@@ -192,6 +210,16 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
   const formatCurrency = useFormatCurrency()
   const [pickerOpen, setPickerOpen] = useState(false)
   const canPickFromStock = inventoryParts.length > 0 && !!onAddBulk
+  const categoryListId = useId()
+  // The categories the stock already uses, offered as the line is typed, so a
+  // free-text part lands in the same group as the stocked ones.
+  const categories = useMemo(
+    () =>
+      [
+        ...new Set(inventoryParts.map((p) => p.category?.trim()).filter(Boolean)),
+      ].sort() as string[],
+    [inventoryParts]
+  )
 
   // Applying a suggestion touches five fields. onUpdate handles one at a time,
   // so call it per field rather than leaving the row half-populated; the
@@ -208,6 +236,7 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
       onUpdate(index, 'name', picked.name)
       onUpdate(index, 'partNumber', picked.partNumber ?? '')
       onUpdate(index, 'unit', picked.unit ?? null)
+      onUpdate(index, 'category', picked.category ?? null)
       onUpdate(index, 'unitCost', unitCost)
       onUpdate(index, 'markupPercent', markupFromCostAndPrice(unitCost, unitPrice))
       onUpdate(index, 'unitPrice', unitPrice)
@@ -234,6 +263,11 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
       </div>
       {partItems.length > 0 && (
         <>
+          <datalist id={categoryListId}>
+            {categories.map((category) => (
+              <option key={category} value={category} />
+            ))}
+          </datalist>
           <div className="hidden grid-cols-[1fr_2fr_0.6fr_0.9fr_0.7fr_0.9fr_0.9fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
             <span>{t('parts.partNumber')}</span>
             <span>{t('parts.name')}</span>
@@ -255,6 +289,8 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
               inventoryParts={inventoryParts}
               onSelectSuggestion={handleSelectSuggestion}
               tPartNumber={t('parts.partNumber')}
+              tCategory={t('parts.category')}
+              categoryListId={categoryListId}
               tNamePlaceholder={t('parts.namePlaceholder')}
               tDeleteRow={t('parts.deleteRow')}
               tExcludeFromTotal={t('parts.excludeFromTotal')}
@@ -295,6 +331,7 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
                 name: picked.name,
                 quantity: picked.quantity,
                 unit: picked.unit ?? null,
+                category: picked.category ?? null,
                 unitCost: Number(picked.unitCost) || 0,
                 markupPercent: Number(picked.markupPercent) || 0,
                 unitPrice: picked.unitPrice,

+ 2 - 0
src/features/quotes/Components/quote-page-types.ts

@@ -54,6 +54,7 @@ export interface QuoteRecord {
     name: string
     quantity: number
     unit?: string | null
+    category?: string | null
     unitCost?: number
     markupPercent?: number
     unitPrice: number
@@ -106,6 +107,7 @@ export const emptyPart = (): QuotePartInput => ({
   name: '',
   quantity: 1,
   unit: null,
+  category: null,
   unitCost: 0,
   markupPercent: 0,
   unitPrice: 0,

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

@@ -104,6 +104,7 @@ export function useQuoteFormState({
       name: p.name,
       quantity: p.quantity,
       unit: p.unit ?? null,
+      category: p.category ?? null,
       unitCost: p.unitCost ?? 0,
       markupPercent: p.markupPercent ?? 0,
       unitPrice: p.unitPrice,

+ 1 - 0
src/features/quotes/Lib/copyQuoteToJob.ts

@@ -42,6 +42,7 @@ export async function copyQuotePartsToJob(
       name: p.name,
       quantity: p.quantity,
       unit: p.unit,
+      category: p.category,
       unitCost: p.unitCost,
       markupPercent: p.markupPercent,
       unitPrice: p.unitPrice,

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

@@ -7,6 +7,7 @@ export const quotePartSchema = z.object({
   quantity: z.coerce.number().min(0).default(1),
   /** Unit of measure snapshotted from the picked inventory part. */
   unit: z.string().nullish(),
+  category: z.string().nullish(),
   unitCost: z.coerce.number().min(0).default(0),
   /**
    * Selling below cost is a real decision (a goodwill line, matching a price),

+ 8 - 0
src/features/settings/Schema/invoiceLayoutSchema.ts

@@ -95,6 +95,14 @@ export const invoiceSectionSchema = z.object({
    * Blank or unset prints the document's own name in the reader's language.
    */
   text: z.string().max(60).optional(),
+  /**
+   * How a table of lines is divided. `category` prints the part lines under
+   * a heading per category with a subtotal for each, the way a long bill is
+   * easier to read when brakes, service and tyres are added up on their own.
+   * Only the items and parts tables offer it; labor lines have no category
+   * and print after the parts in their own group. Unset prints one flat list.
+   */
+  groupBy: z.enum(['category']).optional(),
   /** Appearance overrides for this section. Unset uses the document's own. */
   style: invoiceSectionStyleSchema.optional(),
   /** Controls which fields are shown within this section. */

+ 6 - 0
src/features/tire-hotel/Actions/tireJobActions.ts

@@ -294,6 +294,7 @@ export async function createQuoteFromTireSet(input: unknown) {
               name: true,
               partNumber: true,
               unit: true,
+              category: true,
               unitCost: true,
               sellPrice: true,
             },
@@ -376,6 +377,7 @@ export async function createQuoteFromTireSet(input: unknown) {
                       partNumber: part?.partNumber ?? null,
                       quantity: lineQuantity,
                       unit: part?.unit ?? null,
+                      category: part?.category ?? null,
                       unitCost: part?.unitCost ?? 0,
                       unitPrice,
                       total: lineTotal,
@@ -450,6 +452,7 @@ export async function createWorkOrderFromTireSet(input: unknown) {
               name: true,
               partNumber: true,
               unit: true,
+              category: true,
               unitCost: true,
               sellPrice: true,
             },
@@ -472,6 +475,7 @@ export async function createWorkOrderFromTireSet(input: unknown) {
             partNumber: part?.partNumber ?? null,
             quantity: lineQuantity,
             unit: part?.unit ?? null,
+            category: part?.category ?? null,
             unitPrice,
             unitCost: part?.unitCost ?? 0,
             inventoryPartId: part?.id ?? null,
@@ -621,6 +625,7 @@ export async function addTireSetToWorkOrder(input: unknown) {
               name: true,
               partNumber: true,
               unit: true,
+              category: true,
               unitCost: true,
               sellPrice: true,
             },
@@ -640,6 +645,7 @@ export async function addTireSetToWorkOrder(input: unknown) {
             partNumber: part?.partNumber ?? null,
             quantity: lineQuantity,
             unit: part?.unit ?? null,
+            category: part?.category ?? null,
             unitPrice,
             unitCost: part?.unitCost ?? 0,
             inventoryPartId: part?.id ?? null,

+ 2 - 0
src/features/tire-hotel/Lib/addTireLine.ts

@@ -9,6 +9,7 @@ export type TireLineInput = {
   quantity: number
   /** Unit of measure snapshotted from the stocked part; null for a set. */
   unit: string | null
+  category?: string | null
   unitPrice: number
   unitCost: number
   /** Null for a set that is not a catalogue item, which moves no stock. */
@@ -48,6 +49,7 @@ export async function addTireLineToRecord(
       partNumber: line.partNumber,
       quantity: line.quantity,
       unit: line.unit,
+      category: line.category ?? null,
       unitPrice: line.unitPrice,
       unitCost: line.unitCost,
       total: Math.round(line.unitPrice * line.quantity * 100) / 100,

+ 2 - 0
src/features/vehicles/Actions/serviceActions.ts

@@ -539,6 +539,7 @@ export async function createServiceRecord(input: unknown) {
               name: p.name,
               quantity: p.quantity,
               unit: p.unit ?? null,
+              category: p.category ?? null,
               unitPrice: p.unitPrice,
               total: p.total,
               unitCost: resolvedUnitCost,
@@ -824,6 +825,7 @@ export async function updateServiceRecord(input: unknown) {
                 name: p.name,
                 quantity: p.quantity,
                 unit: p.unit ?? null,
+                category: p.category ?? null,
                 unitPrice: p.unitPrice,
                 total: p.total,
                 unitCost: p.unitCost ?? 0,

+ 1 - 0
src/features/vehicles/Components/MyActiveJobs.tsx

@@ -203,6 +203,7 @@ export function MyActiveJobs({
       name: scannedPart.name,
       quantity: qty,
       unit: scannedPart.unit ?? null,
+      category: scannedPart.category ?? null,
       unitPrice: price,
       total: price * qty,
       unitCost: scannedPart.unitCost,

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

@@ -66,6 +66,8 @@ export interface InvoiceData {
     name: string
     quantity: number
     unit?: string | null
+    /** For a layout that groups the lines by category. */
+    category?: string | null
     unitPrice: number
     total: number
   }[]

+ 1 - 0
src/features/vehicles/Components/service-detail/types.ts

@@ -100,6 +100,7 @@ export interface PartItem {
   name: string
   quantity: number
   unit: string | null
+  category?: string | null
   unitPrice: number
   total: number
 }

+ 1 - 0
src/features/vehicles/Components/service-edit/InventoryPickerDialog.tsx

@@ -109,6 +109,7 @@ export function InventoryPickerDialog({
                   name: ip.name,
                   quantity: 1,
                   unit: ip.unit ?? null,
+                  category: ip.category ?? null,
                   unitPrice: price,
                   total: price,
                   unitCost: ip.unitCost,

+ 40 - 6
src/features/vehicles/Components/service-edit/PartsEditor.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useRef, useCallback, useState, useEffect } from 'react'
+import { useRef, useCallback, useState, useEffect, useId, useMemo } from 'react'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Textarea } from '@/components/ui/textarea'
@@ -78,10 +78,13 @@ function SortablePartRow({
   onSelectSuggestion,
   defaultMarkupPercent,
   markupAppliesToInventory,
+  categoryListId,
 }: {
   id: string
   part: ServicePartInput
   index: number
+  /** The datalist of categories the workshop's stock already uses. */
+  categoryListId: string
   updatePart: (
     index: number,
     field: keyof ServicePartInput,
@@ -134,11 +137,24 @@ function SortablePartRow({
       </button>
       <div className="flex min-w-0 flex-col gap-2 @2xl:contents">
         <FieldRow label={t('partNumber')}>
-          <Input
-            placeholder={t('partNumber')}
-            value={part.partNumber ?? ''}
-            onChange={(e) => updatePart(index, 'partNumber', e.target.value)}
-          />
+          <div className="flex w-full flex-col gap-1">
+            <Input
+              placeholder={t('partNumber')}
+              value={part.partNumber ?? ''}
+              onChange={(e) => updatePart(index, 'partNumber', e.target.value)}
+            />
+            {/* The category decides which group the line prints under on a
+                sheet that groups its parts. A stocked part brings its own;
+                a typed line can be given one here. */}
+            <Input
+              list={categoryListId}
+              placeholder={t('category')}
+              aria-label={t('category')}
+              value={part.category ?? ''}
+              onChange={(e) => updatePart(index, 'category', e.target.value)}
+              className="h-7 text-xs"
+            />
+          </div>
         </FieldRow>
         {/* The name identifies the row, so it leads the stacked card even
             though the part number comes first in the desktop grid */}
@@ -262,6 +278,16 @@ export function PartsEditor({
   const formatCurrency = useFormatCurrency()
   const t = useTranslations('service.parts')
   const modern = useModernWorkOrder()
+  const categoryListId = useId()
+  // The categories the stock already uses, offered as the line is typed, so a
+  // free-text part lands in the same group as the stocked ones.
+  const categories = useMemo(
+    () =>
+      [
+        ...new Set(inventoryParts.map((p) => p.category?.trim()).filter(Boolean)),
+      ].sort() as string[],
+    [inventoryParts]
+  )
   const [mounted, setMounted] = useState(false)
   useEffect(() => setMounted(true), [])
   const keyCounterRef = useRef(0)
@@ -286,6 +312,7 @@ export function PartsEditor({
             name: picked.name,
             partNumber: picked.partNumber ?? '',
             unit: picked.unit ?? null,
+            category: picked.category ?? null,
             unitCost: picked.unitCost,
             unitPrice,
             markupPercent,
@@ -394,6 +421,11 @@ export function PartsEditor({
     <>
       {partItems.length > 0 && (
         <>
+          <datalist id={categoryListId}>
+            {categories.map((category) => (
+              <option key={category} value={category} />
+            ))}
+          </datalist>
           <div className="hidden grid-cols-[auto_1fr_2fr_0.6fr_0.9fr_0.7fr_0.9fr_0.9fr_auto] gap-2 text-xs font-medium text-muted-foreground @2xl:grid">
             <span className="w-6" />
             <span>{t('partNumber')}</span>
@@ -416,6 +448,7 @@ export function PartsEditor({
                   <SortablePartRow
                     key={keysRef.current[i]}
                     id={keysRef.current[i]}
+                    categoryListId={categoryListId}
                     part={part}
                     index={i}
                     updatePart={updatePart}
@@ -434,6 +467,7 @@ export function PartsEditor({
           ) : (
             partItems.map((part, i) => (
               <SortablePartRow
+                categoryListId={categoryListId}
                 key={keysRef.current[i]}
                 id={keysRef.current[i]}
                 part={part}

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

@@ -265,6 +265,7 @@ export function ServicePageClient({
             name: part.name,
             quantity: 1,
             unit: part.unit ?? null,
+            category: part.category ?? null,
             unitPrice,
             total: lineTotal(1, unitPrice),
             unitCost: part.unitCost,
@@ -386,6 +387,7 @@ export function ServicePageClient({
         partNumber: part.partNumber || '',
         quantity: part.quantity,
         unit: part.unit ?? null,
+        category: part.category ?? null,
         unitPrice: part.unitPrice,
         total: lineTotal(part.quantity, part.unitPrice),
         unitCost: 0,

+ 1 - 0
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -277,6 +277,7 @@ export async function ServiceRecordPage({
       name: p.name,
       quantity: p.quantity,
       unit: p.unit ?? null,
+      category: p.category ?? null,
       unitPrice: p.unitPrice,
       total: p.total,
       unitCost: p.unitCost ?? 0,

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

@@ -22,6 +22,7 @@ export interface AddPartInput {
   name: string
   quantity: number
   unit?: string | null
+  category?: string | null
   unitPrice: number
   total: number
   unitCost: number
@@ -74,6 +75,7 @@ export async function addPart(args: {
         name: input.name,
         quantity: input.quantity,
         unit: input.unit || null,
+        category: input.category || null,
         unitPrice: input.unitPrice,
         total: input.total,
         unitCost: input.unitCost,

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

@@ -7,6 +7,7 @@ export const servicePartSchema = z.object({
   quantity: z.coerce.number().min(0).default(1),
   /** Unit of measure snapshotted from the picked inventory part. */
   unit: z.string().nullish(),
+  category: z.string().nullish(),
   unitPrice: z.coerce.number().min(0).default(0),
   total: z.coerce.number().min(0).default(0),
   unitCost: z.coerce.number().min(0).default(0),