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 = { 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 = { sats: 1e8, BTC: 1 }; /** Units of a currency per 1 BTC (sats/BTC = fixed; fiats come from rates). */ function ratePerBtc(cur: string, rates?: Record): 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 = { 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 (
{open &&
{children}
}
); } // ─── 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 { 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(); const ma = new Map(); const pr = new Map(); const pk = new Map(); 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('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(() => ({ ...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('skatespots:deck-cost:quote', DEFAULT_QUOTE); // Merge over defaults so a quote saved before a new field existed can't crash. const quote = useMemo( () => ({ ...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('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) => 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) => { 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) => { 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) => ( 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 (
{/* Currency — searchable; switching CONVERTS every money field via live BTC rates (sats included). */}
{t('deckCostUi.currency')}
setQuery(e.target.value)} placeholder="Search currency…" className="h-8 border-white/10 bg-white/5 pl-8 text-xs" />
{filteredCurrencies.map((c) => ( ))} {filteredCurrencies.length === 0 && (

{t('deckCostUi.noCurrencyMatches')}

)}
{!rates && (

{t('deckCostUi.ratesUnavailable')}

)}
{rates && {t('deckCostUi.liveRates')}} {fmt(r.costWithPackUnit)} {t('deckCostUi.chipCost')} · {fmt(r.priceWithPack)} {t('deckCostUi.chipSell')}{r.qty > 1 ? <> · {fmt(r.orderTotal)} {t('deckCostUi.chipTotal')} : null}
{/* Quick-start templates */}
{t('deckCostUi.startFrom')} {SHEET_TEMPLATES.map((tpl) => ( ))}
{/* Sanity warnings */} {warnings.length > 0 && (
{warnings.map((w, i) => (

{w}

))}
)} {/* Labor */}
{t('deckCostUi.loadedRate')}
{fmt(s.hourlyRate * s.overheadFactor)}/hr

{t('deckCostUi.loadedRateHintBefore')}{t('deckCostUi.sectionOperations')}{t('deckCostUi.loadedRateHintAfter')}

{/* Equipment */}
{s.equipment.map((e) => ( ))}
{t('deckCostUi.colEquipment')}{t('deckCostUi.colCostUnit', { unit })}{t('deckCostUi.colSetupH')}{t('deckCostUi.colLifetimeBoards')}{t('deckCostUi.colPerBoard')}
patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, name: ev.target.value } : x) })} className="h-8 min-w-36" /> {numInput(e.cost, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, cost: n } : x) }))} {numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))} {numInput(e.boards, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, boards: n } : x) }))} {fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)}

{t('deckCostUi.equipmentPerBoard')} {fmt(r.equipment)}

{/* Operations */}

{t('deckCostUi.perBoard')} {fmt(r.operations)}

{/* Materials */}
{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) => patch({ materials: s.materials.map((x) => x.id === m.id ? { ...x, ...p } : x) }); return ( ); })}
{t('deckCostUi.colMaterial')}{t('deckCostUi.colOrderUnit', { unit })}{t('deckCostUi.colShipUnit', { unit })}{t('deckCostUi.colBoardsOrder')}{t('deckCostUi.colOrderM')}{t('deckCostUi.colUnloadM')}{t('deckCostUi.colPerBoard')}
up({ name: e.target.value })} className="h-8 min-w-36" /> {numInput(m.orderCost, (n) => up({ orderCost: n }))} {numInput(m.shipping, (n) => up({ shipping: n }))} {numInput(m.boards, (n) => up({ boards: n }))} {numInput(m.orderMin, (n) => up({ orderMin: n }))} {numInput(m.unloadMin, (n) => up({ unloadMin: n }))} {fmt(per)}

{t('deckCostUi.materialsPerBoard')} {fmt(r.materials)}

{/* Processes */}
{advanced && }{advanced && } {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) => patch({ processes: s.processes.map((x) => x.id === p.id ? { ...x, ...pp } : x) }); return ( {advanced && } {advanced && } ); })}
{t('deckCostUi.colProcess')}{t('deckCostUi.colTimeM')}{t('deckCostUi.colBoardsRun')}{t('deckCostUi.colRateHr')}{t('deckCostUi.colScrapPct')}{t('deckCostUi.colPerBoard')}
up({ name: e.target.value })} className="h-8 min-w-36" /> {numInput(p.minutes, (n) => up({ minutes: n }))} {numInput(p.boards, (n) => up({ boards: n }))}{numInput(p.rate, (n) => up({ rate: n }))}{numInput(p.scrapPct, (n) => up({ scrapPct: Math.min(99, n) }))}{fmt(per)}

{t('deckCostUi.processesPerBoard')} {fmt(r.processes)}

{/* Packaging */}
{s.packaging.map((p) => { const per = p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin; const up = (pp: Partial) => patch({ packaging: s.packaging.map((x) => x.id === p.id ? { ...x, ...pp } : x) }); return ( ); })}
{t('deckCostUi.colPackaging')}{t('deckCostUi.colTimeBoardM')}{t('deckCostUi.colMaterialsBoardUnit', { unit })}{t('deckCostUi.colPerBoard')}
up({ name: e.target.value })} className="h-8 min-w-36" /> {numInput(p.minutesPerBoard, (n) => up({ minutesPerBoard: n }))} {numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))} {fmt(per)}

{t('deckCostUi.packagingPerBoard')} {fmt(r.packaging)}

{/* Margins + results */}

{t('deckCostUi.sectionMargins')}

{t('deckCostUi.costNoPackaging')}
{fmt(r.costNoPackUnit)}
{t('deckCostUi.costWithPackaging')}
{fmt(r.costWithPackUnit)}
{advanced && (
{r.batchSetup > 0 &&
{t('deckCostUi.setupPerBoard')}
{fmt(r.setupPerUnit)}
}
)}

{t('deckCostUi.marginNoteBefore', { markup: s.profitMargin })}{t('deckCostUi.grossMargin', { pct: marginPct })}{t('deckCostUi.marginNoteAfter', { waste: r.effectiveWastePct.toFixed(1) })}

{t('deckCostUi.sellingPriceNoPackaging')}

{fmt(r.priceNoPack)}

{t('deckCostUi.sellingPriceWithPackaging')}

{fmt(r.priceWithPack)}

{t('deckCostUi.profitPerDeck')}

{fmt(r.profitPerUnit)}

{t('deckCostUi.wholesalePerDeck')}

{fmt(r.wholesaleUnit)}

{r.qty > 1 ? t('deckCostUi.orderTotalQty', { qty: r.qty }) : t('deckCostUi.orderTotal')}

{fmt(r.orderTotal)}

{t('deckCostUi.breakeven')}

{r.breakevenDecks > 0 ? t('deckCostUi.breakevenPerMonth', { count: r.breakevenDecks }) : '—'}

{r.targetDecks > 0 && (

{t('deckCostUi.targetDecksHint', { amount: fmt(s.targetMonthlyProfit), decks: r.targetDecks })}

)} {s.taxPct > 0 && (

{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) })}}

)} {advanced && (

{t('deckCostUi.costVsQuantity')}

{r.curve.map((c) => (
{c.qty} {c.qty === 1 ? t('deckCostUi.deckOne') : t('deckCostUi.deckOther')} {fmt(c.unit)} {t('deckCostUi.perDeckSuffix')} {t('deckCostUi.orderSuffix', { amount: fmt(c.total) })}
))}
)}

{t('deckCostUi.shippingNotIncluded')}

{/* Quote details — your identity on the client PDF (collapsed by default). */}
{t('deckCostUi.clientQuoteDetails')}
{/* Payment details & terms */}
{t('deckCostUi.paymentMethods')}
{PAY_METHODS.map((m) => { const on = quote.payMethods.includes(m); return ( ); })}
{quote.logo && } {quote.logo && ( )}