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:
parent
3b8fadad2c
commit
df92399abf
2 changed files with 195 additions and 30 deletions
14
AGENTS.md
14
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**
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue