Deck calculator polish: collapse quote fields, duplicate rows, tax-aware hand-off

- The Client quote fields (15+ inputs) are now a collapsed <details>, so the
  always-visible Margins card stays short; the chevron rotates on open.
- CalcSection buttons set aria-expanded.
- Every equipment/material/process/packaging row gains a duplicate (Copy) action.
- The sticky summary chip shows the order total when quantity > 1 and uses the
  setup-inclusive cost.
- "List a deck at this price" passes a buyer-inclusive unit price (tax added on
  top unless tax is marked inclusive).
This commit is contained in:
SkateSpots 2026-10-05 12:45:23 +07:00
parent 475497b205
commit bed0fb8e34
2 changed files with 49 additions and 14 deletions

View file

@ -853,7 +853,14 @@ session with **no other memory of this project**. So:
the prices use them. All four round-trip through CSV, and the state is merged
over `DEFAULT_CALC` on read (old sheets/presets can't produce NaN — `clampPct`
is NaN-safe too). Currency switching also converts `targetMonthlyProfit`,
`batchSetupCost` and process `rate`. **Currency
`batchSetupCost` and process `rate`.
**Polish (2026-10-05, part 2):** the client-quote fields are a collapsed
`<details>` ("Client quote details") so the always-visible Margins card stays
short; `CalcSection` sets `aria-expanded`; every table row gains a **duplicate**
(Copy) action beside delete; the sticky summary chip shows the **order total**
when `quantity > 1` (and uses the setup-inclusive cost); and the marketplace
hand-off passes a **buyer-inclusive** unit price (tax added on top when tax
isn't marked inclusive). **Currency
(2026-10-05):** a **searchable currency selector** (Popover + search) covering
**sats + BTC** (both Bitcoin units) and every **fiat** Coinbase reports (crypto
is filtered out via `FIAT_CODES`), and switching **converts every money field**

View file

@ -5,7 +5,7 @@ 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, AtSign, AlertTriangle, LayoutTemplate, SlidersHorizontal } from 'lucide-react';
import { Plus, Trash2, RotateCcw, Search, ChevronDown, ChevronRight, Check, Save, FolderOpen, Download, Upload, AtSign, AlertTriangle, LayoutTemplate, SlidersHorizontal, Copy } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useBtcRates } from '@/hooks/useExchangeRate';
import { useToast } from '@/hooks/useToast';
@ -292,7 +292,7 @@ function CalcSection({ title, subtotal, defaultOpen = false, children }: { title
const [open, setOpen] = useState(defaultOpen);
return (
<section className="rounded-2xl border border-white/10 bg-white/[0.02]">
<button type="button" onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-2 px-4 py-4 text-left">
<button type="button" aria-expanded={open} onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-2 px-4 py-4 text-left">
<ChevronRight className={cn('h-4 w-4 shrink-0 text-gray-500 transition-transform', open && 'rotate-90')} />
<h3 className="text-sm font-black uppercase tracking-wide text-[#ccff00]">{title}</h3>
{subtotal && <span className="ml-auto text-xs font-bold text-gray-300">{subtotal}</span>}
@ -638,6 +638,9 @@ export function DeckCostCalculator() {
/** Markup (×) expressed as a gross margin, so the name matches the maths. */
const marginPct = s.profitMargin > 0 ? Math.round((1 - 1 / s.profitMargin) * 100) : 0;
/** What a buyer actually pays for one deck (tax-inclusive when tax is added on top). */
const buyerUnit = s.taxInclusive || s.taxPct <= 0 ? r.priceWithPack : r.priceWithPack * (1 + s.taxPct / 100);
/** Cheap sanity checks — surface the numbers that are probably mistakes. */
const warnings = useMemo(() => {
const w: string[] = [];
@ -1225,7 +1228,7 @@ export function DeckCostCalculator() {
</Popover>
{rates && <span className="text-[10px] text-gray-500">live BTC rates · sats included</span>}
<span className="ml-auto text-[11px] font-bold text-gray-400">
{fmt(r.costWithPack)} cost · <span className="text-[#ccff00]">{fmt(r.priceWithPack)} sell</span>
{fmt(r.costWithPackUnit)} cost · <span className="text-[#ccff00]">{fmt(r.priceWithPack)} sell</span>{r.qty > 1 ? <> · <span className="text-gray-300">{fmt(r.orderTotal)} total</span></> : null}
</span>
</div>
@ -1300,7 +1303,12 @@ export function DeckCostCalculator() {
<td className={td}>{numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))}</td>
<td className={td}>{numInput(e.boards, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, boards: n } : x) }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)}</td>
<td className={td}><button onClick={() => patch({ equipment: s.equipment.filter((x) => x.id !== e.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button></td>
<td className={td}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ equipment: [...s.equipment, { ...e, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label="Duplicate"><Copy className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ equipment: s.equipment.filter((x) => x.id !== e.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</td>
</tr>
))}
</tbody>
@ -1341,7 +1349,12 @@ export function DeckCostCalculator() {
<td className={td}>{numInput(m.orderMin, (n) => up({ orderMin: n }))}</td>
<td className={td}>{numInput(m.unloadMin, (n) => up({ unloadMin: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(per)}</td>
<td className={td}><button onClick={() => patch({ materials: s.materials.filter((x) => x.id !== m.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button></td>
<td className={td}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ materials: [...s.materials, { ...m, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label="Duplicate"><Copy className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ materials: s.materials.filter((x) => x.id !== m.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</td>
</tr>
);
})}
@ -1372,7 +1385,12 @@ export function DeckCostCalculator() {
{advanced && <td className={td}>{numInput(p.rate, (n) => up({ rate: n }))}</td>}
{advanced && <td className={td}>{numInput(p.scrapPct, (n) => up({ scrapPct: Math.min(99, n) }))}</td>}
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(per)}</td>
<td className={td}><button onClick={() => patch({ processes: s.processes.filter((x) => x.id !== p.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button></td>
<td className={td}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ processes: [...s.processes, { ...p, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label="Duplicate"><Copy className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ processes: s.processes.filter((x) => x.id !== p.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</td>
</tr>
);
})}
@ -1400,7 +1418,12 @@ export function DeckCostCalculator() {
<td className={td}>{numInput(p.minutesPerBoard, (n) => up({ minutesPerBoard: n }))}</td>
<td className={td}>{numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(per)}</td>
<td className={td}><button onClick={() => patch({ packaging: s.packaging.filter((x) => x.id !== p.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button></td>
<td className={td}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ packaging: [...s.packaging, { ...p, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label="Duplicate"><Copy className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => patch({ packaging: s.packaging.filter((x) => x.id !== p.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</td>
</tr>
);
})}
@ -1497,10 +1520,14 @@ export function DeckCostCalculator() {
)}
<p className="mt-2 text-[11px] text-gray-500">Does not include shipping to the buyer.</p>
{/* 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">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">Quote details (for the client PDF)</p>
{/* Quote details — your identity on the client PDF (collapsed by default). */}
<details className="group mt-4 rounded-xl border border-white/10 bg-black/30 p-3">
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 text-[11px] font-black uppercase tracking-wider text-gray-500 [&::-webkit-details-marker]:hidden">
<span>Client quote details (for the PDF)</span>
<ChevronDown className="h-3.5 w-3.5 transition-transform group-open:rotate-180" />
</summary>
<div className="mt-3 space-y-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={fillFromProfile}
@ -1616,13 +1643,14 @@ export function DeckCostCalculator() {
<span className={label}>Notes / terms</span>
<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>
</div>
</details>
{/* Turn the number into an action: list it at this price, or find someone
to build it. */}
<div className="mt-4 flex flex-wrap gap-2">
<Link
to={`/create-listing?price=${encodeURIComponent(String(Math.round(r.priceWithPack * 100) / 100))}&currency=${encodeURIComponent(cur)}`}
to={`/create-listing?price=${encodeURIComponent(String(Math.round(buyerUnit * 100) / 100))}&currency=${encodeURIComponent(cur)}`}
className="inline-flex items-center gap-1.5 rounded-full bg-[#ccff00] px-4 py-2 text-xs font-black uppercase tracking-wide text-black transition-colors hover:bg-[#ccff00]/90"
>
List a deck at this price <ChevronRight className="h-3.5 w-3.5" />