665 lines
38 KiB
React
665 lines
38 KiB
React
// Add Product, Mark Consumed/Gone, Audit, Bins, Charts, Settings
|
|
|
|
const AddProductFlow = ({data, onClose, onSave}) => {
|
|
const [form, setForm] = React.useState({
|
|
name: "", brandId: data.brands[0].id, shopId: data.shops[0].id, type: "Flower",
|
|
weight: 3.5, countOriginal: 1, unitWeight: 0.7,
|
|
price: 45, thc: 22, cbd: 0.4, totalCannabinoids: 26,
|
|
purchaseDate: "2026-04-25", binId: data.bins[0].id,
|
|
sku: "", assetTag: ""
|
|
});
|
|
const update = (k, v) => setForm(f => ({...f, [k]: v}));
|
|
const cfg = data.types.find(t => t.id === form.type);
|
|
const isDiscrete = cfg?.kind === "discrete";
|
|
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
|
|
|
|
return (
|
|
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
|
|
<div onClick={e => e.stopPropagation()} style={{
|
|
width: "min(840px, 96vw)", margin: "40px 20px", background: "var(--bg)",
|
|
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
|
|
}}>
|
|
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
|
|
<div>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)"}}>New entry</div>
|
|
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>Add a product</h2>
|
|
</div>
|
|
<Btn variant="ghost" icon="close" onClick={onClose} />
|
|
</div>
|
|
|
|
<div style={{padding: 32}}>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 16}}>Identity</div>
|
|
<div style={{display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28}}>
|
|
<Field label="Product name" span={2}><Input value={form.name} placeholder="e.g. Garden Ghost" onChange={e => update("name", e.target.value)} /></Field>
|
|
<Field label="Brand"><Select value={form.brandId} onChange={e => update("brandId", e.target.value)}>{data.brands.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}</Select></Field>
|
|
<Field label="Shop"><Select value={form.shopId} onChange={e => update("shopId", e.target.value)}>{data.shops.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}</Select></Field>
|
|
<Field label="Type"><Select value={form.type} onChange={e => update("type", e.target.value)}>{data.types.map(t => <option key={t.id} value={t.id}>{t.id} ({t.kind})</option>)}</Select></Field>
|
|
<Field label="Bin"><Select value={form.binId} onChange={e => update("binId", e.target.value)}>{data.bins.map(b => <option key={b.id} value={b.id}>{b.name} — {b.location}</option>)}</Select></Field>
|
|
<Field label="SKU" hint="Leave blank — we'll generate one"><Input value={form.sku} placeholder="SKU-…" onChange={e => update("sku", e.target.value)} /></Field>
|
|
<Field label="Asset tag (optional)" hint="If you've physically tagged the item"><Input value={form.assetTag} placeholder="AT-0000" onChange={e => update("assetTag", e.target.value)} /></Field>
|
|
</div>
|
|
|
|
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 16}}>Acquisition</div>
|
|
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 8}}>
|
|
{isDiscrete ? (
|
|
<>
|
|
<Field label={`Quantity (${cfg.unit})`}><Input type="number" step="1" value={form.countOriginal} onChange={e => update("countOriginal", +e.target.value)} /></Field>
|
|
<Field label="Per-unit weight (g)" hint="For grams stats"><Input type="number" step="0.1" value={form.unitWeight} onChange={e => update("unitWeight", +e.target.value)} /></Field>
|
|
</>
|
|
) : (
|
|
<Field label={`Size (${cfg?.unit || "g"})`} span={2}><Input type="number" step="0.1" value={form.weight} onChange={e => update("weight", +e.target.value)} /></Field>
|
|
)}
|
|
<Field label="Price ($)"><Input type="number" step="0.01" value={form.price} onChange={e => update("price", +e.target.value)} /></Field>
|
|
<Field label="Purchase date"><Input type="date" value={form.purchaseDate} onChange={e => update("purchaseDate", e.target.value)} /></Field>
|
|
</div>
|
|
|
|
{!isDiscrete && cpg > 0 && (
|
|
<div style={{marginTop: 12, fontSize: 12, color: "var(--ink-3)"}}>
|
|
Cost per {cfg?.unit || "g"}: <span className="mono" style={{color: "var(--ink-2)"}}>{fmt.money(cpg)}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="smallcaps" style={{color: "var(--ink-3)", margin: "28px 0 16px"}}>Cannabinoid profile</div>
|
|
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16}}>
|
|
<Field label="THC %"><Input type="number" step="0.1" value={form.thc} onChange={e => update("thc", +e.target.value)} /></Field>
|
|
<Field label="CBD %"><Input type="number" step="0.1" value={form.cbd} onChange={e => update("cbd", +e.target.value)} /></Field>
|
|
<Field label="Total cannabinoids %"><Input type="number" step="0.1" value={form.totalCannabinoids} onChange={e => update("totalCannabinoids", +e.target.value)} /></Field>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center", background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
|
|
<div style={{fontSize: 12, color: "var(--ink-3)"}}>
|
|
{form.name ? `"${form.name}" → ${data.bins.find(b=>b.id===form.binId)?.name}.` : "Fill in the name to continue."}
|
|
</div>
|
|
<div style={{display: "flex", gap: 8}}>
|
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
<Btn variant="primary" icon="check" disabled={!form.name} onClick={() => onSave(form)}>Save product</Btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── CONSUME (mark finished) ──────────────────────────────────────
|
|
const ConsumeFlow = ({data, onClose, product: initialProduct}) => {
|
|
const active = data.products.filter(p => p.status === "active");
|
|
const [productId, setProductId] = React.useState(initialProduct?.id || active[0]?.id);
|
|
const [rating, setRating] = React.useState(4);
|
|
const [notes, setNotes] = React.useState("");
|
|
const [date, setDate] = React.useState("2026-04-25");
|
|
|
|
const product = data.products.find(p => p.id === productId);
|
|
if (!product) return null;
|
|
const bin = data.bins.find(b => b.id === product.binId);
|
|
const lifespan = Math.round((new Date(date) - new Date(product.purchaseDate))/86400000);
|
|
|
|
return (
|
|
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
|
|
<div onClick={e => e.stopPropagation()} style={{
|
|
width: "min(720px, 96vw)", margin: "40px 20px", background: "var(--bg)",
|
|
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
|
|
}}>
|
|
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
|
|
<div>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Archive · used up</div>
|
|
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>Mark as finished</h2>
|
|
</div>
|
|
<Btn variant="ghost" icon="close" onClick={onClose} />
|
|
</div>
|
|
|
|
<div style={{padding: 32}}>
|
|
<Field label="Product">
|
|
<Select value={productId} onChange={e => setProductId(e.target.value)}>
|
|
{active.map(p => <option key={p.id} value={p.id}>{p.name} — {H.brandName(data, p.brandId)} ({remainingShort(p)} left)</option>)}
|
|
</Select>
|
|
</Field>
|
|
|
|
<div style={{marginTop: 16, padding: 16, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
|
|
<div>
|
|
<div className="serif" style={{fontSize: 22, fontWeight: 500}}>{product.name}</div>
|
|
<div style={{fontSize: 12, color: "var(--ink-3)"}}>{H.brandName(data, product.brandId)} · {bin?.name} · purchased {fmt.dateShort(product.purchaseDate)}</div>
|
|
</div>
|
|
<div style={{textAlign: "right"}}>
|
|
<div className="mono" style={{fontSize: 11, color: "var(--ink-3)"}}>LASTED</div>
|
|
<div className="serif" style={{fontSize: 24}}>{lifespan} days</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24}}>
|
|
<Field label="Date finished"><Input type="date" value={date} onChange={e => setDate(e.target.value)} /></Field>
|
|
<Field label="Rating">
|
|
<div style={{display: "flex", gap: 4, alignItems: "center", padding: "10px 12px", background: "var(--bg)", border: "1px solid var(--line)", borderRadius: "var(--r-md)"}}>
|
|
{[1,2,3,4,5].map(n => (
|
|
<button key={n} onClick={() => setRating(n)} style={{border: "none", background: "transparent", cursor: "pointer", padding: 2}}>
|
|
<Icon name="star" size={20} color={n <= rating ? "var(--amber)" : "var(--ink-4)"} />
|
|
</button>
|
|
))}
|
|
<span style={{marginLeft: "auto", fontSize: 12, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{rating}/5</span>
|
|
</div>
|
|
</Field>
|
|
</div>
|
|
<div style={{marginTop: 16}}>
|
|
<Field label="Final notes" hint="Flavor, effects, would you rebuy">
|
|
<Textarea value={notes} onChange={e => setNotes(e.target.value)} placeholder="What stood out?" />
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "flex-end", gap: 8, background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
|
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
<Btn variant="primary" icon="check" onClick={onClose}>Mark finished</Btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── MARK GONE (lost / damaged / expired) ─────────────────────────
|
|
const MarkGoneFlow = ({data, onClose, product: initialProduct}) => {
|
|
const active = data.products.filter(p => p.status === "active");
|
|
const [productId, setProductId] = React.useState(initialProduct?.id || active[0]?.id);
|
|
const [reason, setReason] = React.useState("lost");
|
|
const [notes, setNotes] = React.useState("");
|
|
const [date, setDate] = React.useState("2026-04-25");
|
|
const product = data.products.find(p => p.id === productId);
|
|
if (!product) return null;
|
|
|
|
const reasons = [
|
|
["lost", "Lost / misplaced"],
|
|
["damaged", "Damaged"],
|
|
["expired", "Expired"],
|
|
["gifted", "Gifted away"],
|
|
["other", "Other"]
|
|
];
|
|
|
|
return (
|
|
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
|
|
<div onClick={e => e.stopPropagation()} style={{
|
|
width: "min(640px, 96vw)", margin: "40px 20px", background: "var(--bg)",
|
|
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
|
|
}}>
|
|
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
|
|
<div>
|
|
<div className="smallcaps" style={{color: "var(--terracotta)"}}>Archive · not consumed</div>
|
|
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>Mark as gone</h2>
|
|
</div>
|
|
<Btn variant="ghost" icon="close" onClick={onClose} />
|
|
</div>
|
|
|
|
<div style={{padding: 32}}>
|
|
<div style={{fontSize: 13, color: "var(--ink-2)", marginBottom: 20, padding: 14, background: "var(--amber-soft)", borderRadius: "var(--r-md)"}}>
|
|
Use this when an item is lost, damaged, expired, or gifted away. Counts as <strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
|
|
</div>
|
|
|
|
<Field label="Product">
|
|
<Select value={productId} onChange={e => setProductId(e.target.value)}>
|
|
{active.map(p => <option key={p.id} value={p.id}>{p.name} — {H.brandName(data, p.brandId)} ({remainingShort(p)} left)</option>)}
|
|
</Select>
|
|
</Field>
|
|
|
|
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 16}}>
|
|
<Field label="Reason">
|
|
<Select value={reason} onChange={e => setReason(e.target.value)}>
|
|
{reasons.map(([k,l]) => <option key={k} value={k}>{l}</option>)}
|
|
</Select>
|
|
</Field>
|
|
<Field label="Date">
|
|
<Input type="date" value={date} onChange={e => setDate(e.target.value)} />
|
|
</Field>
|
|
</div>
|
|
|
|
<div style={{marginTop: 16}}>
|
|
<Field label="Notes (optional)" hint="What happened">
|
|
<Textarea value={notes} onChange={e => setNotes(e.target.value)} placeholder="e.g. Pack went through the wash" />
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "flex-end", gap: 8, background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
|
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
<Btn variant="danger" icon="bin" onClick={onClose}>Mark gone</Btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── AUDIT MODAL ──────────────────────────────────────────────────
|
|
const AuditFlow = ({data, onClose, product: initialProduct}) => {
|
|
const overdueFirst = [...data.products]
|
|
.filter(p => p.status === "active")
|
|
.sort((a, b) => H.daysSinceCheck(b) - H.daysSinceCheck(a));
|
|
const [productId, setProductId] = React.useState(initialProduct?.id || overdueFirst[0]?.id);
|
|
const [date, setDate] = React.useState("2026-04-25");
|
|
const product = data.products.find(p => p.id === productId);
|
|
const cfg = product ? data.types.find(t => t.id === product.type) : null;
|
|
|
|
// For bulk: weighed/estimated value. For discrete: count + confirmedBy.
|
|
const last = product ? H.lastAudit(product) : null;
|
|
const initialValue = product
|
|
? (product.kind === "discrete"
|
|
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
|
|
: H.estimatedRemaining(product, "2026-04-25").toFixed(2))
|
|
: 0;
|
|
const [value, setValue] = React.useState(initialValue);
|
|
const [confirmedBy, setConfirmedBy] = React.useState("SKU");
|
|
|
|
React.useEffect(() => {
|
|
if (product) {
|
|
setValue(product.kind === "discrete"
|
|
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
|
|
: H.estimatedRemaining(product, "2026-04-25").toFixed(2));
|
|
}
|
|
}, [productId]);
|
|
|
|
if (!product) return null;
|
|
const auditMode = cfg?.auditMode || "weigh";
|
|
const prevValue = product.kind === "discrete"
|
|
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
|
|
: (last ? last.value : product.weight);
|
|
|
|
const auditModeLabels = {
|
|
weigh: { title: "Reweigh on a scale", desc: "Place the jar (minus tare) and record the new weight." },
|
|
estimate: { title: "Visual estimate", desc: "Eyeball the remaining amount — quick and approximate." },
|
|
presence: { title: "Confirm presence", desc: "Verify the item is still where you left it. Count units if applicable." }
|
|
};
|
|
const ml = auditModeLabels[auditMode];
|
|
|
|
return (
|
|
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
|
|
<div onClick={e => e.stopPropagation()} style={{
|
|
width: "min(720px, 96vw)", margin: "40px 20px", background: "var(--bg)",
|
|
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
|
|
}}>
|
|
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
|
|
<div>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Audit</div>
|
|
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>{ml.title}</h2>
|
|
</div>
|
|
<Btn variant="ghost" icon="close" onClick={onClose} />
|
|
</div>
|
|
|
|
<div style={{padding: 32}}>
|
|
<Field label="Product">
|
|
<Select value={productId} onChange={e => setProductId(e.target.value)}>
|
|
{overdueFirst.map(p => {
|
|
const od = H.auditOverdue(data, p);
|
|
const sc = H.daysSinceCheck(p);
|
|
return <option key={p.id} value={p.id}>{od ? "⚠ " : ""}{p.name} — {H.brandName(data, p.brandId)} · {sc}d since check</option>;
|
|
})}
|
|
</Select>
|
|
</Field>
|
|
|
|
<div style={{marginTop: 16, padding: 16, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)"}}>
|
|
<div style={{display: "flex", justifyContent: "space-between", alignItems: "center"}}>
|
|
<div>
|
|
<div className="serif" style={{fontSize: 20, fontWeight: 500}}>{product.name}</div>
|
|
<div style={{fontSize: 12, color: "var(--ink-3)"}}>
|
|
{product.type} · {product.kind} · cadence every {cfg?.cadenceDays}d
|
|
</div>
|
|
</div>
|
|
<div style={{textAlign: "right"}}>
|
|
<div className="mono" style={{fontSize: 11, color: "var(--ink-3)"}}>LAST CHECKED</div>
|
|
<div className="serif" style={{fontSize: 18}}>{last ? `${H.daysSinceCheck(product)}d ago` : "Never"}</div>
|
|
</div>
|
|
</div>
|
|
<div style={{fontSize: 12, color: "var(--ink-2)", marginTop: 10, fontStyle: "italic"}}>{ml.desc}</div>
|
|
</div>
|
|
|
|
<div style={{display: "grid", gridTemplateColumns: auditMode === "presence" ? "1fr 1fr 1fr" : "1fr 1fr", gap: 16, marginTop: 24}}>
|
|
<Field label={
|
|
product.kind === "discrete"
|
|
? `Count now (${cfg?.unit})`
|
|
: auditMode === "weigh" ? `Weight now (${cfg?.unit})` : `Estimate now (${cfg?.unit})`
|
|
}>
|
|
<Input
|
|
type="number"
|
|
step={product.kind === "discrete" ? "1" : "0.1"}
|
|
value={value}
|
|
onChange={e => setValue(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Date"><Input type="date" value={date} onChange={e => setDate(e.target.value)} /></Field>
|
|
{auditMode === "presence" && (
|
|
<Field label="Confirmed by">
|
|
<Select value={confirmedBy} onChange={e => setConfirmedBy(e.target.value)}>
|
|
<option value="SKU">SKU label</option>
|
|
<option value="asset">Asset tag</option>
|
|
<option value="visual">Visual ID</option>
|
|
</Select>
|
|
</Field>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{marginTop: 20, padding: 14, background: "var(--surface)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16}}>
|
|
<div>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Was</div>
|
|
<div className="serif" style={{fontSize: 22}}>{prevValue} {cfg?.unit}</div>
|
|
</div>
|
|
<div>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Now</div>
|
|
<div className="serif" style={{fontSize: 22, color: "var(--sage)"}}>{value} {cfg?.unit}</div>
|
|
</div>
|
|
<div>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Δ since last</div>
|
|
<div className="serif" style={{fontSize: 22, color: (+value - +prevValue) < 0 ? "var(--terracotta)" : "var(--ink)"}}>
|
|
{(+value - +prevValue).toFixed(product.kind === "discrete" ? 0 : 2)} {cfg?.unit}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center", background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
|
|
<div style={{fontSize: 12, color: "var(--ink-3)"}}>Next audit due in {cfg?.cadenceDays}d</div>
|
|
<div style={{display: "flex", gap: 8}}>
|
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
<Btn variant="primary" icon="check" onClick={onClose}>Save audit</Btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── BINS ─────────────────────────────────────────────────────────
|
|
const BinsView = ({data, onSelectProduct}) => {
|
|
return (
|
|
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
|
|
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24}}>
|
|
<div>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{data.bins.length} bins</div>
|
|
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Bins & storage</h1>
|
|
</div>
|
|
<Btn variant="secondary" icon="plus">New bin</Btn>
|
|
</div>
|
|
<div style={{fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600}}>
|
|
Where each active product physically lives. Archived items aren't assigned to a bin.
|
|
</div>
|
|
|
|
<div style={{display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(380px, 1fr))", gap: 14}}>
|
|
{data.bins.map(bin => {
|
|
const items = data.products.filter(p => p.binId === bin.id && p.status === "active");
|
|
const fillPct = items.length / bin.capacity;
|
|
const totalValue = items.reduce((s, p) => s + p.price * H.pctRemaining(p, "2026-04-25"), 0);
|
|
return (
|
|
<Card key={bin.id} padded={false} style={{display: "flex", flexDirection: "column"}}>
|
|
<div style={{padding: "20px 22px 16px", borderBottom: "1px solid var(--line)"}}>
|
|
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 4}}>
|
|
<h3 className="serif" style={{fontSize: 24, margin: 0, fontWeight: 500}}>{bin.name}</h3>
|
|
<Pill tone="outline">{items.length} / {bin.capacity}</Pill>
|
|
</div>
|
|
<div style={{fontSize: 12, color: "var(--ink-3)", display: "flex", justifyContent: "space-between"}}>
|
|
<span>{bin.location}</span>
|
|
<span className="mono">{fmt.money(totalValue)}</span>
|
|
</div>
|
|
<div style={{marginTop: 12, height: 4, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden"}}>
|
|
<div style={{width: `${Math.min(fillPct, 1)*100}%`, height: "100%", background: fillPct > 0.9 ? "var(--terracotta)" : fillPct > 0.7 ? "var(--amber)" : "var(--sage)"}} />
|
|
</div>
|
|
</div>
|
|
<div style={{padding: 8, flex: 1}}>
|
|
{items.length === 0 && <div style={{padding: 30, textAlign: "center", fontSize: 12, color: "var(--ink-3)", fontStyle: "italic"}}>Empty</div>}
|
|
{items.map(p => (
|
|
<div key={p.id} onClick={() => onSelectProduct(p)} style={{display: "flex", alignItems: "center", gap: 10, padding: "8px 14px", borderRadius: "var(--r-sm)", cursor: "pointer"}}>
|
|
<div style={{fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)", width: 18}}>{TYPE_GLYPHS[p.type]}</div>
|
|
<div style={{flex: 1, minWidth: 0}}>
|
|
<div style={{fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>{p.name}</div>
|
|
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{H.brandName(data, p.brandId)}</div>
|
|
</div>
|
|
<div className="mono" style={{fontSize: 11, color: "var(--ink-2)"}}>{remainingShort(p)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── CHARTS ───────────────────────────────────────────────────────
|
|
const ChartsView = ({data, stats}) => {
|
|
const series = stats.series90.map(s => ({date: s.date, grams: s.grams, label: ""}));
|
|
|
|
const spendByMonth = {};
|
|
data.products.forEach(p => {
|
|
const k = p.purchaseDate.slice(0, 7);
|
|
spendByMonth[k] = (spendByMonth[k] || 0) + p.price;
|
|
});
|
|
const months = Object.entries(spendByMonth).sort();
|
|
|
|
const spendByShop = {};
|
|
data.products.forEach(p => {
|
|
const name = H.shopName(data, p.shopId);
|
|
spendByShop[name] = (spendByShop[name] || 0) + p.price;
|
|
});
|
|
const shopRanked = Object.entries(spendByShop).sort((a,b) => b[1]-a[1]);
|
|
|
|
return (
|
|
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
|
|
<div style={{marginBottom: 24}}>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Last 90 days</div>
|
|
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Patterns & spend</h1>
|
|
</div>
|
|
|
|
<Card style={{marginBottom: 14}}>
|
|
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 18}}>
|
|
<div className="serif" style={{fontSize: 22}}>Daily grams · 90 days</div>
|
|
<div style={{display: "flex", gap: 24, fontSize: 12, color: "var(--ink-3)"}}>
|
|
<div>Total <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{series.reduce((s,e)=>s+e.grams,0).toFixed(1)} g</span></div>
|
|
<div>Avg <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{(series.reduce((s,e)=>s+e.grams,0)/90).toFixed(2)} g/day</span></div>
|
|
<div>Items finished <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{stats.consumedCount}</span></div>
|
|
{stats.goneCount > 0 && <div>Items gone <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{stats.goneCount}</span></div>}
|
|
</div>
|
|
</div>
|
|
<BarChart data={series.map(s => ({value: s.grams, label: ""}))} height={180} color="var(--sage)" />
|
|
</Card>
|
|
|
|
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14}}>
|
|
<Card>
|
|
<div className="serif" style={{fontSize: 22, marginBottom: 18}}>Spend by month</div>
|
|
<div style={{display: "flex", flexDirection: "column", gap: 14}}>
|
|
{months.map(([m, v]) => {
|
|
const max = Math.max(...months.map(x => x[1]));
|
|
const d = new Date(m + "-01");
|
|
return (
|
|
<div key={m} style={{display: "flex", alignItems: "center", gap: 12}}>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)", width: 60}}>{d.toLocaleDateString("en-US", {month: "short", year: "2-digit"})}</div>
|
|
<div style={{flex: 1, height: 24, background: "var(--bg-2)", borderRadius: 4, position: "relative"}}>
|
|
<div style={{width: `${(v/max)*100}%`, height: "100%", background: "var(--terracotta)", borderRadius: 4, opacity: 0.85}} />
|
|
</div>
|
|
<div className="mono" style={{width: 70, textAlign: "right", fontSize: 13}}>{fmt.moneyShort(v)}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<div className="serif" style={{fontSize: 22, marginBottom: 18}}>Spend by shop</div>
|
|
<div style={{display: "flex", flexDirection: "column", gap: 14}}>
|
|
{shopRanked.map(([s, v]) => {
|
|
const max = shopRanked[0][1];
|
|
return (
|
|
<div key={s} style={{display: "flex", alignItems: "center", gap: 12}}>
|
|
<div style={{flex: 1.5, fontSize: 13, color: "var(--ink-2)"}}>{s}</div>
|
|
<div style={{flex: 2, height: 8, background: "var(--bg-2)", borderRadius: 4, position: "relative"}}>
|
|
<div style={{width: `${(v/max)*100}%`, height: "100%", background: "var(--sage)", borderRadius: 4}} />
|
|
</div>
|
|
<div className="mono" style={{width: 70, textAlign: "right", fontSize: 13}}>{fmt.moneyShort(v)}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<div className="serif" style={{fontSize: 22, marginBottom: 6}}>Inferred consumption heatmap</div>
|
|
<div style={{fontSize: 12, color: "var(--ink-3)", marginBottom: 18}}>13 weeks · darker = higher inferred daily use, prorated across each item's lifespan</div>
|
|
<Heatmap series={series} />
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Heatmap = ({series}) => {
|
|
const first = new Date(series[0].date);
|
|
const offset = first.getDay();
|
|
const cells = [];
|
|
for (let i = 0; i < offset; i++) cells.push(null);
|
|
series.forEach(s => cells.push(s));
|
|
while (cells.length < 13 * 7) cells.push(null);
|
|
|
|
const max = Math.max(...series.map(s => s.grams), 0.001);
|
|
const colorFor = (g) => {
|
|
if (g === 0) return "var(--bg-3)";
|
|
const t = g / max;
|
|
return `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`;
|
|
};
|
|
|
|
const days = ["S","M","T","W","T","F","S"];
|
|
return (
|
|
<div style={{display: "flex", gap: 8, alignItems: "flex-start"}}>
|
|
<div style={{display: "flex", flexDirection: "column", gap: 3, paddingTop: 18}}>
|
|
{days.map((d, i) => <div key={i} style={{height: 14, fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{d}</div>)}
|
|
</div>
|
|
<div style={{flex: 1}}>
|
|
<div style={{display: "grid", gridTemplateColumns: "repeat(13, 1fr)", gap: 3, marginBottom: 4}}>
|
|
{Array.from({length: 13}).map((_, w) => {
|
|
const firstDay = cells[w * 7];
|
|
return <div key={w} style={{fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)", textAlign: "center"}}>{firstDay && new Date(firstDay.date).getDate() <= 7 ? new Date(firstDay.date).toLocaleDateString("en-US", {month: "short"}) : ""}</div>;
|
|
})}
|
|
</div>
|
|
<div style={{display: "grid", gridTemplateRows: "repeat(7, 1fr)", gridAutoFlow: "column", gap: 3}}>
|
|
{cells.map((c, i) => (
|
|
<div key={i} title={c ? `${c.date}: ${c.grams.toFixed(2)}g` : ""} style={{
|
|
aspectRatio: "1",
|
|
minHeight: 14,
|
|
background: c ? colorFor(c.grams) : "transparent",
|
|
borderRadius: 2
|
|
}} />
|
|
))}
|
|
</div>
|
|
<div style={{display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 6, marginTop: 14, fontSize: 10, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>
|
|
<span>Less</span>
|
|
{[0, 0.25, 0.5, 0.75, 1].map(t => (
|
|
<div key={t} style={{width: 14, height: 14, background: t === 0 ? "var(--bg-3)" : `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`, borderRadius: 2}} />
|
|
))}
|
|
<span>More</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── SETTINGS ─────────────────────────────────────────────────────
|
|
const SettingsView = ({data, tweaks, onTweakChange}) => {
|
|
return (
|
|
<div style={{padding: "32px 40px 80px", maxWidth: 800, margin: "0 auto"}}>
|
|
<div style={{marginBottom: 24}}>
|
|
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Settings</div>
|
|
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Preferences</h1>
|
|
</div>
|
|
|
|
<Card style={{marginBottom: 14}}>
|
|
<div className="serif" style={{fontSize: 22, marginBottom: 16}}>Appearance</div>
|
|
<div style={{display: "flex", flexDirection: "column", gap: 14}}>
|
|
<SettingRow label="Theme" hint="Light parchment or dim ink">
|
|
<div style={{display: "inline-flex", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: 3}}>
|
|
{["light", "dark"].map(t => (
|
|
<button key={t} onClick={() => onTweakChange("theme", t)} style={{padding: "6px 14px", fontSize: 12, fontWeight: 500, borderRadius: 6, border: "none", background: tweaks.theme === t ? "var(--surface)" : "transparent", color: tweaks.theme === t ? "var(--ink)" : "var(--ink-3)", cursor: "pointer", textTransform: "capitalize"}}>{t}</button>
|
|
))}
|
|
</div>
|
|
</SettingRow>
|
|
<SettingRow label="Dashboard layout" hint="Editorial leans on type; data-dense packs more in">
|
|
<div style={{display: "inline-flex", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: 3}}>
|
|
{[["editorial","Editorial"], ["dense","Data-dense"], ["minimal","Minimal"]].map(([k,l]) => (
|
|
<button key={k} onClick={() => onTweakChange("dashboard", k)} style={{padding: "6px 14px", fontSize: 12, fontWeight: 500, borderRadius: 6, border: "none", background: tweaks.dashboard === k ? "var(--surface)" : "transparent", color: tweaks.dashboard === k ? "var(--ink)" : "var(--ink-3)", cursor: "pointer"}}>{l}</button>
|
|
))}
|
|
</div>
|
|
</SettingRow>
|
|
<SettingRow label="Tone">
|
|
<Select value={tweaks.tone} onChange={e => onTweakChange("tone", e.target.value)} style={{...inputStyle, width: 200}}>
|
|
<option value="botanical">Botanical (default)</option>
|
|
<option value="neutral">Neutral inventory</option>
|
|
<option value="discreet">Discreet (code names)</option>
|
|
</Select>
|
|
</SettingRow>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Shops */}
|
|
<Card style={{marginBottom: 14}}>
|
|
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 14}}>
|
|
<div className="serif" style={{fontSize: 22}}>Shops</div>
|
|
<Btn variant="ghost" icon="plus">Add shop</Btn>
|
|
</div>
|
|
<div style={{border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden"}}>
|
|
{data.shops.map((s, i) => {
|
|
const count = data.products.filter(p => p.shopId === s.id).length;
|
|
return (
|
|
<div key={s.id} style={{padding: "12px 16px", borderBottom: i < data.shops.length-1 ? "1px solid var(--line)" : "none", display: "flex", alignItems: "center", gap: 12}}>
|
|
<div style={{flex: 1}}>
|
|
<div style={{fontSize: 14, fontWeight: 500}}>{s.name}</div>
|
|
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{s.location}</div>
|
|
</div>
|
|
<Pill tone="outline">{count} purchase{count===1?"":"s"}</Pill>
|
|
<Btn variant="ghost" icon="edit" />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Brands */}
|
|
<Card style={{marginBottom: 14}}>
|
|
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 14}}>
|
|
<div className="serif" style={{fontSize: 22}}>Brands</div>
|
|
<Btn variant="ghost" icon="plus">Add brand</Btn>
|
|
</div>
|
|
<div style={{display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8}}>
|
|
{data.brands.map(b => {
|
|
const count = data.products.filter(p => p.brandId === b.id).length;
|
|
return (
|
|
<div key={b.id} style={{padding: "10px 14px", border: "1px solid var(--line)", borderRadius: "var(--r-md)", display: "flex", alignItems: "center", gap: 12}}>
|
|
<div style={{flex: 1, fontSize: 13, fontWeight: 500}}>{b.name}</div>
|
|
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)"}}>{count}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card style={{marginBottom: 14}}>
|
|
<div className="serif" style={{fontSize: 22, marginBottom: 16}}>Library</div>
|
|
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginBottom: 16}}>
|
|
<Stat label="Active" value={data.products.filter(p=>p.status==="active").length} />
|
|
<Stat label="Consumed" value={data.products.filter(p=>p.status==="consumed").length} />
|
|
<Stat label="Gone" value={data.products.filter(p=>p.status==="gone").length} />
|
|
<Stat label="Bins" value={data.bins.length} />
|
|
</div>
|
|
<div style={{fontSize: 12, color: "var(--ink-3)"}}>All data is stored locally. Export anytime.</div>
|
|
<div style={{display: "flex", gap: 8, marginTop: 12}}>
|
|
<Btn variant="secondary">Export CSV</Btn>
|
|
<Btn variant="secondary">Export JSON</Btn>
|
|
<Btn variant="ghost" style={{color: "var(--terracotta)"}}>Reset all data</Btn>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const SettingRow = ({label, hint, children}) => (
|
|
<div style={{display: "flex", alignItems: "center", justifyContent: "space-between", paddingBottom: 14, borderBottom: "1px solid var(--line)"}}>
|
|
<div style={{flex: 1}}>
|
|
<div style={{fontSize: 13, fontWeight: 500}}>{label}</div>
|
|
{hint && <div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 2}}>{hint}</div>}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
Object.assign(window, { AddProductFlow, ConsumeFlow, MarkGoneFlow, AuditFlow, BinsView, ChartsView, SettingsView });
|