Deck cost calculator: CSV + Markdown export, CSV import
- Export is now a menu: PDF (quote/print), CSV (spreadsheet-friendly section,item,field,value rows), Markdown, JSON (backup). - Import auto-detects .json / .csv: JSON loads a backup, CSV round-trips the whole sheet (hand-rolled CSV quote/parse so commas in material names are safe).
This commit is contained in:
parent
885e818e3b
commit
daeb3fbf3e
2 changed files with 220 additions and 10 deletions
|
|
@ -840,7 +840,11 @@ session with **no other memory of this project**. So:
|
|||
export remains (for Import/backup); Import reads JSON. The default **Materials**
|
||||
list is a full deck-build checklist (veneer/edge tape/logs, glue/epoxy/filler,
|
||||
mold release/vacuum film, sandpaper/saw+drill bits/masking tape, stain/primer/
|
||||
paint/clearcoat/grip). Next: write our own
|
||||
paint/clearcoat/grip). **Export formats (2026-10-05):** an **Export ▾** menu —
|
||||
**PDF** (quote/print), **CSV** (spreadsheet, `section,item,field,value`),
|
||||
**Markdown**, **JSON** (backup) — and **Import** auto-detects `.json`/`.csv`
|
||||
(CSV round-trips the whole sheet; hand-rolled CSV parse/escape so commas in
|
||||
names are safe). Next: write our own
|
||||
step-by-step guides (NIP-23 `30023` with `t=diy`) to flip the coming-soon cards
|
||||
live.
|
||||
- **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be
|
||||
|
|
|
|||
|
|
@ -173,6 +173,135 @@ function freshDefault(): CalcState {
|
|||
};
|
||||
}
|
||||
|
||||
// ─── 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 !== ''));
|
||||
}
|
||||
|
||||
/** Rows for the CSV export: `section,item,field,value`. */
|
||||
function calcCsvRows(s: CalcState): string[][] {
|
||||
const rows: string[][] = [['section', 'item', 'field', 'value']];
|
||||
const num = (n: number) => String(n);
|
||||
rows.push(['settings', 'currency', 'value', s.currency]);
|
||||
rows.push(['labor', 'hourly_rate', 'value', num(s.hourlyRate)]);
|
||||
rows.push(['labor', 'overhead_factor', 'value', num(s.overheadFactor)]);
|
||||
rows.push(['operations', 'boards_per_month', 'value', num(s.boardsPerMonth)]);
|
||||
rows.push(['operations', 'rent', 'value', num(s.rent)]);
|
||||
rows.push(['operations', 'utilities', 'value', num(s.utilities)]);
|
||||
rows.push(['operations', 'subscriptions', 'value', num(s.subscriptions)]);
|
||||
rows.push(['margins', 'profit_margin', 'value', num(s.profitMargin)]);
|
||||
rows.push(['margins', 'scrap_pct', 'value', num(s.scrapPct)]);
|
||||
for (const e of s.equipment) {
|
||||
rows.push(['equipment', e.name, 'cost', num(e.cost)]);
|
||||
rows.push(['equipment', e.name, 'setup_hrs', num(e.setupHrs)]);
|
||||
rows.push(['equipment', e.name, 'boards', num(e.boards)]);
|
||||
}
|
||||
for (const m of s.materials) {
|
||||
rows.push(['materials', m.name, 'order_cost', num(m.orderCost)]);
|
||||
rows.push(['materials', m.name, 'shipping', num(m.shipping)]);
|
||||
rows.push(['materials', m.name, 'boards', num(m.boards)]);
|
||||
rows.push(['materials', m.name, 'order_min', num(m.orderMin)]);
|
||||
rows.push(['materials', m.name, 'unload_min', num(m.unloadMin)]);
|
||||
}
|
||||
for (const p of s.processes) {
|
||||
rows.push(['processes', p.name, 'minutes', num(p.minutes)]);
|
||||
rows.push(['processes', p.name, 'boards', num(p.boards)]);
|
||||
}
|
||||
for (const p of s.packaging) {
|
||||
rows.push(['packaging', p.name, 'minutes_per_board', num(p.minutesPerBoard)]);
|
||||
rows.push(['packaging', p.name, 'materials_per_board', num(p.materialsPerBoard)]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Parse an exported CSV back into a CalcState (unknown sections ignored). */
|
||||
function calcStateFromCsv(text: string): CalcState | null {
|
||||
const rows = parseCsv(text);
|
||||
if (rows.length < 2) return null;
|
||||
const out = freshDefault();
|
||||
const eq = new Map<string, EquipmentRow>();
|
||||
const ma = new Map<string, MaterialRow>();
|
||||
const pr = new Map<string, ProcessRow>();
|
||||
const pk = new Map<string, PackageRow>();
|
||||
const n = (v: string) => Number(v) || 0;
|
||||
|
||||
for (const r of rows.slice(1)) {
|
||||
const [section, item, field, value] = r;
|
||||
if (!section || !field) continue;
|
||||
if (section === 'settings' && field === 'value') { out.currency = item || 'USD'; continue; }
|
||||
if (section === 'labor' && field === 'value') {
|
||||
if (item === 'hourly_rate') out.hourlyRate = n(value);
|
||||
else if (item === 'overhead_factor') out.overheadFactor = n(value);
|
||||
continue;
|
||||
}
|
||||
if (section === 'operations' && field === 'value') {
|
||||
if (item === 'boards_per_month') out.boardsPerMonth = n(value);
|
||||
else if (item === 'rent') out.rent = n(value);
|
||||
else if (item === 'utilities') out.utilities = n(value);
|
||||
else if (item === 'subscriptions') out.subscriptions = n(value);
|
||||
continue;
|
||||
}
|
||||
if (section === 'margins' && field === 'value') {
|
||||
if (item === 'profit_margin') out.profitMargin = n(value);
|
||||
else if (item === 'scrap_pct') out.scrapPct = n(value);
|
||||
continue;
|
||||
}
|
||||
if (section === 'equipment') {
|
||||
const row = eq.get(item) ?? { id: uid(), name: item, cost: 0, setupHrs: 0, boards: 1 };
|
||||
if (field === 'cost') row.cost = n(value);
|
||||
else if (field === 'setup_hrs') row.setupHrs = n(value);
|
||||
else if (field === 'boards') row.boards = n(value);
|
||||
eq.set(item, row);
|
||||
} else if (section === 'materials') {
|
||||
const row = ma.get(item) ?? { id: uid(), name: item, orderCost: 0, shipping: 0, boards: 1, orderMin: 0, unloadMin: 0 };
|
||||
if (field === 'order_cost') row.orderCost = n(value);
|
||||
else if (field === 'shipping') row.shipping = n(value);
|
||||
else if (field === 'boards') row.boards = n(value);
|
||||
else if (field === 'order_min') row.orderMin = n(value);
|
||||
else if (field === 'unload_min') row.unloadMin = n(value);
|
||||
ma.set(item, row);
|
||||
} else if (section === 'processes') {
|
||||
const row = pr.get(item) ?? { id: uid(), name: item, minutes: 0, boards: 1 };
|
||||
if (field === 'minutes') row.minutes = n(value);
|
||||
else if (field === 'boards') row.boards = n(value);
|
||||
pr.set(item, row);
|
||||
} else if (section === 'packaging') {
|
||||
const row = pk.get(item) ?? { id: uid(), name: item, minutesPerBoard: 0, materialsPerBoard: 0 };
|
||||
if (field === 'minutes_per_board') row.minutesPerBoard = n(value);
|
||||
else if (field === 'materials_per_board') row.materialsPerBoard = n(value);
|
||||
pk.set(item, row);
|
||||
}
|
||||
}
|
||||
out.equipment = [...eq.values()];
|
||||
out.materials = [...ma.values()];
|
||||
out.processes = [...pr.values()];
|
||||
out.packaging = [...pk.values()];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function DeckCostCalculator() {
|
||||
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost', DEFAULT_CALC);
|
||||
const { data: rates } = useBtcRates();
|
||||
|
|
@ -387,12 +516,76 @@ export function DeckCostCalculator() {
|
|||
|
||||
doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
};
|
||||
const importJson = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const download = (filename: string, content: string, mime: string) => {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
const exportCsv = () => download('deck-cost.csv', toCsv(calcCsvRows(s)), 'text/csv');
|
||||
const exportMarkdown = () => {
|
||||
const md = [
|
||||
'# Deck cost calculator',
|
||||
'',
|
||||
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`,
|
||||
'',
|
||||
'## Labor',
|
||||
`- Hourly rate: ${fmt(s.hourlyRate)}`,
|
||||
`- Overhead factor: ${s.overheadFactor}×`,
|
||||
`- Labor cost: ${fmt(s.hourlyRate * s.overheadFactor)}/hr`,
|
||||
'',
|
||||
'## Equipment',
|
||||
...s.equipment.map((e) => `- ${e.name}: ${fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)} (${fmt(e.cost)} · ${e.boards.toLocaleString()} boards)`),
|
||||
`- **Equipment per board: ${fmt(r.equipment)}**`,
|
||||
'',
|
||||
'## Operation expenses',
|
||||
`- Boards/month: ${s.boardsPerMonth}`,
|
||||
`- Rent: ${fmt(s.rent)}`,
|
||||
`- Utilities: ${fmt(s.utilities)}`,
|
||||
`- Subscriptions: ${fmt(s.subscriptions)}`,
|
||||
`- **Per board: ${fmt(r.operations)}**`,
|
||||
'',
|
||||
'## Materials',
|
||||
...s.materials.map((m) => `- ${m.name}: ${fmt(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)} (${fmt(m.orderCost)} · ${m.boards.toLocaleString()} boards)`),
|
||||
`- **Materials per board: ${fmt(r.materials)}**`,
|
||||
'',
|
||||
'## Process costs',
|
||||
...s.processes.map((p) => `- ${p.name}: ${fmt(p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0)} (${p.minutes} min)`),
|
||||
`- **Processes per board: ${fmt(r.processes)}**`,
|
||||
'',
|
||||
'## Packaging',
|
||||
...s.packaging.map((p) => `- ${p.name}: ${fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)}`),
|
||||
`- **Packaging per board: ${fmt(r.packaging)}**`,
|
||||
'',
|
||||
'## Result',
|
||||
`- Profit margin: ${s.profitMargin}× · Scrap: ${s.scrapPct}%`,
|
||||
`- Cost/board (no packaging): ${fmt(r.costNoPack)}`,
|
||||
`- Cost/board (with packaging): ${fmt(r.costWithPack)}`,
|
||||
`- **Selling price (no packaging): ${fmt(r.priceNoPack)}**`,
|
||||
`- **Selling price (with packaging): ${fmt(r.priceWithPack)}**`,
|
||||
'',
|
||||
'_Does not include shipping to the buyer. Generated by SkateSpots._',
|
||||
].join('\n');
|
||||
download('deck-cost.md', md, 'text/markdown');
|
||||
};
|
||||
const importFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const isCsv = file.name.toLowerCase().endsWith('.csv');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try { setS({ ...freshDefault(), ...(JSON.parse(String(reader.result)) as CalcState) }); } catch { /* ignore malformed */ }
|
||||
try {
|
||||
const text = String(reader.result);
|
||||
if (isCsv) {
|
||||
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 = '';
|
||||
|
|
@ -681,15 +874,28 @@ export function DeckCostCalculator() {
|
|||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={exportPdf}>
|
||||
<Download className="h-3.5 w-3.5" /> Export PDF
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1.5 text-xs text-gray-400" onClick={exportJson}>
|
||||
JSON
|
||||
</Button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs">
|
||||
<Download className="h-3.5 w-3.5" /> Export <ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-52 p-1">
|
||||
{[
|
||||
{ label: 'PDF (print / quote)', run: () => void exportPdf() },
|
||||
{ label: 'CSV (spreadsheet)', run: exportCsv },
|
||||
{ label: 'Markdown', run: exportMarkdown },
|
||||
{ label: 'JSON (backup)', run: exportJson },
|
||||
].map((o) => (
|
||||
<button key={o.label} type="button" onClick={o.run} className="flex w-full min-h-0 rounded-lg px-2.5 py-1.5 text-left text-xs font-semibold text-gray-300 hover:bg-white/5">
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<label className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/15 px-3 text-xs font-semibold hover:bg-white/5">
|
||||
<Upload className="h-3.5 w-3.5" /> Import
|
||||
<input type="file" accept="application/json" className="hidden" onChange={importJson} />
|
||||
<input type="file" accept=".json,.csv,application/json,text/csv" className="hidden" onChange={importFile} />
|
||||
</label>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={reset}>
|
||||
<RotateCcw className="h-3.5 w-3.5" /> Reset
|
||||
|
|
|
|||
Loading…
Reference in a new issue