355 lines
15 KiB
JavaScript
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;
|