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:
parent
475497b205
commit
bed0fb8e34
2 changed files with 49 additions and 14 deletions
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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))}¤cy=${encodeURIComponent(cur)}`}
|
||||
to={`/create-listing?price=${encodeURIComponent(String(Math.round(buyerUnit * 100) / 100))}¤cy=${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" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue