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

Take the inventory markup as a percentage and show what it does (#561)

* Take the inventory markup as a percentage and show what it does

Apply Markup on the inventory list asked for a bare multiplier, next to
the workshop setting Default Markup %, which takes a percentage. Typing
30 for "30%" priced a part that cost 10 at 300. Reported in #560.

- The dialog now takes a percentage, shows a worked example in the
  workshop's currency before anything is applied, and says how it
  differs from Default Markup % under Settings > Workshop.
- It opens on the markup applied last, or on Default Markup % when no
  catalog markup was ever applied. A blank field or -100% and below
  cannot be applied.
- The Add Part form says where a filled-in sell price comes from and
  where to change it.
- A percentage typed in the dialog and then cancelled no longer prices
  the next new part.

The stored setting and the applyMarkupToAll action are unchanged: the
percentage is converted to the multiplier before it is sent.

* Keep Apply Markup closed to a second run and refuse a zero multiplier

- The dialog cannot be dismissed while the catalog is being repriced, so
  it cannot be reopened to start a second update over the first.
- A percentage that rounds to a multiplier of zero, such as -99.999, is
  refused like -100.
- The markup is saved only once the catalog update has succeeded.
Bernt Christian Egeland 1 день назад
Родитель
Сommit
edd9241a5f

+ 10 - 5
messages/de/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Aufschlag auf alle Teile anwenden",
-    "multiplierLabel": "Aufschlagsfaktor",
-    "description": "Der Verkaufspreis wird auf Kosten x {multiplier} für alle {count} Teile gesetzt.",
-    "percentage": "({percent}% Aufschlag)",
+    "intro": "Setzt den Verkaufspreis der Teile auf die Kosten plus einen Prozentsatz.",
+    "percentLabel": "Aufschlag %",
+    "percentHint": "30 eingeben, um 30% auf die Kosten aufzuschlagen.",
+    "percentInvalid": "Der Aufschlag muss größer als -100% sein.",
+    "exampleTitle": "Beispiel",
+    "exampleMarkup": "Aufschlag {percent}%",
     "cancel": "Abbrechen",
     "apply": "Anwenden",
     "overrideExisting": "Bestehende Verkaufspreise überschreiben",
     "overrideExistingHint": "Alle Teile werden aktualisiert, auch solche mit einem bestehenden Verkaufspreis.",
-    "skipExistingHint": "Nur Teile ohne Verkaufspreis werden aktualisiert."
+    "skipExistingHint": "Nur Teile ohne Verkaufspreis werden aktualisiert.",
+    "newPartsNote": "Bei neuen Teilen wird {sellPrice} mit diesem Aufschlag ausgefüllt, sobald {unitCost} eingegeben wird.",
+    "settingsNote": "{setting} unter {settings} > {workshop} ist eine separate Einstellung. Sie gilt, wenn Teile in einen Auftrag kommen."
   },
   "errors": {
-    "multiplierPositive": "Der Multiplikator muss größer als 0 sein",
     "deleteFailed": "Teil konnte nicht gelöscht werden",
     "applyFailed": "Aufschlag konnte nicht angewendet werden",
     "saveFailed": "Teil konnte nicht gespeichert werden",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "Imperial",
     "unitCost": "Stückkosten",
     "sellPrice": "Verkaufspreis",
+    "sellPriceMarkupHint": "{sellPrice} wird mit {unitCost} + {percent}% ausgefüllt. Für einen eigenen Preis einfach einen Betrag eingeben. Der Prozentsatz lässt sich mit {applyMarkup} auf der Seite {inventory} ändern.",
     "supplier": "Lieferant",
     "supplierPlaceholder": "AutoParts GmbH",
     "supplierPhone": "Lieferanten-Telefon",

+ 10 - 5
messages/en/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Apply Markup to All Parts",
-    "multiplierLabel": "Markup Multiplier",
-    "description": "This will set sell price = cost x {multiplier} for all {count} parts.",
-    "percentage": "({percent}% markup)",
+    "intro": "Sets the sell price of your parts to their cost plus a percentage.",
+    "percentLabel": "Markup %",
+    "percentHint": "Enter 30 to add 30% on top of the cost.",
+    "percentInvalid": "The markup must be higher than -100%.",
+    "exampleTitle": "Example",
+    "exampleMarkup": "Markup {percent}%",
     "cancel": "Cancel",
     "apply": "Apply",
     "overrideExisting": "Override existing sell prices",
     "overrideExistingHint": "All parts will be updated, including those with an existing sell price.",
-    "skipExistingHint": "Only parts without a sell price will be updated."
+    "skipExistingHint": "Only parts without a sell price will be updated.",
+    "newPartsNote": "New parts fill in {sellPrice} at this markup when you enter the {unitCost}.",
+    "settingsNote": "{setting} under {settings} > {workshop} is a separate setting, added when parts go on a job."
   },
   "errors": {
-    "multiplierPositive": "Multiplier must be greater than 0",
     "deleteFailed": "Failed to delete part",
     "applyFailed": "Failed to apply markup",
     "saveFailed": "Failed to save part",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "Imperial",
     "unitCost": "Unit Cost",
     "sellPrice": "Sell Price",
+    "sellPriceMarkupHint": "{sellPrice} fills in at {unitCost} + {percent}%. Type a price to set your own. To change the percentage, use {applyMarkup} on the {inventory} page.",
     "supplier": "Supplier",
     "supplierPlaceholder": "AutoParts Inc.",
     "supplierPhone": "Supplier Phone",

+ 10 - 5
messages/es/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Aplicar margen a todas las piezas",
-    "multiplierLabel": "Multiplicador de margen",
-    "description": "Esto establecerá el precio de venta = coste x {multiplier} para las {count} piezas.",
-    "percentage": "({percent}% de margen)",
+    "intro": "Establece el precio de venta de las piezas en su coste más un porcentaje.",
+    "percentLabel": "Margen %",
+    "percentHint": "Escribe 30 para añadir un 30% sobre el coste.",
+    "percentInvalid": "El margen debe ser superior a -100%.",
+    "exampleTitle": "Ejemplo",
+    "exampleMarkup": "Margen {percent}%",
     "cancel": "Cancelar",
     "apply": "Aplicar",
     "overrideExisting": "Sobrescribir precios de venta existentes",
     "overrideExistingHint": "Se actualizarán todas las piezas, incluidas las que ya tienen precio de venta.",
-    "skipExistingHint": "Solo se actualizarán las piezas sin precio de venta."
+    "skipExistingHint": "Solo se actualizarán las piezas sin precio de venta.",
+    "newPartsNote": "En las piezas nuevas, {sellPrice} se rellena con este margen al introducir {unitCost}.",
+    "settingsNote": "{setting} en {settings} > {workshop} es un ajuste distinto, que se aplica cuando las piezas se añaden a un trabajo."
   },
   "errors": {
-    "multiplierPositive": "El multiplicador debe ser mayor que 0",
     "deleteFailed": "No se pudo eliminar la pieza",
     "applyFailed": "No se pudo aplicar el margen",
     "saveFailed": "No se pudo guardar la pieza",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "Imperial",
     "unitCost": "Coste unitario",
     "sellPrice": "Precio de venta",
+    "sellPriceMarkupHint": "{sellPrice} se rellena con {unitCost} + {percent}%. Escribe un precio para fijar el tuyo. Para cambiar el porcentaje, usa {applyMarkup} en la página {inventory}.",
     "supplier": "Proveedor",
     "supplierPlaceholder": "AutoParts S.L.",
     "supplierPhone": "Teléfono del proveedor",

+ 10 - 5
messages/fr/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Appliquer la marge à toutes les pièces",
-    "multiplierLabel": "Multiplicateur de marge",
-    "description": "Le prix de vente sera défini à coût x {multiplier} pour les {count} pièces.",
-    "percentage": "({percent}% de marge)",
+    "intro": "Définit le prix de vente des pièces à leur coût plus un pourcentage.",
+    "percentLabel": "Marge %",
+    "percentHint": "Saisissez 30 pour ajouter 30% au coût.",
+    "percentInvalid": "La marge doit être supérieure à -100%.",
+    "exampleTitle": "Exemple",
+    "exampleMarkup": "Marge {percent}%",
     "cancel": "Annuler",
     "apply": "Appliquer",
     "overrideExisting": "Remplacer les prix de vente existants",
     "overrideExistingHint": "Toutes les pièces seront mises à jour, y compris celles ayant déjà un prix de vente.",
-    "skipExistingHint": "Seules les pièces sans prix de vente seront mises à jour."
+    "skipExistingHint": "Seules les pièces sans prix de vente seront mises à jour.",
+    "newPartsNote": "Pour les nouvelles pièces, {sellPrice} est rempli avec cette marge dès la saisie de {unitCost}.",
+    "settingsNote": "{setting} dans {settings} > {workshop} est un réglage distinct, appliqué quand les pièces sont ajoutées à une intervention."
   },
   "errors": {
-    "multiplierPositive": "Le multiplicateur doit être supérieur à 0",
     "deleteFailed": "Impossible de supprimer la pièce",
     "applyFailed": "Impossible d'appliquer la marge",
     "saveFailed": "Impossible d'enregistrer la pièce",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "Impérial",
     "unitCost": "Coût unitaire",
     "sellPrice": "Prix de vente",
+    "sellPriceMarkupHint": "{sellPrice} est rempli avec {unitCost} + {percent}%. Saisissez un prix pour fixer le vôtre. Pour changer le pourcentage, utilisez {applyMarkup} sur la page {inventory}.",
     "supplier": "Fournisseur",
     "supplierPlaceholder": "AutoParts SARL",
     "supplierPhone": "Téléphone fournisseur",

+ 10 - 5
messages/it/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Applica ricarico a tutti i ricambi",
-    "multiplierLabel": "Moltiplicatore di ricarico",
-    "description": "Il prezzo di vendita sarà impostato a costo x {multiplier} per tutti i {count} ricambi.",
-    "percentage": "({percent}% di ricarico)",
+    "intro": "Imposta il prezzo di vendita dei ricambi al costo più una percentuale.",
+    "percentLabel": "Ricarico %",
+    "percentHint": "Inserisci 30 per aggiungere il 30% al costo.",
+    "percentInvalid": "Il ricarico deve essere superiore a -100%.",
+    "exampleTitle": "Esempio",
+    "exampleMarkup": "Ricarico {percent}%",
     "cancel": "Annulla",
     "apply": "Applica",
     "overrideExisting": "Sovrascrivi prezzi di vendita esistenti",
     "overrideExistingHint": "Tutti i ricambi verranno aggiornati, inclusi quelli con un prezzo di vendita esistente.",
-    "skipExistingHint": "Verranno aggiornati solo i ricambi senza prezzo di vendita."
+    "skipExistingHint": "Verranno aggiornati solo i ricambi senza prezzo di vendita.",
+    "newPartsNote": "Per i nuovi ricambi, {sellPrice} viene compilato con questo ricarico quando inserisci {unitCost}.",
+    "settingsNote": "{setting} in {settings} > {workshop} è un'impostazione separata, applicata quando i ricambi vengono aggiunti a un lavoro."
   },
   "errors": {
-    "multiplierPositive": "Il moltiplicatore deve essere maggiore di 0",
     "deleteFailed": "Impossibile eliminare il ricambio",
     "applyFailed": "Impossibile applicare il ricarico",
     "saveFailed": "Impossibile salvare il ricambio",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "Imperiale",
     "unitCost": "Costo unitario",
     "sellPrice": "Prezzo di vendita",
+    "sellPriceMarkupHint": "{sellPrice} viene compilato con {unitCost} + {percent}%. Inserisci un prezzo per impostare il tuo. Per cambiare la percentuale, usa {applyMarkup} nella pagina {inventory}.",
     "supplier": "Fornitore",
     "supplierPlaceholder": "AutoParts S.r.l.",
     "supplierPhone": "Telefono fornitore",

+ 10 - 5
messages/lt/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Taikyti antkainį visoms dalims",
-    "multiplierLabel": "Antkainio koeficientas",
-    "description": "Bus nustatyta pardavimo kaina = savikaina × {multiplier} visoms {count} dalims.",
-    "percentage": "({percent}% antkainis)",
+    "intro": "Nustato dalių pardavimo kainą kaip savikainą plius procentą.",
+    "percentLabel": "Antkainis %",
+    "percentHint": "Įveskite 30, kad prie savikainos pridėtumėte 30%.",
+    "percentInvalid": "Antkainis turi būti didesnis nei -100%.",
+    "exampleTitle": "Pavyzdys",
+    "exampleMarkup": "Antkainis {percent}%",
     "cancel": "Atšaukti",
     "apply": "Taikyti",
     "overrideExisting": "Perrašyti esamas pardavimo kainas",
     "overrideExistingHint": "Visos dalys bus atnaujintos, įskaitant tas, kurios jau turi pardavimo kainą.",
-    "skipExistingHint": "Bus atnaujintos tik dalys be pardavimo kainos."
+    "skipExistingHint": "Bus atnaujintos tik dalys be pardavimo kainos.",
+    "newPartsNote": "Naujoms dalims laukas „{sellPrice}“ užpildomas su šiuo antkainiu, kai įvedate lauką „{unitCost}“.",
+    "settingsNote": "„{setting}“ skiltyje {settings} > {workshop} yra atskiras nustatymas, taikomas, kai dalys pridedamos prie darbo."
   },
   "errors": {
-    "multiplierPositive": "Koeficientas turi būti didesnis nei 0",
     "deleteFailed": "Nepavyko ištrinti dalies",
     "applyFailed": "Nepavyko taikyti antkainio",
     "saveFailed": "Nepavyko išsaugoti dalies",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "Imperiniai",
     "unitCost": "Vnt. savikaina",
     "sellPrice": "Pardavimo kaina",
+    "sellPriceMarkupHint": "Laukas „{sellPrice}“ užpildomas kaip „{unitCost}“ + {percent}%. Įveskite kainą, jei norite nustatyti savo. Procentą pakeisite mygtuku „{applyMarkup}“ puslapyje „{inventory}“.",
     "supplier": "Tiekėjas",
     "supplierPlaceholder": "AutoDalys UAB",
     "supplierPhone": "Tiekėjo telefonas",

+ 10 - 5
messages/nb/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Bruk påslag på alle deler",
-    "multiplierLabel": "Påslagsmultiplikator",
-    "description": "Dette setter salgspris = kostnad x {multiplier} for alle {count} deler.",
-    "percentage": "({percent}% påslag)",
+    "intro": "Setter salgsprisen på delene til kostnad pluss en prosent.",
+    "percentLabel": "Påslag %",
+    "percentHint": "Skriv 30 for å legge 30% på kostnaden.",
+    "percentInvalid": "Påslaget må være høyere enn -100%.",
+    "exampleTitle": "Eksempel",
+    "exampleMarkup": "Påslag {percent}%",
     "cancel": "Avbryt",
     "apply": "Bruk",
     "overrideExisting": "Overskriv eksisterende salgspriser",
     "overrideExistingHint": "Alle deler vil bli oppdatert, inkludert de med eksisterende salgspris.",
-    "skipExistingHint": "Kun deler uten salgspris vil bli oppdatert."
+    "skipExistingHint": "Kun deler uten salgspris vil bli oppdatert.",
+    "newPartsNote": "Nye deler får {sellPrice} fylt ut med dette påslaget når du skriver inn {unitCost}.",
+    "settingsNote": "{setting} under {settings} > {workshop} er en egen innstilling, som brukes når deler legges på en arbeidsordre."
   },
   "errors": {
-    "multiplierPositive": "Multiplikatoren må være større enn 0",
     "deleteFailed": "Kunne ikke slette delen",
     "applyFailed": "Kunne ikke bruke påslaget",
     "saveFailed": "Kunne ikke lagre delen",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "Imperisk",
     "unitCost": "Enhetskostnad",
     "sellPrice": "Salgspris",
+    "sellPriceMarkupHint": "{sellPrice} fylles ut med {unitCost} + {percent}%. Skriv inn en pris for å sette din egen. Endre prosenten med {applyMarkup} på siden {inventory}.",
     "supplier": "Leverandør",
     "supplierPlaceholder": "AutoParts AS",
     "supplierPhone": "Leverandørtelefon",

+ 10 - 5
messages/nl/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Opslag toepassen op alle onderdelen",
-    "multiplierLabel": "Opslagfactor",
-    "description": "De verkoopprijs wordt ingesteld op kostprijs x {multiplier} voor alle {count} onderdelen.",
-    "percentage": "({percent}% opslag)",
+    "intro": "Stelt de verkoopprijs van onderdelen in op de kostprijs plus een percentage.",
+    "percentLabel": "Opslag %",
+    "percentHint": "Vul 30 in om 30% bovenop de kostprijs te rekenen.",
+    "percentInvalid": "De opslag moet hoger zijn dan -100%.",
+    "exampleTitle": "Voorbeeld",
+    "exampleMarkup": "Opslag {percent}%",
     "cancel": "Annuleren",
     "apply": "Toepassen",
     "overrideExisting": "Bestaande verkoopprijzen overschrijven",
     "overrideExistingHint": "Alle onderdelen worden bijgewerkt, inclusief die met een bestaande verkoopprijs.",
-    "skipExistingHint": "Alleen onderdelen zonder verkoopprijs worden bijgewerkt."
+    "skipExistingHint": "Alleen onderdelen zonder verkoopprijs worden bijgewerkt.",
+    "newPartsNote": "Bij nieuwe onderdelen wordt {sellPrice} met deze opslag ingevuld zodra {unitCost} wordt ingevuld.",
+    "settingsNote": "{setting} onder {settings} > {workshop} is een aparte instelling, die geldt wanneer onderdelen op een werkorder komen."
   },
   "errors": {
-    "multiplierPositive": "De vermenigvuldiger moet groter zijn dan 0",
     "deleteFailed": "Kan onderdeel niet verwijderen",
     "applyFailed": "Kan opslag niet toepassen",
     "saveFailed": "Kan onderdeel niet opslaan",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "Imperiaal",
     "unitCost": "Kostprijs",
     "sellPrice": "Verkoopprijs",
+    "sellPriceMarkupHint": "{sellPrice} wordt ingevuld als {unitCost} + {percent}%. Vul een prijs in om een eigen prijs te gebruiken. Wijzig het percentage met {applyMarkup} op de pagina {inventory}.",
     "supplier": "Leverancier",
     "supplierPlaceholder": "AutoParts B.V.",
     "supplierPhone": "Telefoon leverancier",

+ 10 - 5
messages/pl/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Zastosuj marżę do wszystkich części",
-    "multiplierLabel": "Mnożnik marży",
-    "description": "Cena sprzedaży zostanie ustawiona na koszt x {multiplier} dla wszystkich {count} części.",
-    "percentage": "({percent}% marży)",
+    "intro": "Ustawia cenę sprzedaży części jako koszt plus procent.",
+    "percentLabel": "Marża %",
+    "percentHint": "Wpisz 30, aby doliczyć 30% do kosztu.",
+    "percentInvalid": "Marża musi być większa niż -100%.",
+    "exampleTitle": "Przykład",
+    "exampleMarkup": "Marża {percent}%",
     "cancel": "Anuluj",
     "apply": "Zastosuj",
     "overrideExisting": "Nadpisz istniejące ceny sprzedaży",
     "overrideExistingHint": "Wszystkie części zostaną zaktualizowane, w tym te z istniejącą ceną sprzedaży.",
-    "skipExistingHint": "Zaktualizowane zostaną tylko części bez ceny sprzedaży."
+    "skipExistingHint": "Zaktualizowane zostaną tylko części bez ceny sprzedaży.",
+    "newPartsNote": "W nowych częściach pole „{sellPrice}” wypełnia się z tą marżą po wpisaniu pola „{unitCost}”.",
+    "settingsNote": "„{setting}” w {settings} > {workshop} to osobne ustawienie, stosowane, gdy części trafiają do zlecenia."
   },
   "errors": {
-    "multiplierPositive": "Mnożnik musi być większy niż 0",
     "deleteFailed": "Nie udało się usunąć części",
     "applyFailed": "Nie udało się zastosować marży",
     "saveFailed": "Nie udało się zapisać części",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "Imperialne",
     "unitCost": "Koszt jedn.",
     "sellPrice": "Cena sprzedaży",
+    "sellPriceMarkupHint": "Pole „{sellPrice}” wypełnia się jako „{unitCost}” + {percent}%. Wpisz cenę, aby ustawić własną. Procent zmienisz przyciskiem „{applyMarkup}” na stronie „{inventory}”.",
     "supplier": "Dostawca",
     "supplierPlaceholder": "AutoParts Sp. z o.o.",
     "supplierPhone": "Telefon dostawcy",

+ 10 - 5
messages/pt-BR/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Aplicar margem a todas as peças",
-    "multiplierLabel": "Multiplicador de margem",
-    "description": "Isso definirá o preço de venda = custo x {multiplier} para todas as {count} peças.",
-    "percentage": "({percent}% de margem)",
+    "intro": "Define o preço de venda das peças como o custo mais uma porcentagem.",
+    "percentLabel": "Margem %",
+    "percentHint": "Digite 30 para adicionar 30% sobre o custo.",
+    "percentInvalid": "A margem deve ser maior que -100%.",
+    "exampleTitle": "Exemplo",
+    "exampleMarkup": "Margem {percent}%",
     "cancel": "Cancelar",
     "apply": "Aplicar",
     "overrideExisting": "Substituir preços de venda existentes",
     "overrideExistingHint": "Todas as peças serão atualizadas, incluindo aquelas com preço de venda existente.",
-    "skipExistingHint": "Apenas peças sem preço de venda serão atualizadas."
+    "skipExistingHint": "Apenas peças sem preço de venda serão atualizadas.",
+    "newPartsNote": "Em peças novas, {sellPrice} é preenchido com esta margem quando você informa {unitCost}.",
+    "settingsNote": "{setting} em {settings} > {workshop} é uma configuração separada, aplicada quando as peças entram em uma ordem de serviço."
   },
   "errors": {
-    "multiplierPositive": "O multiplicador deve ser maior que 0",
     "deleteFailed": "Não foi possível excluir a peça",
     "applyFailed": "Não foi possível aplicar a margem",
     "saveFailed": "Não foi possível salvar a peça",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "Imperial",
     "unitCost": "Custo unitário",
     "sellPrice": "Preço de venda",
+    "sellPriceMarkupHint": "{sellPrice} é preenchido com {unitCost} + {percent}%. Digite um preço para definir o seu. Para mudar a porcentagem, use {applyMarkup} na página {inventory}.",
     "supplier": "Fornecedor",
     "supplierPlaceholder": "AutoParts Ltda.",
     "supplierPhone": "Telefone do fornecedor",

+ 10 - 5
messages/pt-PT/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Aplicar margem a todas as peças",
-    "multiplierLabel": "Multiplicador de margem",
-    "description": "O preço de venda passará a ser custo x {multiplier} para todas as {count} peças.",
-    "percentage": "({percent}% de margem)",
+    "intro": "Define o preço de venda das peças como o custo mais uma percentagem.",
+    "percentLabel": "Margem %",
+    "percentHint": "Introduza 30 para acrescentar 30% ao custo.",
+    "percentInvalid": "A margem tem de ser superior a -100%.",
+    "exampleTitle": "Exemplo",
+    "exampleMarkup": "Margem {percent}%",
     "cancel": "Cancelar",
     "apply": "Aplicar",
     "overrideExisting": "Substituir os preços de venda existentes",
     "overrideExistingHint": "Todas as peças serão atualizadas, incluindo as que já têm preço de venda.",
-    "skipExistingHint": "Só serão atualizadas as peças sem preço de venda."
+    "skipExistingHint": "Só serão atualizadas as peças sem preço de venda.",
+    "newPartsNote": "Nas peças novas, {sellPrice} é preenchido com esta margem quando introduz {unitCost}.",
+    "settingsNote": "{setting} em {settings} > {workshop} é uma definição separada, aplicada quando as peças são adicionadas a uma ordem de reparação."
   },
   "errors": {
-    "multiplierPositive": "O multiplicador tem de ser superior a 0",
     "deleteFailed": "Não foi possível eliminar a peça",
     "applyFailed": "Não foi possível aplicar a margem",
     "saveFailed": "Não foi possível guardar a peça",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "Imperial",
     "unitCost": "Custo unitário",
     "sellPrice": "Preço de venda",
+    "sellPriceMarkupHint": "{sellPrice} é preenchido com {unitCost} + {percent}%. Introduza um preço para definir o seu. Para alterar a percentagem, use {applyMarkup} na página {inventory}.",
     "supplier": "Fornecedor",
     "supplierPlaceholder": "AutoPeças, Lda.",
     "supplierPhone": "Telefone do fornecedor",

+ 10 - 5
messages/ru/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Применить наценку ко всем запчастям",
-    "multiplierLabel": "Множитель наценки",
-    "description": "Цена продажи будет установлена как себестоимость x {multiplier} для всех {count} запчастей.",
-    "percentage": "(наценка {percent}%)",
+    "intro": "Устанавливает цену продажи запчастей как себестоимость плюс процент.",
+    "percentLabel": "Наценка %",
+    "percentHint": "Введите 30, чтобы добавить 30% к себестоимости.",
+    "percentInvalid": "Наценка должна быть больше -100%.",
+    "exampleTitle": "Пример",
+    "exampleMarkup": "Наценка {percent}%",
     "cancel": "Отмена",
     "apply": "Применить",
     "overrideExisting": "Перезаписать существующие цены продажи",
     "overrideExistingHint": "Все запчасти будут обновлены, включая те, у которых уже есть цена продажи.",
-    "skipExistingHint": "Будут обновлены только запчасти без цены продажи."
+    "skipExistingHint": "Будут обновлены только запчасти без цены продажи.",
+    "newPartsNote": "Для новых запчастей поле «{sellPrice}» заполняется с этой наценкой, когда вы вводите «{unitCost}».",
+    "settingsNote": "«{setting}» в разделе {settings} > {workshop} является отдельной настройкой и применяется, когда запчасти добавляются в заказ-наряд."
   },
   "errors": {
-    "multiplierPositive": "Множитель должен быть больше 0",
     "deleteFailed": "Не удалось удалить запчасть",
     "applyFailed": "Не удалось применить наценку",
     "saveFailed": "Не удалось сохранить запчасть",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "Имперские",
     "unitCost": "Себестоимость",
     "sellPrice": "Цена продажи",
+    "sellPriceMarkupHint": "Поле «{sellPrice}» заполняется как «{unitCost}» + {percent}%. Введите цену, чтобы задать свою. Изменить процент можно кнопкой «{applyMarkup}» на странице «{inventory}».",
     "supplier": "Поставщик",
     "supplierPlaceholder": "ЗапчастиПро",
     "supplierPhone": "Телефон поставщика",

+ 10 - 5
messages/tr/inventory.json

@@ -44,17 +44,21 @@
   },
   "markup": {
     "title": "Tüm parçalara kâr marjı uygula",
-    "multiplierLabel": "Kâr marjı çarpanı",
-    "description": "Satış fiyatı tüm {count} parça için maliyet x {multiplier} olarak ayarlanacaktır.",
-    "percentage": "(%{percent} kâr marjı)",
+    "intro": "Parçaların satış fiyatını maliyet artı bir yüzde olarak ayarlar.",
+    "percentLabel": "Kâr marjı %",
+    "percentHint": "Maliyetin üzerine %30 eklemek için 30 girin.",
+    "percentInvalid": "Kâr marjı -%100'den yüksek olmalıdır.",
+    "exampleTitle": "Örnek",
+    "exampleMarkup": "Kâr marjı %{percent}",
     "cancel": "İptal",
     "apply": "Uygula",
     "overrideExisting": "Mevcut satış fiyatlarını geçersiz kıl",
     "overrideExistingHint": "Mevcut satış fiyatı olanlar dahil tüm parçalar güncellenecek.",
-    "skipExistingHint": "Yalnızca satış fiyatı olmayan parçalar güncellenecek."
+    "skipExistingHint": "Yalnızca satış fiyatı olmayan parçalar güncellenecek.",
+    "newPartsNote": "Yeni parçalarda {unitCost} girildiğinde {sellPrice} bu kâr marjıyla doldurulur.",
+    "settingsNote": "{settings} > {workshop} altındaki {setting} ayrı bir ayardır ve parçalar bir işe eklendiğinde uygulanır."
   },
   "errors": {
-    "multiplierPositive": "Çarpan 0'dan büyük olmalıdır",
     "deleteFailed": "Parça silinemedi",
     "applyFailed": "Kâr marjı uygulanamadı",
     "saveFailed": "Parça kaydedilemedi",
@@ -119,6 +123,7 @@
     "unitGroupImperial": "İngiliz",
     "unitCost": "Birim maliyet",
     "sellPrice": "Satış fiyatı",
+    "sellPriceMarkupHint": "{sellPrice}, {unitCost} + %{percent} olarak doldurulur. Kendi fiyatınızı belirlemek için bir fiyat yazın. Yüzdeyi değiştirmek için {inventory} sayfasındaki {applyMarkup} düğmesini kullanın.",
     "supplier": "Tedarikçi",
     "supplierPlaceholder": "AutoParts Ltd.",
     "supplierPhone": "Tedarikçi telefonu",

+ 211 - 0
src/__tests__/features/inventory/apply-markup-dialog.test.tsx

@@ -0,0 +1,211 @@
+/**
+ * Tests for the Apply Markup dialog on the inventory list.
+ *
+ * The dialog used to take a bare multiplier, next to a workshop setting called
+ * Default Markup % that takes a percentage. A workshop typed 30 for "30%" and
+ * had a part that cost 10 priced at 300. These tests hold the dialog to what a
+ * person reading it would expect: a percentage in, cost plus that percentage
+ * out, and the result on screen before anything is applied.
+ */
+
+import { useState } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+const applyMarkupToAll = vi.fn()
+const setSetting = vi.fn()
+const openModal = vi.fn()
+
+vi.mock('@/features/inventory/Actions/inventoryActions', () => ({
+  applyMarkupToAll: (...args: unknown[]) => applyMarkupToAll(...args),
+}))
+vi.mock('@/features/settings/Actions/settingsActions', () => ({
+  setSetting: (...args: unknown[]) => setSetting(...args),
+}))
+vi.mock('@/components/glass-modal', () => ({
+  useGlassModal: () => ({ open: openModal }),
+}))
+
+import { ApplyMarkupDialog } from '@/features/inventory/Components/ApplyMarkupDialog'
+
+const noop = vi.fn()
+
+function Harness({
+  initialPercent = 0,
+  onApplied = noop,
+}: {
+  initialPercent?: number
+  onApplied?: () => void
+}) {
+  const [open, setOpen] = useState(true)
+  return (
+    <>
+      <button type="button" onClick={() => setOpen(true)}>
+        open dialog
+      </button>
+      <ApplyMarkupDialog
+        open={open}
+        onOpenChange={setOpen}
+        initialPercent={initialPercent}
+        onApplied={onApplied}
+      />
+    </>
+  )
+}
+
+const percentField = () => screen.getByLabelText('Markup %') as HTMLInputElement
+
+async function typePercent(user: ReturnType<typeof userEvent.setup>, value: string) {
+  await user.clear(percentField())
+  if (value) await user.type(percentField(), value)
+}
+
+beforeEach(() => {
+  applyMarkupToAll.mockReset().mockResolvedValue({ success: true, data: { updated: 3 } })
+  setSetting.mockReset().mockResolvedValue({ success: true })
+  openModal.mockReset()
+})
+
+describe('ApplyMarkupDialog', () => {
+  it('asks for a percentage and opens with the markup the workshop already uses', () => {
+    render(<Harness initialPercent={30} />)
+
+    expect(percentField().value).toBe('30')
+    expect(screen.getByText('Enter 30 to add 30% on top of the cost.')).toBeInTheDocument()
+  })
+
+  it('shows what a part will sell for before anything is applied', async () => {
+    const user = userEvent.setup()
+    render(<Harness />)
+
+    await typePercent(user, '30')
+    expect(screen.getByText('Markup 30%')).toBeInTheDocument()
+    expect(screen.getByText(/100\.00/)).toBeInTheDocument()
+    expect(screen.getByText(/130\.00/)).toBeInTheDocument()
+
+    // The mistake is visible the moment it is typed, not after the catalog is repriced.
+    await typePercent(user, '300')
+    expect(screen.getByText(/400\.00/)).toBeInTheDocument()
+    expect(applyMarkupToAll).not.toHaveBeenCalled()
+  })
+
+  it('applies 30 as cost plus 30 percent, not thirty times the cost', async () => {
+    const user = userEvent.setup()
+    const onApplied = vi.fn()
+    render(<Harness onApplied={onApplied} />)
+
+    await typePercent(user, '30')
+    await user.click(screen.getByRole('button', { name: 'Apply' }))
+
+    expect(applyMarkupToAll).toHaveBeenCalledWith({ multiplier: 1.3, overrideExisting: false })
+    expect(setSetting).toHaveBeenCalledWith('inventory.markupMultiplier', '1.3')
+    // The list prices the next new part with this at once, without waiting
+    // for the page to be read again.
+    expect(onApplied).toHaveBeenCalledWith(1.3)
+    expect(screen.queryByLabelText('Markup %')).not.toBeInTheDocument()
+  })
+
+  it('leaves priced parts alone unless told to override them', async () => {
+    const user = userEvent.setup()
+    render(<Harness initialPercent={25} />)
+
+    expect(screen.getByText('Only parts without a sell price will be updated.')).toBeInTheDocument()
+
+    await user.click(screen.getByRole('switch'))
+    expect(
+      screen.getByText('All parts will be updated, including those with an existing sell price.')
+    ).toBeInTheDocument()
+
+    await user.click(screen.getByRole('button', { name: 'Apply' }))
+    expect(applyMarkupToAll).toHaveBeenCalledWith({ multiplier: 1.25, overrideExisting: true })
+  })
+
+  it('cannot be applied with the field empty', async () => {
+    const user = userEvent.setup()
+    render(<Harness initialPercent={30} />)
+
+    await typePercent(user, '')
+
+    expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled()
+    expect(screen.queryByText('Example')).not.toBeInTheDocument()
+  })
+
+  it('refuses a markup that would give parts away, and says why', async () => {
+    const user = userEvent.setup()
+    render(<Harness />)
+
+    await typePercent(user, '-100')
+
+    expect(screen.getByText('The markup must be higher than -100%.')).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled()
+  })
+
+  it('forgets a percentage that was typed and then cancelled', async () => {
+    const user = userEvent.setup()
+    render(<Harness initialPercent={30} />)
+
+    await typePercent(user, '300')
+    await user.click(screen.getByRole('button', { name: 'Cancel' }))
+    expect(applyMarkupToAll).not.toHaveBeenCalled()
+    expect(setSetting).not.toHaveBeenCalled()
+
+    await user.click(screen.getByRole('button', { name: 'open dialog' }))
+    expect(percentField().value).toBe('30')
+  })
+
+  it('says how this markup differs from the one in the workshop settings', () => {
+    render(<Harness initialPercent={30} />)
+
+    expect(
+      screen.getByText(
+        'Default Markup % under Settings > Workshop is a separate setting, added when parts go on a job.'
+      )
+    ).toBeInTheDocument()
+    expect(
+      screen.getByText('New parts fill in Sell Price at this markup when you enter the Unit Cost.')
+    ).toBeInTheDocument()
+  })
+
+  it('keeps the dialog open and reports it when the catalog could not be repriced', async () => {
+    applyMarkupToAll.mockResolvedValue({ success: false, error: 'No permission' })
+    const user = userEvent.setup()
+    const onApplied = vi.fn()
+    render(<Harness initialPercent={30} onApplied={onApplied} />)
+
+    await user.click(screen.getByRole('button', { name: 'Apply' }))
+
+    expect(openModal).toHaveBeenCalledWith('error', 'Error', 'No permission')
+    expect(onApplied).not.toHaveBeenCalled()
+    expect(percentField()).toBeInTheDocument()
+    // A markup the catalog was never repriced with is not remembered either.
+    expect(setSetting).not.toHaveBeenCalled()
+  })
+
+  it('cannot be dismissed or applied a second time while the catalog is being repriced', async () => {
+    let finish: (result: unknown) => void = () => undefined
+    applyMarkupToAll.mockReturnValue(
+      new Promise((resolve) => {
+        finish = resolve
+      })
+    )
+    const user = userEvent.setup()
+    render(<Harness initialPercent={30} />)
+
+    await user.click(screen.getByRole('button', { name: 'Apply' }))
+
+    expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled()
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled()
+    expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
+
+    // Closing and reopening would hand out a fresh Apply button, and a second
+    // catalog-wide update running over the first.
+    await user.keyboard('{Escape}')
+    expect(percentField()).toBeInTheDocument()
+
+    finish({ success: true, data: { updated: 3 } })
+    await waitFor(() => expect(screen.queryByLabelText('Markup %')).not.toBeInTheDocument())
+    expect(applyMarkupToAll).toHaveBeenCalledTimes(1)
+    expect(setSetting).toHaveBeenCalledWith('inventory.markupMultiplier', '1.3')
+  })
+})

+ 154 - 0
src/__tests__/features/inventory/inventory-list-markup.test.tsx

@@ -0,0 +1,154 @@
+/**
+ * Tests for how the inventory list ties Apply Markup to the Add Part form.
+ *
+ * Two things went wrong here that no test of either piece alone would catch.
+ * The form used to price a new part with whatever was sitting in the markup
+ * dialog, so a number typed there and then cancelled still set the price. And
+ * a markup that had just been applied only reached the form once the page had
+ * been read again, so a part added straight away filled in at the old one.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+const applyMarkupToAll = vi.fn()
+const setSetting = vi.fn()
+const refresh = vi.fn()
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ refresh, push: vi.fn(), replace: vi.fn() }),
+  usePathname: () => '/inventory',
+  useSearchParams: () => new URLSearchParams(),
+}))
+vi.mock('@/components/glass-modal', () => ({
+  useGlassModal: () => ({ open: vi.fn() }),
+}))
+vi.mock('@/components/confirm-dialog', () => ({
+  useConfirm: () => vi.fn(),
+}))
+vi.mock('@/hooks/use-native-camera', () => ({
+  useNativeCamera: () => false,
+}))
+vi.mock('@/hooks/use-hardware-scanner', () => ({
+  useHardwareScanner: vi.fn(),
+}))
+vi.mock('@/hooks/use-remembered-sort', () => ({
+  useRememberedSort: vi.fn(),
+}))
+vi.mock('@/components/barcode-scanner-dialog', () => ({
+  BarcodeScannerDialog: () => null,
+}))
+vi.mock('@/features/inventory/Components/BarcodeScanActionDialog', () => ({
+  BarcodeScanActionDialog: () => null,
+}))
+vi.mock('@/features/inventory/Actions/lookupPartByBarcode', () => ({
+  lookupPartByBarcode: vi.fn(),
+}))
+vi.mock('@/features/inventory/Actions/aiAnalyzePartImage', () => ({
+  aiAnalyzePartImage: vi.fn(),
+}))
+vi.mock('@/features/inventory/Actions/inventoryActions', () => ({
+  applyMarkupToAll: (...args: unknown[]) => applyMarkupToAll(...args),
+  createInventoryPart: vi.fn(),
+  updateInventoryPart: vi.fn(),
+  deleteInventoryPart: vi.fn(),
+  deleteInventoryParts: vi.fn(),
+  deleteOrphanedUploads: vi.fn(),
+}))
+vi.mock('@/features/settings/Actions/settingsActions', () => ({
+  setSetting: (...args: unknown[]) => setSetting(...args),
+}))
+
+import { InventoryClient } from '@/app/(authenticated)/inventory/inventory-client'
+
+const emptyList = { parts: [], total: 0, page: 1, pageSize: 25, totalPages: 0 }
+
+function renderList(props: Partial<React.ComponentProps<typeof InventoryClient>> = {}) {
+  render(<InventoryClient data={emptyList} search="" category="" categories={[]} {...props} />)
+}
+
+/** Opens Apply Markup, types a percentage and presses the named button. */
+async function markup(
+  user: ReturnType<typeof userEvent.setup>,
+  percent: string,
+  button: 'Apply' | 'Cancel'
+) {
+  await user.click(screen.getByRole('button', { name: 'Apply Markup' }))
+  const dialog = screen.getByRole('dialog')
+  const field = within(dialog).getByLabelText('Markup %')
+  const openedWith = (field as HTMLInputElement).value
+  await user.clear(field)
+  await user.type(field, percent)
+  await user.click(within(dialog).getByRole('button', { name: button }))
+  expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+  return openedWith
+}
+
+/** Opens Add Part, types a cost and reads the sell price that fills in. */
+async function sellPriceForCost(user: ReturnType<typeof userEvent.setup>, cost: string) {
+  await user.click(screen.getAllByRole('button', { name: /add part/i })[0])
+  const dialog = screen.getByRole('dialog')
+  fireEvent.change(within(dialog).getByLabelText('Unit Cost'), { target: { value: cost } })
+  const sell = (within(dialog).getByLabelText('Sell Price') as HTMLInputElement).value
+  await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+  return sell
+}
+
+beforeEach(() => {
+  applyMarkupToAll.mockReset().mockResolvedValue({ success: true, data: { updated: 0 } })
+  setSetting.mockReset().mockResolvedValue({ success: true })
+  refresh.mockReset()
+})
+
+describe('markup on the inventory list', () => {
+  it('prices a new part at cost plus the saved markup', async () => {
+    const user = userEvent.setup()
+    renderList({ markupMultiplier: 1.3, applyMarkupPercent: 30 })
+
+    expect(await sellPriceForCost(user, '10')).toBe('13')
+  })
+
+  it('prices the very next part with a markup that was just applied', async () => {
+    const user = userEvent.setup()
+    // Nothing saved yet. The page is not read again in this test, exactly as
+    // it has not been yet in the moment after Apply is pressed.
+    renderList({ markupMultiplier: 1, applyMarkupPercent: 0 })
+
+    await markup(user, '30', 'Apply')
+
+    expect(applyMarkupToAll).toHaveBeenCalledWith({ multiplier: 1.3, overrideExisting: false })
+    expect(refresh).toHaveBeenCalled()
+    expect(await sellPriceForCost(user, '10')).toBe('13')
+  })
+
+  it('opens the dialog again on the markup that was just applied', async () => {
+    const user = userEvent.setup()
+    renderList({ markupMultiplier: 1, applyMarkupPercent: 0 })
+
+    await markup(user, '30', 'Apply')
+
+    expect(await markup(user, '45', 'Cancel')).toBe('30')
+  })
+
+  it('does not price a part with a markup that was typed and then cancelled', async () => {
+    const user = userEvent.setup()
+    renderList({ markupMultiplier: 1.3, applyMarkupPercent: 30 })
+
+    await markup(user, '300', 'Cancel')
+
+    expect(applyMarkupToAll).not.toHaveBeenCalled()
+    expect(await sellPriceForCost(user, '10')).toBe('13')
+  })
+
+  it("offers the workshop's Default Markup % the first time the dialog is opened", async () => {
+    const user = userEvent.setup()
+    // What the page passes when no catalog markup was ever saved and
+    // Settings > Workshop has Default Markup % at 30.
+    renderList({ markupMultiplier: 1, applyMarkupPercent: 30 })
+
+    expect(await markup(user, '30', 'Cancel')).toBe('30')
+    // Offered, not applied: until Apply is pressed a new part still fills in at cost.
+    expect(await sellPriceForCost(user, '10')).toBe('10')
+  })
+})

+ 107 - 0
src/__tests__/features/inventory/part-form-sell-price-markup.test.tsx

@@ -0,0 +1,107 @@
+/**
+ * Tests for the sell price that fills in on the inventory part form.
+ *
+ * Typing a cost fills in the sell price from the saved catalog markup. A price
+ * that appears without being typed has to say where it came from, and it has
+ * to be the cost plus a percentage: a workshop whose markup had been saved as
+ * 30 rather than 1.3 saw a part that cost 10 fill in at 300 with nothing on
+ * the form to explain it.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
+}))
+vi.mock('@/components/glass-modal', () => ({
+  useGlassModal: () => ({ open: vi.fn() }),
+}))
+vi.mock('@/hooks/use-native-camera', () => ({
+  useNativeCamera: () => false,
+}))
+vi.mock('@/features/inventory/Actions/inventoryActions', () => ({
+  createInventoryPart: vi.fn(),
+  updateInventoryPart: vi.fn(),
+  deleteOrphanedUploads: vi.fn(),
+}))
+vi.mock('@/features/inventory/Actions/aiAnalyzePartImage', () => ({
+  aiAnalyzePartImage: vi.fn(),
+}))
+
+import { InventoryPartForm } from '@/features/inventory/Components/InventoryPartForm'
+import { multiplierFromMarkupPercent } from '@/features/inventory/Lib/partPricing'
+
+type FormProps = React.ComponentProps<typeof InventoryPartForm>
+
+function renderForm(props: Partial<FormProps> = {}) {
+  render(<InventoryPartForm open onOpenChange={vi.fn()} {...props} />)
+  return {
+    typeCost: (value: string) =>
+      fireEvent.change(screen.getByLabelText('Unit Cost'), { target: { value } }),
+    sellPrice: () => (screen.getByLabelText('Sell Price') as HTMLInputElement).value,
+  }
+}
+
+const HINT = /Sell Price fills in at Unit Cost \+/
+
+const savedPart: NonNullable<FormProps['part']> = {
+  id: 'part-1',
+  partNumber: 'BRK-001',
+  barcode: null,
+  name: 'Brake pad set',
+  description: null,
+  category: null,
+  quantity: 4,
+  minQuantity: 0,
+  unit: 'pcs',
+  unitCost: 10,
+  sellPrice: 25,
+  supplier: null,
+  supplierPhone: null,
+  supplierEmail: null,
+  supplierUrl: null,
+  imageUrl: null,
+  gallery: [],
+  location: null,
+  tags: [],
+}
+
+describe('sell price on the inventory part form', () => {
+  it('fills in at cost plus the markup the workshop applied as a percentage', () => {
+    // What Apply Markup saves when 30 is typed into it.
+    const form = renderForm({ markupMultiplier: multiplierFromMarkupPercent('30') ?? undefined })
+
+    form.typeCost('10')
+    expect(form.sellPrice()).toBe('13')
+
+    form.typeCost('200')
+    expect(form.sellPrice()).toBe('260')
+  })
+
+  it('says where the filled-in price comes from and where to change it', () => {
+    renderForm({ markupMultiplier: 1.3 })
+
+    expect(
+      screen.getByText(
+        'Sell Price fills in at Unit Cost + 30%. Type a price to set your own. To change the percentage, use Apply Markup on the Inventory page.'
+      )
+    ).toBeInTheDocument()
+  })
+
+  it('says nothing about a markup when none is applied', () => {
+    const form = renderForm({ markupMultiplier: 1 })
+
+    form.typeCost('10')
+    expect(form.sellPrice()).toBe('10')
+    expect(screen.queryByText(HINT)).not.toBeInTheDocument()
+  })
+
+  it('leaves a price that was set by hand alone, and does not claim to fill it in', () => {
+    const form = renderForm({ markupMultiplier: 1.3, part: savedPart })
+
+    form.typeCost('40')
+    expect(form.sellPrice()).toBe('25')
+    expect(screen.queryByText(HINT)).not.toBeInTheDocument()
+  })
+})

+ 93 - 0
src/__tests__/features/inventory/part-pricing.test.ts

@@ -15,8 +15,11 @@
 
 import { describe, it, expect } from 'vitest'
 import {
+  initialCatalogMarkupPercent,
   lineTotal,
   markupFromCostAndPrice,
+  markupPercentFromMultiplier,
+  multiplierFromMarkupPercent,
   isPriceOverridden,
   parseQuantity,
   repricePartRow,
@@ -593,3 +596,93 @@ describe('what the markup field shows while a cost is being typed', () => {
     expect(shown).toEqual([1, 15, 150, 1500])
   })
 })
+
+describe('the catalog markup as a percentage', () => {
+  // The catalog markup is stored as a multiplier and typed as a percentage.
+  // A workshop that typed 30 for "30%" into a field that took the multiplier
+  // itself had every part priced at thirty times its cost.
+  it('turns a typed percentage into the multiplier that is stored', () => {
+    expect(multiplierFromMarkupPercent('30')).toBe(1.3)
+    expect(multiplierFromMarkupPercent(50)).toBe(1.5)
+    expect(multiplierFromMarkupPercent('12.5')).toBe(1.125)
+    expect(multiplierFromMarkupPercent('0')).toBe(1)
+  })
+
+  it('prices a part at cost plus the percentage, not cost times it', () => {
+    const multiplier = multiplierFromMarkupPercent('30')
+    expect(priceFromCostAndMultiplier(10, multiplier)).toBe(13)
+    expect(priceFromCostAndMultiplier(200, multiplier)).toBe(260)
+  })
+
+  it('agrees with the percentage a job line uses', () => {
+    for (const percent of [0, 7.5, 30, 50, 125]) {
+      for (const cost of [0.01, 9.99, 44.99, 100, 137.5]) {
+        expect(priceFromCostAndMultiplier(cost, multiplierFromMarkupPercent(percent))).toBe(
+          priceFromCostAndMarkup(cost, percent)
+        )
+      }
+    }
+  })
+
+  it('allows selling below cost, down to but not including giving parts away', () => {
+    expect(multiplierFromMarkupPercent('-10')).toBe(0.9)
+    expect(multiplierFromMarkupPercent('-100')).toBeNull()
+    expect(multiplierFromMarkupPercent('-250')).toBeNull()
+  })
+
+  it('refuses a percentage so close to -100 that it rounds to a multiplier of zero', () => {
+    // The smallest multiplier that can be stored is 0.0001, a markup of -99.99%.
+    expect(multiplierFromMarkupPercent('-99.99')).toBe(0.0001)
+    expect(multiplierFromMarkupPercent('-99.999')).toBeNull()
+    expect(multiplierFromMarkupPercent('-99.99999999')).toBeNull()
+    expect(multiplierFromMarkupPercent('1e400')).toBeNull()
+  })
+
+  it('has nothing to apply for a blank or unreadable field', () => {
+    // A blank box read as 0% would reprice the whole catalog at cost.
+    expect(multiplierFromMarkupPercent('')).toBeNull()
+    expect(multiplierFromMarkupPercent('   ')).toBeNull()
+    expect(multiplierFromMarkupPercent('abc')).toBeNull()
+    expect(multiplierFromMarkupPercent(undefined)).toBeNull()
+    expect(multiplierFromMarkupPercent(null)).toBeNull()
+  })
+
+  it('reads a stored multiplier back as the percentage that was typed', () => {
+    expect(markupPercentFromMultiplier(1.3)).toBe(30)
+    expect(markupPercentFromMultiplier('1.125')).toBe(12.5)
+    expect(markupPercentFromMultiplier(1)).toBe(0)
+    expect(markupPercentFromMultiplier(0.9)).toBe(-10)
+    // What the workshop in the report had stored after typing 30.
+    expect(markupPercentFromMultiplier(30)).toBe(2900)
+  })
+
+  it('survives the round trip for every percentage the field accepts', () => {
+    for (const percent of [-99.99, -10, 0, 0.1, 7.25, 30, 33.33, 150, 2900]) {
+      expect(markupPercentFromMultiplier(multiplierFromMarkupPercent(percent))).toBe(percent)
+    }
+  })
+
+  it('has no percentage for a multiplier that was never saved', () => {
+    expect(markupPercentFromMultiplier(undefined)).toBe(0)
+    expect(markupPercentFromMultiplier('')).toBe(0)
+    expect(markupPercentFromMultiplier(0)).toBe(0)
+  })
+})
+
+describe('initialCatalogMarkupPercent', () => {
+  it('opens with the catalog markup that was applied last', () => {
+    expect(initialCatalogMarkupPercent('1.3', '50')).toBe(30)
+    expect(initialCatalogMarkupPercent('1', '50')).toBe(0)
+  })
+
+  it("offers the workshop's Default Markup % when no catalog markup was ever applied", () => {
+    expect(initialCatalogMarkupPercent(undefined, '30')).toBe(30)
+    expect(initialCatalogMarkupPercent('', '12.5')).toBe(12.5)
+  })
+
+  it('opens at 0 when the workshop has set neither', () => {
+    expect(initialCatalogMarkupPercent(undefined, undefined)).toBe(0)
+    expect(initialCatalogMarkupPercent(undefined, 'abc')).toBe(0)
+    expect(initialCatalogMarkupPercent(undefined, '-5')).toBe(0)
+  })
+})

+ 28 - 83
src/app/(authenticated)/inventory/inventory-client.tsx

@@ -46,25 +46,16 @@ 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 { ApplyMarkupDialog } from '@/features/inventory/Components/ApplyMarkupDialog'
 import { InventoryPartForm } from '@/features/inventory/Components/InventoryPartForm'
 import { PartTagChips } from '@/features/inventory/Components/PartTagChips'
+import { markupPercentFromMultiplier } from '@/features/inventory/Lib/partPricing'
 import { sameTag } from '@/features/inventory/Lib/partTags'
 import { cn } from '@/lib/utils'
 import {
   deleteInventoryPart,
   deleteInventoryParts,
-  applyMarkupToAll,
 } from '@/features/inventory/Actions/inventoryActions'
-import { setSetting } from '@/features/settings/Actions/settingsActions'
-import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import {
-  Dialog as MarkupDialog,
-  DialogContent as MarkupDialogContent,
-  DialogHeader as MarkupDialogHeader,
-  DialogTitle as MarkupDialogTitle,
-} from '@/components/ui/dialog'
-import { Label } from '@/components/ui/label'
-import { Switch } from '@/components/ui/switch'
 import {
   ArrowDown,
   ArrowUp,
@@ -136,7 +127,8 @@ export function InventoryClient({
   tag = '',
   tags = [],
   currencyCode = DEFAULT_CURRENCY_CODE,
-  markupMultiplier: initialMarkup = 1.0,
+  markupMultiplier = 1.0,
+  applyMarkupPercent = 0,
   defaultUnit = '',
   unitSystem = 'imperial',
   sortBy: initialSortBy = 'updatedAt',
@@ -154,7 +146,10 @@ export function InventoryClient({
   /** Every tag the workshop's parts carry, for the filter and the form. */
   tags?: string[]
   currencyCode?: string
+  /** The saved catalog markup, as the multiplier a new part's sell price fills in with. */
   markupMultiplier?: number
+  /** The percentage the Apply Markup dialog opens with. */
+  applyMarkupPercent?: number
   /** Unit pre-filled on new parts (inventory.defaultUnit setting). */
   defaultUnit?: string
   /** workshop.unitSystem, orders unit suggestions in the part form. */
@@ -179,9 +174,11 @@ export function InventoryClient({
   const [showForm, setShowForm] = useState(false)
   const [editPart, setEditPart] = useState<InventoryPart | null>(null)
   const [showMarkup, setShowMarkup] = useState(false)
-  const [markupValue, setMarkupValue] = useState(String(initialMarkup))
-  const [applyingMarkup, setApplyingMarkup] = useState(false)
-  const [overrideExisting, setOverrideExisting] = useState(false)
+  // What Apply Markup just saved. The page's own copy only arrives with the
+  // refresh that follows, and a part added in between would otherwise fill in
+  // at the markup that was just replaced.
+  const [appliedMultiplier, setAppliedMultiplier] = useState<number | null>(null)
+  const catalogMultiplier = appliedMultiplier ?? markupMultiplier
   const [showScanner, setShowScanner] = useState(false)
   const [galleryImages, setGalleryImages] = useState<string[]>([])
   const [galleryIndex, setGalleryIndex] = useState<number | null>(null)
@@ -326,31 +323,6 @@ export function InventoryClient({
     setIsBulkDeleting(false)
   }
 
-  const handleApplyMarkup = async () => {
-    const multiplier = Number(markupValue)
-    if (!multiplier || multiplier <= 0) {
-      modal.open('error', t('errors.error'), t('errors.multiplierPositive'))
-      return
-    }
-    setApplyingMarkup(true)
-    try {
-      const [result] = await Promise.all([
-        applyMarkupToAll({ multiplier, overrideExisting }),
-        setSetting(SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER, String(multiplier)),
-      ])
-      if (result.success) {
-        setShowMarkup(false)
-        router.refresh()
-      } else {
-        modal.open('error', t('errors.error'), result.error || t('errors.applyFailed'))
-      }
-    } catch {
-      modal.open('error', t('errors.error'), t('errors.applyFailed'))
-    } finally {
-      setApplyingMarkup(false)
-    }
-  }
-
   // An empty Low view is ambiguous on its own: it means either "nothing is
   // running out" (good) or "nothing is being watched" (needs setup). Say which,
   // and link to the fix. Shared by the card list and the table.
@@ -973,7 +945,7 @@ export function InventoryClient({
           }
         }}
         part={editPart ?? undefined}
-        markupMultiplier={Number(markupValue) || initialMarkup}
+        markupMultiplier={catalogMultiplier}
         defaultUnit={defaultUnit}
         unitSystem={unitSystem}
         initialBarcode={!editPart ? scannedBarcode : undefined}
@@ -985,48 +957,21 @@ export function InventoryClient({
         }}
       />
 
-      {/* Bulk Markup Dialog */}
-      <MarkupDialog open={showMarkup} onOpenChange={setShowMarkup}>
-        <MarkupDialogContent className="sm:max-w-sm">
-          <MarkupDialogHeader>
-            <MarkupDialogTitle>{t('markup.title')}</MarkupDialogTitle>
-          </MarkupDialogHeader>
-          <div className="space-y-4">
-            <div className="space-y-2">
-              <Label htmlFor="markupMultiplier">{t('markup.multiplierLabel')}</Label>
-              <Input
-                id="markupMultiplier"
-                type="number"
-                min="0.01"
-                step="0.01"
-                value={markupValue}
-                onChange={(e) => setMarkupValue(e.target.value)}
-              />
-              <p className="text-xs text-muted-foreground">
-                {t('markup.description', { multiplier: markupValue || '?', count: data.total })}
-                {Number(markupValue) > 1 &&
-                  ` ${t('markup.percentage', { percent: Math.round((Number(markupValue) - 1) * 100) })}`}
-              </p>
-            </div>
-            <label className="flex items-center gap-2">
-              <Switch checked={overrideExisting} onCheckedChange={setOverrideExisting} />
-              <span className="text-sm">{t('markup.overrideExisting')}</span>
-            </label>
-            <p className="text-xs text-muted-foreground">
-              {overrideExisting ? t('markup.overrideExistingHint') : t('markup.skipExistingHint')}
-            </p>
-            <div className="flex justify-end gap-2">
-              <Button variant="outline" size="sm" onClick={() => setShowMarkup(false)}>
-                {t('markup.cancel')}
-              </Button>
-              <Button size="sm" onClick={handleApplyMarkup} disabled={applyingMarkup}>
-                {applyingMarkup && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                {t('markup.apply')}
-              </Button>
-            </div>
-          </div>
-        </MarkupDialogContent>
-      </MarkupDialog>
+      {/* The form above takes the saved markup, never what is being typed
+          here: a percentage that is cancelled must not price the next part. */}
+      <ApplyMarkupDialog
+        open={showMarkup}
+        onOpenChange={setShowMarkup}
+        initialPercent={
+          appliedMultiplier === null
+            ? applyMarkupPercent
+            : markupPercentFromMultiplier(appliedMultiplier)
+        }
+        onApplied={(multiplier) => {
+          setAppliedMultiplier(multiplier)
+          router.refresh()
+        }}
+      />
 
       <BarcodeScannerDialog
         open={showScanner}

+ 9 - 1
src/app/(authenticated)/inventory/page.tsx

@@ -1,3 +1,4 @@
+import { initialCatalogMarkupPercent } from '@/features/inventory/Lib/partPricing'
 import { measureSystemIn } from '@/features/inventory/Lib/units'
 import { resolveCurrencyCode } from '@/lib/currencies'
 import { resolveListSort } from '@/lib/list-sort-preference.server'
@@ -48,6 +49,7 @@ export default async function InventoryPage({
     getDisplaySettings([
       SETTING_KEYS.CURRENCY_CODE,
       SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER,
+      SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT,
       SETTING_KEYS.INVENTORY_DEFAULT_UNIT,
       SETTING_KEYS.UNIT_SYSTEM,
       SETTING_KEYS.MEASURE_SYSTEM,
@@ -71,7 +73,12 @@ export default async function InventoryPage({
   const tags = tagsResult.success && tagsResult.data ? tagsResult.data : []
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
   const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
-  const markupMultiplier = Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1.0
+  const savedMarkupMultiplier = settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]
+  const markupMultiplier = Number(savedMarkupMultiplier) || 1.0
+  const applyMarkupPercent = initialCatalogMarkupPercent(
+    savedMarkupMultiplier,
+    settings[SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]
+  )
 
   return (
     <>
@@ -86,6 +93,7 @@ export default async function InventoryPage({
           tags={tags}
           currencyCode={currencyCode}
           markupMultiplier={markupMultiplier}
+          applyMarkupPercent={applyMarkupPercent}
           defaultUnit={settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''}
           unitSystem={measureSystemIn(settings)}
           sortBy={sort.sortBy || ''}

+ 235 - 0
src/features/inventory/Components/ApplyMarkupDialog.tsx

@@ -0,0 +1,235 @@
+'use client'
+
+import { useState } from 'react'
+import { useFormatter, useTranslations } from 'next-intl'
+import { Loader2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogBody,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { useFormatCurrency } from '@/components/currency-settings-context'
+import { useGlassModal } from '@/components/glass-modal'
+import { applyMarkupToAll } from '@/features/inventory/Actions/inventoryActions'
+import {
+  multiplierFromMarkupPercent,
+  priceFromCostAndMultiplier,
+} from '@/features/inventory/Lib/partPricing'
+import { setSetting } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+/** What the worked example prices. A round number reads the same in any currency. */
+const EXAMPLE_COST = 100
+
+interface ApplyMarkupDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  /** The markup the dialog opens with, as a percentage: 30 for cost + 30%. */
+  initialPercent: number
+  /** Called with the saved multiplier once the catalog is repriced. */
+  onApplied: (multiplier: number) => void
+}
+
+/**
+ * Reprices the inventory catalog from cost plus a percentage, and saves that
+ * percentage as what a new part's sell price fills in with.
+ *
+ * The markup is asked for as a percentage and shown as a worked example before
+ * anything is applied. It used to be a bare multiplier, which sat next to a
+ * percentage setting of nearly the same name: 30 typed for "30%" priced every
+ * part at thirty times its cost, and the only clue was a line of small grey
+ * text.
+ */
+export function ApplyMarkupDialog({
+  open,
+  onOpenChange,
+  initialPercent,
+  onApplied,
+}: ApplyMarkupDialogProps) {
+  const t = useTranslations('inventory')
+  // Held here rather than in the form: the form is remounted every time the
+  // dialog opens, and a run that is still going must outlive that. While it is
+  // set the dialog cannot be dismissed, so it cannot be reopened to start a
+  // second catalog-wide update over the first.
+  const [applying, setApplying] = useState(false)
+
+  return (
+    <Dialog
+      open={open}
+      onOpenChange={(next) => {
+        if (!applying) onOpenChange(next)
+      }}
+    >
+      <DialogContent className="sm:max-w-md" showCloseButton={!applying}>
+        <DialogHeader>
+          <DialogTitle>{t('markup.title')}</DialogTitle>
+          <DialogDescription>{t('markup.intro')}</DialogDescription>
+        </DialogHeader>
+        {/* Mounted with the dialog, so a percentage that was typed and then
+            cancelled is gone the next time it opens. */}
+        <ApplyMarkupForm
+          initialPercent={initialPercent}
+          applying={applying}
+          onApplyingChange={setApplying}
+          onCancel={() => onOpenChange(false)}
+          onApplied={(multiplier) => {
+            onApplied(multiplier)
+            onOpenChange(false)
+          }}
+        />
+      </DialogContent>
+    </Dialog>
+  )
+}
+
+function ApplyMarkupForm({
+  initialPercent,
+  applying,
+  onApplyingChange,
+  onCancel,
+  onApplied,
+}: {
+  initialPercent: number
+  applying: boolean
+  onApplyingChange: (applying: boolean) => void
+  onCancel: () => void
+  onApplied: (multiplier: number) => void
+}) {
+  const t = useTranslations('inventory')
+  const tSettings = useTranslations('settings')
+  const tNav = useTranslations('navigation')
+  const format = useFormatter()
+  const formatCurrency = useFormatCurrency()
+  const modal = useGlassModal()
+  const [percent, setPercent] = useState(String(initialPercent))
+  const [overrideExisting, setOverrideExisting] = useState(false)
+
+  const multiplier = multiplierFromMarkupPercent(percent)
+  const invalid = percent.trim() !== '' && multiplier === null
+  const examplePrice =
+    multiplier === null ? null : priceFromCostAndMultiplier(EXAMPLE_COST, multiplier)
+
+  const handleApply = async () => {
+    if (multiplier === null || applying) return
+    onApplyingChange(true)
+    try {
+      const result = await applyMarkupToAll({ multiplier, overrideExisting })
+      if (!result.success) {
+        modal.open('error', t('errors.error'), result.error || t('errors.applyFailed'))
+        return
+      }
+      // Saved only once the catalog has been repriced with it. Sent alongside
+      // the update, a markup the update refused was remembered all the same.
+      await setSetting(SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER, String(multiplier))
+      onApplied(multiplier)
+    } catch {
+      modal.open('error', t('errors.error'), t('errors.applyFailed'))
+    } finally {
+      onApplyingChange(false)
+    }
+  }
+
+  return (
+    <>
+      <DialogBody className="space-y-5">
+        <div className="space-y-2">
+          <Label htmlFor="markupPercent">{t('markup.percentLabel')}</Label>
+          <div className="flex items-center gap-2">
+            <Input
+              id="markupPercent"
+              type="number"
+              inputMode="decimal"
+              min="-99.99"
+              step="0.1"
+              className="w-28"
+              value={percent}
+              onChange={(e) => setPercent(e.target.value)}
+              aria-invalid={invalid}
+              aria-describedby="markupPercentHint"
+            />
+            <span className="text-sm text-muted-foreground" aria-hidden="true">
+              %
+            </span>
+          </div>
+          <p
+            id="markupPercentHint"
+            className={invalid ? 'text-xs text-destructive' : 'text-xs text-muted-foreground'}
+          >
+            {invalid ? t('markup.percentInvalid') : t('markup.percentHint')}
+          </p>
+        </div>
+
+        {examplePrice !== null && (
+          <div className="rounded-md border bg-muted/40 p-3" aria-live="polite">
+            <p className="text-xs font-medium text-muted-foreground">{t('markup.exampleTitle')}</p>
+            <dl className="mt-2 grid grid-cols-3 gap-3">
+              <div>
+                <dt className="text-xs text-muted-foreground">{t('form.unitCost')}</dt>
+                <dd className="text-sm tabular-nums">{formatCurrency(EXAMPLE_COST)}</dd>
+              </div>
+              <div>
+                <dt className="text-xs text-muted-foreground">
+                  {t('markup.exampleMarkup', {
+                    percent: format.number(Number(percent), { maximumFractionDigits: 2 }),
+                  })}
+                </dt>
+                <dd className="text-sm tabular-nums">
+                  {formatCurrency(examplePrice - EXAMPLE_COST)}
+                </dd>
+              </div>
+              <div>
+                <dt className="text-xs text-muted-foreground">{t('form.sellPrice')}</dt>
+                <dd className="text-sm font-semibold tabular-nums">
+                  {formatCurrency(examplePrice)}
+                </dd>
+              </div>
+            </dl>
+          </div>
+        )}
+
+        <div className="space-y-2">
+          <label className="flex items-center gap-2">
+            <Switch checked={overrideExisting} onCheckedChange={setOverrideExisting} />
+            <span className="text-sm">{t('markup.overrideExisting')}</span>
+          </label>
+          <p className="text-xs text-muted-foreground">
+            {overrideExisting ? t('markup.overrideExistingHint') : t('markup.skipExistingHint')}
+          </p>
+        </div>
+
+        <ul className="list-disc space-y-1 pl-4 text-xs text-muted-foreground">
+          <li>
+            {t('markup.newPartsNote', {
+              sellPrice: t('form.sellPrice'),
+              unitCost: t('form.unitCost'),
+            })}
+          </li>
+          <li>
+            {t('markup.settingsNote', {
+              setting: tSettings('workshop.defaultMarkupPercent'),
+              settings: tNav('breadcrumbs.settings'),
+              workshop: tNav('breadcrumbs.workshop'),
+            })}
+          </li>
+        </ul>
+      </DialogBody>
+      <DialogFooter>
+        <Button variant="outline" onClick={onCancel} disabled={applying}>
+          {t('markup.cancel')}
+        </Button>
+        <Button onClick={handleApply} disabled={applying || multiplier === null}>
+          {applying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+          {t('markup.apply')}
+        </Button>
+      </DialogFooter>
+    </>
+  )
+}

+ 22 - 2
src/features/inventory/Components/InventoryPartForm.tsx

@@ -2,7 +2,7 @@
 
 import { interactiveRow } from '@/lib/interactive-row'
 import { useState, useRef, useCallback, useEffect } from 'react'
-import { useTranslations } from 'next-intl'
+import { useFormatter, useTranslations } from 'next-intl'
 import Link from 'next/link'
 import { useRouter } from 'next/navigation'
 import { Button } from '@/components/ui/button'
@@ -51,7 +51,10 @@ import {
 } from '@/components/ui/command'
 import { cn } from '@/lib/utils'
 import { compressImage } from '@/lib/compress-image'
-import { priceFromCostAndMultiplier } from '@/features/inventory/Lib/partPricing'
+import {
+  markupPercentFromMultiplier,
+  priceFromCostAndMultiplier,
+} from '@/features/inventory/Lib/partPricing'
 import { UnitCombobox } from '@/features/inventory/Components/UnitCombobox'
 import { clearableInput } from '@/lib/clearable'
 import { TagInput } from '@/components/tag-input'
@@ -123,7 +126,13 @@ export function InventoryPartForm({
   const router = useRouter()
   const modal = useGlassModal()
   const t = useTranslations('inventory')
+  const tNav = useTranslations('navigation')
+  const format = useFormatter()
   const isMarine = useServiceType() === 'marine'
+  // A sell price that appears by itself needs to say where it came from. Only
+  // while it still follows the cost: a part that already has a price keeps it.
+  const autoFillMarkupPercent =
+    part?.sellPrice && part.sellPrice > 0 ? 0 : markupPercentFromMultiplier(markupMultiplier)
   const [loading, setLoading] = useState(false)
   const [fetching, setFetching] = useState(false)
   const [uploading, setUploading] = useState(false)
@@ -888,6 +897,17 @@ export function InventoryPartForm({
                     />
                   </div>
                 </div>
+                {autoFillMarkupPercent > 0 && (
+                  <p className="text-xs text-muted-foreground">
+                    {t('form.sellPriceMarkupHint', {
+                      sellPrice: t('form.sellPrice'),
+                      unitCost: t('form.unitCost'),
+                      applyMarkup: t('applyMarkup'),
+                      inventory: tNav('sidebar.inventory'),
+                      percent: format.number(autoFillMarkupPercent, { maximumFractionDigits: 2 }),
+                    })}
+                  </p>
+                )}
               </div>
             </div>
 

+ 50 - 0
src/features/inventory/Lib/partPricing.ts

@@ -19,6 +19,12 @@ import { roundMoney } from '@/lib/money'
  *    cost + 50%.
  *  - `markupMultiplier` multiplies cost in the inventory catalog form. 1.5
  *    means the same thing as a 50 percent markup.
+ *
+ * The multiplier is only how the catalog markup is stored. Nobody types one:
+ * every screen asks for a percentage and converts with
+ * {@link multiplierFromMarkupPercent}. A field that took the multiplier itself
+ * was read as a percentage, and 30 typed for "30%" priced a part at thirty
+ * times its cost.
  */
 
 export interface PricedPart {
@@ -80,6 +86,50 @@ export function priceFromCostAndMultiplier(cost: unknown, multiplier: unknown):
   return roundMoney(parseNumber(cost) * parseNumber(multiplier))
 }
 
+/**
+ * The catalog multiplier for a markup typed as a percentage: 30 becomes 1.3.
+ *
+ * Null when there is nothing usable to apply. A blank field must not read as
+ * 0%, which would reprice a whole catalog at cost, and -100% or below would
+ * sell parts for nothing or less.
+ */
+export function multiplierFromMarkupPercent(percent: unknown): number | null {
+  if (percent === null || percent === undefined) return null
+  if (typeof percent === 'string' && percent.trim() === '') return null
+  const parsed = Number(percent)
+  if (!Number.isFinite(parsed)) return null
+  // Checked after rounding: -99.999% is above -100 and still rounds to a
+  // multiplier of zero, which the catalog update refuses.
+  const multiplier = Math.round((1 + parsed / 100) * 10000) / 10000
+  return Number.isFinite(multiplier) && multiplier > 0 ? multiplier : null
+}
+
+/**
+ * The inverse of {@link multiplierFromMarkupPercent}: the percentage a stored
+ * catalog multiplier stands for. 1.3 reads as 30. Held to two decimals so a
+ * value survives the round trip without picking up float noise.
+ */
+export function markupPercentFromMultiplier(multiplier: unknown): number {
+  const parsed = parseNumber(multiplier)
+  if (parsed <= 0) return 0
+  return Math.round((parsed - 1) * 10000) / 100
+}
+
+/**
+ * The percentage the Apply Markup dialog opens with.
+ *
+ * A workshop that has applied a catalog markup before sees that one again.
+ * One that never has is offered its Default Markup % from the workshop
+ * settings, since that is the number it already told us it works with.
+ */
+export function initialCatalogMarkupPercent(
+  savedMultiplier: unknown,
+  defaultMarkupPercent: unknown
+): number {
+  if (parseNumber(savedMultiplier) > 0) return markupPercentFromMultiplier(savedMultiplier)
+  return Math.max(0, parseNumber(defaultMarkupPercent))
+}
+
 /**
  * The inverse of {@link priceFromCostAndMarkup}: what markup does this price
  * imply over this cost?