Deck calculator: collapsible sections, templates, batch/trade pricing, warnings

A+B+C pass on /deck-cost.

A - Declutter: each sheet section is now an accordion (CalcSection) with its
live per-board subtotal in the header (Labor open by default, Margins always
visible), plus quick-start templates (Hobby starter / Small shop) that prefll a
realistic sheet.

B - Batch & trade: a quantity field (unit price vs order total), a wholesale
markup giving a wholesale/unit price, profit per deck, and a breakeven
(fixed / profit) with an optional target-monthly-profit goal. The client quote
shows "x N" and the order total; the deposit is taken on the total.

C - Guards: an amber warning block for markup < 1x, wholesale >= retail, rows
with 0 boards, fixed costs with 0 boards/month, no materials, equipment+overhead
over 60% of cost, and equipment spread over a single board; number inputs clamp
negatives to 0.

New state round-trips through CSV (margins rows). The default sheet produces no
warnings.
This commit is contained in:
SkateSpots 2026-10-05 12:25:27 +07:00
parent 3b8fadad2c
commit df92399abf
2 changed files with 195 additions and 30 deletions

View file

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

View file

@ -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 (
<section className="rounded-2xl border border-white/10 bg-white/[0.02]">
<button type="button" onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-2 px-4 py-4 text-left">
<ChevronRight className={cn('h-4 w-4 shrink-0 text-gray-500 transition-transform', open && 'rotate-90')} />
<h3 className="text-sm font-black uppercase tracking-wide text-[#ccff00]">{title}</h3>
{subtotal && <span className="ml-auto text-xs font-bold text-gray-300">{subtotal}</span>}
</button>
{open && <div className="px-4 pb-4">{children}</div>}
</section>
);
}
// ─── 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<CalcState>) => 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 · <span className="text-[#ccff00]">{fmt(r.priceWithPack)} sell</span>
</span>
</div>
{/* Quick-start templates */}
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider text-gray-500">
<LayoutTemplate className="h-3.5 w-3.5" /> Start from
</span>
{SHEET_TEMPLATES.map((tpl) => (
<button
key={tpl.id}
type="button"
title={tpl.blurb}
onClick={() => setS(tpl.build())}
className="min-h-0 rounded-full border border-white/15 px-3 py-1 text-xs font-semibold text-gray-300 hover:border-[#ccff00]/60 hover:text-white"
>
{tpl.name}
</button>
))}
</div>
{/* Sanity warnings */}
{warnings.length > 0 && (
<div className="space-y-1 rounded-xl border border-amber-500/30 bg-amber-500/[0.06] p-3">
{warnings.map((w, i) => (
<p key={i} className="flex items-start gap-2 text-[11px] text-amber-200/90">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" /> {w}
</p>
))}
</div>
)}
{/* Labor */}
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<h3 className="mb-3 text-sm font-black uppercase tracking-wide text-[#ccff00]">Labor</h3>
<CalcSection title="Labor" subtotal={`${fmt(r.laborPerHr)}/hr loaded`} defaultOpen>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<label className="space-y-1">
<span className={label}>Hourly rate ({unit})</span>
@ -1057,11 +1188,10 @@ export function DeckCostCalculator() {
</div>
</div>
<p className="mt-2 text-[11px] text-gray-500">Loaded rate = wage × burden (employer taxes and insurance on labour). Rent and bills go under <span className="text-gray-300">Operation expenses</span> — don’t count them in both.</p>
</section>
</CalcSection>
{/* Equipment */}
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<h3 className="mb-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">Equipment</h3>
<CalcSection title="Equipment" subtotal={`${fmt(r.equipment)}/board`}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead><tr><th className={th}>Equipment</th><th className={th}>Cost ({unit})</th><th className={th}>Setup (h)</th><th className={th}>Lifetime boards</th><th className={th}>Per board</th><th /></tr></thead>
@ -1083,11 +1213,10 @@ export function DeckCostCalculator() {
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ equipment: [...s.equipment, { id: uid(), name: 'New', cost: 0, setupHrs: 0, boards: 1 }] })}>
<Plus className="h-3.5 w-3.5" /> Add equipment
</Button>
</section>
</CalcSection>
{/* Operations */}
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<h3 className="mb-3 text-sm font-black uppercase tracking-wide text-[#ccff00]">Operation expenses</h3>
<CalcSection title="Operation expenses" subtotal={`${fmt(r.operations)}/board`}>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<label className="space-y-1"><span className={label}>Boards / month</span>{numInput(s.boardsPerMonth, (n) => patch({ boardsPerMonth: n }))}</label>
<label className="space-y-1"><span className={label}>Rent / month</span>{numInput(s.rent, (n) => patch({ rent: n }))}</label>
@ -1095,11 +1224,10 @@ export function DeckCostCalculator() {
<label className="space-y-1"><span className={label}>Subscriptions / month</span>{numInput(s.subscriptions, (n) => patch({ subscriptions: n }))}</label>
</div>
<p className="mt-2 text-xs text-gray-500">Per board: <span className="font-bold text-gray-300">{fmt(r.operations)}</span></p>
</section>
</CalcSection>
{/* Materials */}
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<h3 className="mb-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">Materials</h3>
<CalcSection title="Materials" subtotal={`${fmt(r.materials)}/board`}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead><tr><th className={th}>Material</th><th className={th}>Order ({unit})</th><th className={th}>Ship ({unit})</th><th className={th}>Boards / order</th><th className={th}>Order (m)</th><th className={th}>Unload (m)</th><th className={th}>Per board</th><th /></tr></thead>
@ -1127,11 +1255,10 @@ export function DeckCostCalculator() {
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ materials: [...s.materials, { id: uid(), name: 'New', orderCost: 0, shipping: 0, boards: 1, orderMin: 0, unloadMin: 0 }] })}>
<Plus className="h-3.5 w-3.5" /> Add material
</Button>
</section>
</CalcSection>
{/* Processes */}
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<h3 className="mb-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">Process costs</h3>
<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>
@ -1156,11 +1283,10 @@ export function DeckCostCalculator() {
<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 }] })}>
<Plus className="h-3.5 w-3.5" /> Add process
</Button>
</section>
</CalcSection>
{/* Packaging */}
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<h3 className="mb-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">Packaging</h3>
<CalcSection title="Packaging" subtotal={`${fmt(r.packaging)}/board`}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead><tr><th className={th}>Packaging</th><th className={th}>Time/board (m)</th><th className={th}>Materials/board ({unit})</th><th className={th}>Per board</th><th /></tr></thead>
@ -1185,14 +1311,17 @@ export function DeckCostCalculator() {
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ packaging: [...s.packaging, { id: uid(), name: 'New', minutesPerBoard: 0, materialsPerBoard: 0 }] })}>
<Plus className="h-3.5 w-3.5" /> Add packaging step
</Button>
</section>
</CalcSection>
{/* Margins + results */}
<section className="rounded-2xl border-2 border-[#ccff00]/30 bg-[#ccff00]/[0.04] p-4">
<h3 className="mb-3 text-sm font-black uppercase tracking-wide text-[#ccff00]">Margins</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<label className="space-y-1"><span className={label}>Markup (×)</span>{numInput(s.profitMargin, (n) => patch({ profitMargin: n }))}</label>
<label className="space-y-1"><span className={label}>Wholesale markup (×)</span>{numInput(s.wholesaleMarkup, (n) => patch({ wholesaleMarkup: n }))}</label>
<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>
@ -1210,6 +1339,30 @@ export function DeckCostCalculator() {
<p className="mt-1 text-2xl font-black text-[#ccff00]">{fmt(r.priceWithPack)}</p>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
<div className="rounded-lg border border-white/10 bg-black/30 p-2.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Profit / deck</p>
<p className="mt-0.5 text-sm font-black text-gray-100">{fmt(r.profitPerUnit)}</p>
</div>
<div className="rounded-lg border border-white/10 bg-black/30 p-2.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Wholesale / deck</p>
<p className="mt-0.5 text-sm font-black text-gray-100">{fmt(r.wholesaleUnit)}</p>
</div>
<div className="rounded-lg border border-white/10 bg-black/30 p-2.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">{r.qty > 1 ? `Order total (${r.qty})` : 'Order total'}</p>
<p className="mt-0.5 text-sm font-black text-gray-100">{fmt(r.orderTotal)}</p>
</div>
<div className="rounded-lg border border-white/10 bg-black/30 p-2.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Breakeven</p>
<p className="mt-0.5 text-sm font-black text-gray-100">{r.breakevenDecks > 0 ? `${r.breakevenDecks}/mo` : '—'}</p>
</div>
</div>
{r.targetDecks > 0 && (
<p className="mt-2 text-[11px] text-gray-500">
To clear {fmt(s.targetMonthlyProfit)}/month on top of fixed costs, sell <span className="text-gray-300">{r.targetDecks} decks/month</span>.
</p>
)}
<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. */}