SkateSpots/src/components/DeckCostCalculator.tsx

1745 lines
99 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, 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, AlertTriangle, LayoutTemplate, SlidersHorizontal, Copy } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useBtcRates } from '@/hooks/useExchangeRate';
import { useToast } from '@/hooks/useToast';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { useTranslation } from '@/contexts/language';
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; /** Loaded hourly rate override (0 = use the shop rate). */ rate: number; /** Waste at this operation, % (0 = none). */ scrapPct: number }
interface PackageRow { id: string; name: string; minutesPerBoard: number; materialsPerBoard: number }
type CurrencyCode = string;
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;
/** Nostr identity — shown on the quote so the client can verify/contact you. */
npub: string;
/** Human-readable Nostr address (you@domain). */
nip05: string;
/** Lightning address (lud16) — where the client can pay. */
lightning: string;
/** Accepted payment methods (labels from `PAY_METHODS`). */
payMethods: string[];
/** Deposit percent due up front (0–100). */
depositPct: number;
/** Payment terms / schedule (free text). */
paymentTerms: string;
/** How to pay (bank/PayPal/other details), separate from the schedule. */
paymentDetails: string;
/** Optional Incoterm code ('' = none). */
incoterm: string;
}
const DEFAULT_QUOTE: QuoteProfile = {
businessName: '', logo: '', email: '', phone: '', website: '', address: '', notes: '', number: 1, validDays: 30,
npub: '', nip05: '', lightning: '', payMethods: ['Cash', 'Lightning'], depositPct: 50, paymentTerms: '', paymentDetails: '', incoterm: '',
};
/** Payment methods offered on a quote. */
const PAY_METHODS = ['Cash', 'Lightning', 'Cashu', 'Bank transfer', 'Card', 'Other'];
/** Translation keys for the payment-method chips (the stored values stay stable). */
const PAY_METHOD_KEYS: Record<string, string> = {
Cash: 'deckCostUi.payMethodCash',
Lightning: 'deckCostUi.payMethodLightning',
Cashu: 'deckCostUi.payMethodCashu',
'Bank transfer': 'deckCostUi.payMethodBank',
Card: 'deckCostUi.payMethodCard',
Other: 'deckCostUi.payMethodOther',
};
/** Optional Incoterms (2020) for cross-border sales — light, not a contract. */
const INCOTERM_OPTIONS: { code: string; labelKey: string }[] = [
{ code: '', labelKey: 'deckCostUi.incotermNone' },
{ code: 'EXW', labelKey: 'deckCostUi.incotermExw' },
{ code: 'FCA', labelKey: 'deckCostUi.incotermFca' },
{ code: 'DAP', labelKey: 'deckCostUi.incotermDap' },
{ code: 'DDP', labelKey: 'deckCostUi.incotermDdp' },
];
interface CalcState {
currency: CurrencyCode;
hourlyRate: number;
overheadFactor: number;
boardsPerMonth: number;
rent: number;
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;
/** One-off setup per production run — hours of labour (amortised over the order). */
batchSetupHours: number;
/** One-off setup per production run — direct cost (amortised over the order). */
batchSetupCost: number;
/** Tax / VAT / sales tax, %. */
taxPct: number;
/** Whether the quoted price already includes tax. */
taxInclusive: boolean;
scrapPct: number;
equipment: EquipmentRow[];
materials: MaterialRow[];
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,
wholesaleMarkup: 2,
quantity: 1,
targetMonthlyProfit: 0,
batchSetupHours: 0,
batchSetupCost: 0,
taxPct: 0,
taxInclusive: false,
scrapPct: 5,
equipment: [
{ id: uid(), name: 'Molds', cost: 900, setupHrs: 1, boards: 10000 },
{ 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, rate: 0, scrapPct: 0 },
{ id: uid(), name: 'Marking & Registration', minutes: 2, boards: 1, rate: 0, scrapPct: 0 },
{ id: uid(), name: 'Drilling', minutes: 5, boards: 3, rate: 0, scrapPct: 0 },
{ id: uid(), name: 'Shaping', minutes: 4, boards: 1, rate: 0, scrapPct: 0 },
{ id: uid(), name: 'Routing Edges', minutes: 1, boards: 1, rate: 0, scrapPct: 0 },
{ id: uid(), name: 'Sanding', minutes: 10, boards: 1, rate: 0, scrapPct: 0 },
{ id: uid(), name: 'Clearcoating', minutes: 5, boards: 1, rate: 0, scrapPct: 0 },
{ id: uid(), name: 'Graphics', minutes: 4, boards: 1, rate: 0, scrapPct: 0 },
{ id: uid(), name: 'Other Processes', minutes: 0, boards: 1, rate: 0, scrapPct: 0 },
],
packaging: [
{ id: uid(), name: 'Sticker & poly bag', minutesPerBoard: 4, materialsPerBoard: 0.06 },
{ 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() })),
};
}
/** 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, rate: 0, scrapPct: 0 },
{ id: uid(), name: 'Shape & drill', minutes: 30, boards: 1, rate: 0, scrapPct: 0 },
{ id: uid(), name: 'Sand & finish', minutes: 45, boards: 1, rate: 0, scrapPct: 0 },
{ id: uid(), name: 'Graphics', minutes: 20, boards: 1, rate: 0, scrapPct: 0 },
],
packaging: [
{ id: uid(), name: 'Bag & sticker', minutesPerBoard: 3, materialsPerBoard: 0.1 },
{ id: uid(), name: 'Box & pack', minutesPerBoard: 6, materialsPerBoard: 0.8 },
],
};
}
interface SheetTemplate { id: string; nameKey: string; blurbKey: string; build: () => CalcState }
/** Quick-start sheets — a lean starter and the full workshop default. */
const SHEET_TEMPLATES: SheetTemplate[] = [
{ id: 'starter', nameKey: 'deckCostUi.templateStarterName', blurbKey: 'deckCostUi.templateStarterBlurb', build: starterSheet },
{ id: 'shop', nameKey: 'deckCostUi.templateShopName', blurbKey: 'deckCostUi.templateShopBlurb', 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" aria-expanded={open} 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 {
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', 'wholesale_markup', 'value', num(s.wholesaleMarkup)]);
rows.push(['margins', 'quantity', 'value', num(s.quantity)]);
rows.push(['margins', 'target_monthly_profit', 'value', num(s.targetMonthlyProfit)]);
rows.push(['margins', 'batch_setup_hours', 'value', num(s.batchSetupHours)]);
rows.push(['margins', 'batch_setup_cost', 'value', num(s.batchSetupCost)]);
rows.push(['margins', 'tax_pct', 'value', num(s.taxPct)]);
rows.push(['margins', 'tax_inclusive', 'value', s.taxInclusive ? '1' : '0']);
rows.push(['margins', 'scrap_pct', 'value', num(s.scrapPct)]);
for (const e of s.equipment) {
rows.push(['equipment', e.name, 'cost', num(e.cost)]);
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)]);
rows.push(['processes', p.name, 'rate', num(p.rate)]);
rows.push(['processes', p.name, 'scrap_pct', num(p.scrapPct)]);
}
for (const p of s.packaging) {
rows.push(['packaging', p.name, 'minutes_per_board', num(p.minutesPerBoard)]);
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 === 'wholesale_markup') out.wholesaleMarkup = n(value);
else if (item === 'quantity') out.quantity = Math.max(1, n(value));
else if (item === 'target_monthly_profit') out.targetMonthlyProfit = n(value);
else if (item === 'batch_setup_hours') out.batchSetupHours = n(value);
else if (item === 'batch_setup_cost') out.batchSetupCost = n(value);
else if (item === 'tax_pct') out.taxPct = n(value);
else if (item === 'tax_inclusive') out.taxInclusive = value === '1';
else if (item === 'scrap_pct') out.scrapPct = n(value);
continue;
}
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, rate: 0, scrapPct: 0 };
if (field === 'minutes') row.minutes = n(value);
else if (field === 'boards') row.boards = n(value);
else if (field === 'rate') row.rate = n(value);
else if (field === 'scrap_pct') row.scrapPct = n(value);
pr.set(item, row);
} else if (section === 'packaging') {
const row = pk.get(item) ?? { id: uid(), name: item, minutesPerBoard: 0, materialsPerBoard: 0 };
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 { t } = useTranslation();
const [rawS, setS] = useLocalStorage<CalcState>('skatespots:deck-cost:v2', DEFAULT_CALC);
// Merge over defaults so a sheet saved before a new field existed can't produce
// NaN (e.g. the per-operation / batch-setup / tax fields added with Advanced).
const s = useMemo<CalcState>(() => ({ ...DEFAULT_CALC, ...rawS }), [rawS]);
const { data: rates } = useBtcRates();
const { toast } = useToast();
// Quote identity lives in its own store, so switching presets keeps the same
// business name / logo / contact details.
const [rawQuote, setQuote] = useLocalStorage<QuoteProfile>('skatespots:deck-cost:quote', DEFAULT_QUOTE);
// Merge over defaults so a quote saved before a new field existed can't crash.
const quote = useMemo<QuoteProfile>(
() => ({ ...DEFAULT_QUOTE, ...rawQuote, payMethods: rawQuote.payMethods ?? DEFAULT_QUOTE.payMethods }),
[rawQuote],
);
// Advanced reveals per-operation rates/scrap, batch setup, tax and the cost curve.
const [advanced, setAdvanced] = useLocalStorage<boolean>('skatespots:deck-cost:advanced', false);
// Nostr identity can be pulled from the logged-in profile (kind 0).
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey);
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),
targetMonthlyProfit: r2(prev.targetMonthlyProfit ?? 0),
batchSetupCost: r2(prev.batchSetupCost ?? 0),
equipment: prev.equipment.map((e) => ({ ...e, cost: r2(e.cost) })),
materials: prev.materials.map((m) => ({ ...m, orderCost: r2(m.orderCost), shipping: r2(m.shipping) })),
processes: prev.processes.map((p) => ({ ...p, rate: r2(p.rate ?? 0) })),
packaging: prev.packaging.map((p) => ({ ...p, materialsPerBoard: r2(p.materialsPerBoard) })),
};
});
};
const r = useMemo(() => {
const laborPerHr = s.hourlyRate * s.overheadFactor;
const laborPerMin = laborPerHr / 60;
const clampPct = (v: number) => Math.min(99, Math.max(0, Number.isFinite(v) ? v : 0));
const rateFor = (rate: number) => (rate > 0 ? rate : laborPerHr);
const equipment = s.equipment.reduce((a, e) => a + (e.boards > 0 ? (e.cost + e.setupHrs * laborPerHr) / e.boards : 0), 0);
const operations = s.boardsPerMonth > 0 ? (s.rent + s.utilities + s.subscriptions) / s.boardsPerMonth : 0;
const materials = s.materials.reduce((a, m) => a + (m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * laborPerMin) / m.boards : 0), 0);
const processes = s.processes.reduce((a, p) => a + (p.boards > 0 ? (p.minutes * (rateFor(p.rate) / 60)) / p.boards : 0), 0);
const packaging = s.packaging.reduce((a, p) => a + p.materialsPerBoard + p.minutesPerBoard * laborPerMin, 0);
const costNoPack = equipment + operations + materials + processes;
const costWithPack = costNoPack + packaging;
// Waste: the general rate plus each operation's own scrap, compounded.
const opYield = s.processes.reduce((y, p) => y * (1 - clampPct(p.scrapPct) / 100), 1);
const yieldFactor = (1 - clampPct(s.scrapPct) / 100) * opYield;
const factor = yieldFactor > 0 ? s.profitMargin / yieldFactor : s.profitMargin;
const wholesaleFactor = yieldFactor > 0 ? s.wholesaleMarkup / yieldFactor : s.wholesaleMarkup;
const effectiveWastePct = (1 - yieldFactor) * 100;
const qty = Math.max(1, Math.round(s.quantity) || 1);
// One-off setup per run, amortised across the order quantity.
const batchSetup = s.batchSetupHours * laborPerHr + s.batchSetupCost;
const setupPerUnit = batchSetup / qty;
const costNoPackUnit = costNoPack + setupPerUnit;
const costWithPackUnit = costWithPack + setupPerUnit;
const priceNoPack = costNoPackUnit * factor;
const priceWithPack = costWithPackUnit * factor;
const wholesaleUnit = costWithPackUnit * wholesaleFactor;
const profitPerUnit = priceWithPack - costWithPackUnit;
const fixedMonthly = s.rent + s.utilities + s.subscriptions;
const breakevenDecks = profitPerUnit > 0 && fixedMonthly > 0 ? Math.ceil(fixedMonthly / profitPerUnit) : 0;
const targetDecks = profitPerUnit > 0 ? Math.ceil((fixedMonthly + Math.max(0, s.targetMonthlyProfit)) / profitPerUnit) : 0;
// Cost-vs-quantity curve (one-off setup amortised over the batch).
const curve = [1, 5, 10, 25, 50, 100].map((q) => {
const unit = (costWithPack + batchSetup / q) * factor;
return { qty: q, unit, total: unit * q };
});
// Tax on the order (inclusive or added on top).
const orderSubtotal = priceWithPack * qty;
const taxRate = clampPct(s.taxPct);
const orderTax = s.taxInclusive
? orderSubtotal * (taxRate / (100 + taxRate))
: orderSubtotal * (taxRate / 100);
const orderTotal = s.taxInclusive ? orderSubtotal : orderSubtotal + orderTax;
return {
laborPerHr,
laborPerMin,
equipment,
operations,
materials,
processes,
packaging,
costNoPack,
costWithPack,
costNoPackUnit,
costWithPackUnit,
setupPerUnit,
batchSetup,
priceNoPack,
priceWithPack,
wholesaleUnit,
qty,
profitPerUnit,
orderSubtotal,
orderTax,
orderTotal,
wholesaleTotal: wholesaleUnit * qty,
fixedMonthly,
breakevenDecks,
targetDecks,
curve,
effectiveWastePct,
};
}, [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;
/** What a buyer actually pays for one deck (tax-inclusive when tax is added on top). */
const buyerUnit = s.taxInclusive || s.taxPct <= 0 ? r.priceWithPack : r.priceWithPack * (1 + s.taxPct / 100);
/** 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(t('deckCostUi.warnRetailBelowOne'));
if (s.wholesaleMarkup > 0 && s.profitMargin > 0 && s.wholesaleMarkup >= s.profitMargin) w.push(t('deckCostUi.warnWholesaleAboveRetail'));
const zeroRows = [...s.equipment, ...s.materials, ...s.processes].filter((row) => row.boards <= 0).length;
if (zeroRows > 0) w.push(t('deckCostUi.warnZeroBoards', { count: zeroRows }));
if (r.fixedMonthly > 0 && s.boardsPerMonth <= 0) w.push(t('deckCostUi.warnFixedNoBoards'));
if (r.materials <= 0) w.push(t('deckCostUi.warnNoMaterials'));
if (r.costWithPack > 0 && r.equipment + r.operations > r.costWithPack * 0.6) w.push(t('deckCostUi.warnEquipmentOverhead'));
const oneBoard = s.equipment.filter((e) => e.cost > 100 && e.boards === 1);
if (oneBoard.length > 0) w.push(t('deckCostUi.warnOneBoard'));
return w;
}, [s, r, t]);
const patch = (p: Partial<CalcState>) => setS((prev) => ({ ...prev, ...p }));
const savePreset = () => {
const name = window.prompt(t('deckCostUi.presetPrompt'), t('deckCostUi.presetDefaultName', { n: 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(t('legacyMiscUi.exportTitle'), 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(t('legacyMiscUi.exportSectionLabor'));
kv(t('legacyMiscUi.exportKvHourlyRate'), fmt(s.hourlyRate));
kv(t('legacyMiscUi.exportKvLabourBurden'), `${s.overheadFactor}\u00d7`);
kv(t('legacyMiscUi.exportKvLoadedRate'), `${fmt(s.hourlyRate * s.overheadFactor)}/hr`);
section(t('legacyMiscUi.exportSectionEquipment'));
s.equipment.forEach((e) => {
const per = e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0;
const setup = e.setupHrs ? ` ${t('legacyMiscUi.exportDetailSetup', { hours: e.setupHrs })}` : '';
item(e.name, fmt(per), `${fmt(e.cost)}${setup} \u00b7 ${t('legacyMiscUi.exportDetailBoards', { boards: e.boards.toLocaleString() })}`);
});
kv(t('legacyMiscUi.exportKvEquipmentPerBoard'), fmt(r.equipment), true);
section(t('legacyMiscUi.exportSectionOperations'));
kv(t('legacyMiscUi.exportKvBoardsPerMonth'), String(s.boardsPerMonth));
kv(t('legacyMiscUi.exportKvRentPerMonth'), fmt(s.rent));
kv(t('legacyMiscUi.exportKvUtilitiesPerMonth'), fmt(s.utilities));
kv(t('legacyMiscUi.exportKvSubscriptionsPerMonth'), fmt(s.subscriptions));
kv(t('legacyMiscUi.exportKvPerBoard'), fmt(r.operations), true);
section(t('legacyMiscUi.exportSectionMaterials'));
s.materials.forEach((m) => {
const per = m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0;
const ship = m.shipping ? ` ${t('legacyMiscUi.exportDetailShip', { amount: fmt(m.shipping) })}` : '';
item(m.name, fmt(per), `${fmt(m.orderCost)}${ship} \u00b7 ${t('legacyMiscUi.exportDetailBoards', { boards: m.boards.toLocaleString() })}`);
});
kv(t('legacyMiscUi.exportKvMaterialsPerBoard'), fmt(r.materials), true);
section(t('legacyMiscUi.exportSectionProcesses'));
s.processes.forEach((p) => {
const rate = p.rate > 0 ? p.rate : r.laborPerHr;
const perBoards = p.boards > 1 ? ` ${t('legacyMiscUi.exportDetailPerBoards', { boards: p.boards })}` : '';
const scrap = p.scrapPct ? ` \u00b7 ${t('legacyMiscUi.exportDetailScrap', { pct: p.scrapPct })}` : '';
item(p.name, fmt(p.boards > 0 ? (p.minutes * (rate / 60)) / p.boards : 0), `${t('legacyMiscUi.exportDetailMinutes', { minutes: p.minutes })}${perBoards}${scrap}`);
});
kv(t('legacyMiscUi.exportKvProcessesPerBoard'), fmt(r.processes), true);
section(t('legacyMiscUi.exportSectionPackaging'));
s.packaging.forEach((p) => item(p.name, fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin), `${t('legacyMiscUi.exportDetailMinutes', { minutes: p.minutesPerBoard })} \u00b7 ${t('legacyMiscUi.exportDetailMaterials', { materials: fmt(p.materialsPerBoard) })}`));
kv(t('legacyMiscUi.exportKvPackagingPerBoard'), fmt(r.packaging), true);
// ── Result (kept together) ──
ensure(180);
section(t('legacyMiscUi.exportSectionResult'));
kv(t('legacyMiscUi.exportKvMarkup'), t('legacyMiscUi.exportMarkupValue', { markup: s.profitMargin, pct: marginPct }));
kv(t('legacyMiscUi.exportKvWasteEffective'), `${r.effectiveWastePct.toFixed(1)}%`);
kv(t('legacyMiscUi.exportKvCostNoPack'), fmt(r.costNoPackUnit));
kv(t('legacyMiscUi.exportKvCostWithPack'), fmt(r.costWithPackUnit));
if (r.batchSetup > 0) kv(t('legacyMiscUi.exportKvBatchSetupPerBoard'), fmt(r.setupPerUnit));
if (s.taxPct > 0) kv(s.taxInclusive ? t('legacyMiscUi.exportKvTaxIncluded', { pct: s.taxPct }) : t('legacyMiscUi.exportKvTax', { pct: s.taxPct }), fmt(r.orderTax));
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(t('legacyMiscUi.exportKvSellingNoPack'), fmt(r.priceNoPack));
priceBox(t('legacyMiscUi.exportKvSellingWithPack'), fmt(r.priceWithPack));
ensure(30);
y += 4;
setText(8, false, 135); doc.text(t('legacyMiscUi.exportShippingNote'), M, y); y += 16;
setText(8, false, 155); doc.text(t('legacyMiscUi.exportGeneratedBy'), 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;
const pageH = doc.internal.pageSize.getHeight();
let y = M + 24;
const ensureRoom = (n: number) => { if (y + n > pageH - M) { doc.addPage(); y = M + 20; } };
// 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,
q.nip05 ? `NIP-05 ${q.nip05}` : '',
q.npub ? `nostr ${q.npub}` : '',
].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(t('legacyMiscUi.exportQuoteTitle'), 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(t('legacyMiscUi.exportQuoteRef', { 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(qty > 1 ? t('legacyMiscUi.exportQuoteLineItemQty', { qty }) : t('legacyMiscUi.exportQuoteLineItem'), 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(qty > 1 ? t('legacyMiscUi.exportQuoteLineDescQty', { price: fmt(r.priceWithPack) }) : t('legacyMiscUi.exportQuoteLineDesc'), M, y); y += 16;
doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 24;
if (s.taxPct > 0 && !s.taxInclusive) {
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
doc.text(qty > 1 ? t('legacyMiscUi.exportQuoteSubtotalQty', { qty }) : t('legacyMiscUi.exportQuoteSubtotal'), M, y);
doc.text(fmt(r.orderSubtotal), rightX, y, { align: 'right' }); y += 13;
doc.text(t('legacyMiscUi.exportQuoteTax', { pct: s.taxPct }), M, y);
doc.text(fmt(r.orderTax), rightX, y, { align: 'right' }); y += 16;
}
doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(20, 20, 20);
doc.text(qty > 1 ? t('legacyMiscUi.exportQuoteTotalQty', { qty }) : t('legacyMiscUi.exportQuoteTotal'), M, y);
doc.text(fmt(r.orderTotal), rightX, y, { align: 'right' }); y += 6;
if (s.taxPct > 0 && s.taxInclusive) {
doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(120, 120, 120);
y += 6; doc.text(t('legacyMiscUi.exportQuoteIncludesTax', { pct: s.taxPct, amount: fmt(r.orderTax) }), M, y);
}
y += 20;
// Payment: deposit + balance + methods + terms + delivery.
ensureRoom(70);
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(t('legacyMiscUi.exportQuoteDeposit', { pct: 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(t('legacyMiscUi.exportQuoteBalance'), M, y);
doc.text(fmt(r.orderTotal - deposit), rightX, y, { align: 'right' }); y += 18;
}
const payLines = [
q.payMethods.length ? t('legacyMiscUi.exportQuotePayment', { value: q.payMethods.join(' \u00b7 ') }) : '',
q.lightning.trim() ? t('legacyMiscUi.exportQuoteLightning', { value: q.lightning.trim() }) : '',
q.paymentDetails.trim() ? t('legacyMiscUi.exportQuoteDetails', { value: q.paymentDetails.trim() }) : '',
q.paymentTerms.trim() ? t('legacyMiscUi.exportQuoteTerms', { value: q.paymentTerms.trim() }) : '',
q.incoterm ? t('legacyMiscUi.exportQuoteDelivery', { value: q.incoterm }) : '',
].filter(Boolean);
if (payLines.length) {
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90, 90, 90);
for (const line of payLines) { doc.text(line, M, y); y += 12; }
y += 6;
}
if (q.notes.trim()) {
ensureRoom(50);
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;
}
ensureRoom(30);
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(t('legacyMiscUi.exportQuoteValidUntil', { date: until.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }) }), M, y); y += 14;
doc.text(t('legacyMiscUi.exportGeneratedWith'), 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(t('legacyMiscUi.exportPngTitle'), 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';
}
const idLine = quote.nip05.trim() || quote.npub.trim();
if (idLine) {
ctx.fillStyle = '#9ca3af'; ctx.font = '13px system-ui, sans-serif';
ctx.textAlign = 'right'; ctx.fillText(idLine, W - 48, 162); ctx.textAlign = 'left';
}
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
ctx.fillText(t('legacyMiscUi.exportPngSellingWith'), 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(t('legacyMiscUi.exportPngSellingNo'), 540, 206);
ctx.fillStyle = '#e5e7eb'; ctx.font = 'bold 32px system-ui, sans-serif';
ctx.fillText(fmt(r.priceNoPack), 540, 250);
const rows: [string, number][] = [
[t('legacyMiscUi.exportSectionEquipment'), r.equipment],
[t('legacyMiscUi.exportLabelOperations'), r.operations],
[t('legacyMiscUi.exportSectionMaterials'), r.materials],
[t('legacyMiscUi.exportLabelProcesses'), r.processes],
[t('legacyMiscUi.exportSectionPackaging'), 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(t('legacyMiscUi.exportPngCostWith'), 48, y);
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText(fmt(r.costWithPack), 48, y + 32);
if (quote.depositPct > 0) {
ctx.textAlign = 'right';
ctx.fillStyle = '#9ca3af'; ctx.font = '15px system-ui, sans-serif';
ctx.fillText(t('legacyMiscUi.exportPngDeposit', { pct: quote.depositPct, amount: fmt((r.priceWithPack * quote.depositPct) / 100) }), W - 48, y + 32);
ctx.textAlign = 'left';
}
ctx.fillStyle = '#6b7280'; ctx.font = '12px system-ui, sans-serif';
ctx.fillText(t('legacyMiscUi.exportGeneratedWith'), 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 — our tiny local OOXML writer is only
// loaded when the user asks for the spreadsheet).
const exportXlsx = async () => {
const { downloadXlsx } = await import('@/lib/xlsx');
const num = (v: number) => Number(v.toFixed(4));
const summary: (string | number)[][] = [
[t('legacyMiscUi.exportTitle')],
[],
[t('legacyMiscUi.exportXlsxCurrency'), cur],
[t('legacyMiscUi.exportXlsxDate'), new Date().toISOString().slice(0, 10)],
[],
[t('legacyMiscUi.exportKvCostNoPack'), num(r.costNoPack)],
[t('legacyMiscUi.exportKvCostWithPack'), num(r.costWithPack)],
[t('legacyMiscUi.exportKvSellingNoPack'), num(r.priceNoPack)],
[t('legacyMiscUi.exportKvSellingWithPack'), num(r.priceWithPack)],
[],
[t('legacyMiscUi.exportKvEquipmentPerBoard'), num(r.equipment)],
[t('legacyMiscUi.exportXlsxOperationsPerBoard'), num(r.operations)],
[t('legacyMiscUi.exportKvMaterialsPerBoard'), num(r.materials)],
[t('legacyMiscUi.exportKvProcessesPerBoard'), num(r.processes)],
[t('legacyMiscUi.exportKvPackagingPerBoard'), num(r.packaging)],
];
const equipment: (string | number)[][] = [
[t('legacyMiscUi.exportSectionEquipment'), t('legacyMiscUi.exportColCost'), t('legacyMiscUi.exportColSetupH'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')],
...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)]),
];
const materials: (string | number)[][] = [
[t('legacyMiscUi.exportColMaterial'), t('legacyMiscUi.exportColOrderCost'), t('legacyMiscUi.exportColShipping'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColOrderMin'), t('legacyMiscUi.exportColUnloadMin'), t('legacyMiscUi.exportColPerBoard')],
...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)]),
];
const processes: (string | number)[][] = [
[t('legacyMiscUi.exportColProcess'), t('legacyMiscUi.exportColTimeMin'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')],
...s.processes.map((p) => [p.name, p.minutes, p.boards, p.rate, p.scrapPct, num(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)]),
];
const packaging: (string | number)[][] = [
[t('legacyMiscUi.exportSectionPackaging'), t('legacyMiscUi.exportColTimeBoardMin'), t('legacyMiscUi.exportColMaterialsBoard'), t('legacyMiscUi.exportColPerBoard')],
...s.packaging.map((p) => [p.name, p.minutesPerBoard, p.materialsPerBoard, num(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)]),
];
downloadXlsx(`deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`, [
{ name: t('legacyMiscUi.exportXlsxSummary'), rows: summary },
{ name: t('legacyMiscUi.exportSectionEquipment'), rows: equipment },
{ name: t('legacyMiscUi.exportSectionMaterials'), rows: materials },
{ name: t('legacyMiscUi.exportLabelProcesses'), rows: processes },
{ name: t('legacyMiscUi.exportSectionPackaging'), rows: packaging },
]);
};
const copySummary = async () => {
const text = [
t('legacyMiscUi.exportCopyTitle', { currency: currencyLabel(cur) }),
t('legacyMiscUi.exportCopyCostWith', { amount: fmt(r.costWithPack) }),
t('legacyMiscUi.exportCopySellingNo', { amount: fmt(r.priceNoPack) }),
t('legacyMiscUi.exportCopySellingWith', { amount: fmt(r.priceWithPack) }),
'',
t('legacyMiscUi.exportCopyBreakdown', { 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: t('deckCostUi.summaryCopied') });
} catch {
toast({ title: t('deckCostUi.couldNotCopy'), 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 = [
`# ${t('legacyMiscUi.exportMarkdownTitle')}`,
'',
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`,
'',
`## ${t('legacyMiscUi.exportSectionLabor')}`,
`- ${t('legacyMiscUi.exportMdHourlyRate', { value: fmt(s.hourlyRate) })}`,
`- ${t('legacyMiscUi.exportMdLabourBurden', { value: `${s.overheadFactor}×` })}`,
`- ${t('legacyMiscUi.exportMdLoadedRate', { value: `${fmt(s.hourlyRate * s.overheadFactor)}/hr` })}`,
'',
`## ${t('legacyMiscUi.exportSectionEquipment')}`,
...s.equipment.map((e) => `- ${e.name}: ${fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)} (${fmt(e.cost)} · ${t('legacyMiscUi.exportDetailBoards', { boards: e.boards.toLocaleString() })})`),
`- **${t('legacyMiscUi.exportKvEquipmentPerBoard')}: ${fmt(r.equipment)}**`,
'',
`## ${t('legacyMiscUi.exportSectionOperations')}`,
`- ${t('legacyMiscUi.exportMdBoardsPerMonth', { value: s.boardsPerMonth })}`,
`- ${t('legacyMiscUi.exportMdRent', { value: fmt(s.rent) })}`,
`- ${t('legacyMiscUi.exportMdUtilities', { value: fmt(s.utilities) })}`,
`- ${t('legacyMiscUi.exportMdSubscriptions', { value: fmt(s.subscriptions) })}`,
`- **${t('legacyMiscUi.exportMdPerBoard', { value: fmt(r.operations) })}**`,
'',
`## ${t('legacyMiscUi.exportSectionMaterials')}`,
...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)} · ${t('legacyMiscUi.exportDetailBoards', { boards: m.boards.toLocaleString() })})`),
`- **${t('legacyMiscUi.exportKvMaterialsPerBoard')}: ${fmt(r.materials)}**`,
'',
`## ${t('legacyMiscUi.exportSectionProcesses')}`,
...s.processes.map((p) => `- ${p.name}: ${fmt(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)} (${t('legacyMiscUi.exportDetailMinutes', { minutes: p.minutes })}${p.scrapPct ? ` · ${t('legacyMiscUi.exportDetailScrap', { pct: p.scrapPct })}` : ''})`),
`- **${t('legacyMiscUi.exportKvProcessesPerBoard')}: ${fmt(r.processes)}**`,
'',
`## ${t('legacyMiscUi.exportSectionPackaging')}`,
...s.packaging.map((p) => `- ${p.name}: ${fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)}`),
`- **${t('legacyMiscUi.exportKvPackagingPerBoard')}: ${fmt(r.packaging)}**`,
'',
`## ${t('legacyMiscUi.exportSectionResult')}`,
`- ${t('legacyMiscUi.exportMdMarkupWaste', { markup: s.profitMargin, pct: marginPct, waste: r.effectiveWastePct.toFixed(1) })}`,
`- ${t('legacyMiscUi.exportMdCostNoPack', { value: fmt(r.costNoPackUnit) })}`,
`- ${t('legacyMiscUi.exportMdCostWithPack', { value: fmt(r.costWithPackUnit) })}`,
...(r.batchSetup > 0 ? [`- ${t('legacyMiscUi.exportMdBatchSetup', { value: fmt(r.setupPerUnit) })}`] : []),
...(s.taxPct > 0 ? [`- ${s.taxInclusive ? t('legacyMiscUi.exportKvTaxIncluded', { pct: s.taxPct }) : t('legacyMiscUi.exportKvTax', { pct: s.taxPct })}: ${fmt(r.orderTax)}`] : []),
`- **${t('legacyMiscUi.exportMdSellingNo', { value: fmt(r.priceNoPack) })}**`,
`- **${t('legacyMiscUi.exportMdSellingWith', { value: fmt(r.priceWithPack) })}**`,
'',
`_${t('legacyMiscUi.exportMdDisclaimer')}_`,
].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(t('deckCostUi.resetConfirm'))) 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: t('deckCostUi.logoTooLarge'), variant: 'destructive' });
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => setQuote({ ...quote, logo: String(reader.result) });
reader.readAsDataURL(file);
e.target.value = '';
};
/** Autofill the quote identity from the logged-in Nostr profile (kind 0). */
const fillFromProfile = () => {
if (!user) {
toast({ title: t('deckCostUi.loginToFill'), variant: 'destructive' });
return;
}
const meta = author.data?.metadata;
setQuote({
...quote,
businessName: quote.businessName || meta?.display_name || meta?.name || '',
website: quote.website || meta?.website || '',
nip05: quote.nip05 || meta?.nip05 || '',
lightning: quote.lightning || meta?.lud16 || meta?.lud06 || '',
npub: quote.npub || nip19.npubEncode(user.pubkey),
});
toast({ title: t('deckCostUi.filledFromProfile') });
};
const numInput = (value: number, onChange: (n: number) => void, className?: string) => (
<Input
type="number"
inputMode="decimal"
value={Number.isFinite(value) ? value : 0}
onChange={(e) => onChange(Math.max(0, 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">{t('deckCostUi.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">{t('deckCostUi.noCurrencyMatches')}</p>
)}
</div>
{!rates && (
<p className="border-t border-white/10 px-3 py-2 text-[10px] text-gray-500">{t('deckCostUi.ratesUnavailable')}</p>
)}
</PopoverContent>
</Popover>
{rates && <span className="text-[10px] text-gray-500">{t('deckCostUi.liveRates')}</span>}
<span className="ml-auto text-[11px] font-bold text-gray-400">
{fmt(r.costWithPackUnit)} {t('deckCostUi.chipCost')} · <span className="text-[#ccff00]">{fmt(r.priceWithPack)} {t('deckCostUi.chipSell')}</span>{r.qty > 1 ? <> · <span className="text-gray-300">{fmt(r.orderTotal)} {t('deckCostUi.chipTotal')}</span></> : null}
</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" /> {t('deckCostUi.startFrom')}
</span>
{SHEET_TEMPLATES.map((tpl) => (
<button
key={tpl.id}
type="button"
title={t(tpl.blurbKey)}
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"
>
{t(tpl.nameKey)}
</button>
))}
<button
type="button"
onClick={() => setAdvanced((a) => !a)}
className={`ml-auto inline-flex min-h-0 items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold ${advanced ? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-[#ccff00]' : 'border-white/15 text-gray-300 hover:bg-white/5'}`}
title={t('deckCostUi.advancedToggleHint')}
>
<SlidersHorizontal className="h-3.5 w-3.5" /> {advanced ? t('deckCostUi.advanced') : t('deckCostUi.simple')}
</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 */}
<CalcSection title={t('deckCostUi.sectionLabor')} subtotal={t('deckCostUi.subtotalLoaded', { value: fmt(r.laborPerHr) })} defaultOpen>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<label className="space-y-1">
<span className={label}>{t('deckCostUi.hourlyRate', { unit })}</span>
{numInput(s.hourlyRate, (n) => patch({ hourlyRate: n }))}
</label>
<label className="space-y-1">
<span className={label}>{t('deckCostUi.labourBurden')}</span>
{numInput(s.overheadFactor, (n) => patch({ overheadFactor: n }))}
</label>
<div className="space-y-1">
<span className={label}>{t('deckCostUi.loadedRate')}</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>
<p className="mt-2 text-[11px] text-gray-500">{t('deckCostUi.loadedRateHintBefore')}<span className="text-gray-300">{t('deckCostUi.sectionOperations')}</span>{t('deckCostUi.loadedRateHintAfter')}</p>
</CalcSection>
{/* Equipment */}
<CalcSection title={t('deckCostUi.sectionEquipment')} subtotal={t('deckCostUi.subtotalPerBoard', { value: fmt(r.equipment) })}>
<div className="overflow-x-auto">
<table className="ss-sheet-table w-full text-xs">
<thead><tr><th className={th}>{t('deckCostUi.colEquipment')}</th><th className={th}>{t('deckCostUi.colCostUnit', { unit })}</th><th className={th}>{t('deckCostUi.colSetupH')}</th><th className={th}>{t('deckCostUi.colLifetimeBoards')}</th><th className={th}>{t('deckCostUi.colPerBoard')}</th><th /></tr></thead>
<tbody>
{s.equipment.map((e) => (
<tr key={e.id} className="border-t border-white/5">
<td className={`${td} ss-cell-name`}><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} data-label={t('deckCostUi.colCost')}>{numInput(e.cost, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, cost: n } : x) }))}</td>
<td className={td} data-label={t('deckCostUi.colSetupH')}>{numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))}</td>
<td className={td} data-label={t('deckCostUi.colLifetimeBoards')}>{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`} data-label={t('deckCostUi.colPerBoard')}>{fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)}</td>
<td className={`${td} ss-cell-actions`}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ equipment: [...s.equipment, { ...e, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label={t('deckCostUi.duplicate')}><Copy className="h-3.5 w-3.5" /></button>
<button type="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={t('deckCostUi.remove')}><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-gray-500">{t('deckCostUi.equipmentPerBoard')} <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" /> {t('deckCostUi.addEquipment')}
</Button>
</CalcSection>
{/* Operations */}
<CalcSection title={t('deckCostUi.sectionOperations')} subtotal={t('deckCostUi.subtotalPerBoard', { value: fmt(r.operations) })}>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<label className="space-y-1"><span className={label}>{t('deckCostUi.boardsPerMonth')}</span>{numInput(s.boardsPerMonth, (n) => patch({ boardsPerMonth: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.rentPerMonth')}</span>{numInput(s.rent, (n) => patch({ rent: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.utilitiesPerMonth')}</span>{numInput(s.utilities, (n) => patch({ utilities: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.subscriptionsPerMonth')}</span>{numInput(s.subscriptions, (n) => patch({ subscriptions: n }))}</label>
</div>
<p className="mt-2 text-xs text-gray-500">{t('deckCostUi.perBoard')} <span className="font-bold text-gray-300">{fmt(r.operations)}</span></p>
</CalcSection>
{/* Materials */}
<CalcSection title={t('deckCostUi.sectionMaterials')} subtotal={t('deckCostUi.subtotalPerBoard', { value: fmt(r.materials) })}>
<div className="overflow-x-auto">
<table className="ss-sheet-table w-full text-xs">
<thead><tr><th className={th}>{t('deckCostUi.colMaterial')}</th><th className={th}>{t('deckCostUi.colOrderUnit', { unit })}</th><th className={th}>{t('deckCostUi.colShipUnit', { unit })}</th><th className={th}>{t('deckCostUi.colBoardsOrder')}</th><th className={th}>{t('deckCostUi.colOrderM')}</th><th className={th}>{t('deckCostUi.colUnloadM')}</th><th className={th}>{t('deckCostUi.colPerBoard')}</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} ss-cell-name`}><Input value={m.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td} data-label={t('deckCostUi.colOrder')}>{numInput(m.orderCost, (n) => up({ orderCost: n }))}</td>
<td className={td} data-label={t('deckCostUi.colShipping')}>{numInput(m.shipping, (n) => up({ shipping: n }))}</td>
<td className={td} data-label={t('deckCostUi.colBoardsOrder')}>{numInput(m.boards, (n) => up({ boards: n }))}</td>
<td className={td} data-label={t('deckCostUi.colOrderMin')}>{numInput(m.orderMin, (n) => up({ orderMin: n }))}</td>
<td className={td} data-label={t('deckCostUi.colUnloadMin')}>{numInput(m.unloadMin, (n) => up({ unloadMin: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label={t('deckCostUi.colPerBoard')}>{fmt(per)}</td>
<td className={`${td} ss-cell-actions`}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ materials: [...s.materials, { ...m, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label={t('deckCostUi.duplicate')}><Copy className="h-3.5 w-3.5" /></button>
<button type="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={t('deckCostUi.remove')}><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-gray-500">{t('deckCostUi.materialsPerBoard')} <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" /> {t('deckCostUi.addMaterial')}
</Button>
</CalcSection>
{/* Processes */}
<CalcSection title={t('deckCostUi.sectionProcesses')} subtotal={t('deckCostUi.subtotalPerBoard', { value: fmt(r.processes) })}>
<div className="overflow-x-auto">
<table className="ss-sheet-table w-full text-xs">
<thead><tr><th className={th}>{t('deckCostUi.colProcess')}</th><th className={th}>{t('deckCostUi.colTimeM')}</th><th className={th}>{t('deckCostUi.colBoardsRun')}</th>{advanced && <th className={th}>{t('deckCostUi.colRateHr')}</th>}{advanced && <th className={th}>{t('deckCostUi.colScrapPct')}</th>}<th className={th}>{t('deckCostUi.colPerBoard')}</th><th /></tr></thead>
<tbody>
{s.processes.map((p) => {
const rate = p.rate > 0 ? p.rate : r.laborPerHr;
const per = p.boards > 0 ? (p.minutes * (rate / 60)) / p.boards : 0;
const up = (pp: Partial<ProcessRow>) => patch({ processes: s.processes.map((x) => x.id === p.id ? { ...x, ...pp } : x) });
return (
<tr key={p.id} className="border-t border-white/5">
<td className={`${td} ss-cell-name`}><Input value={p.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td} data-label={t('deckCostUi.colTimeMin')}>{numInput(p.minutes, (n) => up({ minutes: n }))}</td>
<td className={td} data-label={t('deckCostUi.colBoardsRun')}>{numInput(p.boards, (n) => up({ boards: n }))}</td>
{advanced && <td className={td} data-label={t('deckCostUi.colRateHrShort')}>{numInput(p.rate, (n) => up({ rate: n }))}</td>}
{advanced && <td className={td} data-label={t('deckCostUi.colScrapPct')}>{numInput(p.scrapPct, (n) => up({ scrapPct: Math.min(99, n) }))}</td>}
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label={t('deckCostUi.colPerBoard')}>{fmt(per)}</td>
<td className={`${td} ss-cell-actions`}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ processes: [...s.processes, { ...p, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label={t('deckCostUi.duplicate')}><Copy className="h-3.5 w-3.5" /></button>
<button type="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={t('deckCostUi.remove')}><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-gray-500">{t('deckCostUi.processesPerBoard')} <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, rate: 0, scrapPct: 0 }] })}>
<Plus className="h-3.5 w-3.5" /> {t('deckCostUi.addProcess')}
</Button>
</CalcSection>
{/* Packaging */}
<CalcSection title={t('deckCostUi.sectionPackaging')} subtotal={t('deckCostUi.subtotalPerBoard', { value: fmt(r.packaging) })}>
<div className="overflow-x-auto">
<table className="ss-sheet-table w-full text-xs">
<thead><tr><th className={th}>{t('deckCostUi.colPackaging')}</th><th className={th}>{t('deckCostUi.colTimeBoardM')}</th><th className={th}>{t('deckCostUi.colMaterialsBoardUnit', { unit })}</th><th className={th}>{t('deckCostUi.colPerBoard')}</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} ss-cell-name`}><Input value={p.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td} data-label={t('deckCostUi.colTimeBoardMin')}>{numInput(p.minutesPerBoard, (n) => up({ minutesPerBoard: n }))}</td>
<td className={td} data-label={t('deckCostUi.colMaterialsBoard')}>{numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label={t('deckCostUi.colPerBoard')}>{fmt(per)}</td>
<td className={`${td} ss-cell-actions`}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ packaging: [...s.packaging, { ...p, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label={t('deckCostUi.duplicate')}><Copy className="h-3.5 w-3.5" /></button>
<button type="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={t('deckCostUi.remove')}><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-gray-500">{t('deckCostUi.packagingPerBoard')} <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" /> {t('deckCostUi.addPackagingStep')}
</Button>
</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]">{t('deckCostUi.sectionMargins')}</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<label className="space-y-1"><span className={label}>{t('deckCostUi.markup')}</span>{numInput(s.profitMargin, (n) => patch({ profitMargin: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.wholesaleMarkup')}</span>{numInput(s.wholesaleMarkup, (n) => patch({ wholesaleMarkup: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.scrapWaste')}</span>{numInput(s.scrapPct, (n) => patch({ scrapPct: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.quantityDecks')}</span>{numInput(s.quantity, (n) => patch({ quantity: Math.max(1, Math.round(n) || 1) }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.targetMonthlyProfit', { unit })}</span>{numInput(s.targetMonthlyProfit, (n) => patch({ targetMonthlyProfit: n }))}</label>
<div className="space-y-1"><span className={label}>{t('deckCostUi.costNoPackaging')}</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.costNoPackUnit)}</div></div>
<div className="space-y-1"><span className={label}>{t('deckCostUi.costWithPackaging')}</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.costWithPackUnit)}</div></div>
</div>
{advanced && (
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-3 rounded-xl border border-white/10 bg-black/20 p-3">
<label className="space-y-1"><span className={label}>{t('deckCostUi.batchSetupHours')}</span>{numInput(s.batchSetupHours, (n) => patch({ batchSetupHours: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.batchSetupCost', { unit })}</span>{numInput(s.batchSetupCost, (n) => patch({ batchSetupCost: n }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.taxVat')}</span>{numInput(s.taxPct, (n) => patch({ taxPct: Math.min(99, n) }))}</label>
<label className="space-y-1"><span className={label}>{t('deckCostUi.quoteTax')}</span>
<button type="button" onClick={() => patch({ taxInclusive: !s.taxInclusive })} className="flex h-8 w-full items-center rounded-md border border-white/10 bg-black/40 px-2 text-xs font-bold text-gray-200 hover:bg-white/5">
{s.taxInclusive ? t('deckCostUi.includedInPrice') : t('deckCostUi.addedOnTop')}
</button>
</label>
{r.batchSetup > 0 && <div className="space-y-1"><span className={label}>{t('deckCostUi.setupPerBoard')}</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.setupPerUnit)}</div></div>}
</div>
)}
<p className="mt-2 text-[11px] text-gray-500">
{t('deckCostUi.marginNoteBefore', { markup: s.profitMargin })}<span className="text-gray-300">{t('deckCostUi.grossMargin', { pct: marginPct })}</span>{t('deckCostUi.marginNoteAfter', { waste: r.effectiveWastePct.toFixed(1) })}
</p>
<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">{t('deckCostUi.sellingPriceNoPackaging')}</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">{t('deckCostUi.sellingPriceWithPackaging')}</p>
<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">{t('deckCostUi.profitPerDeck')}</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">{t('deckCostUi.wholesalePerDeck')}</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 ? t('deckCostUi.orderTotalQty', { qty: r.qty }) : t('deckCostUi.orderTotal')}</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">{t('deckCostUi.breakeven')}</p>
<p className="mt-0.5 text-sm font-black text-gray-100">{r.breakevenDecks > 0 ? t('deckCostUi.breakevenPerMonth', { count: r.breakevenDecks }) : '—'}</p>
</div>
</div>
{r.targetDecks > 0 && (
<p className="mt-2 text-[11px] text-gray-500">
{t('deckCostUi.targetDecksHint', { amount: fmt(s.targetMonthlyProfit), decks: r.targetDecks })}
</p>
)}
{s.taxPct > 0 && (
<p className="mt-2 text-[11px] text-gray-500">
{s.taxInclusive
? <>{t('deckCostUi.priceIncludesTax', { pct: s.taxPct, amount: fmt(r.orderTax) })}</>
: <>{t('deckCostUi.plusTax', { pct: s.taxPct, amount: fmt(r.orderTax), total: fmt(r.orderTotal) })}</>}
</p>
)}
{advanced && (
<div className="mt-3 rounded-xl border border-white/10 bg-black/20 p-3">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-gray-500">{t('deckCostUi.costVsQuantity')}</p>
<div>
{r.curve.map((c) => (
<div key={c.qty} className="flex items-start justify-between gap-3 border-t border-white/5 py-1.5 text-xs">
<span className="shrink-0 pt-0.5 font-bold text-gray-400">{c.qty} {c.qty === 1 ? t('deckCostUi.deckOne') : t('deckCostUi.deckOther')}</span>
<span className="min-w-0 text-right">
<span className="block font-bold text-gray-100">{fmt(c.unit)}<span className="font-normal text-gray-500"> {t('deckCostUi.perDeckSuffix')}</span></span>
<span className="block text-[10px] text-gray-500">{t('deckCostUi.orderSuffix', { amount: fmt(c.total) })}</span>
</span>
</div>
))}
</div>
</div>
)}
<p className="mt-2 text-[11px] text-gray-500">{t('deckCostUi.shippingNotIncluded')}</p>
{/* Quote details — your identity on the client PDF (collapsed by default). */}
<details className="group mt-4 rounded-xl border border-white/10 bg-black/30 p-3">
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 text-[11px] font-black uppercase tracking-wider text-gray-500 [&::-webkit-details-marker]:hidden">
<span>{t('deckCostUi.clientQuoteDetails')}</span>
<ChevronDown className="h-3.5 w-3.5 transition-transform group-open:rotate-180" />
</summary>
<div className="mt-3 space-y-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={fillFromProfile}
className="inline-flex min-h-0 items-center gap-1 rounded-md border border-white/15 px-2 py-1 text-[11px] font-semibold hover:bg-white/5"
title={t('deckCostUi.fillFromProfileTitle')}
>
<AtSign className="h-3.5 w-3.5" /> {t('deckCostUi.fillFromProfile')}
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className={label}>{t('deckCostUi.businessName')}</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}>{t('deckCostUi.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}>{t('deckCostUi.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}>{t('deckCostUi.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}>{t('deckCostUi.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}>{t('deckCostUi.quoteNumber')}</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={t('deckCostUi.nextNumber')}>+1</button>
</div>
</label>
<label className="space-y-1">
<span className={label}>{t('deckCostUi.validDays')}</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="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className={label}>{t('deckCostUi.nip05')}</span>
<Input value={quote.nip05} onChange={(e) => setQuote({ ...quote, nip05: e.target.value })} placeholder="you@skate-spots.org" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>{t('deckCostUi.lightningAddress')}</span>
<Input value={quote.lightning} onChange={(e) => setQuote({ ...quote, lightning: e.target.value })} placeholder="you@getalby.com" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1 sm:col-span-2">
<span className={label}>{t('deckCostUi.nostrNpub')}</span>
<Input value={quote.npub} onChange={(e) => setQuote({ ...quote, npub: e.target.value })} placeholder="npub1…" className="h-8 bg-black/40 border-white/10 font-mono text-[11px]" />
</label>
</div>
{/* Payment details & terms */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className={label}>{t('deckCostUi.depositPct')}</span>
<Input type="number" min={0} max={100} value={quote.depositPct} onChange={(e) => setQuote({ ...quote, depositPct: Math.min(100, Math.max(0, Number(e.target.value) || 0)) })} className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>{t('deckCostUi.deliveryTerms')}</span>
<select
value={quote.incoterm}
onChange={(e) => setQuote({ ...quote, incoterm: e.target.value })}
className="h-8 w-full rounded-md border border-white/10 bg-black/40 px-2 text-sm text-gray-200"
>
{INCOTERM_OPTIONS.map((it) => (
<option key={it.code} value={it.code} className="bg-neutral-900">{t(it.labelKey)}</option>
))}
</select>
</label>
</div>
<div className="space-y-1">
<span className={label}>{t('deckCostUi.paymentMethods')}</span>
<div className="flex flex-wrap gap-1.5">
{PAY_METHODS.map((m) => {
const on = quote.payMethods.includes(m);
return (
<button
key={m}
type="button"
onClick={() => setQuote({ ...quote, payMethods: on ? quote.payMethods.filter((x) => x !== m) : [...quote.payMethods, m] })}
className={`min-h-0 rounded-full border px-2.5 py-1 text-[11px] font-semibold ${on ? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white' : 'border-white/15 text-gray-400 hover:bg-white/5'}`}
>
{t(PAY_METHOD_KEYS[m] ?? m)}
</button>
);
})}
</div>
</div>
<label className="block space-y-1">
<span className={label}>{t('deckCostUi.paymentTerms')}</span>
<Input value={quote.paymentTerms} onChange={(e) => setQuote({ ...quote, paymentTerms: e.target.value })} placeholder="50% deposit, balance before dispatch" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="block space-y-1">
<span className={label}>{t('deckCostUi.paymentDetails')}</span>
<Input value={quote.paymentDetails} onChange={(e) => setQuote({ ...quote, paymentDetails: e.target.value })} placeholder="Bank/IBAN, PayPal, Revolut… (optional)" className="h-8 bg-black/40 border-white/10" />
</label>
<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 ? t('deckCostUi.changeLogo') : t('deckCostUi.addLogo')}
<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">{t('deckCostUi.remove')}</button>
)}
</div>
<label className="block space-y-1">
<span className={label}>{t('deckCostUi.notesTerms')}</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>
</details>
{/* 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(buyerUnit * 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"
>
{t('deckCostUi.listDeckAtPrice')} <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"
>
{t('deckCostUi.getItMade')}
</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" /> {t('deckCostUi.savePreset')}
</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" /> {t('deckCostUi.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={t('deckCostUi.deletePreset')} 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" /> {t('deckCostUi.export')} <ChevronDown className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-52 p-1">
{[
{ label: t('deckCostUi.exportPdfFull'), run: () => void exportPdf() },
{ label: t('deckCostUi.exportPdfQuote'), run: () => void exportQuotePdf() },
{ label: t('deckCostUi.exportPng'), run: () => void exportPng() },
{ label: t('deckCostUi.exportXlsx'), run: () => void exportXlsx() },
{ label: t('deckCostUi.exportCsv'), run: exportCsv },
{ label: t('deckCostUi.exportMarkdown'), run: exportMarkdown },
{ label: t('deckCostUi.exportJson'), run: exportJson },
{ label: t('deckCostUi.exportCopySummary'), 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" /> {t('deckCostUi.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" /> {t('deckCostUi.reset')}
</Button>
</div>
</div>
);
}