1745 lines
99 KiB
TypeScript
1745 lines
99 KiB
TypeScript
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))}¤cy=${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>
|
||
);
|
||
}
|