Implement stores and refills, improve testing
This commit is contained in:
parent
9f416903ef
commit
5536acd67d
137 changed files with 21218 additions and 221 deletions
355
docs/design/src/Cabinet.jsx
Normal file
355
docs/design/src/Cabinet.jsx
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
// 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;
|
||||
251
docs/design/src/Dashboard.jsx
Normal file
251
docs/design/src/Dashboard.jsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
// Dashboard — today at a glance. Integrated view of meds + intake + refills + spending.
|
||||
|
||||
const { TODAY_INTAKE, MED, CABINET, REGIMENS, SPENDING, PURCHASES, ACTIVITY, PRICES, STORES } = window.MESHI;
|
||||
|
||||
const DashboardPage = ({ onNav }) => {
|
||||
const [intake, setIntake] = useState(TODAY_INTAKE);
|
||||
|
||||
const taken = intake.filter(i => i.status === 'taken').length;
|
||||
const total = intake.length;
|
||||
const pct = Math.round((taken / total) * 100);
|
||||
|
||||
const lowMeds = CABINET.map(r => ({ r, s: cabinetStatus(r) })).filter(x => x.s.level !== 'ok').sort((a, b) => a.s.days - b.s.days);
|
||||
|
||||
const thisMonth = SPENDING[SPENDING.length - 1].amount;
|
||||
const lastMonth = SPENDING[SPENDING.length - 2].amount;
|
||||
const spendDelta = ((thisMonth - lastMonth) / lastMonth) * 100;
|
||||
|
||||
const toggleTake = (id) => {
|
||||
setIntake(intake.map(i => i.id === id ? { ...i, status: i.status === 'taken' ? 'upcoming' : 'taken', takenAt: '21:04' } : i));
|
||||
};
|
||||
|
||||
// Days supply across meds for mini sparkline
|
||||
const sparkMax = 60;
|
||||
const supplyData = CABINET.map(r => ({ med: MED[r.medId], days: cabinetStatus(r).days }))
|
||||
.sort((a, b) => a.days - b.days);
|
||||
|
||||
return (
|
||||
<div className="mt-page">
|
||||
<div className="dash-hero">
|
||||
<div className="dash-hero__greet">
|
||||
<div className="dash-hero__day">Saturday · April 18</div>
|
||||
<div className="dash-hero__title">Good evening, aeri.</div>
|
||||
<div className="dash-hero__sub">Evening pills are due in <span className="mt-mono" style={{color:'var(--ink-strong)'}}>2h 34m</span>. Everything else is on track.</div>
|
||||
</div>
|
||||
<div className="dash-hero__ring">
|
||||
<svg viewBox="0 0 120 120" width="120" height="120">
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="var(--bg-inset)" strokeWidth="10" />
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="var(--brand)" strokeWidth="10"
|
||||
strokeDasharray={2 * Math.PI * 52}
|
||||
strokeDashoffset={2 * Math.PI * 52 * (1 - pct / 100)}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 60 60)"
|
||||
/>
|
||||
<text x="60" y="56" textAnchor="middle" fill="var(--ink-strong)" style={{fontFamily:'var(--font-display)', fontSize:24, fontWeight:500}}>{taken}<tspan fill="var(--ink-muted)" fontSize="14">/{total}</tspan></text>
|
||||
<text x="60" y="74" textAnchor="middle" fill="var(--ink-muted)" fontSize="10" letterSpacing="1">DOSES TAKEN</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dash-grid">
|
||||
<section className="mt-card dash-panel" style={{ gridColumn: 'span 8' }}>
|
||||
<div className="mt-card__head">
|
||||
<div>
|
||||
<div className="mt-card__title">Today's schedule</div>
|
||||
<div className="mt-card__sub">{total - taken} upcoming · {taken} completed</div>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('schedule')}>Open log <Icon name="arrow" size={12}/></button>
|
||||
</div>
|
||||
<div className="dash-schedule">
|
||||
{['08:00', '21:00'].map(time => {
|
||||
const items = intake.filter(i => i.scheduled === time);
|
||||
if (!items.length) return null;
|
||||
const allTaken = items.every(i => i.status === 'taken');
|
||||
return (
|
||||
<div key={time} className={'dash-sched__slot' + (allTaken ? ' is-done' : '')}>
|
||||
<div className="dash-sched__time">
|
||||
<div className="mono" style={{fontSize:18, fontWeight:500, color:'var(--ink-strong)'}}>{time}</div>
|
||||
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.06em'}}>{time === '08:00' ? 'Morning' : 'Evening'}</div>
|
||||
</div>
|
||||
<div className="dash-sched__items">
|
||||
{items.map(i => {
|
||||
const m = MED[i.medId];
|
||||
return (
|
||||
<button key={i.id} className={'dash-sched__pill' + (i.status === 'taken' ? ' is-taken' : '')} onClick={() => toggleTake(i.id)}>
|
||||
<span className={'dash-sched__check' + (i.status === 'taken' ? ' is-taken' : '')}>
|
||||
{i.status === 'taken' && <Icon name="check" size={10} />}
|
||||
</span>
|
||||
<span style={{ color: 'var(--viz-' + m.color + ')', width: 6, height: 6, borderRadius: 3, background: 'currentColor', display: 'inline-block' }} />
|
||||
<span className="dash-sched__name">{m.name}</span>
|
||||
<span className="dash-sched__dose">{i.qty} {m.form.toLowerCase()}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-card dash-panel" style={{ gridColumn: 'span 4' }}>
|
||||
<div className="mt-card__head">
|
||||
<div>
|
||||
<div className="mt-card__title">Running low</div>
|
||||
<div className="mt-card__sub">{lowMeds.length} need attention</div>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('refills')}>Refills</button>
|
||||
</div>
|
||||
<div className="dash-low">
|
||||
{lowMeds.slice(0, 4).map(({ r, s }) => {
|
||||
const m = MED[r.medId];
|
||||
return (
|
||||
<div key={r.medId} className="dash-low__item">
|
||||
<div className="cab-card__swatch cab-card__swatch--sm" style={{ '--c': 'var(--viz-' + m.color + ')' }}>
|
||||
<Icon name={m.form === 'Injection' ? 'injection' : m.form === 'Capsule' ? 'capsule' : 'pill'} size={12} />
|
||||
</div>
|
||||
<div style={{flex:1, minWidth:0}}>
|
||||
<div style={{fontSize:13, fontWeight:500, color:'var(--ink-strong)'}}>{m.name}</div>
|
||||
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{r.qty} {r.unit}</div>
|
||||
</div>
|
||||
<div style={{textAlign:'right'}}>
|
||||
<div className="num" style={{fontSize:14, fontWeight:600, color: s.level === 'critical' ? 'var(--danger)' : 'var(--warn)'}}>{s.days}d</div>
|
||||
<div style={{fontSize:10, color:'var(--ink-faint)'}}>left</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-card dash-panel" style={{ gridColumn: 'span 5' }}>
|
||||
<div className="mt-card__head">
|
||||
<div>
|
||||
<div className="mt-card__title">Spending · 6 months</div>
|
||||
<div className="mt-card__sub">{fmtJPY(thisMonth)} this month <span style={{color: spendDelta < 0 ? 'var(--ok)' : 'var(--danger)', marginLeft:4}}>{spendDelta < 0 ? '↓' : '↑'} {Math.abs(Math.round(spendDelta))}%</span></div>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('activity')}>Details</button>
|
||||
</div>
|
||||
<div className="dash-spend">
|
||||
<svg viewBox="0 0 360 140" width="100%" height="140" preserveAspectRatio="none">
|
||||
{SPENDING.map((m, i) => {
|
||||
const max = Math.max(...SPENDING.map(s => s.amount));
|
||||
const h = (m.amount / max) * 100;
|
||||
const x = 20 + i * 56;
|
||||
const isLast = i === SPENDING.length - 1;
|
||||
return (
|
||||
<g key={m.month}>
|
||||
<rect x={x} y={120 - h} width="36" height={h} rx="3"
|
||||
fill={isLast ? 'var(--brand)' : 'var(--brand-soft)'}
|
||||
stroke={isLast ? 'var(--brand-deep)' : 'none'}
|
||||
/>
|
||||
<text x={x + 18} y="135" textAnchor="middle" fontSize="9" fill="var(--ink-faint)">{m.month.slice(5)}</text>
|
||||
<text x={x + 18} y={120 - h - 4} textAnchor="middle" fontSize="9" fill={isLast ? 'var(--ink)' : 'var(--ink-muted)'} fontWeight={isLast ? 600 : 400}>
|
||||
{(m.amount / 1000).toFixed(0)}k
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-card dash-panel" style={{ gridColumn: 'span 7' }}>
|
||||
<div className="mt-card__head">
|
||||
<div>
|
||||
<div className="mt-card__title">Cabinet — days of supply</div>
|
||||
<div className="mt-card__sub">At current usage</div>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('cabinet')}>Open cabinet</button>
|
||||
</div>
|
||||
<div className="dash-supply">
|
||||
{supplyData.map(s => {
|
||||
const pct = Math.min(100, (s.days / sparkMax) * 100);
|
||||
const tone = s.days <= 7 ? 'critical' : s.days <= 14 ? 'low' : 'ok';
|
||||
return (
|
||||
<div key={s.med.id} className="dash-supply__row">
|
||||
<div className="dash-supply__name">{s.med.name}</div>
|
||||
<div className="dash-supply__bar">
|
||||
<div className={'dash-supply__fill dash-supply__fill--' + tone} style={{ width: pct + '%' }} />
|
||||
</div>
|
||||
<div className="dash-supply__days num">{s.days}<span>d</span></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-card dash-panel" style={{ gridColumn: 'span 6' }}>
|
||||
<div className="mt-card__head">
|
||||
<div>
|
||||
<div className="mt-card__title">Pending orders</div>
|
||||
<div className="mt-card__sub">{PURCHASES.filter(p => p.status === 'pending').length} awaiting arrival</div>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('purchases')}>All purchases</button>
|
||||
</div>
|
||||
<div className="dash-orders">
|
||||
{PURCHASES.filter(p => p.status === 'pending').map(p => {
|
||||
const s = STORES.find(s => s.id === p.store);
|
||||
const total = p.items.reduce((a, i) => a + i.price, 0);
|
||||
return (
|
||||
<div key={p.id} className="dash-order">
|
||||
<div className="dash-order__icon"><Icon name="truck" size={14} /></div>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{fontWeight:500, fontSize:13}}>{s?.name}</div>
|
||||
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{p.items.map(i => MED[i.medId].name).join(', ')}</div>
|
||||
</div>
|
||||
<div style={{textAlign:'right'}}>
|
||||
<div className="num" style={{fontSize:13, fontWeight:500}}>{fmtJPY(total)}</div>
|
||||
<div style={{fontSize:10, color:'var(--ink-faint)'}}>ordered {p.date}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{PURCHASES.filter(p => p.status === 'received').slice(0, 2).map(p => {
|
||||
const s = STORES.find(s => s.id === p.store);
|
||||
return (
|
||||
<div key={p.id} className="dash-order is-past">
|
||||
<div className="dash-order__icon"><Icon name="check" size={14} /></div>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{fontSize:12, color:'var(--ink-muted)'}}>{s?.name} · {p.items.length} items received</div>
|
||||
</div>
|
||||
<div style={{fontSize:10, color:'var(--ink-faint)'}}>{p.date}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-card dash-panel" style={{ gridColumn: 'span 6' }}>
|
||||
<div className="mt-card__head">
|
||||
<div>
|
||||
<div className="mt-card__title">Recent activity</div>
|
||||
<div className="mt-card__sub">Cabinet changes</div>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('activity')}>See all</button>
|
||||
</div>
|
||||
<div className="dash-activity">
|
||||
{ACTIVITY.slice(0, 5).map(a => {
|
||||
const m = MED[a.medId];
|
||||
return (
|
||||
<div key={a.id} className="dash-act">
|
||||
<span className={'mt-pill mt-pill--' + (a.type === 'consumed' ? 'info' : a.type === 'added' ? 'ok' : 'warn')} style={{minWidth:76, justifyContent:'center'}}>{a.type}</span>
|
||||
<span style={{flex:1, fontSize:12}}>
|
||||
<strong style={{fontWeight:500}}>{m.name}</strong>
|
||||
<span className="num" style={{marginLeft:6, color: a.delta < 0 ? 'var(--ink-muted)' : 'var(--ok)'}}>
|
||||
{a.delta > 0 ? '+' : ''}{a.delta}
|
||||
</span>
|
||||
<span className="num" style={{color:'var(--ink-faint)', marginLeft:6}}>{a.before}→{a.after}</span>
|
||||
</span>
|
||||
<span className="mono" style={{fontSize:10, color:'var(--ink-faint)'}}>{a.at.slice(5)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.DashboardPage = DashboardPage;
|
||||
44
docs/design/src/Icon.jsx
Normal file
44
docs/design/src/Icon.jsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Minimal inline-SVG icon set. Stroke-based, 1.6px weight, 20px box.
|
||||
const Icon = ({ name, size = 18, className = '', style }) => {
|
||||
const s = { width: size, height: size, display: 'inline-block', flexShrink: 0, ...style };
|
||||
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', className };
|
||||
const paths = {
|
||||
dashboard: <><rect x="3" y="3" width="8" height="10" rx="1.5"/><rect x="13" y="3" width="8" height="6" rx="1.5"/><rect x="3" y="15" width="8" height="6" rx="1.5"/><rect x="13" y="11" width="8" height="10" rx="1.5"/></>,
|
||||
cabinet: <><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M4 12h16"/><circle cx="10" cy="7.5" r="0.6" fill="currentColor"/><circle cx="10" cy="16.5" r="0.6" fill="currentColor"/></>,
|
||||
pill: <><rect x="2" y="9" width="20" height="6" rx="3" transform="rotate(-30 12 12)"/><path d="M7.4 7.8l9 5.2" transform="rotate(-30 12 12)"/></>,
|
||||
clock: <><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></>,
|
||||
list: <><path d="M8 6h13M8 12h13M8 18h13"/><circle cx="3.5" cy="6" r="0.8" fill="currentColor"/><circle cx="3.5" cy="12" r="0.8" fill="currentColor"/><circle cx="3.5" cy="18" r="0.8" fill="currentColor"/></>,
|
||||
calendar: <><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></>,
|
||||
store: <><path d="M3 9l1.5-5h15L21 9M3 9v11h18V9M3 9h18"/><path d="M9 20v-6h6v6"/></>,
|
||||
tag: <><path d="M3 12l9-9h8v8l-9 9z"/><circle cx="15.5" cy="8.5" r="1.4"/></>,
|
||||
truck: <><rect x="2" y="7" width="12" height="10" rx="1.5"/><path d="M14 10h5l3 4v3h-8"/><circle cx="7" cy="18.5" r="1.8"/><circle cx="17" cy="18.5" r="1.8"/></>,
|
||||
refresh: <><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></>,
|
||||
search: <><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></>,
|
||||
plus: <><path d="M12 5v14M5 12h14"/></>,
|
||||
check: <><path d="M5 12l5 5L20 6"/></>,
|
||||
x: <><path d="M6 6l12 12M18 6L6 18"/></>,
|
||||
chev: <><path d="M9 6l6 6-6 6"/></>,
|
||||
chevDown: <><path d="M6 9l6 6 6-6"/></>,
|
||||
alert: <><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></>,
|
||||
bell: <><path d="M6 8a6 6 0 1 1 12 0c0 6 2 7 2 7H4s2-1 2-7zM10 19a2 2 0 0 0 4 0"/></>,
|
||||
filter: <><path d="M4 5h16l-6 8v6l-4-2v-4z"/></>,
|
||||
settings: <><circle cx="12" cy="12" r="3"/><path d="M20 12a8 8 0 0 0-.2-1.8l2-1.6-2-3.5-2.4 1a8 8 0 0 0-3-1.8L14 2h-4l-.4 2.4a8 8 0 0 0-3 1.8l-2.4-1-2 3.5 2 1.6A8 8 0 0 0 4 12c0 .6.1 1.2.2 1.8l-2 1.6 2 3.5 2.4-1a8 8 0 0 0 3 1.8L10 22h4l.4-2.4a8 8 0 0 0 3-1.8l2.4 1 2-3.5-2-1.6c.1-.6.2-1.2.2-1.8z"/></>,
|
||||
sun: <><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5L19 19M5 19l1.5-1.5M17.5 6.5L19 5"/></>,
|
||||
moon: <><path d="M20 14A8 8 0 0 1 10 4a8 8 0 1 0 10 10z"/></>,
|
||||
home: <><path d="M3 11l9-8 9 8v10H3z"/><path d="M9 21v-7h6v7"/></>,
|
||||
trash: <><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></>,
|
||||
edit: <><path d="M4 20l4-1 11-11-3-3L5 16zM14 6l3 3"/></>,
|
||||
arrow: <><path d="M5 12h14M13 6l6 6-6 6"/></>,
|
||||
vial: <><path d="M8 3h8M9 3v14a3 3 0 0 0 6 0V3"/><path d="M9 11h6"/></>,
|
||||
capsule: <><rect x="3" y="9" width="18" height="6" rx="3"/><path d="M12 9v6"/></>,
|
||||
injection: <><path d="M17 3l4 4M15 5l4 4M8 12l7-7 3 3-7 7-3.5.5zM8 12l-5 5M4 16l3 3"/></>,
|
||||
trend: <><path d="M3 17l6-6 4 4 8-8"/><path d="M14 7h7v7"/></>,
|
||||
fridge: <><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M5 10h14"/><path d="M8 6v2M8 13v4"/></>,
|
||||
box: <><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 11v10"/></>,
|
||||
yen: <><path d="M5 4l7 9 7-9M7 13h10M7 17h10M12 13v7"/></>,
|
||||
zap: <><path d="M13 2L4 14h7l-1 8 9-12h-7z"/></>,
|
||||
};
|
||||
return <svg {...common} style={s}>{paths[name] || null}</svg>;
|
||||
};
|
||||
|
||||
window.Icon = Icon;
|
||||
518
docs/design/src/OtherPages.jsx
Normal file
518
docs/design/src/OtherPages.jsx
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
// Medium-fi placeholder pages for the remaining medicine + purchasing sections.
|
||||
const { MED: M2, MEDICINES: MEDS2, CABINET: C2, REGIMENS: REG2, STORES: ST2, PRICES: PR2, PURCHASES: PU2, ACTIVITY: AC2 } = window.MESHI;
|
||||
|
||||
// ---- Library ----
|
||||
const LibraryPage = () => {
|
||||
const [q, setQ] = useState('');
|
||||
const rows = MEDS2.filter(m => m.name.toLowerCase().includes(q.toLowerCase()));
|
||||
return (
|
||||
<div className="mt-page">
|
||||
<div className="sub-toolbar">
|
||||
<div className="cab-filters__search" style={{flex:1, maxWidth: 320}}>
|
||||
<Icon name="search" size={13}/>
|
||||
<input placeholder="Search medicines…" value={q} onChange={e=>setQ(e.target.value)} style={{width:'100%'}}/>
|
||||
</div>
|
||||
<div style={{display:'flex', gap: 8}}>
|
||||
<button className="mt-btn mt-btn--ghost">All categories <Icon name="chevDown" size={12}/></button>
|
||||
<button className="mt-btn mt-btn--ghost">All forms <Icon name="chevDown" size={12}/></button>
|
||||
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Add medicine</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-card" style={{overflow:'hidden'}}>
|
||||
{rows.map((m, i) => (
|
||||
<div key={m.id} className="lib-row">
|
||||
<div className="cab-card__swatch cab-card__swatch--sm" style={{'--c':'var(--viz-'+m.color+')'}}>
|
||||
<Icon name={m.form === 'Injection' ? 'injection' : m.form === 'Capsule' ? 'capsule' : 'pill'} size={12}/>
|
||||
</div>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{fontWeight:500, fontSize:13, color:'var(--ink-strong)'}}>{m.name}</div>
|
||||
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{m.strength} · {m.form}</div>
|
||||
</div>
|
||||
<span className={'mt-pill mt-pill--' + (m.category === 'Prescription' ? 'info' : 'ghost')}>{m.category}</span>
|
||||
<button className="mt-btn mt-btn--subtle"><Icon name="edit" size={12}/></button>
|
||||
<button className="mt-btn mt-btn--subtle"><Icon name="trash" size={12}/></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Regimens ----
|
||||
const RegimensPage = () => (
|
||||
<div className="mt-page">
|
||||
<div className="sub-toolbar">
|
||||
<div style={{display:'flex', gap:8}}>
|
||||
<button className="mt-btn mt-btn--ghost">All regimens <Icon name="chevDown" size={12}/></button>
|
||||
<button className="mt-btn mt-btn--ghost"><Icon name="trend" size={12}/> Burn rate</button>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> New regimen</button>
|
||||
</div>
|
||||
<div className="mt-col">
|
||||
{REG2.map(r => (
|
||||
<div key={r.id} className="mt-card" style={{padding: 18}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'flex-start', gap: 16}}>
|
||||
<div>
|
||||
<div style={{display:'flex', alignItems:'center', gap: 8, marginBottom: 4}}>
|
||||
<span style={{fontFamily:'var(--font-display)', fontSize: 18, fontWeight: 500, color:'var(--ink-strong)'}}>Aeri's {r.name}</span>
|
||||
<span className="mt-pill mt-pill--ok">Active</span>
|
||||
</div>
|
||||
<div style={{fontSize:12, color:'var(--ink-muted)'}}>{r.items.length} medication{r.items.length>1?'s':''} · {r.time} · {r.freq}</div>
|
||||
</div>
|
||||
<div style={{display:'flex', gap: 6}}>
|
||||
<button className="mt-btn mt-btn--ghost">Deactivate</button>
|
||||
<button className="mt-btn mt-btn--subtle"><Icon name="edit" size={12}/></button>
|
||||
<button className="mt-btn mt-btn--subtle"><Icon name="trash" size={12}/></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex', flexWrap:'wrap', gap: 6, marginTop: 14}}>
|
||||
{r.items.map(it => {
|
||||
const m = M2[it.medId];
|
||||
return (
|
||||
<span key={it.medId} className="mt-pill mt-pill--outline" style={{padding: '4px 10px', fontSize: 11}}>
|
||||
<span style={{background:'var(--viz-'+m.color+')', width:6, height:6, borderRadius:3, display:'inline-block', marginRight: 4}}/>
|
||||
<strong style={{fontWeight: 500, color:'var(--ink-strong)'}}>{m.name}</strong>
|
||||
<span style={{marginLeft:4, color:'var(--ink-muted)'}}>{it.qty} {it.unit}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---- Organizer ----
|
||||
const OrganizerPage = () => {
|
||||
const [regimenId, setRegimenId] = useState(REG2[1].id);
|
||||
const [days, setDays] = useState(7);
|
||||
const regimen = REG2.find(r => r.id === regimenId);
|
||||
|
||||
// Compute inventory impact: for each item in regimen, how many units will be consumed?
|
||||
const impact = regimen.items.map(it => {
|
||||
const m = M2[it.medId];
|
||||
const cabEntry = C2.find(c => c.medId === it.medId);
|
||||
const onHand = cabEntry ? cabEntry.qty : 0;
|
||||
const needed = it.qty * days;
|
||||
const after = onHand - needed;
|
||||
const perDay = cabEntry ? (cabEntry.perDose || 1) / (cabEntry.daysPerUnit || 1) : 1;
|
||||
const daysAfter = perDay > 0 ? Math.floor(Math.max(0, after) / perDay) : 0;
|
||||
const status = after < 0 ? 'short' : after < needed * 0.5 ? 'low' : 'ok';
|
||||
return { m, it, onHand, needed, after, daysAfter, status, cabEntry };
|
||||
});
|
||||
|
||||
const anyShort = impact.some(i => i.status === 'short');
|
||||
const anyLow = impact.some(i => i.status === 'low');
|
||||
|
||||
return (
|
||||
<div className="mt-page">
|
||||
<div className="org-hero">
|
||||
<div className="org-hero__controls">
|
||||
<div>
|
||||
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.08em', fontWeight:500}}>Fill from regimen</div>
|
||||
<select className="org-hero__select" value={regimenId} onChange={e => setRegimenId(e.target.value)}>
|
||||
{REG2.map(r => <option key={r.id} value={r.id}>Aeri's {r.name}</option>)}
|
||||
</select>
|
||||
<div style={{fontSize:12, color:'var(--ink-muted)', marginTop: 4}}>{regimen.time} · {regimen.items.length} medicines · {regimen.freq}</div>
|
||||
</div>
|
||||
<div className="org-hero__divider"/>
|
||||
<div>
|
||||
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.08em', fontWeight:500, marginBottom:8}}>Days to fill</div>
|
||||
<div className="org-hero__stepper">
|
||||
<button onClick={() => setDays(Math.max(1, days - 1))}>−</button>
|
||||
<span className="num">{days}</span>
|
||||
<button onClick={() => setDays(Math.min(30, days + 1))}>+</button>
|
||||
</div>
|
||||
<div className="org-hero__presets">
|
||||
{[3, 7, 14, 30].map(d => (
|
||||
<button key={d} className={'org-hero__preset' + (days === d ? ' is-active' : '')} onClick={() => setDays(d)}>{d}d</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="org-hero__summary">
|
||||
<div className="org-hero__sumRow">
|
||||
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.08em', fontWeight:500}}>This fill will dispense</div>
|
||||
<div style={{fontSize: 22, fontWeight: 600, color:'var(--ink-strong)', letterSpacing:'-0.01em'}} className="num">
|
||||
{impact.reduce((a,b) => a + b.needed, 0)} <span style={{fontSize:13, fontWeight:400, color:'var(--ink-muted)'}}>units across {impact.length} medicines</span>
|
||||
</div>
|
||||
</div>
|
||||
{anyShort ? (
|
||||
<div className="org-alert org-alert--danger">
|
||||
<Icon name="alert" size={14}/>
|
||||
<div>
|
||||
<strong>Not enough stock.</strong> {impact.filter(i => i.status === 'short').length} medicine{impact.filter(i => i.status === 'short').length > 1 ? 's are' : ' is'} short for this fill.
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--primary">Add to shopping list</button>
|
||||
</div>
|
||||
) : anyLow ? (
|
||||
<div className="org-alert org-alert--warn">
|
||||
<Icon name="alert" size={14}/>
|
||||
<div><strong>Low after fill.</strong> Some meds will drop below 50% headroom.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="org-alert org-alert--ok">
|
||||
<Icon name="check" size={14}/>
|
||||
<div><strong>Ready to dispense.</strong> Cabinet has enough stock with comfortable headroom.</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{display:'flex', gap: 8}}>
|
||||
<button className="mt-btn mt-btn--primary" disabled={anyShort}><Icon name="zap" size={12}/> Dispense {days}-day fill</button>
|
||||
<button className="mt-btn mt-btn--ghost">Print label</button>
|
||||
<div style={{flex:1}}/>
|
||||
<button className="mt-btn mt-btn--ghost">Fill history</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inventory impact table */}
|
||||
<div className="mt-card" style={{marginTop: 16}}>
|
||||
<div className="mt-card__head">
|
||||
<div>
|
||||
<div className="mt-card__title">Inventory impact</div>
|
||||
<div className="mt-card__sub">What this fill will take from the cabinet</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="org-impact">
|
||||
<div className="org-impact__head">
|
||||
<div>Medicine</div>
|
||||
<div style={{textAlign:'right'}}>On hand</div>
|
||||
<div style={{textAlign:'right'}}>Needed</div>
|
||||
<div>After fill</div>
|
||||
<div style={{textAlign:'right'}}>Days left</div>
|
||||
</div>
|
||||
{impact.map(row => {
|
||||
const m = row.m;
|
||||
const pct = row.onHand > 0 ? Math.max(0, Math.min(100, (row.after / row.onHand) * 100)) : 0;
|
||||
return (
|
||||
<div key={m.id} className={'org-impact__row org-impact__row--' + row.status}>
|
||||
<div style={{display:'flex', alignItems:'center', gap: 10}}>
|
||||
<div className="cab-card__swatch cab-card__swatch--sm" style={{'--c':'var(--viz-'+m.color+')'}}>
|
||||
<Icon name={m.form === 'Injection' ? 'injection' : m.form === 'Capsule' ? 'capsule' : 'pill'} size={12}/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{fontWeight:500, fontSize:13, color:'var(--ink-strong)'}}>{m.name}</div>
|
||||
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{row.it.qty} × {days} days = {row.needed} {m.form.toLowerCase()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{textAlign:'right'}}><span className="num" style={{fontSize:14, fontWeight:600}}>{row.onHand}</span></div>
|
||||
<div style={{textAlign:'right'}}><span className="num" style={{fontSize:14, color:'var(--ink-muted)'}}>−{row.needed}</span></div>
|
||||
<div style={{display:'flex', alignItems:'center', gap: 8}}>
|
||||
<div className="org-impact__bar">
|
||||
<div className="org-impact__barBg"/>
|
||||
<div className="org-impact__barFill" style={{width: Math.max(0, pct) + '%', background: row.status === 'short' ? 'var(--danger)' : row.status === 'low' ? 'var(--warn)' : 'var(--ok)'}}/>
|
||||
</div>
|
||||
<span className="num" style={{fontSize:13, fontWeight:600, color: row.after < 0 ? 'var(--danger)' : 'var(--ink-strong)', minWidth: 38, textAlign:'right'}}>{row.after < 0 ? row.after : row.after}</span>
|
||||
</div>
|
||||
<div style={{textAlign:'right'}}>
|
||||
{row.status === 'short'
|
||||
? <span className="mt-pill mt-pill--danger">Short</span>
|
||||
: <span className="num" style={{fontSize:13, color: row.status === 'low' ? 'var(--warn)' : 'var(--ink-muted)'}}>{row.daysAfter}d</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Activity ----
|
||||
const ActivityPage = () => {
|
||||
const { SPENDING } = window.MESHI;
|
||||
return (
|
||||
<div className="mt-page">
|
||||
<div className="mt-card" style={{marginBottom: 16}}>
|
||||
<div className="mt-card__head">
|
||||
<div>
|
||||
<div className="mt-card__title">Spending summary</div>
|
||||
<div className="mt-card__sub">Last 6 months</div>
|
||||
</div>
|
||||
<div className="mt-row" style={{gap:6}}>
|
||||
<button className="mt-btn mt-btn--ghost">This year <Icon name="chevDown" size={12}/></button>
|
||||
<button className="mt-btn mt-btn--ghost">All medicines <Icon name="chevDown" size={12}/></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{padding:'18px', display:'grid', gridTemplateColumns:'2fr 1fr', gap: 20}}>
|
||||
<svg viewBox="0 0 360 180" width="100%" height="180">
|
||||
{SPENDING.map((s, i) => {
|
||||
const max = Math.max(...SPENDING.map(x=>x.amount));
|
||||
const h = (s.amount / max) * 140;
|
||||
const x = 20 + i * 56;
|
||||
return (
|
||||
<g key={s.month}>
|
||||
<rect x={x} y={160 - h} width="36" height={h} rx="3" fill="var(--brand)" opacity={0.3 + (i/SPENDING.length)*0.7}/>
|
||||
<text x={x+18} y="175" textAnchor="middle" fontSize="9" fill="var(--ink-faint)">{s.month.slice(5)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="mt-col">
|
||||
<div><div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.06em'}}>Total spent</div><div style={{fontFamily:'var(--font-display)', fontSize:28, fontWeight:500, color:'var(--ink-strong)'}} className="num">{fmtJPY(SPENDING.reduce((a,b)=>a+b.amount,0))}</div></div>
|
||||
<div><div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.06em'}}>Monthly average</div><div className="num" style={{fontSize:18, fontWeight:500}}>{fmtJPY(Math.round(SPENDING.reduce((a,b)=>a+b.amount,0)/SPENDING.length))}</div></div>
|
||||
<div><div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.06em'}}>Top medicine</div><div style={{fontSize:14, fontWeight:500}}>Semaglutide <span className="num" style={{color:'var(--ink-muted)'}}>{fmtJPY(56062)}</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-card">
|
||||
<div className="mt-card__head">
|
||||
<div>
|
||||
<div className="mt-card__title">Cabinet activity</div>
|
||||
<div className="mt-card__sub">{AC2.length} events</div>
|
||||
</div>
|
||||
<div className="mt-row" style={{gap:6}}>
|
||||
<button className="mt-btn mt-btn--ghost">All types <Icon name="chevDown" size={12}/></button>
|
||||
<button className="mt-btn mt-btn--ghost">All medicines <Icon name="chevDown" size={12}/></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{padding:'4px 18px 16px'}}>
|
||||
{AC2.map(a => {
|
||||
const m = M2[a.medId];
|
||||
return (
|
||||
<div key={a.id} className="act-row">
|
||||
<span className="act-rail"><span className={'act-dot act-dot--'+a.type}/></span>
|
||||
<span className={'mt-pill mt-pill--' + (a.type==='consumed'?'info':a.type==='added'?'ok':'warn')} style={{minWidth: 76, justifyContent:'center'}}>{a.type}</span>
|
||||
<strong style={{fontSize:13, fontWeight:500, color:'var(--ink-strong)'}}>{m.name}</strong>
|
||||
<span className="num" style={{color: a.delta < 0 ? 'var(--ink-muted)' : 'var(--ok)', fontSize: 13, fontWeight:500}}>{a.delta > 0?'+':''}{a.delta}</span>
|
||||
<span className="num" style={{color:'var(--ink-faint)', fontSize:12}}>{a.before}→{a.after}</span>
|
||||
<span style={{flex:1}}/>
|
||||
<span className="mono" style={{fontSize:11, color:'var(--ink-muted)'}}>{a.at}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Stores ----
|
||||
const StoresPage = () => (
|
||||
<div className="mt-page">
|
||||
<div className="sub-toolbar">
|
||||
<div style={{display:'flex', gap:8, alignItems:'center'}}>
|
||||
<div className="cab-filters__search"><Icon name="search" size={13}/><input placeholder="Search stores…"/></div>
|
||||
<button className="mt-btn mt-btn--ghost">All tags <Icon name="chevDown" size={12}/></button>
|
||||
<label style={{display:'flex', gap:6, alignItems:'center', fontSize:12, color:'var(--ink-muted)'}}><input type="checkbox"/> Show inactive</label>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Add store</button>
|
||||
</div>
|
||||
<div className="mt-grid" style={{gridTemplateColumns:'repeat(auto-fill, minmax(280px, 1fr))'}}>
|
||||
{ST2.map(s => (
|
||||
<div key={s.id} className="mt-card" style={{padding: 16}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'flex-start'}}>
|
||||
<div>
|
||||
<div style={{fontWeight:500, fontSize:14, color:'var(--ink-strong)'}}>{s.name}</div>
|
||||
<div style={{fontSize:11, color:'var(--brand)', marginTop:2}}>{s.url}</div>
|
||||
</div>
|
||||
<div style={{display:'flex', gap:4}}>
|
||||
<button className="mt-btn mt-btn--subtle"><Icon name="edit" size={12}/></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex', flexWrap:'wrap', gap: 4, marginTop: 10}}>
|
||||
{s.tags.map(t => <span key={t} className="mt-pill mt-pill--ghost" style={{fontSize:10}}>{t}</span>)}
|
||||
</div>
|
||||
<div className="mt-divider"/>
|
||||
<div style={{display:'flex', justifyContent:'space-between', fontSize:11, color:'var(--ink-muted)'}}>
|
||||
<span>{PR2.filter(p=>p.store===s.id).length} prices tracked</span>
|
||||
<span>{PU2.filter(p=>p.store===s.id).length} orders</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---- Prices ----
|
||||
const PricesPage = () => (
|
||||
<div className="mt-page">
|
||||
<div className="sub-toolbar">
|
||||
<div style={{display:'flex', gap: 8}}>
|
||||
<button className="mt-btn mt-btn--ghost">Semaglutide (14mg) <Icon name="chevDown" size={12}/></button>
|
||||
<button className="mt-btn mt-btn--ghost">All stores <Icon name="chevDown" size={12}/></button>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Record price</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-card" style={{marginBottom: 16}}>
|
||||
<div className="mt-card__head"><div><div className="mt-card__title">Store comparison</div><div className="mt-card__sub">Best price highlighted</div></div></div>
|
||||
<div className="prices-table">
|
||||
<div className="prices-table__head">
|
||||
<div>Store</div><div className="num">Price</div><div className="num">Per unit</div><div>Date</div>
|
||||
</div>
|
||||
{PR2.filter(p=>p.medId==='semaglutide').map(p => {
|
||||
const s = ST2.find(s=>s.id===p.store);
|
||||
const isBest = p.price/p.pkg === Math.min(...PR2.filter(x=>x.medId===p.medId).map(x=>x.price/x.pkg));
|
||||
return (
|
||||
<div key={p.store+p.date} className={'prices-table__row' + (isBest?' is-best':'')}>
|
||||
<div style={{display:'flex', alignItems:'center', gap:8}}>
|
||||
<span style={{fontWeight:500}}>{s?.name}</span>
|
||||
{isBest && <span className="mt-pill mt-pill--ok" style={{fontSize:10}}>Cheapest</span>}
|
||||
</div>
|
||||
<div className="num">{fmtJPY(p.price)}</div>
|
||||
<div className="num" style={{color:'var(--ink-muted)'}}>{fmtJPY(Math.round(p.price/p.pkg))}</div>
|
||||
<div className="num" style={{color:'var(--ink-muted)'}}>{p.date}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-card">
|
||||
<div className="mt-card__head"><div><div className="mt-card__title">All price records</div><div className="mt-card__sub">{PR2.length} entries</div></div></div>
|
||||
<div className="prices-table">
|
||||
<div className="prices-table__head" style={{gridTemplateColumns:'1.5fr 1fr 0.7fr 0.8fr 0.8fr 0.8fr'}}>
|
||||
<div>Medicine</div><div>Store</div><div className="num">Qty</div><div className="num">Price</div><div className="num">Per unit</div><div>Date</div>
|
||||
</div>
|
||||
{PR2.map(p => {
|
||||
const s = ST2.find(s=>s.id===p.store);
|
||||
const m = M2[p.medId];
|
||||
return (
|
||||
<div key={p.medId+p.store+p.date} className="prices-table__row" style={{gridTemplateColumns:'1.5fr 1fr 0.7fr 0.8fr 0.8fr 0.8fr'}}>
|
||||
<div style={{fontWeight:500}}>{m.name}</div>
|
||||
<div>{s?.name}</div>
|
||||
<div className="num">{p.pkg} {p.unit}</div>
|
||||
<div className="num">{fmtJPY(p.price)}</div>
|
||||
<div className="num" style={{color:'var(--ink-muted)'}}>{fmtJPY(Math.round(p.price/p.pkg))}</div>
|
||||
<div className="num" style={{color:'var(--ink-muted)'}}>{p.date}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---- Refills ----
|
||||
const RefillsPage = () => {
|
||||
const low = C2.map(r => ({r, s: cabinetStatus(r)})).filter(x => x.s.level !== 'ok');
|
||||
return (
|
||||
<div className="mt-page">
|
||||
<div className="mt-card" style={{marginBottom: 16}}>
|
||||
<div className="mt-card__head">
|
||||
<div>
|
||||
<div className="mt-card__title">Refill alerts</div>
|
||||
<div className="mt-card__sub">Threshold: 14 days</div>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Generate refill list</button>
|
||||
</div>
|
||||
<div style={{padding:'4px 18px 16px'}}>
|
||||
{low.map(({r, s}) => {
|
||||
const m = M2[r.medId];
|
||||
const bestPrice = PR2.filter(p=>p.medId===r.medId).sort((a,b)=> a.price/a.pkg - b.price/b.pkg)[0];
|
||||
const bestStore = bestPrice && ST2.find(st=>st.id===bestPrice.store);
|
||||
return (
|
||||
<div key={r.medId} className="refill-row">
|
||||
<div className="cab-card__swatch cab-card__swatch--sm" style={{'--c':'var(--viz-'+m.color+')'}}>
|
||||
<Icon name={m.form === 'Injection' ? 'injection' : m.form === 'Capsule' ? 'capsule' : 'pill'} size={12}/>
|
||||
</div>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{fontWeight:500, color:'var(--ink-strong)'}}>{m.name} <span style={{color:'var(--ink-muted)', fontWeight:400}}>{m.strength}</span></div>
|
||||
<div style={{fontSize:11, color: s.level==='critical' ? 'var(--danger)':'var(--warn)', fontWeight:500}}>{s.days} days left · {r.qty} {r.unit} in cabinet</div>
|
||||
</div>
|
||||
<div style={{textAlign:'right'}}>
|
||||
<div style={{fontSize:11, color:'var(--ink-faint)'}}>Suggested</div>
|
||||
<div className="num" style={{fontWeight:500}}>{bestPrice?.pkg || 30} units</div>
|
||||
</div>
|
||||
<div style={{textAlign:'right', minWidth: 140}}>
|
||||
<div style={{fontSize:11, color:'var(--ink-faint)'}}>Best price</div>
|
||||
<div style={{fontSize:12, fontWeight:500, color:'var(--brand)'}}>{bestStore?.name} · <span className="num">{bestPrice ? fmtJPY(bestPrice.price) : '—'}</span></div>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--primary">Add to list</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-card">
|
||||
<div className="mt-card__head">
|
||||
<div><div className="mt-card__title">Refill lists</div><div className="mt-card__sub">Create from alerts or start blank</div></div>
|
||||
<button className="mt-btn mt-btn--ghost"><Icon name="plus" size={12}/> New list</button>
|
||||
</div>
|
||||
<div style={{padding:'20px 18px', color:'var(--ink-muted)', fontSize:12, textAlign:'center'}}>No refill lists yet. Create one above or generate from alerts.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Purchases ----
|
||||
const PurchasesPage = () => (
|
||||
<div className="mt-page">
|
||||
<div className="sub-toolbar">
|
||||
<div style={{display:'flex', gap:8}}>
|
||||
<button className="mt-btn mt-btn--ghost">All statuses <Icon name="chevDown" size={12}/></button>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Record purchase</button>
|
||||
</div>
|
||||
{['pending', 'received'].map(status => {
|
||||
const items = PU2.filter(p => p.status === status);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div key={status} style={{marginBottom: 20}}>
|
||||
<div style={{fontSize:11, textTransform:'uppercase', letterSpacing:'.08em', color:'var(--ink-muted)', fontWeight:600, marginBottom: 8}}>{status === 'pending' ? 'Pending arrival' : 'Received'}</div>
|
||||
<div className="mt-col">
|
||||
{items.map(p => {
|
||||
const s = ST2.find(s => s.id === p.store);
|
||||
const total = p.items.reduce((a,i) => a + i.price, 0);
|
||||
return (
|
||||
<div key={p.id} className="mt-card" style={{padding: 16}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom: 10}}>
|
||||
<div>
|
||||
<div style={{fontWeight:500, fontSize:14, color:'var(--ink-strong)'}}>{s?.name}</div>
|
||||
<div className="num" style={{fontSize:11, color:'var(--ink-muted)'}}>{p.date}</div>
|
||||
</div>
|
||||
<span className={'mt-pill mt-pill--' + (status==='pending'?'warn':'ok')}>{status}</span>
|
||||
</div>
|
||||
<div style={{display:'flex', flexDirection:'column', gap:6, padding: '10px 0', borderTop:'1px dashed var(--border)'}}>
|
||||
{p.items.map(i => {
|
||||
const m = M2[i.medId];
|
||||
return (
|
||||
<div key={i.medId} style={{display:'flex', justifyContent:'space-between', fontSize:12}}>
|
||||
<span>{m.name}</span>
|
||||
<span style={{color:'var(--ink-muted)'}} className="num">{i.qty} {i.unit} · {fmtJPY(i.price)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', paddingTop: 10, borderTop:'1px solid var(--border)'}}>
|
||||
<div className="num" style={{fontSize:15, fontWeight:600}}>{fmtJPY(total)}</div>
|
||||
{status === 'pending' && <div style={{display:'flex', gap:6}}>
|
||||
<button className="mt-btn mt-btn--primary">Mark as received</button>
|
||||
<button className="mt-btn mt-btn--ghost">Cancel</button>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---- Settings ----
|
||||
const SettingsPage = () => (
|
||||
<div className="mt-page" style={{maxWidth: 720}}>
|
||||
<div className="mt-card" style={{marginBottom: 16}}>
|
||||
<div className="mt-card__head"><div className="mt-card__title">Household</div></div>
|
||||
<div style={{padding: 18}}>
|
||||
<div className="settings-row"><span>Name</span><span style={{fontWeight:500}}>Red Panda Den</span><button className="mt-btn mt-btn--ghost">Edit</button></div>
|
||||
<div className="settings-row"><span>Invite code</span><span className="mono">2496F-7CF</span><button className="mt-btn mt-btn--ghost">Regenerate</button></div>
|
||||
<div className="settings-row"><span>Members</span><span>1</span><button className="mt-btn mt-btn--ghost">Invite</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-card" style={{marginBottom: 16}}>
|
||||
<div className="mt-card__head"><div className="mt-card__title">Account</div></div>
|
||||
<div style={{padding: 18}}>
|
||||
<p style={{fontSize:12, color:'var(--ink-muted)'}}>Account settings are managed through Keycloak.</p>
|
||||
<button className="mt-btn mt-btn--ghost">Manage Keycloak account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Object.assign(window, { LibraryPage, RegimensPage, OrganizerPage, ActivityPage, StoresPage, PricesPage, RefillsPage, PurchasesPage, SettingsPage });
|
||||
162
docs/design/src/Schedule.jsx
Normal file
162
docs/design/src/Schedule.jsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// Schedule & Intake Log — slot-based, collapsed by default
|
||||
const { TODAY_INTAKE: INTAKE_SEED, MED: MED_S, REGIMENS: REGS_S } = window.MESHI;
|
||||
|
||||
const SchedulePage = () => {
|
||||
const [intake, setIntake] = useState(INTAKE_SEED);
|
||||
const [day, setDay] = useState(5);
|
||||
const [expanded, setExpanded] = useState(null); // slot time
|
||||
|
||||
const weekDays = ['Mon 13', 'Tue 14', 'Wed 15', 'Thu 16', 'Fri 17', 'Sat 18', 'Sun 19'];
|
||||
const adherence = [1.0, 1.0, 1.0, 0.88, 1.0, 0.50, 0.0];
|
||||
|
||||
const toggle = (id) => setIntake(intake.map(i => i.id === id ? { ...i, status: i.status === 'taken' ? 'upcoming' : 'taken', takenAt: '21:04' } : i));
|
||||
const markSlot = (time) => setIntake(intake.map(i => i.scheduled === time ? { ...i, status: 'taken', takenAt: time } : i));
|
||||
|
||||
// Group by scheduled time
|
||||
const slots = [
|
||||
{ time: '08:00', label: 'Morning', icon: 'sun' },
|
||||
{ time: '21:00', label: 'Evening', icon: 'moon' },
|
||||
].map(s => {
|
||||
const items = intake.filter(i => i.scheduled === s.time);
|
||||
const done = items.filter(i => i.status === 'taken').length;
|
||||
return { ...s, items, done, total: items.length, complete: done === items.length && items.length > 0 };
|
||||
});
|
||||
|
||||
const totalDone = intake.filter(i => i.status === 'taken').length;
|
||||
|
||||
return (
|
||||
<div className="mt-page">
|
||||
{/* Week strip — adherence heatmap */}
|
||||
<div className="sched-weekbar">
|
||||
<div className="sched-weekbar__label">
|
||||
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.08em', fontWeight:500}}>This week</div>
|
||||
<div style={{fontSize:13, color:'var(--ink-strong)', fontWeight:500, marginTop:2}}>
|
||||
<span className="num">{Math.round(adherence.reduce((a,b)=>a+b,0)/adherence.length*100)}%</span> adherence
|
||||
</div>
|
||||
</div>
|
||||
<div className="sched-weekbar__days">
|
||||
{weekDays.map((d, i) => {
|
||||
const isToday = i === day;
|
||||
const a = adherence[i];
|
||||
return (
|
||||
<button key={d} className={'sched-weekday' + (isToday ? ' is-active' : '')} onClick={() => setDay(i)}>
|
||||
<div className="sched-weekday__label">{d.split(' ')[0]}</div>
|
||||
<div className="sched-weekday__num">{d.split(' ')[1]}</div>
|
||||
<div className="sched-weekday__bar">
|
||||
<div className="sched-weekday__fill" style={{height: (a*100) + '%', background: a >= 1 ? 'var(--ok)' : a >= 0.5 ? 'var(--warn)' : a > 0 ? 'var(--danger)' : 'var(--border-strong)'}}/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display:'grid', gridTemplateColumns:'2fr 1fr', gap: 16, marginTop: 16}}>
|
||||
<div className="mt-card">
|
||||
<div className="mt-card__head">
|
||||
<div>
|
||||
<div className="mt-card__title">Saturday, April 18</div>
|
||||
<div className="mt-card__sub"><span className="num">{totalDone}</span> of <span className="num">{intake.length}</span> doses · 18:30 now</div>
|
||||
</div>
|
||||
<button className="mt-btn mt-btn--ghost"><Icon name="plus" size={12}/> Log off-schedule</button>
|
||||
</div>
|
||||
|
||||
<div className="sched-slots">
|
||||
{slots.map(slot => {
|
||||
const isOpen = expanded === slot.time;
|
||||
const now = 18.5;
|
||||
const slotHr = parseInt(slot.time);
|
||||
const status = slot.complete ? 'done' : slotHr < now ? 'overdue' : 'upcoming';
|
||||
return (
|
||||
<div key={slot.time} className={'sched-slot sched-slot--' + status + (isOpen ? ' is-open' : '')}>
|
||||
<button className="sched-slot__row" onClick={() => setExpanded(isOpen ? null : slot.time)}>
|
||||
<div className="sched-slot__time">
|
||||
<div className="sched-slot__hour num">{slot.time}</div>
|
||||
<div className="sched-slot__phase">{slot.label}</div>
|
||||
</div>
|
||||
<div className="sched-slot__main">
|
||||
<div className="sched-slot__summary">
|
||||
<span className="num" style={{fontSize:15, fontWeight:600, color:'var(--ink-strong)'}}>{slot.done}/{slot.total}</span>
|
||||
<span style={{fontSize:13, color:'var(--ink-muted)'}}>doses</span>
|
||||
<div className="sched-slot__dots">
|
||||
{slot.items.map(i => {
|
||||
const m = MED_S[i.medId];
|
||||
return <span key={i.id} className={'sched-slot__dot' + (i.status === 'taken' ? ' is-taken' : '')} style={{background: i.status === 'taken' ? 'var(--viz-' + m.color + ')' : 'transparent', borderColor: 'var(--viz-' + m.color + ')'}}/>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{status === 'done' && <span className="mt-pill mt-pill--ok">All taken</span>}
|
||||
{status === 'overdue' && <span className="mt-pill mt-pill--warn">Overdue</span>}
|
||||
{status === 'upcoming' && <span className="mt-pill mt-pill--ghost">Upcoming</span>}
|
||||
</div>
|
||||
<div className="sched-slot__actions" onClick={e => e.stopPropagation()}>
|
||||
{!slot.complete && <button className="mt-btn mt-btn--primary" onClick={() => markSlot(slot.time)}><Icon name="check" size={12}/> Take all</button>}
|
||||
<Icon name="chevDown" size={14} style={{transform: isOpen ? 'rotate(180deg)' : 'none', transition:'transform .2s', color:'var(--ink-faint)'}}/>
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="sched-slot__detail">
|
||||
{slot.items.map(i => {
|
||||
const m = MED_S[i.medId];
|
||||
return (
|
||||
<button key={i.id} className={'sched-slot__item' + (i.status === 'taken' ? ' is-taken' : '')} onClick={() => toggle(i.id)}>
|
||||
<span className={'sched-check' + (i.status === 'taken' ? ' is-taken' : '')}>{i.status === 'taken' && <Icon name="check" size={10}/>}</span>
|
||||
<span style={{background:'var(--viz-'+m.color+')', width:3, height:20, borderRadius:2}}/>
|
||||
<span style={{flex:1, display:'flex', flexDirection:'column', alignItems:'flex-start'}}>
|
||||
<span style={{fontWeight:500, fontSize:13, color:'var(--ink-strong)'}}>{m.name}</span>
|
||||
<span style={{fontSize:11, color:'var(--ink-muted)'}}>{i.qty} {m.form.toLowerCase()} · {m.strength}</span>
|
||||
</span>
|
||||
{i.status === 'taken' && <span className="mono" style={{fontSize:11, color:'var(--ink-muted)'}}>{i.takenAt}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{padding:'4px 18px 16px', display:'flex', gap: 8, alignItems:'center', color:'var(--ink-faint)', fontSize:11}}>
|
||||
<Icon name="info" size={12}/> Tap a slot to see individual medicines. Off-schedule doses logged separately.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-card">
|
||||
<div className="mt-card__head">
|
||||
<div>
|
||||
<div className="mt-card__title">Active regimens</div>
|
||||
<div className="mt-card__sub"><span className="num">{REGS_S.length}</span> configured</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{padding:'4px 18px 16px'}}>
|
||||
{REGS_S.map(r => (
|
||||
<div key={r.id} className="sched-regimen">
|
||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:8}}>
|
||||
<div>
|
||||
<div style={{fontWeight:500, fontSize:13, color:'var(--ink-strong)'}}>Aeri's {r.name}</div>
|
||||
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{r.time} · {r.freq}</div>
|
||||
</div>
|
||||
<span className="mt-pill mt-pill--brand">Active</span>
|
||||
</div>
|
||||
<div style={{display:'flex', flexWrap:'wrap', gap: 4}}>
|
||||
{r.items.map(it => {
|
||||
const m = MED_S[it.medId];
|
||||
return (
|
||||
<span key={it.medId} className="mt-pill mt-pill--ghost" style={{fontSize:10}}>
|
||||
<span style={{background:'var(--viz-'+m.color+')', width:6, height:6, borderRadius:3, display:'inline-block', marginRight:2}}/>
|
||||
{m.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.SchedulePage = SchedulePage;
|
||||
110
docs/design/src/Shell.jsx
Normal file
110
docs/design/src/Shell.jsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// App shell: sidebar + topbar + routing
|
||||
const { useState, useEffect, useMemo, useRef, createContext, useContext } = React;
|
||||
|
||||
const AppCtx = createContext(null);
|
||||
const useApp = () => useContext(AppCtx);
|
||||
|
||||
const NAV = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: 'dashboard' },
|
||||
{ id: 'cabinet', label: 'Cabinet', icon: 'cabinet', section: 'Medicines', badge: 2 },
|
||||
{ id: 'schedule', label: 'Schedule & Log', icon: 'clock', section: 'Medicines' },
|
||||
{ id: 'regimens', label: 'Regimens', icon: 'list', section: 'Medicines' },
|
||||
{ id: 'organizer', label: 'Pill Organizer', icon: 'calendar', section: 'Medicines' },
|
||||
{ id: 'library', label: 'Library', icon: 'pill', section: 'Medicines' },
|
||||
{ id: 'refills', label: 'Shopping list', icon: 'refresh', section: 'Medicines', badge: 1 },
|
||||
{ id: 'purchases', label: 'Purchases', icon: 'truck', section: 'Medicines' },
|
||||
{ id: 'prices', label: 'Prices', icon: 'tag', section: 'Medicines' },
|
||||
{ id: 'stores', label: 'Stores', icon: 'store', section: 'Medicines' },
|
||||
{ id: 'activity', label: 'Activity & Spend', icon: 'trend', section: 'Medicines' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
||||
];
|
||||
|
||||
const Sidebar = ({ route, onNav }) => {
|
||||
const grouped = useMemo(() => {
|
||||
const g = {};
|
||||
NAV.forEach(n => { (g[n.section] = g[n.section] || []).push(n); });
|
||||
return g;
|
||||
}, []);
|
||||
return (
|
||||
<aside className="mt-side">
|
||||
<div className="mt-side__brand">
|
||||
<div className="mt-side__logo">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="5" fill="var(--brand)"/><path d="M7 15l3-6 2 4 2-3 3 5" stroke="var(--brand-ink)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</div>
|
||||
<div className="mt-side__wordmark">
|
||||
<div className="mt-side__title">MeshiTrack</div>
|
||||
<div className="mt-side__sub">Red Panda Den</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="mt-side__nav">
|
||||
{Object.entries(grouped).map(([section, items]) => (
|
||||
<div key={section} className="mt-side__group">
|
||||
<div className="mt-side__section">{section}</div>
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={'mt-side__item' + (route === item.id ? ' is-active' : '') + (item.soon ? ' is-soon' : '')}
|
||||
onClick={() => !item.soon && onNav(item.id)}
|
||||
disabled={item.soon}
|
||||
>
|
||||
<Icon name={item.icon} size={16} />
|
||||
<span>{item.label}</span>
|
||||
{item.badge ? <span className="mt-side__badge">{item.badge}</span> : null}
|
||||
{item.soon ? <span className="mt-side__soon">Soon</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-side__foot">
|
||||
<div className="mt-side__avatar">A</div>
|
||||
<div className="mt-side__who">
|
||||
<div className="mt-side__whoName">aerimagne</div>
|
||||
<div className="mt-side__whoRole">Household owner</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
const Topbar = ({ title, subtitle, crumbs, onSearch, actions, theme, onToggleTheme }) => {
|
||||
return (
|
||||
<header className="mt-top">
|
||||
<div className="mt-top__left">
|
||||
{crumbs && crumbs.length > 1 && (
|
||||
<div className="mt-top__crumbs">
|
||||
{crumbs.map((c, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <Icon name="chev" size={12} style={{ opacity: 0.4 }} />}
|
||||
<span className={i === crumbs.length - 1 ? 'is-current' : ''}>{c}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-top__titles">
|
||||
<h1 className="mt-top__title">{title}</h1>
|
||||
{subtitle && <div className="mt-top__sub">{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-top__right">
|
||||
<div className="mt-top__search">
|
||||
<Icon name="search" size={14} />
|
||||
<input placeholder="Search medicines, regimens, stores…" />
|
||||
<kbd>⌘K</kbd>
|
||||
</div>
|
||||
<button className="mt-iconbtn" title="Notifications">
|
||||
<Icon name="bell" size={16} />
|
||||
<span className="mt-iconbtn__dot"></span>
|
||||
</button>
|
||||
<button className="mt-iconbtn" onClick={onToggleTheme} title="Toggle theme">
|
||||
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={16} />
|
||||
</button>
|
||||
{actions}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
window.Shell = { Sidebar, Topbar, AppCtx, useApp, NAV };
|
||||
129
docs/design/src/data.jsx
Normal file
129
docs/design/src/data.jsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// Mock data for MeshiTrack. Grounded in the real household ("Red Panda Den"),
|
||||
// extended with plausible timestamps, quantities, purchases and price history.
|
||||
|
||||
const MEDICINES = [
|
||||
{ id: 'semaglutide', name: 'Semaglutide', strength: '14 mg', form: 'Tablet', category: 'Prescription', color: 1, notes: 'Weekly GLP-1. Store at room temp.' },
|
||||
{ id: 'estradiol', name: 'Estradiol Valerate', strength: '10 mg', form: 'Injection', category: 'Prescription', color: 4, notes: 'Intramuscular, weekly.' },
|
||||
{ id: 'spironolactone', name: 'Spironolactone', strength: '100 mg', form: 'Tablet', category: 'Prescription', color: 3 },
|
||||
{ id: 'tadalafil', name: 'Tadalafil', strength: '5 mg', form: 'Tablet', category: 'Prescription', color: 3 },
|
||||
{ id: 'dutasteride', name: 'Dutasteride', strength: '0.5 mg', form: 'Tablet', category: 'Prescription', color: 3 },
|
||||
{ id: 'progesterone', name: 'Progesterone', strength: '200 mg', form: 'Capsule', category: 'Prescription', color: 4 },
|
||||
{ id: 'magnesium', name: 'Magnesium', strength: '400 mg', form: 'Capsule', category: 'Supplement', color: 5 },
|
||||
{ id: 'vitamin-d', name: 'Vitamin D', strength: '2000 IU', form: 'Capsule', category: 'Supplement', color: 2 },
|
||||
{ id: 'vitamin-a', name: 'Vitamin A', strength: '5000 IU', form: 'Capsule', category: 'Supplement', color: 2 },
|
||||
];
|
||||
|
||||
const MED = Object.fromEntries(MEDICINES.map(m => [m.id, m]));
|
||||
|
||||
// Cabinet inventory — summed across item lots
|
||||
const CABINET = [
|
||||
{ medId: 'dutasteride', qty: 80, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2027-08-12', lowAt: 14 },
|
||||
{ medId: 'estradiol', qty: 2, unit: 'vials', lots: 1, daysPerUnit: 14, perDose: 0.5, expiry: '2026-11-30', lowAt: 1 },
|
||||
{ medId: 'magnesium', qty: 115, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2028-02-01', lowAt: 20 },
|
||||
{ medId: 'progesterone', qty: 35, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2027-04-30', lowAt: 14 },
|
||||
{ medId: 'semaglutide', qty: 26, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2026-09-15', lowAt: 7 },
|
||||
{ medId: 'spironolactone', qty: 62, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 2, expiry: '2027-01-20', lowAt: 14 },
|
||||
{ medId: 'tadalafil', qty: 7, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2026-12-01', lowAt: 7 },
|
||||
{ medId: 'vitamin-a', qty: 211, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2028-05-05', lowAt: 30 },
|
||||
{ medId: 'vitamin-d', qty: 96, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2028-03-10', lowAt: 30 },
|
||||
];
|
||||
|
||||
// Daily schedule regimens
|
||||
const REGIMENS = [
|
||||
{
|
||||
id: 'morning', name: 'Morning Pills', time: '08:00', freq: 'daily', active: true,
|
||||
items: [
|
||||
{ medId: 'semaglutide', qty: 1, unit: 'tablet' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evening', name: 'Evening Pills', time: '21:00', freq: 'daily', active: true,
|
||||
items: [
|
||||
{ medId: 'spironolactone', qty: 1, unit: 'tablet' },
|
||||
{ medId: 'tadalafil', qty: 1, unit: 'tablet' },
|
||||
{ medId: 'dutasteride', qty: 1, unit: 'tablet' },
|
||||
{ medId: 'progesterone', qty: 1, unit: 'capsule' },
|
||||
{ medId: 'vitamin-a', qty: 1, unit: 'capsule' },
|
||||
{ medId: 'magnesium', qty: 1, unit: 'capsule' },
|
||||
{ medId: 'vitamin-d', qty: 1, unit: 'capsule' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'injections', name: 'Injections', time: 'Sun 10:00', freq: 'weekly', active: true,
|
||||
items: [
|
||||
{ medId: 'estradiol', qty: 0.5, unit: 'vial' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Today's intake — some done, some upcoming
|
||||
const TODAY_INTAKE = [
|
||||
{ id: 't1', regimen: 'morning', medId: 'semaglutide', qty: 1, scheduled: '08:00', status: 'taken', takenAt: '08:12' },
|
||||
{ id: 't2', regimen: 'evening', medId: 'spironolactone', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||
{ id: 't3', regimen: 'evening', medId: 'tadalafil', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||
{ id: 't4', regimen: 'evening', medId: 'dutasteride', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||
{ id: 't5', regimen: 'evening', medId: 'progesterone', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||
{ id: 't6', regimen: 'evening', medId: 'vitamin-a', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||
{ id: 't7', regimen: 'evening', medId: 'magnesium', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||
{ id: 't8', regimen: 'evening', medId: 'vitamin-d', qty: 1, scheduled: '21:00', status: 'upcoming' },
|
||||
];
|
||||
|
||||
// Stores
|
||||
const STORES = [
|
||||
{ id: 'osakado', name: 'Osakado', url: 'osakado.clinic', tags: ['pharmacy', 'online'], currency: 'JPY', active: true },
|
||||
{ id: 'inhouse', name: 'In-House Pharmacy', url: 'local', tags: ['pharmacy'], currency: 'JPY', active: true },
|
||||
{ id: 'amazon-jp', name: 'Amazon JP', url: 'amazon.co.jp', tags: ['online', 'bulk'], currency: 'JPY', active: true },
|
||||
{ id: 'matsumoto', name: 'Matsumoto Kiyoshi', url: 'matsukiyo.co.jp', tags: ['pharmacy', 'grocery'], currency: 'JPY', active: true },
|
||||
];
|
||||
|
||||
// Prices — multiple stores per med where it makes sense
|
||||
const PRICES = [
|
||||
{ medId: 'semaglutide', store: 'osakado', price: 24862, pkg: 30, unit: 'tablet', date: '2026-03-29' },
|
||||
{ medId: 'semaglutide', store: 'inhouse', price: 31200, pkg: 30, unit: 'tablet', date: '2026-02-10' },
|
||||
{ medId: 'tadalafil', store: 'osakado', price: 4537, pkg: 30, unit: 'tablet', date: '2026-04-02' },
|
||||
{ medId: 'tadalafil', store: 'amazon-jp', price: 5180, pkg: 30, unit: 'tablet', date: '2026-03-15' },
|
||||
{ medId: 'spironolactone', store: 'osakado', price: 2190, pkg: 100, unit: 'tablet', date: '2026-03-01' },
|
||||
{ medId: 'dutasteride', store: 'osakado', price: 3860, pkg: 30, unit: 'tablet', date: '2026-03-20' },
|
||||
{ medId: 'estradiol', store: 'osakado', price: 9800, pkg: 5, unit: 'vial', date: '2026-02-28' },
|
||||
{ medId: 'progesterone', store: 'osakado', price: 2980, pkg: 30, unit: 'capsule', date: '2026-03-05' },
|
||||
{ medId: 'magnesium', store: 'amazon-jp', price: 1480, pkg: 120, unit: 'capsule', date: '2026-01-22' },
|
||||
{ medId: 'magnesium', store: 'matsumoto', price: 1680, pkg: 120, unit: 'capsule', date: '2026-02-14' },
|
||||
{ medId: 'vitamin-d', store: 'amazon-jp', price: 890, pkg: 100, unit: 'capsule', date: '2026-02-10' },
|
||||
{ medId: 'vitamin-a', store: 'amazon-jp', price: 1240, pkg: 250, unit: 'capsule', date: '2026-01-08' },
|
||||
];
|
||||
|
||||
// Purchases
|
||||
const PURCHASES = [
|
||||
{ id: 'p1', store: 'osakado', date: '2026-04-02', status: 'pending', items: [{ medId: 'tadalafil', qty: 28, unit: 'tablet', price: 4537 }] },
|
||||
{ id: 'p2', store: 'osakado', date: '2026-04-02', status: 'received', items: [{ medId: 'semaglutide', qty: 30, unit: 'tablet', price: 24862 }] },
|
||||
{ id: 'p3', store: 'amazon-jp', date: '2026-03-22', status: 'received', items: [{ medId: 'magnesium', qty: 120, unit: 'capsule', price: 1480 }, { medId: 'vitamin-d', qty: 100, unit: 'capsule', price: 890 }] },
|
||||
{ id: 'p4', store: 'osakado', date: '2026-03-20', status: 'received', items: [{ medId: 'dutasteride', qty: 30, unit: 'tablet', price: 3860 }, { medId: 'spironolactone', qty: 100, unit: 'tablet', price: 2190 }] },
|
||||
{ id: 'p5', store: 'osakado', date: '2026-02-28', status: 'received', items: [{ medId: 'estradiol', qty: 5, unit: 'vial', price: 9800 }] },
|
||||
];
|
||||
|
||||
// Cabinet activity events (consumed / adjusted / added)
|
||||
const ACTIVITY = [
|
||||
{ id: 'a1', type: 'consumed', medId: 'vitamin-d', delta: -7, before: 103, after: 96, at: '2026-04-03 08:52' },
|
||||
{ id: 'a2', type: 'consumed', medId: 'magnesium', delta: -7, before: 122, after: 115, at: '2026-04-03 08:52' },
|
||||
{ id: 'a3', type: 'adjusted', medId: 'estradiol', delta: -1, before: 3, after: 2, at: '2026-04-03 08:57' },
|
||||
{ id: 'a4', type: 'consumed', medId: 'vitamin-a', delta: -7, before: 218, after: 211, at: '2026-04-03 08:52' },
|
||||
{ id: 'a5', type: 'consumed', medId: 'spironolactone', delta: -14, before: 76, after: 62, at: '2026-04-03 08:52' },
|
||||
{ id: 'a6', type: 'added', medId: 'tadalafil', delta: +30, before: -23, after: 7, at: '2026-04-02 14:10', ref: 'Purchase p2' },
|
||||
{ id: 'a7', type: 'consumed', medId: 'semaglutide', delta: -1, before: 27, after: 26, at: '2026-04-01 08:15' },
|
||||
{ id: 'a8', type: 'consumed', medId: 'progesterone', delta: -1, before: 36, after: 35, at: '2026-04-03 21:04' },
|
||||
];
|
||||
|
||||
// Monthly spending (last 6 months), JPY
|
||||
const SPENDING = [
|
||||
{ month: '2025-11', amount: 28400 },
|
||||
{ month: '2025-12', amount: 41200 },
|
||||
{ month: '2026-01', amount: 19600 },
|
||||
{ month: '2026-02', amount: 38480 },
|
||||
{ month: '2026-03', amount: 35780 },
|
||||
{ month: '2026-04', amount: 9937 },
|
||||
];
|
||||
|
||||
window.MESHI = {
|
||||
MEDICINES, MED, CABINET, REGIMENS, TODAY_INTAKE,
|
||||
STORES, PRICES, PURCHASES, ACTIVITY, SPENDING,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue