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,
|
||||
mold release/vacuum film, sandpaper/saw+drill bits/masking tape, stain/primer/
|
||||
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`),
|
||||
**CSV** (`section,item,field,value`), **Markdown**, **JSON** (backup), **Copy
|
||||
summary** (clipboard). **Import** auto-detects `.json`/`.csv`: a CSV whose
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
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 } from 'lucide-react';
|
||||
|
|
@ -86,6 +87,15 @@ interface CalcState {
|
|||
currency: CurrencyCode;
|
||||
/** Optional business/shop name, used on client quotes. */
|
||||
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;
|
||||
overheadFactor: number;
|
||||
boardsPerMonth: number;
|
||||
|
|
@ -105,6 +115,7 @@ const uid = () => Math.random().toString(36).slice(2, 9);
|
|||
const DEFAULT_CALC: CalcState = {
|
||||
currency: 'USD',
|
||||
businessName: '',
|
||||
quote: { logo: '', email: '', phone: '', website: '', address: '', notes: '' },
|
||||
hourlyRate: 20,
|
||||
overheadFactor: 2.5,
|
||||
boardsPerMonth: 160,
|
||||
|
|
@ -205,6 +216,16 @@ function parseCsv(text: string): string[][] {
|
|||
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']];
|
||||
|
|
@ -306,8 +327,7 @@ function calcStateFromCsv(text: string): CalcState | null {
|
|||
return out;
|
||||
}
|
||||
|
||||
/** Supplier price-list shape: `name,order_cost,shipping,boards,order_min,unload_min`. */
|
||||
function materialsFromSupplierCsv(text: string): MaterialRow[] | null {
|
||||
/** 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());
|
||||
|
|
@ -332,7 +352,7 @@ function materialsFromSupplierCsv(text: string): MaterialRow[] | null {
|
|||
}
|
||||
|
||||
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 { toast } = useToast();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
|
@ -416,7 +436,10 @@ export function DeckCostCalculator() {
|
|||
if (!name) return;
|
||||
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 exportJson = () => {
|
||||
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`);
|
||||
};
|
||||
|
||||
// Customer-facing quote (optional business name).
|
||||
// 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;
|
||||
let y = 84;
|
||||
const q = s.quote;
|
||||
let y = M + 24;
|
||||
|
||||
doc.setFillColor(255, 0, 64); doc.rect(M, y - 24, 40, 4, 'F');
|
||||
doc.setFont('helvetica', 'bold'); doc.setFontSize(20); doc.setTextColor(15, 15, 15);
|
||||
doc.text(s.businessName.trim() || 'SkateSpots', M, y); y += 20;
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(10); doc.setTextColor(120, 120, 120);
|
||||
doc.text(new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), M, y); y += 44;
|
||||
// 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(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.text('QUOTE', M, y); y += 26;
|
||||
doc.setDrawColor(220, 220, 220); doc.setLineWidth(0.7); doc.line(M, y, rightX, y); y += 26;
|
||||
doc.text('QUOTE', M, y);
|
||||
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.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.text('Hand-built, finished and packed. Quantity 1.', M, y); y += 30;
|
||||
doc.setDrawColor(220, 220, 220); doc.line(M, y, rightX, y); y += 24;
|
||||
doc.text('Hand-built, finished and packed. Quantity 1.', M, y); y += 16;
|
||||
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.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.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 · skate-spots.org', M, y);
|
||||
doc.text('Generated with SkateSpots \u00b7 skate-spots.org', M, y);
|
||||
|
||||
doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
};
|
||||
|
||||
|
|
@ -777,6 +821,20 @@ export function DeckCostCalculator() {
|
|||
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) => (
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -1018,16 +1076,45 @@ export function DeckCostCalculator() {
|
|||
</div>
|
||||
<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. */}
|
||||
<div className="mt-3">
|
||||
<label className="mb-1 block text-[11px] font-semibold text-gray-400" htmlFor="calc-business">Quote name (optional — for the client PDF)</label>
|
||||
<Input
|
||||
id="calc-business"
|
||||
value={s.businessName}
|
||||
onChange={(e) => patch({ businessName: e.target.value })}
|
||||
placeholder="Your name / shop"
|
||||
className="max-w-xs bg-black/40 border-white/10"
|
||||
/>
|
||||
{/* Quote details — your identity on the client PDF. */}
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
|
||||
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">Quote details (for the client PDF)</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className={label}>Business / shop name</span>
|
||||
<Input value={s.businessName} onChange={(e) => patch({ 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}>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>
|
||||
|
||||
{/* Turn the number into an action: list it at this price, or find someone
|
||||
|
|
|
|||
Loading…
Reference in a new issue