Parcourir la source

Add cost/markup to quote parts and internal parts cost on Bills (#225)

* Add cost and markup to quote parts and internal parts cost on Bills

Quote parts now carry unitCost and markupPercent with the same linked
pricing math as work order parts (price = cost x (1 + markup/100));
inventory picks fill cost and derived markup, and conversion to a work
order carries both through to the service parts. The Bill totals card
shows a muted internal parts-cost line (pre-markup), kept off the PDF
and share views. Public quote and invoice share pages now select part
fields explicitly so internal cost/markup never reaches customer-facing
payloads (the invoice share previously leaked them). Idempotent
migration; labels reuse the existing service editor translations.

* Warn loudly when a part row has no name

Part rows without a name are required-invalid and get dropped on save,
which read as parts vanishing from the quote PDF. The name field now
shows a destructive ring and both parts editors (quote and work order)
show an explicit 'rows without a part name are not saved' notice while
such a row exists.
Bernt Christian Egeland il y a 1 mois
Parent
commit
47442e6ab6
38 fichiers modifiés avec 192 ajouts et 51 suppressions
  1. 4 1
      messages/de/quotes.json
  2. 4 2
      messages/de/service.json
  3. 4 1
      messages/en/quotes.json
  4. 4 2
      messages/en/service.json
  5. 4 1
      messages/es/quotes.json
  6. 4 2
      messages/es/service.json
  7. 4 1
      messages/fr/quotes.json
  8. 4 2
      messages/fr/service.json
  9. 4 1
      messages/it/quotes.json
  10. 4 2
      messages/it/service.json
  11. 4 1
      messages/lt/quotes.json
  12. 4 2
      messages/lt/service.json
  13. 4 1
      messages/nb/quotes.json
  14. 4 2
      messages/nb/service.json
  15. 4 1
      messages/nl/quotes.json
  16. 4 2
      messages/nl/service.json
  17. 4 1
      messages/pl/quotes.json
  18. 4 2
      messages/pl/service.json
  19. 4 1
      messages/pt-BR/quotes.json
  20. 4 2
      messages/pt-BR/service.json
  21. 4 1
      messages/ru/quotes.json
  22. 4 2
      messages/ru/service.json
  23. 4 1
      messages/tr/quotes.json
  24. 4 2
      messages/tr/service.json
  25. 3 0
      prisma/migrations/20260811181500_quote_part_cost/migration.sql
  26. 4 0
      prisma/schema.prisma
  27. 12 1
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  28. 13 1
      src/app/(public)/share/quote/[orgId]/[token]/page.tsx
  29. 2 0
      src/features/quotes/Actions/quoteActions.ts
  30. 2 0
      src/features/quotes/Components/QuotePageClient.tsx
  31. 20 7
      src/features/quotes/Components/QuotePartsEditor.tsx
  32. 3 1
      src/features/quotes/Components/quote-page-types.ts
  33. 13 3
      src/features/quotes/Components/useQuoteFormState.ts
  34. 2 0
      src/features/quotes/Schema/quoteSchema.ts
  35. 5 1
      src/features/vehicles/Components/service-edit/PartsEditor.tsx
  36. 9 0
      src/features/vehicles/Components/service-edit/TotalsSection.tsx
  37. 1 0
      src/features/vehicles/Components/service-page/DetailsRightColumn.tsx
  38. 7 1
      src/features/vehicles/Components/service-page/useServiceFormState.ts

+ 4 - 1
messages/de/quotes.json

@@ -73,7 +73,10 @@
     "subtotal": "Teile Zwischensumme: {amount}",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total",
-    "addFromInventory": "Aus Lager"
+    "addFromInventory": "Aus Lager",
+    "unitCost": "Kosten",
+    "markupPercent": "Aufschlag %",
+    "nameMissingHint": "Zeilen ohne Teilenamen werden nicht gespeichert."
   },
   "labor": {
     "title": "Arbeitszeit",

+ 4 - 2
messages/de/service.json

@@ -107,7 +107,8 @@
     "partNotFound": "Kein Teil für Barcode \"{barcode}\" gefunden",
     "deleteRow": "Delete row",
     "outOfStock": "Nicht auf Lager",
-    "onBackorder": "Im Rückstand ({count})"
+    "onBackorder": "Im Rückstand ({count})",
+    "nameMissingHint": "Zeilen ohne Teilenamen werden nicht gespeichert."
   },
   "labor": {
     "title": "Arbeit",
@@ -146,7 +147,8 @@
     "tax": "Steuern",
     "taxIncluded": "Steuer (inkl.)",
     "total": "Gesamt",
-    "inclusiveModeHint": "Zeilenpreise werden inklusive Steuer eingegeben. Die Summen zeigen die Aufteilung netto + Steuer."
+    "inclusiveModeHint": "Zeilenpreise werden inklusive Steuer eingegeben. Die Summen zeigen die Aufteilung netto + Steuer.",
+    "partsCost": "Teilekosten (intern)"
   },
   "notes": {
     "title": "Notizen",

+ 4 - 1
messages/en/quotes.json

@@ -73,7 +73,10 @@
     "subtotal": "Parts Subtotal: {amount}",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total",
-    "addFromInventory": "From stock"
+    "addFromInventory": "From stock",
+    "unitCost": "Cost",
+    "markupPercent": "Markup %",
+    "nameMissingHint": "Rows without a part name are not saved."
   },
   "labor": {
     "title": "Labor & Services",

+ 4 - 2
messages/en/service.json

@@ -107,7 +107,8 @@
     "partNotFound": "No part found for barcode \"{barcode}\"",
     "deleteRow": "Delete row",
     "outOfStock": "Out of stock",
-    "onBackorder": "On backorder ({count})"
+    "onBackorder": "On backorder ({count})",
+    "nameMissingHint": "Rows without a part name are not saved."
   },
   "labor": {
     "title": "Labor & Services",
@@ -146,7 +147,8 @@
     "tax": "Tax",
     "taxIncluded": "Tax (incl.)",
     "total": "Total",
-    "inclusiveModeHint": "Line item prices are entered with tax included. Totals show the net + tax breakdown."
+    "inclusiveModeHint": "Line item prices are entered with tax included. Totals show the net + tax breakdown.",
+    "partsCost": "Parts cost (internal)"
   },
   "notes": {
     "title": "Notes",

+ 4 - 1
messages/es/quotes.json

@@ -73,7 +73,10 @@
     "subtotal": "Subtotal de repuestos: {amount}",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total",
-    "addFromInventory": "Del inventario"
+    "addFromInventory": "Del inventario",
+    "unitCost": "Costo",
+    "markupPercent": "Margen %",
+    "nameMissingHint": "Las filas sin nombre de pieza no se guardan."
   },
   "labor": {
     "title": "Mano de obra",

+ 4 - 2
messages/es/service.json

@@ -107,7 +107,8 @@
     "partNotFound": "No se encontró pieza para el código de barras \"{barcode}\"",
     "deleteRow": "Delete row",
     "outOfStock": "Agotado",
-    "onBackorder": "En pedido pendiente ({count})"
+    "onBackorder": "En pedido pendiente ({count})",
+    "nameMissingHint": "Las filas sin nombre de pieza no se guardan."
   },
   "labor": {
     "title": "Mano de Obra",
@@ -146,7 +147,8 @@
     "tax": "Impuesto",
     "taxIncluded": "Impuesto (incl.)",
     "total": "Total",
-    "inclusiveModeHint": "Los precios por línea se introducen con impuesto incluido. Los totales muestran el desglose neto + impuesto."
+    "inclusiveModeHint": "Los precios por línea se introducen con impuesto incluido. Los totales muestran el desglose neto + impuesto.",
+    "partsCost": "Costo de repuestos (interno)"
   },
   "notes": {
     "title": "Notas",

+ 4 - 1
messages/fr/quotes.json

@@ -73,7 +73,10 @@
     "subtotal": "Sous-total pièces : {amount}",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total",
-    "addFromInventory": "Du stock"
+    "addFromInventory": "Du stock",
+    "unitCost": "Coût",
+    "markupPercent": "Marge %",
+    "nameMissingHint": "Les lignes sans nom de pièce ne sont pas enregistrées."
   },
   "labor": {
     "title": "Main-d'œuvre",

+ 4 - 2
messages/fr/service.json

@@ -107,7 +107,8 @@
     "partNotFound": "Aucune pièce trouvée pour le code-barres \"{barcode}\"",
     "deleteRow": "Delete row",
     "outOfStock": "En rupture de stock",
-    "onBackorder": "En réapprovisionnement ({count})"
+    "onBackorder": "En réapprovisionnement ({count})",
+    "nameMissingHint": "Les lignes sans nom de pièce ne sont pas enregistrées."
   },
   "labor": {
     "title": "Main-d'œuvre",
@@ -146,7 +147,8 @@
     "tax": "Taxe",
     "taxIncluded": "Taxe (incl.)",
     "total": "Total",
-    "inclusiveModeHint": "Les prix de ligne sont saisis taxe incluse. Les totaux affichent la répartition net + taxe."
+    "inclusiveModeHint": "Les prix de ligne sont saisis taxe incluse. Les totaux affichent la répartition net + taxe.",
+    "partsCost": "Coût des pièces (interne)"
   },
   "notes": {
     "title": "Notes",

+ 4 - 1
messages/it/quotes.json

@@ -73,7 +73,10 @@
     "subtotal": "Subtotale ricambi: {amount}",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total",
-    "addFromInventory": "Dal magazzino"
+    "addFromInventory": "Dal magazzino",
+    "unitCost": "Costo",
+    "markupPercent": "Ricarico %",
+    "nameMissingHint": "Le righe senza nome del ricambio non vengono salvate."
   },
   "labor": {
     "title": "Manodopera",

+ 4 - 2
messages/it/service.json

@@ -107,7 +107,8 @@
     "partNotFound": "Nessun pezzo trovato per il codice a barre \"{barcode}\"",
     "deleteRow": "Delete row",
     "outOfStock": "Esaurito",
-    "onBackorder": "In arretrato ({count})"
+    "onBackorder": "In arretrato ({count})",
+    "nameMissingHint": "Le righe senza nome del ricambio non vengono salvate."
   },
   "labor": {
     "title": "Manodopera",
@@ -146,7 +147,8 @@
     "tax": "Imposta",
     "taxIncluded": "Imposta (incl.)",
     "total": "Totale",
-    "inclusiveModeHint": "I prezzi delle righe sono inseriti con imposta inclusa. I totali mostrano la suddivisione netto + imposta."
+    "inclusiveModeHint": "I prezzi delle righe sono inseriti con imposta inclusa. I totali mostrano la suddivisione netto + imposta.",
+    "partsCost": "Costo ricambi (interno)"
   },
   "notes": {
     "title": "Note",

+ 4 - 1
messages/lt/quotes.json

@@ -73,7 +73,10 @@
     "subtotal": "Dalių tarpinė suma: {amount}",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total",
-    "addFromInventory": "Iš sandėlio"
+    "addFromInventory": "Iš sandėlio",
+    "unitCost": "Savikaina",
+    "markupPercent": "Antkainis %",
+    "nameMissingHint": "Eilutės be dalies pavadinimo neišsaugomos."
   },
   "labor": {
     "title": "Darbai ir paslaugos",

+ 4 - 2
messages/lt/service.json

@@ -107,7 +107,8 @@
     "partNotFound": "Dalis brūkšniniam kodui \"{barcode}\" nerasta",
     "deleteRow": "Delete row",
     "outOfStock": "Nėra sandėlyje",
-    "onBackorder": "Užsakoma ({count})"
+    "onBackorder": "Užsakoma ({count})",
+    "nameMissingHint": "Eilutės be dalies pavadinimo neišsaugomos."
   },
   "labor": {
     "title": "Darbai ir paslaugos",
@@ -146,7 +147,8 @@
     "tax": "Mokestis",
     "taxIncluded": "Mokestis (įsk.)",
     "total": "Iš viso",
-    "inclusiveModeHint": "Eilučių kainos įvedamos su mokesčiu. Sumos rodo paskirstymą be mokesčio + mokestis."
+    "inclusiveModeHint": "Eilučių kainos įvedamos su mokesčiu. Sumos rodo paskirstymą be mokesčio + mokestis.",
+    "partsCost": "Dalių savikaina (vidinė)"
   },
   "notes": {
     "title": "Pastabos",

+ 4 - 1
messages/nb/quotes.json

@@ -73,7 +73,10 @@
     "subtotal": "Deler delsum: {amount}",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total",
-    "addFromInventory": "Fra lager"
+    "addFromInventory": "Fra lager",
+    "unitCost": "Kostnad",
+    "markupPercent": "Påslag %",
+    "nameMissingHint": "Rader uten delenavn lagres ikke."
   },
   "labor": {
     "title": "Arbeid",

+ 4 - 2
messages/nb/service.json

@@ -107,7 +107,8 @@
     "partNotFound": "Ingen del funnet for strekkode \"{barcode}\"",
     "deleteRow": "Delete row",
     "outOfStock": "Utsolgt",
-    "onBackorder": "På restordre ({count})"
+    "onBackorder": "På restordre ({count})",
+    "nameMissingHint": "Rader uten delenavn lagres ikke."
   },
   "labor": {
     "title": "Arbeid",
@@ -146,7 +147,8 @@
     "tax": "Skatt",
     "taxIncluded": "Avgift (inkl.)",
     "total": "Total",
-    "inclusiveModeHint": "Linjepriser legges inn med avgift inkludert. Totalene viser fordelingen netto + avgift."
+    "inclusiveModeHint": "Linjepriser legges inn med avgift inkludert. Totalene viser fordelingen netto + avgift.",
+    "partsCost": "Delekostnad (intern)"
   },
   "notes": {
     "title": "Notater",

+ 4 - 1
messages/nl/quotes.json

@@ -73,7 +73,10 @@
     "subtotal": "Subtotaal onderdelen: {amount}",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total",
-    "addFromInventory": "Uit voorraad"
+    "addFromInventory": "Uit voorraad",
+    "unitCost": "Inkoop",
+    "markupPercent": "Marge %",
+    "nameMissingHint": "Rijen zonder onderdeelnaam worden niet opgeslagen."
   },
   "labor": {
     "title": "Arbeid",

+ 4 - 2
messages/nl/service.json

@@ -107,7 +107,8 @@
     "partNotFound": "Geen onderdeel gevonden voor barcode \"{barcode}\"",
     "deleteRow": "Delete row",
     "outOfStock": "Niet op voorraad",
-    "onBackorder": "In nabestelling ({count})"
+    "onBackorder": "In nabestelling ({count})",
+    "nameMissingHint": "Rijen zonder onderdeelnaam worden niet opgeslagen."
   },
   "labor": {
     "title": "Arbeidskosten",
@@ -146,7 +147,8 @@
     "tax": "Belasting",
     "taxIncluded": "Belasting (incl.)",
     "total": "Totaal",
-    "inclusiveModeHint": "Regelprijzen worden inclusief belasting ingevoerd. De totalen tonen de uitsplitsing netto + belasting."
+    "inclusiveModeHint": "Regelprijzen worden inclusief belasting ingevoerd. De totalen tonen de uitsplitsing netto + belasting.",
+    "partsCost": "Onderdelenkosten (intern)"
   },
   "notes": {
     "title": "Opmerkingen",

+ 4 - 1
messages/pl/quotes.json

@@ -73,7 +73,10 @@
     "subtotal": "Części suma częściowa: {amount}",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total",
-    "addFromInventory": "Z magazynu"
+    "addFromInventory": "Z magazynu",
+    "unitCost": "Koszt",
+    "markupPercent": "Marża %",
+    "nameMissingHint": "Wiersze bez nazwy części nie są zapisywane."
   },
   "labor": {
     "title": "Robocizna",

+ 4 - 2
messages/pl/service.json

@@ -107,7 +107,8 @@
     "partNotFound": "Nie znaleziono części dla kodu kreskowego \"{barcode}\"",
     "deleteRow": "Delete row",
     "outOfStock": "Brak w magazynie",
-    "onBackorder": "W zamówieniu ({count})"
+    "onBackorder": "W zamówieniu ({count})",
+    "nameMissingHint": "Wiersze bez nazwy części nie są zapisywane."
   },
   "labor": {
     "title": "Praca",
@@ -146,7 +147,8 @@
     "tax": "Podatek",
     "taxIncluded": "Podatek (zaw.)",
     "total": "Razem",
-    "inclusiveModeHint": "Ceny pozycji są wprowadzane z podatkiem. Sumy pokazują podział netto + podatek."
+    "inclusiveModeHint": "Ceny pozycji są wprowadzane z podatkiem. Sumy pokazują podział netto + podatek.",
+    "partsCost": "Koszt części (wewnętrzny)"
   },
   "notes": {
     "title": "Notatki",

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

@@ -73,7 +73,10 @@
     "subtotal": "Subtotal de peças: {amount}",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total",
-    "addFromInventory": "Do estoque"
+    "addFromInventory": "Do estoque",
+    "unitCost": "Custo",
+    "markupPercent": "Margem %",
+    "nameMissingHint": "Linhas sem nome da peça não são salvas."
   },
   "labor": {
     "title": "Mão de obra",

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

@@ -107,7 +107,8 @@
     "partNotFound": "Nenhuma peça encontrada para o código de barras \"{barcode}\"",
     "deleteRow": "Delete row",
     "outOfStock": "Fora de estoque",
-    "onBackorder": "Sob encomenda ({count})"
+    "onBackorder": "Sob encomenda ({count})",
+    "nameMissingHint": "Linhas sem nome da peça não são salvas."
   },
   "labor": {
     "title": "Mão de Obra",
@@ -146,7 +147,8 @@
     "tax": "Imposto",
     "taxIncluded": "Imposto (incl.)",
     "total": "Total",
-    "inclusiveModeHint": "Os preços das linhas são inseridos com imposto incluído. Os totais mostram a divisão líquido + imposto."
+    "inclusiveModeHint": "Os preços das linhas são inseridos com imposto incluído. Os totais mostram a divisão líquido + imposto.",
+    "partsCost": "Custo das peças (interno)"
   },
   "notes": {
     "title": "Notas",

+ 4 - 1
messages/ru/quotes.json

@@ -73,7 +73,10 @@
     "subtotal": "Подитог запчастей: {amount}",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total",
-    "addFromInventory": "Со склада"
+    "addFromInventory": "Со склада",
+    "unitCost": "Себестоимость",
+    "markupPercent": "Наценка %",
+    "nameMissingHint": "Строки без названия детали не сохраняются."
   },
   "labor": {
     "title": "Работа",

+ 4 - 2
messages/ru/service.json

@@ -107,7 +107,8 @@
     "partNotFound": "Деталь для штрихкода \"{barcode}\" не найдена",
     "deleteRow": "Delete row",
     "outOfStock": "Нет в наличии",
-    "onBackorder": "Под заказ ({count})"
+    "onBackorder": "Под заказ ({count})",
+    "nameMissingHint": "Строки без названия детали не сохраняются."
   },
   "labor": {
     "title": "Работа",
@@ -146,7 +147,8 @@
     "tax": "Налог",
     "taxIncluded": "Налог (вкл.)",
     "total": "Итого",
-    "inclusiveModeHint": "Цены позиций вводятся с НДС. Итоги показывают разбивку: без НДС + НДС."
+    "inclusiveModeHint": "Цены позиций вводятся с НДС. Итоги показывают разбивку: без НДС + НДС.",
+    "partsCost": "Себестоимость запчастей (внутр.)"
   },
   "notes": {
     "title": "Заметки",

+ 4 - 1
messages/tr/quotes.json

@@ -73,7 +73,10 @@
     "subtotal": "Parçalar Ara Toplam: {amount}",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total",
-    "addFromInventory": "Stoktan"
+    "addFromInventory": "Stoktan",
+    "unitCost": "Maliyet",
+    "markupPercent": "Kâr Marjı %",
+    "nameMissingHint": "Parça adı olmayan satırlar kaydedilmez."
   },
   "labor": {
     "title": "İşçilik",

+ 4 - 2
messages/tr/service.json

@@ -107,7 +107,8 @@
     "partNotFound": "\"{barcode}\" barkodu için parça bulunamadı",
     "deleteRow": "Delete row",
     "outOfStock": "Stokta yok",
-    "onBackorder": "Ön siparişte ({count})"
+    "onBackorder": "Ön siparişte ({count})",
+    "nameMissingHint": "Parça adı olmayan satırlar kaydedilmez."
   },
   "labor": {
     "title": "İşçilik",
@@ -146,7 +147,8 @@
     "tax": "Vergi",
     "taxIncluded": "Vergi (dahil)",
     "total": "Toplam",
-    "inclusiveModeHint": "Satır fiyatları KDV dahil girilir. Toplamlar net + vergi dağılımını gösterir."
+    "inclusiveModeHint": "Satır fiyatları KDV dahil girilir. Toplamlar net + vergi dağılımını gösterir.",
+    "partsCost": "Parça maliyeti (dahili)"
   },
   "notes": {
     "title": "Notlar",

+ 3 - 0
prisma/migrations/20260811181500_quote_part_cost/migration.sql

@@ -0,0 +1,3 @@
+-- Internal cost and markup on quote parts, mirroring service parts
+ALTER TABLE "quote_parts" ADD COLUMN IF NOT EXISTS "unitCost" DOUBLE PRECISION NOT NULL DEFAULT 0;
+ALTER TABLE "quote_parts" ADD COLUMN IF NOT EXISTS "markupPercent" DOUBLE PRECISION NOT NULL DEFAULT 0;

+ 4 - 0
prisma/schema.prisma

@@ -644,6 +644,10 @@ model QuotePart {
   partNumber String?
   name       String
   quantity   Float   @default(1)
+  /// Internal purchase cost — never shown to customers (quote PDF/share
+  /// render unitPrice only)
+  unitCost      Float @default(0)
+  markupPercent Float @default(0)
   unitPrice  Float   @default(0)
   total      Float   @default(0)
   excluded   Boolean @default(false)

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

@@ -34,7 +34,18 @@ export default async function PublicInvoicePage({
   const record = await db.serviceRecord.findUnique({
     where: { publicToken: token },
     include: {
-      partItems: true,
+      // Explicit select: internal unitCost/markupPercent must never reach
+      // the public customer-facing payload
+      partItems: {
+        select: {
+          id: true,
+          partNumber: true,
+          name: true,
+          quantity: true,
+          unitPrice: true,
+          total: true,
+        },
+      },
       laborItems: true,
       attachments: true,
       payments: { orderBy: { date: "desc" } },

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

@@ -26,7 +26,19 @@ export default async function PublicQuotePage({
   const quote = await db.quote.findFirst({
     where: { publicToken: token, organizationId: orgId },
     include: {
-      partItems: true,
+      // Explicit select: internal unitCost/markupPercent must never reach
+      // the public customer-facing payload
+      partItems: {
+        select: {
+          id: true,
+          partNumber: true,
+          name: true,
+          quantity: true,
+          unitPrice: true,
+          total: true,
+          excluded: true,
+        },
+      },
       laborItems: true,
       attachments: true,
       customer: {

+ 2 - 0
src/features/quotes/Actions/quoteActions.ts

@@ -427,6 +427,8 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
             partNumber: p.partNumber,
             name: p.name,
             quantity: p.quantity,
+            unitCost: p.unitCost,
+            markupPercent: p.markupPercent,
             unitPrice: p.unitPrice,
             total: p.total,
             // Preserve the stock link so the job — and any later edit or

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

@@ -123,6 +123,8 @@ export function QuotePageClient({
           name: part.name,
           partNumber: part.partNumber || '',
           quantity: part.quantity,
+          unitCost: 0,
+          markupPercent: 0,
           unitPrice: part.unitPrice,
           total: lineTotal(part.quantity, part.unitPrice),
           excluded: false,

+ 20 - 7
src/features/quotes/Components/QuotePartsEditor.tsx

@@ -12,7 +12,7 @@ import {
   PartNameSuggestions,
   type PartSuggestion,
 } from "@/features/inventory/Components/PartNameSuggestions";
-import { lineTotal, parseQuantity, resolvePartPrice } from "@/features/inventory/Lib/partPricing";
+import { lineTotal, markupFromCostAndPrice, parseQuantity, resolvePartPrice } from "@/features/inventory/Lib/partPricing";
 
 const QuotePartRow = memo(function QuotePartRow({
   part,
@@ -40,11 +40,16 @@ const QuotePartRow = memo(function QuotePartRow({
   tExcludeFromTotal: string;
 }) {
   const formatCurrency = useFormatCurrency();
+  // Content without a name would be silently dropped on save (name is
+  // required); flag it loudly instead
+  const nameMissing =
+    !part.name.trim() &&
+    !!(part.partNumber || Number(part.unitCost) > 0 || Number(part.unitPrice) > 0 || Number(part.total) > 0);
   return (
-    <div className={`grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto] ${part.excluded ? "line-through opacity-50" : ""}`}>
+    <div className={`grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.6fr_0.9fr_0.7fr_0.9fr_0.9fr_auto] ${part.excluded ? "line-through opacity-50" : ""}`}>
       <Input placeholder={tPartNumber} value={part.partNumber ?? ""} onChange={(e) => onUpdate(index, "partNumber", e.target.value)} />
       <div className="relative">
-        <Input placeholder={tNamePlaceholder} value={part.name} onChange={(e) => onUpdate(index, "name", e.target.value)} />
+        <Input placeholder={tNamePlaceholder} value={part.name} onChange={(e) => onUpdate(index, "name", e.target.value)} aria-invalid={nameMissing} className={nameMissing ? "border-destructive focus-visible:ring-destructive" : undefined} />
         <PartNameSuggestions
           query={part.name}
           parts={inventoryParts}
@@ -54,6 +59,8 @@ const QuotePartRow = memo(function QuotePartRow({
         />
       </div>
       <Input type="number" min="0" step="1" value={part.quantity} onChange={(e) => onUpdate(index, "quantity", e.target.value)} />
+      <Input type="number" min="0" step="0.01" value={part.unitCost} onChange={(e) => onUpdate(index, "unitCost", e.target.value)} />
+      <Input type="number" min="0" step="0.1" value={part.markupPercent} onChange={(e) => onUpdate(index, "markupPercent", e.target.value)} />
       <Input type="number" min="0" step="0.01" value={part.unitPrice} onChange={(e) => onUpdate(index, "unitPrice", e.target.value)} />
       <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(part.total, currencyCode)}</div>
       <div className="flex items-center gap-1">
@@ -102,11 +109,12 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
       // total === quantity * unitPrice. Defaulting an empty or zero quantity
       // to 1 would bill a total the row's own fields do not add up to.
       const quantity = parseQuantity(current?.quantity)
-      // Quotes carry no cost/markup model, so this resolves to the part's sell
-      // price, falling back to cost rather than billing at zero.
       const { unitPrice } = resolvePartPrice(picked)
+      const unitCost = Number(picked.unitCost) || 0
       onUpdate(index, "name", picked.name)
       onUpdate(index, "partNumber", picked.partNumber ?? "")
+      onUpdate(index, "unitCost", unitCost)
+      onUpdate(index, "markupPercent", markupFromCostAndPrice(unitCost, unitPrice))
       onUpdate(index, "unitPrice", unitPrice)
       onUpdate(index, "total", lineTotal(quantity, unitPrice))
       onUpdate(index, "inventoryPartId", picked.id)
@@ -131,8 +139,8 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
       </div>
       {partItems.length > 0 && (
         <>
-          <div className="hidden grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
-            <span>{t("parts.partNumber")}</span><span>{t("parts.name")}</span><span>{t("parts.qty")}</span><span>{t("parts.unitPrice")}</span><span>{t("parts.total")}</span><span />
+          <div className="hidden grid-cols-[1fr_2fr_0.6fr_0.9fr_0.7fr_0.9fr_0.9fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
+            <span>{t("parts.partNumber")}</span><span>{t("parts.name")}</span><span>{t("parts.qty")}</span><span>{t("parts.unitCost")}</span><span>{t("parts.markupPercent")}</span><span>{t("parts.unitPrice")}</span><span>{t("parts.total")}</span><span />
           </div>
           {partItems.map((part, i) => (
             <QuotePartRow
@@ -151,6 +159,9 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
             />
           ))}
           <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={onAdd}><Plus className="h-4 w-4" /></button>
+          {partItems.some((p) => !p.name.trim() && (p.partNumber || Number(p.unitCost) > 0 || Number(p.unitPrice) > 0 || Number(p.total) > 0)) && (
+            <p className="text-xs text-destructive">{t("parts.nameMissingHint")}</p>
+          )}
           <div className="flex justify-end pt-1 text-sm"><span className="font-medium">{t("parts.subtotal", { amount: formatCurrency(partsSubtotal, currencyCode) })}</span></div>
         </>
       )}
@@ -166,6 +177,8 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
                 partNumber: picked.partNumber ?? "",
                 name: picked.name,
                 quantity: picked.quantity,
+                unitCost: Number(picked.unitCost) || 0,
+                markupPercent: Number(picked.markupPercent) || 0,
                 unitPrice: picked.unitPrice,
                 total: lineTotal(picked.quantity, picked.unitPrice),
                 excluded: false,

+ 3 - 1
src/features/quotes/Components/quote-page-types.ts

@@ -40,7 +40,7 @@ export interface QuoteRecord {
   inspectionId: string | null;
   createdAt: Date;
   updatedAt: Date;
-  partItems: { id: string; partNumber: string | null; name: string; quantity: number; unitPrice: number; total: number; excluded?: boolean; inventoryPartId?: string | null }[];
+  partItems: { id: string; partNumber: string | null; name: string; quantity: number; unitCost?: number; markupPercent?: number; unitPrice: number; total: number; excluded?: boolean; inventoryPartId?: string | null }[];
   laborItems: { id: string; description: string; hours: number; rate: number; total: number; pricingType?: string; excluded?: boolean }[];
   customer: { id: string; name: string; email: string | null; phone: string | null; address: string | null; company: string | null } | null;
   vehicle: { id: string; make: string; model: string; year: number; vin: string | null; licensePlate: string | null; mileage: number } | null;
@@ -60,6 +60,8 @@ export const emptyPart = (): QuotePartInput => ({
   partNumber: "",
   name: "",
   quantity: 1,
+  unitCost: 0,
+  markupPercent: 0,
   unitPrice: 0,
   total: 0,
   excluded: false,

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

@@ -8,7 +8,7 @@ import { useConfirm } from "@/components/confirm-dialog";
 import { updateQuote, deleteQuote, convertQuoteToServiceRecord } from "@/features/quotes/Actions/quoteActions";
 import { acknowledgeQuoteResponse } from "@/features/quotes/Actions/quoteResponseActions";
 import { calculateTotals } from "@/lib/tax";
-import { lineTotal } from "@/features/inventory/Lib/partPricing";
+import { lineTotal, priceFromCostAndMarkup, markupFromCostAndPrice } from "@/features/inventory/Lib/partPricing";
 import type { QuoteRecord, QuotePartInput, QuoteLaborInput } from "./quote-page-types";
 import { emptyPart, makeEmptyLabor, makeEmptyService } from "./quote-page-types";
 
@@ -58,7 +58,7 @@ export function useQuoteFormState({
   const [customerId, setCustomerId] = useState(quote.customer?.id || "");
   const [vehicleId, setVehicleId] = useState(quote.vehicle?.id || "");
   const [partItems, setPartItems] = useState<QuotePartInput[]>(
-    quote.partItems.map((p) => ({ partNumber: p.partNumber || "", name: p.name, quantity: p.quantity, unitPrice: p.unitPrice, total: p.total, excluded: p.excluded ?? false, inventoryPartId: p.inventoryPartId ?? null }))
+    quote.partItems.map((p) => ({ partNumber: p.partNumber || "", name: p.name, quantity: p.quantity, unitCost: p.unitCost ?? 0, markupPercent: p.markupPercent ?? 0, unitPrice: p.unitPrice, total: p.total, excluded: p.excluded ?? false, inventoryPartId: p.inventoryPartId ?? null }))
   );
   const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(
     quote.laborItems.map((l) => ({ description: l.description, hours: l.hours, rate: l.rate, total: l.total, pricingType: (l.pricingType as "hourly" | "service") || "hourly", excluded: l.excluded ?? false }))
@@ -152,11 +152,21 @@ export function useQuoteFormState({
     taxInclusive,
   });
 
+  // Same linked pricing model as work order parts:
+  //   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) => {
     setPartItems((prev) => {
       const updated = [...prev];
       const part = { ...updated[index], [field]: value };
-      if (field === "quantity" || field === "unitPrice") part.total = lineTotal(part.quantity, part.unitPrice);
+      if (field === "unitCost" || field === "markupPercent") {
+        part.unitPrice = priceFromCostAndMarkup(part.unitCost, part.markupPercent);
+      } else if (field === "unitPrice") {
+        part.markupPercent = markupFromCostAndPrice(part.unitCost, part.unitPrice);
+      }
+      if (field === "quantity" || field === "unitPrice" || field === "unitCost" || field === "markupPercent") {
+        part.total = lineTotal(part.quantity, part.unitPrice);
+      }
       updated[index] = part;
       return updated;
     });

+ 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),
+  unitCost: z.coerce.number().min(0).default(0),
+  markupPercent: z.coerce.number().min(0).default(0),
   unitPrice: z.coerce.number().min(0).default(0),
   total: z.coerce.number().min(0).default(0),
   excluded: z.boolean().optional().default(false),

+ 5 - 1
src/features/vehicles/Components/service-edit/PartsEditor.tsx

@@ -121,7 +121,8 @@ function SortablePartRow({
             value={part.name}
             onChange={(e) => updatePart(index, 'name', e.target.value)}
             rows={1}
-            className="min-h-9 w-full resize-none"
+            aria-invalid={!part.name.trim() && !!(part.partNumber || Number(part.unitCost) > 0 || Number(part.unitPrice) > 0)}
+            className={`min-h-9 w-full resize-none ${!part.name.trim() && (part.partNumber || Number(part.unitCost) > 0 || Number(part.unitPrice) > 0) ? 'border-destructive focus-visible:ring-destructive' : ''}`}
           />
           <PartNameSuggestions
             query={part.name}
@@ -408,6 +409,9 @@ export function PartsEditor({
           >
             <Plus className="h-4 w-4" />
           </button>
+          {partItems.some(
+            (p) => !p.name.trim() && (p.partNumber || Number(p.unitCost) > 0 || Number(p.unitPrice) > 0)
+          ) && <p className="text-xs text-destructive">{t('nameMissingHint')}</p>}
           <div className="flex justify-end pt-1 text-sm">
             <span className="font-medium">
               {t('subtotal', { amount: formatCurrency(partsSubtotal, currencyCode) })}

+ 9 - 0
src/features/vehicles/Components/service-edit/TotalsSection.tsx

@@ -14,6 +14,8 @@ import { netLineTotal } from '@/lib/tax'
 
 interface TotalsSectionProps {
   partsSubtotal: number
+  /** Internal pre-markup parts cost; rendered muted, never on PDF/share */
+  partsCostSubtotal: number
   laborSubtotal: number
   subtotal: number
   discountType: string
@@ -32,6 +34,7 @@ interface TotalsSectionProps {
 
 export function TotalsSection({
   partsSubtotal,
+  partsCostSubtotal,
   laborSubtotal,
   subtotal,
   discountType,
@@ -66,6 +69,12 @@ export function TotalsSection({
           <span className="text-muted-foreground">{t('parts')}</span>
           <span>{formatCurrency(displayPartsSubtotal, currencyCode)}</span>
         </div>
+        {partsCostSubtotal > 0 && (
+          <div className="flex items-center justify-between text-xs text-muted-foreground/70">
+            <span>{t('partsCost')}</span>
+            <span>{formatCurrency(partsCostSubtotal, currencyCode)}</span>
+          </div>
+        )}
         <div className="flex items-center justify-between text-sm">
           <span className="text-muted-foreground">{t('labor')}</span>
           <span>{formatCurrency(displayLaborSubtotal, currencyCode)}</span>

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

@@ -104,6 +104,7 @@ export function DetailsRightColumn({
       />
       <TotalsSection
         partsSubtotal={formState.partsSubtotal}
+        partsCostSubtotal={formState.partsCostSubtotal}
         laborSubtotal={formState.laborSubtotal}
         subtotal={formState.subtotal}
         discountType={formState.discountType}

+ 7 - 1
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -109,6 +109,12 @@ export function useServiceFormState({
 
   // Computed totals
   const partsSubtotal = partItems.reduce((sum, p) => sum + p.total, 0)
+  // Internal-only: what the parts cost the workshop before markup. Shown in
+  // the totals card for the mechanic, never on the PDF or share views.
+  const partsCostSubtotal = partItems.reduce(
+    (sum, p) => sum + (Number(p.unitCost) || 0) * (Number(p.quantity) || 0),
+    0
+  )
   const laborSubtotal = laborItems.reduce((sum, l) => sum + l.total, 0)
   const subtotal = partsSubtotal + laborSubtotal
   const discountAmount =
@@ -232,7 +238,7 @@ export function useServiceFormState({
     // Custom fields
     customFieldsSaveRef, onCustomFieldsReady,
     // Computed
-    partsSubtotal, laborSubtotal, subtotal,
+    partsSubtotal, partsCostSubtotal, laborSubtotal, subtotal,
     discountAmount, taxAmount, totalAmount,
     displayTotal, totalPaid, balanceDue, paymentStatus, setLocalManuallyPaid,
     imageAttachments, vehicleName,