Deck calculator: Advanced mode — per-op rate/scrap, batch setup, tax, cost curve

A Simple <-> Advanced switch (not "Pro" — that name is Sasquatch Pro). Advanced
reveals four things a working shop needs:

- Per-operation labour rate (0 = shop rate) and per-operation scrap, which
  compounds with the general scrap into an effective waste %.
- One-off batch setup (hours + cost), amortised over the order quantity, so the
  unit price depends on qty; cost/board now uses the setup-inclusive unit cost.
- A tax / VAT line (inclusive or added on top), shown on the client quote as
  Subtotal / Tax / Total or "includes X%".
- A cost-vs-quantity curve (1/5/10/25/50/100 decks).

All fields round-trip through CSV. The sheet is merged over DEFAULT_CALC on read
and clampPct is NaN-safe, so an old saved sheet or preset can't produce NaN;
currency switching also converts the new money fields.
This commit is contained in:
SkateSpots 2026-10-05 12:38:58 +07:00
parent df92399abf
commit 475497b205
2 changed files with 188 additions and 42 deletions

View file

@ -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**

View file

@ -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<CalcState>('skatespots:deck-cost:v2', DEFAULT_CALC);
const [rawS, setS] = useLocalStorage<CalcState>('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<CalcState>(() => ({ ...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<boolean>('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}
</button>
))}
<button
type="button"
onClick={() => setAdvanced((a) => !a)}
className={`ml-auto inline-flex min-h-0 items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold ${advanced ? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-[#ccff00]' : 'border-white/15 text-gray-300 hover:bg-white/5'}`}
title="Reveal per-operation rates and scrap, batch setup, tax and the cost-vs-quantity curve"
>
<SlidersHorizontal className="h-3.5 w-3.5" /> {advanced ? 'Advanced' : 'Simple'}
</button>
</div>
{/* Sanity warnings */}
@ -1261,16 +1358,19 @@ export function DeckCostCalculator() {
<CalcSection title="Process costs" subtotal={`${fmt(r.processes)}/board`}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead><tr><th className={th}>Process</th><th className={th}>Time (m)</th><th className={th}>Boards / run</th><th className={th}>Per board</th><th /></tr></thead>
<thead><tr><th className={th}>Process</th><th className={th}>Time (m)</th><th className={th}>Boards / run</th>{advanced && <th className={th}>Rate/hr (0 = shop)</th>}{advanced && <th className={th}>Scrap %</th>}<th className={th}>Per board</th><th /></tr></thead>
<tbody>
{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<ProcessRow>) => patch({ processes: s.processes.map((x) => x.id === p.id ? { ...x, ...pp } : x) });
return (
<tr key={p.id} className="border-t border-white/5">
<td className={td}><Input value={p.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td}>{numInput(p.minutes, (n) => up({ minutes: n }))}</td>
<td className={td}>{numInput(p.boards, (n) => up({ boards: n }))}</td>
{advanced && <td className={td}>{numInput(p.rate, (n) => up({ rate: n }))}</td>}
{advanced && <td className={td}>{numInput(p.scrapPct, (n) => up({ scrapPct: Math.min(99, n) }))}</td>}
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(per)}</td>
<td className={td}><button onClick={() => patch({ processes: s.processes.filter((x) => x.id !== p.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button></td>
</tr>
@ -1280,7 +1380,7 @@ export function DeckCostCalculator() {
</table>
</div>
<p className="mt-2 text-xs text-gray-500">Processes per board: <span className="font-bold text-gray-300">{fmt(r.processes)}</span></p>
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ processes: [...s.processes, { id: uid(), name: 'New', minutes: 0, boards: 1 }] })}>
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ processes: [...s.processes, { id: uid(), name: 'New', minutes: 0, boards: 1, rate: 0, scrapPct: 0 }] })}>
<Plus className="h-3.5 w-3.5" /> Add process
</Button>
</CalcSection>
@ -1322,11 +1422,24 @@ export function DeckCostCalculator() {
<label className="space-y-1"><span className={label}>Scrap / waste (%)</span>{numInput(s.scrapPct, (n) => patch({ scrapPct: n }))}</label>
<label className="space-y-1"><span className={label}>Quantity (decks)</span>{numInput(s.quantity, (n) => patch({ quantity: Math.max(1, Math.round(n) || 1) }))}</label>
<label className="space-y-1"><span className={label}>Target monthly profit ({unit})</span>{numInput(s.targetMonthlyProfit, (n) => patch({ targetMonthlyProfit: n }))}</label>
<div className="space-y-1"><span className={label}>Cost / board (no packaging)</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.costNoPack)}</div></div>
<div className="space-y-1"><span className={label}>Cost / board (with packaging)</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.costWithPack)}</div></div>
<div className="space-y-1"><span className={label}>Cost / board (no packaging)</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.costNoPackUnit)}</div></div>
<div className="space-y-1"><span className={label}>Cost / board (with packaging)</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.costWithPackUnit)}</div></div>
</div>
{advanced && (
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-3 rounded-xl border border-white/10 bg-black/20 p-3">
<label className="space-y-1"><span className={label}>Batch setup (h / run)</span>{numInput(s.batchSetupHours, (n) => patch({ batchSetupHours: n }))}</label>
<label className="space-y-1"><span className={label}>Batch setup cost ({unit} / run)</span>{numInput(s.batchSetupCost, (n) => patch({ batchSetupCost: n }))}</label>
<label className="space-y-1"><span className={label}>Tax / VAT (%)</span>{numInput(s.taxPct, (n) => patch({ taxPct: Math.min(99, n) }))}</label>
<label className="space-y-1"><span className={label}>Quote tax</span>
<button type="button" onClick={() => patch({ taxInclusive: !s.taxInclusive })} className="flex h-8 w-full items-center rounded-md border border-white/10 bg-black/40 px-2 text-xs font-bold text-gray-200 hover:bg-white/5">
{s.taxInclusive ? 'Included in price' : 'Added on top'}
</button>
</label>
{r.batchSetup > 0 && <div className="space-y-1"><span className={label}>Setup / board</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.setupPerUnit)}</div></div>}
</div>
)}
<p className="mt-2 text-[11px] text-gray-500">
Markup {s.profitMargin}× ≈ <span className="text-gray-300">{marginPct}% gross margin</span> · waste {s.scrapPct}% is spread over the good boards.
Markup {s.profitMargin}× ≈ <span className="text-gray-300">{marginPct}% gross margin</span> · waste {r.effectiveWastePct.toFixed(1)}% is spread over the good boards.
</p>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
@ -1363,6 +1476,25 @@ export function DeckCostCalculator() {
To clear {fmt(s.targetMonthlyProfit)}/month on top of fixed costs, sell <span className="text-gray-300">{r.targetDecks} decks/month</span>.
</p>
)}
{s.taxPct > 0 && (
<p className="mt-2 text-[11px] text-gray-500">
{s.taxInclusive
? <>Price includes {s.taxPct}% tax: <span className="text-gray-300">{fmt(r.orderTax)}</span> of the total.</>
: <>Plus {s.taxPct}% tax: <span className="text-gray-300">{fmt(r.orderTax)}</span> → {fmt(r.orderTotal)} total.</>}
</p>
)}
{advanced && (
<div className="mt-3 overflow-x-auto rounded-xl border border-white/10 bg-black/20 p-3">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-gray-500">Cost vs quantity (setup amortised)</p>
<table className="w-full text-xs">
<thead><tr><th className={th}>Decks</th>{r.curve.map((c) => <th key={c.qty} className={th}>{c.qty}</th>)}</tr></thead>
<tbody>
<tr className="border-t border-white/5"><td className={td}>Unit price</td>{r.curve.map((c) => <td key={c.qty} className={td}>{fmt(c.unit)}</td>)}</tr>
<tr className="border-t border-white/5"><td className={td}>Order total</td>{r.curve.map((c) => <td key={c.qty} className={td}>{fmt(c.total)}</td>)}</tr>
</tbody>
</table>
</div>
)}
<p className="mt-2 text-[11px] text-gray-500">Does not include shipping to the buyer.</p>
{/* Quote details — your identity on the client PDF. */}