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

Restore part cost, markup and stock links from backups; pick preset parts from inventory (#266)

* Inventory units of measure: stock parts in pcs, liters, kg or imperial units

Parts now carry a free-text unit (metric and imperial suggestions, ordered by
the workshop's unit system, localized count units) that is snapshotted onto
work order, quote, preset and tire lines at pick time and rendered on every
quantity: invoices, quotes, public shares, PDFs, reports, CSV, search,
alerts, dashboards. Stock quantities and the movement ledger are fractional
now, so booking 2.5 l deducts 2.5 instead of silently rounding to 3. A new
workshop setting pre-fills the default unit on new parts. Backup restore
keeps unit and no longer zeroes sell prices.

* Restore part cost, markup and stock links from backups; pick preset parts from inventory

A backup restore rebuilt service and quote part lines without unitCost,
markupPercent, inventoryPartId (and excluded on quotes), corrupting profit
reports and stock links after every restore. Both maps now carry every
column, guarded by a coverage test that derives the expected fields from the
schema so a future column cannot be silently dropped again.

The labor preset form's inventory picker was never fed data on the presets
page, so parts could not be picked from stock there. The page now loads the
parts list, and a preset applied to a job or quote keeps the stock link (and
unit), so the material books out of inventory automatically.

* Preset parts: inline inventory suggestions while typing, like the work order editor

Typing a part name in a labor preset now surfaces the stocked matches
directly under the field (the same typeahead the work order and quote
editors use), and picking one links the row to inventory so applying the
preset books the part out of stock. The dialog behind the import button
stays for browsing.

* Unit picker: searchable combobox instead of the native datalist

Same Popover + Command combobox the category field uses: searchable,
grouped (localized count units, then the workshop's measurement system,
then the other one), and anything typed becomes a free-text unit. Used in
the part form and the workshop default-unit setting.

* Let the unit and category dropdowns scroll inside the part form

A popover portalled out of a modal dialog has its wheel events swallowed
by the dialog's scroll lock; making the popover modal restores scrolling.

* Low stock digest: the reorder point carries the part's unit too

"1.5 l left, reorder at 5" read as five of something else; both numbers
are in the part's own unit, so both now say so.
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
4fdace7dcf
100 измененных файлов с 8816 добавлено и 6052 удалено
  1. 1 0
      messages/de/dashboard.json
  2. 8 0
      messages/de/inventory.json
  3. 2 0
      messages/de/settings.json
  4. 1 0
      messages/en/dashboard.json
  5. 8 0
      messages/en/inventory.json
  6. 2 0
      messages/en/settings.json
  7. 1 0
      messages/es/dashboard.json
  8. 8 0
      messages/es/inventory.json
  9. 2 0
      messages/es/settings.json
  10. 1 0
      messages/fr/dashboard.json
  11. 8 0
      messages/fr/inventory.json
  12. 2 0
      messages/fr/settings.json
  13. 1 0
      messages/it/dashboard.json
  14. 8 0
      messages/it/inventory.json
  15. 2 0
      messages/it/settings.json
  16. 1 0
      messages/lt/dashboard.json
  17. 8 0
      messages/lt/inventory.json
  18. 2 0
      messages/lt/settings.json
  19. 1 0
      messages/nb/dashboard.json
  20. 8 0
      messages/nb/inventory.json
  21. 2 0
      messages/nb/settings.json
  22. 1 0
      messages/nl/dashboard.json
  23. 8 0
      messages/nl/inventory.json
  24. 2 0
      messages/nl/settings.json
  25. 1 0
      messages/pl/dashboard.json
  26. 8 0
      messages/pl/inventory.json
  27. 2 0
      messages/pl/settings.json
  28. 1 0
      messages/pt-BR/dashboard.json
  29. 8 0
      messages/pt-BR/inventory.json
  30. 2 0
      messages/pt-BR/settings.json
  31. 1 0
      messages/ru/dashboard.json
  32. 8 0
      messages/ru/inventory.json
  33. 2 0
      messages/ru/settings.json
  34. 1 0
      messages/tr/dashboard.json
  35. 8 0
      messages/tr/inventory.json
  36. 2 0
      messages/tr/settings.json
  37. 23 0
      prisma/migrations/20260826155804_inventory_units_fractional_stock/migration.sql
  38. 19 6
      prisma/schema.prisma
  39. 74 0
      src/__tests__/features/backup/part-restore-fields.test.ts
  40. 9 0
      src/__tests__/features/inventory/low-stock-alerts.test.ts
  41. 17 4
      src/__tests__/features/inventory/reconcile-stock.test.ts
  42. 42 0
      src/__tests__/features/inventory/units.test.ts
  43. 1 0
      src/__tests__/features/tire-hotel/add-tire-line.test.ts
  44. 32 0
      src/__tests__/features/workorders/add-part.test.ts
  45. 45 48
      src/app/(authenticated)/inventory/[id]/page.tsx
  46. 130 113
      src/app/(authenticated)/inventory/[id]/part-detail-client.tsx
  47. 439 359
      src/app/(authenticated)/inventory/inventory-client.tsx
  48. 39 27
      src/app/(authenticated)/inventory/page.tsx
  49. 229 178
      src/app/(authenticated)/labor-presets/labor-presets-client.tsx
  50. 41 26
      src/app/(authenticated)/labor-presets/page.tsx
  51. 498 374
      src/app/(authenticated)/reports/reports-client.tsx
  52. 157 121
      src/app/(authenticated)/settings/workshop/workshop-settings.tsx
  53. 1001 523
      src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx
  54. 115 104
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  55. 86 74
      src/app/(public)/share/quote/[orgId]/[token]/page.tsx
  56. 928 547
      src/app/(public)/share/quote/[orgId]/[token]/quote-view.tsx
  57. 15 0
      src/app/api/protected/backup/import/route.ts
  58. 91 82
      src/app/api/protected/vehicles/[id]/service-history-pdf/route.ts
  59. 143 147
      src/features/ai/tools/workshop-tools.ts
  60. 422 340
      src/features/billing/Components/RecurringInvoicesClient.tsx
  61. 13 13
      src/features/billing/Schema/recurringInvoiceSchema.ts
  62. 1 0
      src/features/dashboard/custom-cards/registry.ts
  63. 2 0
      src/features/dashboard/custom-cards/server-registry.ts
  64. 38 39
      src/features/inventory/Actions/aiAnalyzePartImage.ts
  65. 446 341
      src/features/inventory/Actions/inventoryActions.ts
  66. 40 36
      src/features/inventory/Actions/lookupPartByBarcode.ts
  67. 28 16
      src/features/inventory/Components/BarcodeScanActionDialog.tsx
  68. 414 305
      src/features/inventory/Components/InventoryPartForm.tsx
  69. 12 12
      src/features/inventory/Components/PartNameSuggestions.tsx
  70. 143 0
      src/features/inventory/Components/UnitCombobox.tsx
  71. 40 43
      src/features/inventory/Lib/lowStockAlerts.ts
  72. 43 41
      src/features/inventory/Lib/reconcileStock.ts
  73. 35 0
      src/features/inventory/Lib/units.ts
  74. 26 20
      src/features/inventory/Schema/inventorySchema.ts
  75. 262 215
      src/features/labor-presets/Actions/laborPresetActions.ts
  76. 33 36
      src/features/labor-presets/Components/InventorySearchDialog.tsx
  77. 316 201
      src/features/labor-presets/Components/LaborPresetForm.tsx
  78. 95 57
      src/features/labor-presets/Components/LaborPresetPickerDialog.tsx
  79. 113 58
      src/features/labor-presets/Components/PresetPartsEditor.tsx
  80. 19 17
      src/features/labor-presets/Schema/laborPresetSchema.ts
  81. 117 118
      src/features/onboarding/Lib/sampleData.ts
  82. 5 1
      src/features/quotes/Actions/quoteActions.ts
  83. 3 1
      src/features/quotes/Components/QuotePDF.tsx
  84. 1 0
      src/features/quotes/Components/QuotePageClient.tsx
  85. 29 9
      src/features/quotes/Components/QuotePartsEditor.tsx
  86. 2 0
      src/features/quotes/Components/quote-page-types.ts
  87. 2 1
      src/features/quotes/Components/useQuoteFormState.ts
  88. 2 0
      src/features/quotes/Schema/quoteSchema.ts
  89. 900 777
      src/features/reports/Actions/reportActions.ts
  90. 349 237
      src/features/reports/Components/ReportPDF.tsx
  91. 194 74
      src/features/reports/Components/csv-export.ts
  92. 173 171
      src/features/reports/Schema/reportTypes.ts
  93. 1 0
      src/features/search/Actions/searchActions.ts
  94. 5 1
      src/features/search/Components/SearchCommand.tsx
  95. 131 133
      src/features/settings/Components/InvoiceLayoutPreviewRenderer.tsx
  96. 3 0
      src/features/settings/Schema/settingsSchema.ts
  97. 45 6
      src/features/tire-hotel/Actions/tireJobActions.ts
  98. 3 0
      src/features/tire-hotel/Lib/addTireLine.ts
  99. 2 0
      src/features/vehicles/Actions/addPartToServiceRecord.ts
  100. 2 0
      src/features/vehicles/Actions/serviceActions.ts

+ 1 - 0
messages/de/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Kategorie",
       "quantity": "Menge",
       "minQuantity": "Mindestmenge",
+      "unit": "Einheit",
       "unitCost": "Kosten",
       "sellPrice": "Preis",
       "supplier": "Lieferant"

+ 8 - 0
messages/de/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Regal A-3",
     "quantity": "Menge",
     "minQty": "Mindestmenge",
+    "unit": "Einheit",
+    "unitPlaceholder": "Stk, l, kg...",
+    "unitSuggestions": "Stk,Satz",
+    "unitSearch": "Suchen oder eingeben...",
+    "unitCreate": "\"{name}\" verwenden",
+    "unitGroupCount": "Zähleinheiten",
+    "unitGroupMetric": "Metrisch",
+    "unitGroupImperial": "Imperial",
     "unitCost": "Stückkosten",
     "sellPrice": "Verkaufspreis",
     "supplier": "Lieferant",

+ 2 - 0
messages/de/settings.json

@@ -831,6 +831,8 @@
     "liters": "Liter (L)",
     "miles": "Meilen (mi)",
     "gallons": "Gallonen (gal)",
+    "defaultStockUnit": "Standard-Mengeneinheit",
+    "defaultStockUnitHint": "Wird bei neuen Lagerteilen vorausgefüllt. Jedes Teil kann weiterhin eine eigene Einheit haben.",
     "saveWorkshop": "Werkstatteinstellungen speichern",
     "saved": "Werkstatteinstellungen gespeichert",
     "assignDialogTitle": "Bestehenden Aufträgen zuweisen?",

+ 1 - 0
messages/en/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Category",
       "quantity": "Quantity",
       "minQuantity": "Min quantity",
+      "unit": "Unit",
       "unitCost": "Cost",
       "sellPrice": "Price",
       "supplier": "Supplier"

+ 8 - 0
messages/en/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Shelf A-3",
     "quantity": "Quantity",
     "minQty": "Min Qty",
+    "unit": "Unit",
+    "unitPlaceholder": "pcs, l, kg...",
+    "unitSuggestions": "pcs,set",
+    "unitSearch": "Search or type...",
+    "unitCreate": "Use \"{name}\"",
+    "unitGroupCount": "Count",
+    "unitGroupMetric": "Metric",
+    "unitGroupImperial": "Imperial",
     "unitCost": "Unit Cost",
     "sellPrice": "Sell Price",
     "supplier": "Supplier",

+ 2 - 0
messages/en/settings.json

@@ -831,6 +831,8 @@
     "liters": "liters (L)",
     "miles": "miles (mi)",
     "gallons": "gallons (gal)",
+    "defaultStockUnit": "Default stock unit",
+    "defaultStockUnitHint": "Pre-filled on new inventory parts. Each part can still use its own unit.",
     "saveWorkshop": "Save Workshop Settings",
     "saved": "Workshop settings saved",
     "assignDialogTitle": "Assign to existing work orders?",

+ 1 - 0
messages/es/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Categoría",
       "quantity": "Cantidad",
       "minQuantity": "Cantidad mínima",
+      "unit": "Unidad",
       "unitCost": "Coste",
       "sellPrice": "Precio",
       "supplier": "Proveedor"

+ 8 - 0
messages/es/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Estante A-3",
     "quantity": "Cantidad",
     "minQty": "Cant. mín.",
+    "unit": "Unidad",
+    "unitPlaceholder": "uds, l, kg...",
+    "unitSuggestions": "uds,juego",
+    "unitSearch": "Buscar o escribir...",
+    "unitCreate": "Usar \"{name}\"",
+    "unitGroupCount": "Recuento",
+    "unitGroupMetric": "Métrico",
+    "unitGroupImperial": "Imperial",
     "unitCost": "Coste unitario",
     "sellPrice": "Precio de venta",
     "supplier": "Proveedor",

+ 2 - 0
messages/es/settings.json

@@ -831,6 +831,8 @@
     "liters": "litros (L)",
     "miles": "millas (mi)",
     "gallons": "galones (gal)",
+    "defaultStockUnit": "Unidad de stock predeterminada",
+    "defaultStockUnitHint": "Se rellena automáticamente en las piezas nuevas del inventario. Cada pieza puede seguir usando su propia unidad.",
     "saveWorkshop": "Guardar configuración del taller",
     "saved": "Configuración del taller guardada",
     "assignDialogTitle": "¿Asignar a órdenes de trabajo existentes?",

+ 1 - 0
messages/fr/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Catégorie",
       "quantity": "Quantité",
       "minQuantity": "Quantité min.",
+      "unit": "Unité",
       "unitCost": "Coût",
       "sellPrice": "Prix",
       "supplier": "Fournisseur"

+ 8 - 0
messages/fr/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Étagère A-3",
     "quantity": "Quantité",
     "minQty": "Qté min.",
+    "unit": "Unité",
+    "unitPlaceholder": "pcs, l, kg...",
+    "unitSuggestions": "pcs,jeu",
+    "unitSearch": "Rechercher ou saisir...",
+    "unitCreate": "Utiliser \"{name}\"",
+    "unitGroupCount": "Comptage",
+    "unitGroupMetric": "Métrique",
+    "unitGroupImperial": "Impérial",
     "unitCost": "Coût unitaire",
     "sellPrice": "Prix de vente",
     "supplier": "Fournisseur",

+ 2 - 0
messages/fr/settings.json

@@ -831,6 +831,8 @@
     "liters": "litres (L)",
     "miles": "miles (mi)",
     "gallons": "gallons (gal)",
+    "defaultStockUnit": "Unité de stock par défaut",
+    "defaultStockUnitHint": "Préremplie pour les nouvelles pièces en stock. Chaque pièce peut toujours utiliser sa propre unité.",
     "saveWorkshop": "Enregistrer les paramètres de l'atelier",
     "saved": "Paramètres de l'atelier enregistrés",
     "assignDialogTitle": "Assigner aux ordres de travail existants ?",

+ 1 - 0
messages/it/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Categoria",
       "quantity": "Quantità",
       "minQuantity": "Quantità min.",
+      "unit": "Unità",
       "unitCost": "Costo",
       "sellPrice": "Prezzo",
       "supplier": "Fornitore"

+ 8 - 0
messages/it/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Scaffale A-3",
     "quantity": "Quantità",
     "minQty": "Qtà min.",
+    "unit": "Unità",
+    "unitPlaceholder": "pz, l, kg...",
+    "unitSuggestions": "pz,set",
+    "unitSearch": "Cerca o digita...",
+    "unitCreate": "Usa \"{name}\"",
+    "unitGroupCount": "Conteggio",
+    "unitGroupMetric": "Metrico",
+    "unitGroupImperial": "Imperiale",
     "unitCost": "Costo unitario",
     "sellPrice": "Prezzo di vendita",
     "supplier": "Fornitore",

+ 2 - 0
messages/it/settings.json

@@ -831,6 +831,8 @@
     "liters": "litri (L)",
     "miles": "miglia (mi)",
     "gallons": "galloni (gal)",
+    "defaultStockUnit": "Unità di magazzino predefinita",
+    "defaultStockUnitHint": "Precompilata per i nuovi ricambi a magazzino. Ogni ricambio può comunque usare la propria unità.",
     "saveWorkshop": "Salva impostazioni officina",
     "saved": "Impostazioni officina salvate",
     "assignDialogTitle": "Assegnare agli ordini di lavoro esistenti?",

+ 1 - 0
messages/lt/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Kategorija",
       "quantity": "Kiekis",
       "minQuantity": "Min. kiekis",
+      "unit": "Vienetas",
       "unitCost": "Savikaina",
       "sellPrice": "Kaina",
       "supplier": "Tiekėjas"

+ 8 - 0
messages/lt/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Lentyna A-3",
     "quantity": "Kiekis",
     "minQty": "Min. kiekis",
+    "unit": "Vienetas",
+    "unitPlaceholder": "vnt., l, kg...",
+    "unitSuggestions": "vnt.,kompl.",
+    "unitSearch": "Ieškoti arba įvesti...",
+    "unitCreate": "Naudoti \"{name}\"",
+    "unitGroupCount": "Vienetiniai",
+    "unitGroupMetric": "Metriniai",
+    "unitGroupImperial": "Imperiniai",
     "unitCost": "Vnt. savikaina",
     "sellPrice": "Pardavimo kaina",
     "supplier": "Tiekėjas",

+ 2 - 0
messages/lt/settings.json

@@ -831,6 +831,8 @@
     "liters": "litrai (l)",
     "miles": "mylios (mi)",
     "gallons": "galonai (gal)",
+    "defaultStockUnit": "Numatytasis sandėlio vienetas",
+    "defaultStockUnitHint": "Automatiškai įrašomas naujoms sandėlio dalims. Kiekviena dalis vis tiek gali turėti savo vienetą.",
     "saveWorkshop": "Išsaugoti dirbtuvių nustatymus",
     "saved": "Dirbtuvių nustatymai išsaugoti",
     "assignDialogTitle": "Priskirti esamiems darbo užsakymams?",

+ 1 - 0
messages/nb/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Kategori",
       "quantity": "Antall",
       "minQuantity": "Min. antall",
+      "unit": "Enhet",
       "unitCost": "Kostnad",
       "sellPrice": "Pris",
       "supplier": "Leverandør"

+ 8 - 0
messages/nb/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Hylle A-3",
     "quantity": "Antall",
     "minQty": "Min. antall",
+    "unit": "Enhet",
+    "unitPlaceholder": "stk, l, kg...",
+    "unitSuggestions": "stk,sett",
+    "unitSearch": "Søk eller skriv...",
+    "unitCreate": "Bruk \"{name}\"",
+    "unitGroupCount": "Antall",
+    "unitGroupMetric": "Metrisk",
+    "unitGroupImperial": "Imperisk",
     "unitCost": "Enhetskostnad",
     "sellPrice": "Salgspris",
     "supplier": "Leverandør",

+ 2 - 0
messages/nb/settings.json

@@ -831,6 +831,8 @@
     "liters": "liter (L)",
     "miles": "miles (mi)",
     "gallons": "gallons (gal)",
+    "defaultStockUnit": "Standard lagerenhet",
+    "defaultStockUnitHint": "Fylles ut automatisk for nye lagerdeler. Hver del kan fortsatt ha sin egen enhet.",
     "saveWorkshop": "Lagre verkstedinnstillinger",
     "saved": "Verkstedinnstillinger lagret",
     "assignDialogTitle": "Tildel til eksisterende arbeidsordre?",

+ 1 - 0
messages/nl/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Categorie",
       "quantity": "Aantal",
       "minQuantity": "Min. aantal",
+      "unit": "Eenheid",
       "unitCost": "Kostprijs",
       "sellPrice": "Prijs",
       "supplier": "Leverancier"

+ 8 - 0
messages/nl/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Schap A-3",
     "quantity": "Aantal",
     "minQty": "Min. aantal",
+    "unit": "Eenheid",
+    "unitPlaceholder": "st, l, kg...",
+    "unitSuggestions": "st,set",
+    "unitSearch": "Zoek of typ...",
+    "unitCreate": "Gebruik \"{name}\"",
+    "unitGroupCount": "Aantal",
+    "unitGroupMetric": "Metrisch",
+    "unitGroupImperial": "Imperiaal",
     "unitCost": "Kostprijs",
     "sellPrice": "Verkoopprijs",
     "supplier": "Leverancier",

+ 2 - 0
messages/nl/settings.json

@@ -831,6 +831,8 @@
     "liters": "liters (L)",
     "miles": "mijlen (mi)",
     "gallons": "gallons (gal)",
+    "defaultStockUnit": "Standaard voorraadeenheid",
+    "defaultStockUnitHint": "Vooraf ingevuld bij nieuwe voorraadonderdelen. Elk onderdeel kan nog steeds een eigen eenheid gebruiken.",
     "saveWorkshop": "Werkplaatsinstellingen opslaan",
     "saved": "Werkplaatsinstellingen opgeslagen",
     "assignDialogTitle": "Toewijzen aan bestaande werkorders?",

+ 1 - 0
messages/pl/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Kategoria",
       "quantity": "Ilość",
       "minQuantity": "Min. ilość",
+      "unit": "Jednostka",
       "unitCost": "Koszt",
       "sellPrice": "Cena",
       "supplier": "Dostawca"

+ 8 - 0
messages/pl/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Półka A-3",
     "quantity": "Ilość",
     "minQty": "Min. ilość",
+    "unit": "Jednostka",
+    "unitPlaceholder": "szt., l, kg...",
+    "unitSuggestions": "szt.,kpl.",
+    "unitSearch": "Szukaj lub wpisz...",
+    "unitCreate": "Użyj \"{name}\"",
+    "unitGroupCount": "Sztuki",
+    "unitGroupMetric": "Metryczne",
+    "unitGroupImperial": "Imperialne",
     "unitCost": "Koszt jedn.",
     "sellPrice": "Cena sprzedaży",
     "supplier": "Dostawca",

+ 2 - 0
messages/pl/settings.json

@@ -831,6 +831,8 @@
     "liters": "litry (L)",
     "miles": "mile (mi)",
     "gallons": "galony (gal)",
+    "defaultStockUnit": "Domyślna jednostka magazynowa",
+    "defaultStockUnitHint": "Wstępnie uzupełniana dla nowych części w magazynie. Każda część może nadal mieć własną jednostkę.",
     "saveWorkshop": "Zapisz ustawienia warsztatu",
     "saved": "Ustawienia warsztatu zapisane",
     "assignDialogTitle": "Przypisać do istniejących zleceń?",

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

@@ -210,6 +210,7 @@
       "category": "Categoria",
       "quantity": "Quantidade",
       "minQuantity": "Qtd. mínima",
+      "unit": "Unidade",
       "unitCost": "Custo",
       "sellPrice": "Preço",
       "supplier": "Fornecedor"

+ 8 - 0
messages/pt-BR/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Prateleira A-3",
     "quantity": "Quantidade",
     "minQty": "Qtd. mín.",
+    "unit": "Unidade",
+    "unitPlaceholder": "pçs, l, kg...",
+    "unitSuggestions": "pçs,jogo",
+    "unitSearch": "Buscar ou digitar...",
+    "unitCreate": "Usar \"{name}\"",
+    "unitGroupCount": "Contagem",
+    "unitGroupMetric": "Métrico",
+    "unitGroupImperial": "Imperial",
     "unitCost": "Custo unitário",
     "sellPrice": "Preço de venda",
     "supplier": "Fornecedor",

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

@@ -831,6 +831,8 @@
     "liters": "litros (L)",
     "miles": "milhas (mi)",
     "gallons": "galoes (gal)",
+    "defaultStockUnit": "Unidade de estoque padrão",
+    "defaultStockUnitHint": "Preenchida automaticamente em novas peças do estoque. Cada peça ainda pode usar sua própria unidade.",
     "saveWorkshop": "Salvar configurações da oficina",
     "saved": "Configurações da oficina salvas",
     "assignDialogTitle": "Atribuir às ordens de serviço existentes?",

+ 1 - 0
messages/ru/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Категория",
       "quantity": "Кол-во",
       "minQuantity": "Мин. кол-во",
+      "unit": "Единица",
       "unitCost": "Себестоимость",
       "sellPrice": "Цена",
       "supplier": "Поставщик"

+ 8 - 0
messages/ru/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Полка А-3",
     "quantity": "Количество",
     "minQty": "Мин. кол-во",
+    "unit": "Единица",
+    "unitPlaceholder": "шт, л, кг...",
+    "unitSuggestions": "шт,компл",
+    "unitSearch": "Поиск или ввод...",
+    "unitCreate": "Использовать \"{name}\"",
+    "unitGroupCount": "Штучные",
+    "unitGroupMetric": "Метрические",
+    "unitGroupImperial": "Имперские",
     "unitCost": "Себестоимость",
     "sellPrice": "Цена продажи",
     "supplier": "Поставщик",

+ 2 - 0
messages/ru/settings.json

@@ -831,6 +831,8 @@
     "liters": "литры (л)",
     "miles": "мили (mi)",
     "gallons": "галлоны (gal)",
+    "defaultStockUnit": "Единица склада по умолчанию",
+    "defaultStockUnitHint": "Подставляется для новых складских позиций. У каждой позиции может быть своя единица.",
     "saveWorkshop": "Сохранить настройки мастерской",
     "saved": "Настройки мастерской сохранены",
     "assignDialogTitle": "Назначить на существующие заказы?",

+ 1 - 0
messages/tr/dashboard.json

@@ -210,6 +210,7 @@
       "category": "Kategori",
       "quantity": "Adet",
       "minQuantity": "Min. adet",
+      "unit": "Birim",
       "unitCost": "Maliyet",
       "sellPrice": "Fiyat",
       "supplier": "Tedarikçi"

+ 8 - 0
messages/tr/inventory.json

@@ -109,6 +109,14 @@
     "locationPlaceholder": "Raf A-3",
     "quantity": "Miktar",
     "minQty": "Min. miktar",
+    "unit": "Birim",
+    "unitPlaceholder": "adet, l, kg...",
+    "unitSuggestions": "adet,takım",
+    "unitSearch": "Ara veya yaz...",
+    "unitCreate": "\"{name}\" kullan",
+    "unitGroupCount": "Adet",
+    "unitGroupMetric": "Metrik",
+    "unitGroupImperial": "İngiliz",
     "unitCost": "Birim maliyet",
     "sellPrice": "Satış fiyatı",
     "supplier": "Tedarikçi",

+ 2 - 0
messages/tr/settings.json

@@ -831,6 +831,8 @@
     "liters": "litre (L)",
     "miles": "mil (mi)",
     "gallons": "galon (gal)",
+    "defaultStockUnit": "Varsayılan stok birimi",
+    "defaultStockUnitHint": "Yeni stok parçalarında önceden doldurulur. Her parça yine de kendi birimini kullanabilir.",
     "saveWorkshop": "Atölye Ayarlarını Kaydet",
     "saved": "Atölye ayarları kaydedildi",
     "assignDialogTitle": "Mevcut iş emirlerine ata?",

+ 23 - 0
prisma/migrations/20260826155804_inventory_units_fractional_stock/migration.sql

@@ -0,0 +1,23 @@
+-- AlterTable
+ALTER TABLE "inventory_parts" ADD COLUMN     "unit" TEXT,
+ALTER COLUMN "quantity" SET DEFAULT 0,
+ALTER COLUMN "quantity" SET DATA TYPE DOUBLE PRECISION,
+ALTER COLUMN "minQuantity" SET DEFAULT 0,
+ALTER COLUMN "minQuantity" SET DATA TYPE DOUBLE PRECISION;
+
+-- AlterTable
+ALTER TABLE "labor_preset_parts" ADD COLUMN     "unit" TEXT;
+
+-- AlterTable
+ALTER TABLE "quote_parts" ADD COLUMN     "unit" TEXT;
+
+-- AlterTable
+ALTER TABLE "recurring_parts" ALTER COLUMN "quantity" SET DEFAULT 1,
+ALTER COLUMN "quantity" SET DATA TYPE DOUBLE PRECISION;
+
+-- AlterTable
+ALTER TABLE "service_parts" ADD COLUMN     "unit" TEXT;
+
+-- AlterTable
+ALTER TABLE "stock_movements" ALTER COLUMN "delta" SET DATA TYPE DOUBLE PRECISION,
+ALTER COLUMN "quantityAfter" SET DATA TYPE DOUBLE PRECISION;

+ 19 - 6
prisma/schema.prisma

@@ -467,6 +467,9 @@ model ServicePart {
   partNumber      String?
   name            String
   quantity        Float   @default(1)
+  /// Unit of measure snapshotted from InventoryPart at pick time; null for
+  /// free-text lines and legacy rows.
+  unit            String?
   unitPrice       Float   @default(0)
   total           Float   @default(0)
   unitCost        Float   @default(0)
@@ -531,6 +534,8 @@ model LaborPresetPart {
   name            String
   partNumber      String?
   quantity        Float   @default(1)
+  /// Unit of measure snapshotted from InventoryPart at pick time.
+  unit            String?
   unitPrice       Float   @default(0)
   inventoryPartId String?
   sortOrder       Int     @default(0)
@@ -549,8 +554,12 @@ model InventoryPart {
   name          String
   description   String?
   category      String?
-  quantity      Int     @default(0)
-  minQuantity   Int     @default(0)
+  quantity      Float   @default(0)
+  minQuantity   Float   @default(0)
+  /// Unit of measure for `quantity` (e.g. "pcs", "l", "kg"). Free text,
+  /// snapshotted onto part lines at pick time so invoices keep the unit the
+  /// part had when it was billed.
+  unit          String?
   unitCost      Float   @default(0)
   sellPrice     Float   @default(0)
   supplier      String?
@@ -593,10 +602,11 @@ model InventoryPart {
 model StockMovement {
   id String @id @default(cuid())
 
-  /// Signed change in whole units. Negative = consumed, positive = restocked.
-  delta         Int
+  /// Signed change in stock units (fractional allowed, e.g. 0.5 l).
+  /// Negative = consumed, positive = restocked.
+  delta         Float
   /// Part balance immediately after this movement was applied.
-  quantityAfter Int
+  quantityAfter Float
   /// Why the stock moved — see StockMovementReason in src/features/inventory/Lib/stockMovement.ts
   reason        String
   note          String?
@@ -725,6 +735,9 @@ model QuotePart {
   partNumber    String?
   name          String
   quantity      Float   @default(1)
+  /// Unit of measure snapshotted from InventoryPart at pick time; null for
+  /// free-text lines and legacy rows.
+  unit          String?
   /// Internal purchase cost — never shown to customers (quote PDF/share
   /// render unitPrice only)
   unitCost      Float   @default(0)
@@ -1009,7 +1022,7 @@ model RecurringPart {
   id         String  @id @default(cuid())
   name       String
   partNumber String?
-  quantity   Int     @default(1)
+  quantity   Float   @default(1)
   unitPrice  Float   @default(0)
 
   recurringInvoiceId String

+ 74 - 0
src/__tests__/features/backup/part-restore-fields.test.ts

@@ -0,0 +1,74 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Restore completeness for part lines.
+ *
+ * The import route rebuilds ServicePart and QuotePart rows through explicit
+ * field maps. A column added to the schema but not to the map is dropped
+ * silently on restore, and the loss only surfaces later — zeroed profit
+ * reports, dead inventory links. Like the tire hotel coverage test, this
+ * reads the route source and the schema rather than running the route: the
+ * failure being guarded against is "someone forgot a field", which is visible
+ * in the source.
+ */
+
+const ROOT = process.cwd()
+const importSource = fs.readFileSync(
+  path.join(ROOT, 'src/app/api/protected/backup/import/route.ts'),
+  'utf-8'
+)
+const schema = fs.readFileSync(path.join(ROOT, 'prisma/schema.prisma'), 'utf-8')
+
+/** Scalar (persisted, non-relation) columns of a model, from the schema. */
+function scalarColumns(model: string): string[] {
+  const match = schema.match(new RegExp(`^model\\s+${model}\\s*\\{([\\s\\S]*?)^\\}`, 'm'))
+  expect(match, `model ${model} not found in schema`).toBeTruthy()
+  const columns: string[] = []
+  for (const line of match![1].split('\n')) {
+    const field = line.match(/^\s{2}(\w+)\s+(String|Int|Float|Boolean|DateTime|Json)(\?|\[\])?/)
+    if (field) columns.push(field[1])
+  }
+  expect(columns.length).toBeGreaterThan(0)
+  return columns
+}
+
+/** The restore map for one model — the createMany call and its data mapper. */
+function restoreMap(delegate: string): string {
+  const marker = `tx.${delegate}.createMany`
+  const start = importSource.indexOf(marker)
+  expect(start, `${marker} not found in import route`).toBeGreaterThan(-1)
+  // One restore site per model; a second one would need its own coverage.
+  expect(importSource.indexOf(marker, start + 1)).toBe(-1)
+  return importSource.slice(start, start + 1500)
+}
+
+describe.each([
+  { model: 'ServicePart', delegate: 'servicePart' },
+  { model: 'QuotePart', delegate: 'quotePart' },
+])('$model restore', ({ model, delegate }) => {
+  it('carries every schema column, so nothing is dropped on restore', () => {
+    const map = restoreMap(delegate)
+    for (const column of scalarColumns(model)) {
+      expect(map, `${model}.${column} is lost by the ${delegate} restore map`).toMatch(
+        new RegExp(`\\b${column}:`)
+      )
+    }
+  })
+})
+
+describe('restore ordering', () => {
+  it('restores inventory parts before the lines that reference them', () => {
+    // inventoryPartId is restored verbatim, which is only honest if the
+    // inventory rows (with their preserved ids) already exist by then.
+    // Service parts restore inside the importServiceRecordTree helper, so the
+    // execution order is the order of the call sites, not of the definitions.
+    const inventory = importSource.indexOf('tx.inventoryPart.createMany')
+    const firstTreeCall = importSource.indexOf('await importServiceRecordTree(')
+    expect(inventory).toBeGreaterThan(-1)
+    expect(firstTreeCall).toBeGreaterThan(-1)
+    expect(inventory).toBeLessThan(firstTreeCall)
+    expect(inventory).toBeLessThan(importSource.indexOf('tx.quotePart.createMany'))
+  })
+})

+ 9 - 0
src/__tests__/features/inventory/low-stock-alerts.test.ts

@@ -25,6 +25,7 @@ function part(over: Partial<LowStockCandidate> = {}): LowStockCandidate {
     partNumber: 'BP-1',
     quantity: 1,
     minQuantity: 5,
+    unit: null,
     lowStockAlertedAt: null,
     ...over,
   }
@@ -169,6 +170,14 @@ describe('digest formatting', () => {
     )
   })
 
+  it('carries the unit of measure into the line', () => {
+    expect(
+      formatLowStockLine(
+        part({ name: 'Engine oil 5W-30', partNumber: null, quantity: 1.5, unit: 'l' })
+      )
+    ).toBe('Engine oil 5W-30: 1.5 l left, reorder at 5 l')
+  })
+
   it('omits an absent part number', () => {
     expect(formatLowStockLine(part({ partNumber: null, quantity: 0 }))).toBe(
       'Brake pad: 0 left, reorder at 5'

+ 17 - 4
src/__tests__/features/inventory/reconcile-stock.test.ts

@@ -154,7 +154,7 @@ describe('reconcileInventoryForParts', () => {
     expect(updates).toHaveLength(2)
   })
 
-  it('rounds fractional quantities to whole stock units', async () => {
+  it('deducts fractional quantities exactly (0.5 l stays 0.5, not 1)', async () => {
     const { tx, updates } = makeTx()
     await reconcileInventoryForParts(
       tx,
@@ -162,12 +162,25 @@ describe('reconcileInventoryForParts', () => {
       [],
       [
         { inventoryPartId: 'p1', quantity: 2.4 },
-        { inventoryPartId: 'p2', quantity: 2.6 },
+        { inventoryPartId: 'p2', quantity: 0.5 },
       ],
       CTX
     )
-    expect(updates).toContainEqual({ id: 'p1', organizationId: ORG, decrement: 2 })
-    expect(updates).toContainEqual({ id: 'p2', organizationId: ORG, decrement: 3 })
+    expect(updates).toContainEqual({ id: 'p1', organizationId: ORG, decrement: 2.4 })
+    expect(updates).toContainEqual({ id: 'p2', organizationId: ORG, decrement: 0.5 })
+  })
+
+  it('clamps quantities to 3 decimals so float noise cannot drift the balance', async () => {
+    const { tx, updates } = makeTx()
+    await reconcileInventoryForParts(
+      tx,
+      ORG,
+      [],
+      // 0.1 + 0.2 style noise: 0.30000000000000004 must enter the ledger as 0.3
+      [{ inventoryPartId: 'p1', quantity: 0.1 + 0.2 }],
+      CTX
+    )
+    expect(updates).toContainEqual({ id: 'p1', organizationId: ORG, decrement: 0.3 })
   })
 
   it("always scopes writes to the caller's organization", async () => {

+ 42 - 0
src/__tests__/features/inventory/units.test.ts

@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import {
+  IMPERIAL_MEASUREMENT_UNITS,
+  METRIC_MEASUREMENT_UNITS,
+  unitSuggestions,
+} from '@/features/inventory/Lib/units'
+
+describe('unitSuggestions', () => {
+  it('leads with the localized count units', () => {
+    const list = unitSuggestions('Stk,Satz', 'metric')
+    expect(list.slice(0, 2)).toEqual(['Stk', 'Satz'])
+  })
+
+  it('orders metric before imperial for a metric workshop', () => {
+    const list = unitSuggestions('pcs', 'metric')
+    expect(list.indexOf('l')).toBeLessThan(list.indexOf('qt'))
+    expect(list.indexOf('kg')).toBeLessThan(list.indexOf('lb'))
+  })
+
+  it('orders imperial before metric for an imperial workshop', () => {
+    const list = unitSuggestions('pcs', 'imperial')
+    expect(list.indexOf('qt')).toBeLessThan(list.indexOf('l'))
+    expect(list.indexOf('lb')).toBeLessThan(list.indexOf('kg'))
+  })
+
+  it('treats an unknown or missing system as metric', () => {
+    expect(unitSuggestions('pcs')).toEqual(unitSuggestions('pcs', 'metric'))
+  })
+
+  it('always offers both systems in full', () => {
+    const list = unitSuggestions('pcs', 'imperial')
+    for (const u of [...METRIC_MEASUREMENT_UNITS, ...IMPERIAL_MEASUREMENT_UNITS]) {
+      expect(list).toContain(u)
+    }
+  })
+
+  it('trims and dedupes against the localized list', () => {
+    const list = unitSuggestions(' pcs , l ,, pcs ', 'metric')
+    expect(list.filter((u) => u === 'pcs')).toHaveLength(1)
+    expect(list.filter((u) => u === 'l')).toHaveLength(1)
+  })
+})

+ 1 - 0
src/__tests__/features/tire-hotel/add-tire-line.test.ts

@@ -20,6 +20,7 @@ const LINE: TireLineInput = {
   name: 'Nokian Hakkapeliitta 225/45R18',
   partNumber: 'NOK-2254518',
   quantity: 4,
+  unit: null,
   unitPrice: 1800,
   unitCost: 1200,
   inventoryPartId: 'part-1',

+ 32 - 0
src/__tests__/features/workorders/add-part.test.ts

@@ -163,6 +163,38 @@ describe('addPartToServiceRecord — totals recalculation', () => {
     expect(updateCall.data.totalAmount).toBeCloseTo(88)
   })
 
+  it('snapshots the unit of measure onto the created line', async () => {
+    setupAuth()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      id: RECORD_ID,
+      vehicleId: VEHICLE_ID,
+      subtotal: 0,
+      taxRate: 0,
+      taxInclusive: false,
+      discountType: null,
+      discountValue: 0,
+    } as any)
+    vi.mocked(db.servicePart.create).mockResolvedValue({ id: 'part-1' } as any)
+    vi.mocked(db.servicePart.aggregate).mockResolvedValue({ _sum: { total: 23 } } as any)
+    vi.mocked(db.serviceLabor.aggregate).mockResolvedValue({ _sum: { total: 0 } } as any)
+
+    await addPartToServiceRecord({
+      serviceRecordId: RECORD_ID,
+      name: 'Engine oil 5W-30',
+      quantity: 2.5,
+      unit: 'l',
+      unitPrice: 9.2,
+      total: 23,
+      unitCost: 6,
+    })
+
+    expect(vi.mocked(db.servicePart.create)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ quantity: 2.5, unit: 'l' }),
+      })
+    )
+  })
+
   it('rejects when the service record does not belong to the org', async () => {
     setupAuth()
     vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)

+ 45 - 48
src/app/(authenticated)/inventory/[id]/page.tsx

@@ -1,62 +1,62 @@
-import { notFound } from "next/navigation";
+import { notFound } from 'next/navigation'
 import {
   getInventoryPart,
   getStockMovementsPaginated,
-} from "@/features/inventory/Actions/getStockMovements";
-import { getInventoryCategories } from "@/features/inventory/Actions/inventoryActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { PageHeader } from "@/components/page-header";
-import { formatDateTime } from "@/lib/format";
-import { PartDetailClient } from "./part-detail-client";
+} from '@/features/inventory/Actions/getStockMovements'
+import { getInventoryCategories } from '@/features/inventory/Actions/inventoryActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { PageHeader } from '@/components/page-header'
+import { formatDateTime } from '@/lib/format'
+import { PartDetailClient } from './part-detail-client'
 
-const PAGE_SIZE = 50;
+const PAGE_SIZE = 50
 
 export default async function InventoryPartDetailPage({
   params,
   searchParams,
 }: {
-  params: Promise<{ id: string }>;
-  searchParams: Promise<{ page?: string; reason?: string }>;
+  params: Promise<{ id: string }>
+  searchParams: Promise<{ page?: string; reason?: string }>
 }) {
-  const { id } = await params;
-  const sp = await searchParams;
-  const page = Math.max(1, Number(sp.page) || 1);
+  const { id } = await params
+  const sp = await searchParams
+  const page = Math.max(1, Number(sp.page) || 1)
 
-  const [partResult, movementsResult, categoriesResult, settingsResult] =
-    await Promise.all([
-      getInventoryPart(id),
-      getStockMovementsPaginated({
-        inventoryPartId: id,
-        page,
-        pageSize: PAGE_SIZE,
-        reason: sp.reason,
-      }),
-      getInventoryCategories(),
-      getSettings([
-        SETTING_KEYS.CURRENCY_CODE,
-        SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
-        SETTING_KEYS.DATE_FORMAT,
-        SETTING_KEYS.TIME_FORMAT,
-        SETTING_KEYS.TIMEZONE,
-        SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
-      ]),
-    ]);
+  const [partResult, movementsResult, categoriesResult, settingsResult] = await Promise.all([
+    getInventoryPart(id),
+    getStockMovementsPaginated({
+      inventoryPartId: id,
+      page,
+      pageSize: PAGE_SIZE,
+      reason: sp.reason,
+    }),
+    getInventoryCategories(),
+    getSettings([
+      SETTING_KEYS.CURRENCY_CODE,
+      SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
+      SETTING_KEYS.UNIT_SYSTEM,
+      SETTING_KEYS.DATE_FORMAT,
+      SETTING_KEYS.TIME_FORMAT,
+      SETTING_KEYS.TIMEZONE,
+      SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
+    ]),
+  ])
 
   // Covers both "no such part" and "belongs to another organization", since
   // the action scopes by organizationId.
   if (!partResult.success || !partResult.data) {
-    notFound();
+    notFound()
   }
 
-  const settings = settingsResult.success ? (settingsResult.data ?? {}) : {};
+  const settings = settingsResult.success ? (settingsResult.data ?? {}) : {}
   const movements = movementsResult.data ?? {
     movements: [],
     total: 0,
     page,
     pageSize: PAGE_SIZE,
     totalPages: 1,
-  };
+  }
 
   // Timestamps are formatted here, on the server, and shipped as strings.
   // Formatting them in the client component instead would render the server's
@@ -69,10 +69,10 @@ export default async function InventoryPartDetailPage({
     createdAtLabel: formatDateTime(
       m.createdAt,
       settings[SETTING_KEYS.DATE_FORMAT],
-      (settings[SETTING_KEYS.TIME_FORMAT] as "12h" | "24h") || undefined,
-      settings[SETTING_KEYS.TIMEZONE] || undefined,
+      (settings[SETTING_KEYS.TIME_FORMAT] as '12h' | '24h') || undefined,
+      settings[SETTING_KEYS.TIMEZONE] || undefined
     ),
-  }));
+  }))
 
   return (
     <>
@@ -84,16 +84,13 @@ export default async function InventoryPartDetailPage({
         page={movements.page}
         pageSize={movements.pageSize}
         totalPages={movements.totalPages}
-        reason={sp.reason ?? ""}
-        currencyCode={settings[SETTING_KEYS.CURRENCY_CODE] || "USD"}
-        markupMultiplier={
-          Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1
-        }
+        reason={sp.reason ?? ''}
+        currencyCode={settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'}
+        markupMultiplier={Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1}
         categories={categoriesResult.data ?? []}
-        lowStockDefault={
-          Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0
-        }
+        lowStockDefault={Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0}
+        unitSystem={settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial'}
       />
     </>
-  );
+  )
 }

+ 130 - 113
src/app/(authenticated)/inventory/[id]/part-detail-client.tsx

@@ -1,11 +1,11 @@
-"use client";
+'use client'
 
-import { useCallback, useState } from "react";
-import Link from "next/link";
-import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
+import { useCallback, useState } from 'react'
+import Link from 'next/link'
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
 import {
   Table,
   TableBody,
@@ -13,39 +13,40 @@ import {
   TableHead,
   TableHeader,
   TableRow,
-} from "@/components/ui/table";
+} from '@/components/ui/table'
 import {
   Select,
   SelectContent,
   SelectItem,
   SelectTrigger,
   SelectValue,
-} from "@/components/ui/select";
-import { DataTablePagination } from "@/components/data-table-pagination";
-import { InventoryPartForm } from "@/features/inventory/Components/InventoryPartForm";
-import { STOCK_MOVEMENT_REASONS } from "@/features/inventory/Lib/stockMovementReasons";
-import { isLow as isLowStock } from "@/features/inventory/Lib/lowStockAlerts";
-import { useFormatCurrency } from "@/components/currency-settings-context";
-import { ArrowLeft, ArrowUpRight, Pencil } from "lucide-react";
+} from '@/components/ui/select'
+import { DataTablePagination } from '@/components/data-table-pagination'
+import { InventoryPartForm } from '@/features/inventory/Components/InventoryPartForm'
+import { STOCK_MOVEMENT_REASONS } from '@/features/inventory/Lib/stockMovementReasons'
+import { isLow as isLowStock } from '@/features/inventory/Lib/lowStockAlerts'
+import { useFormatCurrency } from '@/components/currency-settings-context'
+import { formatQuantity } from '@/lib/format-quantity'
+import { ArrowLeft, ArrowUpRight, Pencil } from 'lucide-react'
 
 interface Movement {
-  id: string;
-  delta: number;
-  quantityAfter: number;
-  reason: string;
-  note: string | null;
-  createdAt: string;
+  id: string
+  delta: number
+  quantityAfter: number
+  reason: string
+  note: string | null
+  createdAt: string
   /** Pre-formatted on the server so SSR and hydration agree. */
-  createdAtLabel: string;
-  userName: string | null;
-  serviceRecordId: string | null;
-  vehicleId: string | null;
-  label: string | null;
-  vehicle: string | null;
+  createdAtLabel: string
+  userName: string | null
+  serviceRecordId: string | null
+  vehicleId: string | null
+  label: string | null
+  vehicle: string | null
 }
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-type Part = any;
+type Part = any
 
 export function PartDetailClient({
   part,
@@ -59,62 +60,65 @@ export function PartDetailClient({
   markupMultiplier,
   categories,
   lowStockDefault,
+  unitSystem,
 }: {
-  part: Part;
-  movements: Movement[];
-  total: number;
-  page: number;
-  pageSize: number;
-  totalPages: number;
-  reason: string;
-  currencyCode: string;
-  markupMultiplier: number;
-  categories: string[];
-  lowStockDefault: number;
+  part: Part
+  movements: Movement[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
+  reason: string
+  currencyCode: string
+  markupMultiplier: number
+  categories: string[]
+  lowStockDefault: number
+  /** workshop.unitSystem, orders unit suggestions in the edit form. */
+  unitSystem?: string
 }) {
-  const t = useTranslations("inventory");
-  const router = useRouter();
-  const pathname = usePathname();
-  const searchParams = useSearchParams();
-  const formatCurrency = useFormatCurrency();
-  const [showForm, setShowForm] = useState(false);
+  const t = useTranslations('inventory')
+  const router = useRouter()
+  const pathname = usePathname()
+  const searchParams = useSearchParams()
+  const formatCurrency = useFormatCurrency()
+  const [showForm, setShowForm] = useState(false)
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
-      const next = new URLSearchParams(searchParams.toString());
+      const next = new URLSearchParams(searchParams.toString())
       for (const [key, value] of Object.entries(params)) {
-        if (value === undefined || value === "") next.delete(key);
-        else next.set(key, String(value));
+        if (value === undefined || value === '') next.delete(key)
+        else next.set(key, String(value))
       }
       // Changing the filter invalidates the current page offset.
-      if (!("page" in params) && "reason" in params) next.delete("page");
-      const qs = next.toString();
-      router.push(qs ? `${pathname}?${qs}` : pathname);
+      if (!('page' in params) && 'reason' in params) next.delete('page')
+      const qs = next.toString()
+      router.push(qs ? `${pathname}?${qs}` : pathname)
     },
-    [router, pathname, searchParams],
-  );
+    [router, pathname, searchParams]
+  )
 
   // Same rule as the alert engine, so the badge never disagrees with alerts.
-  const isLow = isLowStock(part, lowStockDefault);
+  const isLow = isLowStock(part, lowStockDefault)
 
   const reasonLabel = (value: string) => {
     switch (value) {
-      case "service_record":
-        return t("history.reasons.service_record");
-      case "service_record_deleted":
-        return t("history.reasons.service_record_deleted");
-      case "quote_conversion":
-        return t("history.reasons.quote_conversion");
-      case "manual_adjustment":
-        return t("history.reasons.manual_adjustment");
-      case "bulk_markup":
-        return t("history.reasons.bulk_markup");
+      case 'service_record':
+        return t('history.reasons.service_record')
+      case 'service_record_deleted':
+        return t('history.reasons.service_record_deleted')
+      case 'quote_conversion':
+        return t('history.reasons.quote_conversion')
+      case 'manual_adjustment':
+        return t('history.reasons.manual_adjustment')
+      case 'bulk_markup':
+        return t('history.reasons.bulk_markup')
       default:
         // A row written by a newer build may carry a reason this one has no
         // translation for; show the raw value rather than an empty cell.
-        return value;
+        return value
     }
-  };
+  }
 
   return (
     <div className="space-y-6 p-4 pt-0">
@@ -123,50 +127,48 @@ export function PartDetailClient({
           <Button variant="ghost" size="sm" asChild className="-ml-2 h-7">
             <Link href="/inventory">
               <ArrowLeft className="mr-1 h-3.5 w-3.5" />
-              {t("history.backToInventory")}
+              {t('history.backToInventory')}
             </Link>
           </Button>
           <h1 className="text-2xl font-bold tracking-tight">{part.name}</h1>
           <p className="text-sm text-muted-foreground">
-            {[part.partNumber, part.category, part.location]
-              .filter(Boolean)
-              .join(" · ") || "—"}
+            {[part.partNumber, part.category, part.location].filter(Boolean).join(' · ') || '—'}
           </p>
         </div>
         <Button
           onClick={() => setShowForm(true)}
-          aria-label={t("actions.edit")}
-          title={t("actions.edit")}
+          aria-label={t('actions.edit')}
+          title={t('actions.edit')}
           className="h-9 w-9 shrink-0 p-0 md:w-auto md:px-4"
         >
           <Pencil className="h-4 w-4 md:mr-2" />
-          <span className="hidden md:inline">{t("actions.edit")}</span>
+          <span className="hidden md:inline">{t('actions.edit')}</span>
         </Button>
       </div>
 
       {/* Summary — the context you need while reading the ledger below. */}
       <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
         <div className="rounded-lg border bg-card px-3 py-2">
-          <p className="text-[11px] text-muted-foreground">{t("table.inStock")}</p>
-          <p className={`text-lg font-bold ${isLow ? "text-destructive" : ""}`}>
-            {part.quantity}
+          <p className="text-[11px] text-muted-foreground">{t('table.inStock')}</p>
+          <p className={`text-lg font-bold ${isLow ? 'text-destructive' : ''}`}>
+            {formatQuantity(part.quantity, part.unit)}
             {isLow && (
               <Badge variant="destructive" className="ml-2 px-1.5 py-0 text-[10px]">
-                {t("table.low")}
+                {t('table.low')}
               </Badge>
             )}
           </p>
         </div>
         <div className="rounded-lg border bg-card px-3 py-2">
-          <p className="text-[11px] text-muted-foreground">{t("form.minQty")}</p>
-          <p className="text-lg font-bold">{part.minQuantity}</p>
+          <p className="text-[11px] text-muted-foreground">{t('form.minQty')}</p>
+          <p className="text-lg font-bold">{formatQuantity(part.minQuantity, part.unit)}</p>
         </div>
         <div className="rounded-lg border bg-card px-3 py-2">
-          <p className="text-[11px] text-muted-foreground">{t("table.unitCost")}</p>
+          <p className="text-[11px] text-muted-foreground">{t('table.unitCost')}</p>
           <p className="text-lg font-bold">{formatCurrency(part.unitCost, currencyCode)}</p>
         </div>
         <div className="rounded-lg border bg-card px-3 py-2">
-          <p className="text-[11px] text-muted-foreground">{t("table.sellPrice")}</p>
+          <p className="text-[11px] text-muted-foreground">{t('table.sellPrice')}</p>
           <p className="text-lg font-bold">{formatCurrency(part.sellPrice, currencyCode)}</p>
         </div>
       </div>
@@ -174,20 +176,20 @@ export function PartDetailClient({
       <div className="space-y-3">
         <div className="flex flex-wrap items-center justify-between gap-2">
           <div>
-            <h2 className="text-lg font-semibold">{t("history.title")}</h2>
+            <h2 className="text-lg font-semibold">{t('history.title')}</h2>
             <p className="text-sm text-muted-foreground">
-              {t("history.description", { name: part.name })}
+              {t('history.description', { name: part.name })}
             </p>
           </div>
           <Select
-            value={reason || "all"}
-            onValueChange={(v) => navigate({ reason: v === "all" ? undefined : v })}
+            value={reason || 'all'}
+            onValueChange={(v) => navigate({ reason: v === 'all' ? undefined : v })}
           >
             <SelectTrigger className="h-9 w-full sm:w-[220px]">
               <SelectValue />
             </SelectTrigger>
             <SelectContent>
-              <SelectItem value="all">{t("history.allReasons")}</SelectItem>
+              <SelectItem value="all">{t('history.allReasons')}</SelectItem>
               {STOCK_MOVEMENT_REASONS.map((r) => (
                 <SelectItem key={r} value={r}>
                   {reasonLabel(r)}
@@ -201,7 +203,7 @@ export function PartDetailClient({
         <div className="space-y-2 md:hidden">
           {movements.length === 0 ? (
             <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
-              {t("history.empty")}
+              {t('history.empty')}
             </div>
           ) : (
             movements.map((m) => (
@@ -210,14 +212,18 @@ export function PartDetailClient({
                   <div className="min-w-0 flex-1">
                     {m.serviceRecordId ? (
                       <Link
-                        href={m.vehicleId ? `/vehicles/${m.vehicleId}/service/${m.serviceRecordId}` : `/sales/${m.serviceRecordId}`}
+                        href={
+                          m.vehicleId
+                            ? `/vehicles/${m.vehicleId}/service/${m.serviceRecordId}`
+                            : `/sales/${m.serviceRecordId}`
+                        }
                         className="inline-flex items-center gap-1 font-medium text-primary hover:underline"
                       >
                         {m.label}
                         <ArrowUpRight className="h-3 w-3" />
                       </Link>
                     ) : (
-                      <p className="font-medium">{m.label ?? "—"}</p>
+                      <p className="font-medium">{m.label ?? '—'}</p>
                     )}
                     {m.vehicle && (
                       <p className="truncate text-xs text-muted-foreground">{m.vehicle}</p>
@@ -229,13 +235,17 @@ export function PartDetailClient({
                       variant="outline"
                       className={
                         m.delta < 0
-                          ? "border-destructive/40 text-destructive"
-                          : "border-green-500/40 text-green-600"
+                          ? 'border-destructive/40 text-destructive'
+                          : 'border-green-500/40 text-green-600'
                       }
                     >
-                      {m.delta > 0 ? `+${m.delta}` : m.delta}
+                      {m.delta > 0
+                        ? `+${formatQuantity(m.delta, part.unit)}`
+                        : formatQuantity(m.delta, part.unit)}
                     </Badge>
-                    <span className="font-medium">{m.quantityAfter}</span>
+                    <span className="font-medium">
+                      {formatQuantity(m.quantityAfter, part.unit)}
+                    </span>
                   </div>
                 </div>
                 <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
@@ -253,19 +263,19 @@ export function PartDetailClient({
           <Table>
             <TableHeader>
               <TableRow>
-                <TableHead>{t("history.when")}</TableHead>
-                <TableHead>{t("history.change")}</TableHead>
-                <TableHead>{t("history.balance")}</TableHead>
-                <TableHead>{t("history.usedOn")}</TableHead>
-                <TableHead>{t("history.reason")}</TableHead>
-                <TableHead>{t("history.by")}</TableHead>
+                <TableHead>{t('history.when')}</TableHead>
+                <TableHead>{t('history.change')}</TableHead>
+                <TableHead>{t('history.balance')}</TableHead>
+                <TableHead>{t('history.usedOn')}</TableHead>
+                <TableHead>{t('history.reason')}</TableHead>
+                <TableHead>{t('history.by')}</TableHead>
               </TableRow>
             </TableHeader>
             <TableBody>
               {movements.length === 0 ? (
                 <TableRow>
                   <TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
-                    {t("history.empty")}
+                    {t('history.empty')}
                   </TableCell>
                 </TableRow>
               ) : (
@@ -279,25 +289,33 @@ export function PartDetailClient({
                         variant="outline"
                         className={
                           m.delta < 0
-                            ? "border-destructive/40 text-destructive"
-                            : "border-green-500/40 text-green-600"
+                            ? 'border-destructive/40 text-destructive'
+                            : 'border-green-500/40 text-green-600'
                         }
                       >
-                        {m.delta > 0 ? `+${m.delta}` : m.delta}
+                        {m.delta > 0
+                          ? `+${formatQuantity(m.delta, part.unit)}`
+                          : formatQuantity(m.delta, part.unit)}
                       </Badge>
                     </TableCell>
-                    <TableCell className="font-medium">{m.quantityAfter}</TableCell>
+                    <TableCell className="font-medium">
+                      {formatQuantity(m.quantityAfter, part.unit)}
+                    </TableCell>
                     <TableCell>
                       {m.serviceRecordId ? (
                         <Link
-                          href={m.vehicleId ? `/vehicles/${m.vehicleId}/service/${m.serviceRecordId}` : `/sales/${m.serviceRecordId}`}
+                          href={
+                            m.vehicleId
+                              ? `/vehicles/${m.vehicleId}/service/${m.serviceRecordId}`
+                              : `/sales/${m.serviceRecordId}`
+                          }
                           className="inline-flex items-center gap-1 text-primary hover:underline"
                         >
                           {m.label}
                           <ArrowUpRight className="h-3 w-3" />
                         </Link>
                       ) : (
-                        <span className="text-muted-foreground">{m.label ?? "—"}</span>
+                        <span className="text-muted-foreground">{m.label ?? '—'}</span>
                       )}
                       {m.vehicle && (
                         <span className="block text-xs text-muted-foreground">{m.vehicle}</span>
@@ -306,10 +324,8 @@ export function PartDetailClient({
                         <span className="block text-xs text-muted-foreground">{m.note}</span>
                       )}
                     </TableCell>
-                    <TableCell className="text-muted-foreground">
-                      {reasonLabel(m.reason)}
-                    </TableCell>
-                    <TableCell className="text-muted-foreground">{m.userName ?? "—"}</TableCell>
+                    <TableCell className="text-muted-foreground">{reasonLabel(m.reason)}</TableCell>
+                    <TableCell className="text-muted-foreground">{m.userName ?? '—'}</TableCell>
                   </TableRow>
                 ))
               )}
@@ -331,13 +347,14 @@ export function PartDetailClient({
         key={part.id}
         open={showForm}
         onOpenChange={(open) => {
-          setShowForm(open);
-          if (!open) router.refresh();
+          setShowForm(open)
+          if (!open) router.refresh()
         }}
         part={part}
         markupMultiplier={markupMultiplier}
+        unitSystem={unitSystem}
         categories={categories}
       />
     </div>
-  );
+  )
 }

Разница между файлами не показана из-за своего большого размера
+ 439 - 359
src/app/(authenticated)/inventory/inventory-client.tsx


+ 39 - 27
src/app/(authenticated)/inventory/page.tsx

@@ -1,15 +1,27 @@
-import { getInventoryPartsPaginated, getInventoryCategories, hasAnyReorderPoint } from "@/features/inventory/Actions/inventoryActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { InventoryClient } from "./inventory-client";
-import { PageHeader } from "@/components/page-header";
+import {
+  getInventoryPartsPaginated,
+  getInventoryCategories,
+  hasAnyReorderPoint,
+} from '@/features/inventory/Actions/inventoryActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { InventoryClient } from './inventory-client'
+import { PageHeader } from '@/components/page-header'
 
 export default async function InventoryPage({
   searchParams,
 }: {
-  searchParams: Promise<{ page?: string; pageSize?: string; search?: string; category?: string; sortBy?: string; sortOrder?: string; lowStock?: string }>;
+  searchParams: Promise<{
+    page?: string
+    pageSize?: string
+    search?: string
+    category?: string
+    sortBy?: string
+    sortOrder?: string
+    lowStock?: string
+  }>
 }) {
-  const params = await searchParams;
+  const params = await searchParams
   const [result, categoriesResult, settingsResult, reorderResult] = await Promise.all([
     getInventoryPartsPaginated({
       page: params.page ? parseInt(params.page) : 1,
@@ -17,35 +29,35 @@ export default async function InventoryPage({
       search: params.search,
       category: params.category,
       sortBy: params.sortBy,
-      sortOrder: (params.sortOrder as "asc" | "desc") || undefined,
-      lowStock: params.lowStock === "1",
+      sortOrder: (params.sortOrder as 'asc' | 'desc') || undefined,
+      lowStock: params.lowStock === '1',
     }),
     getInventoryCategories(),
     getSettings([
       SETTING_KEYS.CURRENCY_CODE,
       SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
+      SETTING_KEYS.INVENTORY_DEFAULT_UNIT,
+      SETTING_KEYS.UNIT_SYSTEM,
       SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD,
     ]),
     hasAnyReorderPoint(),
-  ]);
+  ])
 
   if (!result.success || !result.data) {
     return (
       <>
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">
-            {result.error || "Failed to load inventory"}
-          </p>
+          <p className="text-muted-foreground">{result.error || 'Failed to load inventory'}</p>
         </div>
       </>
-    );
+    )
   }
 
-  const categories = categoriesResult.success && categoriesResult.data ? categoriesResult.data : [];
-  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
-  const markupMultiplier = Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1.0;
+  const categories = categoriesResult.success && categoriesResult.data ? categoriesResult.data : []
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const markupMultiplier = Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1.0
 
   return (
     <>
@@ -53,20 +65,20 @@ export default async function InventoryPage({
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
         <InventoryClient
           data={result.data}
-          search={params.search || ""}
-          category={params.category || ""}
+          search={params.search || ''}
+          category={params.category || ''}
           categories={categories}
           currencyCode={currencyCode}
           markupMultiplier={markupMultiplier}
-          sortBy={params.sortBy || "updatedAt"}
-          sortOrder={(params.sortOrder as "asc" | "desc") || "desc"}
-          lowStockDefault={
-            Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0
-          }
-          lowStockOnly={params.lowStock === "1"}
+          defaultUnit={settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''}
+          unitSystem={settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial'}
+          sortBy={params.sortBy || 'updatedAt'}
+          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
+          lowStockDefault={Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0}
+          lowStockOnly={params.lowStock === '1'}
           hasAnyReorderPoint={reorderResult.data ?? false}
         />
       </div>
     </>
-  );
+  )
 }

+ 229 - 178
src/app/(authenticated)/labor-presets/labor-presets-client.tsx

@@ -1,14 +1,14 @@
-"use client";
+'use client'
 
-import { useTableKeyboardNav } from "@/hooks/use-table-keyboard-nav";
-import { interactiveRow } from '@/lib/interactive-row';
-import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
+import { interactiveRow } from '@/lib/interactive-row'
+import { useDebouncedSearch } from '@/hooks/use-debounced-search'
 
-import { useState, useCallback, useTransition } from "react";
-import { useTranslations } from "next-intl";
-import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
+import { useState, useCallback, useTransition } from 'react'
+import { useTranslations } from 'next-intl'
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
 import {
   Table,
   TableBody,
@@ -16,41 +16,54 @@ import {
   TableHead,
   TableHeader,
   TableRow,
-} from "@/components/ui/table";
+} from '@/components/ui/table'
 import {
   DropdownMenu,
   DropdownMenuContent,
   DropdownMenuItem,
   DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
+} from '@/components/ui/dropdown-menu'
 import {
   ContextMenu,
   ContextMenuContent,
   ContextMenuItem,
   ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { DataTablePagination } from "@/components/data-table-pagination";
-import { TableContextMenuHint } from "@/components/table-context-menu-hint";
-import { useGlassModal } from "@/components/glass-modal";
-import { useConfirm } from "@/components/confirm-dialog";
-import { LaborPresetForm } from "@/features/labor-presets/Components/LaborPresetForm";
-import { deleteLaborPreset, getLaborPreset } from "@/features/labor-presets/Actions/laborPresetActions";
-import { ArrowDown, ArrowUp, ArrowUpDown, Loader2, MoreVertical, Pencil, Plus, Search, Trash2 } from "lucide-react";
+} from '@/components/ui/context-menu'
+import { DataTablePagination } from '@/components/data-table-pagination'
+import { TableContextMenuHint } from '@/components/table-context-menu-hint'
+import { useGlassModal } from '@/components/glass-modal'
+import { useConfirm } from '@/components/confirm-dialog'
+import { LaborPresetForm } from '@/features/labor-presets/Components/LaborPresetForm'
+import {
+  deleteLaborPreset,
+  getLaborPreset,
+} from '@/features/labor-presets/Actions/laborPresetActions'
+import {
+  ArrowDown,
+  ArrowUp,
+  ArrowUpDown,
+  Loader2,
+  MoreVertical,
+  Pencil,
+  Plus,
+  Search,
+  Trash2,
+} from 'lucide-react'
 
 interface LaborPresetRow {
-  id: string;
-  name: string;
-  description: string | null;
-  _count: { items: number };
-  items: { hours: number; pricingType: string }[];
+  id: string
+  name: string
+  description: string | null
+  _count: { items: number }
+  items: { hours: number; pricingType: string }[]
 }
 
 interface PaginatedData {
-  presets: LaborPresetRow[];
-  total: number;
-  page: number;
-  pageSize: number;
-  totalPages: number;
+  presets: LaborPresetRow[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
 }
 
 export function LaborPresetsClient({
@@ -58,51 +71,68 @@ export function LaborPresetsClient({
   search,
   sortBy,
   sortOrder,
-  currencyCode = "USD",
+  currencyCode = 'USD',
   defaultLaborRate = 0,
+  inventoryParts = [],
 }: {
-  data: PaginatedData;
-  search: string;
-  sortBy: string;
-  sortOrder: "asc" | "desc";
-  currencyCode?: string;
-  defaultLaborRate?: number;
+  data: PaginatedData
+  search: string
+  sortBy: string
+  sortOrder: 'asc' | 'desc'
+  currencyCode?: string
+  defaultLaborRate?: number
+  /** Stocked parts for the preset form's "import from inventory" picker. */
+  inventoryParts?: {
+    id: string
+    name: string
+    partNumber: string | null
+    unit: string | null
+    sellPrice: number
+    unitCost: number
+    quantity: number
+  }[]
 }) {
-  const router = useRouter();
-  const pathname = usePathname();
-  const searchParams = useSearchParams();
-  const t = useTranslations("laborPresets");
-  const [isPending, startTransition] = useTransition();
-  const tableNav = useTableKeyboardNav();
-  const [showForm, setShowForm] = useState(false);
+  const router = useRouter()
+  const pathname = usePathname()
+  const searchParams = useSearchParams()
+  const t = useTranslations('laborPresets')
+  const [isPending, startTransition] = useTransition()
+  const tableNav = useTableKeyboardNav()
+  const [showForm, setShowForm] = useState(false)
   const [editPreset, setEditPreset] = useState<{
-    id: string;
-    name: string;
-    description: string | null;
-    items: { description: string; hours: number; rate: number; pricingType?: string; sortOrder: number }[];
-  } | null>(null);
-  const modal = useGlassModal();
-  const confirm = useConfirm();
+    id: string
+    name: string
+    description: string | null
+    items: {
+      description: string
+      hours: number
+      rate: number
+      pricingType?: string
+      sortOrder: number
+    }[]
+  } | null>(null)
+  const modal = useGlassModal()
+  const confirm = useConfirm()
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
-      const newParams = new URLSearchParams(searchParams.toString());
+      const newParams = new URLSearchParams(searchParams.toString())
       for (const [key, value] of Object.entries(params)) {
-        if (value === undefined || value === "") {
-          newParams.delete(key);
+        if (value === undefined || value === '') {
+          newParams.delete(key)
         } else {
-          newParams.set(key, String(value));
+          newParams.set(key, String(value))
         }
       }
-      if (!("page" in params) && "search" in params) {
-        newParams.delete("page");
+      if (!('page' in params) && 'search' in params) {
+        newParams.delete('page')
       }
       startTransition(() => {
-        router.push(`${pathname}?${newParams.toString()}`);
-      });
+        router.push(`${pathname}?${newParams.toString()}`)
+      })
     },
     [router, pathname, searchParams]
-  );
+  )
 
   // Live search: filters as you type, no Enter required. Submitting the
   // form (Enter) commits immediately, bypassing the debounce.
@@ -110,48 +140,50 @@ export function LaborPresetsClient({
     value: searchInput,
     setValue: setSearchInput,
     commitNow: handleSearch,
-  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }))
 
   const handleSort = useCallback(
     (column: string) => {
-      const newOrder = sortBy === column && sortOrder === "asc" ? "desc" : "asc";
-      navigate({ sortBy: column, sortOrder: newOrder });
+      const newOrder = sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc'
+      navigate({ sortBy: column, sortOrder: newOrder })
     },
     [navigate, sortBy, sortOrder]
-  );
+  )
 
   const SortIcon = ({ column }: { column: string }) => {
-    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />;
-    return sortOrder === "asc"
-      ? <ArrowUp className="ml-1 h-3 w-3" />
-      : <ArrowDown className="ml-1 h-3 w-3" />;
-  };
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />
+    return sortOrder === 'asc' ? (
+      <ArrowUp className="ml-1 h-3 w-3" />
+    ) : (
+      <ArrowDown className="ml-1 h-3 w-3" />
+    )
+  }
 
   const handleEdit = async (id: string) => {
-    const result = await getLaborPreset(id);
+    const result = await getLaborPreset(id)
     if (result.success && result.data) {
-      setEditPreset(result.data);
-      setShowForm(true);
+      setEditPreset(result.data)
+      setShowForm(true)
     } else {
-      modal.open("error", t("errors.error"), result.error || t("errors.loadFailed"));
+      modal.open('error', t('errors.error'), result.error || t('errors.loadFailed'))
     }
-  };
+  }
 
   const handleDelete = async (id: string, name: string) => {
     const ok = await confirm({
-      title: t("deletePreset.title"),
-      description: t("deletePreset.description", { name }),
-      confirmLabel: t("deletePreset.confirm"),
+      title: t('deletePreset.title'),
+      description: t('deletePreset.description', { name }),
+      confirmLabel: t('deletePreset.confirm'),
       destructive: true,
-    });
-    if (!ok) return;
-    const result = await deleteLaborPreset(id);
+    })
+    if (!ok) return
+    const result = await deleteLaborPreset(id)
     if (result.success) {
-      router.refresh();
+      router.refresh()
     } else {
-      modal.open("error", t("errors.error"), result.error || t("errors.deleteFailed"));
+      modal.open('error', t('errors.error'), result.error || t('errors.deleteFailed'))
     }
-  };
+  }
 
   return (
     <div className="space-y-4">
@@ -161,7 +193,7 @@ export function LaborPresetsClient({
           <form onSubmit={handleSearch} className="relative flex-1 sm:max-w-sm">
             <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
             <Input
-              placeholder={t("searchPlaceholder")}
+              placeholder={t('searchPlaceholder')}
               value={searchInput}
               onChange={(e) => setSearchInput(e.target.value)}
               className="h-9 pl-9"
@@ -173,12 +205,12 @@ export function LaborPresetsClient({
         <Button
           size="sm"
           onClick={() => setShowForm(true)}
-          aria-label={t("addPackage")}
-          title={t("addPackage")}
+          aria-label={t('addPackage')}
+          title={t('addPackage')}
           className="h-9 w-9 shrink-0 p-0 md:h-8 md:w-auto md:px-3"
         >
           <Plus className="h-4 w-4 md:mr-1 md:h-3.5 md:w-3.5" />
-          <span className="hidden md:inline">{t("addPackage")}</span>
+          <span className="hidden md:inline">{t('addPackage')}</span>
         </Button>
       </div>
 
@@ -186,14 +218,14 @@ export function LaborPresetsClient({
       <div className="space-y-2 md:hidden">
         {data.presets.length === 0 ? (
           <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
-            {search ? t("empty.noMatch") : t("empty.noPresets")}
+            {search ? t('empty.noMatch') : t('empty.noPresets')}
           </div>
         ) : (
           data.presets.map((preset) => {
-            const hourlyItems = preset.items.filter((i) => i.pricingType !== "service");
-            const serviceItems = preset.items.filter((i) => i.pricingType === "service");
-            const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0);
-            const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0);
+            const hourlyItems = preset.items.filter((i) => i.pricingType !== 'service')
+            const serviceItems = preset.items.filter((i) => i.pricingType === 'service')
+            const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0)
+            const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0)
             return (
               <div key={preset.id} className="flex items-start gap-2 rounded-lg border bg-card p-3">
                 <button
@@ -209,16 +241,16 @@ export function LaborPresetsClient({
                   )}
                   <div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
                     <span>
-                      {t("table.itemCount")}: {preset._count.items}
+                      {t('table.itemCount')}: {preset._count.items}
                     </span>
                     {totalHours > 0 && (
                       <span className="inline-flex items-center rounded-md border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">
-                        {totalHours} {t("table.hrs")}
+                        {totalHours} {t('table.hrs')}
                       </span>
                     )}
                     {totalUnits > 0 && (
                       <span className="inline-flex items-center rounded-md border border-blue-500/30 bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-600">
-                        {totalUnits} {t("table.units")}
+                        {totalUnits} {t('table.units')}
                       </span>
                     )}
                   </div>
@@ -229,7 +261,7 @@ export function LaborPresetsClient({
                       variant="ghost"
                       size="icon"
                       className="-mr-1 h-9 w-9 shrink-0"
-                      aria-label={t("actions.openMenu")}
+                      aria-label={t('actions.openMenu')}
                     >
                       <MoreVertical className="h-4 w-4" />
                     </Button>
@@ -237,19 +269,19 @@ export function LaborPresetsClient({
                   <DropdownMenuContent align="end">
                     <DropdownMenuItem onClick={() => handleEdit(preset.id)}>
                       <Pencil className="mr-2 h-4 w-4" />
-                      {t("actions.edit")}
+                      {t('actions.edit')}
                     </DropdownMenuItem>
                     <DropdownMenuItem
                       className="text-destructive"
                       onClick={() => handleDelete(preset.id, preset.name)}
                     >
                       <Trash2 className="mr-2 h-4 w-4" />
-                      {t("actions.delete")}
+                      {t('actions.delete')}
                     </DropdownMenuItem>
                   </DropdownMenuContent>
                 </DropdownMenu>
               </div>
-            );
+            )
           })
         )}
       </div>
@@ -261,19 +293,27 @@ export function LaborPresetsClient({
           <TableHeader>
             <TableRow>
               <TableHead>
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("name")}>
-                  {t("table.name")}
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('name')}
+                >
+                  {t('table.name')}
                   <SortIcon column="name" />
                 </button>
               </TableHead>
               <TableHead className="hidden sm:table-cell">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("description")}>
-                  {t("table.description")}
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('description')}
+                >
+                  {t('table.description')}
                   <SortIcon column="description" />
                 </button>
               </TableHead>
-              <TableHead>{t("table.itemCount")}</TableHead>
-              <TableHead className="hidden md:table-cell">{t("table.type")}</TableHead>
+              <TableHead>{t('table.itemCount')}</TableHead>
+              <TableHead className="hidden md:table-cell">{t('table.type')}</TableHead>
               <TableHead className="w-[50px]" />
             </TableRow>
           </TableHeader>
@@ -281,81 +321,90 @@ export function LaborPresetsClient({
             {data.presets.length === 0 ? (
               <TableRow>
                 <TableCell colSpan={5} className="h-32 text-center text-muted-foreground">
-                  {search ? t("empty.noMatch") : t("empty.noPresets")}
+                  {search ? t('empty.noMatch') : t('empty.noPresets')}
                 </TableCell>
               </TableRow>
             ) : (
               data.presets.map((preset) => (
                 <ContextMenu key={preset.id} modal={false}>
-                <ContextMenuTrigger asChild>
-                <TableRow
-                  className="cursor-pointer"
-                  {...interactiveRow(() => handleEdit(preset.id))}
-                >
-                  <TableCell className="font-medium">{preset.name}</TableCell>
-                  <TableCell className="hidden sm:table-cell text-muted-foreground">
-                    {preset.description || "-"}
-                  </TableCell>
-                  <TableCell>{preset._count.items}</TableCell>
-                  <TableCell className="hidden md:table-cell">
-                    {(() => {
-                      const hourlyItems = preset.items.filter((i) => i.pricingType !== "service");
-                      const serviceItems = preset.items.filter((i) => i.pricingType === "service");
-                      const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0);
-                      const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0);
-                      return (
-                        <div className="flex gap-1.5">
-                          {totalHours > 0 && (
-                            <span className="inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-emerald-500/30 bg-emerald-500/10 text-emerald-600">
-                              {totalHours} {t("table.hrs")}
-                            </span>
-                          )}
-                          {totalUnits > 0 && (
-                            <span className="inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-blue-500/30 bg-blue-500/10 text-blue-600">
-                              {totalUnits} {t("table.units")}
-                            </span>
-                          )}
-                        </div>
-                      );
-                    })()}
-                  </TableCell>
-                  <TableCell onClick={(e) => e.stopPropagation()}>
-                    <DropdownMenu>
-                      <DropdownMenuTrigger asChild>
-                        <Button variant="ghost" size="icon" className="h-8 w-8" aria-label={t("actions.openMenu")}>
-                          <MoreVertical className="h-4 w-4" />
-                        </Button>
-                      </DropdownMenuTrigger>
-                      <DropdownMenuContent align="end">
-                        <DropdownMenuItem onClick={() => handleEdit(preset.id)}>
-                          <Pencil className="mr-2 h-4 w-4" />
-                          {t("actions.edit")}
-                        </DropdownMenuItem>
-                        <DropdownMenuItem
-                          className="text-destructive"
-                          onClick={() => handleDelete(preset.id, preset.name)}
-                        >
-                          <Trash2 className="mr-2 h-4 w-4" />
-                          {t("actions.delete")}
-                        </DropdownMenuItem>
-                      </DropdownMenuContent>
-                    </DropdownMenu>
-                  </TableCell>
-                </TableRow>
-                </ContextMenuTrigger>
-                <ContextMenuContent className="min-w-52">
-                  <ContextMenuItem onClick={() => handleEdit(preset.id)}>
-                    <Pencil className="mr-2 h-4 w-4" />
-                    {t("actions.edit")}
-                  </ContextMenuItem>
-                  <ContextMenuItem
-                    variant="destructive"
-                    onClick={() => handleDelete(preset.id, preset.name)}
-                  >
-                    <Trash2 className="mr-2 h-4 w-4" />
-                    {t("actions.delete")}
-                  </ContextMenuItem>
-                </ContextMenuContent>
+                  <ContextMenuTrigger asChild>
+                    <TableRow
+                      className="cursor-pointer"
+                      {...interactiveRow(() => handleEdit(preset.id))}
+                    >
+                      <TableCell className="font-medium">{preset.name}</TableCell>
+                      <TableCell className="hidden sm:table-cell text-muted-foreground">
+                        {preset.description || '-'}
+                      </TableCell>
+                      <TableCell>{preset._count.items}</TableCell>
+                      <TableCell className="hidden md:table-cell">
+                        {(() => {
+                          const hourlyItems = preset.items.filter(
+                            (i) => i.pricingType !== 'service'
+                          )
+                          const serviceItems = preset.items.filter(
+                            (i) => i.pricingType === 'service'
+                          )
+                          const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0)
+                          const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0)
+                          return (
+                            <div className="flex gap-1.5">
+                              {totalHours > 0 && (
+                                <span className="inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-emerald-500/30 bg-emerald-500/10 text-emerald-600">
+                                  {totalHours} {t('table.hrs')}
+                                </span>
+                              )}
+                              {totalUnits > 0 && (
+                                <span className="inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-blue-500/30 bg-blue-500/10 text-blue-600">
+                                  {totalUnits} {t('table.units')}
+                                </span>
+                              )}
+                            </div>
+                          )
+                        })()}
+                      </TableCell>
+                      <TableCell onClick={(e) => e.stopPropagation()}>
+                        <DropdownMenu>
+                          <DropdownMenuTrigger asChild>
+                            <Button
+                              variant="ghost"
+                              size="icon"
+                              className="h-8 w-8"
+                              aria-label={t('actions.openMenu')}
+                            >
+                              <MoreVertical className="h-4 w-4" />
+                            </Button>
+                          </DropdownMenuTrigger>
+                          <DropdownMenuContent align="end">
+                            <DropdownMenuItem onClick={() => handleEdit(preset.id)}>
+                              <Pencil className="mr-2 h-4 w-4" />
+                              {t('actions.edit')}
+                            </DropdownMenuItem>
+                            <DropdownMenuItem
+                              className="text-destructive"
+                              onClick={() => handleDelete(preset.id, preset.name)}
+                            >
+                              <Trash2 className="mr-2 h-4 w-4" />
+                              {t('actions.delete')}
+                            </DropdownMenuItem>
+                          </DropdownMenuContent>
+                        </DropdownMenu>
+                      </TableCell>
+                    </TableRow>
+                  </ContextMenuTrigger>
+                  <ContextMenuContent className="min-w-52">
+                    <ContextMenuItem onClick={() => handleEdit(preset.id)}>
+                      <Pencil className="mr-2 h-4 w-4" />
+                      {t('actions.edit')}
+                    </ContextMenuItem>
+                    <ContextMenuItem
+                      variant="destructive"
+                      onClick={() => handleDelete(preset.id, preset.name)}
+                    >
+                      <Trash2 className="mr-2 h-4 w-4" />
+                      {t('actions.delete')}
+                    </ContextMenuItem>
+                  </ContextMenuContent>
                 </ContextMenu>
               ))
             )}
@@ -372,15 +421,17 @@ export function LaborPresetsClient({
       />
 
       <LaborPresetForm
-        key={editPreset?.id ?? "new"}
+        key={editPreset?.id ?? 'new'}
         open={showForm}
         onOpenChange={(open) => {
-          setShowForm(open);
-          if (!open) setEditPreset(null);
+          setShowForm(open)
+          if (!open) setEditPreset(null)
         }}
         preset={editPreset ?? undefined}
         defaultLaborRate={defaultLaborRate}
+        inventoryParts={inventoryParts}
+        currencyCode={currencyCode}
       />
     </div>
-  );
+  )
 }

+ 41 - 26
src/app/(authenticated)/labor-presets/page.tsx

@@ -1,48 +1,62 @@
-import { getLaborPresetsPaginated } from "@/features/labor-presets/Actions/laborPresetActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { LaborPresetsClient } from "./labor-presets-client";
-import { PageHeader } from "@/components/page-header";
+import { getInventoryPartsList } from '@/features/inventory/Actions/inventoryActions'
+import { getLaborPresetsPaginated } from '@/features/labor-presets/Actions/laborPresetActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { LaborPresetsClient } from './labor-presets-client'
+import { PageHeader } from '@/components/page-header'
 
 export default async function LaborPresetsPage({
   searchParams,
 }: {
   searchParams: Promise<{
-    page?: string;
-    pageSize?: string;
-    search?: string;
-    sortBy?: string;
-    sortOrder?: string;
-  }>;
+    page?: string
+    pageSize?: string
+    search?: string
+    sortBy?: string
+    sortOrder?: string
+  }>
 }) {
-  const params = await searchParams;
-  const [result, settingsResult] = await Promise.all([
+  const params = await searchParams
+  const [result, settingsResult, inventoryResult] = await Promise.all([
     getLaborPresetsPaginated({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
       sortBy: params.sortBy,
-      sortOrder: params.sortOrder as "asc" | "desc" | undefined,
+      sortOrder: params.sortOrder as 'asc' | 'desc' | undefined,
     }),
     getSettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.DEFAULT_LABOR_RATE]),
-  ]);
+    // For the "import from inventory" picker in the preset form. A user
+    // without inventory read permission simply gets no picker.
+    getInventoryPartsList(),
+  ])
 
   if (!result.success || !result.data) {
     return (
       <>
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">
-            {result.error || "Failed to load labor presets"}
-          </p>
+          <p className="text-muted-foreground">{result.error || 'Failed to load labor presets'}</p>
         </div>
       </>
-    );
+    )
   }
 
-  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
-  const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0;
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const inventoryParts =
+    inventoryResult.success && inventoryResult.data
+      ? inventoryResult.data.map((p) => ({
+          id: p.id,
+          name: p.name,
+          partNumber: p.partNumber,
+          unit: p.unit,
+          sellPrice: p.sellPrice,
+          unitCost: p.unitCost,
+          quantity: p.quantity,
+        }))
+      : []
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0
 
   return (
     <>
@@ -50,13 +64,14 @@ export default async function LaborPresetsPage({
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
         <LaborPresetsClient
           data={result.data}
-          search={params.search || ""}
-          sortBy={params.sortBy || "updatedAt"}
-          sortOrder={(params.sortOrder as "asc" | "desc") || "desc"}
+          search={params.search || ''}
+          sortBy={params.sortBy || 'updatedAt'}
+          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
           currencyCode={currencyCode}
           defaultLaborRate={defaultLaborRate}
+          inventoryParts={inventoryParts}
         />
       </div>
     </>
-  );
+  )
 }

Разница между файлами не показана из-за своего большого размера
+ 498 - 374
src/app/(authenticated)/reports/reports-client.tsx


+ 157 - 121
src/app/(authenticated)/settings/workshop/workshop-settings.tsx

@@ -1,14 +1,15 @@
-"use client";
+'use client'
 
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { AppCard } from "@/components/app-card";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Button } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { UnitCombobox } from '@/features/inventory/Components/UnitCombobox'
+import { AppCard } from '@/components/app-card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import { Separator } from '@/components/ui/separator'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 import {
   Command,
   CommandEmpty,
@@ -17,7 +18,7 @@ import {
   CommandItem,
   CommandList,
   CommandSeparator,
-} from "@/components/ui/command";
+} from '@/components/ui/command'
 import {
   AlertDialog,
   AlertDialogAction,
@@ -27,136 +28,138 @@ import {
   AlertDialogFooter,
   AlertDialogHeader,
   AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import { toast } from "sonner";
-import { setSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { assignTechToUnassignedWorkOrders } from "@/features/workboard/Actions/technicianActions";
-import { Loader2, Ruler, Save, Wrench, Check, ChevronsUpDown, Plus } from "lucide-react";
+} from '@/components/ui/alert-dialog'
+import { toast } from 'sonner'
+import { setSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { assignTechToUnassignedWorkOrders } from '@/features/workboard/Actions/technicianActions'
+import { Loader2, Ruler, Save, Wrench, Check, ChevronsUpDown, Plus } from 'lucide-react'
 import {
   Select,
   SelectContent,
   SelectItem,
   SelectTrigger,
   SelectValue,
-} from "@/components/ui/select";
-import { createTechnician } from "@/features/workboard/Actions/technicianActions";
-import { cn } from "@/lib/utils";
-import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from "../read-only-guard";
-import { ServiceTypeSelector } from "../company/service-type-selector";
+} from '@/components/ui/select'
+import { createTechnician } from '@/features/workboard/Actions/technicianActions'
+import { cn } from '@/lib/utils'
+import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
+import { ServiceTypeSelector } from '../company/service-type-selector'
 
 interface TechnicianOption {
   id: string
   name: string
 }
 
-export function WorkshopSettings({ settings, technicians: initialTechnicians = [] }: { settings: Record<string, string>; technicians?: TechnicianOption[] }) {
-  const router = useRouter();
-  const t = useTranslations('settings');
-  const [saving, setSaving] = useState(false);
+export function WorkshopSettings({
+  settings,
+  technicians: initialTechnicians = [],
+}: {
+  settings: Record<string, string>
+  technicians?: TechnicianOption[]
+}) {
+  const router = useRouter()
+  const t = useTranslations('settings')
+  const [saving, setSaving] = useState(false)
   const [serviceType, setServiceType] = useState(
-    settings[SETTING_KEYS.SERVICE_TYPE] || "automotive"
-  );
+    settings[SETTING_KEYS.SERVICE_TYPE] || 'automotive'
+  )
 
   const [defaultTechnicianId, setDefaultTechnicianId] = useState(
-    settings[SETTING_KEYS.DEFAULT_TECHNICIAN_ID] || ""
-  );
-  const [technicians, setTechnicians] = useState<TechnicianOption[]>(initialTechnicians);
-  const [techOpen, setTechOpen] = useState(false);
-  const [techSearch, setTechSearch] = useState('');
-  const [creatingTech, setCreatingTech] = useState(false);
-  const [showNewInput, setShowNewInput] = useState(false);
-  const [newTechName, setNewTechName] = useState('');
-  const [showAssignDialog, setShowAssignDialog] = useState(false);
-  const [pendingTechId, setPendingTechId] = useState<string | null>(null);
-  const [assigning, setAssigning] = useState(false);
+    settings[SETTING_KEYS.DEFAULT_TECHNICIAN_ID] || ''
+  )
+  const [technicians, setTechnicians] = useState<TechnicianOption[]>(initialTechnicians)
+  const [techOpen, setTechOpen] = useState(false)
+  const [techSearch, setTechSearch] = useState('')
+  const [creatingTech, setCreatingTech] = useState(false)
+  const [showNewInput, setShowNewInput] = useState(false)
+  const [newTechName, setNewTechName] = useState('')
+  const [showAssignDialog, setShowAssignDialog] = useState(false)
+  const [pendingTechId, setPendingTechId] = useState<string | null>(null)
+  const [assigning, setAssigning] = useState(false)
   const [defaultLaborRate, setDefaultLaborRate] = useState(
-    settings[SETTING_KEYS.DEFAULT_LABOR_RATE] || ""
-  );
-  const [unitSystem, setUnitSystem] = useState(
-    settings[SETTING_KEYS.UNIT_SYSTEM] || "imperial"
-  );
+    settings[SETTING_KEYS.DEFAULT_LABOR_RATE] || ''
+  )
+  const [unitSystem, setUnitSystem] = useState(settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial')
+  const [defaultUnit, setDefaultUnit] = useState(
+    settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''
+  )
   const [workDayStart, setWorkDayStart] = useState(
-    settings[SETTING_KEYS.WORKBOARD_WORK_DAY_START] || "07:00"
-  );
+    settings[SETTING_KEYS.WORKBOARD_WORK_DAY_START] || '07:00'
+  )
   const [workDayEnd, setWorkDayEnd] = useState(
-    settings[SETTING_KEYS.WORKBOARD_WORK_DAY_END] || "15:00"
-  );
+    settings[SETTING_KEYS.WORKBOARD_WORK_DAY_END] || '15:00'
+  )
 
-  const selectedTechName = technicians.find((t) => t.id === defaultTechnicianId)?.name || "";
+  const selectedTechName = technicians.find((t) => t.id === defaultTechnicianId)?.name || ''
 
   const handleTechSelect = (techId: string) => {
-    setDefaultTechnicianId(techId);
-    setTechOpen(false);
+    setDefaultTechnicianId(techId)
+    setTechOpen(false)
     if (techId) {
-      setPendingTechId(techId);
-      setShowAssignDialog(true);
+      setPendingTechId(techId)
+      setShowAssignDialog(true)
     }
-  };
+  }
 
   const handleAssignUnassigned = async () => {
-    if (!pendingTechId) return;
-    setAssigning(true);
-    const result = await assignTechToUnassignedWorkOrders(pendingTechId);
-    setAssigning(false);
-    setShowAssignDialog(false);
-    setPendingTechId(null);
+    if (!pendingTechId) return
+    setAssigning(true)
+    const result = await assignTechToUnassignedWorkOrders(pendingTechId)
+    setAssigning(false)
+    setShowAssignDialog(false)
+    setPendingTechId(null)
     if (result.success && result.data) {
-      toast.success(t('workshop.assignedUnassigned', { count: result.data.updated }));
+      toast.success(t('workshop.assignedUnassigned', { count: result.data.updated }))
     } else {
-      toast.error(result.error || t('workshop.assignFailed'));
+      toast.error(result.error || t('workshop.assignFailed'))
     }
-  };
+  }
 
   const doCreateTechnician = async (name: string) => {
-    if (!name.trim()) return;
-    setCreatingTech(true);
-    const res = await createTechnician({ name: name.trim() });
-    setCreatingTech(false);
+    if (!name.trim()) return
+    setCreatingTech(true)
+    const res = await createTechnician({ name: name.trim() })
+    setCreatingTech(false)
     if (res.success && res.data) {
-      const newTech = { id: res.data.id, name: res.data.name };
-      setTechnicians((prev) => [...prev, newTech]);
-      setTechSearch('');
-      setNewTechName('');
-      setShowNewInput(false);
-      handleTechSelect(newTech.id);
+      const newTech = { id: res.data.id, name: res.data.name }
+      setTechnicians((prev) => [...prev, newTech])
+      setTechSearch('')
+      setNewTechName('')
+      setShowNewInput(false)
+      handleTechSelect(newTech.id)
     } else {
-      toast.error(t('workshop.failedCreateTech'));
+      toast.error(t('workshop.failedCreateTech'))
     }
-  };
+  }
 
-  const searchLower = techSearch.toLowerCase();
-  const exactMatch = technicians.some((tech) => tech.name.toLowerCase() === searchLower);
+  const searchLower = techSearch.toLowerCase()
+  const exactMatch = technicians.some((tech) => tech.name.toLowerCase() === searchLower)
 
   const handleSave = async () => {
-    setSaving(true);
+    setSaving(true)
     await setSettings({
       [SETTING_KEYS.SERVICE_TYPE]: serviceType,
       [SETTING_KEYS.DEFAULT_TECHNICIAN_ID]: defaultTechnicianId,
       [SETTING_KEYS.DEFAULT_TECHNICIAN]: selectedTechName,
       [SETTING_KEYS.DEFAULT_LABOR_RATE]: defaultLaborRate,
       [SETTING_KEYS.UNIT_SYSTEM]: unitSystem,
+      [SETTING_KEYS.INVENTORY_DEFAULT_UNIT]: defaultUnit.trim(),
       [SETTING_KEYS.WORKBOARD_WORK_DAY_START]: workDayStart,
       [SETTING_KEYS.WORKBOARD_WORK_DAY_END]: workDayEnd,
-    });
-    setSaving(false);
-    router.refresh();
-    toast.success(t('workshop.saved'));
-  };
+    })
+    setSaving(false)
+    router.refresh()
+    toast.success(t('workshop.saved'))
+  }
 
   return (
     <div className="space-y-6">
       <ReadOnlyBanner />
       <ReadOnlyWrapper>
-      <ServiceTypeSelector serviceType={serviceType} onServiceTypeChange={setServiceType} />
-      <AppCard
-        icon={Wrench}
-        title={t('workshop.title')}
-        contentClassName="space-y-6"
-      >
-          <p className="text-sm text-muted-foreground">
-            {t('workshop.description')}
-          </p>
+        <ServiceTypeSelector serviceType={serviceType} onServiceTypeChange={setServiceType} />
+        <AppCard icon={Wrench} title={t('workshop.title')} contentClassName="space-y-6">
+          <p className="text-sm text-muted-foreground">{t('workshop.description')}</p>
 
           <div className="grid gap-4 sm:grid-cols-2">
             <div className="space-y-2">
@@ -188,17 +191,19 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
                         <CommandItem
                           value="__none__"
                           onSelect={() => {
-                            setDefaultTechnicianId("");
-                            setTechOpen(false);
+                            setDefaultTechnicianId('')
+                            setTechOpen(false)
                           }}
                         >
                           <Check
                             className={cn(
                               'mr-2 h-4 w-4',
-                              !defaultTechnicianId ? 'opacity-100' : 'opacity-0',
+                              !defaultTechnicianId ? 'opacity-100' : 'opacity-0'
                             )}
                           />
-                          <span className="text-muted-foreground">{t('workshop.noTechnician')}</span>
+                          <span className="text-muted-foreground">
+                            {t('workshop.noTechnician')}
+                          </span>
                         </CommandItem>
                         {technicians.map((tech) => (
                           <CommandItem
@@ -209,7 +214,7 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
                             <Check
                               className={cn(
                                 'mr-2 h-4 w-4',
-                                defaultTechnicianId === tech.id ? 'opacity-100' : 'opacity-0',
+                                defaultTechnicianId === tech.id ? 'opacity-100' : 'opacity-0'
                               )}
                             />
                             {tech.name}
@@ -224,14 +229,19 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
                             disabled={creatingTech}
                           >
                             <Plus className="mr-2 h-4 w-4" />
-                            {creatingTech ? t('workshop.creating') : t('workshop.createTechnician', { name: techSearch.trim() })}
+                            {creatingTech
+                              ? t('workshop.creating')
+                              : t('workshop.createTechnician', { name: techSearch.trim() })}
                           </CommandItem>
                         </CommandGroup>
                       )}
                       <CommandSeparator />
                       <CommandGroup>
                         {showNewInput ? (
-                          <div className="flex items-center gap-1.5 px-2 py-1.5" onKeyDown={(e) => e.stopPropagation()}>
+                          <div
+                            className="flex items-center gap-1.5 px-2 py-1.5"
+                            onKeyDown={(e) => e.stopPropagation()}
+                          >
                             <Input
                               autoFocus
                               placeholder={t('workshop.newTechPlaceholder')}
@@ -239,12 +249,12 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
                               onChange={(e) => setNewTechName(e.target.value)}
                               onKeyDown={(e) => {
                                 if (e.key === 'Enter') {
-                                  e.preventDefault();
-                                  doCreateTechnician(newTechName);
+                                  e.preventDefault()
+                                  doCreateTechnician(newTechName)
                                 }
                                 if (e.key === 'Escape') {
-                                  setShowNewInput(false);
-                                  setNewTechName('');
+                                  setShowNewInput(false)
+                                  setNewTechName('')
                                 }
                               }}
                               className="h-7 text-sm"
@@ -261,10 +271,7 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
                             </Button>
                           </div>
                         ) : (
-                          <CommandItem
-                            value="__add_new__"
-                            onSelect={() => setShowNewInput(true)}
-                          >
+                          <CommandItem value="__add_new__" onSelect={() => setShowNewInput(true)}>
                             <Plus className="mr-2 h-4 w-4" />
                             {t('workshop.addNewTech')}
                           </CommandItem>
@@ -315,9 +322,7 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
               <Ruler className="h-5 w-5 text-muted-foreground" />
               <h3 className="text-lg font-semibold">{t('workshop.unitsTitle')}</h3>
             </div>
-            <p className="text-sm text-muted-foreground">
-              {t('workshop.unitsDescription')}
-            </p>
+            <p className="text-sm text-muted-foreground">{t('workshop.unitsDescription')}</p>
 
             <div className="space-y-2">
               <Label htmlFor="unitSystem">{t('workshop.unitSystem')}</Label>
@@ -333,18 +338,44 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
             </div>
 
             <div className="rounded-lg border p-3 text-sm text-muted-foreground">
-              {unitSystem === "metric" ? (
+              {unitSystem === 'metric' ? (
                 <div className="space-y-1">
-                  <p>{t('workshop.distanceLabel')}: <span className="font-medium text-foreground">{t('workshop.kilometers')}</span></p>
-                  <p>{t('workshop.volumeLabel')}: <span className="font-medium text-foreground">{t('workshop.liters')}</span></p>
+                  <p>
+                    {t('workshop.distanceLabel')}:{' '}
+                    <span className="font-medium text-foreground">{t('workshop.kilometers')}</span>
+                  </p>
+                  <p>
+                    {t('workshop.volumeLabel')}:{' '}
+                    <span className="font-medium text-foreground">{t('workshop.liters')}</span>
+                  </p>
                 </div>
               ) : (
                 <div className="space-y-1">
-                  <p>{t('workshop.distanceLabel')}: <span className="font-medium text-foreground">{t('workshop.miles')}</span></p>
-                  <p>{t('workshop.volumeLabel')}: <span className="font-medium text-foreground">{t('workshop.gallons')}</span></p>
+                  <p>
+                    {t('workshop.distanceLabel')}:{' '}
+                    <span className="font-medium text-foreground">{t('workshop.miles')}</span>
+                  </p>
+                  <p>
+                    {t('workshop.volumeLabel')}:{' '}
+                    <span className="font-medium text-foreground">{t('workshop.gallons')}</span>
+                  </p>
                 </div>
               )}
             </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="defaultUnit">{t('workshop.defaultStockUnit')}</Label>
+              {/* Suggestions follow the (unsaved) system picked above, so
+                  switching to imperial immediately offers qt/gal/lb. */}
+              <UnitCombobox
+                id="defaultUnit"
+                className="w-48"
+                value={defaultUnit}
+                onChange={setDefaultUnit}
+                unitSystem={unitSystem}
+              />
+              <p className="text-sm text-muted-foreground">{t('workshop.defaultStockUnitHint')}</p>
+            </div>
           </div>
 
           <SaveButton>
@@ -363,15 +394,20 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
         </AppCard>
       </ReadOnlyWrapper>
 
-      <AlertDialog open={showAssignDialog} onOpenChange={(open) => {
-        if (!open) setPendingTechId(null);
-        setShowAssignDialog(open);
-      }}>
+      <AlertDialog
+        open={showAssignDialog}
+        onOpenChange={(open) => {
+          if (!open) setPendingTechId(null)
+          setShowAssignDialog(open)
+        }}
+      >
         <AlertDialogContent>
           <AlertDialogHeader>
             <AlertDialogTitle>{t('workshop.assignDialogTitle')}</AlertDialogTitle>
             <AlertDialogDescription>
-              {t('workshop.assignDialogDescription', { name: technicians.find((tech) => tech.id === pendingTechId)?.name || '' })}
+              {t('workshop.assignDialogDescription', {
+                name: technicians.find((tech) => tech.id === pendingTechId)?.name || '',
+              })}
             </AlertDialogDescription>
           </AlertDialogHeader>
           <AlertDialogFooter>
@@ -384,5 +420,5 @@ export function WorkshopSettings({ settings, technicians: initialTechnicians = [
         </AlertDialogContent>
       </AlertDialog>
     </div>
-  );
+  )
 }

Разница между файлами не показана из-за своего большого размера
+ 1001 - 523
src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx


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

@@ -1,35 +1,35 @@
-import { db } from "@/lib/db";
-import { notFound } from "next/navigation";
-import { InvoiceView } from "./invoice-view";
-import { getFeatures } from "@/lib/features";
-import { resolvePortalOrg } from "@/lib/portal-slug";
-import { mergeWithDefaults } from "@/features/settings/Schema/invoiceLayoutSchema";
-import type { Metadata } from "next";
+import { db } from '@/lib/db'
+import { notFound } from 'next/navigation'
+import { InvoiceView } from './invoice-view'
+import { getFeatures } from '@/lib/features'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { Metadata } from 'next'
 
 /** Rewrites /api/protected/files/[orgId]/[category]/[filename] to /api/public/files/[token]/[category]/[filename] */
 function toPublicFileUrl(fileUrl: string, token: string): string {
-  const match = fileUrl.match(/^\/api\/protected\/files\/[^/]+\/(.+)$/);
-  if (match) return `/api/public/files/${token}/${match[1]}`;
+  const match = fileUrl.match(/^\/api\/protected\/files\/[^/]+\/(.+)$/)
+  if (match) return `/api/public/files/${token}/${match[1]}`
   // Legacy URLs pass through as-is
-  return fileUrl;
+  return fileUrl
 }
 
-export const revalidate = 60;
+export const revalidate = 60
 
 export const metadata: Metadata = {
   robots: { index: false, follow: false },
-};
+}
 
 export default async function PublicInvoicePage({
   params,
 }: {
-  params: Promise<{ orgId: string; token: string }>;
+  params: Promise<{ orgId: string; token: string }>
 }) {
-  const { orgId: orgParam, token } = await params;
+  const { orgId: orgParam, token } = await params
 
   // Resolve slug (e.g. "egelandauto") or UUID to the real org ID
-  const resolvedOrg = await resolvePortalOrg(orgParam);
-  const orgId = resolvedOrg?.id ?? orgParam;
+  const resolvedOrg = await resolvePortalOrg(orgParam)
+  const orgId = resolvedOrg?.id ?? orgParam
 
   const record = await db.serviceRecord.findUnique({
     where: { publicToken: token },
@@ -42,13 +42,14 @@ export default async function PublicInvoicePage({
           partNumber: true,
           name: true,
           quantity: true,
+          unit: true,
           unitPrice: true,
           total: true,
         },
       },
       laborItems: true,
       attachments: true,
-      payments: { orderBy: { date: "desc" } },
+      payments: { orderBy: { date: 'desc' } },
       customer: {
         select: {
           name: true,
@@ -82,10 +83,10 @@ export default async function PublicInvoicePage({
         },
       },
     },
-  });
+  })
 
   if (!record || record.organizationId !== orgId) {
-    notFound();
+    notFound()
   }
 
   // Fetch workshop settings and features
@@ -95,34 +96,34 @@ export default async function PublicInvoicePage({
         organizationId: record.organizationId,
         key: {
           in: [
-            "workshop.address",
-            "workshop.phone",
-            "workshop.email",
-            "workshop.logo",
-            "workshop.currencyCode",
-            "workshop.currencyFormat",
-            "invoice.bankAccount",
-            "invoice.orgNumber",
-            "invoice.paymentTerms",
-            "invoice.footerNote",
-            "invoice.showBankAccount",
-            "invoice.showOrgNumber",
-            "invoice.dueDays",
-            "invoice.showLogo",
-            "invoice.showCompanyName",
-            "invoice.primaryColor",
-            "invoice.headerStyle",
-            "invoice.logoSize",
-            "payment.providersEnabled",
-            "payment.termsOfSale",
-            "payment.termsOfSaleUrl",
-            "workshop.dateFormat",
-            "workshop.timezone",
-            "workshop.serviceType",
-            "workshop.taxLabel",
-            "portal.enabled",
-            "invoice.layoutConfig",
-            "telegram.botUsername",
+            'workshop.address',
+            'workshop.phone',
+            'workshop.email',
+            'workshop.logo',
+            'workshop.currencyCode',
+            'workshop.currencyFormat',
+            'invoice.bankAccount',
+            'invoice.orgNumber',
+            'invoice.paymentTerms',
+            'invoice.footerNote',
+            'invoice.showBankAccount',
+            'invoice.showOrgNumber',
+            'invoice.dueDays',
+            'invoice.showLogo',
+            'invoice.showCompanyName',
+            'invoice.primaryColor',
+            'invoice.headerStyle',
+            'invoice.logoSize',
+            'payment.providersEnabled',
+            'payment.termsOfSale',
+            'payment.termsOfSaleUrl',
+            'workshop.dateFormat',
+            'workshop.timezone',
+            'workshop.serviceType',
+            'workshop.taxLabel',
+            'portal.enabled',
+            'invoice.layoutConfig',
+            'telegram.botUsername',
           ],
         },
       },
@@ -134,60 +135,70 @@ export default async function PublicInvoicePage({
         })
       : null,
     getFeatures(orgId),
-  ]);
+  ])
 
   // Fetch findings for this service record (open ones to show on invoice)
   const findings = await db.vehicleFinding.findMany({
-    where: { serviceRecordId: record.id, status: { not: "resolved" } },
+    where: { serviceRecordId: record.id, status: { not: 'resolved' } },
     select: { description: true, severity: true, notes: true },
-    orderBy: { createdAt: "desc" },
-  });
+    orderBy: { createdAt: 'desc' },
+  })
 
   // Fetch custom field values for this service record
   const customFieldValues = await db.customFieldValue.findMany({
-    where: { entityId: record.id, entityType: "service_record" },
-    include: { field: { select: { id: true, label: true, fieldType: true, isActive: true, sortOrder: true } } },
-    orderBy: { field: { sortOrder: "asc" } },
-  });
+    where: { entityId: record.id, entityType: 'service_record' },
+    include: {
+      field: {
+        select: { id: true, label: true, fieldType: true, isActive: true, sortOrder: true },
+      },
+    },
+    orderBy: { field: { sortOrder: 'asc' } },
+  })
 
   const customFields = customFieldValues
-    .filter(v => v.field.isActive && v.value)
-    .map(v => ({ label: v.field.label, value: v.value, fieldType: v.field.fieldType, fieldId: v.field.id }));
+    .filter((v) => v.field.isActive && v.value)
+    .map((v) => ({
+      label: v.field.label,
+      value: v.value,
+      fieldType: v.field.fieldType,
+      fieldId: v.field.id,
+    }))
 
-  const settingsMap: Record<string, string> = {};
-  for (const s of settings) settingsMap[s.key] = s.value;
+  const settingsMap: Record<string, string> = {}
+  for (const s of settings) settingsMap[s.key] = s.value
 
   const workshop = {
-    name: org?.name || "",
-    address: settingsMap["workshop.address"] || "",
-    phone: settingsMap["workshop.phone"] || "",
-    email: settingsMap["workshop.email"] || "",
-  };
+    name: org?.name || '',
+    address: settingsMap['workshop.address'] || '',
+    phone: settingsMap['workshop.phone'] || '',
+    email: settingsMap['workshop.email'] || '',
+  }
 
-  const currencyCode = settingsMap["workshop.currencyCode"] || "USD";
-  const currencyFormat: "symbol" | "code" = settingsMap["workshop.currencyFormat"] === "code" ? "code" : "symbol";
+  const currencyCode = settingsMap['workshop.currencyCode'] || 'USD'
+  const currencyFormat: 'symbol' | 'code' =
+    settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol'
 
   const invoiceSettings = {
-    bankAccount: settingsMap["invoice.bankAccount"] || "",
-    orgNumber: settingsMap["invoice.orgNumber"] || "",
-    paymentTerms: settingsMap["invoice.paymentTerms"] || "",
-    footerNote: settingsMap["invoice.footerNote"] || "",
-    showBankAccount: settingsMap["invoice.showBankAccount"] !== "false",
-    showOrgNumber: settingsMap["invoice.showOrgNumber"] !== "false",
-    dueDays: Number(settingsMap["invoice.dueDays"]) || 0,
-  };
-
-  const showLogo = settingsMap["invoice.showLogo"] !== "false";
-  const showCompanyName = settingsMap["invoice.showCompanyName"] !== "false";
-  const rawLogoUrl = settingsMap["workshop.logo"] || "";
-  const logoUrl = rawLogoUrl ? toPublicFileUrl(rawLogoUrl, token) : "";
+    bankAccount: settingsMap['invoice.bankAccount'] || '',
+    orgNumber: settingsMap['invoice.orgNumber'] || '',
+    paymentTerms: settingsMap['invoice.paymentTerms'] || '',
+    footerNote: settingsMap['invoice.footerNote'] || '',
+    showBankAccount: settingsMap['invoice.showBankAccount'] !== 'false',
+    showOrgNumber: settingsMap['invoice.showOrgNumber'] !== 'false',
+    dueDays: Number(settingsMap['invoice.dueDays']) || 0,
+  }
+
+  const showLogo = settingsMap['invoice.showLogo'] !== 'false'
+  const showCompanyName = settingsMap['invoice.showCompanyName'] !== 'false'
+  const rawLogoUrl = settingsMap['workshop.logo'] || ''
+  const logoUrl = rawLogoUrl ? toPublicFileUrl(rawLogoUrl, token) : ''
 
   // Determine which online payment providers are enabled for this org
-  const enabledProvidersRaw = settingsMap["payment.providersEnabled"] || "";
+  const enabledProvidersRaw = settingsMap['payment.providersEnabled'] || ''
   const enabledProviders = enabledProvidersRaw
-    .split(",")
+    .split(',')
     .map((s) => s.trim())
-    .filter(Boolean);
+    .filter(Boolean)
 
   // Rewrite attachment file URLs to use the public file route (no auth required)
   // Only include attachments marked for invoice display
@@ -199,26 +210,26 @@ export default async function PublicInvoicePage({
         ...att,
         fileUrl: toPublicFileUrl(att.fileUrl, token),
       })),
-  };
+  }
 
   // Parse layout config
   const layoutConfig = mergeWithDefaults(
-    settingsMap["invoice.layoutConfig"] ? JSON.parse(settingsMap["invoice.layoutConfig"]) : {}
-  );
+    settingsMap['invoice.layoutConfig'] ? JSON.parse(settingsMap['invoice.layoutConfig']) : {}
+  )
 
-  const termsOfSaleUrl = settingsMap["payment.termsOfSaleUrl"]
-    || (settingsMap["payment.termsOfSale"] ? `/share/terms/${orgId}` : undefined);
+  const termsOfSaleUrl =
+    settingsMap['payment.termsOfSaleUrl'] ||
+    (settingsMap['payment.termsOfSale'] ? `/share/terms/${orgId}` : undefined)
 
-  const appUrl = process.env.NEXT_PUBLIC_APP_URL
-    || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000");
-  const portalSlug = org?.portalSlug;
-  const portalEnabled = settingsMap["portal.enabled"] === "true";
-  const portalUrl = portalEnabled
-    ? `${appUrl}/portal/${portalSlug || orgId}`
-    : undefined;
+  const appUrl =
+    process.env.NEXT_PUBLIC_APP_URL ||
+    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+  const portalSlug = org?.portalSlug
+  const portalEnabled = settingsMap['portal.enabled'] === 'true'
+  const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
 
-  const telegramBotUsername = settingsMap["telegram.botUsername"] || "";
-  const telegramBotLink = telegramBotUsername ? `https://t.me/${telegramBotUsername}` : undefined;
+  const telegramBotUsername = settingsMap['telegram.botUsername'] || ''
+  const telegramBotLink = telegramBotUsername ? `https://t.me/${telegramBotUsername}` : undefined
 
   return (
     <InvoiceView
@@ -234,19 +245,19 @@ export default async function PublicInvoicePage({
       showLogo={showLogo}
       showCompanyName={showCompanyName}
       showTorqvoiceBranding={!features.brandingRemoved}
-      dateFormat={settingsMap["workshop.dateFormat"] || undefined}
-      timezone={settingsMap["workshop.timezone"] || undefined}
+      dateFormat={settingsMap['workshop.dateFormat'] || undefined}
+      timezone={settingsMap['workshop.timezone'] || undefined}
       termsOfSaleUrl={termsOfSaleUrl}
-      primaryColor={settingsMap["invoice.primaryColor"] || "#d97706"}
-      headerStyle={settingsMap["invoice.headerStyle"] || "standard"}
-      logoSize={Number(settingsMap["invoice.logoSize"]) || 100}
+      primaryColor={settingsMap['invoice.primaryColor'] || '#d97706'}
+      headerStyle={settingsMap['invoice.headerStyle'] || 'standard'}
+      logoSize={Number(settingsMap['invoice.logoSize']) || 100}
       portalUrl={portalUrl}
       layoutConfig={layoutConfig}
       customFields={customFields}
       findings={findings}
       telegramBotLink={telegramBotLink}
-      serviceType={(settingsMap["workshop.serviceType"] || "automotive") as "automotive" | "marine"}
-      taxLabel={settingsMap["workshop.taxLabel"]?.trim() || undefined}
+      serviceType={(settingsMap['workshop.serviceType'] || 'automotive') as 'automotive' | 'marine'}
+      taxLabel={settingsMap['workshop.taxLabel']?.trim() || undefined}
     />
-  );
+  )
 }

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

@@ -1,27 +1,27 @@
-import { db } from "@/lib/db";
-import { notFound } from "next/navigation";
-import { QuoteView } from "./quote-view";
-import { getFeatures } from "@/lib/features";
-import { resolvePortalOrg } from "@/lib/portal-slug";
-import { mergeWithDefaults } from "@/features/settings/Schema/invoiceLayoutSchema";
-import type { Metadata } from "next";
+import { db } from '@/lib/db'
+import { notFound } from 'next/navigation'
+import { QuoteView } from './quote-view'
+import { getFeatures } from '@/lib/features'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { Metadata } from 'next'
 
-export const revalidate = 60;
+export const revalidate = 60
 
 export const metadata: Metadata = {
   robots: { index: false, follow: false },
-};
+}
 
 export default async function PublicQuotePage({
   params,
 }: {
-  params: Promise<{ orgId: string; token: string }>;
+  params: Promise<{ orgId: string; token: string }>
 }) {
-  const { orgId: orgParam, token } = await params;
+  const { orgId: orgParam, token } = await params
 
   // Resolve slug (e.g. "egelandauto") or UUID to the real org ID
-  const resolvedOrg = await resolvePortalOrg(orgParam);
-  const orgId = resolvedOrg?.id ?? orgParam;
+  const resolvedOrg = await resolvePortalOrg(orgParam)
+  const orgId = resolvedOrg?.id ?? orgParam
 
   const quote = await db.quote.findFirst({
     where: { publicToken: token, organizationId: orgId },
@@ -34,6 +34,7 @@ export default async function PublicQuotePage({
           partNumber: true,
           name: true,
           quantity: true,
+          unit: true,
           unitPrice: true,
           total: true,
           excluded: true,
@@ -61,10 +62,10 @@ export default async function PublicQuotePage({
         },
       },
     },
-  });
+  })
 
   if (!quote) {
-    notFound();
+    notFound()
   }
 
   const [settings, org, features] = await Promise.all([
@@ -73,23 +74,23 @@ export default async function PublicQuotePage({
         organizationId: orgId,
         key: {
           in: [
-            "workshop.address",
-            "workshop.phone",
-            "workshop.email",
-            "workshop.logo",
-            "workshop.currencyCode",
-            "workshop.currencyFormat",
-            "workshop.dateFormat",
-            "workshop.timezone",
-            "quote.primaryColor",
-            "quote.headerStyle",
-            "quote.logoSize",
-            "invoice.primaryColor",
-            "invoice.headerStyle",
-            "portal.enabled",
-            "quote.layoutConfig",
-            "workshop.serviceType",
-            "workshop.taxLabel",
+            'workshop.address',
+            'workshop.phone',
+            'workshop.email',
+            'workshop.logo',
+            'workshop.currencyCode',
+            'workshop.currencyFormat',
+            'workshop.dateFormat',
+            'workshop.timezone',
+            'quote.primaryColor',
+            'quote.headerStyle',
+            'quote.logoSize',
+            'invoice.primaryColor',
+            'invoice.headerStyle',
+            'portal.enabled',
+            'quote.layoutConfig',
+            'workshop.serviceType',
+            'workshop.taxLabel',
           ],
         },
       },
@@ -99,76 +100,87 @@ export default async function PublicQuotePage({
       select: { name: true, portalSlug: true },
     }),
     getFeatures(orgId),
-  ]);
+  ])
 
   // Fetch custom field values for this quote
   const customFieldValues = await db.customFieldValue.findMany({
-    where: { entityId: quote.id, entityType: "quote" },
-    include: { field: { select: { id: true, label: true, fieldType: true, isActive: true, sortOrder: true } } },
-    orderBy: { field: { sortOrder: "asc" } },
-  });
+    where: { entityId: quote.id, entityType: 'quote' },
+    include: {
+      field: {
+        select: { id: true, label: true, fieldType: true, isActive: true, sortOrder: true },
+      },
+    },
+    orderBy: { field: { sortOrder: 'asc' } },
+  })
 
   const customFields = customFieldValues
-    .filter(v => v.field.isActive && v.value)
-    .map(v => ({ label: v.field.label, value: v.value, fieldType: v.field.fieldType, fieldId: v.field.id }));
+    .filter((v) => v.field.isActive && v.value)
+    .map((v) => ({
+      label: v.field.label,
+      value: v.value,
+      fieldType: v.field.fieldType,
+      fieldId: v.field.id,
+    }))
 
-  const settingsMap: Record<string, string> = {};
-  for (const s of settings) settingsMap[s.key] = s.value;
+  const settingsMap: Record<string, string> = {}
+  for (const s of settings) settingsMap[s.key] = s.value
 
   const workshop = {
-    name: org?.name || "",
-    address: settingsMap["workshop.address"] || "",
-    phone: settingsMap["workshop.phone"] || "",
-    email: settingsMap["workshop.email"] || "",
-  };
+    name: org?.name || '',
+    address: settingsMap['workshop.address'] || '',
+    phone: settingsMap['workshop.phone'] || '',
+    email: settingsMap['workshop.email'] || '',
+  }
 
-  const currencyCode = settingsMap["workshop.currencyCode"] || "USD";
-  const currencyFormat: "symbol" | "code" = settingsMap["workshop.currencyFormat"] === "code" ? "code" : "symbol";
+  const currencyCode = settingsMap['workshop.currencyCode'] || 'USD'
+  const currencyFormat: 'symbol' | 'code' =
+    settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol'
 
   // Rewrite logo URL for public access
-  const rawLogoUrl = settingsMap["workshop.logo"] || "";
-  let logoUrl = "";
+  const rawLogoUrl = settingsMap['workshop.logo'] || ''
+  let logoUrl = ''
   if (rawLogoUrl) {
-    const match = rawLogoUrl.match(/^\/api\/files\/[^/]+\/(.+)$/);
-    if (match) logoUrl = `/api/public/files/${token}/${match[1]}`;
-    else logoUrl = rawLogoUrl;
+    const match = rawLogoUrl.match(/^\/api\/files\/[^/]+\/(.+)$/)
+    if (match) logoUrl = `/api/public/files/${token}/${match[1]}`
+    else logoUrl = rawLogoUrl
   }
 
   // Rewrite attachment URLs for public access
   const imageAttachments = (quote.attachments || [])
-    .filter((a) => a.category === "image")
+    .filter((a) => a.category === 'image')
     .map((a) => ({
       ...a,
       fileUrl: a.fileUrl.replace(
         /^\/api\/protected\/files\/[^/]+\//,
         `/api/public/files/${token}/`
       ),
-    }));
+    }))
   const documentAttachments = (quote.attachments || [])
-    .filter((a) => a.category === "document")
+    .filter((a) => a.category === 'document')
     .map((a) => ({
       ...a,
       fileUrl: a.fileUrl.replace(
         /^\/api\/protected\/files\/[^/]+\//,
         `/api/public/files/${token}/`
       ),
-    }));
+    }))
 
   // Parse layout config
   const layoutConfig = mergeWithDefaults(
-    settingsMap["quote.layoutConfig"] ? JSON.parse(settingsMap["quote.layoutConfig"]) : {}
-  );
+    settingsMap['quote.layoutConfig'] ? JSON.parse(settingsMap['quote.layoutConfig']) : {}
+  )
 
-  const primaryColor = settingsMap["quote.primaryColor"] || settingsMap["invoice.primaryColor"] || "#d97706";
-  const headerStyle = settingsMap["quote.headerStyle"] || settingsMap["invoice.headerStyle"] || "standard";
+  const primaryColor =
+    settingsMap['quote.primaryColor'] || settingsMap['invoice.primaryColor'] || '#d97706'
+  const headerStyle =
+    settingsMap['quote.headerStyle'] || settingsMap['invoice.headerStyle'] || 'standard'
 
-  const appUrl = process.env.NEXT_PUBLIC_APP_URL
-    || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000");
-  const portalSlug = org?.portalSlug;
-  const portalEnabled = settingsMap["portal.enabled"] === "true";
-  const portalUrl = portalEnabled
-    ? `${appUrl}/portal/${portalSlug || orgId}`
-    : undefined;
+  const appUrl =
+    process.env.NEXT_PUBLIC_APP_URL ||
+    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+  const portalSlug = org?.portalSlug
+  const portalEnabled = settingsMap['portal.enabled'] === 'true'
+  const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
 
   return (
     <QuoteView
@@ -180,18 +192,18 @@ export default async function PublicQuotePage({
       token={token}
       logoUrl={logoUrl}
       showTorqvoiceBranding={!features.brandingRemoved}
-      dateFormat={settingsMap["workshop.dateFormat"] || undefined}
-      timezone={settingsMap["workshop.timezone"] || undefined}
+      dateFormat={settingsMap['workshop.dateFormat'] || undefined}
+      timezone={settingsMap['workshop.timezone'] || undefined}
       primaryColor={primaryColor}
       headerStyle={headerStyle}
-      logoSize={Number(settingsMap["quote.logoSize"]) || 100}
+      logoSize={Number(settingsMap['quote.logoSize']) || 100}
       portalUrl={portalUrl}
       imageAttachments={imageAttachments}
       documentAttachments={documentAttachments}
       layoutConfig={layoutConfig}
       customFields={customFields}
-      serviceType={(settingsMap["workshop.serviceType"] || "automotive") as "automotive" | "marine"}
-      taxLabel={settingsMap["workshop.taxLabel"]?.trim() || undefined}
+      serviceType={(settingsMap['workshop.serviceType'] || 'automotive') as 'automotive' | 'marine'}
+      taxLabel={settingsMap['workshop.taxLabel']?.trim() || undefined}
     />
-  );
+  )
 }

Разница между файлами не показана из-за своего большого размера
+ 928 - 547
src/app/(public)/share/quote/[orgId]/[token]/quote-view.tsx


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

@@ -137,8 +137,14 @@ async function importServiceRecordTree(
         partNumber: (p.partNumber as string) || null,
         name: p.name as string,
         quantity: (p.quantity as number) || 1,
+        unit: (p.unit as string) || null,
         unitPrice: (p.unitPrice as number) || 0,
         total: (p.total as number) || 0,
+        unitCost: (p.unitCost as number) || 0,
+        markupPercent: (p.markupPercent as number) || 0,
+        // Inventory parts are restored earlier in this transaction with their
+        // ids preserved, so the stock link survives a restore verbatim.
+        inventoryPartId: (p.inventoryPartId as string) || null,
         serviceRecordId: sr.id as string,
       })),
     })
@@ -499,7 +505,9 @@ export async function POST(request: NextRequest) {
               category: (p.category as string) || null,
               quantity: (p.quantity as number) || 0,
               minQuantity: (p.minQuantity as number) || 0,
+              unit: (p.unit as string) || null,
               unitCost: (p.unitCost as number) || 0,
+              sellPrice: (p.sellPrice as number) || 0,
               supplier: (p.supplier as string) || null,
               supplierPhone: (p.supplierPhone as string) || null,
               supplierEmail: (p.supplierEmail as string) || null,
@@ -769,8 +777,15 @@ export async function POST(request: NextRequest) {
                 partNumber: (p.partNumber as string) || null,
                 name: p.name as string,
                 quantity: (p.quantity as number) || 1,
+                unit: (p.unit as string) || null,
                 unitPrice: (p.unitPrice as number) || 0,
                 total: (p.total as number) || 0,
+                unitCost: (p.unitCost as number) || 0,
+                markupPercent: (p.markupPercent as number) || 0,
+                excluded: (p.excluded as boolean) || false,
+                // Same-transaction inventory restore keeps ids, so the link
+                // carried into ServicePart on conversion stays intact.
+                inventoryPartId: (p.inventoryPartId as string) || null,
                 quoteId: q.id as string,
               })),
             })

+ 91 - 82
src/app/api/protected/vehicles/[id]/service-history-pdf/route.ts

@@ -1,43 +1,40 @@
-import { NextResponse } from "next/server";
-import { renderToBuffer } from "@react-pdf/renderer";
-import "@/features/vehicles/Components/invoice-pdf/fonts";
-import { cookies } from "next/headers";
-import { getAuthContext } from "@/lib/get-auth-context";
-import { db } from "@/lib/db";
-import { ServiceHistoryPDF } from "@/features/vehicles/Components/service-history-pdf/ServiceHistoryPDF";
-import React from "react";
-import { readFile } from "fs/promises";
-import { resolveUploadPath } from "@/lib/resolve-upload-path";
-import { getFeatures } from "@/lib/features";
-import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
-
-export async function GET(
-  _request: Request,
-  { params }: { params: Promise<{ id: string }> }
-) {
+import { NextResponse } from 'next/server'
+import { renderToBuffer } from '@react-pdf/renderer'
+import '@/features/vehicles/Components/invoice-pdf/fonts'
+import { cookies } from 'next/headers'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { db } from '@/lib/db'
+import { ServiceHistoryPDF } from '@/features/vehicles/Components/service-history-pdf/ServiceHistoryPDF'
+import React from 'react'
+import { readFile } from 'fs/promises'
+import { resolveUploadPath } from '@/lib/resolve-upload-path'
+import { getFeatures } from '@/lib/features'
+import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
+
+export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
   try {
-    const ctx = await getAuthContext();
+    const ctx = await getAuthContext()
 
     if (!ctx) {
-      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
     }
 
     // Load locale-based PDF translations
-    const cookieStore = await cookies();
-    const locale = cookieStore.get("locale")?.value || "en";
-    let pdfMessages: Record<string, Record<string, string>>;
+    const cookieStore = await cookies()
+    const locale = cookieStore.get('locale')?.value || 'en'
+    let pdfMessages: Record<string, Record<string, string>>
     try {
-      pdfMessages = (await import(`../../../../../../../messages/${locale}/pdf.json`)).default;
+      pdfMessages = (await import(`../../../../../../../messages/${locale}/pdf.json`)).default
     } catch {
-      pdfMessages = (await import(`../../../../../../../messages/en/pdf.json`)).default;
+      pdfMessages = (await import(`../../../../../../../messages/en/pdf.json`)).default
     }
     const labels = {
       ...pdfMessages.invoice,
       ...pdfMessages.common,
       ...(pdfMessages.serviceHistory || {}),
-    };
+    }
 
-    const { id } = await params;
+    const { id } = await params
 
     const [vehicle, settings, org] = await Promise.all([
       db.vehicle.findFirst({
@@ -67,16 +64,16 @@ export async function GET(
         where: { id: ctx.organizationId },
         select: { name: true },
       }),
-    ]);
+    ])
 
     if (!vehicle) {
-      return NextResponse.json({ error: "Vehicle not found" }, { status: 404 });
+      return NextResponse.json({ error: 'Vehicle not found' }, { status: 404 })
     }
 
     // Fetch all service records for this vehicle, ordered by date descending
     const records = await db.serviceRecord.findMany({
       where: { vehicleId: id },
-      orderBy: { serviceDate: "desc" },
+      orderBy: { serviceDate: 'desc' },
       select: {
         id: true,
         title: true,
@@ -89,77 +86,92 @@ export async function GET(
         cost: true,
         totalAmount: true,
         partItems: {
-          select: { name: true, quantity: true, unitPrice: true, total: true },
+          select: { name: true, quantity: true, unit: true, unitPrice: true, total: true },
         },
         laborItems: {
           select: { description: true, hours: true, rate: true, total: true },
         },
       },
-    });
+    })
 
     // Build settings map
-    const settingsMap: Record<string, string> = {};
-    for (const s of settings) settingsMap[s.key] = s.value;
+    const settingsMap: Record<string, string> = {}
+    for (const s of settings) settingsMap[s.key] = s.value
 
     // Override labels for marine service type
-    const serviceType = settingsMap["workshop.serviceType"] || "automotive";
-    if (serviceType === "marine") {
-      if (pdfMessages.invoice.mileageMarine) labels.mileage = pdfMessages.invoice.mileageMarine;
-      if (pdfMessages.invoice.vinMarine) labels.vin = pdfMessages.invoice.vinMarine;
-      if (pdfMessages.invoice.plateMarine) labels.plate = pdfMessages.invoice.plateMarine;
-      if (pdfMessages.invoice.vehicleMarine) labels.vehicle = pdfMessages.invoice.vehicleMarine;
-      labels.km = "hrs";
-      labels.mi = "hrs";
-      labels.mileageCol = labels.mileageColMarine || "Engine Hrs";
+    const serviceType = settingsMap['workshop.serviceType'] || 'automotive'
+    if (serviceType === 'marine') {
+      if (pdfMessages.invoice.mileageMarine) labels.mileage = pdfMessages.invoice.mileageMarine
+      if (pdfMessages.invoice.vinMarine) labels.vin = pdfMessages.invoice.vinMarine
+      if (pdfMessages.invoice.plateMarine) labels.plate = pdfMessages.invoice.plateMarine
+      if (pdfMessages.invoice.vehicleMarine) labels.vehicle = pdfMessages.invoice.vehicleMarine
+      labels.km = 'hrs'
+      labels.mi = 'hrs'
+      labels.mileageCol = labels.mileageColMarine || 'Engine Hrs'
     }
 
     // Load company logo as base64 data URI
-    let logoDataUri: string | undefined;
-    const logoPath = settingsMap["workshop.logo"];
+    let logoDataUri: string | undefined
+    const logoPath = settingsMap['workshop.logo']
     if (logoPath) {
       try {
-        const fullPath = resolveUploadPath(logoPath);
-        const logoBuffer = await readFile(fullPath);
-        const ext = logoPath.split(".").pop()?.toLowerCase() || "png";
-        const mimeMap: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", svg: "image/svg+xml" };
-        const mime = mimeMap[ext] || "image/png";
-        logoDataUri = `data:${mime};base64,${logoBuffer.toString("base64")}`;
+        const fullPath = resolveUploadPath(logoPath)
+        const logoBuffer = await readFile(fullPath)
+        const ext = logoPath.split('.').pop()?.toLowerCase() || 'png'
+        const mimeMap: Record<string, string> = {
+          png: 'image/png',
+          jpg: 'image/jpeg',
+          jpeg: 'image/jpeg',
+          webp: 'image/webp',
+          svg: 'image/svg+xml',
+        }
+        const mime = mimeMap[ext] || 'image/png'
+        logoDataUri = `data:${mime};base64,${logoBuffer.toString('base64')}`
       } catch {
         // Logo file not found, skip
       }
     }
 
     const invoiceSettings = {
-      currencyCode: settingsMap["workshop.currencyCode"] || "USD",
-      currencyFormat: (settingsMap["workshop.currencyFormat"] === "code" ? "code" : "symbol") as "symbol" | "code",
-      unitSystem: settingsMap["workshop.unitSystem"] || "imperial",
-      dateFormat: settingsMap["workshop.dateFormat"] || undefined,
-      timezone: settingsMap["workshop.timezone"] || undefined,
-    };
+      currencyCode: settingsMap['workshop.currencyCode'] || 'USD',
+      currencyFormat: (settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol') as
+        | 'symbol'
+        | 'code',
+      unitSystem: settingsMap['workshop.unitSystem'] || 'imperial',
+      dateFormat: settingsMap['workshop.dateFormat'] || undefined,
+      timezone: settingsMap['workshop.timezone'] || undefined,
+    }
 
     const templateConfig = {
-      primaryColor: settingsMap["invoice.primaryColor"] || settingsMap["invoice.template.primaryColor"] || "#d97706",
-      fontFamily: settingsMap["invoice.fontFamily"] || settingsMap["invoice.template.fontFamily"] || "Helvetica",
-      showLogo: (settingsMap["invoice.showLogo"] ?? settingsMap["invoice.template.showLogo"]) !== "false",
-      showCompanyName: settingsMap["invoice.showCompanyName"] !== "false",
-      logoSize: Number(settingsMap["invoice.logoSize"]) || 100,
-    };
+      primaryColor:
+        settingsMap['invoice.primaryColor'] ||
+        settingsMap['invoice.template.primaryColor'] ||
+        '#d97706',
+      fontFamily:
+        settingsMap['invoice.fontFamily'] ||
+        settingsMap['invoice.template.fontFamily'] ||
+        'Helvetica',
+      showLogo:
+        (settingsMap['invoice.showLogo'] ?? settingsMap['invoice.template.showLogo']) !== 'false',
+      showCompanyName: settingsMap['invoice.showCompanyName'] !== 'false',
+      logoSize: Number(settingsMap['invoice.logoSize']) || 100,
+    }
 
     // Check if Torqvoice branding should be shown
-    const features = await getFeatures(ctx.organizationId);
-    let torqvoiceLogoDataUri: string | undefined;
+    const features = await getFeatures(ctx.organizationId)
+    let torqvoiceLogoDataUri: string | undefined
     if (!features.brandingRemoved) {
-      torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri();
+      torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri()
     }
 
     const element = React.createElement(ServiceHistoryPDF, {
       vehicle,
       records,
       workshop: {
-        name: org?.name || "",
-        address: settingsMap["workshop.address"] || "",
-        phone: settingsMap["workshop.phone"] || "",
-        email: settingsMap["workshop.email"] || "",
+        name: org?.name || '',
+        address: settingsMap['workshop.address'] || '',
+        phone: settingsMap['workshop.phone'] || '',
+        email: settingsMap['workshop.email'] || '',
       },
       invoiceSettings,
       logoDataUri,
@@ -167,28 +179,25 @@ export async function GET(
       torqvoiceLogoDataUri,
       labels,
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    }) as any;
-    const pdfBuffer = await renderToBuffer(element);
+    }) as any
+    const pdfBuffer = await renderToBuffer(element)
 
-    const vehicleName = `${vehicle.year} ${vehicle.make} ${vehicle.model}`;
-    const fileName = `Service-History-${vehicleName.replace(/\s+/g, "-")}`;
+    const vehicleName = `${vehicle.year} ${vehicle.make} ${vehicle.model}`
+    const fileName = `Service-History-${vehicleName.replace(/\s+/g, '-')}`
 
     const finalBuffer = pdfBuffer.buffer.slice(
       pdfBuffer.byteOffset,
       pdfBuffer.byteOffset + pdfBuffer.byteLength
-    ) as ArrayBuffer;
+    ) as ArrayBuffer
 
     return new NextResponse(finalBuffer, {
       headers: {
-        "Content-Type": "application/pdf",
-        "Content-Disposition": `attachment; filename="${fileName}.pdf"`,
+        'Content-Type': 'application/pdf',
+        'Content-Disposition': `attachment; filename="${fileName}.pdf"`,
       },
-    });
+    })
   } catch (error) {
-    console.error("[Service History PDF] Error:", error);
-    return NextResponse.json(
-      { error: "Failed to generate PDF" },
-      { status: 500 }
-    );
+    console.error('[Service History PDF] Error:', error)
+    return NextResponse.json({ error: 'Failed to generate PDF' }, { status: 500 })
   }
 }

+ 143 - 147
src/features/ai/tools/workshop-tools.ts

@@ -1,121 +1,121 @@
-import "server-only";
-import { db } from "@/lib/db";
-import type OpenAI from "openai";
-import { Prisma } from "@/generated/prisma/client";
+import 'server-only'
+import { db } from '@/lib/db'
+import type OpenAI from 'openai'
+import { Prisma } from '@/generated/prisma/client'
 
-const MAX_ROWS = 100;
+const MAX_ROWS = 100
 
 /**
  * Single tool: AI generates a SQL query, we validate and execute it.
  */
 export const workshopTools: OpenAI.ChatCompletionTool[] = [
   {
-    type: "function",
+    type: 'function',
     function: {
-      name: "run_sql_query",
+      name: 'run_sql_query',
       description:
-        "Execute a read-only SQL SELECT query against the workshop database. The query MUST be a SELECT statement. You do NOT need to filter by organization — that is enforced automatically. Always use the actual PostgreSQL table names (snake_case with @@map names). Limit results to 100 rows max.",
+        'Execute a read-only SQL SELECT query against the workshop database. The query MUST be a SELECT statement. You do NOT need to filter by organization — that is enforced automatically. Always use the actual PostgreSQL table names (snake_case with @@map names). Limit results to 100 rows max.',
       parameters: {
-        type: "object",
+        type: 'object',
         properties: {
           sql: {
-            type: "string",
-            description: "A PostgreSQL SELECT query. Must start with SELECT.",
+            type: 'string',
+            description: 'A PostgreSQL SELECT query. Must start with SELECT.',
           },
         },
-        required: ["sql"],
+        required: ['sql'],
       },
     },
   },
-];
+]
 
 // ─── Blocked keywords (mutation / DDL / admin) ─────────────────────────────
 
 const BLOCKED_KEYWORDS = [
-  "INSERT",
-  "UPDATE",
-  "DELETE",
-  "DROP",
-  "ALTER",
-  "TRUNCATE",
-  "CREATE",
-  "REPLACE",
-  "UPSERT",
-  "MERGE",
-  "GRANT",
-  "REVOKE",
-  "EXEC",
-  "EXECUTE",
-  "CALL",
-  "COPY",
-  "VACUUM",
-  "REINDEX",
-  "CLUSTER",
-  "COMMENT",
-  "LOCK",
-  "NOTIFY",
-  "LISTEN",
-  "PREPARE",
-  "DEALLOCATE",
-  "INTO",
-  "SET",
-  "RESET",
-  "DISCARD",
-  "LOAD",
-  "SECURITY",
-  "REASSIGN",
-  "REFRESH",
-] as const;
+  'INSERT',
+  'UPDATE',
+  'DELETE',
+  'DROP',
+  'ALTER',
+  'TRUNCATE',
+  'CREATE',
+  'REPLACE',
+  'UPSERT',
+  'MERGE',
+  'GRANT',
+  'REVOKE',
+  'EXEC',
+  'EXECUTE',
+  'CALL',
+  'COPY',
+  'VACUUM',
+  'REINDEX',
+  'CLUSTER',
+  'COMMENT',
+  'LOCK',
+  'NOTIFY',
+  'LISTEN',
+  'PREPARE',
+  'DEALLOCATE',
+  'INTO',
+  'SET',
+  'RESET',
+  'DISCARD',
+  'LOAD',
+  'SECURITY',
+  'REASSIGN',
+  'REFRESH',
+] as const
 
 // Tables the AI is allowed to query (whitelist approach — everything else is blocked)
 const ALLOWED_TABLES = [
-  "vehicles",
-  "service_records",
-  "service_parts",
-  "service_labor",
-  "service_attachments",
-  "payments",
-  "customers",
-  "reminders",
-  "quotes",
-  "quote_parts",
-  "quote_labor",
-  "quote_attachments",
-  "inventory_parts",
-  "notes",
-  "fuel_logs",
-  "technicians",
-  "inspections",
-  "inspection_items",
-  "recurring_invoices",
-  "recurring_parts",
-  "recurring_labor",
-  "notifications",
-  "sms_messages",
-  "inspection_quote_requests",
-] as const;
+  'vehicles',
+  'service_records',
+  'service_parts',
+  'service_labor',
+  'service_attachments',
+  'payments',
+  'customers',
+  'reminders',
+  'quotes',
+  'quote_parts',
+  'quote_labor',
+  'quote_attachments',
+  'inventory_parts',
+  'notes',
+  'fuel_logs',
+  'technicians',
+  'inspections',
+  'inspection_items',
+  'recurring_invoices',
+  'recurring_parts',
+  'recurring_labor',
+  'notifications',
+  'sms_messages',
+  'inspection_quote_requests',
+] as const
 
 // ─── Validation ─────────────────────────────────────────────────────────────
 
 export function validateSql(sql: string): { valid: boolean; error?: string } {
-  const trimmed = sql.trim();
+  const trimmed = sql.trim()
 
   // Must start with SELECT (case-insensitive)
   if (!/^SELECT\b/i.test(trimmed)) {
-    return { valid: false, error: "Only SELECT queries are allowed." };
+    return { valid: false, error: 'Only SELECT queries are allowed.' }
   }
 
   // Must not contain semicolons (prevent multi-statement injection)
-  if (trimmed.includes(";")) {
-    return { valid: false, error: "Multiple statements are not allowed." };
+  if (trimmed.includes(';')) {
+    return { valid: false, error: 'Multiple statements are not allowed.' }
   }
 
   // Block SQL comments
   if (/--/.test(trimmed)) {
-    return { valid: false, error: "SQL comments are not allowed." };
+    return { valid: false, error: 'SQL comments are not allowed.' }
   }
   if (/\/\*/.test(trimmed)) {
-    return { valid: false, error: "SQL block comments are not allowed." };
+    return { valid: false, error: 'SQL block comments are not allowed.' }
   }
 
   // Block ANY schema-qualified table references (schema.table or "schema"."table")
@@ -123,49 +123,55 @@ export function validateSql(sql: string): { valid: boolean; error?: string } {
   if (/\b[a-zA-Z_][a-zA-Z0-9_]*\s*\.\s*"?[a-zA-Z_]/i.test(trimmed)) {
     // Allow column references like t.id or vehicles.id (single letter or allowed table aliases)
     // but block schema references by checking if left side looks like a schema name
-    const schemaPattern = /\b(public|pg_temp|pg_catalog|information_schema)\s*\./i;
+    const schemaPattern = /\b(public|pg_temp|pg_catalog|information_schema)\s*\./i
     if (schemaPattern.test(trimmed)) {
-      return { valid: false, error: "Schema-qualified table names are not allowed." };
+      return { valid: false, error: 'Schema-qualified table names are not allowed.' }
     }
     // Also block "quoted"."qualified" patterns
     if (/"[^"]+"\s*\.\s*"[^"]+"/.test(trimmed)) {
-      return { valid: false, error: "Schema-qualified table names are not allowed." };
+      return { valid: false, error: 'Schema-qualified table names are not allowed.' }
     }
   }
 
   // Block information_schema / pg_catalog / pg_temp access
-  if (/\b(information_schema|pg_catalog|pg_temp|pg_tables|pg_views|pg_roles|pg_user)\b/i.test(trimmed)) {
-    return { valid: false, error: "System catalog access is not allowed." };
+  if (
+    /\b(information_schema|pg_catalog|pg_temp|pg_tables|pg_views|pg_roles|pg_user)\b/i.test(trimmed)
+  ) {
+    return { valid: false, error: 'System catalog access is not allowed.' }
   }
 
   // Block Postgres function calls that could leak data or cause side effects
-  if (/\b(pg_read_file|pg_ls_dir|lo_import|lo_export|dblink|query_to_xml|current_setting|set_config)\b/i.test(trimmed)) {
-    return { valid: false, error: "Unsafe function call is not allowed." };
+  if (
+    /\b(pg_read_file|pg_ls_dir|lo_import|lo_export|dblink|query_to_xml|current_setting|set_config)\b/i.test(
+      trimmed
+    )
+  ) {
+    return { valid: false, error: 'Unsafe function call is not allowed.' }
   }
 
   // Normalize for keyword checking: collapse whitespace, uppercase
-  const normalized = trimmed.replace(/\s+/g, " ").toUpperCase();
+  const normalized = trimmed.replace(/\s+/g, ' ').toUpperCase()
 
   // Check for blocked mutation/DDL keywords
   for (const keyword of BLOCKED_KEYWORDS) {
-    const regex = new RegExp(`\\b${keyword}\\b`);
+    const regex = new RegExp(`\\b${keyword}\\b`)
     if (regex.test(normalized)) {
-      return { valid: false, error: `Forbidden keyword: ${keyword}` };
+      return { valid: false, error: `Forbidden keyword: ${keyword}` }
     }
   }
 
   // Whitelist: extract all table-like identifiers from FROM / JOIN clauses
   // and verify they're in the allowed list
-  const tableRefPattern = /\b(?:FROM|JOIN)\s+"?([a-z_][a-z0-9_]*)"?/gi;
-  let match;
+  const tableRefPattern = /\b(?:FROM|JOIN)\s+"?([a-z_][a-z0-9_]*)"?/gi
+  let match
   while ((match = tableRefPattern.exec(trimmed)) !== null) {
-    const tableName = match[1].toLowerCase();
+    const tableName = match[1].toLowerCase()
     if (!ALLOWED_TABLES.includes(tableName as (typeof ALLOWED_TABLES)[number])) {
-      return { valid: false, error: `Access to table "${tableName}" is not allowed.` };
+      return { valid: false, error: `Access to table "${tableName}" is not allowed.` }
     }
   }
 
-  return { valid: true };
+  return { valid: true }
 }
 
 // ─── Org-scoped execution ───────────────────────────────────────────────────
@@ -186,34 +192,34 @@ export function validateSql(sql: string): { valid: boolean; error?: string } {
 async function executeOrgScopedQuery(sql: string, orgId: string): Promise<unknown[]> {
   // Validate orgId is a safe CUID (alphanumeric only) — defense-in-depth
   if (!/^[a-zA-Z0-9_-]+$/.test(orgId)) {
-    throw new Error("Invalid organization ID");
+    throw new Error('Invalid organization ID')
   }
 
   return db.$transaction(async (tx) => {
     // 1. Set search_path so pg_temp (temp views) takes priority over public
-    await tx.$executeRawUnsafe("SET LOCAL search_path TO pg_temp, public");
+    await tx.$executeRawUnsafe('SET LOCAL search_path TO pg_temp, public')
 
     // 2. Create temp views BEFORE setting read-only (CREATE VIEW requires write)
     //    These views are the security boundary — they enforce org isolation.
     const orgDirectTables = [
-      "vehicles",
-      "customers",
-      "quotes",
-      "inventory_parts",
-      "notifications",
-      "sms_messages",
-      "technicians",
-      "inspections",
-      "inspection_quote_requests",
+      'vehicles',
+      'customers',
+      'quotes',
+      'inventory_parts',
+      'notifications',
+      'sms_messages',
+      'technicians',
+      'inspections',
+      'inspection_quote_requests',
       // Service records carry their own organizationId (counter sales have no
       // vehicle, so scoping through vehicles would hide them).
-      "service_records",
-    ];
+      'service_records',
+    ]
 
     for (const table of orgDirectTables) {
       await tx.$executeRaw`${Prisma.raw(
         `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT * FROM "public"."${table}" WHERE "organizationId" = '${orgId}'`
-      )}`;
+      )}`
     }
 
     // 4. Create temp views for child tables scoped through their parent
@@ -221,59 +227,49 @@ async function executeOrgScopedQuery(sql: string, orgId: string): Promise<unknow
     //    the already-scoped parent view.
 
     // Children of vehicles (via vehicleId → vehicles.id)
-    const vehicleChildren = [
-      "notes",
-      "fuel_logs",
-      "reminders",
-      "recurring_invoices",
-    ];
+    const vehicleChildren = ['notes', 'fuel_logs', 'reminders', 'recurring_invoices']
     for (const table of vehicleChildren) {
       await tx.$executeRaw`${Prisma.raw(
         `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN "vehicles" v ON t."vehicleId" = v.id`
-      )}`;
+      )}`
     }
 
     // Children of service_records (via serviceRecordId → service_records.id)
-    const serviceChildren = [
-      "service_parts",
-      "service_labor",
-      "service_attachments",
-      "payments",
-    ];
+    const serviceChildren = ['service_parts', 'service_labor', 'service_attachments', 'payments']
     for (const table of serviceChildren) {
       await tx.$executeRaw`${Prisma.raw(
         `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN "service_records" sr ON t."serviceRecordId" = sr.id`
-      )}`;
+      )}`
     }
 
     // Children of quotes (via quoteId → quotes.id)
-    const quoteChildren = ["quote_parts", "quote_labor", "quote_attachments"];
+    const quoteChildren = ['quote_parts', 'quote_labor', 'quote_attachments']
     for (const table of quoteChildren) {
       await tx.$executeRaw`${Prisma.raw(
         `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN "quotes" q ON t."quoteId" = q.id`
-      )}`;
+      )}`
     }
 
     // Children of recurring_invoices
-    const recurringChildren = ["recurring_parts", "recurring_labor"];
+    const recurringChildren = ['recurring_parts', 'recurring_labor']
     for (const table of recurringChildren) {
       await tx.$executeRaw`${Prisma.raw(
         `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN "recurring_invoices" ri ON t."recurringInvoiceId" = ri.id`
-      )}`;
+      )}`
     }
 
     // Children of inspections (via inspectionId → inspections.id)
     await tx.$executeRaw`${Prisma.raw(
       `CREATE OR REPLACE TEMPORARY VIEW "inspection_items" AS SELECT t.* FROM "public"."inspection_items" t INNER JOIN "inspections" i ON t."inspectionId" = i.id`
-    )}`;
+    )}`
 
     // 5. NOW set transaction read-only — the AI query cannot mutate anything
-    await tx.$executeRawUnsafe("SET TRANSACTION READ ONLY");
+    await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY')
 
     // 6. Execute the AI's query against the temp views
-    const rows = await tx.$queryRawUnsafe(sql);
-    return Array.isArray(rows) ? rows.slice(0, MAX_ROWS) : [];
-  });
+    const rows = await tx.$queryRawUnsafe(sql)
+    return Array.isArray(rows) ? rows.slice(0, MAX_ROWS) : []
+  })
 }
 
 // ─── Tool execution ─────────────────────────────────────────────────────────
@@ -281,34 +277,34 @@ async function executeOrgScopedQuery(sql: string, orgId: string): Promise<unknow
 export async function executeTool(
   name: string,
   args: Record<string, unknown>,
-  organizationId: string,
+  organizationId: string
 ): Promise<string> {
-  if (name !== "run_sql_query") {
-    return JSON.stringify({ error: `Unknown tool: ${name}` });
+  if (name !== 'run_sql_query') {
+    return JSON.stringify({ error: `Unknown tool: ${name}` })
   }
 
-  const sql = (args.sql as string) || "";
+  const sql = (args.sql as string) || ''
 
   // Validate
-  const validation = validateSql(sql);
+  const validation = validateSql(sql)
   if (!validation.valid) {
-    return JSON.stringify({ error: validation.error });
+    return JSON.stringify({ error: validation.error })
   }
 
   try {
     // Add LIMIT if missing
-    const hasLimit = /\bLIMIT\b/i.test(sql);
-    const limitedSql = hasLimit ? sql : `${sql} LIMIT ${MAX_ROWS}`;
+    const hasLimit = /\bLIMIT\b/i.test(sql)
+    const limitedSql = hasLimit ? sql : `${sql} LIMIT ${MAX_ROWS}`
 
-    const rows = await executeOrgScopedQuery(limitedSql, organizationId);
+    const rows = await executeOrgScopedQuery(limitedSql, organizationId)
 
     return JSON.stringify(rows, (_key, value) =>
-      typeof value === "bigint" ? Number(value) : value,
-    );
+      typeof value === 'bigint' ? Number(value) : value
+    )
   } catch (err) {
     return JSON.stringify({
-      error: err instanceof Error ? err.message : "Query execution failed",
-    });
+      error: err instanceof Error ? err.message : 'Query execution failed',
+    })
   }
 }
 
@@ -321,7 +317,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, "unitPrice", total, "serviceRecordId")
+service_parts (id, "partNumber", name, quantity, unit, "unitPrice", total, "serviceRecordId")
 
 service_labor (id, description, hours, rate, total, "serviceRecordId")
 
@@ -333,11 +329,11 @@ 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, "unitPrice", total, excluded, "quoteId")
+quote_parts (id, "partNumber", name, quantity, unit, "unitPrice", total, excluded, "quoteId")
 
 quote_labor (id, description, hours, rate, total, excluded, "quoteId")
 
-inventory_parts (id, "partNumber", name, description, category, quantity, "minQuantity", "unitCost", "sellPrice", supplier, "supplierPhone", "supplierEmail", "supplierUrl", location, "isArchived", "createdAt", "updatedAt")
+inventory_parts (id, "partNumber", name, description, category, quantity, "minQuantity", unit, "unitCost", "sellPrice", supplier, "supplierPhone", "supplierEmail", "supplierUrl", location, "isArchived", "createdAt", "updatedAt")
 
 notes (id, title, content, "isPinned", "createdAt", "updatedAt", "vehicleId")
 
@@ -401,4 +397,4 @@ Always include id columns in your queries — for the primary entity AND any rel
 When a query involves JOINs, make every related entity clickable. For example, when showing invoices with vehicle and customer info:
 | [Oil Change](/vehicles/abc123/service/def456) | [Toyota Corolla](/vehicles/abc123) | [John Smith](/customers/cust789) | $150 |
 Always JOIN to vehicles and customers when relevant so you can include their ids and link to them.
-`.trim();
+`.trim()

Разница между файлами не показана из-за своего большого размера
+ 422 - 340
src/features/billing/Components/RecurringInvoicesClient.tsx


+ 13 - 13
src/features/billing/Schema/recurringInvoiceSchema.ts

@@ -1,37 +1,37 @@
-import { z } from "zod";
+import { z } from 'zod'
 
 const recurringPartSchema = z.object({
   name: z.string().min(1),
   partNumber: z.string().optional(),
-  quantity: z.number().int().min(1).default(1),
+  quantity: z.number().min(0).default(1),
   unitPrice: z.number().min(0).default(0),
-});
+})
 
 const recurringLaborSchema = z.object({
   description: z.string().min(1),
   hours: z.number().min(0).default(0),
   rate: z.number().min(0).default(0),
-});
+})
 
 export const createRecurringInvoiceSchema = z.object({
-  title: z.string().min(1, "Title is required"),
+  title: z.string().min(1, 'Title is required'),
   description: z.string().optional(),
-  frequency: z.enum(["weekly", "biweekly", "monthly", "quarterly", "yearly"]),
-  nextRunDate: z.string().min(1, "Start date is required"),
+  frequency: z.enum(['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly']),
+  nextRunDate: z.string().min(1, 'Start date is required'),
   endDate: z.string().optional(),
-  vehicleId: z.string().min(1, "Vehicle is required"),
-  type: z.string().default("maintenance"),
+  vehicleId: z.string().min(1, 'Vehicle is required'),
+  type: z.string().default('maintenance'),
   cost: z.number().min(0).default(0),
   taxRate: z.number().min(0).max(100).default(0),
   taxInclusive: z.boolean().default(false),
   invoiceNotes: z.string().optional(),
   templateParts: z.array(recurringPartSchema).default([]),
   templateLabor: z.array(recurringLaborSchema).default([]),
-});
+})
 
 export const updateRecurringInvoiceSchema = createRecurringInvoiceSchema.partial().extend({
   id: z.string().min(1),
-});
+})
 
-export type CreateRecurringInvoiceInput = z.infer<typeof createRecurringInvoiceSchema>;
-export type UpdateRecurringInvoiceInput = z.infer<typeof updateRecurringInvoiceSchema>;
+export type CreateRecurringInvoiceInput = z.infer<typeof createRecurringInvoiceSchema>
+export type UpdateRecurringInvoiceInput = z.infer<typeof updateRecurringInvoiceSchema>

+ 1 - 0
src/features/dashboard/custom-cards/registry.ts

@@ -98,6 +98,7 @@ export const CARD_ENTITY_FIELDS: Record<CardEntity, CardFieldDef[]> = {
     f('category', 'text'),
     f('quantity', 'number'),
     f('minQuantity', 'number'),
+    f('unit', 'text'),
     f('unitCost', 'number'),
     f('sellPrice', 'number'),
     f('supplier', 'text'),

+ 2 - 0
src/features/dashboard/custom-cards/server-registry.ts

@@ -293,6 +293,7 @@ export async function runEntityQuery(
           category: true,
           quantity: true,
           minQuantity: true,
+          unit: true,
           unitCost: true,
           sellPrice: true,
           supplier: true,
@@ -309,6 +310,7 @@ export async function runEntityQuery(
           category: r.category,
           quantity: r.quantity,
           minQuantity: r.minQuantity,
+          unit: r.unit,
           unitCost: r.unitCost,
           sellPrice: r.sellPrice,
           supplier: r.supplier,

+ 38 - 39
src/features/inventory/Actions/aiAnalyzePartImage.ts

@@ -1,66 +1,65 @@
-"use server";
+'use server'
 
-import { withAuth } from "@/lib/with-auth";
-import { PermissionAction, PermissionSubject } from "@/lib/permissions";
-import { visionCompletion } from "@/lib/ai";
-import { getLocale } from "next-intl/server";
-import { localeNames, type Locale } from "@/i18n/config";
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { visionCompletion } from '@/lib/ai'
+import { getLocale } from 'next-intl/server'
+import { localeNames, type Locale } from '@/i18n/config'
 
 export interface PartAnalysisResult {
-  name?: string;
-  partNumber?: string;
-  barcode?: string;
-  category?: string;
-  description?: string;
-  supplier?: string;
+  name?: string
+  partNumber?: string
+  barcode?: string
+  category?: string
+  description?: string
+  supplier?: string
+  unit?: string
 }
 
 export async function aiAnalyzePartImage(imageDataUris: string | string[]) {
   return withAuth(
     async ({ organizationId }) => {
-      const locale = (await getLocale()) as Locale;
-      const langName = localeNames[locale] || "English";
+      const locale = (await getLocale()) as Locale
+      const langName = localeNames[locale] || 'English'
 
-      const uris = Array.isArray(imageDataUris) ? imageDataUris : [imageDataUris];
-      const imageCount = uris.length;
+      const uris = Array.isArray(imageDataUris) ? imageDataUris : [imageDataUris]
+      const imageCount = uris.length
 
-      const langInstruction = locale !== "en"
-        ? `IMPORTANT: The user's language is ${langName}. You MUST translate the "name", "category", and "description" fields into ${langName}, regardless of what language appears on the packaging or part. For example, if the packaging is in German but the user's language is Norwegian, translate to Norwegian. Keep "partNumber", "barcode", and "supplier" in their original form — do NOT translate those.`
-        : "";
+      const langInstruction =
+        locale !== 'en'
+          ? `IMPORTANT: The user's language is ${langName}. You MUST translate the "name", "category", and "description" fields into ${langName}, regardless of what language appears on the packaging or part. For example, if the packaging is in German but the user's language is Norwegian, translate to Norwegian. Keep "partNumber", "barcode", and "supplier" in their original form — do NOT translate those.`
+          : ''
 
-      const systemPrompt = `You are an expert automotive parts identifier. Analyze the ${imageCount > 1 ? `${imageCount} images` : "image"} of a part or its packaging and extract as much information as possible. ${imageCount > 1 ? "The images show the same part from different angles. Combine information from all images." : ""}
+      const systemPrompt = `You are an expert automotive parts identifier. Analyze the ${imageCount > 1 ? `${imageCount} images` : 'image'} of a part or its packaging and extract as much information as possible. ${imageCount > 1 ? 'The images show the same part from different angles. Combine information from all images.' : ''}
 
 ${langInstruction}
 
 Return ONLY valid JSON with these fields (omit fields you cannot determine):
 {
-  "name": "part name${locale !== "en" ? ` (in ${langName})` : ""}",
+  "name": "part name${locale !== 'en' ? ` (in ${langName})` : ''}",
   "partNumber": "manufacturer part number",
   "barcode": "barcode/UPC/EAN number if visible",
-  "category": "part category${locale !== "en" ? ` (in ${langName})` : ""} (e.g. Brakes, Filters, Engine, Electrical)",
-  "description": "brief description of the part${locale !== "en" ? ` (in ${langName})` : ""}",
-  "supplier": "manufacturer or brand name"
-}`;
+  "category": "part category${locale !== 'en' ? ` (in ${langName})` : ''} (e.g. Brakes, Filters, Engine, Electrical)",
+  "description": "brief description of the part${locale !== 'en' ? ` (in ${langName})` : ''}",
+  "supplier": "manufacturer or brand name",
+  "unit": "unit of measure for stock counting, only for fluids and consumables sold by volume or weight (e.g. l, ml, kg, g); omit for parts counted in pieces"
+}`
 
-      const userText = imageCount > 1
-        ? `Analyze these ${imageCount} images of the same automotive/repair part from different angles and extract all identifiable information.`
-        : "Analyze this automotive/repair part image and extract all identifiable information.";
+      const userText =
+        imageCount > 1
+          ? `Analyze these ${imageCount} images of the same automotive/repair part from different angles and extract all identifiable information.`
+          : 'Analyze this automotive/repair part image and extract all identifiable information.'
 
-      const raw = await visionCompletion(
-        organizationId,
-        systemPrompt,
-        userText,
-        uris,
-      );
+      const raw = await visionCompletion(organizationId, systemPrompt, userText, uris)
 
-      const cleaned = raw.replace(/^```json?\n?|\n?```$/g, "").trim();
-      const result: PartAnalysisResult = JSON.parse(cleaned);
-      return result;
+      const cleaned = raw.replace(/^```json?\n?|\n?```$/g, '').trim()
+      const result: PartAnalysisResult = JSON.parse(cleaned)
+      return result
     },
     {
       requiredPermissions: [
         { action: PermissionAction.CREATE, subject: PermissionSubject.INVENTORY },
       ],
-    },
-  );
+    }
+  )
 }

+ 446 - 341
src/features/inventory/Actions/inventoryActions.ts

@@ -1,402 +1,500 @@
-"use server";
-
-import { db } from "@/lib/db";
-import { withAuth } from "@/lib/with-auth";
-import { createInventoryPartSchema, updateInventoryPartSchema, adjustStockSchema } from "../Schema/inventorySchema";
-import { onInventoryChanged } from "../Lib/onInventoryChanged";
-import { Prisma } from "@/generated/prisma/client";
-import { z } from "zod";
-import { unlink } from "fs/promises";
-import { resolveUploadPath } from "@/lib/resolve-upload-path";
-import { PermissionAction, PermissionSubject } from "@/lib/permissions";
-import { normalizeBarcode, withBarcodeConflictMessage } from "../Lib/barcode";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+'use server'
+
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import {
+  createInventoryPartSchema,
+  updateInventoryPartSchema,
+  adjustStockSchema,
+} from '../Schema/inventorySchema'
+import { onInventoryChanged } from '../Lib/onInventoryChanged'
+import { Prisma } from '@/generated/prisma/client'
+import { z } from 'zod'
+import { unlink } from 'fs/promises'
+import { resolveUploadPath } from '@/lib/resolve-upload-path'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { normalizeBarcode, withBarcodeConflictMessage } from '../Lib/barcode'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 
 export async function getInventoryPartsPaginated(params: {
-  page?: number;
-  pageSize?: number;
-  search?: string;
-  category?: string;
-  sortBy?: string;
-  sortOrder?: "asc" | "desc";
+  page?: number
+  pageSize?: number
+  search?: string
+  category?: string
+  sortBy?: string
+  sortOrder?: 'asc' | 'desc'
   /** Restrict to parts at or below their reorder point. */
-  lowStock?: boolean;
+  lowStock?: boolean
 }) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const page = params.page || 1;
-    const pageSize = params.pageSize || 20;
-    const skip = (page - 1) * pageSize;
-    const mode = "insensitive" as Prisma.QueryMode;
-
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const where: any = { organizationId, isArchived: false };
-
-    if (params.search) {
-      const q = params.search.trim();
-      where.OR = [
-        { name: { contains: q, mode } },
-        { partNumber: { contains: q, mode } },
-        { barcode: { contains: q, mode } },
-        { description: { contains: q, mode } },
-        { supplier: { contains: q, mode } },
-        { location: { contains: q, mode } },
-      ];
-    }
-
-    if (params.category && params.category !== "all") {
-      where.category = params.category;
-    }
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const page = params.page || 1
+      const pageSize = params.pageSize || 20
+      const skip = (page - 1) * pageSize
+      const mode = 'insensitive' as Prisma.QueryMode
+
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const where: any = { organizationId, isArchived: false }
+
+      if (params.search) {
+        const q = params.search.trim()
+        where.OR = [
+          { name: { contains: q, mode } },
+          { partNumber: { contains: q, mode } },
+          { barcode: { contains: q, mode } },
+          { description: { contains: q, mode } },
+          { supplier: { contains: q, mode } },
+          { location: { contains: q, mode } },
+        ]
+      }
 
-    if (params.lowStock) {
-      // Prisma cannot compare two columns of the same row in a `where`, so the
-      // matching ids are resolved in SQL first. The low-stock set is small by
-      // definition (parts at or below their reorder point), so the extra query
-      // stays cheap and pagination/sorting below is unaffected.
-      // Same rule as isLow() in Lib/lowStockAlerts and the dashboard count:
-      // the part's own reorder point wins, else the org-wide default.
-      const thresholdRow = await db.appSetting.findFirst({
-        where: { organizationId, key: SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD },
-        select: { value: true },
-      });
-      const parsedDefault = Number(thresholdRow?.value);
-      const lowStockDefault =
-        Number.isFinite(parsedDefault) && parsedDefault > 0 ? parsedDefault : 0;
+      if (params.category && params.category !== 'all') {
+        where.category = params.category
+      }
 
-      const lowRows = await db.$queryRaw<{ id: string }[]>`
+      if (params.lowStock) {
+        // Prisma cannot compare two columns of the same row in a `where`, so the
+        // matching ids are resolved in SQL first. The low-stock set is small by
+        // definition (parts at or below their reorder point), so the extra query
+        // stays cheap and pagination/sorting below is unaffected.
+        // Same rule as isLow() in Lib/lowStockAlerts and the dashboard count:
+        // the part's own reorder point wins, else the org-wide default.
+        const thresholdRow = await db.appSetting.findFirst({
+          where: { organizationId, key: SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD },
+          select: { value: true },
+        })
+        const parsedDefault = Number(thresholdRow?.value)
+        const lowStockDefault =
+          Number.isFinite(parsedDefault) && parsedDefault > 0 ? parsedDefault : 0
+
+        const lowRows = await db.$queryRaw<{ id: string }[]>`
         SELECT "id"
         FROM "inventory_parts"
         WHERE "organizationId" = ${organizationId}
           AND "isArchived" = false
           AND COALESCE(NULLIF("minQuantity", 0), ${lowStockDefault}) > 0
           AND "quantity" <= COALESCE(NULLIF("minQuantity", 0), ${lowStockDefault})
-      `;
-      where.id = { in: lowRows.map((r) => r.id) };
-    }
+      `
+        where.id = { in: lowRows.map((r) => r.id) }
+      }
 
-    const dir = params.sortOrder || "desc";
-    const sortableColumns = ["name", "partNumber", "category", "quantity", "unitCost", "sellPrice", "supplier", "location", "updatedAt"];
-    const sortColumn = params.sortBy && sortableColumns.includes(params.sortBy) ? params.sortBy : "updatedAt";
-
-    const [parts, total] = await Promise.all([
-      db.inventoryPart.findMany({
-        where,
-        orderBy: { [sortColumn]: dir },
-        skip,
-        take: pageSize,
-        include: { gallery: { orderBy: { sortOrder: "asc" } } },
-      }),
-      db.inventoryPart.count({ where }),
-    ]);
-
-    return {
-      parts,
-      total,
-      page,
-      pageSize,
-      totalPages: Math.ceil(total / pageSize),
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.INVENTORY }] });
+      const dir = params.sortOrder || 'desc'
+      const sortableColumns = [
+        'name',
+        'partNumber',
+        'category',
+        'quantity',
+        'unitCost',
+        'sellPrice',
+        'supplier',
+        'location',
+        'updatedAt',
+      ]
+      const sortColumn =
+        params.sortBy && sortableColumns.includes(params.sortBy) ? params.sortBy : 'updatedAt'
+
+      const [parts, total] = await Promise.all([
+        db.inventoryPart.findMany({
+          where,
+          orderBy: { [sortColumn]: dir },
+          skip,
+          take: pageSize,
+          include: { gallery: { orderBy: { sortOrder: 'asc' } } },
+        }),
+        db.inventoryPart.count({ where }),
+      ])
+
+      return {
+        parts,
+        total,
+        page,
+        pageSize,
+        totalPages: Math.ceil(total / pageSize),
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
+      ],
+    }
+  )
 }
 
 export async function getInventoryPart(partId: string) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const part = await db.inventoryPart.findFirst({
-      where: { id: partId, organizationId },
-      include: { gallery: { orderBy: { sortOrder: "asc" } } },
-    });
-    if (!part) throw new Error("Part not found");
-    return part;
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.INVENTORY }] });
-}
-
-export async function createInventoryPart(input: unknown) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const data = createInventoryPartSchema.parse(input);
-    const { gallery, ...rest } = data;
-    const barcode = normalizeBarcode(rest.barcode);
-    const part = await withBarcodeConflictMessage(organizationId, barcode, () =>
-      db.inventoryPart.create({
-      data: {
-        ...rest,
-        partNumber: rest.partNumber || undefined,
-        barcode: barcode ?? undefined,
-        description: rest.description || undefined,
-        category: rest.category || undefined,
-        supplier: rest.supplier || undefined,
-        supplierPhone: rest.supplierPhone || undefined,
-        supplierEmail: rest.supplierEmail || undefined,
-        supplierUrl: rest.supplierUrl || undefined,
-        imageUrl: gallery?.[0]?.url || undefined,
-        location: rest.location || undefined,
-        userId,
-        organizationId,
-      },
-      }),
-    );
-
-    if (gallery && gallery.length > 0) {
-      await db.storedImage.createMany({
-        data: gallery.map((img, i) => ({
-          url: img.url,
-          fileName: img.fileName || null,
-          description: img.description || null,
-          sortOrder: i,
-          inventoryPartId: part.id,
-        })),
-      });
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const part = await db.inventoryPart.findFirst({
+        where: { id: partId, organizationId },
+        include: { gallery: { orderBy: { sortOrder: 'asc' } } },
+      })
+      if (!part) throw new Error('Part not found')
+      return part
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
+      ],
     }
-
-    await onInventoryChanged(organizationId);
-    return part;
-  }, {
-    requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.INVENTORY }],
-    audit: ({ result }) => ({
-      action: "inventory.create",
-      entity: "InventoryPart",
-      entityId: result.id,
-      details: { key: "inventory_create", params: { name: result.name } },
-      metadata: { partId: result.id },
-    }),
-  });
+  )
 }
 
-export async function updateInventoryPart(input: unknown) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const data = updateInventoryPartSchema.parse(input);
-    const { id, gallery: galleryData, ...updateData } = data;
-
-    // Handle gallery updates
-    if (galleryData !== undefined) {
-      // Clean up old files that are no longer in the gallery
-      const existingImages = await db.storedImage.findMany({ where: { inventoryPartId: id } });
-      const newUrls = new Set(galleryData.map(g => g.url));
-      for (const old of existingImages) {
-        if (!newUrls.has(old.url)) {
-          try { await unlink(resolveUploadPath(old.url)); } catch { /* already gone */ }
-        }
-      }
-      // Replace all gallery records
-      await db.storedImage.deleteMany({ where: { inventoryPartId: id } });
-      if (galleryData.length > 0) {
+export async function createInventoryPart(input: unknown) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const data = createInventoryPartSchema.parse(input)
+      const { gallery, ...rest } = data
+      const barcode = normalizeBarcode(rest.barcode)
+      const part = await withBarcodeConflictMessage(organizationId, barcode, () =>
+        db.inventoryPart.create({
+          data: {
+            ...rest,
+            partNumber: rest.partNumber || undefined,
+            unit: rest.unit?.trim() || undefined,
+            barcode: barcode ?? undefined,
+            description: rest.description || undefined,
+            category: rest.category || undefined,
+            supplier: rest.supplier || undefined,
+            supplierPhone: rest.supplierPhone || undefined,
+            supplierEmail: rest.supplierEmail || undefined,
+            supplierUrl: rest.supplierUrl || undefined,
+            imageUrl: gallery?.[0]?.url || undefined,
+            location: rest.location || undefined,
+            userId,
+            organizationId,
+          },
+        })
+      )
+
+      if (gallery && gallery.length > 0) {
         await db.storedImage.createMany({
-          data: galleryData.map((img, i) => ({
+          data: gallery.map((img, i) => ({
             url: img.url,
             fileName: img.fileName || null,
             description: img.description || null,
             sortOrder: i,
-            inventoryPartId: id,
+            inventoryPartId: part.id,
           })),
-        });
+        })
       }
-      // Update imageUrl for backward compat
-      await db.inventoryPart.updateMany({
-        where: { id, organizationId },
-        data: { imageUrl: galleryData[0]?.url || null },
-      });
+
+      await onInventoryChanged(organizationId)
+      return part
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.INVENTORY },
+      ],
+      audit: ({ result }) => ({
+        action: 'inventory.create',
+        entity: 'InventoryPart',
+        entityId: result.id,
+        details: { key: 'inventory_create', params: { name: result.name } },
+        metadata: { partId: result.id },
+      }),
     }
+  )
+}
 
-    const nextBarcode =
-      updateData.barcode !== undefined ? normalizeBarcode(updateData.barcode) : undefined;
-    const result = await withBarcodeConflictMessage(organizationId, nextBarcode ?? null, () =>
-      db.inventoryPart.updateMany({
-      where: { id, organizationId },
-      data: {
-        ...updateData,
-        partNumber: updateData.partNumber !== undefined ? (updateData.partNumber || null) : undefined,
-        barcode: nextBarcode,
-        description: updateData.description !== undefined ? (updateData.description || null) : undefined,
-        category: updateData.category !== undefined ? (updateData.category || null) : undefined,
-        supplier: updateData.supplier !== undefined ? (updateData.supplier || null) : undefined,
-        supplierPhone: updateData.supplierPhone !== undefined ? (updateData.supplierPhone || null) : undefined,
-        supplierEmail: updateData.supplierEmail !== undefined ? (updateData.supplierEmail || null) : undefined,
-        supplierUrl: updateData.supplierUrl !== undefined ? (updateData.supplierUrl || null) : undefined,
-        location: updateData.location !== undefined ? (updateData.location || null) : undefined,
-      },
+export async function updateInventoryPart(input: unknown) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const data = updateInventoryPartSchema.parse(input)
+      const { id, gallery: galleryData, ...updateData } = data
+
+      // Handle gallery updates
+      if (galleryData !== undefined) {
+        // Clean up old files that are no longer in the gallery
+        const existingImages = await db.storedImage.findMany({ where: { inventoryPartId: id } })
+        const newUrls = new Set(galleryData.map((g) => g.url))
+        for (const old of existingImages) {
+          if (!newUrls.has(old.url)) {
+            try {
+              await unlink(resolveUploadPath(old.url))
+            } catch {
+              /* already gone */
+            }
+          }
+        }
+        // Replace all gallery records
+        await db.storedImage.deleteMany({ where: { inventoryPartId: id } })
+        if (galleryData.length > 0) {
+          await db.storedImage.createMany({
+            data: galleryData.map((img, i) => ({
+              url: img.url,
+              fileName: img.fileName || null,
+              description: img.description || null,
+              sortOrder: i,
+              inventoryPartId: id,
+            })),
+          })
+        }
+        // Update imageUrl for backward compat
+        await db.inventoryPart.updateMany({
+          where: { id, organizationId },
+          data: { imageUrl: galleryData[0]?.url || null },
+        })
+      }
+
+      const nextBarcode =
+        updateData.barcode !== undefined ? normalizeBarcode(updateData.barcode) : undefined
+      const result = await withBarcodeConflictMessage(organizationId, nextBarcode ?? null, () =>
+        db.inventoryPart.updateMany({
+          where: { id, organizationId },
+          data: {
+            ...updateData,
+            partNumber:
+              updateData.partNumber !== undefined ? updateData.partNumber || null : undefined,
+            unit: updateData.unit !== undefined ? updateData.unit.trim() || null : undefined,
+            barcode: nextBarcode,
+            description:
+              updateData.description !== undefined ? updateData.description || null : undefined,
+            category: updateData.category !== undefined ? updateData.category || null : undefined,
+            supplier: updateData.supplier !== undefined ? updateData.supplier || null : undefined,
+            supplierPhone:
+              updateData.supplierPhone !== undefined ? updateData.supplierPhone || null : undefined,
+            supplierEmail:
+              updateData.supplierEmail !== undefined ? updateData.supplierEmail || null : undefined,
+            supplierUrl:
+              updateData.supplierUrl !== undefined ? updateData.supplierUrl || null : undefined,
+            location: updateData.location !== undefined ? updateData.location || null : undefined,
+          },
+        })
+      )
+      if (result.count === 0) throw new Error('Part not found')
+      await onInventoryChanged(organizationId)
+      return { updated: true, partId: id }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.INVENTORY },
+      ],
+      audit: ({ result }) => ({
+        action: 'inventory.update',
+        entity: 'InventoryPart',
+        entityId: result.partId,
+        details: { key: 'inventory_update', params: { id: result.partId } },
+        metadata: { partId: result.partId },
       }),
-    );
-    if (result.count === 0) throw new Error("Part not found");
-    await onInventoryChanged(organizationId);
-    return { updated: true, partId: id };
-  }, {
-    requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.INVENTORY }],
-    audit: ({ result }) => ({
-      action: "inventory.update",
-      entity: "InventoryPart",
-      entityId: result.partId,
-      details: { key: "inventory_update", params: { id: result.partId } },
-      metadata: { partId: result.partId },
-    }),
-  });
+    }
+  )
 }
 
 export async function deleteInventoryPart(partId: string) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const part = await db.inventoryPart.findFirst({
-      where: { id: partId, organizationId },
-      select: { imageUrl: true, gallery: { select: { url: true } } },
-    });
-
-    const result = await db.inventoryPart.deleteMany({
-      where: { id: partId, organizationId },
-    });
-    if (result.count === 0) throw new Error("Part not found");
-
-    if (part) {
-      const allUrls = [
-        part.imageUrl,
-        ...part.gallery.map(g => g.url),
-      ].filter(Boolean) as string[];
-      // Deduplicate URLs before deleting files
-      const uniqueUrls = [...new Set(allUrls)];
-      for (const url of uniqueUrls) {
-        try { await unlink(resolveUploadPath(url)); } catch { /* already gone */ }
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const part = await db.inventoryPart.findFirst({
+        where: { id: partId, organizationId },
+        select: { imageUrl: true, gallery: { select: { url: true } } },
+      })
+
+      const result = await db.inventoryPart.deleteMany({
+        where: { id: partId, organizationId },
+      })
+      if (result.count === 0) throw new Error('Part not found')
+
+      if (part) {
+        const allUrls = [part.imageUrl, ...part.gallery.map((g) => g.url)].filter(
+          Boolean
+        ) as string[]
+        // Deduplicate URLs before deleting files
+        const uniqueUrls = [...new Set(allUrls)]
+        for (const url of uniqueUrls) {
+          try {
+            await unlink(resolveUploadPath(url))
+          } catch {
+            /* already gone */
+          }
+        }
       }
-    }
 
-    await onInventoryChanged(organizationId);
-    return { deleted: true, partId };
-  }, {
-    requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.INVENTORY }],
-    audit: ({ result }) => ({
-      action: "inventory.delete",
-      entity: "InventoryPart",
-      entityId: result.partId,
-      details: { key: "inventory_delete", params: { id: result.partId } },
-      metadata: { partId: result.partId },
-    }),
-  });
+      await onInventoryChanged(organizationId)
+      return { deleted: true, partId }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.INVENTORY },
+      ],
+      audit: ({ result }) => ({
+        action: 'inventory.delete',
+        entity: 'InventoryPart',
+        entityId: result.partId,
+        details: { key: 'inventory_delete', params: { id: result.partId } },
+        metadata: { partId: result.partId },
+      }),
+    }
+  )
 }
 
 export async function deleteInventoryParts(partIds: string[]) {
-  return withAuth(async ({ userId, organizationId }) => {
-    if (partIds.length === 0) throw new Error("No parts selected");
-
-    // Gather image URLs before deleting
-    const parts = await db.inventoryPart.findMany({
-      where: { id: { in: partIds }, organizationId },
-      select: { imageUrl: true, gallery: { select: { url: true } } },
-    });
-
-    const result = await db.inventoryPart.deleteMany({
-      where: { id: { in: partIds }, organizationId },
-    });
-
-    // Clean up associated image files
-    const allUrls = parts.flatMap(part => [
-      part.imageUrl,
-      ...part.gallery.map(g => g.url),
-    ]).filter(Boolean) as string[];
-    const uniqueUrls = [...new Set(allUrls)];
-    for (const url of uniqueUrls) {
-      try { await unlink(resolveUploadPath(url)); } catch { /* already gone */ }
-    }
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      if (partIds.length === 0) throw new Error('No parts selected')
+
+      // Gather image URLs before deleting
+      const parts = await db.inventoryPart.findMany({
+        where: { id: { in: partIds }, organizationId },
+        select: { imageUrl: true, gallery: { select: { url: true } } },
+      })
+
+      const result = await db.inventoryPart.deleteMany({
+        where: { id: { in: partIds }, organizationId },
+      })
+
+      // Clean up associated image files
+      const allUrls = parts
+        .flatMap((part) => [part.imageUrl, ...part.gallery.map((g) => g.url)])
+        .filter(Boolean) as string[]
+      const uniqueUrls = [...new Set(allUrls)]
+      for (const url of uniqueUrls) {
+        try {
+          await unlink(resolveUploadPath(url))
+        } catch {
+          /* already gone */
+        }
+      }
 
-    await onInventoryChanged(organizationId);
-    return { deleted: result.count };
-  }, {
-    requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.INVENTORY }],
-    audit: ({ result }) => ({
-      action: "inventory.bulkDelete",
-      entity: "InventoryPart",
-      entityId: partIds.join(","),
-      details: { key: "inventory_bulkDelete", params: { count: result.deleted } },
-      metadata: { partIds, deleted: result.deleted },
-    }),
-  });
+      await onInventoryChanged(organizationId)
+      return { deleted: result.count }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.INVENTORY },
+      ],
+      audit: ({ result }) => ({
+        action: 'inventory.bulkDelete',
+        entity: 'InventoryPart',
+        entityId: partIds.join(','),
+        details: { key: 'inventory_bulkDelete', params: { count: result.deleted } },
+        metadata: { partIds, deleted: result.deleted },
+      }),
+    }
+  )
 }
 
 export async function adjustInventoryStock(input: unknown) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const { id, adjustment } = adjustStockSchema.parse(input);
-    const part = await db.inventoryPart.findFirst({
-      where: { id, organizationId },
-    });
-    if (!part) throw new Error("Part not found");
-
-    const newQuantity = part.quantity + adjustment;
-    if (newQuantity < 0) throw new Error("Insufficient stock");
-
-    await db.inventoryPart.updateMany({
-      where: { id, organizationId },
-      data: { quantity: newQuantity },
-    });
-    await onInventoryChanged(organizationId);
-    return { quantity: newQuantity };
-  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.INVENTORY }] });
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const { id, adjustment } = adjustStockSchema.parse(input)
+      const part = await db.inventoryPart.findFirst({
+        where: { id, organizationId },
+      })
+      if (!part) throw new Error('Part not found')
+
+      const newQuantity = part.quantity + adjustment
+      if (newQuantity < 0) throw new Error('Insufficient stock')
+
+      await db.inventoryPart.updateMany({
+        where: { id, organizationId },
+        data: { quantity: newQuantity },
+      })
+      await onInventoryChanged(organizationId)
+      return { quantity: newQuantity }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.INVENTORY },
+      ],
+    }
+  )
 }
 
 export async function getInventoryCategories() {
-  return withAuth(async ({ userId, organizationId }) => {
-    const results = await db.inventoryPart.findMany({
-      where: { organizationId, category: { not: null }, isArchived: false },
-      select: { category: true },
-      distinct: ["category"],
-      orderBy: { category: "asc" },
-    });
-    return results.map((r) => r.category).filter(Boolean) as string[];
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.INVENTORY }] });
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const results = await db.inventoryPart.findMany({
+        where: { organizationId, category: { not: null }, isArchived: false },
+        select: { category: true },
+        distinct: ['category'],
+        orderBy: { category: 'asc' },
+      })
+      return results.map((r) => r.category).filter(Boolean) as string[]
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
+      ],
+    }
+  )
 }
 
 export async function getInventoryPartsList() {
-  return withAuth(async ({ userId, organizationId }) => {
-    // Include out-of-stock and backordered (negative) parts too — the picker
-    // shows their stock state so a part can still be added to a work order
-    // when depleted (which then correctly drives stock negative / backorder).
-    return db.inventoryPart.findMany({
-      where: { organizationId, isArchived: false },
-      select: {
-        id: true,
-        partNumber: true,
-        barcode: true,
-        name: true,
-        description: true,
-        unitCost: true,
-        sellPrice: true,
-        quantity: true,
-        category: true,
-        gallery: { select: { id: true, url: true, sortOrder: true }, orderBy: { sortOrder: "asc" } },
-      },
-      orderBy: { name: "asc" },
-    });
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.INVENTORY }] });
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      // Include out-of-stock and backordered (negative) parts too — the picker
+      // shows their stock state so a part can still be added to a work order
+      // when depleted (which then correctly drives stock negative / backorder).
+      return db.inventoryPart.findMany({
+        where: { organizationId, isArchived: false },
+        select: {
+          id: true,
+          partNumber: true,
+          barcode: true,
+          name: true,
+          description: true,
+          unit: true,
+          unitCost: true,
+          sellPrice: true,
+          quantity: true,
+          category: true,
+          gallery: {
+            select: { id: true, url: true, sortOrder: true },
+            orderBy: { sortOrder: 'asc' },
+          },
+        },
+        orderBy: { name: 'asc' },
+      })
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
+      ],
+    }
+  )
 }
 
 const applyMarkupSchema = z.object({
-  multiplier: z.number().positive("Multiplier must be greater than 0"),
+  multiplier: z.number().positive('Multiplier must be greater than 0'),
   overrideExisting: z.boolean().default(false),
-});
+})
 
 export async function applyMarkupToAll(input: unknown) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const { multiplier, overrideExisting } = applyMarkupSchema.parse(input);
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const { multiplier, overrideExisting } = applyMarkupSchema.parse(input)
 
-    // Use raw SQL to avoid Prisma's @updatedAt auto-update which changes sort order
-    const result = await db.$executeRaw`
+      // Use raw SQL to avoid Prisma's @updatedAt auto-update which changes sort order
+      const result = await db.$executeRaw`
       UPDATE "inventory_parts"
       SET "sellPrice" = ROUND(("unitCost" * ${multiplier})::numeric, 2)
       WHERE "organizationId" = ${organizationId}
         AND "isArchived" = false
         ${overrideExisting ? Prisma.sql`` : Prisma.sql`AND ("sellPrice" = 0 OR "sellPrice" IS NULL)`}
-    `;
-
-    await onInventoryChanged(organizationId);
-    return { updated: result };
-  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.INVENTORY }] });
+    `
+
+      await onInventoryChanged(organizationId)
+      return { updated: result }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.INVENTORY },
+      ],
+    }
+  )
 }
 
 export async function deleteOrphanedUploads(fileUrls: string[]) {
   return withAuth(async ({ organizationId }) => {
     for (const url of fileUrls) {
       // Only allow deleting files belonging to this org's inventory folder
-      if (!url.includes(`/${organizationId}/inventory/`)) continue;
+      if (!url.includes(`/${organizationId}/inventory/`)) continue
       try {
-        await unlink(resolveUploadPath(url));
+        await unlink(resolveUploadPath(url))
       } catch {
         // File may already be gone — ignore
       }
     }
-    return { success: true };
-  });
+    return { success: true }
+  })
 }
 
 /**
@@ -408,16 +506,23 @@ export async function deleteOrphanedUploads(fileUrls: string[]) {
  * showing a blank table.
  */
 export async function hasAnyReorderPoint() {
-  return withAuth(async ({ organizationId }) => {
-    const setting = await db.appSetting.findFirst({
-      where: { organizationId, key: SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD },
-      select: { value: true },
-    });
-    if ((Number(setting?.value) || 0) > 0) return true;
-
-    const configured = await db.inventoryPart.count({
-      where: { organizationId, isArchived: false, minQuantity: { gt: 0 } },
-    });
-    return configured > 0;
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.INVENTORY }] });
+  return withAuth(
+    async ({ organizationId }) => {
+      const setting = await db.appSetting.findFirst({
+        where: { organizationId, key: SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD },
+        select: { value: true },
+      })
+      if ((Number(setting?.value) || 0) > 0) return true
+
+      const configured = await db.inventoryPart.count({
+        where: { organizationId, isArchived: false, minQuantity: { gt: 0 } },
+      })
+      return configured > 0
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
+      ],
+    }
+  )
 }

+ 40 - 36
src/features/inventory/Actions/lookupPartByBarcode.ts

@@ -1,41 +1,45 @@
-"use server"
+'use server'
 
-import { db } from "@/lib/db"
-import { withAuth } from "@/lib/with-auth"
-import { PermissionAction, PermissionSubject } from "@/lib/permissions"
-import { normalizeBarcode } from "../Lib/barcode"
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { normalizeBarcode } from '../Lib/barcode'
 
 export async function lookupPartByBarcode(barcode: string) {
-  return withAuth(async ({ organizationId }) => {
-    // Scanned input goes through the same normalisation as stored barcodes, so
-    // an exact match is enough. Combined with the (organizationId, barcode)
-    // unique index this resolves to at most one part — previously a
-    // case-insensitive `findFirst` could pick arbitrarily between duplicates.
-    const normalized = normalizeBarcode(barcode)
-    if (!normalized) return null
+  return withAuth(
+    async ({ organizationId }) => {
+      // Scanned input goes through the same normalisation as stored barcodes, so
+      // an exact match is enough. Combined with the (organizationId, barcode)
+      // unique index this resolves to at most one part — previously a
+      // case-insensitive `findFirst` could pick arbitrarily between duplicates.
+      const normalized = normalizeBarcode(barcode)
+      if (!normalized) return null
 
-    const part = await db.inventoryPart.findFirst({
-      where: {
-        organizationId,
-        barcode: normalized,
-        isArchived: false,
-      },
-      select: {
-        id: true,
-        partNumber: true,
-        barcode: true,
-        name: true,
-        description: true,
-        unitCost: true,
-        sellPrice: true,
-        quantity: true,
-        category: true,
-      },
-    })
-    return part
-  }, {
-    requiredPermissions: [
-      { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
-    ],
-  })
+      const part = await db.inventoryPart.findFirst({
+        where: {
+          organizationId,
+          barcode: normalized,
+          isArchived: false,
+        },
+        select: {
+          id: true,
+          partNumber: true,
+          barcode: true,
+          name: true,
+          description: true,
+          unit: true,
+          unitCost: true,
+          sellPrice: true,
+          quantity: true,
+          category: true,
+        },
+      })
+      return part
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
+      ],
+    }
+  )
 }

+ 28 - 16
src/features/inventory/Components/BarcodeScanActionDialog.tsx

@@ -1,13 +1,9 @@
 'use client'
 
 import { useState } from 'react'
+import { formatQuantity } from '@/lib/format-quantity'
 import { useTranslations } from 'next-intl'
-import {
-  Dialog,
-  DialogContent,
-  DialogHeader,
-  DialogTitle,
-} from '@/components/ui/dialog'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
@@ -25,6 +21,7 @@ interface BarcodeScanActionDialogProps {
     partNumber: string | null
     barcode: string | null
     quantity: number
+    unit: string | null
     category: string | null
   } | null
   barcode: string
@@ -56,8 +53,8 @@ export function BarcodeScanActionDialog({
   }
 
   const handleCustomAdd = async () => {
-    const qty = parseInt(customQty, 10)
-    if (qty > 0) await handleAddStock(qty)
+    const qty = Number.parseFloat(customQty)
+    if (Number.isFinite(qty) && qty > 0) await handleAddStock(qty)
   }
 
   return (
@@ -73,24 +70,41 @@ export function BarcodeScanActionDialog({
               <p className="font-medium">{part.name}</p>
               <div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
                 {part.partNumber && <span className="font-mono">{part.partNumber}</span>}
-                {part.category && <Badge variant="secondary" className="text-xs">{part.category}</Badge>}
+                {part.category && (
+                  <Badge variant="secondary" className="text-xs">
+                    {part.category}
+                  </Badge>
+                )}
               </div>
-              <p className="mt-1 text-sm">{t('currentStock', { quantity: part.quantity })}</p>
+              <p className="mt-1 text-sm">
+                {t('currentStock', { quantity: formatQuantity(part.quantity, part.unit) })}
+              </p>
             </div>
 
             <div className="space-y-2">
               <Label className="text-sm font-medium">{t('addStock')}</Label>
               <div className="flex gap-2">
-                <Button size="sm" variant="outline" onClick={() => handleAddStock(1)} disabled={loading}>
+                <Button
+                  size="sm"
+                  variant="outline"
+                  onClick={() => handleAddStock(1)}
+                  disabled={loading}
+                >
                   +1
                 </Button>
-                <Button size="sm" variant="outline" onClick={() => handleAddStock(5)} disabled={loading}>
+                <Button
+                  size="sm"
+                  variant="outline"
+                  onClick={() => handleAddStock(5)}
+                  disabled={loading}
+                >
                   +5
                 </Button>
                 <div className="flex flex-1 gap-1">
                   <Input
                     type="number"
-                    min="1"
+                    min="0"
+                    step="any"
                     placeholder={t('customAmount')}
                     value={customQty}
                     onChange={(e) => setCustomQty(e.target.value)}
@@ -119,9 +133,7 @@ export function BarcodeScanActionDialog({
           <div className="space-y-4">
             <div className="rounded-lg border border-dashed p-4 text-center">
               <Package className="mx-auto h-8 w-8 text-muted-foreground" />
-              <p className="mt-2 text-sm text-muted-foreground">
-                {t('notFound', { barcode })}
-              </p>
+              <p className="mt-2 text-sm text-muted-foreground">{t('notFound', { barcode })}</p>
             </div>
             <Button
               className="w-full"

Разница между файлами не показана из-за своего большого размера
+ 414 - 305
src/features/inventory/Components/InventoryPartForm.tsx


+ 12 - 12
src/features/inventory/Components/PartNameSuggestions.tsx

@@ -1,6 +1,7 @@
 'use client'
 
 import { useEffect, useMemo, useRef, useState } from 'react'
+import { formatQuantity } from '@/lib/format-quantity'
 import { useTranslations } from 'next-intl'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { Package } from 'lucide-react'
@@ -14,6 +15,7 @@ export interface PartSuggestion {
   barcode?: string | null
   category?: string | null
   description?: string | null
+  unit?: string | null
   unitCost: number
   sellPrice: number
   quantity: number
@@ -125,14 +127,12 @@ export function PartNameSuggestions({
   }, [suggestions.length, dismissed])
 
   // An exact name hit means the row already holds that part; nothing to offer.
-  const alreadyExact =
-    suggestions.length === 1 && suggestions[0].name.toLowerCase() === trimmed
+  const alreadyExact = suggestions.length === 1 && suggestions[0].name.toLowerCase() === trimmed
 
   if (dismissed || alreadyExact || suggestions.length === 0) return null
 
   const priceOf = (part: PartSuggestion) =>
-    resolvePartPrice(part, { defaultMarkupPercent, markupAppliesToInventory })
-      .unitPrice
+    resolvePartPrice(part, { defaultMarkupPercent, markupAppliesToInventory }).unitPrice
 
   const choose = (part: PartSuggestion) => {
     onSelect(part)
@@ -181,9 +181,7 @@ export function PartNameSuggestions({
               </span>
             )}
             {part.category && (
-              <span className="shrink-0 text-xs text-muted-foreground">
-                {part.category}
-              </span>
+              <span className="shrink-0 text-xs text-muted-foreground">{part.category}</span>
             )}
           </span>
           <span className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
@@ -198,19 +196,21 @@ export function PartNameSuggestions({
                 the cost in the one case it matters most: markup pricing on a
                 part whose sell price was never set. */}
             {part.unitCost > 0 && priceOf(part) !== part.unitCost && (
-              <span className="line-through">
-                {formatCurrency(part.unitCost, currencyCode)}
-              </span>
+              <span className="line-through">{formatCurrency(part.unitCost, currencyCode)}</span>
             )}
             {part.quantity > 0 ? (
-              <span>{t('suggestions.inStock', { quantity: part.quantity })}</span>
+              <span>
+                {t('suggestions.inStock', { quantity: formatQuantity(part.quantity, part.unit) })}
+              </span>
             ) : part.quantity === 0 ? (
               <span className="font-medium text-amber-600 dark:text-amber-500">
                 {t('suggestions.outOfStock')}
               </span>
             ) : (
               <span className="font-medium text-red-600 dark:text-red-500">
-                {t('suggestions.onBackorder', { count: Math.abs(part.quantity) })}
+                {t('suggestions.onBackorder', {
+                  count: formatQuantity(Math.abs(part.quantity), part.unit),
+                })}
               </span>
             )}
           </span>

+ 143 - 0
src/features/inventory/Components/UnitCombobox.tsx

@@ -0,0 +1,143 @@
+'use client'
+
+import { Check, ChevronsUpDown, Plus } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/command'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+  IMPERIAL_MEASUREMENT_UNITS,
+  METRIC_MEASUREMENT_UNITS,
+} from '@/features/inventory/Lib/units'
+import { cn } from '@/lib/utils'
+
+/**
+ * Searchable unit-of-measure picker: the same Popover + Command combobox the
+ * category field uses, so both feel identical. Suggestions come grouped —
+ * localized count units, then the workshop's own measurement system, then the
+ * other system — and anything typed that matches nothing becomes a free-text
+ * unit, because these lists are suggestions, not a taxonomy.
+ */
+export function UnitCombobox({
+  value,
+  onChange,
+  unitSystem,
+  id,
+  className,
+}: {
+  value: string
+  onChange: (value: string) => void
+  /** workshop.unitSystem — anything other than "imperial" is treated as metric. */
+  unitSystem?: string
+  id?: string
+  className?: string
+}) {
+  const t = useTranslations('inventory')
+  const [open, setOpen] = useState(false)
+  const [search, setSearch] = useState('')
+
+  const countUnits = t('form.unitSuggestions')
+    .split(',')
+    .map((u) => u.trim())
+    .filter(Boolean)
+  const imperialFirst = unitSystem === 'imperial'
+  const groups: { label: string; units: readonly string[] }[] = [
+    { label: t('form.unitGroupCount'), units: countUnits },
+    ...(imperialFirst
+      ? [
+          { label: t('form.unitGroupImperial'), units: IMPERIAL_MEASUREMENT_UNITS },
+          { label: t('form.unitGroupMetric'), units: METRIC_MEASUREMENT_UNITS },
+        ]
+      : [
+          { label: t('form.unitGroupMetric'), units: METRIC_MEASUREMENT_UNITS },
+          { label: t('form.unitGroupImperial'), units: IMPERIAL_MEASUREMENT_UNITS },
+        ]),
+  ]
+  const known = new Set(groups.flatMap((g) => [...g.units].map((u) => u.toLowerCase())))
+  const trimmed = search.trim()
+
+  const pick = (unit: string) => {
+    // Re-selecting the current unit clears it, same as the category picker.
+    onChange(unit === value ? '' : unit)
+    setSearch('')
+    setOpen(false)
+  }
+
+  return (
+    // modal: inside a modal Dialog (the part form) a non-modal popover's wheel
+    // events are swallowed by the dialog's scroll lock, making the list unscrollable.
+    <Popover open={open} onOpenChange={setOpen} modal>
+      <PopoverTrigger asChild>
+        <Button
+          id={id}
+          type="button"
+          variant="outline"
+          role="combobox"
+          aria-expanded={open}
+          className={cn('h-9 w-full justify-between font-normal', className)}
+        >
+          <span className={cn('truncate', !value && 'text-muted-foreground')}>
+            {value || t('form.unitPlaceholder')}
+          </span>
+          <ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+        <Command>
+          <CommandInput
+            placeholder={t('form.unitSearch')}
+            value={search}
+            onValueChange={setSearch}
+          />
+          <CommandList>
+            <CommandEmpty>
+              {trimmed ? (
+                <button
+                  type="button"
+                  className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
+                  onClick={() => pick(trimmed)}
+                >
+                  <Plus className="h-3.5 w-3.5" />
+                  {t('form.unitCreate', { name: trimmed })}
+                </button>
+              ) : (
+                <span className="text-xs text-muted-foreground">{t('form.unitPlaceholder')}</span>
+              )}
+            </CommandEmpty>
+            {groups.map((group) => (
+              <CommandGroup key={group.label} heading={group.label}>
+                {group.units.map((unit) => (
+                  <CommandItem key={unit} value={unit} onSelect={() => pick(unit)}>
+                    <Check
+                      className={cn(
+                        'mr-2 h-3.5 w-3.5',
+                        value === unit ? 'opacity-100' : 'opacity-0'
+                      )}
+                    />
+                    {unit}
+                  </CommandItem>
+                ))}
+              </CommandGroup>
+            ))}
+            {trimmed && !known.has(trimmed.toLowerCase()) && (
+              <CommandGroup>
+                <CommandItem value={`__use__${trimmed}`} onSelect={() => pick(trimmed)}>
+                  <Plus className="mr-2 h-3.5 w-3.5" />
+                  {t('form.unitCreate', { name: trimmed })}
+                </CommandItem>
+              </CommandGroup>
+            )}
+          </CommandList>
+        </Command>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 40 - 43
src/features/inventory/Lib/lowStockAlerts.ts

@@ -15,19 +15,20 @@
  */
 
 export interface LowStockCandidate {
-  id: string;
-  name: string;
-  partNumber: string | null;
-  quantity: number;
-  minQuantity: number;
-  lowStockAlertedAt: Date | null;
+  id: string
+  name: string
+  partNumber: string | null
+  quantity: number
+  minQuantity: number
+  unit: string | null
+  lowStockAlertedAt: Date | null
 }
 
 export interface LowStockDecision {
   /** Parts that just crossed below their reorder point — alert on these. */
-  newlyLow: LowStockCandidate[];
+  newlyLow: LowStockCandidate[]
   /** Parts that recovered; clear their marker so a future dip alerts again. */
-  toRearm: string[];
+  toRearm: string[]
 }
 
 /**
@@ -41,11 +42,8 @@ export interface LowStockDecision {
  * A default of 0 means "no org-wide threshold", so only explicitly configured
  * parts are watched. That is the out-of-the-box behaviour.
  */
-export function effectiveThreshold(
-  part: { minQuantity: number },
-  defaultThreshold = 0,
-): number {
-  return part.minQuantity > 0 ? part.minQuantity : Math.max(0, defaultThreshold);
+export function effectiveThreshold(part: { minQuantity: number }, defaultThreshold = 0): number {
+  return part.minQuantity > 0 ? part.minQuantity : Math.max(0, defaultThreshold)
 }
 
 /**
@@ -57,10 +55,10 @@ export function effectiveThreshold(
  */
 export function isLow(
   part: { quantity: number; minQuantity: number },
-  defaultThreshold = 0,
+  defaultThreshold = 0
 ): boolean {
-  const threshold = effectiveThreshold(part, defaultThreshold);
-  return threshold > 0 && part.quantity <= threshold;
+  const threshold = effectiveThreshold(part, defaultThreshold)
+  return threshold > 0 && part.quantity <= threshold
 }
 
 /**
@@ -71,22 +69,22 @@ export function isLow(
  */
 export function decideLowStockAlerts(
   parts: readonly LowStockCandidate[],
-  defaultThreshold = 0,
+  defaultThreshold = 0
 ): LowStockDecision {
-  const newlyLow: LowStockCandidate[] = [];
-  const toRearm: string[] = [];
+  const newlyLow: LowStockCandidate[] = []
+  const toRearm: string[] = []
 
   for (const part of parts) {
     if (isLow(part, defaultThreshold)) {
       // Already alerted for this dip — stay quiet.
-      if (part.lowStockAlertedAt === null) newlyLow.push(part);
+      if (part.lowStockAlertedAt === null) newlyLow.push(part)
     } else if (part.lowStockAlertedAt !== null) {
       // Back above the reorder point: arm it for the next dip.
-      toRearm.push(part.id);
+      toRearm.push(part.id)
     }
   }
 
-  return { newlyLow, toRearm };
+  return { newlyLow, toRearm }
 }
 
 /**
@@ -101,22 +99,21 @@ export function decideLowStockAlerts(
 export function canSendDigestEmail(
   lastSentAt: Date | null,
   now: Date,
-  minIntervalHours: number,
+  minIntervalHours: number
 ): boolean {
-  if (!lastSentAt) return true;
-  if (minIntervalHours <= 0) return true;
-  const elapsedMs = now.getTime() - lastSentAt.getTime();
-  return elapsedMs >= minIntervalHours * 60 * 60 * 1000;
+  if (!lastSentAt) return true
+  if (minIntervalHours <= 0) return true
+  const elapsedMs = now.getTime() - lastSentAt.getTime()
+  return elapsedMs >= minIntervalHours * 60 * 60 * 1000
 }
 
 /** One-line summary per part for the digest body. */
-export function formatLowStockLine(
-  part: LowStockCandidate,
-  defaultThreshold = 0,
-): string {
-  const ref = part.partNumber ? ` (${part.partNumber})` : "";
-  const threshold = effectiveThreshold(part, defaultThreshold);
-  return `${part.name}${ref}: ${part.quantity} left, reorder at ${threshold}`;
+export function formatLowStockLine(part: LowStockCandidate, defaultThreshold = 0): string {
+  const ref = part.partNumber ? ` (${part.partNumber})` : ''
+  const threshold = effectiveThreshold(part, defaultThreshold)
+  // The reorder point is in the part's own unit, so both numbers carry it.
+  const withUnit = (n: number) => (part.unit ? `${n} ${part.unit}` : `${n}`)
+  return `${part.name}${ref}: ${withUnit(part.quantity)} left, reorder at ${withUnit(threshold)}`
 }
 
 /**
@@ -125,27 +122,27 @@ export function formatLowStockLine(
  */
 export function buildAlertSummary(
   parts: readonly LowStockCandidate[],
-  defaultThreshold = 0,
+  defaultThreshold = 0
 ): {
-  title: string;
-  message: string;
+  title: string
+  message: string
 } {
   if (parts.length === 1) {
-    const p = parts[0];
+    const p = parts[0]
     return {
-      title: "Low stock",
+      title: 'Low stock',
       message: formatLowStockLine(p, defaultThreshold),
-    };
+    }
   }
 
   const preview = parts
     .slice(0, 3)
     .map((p) => p.name)
-    .join(", ");
-  const remainder = parts.length - Math.min(3, parts.length);
+    .join(', ')
+  const remainder = parts.length - Math.min(3, parts.length)
 
   return {
     title: `${parts.length} parts low on stock`,
     message: remainder > 0 ? `${preview} and ${remainder} more` : preview,
-  };
+  }
 }

+ 43 - 41
src/features/inventory/Lib/reconcileStock.ts

@@ -1,12 +1,12 @@
-import type { Prisma } from "@/generated/prisma/client";
+import type { Prisma } from '@/generated/prisma/client'
 
 /**
  * A part line that may be linked to an inventory item. Only lines with a
  * non-null `inventoryPartId` move stock; free-text parts are ignored.
  */
 export interface StockPartLine {
-  inventoryPartId?: string | null;
-  quantity: number;
+  inventoryPartId?: string | null
+  quantity: number
 }
 
 /**
@@ -15,40 +15,42 @@ export interface StockPartLine {
  * renaming existing ones.
  */
 export type StockMovementReason =
-  | "service_record"
-  | "service_record_deleted"
-  | "quote_conversion"
-  | "manual_adjustment"
-  | "bulk_markup";
+  | 'service_record'
+  | 'service_record_deleted'
+  | 'quote_conversion'
+  | 'manual_adjustment'
+  | 'bulk_markup'
 
 /**
  * Provenance recorded alongside every movement, so the ledger can answer
  * "where was this part used, and who did it?".
  */
 export interface StockMovementContext {
-  reason: StockMovementReason;
-  userId?: string | null;
+  reason: StockMovementReason
+  userId?: string | null
   /** The job the parts were used on, when there is one. */
-  serviceRecordId?: string | null;
+  serviceRecordId?: string | null
   /**
    * Human-readable job label, denormalised so history stays meaningful after
    * the service record is deleted and `serviceRecordId` is nulled out.
    */
-  serviceRecordLabel?: string | null;
-  note?: string | null;
+  serviceRecordLabel?: string | null
+  note?: string | null
 }
 
 /**
- * Convert a (possibly fractional) part quantity into whole stock units.
+ * Normalise a part quantity before it enters the stock ledger.
  *
- * `InventoryPart.quantity` is an integer column while `ServicePart.quantity`
- * is a float, so we round to the nearest whole unit. Rounding is applied
- * identically to the previous and next sets, so any delta stays internally
- * consistent across create / edit / delete.
+ * Quantities are fractional (0.5 l of oil, 250 g of grease), so no whole-unit
+ * rounding happens here. Values are clamped to 3 decimals instead: that is
+ * precise enough for ml/g while stopping float noise (0.1 + 0.2) from
+ * accumulating drift in `quantity = quantity - delta` over many movements.
+ * Rounding is applied identically to the previous and next sets, so any delta
+ * stays internally consistent across create / edit / delete.
  */
 function stockUnits(quantity: number): number {
-  if (!Number.isFinite(quantity) || quantity <= 0) return 0;
-  return Math.round(quantity);
+  if (!Number.isFinite(quantity) || quantity <= 0) return 0
+  return Math.round(quantity * 1000) / 1000
 }
 
 /**
@@ -86,26 +88,26 @@ export async function reconcileInventoryForParts(
   organizationId: string,
   previous: readonly StockPartLine[],
   next: readonly StockPartLine[],
-  context: StockMovementContext,
+  context: StockMovementContext
 ): Promise<void> {
   // inventoryPartId -> net units to DECREMENT (negative = restock)
-  const netDecrement = new Map<string, number>();
+  const netDecrement = new Map<string, number>()
 
   for (const line of previous) {
-    if (!line.inventoryPartId) continue;
-    const current = netDecrement.get(line.inventoryPartId) ?? 0;
-    netDecrement.set(line.inventoryPartId, current - stockUnits(line.quantity));
+    if (!line.inventoryPartId) continue
+    const current = netDecrement.get(line.inventoryPartId) ?? 0
+    netDecrement.set(line.inventoryPartId, current - stockUnits(line.quantity))
   }
   for (const line of next) {
-    if (!line.inventoryPartId) continue;
-    const current = netDecrement.get(line.inventoryPartId) ?? 0;
-    netDecrement.set(line.inventoryPartId, current + stockUnits(line.quantity));
+    if (!line.inventoryPartId) continue
+    const current = netDecrement.get(line.inventoryPartId) ?? 0
+    netDecrement.set(line.inventoryPartId, current + stockUnits(line.quantity))
   }
 
-  const ledgerRows: Prisma.StockMovementCreateManyInput[] = [];
+  const ledgerRows: Prisma.StockMovementCreateManyInput[] = []
 
   for (const [inventoryPartId, decrement] of netDecrement) {
-    if (decrement === 0) continue;
+    if (decrement === 0) continue
 
     // Atomic, org-scoped, and returns the post-write balance in one round trip.
     const updated = await tx.$queryRaw<{ quantity: number }[]>`
@@ -113,10 +115,10 @@ export async function reconcileInventoryForParts(
       SET "quantity" = "quantity" - ${decrement}, "updatedAt" = NOW()
       WHERE "id" = ${inventoryPartId} AND "organizationId" = ${organizationId}
       RETURNING "quantity"
-    `;
+    `
 
     // No row matched — unknown part, or one belonging to another organization.
-    if (updated.length === 0) continue;
+    if (updated.length === 0) continue
 
     ledgerRows.push({
       inventoryPartId,
@@ -129,11 +131,11 @@ export async function reconcileInventoryForParts(
       serviceRecordId: context.serviceRecordId ?? null,
       serviceRecordLabel: context.serviceRecordLabel ?? null,
       note: context.note ?? null,
-    });
+    })
   }
 
   if (ledgerRows.length > 0) {
-    await tx.stockMovement.createMany({ data: ledgerRows });
+    await tx.stockMovement.createMany({ data: ledgerRows })
   }
 }
 
@@ -156,7 +158,7 @@ export async function recordAbsoluteStockChange(
   organizationId: string,
   inventoryPartId: string,
   newQuantity: number,
-  context: StockMovementContext,
+  context: StockMovementContext
 ): Promise<boolean> {
   const updated = await tx.$queryRaw<{ quantity: number; delta: number }[]>`
     UPDATE "inventory_parts" AS p
@@ -166,12 +168,12 @@ export async function recordAbsoluteStockChange(
       AND p."id" = ${inventoryPartId}
       AND p."organizationId" = ${organizationId}
     RETURNING p."quantity" AS "quantity", (p."quantity" - prev."quantity") AS "delta"
-  `;
+  `
 
-  if (updated.length === 0) return false;
+  if (updated.length === 0) return false
 
-  const delta = Number(updated[0].delta);
-  if (delta === 0) return true;
+  const delta = Number(updated[0].delta)
+  if (delta === 0) return true
 
   await tx.stockMovement.create({
     data: {
@@ -185,7 +187,7 @@ export async function recordAbsoluteStockChange(
       serviceRecordLabel: context.serviceRecordLabel ?? null,
       note: context.note ?? null,
     },
-  });
+  })
 
-  return true;
+  return true
 }

+ 35 - 0
src/features/inventory/Lib/units.ts

@@ -0,0 +1,35 @@
+/**
+ * Units of measure offered when stocking a part.
+ *
+ * The unit itself is free text — a workshop can type anything — these lists
+ * only feed the suggestion dropdowns. Count units ("pcs", "Stk", "vnt.") are
+ * language-specific and live in the locale files; measurement units are
+ * system-specific and live here, ordered by the workshop's configured unit
+ * system so a metric shop sees litres first and an imperial shop quarts, with
+ * the other system still one scroll away (a German shop can stock a US-spec
+ * quart bottle).
+ */
+
+export const METRIC_MEASUREMENT_UNITS = ['l', 'ml', 'kg', 'g', 'm', 'cm'] as const
+
+export const IMPERIAL_MEASUREMENT_UNITS = ['qt', 'gal', 'fl oz', 'lb', 'oz', 'ft', 'in'] as const
+
+/**
+ * Full suggestion list for the unit field.
+ *
+ * @param localizedCountUnits comma-separated count units from the locale
+ *   (`inventory.form.unitSuggestions`, e.g. "Stk,Satz")
+ * @param unitSystem the workshop's `workshop.unitSystem` setting;
+ *   anything other than "imperial" is treated as metric
+ */
+export function unitSuggestions(localizedCountUnits: string, unitSystem?: string): string[] {
+  const countUnits = localizedCountUnits
+    .split(',')
+    .map((u) => u.trim())
+    .filter(Boolean)
+  const [primary, secondary] =
+    unitSystem === 'imperial'
+      ? [IMPERIAL_MEASUREMENT_UNITS, METRIC_MEASUREMENT_UNITS]
+      : [METRIC_MEASUREMENT_UNITS, IMPERIAL_MEASUREMENT_UNITS]
+  return [...new Set([...countUnits, ...primary, ...secondary])]
+}

+ 26 - 20
src/features/inventory/Schema/inventorySchema.ts

@@ -1,37 +1,43 @@
-import { z } from "zod";
+import { z } from 'zod'
 
 export const createInventoryPartSchema = z.object({
-  name: z.string().min(1, "Part name is required"),
+  name: z.string().min(1, 'Part name is required'),
   partNumber: z.string().optional(),
   barcode: z.string().optional(),
   description: z.string().optional(),
   category: z.string().optional(),
-  quantity: z.coerce.number().int().min(0, "Quantity must be 0 or more").default(0),
-  minQuantity: z.coerce.number().int().min(0, "Min quantity must be 0 or more").default(0),
-  unitCost: z.coerce.number().min(0, "Unit cost must be 0 or more").default(0),
-  sellPrice: z.coerce.number().min(0, "Sell price must be 0 or more").default(0),
+  quantity: z.coerce.number().min(0, 'Quantity must be 0 or more').default(0),
+  minQuantity: z.coerce.number().min(0, 'Min quantity must be 0 or more').default(0),
+  unit: z.string().max(20).optional(),
+  unitCost: z.coerce.number().min(0, 'Unit cost must be 0 or more').default(0),
+  sellPrice: z.coerce.number().min(0, 'Sell price must be 0 or more').default(0),
   supplier: z.string().optional(),
   supplierPhone: z.string().optional(),
   supplierEmail: z.string().optional(),
-  supplierUrl: z.string().url().optional().or(z.literal("")),
-  gallery: z.array(z.object({
-    id: z.string().optional(),
-    url: z.string(),
-    fileName: z.string().optional(),
-    description: z.string().optional(),
-    sortOrder: z.number().int().default(0),
-  })).optional().default([]),
+  supplierUrl: z.string().url().optional().or(z.literal('')),
+  gallery: z
+    .array(
+      z.object({
+        id: z.string().optional(),
+        url: z.string(),
+        fileName: z.string().optional(),
+        description: z.string().optional(),
+        sortOrder: z.number().int().default(0),
+      })
+    )
+    .optional()
+    .default([]),
   location: z.string().optional(),
-});
+})
 
 export const updateInventoryPartSchema = createInventoryPartSchema.partial().extend({
   id: z.string(),
-});
+})
 
 export const adjustStockSchema = z.object({
   id: z.string(),
-  adjustment: z.number().int(),
-});
+  adjustment: z.number(),
+})
 
-export type CreateInventoryPartInput = z.infer<typeof createInventoryPartSchema>;
-export type UpdateInventoryPartInput = z.infer<typeof updateInventoryPartSchema>;
+export type CreateInventoryPartInput = z.infer<typeof createInventoryPartSchema>
+export type UpdateInventoryPartInput = z.infer<typeof updateInventoryPartSchema>

+ 262 - 215
src/features/labor-presets/Actions/laborPresetActions.ts

@@ -1,245 +1,292 @@
-"use server";
+'use server'
 
-import { db } from "@/lib/db";
-import { withAuth } from "@/lib/with-auth";
-import { createLaborPresetSchema, updateLaborPresetSchema } from "../Schema/laborPresetSchema";
-import { revalidatePath } from "next/cache";
-import { Prisma } from "@/generated/prisma/client";
-import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { createLaborPresetSchema, updateLaborPresetSchema } from '../Schema/laborPresetSchema'
+import { revalidatePath } from 'next/cache'
+import { Prisma } from '@/generated/prisma/client'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 
 export async function getLaborPresetsPaginated(params: {
-  page?: number;
-  pageSize?: number;
-  search?: string;
-  sortBy?: string;
-  sortOrder?: "asc" | "desc";
+  page?: number
+  pageSize?: number
+  search?: string
+  sortBy?: string
+  sortOrder?: 'asc' | 'desc'
 }) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const page = params.page || 1;
-    const pageSize = params.pageSize || 20;
-    const skip = (page - 1) * pageSize;
-    const mode = "insensitive" as Prisma.QueryMode;
-
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const where: any = { organizationId, isArchived: false };
-
-    if (params.search) {
-      const q = params.search.trim();
-      where.OR = [
-        { name: { contains: q, mode } },
-        { description: { contains: q, mode } },
-      ];
-    }
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const page = params.page || 1
+      const pageSize = params.pageSize || 20
+      const skip = (page - 1) * pageSize
+      const mode = 'insensitive' as Prisma.QueryMode
+
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const where: any = { organizationId, isArchived: false }
 
-    const dir = params.sortOrder || "desc";
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const orderBy: any = (() => {
-      switch (params.sortBy) {
-        case "name": return { name: dir };
-        case "description": return { description: dir };
-        case "createdAt": return { createdAt: dir };
-        default: return { updatedAt: dir };
+      if (params.search) {
+        const q = params.search.trim()
+        where.OR = [{ name: { contains: q, mode } }, { description: { contains: q, mode } }]
       }
-    })();
-
-    const [presets, total] = await Promise.all([
-      db.laborPreset.findMany({
-        where,
-        orderBy,
-        skip,
-        take: pageSize,
-        include: {
-          _count: { select: { items: true, parts: true } },
-          items: { select: { hours: true, pricingType: true } },
-        },
-      }),
-      db.laborPreset.count({ where }),
-    ]);
-
-    return {
-      presets,
-      total,
-      page,
-      pageSize,
-      totalPages: Math.ceil(total / pageSize),
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.LABOR_PRESETS }] });
-}
 
-export async function getLaborPreset(presetId: string) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const preset = await db.laborPreset.findFirst({
-      where: { id: presetId, organizationId },
-      include: {
-        items: { orderBy: { sortOrder: "asc" } },
-        parts: { orderBy: { sortOrder: "asc" } },
-      },
-    });
-    if (!preset) throw new Error("Preset not found");
-    return preset;
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.LABOR_PRESETS }] });
-}
+      const dir = params.sortOrder || 'desc'
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const orderBy: any = (() => {
+        switch (params.sortBy) {
+          case 'name':
+            return { name: dir }
+          case 'description':
+            return { description: dir }
+          case 'createdAt':
+            return { createdAt: dir }
+          default:
+            return { updatedAt: dir }
+        }
+      })()
 
-export async function createLaborPreset(input: unknown) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const data = createLaborPresetSchema.parse(input);
-    const resolvedName = data.name?.trim() || data.items[0]?.description || "Untitled";
-    const preset = await db.laborPreset.create({
-      data: {
-        name: resolvedName,
-        description: data.description || undefined,
-        userId,
-        organizationId,
-        items: {
-          create: data.items.map((item, index) => ({
-            description: item.description,
-            hours: item.hours,
-            rate: item.rate,
-            pricingType: item.pricingType || "hourly",
-            sortOrder: index,
-          })),
-        },
-        parts: data.parts?.length ? {
-          create: data.parts.map((part, index) => ({
-            name: part.name,
-            partNumber: part.partNumber || null,
-            quantity: part.quantity,
-            unitPrice: part.unitPrice,
-            inventoryPartId: part.inventoryPartId || null,
-            sortOrder: index,
-          })),
-        } : undefined,
-      },
-      include: { items: true, parts: true },
-    });
-    revalidatePath("/labor-presets");
-    return preset;
-  }, {
-    requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.LABOR_PRESETS }],
-    audit: ({ result }) => ({
-      action: "labor_preset.create",
-      entity: "LaborPreset",
-      entityId: result.id,
-      details: { key: "labor_preset_create", params: { name: result.name } },
-      metadata: { presetId: result.id },
-    }),
-  });
-}
+      const [presets, total] = await Promise.all([
+        db.laborPreset.findMany({
+          where,
+          orderBy,
+          skip,
+          take: pageSize,
+          include: {
+            _count: { select: { items: true, parts: true } },
+            items: { select: { hours: true, pricingType: true } },
+          },
+        }),
+        db.laborPreset.count({ where }),
+      ])
 
-export async function updateLaborPreset(input: unknown) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const data = updateLaborPresetSchema.parse(input);
-    const { id, items, parts, ...updateData } = data;
-    // Resolve name from first item if not provided
-    if (updateData.name !== undefined) {
-      updateData.name = updateData.name?.trim() || items?.[0]?.description || "Untitled";
+      return {
+        presets,
+        total,
+        page,
+        pageSize,
+        totalPages: Math.ceil(total / pageSize),
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.LABOR_PRESETS },
+      ],
     }
+  )
+}
 
-    const existing = await db.laborPreset.findFirst({
-      where: { id, organizationId },
-    });
-    if (!existing) throw new Error("Preset not found");
-
-    await db.$transaction(async (tx) => {
-      // Delete old items and parts
-      await tx.laborPresetItem.deleteMany({ where: { presetId: id } });
-      await tx.laborPresetPart.deleteMany({ where: { presetId: id } });
+export async function getLaborPreset(presetId: string) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const preset = await db.laborPreset.findFirst({
+        where: { id: presetId, organizationId },
+        include: {
+          items: { orderBy: { sortOrder: 'asc' } },
+          parts: { orderBy: { sortOrder: 'asc' } },
+        },
+      })
+      if (!preset) throw new Error('Preset not found')
+      return preset
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.LABOR_PRESETS },
+      ],
+    }
+  )
+}
 
-      // Update preset and create new items/parts
-      return tx.laborPreset.update({
-        where: { id },
+export async function createLaborPreset(input: unknown) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const data = createLaborPresetSchema.parse(input)
+      const resolvedName = data.name?.trim() || data.items[0]?.description || 'Untitled'
+      const preset = await db.laborPreset.create({
         data: {
-          ...updateData,
-          description: updateData.description !== undefined ? (updateData.description || null) : undefined,
-          items: items ? {
-            create: items.map((item, index) => ({
+          name: resolvedName,
+          description: data.description || undefined,
+          userId,
+          organizationId,
+          items: {
+            create: data.items.map((item, index) => ({
               description: item.description,
               hours: item.hours,
               rate: item.rate,
-              pricingType: item.pricingType || "hourly",
-              sortOrder: index,
-            })),
-          } : undefined,
-          parts: parts?.length ? {
-            create: parts.map((part, index) => ({
-              name: part.name,
-              partNumber: part.partNumber || null,
-              quantity: part.quantity,
-              unitPrice: part.unitPrice,
-              inventoryPartId: part.inventoryPartId || null,
+              pricingType: item.pricingType || 'hourly',
               sortOrder: index,
             })),
-          } : undefined,
+          },
+          parts: data.parts?.length
+            ? {
+                create: data.parts.map((part, index) => ({
+                  name: part.name,
+                  partNumber: part.partNumber || null,
+                  quantity: part.quantity,
+                  unit: part.unit ?? null,
+                  unitPrice: part.unitPrice,
+                  inventoryPartId: part.inventoryPartId || null,
+                  sortOrder: index,
+                })),
+              }
+            : undefined,
         },
         include: { items: true, parts: true },
-      });
-    });
-
-    revalidatePath("/labor-presets");
-    return { updated: true, presetId: id };
-  }, {
-    requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.LABOR_PRESETS }],
-    audit: ({ result }) => ({
-      action: "labor_preset.update",
-      entity: "LaborPreset",
-      entityId: result.presetId,
-      details: { key: "labor_preset_update", params: { id: result.presetId } },
-      metadata: { presetId: result.presetId },
-    }),
-  });
+      })
+      revalidatePath('/labor-presets')
+      return preset
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.LABOR_PRESETS },
+      ],
+      audit: ({ result }) => ({
+        action: 'labor_preset.create',
+        entity: 'LaborPreset',
+        entityId: result.id,
+        details: { key: 'labor_preset_create', params: { name: result.name } },
+        metadata: { presetId: result.id },
+      }),
+    }
+  )
+}
+
+export async function updateLaborPreset(input: unknown) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const data = updateLaborPresetSchema.parse(input)
+      const { id, items, parts, ...updateData } = data
+      // Resolve name from first item if not provided
+      if (updateData.name !== undefined) {
+        updateData.name = updateData.name?.trim() || items?.[0]?.description || 'Untitled'
+      }
+
+      const existing = await db.laborPreset.findFirst({
+        where: { id, organizationId },
+      })
+      if (!existing) throw new Error('Preset not found')
+
+      await db.$transaction(async (tx) => {
+        // Delete old items and parts
+        await tx.laborPresetItem.deleteMany({ where: { presetId: id } })
+        await tx.laborPresetPart.deleteMany({ where: { presetId: id } })
+
+        // Update preset and create new items/parts
+        return tx.laborPreset.update({
+          where: { id },
+          data: {
+            ...updateData,
+            description:
+              updateData.description !== undefined ? updateData.description || null : undefined,
+            items: items
+              ? {
+                  create: items.map((item, index) => ({
+                    description: item.description,
+                    hours: item.hours,
+                    rate: item.rate,
+                    pricingType: item.pricingType || 'hourly',
+                    sortOrder: index,
+                  })),
+                }
+              : undefined,
+            parts: parts?.length
+              ? {
+                  create: parts.map((part, index) => ({
+                    name: part.name,
+                    partNumber: part.partNumber || null,
+                    quantity: part.quantity,
+                    unit: part.unit ?? null,
+                    unitPrice: part.unitPrice,
+                    inventoryPartId: part.inventoryPartId || null,
+                    sortOrder: index,
+                  })),
+                }
+              : undefined,
+          },
+          include: { items: true, parts: true },
+        })
+      })
+
+      revalidatePath('/labor-presets')
+      return { updated: true, presetId: id }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.LABOR_PRESETS },
+      ],
+      audit: ({ result }) => ({
+        action: 'labor_preset.update',
+        entity: 'LaborPreset',
+        entityId: result.presetId,
+        details: { key: 'labor_preset_update', params: { id: result.presetId } },
+        metadata: { presetId: result.presetId },
+      }),
+    }
+  )
 }
 
 export async function deleteLaborPreset(presetId: string) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const result = await db.laborPreset.deleteMany({
-      where: { id: presetId, organizationId },
-    });
-    if (result.count === 0) throw new Error("Preset not found");
-    revalidatePath("/labor-presets");
-    return { deleted: true, presetId };
-  }, {
-    requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.LABOR_PRESETS }],
-    audit: ({ result }) => ({
-      action: "labor_preset.delete",
-      entity: "LaborPreset",
-      entityId: result.presetId,
-      details: { key: "labor_preset_delete", params: { id: result.presetId } },
-      metadata: { presetId: result.presetId },
-    }),
-  });
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const result = await db.laborPreset.deleteMany({
+        where: { id: presetId, organizationId },
+      })
+      if (result.count === 0) throw new Error('Preset not found')
+      revalidatePath('/labor-presets')
+      return { deleted: true, presetId }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.LABOR_PRESETS },
+      ],
+      audit: ({ result }) => ({
+        action: 'labor_preset.delete',
+        entity: 'LaborPreset',
+        entityId: result.presetId,
+        details: { key: 'labor_preset_delete', params: { id: result.presetId } },
+        metadata: { presetId: result.presetId },
+      }),
+    }
+  )
 }
 
 export async function getLaborPresetsList() {
-  return withAuth(async ({ userId, organizationId }) => {
-    return db.laborPreset.findMany({
-      where: { organizationId, isArchived: false },
-      select: {
-        id: true,
-        name: true,
-        description: true,
-        items: {
-          select: {
-            description: true,
-            hours: true,
-            rate: true,
-            pricingType: true,
-            sortOrder: true,
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      return db.laborPreset.findMany({
+        where: { organizationId, isArchived: false },
+        select: {
+          id: true,
+          name: true,
+          description: true,
+          items: {
+            select: {
+              description: true,
+              hours: true,
+              rate: true,
+              pricingType: true,
+              sortOrder: true,
+            },
+            orderBy: { sortOrder: 'asc' },
           },
-          orderBy: { sortOrder: "asc" },
-        },
-        parts: {
-          select: {
-            name: true,
-            partNumber: true,
-            quantity: true,
-            unitPrice: true,
-            inventoryPartId: true,
-            sortOrder: true,
+          parts: {
+            select: {
+              name: true,
+              partNumber: true,
+              quantity: true,
+              unit: true,
+              unitPrice: true,
+              inventoryPartId: true,
+              sortOrder: true,
+            },
+            orderBy: { sortOrder: 'asc' },
           },
-          orderBy: { sortOrder: "asc" },
         },
-      },
-      orderBy: { name: "asc" },
-    });
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.LABOR_PRESETS }] });
+        orderBy: { name: 'asc' },
+      })
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.LABOR_PRESETS },
+      ],
+    }
+  )
 }

+ 33 - 36
src/features/labor-presets/Components/InventorySearchDialog.tsx

@@ -1,29 +1,27 @@
-"use client";
+'use client'
 
-import { useState } from "react";
-import { useTranslations } from "next-intl";
-import { Input } from "@/components/ui/input";
-import {
-  Dialog,
-  DialogContent,
-  DialogHeader,
-  DialogTitle,
-} from "@/components/ui/dialog";
-import { Search } from "lucide-react";
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Input } from '@/components/ui/input'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Search } from 'lucide-react'
 
 export interface InventoryPartOption {
-  id: string;
-  name: string;
-  partNumber: string | null;
-  sellPrice: number;
-  unitCost: number;
+  id: string
+  name: string
+  partNumber: string | null
+  unit?: string | null
+  sellPrice: number
+  unitCost: number
+  /** On-hand stock, shown by the inline name suggestions. */
+  quantity: number
 }
 
 interface InventorySearchDialogProps {
-  open: boolean;
-  onOpenChange: (v: boolean) => void;
-  inventoryParts: InventoryPartOption[];
-  onSelect: (part: InventoryPartOption) => void;
+  open: boolean
+  onOpenChange: (v: boolean) => void
+  inventoryParts: InventoryPartOption[]
+  onSelect: (part: InventoryPartOption) => void
 }
 
 export function InventorySearchDialog({
@@ -32,34 +30,33 @@ export function InventorySearchDialog({
   inventoryParts,
   onSelect,
 }: InventorySearchDialogProps) {
-  const t = useTranslations("laborPresets");
-  const [search, setSearch] = useState("");
+  const t = useTranslations('laborPresets')
+  const [search, setSearch] = useState('')
 
   const filtered = inventoryParts.filter((p) => {
-    if (!search) return true;
-    const q = search.toLowerCase();
+    if (!search) return true
+    const q = search.toLowerCase()
     return (
-      p.name.toLowerCase().includes(q) ||
-      (p.partNumber && p.partNumber.toLowerCase().includes(q))
-    );
-  });
+      p.name.toLowerCase().includes(q) || (p.partNumber && p.partNumber.toLowerCase().includes(q))
+    )
+  })
 
   return (
     <Dialog
       open={open}
       onOpenChange={(v) => {
-        onOpenChange(v);
-        if (!v) setSearch("");
+        onOpenChange(v)
+        if (!v) setSearch('')
       }}
     >
       <DialogContent className="sm:max-w-lg" aria-describedby={undefined}>
         <DialogHeader>
-          <DialogTitle>{t("form.importFromInventory")}</DialogTitle>
+          <DialogTitle>{t('form.importFromInventory')}</DialogTitle>
         </DialogHeader>
         <div className="relative">
           <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
           <Input
-            placeholder={t("form.searchInventory")}
+            placeholder={t('form.searchInventory')}
             value={search}
             onChange={(e) => setSearch(e.target.value)}
             className="pl-9"
@@ -72,8 +69,8 @@ export function InventorySearchDialog({
               type="button"
               className="w-full text-left rounded-md px-2.5 py-1.5 hover:bg-accent transition-colors flex items-center justify-between gap-4"
               onClick={() => {
-                onSelect(ip);
-                onOpenChange(false);
+                onSelect(ip)
+                onOpenChange(false)
               }}
             >
               <div className="min-w-0">
@@ -91,11 +88,11 @@ export function InventorySearchDialog({
           ))}
           {filtered.length === 0 && (
             <p className="py-6 text-center text-sm text-muted-foreground">
-              {t("form.noPartsFound")}
+              {t('form.noPartsFound')}
             </p>
           )}
         </div>
       </DialogContent>
     </Dialog>
-  );
+  )
 }

+ 316 - 201
src/features/labor-presets/Components/LaborPresetForm.tsx

@@ -1,264 +1,375 @@
-"use client";
-
-import { useState } from "react";
-import { useTranslations } from "next-intl";
-import { useRouter } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Textarea } from "@/components/ui/textarea";
-import {
-  Dialog,
-  DialogContent,
-  DialogHeader,
-  DialogTitle,
-} from "@/components/ui/dialog";
-import { DocsLink } from "@/components/docs-link";
-import { useGlassModal } from "@/components/glass-modal";
-import { createLaborPreset, updateLaborPreset } from "../Actions/laborPresetActions";
-import { Loader2, Plus, Trash2 } from "lucide-react";
-import { PresetPartsEditor } from "./PresetPartsEditor";
-import type { PresetPartItem } from "./PresetPartsEditor";
-
-type PricingType = "hourly" | "service";
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { useRouter } from 'next/navigation'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { DocsLink } from '@/components/docs-link'
+import { useGlassModal } from '@/components/glass-modal'
+import { createLaborPreset, updateLaborPreset } from '../Actions/laborPresetActions'
+import { Loader2, Plus, Trash2 } from 'lucide-react'
+import { PresetPartsEditor } from './PresetPartsEditor'
+import type { PresetPartItem } from './PresetPartsEditor'
+
+type PricingType = 'hourly' | 'service'
 
 interface PresetItem {
-  description: string;
-  hours: number;
-  rate: number;
-  pricingType: PricingType;
+  description: string
+  hours: number
+  rate: number
+  pricingType: PricingType
 }
 
 interface SingleItem {
-  name: string;
-  description: string;
-  hours: number;
-  rate: number;
-  pricingType: PricingType;
+  name: string
+  description: string
+  hours: number
+  rate: number
+  pricingType: PricingType
 }
 
 interface LaborPresetData {
-  id: string;
-  name: string;
-  description: string | null;
-  items: { description: string; hours: number; rate: number; pricingType?: string; sortOrder: number }[];
-  parts?: { name: string; partNumber: string | null; quantity: number; unitPrice: number; inventoryPartId: string | null; sortOrder: number }[];
+  id: string
+  name: string
+  description: string | null
+  items: {
+    description: string
+    hours: number
+    rate: number
+    pricingType?: string
+    sortOrder: number
+  }[]
+  parts?: {
+    name: string
+    partNumber: string | null
+    quantity: number
+    unit: string | null
+    unitPrice: number
+    inventoryPartId: string | null
+    sortOrder: number
+  }[]
 }
 
-type PresetMode = "single" | "package";
+type PresetMode = 'single' | 'package'
 
 interface LaborPresetFormProps {
-  open: boolean;
-  onOpenChange: (open: boolean) => void;
-  preset?: LaborPresetData;
-  defaultLaborRate?: number;
-  inventoryParts?: { id: string; name: string; partNumber: string | null; sellPrice: number; unitCost: number }[];
-  currencyCode?: string;
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  preset?: LaborPresetData
+  defaultLaborRate?: number
+  inventoryParts?: {
+    id: string
+    name: string
+    partNumber: string | null
+    unit: string | null
+    sellPrice: number
+    unitCost: number
+    quantity: number
+  }[]
+  currencyCode?: string
 }
 
 function detectMode(preset?: LaborPresetData): PresetMode {
-  if (!preset) return "single";
-  if (preset.items.length === 1 && preset.name === preset.items[0].description) return "single";
-  return "package";
+  if (!preset) return 'single'
+  if (preset.items.length === 1 && preset.name === preset.items[0].description) return 'single'
+  return 'package'
 }
 
-function PricingTypeToggle({ value, onChange, tHourly, tService }: { value: PricingType; onChange: (v: PricingType) => void; tHourly: string; tService: string }) {
+function PricingTypeToggle({
+  value,
+  onChange,
+  tHourly,
+  tService,
+}: {
+  value: PricingType
+  onChange: (v: PricingType) => void
+  tHourly: string
+  tService: string
+}) {
   return (
     <button
       type="button"
       className={`shrink-0 rounded-md border px-2 text-[10px] font-medium transition-colors ${
-        value === "service"
-          ? "border-blue-500/30 bg-blue-500/10 text-blue-600"
-          : "border-muted text-muted-foreground hover:text-foreground"
+        value === 'service'
+          ? 'border-blue-500/30 bg-blue-500/10 text-blue-600'
+          : 'border-muted text-muted-foreground hover:text-foreground'
       }`}
-      onClick={() => onChange(value === "service" ? "hourly" : "service")}
+      onClick={() => onChange(value === 'service' ? 'hourly' : 'service')}
     >
-      {value === "service" ? tService : tHourly}
+      {value === 'service' ? tService : tHourly}
     </button>
-  );
+  )
 }
 
-export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate = 0, inventoryParts, currencyCode }: LaborPresetFormProps) {
-  const router = useRouter();
-  const modal = useGlassModal();
-  const t = useTranslations("laborPresets");
-  const [loading, setLoading] = useState(false);
-  const [mode, setMode] = useState<PresetMode>(() => detectMode(preset));
+export function LaborPresetForm({
+  open,
+  onOpenChange,
+  preset,
+  defaultLaborRate = 0,
+  inventoryParts,
+  currencyCode,
+}: LaborPresetFormProps) {
+  const router = useRouter()
+  const modal = useGlassModal()
+  const t = useTranslations('laborPresets')
+  const [loading, setLoading] = useState(false)
+  const [mode, setMode] = useState<PresetMode>(() => detectMode(preset))
 
   // Package mode state
-  const [name, setName] = useState(preset?.name ?? "");
-  const [description, setDescription] = useState(preset?.description ?? "");
+  const [name, setName] = useState(preset?.name ?? '')
+  const [description, setDescription] = useState(preset?.description ?? '')
   const [items, setItems] = useState<PresetItem[]>(
-    preset?.items.map((i) => ({ description: i.description, hours: i.hours, rate: i.rate, pricingType: (i.pricingType as PricingType) || "hourly" })) ?? [
-      { description: "", hours: 0, rate: defaultLaborRate, pricingType: "hourly" },
-    ]
-  );
+    preset?.items.map((i) => ({
+      description: i.description,
+      hours: i.hours,
+      rate: i.rate,
+      pricingType: (i.pricingType as PricingType) || 'hourly',
+    })) ?? [{ description: '', hours: 0, rate: defaultLaborRate, pricingType: 'hourly' }]
+  )
 
   // Single item mode state
   const [singleItems, setSingleItems] = useState<SingleItem[]>(() => {
-    if (preset && detectMode(preset) === "single") {
-      return [{ name: preset.items[0].description, description: preset.description ?? "", hours: preset.items[0].hours, rate: preset.items[0].rate, pricingType: (preset.items[0].pricingType as PricingType) || "hourly" }];
+    if (preset && detectMode(preset) === 'single') {
+      return [
+        {
+          name: preset.items[0].description,
+          description: preset.description ?? '',
+          hours: preset.items[0].hours,
+          rate: preset.items[0].rate,
+          pricingType: (preset.items[0].pricingType as PricingType) || 'hourly',
+        },
+      ]
     }
-    return [{ name: "", description: "", hours: 0, rate: defaultLaborRate, pricingType: "hourly" }];
-  });
+    return [{ name: '', description: '', hours: 0, rate: defaultLaborRate, pricingType: 'hourly' }]
+  })
 
   const [parts, setParts] = useState<PresetPartItem[]>(
     preset?.parts?.map((p) => ({
       name: p.name,
-      partNumber: p.partNumber || "",
+      partNumber: p.partNumber || '',
       quantity: p.quantity,
+      unit: p.unit ?? null,
       unitPrice: p.unitPrice,
-      inventoryPartId: p.inventoryPartId || "",
+      inventoryPartId: p.inventoryPartId || '',
     })) ?? []
-  );
+  )
 
   const handleOpenChange = (isOpen: boolean) => {
     if (isOpen) {
-      const detectedMode = detectMode(preset);
-      setMode(detectedMode);
-      setName(preset?.name ?? "");
-      setDescription(preset?.description ?? "");
+      const detectedMode = detectMode(preset)
+      setMode(detectedMode)
+      setName(preset?.name ?? '')
+      setDescription(preset?.description ?? '')
       setItems(
-        preset?.items.map((i) => ({ description: i.description, hours: i.hours, rate: i.rate, pricingType: (i.pricingType as PricingType) || "hourly" })) ?? [
-          { description: "", hours: 0, rate: defaultLaborRate, pricingType: "hourly" },
-        ]
-      );
-      if (detectedMode === "single" && preset) {
-        setSingleItems([{ name: preset.items[0].description, description: preset.description ?? "", hours: preset.items[0].hours, rate: preset.items[0].rate, pricingType: (preset.items[0].pricingType as PricingType) || "hourly" }]);
+        preset?.items.map((i) => ({
+          description: i.description,
+          hours: i.hours,
+          rate: i.rate,
+          pricingType: (i.pricingType as PricingType) || 'hourly',
+        })) ?? [{ description: '', hours: 0, rate: defaultLaborRate, pricingType: 'hourly' }]
+      )
+      if (detectedMode === 'single' && preset) {
+        setSingleItems([
+          {
+            name: preset.items[0].description,
+            description: preset.description ?? '',
+            hours: preset.items[0].hours,
+            rate: preset.items[0].rate,
+            pricingType: (preset.items[0].pricingType as PricingType) || 'hourly',
+          },
+        ])
       } else {
-        setSingleItems([{ name: "", description: "", hours: 0, rate: defaultLaborRate, pricingType: "hourly" }]);
+        setSingleItems([
+          { name: '', description: '', hours: 0, rate: defaultLaborRate, pricingType: 'hourly' },
+        ])
       }
       setParts(
         preset?.parts?.map((p) => ({
           name: p.name,
-          partNumber: p.partNumber || "",
+          partNumber: p.partNumber || '',
           quantity: p.quantity,
+          unit: p.unit ?? null,
           unitPrice: p.unitPrice,
-          inventoryPartId: p.inventoryPartId || "",
+          inventoryPartId: p.inventoryPartId || '',
         })) ?? []
-      );
+      )
     }
-    onOpenChange(isOpen);
-  };
+    onOpenChange(isOpen)
+  }
 
   const handleModeChange = (newMode: PresetMode) => {
-    setMode(newMode);
-  };
+    setMode(newMode)
+  }
 
   // Package mode helpers
   const updateItem = (index: number, field: keyof PresetItem, value: string | number) => {
     setItems((prev) => {
-      const updated = [...prev];
-      updated[index] = { ...updated[index], [field]: value };
-      return updated;
-    });
-  };
+      const updated = [...prev]
+      updated[index] = { ...updated[index], [field]: value }
+      return updated
+    })
+  }
 
   const addItem = () => {
-    setItems((prev) => [...prev, { description: "", hours: 0, rate: defaultLaborRate, pricingType: "hourly" }]);
-  };
+    setItems((prev) => [
+      ...prev,
+      { description: '', hours: 0, rate: defaultLaborRate, pricingType: 'hourly' },
+    ])
+  }
 
   const removeItem = (index: number) => {
-    setItems((prev) => prev.filter((_, i) => i !== index));
-  };
+    setItems((prev) => prev.filter((_, i) => i !== index))
+  }
 
   // Single item mode helpers
   const updateSingleItem = (index: number, field: keyof SingleItem, value: string | number) => {
     setSingleItems((prev) => {
-      const updated = [...prev];
-      updated[index] = { ...updated[index], [field]: value };
-      return updated;
-    });
-  };
+      const updated = [...prev]
+      updated[index] = { ...updated[index], [field]: value }
+      return updated
+    })
+  }
 
   const addSingleItem = () => {
-    setSingleItems((prev) => [...prev, { name: "", description: "", hours: 0, rate: defaultLaborRate, pricingType: "hourly" }]);
-  };
+    setSingleItems((prev) => [
+      ...prev,
+      { name: '', description: '', hours: 0, rate: defaultLaborRate, pricingType: 'hourly' },
+    ])
+  }
 
   const removeSingleItem = (index: number) => {
-    setSingleItems((prev) => prev.filter((_, i) => i !== index));
-  };
+    setSingleItems((prev) => prev.filter((_, i) => i !== index))
+  }
 
   const buildPartsPayload = () =>
-    parts.filter((p) => p.name.trim()).map((part, index) => ({
-      name: part.name,
-      partNumber: part.partNumber || undefined,
-      quantity: Number(part.quantity) || 1,
-      unitPrice: Number(part.unitPrice) || 0,
-      inventoryPartId: part.inventoryPartId || undefined,
-      sortOrder: index,
-    }));
+    parts
+      .filter((p) => p.name.trim())
+      .map((part, index) => ({
+        name: part.name,
+        partNumber: part.partNumber || undefined,
+        quantity: Number(part.quantity) || 1,
+        unit: part.unit ?? undefined,
+        unitPrice: Number(part.unitPrice) || 0,
+        inventoryPartId: part.inventoryPartId || undefined,
+        sortOrder: index,
+      }))
 
   const handleSubmit = async (e: React.FormEvent) => {
-    e.preventDefault();
-    setLoading(true);
-    const partsPayload = buildPartsPayload();
+    e.preventDefault()
+    setLoading(true)
+    const partsPayload = buildPartsPayload()
 
-    if (mode === "single") {
-      const validItems = singleItems.filter((i) => i.name.trim());
+    if (mode === 'single') {
+      const validItems = singleItems.filter((i) => i.name.trim())
       if (validItems.length === 0) {
-        modal.open("error", t("errors.error"), t("errors.noItems"));
-        setLoading(false);
-        return;
+        modal.open('error', t('errors.error'), t('errors.noItems'))
+        setLoading(false)
+        return
       }
 
       if (preset) {
-        const item = validItems[0];
+        const item = validItems[0]
         const result = await updateLaborPreset({
           id: preset.id,
-          name: "",
+          name: '',
           description: item.description || undefined,
-          items: [{ description: item.name, hours: Number(item.hours) || 0, rate: Number(item.rate) || 0, pricingType: item.pricingType, sortOrder: 0 }],
+          items: [
+            {
+              description: item.name,
+              hours: Number(item.hours) || 0,
+              rate: Number(item.rate) || 0,
+              pricingType: item.pricingType,
+              sortOrder: 0,
+            },
+          ],
           parts: partsPayload,
-        });
-        if (result.success) { onOpenChange(false); router.refresh(); }
-        else { modal.open("error", t("errors.error"), result.error || t("errors.saveFailed")); }
+        })
+        if (result.success) {
+          onOpenChange(false)
+          router.refresh()
+        } else {
+          modal.open('error', t('errors.error'), result.error || t('errors.saveFailed'))
+        }
       } else {
-        let allSuccess = true;
+        let allSuccess = true
         for (const item of validItems) {
           const result = await createLaborPreset({
-            name: "",
+            name: '',
             description: item.description || undefined,
-            items: [{ description: item.name, hours: Number(item.hours) || 0, rate: Number(item.rate) || 0, pricingType: item.pricingType, sortOrder: 0 }],
+            items: [
+              {
+                description: item.name,
+                hours: Number(item.hours) || 0,
+                rate: Number(item.rate) || 0,
+                pricingType: item.pricingType,
+                sortOrder: 0,
+              },
+            ],
             parts: partsPayload,
-          });
-          if (!result.success) { modal.open("error", t("errors.error"), result.error || t("errors.saveFailed")); allSuccess = false; break; }
+          })
+          if (!result.success) {
+            modal.open('error', t('errors.error'), result.error || t('errors.saveFailed'))
+            allSuccess = false
+            break
+          }
+        }
+        if (allSuccess) {
+          onOpenChange(false)
+          router.refresh()
         }
-        if (allSuccess) { onOpenChange(false); router.refresh(); }
       }
     } else {
-      const validItems = items.filter((i) => i.description.trim());
+      const validItems = items.filter((i) => i.description.trim())
       if (validItems.length === 0) {
-        modal.open("error", t("errors.error"), t("errors.noItems"));
-        setLoading(false);
-        return;
+        modal.open('error', t('errors.error'), t('errors.noItems'))
+        setLoading(false)
+        return
       }
 
       const data = {
         name,
         description: description || undefined,
         items: validItems.map((item, index) => ({
-          description: item.description, hours: Number(item.hours) || 0, rate: Number(item.rate) || 0, pricingType: item.pricingType, sortOrder: index,
+          description: item.description,
+          hours: Number(item.hours) || 0,
+          rate: Number(item.rate) || 0,
+          pricingType: item.pricingType,
+          sortOrder: index,
         })),
         parts: partsPayload,
-      };
+      }
 
-      const result = preset ? await updateLaborPreset({ ...data, id: preset.id }) : await createLaborPreset(data);
-      if (result.success) { onOpenChange(false); router.refresh(); }
-      else { modal.open("error", t("errors.error"), result.error || t("errors.saveFailed")); }
+      const result = preset
+        ? await updateLaborPreset({ ...data, id: preset.id })
+        : await createLaborPreset(data)
+      if (result.success) {
+        onOpenChange(false)
+        router.refresh()
+      } else {
+        modal.open('error', t('errors.error'), result.error || t('errors.saveFailed'))
+      }
     }
 
-    setLoading(false);
-  };
+    setLoading(false)
+  }
 
   return (
     <Dialog open={open} onOpenChange={handleOpenChange}>
-      <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl" aria-describedby={undefined}>
+      <DialogContent
+        className="max-h-[90vh] overflow-y-auto sm:max-w-2xl"
+        aria-describedby={undefined}
+      >
         <DialogHeader>
-          <DialogTitle>
-            {preset ? t("form.editPackage") : t("form.addPackage")}
-          </DialogTitle>
-          <DocsLink href="/docs/configuration/work-orders/labor-presets" variant="hint" className="self-start" />
+          <DialogTitle>{preset ? t('form.editPackage') : t('form.addPackage')}</DialogTitle>
+          <DocsLink
+            href="/docs/configuration/work-orders/labor-presets"
+            variant="hint"
+            className="self-start"
+          />
         </DialogHeader>
 
         {/* Mode tabs */}
@@ -266,34 +377,34 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
           <button
             type="button"
             className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
-              mode === "single"
-                ? "bg-background text-foreground shadow-sm"
-                : "text-muted-foreground hover:text-foreground"
+              mode === 'single'
+                ? 'bg-background text-foreground shadow-sm'
+                : 'text-muted-foreground hover:text-foreground'
             }`}
-            onClick={() => handleModeChange("single")}
+            onClick={() => handleModeChange('single')}
           >
-            {t("form.singleItem")}
+            {t('form.singleItem')}
           </button>
           <button
             type="button"
             className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
-              mode === "package"
-                ? "bg-background text-foreground shadow-sm"
-                : "text-muted-foreground hover:text-foreground"
+              mode === 'package'
+                ? 'bg-background text-foreground shadow-sm'
+                : 'text-muted-foreground hover:text-foreground'
             }`}
-            onClick={() => handleModeChange("package")}
+            onClick={() => handleModeChange('package')}
           >
-            {t("form.package")}
+            {t('form.package')}
           </button>
         </div>
 
         <form onSubmit={handleSubmit} className="space-y-4">
-          {mode === "single" ? (
+          {mode === 'single' ? (
             <div className="space-y-2">
               <div className="hidden grid-cols-[2fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
-                <span>{t("form.singleNameLabel")}</span>
-                <span>{t("form.itemQtyOrHours")}</span>
-                <span>{t("form.itemRate")}</span>
+                <span>{t('form.singleNameLabel')}</span>
+                <span>{t('form.itemQtyOrHours')}</span>
+                <span>{t('form.itemRate')}</span>
                 <span />
               </div>
 
@@ -302,32 +413,34 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
                   <div className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]">
                     <div className="col-span-2 flex gap-2 sm:col-span-1">
                       <Input
-                        placeholder={t("form.singleNamePlaceholder")}
+                        placeholder={t('form.singleNamePlaceholder')}
                         value={item.name}
-                        onChange={(e) => updateSingleItem(i, "name", e.target.value)}
+                        onChange={(e) => updateSingleItem(i, 'name', e.target.value)}
                         className="flex-1"
                       />
                       <PricingTypeToggle
                         value={item.pricingType}
-                        onChange={(v) => updateSingleItem(i, "pricingType", v)}
-                        tHourly={t("form.hourlyTag")}
-                        tService={t("form.serviceTag")}
+                        onChange={(v) => updateSingleItem(i, 'pricingType', v)}
+                        tHourly={t('form.hourlyTag')}
+                        tService={t('form.serviceTag')}
                       />
                     </div>
                     <Input
                       type="number"
                       min="0"
-                      step={item.pricingType === "service" ? "1" : "any"}
-                      placeholder={item.pricingType === "service" ? t("form.itemQty") : t("form.itemHours")}
+                      step={item.pricingType === 'service' ? '1' : 'any'}
+                      placeholder={
+                        item.pricingType === 'service' ? t('form.itemQty') : t('form.itemHours')
+                      }
                       value={item.hours}
-                      onChange={(e) => updateSingleItem(i, "hours", e.target.value)}
+                      onChange={(e) => updateSingleItem(i, 'hours', e.target.value)}
                     />
                     <Input
                       type="number"
                       min="0"
                       step="0.01"
                       value={item.rate}
-                      onChange={(e) => updateSingleItem(i, "rate", e.target.value)}
+                      onChange={(e) => updateSingleItem(i, 'rate', e.target.value)}
                     />
                     <Button
                       type="button"
@@ -336,15 +449,15 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
                       className="h-9 w-9 text-muted-foreground hover:text-destructive"
                       onClick={() => removeSingleItem(i)}
                       disabled={singleItems.length <= 1}
-                      aria-label={t("actions.delete")}
+                      aria-label={t('actions.delete')}
                     >
                       <Trash2 className="h-4 w-4" />
                     </Button>
                   </div>
                   <Textarea
-                    placeholder={t("form.descriptionPlaceholder")}
+                    placeholder={t('form.descriptionPlaceholder')}
                     value={item.description}
-                    onChange={(e) => updateSingleItem(i, "description", e.target.value)}
+                    onChange={(e) => updateSingleItem(i, 'description', e.target.value)}
                     rows={2}
                   />
                 </div>
@@ -368,10 +481,10 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
           ) : (
             <>
               <div className="space-y-2">
-                <Label htmlFor="preset-name">{t("form.nameLabel")}</Label>
+                <Label htmlFor="preset-name">{t('form.nameLabel')}</Label>
                 <Input
                   id="preset-name"
-                  placeholder={t("form.namePlaceholder")}
+                  placeholder={t('form.namePlaceholder')}
                   value={name}
                   onChange={(e) => setName(e.target.value)}
                   required
@@ -379,10 +492,10 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
               </div>
 
               <div className="space-y-2">
-                <Label htmlFor="preset-description">{t("form.descriptionLabel")}</Label>
+                <Label htmlFor="preset-description">{t('form.descriptionLabel')}</Label>
                 <Textarea
                   id="preset-description"
-                  placeholder={t("form.descriptionPlaceholder")}
+                  placeholder={t('form.descriptionPlaceholder')}
                   value={description}
                   onChange={(e) => setDescription(e.target.value)}
                   rows={2}
@@ -391,17 +504,17 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
 
               <div className="space-y-2">
                 <div className="flex items-center justify-between">
-                  <Label>{t("form.itemsTitle")}</Label>
+                  <Label>{t('form.itemsTitle')}</Label>
                   <Button type="button" variant="outline" size="sm" onClick={addItem}>
                     <Plus className="mr-1 h-3.5 w-3.5" />
-                    {t("form.addItem")}
+                    {t('form.addItem')}
                   </Button>
                 </div>
 
                 <div className="hidden grid-cols-[2fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
-                  <span>{t("form.itemDescription")}</span>
-                  <span>{t("form.itemQtyOrHours")}</span>
-                  <span>{t("form.itemRate")}</span>
+                  <span>{t('form.itemDescription')}</span>
+                  <span>{t('form.itemQtyOrHours')}</span>
+                  <span>{t('form.itemRate')}</span>
                   <span />
                 </div>
 
@@ -409,32 +522,34 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
                   <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]">
                     <div className="col-span-2 flex gap-2 sm:col-span-1">
                       <Input
-                        placeholder={t("form.itemDescriptionPlaceholder")}
+                        placeholder={t('form.itemDescriptionPlaceholder')}
                         value={item.description}
-                        onChange={(e) => updateItem(i, "description", e.target.value)}
+                        onChange={(e) => updateItem(i, 'description', e.target.value)}
                         className="flex-1"
                       />
                       <PricingTypeToggle
                         value={item.pricingType}
-                        onChange={(v) => updateItem(i, "pricingType", v)}
-                        tHourly={t("form.hourlyTag")}
-                        tService={t("form.serviceTag")}
+                        onChange={(v) => updateItem(i, 'pricingType', v)}
+                        tHourly={t('form.hourlyTag')}
+                        tService={t('form.serviceTag')}
                       />
                     </div>
                     <Input
                       type="number"
                       min="0"
-                      step={item.pricingType === "service" ? "1" : "any"}
-                      placeholder={item.pricingType === "service" ? t("form.itemQty") : t("form.itemHours")}
+                      step={item.pricingType === 'service' ? '1' : 'any'}
+                      placeholder={
+                        item.pricingType === 'service' ? t('form.itemQty') : t('form.itemHours')
+                      }
                       value={item.hours}
-                      onChange={(e) => updateItem(i, "hours", e.target.value)}
+                      onChange={(e) => updateItem(i, 'hours', e.target.value)}
                     />
                     <Input
                       type="number"
                       min="0"
                       step="0.01"
                       value={item.rate}
-                      onChange={(e) => updateItem(i, "rate", e.target.value)}
+                      onChange={(e) => updateItem(i, 'rate', e.target.value)}
                     />
                     <Button
                       type="button"
@@ -443,7 +558,7 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
                       className="h-9 w-9 text-muted-foreground hover:text-destructive"
                       onClick={() => removeItem(i)}
                       disabled={items.length <= 1}
-                      aria-label={t("actions.delete")}
+                      aria-label={t('actions.delete')}
                     >
                       <Trash2 className="h-4 w-4" />
                     </Button>
@@ -470,15 +585,15 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
 
           <div className="flex justify-end gap-3 pt-4">
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
-              {t("form.cancel")}
+              {t('form.cancel')}
             </Button>
             <Button type="submit" disabled={loading}>
               {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {preset ? t("form.saveChanges") : t("form.create")}
+              {preset ? t('form.saveChanges') : t('form.create')}
             </Button>
           </div>
         </form>
       </DialogContent>
     </Dialog>
-  );
+  )
 }

+ 95 - 57
src/features/labor-presets/Components/LaborPresetPickerDialog.tsx

@@ -1,24 +1,38 @@
-"use client";
+'use client'
 
-import { useState } from "react";
-import { useTranslations } from "next-intl";
-import { Input } from "@/components/ui/input";
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
-import { ChevronDown, ChevronRight, Search } from "lucide-react";
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Input } from '@/components/ui/input'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { ChevronDown, ChevronRight, Search } from 'lucide-react'
 
 export interface LaborPresetOption {
-  id: string;
-  name: string;
-  description: string | null;
-  items: { description: string; hours: number; rate: number; pricingType?: string; sortOrder: number }[];
-  parts?: { name: string; partNumber: string | null; quantity: number; unitPrice: number; inventoryPartId: string | null; sortOrder: number }[];
+  id: string
+  name: string
+  description: string | null
+  items: {
+    description: string
+    hours: number
+    rate: number
+    pricingType?: string
+    sortOrder: number
+  }[]
+  parts?: {
+    name: string
+    partNumber: string | null
+    quantity: number
+    unit: string | null
+    unitPrice: number
+    inventoryPartId: string | null
+    sortOrder: number
+  }[]
 }
 
 interface LaborPresetPickerDialogProps {
-  open: boolean;
-  onOpenChange: (open: boolean) => void;
-  laborPresets: LaborPresetOption[];
-  onSelectPreset: (preset: LaborPresetOption) => void;
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  laborPresets: LaborPresetOption[]
+  onSelectPreset: (preset: LaborPresetOption) => void
 }
 
 export function LaborPresetPickerDialog({
@@ -27,38 +41,37 @@ export function LaborPresetPickerDialog({
   laborPresets,
   onSelectPreset,
 }: LaborPresetPickerDialogProps) {
-  const t = useTranslations("laborPresets.picker");
-  const [search, setSearch] = useState("");
-  const [expandedId, setExpandedId] = useState<string | null>(null);
+  const t = useTranslations('laborPresets.picker')
+  const [search, setSearch] = useState('')
+  const [expandedId, setExpandedId] = useState<string | null>(null)
 
   const filtered = laborPresets.filter((p) => {
-    if (!search) return true;
-    const q = search.toLowerCase();
+    if (!search) return true
+    const q = search.toLowerCase()
     return (
-      p.name.toLowerCase().includes(q) ||
-      (p.description && p.description.toLowerCase().includes(q))
-    );
-  });
+      p.name.toLowerCase().includes(q) || (p.description && p.description.toLowerCase().includes(q))
+    )
+  })
 
   return (
     <Dialog
       open={open}
       onOpenChange={(v) => {
-        onOpenChange(v);
+        onOpenChange(v)
         if (!v) {
-          setSearch("");
-          setExpandedId(null);
+          setSearch('')
+          setExpandedId(null)
         }
       }}
     >
       <DialogContent className="sm:max-w-2xl" aria-describedby={undefined}>
         <DialogHeader>
-          <DialogTitle>{t("title")}</DialogTitle>
+          <DialogTitle>{t('title')}</DialogTitle>
         </DialogHeader>
         <div className="relative">
           <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
           <Input
-            placeholder={t("search")}
+            placeholder={t('search')}
             value={search}
             onChange={(e) => setSearch(e.target.value)}
             className="pl-9"
@@ -66,28 +79,31 @@ export function LaborPresetPickerDialog({
         </div>
         <div className="max-h-75 overflow-y-auto space-y-1">
           {filtered.map((preset) => {
-            const hourlyItems = preset.items.filter((i) => i.pricingType !== "service");
-            const serviceItems = preset.items.filter((i) => i.pricingType === "service");
-            const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0);
-            const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0);
-            const isExpanded = expandedId === preset.id;
-            const hasDetails = preset.items.length > 1 || (preset.parts && preset.parts.length > 0);
+            const hourlyItems = preset.items.filter((i) => i.pricingType !== 'service')
+            const serviceItems = preset.items.filter((i) => i.pricingType === 'service')
+            const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0)
+            const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0)
+            const isExpanded = expandedId === preset.id
+            const hasDetails = preset.items.length > 1 || (preset.parts && preset.parts.length > 0)
 
             return (
-              <div key={preset.id} className="rounded-md border border-transparent hover:border-border">
+              <div
+                key={preset.id}
+                className="rounded-md border border-transparent hover:border-border"
+              >
                 <div
                   role="button"
                   tabIndex={0}
                   className="w-full text-left rounded-md px-2.5 py-2 hover:bg-accent transition-colors flex items-center justify-between gap-4 cursor-pointer"
                   onClick={() => {
-                    onSelectPreset(preset);
-                    onOpenChange(false);
+                    onSelectPreset(preset)
+                    onOpenChange(false)
                   }}
                   onKeyDown={(e) => {
-                    if (e.key === "Enter" || e.key === " ") {
-                      e.preventDefault();
-                      onSelectPreset(preset);
-                      onOpenChange(false);
+                    if (e.key === 'Enter' || e.key === ' ') {
+                      e.preventDefault()
+                      onSelectPreset(preset)
+                      onOpenChange(false)
                     }
                   }}
                 >
@@ -100,22 +116,28 @@ export function LaborPresetPickerDialog({
                     )}
                   </div>
                   <div className="flex items-center gap-3 shrink-0 text-xs text-muted-foreground">
-                    {hasDetails && <span>{t("items", { count: preset.items.length })}</span>}
-                    {totalHours > 0 && <span>{t("totalHours", { hours: totalHours.toFixed(1) })}</span>}
-                    {totalUnits > 0 && <span>{t("totalUnits", { units: totalUnits })}</span>}
+                    {hasDetails && <span>{t('items', { count: preset.items.length })}</span>}
+                    {totalHours > 0 && (
+                      <span>{t('totalHours', { hours: totalHours.toFixed(1) })}</span>
+                    )}
+                    {totalUnits > 0 && <span>{t('totalUnits', { units: totalUnits })}</span>}
                     {preset.parts && preset.parts.length > 0 && (
-                      <span>{t("totalParts", { count: preset.parts.length })}</span>
+                      <span>{t('totalParts', { count: preset.parts.length })}</span>
                     )}
                     {hasDetails && (
                       <button
                         type="button"
                         className="p-1 hover:text-foreground"
                         onClick={(e) => {
-                          e.stopPropagation();
-                          setExpandedId(isExpanded ? null : preset.id);
+                          e.stopPropagation()
+                          setExpandedId(isExpanded ? null : preset.id)
                         }}
                       >
-                        {isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
+                        {isExpanded ? (
+                          <ChevronDown className="h-3.5 w-3.5" />
+                        ) : (
+                          <ChevronRight className="h-3.5 w-3.5" />
+                        )}
                       </button>
                     )}
                   </div>
@@ -123,17 +145,33 @@ export function LaborPresetPickerDialog({
                 {isExpanded && hasDetails && (
                   <div className="px-3 pb-2 space-y-0.5">
                     {preset.items.map((item, i) => (
-                      <div key={i} className="flex items-center justify-between text-xs text-muted-foreground py-0.5 pl-2 border-l-2 border-muted">
+                      <div
+                        key={i}
+                        className="flex items-center justify-between text-xs text-muted-foreground py-0.5 pl-2 border-l-2 border-muted"
+                      >
                         <span className="truncate">{item.description}</span>
-                        <span className="shrink-0 ml-2">{item.pricingType === "service" ? `${item.hours} unit${item.hours !== 1 ? "s" : ""}` : `${item.hours}h`}</span>
+                        <span className="shrink-0 ml-2">
+                          {item.pricingType === 'service'
+                            ? `${item.hours} unit${item.hours !== 1 ? 's' : ''}`
+                            : `${item.hours}h`}
+                        </span>
                       </div>
                     ))}
                     {preset.parts && preset.parts.length > 0 && (
                       <>
                         {preset.parts.map((part, i) => (
-                          <div key={`part-${i}`} className="flex items-center justify-between text-xs text-muted-foreground py-0.5 pl-2 border-l-2 border-blue-400/50">
-                            <span className="truncate">{part.name}{part.partNumber ? ` (${part.partNumber})` : ''}</span>
-                            <span className="shrink-0 ml-2">x{part.quantity}</span>
+                          <div
+                            key={`part-${i}`}
+                            className="flex items-center justify-between text-xs text-muted-foreground py-0.5 pl-2 border-l-2 border-blue-400/50"
+                          >
+                            <span className="truncate">
+                              {part.name}
+                              {part.partNumber ? ` (${part.partNumber})` : ''}
+                            </span>
+                            <span className="shrink-0 ml-2">
+                              x{part.quantity}
+                              {part.unit ? ` ${part.unit}` : ''}
+                            </span>
                           </div>
                         ))}
                       </>
@@ -141,13 +179,13 @@ export function LaborPresetPickerDialog({
                   </div>
                 )}
               </div>
-            );
+            )
           })}
           {filtered.length === 0 && (
-            <p className="py-6 text-center text-sm text-muted-foreground">{t("noPackages")}</p>
+            <p className="py-6 text-center text-sm text-muted-foreground">{t('noPackages')}</p>
           )}
         </div>
       </DialogContent>
     </Dialog>
-  );
+  )
 }

+ 113 - 58
src/features/labor-presets/Components/PresetPartsEditor.tsx

@@ -1,75 +1,114 @@
-"use client";
+'use client'
 
-import { useState } from "react";
-import { useTranslations } from "next-intl";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Package, Plus, Search, Trash2 } from "lucide-react";
-import { InventorySearchDialog, type InventoryPartOption } from "./InventorySearchDialog";
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Package, Plus, Search, Trash2 } from 'lucide-react'
+import { InventorySearchDialog, type InventoryPartOption } from './InventorySearchDialog'
+import {
+  PartNameSuggestions,
+  type PartSuggestion,
+} from '@/features/inventory/Components/PartNameSuggestions'
+import { resolvePartPrice } from '@/features/inventory/Lib/partPricing'
 
 export interface PresetPartItem {
-  name: string;
-  partNumber: string;
-  quantity: number;
-  unitPrice: number;
-  inventoryPartId: string;
+  name: string
+  partNumber: string
+  quantity: number
+  /** Unit of measure snapshotted from the picked inventory part. */
+  unit: string | null
+  unitPrice: number
+  inventoryPartId: string
 }
 
 interface PresetPartsEditorProps {
-  parts: PresetPartItem[];
-  onPartsChange: (parts: PresetPartItem[]) => void;
-  inventoryParts?: InventoryPartOption[];
-  currencyCode?: string;
+  parts: PresetPartItem[]
+  onPartsChange: (parts: PresetPartItem[]) => void
+  inventoryParts?: InventoryPartOption[]
+  currencyCode?: string
 }
 
 export function PresetPartsEditor({
   parts,
   onPartsChange,
   inventoryParts,
+  currencyCode = 'USD',
 }: PresetPartsEditorProps) {
-  const t = useTranslations("laborPresets");
-  const [pickerOpen, setPickerOpen] = useState(false);
+  const t = useTranslations('laborPresets')
+  const [pickerOpen, setPickerOpen] = useState(false)
 
   const updatePart = (index: number, field: keyof PresetPartItem, value: string | number) => {
-    const updated = [...parts];
-    updated[index] = { ...updated[index], [field]: value };
-    onPartsChange(updated);
-  };
+    const updated = [...parts]
+    updated[index] = { ...updated[index], [field]: value }
+    onPartsChange(updated)
+  }
 
   const addPart = () => {
-    onPartsChange([...parts, { name: "", partNumber: "", quantity: 1, unitPrice: 0, inventoryPartId: "" }]);
-  };
+    onPartsChange([
+      ...parts,
+      { name: '', partNumber: '', quantity: 1, unit: null, unitPrice: 0, inventoryPartId: '' },
+    ])
+  }
 
   const removePart = (index: number) => {
-    onPartsChange(parts.filter((_, i) => i !== index));
-  };
+    onPartsChange(parts.filter((_, i) => i !== index))
+  }
+
+  // Same inline typeahead as the work order and quote editors: typing a name
+  // that exists in stock surfaces the match, and picking it links the row so
+  // applying the preset later books the part out of inventory.
+  const applySuggestion = (index: number, picked: PartSuggestion) => {
+    const { unitPrice } = resolvePartPrice(picked)
+    onPartsChange(
+      parts.map((row, j) =>
+        j === index
+          ? {
+              ...row,
+              name: picked.name,
+              partNumber: picked.partNumber ?? '',
+              unit: picked.unit ?? null,
+              unitPrice,
+              inventoryPartId: picked.id,
+            }
+          : row
+      )
+    )
+  }
 
   const handleInventorySelect = (ip: InventoryPartOption) => {
-    const price = ip.sellPrice > 0 ? ip.sellPrice : ip.unitCost;
+    const price = ip.sellPrice > 0 ? ip.sellPrice : ip.unitCost
     onPartsChange([
       ...parts,
-      { name: ip.name, partNumber: ip.partNumber || "", quantity: 1, unitPrice: price, inventoryPartId: ip.id },
-    ]);
-  };
+      {
+        name: ip.name,
+        partNumber: ip.partNumber || '',
+        quantity: 1,
+        unit: ip.unit ?? null,
+        unitPrice: price,
+        inventoryPartId: ip.id,
+      },
+    ])
+  }
 
   return (
     <div className="space-y-2">
       <div className="flex items-center justify-between">
         <Label className="flex items-center gap-1.5">
           <Package className="h-3.5 w-3.5" />
-          {t("form.partsTitle")}
+          {t('form.partsTitle')}
         </Label>
         <div className="flex gap-1.5">
           {inventoryParts && inventoryParts.length > 0 && (
             <Button type="button" variant="outline" size="sm" onClick={() => setPickerOpen(true)}>
               <Search className="mr-1 h-3.5 w-3.5" />
-              {t("form.importFromInventory")}
+              {t('form.importFromInventory')}
             </Button>
           )}
           <Button type="button" variant="outline" size="sm" onClick={addPart}>
             <Plus className="mr-1 h-3.5 w-3.5" />
-            {t("form.addPart")}
+            {t('form.addPart')}
           </Button>
         </div>
       </div>
@@ -77,41 +116,57 @@ export function PresetPartsEditor({
       {parts.length > 0 && (
         <>
           <div className="hidden grid-cols-[2fr_1fr_0.5fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
-            <span>{t("form.partName")}</span>
-            <span>{t("form.partNumber")}</span>
-            <span>{t("form.partQty")}</span>
-            <span>{t("form.partPrice")}</span>
+            <span>{t('form.partName')}</span>
+            <span>{t('form.partNumber')}</span>
+            <span>{t('form.partQty')}</span>
+            <span>{t('form.partPrice')}</span>
             <span />
           </div>
 
           {parts.map((part, i) => (
             <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_0.5fr_1fr_auto]">
+              <div className="relative col-span-2 sm:col-span-1">
+                <Input
+                  placeholder={t('form.partName')}
+                  value={part.name}
+                  onChange={(e) => updatePart(i, 'name', e.target.value)}
+                />
+                <PartNameSuggestions
+                  query={part.name}
+                  parts={inventoryParts ?? []}
+                  disabled={!!part.inventoryPartId}
+                  currencyCode={currencyCode}
+                  onSelect={(picked) => applySuggestion(i, picked)}
+                />
+              </div>
               <Input
-                placeholder={t("form.partName")}
-                value={part.name}
-                onChange={(e) => updatePart(i, "name", e.target.value)}
-                className="col-span-2 sm:col-span-1"
-              />
-              <Input
-                placeholder={t("form.partNumber")}
+                placeholder={t('form.partNumber')}
                 value={part.partNumber}
-                onChange={(e) => updatePart(i, "partNumber", e.target.value)}
-              />
-              <Input
-                type="number"
-                min="0"
-                step="1"
-                placeholder={t("form.partQty")}
-                value={part.quantity}
-                onChange={(e) => updatePart(i, "quantity", e.target.value)}
+                onChange={(e) => updatePart(i, 'partNumber', e.target.value)}
               />
+              <div className="relative">
+                <Input
+                  type="number"
+                  min="0"
+                  step="any"
+                  placeholder={t('form.partQty')}
+                  value={part.quantity}
+                  onChange={(e) => updatePart(i, 'quantity', e.target.value)}
+                  className={part.unit ? 'pr-9' : undefined}
+                />
+                {part.unit && (
+                  <span className="pointer-events-none absolute right-2 top-1/2 max-w-8 -translate-y-1/2 truncate text-xs text-muted-foreground">
+                    {part.unit}
+                  </span>
+                )}
+              </div>
               <Input
                 type="number"
                 min="0"
                 step="0.01"
-                placeholder={t("form.partPrice")}
+                placeholder={t('form.partPrice')}
                 value={part.unitPrice}
-                onChange={(e) => updatePart(i, "unitPrice", e.target.value)}
+                onChange={(e) => updatePart(i, 'unitPrice', e.target.value)}
               />
               <Button
                 type="button"
@@ -119,7 +174,7 @@ export function PresetPartsEditor({
                 size="icon"
                 className="h-9 w-9 text-muted-foreground hover:text-destructive"
                 onClick={() => removePart(i)}
-                aria-label={t("actions.delete")}
+                aria-label={t('actions.delete')}
               >
                 <Trash2 className="h-4 w-4" />
               </Button>
@@ -145,5 +200,5 @@ export function PresetPartsEditor({
         />
       )}
     </div>
-  );
+  )
 }

+ 19 - 17
src/features/labor-presets/Schema/laborPresetSchema.ts

@@ -1,35 +1,37 @@
-import { z } from "zod";
+import { z } from 'zod'
 
 export const laborPresetItemSchema = z.object({
-  description: z.string().min(1, "Description is required"),
-  hours: z.coerce.number().min(0, "Hours must be 0 or more").default(0),
-  rate: z.coerce.number().min(0, "Rate must be 0 or more").default(0),
-  pricingType: z.enum(["hourly", "service"]).default("hourly"),
+  description: z.string().min(1, 'Description is required'),
+  hours: z.coerce.number().min(0, 'Hours must be 0 or more').default(0),
+  rate: z.coerce.number().min(0, 'Rate must be 0 or more').default(0),
+  pricingType: z.enum(['hourly', 'service']).default('hourly'),
   sortOrder: z.coerce.number().int().min(0).default(0),
-});
+})
 
 export const laborPresetPartSchema = z.object({
-  name: z.string().min(1, "Part name is required"),
+  name: z.string().min(1, 'Part name is required'),
   partNumber: z.string().optional(),
-  quantity: z.coerce.number().min(0, "Quantity must be 0 or more").default(1),
-  unitPrice: z.coerce.number().min(0, "Price must be 0 or more").default(0),
+  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(),
+  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),
-});
+})
 
-export type LaborPresetPartInput = z.infer<typeof laborPresetPartSchema>;
+export type LaborPresetPartInput = z.infer<typeof laborPresetPartSchema>
 
 export const createLaborPresetSchema = z.object({
   name: z.string().optional(),
   description: z.string().optional(),
-  items: z.array(laborPresetItemSchema).min(1, "At least one item is required"),
+  items: z.array(laborPresetItemSchema).min(1, 'At least one item is required'),
   parts: z.array(laborPresetPartSchema).optional().default([]),
-});
+})
 
 export const updateLaborPresetSchema = createLaborPresetSchema.partial().extend({
   id: z.string(),
-});
+})
 
-export type LaborPresetItemInput = z.infer<typeof laborPresetItemSchema>;
-export type CreateLaborPresetInput = z.infer<typeof createLaborPresetSchema>;
-export type UpdateLaborPresetInput = z.infer<typeof updateLaborPresetSchema>;
+export type LaborPresetItemInput = z.infer<typeof laborPresetItemSchema>
+export type CreateLaborPresetInput = z.infer<typeof createLaborPresetSchema>
+export type UpdateLaborPresetInput = z.infer<typeof updateLaborPresetSchema>

+ 117 - 118
src/features/onboarding/Lib/sampleData.ts

@@ -1,41 +1,41 @@
-import { db } from "@/lib/db";
-import { resolveInvoicePrefix } from "@/lib/invoice-utils";
-import type { SampleDataIds } from "./onboardingKeys";
-import type { Translator } from "./onboardingDefaults";
+import { db } from '@/lib/db'
+import { resolveInvoicePrefix } from '@/lib/invoice-utils'
+import type { SampleDataIds } from './onboardingKeys'
+import type { Translator } from './onboardingDefaults'
 
 /** The slice of an inspection template the seed copies items from. */
 interface TemplateForSeed {
-  id: string;
-  severityScale: string;
-  country: string | null;
+  id: string
+  severityScale: string
+  country: string | null
   sections: {
-    name: string;
-    code: string | null;
+    name: string
+    code: string | null
     items: {
-      name: string;
-      description: string | null;
-      code: string | null;
-      sortOrder: number;
-      inputType: string;
-      unit: string | null;
-      minValue: number | null;
-      maxValue: number | null;
-      choices: string[];
-      required: boolean;
-      photoRequired: boolean;
-      defaultSeverity: string | null;
-      defectSuggestions: string[];
-    }[];
-  }[];
+      name: string
+      description: string | null
+      code: string | null
+      sortOrder: number
+      inputType: string
+      unit: string | null
+      minValue: number | null
+      maxValue: number | null
+      choices: string[]
+      required: boolean
+      photoRequired: boolean
+      defaultSeverity: string | null
+      defectSuggestions: string[]
+    }[]
+  }[]
 }
 
 const daysAgo = (n: number) => {
-  const d = new Date();
-  d.setDate(d.getDate() - n);
-  return d;
-};
+  const d = new Date()
+  d.setDate(d.getDate() - n)
+  return d
+}
 
-const daysAhead = (n: number) => daysAgo(-n);
+const daysAhead = (n: number) => daysAgo(-n)
 
 /**
  * Seeds a small, clearly-labelled demo dataset for a brand-new organization:
@@ -54,113 +54,112 @@ export async function seedSampleData(
   t: Translator,
   template: TemplateForSeed
 ): Promise<SampleDataIds> {
-  const prefix = t("sample.prefix");
-  const label = (name: string) => `${prefix} ${name}`;
-  const invoicePrefix = resolveInvoicePrefix("{year}-");
-  const hourlyRate = 95;
+  const prefix = t('sample.prefix')
+  const label = (name: string) => `${prefix} ${name}`
+  const invoicePrefix = resolveInvoicePrefix('{year}-')
+  const hourlyRate = 95
 
   return db.$transaction(async (tx) => {
     // ── Customers ────────────────────────────────────────────────────────
     const anna = await tx.customer.create({
       data: {
-        name: label("Anna Bergström"),
-        customerNumber: "1",
-        email: "anna.sample@example.com",
-        phone: "+1 555 0101",
+        name: label('Anna Bergström'),
+        customerNumber: '1',
+        email: 'anna.sample@example.com',
+        phone: '+1 555 0101',
         userId,
         organizationId,
       },
-    });
+    })
     const marco = await tx.customer.create({
       data: {
-        name: label("Marco Rossi"),
-        customerNumber: "2",
-        email: "marco.sample@example.com",
-        phone: "+1 555 0102",
+        name: label('Marco Rossi'),
+        customerNumber: '2',
+        email: 'marco.sample@example.com',
+        phone: '+1 555 0102',
         userId,
         organizationId,
       },
-    });
+    })
     const nordvik = await tx.customer.create({
       data: {
-        name: label("Nordvik Logistics"),
-        customerNumber: "3",
-        company: label("Nordvik Logistics"),
-        email: "fleet.sample@example.com",
-        phone: "+1 555 0103",
+        name: label('Nordvik Logistics'),
+        customerNumber: '3',
+        company: label('Nordvik Logistics'),
+        email: 'fleet.sample@example.com',
+        phone: '+1 555 0103',
         userId,
         organizationId,
       },
-    });
+    })
 
     // ── Vehicles ─────────────────────────────────────────────────────────
     const golf = await tx.vehicle.create({
       data: {
-        make: "Volkswagen",
-        model: "Golf",
+        make: 'Volkswagen',
+        model: 'Golf',
         year: 2018,
-        licensePlate: "SAMPLE-1",
+        licensePlate: 'SAMPLE-1',
         mileage: 86500,
-        fuelType: "gasoline",
+        fuelType: 'gasoline',
         customerId: anna.id,
         userId,
         organizationId,
       },
-    });
+    })
     const rav4 = await tx.vehicle.create({
       data: {
-        make: "Toyota",
-        model: "RAV4",
+        make: 'Toyota',
+        model: 'RAV4',
         year: 2020,
-        licensePlate: "SAMPLE-2",
+        licensePlate: 'SAMPLE-2',
         mileage: 45200,
-        fuelType: "hybrid",
+        fuelType: 'hybrid',
         customerId: marco.id,
         userId,
         organizationId,
       },
-    });
+    })
     const transit = await tx.vehicle.create({
       data: {
-        make: "Ford",
-        model: "Transit",
+        make: 'Ford',
+        model: 'Transit',
         year: 2016,
-        licensePlate: "SAMPLE-3",
+        licensePlate: 'SAMPLE-3',
         mileage: 158000,
-        fuelType: "diesel",
+        fuelType: 'diesel',
         customerId: nordvik.id,
         userId,
         organizationId,
       },
-    });
+    })
     const bmw = await tx.vehicle.create({
       data: {
-        make: "BMW",
-        model: "320d",
+        make: 'BMW',
+        model: '320d',
         year: 2019,
-        licensePlate: "SAMPLE-4",
+        licensePlate: 'SAMPLE-4',
         mileage: 98400,
-        fuelType: "diesel",
+        fuelType: 'diesel',
         customerId: anna.id,
         userId,
         organizationId,
       },
-    });
+    })
 
     // ── Work orders ──────────────────────────────────────────────────────
     // Completed job with parts + labor, invoiced and paid.
     const oilParts = [
-      { name: t("sample.parts.oilFilter"), quantity: 1, unitPrice: 14.9, total: 14.9 },
-      { name: t("sample.parts.engineOil"), quantity: 4, unitPrice: 11.5, total: 46 },
-    ];
-    const oilLaborTotal = 0.8 * hourlyRate;
-    const oilSubtotal =
-      oilParts.reduce((sum, p) => sum + p.total, 0) + oilLaborTotal;
+      { name: t('sample.parts.oilFilter'), quantity: 1, unitPrice: 14.9, total: 14.9 },
+      { name: t('sample.parts.engineOil'), quantity: 4, unit: 'l', unitPrice: 11.5, total: 46 },
+    ]
+    const oilLaborTotal = 0.8 * hourlyRate
+    const oilSubtotal = oilParts.reduce((sum, p) => sum + p.total, 0) + oilLaborTotal
     const oilJob = await tx.serviceRecord.create({
       data: {
-        title: label(t("sample.oilService")),
-        type: "maintenance",
-        status: "completed",
+        title: label(t('sample.oilService')),
+        type: 'maintenance',
+        status: 'completed',
         mileage: 86200,
         serviceDate: daysAgo(12),
         invoiceDate: daysAgo(12),
@@ -175,25 +174,25 @@ export async function seedSampleData(
         laborItems: {
           create: [
             {
-              description: t("sample.oilService"),
+              description: t('sample.oilService'),
               hours: 0.8,
               rate: hourlyRate,
               total: oilLaborTotal,
-              pricingType: "hourly",
+              pricingType: 'hourly',
             },
           ],
         },
       },
-    });
+    })
 
     // Job on the lift right now.
-    const brakeLaborTotal = 1.5 * hourlyRate;
-    const brakeSubtotal = 62 + brakeLaborTotal;
+    const brakeLaborTotal = 1.5 * hourlyRate
+    const brakeSubtotal = 62 + brakeLaborTotal
     const brakeJob = await tx.serviceRecord.create({
       data: {
-        title: label(t("sample.brakeJob")),
-        type: "repair",
-        status: "in-progress",
+        title: label(t('sample.brakeJob')),
+        type: 'repair',
+        status: 'in-progress',
         mileage: 45200,
         serviceDate: new Date(),
         startDateTime: new Date(),
@@ -206,7 +205,7 @@ export async function seedSampleData(
         partItems: {
           create: [
             {
-              name: t("sample.parts.brakePads"),
+              name: t('sample.parts.brakePads'),
               quantity: 1,
               unitPrice: 62,
               total: 62,
@@ -216,39 +215,39 @@ export async function seedSampleData(
         laborItems: {
           create: [
             {
-              description: t("sample.brakeJob"),
+              description: t('sample.brakeJob'),
               hours: 1.5,
               rate: hourlyRate,
               total: brakeLaborTotal,
-              pricingType: "hourly",
+              pricingType: 'hourly',
             },
           ],
         },
       },
-    });
+    })
 
     // Booked but not started.
     const annualJob = await tx.serviceRecord.create({
       data: {
-        title: label(t("sample.annualService")),
-        type: "maintenance",
-        status: "pending",
+        title: label(t('sample.annualService')),
+        type: 'maintenance',
+        status: 'pending',
         serviceDate: daysAhead(3),
         startDateTime: daysAhead(3),
         invoiceNumber: `${invoicePrefix}1003`,
         vehicleId: transit.id,
         organizationId,
       },
-    });
+    })
 
     // ── Quote ────────────────────────────────────────────────────────────
-    const quoteLaborTotal = 2 * hourlyRate;
-    const quoteSubtotal = 178 + quoteLaborTotal;
+    const quoteLaborTotal = 2 * hourlyRate
+    const quoteSubtotal = 178 + quoteLaborTotal
     const quote = await tx.quote.create({
       data: {
-        quoteNumber: "QT-1001",
-        title: label(t("sample.quoteTitle")),
-        status: "sent",
+        quoteNumber: 'QT-1001',
+        title: label(t('sample.quoteTitle')),
+        status: 'sent',
         validUntil: daysAhead(30),
         subtotal: quoteSubtotal,
         totalAmount: quoteSubtotal,
@@ -259,7 +258,7 @@ export async function seedSampleData(
         partItems: {
           create: [
             {
-              name: t("sample.parts.shockAbsorber"),
+              name: t('sample.parts.shockAbsorber'),
               quantity: 2,
               unitPrice: 89,
               total: 178,
@@ -269,16 +268,16 @@ export async function seedSampleData(
         laborItems: {
           create: [
             {
-              description: t("sample.quoteTitle"),
+              description: t('sample.quoteTitle'),
               hours: 2,
               rate: hourlyRate,
               total: quoteLaborTotal,
-              pricingType: "hourly",
+              pricingType: 'hourly',
             },
           ],
         },
       },
-    });
+    })
 
     // ── Inspection ───────────────────────────────────────────────────────
     // Items are copied from the installed default template exactly like
@@ -286,7 +285,7 @@ export async function seedSampleData(
     // screen shows what a partially-done checklist looks like.
     const inspection = await tx.inspection.create({
       data: {
-        status: "in_progress",
+        status: 'in_progress',
         mileage: 86500,
         severityScale: template.severityScale,
         country: template.country,
@@ -294,22 +293,22 @@ export async function seedSampleData(
         templateId: template.id,
         organizationId,
       },
-    });
+    })
 
-    let inspected = 0;
+    let inspected = 0
     const items = template.sections.flatMap((section, sIdx) =>
       section.items.map((item) => {
         // Grade the first handful of plain condition checks, leave the rest
         // untouched; one gets flagged so the attention state is visible too.
-        let condition = "not_inspected";
-        let notes: string | null = null;
-        if (item.inputType === "condition" && inspected < 5) {
-          inspected += 1;
+        let condition = 'not_inspected'
+        let notes: string | null = null
+        if (item.inputType === 'condition' && inspected < 5) {
+          inspected += 1
           if (inspected === 4) {
-            condition = "attention";
-            notes = t("sample.inspectionNote");
+            condition = 'attention'
+            notes = t('sample.inspectionNote')
           } else {
-            condition = "pass";
+            condition = 'pass'
           }
         }
         return {
@@ -331,11 +330,11 @@ export async function seedSampleData(
           condition,
           notes,
           inspectionId: inspection.id,
-        };
+        }
       })
-    );
+    )
     if (items.length > 0) {
-      await tx.inspectionItem.createMany({ data: items });
+      await tx.inspectionItem.createMany({ data: items })
     }
 
     return {
@@ -344,6 +343,6 @@ export async function seedSampleData(
       serviceRecords: [oilJob.id, brakeJob.id, annualJob.id],
       quotes: [quote.id],
       inspections: [inspection.id],
-    };
-  });
+    }
+  })
 }

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

@@ -477,6 +477,7 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
               partNumber: p.partNumber,
               name: p.name,
               quantity: p.quantity,
+              unit: p.unit,
               unitCost: p.unitCost,
               markupPercent: p.markupPercent,
               unitPrice: p.unitPrice,
@@ -577,7 +578,10 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
         action: 'quote.convert',
         entity: 'Quote',
         entityId: result.convertedFromQuoteId,
-        details: { key: 'quote_convert', params: { ref: result.convertedFromQuoteId, serviceRecordId: result.id } },
+        details: {
+          key: 'quote_convert',
+          params: { ref: result.convertedFromQuoteId, serviceRecordId: result.id },
+        },
         metadata: { quoteId: result.convertedFromQuoteId, serviceRecordId: result.id },
       }),
     }

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

@@ -1,6 +1,7 @@
 import React from 'react'
 import { Document, Page, Text, View, Image } from '@react-pdf/renderer'
 import { formatCurrency, formatDateForPdf, DEFAULT_DATE_FORMAT } from '@/lib/format'
+import { formatQuantity } from '@/lib/format-quantity'
 import { createStyles, gray, getFontBold } from '@/features/vehicles/Components/invoice-pdf/styles'
 import { HtmlToPdf } from '@/features/vehicles/Components/invoice-pdf/Notes'
 import { CustomFields } from '@/features/vehicles/Components/invoice-pdf/CustomFields'
@@ -51,6 +52,7 @@ interface QuoteData {
     partNumber: string | null
     name: string
     quantity: number
+    unit?: string | null
     unitPrice: number
     total: number
     excluded?: boolean
@@ -723,7 +725,7 @@ export function QuotePDF({
                     ...(p.excluded ? { textDecoration: 'line-through' } : {}),
                   }}
                 >
-                  {p.quantity}
+                  {formatQuantity(p.quantity, p.unit)}
                 </Text>
                 <Text
                   style={{

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

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

+ 29 - 9
src/features/quotes/Components/QuotePartsEditor.tsx

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Package, Plus, Trash2 } from 'lucide-react'
 import { useFormatCurrency } from '@/components/currency-settings-context'
+import { cn } from '@/lib/utils'
 import { InventoryPickerDialog } from '@/features/vehicles/Components/service-edit/InventoryPickerDialog'
 import type { InventoryPartOption } from '@/features/vehicles/Components/service-edit/form-types'
 import type { QuotePartInput } from './quote-page-types'
@@ -35,7 +36,11 @@ const QuotePartRow = memo(function QuotePartRow({
   part: QuotePartInput
   index: number
   currencyCode: string
-  onUpdate: (index: number, field: keyof QuotePartInput, value: string | number | boolean) => void
+  onUpdate: (
+    index: number,
+    field: keyof QuotePartInput,
+    value: string | number | boolean | null
+  ) => void
   onDelete: (index: number) => void
   inventoryParts: PartSuggestion[]
   onSelectSuggestion: (index: number, part: PartSuggestion) => void
@@ -80,13 +85,22 @@ const QuotePartRow = memo(function QuotePartRow({
           onSelect={(picked) => onSelectSuggestion(index, picked)}
         />
       </div>
-      <Input
-        type="number"
-        min="0"
-        step="1"
-        value={part.quantity}
-        onChange={(e) => onUpdate(index, 'quantity', e.target.value)}
-      />
+      {/* The unit rides along from the stocked part; shown, not edited. */}
+      <div className="relative">
+        <Input
+          type="number"
+          min="0"
+          step="any"
+          value={part.quantity}
+          onChange={(e) => onUpdate(index, 'quantity', e.target.value)}
+          className={cn(part.unit && 'pr-9')}
+        />
+        {part.unit && (
+          <span className="pointer-events-none absolute right-2 top-1/2 max-w-8 -translate-y-1/2 truncate text-xs text-muted-foreground">
+            {part.unit}
+          </span>
+        )}
+      </div>
       <Input
         type="number"
         min="0"
@@ -139,7 +153,11 @@ interface QuotePartsEditorProps {
   partItems: QuotePartInput[]
   currencyCode: string
   partsSubtotal: number
-  onUpdate: (index: number, field: keyof QuotePartInput, value: string | number | boolean) => void
+  onUpdate: (
+    index: number,
+    field: keyof QuotePartInput,
+    value: string | number | boolean | null
+  ) => void
   onDelete: (index: number) => void
   onAdd: () => void
   onAddBulk?: (items: QuotePartInput[]) => void
@@ -177,6 +195,7 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
       const unitCost = Number(picked.unitCost) || 0
       onUpdate(index, 'name', picked.name)
       onUpdate(index, 'partNumber', picked.partNumber ?? '')
+      onUpdate(index, 'unit', picked.unit ?? null)
       onUpdate(index, 'unitCost', unitCost)
       onUpdate(index, 'markupPercent', markupFromCostAndPrice(unitCost, unitPrice))
       onUpdate(index, 'unitPrice', unitPrice)
@@ -263,6 +282,7 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
                 partNumber: picked.partNumber ?? '',
                 name: picked.name,
                 quantity: picked.quantity,
+                unit: picked.unit ?? null,
                 unitCost: Number(picked.unitCost) || 0,
                 markupPercent: Number(picked.markupPercent) || 0,
                 unitPrice: picked.unitPrice,

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

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

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

@@ -70,6 +70,7 @@ export function useQuoteFormState({
       partNumber: p.partNumber || '',
       name: p.name,
       quantity: p.quantity,
+      unit: p.unit ?? null,
       unitCost: p.unitCost ?? 0,
       markupPercent: p.markupPercent ?? 0,
       unitPrice: p.unitPrice,
@@ -188,7 +189,7 @@ export function useQuoteFormState({
   //   unitPrice = unitCost × (1 + markupPercent / 100)
   // Editing any one of the three keeps the others consistent.
   const updatePart = useCallback(
-    (index: number, field: keyof QuotePartInput, value: string | number | boolean) => {
+    (index: number, field: keyof QuotePartInput, value: string | number | boolean | null) => {
       setPartItems((prev) => {
         const updated = [...prev]
         const part = { ...updated[index], [field]: value }

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

@@ -4,6 +4,8 @@ export const quotePartSchema = z.object({
   partNumber: z.string().optional(),
   name: z.string().min(1, 'Part name is required'),
   quantity: z.coerce.number().min(0).default(1),
+  /** Unit of measure snapshotted from the picked inventory part. */
+  unit: z.string().nullish(),
   unitCost: z.coerce.number().min(0).default(0),
   markupPercent: z.coerce.number().min(0).default(0),
   unitPrice: z.coerce.number().min(0).default(0),

+ 900 - 777
src/features/reports/Actions/reportActions.ts

@@ -1,826 +1,949 @@
-"use server";
+'use server'
+
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { netLineTotal } from '@/lib/tax'
+
+export async function getRevenueReport(params: { startDate?: string; endDate?: string }) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const start = params.startDate
+        ? new Date(params.startDate)
+        : new Date(new Date().getFullYear(), 0, 1)
+      const end = params.endDate ? new Date(params.endDate) : new Date()
+      end.setHours(23, 59, 59, 999)
+
+      const records = await db.serviceRecord.findMany({
+        where: {
+          organizationId,
+          startDateTime: { gte: start, lte: end },
+        },
+        select: {
+          serviceDate: true,
+          startDateTime: true,
+          totalAmount: true,
+          cost: true,
+          type: true,
+          taxRate: true,
+          taxInclusive: true,
+          manuallyPaid: true,
+          payments: { select: { amount: true } },
+          partItems: { select: { unitCost: true, quantity: true, total: true } },
+          laborItems: { select: { total: true } },
+        },
+        orderBy: [{ startDateTime: { sort: 'asc', nulls: 'last' } }, { serviceDate: 'asc' }],
+      })
 
-import { db } from "@/lib/db";
-import { withAuth } from "@/lib/with-auth";
-import { PermissionAction, PermissionSubject } from "@/lib/permissions";
-import { netLineTotal } from "@/lib/tax";
+      // Monthly breakdown.
+      // `revenue` is the gross customer-facing total (what was charged).
+      // `partsRevenue` and `laborRevenue` are NET (pre-tax) so they add up
+      // consistently with `partsCost` (which is always net) for profit math.
+      const monthly: Record<
+        string,
+        {
+          revenue: number
+          collected: number
+          count: number
+          partsCost: number
+          partsRevenue: number
+          laborRevenue: number
+        }
+      > = {}
+      let totalRevenue = 0
+      let totalCollected = 0
+      let totalCount = 0
+      let totalPartsCost = 0
+      let totalPartsRevenue = 0
+      let totalLaborRevenue = 0
+
+      // Type breakdown
+      const byType: Record<string, { revenue: number; count: number }> = {}
+
+      for (const r of records) {
+        const total = r.totalAmount > 0 ? r.totalAmount : r.cost
+        const paid = r.manuallyPaid ? total : r.payments.reduce((s, p) => s + p.amount, 0)
+        const partsCost = r.partItems.reduce((s, p) => s + p.unitCost * p.quantity, 0)
+        const partsRevenue = r.partItems.reduce(
+          (s, p) => s + netLineTotal(p.total, r.taxRate, r.taxInclusive),
+          0
+        )
+        const laborRevenue = r.laborItems.reduce(
+          (s, l) => s + netLineTotal(l.total, r.taxRate, r.taxInclusive),
+          0
+        )
+        const _date = r.startDateTime ?? r.serviceDate
+        const month = `${_date.getFullYear()}-${String(_date.getMonth() + 1).padStart(2, '0')}`
+
+        if (!monthly[month])
+          monthly[month] = {
+            revenue: 0,
+            collected: 0,
+            count: 0,
+            partsCost: 0,
+            partsRevenue: 0,
+            laborRevenue: 0,
+          }
+        monthly[month].revenue += total
+        monthly[month].collected += paid
+        monthly[month].count += 1
+        monthly[month].partsCost += partsCost
+        monthly[month].partsRevenue += partsRevenue
+        monthly[month].laborRevenue += laborRevenue
+
+        totalRevenue += total
+        totalCollected += paid
+        totalCount += 1
+        totalPartsCost += partsCost
+        totalPartsRevenue += partsRevenue
+        totalLaborRevenue += laborRevenue
+
+        if (!byType[r.type]) byType[r.type] = { revenue: 0, count: 0 }
+        byType[r.type].revenue += total
+        byType[r.type].count += 1
+      }
 
-export async function getRevenueReport(params: {
-  startDate?: string;
-  endDate?: string;
-}) {
-  return withAuth(async ({ organizationId }) => {
-    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
-    const end = params.endDate ? new Date(params.endDate) : new Date();
-    end.setHours(23, 59, 59, 999);
-
-    const records = await db.serviceRecord.findMany({
-      where: {
-        organizationId,
-        startDateTime: { gte: start, lte: end },
-      },
-      select: {
-        serviceDate: true,
-        startDateTime: true,
-        totalAmount: true,
-        cost: true,
-        type: true,
-        taxRate: true,
-        taxInclusive: true,
-        manuallyPaid: true,
-        payments: { select: { amount: true } },
-        partItems: { select: { unitCost: true, quantity: true, total: true } },
-        laborItems: { select: { total: true } },
-      },
-      orderBy: [{ startDateTime: { sort: "asc", nulls: "last" } }, { serviceDate: "asc" }],
-    });
-
-    // Monthly breakdown.
-    // `revenue` is the gross customer-facing total (what was charged).
-    // `partsRevenue` and `laborRevenue` are NET (pre-tax) so they add up
-    // consistently with `partsCost` (which is always net) for profit math.
-    const monthly: Record<string, { revenue: number; collected: number; count: number; partsCost: number; partsRevenue: number; laborRevenue: number }> = {};
-    let totalRevenue = 0;
-    let totalCollected = 0;
-    let totalCount = 0;
-    let totalPartsCost = 0;
-    let totalPartsRevenue = 0;
-    let totalLaborRevenue = 0;
-
-    // Type breakdown
-    const byType: Record<string, { revenue: number; count: number }> = {};
-
-    for (const r of records) {
-      const total = r.totalAmount > 0 ? r.totalAmount : r.cost;
-      const paid = r.manuallyPaid ? total : r.payments.reduce((s, p) => s + p.amount, 0);
-      const partsCost = r.partItems.reduce((s, p) => s + (p.unitCost * p.quantity), 0);
-      const partsRevenue = r.partItems.reduce(
-        (s, p) => s + netLineTotal(p.total, r.taxRate, r.taxInclusive),
-        0,
-      );
-      const laborRevenue = r.laborItems.reduce(
-        (s, l) => s + netLineTotal(l.total, r.taxRate, r.taxInclusive),
-        0,
-      );
-      const _date = r.startDateTime ?? r.serviceDate;
-      const month = `${_date.getFullYear()}-${String(_date.getMonth() + 1).padStart(2, "0")}`;
-
-      if (!monthly[month]) monthly[month] = { revenue: 0, collected: 0, count: 0, partsCost: 0, partsRevenue: 0, laborRevenue: 0 };
-      monthly[month].revenue += total;
-      monthly[month].collected += paid;
-      monthly[month].count += 1;
-      monthly[month].partsCost += partsCost;
-      monthly[month].partsRevenue += partsRevenue;
-      monthly[month].laborRevenue += laborRevenue;
-
-      totalRevenue += total;
-      totalCollected += paid;
-      totalCount += 1;
-      totalPartsCost += partsCost;
-      totalPartsRevenue += partsRevenue;
-      totalLaborRevenue += laborRevenue;
-
-      if (!byType[r.type]) byType[r.type] = { revenue: 0, count: 0 };
-      byType[r.type].revenue += total;
-      byType[r.type].count += 1;
-    }
-
-    return {
-      monthly: Object.entries(monthly).map(([month, data]) => ({
-        month,
-        ...data,
-        partsNetProfit: data.partsRevenue - data.partsCost,
-        netProfit: (data.partsRevenue - data.partsCost) + data.laborRevenue,
-      })),
-      byType: Object.entries(byType).map(([type, data]) => ({ type, ...data })),
-      summary: {
-        totalRevenue,
-        totalCollected,
-        outstanding: totalRevenue - totalCollected,
-        totalCount,
-        totalPartsCost,
-        totalPartsRevenue,
-        totalPartsNetProfit: totalPartsRevenue - totalPartsCost,
-        totalLaborRevenue,
-        netProfit: (totalPartsRevenue - totalPartsCost) + totalLaborRevenue,
-      },
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+      return {
+        monthly: Object.entries(monthly).map(([month, data]) => ({
+          month,
+          ...data,
+          partsNetProfit: data.partsRevenue - data.partsCost,
+          netProfit: data.partsRevenue - data.partsCost + data.laborRevenue,
+        })),
+        byType: Object.entries(byType).map(([type, data]) => ({ type, ...data })),
+        summary: {
+          totalRevenue,
+          totalCollected,
+          outstanding: totalRevenue - totalCollected,
+          totalCount,
+          totalPartsCost,
+          totalPartsRevenue,
+          totalPartsNetProfit: totalPartsRevenue - totalPartsCost,
+          totalLaborRevenue,
+          netProfit: totalPartsRevenue - totalPartsCost + totalLaborRevenue,
+        },
+      }
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] }
+  )
 }
 
-export async function getServiceReport(params: {
-  startDate?: string;
-  endDate?: string;
-}) {
-  return withAuth(async ({ organizationId }) => {
-    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
-    const end = params.endDate ? new Date(params.endDate) : new Date();
-    end.setHours(23, 59, 59, 999);
-
-    const records = await db.serviceRecord.findMany({
-      where: {
-        organizationId,
-        startDateTime: { gte: start, lte: end },
-      },
-      select: {
-        type: true,
-        status: true,
-        totalAmount: true,
-        cost: true,
-      },
-    });
-
-    const byStatus: Record<string, number> = {};
-    const byType: Record<string, number> = {};
-
-    for (const r of records) {
-      byStatus[r.status] = (byStatus[r.status] || 0) + 1;
-      byType[r.type] = (byType[r.type] || 0) + 1;
-    }
-
-    return {
-      totalServices: records.length,
-      byStatus: Object.entries(byStatus).map(([status, count]) => ({ status, count })),
-      byType: Object.entries(byType).map(([type, count]) => ({ type, count })),
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+export async function getServiceReport(params: { startDate?: string; endDate?: string }) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const start = params.startDate
+        ? new Date(params.startDate)
+        : new Date(new Date().getFullYear(), 0, 1)
+      const end = params.endDate ? new Date(params.endDate) : new Date()
+      end.setHours(23, 59, 59, 999)
+
+      const records = await db.serviceRecord.findMany({
+        where: {
+          organizationId,
+          startDateTime: { gte: start, lte: end },
+        },
+        select: {
+          type: true,
+          status: true,
+          totalAmount: true,
+          cost: true,
+        },
+      })
+
+      const byStatus: Record<string, number> = {}
+      const byType: Record<string, number> = {}
+
+      for (const r of records) {
+        byStatus[r.status] = (byStatus[r.status] || 0) + 1
+        byType[r.type] = (byType[r.type] || 0) + 1
+      }
+
+      return {
+        totalServices: records.length,
+        byStatus: Object.entries(byStatus).map(([status, count]) => ({ status, count })),
+        byType: Object.entries(byType).map(([type, count]) => ({ type, count })),
+      }
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] }
+  )
 }
 
-export async function getCustomerReport(params: {
-  startDate?: string;
-  endDate?: string;
-}) {
-  return withAuth(async ({ organizationId }) => {
-    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
-    const end = params.endDate ? new Date(params.endDate) : new Date();
-    end.setHours(23, 59, 59, 999);
-
-    const customers = await db.customer.findMany({
-      where: { organizationId },
-      select: {
-        id: true,
-        name: true,
-        company: true,
-        vehicles: {
-          select: {
-            serviceRecords: {
-              where: { startDateTime: { gte: start, lte: end } },
-              select: { totalAmount: true, cost: true },
+export async function getCustomerReport(params: { startDate?: string; endDate?: string }) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const start = params.startDate
+        ? new Date(params.startDate)
+        : new Date(new Date().getFullYear(), 0, 1)
+      const end = params.endDate ? new Date(params.endDate) : new Date()
+      end.setHours(23, 59, 59, 999)
+
+      const customers = await db.customer.findMany({
+        where: { organizationId },
+        select: {
+          id: true,
+          name: true,
+          company: true,
+          vehicles: {
+            select: {
+              serviceRecords: {
+                where: { startDateTime: { gte: start, lte: end } },
+                select: { totalAmount: true, cost: true },
+              },
             },
           },
         },
-      },
-    });
+      })
 
-    const ranked = customers
-      .map((c) => {
-        let totalSpent = 0;
-        let serviceCount = 0;
-        for (const v of c.vehicles) {
-          for (const sr of v.serviceRecords) {
-            totalSpent += sr.totalAmount > 0 ? sr.totalAmount : sr.cost;
-            serviceCount++;
+      const ranked = customers
+        .map((c) => {
+          let totalSpent = 0
+          let serviceCount = 0
+          for (const v of c.vehicles) {
+            for (const sr of v.serviceRecords) {
+              totalSpent += sr.totalAmount > 0 ? sr.totalAmount : sr.cost
+              serviceCount++
+            }
           }
-        }
-        return { id: c.id, name: c.name, company: c.company, totalSpent, serviceCount };
+          return { id: c.id, name: c.name, company: c.company, totalSpent, serviceCount }
+        })
+        .filter((c) => c.serviceCount > 0)
+        .sort((a, b) => b.totalSpent - a.totalSpent)
+
+      return {
+        totalCustomers: customers.length,
+        activeCustomers: ranked.length,
+        topCustomers: ranked.slice(0, 20),
+      }
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] }
+  )
+}
+
+export async function getTechnicianReport(params: { startDate?: string; endDate?: string }) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const start = params.startDate
+        ? new Date(params.startDate)
+        : new Date(new Date().getFullYear(), 0, 1)
+      const end = params.endDate ? new Date(params.endDate) : new Date()
+      end.setHours(23, 59, 59, 999)
+
+      const records = await db.serviceRecord.findMany({
+        where: {
+          organizationId,
+          startDateTime: { gte: start, lte: end },
+          OR: [{ technicianId: { not: null } }, { techName: { not: null } }],
+        },
+        select: {
+          techName: true,
+          technicianId: true,
+          technician: { select: { name: true } },
+          totalAmount: true,
+          cost: true,
+          laborItems: { select: { hours: true } },
+        },
       })
-      .filter((c) => c.serviceCount > 0)
-      .sort((a, b) => b.totalSpent - a.totalSpent);
-
-    return {
-      totalCustomers: customers.length,
-      activeCustomers: ranked.length,
-      topCustomers: ranked.slice(0, 20),
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+
+      // Group by technicianId (canonical) with techName fallback for legacy records
+      const byTech: Record<
+        string,
+        { techName: string; jobCount: number; totalRevenue: number; totalLaborHours: number }
+      > = {}
+
+      for (const r of records) {
+        const key = r.technicianId || `name:${r.techName}`
+        const displayName = r.technician?.name || r.techName || 'Unassigned'
+        if (!byTech[key])
+          byTech[key] = { techName: displayName, jobCount: 0, totalRevenue: 0, totalLaborHours: 0 }
+        byTech[key].jobCount += 1
+        byTech[key].totalRevenue += r.totalAmount > 0 ? r.totalAmount : r.cost
+        byTech[key].totalLaborHours += r.laborItems.reduce((s, l) => s + l.hours, 0)
+      }
+
+      const technicians = Object.values(byTech)
+        .map((data) => ({
+          techName: data.techName,
+          jobCount: data.jobCount,
+          totalRevenue: data.totalRevenue,
+          avgRevenue: data.jobCount > 0 ? data.totalRevenue / data.jobCount : 0,
+          totalLaborHours: data.totalLaborHours,
+          avgHours: data.jobCount > 0 ? data.totalLaborHours / data.jobCount : 0,
+        }))
+        .sort((a, b) => b.totalRevenue - a.totalRevenue)
+
+      return {
+        technicians,
+        totalJobs: records.length,
+        totalRevenue: technicians.reduce((s, t) => s + t.totalRevenue, 0),
+      }
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] }
+  )
 }
 
-export async function getTechnicianReport(params: {
-  startDate?: string;
-  endDate?: string;
-}) {
-  return withAuth(async ({ organizationId }) => {
-    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
-    const end = params.endDate ? new Date(params.endDate) : new Date();
-    end.setHours(23, 59, 59, 999);
-
-    const records = await db.serviceRecord.findMany({
-      where: {
-        organizationId,
-        startDateTime: { gte: start, lte: end },
-        OR: [{ technicianId: { not: null } }, { techName: { not: null } }],
-      },
-      select: {
-        techName: true,
-        technicianId: true,
-        technician: { select: { name: true } },
-        totalAmount: true,
-        cost: true,
-        laborItems: { select: { hours: true } },
-      },
-    });
-
-    // Group by technicianId (canonical) with techName fallback for legacy records
-    const byTech: Record<string, { techName: string; jobCount: number; totalRevenue: number; totalLaborHours: number }> = {};
-
-    for (const r of records) {
-      const key = r.technicianId || `name:${r.techName}`;
-      const displayName = r.technician?.name || r.techName || "Unassigned";
-      if (!byTech[key]) byTech[key] = { techName: displayName, jobCount: 0, totalRevenue: 0, totalLaborHours: 0 };
-      byTech[key].jobCount += 1;
-      byTech[key].totalRevenue += r.totalAmount > 0 ? r.totalAmount : r.cost;
-      byTech[key].totalLaborHours += r.laborItems.reduce((s, l) => s + l.hours, 0);
-    }
-
-    const technicians = Object.values(byTech)
-      .map((data) => ({
-        techName: data.techName,
-        jobCount: data.jobCount,
-        totalRevenue: data.totalRevenue,
-        avgRevenue: data.jobCount > 0 ? data.totalRevenue / data.jobCount : 0,
-        totalLaborHours: data.totalLaborHours,
-        avgHours: data.jobCount > 0 ? data.totalLaborHours / data.jobCount : 0,
+export async function getPartsUsageReport(params: { startDate?: string; endDate?: string }) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const start = params.startDate
+        ? new Date(params.startDate)
+        : new Date(new Date().getFullYear(), 0, 1)
+      const end = params.endDate ? new Date(params.endDate) : new Date()
+      end.setHours(23, 59, 59, 999)
+
+      const parts = await db.servicePart.findMany({
+        where: {
+          serviceRecord: {
+            organizationId,
+            startDateTime: { gte: start, lte: end },
+          },
+        },
+        select: {
+          name: true,
+          partNumber: true,
+          quantity: true,
+          total: true,
+          unitCost: true,
+          serviceRecord: { select: { taxRate: true, taxInclusive: true } },
+        },
+      })
+
+      // Use net (pre-tax) line totals so revenue/profit math is consistent
+      // across inclusive- and exclusive-tax records.
+      const partWithNet = parts.map((p) => ({
+        ...p,
+        netTotal: netLineTotal(p.total, p.serviceRecord.taxRate, p.serviceRecord.taxInclusive),
       }))
-      .sort((a, b) => b.totalRevenue - a.totalRevenue);
-
-    return {
-      technicians,
-      totalJobs: records.length,
-      totalRevenue: technicians.reduce((s, t) => s + t.totalRevenue, 0),
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+
+      const byPart: Record<
+        string,
+        {
+          partNumber: string | null
+          usageCount: number
+          totalQuantity: number
+          totalRevenue: number
+          totalCost: number
+        }
+      > = {}
+
+      for (const p of partWithNet) {
+        const key = p.name.toLowerCase()
+        if (!byPart[key])
+          byPart[key] = {
+            partNumber: p.partNumber,
+            usageCount: 0,
+            totalQuantity: 0,
+            totalRevenue: 0,
+            totalCost: 0,
+          }
+        byPart[key].usageCount += 1
+        byPart[key].totalQuantity += p.quantity
+        byPart[key].totalRevenue += p.netTotal
+        byPart[key].totalCost += p.unitCost * p.quantity
+        if (p.partNumber && !byPart[key].partNumber) byPart[key].partNumber = p.partNumber
+      }
+
+      const partsByKey: Record<string, string> = {}
+      for (const p of parts) {
+        const key = p.name.toLowerCase()
+        if (!partsByKey[key]) partsByKey[key] = p.name
+      }
+
+      const result = Object.entries(byPart)
+        .map(([key, data]) => ({
+          name: partsByKey[key] || key,
+          partNumber: data.partNumber,
+          usageCount: data.usageCount,
+          totalQuantity: data.totalQuantity,
+          totalRevenue: data.totalRevenue,
+          totalCost: data.totalCost,
+          netProfit: data.totalRevenue - data.totalCost,
+        }))
+        .sort((a, b) => b.usageCount - a.usageCount)
+        .slice(0, 20)
+
+      const totalPartsRevenue = partWithNet.reduce((s, p) => s + p.netTotal, 0)
+      const totalPartsCost = parts.reduce((s, p) => s + p.unitCost * p.quantity, 0)
+
+      return {
+        parts: result,
+        totalPartsRevenue,
+        totalPartsCost,
+        totalPartsNetProfit: totalPartsRevenue - totalPartsCost,
+        totalPartsUsed: parts.reduce((s, p) => s + p.quantity, 0),
+      }
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] }
+  )
 }
 
-export async function getPartsUsageReport(params: {
-  startDate?: string;
-  endDate?: string;
-}) {
-  return withAuth(async ({ organizationId }) => {
-    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
-    const end = params.endDate ? new Date(params.endDate) : new Date();
-    end.setHours(23, 59, 59, 999);
-
-    const parts = await db.servicePart.findMany({
-      where: {
-        serviceRecord: {
+export async function getJobAnalyticsReport(params: { startDate?: string; endDate?: string }) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const start = params.startDate
+        ? new Date(params.startDate)
+        : new Date(new Date().getFullYear(), 0, 1)
+      const end = params.endDate ? new Date(params.endDate) : new Date()
+      end.setHours(23, 59, 59, 999)
+
+      const records = await db.serviceRecord.findMany({
+        where: {
           organizationId,
           startDateTime: { gte: start, lte: end },
         },
-      },
-      select: {
-        name: true,
-        partNumber: true,
-        quantity: true,
-        total: true,
-        unitCost: true,
-        serviceRecord: { select: { taxRate: true, taxInclusive: true } },
-      },
-    });
-
-    // Use net (pre-tax) line totals so revenue/profit math is consistent
-    // across inclusive- and exclusive-tax records.
-    const partWithNet = parts.map((p) => ({
-      ...p,
-      netTotal: netLineTotal(p.total, p.serviceRecord.taxRate, p.serviceRecord.taxInclusive),
-    }));
-
-    const byPart: Record<string, { partNumber: string | null; usageCount: number; totalQuantity: number; totalRevenue: number; totalCost: number }> = {};
-
-    for (const p of partWithNet) {
-      const key = p.name.toLowerCase();
-      if (!byPart[key]) byPart[key] = { partNumber: p.partNumber, usageCount: 0, totalQuantity: 0, totalRevenue: 0, totalCost: 0 };
-      byPart[key].usageCount += 1;
-      byPart[key].totalQuantity += p.quantity;
-      byPart[key].totalRevenue += p.netTotal;
-      byPart[key].totalCost += p.unitCost * p.quantity;
-      if (p.partNumber && !byPart[key].partNumber) byPart[key].partNumber = p.partNumber;
-    }
-
-    const partsByKey: Record<string, string> = {};
-    for (const p of parts) {
-      const key = p.name.toLowerCase();
-      if (!partsByKey[key]) partsByKey[key] = p.name;
-    }
-
-    const result = Object.entries(byPart)
-      .map(([key, data]) => ({
-        name: partsByKey[key] || key,
-        partNumber: data.partNumber,
-        usageCount: data.usageCount,
-        totalQuantity: data.totalQuantity,
-        totalRevenue: data.totalRevenue,
-        totalCost: data.totalCost,
-        netProfit: data.totalRevenue - data.totalCost,
-      }))
-      .sort((a, b) => b.usageCount - a.usageCount)
-      .slice(0, 20);
-
-    const totalPartsRevenue = partWithNet.reduce((s, p) => s + p.netTotal, 0);
-    const totalPartsCost = parts.reduce((s, p) => s + (p.unitCost * p.quantity), 0);
-
-    return {
-      parts: result,
-      totalPartsRevenue,
-      totalPartsCost,
-      totalPartsNetProfit: totalPartsRevenue - totalPartsCost,
-      totalPartsUsed: parts.reduce((s, p) => s + p.quantity, 0),
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
-}
+        select: {
+          type: true,
+          totalAmount: true,
+          cost: true,
+          serviceDate: true,
+          startDateTime: true,
+          laborItems: { select: { hours: true } },
+        },
+      })
 
-export async function getJobAnalyticsReport(params: {
-  startDate?: string;
-  endDate?: string;
-}) {
-  return withAuth(async ({ organizationId }) => {
-    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
-    const end = params.endDate ? new Date(params.endDate) : new Date();
-    end.setHours(23, 59, 59, 999);
-
-    const records = await db.serviceRecord.findMany({
-      where: {
-        organizationId,
-        startDateTime: { gte: start, lte: end },
-      },
-      select: {
-        type: true,
-        totalAmount: true,
-        cost: true,
-        serviceDate: true,
-        startDateTime: true,
-        laborItems: { select: { hours: true } },
-      },
-    });
-
-    // Average job value
-    const totalValue = records.reduce((s, r) => s + (r.totalAmount > 0 ? r.totalAmount : r.cost), 0);
-    const avgJobValue = records.length > 0 ? totalValue / records.length : 0;
-
-    // By type with avg value and avg hours
-    const byType: Record<string, { count: number; totalValue: number; totalHours: number }> = {};
-    for (const r of records) {
-      if (!byType[r.type]) byType[r.type] = { count: 0, totalValue: 0, totalHours: 0 };
-      byType[r.type].count += 1;
-      byType[r.type].totalValue += r.totalAmount > 0 ? r.totalAmount : r.cost;
-      byType[r.type].totalHours += r.laborItems.reduce((s, l) => s + l.hours, 0);
-    }
-
-    const topServiceTypes = Object.entries(byType)
-      .map(([type, data]) => ({
-        type,
-        count: data.count,
-        avgValue: data.count > 0 ? data.totalValue / data.count : 0,
-        avgHours: data.count > 0 ? data.totalHours / data.count : 0,
-      }))
-      .sort((a, b) => b.count - a.count)
-      .slice(0, 10);
-
-    // Day of week distribution
-    const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
-    const dayCount = [0, 0, 0, 0, 0, 0, 0];
-    for (const r of records) {
-      dayCount[(r.startDateTime ?? r.serviceDate).getDay()] += 1;
-    }
-    const dayOfWeek = dayNames.map((day, i) => ({ day, count: dayCount[i] }));
-
-    // Monthly trend
-    const monthly: Record<string, { count: number; revenue: number }> = {};
-    for (const r of records) {
-      const _d = r.startDateTime ?? r.serviceDate;
-      const month = `${_d.getFullYear()}-${String(_d.getMonth() + 1).padStart(2, "0")}`;
-      if (!monthly[month]) monthly[month] = { count: 0, revenue: 0 };
-      monthly[month].count += 1;
-      monthly[month].revenue += r.totalAmount > 0 ? r.totalAmount : r.cost;
-    }
-    const monthlyTrend = Object.entries(monthly)
-      .map(([month, data]) => ({ month, ...data }))
-      .sort((a, b) => a.month.localeCompare(b.month));
-
-    return {
-      avgJobValue,
-      totalJobs: records.length,
-      topServiceTypes,
-      dayOfWeek,
-      monthlyTrend,
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+      // Average job value
+      const totalValue = records.reduce(
+        (s, r) => s + (r.totalAmount > 0 ? r.totalAmount : r.cost),
+        0
+      )
+      const avgJobValue = records.length > 0 ? totalValue / records.length : 0
+
+      // By type with avg value and avg hours
+      const byType: Record<string, { count: number; totalValue: number; totalHours: number }> = {}
+      for (const r of records) {
+        if (!byType[r.type]) byType[r.type] = { count: 0, totalValue: 0, totalHours: 0 }
+        byType[r.type].count += 1
+        byType[r.type].totalValue += r.totalAmount > 0 ? r.totalAmount : r.cost
+        byType[r.type].totalHours += r.laborItems.reduce((s, l) => s + l.hours, 0)
+      }
+
+      const topServiceTypes = Object.entries(byType)
+        .map(([type, data]) => ({
+          type,
+          count: data.count,
+          avgValue: data.count > 0 ? data.totalValue / data.count : 0,
+          avgHours: data.count > 0 ? data.totalHours / data.count : 0,
+        }))
+        .sort((a, b) => b.count - a.count)
+        .slice(0, 10)
+
+      // Day of week distribution
+      const dayNames = [
+        'Sunday',
+        'Monday',
+        'Tuesday',
+        'Wednesday',
+        'Thursday',
+        'Friday',
+        'Saturday',
+      ]
+      const dayCount = [0, 0, 0, 0, 0, 0, 0]
+      for (const r of records) {
+        dayCount[(r.startDateTime ?? r.serviceDate).getDay()] += 1
+      }
+      const dayOfWeek = dayNames.map((day, i) => ({ day, count: dayCount[i] }))
+
+      // Monthly trend
+      const monthly: Record<string, { count: number; revenue: number }> = {}
+      for (const r of records) {
+        const _d = r.startDateTime ?? r.serviceDate
+        const month = `${_d.getFullYear()}-${String(_d.getMonth() + 1).padStart(2, '0')}`
+        if (!monthly[month]) monthly[month] = { count: 0, revenue: 0 }
+        monthly[month].count += 1
+        monthly[month].revenue += r.totalAmount > 0 ? r.totalAmount : r.cost
+      }
+      const monthlyTrend = Object.entries(monthly)
+        .map(([month, data]) => ({ month, ...data }))
+        .sort((a, b) => a.month.localeCompare(b.month))
+
+      return {
+        avgJobValue,
+        totalJobs: records.length,
+        topServiceTypes,
+        dayOfWeek,
+        monthlyTrend,
+      }
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] }
+  )
 }
 
-export async function getCustomerRetentionReport(params: {
-  startDate?: string;
-  endDate?: string;
-}) {
-  return withAuth(async ({ organizationId }) => {
-    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
-    const end = params.endDate ? new Date(params.endDate) : new Date();
-    end.setHours(23, 59, 59, 999);
-
-    const customers = await db.customer.findMany({
-      where: { organizationId },
-      select: {
-        id: true,
-        name: true,
-        company: true,
-        vehicles: {
-          select: {
-            serviceRecords: {
-              where: { startDateTime: { gte: start, lte: end } },
-              select: { serviceDate: true, startDateTime: true, totalAmount: true, cost: true },
-              orderBy: [{ startDateTime: { sort: "asc", nulls: "last" } }, { serviceDate: "asc" }],
+export async function getCustomerRetentionReport(params: { startDate?: string; endDate?: string }) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const start = params.startDate
+        ? new Date(params.startDate)
+        : new Date(new Date().getFullYear(), 0, 1)
+      const end = params.endDate ? new Date(params.endDate) : new Date()
+      end.setHours(23, 59, 59, 999)
+
+      const customers = await db.customer.findMany({
+        where: { organizationId },
+        select: {
+          id: true,
+          name: true,
+          company: true,
+          vehicles: {
+            select: {
+              serviceRecords: {
+                where: { startDateTime: { gte: start, lte: end } },
+                select: { serviceDate: true, startDateTime: true, totalAmount: true, cost: true },
+                orderBy: [
+                  { startDateTime: { sort: 'asc', nulls: 'last' } },
+                  { serviceDate: 'asc' },
+                ],
+              },
             },
           },
         },
-      },
-    });
-
-    let returningCustomers = 0;
-    let newCustomers = 0;
-    const allTimeBetween: number[] = [];
-    const topReturning: {
-      id: string;
-      name: string;
-      company: string | null;
-      visitCount: number;
-      totalSpent: number;
-      avgTimeBetweenVisits: number | null;
-    }[] = [];
-
-    for (const c of customers) {
-      const visits: { date: Date; amount: number }[] = [];
-      for (const v of c.vehicles) {
-        for (const sr of v.serviceRecords) {
-          visits.push({ date: sr.startDateTime ?? sr.serviceDate, amount: sr.totalAmount > 0 ? sr.totalAmount : sr.cost });
+      })
+
+      let returningCustomers = 0
+      let newCustomers = 0
+      const allTimeBetween: number[] = []
+      const topReturning: {
+        id: string
+        name: string
+        company: string | null
+        visitCount: number
+        totalSpent: number
+        avgTimeBetweenVisits: number | null
+      }[] = []
+
+      for (const c of customers) {
+        const visits: { date: Date; amount: number }[] = []
+        for (const v of c.vehicles) {
+          for (const sr of v.serviceRecords) {
+            visits.push({
+              date: sr.startDateTime ?? sr.serviceDate,
+              amount: sr.totalAmount > 0 ? sr.totalAmount : sr.cost,
+            })
+          }
         }
-      }
 
-      if (visits.length === 0) continue;
+        if (visits.length === 0) continue
 
-      visits.sort((a, b) => a.date.getTime() - b.date.getTime());
+        visits.sort((a, b) => a.date.getTime() - b.date.getTime())
 
-      if (visits.length > 1) {
-        returningCustomers += 1;
-        const gaps: number[] = [];
-        for (let i = 1; i < visits.length; i++) {
-          const diffDays = (visits[i].date.getTime() - visits[i - 1].date.getTime()) / (1000 * 60 * 60 * 24);
-          gaps.push(diffDays);
+        if (visits.length > 1) {
+          returningCustomers += 1
+          const gaps: number[] = []
+          for (let i = 1; i < visits.length; i++) {
+            const diffDays =
+              (visits[i].date.getTime() - visits[i - 1].date.getTime()) / (1000 * 60 * 60 * 24)
+            gaps.push(diffDays)
+          }
+          const avgGap = gaps.reduce((s, g) => s + g, 0) / gaps.length
+          allTimeBetween.push(avgGap)
+
+          topReturning.push({
+            id: c.id,
+            name: c.name,
+            company: c.company,
+            visitCount: visits.length,
+            totalSpent: visits.reduce((s, v) => s + v.amount, 0),
+            avgTimeBetweenVisits: Math.round(avgGap),
+          })
+        } else {
+          newCustomers += 1
         }
-        const avgGap = gaps.reduce((s, g) => s + g, 0) / gaps.length;
-        allTimeBetween.push(avgGap);
-
-        topReturning.push({
-          id: c.id,
-          name: c.name,
-          company: c.company,
-          visitCount: visits.length,
-          totalSpent: visits.reduce((s, v) => s + v.amount, 0),
-          avgTimeBetweenVisits: Math.round(avgGap),
-        });
-      } else {
-        newCustomers += 1;
       }
-    }
-
-    topReturning.sort((a, b) => b.visitCount - a.visitCount);
-
-    return {
-      returningCustomers,
-      newCustomers,
-      totalActive: returningCustomers + newCustomers,
-      avgTimeBetweenVisits: allTimeBetween.length > 0
-        ? Math.round(allTimeBetween.reduce((s, v) => s + v, 0) / allTimeBetween.length)
-        : null,
-      topReturning: topReturning.slice(0, 20),
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+
+      topReturning.sort((a, b) => b.visitCount - a.visitCount)
+
+      return {
+        returningCustomers,
+        newCustomers,
+        totalActive: returningCustomers + newCustomers,
+        avgTimeBetweenVisits:
+          allTimeBetween.length > 0
+            ? Math.round(allTimeBetween.reduce((s, v) => s + v, 0) / allTimeBetween.length)
+            : null,
+        topReturning: topReturning.slice(0, 20),
+      }
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] }
+  )
 }
 
 export async function getInventoryReport() {
-  return withAuth(async ({ organizationId }) => {
-    const parts = await db.inventoryPart.findMany({
-      where: { organizationId, isArchived: false },
-      select: {
-        id: true,
-        name: true,
-        partNumber: true,
-        quantity: true,
-        unitCost: true,
-        sellPrice: true,
-        minQuantity: true,
-      },
-      orderBy: { name: "asc" },
-    });
-
-    let totalValue = 0;
-    let totalSellValue = 0;
-    let totalItems = 0;
-    const lowStock: typeof parts = [];
-
-    for (const p of parts) {
-      totalValue += (p.unitCost || 0) * p.quantity;
-      totalSellValue += (p.sellPrice > 0 ? p.sellPrice : p.unitCost || 0) * p.quantity;
-      totalItems += p.quantity;
-      if (p.minQuantity && p.quantity <= p.minQuantity) {
-        lowStock.push(p);
+  return withAuth(
+    async ({ organizationId }) => {
+      const parts = await db.inventoryPart.findMany({
+        where: { organizationId, isArchived: false },
+        select: {
+          id: true,
+          name: true,
+          partNumber: true,
+          quantity: true,
+          unit: true,
+          unitCost: true,
+          sellPrice: true,
+          minQuantity: true,
+        },
+        orderBy: { name: 'asc' },
+      })
+
+      let totalValue = 0
+      let totalSellValue = 0
+      let totalItems = 0
+      const lowStock: typeof parts = []
+
+      for (const p of parts) {
+        totalValue += (p.unitCost || 0) * p.quantity
+        totalSellValue += (p.sellPrice > 0 ? p.sellPrice : p.unitCost || 0) * p.quantity
+        totalItems += p.quantity
+        if (p.minQuantity && p.quantity <= p.minQuantity) {
+          lowStock.push(p)
+        }
+      }
+
+      return {
+        totalParts: parts.length,
+        totalItems,
+        totalValue,
+        totalSellValue,
+        lowStock,
       }
-    }
-
-    return {
-      totalParts: parts.length,
-      totalItems,
-      totalValue,
-      totalSellValue,
-      lowStock,
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] }
+  )
 }
 
 export async function getPastDueInvoicesReport() {
-  return withAuth(async ({ organizationId }) => {
-    const now = new Date();
-
-    const records = await db.serviceRecord.findMany({
-      where: {
-        organizationId,
-        invoiceDueDate: { lt: now },
-        status: { not: "cancelled" },
-      },
-      select: {
-        id: true,
-        invoiceNumber: true,
-        invoiceDueDate: true,
-        totalAmount: true,
-        cost: true,
-        manuallyPaid: true,
-        payments: { select: { amount: true } },
-        customer: { select: { name: true, company: true } },
-        vehicle: {
-          select: {
-            year: true,
-            make: true,
-            model: true,
-            customer: { select: { name: true, company: true } },
+  return withAuth(
+    async ({ organizationId }) => {
+      const now = new Date()
+
+      const records = await db.serviceRecord.findMany({
+        where: {
+          organizationId,
+          invoiceDueDate: { lt: now },
+          status: { not: 'cancelled' },
+        },
+        select: {
+          id: true,
+          invoiceNumber: true,
+          invoiceDueDate: true,
+          totalAmount: true,
+          cost: true,
+          manuallyPaid: true,
+          payments: { select: { amount: true } },
+          customer: { select: { name: true, company: true } },
+          vehicle: {
+            select: {
+              year: true,
+              make: true,
+              model: true,
+              customer: { select: { name: true, company: true } },
+            },
           },
         },
-      },
-      orderBy: { invoiceDueDate: "asc" },
-    });
-
-    const invoices: {
-      id: string;
-      invoiceNumber: string | null;
-      customerName: string;
-      customerCompany: string | null;
-      vehicleInfo: string;
-      totalAmount: number;
-      amountPaid: number;
-      amountDue: number;
-      dueDate: string;
-      daysPastDue: number;
-    }[] = [];
-
-    let totalAmountDue = 0;
-    let over30 = 0;
-    let over60 = 0;
-    let over90 = 0;
-
-    for (const r of records) {
-      const total = r.totalAmount > 0 ? r.totalAmount : r.cost;
-      const paid = r.manuallyPaid ? total : r.payments.reduce((s, p) => s + p.amount, 0);
-      const amountDue = total - paid;
-
-      if (amountDue <= 0) continue;
-
-      const dueDate = r.invoiceDueDate!;
-      const daysPastDue = Math.floor((now.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24));
-
-      const vehicleParts = r.vehicle ? [r.vehicle.year, r.vehicle.make, r.vehicle.model].filter(Boolean) : [];
-      const vehicleInfo = vehicleParts.length > 0 ? vehicleParts.join(" ") : "N/A";
-      const invoiceCustomer = r.customer ?? r.vehicle?.customer;
-
-      invoices.push({
-        id: r.id,
-        invoiceNumber: r.invoiceNumber,
-        customerName: invoiceCustomer?.name ?? "Unknown",
-        customerCompany: invoiceCustomer?.company ?? null,
-        vehicleInfo,
-        totalAmount: total,
-        amountPaid: paid,
-        amountDue,
-        dueDate: dueDate.toISOString().split("T")[0],
-        daysPastDue,
-      });
-
-      totalAmountDue += amountDue;
-      if (daysPastDue > 30) over30++;
-      if (daysPastDue > 60) over60++;
-      if (daysPastDue > 90) over90++;
-    }
-
-    return {
-      invoices,
-      summary: {
-        totalPastDue: invoices.length,
-        totalAmountDue,
-        totalInvoices: invoices.length,
-        over30,
-        over60,
-        over90,
-      },
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+        orderBy: { invoiceDueDate: 'asc' },
+      })
+
+      const invoices: {
+        id: string
+        invoiceNumber: string | null
+        customerName: string
+        customerCompany: string | null
+        vehicleInfo: string
+        totalAmount: number
+        amountPaid: number
+        amountDue: number
+        dueDate: string
+        daysPastDue: number
+      }[] = []
+
+      let totalAmountDue = 0
+      let over30 = 0
+      let over60 = 0
+      let over90 = 0
+
+      for (const r of records) {
+        const total = r.totalAmount > 0 ? r.totalAmount : r.cost
+        const paid = r.manuallyPaid ? total : r.payments.reduce((s, p) => s + p.amount, 0)
+        const amountDue = total - paid
+
+        if (amountDue <= 0) continue
+
+        const dueDate = r.invoiceDueDate!
+        const daysPastDue = Math.floor((now.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
+
+        const vehicleParts = r.vehicle
+          ? [r.vehicle.year, r.vehicle.make, r.vehicle.model].filter(Boolean)
+          : []
+        const vehicleInfo = vehicleParts.length > 0 ? vehicleParts.join(' ') : 'N/A'
+        const invoiceCustomer = r.customer ?? r.vehicle?.customer
+
+        invoices.push({
+          id: r.id,
+          invoiceNumber: r.invoiceNumber,
+          customerName: invoiceCustomer?.name ?? 'Unknown',
+          customerCompany: invoiceCustomer?.company ?? null,
+          vehicleInfo,
+          totalAmount: total,
+          amountPaid: paid,
+          amountDue,
+          dueDate: dueDate.toISOString().split('T')[0],
+          daysPastDue,
+        })
+
+        totalAmountDue += amountDue
+        if (daysPastDue > 30) over30++
+        if (daysPastDue > 60) over60++
+        if (daysPastDue > 90) over90++
+      }
+
+      return {
+        invoices,
+        summary: {
+          totalPastDue: invoices.length,
+          totalAmountDue,
+          totalInvoices: invoices.length,
+          over30,
+          over60,
+          over90,
+        },
+      }
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] }
+  )
 }
 
 export async function getVehicleReport(params: {
-  vehicleId: string;
-  startDate?: string;
-  endDate?: string;
+  vehicleId: string
+  startDate?: string
+  endDate?: string
 }) {
-  return withAuth(async ({ organizationId }) => {
-    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
-    const end = params.endDate ? new Date(params.endDate) : new Date();
-    end.setHours(23, 59, 59, 999);
-
-    const vehicle = await db.vehicle.findFirst({
-      where: { id: params.vehicleId, organizationId },
-      select: {
-        id: true, year: true, make: true, model: true,
-        vin: true, licensePlate: true, mileage: true,
-        customer: { select: { name: true } },
-      },
-    });
-    if (!vehicle) throw new Error("Vehicle not found");
-
-    const records = await db.serviceRecord.findMany({
-      where: {
-        vehicleId: params.vehicleId,
-        organizationId,
-        startDateTime: { gte: start, lte: end },
-      },
-      select: {
-        id: true, title: true, type: true, status: true,
-        serviceDate: true, startDateTime: true,
-        totalAmount: true, cost: true,
-        taxRate: true, taxInclusive: true,
-        techName: true,
-        technician: { select: { name: true } },
-        partItems: { select: { name: true, partNumber: true, quantity: true, unitCost: true, total: true } },
-        laborItems: { select: { hours: true, total: true } },
-      },
-      orderBy: [{ startDateTime: { sort: "desc", nulls: "last" } }, { serviceDate: "desc" }],
-    });
-
-    let totalCost = 0;
-    let totalPartsUsed = 0;
-    let totalLaborHours = 0;
-    const typeCounts: Record<string, { count: number; totalCost: number }> = {};
-    const monthly: Record<string, { partsCost: number; laborCost: number; totalCost: number; count: number }> = {};
-    const partUsage: Record<string, { partNumber: string | null; quantity: number; totalCost: number }> = {};
-    const partNames: Record<string, string> = {};
-
-    for (const r of records) {
-      const total = r.totalAmount > 0 ? r.totalAmount : r.cost;
-      totalCost += total;
-
-      const partsCost = r.partItems.reduce((s, p) => s + (p.unitCost * p.quantity), 0);
-      // Labor "cost" here is what was charged to the customer for labor.
-      // Net (pre-tax) so monthly breakdowns are consistent across modes.
-      const laborCost = r.laborItems.reduce(
-        (s, l) => s + netLineTotal(l.total, r.taxRate, r.taxInclusive),
-        0,
-      );
-      const laborHrs = r.laborItems.reduce((s, l) => s + l.hours, 0);
-      totalPartsUsed += r.partItems.reduce((s, p) => s + p.quantity, 0);
-      totalLaborHours += laborHrs;
-
-      if (!typeCounts[r.type]) typeCounts[r.type] = { count: 0, totalCost: 0 };
-      typeCounts[r.type].count += 1;
-      typeCounts[r.type].totalCost += total;
-
-      const _d = r.startDateTime ?? r.serviceDate;
-      const month = `${_d.getFullYear()}-${String(_d.getMonth() + 1).padStart(2, "0")}`;
-      if (!monthly[month]) monthly[month] = { partsCost: 0, laborCost: 0, totalCost: 0, count: 0 };
-      monthly[month].partsCost += partsCost;
-      monthly[month].laborCost += laborCost;
-      monthly[month].totalCost += total;
-      monthly[month].count += 1;
-
-      for (const p of r.partItems) {
-        const key = p.name.toLowerCase();
-        if (!partUsage[key]) partUsage[key] = { partNumber: p.partNumber, quantity: 0, totalCost: 0 };
-        partUsage[key].quantity += p.quantity;
-        partUsage[key].totalCost += p.unitCost * p.quantity;
-        if (p.partNumber && !partUsage[key].partNumber) partUsage[key].partNumber = p.partNumber;
-        if (!partNames[key]) partNames[key] = p.name;
+  return withAuth(
+    async ({ organizationId }) => {
+      const start = params.startDate
+        ? new Date(params.startDate)
+        : new Date(new Date().getFullYear(), 0, 1)
+      const end = params.endDate ? new Date(params.endDate) : new Date()
+      end.setHours(23, 59, 59, 999)
+
+      const vehicle = await db.vehicle.findFirst({
+        where: { id: params.vehicleId, organizationId },
+        select: {
+          id: true,
+          year: true,
+          make: true,
+          model: true,
+          vin: true,
+          licensePlate: true,
+          mileage: true,
+          customer: { select: { name: true } },
+        },
+      })
+      if (!vehicle) throw new Error('Vehicle not found')
+
+      const records = await db.serviceRecord.findMany({
+        where: {
+          vehicleId: params.vehicleId,
+          organizationId,
+          startDateTime: { gte: start, lte: end },
+        },
+        select: {
+          id: true,
+          title: true,
+          type: true,
+          status: true,
+          serviceDate: true,
+          startDateTime: true,
+          totalAmount: true,
+          cost: true,
+          taxRate: true,
+          taxInclusive: true,
+          techName: true,
+          technician: { select: { name: true } },
+          partItems: {
+            select: {
+              name: true,
+              partNumber: true,
+              quantity: true,
+              unit: true,
+              unitCost: true,
+              total: true,
+            },
+          },
+          laborItems: { select: { hours: true, total: true } },
+        },
+        orderBy: [{ startDateTime: { sort: 'desc', nulls: 'last' } }, { serviceDate: 'desc' }],
+      })
+
+      let totalCost = 0
+      let totalPartsUsed = 0
+      let totalLaborHours = 0
+      const typeCounts: Record<string, { count: number; totalCost: number }> = {}
+      const monthly: Record<
+        string,
+        { partsCost: number; laborCost: number; totalCost: number; count: number }
+      > = {}
+      const partUsage: Record<
+        string,
+        { partNumber: string | null; quantity: number; unit: string | null; totalCost: number }
+      > = {}
+      const partNames: Record<string, string> = {}
+
+      for (const r of records) {
+        const total = r.totalAmount > 0 ? r.totalAmount : r.cost
+        totalCost += total
+
+        const partsCost = r.partItems.reduce((s, p) => s + p.unitCost * p.quantity, 0)
+        // Labor "cost" here is what was charged to the customer for labor.
+        // Net (pre-tax) so monthly breakdowns are consistent across modes.
+        const laborCost = r.laborItems.reduce(
+          (s, l) => s + netLineTotal(l.total, r.taxRate, r.taxInclusive),
+          0
+        )
+        const laborHrs = r.laborItems.reduce((s, l) => s + l.hours, 0)
+        totalPartsUsed += r.partItems.reduce((s, p) => s + p.quantity, 0)
+        totalLaborHours += laborHrs
+
+        if (!typeCounts[r.type]) typeCounts[r.type] = { count: 0, totalCost: 0 }
+        typeCounts[r.type].count += 1
+        typeCounts[r.type].totalCost += total
+
+        const _d = r.startDateTime ?? r.serviceDate
+        const month = `${_d.getFullYear()}-${String(_d.getMonth() + 1).padStart(2, '0')}`
+        if (!monthly[month]) monthly[month] = { partsCost: 0, laborCost: 0, totalCost: 0, count: 0 }
+        monthly[month].partsCost += partsCost
+        monthly[month].laborCost += laborCost
+        monthly[month].totalCost += total
+        monthly[month].count += 1
+
+        for (const p of r.partItems) {
+          const key = p.name.toLowerCase()
+          if (!partUsage[key])
+            partUsage[key] = {
+              partNumber: p.partNumber,
+              quantity: 0,
+              unit: p.unit ?? null,
+              totalCost: 0,
+            }
+          partUsage[key].quantity += p.quantity
+          partUsage[key].totalCost += p.unitCost * p.quantity
+          if (p.partNumber && !partUsage[key].partNumber) partUsage[key].partNumber = p.partNumber
+          if (p.unit && !partUsage[key].unit) partUsage[key].unit = p.unit
+          if (!partNames[key]) partNames[key] = p.name
+        }
       }
-    }
-
-    return {
-      vehicleInfo: {
-        id: vehicle.id,
-        year: vehicle.year,
-        make: vehicle.make,
-        model: vehicle.model,
-        vin: vehicle.vin,
-        licensePlate: vehicle.licensePlate,
-        mileage: vehicle.mileage,
-        customerName: vehicle.customer?.name ?? null,
-      },
-      summary: {
-        totalServices: records.length,
-        totalCost,
-        totalPartsUsed,
-        totalLaborHours,
-        repairCount: typeCounts["repair"]?.count ?? 0,
-        maintenanceCount: typeCounts["maintenance"]?.count ?? 0,
-        upgradeCount: typeCounts["upgrade"]?.count ?? 0,
-        inspectionCount: typeCounts["inspection"]?.count ?? 0,
-      },
-      monthlyCosts: Object.entries(monthly)
-        .map(([month, data]) => ({ month, ...data }))
-        .sort((a, b) => a.month.localeCompare(b.month)),
-      serviceTypeBreakdown: Object.entries(typeCounts)
-        .map(([type, data]) => ({ type, ...data }))
-        .sort((a, b) => b.count - a.count),
-      topParts: Object.entries(partUsage)
-        .map(([key, data]) => ({ name: partNames[key] || key, ...data }))
-        .sort((a, b) => b.quantity - a.quantity)
-        .slice(0, 20),
-      serviceHistory: records.map((r) => ({
-        id: r.id,
-        title: r.title,
-        type: r.type,
-        status: r.status,
-        date: (r.startDateTime ?? r.serviceDate).toISOString().split("T")[0],
-        totalAmount: r.totalAmount > 0 ? r.totalAmount : r.cost,
-        partsCount: r.partItems.reduce((s, p) => s + p.quantity, 0),
-        laborHours: r.laborItems.reduce((s, l) => s + l.hours, 0),
-        techName: r.technician?.name ?? r.techName ?? null,
-      })),
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+
+      return {
+        vehicleInfo: {
+          id: vehicle.id,
+          year: vehicle.year,
+          make: vehicle.make,
+          model: vehicle.model,
+          vin: vehicle.vin,
+          licensePlate: vehicle.licensePlate,
+          mileage: vehicle.mileage,
+          customerName: vehicle.customer?.name ?? null,
+        },
+        summary: {
+          totalServices: records.length,
+          totalCost,
+          totalPartsUsed,
+          totalLaborHours,
+          repairCount: typeCounts['repair']?.count ?? 0,
+          maintenanceCount: typeCounts['maintenance']?.count ?? 0,
+          upgradeCount: typeCounts['upgrade']?.count ?? 0,
+          inspectionCount: typeCounts['inspection']?.count ?? 0,
+        },
+        monthlyCosts: Object.entries(monthly)
+          .map(([month, data]) => ({ month, ...data }))
+          .sort((a, b) => a.month.localeCompare(b.month)),
+        serviceTypeBreakdown: Object.entries(typeCounts)
+          .map(([type, data]) => ({ type, ...data }))
+          .sort((a, b) => b.count - a.count),
+        topParts: Object.entries(partUsage)
+          .map(([key, data]) => ({ name: partNames[key] || key, ...data }))
+          .sort((a, b) => b.quantity - a.quantity)
+          .slice(0, 20),
+        serviceHistory: records.map((r) => ({
+          id: r.id,
+          title: r.title,
+          type: r.type,
+          status: r.status,
+          date: (r.startDateTime ?? r.serviceDate).toISOString().split('T')[0],
+          totalAmount: r.totalAmount > 0 ? r.totalAmount : r.cost,
+          partsCount: r.partItems.reduce((s, p) => s + p.quantity, 0),
+          laborHours: r.laborItems.reduce((s, l) => s + l.hours, 0),
+          techName: r.technician?.name ?? r.techName ?? null,
+        })),
+      }
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] }
+  )
 }
 
-export async function getTaxReport(params: {
-  startDate?: string;
-  endDate?: string;
-}) {
-  return withAuth(async ({ organizationId }) => {
-    const start = params.startDate ? new Date(params.startDate) : new Date(new Date().getFullYear(), 0, 1);
-    const end = params.endDate ? new Date(params.endDate) : new Date();
-    end.setHours(23, 59, 59, 999);
-
-    const records = await db.serviceRecord.findMany({
-      where: {
-        organizationId,
-        startDateTime: { gte: start, lte: end },
-      },
-      select: {
-        serviceDate: true,
-        startDateTime: true,
-        subtotal: true,
-        taxRate: true,
-        taxAmount: true,
-        taxInclusive: true,
-        totalAmount: true,
-      },
-      orderBy: [{ startDateTime: { sort: "asc", nulls: "last" } }, { serviceDate: "asc" }],
-    });
-
-    const monthly: Record<string, { taxCollected: number; invoiceCount: number; taxableAmount: number }> = {};
-    const byRate: Record<number, { taxCollected: number; invoiceCount: number }> = {};
-    let totalTaxCollected = 0;
-    let totalTaxableAmount = 0;
-    let totalInvoices = 0;
-
-    for (const r of records) {
-      if (r.taxAmount <= 0) continue;
-
-      const _dt = r.startDateTime ?? r.serviceDate;
-      const month = `${_dt.getFullYear()}-${String(_dt.getMonth() + 1).padStart(2, "0")}`;
-
-      // The taxable base = total - tax. This is the net amount on which tax
-      // was calculated, and it works in BOTH inclusive and exclusive modes
-      // (and accounts for any discount applied).
-      const taxableBase = Math.max(0, r.totalAmount - r.taxAmount);
-
-      if (!monthly[month]) monthly[month] = { taxCollected: 0, invoiceCount: 0, taxableAmount: 0 };
-      monthly[month].taxCollected += r.taxAmount;
-      monthly[month].invoiceCount += 1;
-      monthly[month].taxableAmount += taxableBase;
-
-      if (!byRate[r.taxRate]) byRate[r.taxRate] = { taxCollected: 0, invoiceCount: 0 };
-      byRate[r.taxRate].taxCollected += r.taxAmount;
-      byRate[r.taxRate].invoiceCount += 1;
-
-      totalTaxCollected += r.taxAmount;
-      totalTaxableAmount += taxableBase;
-      totalInvoices += 1;
-    }
-
-    return {
-      monthly: Object.entries(monthly).map(([month, data]) => ({ month, ...data })),
-      byRate: Object.entries(byRate).map(([rate, data]) => ({ taxRate: Number(rate), ...data })),
-      summary: { totalTaxCollected, totalTaxableAmount, totalInvoices },
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] });
+export async function getTaxReport(params: { startDate?: string; endDate?: string }) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const start = params.startDate
+        ? new Date(params.startDate)
+        : new Date(new Date().getFullYear(), 0, 1)
+      const end = params.endDate ? new Date(params.endDate) : new Date()
+      end.setHours(23, 59, 59, 999)
+
+      const records = await db.serviceRecord.findMany({
+        where: {
+          organizationId,
+          startDateTime: { gte: start, lte: end },
+        },
+        select: {
+          serviceDate: true,
+          startDateTime: true,
+          subtotal: true,
+          taxRate: true,
+          taxAmount: true,
+          taxInclusive: true,
+          totalAmount: true,
+        },
+        orderBy: [{ startDateTime: { sort: 'asc', nulls: 'last' } }, { serviceDate: 'asc' }],
+      })
+
+      const monthly: Record<
+        string,
+        { taxCollected: number; invoiceCount: number; taxableAmount: number }
+      > = {}
+      const byRate: Record<number, { taxCollected: number; invoiceCount: number }> = {}
+      let totalTaxCollected = 0
+      let totalTaxableAmount = 0
+      let totalInvoices = 0
+
+      for (const r of records) {
+        if (r.taxAmount <= 0) continue
+
+        const _dt = r.startDateTime ?? r.serviceDate
+        const month = `${_dt.getFullYear()}-${String(_dt.getMonth() + 1).padStart(2, '0')}`
+
+        // The taxable base = total - tax. This is the net amount on which tax
+        // was calculated, and it works in BOTH inclusive and exclusive modes
+        // (and accounts for any discount applied).
+        const taxableBase = Math.max(0, r.totalAmount - r.taxAmount)
+
+        if (!monthly[month]) monthly[month] = { taxCollected: 0, invoiceCount: 0, taxableAmount: 0 }
+        monthly[month].taxCollected += r.taxAmount
+        monthly[month].invoiceCount += 1
+        monthly[month].taxableAmount += taxableBase
+
+        if (!byRate[r.taxRate]) byRate[r.taxRate] = { taxCollected: 0, invoiceCount: 0 }
+        byRate[r.taxRate].taxCollected += r.taxAmount
+        byRate[r.taxRate].invoiceCount += 1
+
+        totalTaxCollected += r.taxAmount
+        totalTaxableAmount += taxableBase
+        totalInvoices += 1
+      }
+
+      return {
+        monthly: Object.entries(monthly).map(([month, data]) => ({ month, ...data })),
+        byRate: Object.entries(byRate).map(([rate, data]) => ({ taxRate: Number(rate), ...data })),
+        summary: { totalTaxCollected, totalTaxableAmount, totalInvoices },
+      }
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.REPORTS }] }
+  )
 }

Разница между файлами не показана из-за своего большого размера
+ 349 - 237
src/features/reports/Components/ReportPDF.tsx


+ 194 - 74
src/features/reports/Components/csv-export.ts

@@ -10,42 +10,49 @@ import type {
   TaxReport,
   PastDueInvoicesReport,
   VehicleReportData,
-} from "../Schema/reportTypes";
-import { formatCurrency } from "@/lib/format";
+} from '../Schema/reportTypes'
+import { formatCurrency } from '@/lib/format'
 
 function downloadCsv(
   filename: string,
   headers: string[],
   rows: Record<string, unknown>[],
-  keys: string[],
+  keys: string[]
 ) {
   const escapeCsv = (v: unknown) => {
-    const s = String(v ?? "");
-    return s.includes(",") || s.includes('"') || s.includes("\n")
-      ? `"${s.replace(/"/g, '""')}"`
-      : s;
-  };
+    const s = String(v ?? '')
+    return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s
+  }
 
   const lines = [
-    headers.map(escape).join(","),
-    ...rows.map((r) => keys.map((k) => escapeCsv(r[k])).join(",")),
-  ];
+    headers.map(escape).join(','),
+    ...rows.map((r) => keys.map((k) => escapeCsv(r[k])).join(',')),
+  ]
 
-  const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8;" });
-  const url = URL.createObjectURL(blob);
-  const a = document.createElement("a");
-  a.href = url;
-  a.download = filename;
-  a.click();
-  URL.revokeObjectURL(url);
+  const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' })
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = filename
+  a.click()
+  URL.revokeObjectURL(url)
 }
 
 export function exportRevenueCsv(
   data: RevenueReport,
   currencyCode: string,
-  headers: [string, string, string, string, string, string, string, string] = ["Month", "Revenue", "Collected", "Count", "Parts Cost", "Parts Net Profit", "Labor", "Net Profit"],
+  headers: [string, string, string, string, string, string, string, string] = [
+    'Month',
+    'Revenue',
+    'Collected',
+    'Count',
+    'Parts Cost',
+    'Parts Net Profit',
+    'Labor',
+    'Net Profit',
+  ]
 ) {
-  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const fmt = (n: number) => formatCurrency(n, currencyCode)
   const rows = data.monthly.map((m) => ({
     month: m.month,
     revenue: fmt(m.revenue),
@@ -55,58 +62,92 @@ export function exportRevenueCsv(
     partsNetProfit: fmt(m.partsNetProfit),
     laborRevenue: fmt(m.laborRevenue),
     netProfit: fmt(m.netProfit),
-  }));
-  downloadCsv("revenue-report.csv", headers, rows, ["month", "revenue", "collected", "count", "partsCost", "partsNetProfit", "laborRevenue", "netProfit"]);
+  }))
+  downloadCsv('revenue-report.csv', headers, rows, [
+    'month',
+    'revenue',
+    'collected',
+    'count',
+    'partsCost',
+    'partsNetProfit',
+    'laborRevenue',
+    'netProfit',
+  ])
 }
 
 export function exportServicesCsv(
   data: ServiceReport,
-  headers: [string, string, string] = ["Category", "Label", "Count"],
+  headers: [string, string, string] = ['Category', 'Label', 'Count']
 ) {
   const rows = [
-    ...data.byStatus.map((s) => ({ category: "Status", label: s.status, count: s.count })),
-    ...data.byType.map((t) => ({ category: "Type", label: t.type, count: t.count })),
-  ];
-  downloadCsv("services-report.csv", headers, rows, ["category", "label", "count"]);
+    ...data.byStatus.map((s) => ({ category: 'Status', label: s.status, count: s.count })),
+    ...data.byType.map((t) => ({ category: 'Type', label: t.type, count: t.count })),
+  ]
+  downloadCsv('services-report.csv', headers, rows, ['category', 'label', 'count'])
 }
 
 export function exportCustomersCsv(
   data: CustomerReport,
   currencyCode: string,
-  headers: [string, string, string, string] = ["Name", "Company", "Services", "Total Spent"],
+  headers: [string, string, string, string] = ['Name', 'Company', 'Services', 'Total Spent']
 ) {
-  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const fmt = (n: number) => formatCurrency(n, currencyCode)
   const rows = data.topCustomers.map((c) => ({
     name: c.name,
-    company: c.company ?? "",
+    company: c.company ?? '',
     serviceCount: c.serviceCount,
     totalSpent: fmt(c.totalSpent),
-  }));
-  downloadCsv("customers-report.csv", headers, rows, ["name", "company", "serviceCount", "totalSpent"]);
+  }))
+  downloadCsv('customers-report.csv', headers, rows, [
+    'name',
+    'company',
+    'serviceCount',
+    'totalSpent',
+  ])
 }
 
 export function exportInventoryCsv(
   data: InventoryReport,
   currencyCode: string,
-  headers: [string, string, string, string, string] = ["Name", "Part #", "Quantity", "Min Quantity", "Unit Cost"],
+  headers: [string, string, string, string, string] = [
+    'Name',
+    'Part #',
+    'Quantity',
+    'Min Quantity',
+    'Unit Cost',
+  ]
 ) {
-  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const fmt = (n: number) => formatCurrency(n, currencyCode)
   const rows = data.lowStock.map((p) => ({
     name: p.name,
-    partNumber: p.partNumber ?? "",
-    quantity: p.quantity,
-    minQuantity: p.minQuantity ?? "",
-    unitCost: p.unitCost != null ? fmt(p.unitCost) : "",
-  }));
-  downloadCsv("inventory-report.csv", headers, rows, ["name", "partNumber", "quantity", "minQuantity", "unitCost"]);
+    partNumber: p.partNumber ?? '',
+    quantity: p.unit ? `${p.quantity} ${p.unit}` : p.quantity,
+    minQuantity:
+      p.minQuantity != null ? (p.unit ? `${p.minQuantity} ${p.unit}` : p.minQuantity) : '',
+    unitCost: p.unitCost != null ? fmt(p.unitCost) : '',
+  }))
+  downloadCsv('inventory-report.csv', headers, rows, [
+    'name',
+    'partNumber',
+    'quantity',
+    'minQuantity',
+    'unitCost',
+  ])
 }
 
 export function exportTechniciansCsv(
   data: TechnicianReport,
   currencyCode: string,
-  headers: [string, string, string, string, string, string] = ["Technician", "Jobs", "Total Revenue", "Avg Revenue", "Total Hours", "Avg Hours"],
+  headers: [string, string, string, string, string, string] = [
+    'Technician',
+    'Jobs',
+    'Total Revenue',
+    'Avg Revenue',
+    'Total Hours',
+    'Avg Hours',
+  ]
 ) {
-  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const fmt = (n: number) => formatCurrency(n, currencyCode)
   const rows = data.technicians.map((t) => ({
     techName: t.techName,
     jobCount: t.jobCount,
@@ -114,84 +155,145 @@ export function exportTechniciansCsv(
     avgRevenue: fmt(t.avgRevenue),
     totalHours: t.totalLaborHours.toFixed(1),
     avgHours: t.avgHours.toFixed(1),
-  }));
-  downloadCsv("technicians-report.csv", headers, rows, ["techName", "jobCount", "totalRevenue", "avgRevenue", "totalHours", "avgHours"]);
+  }))
+  downloadCsv('technicians-report.csv', headers, rows, [
+    'techName',
+    'jobCount',
+    'totalRevenue',
+    'avgRevenue',
+    'totalHours',
+    'avgHours',
+  ])
 }
 
 export function exportPartsCsv(
   data: PartsUsageReport,
   currencyCode: string,
-  headers: [string, string, string, string, string, string, string] = ["Part Name", "Part #", "Usage Count", "Total Qty", "Revenue", "Cost", "Net Profit"],
+  headers: [string, string, string, string, string, string, string] = [
+    'Part Name',
+    'Part #',
+    'Usage Count',
+    'Total Qty',
+    'Revenue',
+    'Cost',
+    'Net Profit',
+  ]
 ) {
-  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const fmt = (n: number) => formatCurrency(n, currencyCode)
   const rows = data.parts.map((p) => ({
     name: p.name,
-    partNumber: p.partNumber ?? "",
+    partNumber: p.partNumber ?? '',
     usageCount: p.usageCount,
     totalQuantity: p.totalQuantity,
     totalRevenue: fmt(p.totalRevenue),
     totalCost: fmt(p.totalCost),
     netProfit: fmt(p.netProfit),
-  }));
-  downloadCsv("parts-usage-report.csv", headers, rows, ["name", "partNumber", "usageCount", "totalQuantity", "totalRevenue", "totalCost", "netProfit"]);
+  }))
+  downloadCsv('parts-usage-report.csv', headers, rows, [
+    'name',
+    'partNumber',
+    'usageCount',
+    'totalQuantity',
+    'totalRevenue',
+    'totalCost',
+    'netProfit',
+  ])
 }
 
 export function exportJobAnalyticsCsv(
   data: JobAnalyticsReport,
   currencyCode: string,
-  headers: [string, string, string, string] = ["Service Type", "Count", "Avg Value", "Avg Hours"],
+  headers: [string, string, string, string] = ['Service Type', 'Count', 'Avg Value', 'Avg Hours']
 ) {
-  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const fmt = (n: number) => formatCurrency(n, currencyCode)
   const rows = data.topServiceTypes.map((t) => ({
     type: t.type,
     count: t.count,
     avgValue: fmt(t.avgValue),
     avgHours: t.avgHours.toFixed(1),
-  }));
-  downloadCsv("job-analytics-report.csv", headers, rows, ["type", "count", "avgValue", "avgHours"]);
+  }))
+  downloadCsv('job-analytics-report.csv', headers, rows, ['type', 'count', 'avgValue', 'avgHours'])
 }
 
 export function exportRetentionCsv(
   data: CustomerRetentionReport,
   currencyCode: string,
-  headers: [string, string, string, string, string] = ["Customer", "Company", "Visits", "Total Spent", "Avg Days Between Visits"],
+  headers: [string, string, string, string, string] = [
+    'Customer',
+    'Company',
+    'Visits',
+    'Total Spent',
+    'Avg Days Between Visits',
+  ]
 ) {
-  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const fmt = (n: number) => formatCurrency(n, currencyCode)
   const rows = data.topReturning.map((c) => ({
     name: c.name,
-    company: c.company ?? "",
+    company: c.company ?? '',
     visitCount: c.visitCount,
     totalSpent: fmt(c.totalSpent),
-    avgDaysBetweenVisits: c.avgTimeBetweenVisits ?? "",
-  }));
-  downloadCsv("retention-report.csv", headers, rows, ["name", "company", "visitCount", "totalSpent", "avgDaysBetweenVisits"]);
+    avgDaysBetweenVisits: c.avgTimeBetweenVisits ?? '',
+  }))
+  downloadCsv('retention-report.csv', headers, rows, [
+    'name',
+    'company',
+    'visitCount',
+    'totalSpent',
+    'avgDaysBetweenVisits',
+  ])
 }
 
 export function exportPastDueInvoicesCsv(
   data: PastDueInvoicesReport,
   currencyCode: string,
-  headers: [string, string, string, string, string, string, string, string] = ["Customer", "Company", "Invoice #", "Total Amount", "Amount Paid", "Amount Due", "Due Date", "Days Past Due"],
+  headers: [string, string, string, string, string, string, string, string] = [
+    'Customer',
+    'Company',
+    'Invoice #',
+    'Total Amount',
+    'Amount Paid',
+    'Amount Due',
+    'Due Date',
+    'Days Past Due',
+  ]
 ) {
-  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const fmt = (n: number) => formatCurrency(n, currencyCode)
   const rows = data.invoices.map((inv) => ({
     customer: inv.customerName,
-    company: inv.customerCompany ?? "",
-    invoiceNumber: inv.invoiceNumber ?? "",
+    company: inv.customerCompany ?? '',
+    invoiceNumber: inv.invoiceNumber ?? '',
     totalAmount: fmt(inv.totalAmount),
     amountPaid: fmt(inv.amountPaid),
     amountDue: fmt(inv.amountDue),
     dueDate: inv.dueDate,
     daysPastDue: inv.daysPastDue,
-  }));
-  downloadCsv("past-due-invoices-report.csv", headers, rows, ["customer", "company", "invoiceNumber", "totalAmount", "amountPaid", "amountDue", "dueDate", "daysPastDue"]);
+  }))
+  downloadCsv('past-due-invoices-report.csv', headers, rows, [
+    'customer',
+    'company',
+    'invoiceNumber',
+    'totalAmount',
+    'amountPaid',
+    'amountDue',
+    'dueDate',
+    'daysPastDue',
+  ])
 }
 
 export function exportVehicleReportCsv(
   data: VehicleReportData,
   currencyCode: string,
-  headers: [string, string, string, string, string, string, string] = ["Date", "Title", "Type", "Status", "Total", "Parts", "Labor Hours"],
+  headers: [string, string, string, string, string, string, string] = [
+    'Date',
+    'Title',
+    'Type',
+    'Status',
+    'Total',
+    'Parts',
+    'Labor Hours',
+  ]
 ) {
-  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const fmt = (n: number) => formatCurrency(n, currencyCode)
   const rows = data.serviceHistory.map((s) => ({
     date: s.date,
     title: s.title,
@@ -200,22 +302,40 @@ export function exportVehicleReportCsv(
     totalAmount: fmt(s.totalAmount),
     partsCount: s.partsCount,
     laborHours: s.laborHours.toFixed(1),
-  }));
-  const vehicleLabel = `${data.vehicleInfo.year} ${data.vehicleInfo.make} ${data.vehicleInfo.model}`;
-  downloadCsv(`vehicle-report-${vehicleLabel.replace(/\s+/g, "-")}.csv`, headers, rows, ["date", "title", "type", "status", "totalAmount", "partsCount", "laborHours"]);
+  }))
+  const vehicleLabel = `${data.vehicleInfo.year} ${data.vehicleInfo.make} ${data.vehicleInfo.model}`
+  downloadCsv(`vehicle-report-${vehicleLabel.replace(/\s+/g, '-')}.csv`, headers, rows, [
+    'date',
+    'title',
+    'type',
+    'status',
+    'totalAmount',
+    'partsCount',
+    'laborHours',
+  ])
 }
 
 export function exportTaxCsv(
   data: TaxReport,
   currencyCode: string,
-  headers: [string, string, string, string] = ["Month", "Tax Collected", "Taxable Amount", "Invoice Count"],
+  headers: [string, string, string, string] = [
+    'Month',
+    'Tax Collected',
+    'Taxable Amount',
+    'Invoice Count',
+  ]
 ) {
-  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const fmt = (n: number) => formatCurrency(n, currencyCode)
   const rows = data.monthly.map((m) => ({
     month: m.month,
     taxCollected: fmt(m.taxCollected),
     taxableAmount: fmt(m.taxableAmount),
     invoiceCount: m.invoiceCount,
-  }));
-  downloadCsv("tax-report.csv", headers, rows, ["month", "taxCollected", "taxableAmount", "invoiceCount"]);
+  }))
+  downloadCsv('tax-report.csv', headers, rows, [
+    'month',
+    'taxCollected',
+    'taxableAmount',
+    'invoiceCount',
+  ])
 }

+ 173 - 171
src/features/reports/Schema/reportTypes.ts

@@ -1,273 +1,275 @@
 export interface MonthlyRevenue {
-  month: string;
-  revenue: number;
-  collected: number;
-  count: number;
-  partsCost: number;
-  partsRevenue: number;
-  partsNetProfit: number;
-  laborRevenue: number;
-  netProfit: number;
+  month: string
+  revenue: number
+  collected: number
+  count: number
+  partsCost: number
+  partsRevenue: number
+  partsNetProfit: number
+  laborRevenue: number
+  netProfit: number
 }
 
 export interface RevenueByType {
-  type: string;
-  revenue: number;
-  count: number;
+  type: string
+  revenue: number
+  count: number
 }
 
 export interface RevenueSummary {
-  totalRevenue: number;
-  totalCollected: number;
-  outstanding: number;
-  totalCount: number;
-  totalPartsCost: number;
-  totalPartsRevenue: number;
-  totalPartsNetProfit: number;
-  totalLaborRevenue: number;
-  netProfit: number;
+  totalRevenue: number
+  totalCollected: number
+  outstanding: number
+  totalCount: number
+  totalPartsCost: number
+  totalPartsRevenue: number
+  totalPartsNetProfit: number
+  totalLaborRevenue: number
+  netProfit: number
 }
 
 export interface RevenueReport {
-  monthly: MonthlyRevenue[];
-  byType: RevenueByType[];
-  summary: RevenueSummary;
+  monthly: MonthlyRevenue[]
+  byType: RevenueByType[]
+  summary: RevenueSummary
 }
 
 export interface ServiceByStatus {
-  status: string;
-  count: number;
+  status: string
+  count: number
 }
 
 export interface ServiceByType {
-  type: string;
-  count: number;
+  type: string
+  count: number
 }
 
 export interface ServiceReport {
-  totalServices: number;
-  byStatus: ServiceByStatus[];
-  byType: ServiceByType[];
+  totalServices: number
+  byStatus: ServiceByStatus[]
+  byType: ServiceByType[]
 }
 
 export interface TopCustomer {
-  id: string;
-  name: string;
-  company: string | null;
-  totalSpent: number;
-  serviceCount: number;
+  id: string
+  name: string
+  company: string | null
+  totalSpent: number
+  serviceCount: number
 }
 
 export interface CustomerReport {
-  totalCustomers: number;
-  activeCustomers: number;
-  topCustomers: TopCustomer[];
+  totalCustomers: number
+  activeCustomers: number
+  topCustomers: TopCustomer[]
 }
 
 export interface LowStockPart {
-  id: string;
-  name: string;
-  partNumber: string | null;
-  quantity: number;
-  unitCost: number | null;
-  sellPrice: number | null;
-  minQuantity: number | null;
+  id: string
+  name: string
+  partNumber: string | null
+  quantity: number
+  unit?: string | null
+  unitCost: number | null
+  sellPrice: number | null
+  minQuantity: number | null
 }
 
 export interface InventoryReport {
-  totalParts: number;
-  totalItems: number;
-  totalValue: number;
-  totalSellValue: number;
-  lowStock: LowStockPart[];
+  totalParts: number
+  totalItems: number
+  totalValue: number
+  totalSellValue: number
+  lowStock: LowStockPart[]
 }
 
 // Enhanced report types
 
 export interface TechnicianMetrics {
-  techName: string;
-  jobCount: number;
-  totalRevenue: number;
-  avgRevenue: number;
-  totalLaborHours: number;
-  avgHours: number;
+  techName: string
+  jobCount: number
+  totalRevenue: number
+  avgRevenue: number
+  totalLaborHours: number
+  avgHours: number
 }
 
 export interface TechnicianReport {
-  technicians: TechnicianMetrics[];
-  totalJobs: number;
-  totalRevenue: number;
+  technicians: TechnicianMetrics[]
+  totalJobs: number
+  totalRevenue: number
 }
 
 export interface PartUsage {
-  name: string;
-  partNumber: string | null;
-  usageCount: number;
-  totalQuantity: number;
-  totalRevenue: number;
-  totalCost: number;
-  netProfit: number;
+  name: string
+  partNumber: string | null
+  usageCount: number
+  totalQuantity: number
+  totalRevenue: number
+  totalCost: number
+  netProfit: number
 }
 
 export interface PartsUsageReport {
-  parts: PartUsage[];
-  totalPartsRevenue: number;
-  totalPartsCost: number;
-  totalPartsNetProfit: number;
-  totalPartsUsed: number;
+  parts: PartUsage[]
+  totalPartsRevenue: number
+  totalPartsCost: number
+  totalPartsNetProfit: number
+  totalPartsUsed: number
 }
 
 export interface DayOfWeekDistribution {
-  day: string;
-  count: number;
+  day: string
+  count: number
 }
 
 export interface MonthlyTrend {
-  month: string;
-  count: number;
-  revenue: number;
+  month: string
+  count: number
+  revenue: number
 }
 
 export interface ServiceTypeAnalytics {
-  type: string;
-  count: number;
-  avgValue: number;
-  avgHours: number;
+  type: string
+  count: number
+  avgValue: number
+  avgHours: number
 }
 
 export interface JobAnalyticsReport {
-  avgJobValue: number;
-  totalJobs: number;
-  topServiceTypes: ServiceTypeAnalytics[];
-  dayOfWeek: DayOfWeekDistribution[];
-  monthlyTrend: MonthlyTrend[];
+  avgJobValue: number
+  totalJobs: number
+  topServiceTypes: ServiceTypeAnalytics[]
+  dayOfWeek: DayOfWeekDistribution[]
+  monthlyTrend: MonthlyTrend[]
 }
 
 export interface RetentionCustomer {
-  id: string;
-  name: string;
-  company: string | null;
-  visitCount: number;
-  totalSpent: number;
-  avgTimeBetweenVisits: number | null;
+  id: string
+  name: string
+  company: string | null
+  visitCount: number
+  totalSpent: number
+  avgTimeBetweenVisits: number | null
 }
 
 export interface CustomerRetentionReport {
-  returningCustomers: number;
-  newCustomers: number;
-  totalActive: number;
-  avgTimeBetweenVisits: number | null;
-  topReturning: RetentionCustomer[];
+  returningCustomers: number
+  newCustomers: number
+  totalActive: number
+  avgTimeBetweenVisits: number | null
+  topReturning: RetentionCustomer[]
 }
 
 export interface PastDueInvoice {
-  id: string;
-  invoiceNumber: string | null;
-  customerName: string;
-  customerCompany: string | null;
-  vehicleInfo: string;
-  totalAmount: number;
-  amountPaid: number;
-  amountDue: number;
-  dueDate: string;
-  daysPastDue: number;
+  id: string
+  invoiceNumber: string | null
+  customerName: string
+  customerCompany: string | null
+  vehicleInfo: string
+  totalAmount: number
+  amountPaid: number
+  amountDue: number
+  dueDate: string
+  daysPastDue: number
 }
 
 export interface PastDueInvoicesReport {
-  invoices: PastDueInvoice[];
+  invoices: PastDueInvoice[]
   summary: {
-    totalPastDue: number;
-    totalAmountDue: number;
-    totalInvoices: number;
-    over30: number;
-    over60: number;
-    over90: number;
-  };
+    totalPastDue: number
+    totalAmountDue: number
+    totalInvoices: number
+    over30: number
+    over60: number
+    over90: number
+  }
 }
 
 export interface MonthlyTax {
-  month: string;
-  taxCollected: number;
-  invoiceCount: number;
-  taxableAmount: number;
+  month: string
+  taxCollected: number
+  invoiceCount: number
+  taxableAmount: number
 }
 
 export interface TaxByRate {
-  taxRate: number;
-  taxCollected: number;
-  invoiceCount: number;
+  taxRate: number
+  taxCollected: number
+  invoiceCount: number
 }
 
 export interface TaxReport {
-  monthly: MonthlyTax[];
-  byRate: TaxByRate[];
+  monthly: MonthlyTax[]
+  byRate: TaxByRate[]
   summary: {
-    totalTaxCollected: number;
-    totalTaxableAmount: number;
-    totalInvoices: number;
-  };
+    totalTaxCollected: number
+    totalTaxableAmount: number
+    totalInvoices: number
+  }
 }
 
 // --- Vehicle Reports ---
 
 export interface VehicleReportSummary {
-  totalServices: number;
-  totalCost: number;
-  totalPartsUsed: number;
-  totalLaborHours: number;
-  repairCount: number;
-  maintenanceCount: number;
-  upgradeCount: number;
-  inspectionCount: number;
+  totalServices: number
+  totalCost: number
+  totalPartsUsed: number
+  totalLaborHours: number
+  repairCount: number
+  maintenanceCount: number
+  upgradeCount: number
+  inspectionCount: number
 }
 
 export interface VehicleMonthlyCost {
-  month: string;
-  partsCost: number;
-  laborCost: number;
-  totalCost: number;
-  count: number;
+  month: string
+  partsCost: number
+  laborCost: number
+  totalCost: number
+  count: number
 }
 
 export interface VehicleServiceTypeBreakdown {
-  type: string;
-  count: number;
-  totalCost: number;
+  type: string
+  count: number
+  totalCost: number
 }
 
 export interface VehiclePartUsage {
-  name: string;
-  partNumber: string | null;
-  quantity: number;
-  totalCost: number;
+  name: string
+  partNumber: string | null
+  quantity: number
+  unit?: string | null
+  totalCost: number
 }
 
 export interface VehicleServiceHistoryItem {
-  id: string;
-  title: string;
-  type: string;
-  status: string;
-  date: string;
-  totalAmount: number;
-  partsCount: number;
-  laborHours: number;
-  techName: string | null;
+  id: string
+  title: string
+  type: string
+  status: string
+  date: string
+  totalAmount: number
+  partsCount: number
+  laborHours: number
+  techName: string | null
 }
 
 export interface VehicleReportData {
   vehicleInfo: {
-    id: string;
-    year: number;
-    make: string;
-    model: string;
-    vin: string | null;
-    licensePlate: string | null;
-    mileage: number;
-    customerName: string | null;
-  };
-  summary: VehicleReportSummary;
-  monthlyCosts: VehicleMonthlyCost[];
-  serviceTypeBreakdown: VehicleServiceTypeBreakdown[];
-  topParts: VehiclePartUsage[];
-  serviceHistory: VehicleServiceHistoryItem[];
+    id: string
+    year: number
+    make: string
+    model: string
+    vin: string | null
+    licensePlate: string | null
+    mileage: number
+    customerName: string | null
+  }
+  summary: VehicleReportSummary
+  monthlyCosts: VehicleMonthlyCost[]
+  serviceTypeBreakdown: VehicleServiceTypeBreakdown[]
+  topParts: VehiclePartUsage[]
+  serviceHistory: VehicleServiceHistoryItem[]
 }

+ 1 - 0
src/features/search/Actions/searchActions.ts

@@ -215,6 +215,7 @@ export async function globalSearch(query: string) {
               name: true,
               partNumber: true,
               quantity: true,
+              unit: true,
             },
             take: 10,
           }),

+ 5 - 1
src/features/search/Components/SearchCommand.tsx

@@ -1,6 +1,7 @@
 'use client'
 
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { formatQuantity } from '@/lib/format-quantity'
 import { useTranslations } from 'next-intl'
 import { useRouter } from 'next/navigation'
 import {
@@ -63,6 +64,7 @@ interface SearchResult {
     name: string
     partNumber: string | null
     quantity: number
+    unit: string | null
   }[]
   quotes: {
     id: string
@@ -487,7 +489,9 @@ export function SearchCommand() {
                     <div className="flex flex-col">
                       <span>{p.name}</span>
                       <span className="text-xs text-muted-foreground">
-                        {[p.partNumber, `${p.quantity} in stock`].filter(Boolean).join(' · ')}
+                        {[p.partNumber, `${formatQuantity(p.quantity, p.unit)} in stock`]
+                          .filter(Boolean)
+                          .join(' · ')}
                       </span>
                     </div>
                   </CommandItem>

+ 131 - 133
src/features/settings/Components/InvoiceLayoutPreviewRenderer.tsx

@@ -1,121 +1,124 @@
-"use client";
+'use client'
 
-import { useMemo, useState, useEffect } from "react";
-import { useMessages } from "next-intl";
-import { PDFViewer } from "@react-pdf/renderer";
-import "@/features/vehicles/Components/invoice-pdf/fonts.client";
-import { InvoicePDF } from "@/features/vehicles/Components/InvoicePDF";
-import { QuotePDF } from "@/features/quotes/Components/QuotePDF";
-import type { InvoiceLayoutPreviewProps } from "./InvoiceLayoutPreview";
-import { useServiceType } from "@/components/service-type-context";
+import { useMemo, useState, useEffect } from 'react'
+import { useMessages } from 'next-intl'
+import { PDFViewer } from '@react-pdf/renderer'
+import '@/features/vehicles/Components/invoice-pdf/fonts.client'
+import { InvoicePDF } from '@/features/vehicles/Components/InvoicePDF'
+import { QuotePDF } from '@/features/quotes/Components/QuotePDF'
+import type { InvoiceLayoutPreviewProps } from './InvoiceLayoutPreview'
+import { useServiceType } from '@/components/service-type-context'
 
 // ---------------------------------------------------------------------------
 // Dummy data matching InvoiceData type exactly
 // ---------------------------------------------------------------------------
 
-function buildDummyCustomFields(
-  customFields?: InvoiceLayoutPreviewProps["customFields"],
-) {
-  if (!customFields) return [];
+function buildDummyCustomFields(customFields?: InvoiceLayoutPreviewProps['customFields']) {
+  if (!customFields) return []
   return customFields
-    .filter((f) => f.isActive && f.entityType === "service_record")
+    .filter((f) => f.isActive && f.entityType === 'service_record')
     .map((f) => ({
       fieldId: f.id,
       label: f.label,
       value:
-        f.fieldType === "checkbox"
-          ? "true"
-          : f.fieldType === "number"
-            ? "42"
-            : f.fieldType === "date"
-              ? "2026-03-10"
-              : "Sample value",
+        f.fieldType === 'checkbox'
+          ? 'true'
+          : f.fieldType === 'number'
+            ? '42'
+            : f.fieldType === 'date'
+              ? '2026-03-10'
+              : 'Sample value',
       fieldType: f.fieldType,
-    }));
+    }))
 }
 
 const DUMMY_INVOICE_DATA = {
-  id: "preview-dummy-id-00000001",
-  title: "Brake Service & Oil Change",
+  id: 'preview-dummy-id-00000001',
+  title: 'Brake Service & Oil Change',
   description: null,
-  type: "Maintenance",
-  serviceDate: new Date("2026-03-10"),
-  startDateTime: new Date("2026-03-10"),
-  shopName: "Your Workshop",
-  techName: "Mike Johnson",
+  type: 'Maintenance',
+  serviceDate: new Date('2026-03-10'),
+  startDateTime: new Date('2026-03-10'),
+  shopName: 'Your Workshop',
+  techName: 'Mike Johnson',
   mileage: 45230,
   diagnosticNotes: null,
   findings: [
-    { description: "Rear brake pads worn to 15%", severity: "needs_work", notes: "Recommend replacement within 5,000 miles" },
-    { description: "Minor oil leak at valve cover gasket", severity: "monitor", notes: null },
+    {
+      description: 'Rear brake pads worn to 15%',
+      severity: 'needs_work',
+      notes: 'Recommend replacement within 5,000 miles',
+    },
+    { description: 'Minor oil leak at valve cover gasket', severity: 'monitor', notes: null },
   ],
   invoiceNotes:
-    "<p>Front brake pads replaced. Oil and filter changed with synthetic 5W-30. Next service recommended at 50,000 miles.</p>",
+    '<p>Front brake pads replaced. Oil and filter changed with synthetic 5W-30. Next service recommended at 50,000 miles.</p>',
   subtotal: 314.5,
   taxRate: 8,
   taxAmount: 25.16,
   totalAmount: 339.66,
   cost: 339.66,
-  invoiceNumber: "INV-2026-1001",
+  invoiceNumber: 'INV-2026-1001',
   discountType: null,
   discountValue: 0,
   discountAmount: 0,
   partItems: [
     {
-      partNumber: "BP-001",
-      name: "Brake Pads (Front)",
+      partNumber: 'BP-001',
+      name: 'Brake Pads (Front)',
       quantity: 2,
       unitPrice: 45.0,
       total: 90.0,
     },
     {
-      partNumber: "OF-042",
-      name: "Oil Filter",
+      partNumber: 'OF-042',
+      name: 'Oil Filter',
       quantity: 1,
       unitPrice: 12.0,
       total: 12.0,
     },
     {
-      partNumber: "SO-530",
-      name: "Synthetic Oil 5W-30",
+      partNumber: 'SO-530',
+      name: 'Synthetic Oil 5W-30',
       quantity: 5,
+      unit: 'l',
       unitPrice: 8.5,
       total: 42.5,
     },
   ],
   laborItems: [
     {
-      description: "Brake Replacement",
+      description: 'Brake Replacement',
       hours: 1.5,
       rate: 85.0,
       total: 127.5,
     },
-    { description: "Oil Change", hours: 0.5, rate: 85.0, total: 42.5 },
+    { description: 'Oil Change', hours: 0.5, rate: 85.0, total: 42.5 },
   ],
   vehicle: {
-    make: "Toyota",
-    model: "Camry",
+    make: 'Toyota',
+    model: 'Camry',
     year: 2022,
-    vin: "1HGBH41JXMN109186",
-    licensePlate: "ABC-1234",
+    vin: '1HGBH41JXMN109186',
+    licensePlate: 'ABC-1234',
     mileage: 45230,
     customer: {
-      name: "John Smith",
-      email: "john@example.com",
-      phone: "(555) 123-4567",
-      address: "123 Main Street, Springfield",
-      company: "Smith Auto Group",
-      taxId: "GB123456789",
+      name: 'John Smith',
+      email: 'john@example.com',
+      phone: '(555) 123-4567',
+      address: '123 Main Street, Springfield',
+      company: 'Smith Auto Group',
+      taxId: 'GB123456789',
     },
   },
-};
+}
 
 const DUMMY_QUOTE_DATA = {
-  quoteNumber: "QT-2026-1001",
-  title: "Brake Service & Oil Change",
+  quoteNumber: 'QT-2026-1001',
+  title: 'Brake Service & Oil Change',
   description: null,
-  validUntil: new Date("2026-04-10"),
-  createdAt: new Date("2026-03-10"),
+  validUntil: new Date('2026-04-10'),
+  createdAt: new Date('2026-03-10'),
   subtotal: 314.5,
   taxRate: 8,
   taxAmount: 25.16,
@@ -123,75 +126,77 @@ const DUMMY_QUOTE_DATA = {
   discountValue: 0,
   discountAmount: 0,
   totalAmount: 339.66,
-  notes: "<p>Front brake pads replacement recommended. Oil and filter change with synthetic 5W-30.</p>",
+  notes:
+    '<p>Front brake pads replacement recommended. Oil and filter change with synthetic 5W-30.</p>',
   partItems: [
     {
-      partNumber: "BP-001",
-      name: "Brake Pads (Front)",
+      partNumber: 'BP-001',
+      name: 'Brake Pads (Front)',
       quantity: 2,
       unitPrice: 45.0,
       total: 90.0,
     },
     {
-      partNumber: "OF-042",
-      name: "Oil Filter",
+      partNumber: 'OF-042',
+      name: 'Oil Filter',
       quantity: 1,
       unitPrice: 12.0,
       total: 12.0,
     },
     {
-      partNumber: "SO-530",
-      name: "Synthetic Oil 5W-30",
+      partNumber: 'SO-530',
+      name: 'Synthetic Oil 5W-30',
       quantity: 5,
+      unit: 'l',
       unitPrice: 8.5,
       total: 42.5,
     },
   ],
   laborItems: [
     {
-      description: "Brake Replacement",
+      description: 'Brake Replacement',
       hours: 1.5,
       rate: 85.0,
       total: 127.5,
     },
-    { description: "Oil Change", hours: 0.5, rate: 85.0, total: 42.5 },
+    { description: 'Oil Change', hours: 0.5, rate: 85.0, total: 42.5 },
   ],
   customer: {
-    name: "John Smith",
-    email: "john@example.com",
-    phone: "(555) 123-4567",
-    address: "123 Main Street, Springfield",
-    company: "Smith Auto Group",
-    taxId: "GB123456789",
+    name: 'John Smith',
+    email: 'john@example.com',
+    phone: '(555) 123-4567',
+    address: '123 Main Street, Springfield',
+    company: 'Smith Auto Group',
+    taxId: 'GB123456789',
   },
   vehicle: {
-    make: "Toyota",
-    model: "Camry",
+    make: 'Toyota',
+    model: 'Camry',
     year: 2022,
-    vin: "1HGBH41JXMN109186",
-    licensePlate: "ABC-1234",
+    vin: '1HGBH41JXMN109186',
+    licensePlate: 'ABC-1234',
   },
-};
+}
 
 const DUMMY_WORKSHOP = {
-  name: "Your Workshop",
-  address: "123 Main Street, Springfield",
-  phone: "(555) 123-4567",
-  email: "shop@example.com",
-};
+  name: 'Your Workshop',
+  address: '123 Main Street, Springfield',
+  phone: '(555) 123-4567',
+  email: 'shop@example.com',
+}
 
 const DUMMY_INVOICE_SETTINGS = {
-  bankAccount: "1234 5678 9012 3456",
-  orgNumber: "912 345 678",
-  paymentTerms: "Net 14",
-  footerNote: "",
+  bankAccount: '1234 5678 9012 3456',
+  orgNumber: '912 345 678',
+  paymentTerms: 'Net 14',
+  footerNote: '',
   showBankAccount: true,
   showOrgNumber: true,
   dueDays: 14,
-  currencyCode: "USD",
-  currencyFormat: "symbol" as const,
-  unitSystem: "imperial",
-};
+  currencyCode: 'USD',
+  currencyFormat: 'symbol' as const,
+  unitSystem: 'imperial',
+}
 
 // ---------------------------------------------------------------------------
 // Renderer (loaded only client-side via dynamic import)
@@ -204,42 +209,37 @@ export function InvoiceLayoutPreviewRenderer({
   template,
   logoUrl,
 }: InvoiceLayoutPreviewProps) {
-  const messages = useMessages();
-  const serviceType = useServiceType();
+  const messages = useMessages()
+  const serviceType = useServiceType()
 
   const labels = useMemo(() => {
-    const pdf = (messages?.pdf ?? {}) as Record<string, Record<string, string>>;
-    const namespace = documentType === "quote" ? "quote" : "invoice";
+    const pdf = (messages?.pdf ?? {}) as Record<string, Record<string, string>>
+    const namespace = documentType === 'quote' ? 'quote' : 'invoice'
     const baseLabels = {
       ...(pdf[namespace] ?? {}),
       ...(pdf.common ?? {}),
-    };
+    }
     // Override labels for marine service type
-    if (serviceType === "marine") {
-      const ns = pdf[namespace] ?? {};
-      if (ns.mileageMarine) baseLabels.mileage = ns.mileageMarine;
-      if (ns.vinMarine) baseLabels.vin = ns.vinMarine;
-      if (ns.plateMarine) baseLabels.plate = ns.plateMarine;
-      if (ns.vehicleMarine) baseLabels.vehicle = ns.vehicleMarine;
-      baseLabels.km = "hrs";
-      baseLabels.mi = "hrs";
+    if (serviceType === 'marine') {
+      const ns = pdf[namespace] ?? {}
+      if (ns.mileageMarine) baseLabels.mileage = ns.mileageMarine
+      if (ns.vinMarine) baseLabels.vin = ns.vinMarine
+      if (ns.plateMarine) baseLabels.plate = ns.plateMarine
+      if (ns.vehicleMarine) baseLabels.vehicle = ns.vehicleMarine
+      baseLabels.km = 'hrs'
+      baseLabels.mi = 'hrs'
     }
-    return baseLabels;
-  }, [messages, documentType, serviceType]);
+    return baseLabels
+  }, [messages, documentType, serviceType])
 
-  const dummyCf = useMemo(
-    () => buildDummyCustomFields(customFields),
-    [customFields],
-  );
+  const dummyCf = useMemo(() => buildDummyCustomFields(customFields), [customFields])
 
   // Stable key to force PDFViewer remount when layout changes
   const configKey = useMemo(
     () =>
-      config.sections
-        .map((s) => `${s.id}:${s.order}:${s.visible}:${s.column ?? "f"}`)
-        .join("|"),
-    [config],
-  );
+      config.sections.map((s) => `${s.id}:${s.order}:${s.visible}:${s.column ?? 'f'}`).join('|'),
+    [config]
+  )
 
   const templateConfig = useMemo(
     () => ({
@@ -251,29 +251,27 @@ export function InvoiceLayoutPreviewRenderer({
       showCompanyName: true,
       layoutConfig: config,
     }),
-    [template, config],
-  );
+    [template, config]
+  )
 
-  const invoiceData = useMemo(
-    () => ({ ...DUMMY_INVOICE_DATA, customFields: dummyCf }),
-    [dummyCf],
-  );
+  const invoiceData = useMemo(() => ({ ...DUMMY_INVOICE_DATA, customFields: dummyCf }), [dummyCf])
 
-  const isQuote = documentType === "quote";
+  const isQuote = documentType === 'quote'
 
   // Generate dummy Telegram QR for preview if section is visible
-  const telegramQrVisible = config.sections.some(
-    (s) => s.id === "telegram_qr" && s.visible
-  );
-  const [telegramQrDataUri, setTelegramQrDataUri] = useState<string | undefined>();
+  const telegramQrVisible = config.sections.some((s) => s.id === 'telegram_qr' && s.visible)
+  const [telegramQrDataUri, setTelegramQrDataUri] = useState<string | undefined>()
   useEffect(() => {
-    if (!telegramQrVisible) { setTelegramQrDataUri(undefined); return; }
-    import("qrcode").then((QRCode) => {
-      QRCode.toDataURL("https://t.me/example_bot", { width: 200, margin: 1 }).then(
-        (uri) => setTelegramQrDataUri(uri)
-      );
-    });
-  }, [telegramQrVisible]);
+    if (!telegramQrVisible) {
+      setTelegramQrDataUri(undefined)
+      return
+    }
+    import('qrcode').then((QRCode) => {
+      QRCode.toDataURL('https://t.me/example_bot', { width: 200, margin: 1 }).then((uri) =>
+        setTelegramQrDataUri(uri)
+      )
+    })
+  }, [telegramQrVisible])
 
   return (
     <div className="rounded-lg shadow-sm overflow-hidden bg-gray-100">
@@ -282,7 +280,7 @@ export function InvoiceLayoutPreviewRenderer({
         width="100%"
         height={920}
         showToolbar={false}
-        style={{ border: "none" }}
+        style={{ border: 'none' }}
       >
         {isQuote ? (
           <QuotePDF
@@ -309,5 +307,5 @@ export function InvoiceLayoutPreviewRenderer({
         )}
       </PDFViewer>
     </div>
-  );
+  )
 }

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

@@ -66,6 +66,9 @@ export const SETTING_KEYS = {
   MAINTENANCE_SERVICE_INTERVAL: 'maintenance.serviceInterval',
   MAINTENANCE_APPROACHING_THRESHOLD: 'maintenance.approachingThreshold',
   INVENTORY_MARKUP_MULTIPLIER: 'inventory.markupMultiplier',
+  /// Unit of measure pre-filled on newly created inventory parts ("pcs",
+  /// "l", "qt"...). Empty means new parts start with no unit.
+  INVENTORY_DEFAULT_UNIT: 'inventory.defaultUnit',
   LOW_STOCK_ALERTS_ENABLED: 'inventory.lowStockAlerts.enabled',
   /// Org-wide fallback reorder point, applied to parts with no minQuantity of
   /// their own. 0 means only explicitly configured parts are watched.

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

@@ -393,7 +393,14 @@ export async function createQuoteFromTireSet(input: unknown) {
       const part = data.inventoryPartId
         ? await db.inventoryPart.findFirst({
             where: { id: data.inventoryPartId, organizationId, isArchived: false },
-            select: { id: true, name: true, partNumber: true, unitCost: true, sellPrice: true },
+            select: {
+              id: true,
+              name: true,
+              partNumber: true,
+              unit: true,
+              unitCost: true,
+              sellPrice: true,
+            },
           })
         : null
 
@@ -469,6 +476,7 @@ export async function createQuoteFromTireSet(input: unknown) {
                       name: part?.name ?? describeSet(set, seasons),
                       partNumber: part?.partNumber ?? null,
                       quantity: lineQuantity,
+                      unit: part?.unit ?? null,
                       unitCost: part?.unitCost ?? 0,
                       unitPrice,
                       total: lineTotal,
@@ -538,7 +546,14 @@ export async function createWorkOrderFromTireSet(input: unknown) {
       const part = data.inventoryPartId
         ? await db.inventoryPart.findFirst({
             where: { id: data.inventoryPartId, organizationId, isArchived: false },
-            select: { id: true, name: true, partNumber: true, unitCost: true, sellPrice: true },
+            select: {
+              id: true,
+              name: true,
+              partNumber: true,
+              unit: true,
+              unitCost: true,
+              sellPrice: true,
+            },
           })
         : null
 
@@ -557,6 +572,7 @@ export async function createWorkOrderFromTireSet(input: unknown) {
             name: part?.name ?? describeSet(set, seasons),
             partNumber: part?.partNumber ?? null,
             quantity: lineQuantity,
+            unit: part?.unit ?? null,
             unitPrice,
             unitCost: part?.unitCost ?? 0,
             inventoryPartId: part?.id ?? null,
@@ -624,7 +640,10 @@ export async function createWorkOrderFromTireSet(input: unknown) {
         action: 'tire_set.create_work_order',
         entity: 'ServiceRecord',
         entityId: result.id,
-        details: { key: 'tire_set_create_work_order', params: { ref: result.invoiceNumber ?? result.id } },
+        details: {
+          key: 'tire_set_create_work_order',
+          params: { ref: result.invoiceNumber ?? result.id },
+        },
         metadata: { tireSetId: result.tireSetId },
       }),
     }
@@ -695,7 +714,14 @@ export async function addTireSetToWorkOrder(input: unknown) {
       const part = data.inventoryPartId
         ? await db.inventoryPart.findFirst({
             where: { id: data.inventoryPartId, organizationId, isArchived: false },
-            select: { id: true, name: true, partNumber: true, unitCost: true, sellPrice: true },
+            select: {
+              id: true,
+              name: true,
+              partNumber: true,
+              unit: true,
+              unitCost: true,
+              sellPrice: true,
+            },
           })
         : null
 
@@ -711,6 +737,7 @@ export async function addTireSetToWorkOrder(input: unknown) {
             name: part?.name ?? describeSet(set, seasons),
             partNumber: part?.partNumber ?? null,
             quantity: lineQuantity,
+            unit: part?.unit ?? null,
             unitPrice,
             unitCost: part?.unitCost ?? 0,
             inventoryPartId: part?.id ?? null,
@@ -759,7 +786,13 @@ export async function addTireSetToWorkOrder(input: unknown) {
         action: 'tire_set.add_to_work_order',
         entity: 'ServiceRecord',
         entityId: result.id,
-        details: { key: 'tire_set_add_to_work_order', params: { ref: result.reference ?? result.tireSetId, jobRef: result.invoiceNumber ?? result.id } },
+        details: {
+          key: 'tire_set_add_to_work_order',
+          params: {
+            ref: result.reference ?? result.tireSetId,
+            jobRef: result.invoiceNumber ?? result.id,
+          },
+        },
         metadata: { tireSetId: result.tireSetId },
       }),
     }
@@ -816,7 +849,13 @@ export async function unlinkTireSetFromWorkOrder(serviceRecordId: string) {
         action: 'tire_set.unlink_work_order',
         entity: 'ServiceRecord',
         entityId: result.id,
-        details: { key: 'tire_set_unlink_work_order', params: { ref: result.reference ?? result.tireSetId, jobRef: result.invoiceNumber ?? result.id } },
+        details: {
+          key: 'tire_set_unlink_work_order',
+          params: {
+            ref: result.reference ?? result.tireSetId,
+            jobRef: result.invoiceNumber ?? result.id,
+          },
+        },
         metadata: { tireSetId: result.tireSetId },
       }),
     }

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

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

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

@@ -13,6 +13,7 @@ export async function addPartToServiceRecord(input: {
   partNumber?: string
   name: string
   quantity: number
+  unit?: string | null
   unitPrice: number
   total: number
   unitCost: number
@@ -44,6 +45,7 @@ export async function addPartToServiceRecord(input: {
             partNumber: input.partNumber || null,
             name: input.name,
             quantity: input.quantity,
+            unit: input.unit || null,
             unitPrice: input.unitPrice,
             total: input.total,
             unitCost: input.unitCost,

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

@@ -423,6 +423,7 @@ export async function createServiceRecord(input: unknown) {
               partNumber: p.partNumber,
               name: p.name,
               quantity: p.quantity,
+              unit: p.unit ?? null,
               unitPrice: p.unitPrice,
               total: p.total,
               unitCost: resolvedUnitCost,
@@ -640,6 +641,7 @@ export async function updateServiceRecord(input: unknown) {
                 partNumber: p.partNumber,
                 name: p.name,
                 quantity: p.quantity,
+                unit: p.unit ?? null,
                 unitPrice: p.unitPrice,
                 total: p.total,
                 unitCost: p.unitCost ?? 0,

Некоторые файлы не были показаны из-за большого количества измененных файлов