MeshiTrack/docs/design/src/Cabinet.jsx

355 lines
15 KiB
JavaScript

// Medicine Cabinet — the hero screen.
// Layout: summary strip + filter rail + "cabinet grid" of med cards with
// days-of-supply bar, expiry, lot info, and inline quick actions.
const { CABINET, MED, MEDICINES, PRICES, STORES } = window.MESHI;
const cabinetStatus = (row) => {
const days = Math.floor(row.qty / (row.perDose * (1 / row.daysPerUnit)));
// days until expiry
const exp = new Date(row.expiry);
const daysToExp = Math.round((exp - new Date('2026-04-18')) / 86400000);
let level = 'ok';
if (row.qty <= row.lowAt) level = 'low';
if (daysToExp < 60) level = level === 'low' ? 'critical' : 'expiring';
if (row.qty <= Math.ceil(row.lowAt / 2)) level = 'critical';
return { days, daysToExp, level };
};
const fmtJPY = (n) => '¥' + Number(n).toLocaleString('en-US');
const CabinetSummary = () => {
const rows = CABINET.map(r => ({ ...r, ...cabinetStatus(r) }));
const total = rows.length;
const low = rows.filter(r => r.level === 'low' || r.level === 'critical').length;
const expiring = rows.filter(r => r.level === 'expiring' || r.level === 'critical').length;
const inventoryValue = rows.reduce((s, r) => {
const p = PRICES.find(p => p.medId === r.medId);
return s + (p ? (p.price / p.pkg) * r.qty : 0);
}, 0);
const stats = [
{ label: 'Items', value: total, hint: 'distinct medicines', tone: 'ink' },
{ label: 'Running low', value: low, hint: 'need a refill soon', tone: 'danger' },
{ label: 'Expiring <60d', value: expiring, hint: 'check dates', tone: 'warn' },
{ label: 'Cabinet value', value: fmtJPY(Math.round(inventoryValue)), hint: 'at last paid price', tone: 'brand' },
];
return (
<div className="cab-summary">
{stats.map(s => (
<div key={s.label} className={'cab-summary__item cab-summary__item--' + s.tone}>
<div className="cab-summary__label">{s.label}</div>
<div className="cab-summary__value num">{s.value}</div>
<div className="cab-summary__hint">{s.hint}</div>
</div>
))}
</div>
);
};
const DaysBar = ({ days, low = 14 }) => {
const cap = 60;
const pct = Math.min(100, (days / cap) * 100);
const tone = days <= low / 2 ? 'critical' : days <= low ? 'low' : 'ok';
return (
<div className={'daysbar daysbar--' + tone} title={days + ' days supply'}>
<div className="daysbar__track">
<div className="daysbar__fill" style={{ width: pct + '%' }} />
<div className="daysbar__mark" style={{ left: ((low / cap) * 100) + '%' }} />
</div>
<div className="daysbar__label num">
<span className="daysbar__num">{days}</span>
<span className="daysbar__unit">days</span>
</div>
</div>
);
};
const CabinetCard = ({ row, onOpen }) => {
const med = MED[row.medId];
const { days, daysToExp, level } = cabinetStatus(row);
const price = PRICES.find(p => p.medId === row.medId);
return (
<button className={'cab-card cab-card--lvl-' + level} onClick={onOpen}>
<div className="cab-card__top">
<div className="cab-card__swatch" style={{ '--c': 'var(--viz-' + med.color + ')' }}>
<Icon name={med.form === 'Injection' ? 'injection' : med.form === 'Capsule' ? 'capsule' : 'pill'} size={16} />
</div>
<div className="cab-card__meta">
<div className="cab-card__name">{med.name}</div>
<div className="cab-card__strength">{med.strength} · {med.form}</div>
</div>
{level !== 'ok' && (
<span className={'cab-card__flag mt-pill mt-pill--' + (level === 'critical' ? 'danger' : level === 'low' ? 'danger' : 'warn')}>
{level === 'critical' ? 'Critical' : level === 'low' ? 'Low' : 'Expiring'}
</span>
)}
</div>
<div className="cab-card__qty">
<div className="cab-card__qtyNum num">{row.qty}</div>
<div className="cab-card__qtyUnit">{row.unit}</div>
</div>
<DaysBar days={days} low={row.lowAt} />
<div className="cab-card__foot">
<div className="cab-card__footItem">
<div className="cab-card__footLabel">Expiry</div>
<div className={'cab-card__footValue num' + (daysToExp < 60 ? ' is-warn' : '')}>
{row.expiry.slice(0, 7)}
</div>
</div>
<div className="cab-card__footItem">
<div className="cab-card__footLabel">Lots</div>
<div className="cab-card__footValue num">{row.lots}</div>
</div>
<div className="cab-card__footItem">
<div className="cab-card__footLabel">Unit cost</div>
<div className="cab-card__footValue num">
{price ? fmtJPY(Math.round(price.price / price.pkg)) : '—'}
</div>
</div>
</div>
</button>
);
};
const CabinetFilters = ({ filter, setFilter, view, setView, query, setQuery }) => {
const tabs = [
{ id: 'all', label: 'All', count: CABINET.length },
{ id: 'low', label: 'Running low', count: CABINET.filter(r => cabinetStatus(r).level !== 'ok').length },
{ id: 'rx', label: 'Prescription', count: CABINET.filter(r => MED[r.medId].category === 'Prescription').length },
{ id: 'supplement', label: 'Supplements', count: CABINET.filter(r => MED[r.medId].category === 'Supplement').length },
];
return (
<div className="cab-filters">
<div className="cab-filters__tabs">
{tabs.map(t => (
<button
key={t.id}
className={'cab-filters__tab' + (filter === t.id ? ' is-active' : '')}
onClick={() => setFilter(t.id)}
>
{t.label}
<span className="cab-filters__count num">{t.count}</span>
</button>
))}
</div>
<div className="cab-filters__tools">
<div className="cab-filters__search">
<Icon name="search" size={13} />
<input placeholder="Filter" value={query} onChange={e => setQuery(e.target.value)} />
</div>
<div className="mt-seg">
<button className={view === 'grid' ? 'is-active' : ''} onClick={() => setView('grid')}>Grid</button>
<button className={view === 'table' ? 'is-active' : ''} onClick={() => setView('table')}>Table</button>
<button className={view === 'shelf' ? 'is-active' : ''} onClick={() => setView('shelf')}>Shelf</button>
</div>
<button className="mt-btn mt-btn--ghost">
<Icon name="filter" size={14} /> Sort: Days left
</button>
</div>
</div>
);
};
const CabinetTable = ({ rows, onOpen }) => (
<div className="cab-table">
<div className="cab-table__head">
<div>Medicine</div>
<div className="num">Qty</div>
<div>Supply</div>
<div>Expiry</div>
<div className="num">Unit cost</div>
<div>Status</div>
</div>
{rows.map(r => {
const med = MED[r.medId];
const { days, daysToExp, level } = cabinetStatus(r);
const price = PRICES.find(p => p.medId === r.medId);
return (
<button key={r.medId} className="cab-table__row" onClick={() => onOpen(r)}>
<div className="cab-table__cell cab-table__cellMed">
<div className="cab-card__swatch cab-card__swatch--sm" style={{ '--c': 'var(--viz-' + med.color + ')' }}>
<Icon name={med.form === 'Injection' ? 'injection' : med.form === 'Capsule' ? 'capsule' : 'pill'} size={12} />
</div>
<div>
<div style={{ fontWeight: 500 }}>{med.name}</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>{med.strength} · {med.form}</div>
</div>
</div>
<div className="num" style={{ fontWeight: 500 }}>{r.qty}<span style={{ color: 'var(--ink-faint)', marginLeft: 4, fontWeight: 400 }}>{r.unit}</span></div>
<div><DaysBar days={days} low={r.lowAt} /></div>
<div className={'num' + (daysToExp < 60 ? ' is-warn' : '')}>{r.expiry}</div>
<div className="num">{price ? fmtJPY(Math.round(price.price / price.pkg)) : '—'}</div>
<div>
{level === 'ok' ? <span className="mt-pill mt-pill--ghost">In stock</span> : level === 'low' ? <span className="mt-pill mt-pill--danger">Low</span> : level === 'critical' ? <span className="mt-pill mt-pill--danger">Critical</span> : <span className="mt-pill mt-pill--warn">Expiring</span>}
</div>
</button>
);
})}
</div>
);
const CabinetShelf = ({ rows, onOpen }) => {
// Group by category, then render as "shelves"
const groups = {};
rows.forEach(r => {
const cat = MED[r.medId].category;
(groups[cat] = groups[cat] || []).push(r);
});
return (
<div className="cab-shelf">
{Object.entries(groups).map(([cat, items]) => (
<div key={cat} className="cab-shelf__level">
<div className="cab-shelf__tag">{cat}</div>
<div className="cab-shelf__bottles">
{items.map(r => {
const med = MED[r.medId];
const { days, level } = cabinetStatus(r);
const fillPct = Math.min(100, (days / 60) * 100);
return (
<button key={r.medId} className={'cab-bottle cab-bottle--lvl-' + level} onClick={() => onOpen(r)}>
<div className="cab-bottle__body" style={{ '--c': 'var(--viz-' + med.color + ')' }}>
<div className="cab-bottle__fill" style={{ height: fillPct + '%' }} />
<div className="cab-bottle__cap" />
</div>
<div className="cab-bottle__label">
<div className="cab-bottle__name">{med.name}</div>
<div className="cab-bottle__qty num">{r.qty} {r.unit}</div>
</div>
</button>
);
})}
</div>
<div className="cab-shelf__line" />
</div>
))}
</div>
);
};
const CabinetDetail = ({ row, onClose }) => {
if (!row) return null;
const med = MED[row.medId];
const { days, daysToExp, level } = cabinetStatus(row);
const price = PRICES.find(p => p.medId === row.medId);
const allPrices = PRICES.filter(p => p.medId === row.medId);
const cheapest = allPrices.reduce((a, b) => (a && a.price / a.pkg < b.price / b.pkg ? a : b), null);
return (
<div className="cab-detail" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="cab-detail__panel">
<div className="cab-detail__head">
<div className="mt-row">
<div className="cab-card__swatch" style={{ '--c': 'var(--viz-' + med.color + ')', width: 44, height: 44 }}>
<Icon name={med.form === 'Injection' ? 'injection' : med.form === 'Capsule' ? 'capsule' : 'pill'} size={20} />
</div>
<div>
<div className="cab-detail__title">{med.name}</div>
<div className="cab-detail__sub">{med.strength} · {med.form} · {med.category}</div>
</div>
</div>
<button className="mt-iconbtn" onClick={onClose}><Icon name="x" size={14} /></button>
</div>
<div className="cab-detail__hero">
<div>
<div className="cab-detail__bigLabel">On hand</div>
<div className="cab-detail__big num">{row.qty} <span>{row.unit}</span></div>
</div>
<div>
<div className="cab-detail__bigLabel">Supply</div>
<div className="cab-detail__big num">{days} <span>days</span></div>
</div>
<div>
<div className="cab-detail__bigLabel">Expires</div>
<div className={'cab-detail__big num' + (daysToExp < 60 ? ' is-warn' : '')}>{row.expiry}</div>
</div>
</div>
<div className="cab-detail__actions">
<button className="mt-btn mt-btn--primary"><Icon name="check" size={14} /> Log intake</button>
<button className="mt-btn mt-btn--ghost"><Icon name="plus" size={14} /> Add to cabinet</button>
<button className="mt-btn mt-btn--ghost"><Icon name="truck" size={14} /> Reorder</button>
<button className="mt-btn mt-btn--subtle"><Icon name="edit" size={14} /> Adjust</button>
</div>
<div className="mt-divider" />
<div className="cab-detail__section">
<div className="cab-detail__sectionTitle">Prices across stores</div>
<div className="cab-detail__prices">
{allPrices.map(p => {
const s = STORES.find(s => s.id === p.store);
const isCheap = p === cheapest;
return (
<div key={p.store + p.date} className={'cab-detail__price' + (isCheap ? ' is-best' : '')}>
<div className="cab-detail__priceStore">
<div style={{ fontWeight: 500 }}>{s?.name}</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>{p.date}</div>
</div>
<div className="cab-detail__priceBody">
<div className="num" style={{ fontSize: 15, fontWeight: 500 }}>{fmtJPY(p.price)}</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }} className="num">{fmtJPY(Math.round(p.price / p.pkg))} / {p.unit}</div>
</div>
{isCheap && <span className="mt-pill mt-pill--ok">Best</span>}
</div>
);
})}
</div>
</div>
{med.notes && (
<div className="cab-detail__section">
<div className="cab-detail__sectionTitle">Notes</div>
<div style={{ fontSize: 13, color: 'var(--ink-muted)' }}>{med.notes}</div>
</div>
)}
</div>
</div>
);
};
const CabinetPage = () => {
const [filter, setFilter] = useState('all');
const [view, setView] = useState('grid');
const [query, setQuery] = useState('');
const [detail, setDetail] = useState(null);
const rows = useMemo(() => {
return CABINET
.filter(r => {
if (filter === 'low') return cabinetStatus(r).level !== 'ok';
if (filter === 'rx') return MED[r.medId].category === 'Prescription';
if (filter === 'supplement') return MED[r.medId].category === 'Supplement';
return true;
})
.filter(r => !query || MED[r.medId].name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => cabinetStatus(a).days - cabinetStatus(b).days);
}, [filter, query]);
return (
<div className="mt-page">
<CabinetSummary />
<div style={{ height: 20 }} />
<CabinetFilters filter={filter} setFilter={setFilter} view={view} setView={setView} query={query} setQuery={setQuery} />
<div style={{ height: 16 }} />
{view === 'grid' && (
<div className="cab-grid">
{rows.map(r => <CabinetCard key={r.medId} row={r} onOpen={() => setDetail(r)} />)}
</div>
)}
{view === 'table' && <CabinetTable rows={rows} onOpen={setDetail} />}
{view === 'shelf' && <CabinetShelf rows={rows} onOpen={setDetail} />}
<CabinetDetail row={detail} onClose={() => setDetail(null)} />
</div>
);
};
window.CabinetPage = CabinetPage;
window.cabinetStatus = cabinetStatus;
window.fmtJPY = fmtJPY;