diff --git a/AGENTS.md b/AGENTS.md index dded2a0..c067384 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -839,7 +839,21 @@ session with **no other memory of this project**. So: and a **breakeven** (`fixed ÷ profit`, plus a `targetMonthlyProfit` goal). New state `quantity` / `wholesaleMarkup` / `targetMonthlyProfit` round-trips through CSV (`margins` rows); `numInput` clamps negatives to 0. The client quote shows - `× N` and the order total (the deposit is taken on the total). **Currency + `× N` and the order total (the deposit is taken on the total). + **Advanced toggle (2026-10-05):** a **Simple ↔ Advanced** switch (persisted + `skatespots:deck-cost:advanced`) — deliberately **not** called "Pro" (that name + is Sasquatch Pro). Advanced reveals four things: **per-operation labour rate** + (`ProcessRow.rate`, 0 = shop rate) and **per-operation scrap** + (`ProcessRow.scrapPct`) that **compounds** with the general scrap into an + *effective waste %*; **batch setup** (`batchSetupHours` + `batchSetupCost`, a + one-off per run amortised over `quantity`, so the unit price depends on qty); + a **tax / VAT** line (`taxPct` + `taxInclusive`, shown on the client quote as + Subtotal/Tax/Total or "includes X% tax"); and a **cost-vs-quantity curve** + (1/5/10/25/50/100 decks). `costWithPackUnit`/`costNoPackUnit` include setup, and + the prices use them. All four round-trip through CSV, and the state is merged + over `DEFAULT_CALC` on read (old sheets/presets can't produce NaN — `clampPct` + is NaN-safe too). Currency switching also converts `targetMonthlyProfit`, + `batchSetupCost` and process `rate`. **Currency (2026-10-05):** a **searchable currency selector** (Popover + search) covering **sats + BTC** (both Bitcoin units) and every **fiat** Coinbase reports (crypto is filtered out via `FIAT_CODES`), and switching **converts every money field** diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx index 0cc31cc..1e1280d 100644 --- a/src/components/DeckCostCalculator.tsx +++ b/src/components/DeckCostCalculator.tsx @@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Plus, Trash2, RotateCcw, Search, ChevronDown, ChevronRight, Check, Save, FolderOpen, Download, Upload, AtSign, AlertTriangle, LayoutTemplate } from 'lucide-react'; +import { Plus, Trash2, RotateCcw, Search, ChevronDown, ChevronRight, Check, Save, FolderOpen, Download, Upload, AtSign, AlertTriangle, LayoutTemplate, SlidersHorizontal } from 'lucide-react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useBtcRates } from '@/hooks/useExchangeRate'; import { useToast } from '@/hooks/useToast'; @@ -26,7 +26,7 @@ import { cn } from '@/lib/utils'; interface EquipmentRow { id: string; name: string; cost: number; setupHrs: number; boards: number } interface MaterialRow { id: string; name: string; orderCost: number; shipping: number; boards: number; orderMin: number; unloadMin: number } -interface ProcessRow { id: string; name: string; minutes: number; boards: number } +interface ProcessRow { id: string; name: string; minutes: number; boards: number; /** Loaded hourly rate override (0 = use the shop rate). */ rate: number; /** Waste at this operation, % (0 = none). */ scrapPct: number } interface PackageRow { id: string; name: string; minutesPerBoard: number; materialsPerBoard: number } type CurrencyCode = string; @@ -150,6 +150,14 @@ interface CalcState { quantity: number; /** Monthly profit goal for the "decks to sell" line (0 = off). */ targetMonthlyProfit: number; + /** One-off setup per production run — hours of labour (amortised over the order). */ + batchSetupHours: number; + /** One-off setup per production run — direct cost (amortised over the order). */ + batchSetupCost: number; + /** Tax / VAT / sales tax, %. */ + taxPct: number; + /** Whether the quoted price already includes tax. */ + taxInclusive: boolean; scrapPct: number; equipment: EquipmentRow[]; materials: MaterialRow[]; @@ -171,6 +179,10 @@ const DEFAULT_CALC: CalcState = { wholesaleMarkup: 2, quantity: 1, targetMonthlyProfit: 0, + batchSetupHours: 0, + batchSetupCost: 0, + taxPct: 0, + taxInclusive: false, scrapPct: 5, equipment: [ { id: uid(), name: 'Molds', cost: 900, setupHrs: 1, boards: 10000 }, @@ -208,15 +220,15 @@ const DEFAULT_CALC: CalcState = { { id: uid(), name: 'Other (hardware, coatings, etc.)', orderCost: 0, shipping: 0, boards: 50, orderMin: 5, unloadMin: 0 }, ], processes: [ - { id: uid(), name: 'Glue & Press', minutes: 20, boards: 3 }, - { id: uid(), name: 'Marking & Registration', minutes: 2, boards: 1 }, - { id: uid(), name: 'Drilling', minutes: 5, boards: 3 }, - { id: uid(), name: 'Shaping', minutes: 4, boards: 1 }, - { id: uid(), name: 'Routing Edges', minutes: 1, boards: 1 }, - { id: uid(), name: 'Sanding', minutes: 10, boards: 1 }, - { id: uid(), name: 'Clearcoating', minutes: 5, boards: 1 }, - { id: uid(), name: 'Graphics', minutes: 4, boards: 1 }, - { id: uid(), name: 'Other Processes', minutes: 0, boards: 1 }, + { id: uid(), name: 'Glue & Press', minutes: 20, boards: 3, rate: 0, scrapPct: 0 }, + { id: uid(), name: 'Marking & Registration', minutes: 2, boards: 1, rate: 0, scrapPct: 0 }, + { id: uid(), name: 'Drilling', minutes: 5, boards: 3, rate: 0, scrapPct: 0 }, + { id: uid(), name: 'Shaping', minutes: 4, boards: 1, rate: 0, scrapPct: 0 }, + { id: uid(), name: 'Routing Edges', minutes: 1, boards: 1, rate: 0, scrapPct: 0 }, + { id: uid(), name: 'Sanding', minutes: 10, boards: 1, rate: 0, scrapPct: 0 }, + { id: uid(), name: 'Clearcoating', minutes: 5, boards: 1, rate: 0, scrapPct: 0 }, + { id: uid(), name: 'Graphics', minutes: 4, boards: 1, rate: 0, scrapPct: 0 }, + { id: uid(), name: 'Other Processes', minutes: 0, boards: 1, rate: 0, scrapPct: 0 }, ], packaging: [ { id: uid(), name: 'Sticker & poly bag', minutesPerBoard: 4, materialsPerBoard: 0.06 }, @@ -255,10 +267,10 @@ function starterSheet(): CalcState { { id: uid(), name: 'Grip tape', orderCost: 20, shipping: 0, boards: 20, orderMin: 0, unloadMin: 0 }, ], processes: [ - { id: uid(), name: 'Glue & press', minutes: 30, boards: 1 }, - { id: uid(), name: 'Shape & drill', minutes: 30, boards: 1 }, - { id: uid(), name: 'Sand & finish', minutes: 45, boards: 1 }, - { id: uid(), name: 'Graphics', minutes: 20, boards: 1 }, + { id: uid(), name: 'Glue & press', minutes: 30, boards: 1, rate: 0, scrapPct: 0 }, + { id: uid(), name: 'Shape & drill', minutes: 30, boards: 1, rate: 0, scrapPct: 0 }, + { id: uid(), name: 'Sand & finish', minutes: 45, boards: 1, rate: 0, scrapPct: 0 }, + { id: uid(), name: 'Graphics', minutes: 20, boards: 1, rate: 0, scrapPct: 0 }, ], packaging: [ { id: uid(), name: 'Bag & sticker', minutesPerBoard: 3, materialsPerBoard: 0.1 }, @@ -343,6 +355,10 @@ function calcCsvRows(s: CalcState): string[][] { rows.push(['margins', 'wholesale_markup', 'value', num(s.wholesaleMarkup)]); rows.push(['margins', 'quantity', 'value', num(s.quantity)]); rows.push(['margins', 'target_monthly_profit', 'value', num(s.targetMonthlyProfit)]); + rows.push(['margins', 'batch_setup_hours', 'value', num(s.batchSetupHours)]); + rows.push(['margins', 'batch_setup_cost', 'value', num(s.batchSetupCost)]); + rows.push(['margins', 'tax_pct', 'value', num(s.taxPct)]); + rows.push(['margins', 'tax_inclusive', 'value', s.taxInclusive ? '1' : '0']); rows.push(['margins', 'scrap_pct', 'value', num(s.scrapPct)]); for (const e of s.equipment) { rows.push(['equipment', e.name, 'cost', num(e.cost)]); @@ -359,6 +375,8 @@ function calcCsvRows(s: CalcState): string[][] { for (const p of s.processes) { rows.push(['processes', p.name, 'minutes', num(p.minutes)]); rows.push(['processes', p.name, 'boards', num(p.boards)]); + rows.push(['processes', p.name, 'rate', num(p.rate)]); + rows.push(['processes', p.name, 'scrap_pct', num(p.scrapPct)]); } for (const p of s.packaging) { rows.push(['packaging', p.name, 'minutes_per_board', num(p.minutesPerBoard)]); @@ -399,6 +417,10 @@ function calcStateFromCsv(text: string): CalcState | null { else if (item === 'wholesale_markup') out.wholesaleMarkup = n(value); else if (item === 'quantity') out.quantity = Math.max(1, n(value)); else if (item === 'target_monthly_profit') out.targetMonthlyProfit = n(value); + else if (item === 'batch_setup_hours') out.batchSetupHours = n(value); + else if (item === 'batch_setup_cost') out.batchSetupCost = n(value); + else if (item === 'tax_pct') out.taxPct = n(value); + else if (item === 'tax_inclusive') out.taxInclusive = value === '1'; else if (item === 'scrap_pct') out.scrapPct = n(value); continue; } @@ -417,9 +439,11 @@ function calcStateFromCsv(text: string): CalcState | null { else if (field === 'unload_min') row.unloadMin = n(value); ma.set(item, row); } else if (section === 'processes') { - const row = pr.get(item) ?? { id: uid(), name: item, minutes: 0, boards: 1 }; + const row = pr.get(item) ?? { id: uid(), name: item, minutes: 0, boards: 1, rate: 0, scrapPct: 0 }; if (field === 'minutes') row.minutes = n(value); else if (field === 'boards') row.boards = n(value); + else if (field === 'rate') row.rate = n(value); + else if (field === 'scrap_pct') row.scrapPct = n(value); pr.set(item, row); } else if (section === 'packaging') { const row = pk.get(item) ?? { id: uid(), name: item, minutesPerBoard: 0, materialsPerBoard: 0 }; @@ -460,7 +484,10 @@ function calcStateFromCsv(text: string): CalcState | null { } export function DeckCostCalculator() { - const [s, setS] = useLocalStorage('skatespots:deck-cost:v2', DEFAULT_CALC); + const [rawS, setS] = useLocalStorage('skatespots:deck-cost:v2', DEFAULT_CALC); + // Merge over defaults so a sheet saved before a new field existed can't produce + // NaN (e.g. the per-operation / batch-setup / tax fields added with Advanced). + const s = useMemo(() => ({ ...DEFAULT_CALC, ...rawS }), [rawS]); const { data: rates } = useBtcRates(); const { toast } = useToast(); // Quote identity lives in its own store, so switching presets keeps the same @@ -471,6 +498,8 @@ export function DeckCostCalculator() { () => ({ ...DEFAULT_QUOTE, ...rawQuote, payMethods: rawQuote.payMethods ?? DEFAULT_QUOTE.payMethods }), [rawQuote], ); + // Advanced reveals per-operation rates/scrap, batch setup, tax and the cost curve. + const [advanced, setAdvanced] = useLocalStorage('skatespots:deck-cost:advanced', false); // Nostr identity can be pulled from the logged-in profile (kind 0). const { user } = useCurrentUser(); const author = useAuthor(user?.pubkey); @@ -515,8 +544,11 @@ export function DeckCostCalculator() { rent: r2(prev.rent), utilities: r2(prev.utilities), subscriptions: r2(prev.subscriptions), + targetMonthlyProfit: r2(prev.targetMonthlyProfit ?? 0), + batchSetupCost: r2(prev.batchSetupCost ?? 0), equipment: prev.equipment.map((e) => ({ ...e, cost: r2(e.cost) })), materials: prev.materials.map((m) => ({ ...m, orderCost: r2(m.orderCost), shipping: r2(m.shipping) })), + processes: prev.processes.map((p) => ({ ...p, rate: r2(p.rate ?? 0) })), packaging: prev.packaging.map((p) => ({ ...p, materialsPerBoard: r2(p.materialsPerBoard) })), }; }); @@ -525,23 +557,53 @@ export function DeckCostCalculator() { const r = useMemo(() => { const laborPerHr = s.hourlyRate * s.overheadFactor; const laborPerMin = laborPerHr / 60; + const clampPct = (v: number) => Math.min(99, Math.max(0, Number.isFinite(v) ? v : 0)); + const rateFor = (rate: number) => (rate > 0 ? rate : laborPerHr); const equipment = s.equipment.reduce((a, e) => a + (e.boards > 0 ? (e.cost + e.setupHrs * laborPerHr) / e.boards : 0), 0); const operations = s.boardsPerMonth > 0 ? (s.rent + s.utilities + s.subscriptions) / s.boardsPerMonth : 0; const materials = s.materials.reduce((a, m) => a + (m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * laborPerMin) / m.boards : 0), 0); - const processes = s.processes.reduce((a, p) => a + (p.boards > 0 ? (p.minutes * laborPerMin) / p.boards : 0), 0); + const processes = s.processes.reduce((a, p) => a + (p.boards > 0 ? (p.minutes * (rateFor(p.rate) / 60)) / p.boards : 0), 0); const packaging = s.packaging.reduce((a, p) => a + p.materialsPerBoard + p.minutesPerBoard * laborPerMin, 0); const costNoPack = equipment + operations + materials + processes; const costWithPack = costNoPack + packaging; - const factor = s.scrapPct < 100 ? s.profitMargin / (1 - s.scrapPct / 100) : s.profitMargin; - const wholesaleFactor = s.scrapPct < 100 ? s.wholesaleMarkup / (1 - s.scrapPct / 100) : s.wholesaleMarkup; - const priceNoPack = costNoPack * factor; - const priceWithPack = costWithPack * factor; - const wholesaleUnit = costWithPack * wholesaleFactor; + + // Waste: the general rate plus each operation's own scrap, compounded. + const opYield = s.processes.reduce((y, p) => y * (1 - clampPct(p.scrapPct) / 100), 1); + const yieldFactor = (1 - clampPct(s.scrapPct) / 100) * opYield; + const factor = yieldFactor > 0 ? s.profitMargin / yieldFactor : s.profitMargin; + const wholesaleFactor = yieldFactor > 0 ? s.wholesaleMarkup / yieldFactor : s.wholesaleMarkup; + const effectiveWastePct = (1 - yieldFactor) * 100; + const qty = Math.max(1, Math.round(s.quantity) || 1); - const profitPerUnit = priceWithPack - costWithPack; + // One-off setup per run, amortised across the order quantity. + const batchSetup = s.batchSetupHours * laborPerHr + s.batchSetupCost; + const setupPerUnit = batchSetup / qty; + const costNoPackUnit = costNoPack + setupPerUnit; + const costWithPackUnit = costWithPack + setupPerUnit; + + const priceNoPack = costNoPackUnit * factor; + const priceWithPack = costWithPackUnit * factor; + const wholesaleUnit = costWithPackUnit * wholesaleFactor; + const profitPerUnit = priceWithPack - costWithPackUnit; + const fixedMonthly = s.rent + s.utilities + s.subscriptions; const breakevenDecks = profitPerUnit > 0 && fixedMonthly > 0 ? Math.ceil(fixedMonthly / profitPerUnit) : 0; const targetDecks = profitPerUnit > 0 ? Math.ceil((fixedMonthly + Math.max(0, s.targetMonthlyProfit)) / profitPerUnit) : 0; + + // Cost-vs-quantity curve (one-off setup amortised over the batch). + const curve = [1, 5, 10, 25, 50, 100].map((q) => { + const unit = (costWithPack + batchSetup / q) * factor; + return { qty: q, unit, total: unit * q }; + }); + + // Tax on the order (inclusive or added on top). + const orderSubtotal = priceWithPack * qty; + const taxRate = clampPct(s.taxPct); + const orderTax = s.taxInclusive + ? orderSubtotal * (taxRate / (100 + taxRate)) + : orderSubtotal * (taxRate / 100); + const orderTotal = s.taxInclusive ? orderSubtotal : orderSubtotal + orderTax; + return { laborPerHr, laborPerMin, @@ -552,16 +614,24 @@ export function DeckCostCalculator() { packaging, costNoPack, costWithPack, + costNoPackUnit, + costWithPackUnit, + setupPerUnit, + batchSetup, priceNoPack, priceWithPack, wholesaleUnit, qty, profitPerUnit, - orderTotal: priceWithPack * qty, + orderSubtotal, + orderTax, + orderTotal, wholesaleTotal: wholesaleUnit * qty, fixedMonthly, breakevenDecks, targetDecks, + curve, + effectiveWastePct, }; }, [s]); @@ -683,7 +753,10 @@ export function DeckCostCalculator() { kv('Materials per board', fmt(r.materials), true); section('Process costs'); - s.processes.forEach((p) => item(p.name, fmt(p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0), `${p.minutes} min${p.boards > 1 ? ` / ${p.boards} boards` : ''}`)); + s.processes.forEach((p) => { + const rate = p.rate > 0 ? p.rate : r.laborPerHr; + item(p.name, fmt(p.boards > 0 ? (p.minutes * (rate / 60)) / p.boards : 0), `${p.minutes} min${p.boards > 1 ? ` / ${p.boards} boards` : ''}${p.scrapPct ? ` \u00b7 ${p.scrapPct}% scrap` : ''}`); + }); kv('Processes per board', fmt(r.processes), true); section('Packaging'); @@ -694,9 +767,11 @@ export function DeckCostCalculator() { ensure(180); section('Result'); kv('Markup', `${s.profitMargin}\u00d7 (\u2248 ${marginPct}% gross margin)`); - kv('Scrap / waste', `${s.scrapPct}%`); - kv('Cost / board (no packaging)', fmt(r.costNoPack)); - kv('Cost / board (with packaging)', fmt(r.costWithPack)); + kv('Waste (effective)', `${r.effectiveWastePct.toFixed(1)}%`); + kv('Cost / board (no packaging)', fmt(r.costNoPackUnit)); + kv('Cost / board (with packaging)', fmt(r.costWithPackUnit)); + if (r.batchSetup > 0) kv('Batch setup / board', fmt(r.setupPerUnit)); + if (s.taxPct > 0) kv(`Tax (${s.taxPct}%${s.taxInclusive ? ', included' : ''})`, fmt(r.orderTax)); y += 6; const priceBox = (label: string, value: string) => { @@ -775,9 +850,21 @@ export function DeckCostCalculator() { doc.text(qty > 1 ? `Hand-built, finished and packed. ${fmt(r.priceWithPack)} each.` : 'Hand-built, finished and packed. Quantity 1.', M, y); y += 16; doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 24; + if (s.taxPct > 0 && !s.taxInclusive) { + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); + doc.text(qty > 1 ? `Subtotal (${qty} decks)` : 'Subtotal', M, y); + doc.text(fmt(r.orderSubtotal), rightX, y, { align: 'right' }); y += 13; + doc.text(`Tax / VAT (${s.taxPct}%)`, M, y); + doc.text(fmt(r.orderTax), rightX, y, { align: 'right' }); y += 16; + } doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(20, 20, 20); doc.text(qty > 1 ? `Total (${qty} decks)` : 'Total', M, y); - doc.text(fmt(r.orderTotal), rightX, y, { align: 'right' }); y += 26; + doc.text(fmt(r.orderTotal), rightX, y, { align: 'right' }); y += 6; + if (s.taxPct > 0 && s.taxInclusive) { + doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(120, 120, 120); + y += 6; doc.text(`Includes ${s.taxPct}% tax (${fmt(r.orderTax)})`, M, y); + } + y += 20; // Payment: deposit + balance + methods + terms + delivery. ensureRoom(70); @@ -929,7 +1016,7 @@ export function DeckCostCalculator() { ]), 'Materials'); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ ['Process', 'Time (min)', 'Boards', 'Per board'], - ...s.processes.map((p) => [p.name, p.minutes, p.boards, num(p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0)]), + ...s.processes.map((p) => [p.name, p.minutes, p.boards, p.rate, p.scrapPct, num(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)]), ]), 'Processes'); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ ['Packaging', 'Time/board (min)', 'Materials/board', 'Per board'], @@ -991,7 +1078,7 @@ export function DeckCostCalculator() { `- **Materials per board: ${fmt(r.materials)}**`, '', '## Process costs', - ...s.processes.map((p) => `- ${p.name}: ${fmt(p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0)} (${p.minutes} min)`), + ...s.processes.map((p) => `- ${p.name}: ${fmt(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)} (${p.minutes} min${p.scrapPct ? ` · ${p.scrapPct}% scrap` : ''})`), `- **Processes per board: ${fmt(r.processes)}**`, '', '## Packaging', @@ -999,9 +1086,11 @@ export function DeckCostCalculator() { `- **Packaging per board: ${fmt(r.packaging)}**`, '', '## Result', - `- Markup: ${s.profitMargin}× (≈ ${marginPct}% gross margin) · Scrap: ${s.scrapPct}%`, - `- Cost/board (no packaging): ${fmt(r.costNoPack)}`, - `- Cost/board (with packaging): ${fmt(r.costWithPack)}`, + `- Markup: ${s.profitMargin}× (≈ ${marginPct}% gross margin) · Waste: ${r.effectiveWastePct.toFixed(1)}%`, + `- Cost/board (no packaging): ${fmt(r.costNoPackUnit)}`, + `- Cost/board (with packaging): ${fmt(r.costWithPackUnit)}`, + ...(r.batchSetup > 0 ? [`- Batch setup/board: ${fmt(r.setupPerUnit)}`] : []), + ...(s.taxPct > 0 ? [`- Tax (${s.taxPct}%${s.taxInclusive ? ', included' : ''}): ${fmt(r.orderTax)}`] : []), `- **Selling price (no packaging): ${fmt(r.priceNoPack)}**`, `- **Selling price (with packaging): ${fmt(r.priceWithPack)}**`, '', @@ -1156,6 +1245,14 @@ export function DeckCostCalculator() { {tpl.name} ))} + {/* Sanity warnings */} @@ -1261,16 +1358,19 @@ export function DeckCostCalculator() {
- + {advanced && }{advanced && } {s.processes.map((p) => { - const per = p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0; + const rate = p.rate > 0 ? p.rate : r.laborPerHr; + const per = p.boards > 0 ? (p.minutes * (rate / 60)) / p.boards : 0; const up = (pp: Partial) => patch({ processes: s.processes.map((x) => x.id === p.id ? { ...x, ...pp } : x) }); return ( + {advanced && } + {advanced && } @@ -1280,7 +1380,7 @@ export function DeckCostCalculator() {
ProcessTime (m)Boards / runPer board
ProcessTime (m)Boards / runRate/hr (0 = shop)Scrap %Per board
up({ name: e.target.value })} className="h-8 min-w-36" /> {numInput(p.minutes, (n) => up({ minutes: n }))} {numInput(p.boards, (n) => up({ boards: n }))}{numInput(p.rate, (n) => up({ rate: n }))}{numInput(p.scrapPct, (n) => up({ scrapPct: Math.min(99, n) }))}{fmt(per)}

Processes per board: {fmt(r.processes)}

-
@@ -1322,11 +1422,24 @@ export function DeckCostCalculator() { -
Cost / board (no packaging)
{fmt(r.costNoPack)}
-
Cost / board (with packaging)
{fmt(r.costWithPack)}
+
Cost / board (no packaging)
{fmt(r.costNoPackUnit)}
+
Cost / board (with packaging)
{fmt(r.costWithPackUnit)}
+ {advanced && ( +
+ + + + + {r.batchSetup > 0 &&
Setup / board
{fmt(r.setupPerUnit)}
} +
+ )}

- Markup {s.profitMargin}× ≈ {marginPct}% gross margin · waste {s.scrapPct}% is spread over the good boards. + Markup {s.profitMargin}× ≈ {marginPct}% gross margin · waste {r.effectiveWastePct.toFixed(1)}% is spread over the good boards.

@@ -1363,6 +1476,25 @@ export function DeckCostCalculator() { To clear {fmt(s.targetMonthlyProfit)}/month on top of fixed costs, sell {r.targetDecks} decks/month.

)} + {s.taxPct > 0 && ( +

+ {s.taxInclusive + ? <>Price includes {s.taxPct}% tax: {fmt(r.orderTax)} of the total. + : <>Plus {s.taxPct}% tax: {fmt(r.orderTax)} → {fmt(r.orderTotal)} total.} +

+ )} + {advanced && ( +
+

Cost vs quantity (setup amortised)

+ + {r.curve.map((c) => )} + + {r.curve.map((c) => )} + {r.curve.map((c) => )} + +
Decks{c.qty}
Unit price{fmt(c.unit)}
Order total{fmt(c.total)}
+
+ )}

Does not include shipping to the buyer.

{/* Quote details — your identity on the client PDF. */}