Ver Fonte

Add option to set markup for parts (#187)

* add markup to the invoice editor

* aria-describedby

* update packages

* better auth
Bernt Christian Egeland há 3 meses atrás
pai
commit
e95ff78ce7
42 ficheiros alterados com 551 adições e 495 exclusões
  1. 6 0
      messages/de/service.json
  2. 6 0
      messages/de/settings.json
  3. 6 0
      messages/en/service.json
  4. 6 0
      messages/en/settings.json
  5. 6 0
      messages/es/service.json
  6. 6 0
      messages/es/settings.json
  7. 6 0
      messages/fr/service.json
  8. 6 0
      messages/fr/settings.json
  9. 6 0
      messages/it/service.json
  10. 6 0
      messages/it/settings.json
  11. 6 0
      messages/lt/service.json
  12. 6 0
      messages/lt/settings.json
  13. 6 0
      messages/nb/service.json
  14. 6 0
      messages/nb/settings.json
  15. 6 0
      messages/nl/service.json
  16. 6 0
      messages/nl/settings.json
  17. 6 0
      messages/pl/service.json
  18. 6 0
      messages/pl/settings.json
  19. 6 0
      messages/pt-BR/service.json
  20. 6 0
      messages/pt-BR/settings.json
  21. 6 0
      messages/ru/service.json
  22. 6 0
      messages/ru/settings.json
  23. 6 0
      messages/tr/service.json
  24. 6 0
      messages/tr/settings.json
  25. 172 468
      package-lock.json
  26. 11 11
      package.json
  27. 2 0
      prisma/migrations/20260525102721_part_markup/migration.sql
  28. 1 0
      prisma/schema.prisma
  29. 56 0
      src/app/(authenticated)/settings/invoice/invoice-settings.tsx
  30. 8 0
      src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx
  31. 1 1
      src/features/email/Components/SendEmailDialog.tsx
  32. 2 0
      src/features/settings/Schema/settingsSchema.ts
  33. 2 0
      src/features/vehicles/Actions/serviceActions.ts
  34. 1 1
      src/features/vehicles/Components/service-detail/ShareDialog.tsx
  35. 17 2
      src/features/vehicles/Components/service-edit/InventoryPickerDialog.tsx
  36. 77 8
      src/features/vehicles/Components/service-edit/PartsEditor.tsx
  37. 2 1
      src/features/vehicles/Components/service-edit/form-types.ts
  38. 6 0
      src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx
  39. 16 2
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  40. 2 0
      src/features/vehicles/Components/service-page/service-page-types.ts
  41. 30 1
      src/features/vehicles/Components/service-page/useServiceFormState.ts
  42. 1 0
      src/features/vehicles/Schema/serviceSchema.ts

+ 6 - 0
messages/de/service.json

@@ -87,6 +87,12 @@
     "partNumber": "Teil #",
     "name": "Name",
     "qty": "Menge",
+    "unitCost": "Kosten",
+    "unitCostHint": "Einkaufspreis (intern — wird dem Kunden nicht angezeigt)",
+    "markupPercent": "Aufschlag %",
+    "markupPercentHint": "Aufschlag auf den Einkaufspreis (intern — wird dem Kunden nicht angezeigt)",
+    "applyMarkup": "{percent}% anwenden",
+    "applyMarkupHint": "{percent}% Aufschlag auf den Einkaufspreis in allen Zeilen erneut anwenden",
     "unitPrice": "Stückpreis",
     "total": "Gesamt",
     "namePlaceholder": "Name *",

+ 6 - 0
messages/de/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Benutzerdefinierte Rechnungsfusszeile",
     "footerPlaceholder": "Vielen Dank fur Ihren Auftrag!",
     "footerHint": "Dieser Text erscheint am Ende jeder Rechnung",
+    "partsMarkupTitle": "Teile-Aufschlag",
+    "partsMarkupDescription": "Aufschlag auf den Einkaufspreis beim Hinzufügen von Teilen. Nur intern — der Kunde sieht weder Kosten noch Aufschlag, nur den Endpreis.",
+    "defaultMarkupPercent": "Standard-Aufschlag %",
+    "defaultMarkupPercentHint": "Neue manuell hinzugefügte Teile werden mit diesem Aufschlag vorausgefüllt. Auf 0 lassen, um Preise manuell einzugeben.",
+    "markupAppliesToInventory": "Aufschlag auf Lagerteile anwenden",
+    "markupAppliesToInventoryHint": "Wenn aktiviert, verwenden aus dem Lager gewählte Teile ebenfalls Einkaufspreis + Standard-Aufschlag (überschreibt den Verkaufspreis des Lagers).",
     "saveInvoice": "Rechnungseinstellungen speichern",
     "saved": "Rechnungseinstellungen gespeichert",
     "layoutDescription": "Passen Sie die Reihenfolge und Sichtbarkeit der Abschnitte in Ihren Rechnungen und Angeboten an.",

+ 6 - 0
messages/en/service.json

@@ -87,6 +87,12 @@
     "partNumber": "Part #",
     "name": "Name",
     "qty": "Qty",
+    "unitCost": "Cost",
+    "unitCostHint": "Vendor cost (internal — not shown to customer)",
+    "markupPercent": "Markup %",
+    "markupPercentHint": "Markup applied to cost (internal — not shown to customer)",
+    "applyMarkup": "Apply {percent}%",
+    "applyMarkupHint": "Re-apply {percent}% markup to cost on every row",
     "unitPrice": "Unit Price",
     "total": "Total",
     "namePlaceholder": "Name *",

+ 6 - 0
messages/en/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Custom Invoice Footer",
     "footerPlaceholder": "Thank you for your business!",
     "footerHint": "This text appears at the bottom of every invoice",
+    "partsMarkupTitle": "Parts Markup",
+    "partsMarkupDescription": "Markup applied to vendor cost when adding parts. Internal only — the customer never sees the cost or markup, only the final price.",
+    "defaultMarkupPercent": "Default Markup %",
+    "defaultMarkupPercentHint": "New manually-added parts pre-fill with this markup. Leave at 0 to enter prices manually.",
+    "markupAppliesToInventory": "Apply markup to inventory parts",
+    "markupAppliesToInventoryHint": "When enabled, parts picked from inventory also use cost + default markup (overrides the inventory sell price).",
     "saveInvoice": "Save Invoice Settings",
     "saved": "Invoice settings saved",
     "layoutDescription": "Customize the order and visibility of sections in your invoices and quotes.",

+ 6 - 0
messages/es/service.json

@@ -87,6 +87,12 @@
     "partNumber": "N° de Pieza",
     "name": "Nombre",
     "qty": "Cantidad",
+    "unitCost": "Costo",
+    "unitCostHint": "Costo del proveedor (interno — no se muestra al cliente)",
+    "markupPercent": "Margen %",
+    "markupPercentHint": "Margen aplicado al costo (interno — no se muestra al cliente)",
+    "applyMarkup": "Aplicar {percent}%",
+    "applyMarkupHint": "Volver a aplicar {percent}% de margen al costo en cada fila",
     "unitPrice": "Precio Unitario",
     "total": "Total",
     "namePlaceholder": "Nombre *",

+ 6 - 0
messages/es/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Pie de pagina personalizado",
     "footerPlaceholder": "Gracias por su preferencia!",
     "footerHint": "Este texto aparece al final de cada factura",
+    "partsMarkupTitle": "Margen de Repuestos",
+    "partsMarkupDescription": "Margen aplicado al costo del proveedor al agregar repuestos. Solo interno — el cliente nunca ve el costo ni el margen, solo el precio final.",
+    "defaultMarkupPercent": "Margen Predeterminado %",
+    "defaultMarkupPercentHint": "Los repuestos nuevos agregados manualmente se prellenan con este margen. Déjelo en 0 para ingresar precios manualmente.",
+    "markupAppliesToInventory": "Aplicar margen a repuestos de inventario",
+    "markupAppliesToInventoryHint": "Cuando está habilitado, los repuestos seleccionados del inventario también usan costo + margen predeterminado (anula el precio de venta del inventario).",
     "saveInvoice": "Guardar configuracion de factura",
     "saved": "Configuracion de factura guardada",
     "layoutDescription": "Personaliza el orden y la visibilidad de las secciones en tus facturas y presupuestos.",

+ 6 - 0
messages/fr/service.json

@@ -87,6 +87,12 @@
     "partNumber": "Nº de Pièce",
     "name": "Nom",
     "qty": "Qté",
+    "unitCost": "Coût",
+    "unitCostHint": "Coût fournisseur (interne — non visible par le client)",
+    "markupPercent": "Marge %",
+    "markupPercentHint": "Marge appliquée au coût (interne — non visible par le client)",
+    "applyMarkup": "Appliquer {percent}%",
+    "applyMarkupHint": "Réappliquer la marge de {percent}% au coût sur chaque ligne",
     "unitPrice": "Prix Unitaire",
     "total": "Total",
     "namePlaceholder": "Nom *",

+ 6 - 0
messages/fr/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Pied de page personnalise",
     "footerPlaceholder": "Merci pour votre confiance !",
     "footerHint": "Ce texte apparait en bas de chaque facture",
+    "partsMarkupTitle": "Marge sur Pièces",
+    "partsMarkupDescription": "Marge appliquée au coût fournisseur lors de l'ajout de pièces. Interne uniquement — le client ne voit jamais le coût ou la marge, seulement le prix final.",
+    "defaultMarkupPercent": "Marge par Défaut %",
+    "defaultMarkupPercentHint": "Les nouvelles pièces ajoutées manuellement sont préremplies avec cette marge. Laisser à 0 pour saisir les prix manuellement.",
+    "markupAppliesToInventory": "Appliquer la marge aux pièces du stock",
+    "markupAppliesToInventoryHint": "Lorsque activé, les pièces sélectionnées dans le stock utilisent aussi coût + marge par défaut (remplace le prix de vente du stock).",
     "saveInvoice": "Enregistrer les parametres de facture",
     "saved": "Parametres de facture enregistres",
     "layoutDescription": "Personnalisez l'ordre et la visibilité des sections dans vos factures et devis.",

+ 6 - 0
messages/it/service.json

@@ -87,6 +87,12 @@
     "partNumber": "Numero Pezzo",
     "name": "Nome",
     "qty": "Qty",
+    "unitCost": "Costo",
+    "unitCostHint": "Costo fornitore (interno — non mostrato al cliente)",
+    "markupPercent": "Ricarico %",
+    "markupPercentHint": "Ricarico applicato al costo (interno — non mostrato al cliente)",
+    "applyMarkup": "Applica {percent}%",
+    "applyMarkupHint": "Riapplicare il ricarico del {percent}% al costo su ogni riga",
     "unitPrice": "Prezzo Unitario",
     "total": "Totale",
     "namePlaceholder": "Nome *",

+ 6 - 0
messages/it/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Pie di pagina fattura personalizzato",
     "footerPlaceholder": "Grazie per la vostra fiducia!",
     "footerHint": "Questo testo appare in fondo a ogni fattura",
+    "partsMarkupTitle": "Ricarico Ricambi",
+    "partsMarkupDescription": "Ricarico applicato al costo del fornitore quando si aggiungono ricambi. Solo interno — il cliente non vede mai il costo o il ricarico, solo il prezzo finale.",
+    "defaultMarkupPercent": "Ricarico Predefinito %",
+    "defaultMarkupPercentHint": "I nuovi ricambi aggiunti manualmente vengono precompilati con questo ricarico. Lasciare a 0 per inserire i prezzi manualmente.",
+    "markupAppliesToInventory": "Applica ricarico ai ricambi a magazzino",
+    "markupAppliesToInventoryHint": "Quando attivato, i ricambi selezionati dal magazzino utilizzano anche costo + ricarico predefinito (sovrascrive il prezzo di vendita del magazzino).",
     "saveInvoice": "Salva impostazioni fattura",
     "saved": "Impostazioni fattura salvate",
     "layoutDescription": "Personalizza l'ordine e la visibilità delle sezioni nelle tue fatture e preventivi.",

+ 6 - 0
messages/lt/service.json

@@ -87,6 +87,12 @@
     "partNumber": "Dalies Nr.",
     "name": "Pavadinimas",
     "qty": "Kiekis",
+    "unitCost": "Savikaina",
+    "unitCostHint": "Tiekėjo kaina (vidinė — nematoma klientui)",
+    "markupPercent": "Antkainis %",
+    "markupPercentHint": "Antkainis pridėtas prie savikainos (vidinis — nematomas klientui)",
+    "applyMarkup": "Taikyti {percent}%",
+    "applyMarkupHint": "Iš naujo taikyti {percent}% antkainį savikainai kiekvienoje eilutėje",
     "unitPrice": "Vnt. kaina",
     "total": "Suma",
     "namePlaceholder": "Pavadinimas *",

+ 6 - 0
messages/lt/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Pasirinktinė sąskaitos poraštė",
     "footerPlaceholder": "Dėkojame už jūsų pasitikėjimą!",
     "footerHint": "Šis tekstas rodomas kiekvienos sąskaitos apačioje",
+    "partsMarkupTitle": "Dalių Antkainis",
+    "partsMarkupDescription": "Antkainis taikomas tiekėjo savikainai pridedant dalis. Tik vidinis — klientas niekada nemato savikainos ar antkainio, tik galutinę kainą.",
+    "defaultMarkupPercent": "Numatytasis Antkainis %",
+    "defaultMarkupPercentHint": "Naujos rankiniu būdu pridėtos dalys užpildomos šiuo antkainiu. Palikite 0, kad įvestumėte kainas rankiniu būdu.",
+    "markupAppliesToInventory": "Taikyti antkainį sandėlio dalims",
+    "markupAppliesToInventoryHint": "Įjungus, dalys parinktos iš sandėlio taip pat naudoja savikainą + numatytąjį antkainį (perrašo sandėlio pardavimo kainą).",
     "saveInvoice": "Išsaugoti sąskaitos nustatymus",
     "saved": "Sąskaitos nustatymai išsaugoti",
     "layoutDescription": "Pritaikykite sekcijų tvarką ir matomumą sąskaitose ir pasiūlymuose.",

+ 6 - 0
messages/nb/service.json

@@ -87,6 +87,12 @@
     "partNumber": "Del #",
     "name": "Navn",
     "qty": "Antall",
+    "unitCost": "Kostnad",
+    "unitCostHint": "Innkjøpspris (intern — vises ikke til kunde)",
+    "markupPercent": "Påslag %",
+    "markupPercentHint": "Påslag på kostnad (intern — vises ikke til kunde)",
+    "applyMarkup": "Bruk {percent}%",
+    "applyMarkupHint": "Bruk {percent}% påslag på kostnad på alle rader på nytt",
     "unitPrice": "Enhetspris",
     "total": "Total",
     "namePlaceholder": "Navn *",

+ 6 - 0
messages/nb/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Egendefinert fakturabunnstykke",
     "footerPlaceholder": "Takk for handelen!",
     "footerHint": "Denne teksten vises nederst pa hver faktura",
+    "partsMarkupTitle": "Delepåslag",
+    "partsMarkupDescription": "Påslag på innkjøpspris når deler legges til. Kun internt — kunden ser aldri kostnad eller påslag, bare sluttpris.",
+    "defaultMarkupPercent": "Standard påslag %",
+    "defaultMarkupPercentHint": "Nye manuelt lagt til deler fylles ut med dette påslaget. La stå på 0 for å skrive priser manuelt.",
+    "markupAppliesToInventory": "Bruk påslag på lagerdeler",
+    "markupAppliesToInventoryHint": "Når aktivert, bruker deler valgt fra lager også kostnad + standardpåslag (overstyrer lagerets salgspris).",
     "saveInvoice": "Lagre fakturainnstillinger",
     "saved": "Fakturainnstillinger lagret",
     "layoutDescription": "Tilpass rekkefølgen og synligheten av seksjoner i fakturaer og tilbud.",

+ 6 - 0
messages/nl/service.json

@@ -87,6 +87,12 @@
     "partNumber": "Onderdeel #",
     "name": "Naam",
     "qty": "Hoeveelheid",
+    "unitCost": "Inkoop",
+    "unitCostHint": "Inkoopprijs (intern — niet zichtbaar voor klant)",
+    "markupPercent": "Marge %",
+    "markupPercentHint": "Marge toegepast op inkoopprijs (intern — niet zichtbaar voor klant)",
+    "applyMarkup": "{percent}% toepassen",
+    "applyMarkupHint": "{percent}% marge opnieuw toepassen op inkoop op elke rij",
     "unitPrice": "Eenheidsprijs",
     "total": "Totaal",
     "namePlaceholder": "Naam *",

+ 6 - 0
messages/nl/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Aangepaste factuurvoettekst",
     "footerPlaceholder": "Bedankt voor uw vertrouwen!",
     "footerHint": "Deze tekst verschijnt onderaan elke factuur",
+    "partsMarkupTitle": "Onderdelenmarge",
+    "partsMarkupDescription": "Marge toegepast op inkoopprijs bij het toevoegen van onderdelen. Alleen intern — de klant ziet nooit de inkoopprijs of de marge, alleen de eindprijs.",
+    "defaultMarkupPercent": "Standaardmarge %",
+    "defaultMarkupPercentHint": "Nieuwe handmatig toegevoegde onderdelen worden voorgevuld met deze marge. Laat op 0 staan om prijzen handmatig in te voeren.",
+    "markupAppliesToInventory": "Marge toepassen op voorraadonderdelen",
+    "markupAppliesToInventoryHint": "Indien ingeschakeld gebruiken onderdelen uit de voorraad ook inkoop + standaardmarge (overschrijft de verkoopprijs van de voorraad).",
     "saveInvoice": "Factuurinstellingen opslaan",
     "saved": "Factuurinstellingen opgeslagen",
     "layoutDescription": "Pas de volgorde en zichtbaarheid van secties in uw facturen en offertes aan.",

+ 6 - 0
messages/pl/service.json

@@ -87,6 +87,12 @@
     "partNumber": "Nr Części",
     "name": "Nazwa",
     "qty": "Ilość",
+    "unitCost": "Koszt",
+    "unitCostHint": "Cena zakupu (wewnętrzne — nie pokazywane klientowi)",
+    "markupPercent": "Marża %",
+    "markupPercentHint": "Marża doliczana do kosztu (wewnętrzne — nie pokazywane klientowi)",
+    "applyMarkup": "Zastosuj {percent}%",
+    "applyMarkupHint": "Zastosuj ponownie marżę {percent}% do kosztu w każdym wierszu",
     "unitPrice": "Cena Jednostkowa",
     "total": "Razem",
     "namePlaceholder": "Nazwa *",

+ 6 - 0
messages/pl/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Niestandardowa stopka faktury",
     "footerPlaceholder": "Dziekujemy za zamowienie!",
     "footerHint": "Ten tekst pojawia sie na dole kazdej faktury",
+    "partsMarkupTitle": "Marża na Części",
+    "partsMarkupDescription": "Marża doliczana do ceny zakupu przy dodawaniu części. Tylko wewnętrznie — klient nigdy nie widzi kosztu ani marży, tylko cenę końcową.",
+    "defaultMarkupPercent": "Domyślna Marża %",
+    "defaultMarkupPercentHint": "Nowe ręcznie dodane części są wstępnie wypełniane tą marżą. Pozostaw 0, aby wprowadzać ceny ręcznie.",
+    "markupAppliesToInventory": "Zastosuj marżę do części z magazynu",
+    "markupAppliesToInventoryHint": "Gdy włączone, części wybrane z magazynu również używają kosztu + domyślnej marży (zastępuje cenę sprzedaży z magazynu).",
     "saveInvoice": "Zapisz ustawienia faktury",
     "saved": "Ustawienia faktury zapisane",
     "layoutDescription": "Dostosuj kolejność i widoczność sekcji w fakturach i wycenach.",

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

@@ -87,6 +87,12 @@
     "partNumber": "Nº da Peça",
     "name": "Nome",
     "qty": "Qtd",
+    "unitCost": "Custo",
+    "unitCostHint": "Custo do fornecedor (interno — não exibido ao cliente)",
+    "markupPercent": "Margem %",
+    "markupPercentHint": "Margem aplicada ao custo (interno — não exibido ao cliente)",
+    "applyMarkup": "Aplicar {percent}%",
+    "applyMarkupHint": "Reaplicar margem de {percent}% ao custo em cada linha",
     "unitPrice": "Preço Unitário",
     "total": "Total",
     "namePlaceholder": "Nome *",

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

@@ -390,6 +390,12 @@
     "customFooter": "Rodape personalizado da fatura",
     "footerPlaceholder": "Obrigado pela preferencia!",
     "footerHint": "Este texto aparece no final de cada fatura",
+    "partsMarkupTitle": "Margem de Peças",
+    "partsMarkupDescription": "Margem aplicada ao custo do fornecedor ao adicionar peças. Apenas interno — o cliente nunca vê o custo ou a margem, somente o preço final.",
+    "defaultMarkupPercent": "Margem Padrão %",
+    "defaultMarkupPercentHint": "Novas peças adicionadas manualmente são pré-preenchidas com esta margem. Deixe em 0 para inserir preços manualmente.",
+    "markupAppliesToInventory": "Aplicar margem a peças do estoque",
+    "markupAppliesToInventoryHint": "Quando ativado, peças selecionadas do estoque também usam custo + margem padrão (substitui o preço de venda do estoque).",
     "saveInvoice": "Salvar configuracoes da fatura",
     "saved": "Configuracoes da fatura salvas",
     "layoutDescription": "Personalize a ordem e visibilidade das seções em suas faturas e orçamentos.",

+ 6 - 0
messages/ru/service.json

@@ -87,6 +87,12 @@
     "partNumber": "Арт. №",
     "name": "Название",
     "qty": "Кол-во",
+    "unitCost": "Себестоимость",
+    "unitCostHint": "Закупочная цена (внутренняя — клиенту не показывается)",
+    "markupPercent": "Наценка %",
+    "markupPercentHint": "Наценка к себестоимости (внутренняя — клиенту не показывается)",
+    "applyMarkup": "Применить {percent}%",
+    "applyMarkupHint": "Повторно применить наценку {percent}% к себестоимости в каждой строке",
     "unitPrice": "Цена за ед.",
     "total": "Итого",
     "namePlaceholder": "Название *",

+ 6 - 0
messages/ru/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Пользовательский подвал счёта",
     "footerPlaceholder": "Спасибо за выбор нашего сервиса!",
     "footerHint": "Этот текст отображается внизу каждого счёта",
+    "partsMarkupTitle": "Наценка на запчасти",
+    "partsMarkupDescription": "Наценка к закупочной цене при добавлении запчастей. Только внутреннее — клиент никогда не видит себестоимость или наценку, только итоговую цену.",
+    "defaultMarkupPercent": "Наценка по умолчанию %",
+    "defaultMarkupPercentHint": "Новые вручную добавленные запчасти заполняются этой наценкой. Оставьте 0, чтобы вводить цены вручную.",
+    "markupAppliesToInventory": "Применить наценку к запчастям со склада",
+    "markupAppliesToInventoryHint": "Если включено, запчасти, выбранные со склада, также используют себестоимость + наценку по умолчанию (переопределяет цену продажи со склада).",
     "saveInvoice": "Сохранить настройки счёта",
     "saved": "Настройки счёта сохранены",
     "layoutDescription": "Настройте порядок и видимость разделов в счетах и предложениях.",

+ 6 - 0
messages/tr/service.json

@@ -87,6 +87,12 @@
     "partNumber": "Parça #",
     "name": "Ad",
     "qty": "Mik.",
+    "unitCost": "Maliyet",
+    "unitCostHint": "Tedarikçi maliyeti (dahili — müşteriye gösterilmez)",
+    "markupPercent": "Kâr Marjı %",
+    "markupPercentHint": "Maliyete uygulanan kâr marjı (dahili — müşteriye gösterilmez)",
+    "applyMarkup": "{percent}% Uygula",
+    "applyMarkupHint": "Her satırdaki maliyete {percent}% marjı yeniden uygula",
     "unitPrice": "Birim Fiyatı",
     "total": "Toplam",
     "namePlaceholder": "Ad *",

+ 6 - 0
messages/tr/settings.json

@@ -390,6 +390,12 @@
     "customFooter": "Özel Fatura Alt Bilgisi",
     "footerPlaceholder": "İşiniz için teşekkür ederiz!",
     "footerHint": "Bu metin her faturanın altında görünür",
+    "partsMarkupTitle": "Parça Kâr Marjı",
+    "partsMarkupDescription": "Parça eklenirken tedarikçi maliyetine uygulanan kâr marjı. Yalnızca dahili — müşteri maliyeti veya marjı asla görmez, sadece son fiyatı.",
+    "defaultMarkupPercent": "Varsayılan Kâr Marjı %",
+    "defaultMarkupPercentHint": "Yeni elle eklenen parçalar bu marjla önceden doldurulur. Fiyatları elle girmek için 0 bırakın.",
+    "markupAppliesToInventory": "Stok parçalarına kâr marjı uygula",
+    "markupAppliesToInventoryHint": "Etkinleştirildiğinde, stoktan seçilen parçalar da maliyet + varsayılan marj kullanır (stoğun satış fiyatını geçersiz kılar).",
     "saveInvoice": "Fatura Ayarlarını Kaydet",
     "saved": "Fatura ayarları kaydedildi",
     "layoutDescription": "Fatura ve tekliflerinizdeki bölümlerin sırasını ve görünürlüğünü özelleştirin.",

Diff do ficheiro suprimidas por serem muito extensas
+ 172 - 468
package-lock.json


+ 11 - 11
package.json

@@ -18,7 +18,7 @@
   },
   "dependencies": {
     "@aws-sdk/client-ses": "^3.1045.0",
-    "@better-auth/passkey": "^1.6.11",
+    "@better-auth/passkey": "^1.6.19",
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
@@ -34,7 +34,7 @@
     "@tiptap/react": "^3.19.0",
     "@tiptap/starter-kit": "^3.19.0",
     "@types/qrcode": "^1.5.6",
-    "better-auth": "^1.6.11",
+    "better-auth": "^1.6.19",
     "bson": "^7.2.0",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
@@ -42,19 +42,19 @@
     "cron": "^4.4.0",
     "date-fns": "^4.1.0",
     "dotenv": "^17.2.4",
-    "form-data": "^4.0.5",
+    "form-data": "^4.0.6",
     "html5-qrcode": "^2.3.8",
     "jszip": "^3.10.1",
     "lucide-react": "^0.563.0",
     "mailgun.js": "^12.7.1",
-    "next": "^16.2.6",
+    "next": "^16.2.9",
     "next-intl": "^4.11.1",
     "next-themes": "^0.4.6",
     "next-ws": "^2.2.7",
-    "nodemailer": "^8.0.5",
+    "nodemailer": "^9.0.1",
     "openai": "^6.27.0",
     "pdf-lib": "^1.17.1",
-    "posthog-js": "^1.373.4",
+    "posthog-js": "^1.390.2",
     "postmark": "^4.0.7",
     "prisma": "^7.8.0",
     "qrcode": "^1.5.4",
@@ -68,12 +68,12 @@
     "react-resizable-panels": "^4.6.4",
     "recharts": "^3.7.0",
     "remark-gfm": "^4.0.1",
-    "resend": "^6.12.2",
+    "resend": "^6.14.0",
     "sonner": "^2.0.7",
     "stripe": "^20.3.1",
     "tailwind-merge": "^3.4.0",
     "vaul": "^1.1.2",
-    "ws": "^8.19.0",
+    "ws": "^8.21.0",
     "zod": "^4.3.6",
     "zustand": "^5.0.11"
   },
@@ -89,13 +89,13 @@
     "@types/react": "^19",
     "@types/react-dom": "^19",
     "@types/ws": "^8.18.1",
-    "@vitest/coverage-v8": "^4.1.5",
+    "@vitest/coverage-v8": "^4.1.9",
     "jsdom": "^29.0.0",
-    "shadcn": "^4.7.0",
+    "shadcn": "^4.11.0",
     "tailwindcss": "^4",
     "tw-animate-css": "^1.4.0",
     "typescript": "^5",
-    "vitest": "^4.1.5"
+    "vitest": "^4.1.9"
   },
   "overrides": {
     "fast-xml-parser": ">=5.3.6",

+ 2 - 0
prisma/migrations/20260525102721_part_markup/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "service_parts" ADD COLUMN     "markupPercent" DOUBLE PRECISION NOT NULL DEFAULT 0;

+ 1 - 0
prisma/schema.prisma

@@ -392,6 +392,7 @@ model ServicePart {
   unitPrice       Float   @default(0)
   total           Float   @default(0)
   unitCost        Float   @default(0)
+  markupPercent   Float   @default(0)
   inventoryPartId String?
 
   serviceRecordId String

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

@@ -8,6 +8,7 @@ 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 { Switch } from '@/components/ui/switch'
 import { Textarea } from '@/components/ui/textarea'
 import { toast } from 'sonner'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
@@ -87,6 +88,12 @@ export function InvoiceSettings({
   )
   const [dueDays, setDueDays] = useState(settings[SETTING_KEYS.INVOICE_DUE_DAYS] || '14')
   const [footerNote, setFooterNote] = useState(settings[SETTING_KEYS.INVOICE_FOOTER_NOTE] || '')
+  const [defaultMarkupPercent, setDefaultMarkupPercent] = useState(
+    settings[SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT] || '0'
+  )
+  const [markupAppliesToInventory, setMarkupAppliesToInventory] = useState(
+    settings[SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY] === 'true'
+  )
 
   // Sections to hide from layout editor when features are disabled
   const hiddenLayoutSections = useMemo(() => {
@@ -121,6 +128,8 @@ export function InvoiceSettings({
       [SETTING_KEYS.INVOICE_START_NUMBER]: invoiceStartNumber,
       [SETTING_KEYS.INVOICE_DUE_DAYS]: dueDays,
       [SETTING_KEYS.INVOICE_FOOTER_NOTE]: footerNote,
+      [SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]: defaultMarkupPercent,
+      [SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY]: markupAppliesToInventory ? 'true' : 'false',
     })
     setSaving(false)
     router.refresh()
@@ -272,6 +281,53 @@ export function InvoiceSettings({
                 <p className="text-xs text-muted-foreground">{t('invoice.footerHint')}</p>
               </div>
 
+              <Separator />
+
+              <div className="space-y-3">
+                <div>
+                  <h3 className="text-sm font-semibold">{t('invoice.partsMarkupTitle')}</h3>
+                  <p className="text-xs text-muted-foreground">
+                    {t('invoice.partsMarkupDescription')}
+                  </p>
+                </div>
+                <div className="grid gap-4 sm:grid-cols-2">
+                  <div className="space-y-2">
+                    <Label htmlFor="defaultMarkupPercent">
+                      {t('invoice.defaultMarkupPercent')}
+                    </Label>
+                    <Input
+                      id="defaultMarkupPercent"
+                      type="number"
+                      min="0"
+                      step="0.1"
+                      placeholder="0"
+                      value={defaultMarkupPercent}
+                      onChange={(e) => setDefaultMarkupPercent(e.target.value)}
+                      className="w-32"
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      {t('invoice.defaultMarkupPercentHint')}
+                    </p>
+                  </div>
+                  <div className="space-y-2">
+                    <Label
+                      htmlFor="markupAppliesToInventory"
+                      className="flex items-center justify-between gap-3"
+                    >
+                      <span>{t('invoice.markupAppliesToInventory')}</span>
+                      <Switch
+                        id="markupAppliesToInventory"
+                        checked={markupAppliesToInventory}
+                        onCheckedChange={setMarkupAppliesToInventory}
+                      />
+                    </Label>
+                    <p className="text-xs text-muted-foreground">
+                      {t('invoice.markupAppliesToInventoryHint')}
+                    </p>
+                  </div>
+                </div>
+              </div>
+
               <SaveButton>
                 <Separator />
                 <div className="flex items-center gap-3">

+ 8 - 0
src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx

@@ -46,6 +46,8 @@ export default async function ServiceDetailPage({
       SETTING_KEYS.TAX_ENABLED,
       SETTING_KEYS.DEFAULT_LABOR_RATE,
       SETTING_KEYS.INVOICE_DUE_DAYS,
+      SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT,
+      SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY,
     ]),
     getInventoryPartsList(),
     getTechnicians(),
@@ -78,6 +80,9 @@ export default async function ServiceDetailPage({
   const defaultTaxRate = taxEnabled ? Number(settings[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0 : 0
   const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0
   const defaultDueDays = Number(settings[SETTING_KEYS.INVOICE_DUE_DAYS]) || 0
+  const defaultMarkupPercent = Number(settings[SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]) || 0
+  const markupAppliesToInventory =
+    settings[SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY] === 'true'
   const inventoryParts = inventoryResult.success && inventoryResult.data ? inventoryResult.data : []
   const laborPresets = presetsResult.success && presetsResult.data ? presetsResult.data : []
   const initialVehicle = {
@@ -156,6 +161,7 @@ export default async function ServiceDetailPage({
       unitPrice: p.unitPrice,
       total: p.total,
       unitCost: p.unitCost ?? 0,
+      markupPercent: p.markupPercent ?? 0,
     })),
     laborItems: record.laborItems.map((l) => ({
       description: l.description,
@@ -244,6 +250,8 @@ export default async function ServiceDetailPage({
         telegramEnabled={features?.telegram ?? false}
         aiEnabled={aiEnabled}
         defaultDueDays={defaultDueDays}
+        defaultMarkupPercent={defaultMarkupPercent}
+        markupAppliesToInventory={markupAppliesToInventory}
         statusReports={(statusReportsResult.success && statusReportsResult.data
           ? statusReportsResult.data
           : []

+ 1 - 1
src/features/email/Components/SendEmailDialog.tsx

@@ -51,7 +51,7 @@ export function SendEmailDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="max-w-md">
+      <DialogContent className="max-w-md" aria-describedby={undefined}>
         <DialogHeader>
           <DialogTitle className="flex items-center gap-2">
             <Mail className="h-5 w-5" />

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

@@ -66,6 +66,8 @@ export const SETTING_KEYS = {
   MAINTENANCE_SERVICE_INTERVAL: 'maintenance.serviceInterval',
   MAINTENANCE_APPROACHING_THRESHOLD: 'maintenance.approachingThreshold',
   INVENTORY_MARKUP_MULTIPLIER: 'inventory.markupMultiplier',
+  PARTS_DEFAULT_MARKUP_PERCENT: 'parts.defaultMarkupPercent',
+  PARTS_MARKUP_APPLIES_TO_INVENTORY: 'parts.markupAppliesToInventory',
   SMS_TEMPLATE_INVOICE_READY: 'sms.template.invoiceReady',
   SMS_TEMPLATE_QUOTE_READY: 'sms.template.quoteReady',
   SMS_TEMPLATE_INSPECTION_READY: 'sms.template.inspectionReady',

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

@@ -324,6 +324,7 @@ export async function createServiceRecord(input: unknown) {
             unitPrice: p.unitPrice,
             total: p.total,
             unitCost: resolvedUnitCost,
+            markupPercent: p.markupPercent ?? 0,
             inventoryPartId: p.inventoryPartId || null,
             serviceRecordId: created.id,
           });
@@ -458,6 +459,7 @@ export async function updateServiceRecord(input: unknown) {
               unitPrice: p.unitPrice,
               total: p.total,
               unitCost: p.unitCost ?? 0,
+              markupPercent: p.markupPercent ?? 0,
               inventoryPartId: p.inventoryPartId || null,
               serviceRecordId: id,
             })),

+ 1 - 1
src/features/vehicles/Components/service-detail/ShareDialog.tsx

@@ -136,7 +136,7 @@ export function ShareDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="max-w-md">
+      <DialogContent className="max-w-md" aria-describedby={undefined}>
         <DialogHeader>
           <DialogTitle className="flex items-center gap-2">
             <Link2 className="h-5 w-5" />

+ 17 - 2
src/features/vehicles/Components/service-edit/InventoryPickerDialog.tsx

@@ -17,6 +17,10 @@ interface InventoryPickerDialogProps {
   inventoryParts: InventoryPartOption[]
   currencyCode: string
   onSelectPart: (part: ServicePartInput) => void
+  /** Default markup % to apply when markupAppliesToInventory is true. */
+  defaultMarkupPercent?: number
+  /** When true, picked inventory parts use cost + default markup instead of sellPrice. */
+  markupAppliesToInventory?: boolean
 }
 
 export function InventoryPickerDialog({
@@ -25,6 +29,8 @@ export function InventoryPickerDialog({
   inventoryParts,
   currencyCode,
   onSelectPart,
+  defaultMarkupPercent = 0,
+  markupAppliesToInventory = false,
 }: InventoryPickerDialogProps) {
   const formatCurrency = useFormatCurrency()
   const t = useTranslations('service.parts')
@@ -69,7 +75,7 @@ export function InventoryPickerDialog({
         if (!v) setSearch('')
       }}
     >
-      <DialogContent className="sm:max-w-2xl">
+      <DialogContent className="sm:max-w-2xl" aria-describedby={undefined}>
         <DialogHeader>
           <DialogTitle>{t('inventoryTitle')}</DialogTitle>
         </DialogHeader>
@@ -89,7 +95,15 @@ export function InventoryPickerDialog({
               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={() => {
-                const price = ip.sellPrice > 0 ? ip.sellPrice : ip.unitCost
+                // When markup-applies-to-inventory is enabled, recompute the sell price
+                // from cost + default markup. Otherwise fall back to the inventory's
+                // own sellPrice (which has its own separate markup system).
+                const useGlobalMarkup = markupAppliesToInventory && defaultMarkupPercent > 0
+                const price = useGlobalMarkup
+                  ? Math.round(ip.unitCost * (1 + defaultMarkupPercent / 100) * 100) / 100
+                  : ip.sellPrice > 0
+                    ? ip.sellPrice
+                    : ip.unitCost
                 onSelectPart({
                   partNumber: ip.partNumber || '',
                   name: ip.name,
@@ -97,6 +111,7 @@ export function InventoryPickerDialog({
                   unitPrice: price,
                   total: price,
                   unitCost: ip.unitCost,
+                  markupPercent: useGlobalMarkup ? defaultMarkupPercent : 0,
                   inventoryPartId: ip.id,
                 })
                 onOpenChange(false)

+ 77 - 8
src/features/vehicles/Components/service-edit/PartsEditor.tsx

@@ -4,7 +4,7 @@ import { useRef, useCallback, useState, useEffect } from 'react'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Textarea } from '@/components/ui/textarea'
-import { GripVertical, Package, Plus, ScanBarcode, Trash2 } from 'lucide-react'
+import { GripVertical, Package, Percent, Plus, ScanBarcode, Trash2 } from 'lucide-react'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { useTranslations } from 'next-intl'
 import type { ServicePartInput } from '@/features/vehicles/Schema/serviceSchema'
@@ -36,6 +36,10 @@ interface PartsEditorProps {
   hasInventory: boolean
   onOpenInventory: () => void
   onScanBarcode?: () => void
+  /** Default markup % applied to new manually-added rows. Hidden from customers. */
+  defaultMarkupPercent?: number
+  /** When true, the bulk "Apply markup" button affects inventory-sourced rows too. */
+  markupAppliesToInventory?: boolean
 }
 
 function SortablePartRow({
@@ -76,7 +80,7 @@ function SortablePartRow({
     <div
       ref={dragEnabled ? setNodeRef : undefined}
       style={style}
-      className={`grid grid-cols-[auto_1fr] gap-2 sm:grid-cols-[auto_1fr_2fr_0.7fr_1fr_1fr_auto] ${isDragging && dragEnabled ? 'z-10 opacity-75' : ''}`}
+      className={`grid grid-cols-[auto_1fr] gap-2 sm:grid-cols-[auto_1fr_2fr_0.6fr_0.9fr_0.7fr_0.9fr_0.9fr_auto] ${isDragging && dragEnabled ? 'z-10 opacity-75' : ''}`}
     >
       <button
         type="button"
@@ -101,10 +105,32 @@ function SortablePartRow({
         <Input
           type="number"
           min="0"
-          step="0.01"
+          step="0.1"
           value={part.quantity}
           onChange={(e) => updatePart(index, 'quantity', e.target.value)}
         />
+        <Input
+          type="number"
+          min="0"
+          step="0.01"
+          placeholder={t('unitCost')}
+          title={t('unitCostHint')}
+          value={part.unitCost ?? 0}
+          onChange={(e) => updatePart(index, 'unitCost', e.target.value)}
+        />
+        <div className="relative">
+          <Input
+            type="number"
+            min="0"
+            step="0.1"
+            placeholder={t('markupPercent')}
+            title={t('markupPercentHint')}
+            value={part.markupPercent ?? 0}
+            onChange={(e) => updatePart(index, 'markupPercent', e.target.value)}
+            className="pr-6"
+          />
+          <Percent className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
+        </div>
         <Input
           type="number"
           min="0"
@@ -139,6 +165,8 @@ export function PartsEditor({
   hasInventory,
   onOpenInventory,
   onScanBarcode,
+  defaultMarkupPercent = 0,
+  markupAppliesToInventory = false,
 }: PartsEditorProps) {
   const formatCurrency = useFormatCurrency()
   const t = useTranslations('service.parts')
@@ -173,25 +201,64 @@ export function PartsEditor({
   const addPartAtStart = useCallback(() => {
     const key = `part-${keyCounterRef.current++}`
     keysRef.current = [key, ...keysRef.current]
-    setPartItems((prev) => [emptyPart(), ...prev])
-  }, [setPartItems])
+    setPartItems((prev) => [emptyPart(defaultMarkupPercent), ...prev])
+  }, [setPartItems, defaultMarkupPercent])
 
   const addPartAtEnd = useCallback(() => {
     const key = `part-${keyCounterRef.current++}`
     keysRef.current = [...keysRef.current, key]
-    setPartItems((prev) => [...prev, emptyPart()])
-  }, [setPartItems])
+    setPartItems((prev) => [...prev, emptyPart(defaultMarkupPercent)])
+  }, [setPartItems, defaultMarkupPercent])
 
   const deletePart = useCallback((index: number) => {
     keysRef.current = keysRef.current.filter((_, j) => j !== index)
     setPartItems((prev) => prev.filter((_, j) => j !== index))
   }, [setPartItems])
 
+  /**
+   * Apply the default markup % to every eligible row. Eligible means:
+   *  - non-inventory rows always, OR
+   *  - all rows when `markupAppliesToInventory` is true.
+   * Recomputes unitPrice = unitCost * (1 + markup/100) and total = qty * unitPrice.
+   * Customer never sees cost or markup — only unitPrice/total.
+   */
+  const applyMarkupToAll = useCallback(() => {
+    setPartItems((prev) =>
+      prev.map((p) => {
+        const eligible = markupAppliesToInventory || !p.inventoryPartId
+        if (!eligible) return p
+        const cost = Number(p.unitCost) || 0
+        const markup = defaultMarkupPercent
+        const unitPrice = Math.round(cost * (1 + markup / 100) * 100) / 100
+        return {
+          ...p,
+          markupPercent: markup,
+          unitPrice,
+          total: Number(p.quantity) * unitPrice,
+        }
+      })
+    )
+  }, [setPartItems, defaultMarkupPercent, markupAppliesToInventory])
+
   return (
     <div className="rounded-lg border p-3 space-y-2">
       <div className="flex items-center justify-between">
         <h3 className="text-sm font-semibold">{t('title')}</h3>
         <div className="flex gap-1.5">
+          {defaultMarkupPercent > 0 && partItems.length > 0 && (
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              onClick={applyMarkupToAll}
+              title={t('applyMarkupHint', { percent: defaultMarkupPercent })}
+            >
+              <Percent className="h-3.5 w-3.5 sm:mr-1" />
+              <span className="hidden sm:inline">
+                {t('applyMarkup', { percent: defaultMarkupPercent })}
+              </span>
+            </Button>
+          )}
           {hasInventory && (
             <Button type="button" variant="outline" size="sm" onClick={onOpenInventory}>
               <Package className="h-3.5 w-3.5 sm:mr-1" />
@@ -218,11 +285,13 @@ export function PartsEditor({
 
       {partItems.length > 0 && (
         <>
-          <div className="hidden grid-cols-[auto_1fr_2fr_0.7fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
+          <div className="hidden grid-cols-[auto_1fr_2fr_0.6fr_0.9fr_0.7fr_0.9fr_0.9fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
             <span className="w-6" />
             <span>{t('partNumber')}</span>
             <span>{t('name')}</span>
             <span>{t('qty')}</span>
+            <span title={t('unitCostHint')}>{t('unitCost')}</span>
+            <span title={t('markupPercentHint')}>{t('markupPercent')}</span>
             <span>{t('unitPrice')}</span>
             <span>{t('total')}</span>
             <span />

+ 2 - 1
src/features/vehicles/Components/service-edit/form-types.ts

@@ -72,13 +72,14 @@ export interface ServiceRecordFormProps {
   currentUserName?: string
 }
 
-export const emptyPart = (): ServicePartInput => ({
+export const emptyPart = (defaultMarkupPercent: number = 0): ServicePartInput => ({
   partNumber: '',
   name: '',
   quantity: 1,
   unitPrice: 0,
   total: 0,
   unitCost: 0,
+  markupPercent: defaultMarkupPercent,
 })
 
 export const makeEmptyLabor = (defaultRate: number): ServiceLaborInput => ({

+ 6 - 0
src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx

@@ -16,6 +16,8 @@ interface DetailsLeftColumnProps {
   currencyCode: string
   defaultLaborRate: number
   inventoryParts: InventoryPartOption[]
+  defaultMarkupPercent?: number
+  markupAppliesToInventory?: boolean
   hasPresets?: boolean
   onOpenPresets?: () => void
   onScanBarcode?: () => void
@@ -35,6 +37,8 @@ export function DetailsLeftColumn({
   currencyCode,
   defaultLaborRate,
   inventoryParts,
+  defaultMarkupPercent = 0,
+  markupAppliesToInventory = false,
   hasPresets,
   onOpenPresets,
   onScanBarcode,
@@ -57,6 +61,8 @@ export function DetailsLeftColumn({
         hasInventory={inventoryParts.length > 0}
         onOpenInventory={() => formState.setShowInventoryPicker(true)}
         onScanBarcode={onScanBarcode}
+        defaultMarkupPercent={defaultMarkupPercent}
+        markupAppliesToInventory={markupAppliesToInventory}
       />
       <LaborEditor
         laborItems={formState.laborItems}

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

@@ -77,6 +77,8 @@ export function ServicePageClient({
   telegramEnabled = false,
   aiEnabled = false,
   defaultDueDays = 0,
+  defaultMarkupPercent = 0,
+  markupAppliesToInventory = false,
   statusReports = [],
   initialTab,
   findings = [],
@@ -160,7 +162,13 @@ export function ServicePageClient({
       const result = await lookupPartByBarcode(barcode)
       if (result.success && result.data) {
         const part = result.data
-        const price = part.sellPrice > 0 ? part.sellPrice : part.unitCost
+        // Same rule as the inventory picker — opt-in markup applies to inventory parts too.
+        const useGlobalMarkup = markupAppliesToInventory && defaultMarkupPercent > 0
+        const price = useGlobalMarkup
+          ? Math.round(part.unitCost * (1 + defaultMarkupPercent / 100) * 100) / 100
+          : part.sellPrice > 0
+            ? part.sellPrice
+            : part.unitCost
         formState.dirtySetPartItems((prev) => [
           {
             partNumber: part.partNumber || '',
@@ -169,6 +177,7 @@ export function ServicePageClient({
             unitPrice: price,
             total: price,
             unitCost: part.unitCost,
+            markupPercent: useGlobalMarkup ? defaultMarkupPercent : 0,
             inventoryPartId: part.id,
           },
           ...prev,
@@ -178,7 +187,7 @@ export function ServicePageClient({
         toast.error(t('parts.partNotFound', { barcode }))
       }
     },
-    [formState, t]
+    [formState, t, defaultMarkupPercent, markupAppliesToInventory]
   )
 
   useHardwareScanner({ onScan: handleBarcodeScan, enabled: activeTab === 'details' })
@@ -261,6 +270,7 @@ export function ServicePageClient({
         unitPrice: part.unitPrice,
         total: part.quantity * part.unitPrice,
         unitCost: 0,
+        markupPercent: 0,
         inventoryPartId: part.inventoryPartId || '',
       }))
       formState.dirtySetPartItems((prev) => [...newParts, ...prev])
@@ -336,6 +346,8 @@ export function ServicePageClient({
                   currencyCode={currencyCode}
                   defaultLaborRate={defaultLaborRate}
                   inventoryParts={inventoryParts}
+                  defaultMarkupPercent={defaultMarkupPercent}
+                  markupAppliesToInventory={markupAppliesToInventory}
                   hasPresets={laborPresets.length > 0}
                   onOpenPresets={() => formState.setShowPresetPicker(true)}
                   onScanBarcode={() => formState.setShowBarcodeScanner(true)}
@@ -438,6 +450,8 @@ export function ServicePageClient({
         inventoryParts={inventoryParts}
         currencyCode={currencyCode}
         onSelectPart={(part) => formState.dirtySetPartItems((prev) => [part, ...prev])}
+        defaultMarkupPercent={defaultMarkupPercent}
+        markupAppliesToInventory={markupAppliesToInventory}
       />
 
       <LaborPresetPickerDialog

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

@@ -54,6 +54,8 @@ export interface ServicePageClientProps {
   telegramEnabled?: boolean
   aiEnabled?: boolean
   defaultDueDays?: number
+  defaultMarkupPercent?: number
+  markupAppliesToInventory?: boolean
   statusReports?: { id: string; title: string | null; message: string | null; status: string; videoUrl: string | null; createdAt: string; publicToken: string; expiresAt: string | null; customerFeedback: string | null; feedbackAt: string | null; sentVia: string | null; sentAt: string | null }[]
   initialTab?: string
   findings?: { id: string; description: string; severity: string; status: string; notes: string | null }[]

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

@@ -135,12 +135,41 @@ export function useServiceFormState({
   const vehicleName = `${record.vehicle.year} ${record.vehicle.make} ${record.vehicle.model}`
 
   // Part/labor update helpers
+  //
+  // The three pricing fields (unitCost, markupPercent, unitPrice) are linked by:
+  //   unitPrice = unitCost × (1 + markupPercent / 100)
+  // The user can edit any one and the others auto-sync so the row stays
+  // mathematically consistent — Markup % never lies about real margin.
   const updatePart = useCallback(
     (index: number, field: keyof ServicePartInput, value: string | number) => {
       setPartItems((prev) => {
         const updated = [...prev]
         const part = { ...updated[index], [field]: value }
-        if (field === 'quantity' || field === 'unitPrice') {
+        const cost = Number(part.unitCost) || 0
+
+        if (field === 'unitCost' || field === 'markupPercent') {
+          // Cost or markup changed → recompute the customer-facing price.
+          const markup = Number(part.markupPercent) || 0
+          part.unitPrice = Math.round(cost * (1 + markup / 100) * 100) / 100
+        } else if (field === 'unitPrice') {
+          // Price was edited directly → derive markup back from cost so the
+          // displayed margin matches reality. If cost is 0 there's no
+          // meaningful percentage; leave markup at 0 and treat price as a
+          // free override.
+          const price = Number(part.unitPrice) || 0
+          if (cost > 0) {
+            part.markupPercent = Math.round(((price / cost) - 1) * 1000) / 10
+          } else {
+            part.markupPercent = 0
+          }
+        }
+
+        if (
+          field === 'quantity' ||
+          field === 'unitPrice' ||
+          field === 'unitCost' ||
+          field === 'markupPercent'
+        ) {
           part.total = Number(part.quantity) * Number(part.unitPrice)
         }
         updated[index] = part

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

@@ -7,6 +7,7 @@ export const servicePartSchema = z.object({
   unitPrice: z.coerce.number().min(0).default(0),
   total: z.coerce.number().min(0).default(0),
   unitCost: z.coerce.number().min(0).default(0),
+  markupPercent: z.coerce.number().min(0).default(0),
   inventoryPartId: z.string().optional(),
 });
 

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff