diff --git a/AGENTS.md b/AGENTS.md index a0dfda0..dded2a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -827,7 +827,19 @@ session with **no other memory of this project**. So: **gross margin %** shown (it is *not* a margin). The three `# boards` columns are context-specific: **Lifetime boards** (equipment), **Boards / order** (materials), **Boards / run** (processes). CSV keys stay machine-stable - (`overhead_factor`, `profit_margin`, …) for round-tripping. **Currency + (`overhead_factor`, `profit_margin`, …) for round-tripping. + **UX + batch/trade (2026-10-05):** the long sheet is now an **accordion** + (`CalcSection` — each header carries its live per-board subtotal; Labor open by + default, Margins always visible) with **quick-start templates** + (`SHEET_TEMPLATES`: *Hobby starter* / *Small shop*) and an amber + **sanity-warning** block (markup < 1×, wholesale ≥ retail, rows with 0 boards, + fixed costs with 0 boards/month, no materials, equipment+overhead > 60% of + cost, equipment spread over 1 board). The result adds **quantity** (unit price + vs order total), a **wholesale markup** → wholesale/unit price, **profit/deck** + 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 (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 5ddbdf0..0cc31cc 100644 --- a/src/components/DeckCostCalculator.tsx +++ b/src/components/DeckCostCalculator.tsx @@ -1,11 +1,11 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState, type ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; 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 } from 'lucide-react'; +import { Plus, Trash2, RotateCcw, Search, ChevronDown, ChevronRight, Check, Save, FolderOpen, Download, Upload, AtSign, AlertTriangle, LayoutTemplate } from 'lucide-react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useBtcRates } from '@/hooks/useExchangeRate'; import { useToast } from '@/hooks/useToast'; @@ -144,6 +144,12 @@ interface CalcState { utilities: number; subscriptions: number; profitMargin: number; + /** Trade/wholesale markup (×) — what a shop pays (normally below retail). */ + wholesaleMarkup: number; + /** Decks in this order/quote (line quantity). */ + quantity: number; + /** Monthly profit goal for the "decks to sell" line (0 = off). */ + targetMonthlyProfit: number; scrapPct: number; equipment: EquipmentRow[]; materials: MaterialRow[]; @@ -162,6 +168,9 @@ const DEFAULT_CALC: CalcState = { utilities: 100, subscriptions: 50, profitMargin: 2.5, + wholesaleMarkup: 2, + quantity: 1, + targetMonthlyProfit: 0, scrapPct: 5, equipment: [ { id: uid(), name: 'Molds', cost: 900, setupHrs: 1, boards: 10000 }, @@ -227,6 +236,60 @@ function freshDefault(): CalcState { }; } +/** A lean sheet for a first-time / hobby builder — few rows, no fixed costs. */ +function starterSheet(): CalcState { + return { + ...DEFAULT_CALC, + boardsPerMonth: 10, + rent: 0, + utilities: 0, + subscriptions: 0, + equipment: [ + { id: uid(), name: 'Press / vacuum bag', cost: 800, setupHrs: 1, boards: 500 }, + { id: uid(), name: 'Jigsaw / sander', cost: 250, setupHrs: 0, boards: 500 }, + ], + materials: [ + { id: uid(), name: 'Veneer (maple plies)', orderCost: 300, shipping: 60, boards: 40, orderMin: 10, unloadMin: 30 }, + { id: uid(), name: 'Wood glue / epoxy', orderCost: 35, shipping: 0, boards: 10, orderMin: 10, unloadMin: 0 }, + { id: uid(), name: 'Sandpaper', orderCost: 15, shipping: 0, boards: 40, orderMin: 0, unloadMin: 0 }, + { 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 }, + ], + packaging: [ + { id: uid(), name: 'Bag & sticker', minutesPerBoard: 3, materialsPerBoard: 0.1 }, + { id: uid(), name: 'Box & pack', minutesPerBoard: 6, materialsPerBoard: 0.8 }, + ], + }; +} + +interface SheetTemplate { id: string; name: string; blurb: string; build: () => CalcState } + +/** Quick-start sheets — a lean starter and the full workshop default. */ +const SHEET_TEMPLATES: SheetTemplate[] = [ + { id: 'starter', name: 'Hobby starter', blurb: 'A few rows, no fixed costs', build: starterSheet }, + { id: 'shop', name: 'Small shop', blurb: 'Full sheet with rent & overhead', build: () => freshDefault() }, +]; + +/** Collapsible sheet section with a live per-board subtotal in the header. */ +function CalcSection({ title, subtotal, defaultOpen = false, children }: { title: string; subtotal?: string; defaultOpen?: boolean; children: ReactNode }) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ + {open &&
{children}
} +
+ ); +} + // ─── CSV helpers (round-trippable sheet; also handy for supplier price lists) ── function csvEscape(v: string): string { @@ -277,6 +340,9 @@ function calcCsvRows(s: CalcState): string[][] { rows.push(['operations', 'utilities', 'value', num(s.utilities)]); rows.push(['operations', 'subscriptions', 'value', num(s.subscriptions)]); rows.push(['margins', 'profit_margin', 'value', num(s.profitMargin)]); + 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', 'scrap_pct', 'value', num(s.scrapPct)]); for (const e of s.equipment) { rows.push(['equipment', e.name, 'cost', num(e.cost)]); @@ -330,6 +396,9 @@ function calcStateFromCsv(text: string): CalcState | null { } if (section === 'margins' && field === 'value') { if (item === 'profit_margin') out.profitMargin = n(value); + 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 === 'scrap_pct') out.scrapPct = n(value); continue; } @@ -464,6 +533,15 @@ export function DeckCostCalculator() { 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; + const qty = Math.max(1, Math.round(s.quantity) || 1); + const profitPerUnit = priceWithPack - costWithPack; + 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; return { laborPerHr, laborPerMin, @@ -474,14 +552,37 @@ export function DeckCostCalculator() { packaging, costNoPack, costWithPack, - priceNoPack: costNoPack * factor, - priceWithPack: costWithPack * factor, + priceNoPack, + priceWithPack, + wholesaleUnit, + qty, + profitPerUnit, + orderTotal: priceWithPack * qty, + wholesaleTotal: wholesaleUnit * qty, + fixedMonthly, + breakevenDecks, + targetDecks, }; }, [s]); /** Markup (×) expressed as a gross margin, so the name matches the maths. */ const marginPct = s.profitMargin > 0 ? Math.round((1 - 1 / s.profitMargin) * 100) : 0; + /** Cheap sanity checks — surface the numbers that are probably mistakes. */ + const warnings = useMemo(() => { + const w: string[] = []; + if (s.profitMargin > 0 && s.profitMargin < 1) w.push('Retail markup is below 1× — you would sell at a loss.'); + if (s.wholesaleMarkup > 0 && s.profitMargin > 0 && s.wholesaleMarkup >= s.profitMargin) w.push('Wholesale markup should be lower than the retail markup.'); + const zeroRows = [...s.equipment, ...s.materials, ...s.processes].filter((row) => row.boards <= 0).length; + if (zeroRows > 0) w.push(`${zeroRows} row${zeroRows > 1 ? 's' : ''} use 0 boards — that row's cost is ignored.`); + if (r.fixedMonthly > 0 && s.boardsPerMonth <= 0) w.push('Fixed monthly costs are set but boards/month is 0, so operations cost is ignored.'); + if (r.materials <= 0) w.push('No materials cost — add at least one material.'); + if (r.costWithPack > 0 && r.equipment + r.operations > r.costWithPack * 0.6) w.push('Equipment + overhead are over 60% of the cost — this model assumes high volume; check your board counts.'); + const oneBoard = s.equipment.filter((e) => e.cost > 100 && e.boards === 1); + if (oneBoard.length > 0) w.push('Some equipment is spread over a single board — raise its board count to amortise it.'); + return w; + }, [s, r]); + const patch = (p: Partial) => setS((prev) => ({ ...prev, ...p })); const savePreset = () => { @@ -665,28 +766,29 @@ export function DeckCostCalculator() { doc.text(`Ref Q-${String(q.number).padStart(4, '0')}`, rightX, y + 12, { align: 'right' }); y += 34; + const qty = r.qty; doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 20; doc.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.setTextColor(20, 20, 20); - doc.text('Custom skateboard deck', M, y); + doc.text(`Custom skateboard deck${qty > 1 ? ` \u00d7 ${qty}` : ''}`, M, y); doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 16; doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); - doc.text('Hand-built, finished and packed. Quantity 1.', M, y); y += 16; + 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; doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(20, 20, 20); - doc.text('Total', M, y); - doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 26; + doc.text(qty > 1 ? `Total (${qty} decks)` : 'Total', M, y); + doc.text(fmt(r.orderTotal), rightX, y, { align: 'right' }); y += 26; // Payment: deposit + balance + methods + terms + delivery. ensureRoom(70); - const deposit = q.depositPct > 0 ? (r.priceWithPack * q.depositPct) / 100 : 0; + const deposit = q.depositPct > 0 ? (r.orderTotal * q.depositPct) / 100 : 0; if (deposit > 0) { doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(20, 20, 20); doc.text(`Deposit due now (${q.depositPct}%)`, M, y); doc.text(fmt(deposit), rightX, y, { align: 'right' }); y += 13; doc.setFont('helvetica', 'normal'); doc.setTextColor(120, 120, 120); doc.text('Balance on completion', M, y); - doc.text(fmt(r.priceWithPack - deposit), rightX, y, { align: 'right' }); y += 18; + doc.text(fmt(r.orderTotal - deposit), rightX, y, { align: 'right' }); y += 18; } const payLines = [ @@ -972,7 +1074,7 @@ export function DeckCostCalculator() { type="number" inputMode="decimal" value={Number.isFinite(value) ? value : 0} - onChange={(e) => onChange(Number(e.target.value) || 0)} + onChange={(e) => onChange(Math.max(0, Number(e.target.value) || 0))} className={`h-8 min-w-16 ${className ?? ''}`} /> ); @@ -1037,9 +1139,38 @@ export function DeckCostCalculator() { {fmt(r.costWithPack)} cost · {fmt(r.priceWithPack)} sell + + {/* Quick-start templates */} +
+ + Start from + + {SHEET_TEMPLATES.map((tpl) => ( + + ))} +
+ + {/* Sanity warnings */} + {warnings.length > 0 && ( +
+ {warnings.map((w, i) => ( +

+ {w} +

+ ))} +
+ )} + {/* Labor */} -
-

Labor

+

Loaded rate = wage × burden (employer taxes and insurance on labour). Rent and bills go under Operation expenses — don’t count them in both.

-
+ {/* Equipment */} -
-

Equipment

+
@@ -1083,11 +1213,10 @@ export function DeckCostCalculator() { - + {/* Operations */} -
-

Operation expenses

+
@@ -1095,11 +1224,10 @@ export function DeckCostCalculator() {

Per board: {fmt(r.operations)}

-
+ {/* Materials */} -
-

Materials

+
EquipmentCost ({unit})Setup (h)Lifetime boardsPer board
@@ -1127,11 +1255,10 @@ export function DeckCostCalculator() { - + {/* Processes */} -
-

Process costs

+
MaterialOrder ({unit})Ship ({unit})Boards / orderOrder (m)Unload (m)Per board
@@ -1156,11 +1283,10 @@ export function DeckCostCalculator() { - + {/* Packaging */} -
-

Packaging

+
ProcessTime (m)Boards / runPer board
@@ -1185,14 +1311,17 @@ export function DeckCostCalculator() { - + {/* Margins + results */}

Margins

+ + +
Cost / board (no packaging)
{fmt(r.costNoPack)}
Cost / board (with packaging)
{fmt(r.costWithPack)}
@@ -1210,6 +1339,30 @@ export function DeckCostCalculator() {

{fmt(r.priceWithPack)}

+ +
+
+

Profit / deck

+

{fmt(r.profitPerUnit)}

+
+
+

Wholesale / deck

+

{fmt(r.wholesaleUnit)}

+
+
+

{r.qty > 1 ? `Order total (${r.qty})` : 'Order total'}

+

{fmt(r.orderTotal)}

+
+
+

Breakeven

+

{r.breakevenDecks > 0 ? `${r.breakevenDecks}/mo` : '—'}

+
+
+ {r.targetDecks > 0 && ( +

+ To clear {fmt(s.targetMonthlyProfit)}/month on top of fixed costs, sell {r.targetDecks} decks/month. +

+ )}

Does not include shipping to the buyer.

{/* Quote details — your identity on the client PDF. */}
PackagingTime/board (m)Materials/board ({unit})Per board