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**
|
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,
|
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/
|
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
|
step-by-step guides (NIP-23 `30023` with `t=diy`) to flip the coming-soon cards
|
||||||
live.
|
live.
|
||||||
- **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be
|
- **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() {
|
export function DeckCostCalculator() {
|
||||||
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost', DEFAULT_CALC);
|
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost', DEFAULT_CALC);
|
||||||
const { data: rates } = useBtcRates();
|
const { data: rates } = useBtcRates();
|
||||||
|
|
@ -387,12 +516,76 @@ export function DeckCostCalculator() {
|
||||||
|
|
||||||
doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`);
|
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];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
const isCsv = file.name.toLowerCase().endsWith('.csv');
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
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);
|
reader.readAsText(file);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
|
|
@ -681,15 +874,28 @@ export function DeckCostCalculator() {
|
||||||
))}
|
))}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={exportPdf}>
|
<Popover>
|
||||||
<Download className="h-3.5 w-3.5" /> Export PDF
|
<PopoverTrigger asChild>
|
||||||
</Button>
|
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs">
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1.5 text-xs text-gray-400" onClick={exportJson}>
|
<Download className="h-3.5 w-3.5" /> Export <ChevronDown className="h-3.5 w-3.5" />
|
||||||
JSON
|
</Button>
|
||||||
</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">
|
<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
|
<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>
|
</label>
|
||||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={reset}>
|
<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
|
<RotateCcw className="h-3.5 w-3.5" /> Reset
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue