Deck calculator: quote number/validity, logo on PNG, shared quote profile

- Extract the client-quote identity into its own store (QuoteProfile,
  skatespots:deck-cost:quote) so it is NOT part of CalcState — loading or saving
  a preset now keeps the same business name/logo/contact details.
- Client quote PDF: a real quote number counter (Q-0007, with a +1 button) and a
  configurable validity window, shown as "Quote valid until <date>".
- PNG summary card now also draws the logo (top-right) and the business name.
This commit is contained in:
SkateSpots 2026-10-05 11:04:20 +07:00
parent c8cbb848e1
commit 3f90967f6c
2 changed files with 81 additions and 37 deletions

View file

@ -842,11 +842,17 @@ session with **no other memory of this project**. So:
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 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`),
business name, a data-URL logo, email/phone/website/address, notes/terms, a
**quote # counter** (`Q-0007`, with a +1 button) and **validity days**; shows
logo + contact header, line item, total, `Quote valid until <date>` and notes,
not the internal breakdown)**, **PNG** summary card (canvas — now also draws
the **logo + business name**), **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
summary** (clipboard). ⚠️ The **quote identity lives in its own store**
(`skatespots:deck-cost:quote`, `QuoteProfile`), **not** in `CalcState`, so
loading/saving presets keeps the same business name/logo/contact (the sheet is
numbers only). The sheet's key is `skatespots:deck-cost:v2`. **Import**
auto-detects `.json`/`.csv`: a CSV whose
header is `name,order_cost,…` is treated as a **supplier price list** (replaces
the Materials table), otherwise it round-trips the whole sheet. Hand-rolled CSV
parse/escape so commas in names are safe. Next: write our own

View file

@ -83,19 +83,28 @@ function currencyLabel(cur: string): string {
return s ? `${s} ${cur}` : cur;
}
interface CalcState {
currency: CurrencyCode;
/** Optional business/shop name, used on client quotes. */
/** Client-quote identity — kept OUT of the sheet so presets share one identity. */
interface QuoteProfile {
businessName: string;
/** Client-quote details (the logo is a data URL — no upload/network). */
quote: {
/** 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;
}
const DEFAULT_QUOTE: QuoteProfile = {
businessName: '', logo: '', email: '', phone: '', website: '', address: '', notes: '', number: 1, validDays: 30,
};
interface CalcState {
currency: CurrencyCode;
hourlyRate: number;
overheadFactor: number;
boardsPerMonth: number;
@ -114,8 +123,6 @@ 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,
@ -355,6 +362,9 @@ export function DeckCostCalculator() {
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost:v2', DEFAULT_CALC);
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 [quote, setQuote] = useLocalStorage<QuoteProfile>('skatespots:deck-cost:quote', DEFAULT_QUOTE);
const [pickerOpen, setPickerOpen] = useState(false);
const [query, setQuery] = useState('');
const [presets, setPresets] = useLocalStorage<{ name: string; state: CalcState }[]>('skatespots:deck-cost-presets', []);
@ -437,7 +447,8 @@ export function DeckCostCalculator() {
setPresets((prev) => [...prev.filter((p) => p.name !== name), { name, state: s }]);
};
const loadPreset = (state: CalcState) => {
setS({ ...freshDefault(), ...state, quote: { ...DEFAULT_CALC.quote, ...(state.quote ?? {}) } });
// 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));
@ -577,7 +588,7 @@ export function DeckCostCalculator() {
const pageW = doc.internal.pageSize.getWidth();
const M = 48;
const rightX = pageW - M;
const q = s.quote;
const q = quote;
let y = M + 24;
// Header — logo left, business + contact right.
@ -589,7 +600,7 @@ export function DeckCostCalculator() {
} 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.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].map((v) => v.trim()).filter(Boolean);
contact.forEach((line, i) => doc.text(line, rightX, y + 26 + i * 12, { align: 'right' }));
@ -600,8 +611,9 @@ export function DeckCostCalculator() {
doc.setFont('helvetica', 'bold'); doc.setFontSize(26); doc.setTextColor(15, 15, 15);
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' });
const now = new Date();
doc.text(now.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), rightX, y, { align: 'right' });
doc.text(`Ref Q-${String(q.number).padStart(4, '0')}`, rightX, y + 12, { align: 'right' });
y += 34;
doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 20;
@ -622,14 +634,15 @@ export function DeckCostCalculator() {
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;
const until = new Date(now.getTime() + Math.max(1, q.validDays) * 86_400_000);
doc.text(`Does not include shipping. Quote valid until ${until.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })}.`, M, y); y += 14;
doc.text('Generated with SkateSpots \u00b7 skate-spots.org', M, y);
doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`);
};
// Shareable PNG summary card (canvas).
const exportPng = () => {
// Shareable PNG summary card (canvas). Carries the quote identity too.
const exportPng = async () => {
const W = 1000;
const H = 660;
const c = document.createElement('canvas');
@ -637,11 +650,25 @@ export function DeckCostCalculator() {
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('Deck cost', 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';
}
ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
ctx.fillText('SELLING PRICE · WITH PACKAGING', 48, 206);
@ -830,7 +857,7 @@ export function DeckCostCalculator() {
return;
}
const reader = new FileReader();
reader.onload = () => patch({ quote: { ...s.quote, logo: String(reader.result) } });
reader.onload = () => setQuote({ ...quote, logo: String(reader.result) });
reader.readAsDataURL(file);
e.target.value = '';
};
@ -1082,38 +1109,49 @@ export function DeckCostCalculator() {
<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" />
<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}>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" />
<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}>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" />
<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}>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" />
<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}>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" />
<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}>Quote #</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="Next number">+1</button>
</div>
</label>
<label className="space-y-1">
<span className={label}>Valid (days)</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="flex flex-wrap items-center gap-3">
{s.quote.logo && <img src={s.quote.logo} alt="" className="h-10 rounded border border-white/10" />}
{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" /> {s.quote.logo ? 'Change logo' : 'Add logo'}
<Upload className="h-3.5 w-3.5" /> {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>
{quote.logo && (
<button type="button" onClick={() => setQuote({ ...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" />
<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>
@ -1165,7 +1203,7 @@ export function DeckCostCalculator() {
{[
{ label: 'PDF — full sheet', run: () => void exportPdf() },
{ label: 'PDF — client quote', run: () => void exportQuotePdf() },
{ label: 'PNG image', run: exportPng },
{ label: 'PNG image', run: () => void exportPng() },
{ label: 'XLSX workbook', run: () => void exportXlsx() },
{ label: 'CSV (spreadsheet)', run: exportCsv },
{ label: 'Markdown', run: exportMarkdown },