SkateSpots/src/components/DeckCostCalculator.tsx
SkateSpots 3f90967f6c Deck calculator: quote number/validity, logo on PNG, shared quote profile
- Extract the client-quote identity into its own store (QuoteProfile,
  skatespots:deck-cost:quote) so it is NOT part of CalcState — loading or saving
  a preset now keeps the same business name/logo/contact details.
- Client quote PDF: a real quote number counter (Q-0007, with a +1 button) and a
  configurable validity window, shown as "Quote valid until <date>".
- PNG summary card now also draws the logo (top-right) and the business name.
2026-10-05 11:04:20 +07:00

1229 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
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 } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useBtcRates } from '@/hooks/useExchangeRate';
import { useToast } from '@/hooks/useToast';
import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { cn } from '@/lib/utils';
/**
* Deck cost calculator — a self-contained tool for working out what a deck
* costs to make and a fair selling price (labour, equipment amortisation,
* operation expenses, materials, process time, packaging, margin, scrap).
*
* No server, no accounts: state lives in localStorage. Mirrors the well-known
* open-source skateboard pricing model. Column headers stay English on purpose
* (technical terms); results are labelled by the host page.
*/
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 PackageRow { id: string; name: string; minutesPerBoard: number; materialsPerBoard: number }
type CurrencyCode = string;
const CURRENCY_SYMBOLS: Record<string, string> = {
USD: '$', EUR: '€', GBP: '£', JPY: '¥', CNY: '¥', AUD: 'A$', CAD: 'C$', CHF: 'CHF', HKD: 'HK$',
SGD: 'S$', NZD: 'NZ$', SEK: 'kr', NOK: 'kr', DKK: 'kr', PLN: 'zł', CZK: 'Kč', HUF: 'Ft',
RON: 'lei', BGN: 'лв', TRY: '₺', RUB: '₽', UAH: '₴', INR: '₹', KRW: '₩', TWD: 'NT$',
THB: '฿', MYR: 'RM', IDR: 'Rp', PHP: '₱', VND: '₫', BRL: 'R$', MXN: 'MX$', ARS: 'AR$',
CLP: 'CL$', COP: 'CO$', PEN: 'S/', ZAR: 'R', NGN: '₦', KES: 'KSh', GHS: '₵', EGP: 'E£',
ILS: '₪', PKR: '₨', BDT: '৳', LKR: 'Rs', NPR: 'Rs',
};
/** ISO-4217 fiat codes — the only unit currencies we offer (plus `sats`). Keeps
* crypto/stables (BTC, ETH, USDT, …) out of the picker. */
const FIAT_CODES = new Set([
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN',
'BHD', 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF',
'CLP', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB',
'EUR', 'FJD', 'FKP', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL',
'HRK', 'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'IRR', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY',
'KES', 'KGS', 'KHR', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD',
'MAD', 'MDL', 'MGA', 'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN',
'NAD', 'NGN', 'NIO', 'NOK', 'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG',
'QAR', 'RON', 'RSD', 'RUB', 'RWF', 'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLL', 'SOS',
'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB', 'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TWD', 'TZS',
'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES', 'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XOF', 'XPF', 'YER',
'ZAR', 'ZMW',
]);
/** How a currency is shown: `$1.00`, `CHF 1.00`, `1,000 sats`, `0.00012345 BTC`. */
function formatMoney(v: number, cur: string): string {
if (cur === 'sats') return `${Math.round(v).toLocaleString()} sats`;
if (cur === 'BTC') {
const str = v.toFixed(8).replace(/0+$/, '').replace(/\.$/, '');
return `${str || '0'} BTC`;
}
const s = CURRENCY_SYMBOLS[cur];
const n = v.toFixed(2);
if (!s) return `${cur} ${n}`;
return /[A-Za-z]/.test(s) ? `${s} ${n}` : `${s}${n}`;
}
/** Bitcoin is one asset shown two ways — `sats` and `BTC` are both offered. */
const BITCOIN_UNITS: Record<string, number> = { sats: 1e8, BTC: 1 };
/** Units of a currency per 1 BTC (sats/BTC = fixed; fiats come from rates). */
function ratePerBtc(cur: string, rates?: Record<string, number>): number | undefined {
return BITCOIN_UNITS[cur] ?? rates?.[cur];
}
/** Chip/trigger label, e.g. `₿ sats`, `₿ BTC`, `$ USD`. */
function currencyLabel(cur: string): string {
if (cur === 'sats') return '₿ sats';
if (cur === 'BTC') return '₿ BTC';
const s = CURRENCY_SYMBOLS[cur];
return s ? `${s} ${cur}` : cur;
}
/** Client-quote identity — kept OUT of the sheet so presets share one identity. */
interface QuoteProfile {
businessName: string;
/** Data URL — no upload/network. */
logo: string;
email: string;
phone: string;
website: string;
address: string;
notes: string;
/** Quote number counter (displayed as `Q-0007`). */
number: number;
/** Days the quote stays valid. */
validDays: number;
}
const DEFAULT_QUOTE: QuoteProfile = {
businessName: '', logo: '', email: '', phone: '', website: '', address: '', notes: '', number: 1, validDays: 30,
};
interface CalcState {
currency: CurrencyCode;
hourlyRate: number;
overheadFactor: number;
boardsPerMonth: number;
rent: number;
utilities: number;
subscriptions: number;
profitMargin: number;
scrapPct: number;
equipment: EquipmentRow[];
materials: MaterialRow[];
processes: ProcessRow[];
packaging: PackageRow[];
}
const uid = () => Math.random().toString(36).slice(2, 9);
const DEFAULT_CALC: CalcState = {
currency: 'USD',
hourlyRate: 20,
overheadFactor: 2.5,
boardsPerMonth: 160,
rent: 1000,
utilities: 100,
subscriptions: 50,
profitMargin: 2.5,
scrapPct: 5,
equipment: [
{ id: uid(), name: 'Molds', cost: 900, setupHrs: 1, boards: 10000 },
{ id: uid(), name: 'Press', cost: 5000, setupHrs: 1, boards: 5000 },
{ id: uid(), name: '3D Printer', cost: 900, setupHrs: 8, boards: 100000 },
{ id: uid(), name: 'CNC Router', cost: 6000, setupHrs: 24, boards: 40000 },
{ id: uid(), name: 'Saws', cost: 200, setupHrs: 0, boards: 10000 },
{ id: uid(), name: 'Sanders', cost: 200, setupHrs: 0, boards: 5000 },
{ id: uid(), name: 'Spraying Equipment', cost: 500, setupHrs: 0, boards: 100000 },
{ id: uid(), name: 'Graphics Equipment', cost: 400, setupHrs: 8, boards: 10000 },
{ id: uid(), name: 'Other Equipment', cost: 0, setupHrs: 0, boards: 100000 },
],
materials: [
// Wood & core
{ id: uid(), name: 'Veneer (maple plies)', orderCost: 1500, shipping: 500, boards: 200, orderMin: 15, unloadMin: 120 },
{ id: uid(), name: 'Veneer tape / edge tape', orderCost: 0, shipping: 0, boards: 100, orderMin: 5, unloadMin: 0 },
{ id: uid(), name: 'Logs (if milling your own veneer)', orderCost: 80, shipping: 0, boards: 500, orderMin: 0, unloadMin: 0 },
// Adhesives & fillers
{ id: uid(), name: 'Wood glue / epoxy resin', orderCost: 35, shipping: 0, boards: 10, orderMin: 20, unloadMin: 0 },
{ id: uid(), name: 'Wood filler / putty', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
// Press / mold consumables
{ id: uid(), name: 'Mold release / wax paper', orderCost: 0, shipping: 0, boards: 100, orderMin: 0, unloadMin: 0 },
{ id: uid(), name: 'Vacuum bag / breather film', orderCost: 0, shipping: 0, boards: 200, orderMin: 0, unloadMin: 0 },
// Shaping consumables
{ id: uid(), name: 'Sandpaper', orderCost: 30, shipping: 0, boards: 100, orderMin: 5, unloadMin: 15 },
{ id: uid(), name: 'Saw blades', orderCost: 20, shipping: 0, boards: 500, orderMin: 5, unloadMin: 0 },
{ id: uid(), name: 'Drill bits / router bits', orderCost: 50, shipping: 0, boards: 500, orderMin: 5, unloadMin: 0 },
{ id: uid(), name: 'Masking tape', orderCost: 0, shipping: 0, boards: 100, orderMin: 0, unloadMin: 0 },
// Finish & graphics
{ id: uid(), name: 'Stain / dye', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
{ id: uid(), name: 'Primer / base coat', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
{ id: uid(), name: 'Paint / screen-print ink', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
{ id: uid(), name: 'Clearcoat / lacquer', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
{ id: uid(), name: 'Grip tape', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
{ 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 },
],
packaging: [
{ id: uid(), name: 'Sticker & poly bag', minutesPerBoard: 4, materialsPerBoard: 0.06 },
{ id: uid(), name: 'Pack board', minutesPerBoard: 8, materialsPerBoard: 0.67 },
{ id: uid(), name: 'Drop off', minutesPerBoard: 10, materialsPerBoard: 0 },
],
};
/** A pristine default with fresh row ids (so React keys don't collide). */
function freshDefault(): CalcState {
return {
...DEFAULT_CALC,
equipment: DEFAULT_CALC.equipment.map((e) => ({ ...e, id: uid() })),
materials: DEFAULT_CALC.materials.map((m) => ({ ...m, id: uid() })),
processes: DEFAULT_CALC.processes.map((p) => ({ ...p, id: uid() })),
packaging: DEFAULT_CALC.packaging.map((p) => ({ ...p, id: uid() })),
};
}
// ─── CSV helpers (round-trippable sheet; also handy for supplier price lists) ──
function csvEscape(v: string): string {
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
}
function toCsv(rows: string[][]): string {
return rows.map((r) => r.map(csvEscape).join(',')).join('\n');
}
function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; }
} else field += c;
} else if (c === '"') inQuotes = true;
else if (c === ',') { row.push(field); field = ''; }
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
else if (c !== '\r') field += c;
}
if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); }
return rows.filter((r) => r.some((c) => c !== ''));
}
/** Load an image data URL (for embedding a quote logo in the PDF). */
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
/** Rows for the CSV export: `section,item,field,value`. */
function calcCsvRows(s: CalcState): string[][] {
const rows: string[][] = [['section', 'item', 'field', 'value']];
const num = (n: number) => String(n);
rows.push(['settings', 'currency', 'value', s.currency]);
rows.push(['labor', 'hourly_rate', 'value', num(s.hourlyRate)]);
rows.push(['labor', 'overhead_factor', 'value', num(s.overheadFactor)]);
rows.push(['operations', 'boards_per_month', 'value', num(s.boardsPerMonth)]);
rows.push(['operations', 'rent', 'value', num(s.rent)]);
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', 'scrap_pct', 'value', num(s.scrapPct)]);
for (const e of s.equipment) {
rows.push(['equipment', e.name, 'cost', num(e.cost)]);
rows.push(['equipment', e.name, 'setup_hrs', num(e.setupHrs)]);
rows.push(['equipment', e.name, 'boards', num(e.boards)]);
}
for (const m of s.materials) {
rows.push(['materials', m.name, 'order_cost', num(m.orderCost)]);
rows.push(['materials', m.name, 'shipping', num(m.shipping)]);
rows.push(['materials', m.name, 'boards', num(m.boards)]);
rows.push(['materials', m.name, 'order_min', num(m.orderMin)]);
rows.push(['materials', m.name, 'unload_min', num(m.unloadMin)]);
}
for (const p of s.processes) {
rows.push(['processes', p.name, 'minutes', num(p.minutes)]);
rows.push(['processes', p.name, 'boards', num(p.boards)]);
}
for (const p of s.packaging) {
rows.push(['packaging', p.name, 'minutes_per_board', num(p.minutesPerBoard)]);
rows.push(['packaging', p.name, 'materials_per_board', num(p.materialsPerBoard)]);
}
return rows;
}
/** Parse an exported CSV back into a CalcState (unknown sections ignored). */
function calcStateFromCsv(text: string): CalcState | null {
const rows = parseCsv(text);
if (rows.length < 2) return null;
const out = freshDefault();
const eq = new Map<string, EquipmentRow>();
const ma = new Map<string, MaterialRow>();
const pr = new Map<string, ProcessRow>();
const pk = new Map<string, PackageRow>();
const n = (v: string) => Number(v) || 0;
for (const r of rows.slice(1)) {
const [section, item, field, value] = r;
if (!section || !field) continue;
if (section === 'settings' && field === 'value') { out.currency = item || 'USD'; continue; }
if (section === 'labor' && field === 'value') {
if (item === 'hourly_rate') out.hourlyRate = n(value);
else if (item === 'overhead_factor') out.overheadFactor = n(value);
continue;
}
if (section === 'operations' && field === 'value') {
if (item === 'boards_per_month') out.boardsPerMonth = n(value);
else if (item === 'rent') out.rent = n(value);
else if (item === 'utilities') out.utilities = n(value);
else if (item === 'subscriptions') out.subscriptions = n(value);
continue;
}
if (section === 'margins' && field === 'value') {
if (item === 'profit_margin') out.profitMargin = n(value);
else if (item === 'scrap_pct') out.scrapPct = n(value);
continue;
}
if (section === 'equipment') {
const row = eq.get(item) ?? { id: uid(), name: item, cost: 0, setupHrs: 0, boards: 1 };
if (field === 'cost') row.cost = n(value);
else if (field === 'setup_hrs') row.setupHrs = n(value);
else if (field === 'boards') row.boards = n(value);
eq.set(item, row);
} else if (section === 'materials') {
const row = ma.get(item) ?? { id: uid(), name: item, orderCost: 0, shipping: 0, boards: 1, orderMin: 0, unloadMin: 0 };
if (field === 'order_cost') row.orderCost = n(value);
else if (field === 'shipping') row.shipping = n(value);
else if (field === 'boards') row.boards = n(value);
else if (field === 'order_min') row.orderMin = n(value);
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 };
if (field === 'minutes') row.minutes = n(value);
else if (field === 'boards') row.boards = n(value);
pr.set(item, row);
} else if (section === 'packaging') {
const row = pk.get(item) ?? { id: uid(), name: item, minutesPerBoard: 0, materialsPerBoard: 0 };
if (field === 'minutes_per_board') row.minutesPerBoard = n(value);
else if (field === 'materials_per_board') row.materialsPerBoard = n(value);
pk.set(item, row);
}
}
out.equipment = [...eq.values()];
out.materials = [...ma.values()];
out.processes = [...pr.values()];
out.packaging = [...pk.values()];
return out;
}
/** Supplier price-list shape: `name,order_cost,shipping,boards,order_min,unload_min`. */function materialsFromSupplierCsv(text: string): MaterialRow[] | null {
const rows = parseCsv(text);
if (rows.length < 2) return null;
const header = rows[0].map((h) => h.trim().toLowerCase());
const idx = (n: string) => header.indexOf(n);
if (idx('name') !== 0 || idx('order_cost') < 0) return null;
const out: MaterialRow[] = [];
for (const r of rows.slice(1)) {
const name = (r[idx('name')] ?? '').trim();
if (!name) continue;
const n = (col: string) => (idx(col) >= 0 ? Number(r[idx(col)]) || 0 : 0);
out.push({
id: uid(),
name,
orderCost: n('order_cost'),
shipping: n('shipping'),
boards: n('boards') || 1,
orderMin: n('order_min'),
unloadMin: n('unload_min'),
});
}
return out.length > 0 ? out : null;
}
export function DeckCostCalculator() {
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost:v2', DEFAULT_CALC);
const { data: rates } = useBtcRates();
const { toast } = useToast();
// Quote identity lives in its own store, so switching presets keeps the same
// business name / logo / contact details.
const [quote, setQuote] = useLocalStorage<QuoteProfile>('skatespots:deck-cost:quote', DEFAULT_QUOTE);
const [pickerOpen, setPickerOpen] = useState(false);
const [query, setQuery] = useState('');
const [presets, setPresets] = useLocalStorage<{ name: string; state: CalcState }[]>('skatespots:deck-cost-presets', []);
const [loadOpen, setLoadOpen] = useState(false);
const cur = s.currency;
const unit = cur === 'sats' ? 'sats' : (CURRENCY_SYMBOLS[cur] ?? cur);
const fmt = (n: number) => formatMoney(Number.isFinite(n) ? n : 0, cur);
const currencies = useMemo(() => {
const fiat = rates
? Object.keys(rates).filter((c) => FIAT_CODES.has(c)).sort()
: ['USD', 'EUR', 'GBP'];
return ['sats', 'BTC', ...fiat];
}, [rates]);
const filteredCurrencies = query
? currencies.filter((c) => c.toLowerCase().includes(query.toLowerCase()))
: currencies;
// Convert every MONEY field when the unit changes (unitless fields like the
// overhead factor, board counts, minutes and margins are left untouched).
const changeCurrency = (to: CurrencyCode) => {
setPickerOpen(false);
setQuery('');
setS((prev) => {
if (to === prev.currency) return prev;
const rf = ratePerBtc(prev.currency, rates);
const rt = ratePerBtc(to, rates);
if (!rf || !rt) return { ...prev, currency: to }; // no rate → relabel only
const k = rt / rf;
const r2 = (v: number) =>
to === 'sats' ? Math.round(v * k)
: to === 'BTC' ? Math.round(v * k * 1e8) / 1e8
: Math.round(v * k * 100) / 100;
return {
...prev,
currency: to,
hourlyRate: r2(prev.hourlyRate),
rent: r2(prev.rent),
utilities: r2(prev.utilities),
subscriptions: r2(prev.subscriptions),
equipment: prev.equipment.map((e) => ({ ...e, cost: r2(e.cost) })),
materials: prev.materials.map((m) => ({ ...m, orderCost: r2(m.orderCost), shipping: r2(m.shipping) })),
packaging: prev.packaging.map((p) => ({ ...p, materialsPerBoard: r2(p.materialsPerBoard) })),
};
});
};
const r = useMemo(() => {
const laborPerHr = s.hourlyRate * s.overheadFactor;
const laborPerMin = laborPerHr / 60;
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 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;
return {
laborPerHr,
laborPerMin,
equipment,
operations,
materials,
processes,
packaging,
costNoPack,
costWithPack,
priceNoPack: costNoPack * factor,
priceWithPack: costWithPack * factor,
};
}, [s]);
const patch = (p: Partial<CalcState>) => setS((prev) => ({ ...prev, ...p }));
const savePreset = () => {
const name = window.prompt('Name this preset', `Preset ${presets.length + 1}`);
if (!name) return;
setPresets((prev) => [...prev.filter((p) => p.name !== name), { name, state: s }]);
};
const loadPreset = (state: CalcState) => {
// Presets hold the sheet only — the quote identity is stored separately.
setS({ ...freshDefault(), ...state });
setLoadOpen(false);
};
const deletePreset = (name: string) => setPresets((prev) => prev.filter((p) => p.name !== name));
const exportJson = () => {
const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'deck-cost.json';
a.click();
URL.revokeObjectURL(url);
};
// A real PDF (jsPDF) — print-to-PDF doesn't work inside the native WebView.
// jsPDF is imported lazily so it never touches the initial bundle.
const exportPdf = async () => {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF({ unit: 'pt', format: 'a4' });
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const M = 48;
const rightX = pageW - M;
const contentW = pageW - M * 2;
let y = M + 8;
const ensure = (h: number) => { if (y + h > pageH - M) { doc.addPage(); y = M; } };
const setText = (size: number, bold: boolean, gray: number) => {
doc.setFontSize(size);
doc.setFont('helvetica', bold ? 'bold' : 'normal');
doc.setTextColor(gray, gray, gray);
};
const section = (title: string) => {
ensure(52);
y += 12;
setText(10, true, 40); doc.text(title.toUpperCase(), M, y); y += 6;
doc.setDrawColor(180, 220, 0); doc.setLineWidth(1.4); doc.line(M, y, M + 26, y); y += 12;
doc.setDrawColor(230, 230, 230); doc.setLineWidth(0.6); doc.line(M, y, rightX, y); y += 12;
};
const kv = (label: string, value: string, accent = false) => {
ensure(15);
setText(9.5, false, 95); doc.text(label, M, y);
setText(9.5, true, accent ? 40 : 20); doc.text(value, rightX, y, { align: 'right' });
y += 15;
};
const item = (name: string, value: string, detail?: string) => {
ensure(detail ? 30 : 16);
setText(9.5, true, 20); doc.text(name, M, y);
setText(9.5, true, 20); doc.text(value, rightX, y, { align: 'right' });
y += 12;
if (detail) { setText(8, false, 135); doc.text(detail, M, y); y += 12; }
y += 2;
};
// ── Header ──
doc.setFillColor(255, 0, 64); doc.rect(M, y - 12, 34, 3, 'F');
setText(17, true, 15); doc.text('SkateSpots — Deck cost calculator', M, y + 8); y += 26;
setText(9, false, 120);
doc.text(
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${cur === 'sats' ? 'sats (Bitcoin)' : cur === 'BTC' ? 'BTC (Bitcoin)' : cur}`,
M, y,
);
y += 10;
section('Labor');
kv('Hourly rate', fmt(s.hourlyRate));
kv('Overhead factor', `${s.overheadFactor}\u00d7`);
kv('Labor cost', `${fmt(s.hourlyRate * s.overheadFactor)}/hr`);
section('Equipment');
s.equipment.forEach((e) => {
const per = e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0;
item(e.name, fmt(per), `${fmt(e.cost)}${e.setupHrs ? ` + ${e.setupHrs}h setup` : ''} \u00b7 ${e.boards.toLocaleString()} boards`);
});
kv('Equipment per board', fmt(r.equipment), true);
section('Operation expenses');
kv('Boards / month', String(s.boardsPerMonth));
kv('Rent / month', fmt(s.rent));
kv('Utilities / month', fmt(s.utilities));
kv('Subscriptions / month', fmt(s.subscriptions));
kv('Per board', fmt(r.operations), true);
section('Materials');
s.materials.forEach((m) => {
const per = m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0;
item(m.name, fmt(per), `${fmt(m.orderCost)}${m.shipping ? ` + ${fmt(m.shipping)} ship` : ''} \u00b7 ${m.boards.toLocaleString()} boards`);
});
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` : ''}`));
kv('Processes per board', fmt(r.processes), true);
section('Packaging');
s.packaging.forEach((p) => item(p.name, fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin), `${p.minutesPerBoard} min \u00b7 ${fmt(p.materialsPerBoard)} materials`));
kv('Packaging per board', fmt(r.packaging), true);
// ── Result (kept together) ──
ensure(180);
section('Result');
kv('Profit margin', `${s.profitMargin}\u00d7`);
kv('Scrap', `${s.scrapPct}%`);
kv('Cost / board (no packaging)', fmt(r.costNoPack));
kv('Cost / board (with packaging)', fmt(r.costWithPack));
y += 6;
const priceBox = (label: string, value: string) => {
ensure(36);
doc.setFillColor(244, 252, 218); doc.roundedRect(M, y - 2, contentW, 27, 4, 4, 'F');
setText(9, false, 90); doc.text(label, M + 10, y + 16);
setText(14, true, 30); doc.text(value, rightX - 10, y + 17, { align: 'right' });
y += 36;
};
priceBox('Selling price (no packaging)', fmt(r.priceNoPack));
priceBox('Selling price (with packaging)', fmt(r.priceWithPack));
ensure(30);
y += 4;
setText(8, false, 135); doc.text('Does not include shipping to the buyer.', M, y); y += 16;
setText(8, false, 155); doc.text('Generated by SkateSpots \u00b7 skate-spots.org', M, y);
// ── Page numbers ──
const pages = doc.getNumberOfPages();
for (let i = 1; i <= pages; i++) {
doc.setPage(i);
setText(8, false, 160);
doc.text(`${i} / ${pages}`, rightX, pageH - 24, { align: 'right' });
}
doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`);
};
// Customer-facing quote: logo + business block + line item + notes.
const exportQuotePdf = async () => {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF({ unit: 'pt', format: 'a4' });
const pageW = doc.internal.pageSize.getWidth();
const M = 48;
const rightX = pageW - M;
const q = quote;
let y = M + 24;
// Header — logo left, business + contact right.
if (q.logo.startsWith('data:image/')) {
try {
const img = await loadImage(q.logo);
const ratio = Math.min(150 / img.width, 64 / img.height, 1);
doc.addImage(q.logo, q.logo.startsWith('data:image/png') ? 'PNG' : 'JPEG', M, y, img.width * ratio, img.height * ratio);
} catch { /* ignore a bad logo */ }
}
doc.setFont('helvetica', 'bold'); doc.setFontSize(16); doc.setTextColor(15, 15, 15);
doc.text(quote.businessName.trim() || 'SkateSpots', rightX, y + 10, { align: 'right' });
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
const contact = [q.email, q.phone, q.website, q.address].map((v) => v.trim()).filter(Boolean);
contact.forEach((line, i) => doc.text(line, rightX, y + 26 + i * 12, { align: 'right' }));
y += Math.max(64, 26 + contact.length * 12) + 22;
doc.setDrawColor(220, 220, 220); doc.setLineWidth(0.7); doc.line(M, y, rightX, y); y += 30;
doc.setFont('helvetica', 'bold'); doc.setFontSize(26); doc.setTextColor(15, 15, 15);
doc.text('QUOTE', M, y);
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
const now = new Date();
doc.text(now.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), rightX, y, { align: 'right' });
doc.text(`Ref Q-${String(q.number).padStart(4, '0')}`, rightX, y + 12, { align: 'right' });
y += 34;
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(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.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 += 30;
if (q.notes.trim()) {
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90, 90, 90);
const wrapped = doc.splitTextToSize(q.notes.trim(), pageW - M * 2) as string[];
doc.text(wrapped, M, y); y += wrapped.length * 12 + 10;
}
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
const until = new Date(now.getTime() + Math.max(1, q.validDays) * 86_400_000);
doc.text(`Does not include shipping. Quote valid until ${until.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })}.`, M, y); y += 14;
doc.text('Generated with SkateSpots \u00b7 skate-spots.org', M, y);
doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`);
};
// Shareable PNG summary card (canvas). Carries the quote identity too.
const exportPng = async () => {
const W = 1000;
const H = 660;
const c = document.createElement('canvas');
c.width = W; c.height = H;
const ctx = c.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#0a0a0a'; ctx.fillRect(0, 0, W, H);
// Logo (top-right) when the user has set one.
if (quote.logo.startsWith('data:image/')) {
try {
const img = await loadImage(quote.logo);
const ratio = Math.min(200 / img.width, 62 / img.height, 1);
ctx.drawImage(img, W - 48 - img.width * ratio, 42, img.width * ratio, img.height * ratio);
} catch { /* ignore a bad logo */ }
}
ctx.fillStyle = '#ff0040'; ctx.fillRect(48, 46, 64, 6);
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 34px system-ui, sans-serif';
ctx.fillText('Deck cost', 48, 110);
ctx.fillStyle = '#9ca3af'; ctx.font = '16px system-ui, sans-serif';
ctx.fillText(`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`, 48, 140);
if (quote.businessName.trim()) {
ctx.fillStyle = '#e5e7eb'; ctx.font = 'bold 18px system-ui, sans-serif';
ctx.textAlign = 'right'; ctx.fillText(quote.businessName.trim(), W - 48, 140); ctx.textAlign = 'left';
}
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
ctx.fillText('SELLING PRICE · WITH PACKAGING', 48, 206);
ctx.fillStyle = '#ccff00'; ctx.font = 'bold 46px system-ui, sans-serif';
ctx.fillText(fmt(r.priceWithPack), 48, 256);
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
ctx.fillText('SELLING PRICE · NO PACKAGING', 540, 206);
ctx.fillStyle = '#e5e7eb'; ctx.font = 'bold 32px system-ui, sans-serif';
ctx.fillText(fmt(r.priceNoPack), 540, 250);
const rows: [string, number][] = [
['Equipment', r.equipment],
['Operations', r.operations],
['Materials', r.materials],
['Processes', r.processes],
['Packaging', r.packaging],
];
let y = 330;
ctx.font = '15px system-ui, sans-serif';
for (const [label, val] of rows) {
ctx.fillStyle = '#9ca3af'; ctx.fillText(label, 48, y);
ctx.fillStyle = '#e5e7eb'; ctx.textAlign = 'right'; ctx.fillText(fmt(val), W - 48, y); ctx.textAlign = 'left';
y += 30;
}
y += 12;
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
ctx.fillText('COST / BOARD (WITH PACKAGING)', 48, y);
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText(fmt(r.costWithPack), 48, y + 32);
ctx.fillStyle = '#6b7280'; ctx.font = '12px system-ui, sans-serif';
ctx.fillText('Generated with SkateSpots · skate-spots.org', 48, H - 40);
const a = document.createElement('a');
a.href = c.toDataURL('image/png');
a.download = 'deck-cost.png';
a.click();
};
// Multi-sheet XLSX workbook (lazy — SheetJS is only loaded on demand).
const exportXlsx = async () => {
const XLSX = await import('xlsx');
const wb = XLSX.utils.book_new();
const num = (v: number) => Number(v.toFixed(4));
const summary: (string | number)[][] = [
['SkateSpots — Deck cost calculator'],
[],
['Currency', cur],
['Date', new Date().toISOString().slice(0, 10)],
[],
['Cost / board (no packaging)', num(r.costNoPack)],
['Cost / board (with packaging)', num(r.costWithPack)],
['Selling price (no packaging)', num(r.priceNoPack)],
['Selling price (with packaging)', num(r.priceWithPack)],
[],
['Equipment per board', num(r.equipment)],
['Operations per board', num(r.operations)],
['Materials per board', num(r.materials)],
['Processes per board', num(r.processes)],
['Packaging per board', num(r.packaging)],
];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), 'Summary');
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
['Equipment', 'Cost', 'Setup (h)', 'Boards', 'Per board'],
...s.equipment.map((e) => [e.name, e.cost, e.setupHrs, e.boards, num(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)]),
]), 'Equipment');
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
['Material', 'Order cost', 'Shipping', 'Boards', 'Order (min)', 'Unload (min)', 'Per board'],
...s.materials.map((m) => [m.name, m.orderCost, m.shipping, m.boards, m.orderMin, m.unloadMin, num(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)]),
]), '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)]),
]), 'Processes');
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
['Packaging', 'Time/board (min)', 'Materials/board', 'Per board'],
...s.packaging.map((p) => [p.name, p.minutesPerBoard, p.materialsPerBoard, num(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)]),
]), 'Packaging');
XLSX.writeFile(wb, `deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`);
};
const copySummary = async () => {
const text = [
`Deck cost — ${currencyLabel(cur)}`,
`Cost / board (with packaging): ${fmt(r.costWithPack)}`,
`Selling price (no packaging): ${fmt(r.priceNoPack)}`,
`Selling price (with packaging): ${fmt(r.priceWithPack)}`,
'',
`Equipment ${fmt(r.equipment)} · Operations ${fmt(r.operations)} · Materials ${fmt(r.materials)} · Processes ${fmt(r.processes)} · Packaging ${fmt(r.packaging)}`,
].join('\n');
try {
await navigator.clipboard.writeText(text);
toast({ title: 'Summary copied' });
} catch {
toast({ title: 'Could not copy', variant: 'destructive' });
}
};
const download = (filename: string, content: string, mime: string) => {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const exportCsv = () => download('deck-cost.csv', toCsv(calcCsvRows(s)), 'text/csv');
const exportMarkdown = () => {
const md = [
'# Deck cost calculator',
'',
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`,
'',
'## Labor',
`- Hourly rate: ${fmt(s.hourlyRate)}`,
`- Overhead factor: ${s.overheadFactor}×`,
`- Labor cost: ${fmt(s.hourlyRate * s.overheadFactor)}/hr`,
'',
'## Equipment',
...s.equipment.map((e) => `- ${e.name}: ${fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)} (${fmt(e.cost)} · ${e.boards.toLocaleString()} boards)`),
`- **Equipment per board: ${fmt(r.equipment)}**`,
'',
'## Operation expenses',
`- Boards/month: ${s.boardsPerMonth}`,
`- Rent: ${fmt(s.rent)}`,
`- Utilities: ${fmt(s.utilities)}`,
`- Subscriptions: ${fmt(s.subscriptions)}`,
`- **Per board: ${fmt(r.operations)}**`,
'',
'## Materials',
...s.materials.map((m) => `- ${m.name}: ${fmt(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)} (${fmt(m.orderCost)} · ${m.boards.toLocaleString()} boards)`),
`- **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)`),
`- **Processes per board: ${fmt(r.processes)}**`,
'',
'## Packaging',
...s.packaging.map((p) => `- ${p.name}: ${fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)}`),
`- **Packaging per board: ${fmt(r.packaging)}**`,
'',
'## Result',
`- Profit margin: ${s.profitMargin}× · Scrap: ${s.scrapPct}%`,
`- Cost/board (no packaging): ${fmt(r.costNoPack)}`,
`- Cost/board (with packaging): ${fmt(r.costWithPack)}`,
`- **Selling price (no packaging): ${fmt(r.priceNoPack)}**`,
`- **Selling price (with packaging): ${fmt(r.priceWithPack)}**`,
'',
'_Does not include shipping to the buyer. Generated by SkateSpots._',
].join('\n');
download('deck-cost.md', md, 'text/markdown');
};
const importFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const isCsv = file.name.toLowerCase().endsWith('.csv');
const reader = new FileReader();
reader.onload = () => {
try {
const text = String(reader.result);
if (isCsv) {
const mats = materialsFromSupplierCsv(text);
if (mats) setS((prev) => ({ ...prev, materials: mats }));
else {
const parsed = calcStateFromCsv(text);
if (parsed) setS(parsed);
}
} else {
setS({ ...freshDefault(), ...(JSON.parse(text) as CalcState) });
}
} catch { /* ignore malformed */ }
};
reader.readAsText(file);
e.target.value = '';
};
const reset = () => {
if (!window.confirm('Reset everything to the example values?')) return;
setS(freshDefault());
};
const onLogo = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 500 * 1024) {
toast({ title: 'Logo too large (max 500 KB)', variant: 'destructive' });
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => setQuote({ ...quote, logo: String(reader.result) });
reader.readAsDataURL(file);
e.target.value = '';
};
const numInput = (value: number, onChange: (n: number) => void, className?: string) => (
<Input
type="number"
inputMode="decimal"
value={Number.isFinite(value) ? value : 0}
onChange={(e) => onChange(Number(e.target.value) || 0)}
className={`h-8 min-w-16 ${className ?? ''}`}
/>
);
const th = 'px-2 py-1 text-left text-[10px] font-bold uppercase tracking-wider text-gray-500 whitespace-nowrap';
const td = 'px-1 py-1 align-middle';
const label = 'text-[11px] font-semibold text-gray-400';
return (
<div className="space-y-5">
{/* Currency — searchable; switching CONVERTS every money field via live
BTC rates (sats included). */}
<div className="sticky top-16 z-20 -mx-1 flex flex-wrap items-center gap-2 rounded-xl border border-white/10 bg-black/90 px-3 py-2 backdrop-blur">
<span className="text-[10px] font-black uppercase tracking-wider text-gray-500">Currency</span>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex min-h-0 items-center gap-2 rounded-full border border-[#ccff00]/40 bg-[#ccff00]/10 px-3 py-1.5 text-xs font-bold text-[#ccff00] hover:bg-[#ccff00]/15"
>
{currencyLabel(cur)}
<ChevronDown className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-0">
<div className="relative border-b border-white/10 p-2">
<Search className="absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-500" />
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search currency…"
className="h-8 border-white/10 bg-white/5 pl-8 text-xs"
/>
</div>
<div className="max-h-64 overflow-y-auto p-1">
{filteredCurrencies.map((c) => (
<button
key={c}
type="button"
onClick={() => changeCurrency(c)}
className={cn(
'flex w-full min-h-0 items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs font-semibold',
c === cur ? 'bg-[#ccff00]/15 text-[#ccff00]' : 'text-gray-300 hover:bg-white/5',
)}
>
<span>{currencyLabel(c)}</span>
{c === cur && <Check className="h-3.5 w-3.5" />}
</button>
))}
{filteredCurrencies.length === 0 && (
<p className="px-3 py-4 text-center text-xs text-gray-500">No currency matches.</p>
)}
</div>
{!rates && (
<p className="border-t border-white/10 px-3 py-2 text-[10px] text-gray-500">Live rates unavailable — switching relabels only.</p>
)}
</PopoverContent>
</Popover>
{rates && <span className="text-[10px] text-gray-500">live BTC rates · sats included</span>}
<span className="ml-auto text-[11px] font-bold text-gray-400">
{fmt(r.costWithPack)} cost · <span className="text-[#ccff00]">{fmt(r.priceWithPack)} sell</span>
</span>
</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>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<label className="space-y-1">
<span className={label}>Hourly rate ({unit})</span>
{numInput(s.hourlyRate, (n) => patch({ hourlyRate: n }))}
</label>
<label className="space-y-1">
<span className={label}>Overhead factor (2–3)</span>
{numInput(s.overheadFactor, (n) => patch({ overheadFactor: n }))}
</label>
<div className="space-y-1">
<span className={label}>Labor cost</span>
<div className="flex h-8 items-center rounded-md border border-white/10 bg-black/40 px-2 text-sm font-bold text-[#ccff00]">
{fmt(s.hourlyRate * s.overheadFactor)}/hr
</div>
</div>
</div>
</section>
{/* 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>
<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}># boards</th><th className={th}>Per board</th><th /></tr></thead>
<tbody>
{s.equipment.map((e) => (
<tr key={e.id} className="border-t border-white/5">
<td className={td}><Input value={e.name} onChange={(ev) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, name: ev.target.value } : x) })} className="h-8 min-w-36" /></td>
<td className={td}>{numInput(e.cost, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, cost: n } : x) }))}</td>
<td className={td}>{numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))}</td>
<td className={td}>{numInput(e.boards, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, boards: n } : x) }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)}</td>
<td className={td}><button onClick={() => patch({ equipment: s.equipment.filter((x) => x.id !== e.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>
))}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-gray-500">Equipment per board: <span className="font-bold text-gray-300">{fmt(r.equipment)}</span></p>
<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>
{/* 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>
<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>
<label className="space-y-1"><span className={label}>Utilities / month</span>{numInput(s.utilities, (n) => patch({ utilities: n }))}</label>
<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>
{/* 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>
<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</th><th className={th}>Order (m)</th><th className={th}>Unload (m)</th><th className={th}>Per board</th><th /></tr></thead>
<tbody>
{s.materials.map((m) => {
const per = m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0;
const up = (p: Partial<MaterialRow>) => patch({ materials: s.materials.map((x) => x.id === m.id ? { ...x, ...p } : x) });
return (
<tr key={m.id} className="border-t border-white/5">
<td className={td}><Input value={m.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td}>{numInput(m.orderCost, (n) => up({ orderCost: n }))}</td>
<td className={td}>{numInput(m.shipping, (n) => up({ shipping: n }))}</td>
<td className={td}>{numInput(m.boards, (n) => up({ boards: n }))}</td>
<td className={td}>{numInput(m.orderMin, (n) => up({ orderMin: n }))}</td>
<td className={td}>{numInput(m.unloadMin, (n) => up({ unloadMin: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(per)}</td>
<td className={td}><button onClick={() => patch({ materials: s.materials.filter((x) => x.id !== m.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>
);
})}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-gray-500">Materials per board: <span className="font-bold text-gray-300">{fmt(r.materials)}</span></p>
<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>
{/* 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>
<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</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 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>
<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>
);
})}
</tbody>
</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 }] })}>
<Plus className="h-3.5 w-3.5" /> Add process
</Button>
</section>
{/* 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>
<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>
<tbody>
{s.packaging.map((p) => {
const per = p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin;
const up = (pp: Partial<PackageRow>) => patch({ packaging: s.packaging.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.minutesPerBoard, (n) => up({ minutesPerBoard: n }))}</td>
<td className={td}>{numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(per)}</td>
<td className={td}><button onClick={() => patch({ packaging: s.packaging.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>
);
})}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-gray-500">Packaging per board: <span className="font-bold text-gray-300">{fmt(r.packaging)}</span></p>
<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>
{/* 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}>Profit margin (×)</span>{numInput(s.profitMargin, (n) => patch({ profitMargin: n }))}</label>
<label className="space-y-1"><span className={label}>Scrap (%)</span>{numInput(s.scrapPct, (n) => patch({ scrapPct: 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>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-xl border border-[#ccff00]/40 bg-black/40 p-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Selling price · no packaging</p>
<p className="mt-1 text-2xl font-black text-[#ccff00]">{fmt(r.priceNoPack)}</p>
</div>
<div className="rounded-xl border border-[#ccff00]/40 bg-black/40 p-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Selling price · with packaging</p>
<p className="mt-1 text-2xl font-black text-[#ccff00]">{fmt(r.priceWithPack)}</p>
</div>
</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. */}
<div className="mt-4 space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">Quote details (for the client PDF)</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className={label}>Business / shop name</span>
<Input value={quote.businessName} onChange={(e) => setQuote({ ...quote, businessName: e.target.value })} placeholder="Your name / shop" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Email</span>
<Input value={quote.email} onChange={(e) => setQuote({ ...quote, email: e.target.value })} placeholder="you@example.com" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Phone</span>
<Input value={quote.phone} onChange={(e) => setQuote({ ...quote, phone: e.target.value })} placeholder="+1 …" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Website</span>
<Input value={quote.website} onChange={(e) => setQuote({ ...quote, website: e.target.value })} placeholder="yourshop.com" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1 sm:col-span-2">
<span className={label}>Address</span>
<Input value={quote.address} onChange={(e) => setQuote({ ...quote, address: e.target.value })} placeholder="Street, city" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Quote #</span>
<div className="flex items-center gap-1.5">
<Input type="number" min={1} value={quote.number} onChange={(e) => setQuote({ ...quote, number: Math.max(1, Number(e.target.value) || 1) })} className="h-8 bg-black/40 border-white/10" />
<button type="button" onClick={() => setQuote({ ...quote, number: quote.number + 1 })} className="min-h-0 shrink-0 rounded-md border border-white/15 px-2 py-1 text-xs font-semibold hover:bg-white/5" title="Next number">+1</button>
</div>
</label>
<label className="space-y-1">
<span className={label}>Valid (days)</span>
<Input type="number" min={1} value={quote.validDays} onChange={(e) => setQuote({ ...quote, validDays: Math.max(1, Number(e.target.value) || 1) })} className="h-8 bg-black/40 border-white/10" />
</label>
</div>
<div className="flex flex-wrap items-center gap-3">
{quote.logo && <img src={quote.logo} alt="" className="h-10 rounded border border-white/10" />}
<label className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/15 px-3 text-xs font-semibold hover:bg-white/5">
<Upload className="h-3.5 w-3.5" /> {quote.logo ? 'Change logo' : 'Add logo'}
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={onLogo} />
</label>
{quote.logo && (
<button type="button" onClick={() => setQuote({ ...quote, logo: '' })} className="min-h-0 text-xs text-gray-500 hover:text-red-400">Remove</button>
)}
</div>
<label className="block space-y-1">
<span className={label}>Notes / terms</span>
<Textarea value={quote.notes} onChange={(e) => setQuote({ ...quote, notes: e.target.value })} rows={2} placeholder="Lead time, deposit, warranty…" className="bg-black/40 border-white/10" />
</label>
</div>
{/* Turn the number into an action: list it at this price, or find someone
to build it. */}
<div className="mt-4 flex flex-wrap gap-2">
<Link
to={`/create-listing?price=${encodeURIComponent(String(Math.round(r.priceWithPack * 100) / 100))}&currency=${encodeURIComponent(cur)}`}
className="inline-flex items-center gap-1.5 rounded-full bg-[#ccff00] px-4 py-2 text-xs font-black uppercase tracking-wide text-black transition-colors hover:bg-[#ccff00]/90"
>
List a deck at this price <ChevronRight className="h-3.5 w-3.5" />
</Link>
<Link
to="/lab?craft=building"
className="inline-flex items-center gap-1.5 rounded-full border border-white/15 px-4 py-2 text-xs font-bold text-gray-300 transition-colors hover:bg-white/5"
>
Get it made — find a builder
</Link>
</div>
</section>
{/* Presets & data */}
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={savePreset}>
<Save className="h-3.5 w-3.5" /> Save preset
</Button>
<Popover open={loadOpen} onOpenChange={setLoadOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" disabled={presets.length === 0}>
<FolderOpen className="h-3.5 w-3.5" /> Load{presets.length > 0 ? ` (${presets.length})` : ''}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
{presets.map((p) => (
<div key={p.name} className="flex items-center gap-1">
<button type="button" onClick={() => loadPreset(p.state)} className="min-h-0 flex-1 rounded-lg px-2.5 py-1.5 text-left text-xs font-semibold text-gray-300 hover:bg-white/5">{p.name}</button>
<button type="button" onClick={() => deletePreset(p.name)} aria-label="Delete preset" className="min-h-0 min-w-0 p-1 text-gray-600 hover:text-red-400"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
))}
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs">
<Download className="h-3.5 w-3.5" /> Export <ChevronDown className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-52 p-1">
{[
{ label: 'PDF — full sheet', run: () => void exportPdf() },
{ label: 'PDF — client quote', run: () => void exportQuotePdf() },
{ label: 'PNG image', run: () => void exportPng() },
{ label: 'XLSX workbook', run: () => void exportXlsx() },
{ label: 'CSV (spreadsheet)', run: exportCsv },
{ label: 'Markdown', run: exportMarkdown },
{ label: 'JSON (backup)', run: exportJson },
{ label: 'Copy summary', run: () => void copySummary() },
].map((o) => (
<button key={o.label} type="button" onClick={o.run} className="flex w-full min-h-0 rounded-lg px-2.5 py-1.5 text-left text-xs font-semibold text-gray-300 hover:bg-white/5">
{o.label}
</button>
))}
</PopoverContent>
</Popover>
<label className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/15 px-3 text-xs font-semibold hover:bg-white/5">
<Upload className="h-3.5 w-3.5" /> Import
<input type="file" accept=".json,.csv,application/json,text/csv" className="hidden" onChange={importFile} />
</label>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={reset}>
<RotateCcw className="h-3.5 w-3.5" /> Reset
</Button>
</div>
</div>
);
}