Deck cost calculator: quote logo + business/contact details + notes
A "Quote details" block (in the Result section) lets the user set a business/ shop name, a logo (stored as a data URL — no upload/network, capped 500 KB), email/phone/website/address and notes/terms. The client quote PDF now renders a logo + contact header, the line item + total, and the notes/terms. Bumped the localStorage key to :v2 (and guarded preset/JSON loads) so an older saved sheet without the quote block can't crash the tool.
This commit is contained in:
parent
1a8c483682
commit
c8cbb848e1
2 changed files with 119 additions and 30 deletions
|
|
@ -841,7 +841,9 @@ session with **no other memory of this project**. So:
|
||||||
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). **Export/Import (2026-10-05):** **Export ▾** —
|
paint/clearcoat/grip). **Export/Import (2026-10-05):** **Export ▾** —
|
||||||
**PDF (full sheet)**, **PDF (client quote, uses an optional `businessName`)**,
|
**PDF (full sheet)**, **PDF (client quote — uses a "Quote details" block:
|
||||||
|
business name, a data-URL logo, email/phone/website/address, notes/terms; shows
|
||||||
|
logo + contact header, line item, total and notes, not the internal breakdown)**,
|
||||||
**PNG** summary card (canvas), **XLSX** multi-sheet workbook (lazy `xlsx`),
|
**PNG** summary card (canvas), **XLSX** multi-sheet workbook (lazy `xlsx`),
|
||||||
**CSV** (`section,item,field,value`), **Markdown**, **JSON** (backup), **Copy
|
**CSV** (`section,item,field,value`), **Markdown**, **JSON** (backup), **Copy
|
||||||
summary** (clipboard). **Import** auto-detects `.json`/`.csv`: a CSV whose
|
summary** (clipboard). **Import** auto-detects `.json`/`.csv`: a CSV whose
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Plus, Trash2, RotateCcw, Search, ChevronDown, ChevronRight, Check, Save, FolderOpen, Download, Upload } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, Search, ChevronDown, ChevronRight, Check, Save, FolderOpen, Download, Upload } from 'lucide-react';
|
||||||
|
|
@ -86,6 +87,15 @@ interface CalcState {
|
||||||
currency: CurrencyCode;
|
currency: CurrencyCode;
|
||||||
/** Optional business/shop name, used on client quotes. */
|
/** Optional business/shop name, used on client quotes. */
|
||||||
businessName: string;
|
businessName: string;
|
||||||
|
/** Client-quote details (the logo is a data URL — no upload/network). */
|
||||||
|
quote: {
|
||||||
|
logo: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
website: string;
|
||||||
|
address: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
hourlyRate: number;
|
hourlyRate: number;
|
||||||
overheadFactor: number;
|
overheadFactor: number;
|
||||||
boardsPerMonth: number;
|
boardsPerMonth: number;
|
||||||
|
|
@ -105,6 +115,7 @@ const uid = () => Math.random().toString(36).slice(2, 9);
|
||||||
const DEFAULT_CALC: CalcState = {
|
const DEFAULT_CALC: CalcState = {
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
businessName: '',
|
businessName: '',
|
||||||
|
quote: { logo: '', email: '', phone: '', website: '', address: '', notes: '' },
|
||||||
hourlyRate: 20,
|
hourlyRate: 20,
|
||||||
overheadFactor: 2.5,
|
overheadFactor: 2.5,
|
||||||
boardsPerMonth: 160,
|
boardsPerMonth: 160,
|
||||||
|
|
@ -205,6 +216,16 @@ function parseCsv(text: string): string[][] {
|
||||||
return rows.filter((r) => r.some((c) => c !== ''));
|
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`. */
|
/** Rows for the CSV export: `section,item,field,value`. */
|
||||||
function calcCsvRows(s: CalcState): string[][] {
|
function calcCsvRows(s: CalcState): string[][] {
|
||||||
const rows: string[][] = [['section', 'item', 'field', 'value']];
|
const rows: string[][] = [['section', 'item', 'field', 'value']];
|
||||||
|
|
@ -306,8 +327,7 @@ function calcStateFromCsv(text: string): CalcState | null {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Supplier price-list shape: `name,order_cost,shipping,boards,order_min,unload_min`. */
|
/** Supplier price-list shape: `name,order_cost,shipping,boards,order_min,unload_min`. */function materialsFromSupplierCsv(text: string): MaterialRow[] | null {
|
||||||
function materialsFromSupplierCsv(text: string): MaterialRow[] | null {
|
|
||||||
const rows = parseCsv(text);
|
const rows = parseCsv(text);
|
||||||
if (rows.length < 2) return null;
|
if (rows.length < 2) return null;
|
||||||
const header = rows[0].map((h) => h.trim().toLowerCase());
|
const header = rows[0].map((h) => h.trim().toLowerCase());
|
||||||
|
|
@ -332,7 +352,7 @@ function materialsFromSupplierCsv(text: string): MaterialRow[] | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeckCostCalculator() {
|
export function DeckCostCalculator() {
|
||||||
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost', DEFAULT_CALC);
|
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost:v2', DEFAULT_CALC);
|
||||||
const { data: rates } = useBtcRates();
|
const { data: rates } = useBtcRates();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
@ -416,7 +436,10 @@ export function DeckCostCalculator() {
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
setPresets((prev) => [...prev.filter((p) => p.name !== name), { name, state: s }]);
|
setPresets((prev) => [...prev.filter((p) => p.name !== name), { name, state: s }]);
|
||||||
};
|
};
|
||||||
const loadPreset = (state: CalcState) => { setS(state); setLoadOpen(false); };
|
const loadPreset = (state: CalcState) => {
|
||||||
|
setS({ ...freshDefault(), ...state, quote: { ...DEFAULT_CALC.quote, ...(state.quote ?? {}) } });
|
||||||
|
setLoadOpen(false);
|
||||||
|
};
|
||||||
const deletePreset = (name: string) => setPresets((prev) => prev.filter((p) => p.name !== name));
|
const deletePreset = (name: string) => setPresets((prev) => prev.filter((p) => p.name !== name));
|
||||||
const exportJson = () => {
|
const exportJson = () => {
|
||||||
const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' });
|
||||||
|
|
@ -547,40 +570,61 @@ 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`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Customer-facing quote (optional business name).
|
// Customer-facing quote: logo + business block + line item + notes.
|
||||||
const exportQuotePdf = async () => {
|
const exportQuotePdf = async () => {
|
||||||
const { jsPDF } = await import('jspdf');
|
const { jsPDF } = await import('jspdf');
|
||||||
const doc = new jsPDF({ unit: 'pt', format: 'a4' });
|
const doc = new jsPDF({ unit: 'pt', format: 'a4' });
|
||||||
const pageW = doc.internal.pageSize.getWidth();
|
const pageW = doc.internal.pageSize.getWidth();
|
||||||
const M = 48;
|
const M = 48;
|
||||||
const rightX = pageW - M;
|
const rightX = pageW - M;
|
||||||
let y = 84;
|
const q = s.quote;
|
||||||
|
let y = M + 24;
|
||||||
|
|
||||||
doc.setFillColor(255, 0, 64); doc.rect(M, y - 24, 40, 4, 'F');
|
// Header — logo left, business + contact right.
|
||||||
doc.setFont('helvetica', 'bold'); doc.setFontSize(20); doc.setTextColor(15, 15, 15);
|
if (q.logo.startsWith('data:image/')) {
|
||||||
doc.text(s.businessName.trim() || 'SkateSpots', M, y); y += 20;
|
try {
|
||||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(10); doc.setTextColor(120, 120, 120);
|
const img = await loadImage(q.logo);
|
||||||
doc.text(new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), M, y); y += 44;
|
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(s.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].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.setFont('helvetica', 'bold'); doc.setFontSize(26); doc.setTextColor(15, 15, 15);
|
||||||
doc.text('QUOTE', M, y); y += 26;
|
doc.text('QUOTE', M, y);
|
||||||
doc.setDrawColor(220, 220, 220); doc.setLineWidth(0.7); doc.line(M, y, rightX, y); y += 26;
|
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
|
||||||
|
doc.text(new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), rightX, y, { align: 'right' });
|
||||||
|
doc.text(`Ref ${new Date().toISOString().slice(0, 10)}`, rightX, y + 12, { align: 'right' });
|
||||||
|
y += 34;
|
||||||
|
|
||||||
|
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.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.setTextColor(20, 20, 20);
|
||||||
doc.text('Custom skateboard deck', M, y);
|
doc.text('Custom skateboard deck', M, y);
|
||||||
doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 18;
|
doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 16;
|
||||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
|
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
|
||||||
doc.text('Hand-built, finished and packed. Quantity 1.', M, y); y += 30;
|
doc.text('Hand-built, finished and packed. Quantity 1.', M, y); y += 16;
|
||||||
doc.setDrawColor(220, 220, 220); doc.line(M, y, rightX, y); y += 24;
|
doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 24;
|
||||||
|
|
||||||
doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(20, 20, 20);
|
doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(20, 20, 20);
|
||||||
doc.text('Total', M, y);
|
doc.text('Total', M, y);
|
||||||
doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 32;
|
doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 30;
|
||||||
|
|
||||||
|
if (q.notes.trim()) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
|
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
|
||||||
doc.text('Does not include shipping. Quote valid 30 days.', M, y); y += 14;
|
doc.text('Does not include shipping. Quote valid 30 days.', M, y); y += 14;
|
||||||
if (s.businessName.trim()) { doc.text(`Prepared by ${s.businessName.trim()}.`, M, y); y += 14; }
|
doc.text('Generated with SkateSpots \u00b7 skate-spots.org', M, y);
|
||||||
doc.text('Generated with SkateSpots · skate-spots.org', M, y);
|
|
||||||
doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`);
|
doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -777,6 +821,20 @@ export function DeckCostCalculator() {
|
||||||
setS(freshDefault());
|
setS(freshDefault());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onLogo = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 500 * 1024) {
|
||||||
|
toast({ title: 'Logo too large (max 500 KB)', variant: 'destructive' });
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => patch({ quote: { ...s.quote, logo: String(reader.result) } });
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
const numInput = (value: number, onChange: (n: number) => void, className?: string) => (
|
const numInput = (value: number, onChange: (n: number) => void, className?: string) => (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -1018,16 +1076,45 @@ export function DeckCostCalculator() {
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11px] text-gray-500">Does not include shipping to the buyer.</p>
|
<p className="mt-2 text-[11px] text-gray-500">Does not include shipping to the buyer.</p>
|
||||||
|
|
||||||
{/* Optional business name — appears on the client quote PDF. */}
|
{/* Quote details — your identity on the client PDF. */}
|
||||||
<div className="mt-3">
|
<div className="mt-4 space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
|
||||||
<label className="mb-1 block text-[11px] font-semibold text-gray-400" htmlFor="calc-business">Quote name (optional — for the client PDF)</label>
|
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">Quote details (for the client PDF)</p>
|
||||||
<Input
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
id="calc-business"
|
<label className="space-y-1">
|
||||||
value={s.businessName}
|
<span className={label}>Business / shop name</span>
|
||||||
onChange={(e) => patch({ businessName: e.target.value })}
|
<Input value={s.businessName} onChange={(e) => patch({ businessName: e.target.value })} placeholder="Your name / shop" className="h-8 bg-black/40 border-white/10" />
|
||||||
placeholder="Your name / shop"
|
</label>
|
||||||
className="max-w-xs bg-black/40 border-white/10"
|
<label className="space-y-1">
|
||||||
/>
|
<span className={label}>Email</span>
|
||||||
|
<Input value={s.quote.email} onChange={(e) => patch({ quote: { ...s.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}>Phone</span>
|
||||||
|
<Input value={s.quote.phone} onChange={(e) => patch({ quote: { ...s.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}>Website</span>
|
||||||
|
<Input value={s.quote.website} onChange={(e) => patch({ quote: { ...s.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}>Address</span>
|
||||||
|
<Input value={s.quote.address} onChange={(e) => patch({ quote: { ...s.quote, address: e.target.value } })} placeholder="Street, city" className="h-8 bg-black/40 border-white/10" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{s.quote.logo && <img src={s.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" /> {s.quote.logo ? 'Change logo' : 'Add logo'}
|
||||||
|
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={onLogo} />
|
||||||
|
</label>
|
||||||
|
{s.quote.logo && (
|
||||||
|
<button type="button" onClick={() => patch({ quote: { ...s.quote, logo: '' } })} className="min-h-0 text-xs text-gray-500 hover:text-red-400">Remove</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className={label}>Notes / terms</span>
|
||||||
|
<Textarea value={s.quote.notes} onChange={(e) => patch({ quote: { ...s.quote, notes: e.target.value } })} rows={2} placeholder="Lead time, deposit, warranty…" className="bg-black/40 border-white/10" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Turn the number into an action: list it at this price, or find someone
|
{/* Turn the number into an action: list it at this price, or find someone
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue