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:
parent
c8cbb848e1
commit
3f90967f6c
2 changed files with 81 additions and 37 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -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/
|
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 a "Quote details" block:
|
**PDF (full sheet)**, **PDF (client quote — uses a "Quote details" block:
|
||||||
business name, a data-URL logo, email/phone/website/address, notes/terms; shows
|
business name, a data-URL logo, email/phone/website/address, notes/terms, a
|
||||||
logo + contact header, line item, total and notes, not the internal breakdown)**,
|
**quote # counter** (`Q-0007`, with a +1 button) and **validity days**; shows
|
||||||
**PNG** summary card (canvas), **XLSX** multi-sheet workbook (lazy `xlsx`),
|
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
|
**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
|
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
|
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
|
parse/escape so commas in names are safe. Next: write our own
|
||||||
|
|
|
||||||
|
|
@ -83,19 +83,28 @@ function currencyLabel(cur: string): string {
|
||||||
return s ? `${s} ${cur}` : cur;
|
return s ? `${s} ${cur}` : cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Client-quote identity — kept OUT of the sheet so presets share one identity. */
|
||||||
|
interface QuoteProfile {
|
||||||
|
businessName: string;
|
||||||
|
/** 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 {
|
interface CalcState {
|
||||||
currency: CurrencyCode;
|
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;
|
hourlyRate: number;
|
||||||
overheadFactor: number;
|
overheadFactor: number;
|
||||||
boardsPerMonth: number;
|
boardsPerMonth: number;
|
||||||
|
|
@ -114,8 +123,6 @@ const uid = () => Math.random().toString(36).slice(2, 9);
|
||||||
|
|
||||||
const DEFAULT_CALC: CalcState = {
|
const DEFAULT_CALC: CalcState = {
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
businessName: '',
|
|
||||||
quote: { logo: '', email: '', phone: '', website: '', address: '', notes: '' },
|
|
||||||
hourlyRate: 20,
|
hourlyRate: 20,
|
||||||
overheadFactor: 2.5,
|
overheadFactor: 2.5,
|
||||||
boardsPerMonth: 160,
|
boardsPerMonth: 160,
|
||||||
|
|
@ -355,6 +362,9 @@ export function DeckCostCalculator() {
|
||||||
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost:v2', 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();
|
||||||
|
// 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 [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [presets, setPresets] = useLocalStorage<{ name: string; state: CalcState }[]>('skatespots:deck-cost-presets', []);
|
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 }]);
|
setPresets((prev) => [...prev.filter((p) => p.name !== name), { name, state: s }]);
|
||||||
};
|
};
|
||||||
const loadPreset = (state: CalcState) => {
|
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);
|
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));
|
||||||
|
|
@ -577,7 +588,7 @@ export function DeckCostCalculator() {
|
||||||
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;
|
||||||
const q = s.quote;
|
const q = quote;
|
||||||
let y = M + 24;
|
let y = M + 24;
|
||||||
|
|
||||||
// Header — logo left, business + contact right.
|
// Header — logo left, business + contact right.
|
||||||
|
|
@ -589,7 +600,7 @@ export function DeckCostCalculator() {
|
||||||
} catch { /* ignore a bad logo */ }
|
} catch { /* ignore a bad logo */ }
|
||||||
}
|
}
|
||||||
doc.setFont('helvetica', 'bold'); doc.setFontSize(16); doc.setTextColor(15, 15, 15);
|
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);
|
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);
|
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' }));
|
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.setFont('helvetica', 'bold'); doc.setFontSize(26); doc.setTextColor(15, 15, 15);
|
||||||
doc.text('QUOTE', M, y);
|
doc.text('QUOTE', M, y);
|
||||||
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(new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), rightX, y, { align: 'right' });
|
const now = new Date();
|
||||||
doc.text(`Ref ${new Date().toISOString().slice(0, 10)}`, rightX, y + 12, { align: 'right' });
|
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;
|
y += 34;
|
||||||
|
|
||||||
doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 20;
|
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.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;
|
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.text('Generated with SkateSpots \u00b7 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`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shareable PNG summary card (canvas).
|
// Shareable PNG summary card (canvas). Carries the quote identity too.
|
||||||
const exportPng = () => {
|
const exportPng = async () => {
|
||||||
const W = 1000;
|
const W = 1000;
|
||||||
const H = 660;
|
const H = 660;
|
||||||
const c = document.createElement('canvas');
|
const c = document.createElement('canvas');
|
||||||
|
|
@ -637,11 +650,25 @@ export function DeckCostCalculator() {
|
||||||
const ctx = c.getContext('2d');
|
const ctx = c.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.fillStyle = '#0a0a0a'; ctx.fillRect(0, 0, W, H);
|
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 = '#ff0040'; ctx.fillRect(48, 46, 64, 6);
|
||||||
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 34px system-ui, sans-serif';
|
ctx.fillStyle = '#ffffff'; ctx.font = 'bold 34px system-ui, sans-serif';
|
||||||
ctx.fillText('Deck cost', 48, 110);
|
ctx.fillText('Deck cost', 48, 110);
|
||||||
ctx.fillStyle = '#9ca3af'; ctx.font = '16px system-ui, sans-serif';
|
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);
|
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.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
|
||||||
ctx.fillText('SELLING PRICE · WITH PACKAGING', 48, 206);
|
ctx.fillText('SELLING PRICE · WITH PACKAGING', 48, 206);
|
||||||
|
|
@ -830,7 +857,7 @@ export function DeckCostCalculator() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reader = new FileReader();
|
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);
|
reader.readAsDataURL(file);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
@ -1082,38 +1109,49 @@ export function DeckCostCalculator() {
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className={label}>Business / shop name</span>
|
<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>
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className={label}>Email</span>
|
<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>
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className={label}>Phone</span>
|
<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>
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className={label}>Website</span>
|
<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>
|
||||||
<label className="space-y-1 sm:col-span-2">
|
<label className="space-y-1 sm:col-span-2">
|
||||||
<span className={label}>Address</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<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">
|
<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} />
|
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={onLogo} />
|
||||||
</label>
|
</label>
|
||||||
{s.quote.logo && (
|
{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>
|
<button type="button" onClick={() => setQuote({ ...quote, logo: '' })} className="min-h-0 text-xs text-gray-500 hover:text-red-400">Remove</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
<span className={label}>Notes / terms</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1165,7 +1203,7 @@ export function DeckCostCalculator() {
|
||||||
{[
|
{[
|
||||||
{ label: 'PDF — full sheet', run: () => void exportPdf() },
|
{ label: 'PDF — full sheet', run: () => void exportPdf() },
|
||||||
{ label: 'PDF — client quote', run: () => void exportQuotePdf() },
|
{ 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: 'XLSX workbook', run: () => void exportXlsx() },
|
||||||
{ label: 'CSV (spreadsheet)', run: exportCsv },
|
{ label: 'CSV (spreadsheet)', run: exportCsv },
|
||||||
{ label: 'Markdown', run: exportMarkdown },
|
{ label: 'Markdown', run: exportMarkdown },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue