Implement stores and refills, improve testing
144
docs/design/MeshiTrack.html
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>MeshiTrack</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="styles/tokens.css"/>
|
||||
<link rel="stylesheet" href="styles/shell.css"/>
|
||||
<link rel="stylesheet" href="styles/cabinet.css"/>
|
||||
<link rel="stylesheet" href="styles/dashboard.css"/>
|
||||
<link rel="stylesheet" href="styles/schedule.css?v=2"/>
|
||||
<link rel="stylesheet" href="styles/other.css?v=2"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script type="text/babel" src="src/data.jsx"></script>
|
||||
<script type="text/babel" src="src/Icon.jsx"></script>
|
||||
<script type="text/babel" src="src/Shell.jsx"></script>
|
||||
<script type="text/babel" src="src/Cabinet.jsx"></script>
|
||||
<script type="text/babel" src="src/Dashboard.jsx"></script>
|
||||
<script type="text/babel" src="src/Schedule.jsx"></script>
|
||||
<script type="text/babel" src="src/OtherPages.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
const TWEAKS = /*EDITMODE-BEGIN*/{
|
||||
"theme": "light",
|
||||
"accent": "sage"
|
||||
}/*EDITMODE-END*/;
|
||||
|
||||
const ACCENTS = {
|
||||
sage: { brand: '#2f6b4a', deep: '#1e4a32', soft: '#e6efe8' },
|
||||
cobalt: { brand: '#2e5aa8', deep: '#1d3d75', soft: '#e4eaf5' },
|
||||
terracotta: { brand: '#b55438', deep: '#7d3825', soft: '#f6e6de' },
|
||||
graphite: { brand: '#2c2c28', deep: '#000', soft: '#e8e6df' },
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const [route, setRoute] = useState('dashboard');
|
||||
const [theme, setTheme] = useState(TWEAKS.theme || 'light');
|
||||
const [accent, setAccent] = useState(TWEAKS.accent || 'sage');
|
||||
const [tweaksOpen, setTweaksOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
const a = ACCENTS[accent] || ACCENTS.sage;
|
||||
document.documentElement.style.setProperty('--brand', a.brand);
|
||||
document.documentElement.style.setProperty('--brand-deep', a.deep);
|
||||
document.documentElement.style.setProperty('--brand-soft', a.soft);
|
||||
document.documentElement.style.setProperty('--brand-soft-ink', a.deep);
|
||||
document.documentElement.style.setProperty('--viz-1', a.brand);
|
||||
}, [theme, accent]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMsg = (e) => {
|
||||
if (!e.data || typeof e.data !== 'object') return;
|
||||
if (e.data.type === '__activate_edit_mode') setTweaksOpen(true);
|
||||
if (e.data.type === '__deactivate_edit_mode') setTweaksOpen(false);
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||
return () => window.removeEventListener('message', onMsg);
|
||||
}, []);
|
||||
|
||||
const setTweak = (key, val) => {
|
||||
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: val } }, '*');
|
||||
};
|
||||
|
||||
const { Sidebar, Topbar, NAV } = window.Shell;
|
||||
const current = NAV.find(n => n.id === route);
|
||||
|
||||
const pages = {
|
||||
dashboard: { title: 'Dashboard', subtitle: 'Household: Red Panda Den', Page: window.DashboardPage },
|
||||
cabinet: { title: 'Medicine Cabinet', subtitle: 'Everything on hand, with days of supply', Page: window.CabinetPage },
|
||||
schedule: { title: 'Schedule & Log', subtitle: 'Today and this week', Page: window.SchedulePage },
|
||||
regimens: { title: 'Regimens', subtitle: 'Daily medication schedules', crumbs: ['Medicines', 'Regimens'], Page: window.RegimensPage },
|
||||
organizer: { title: 'Pill Organizer', subtitle: 'Fill a week of pills at once', crumbs: ['Medicines', 'Organizer'], Page: window.OrganizerPage },
|
||||
activity: { title: 'Activity', subtitle: 'Spending and cabinet changes', crumbs: ['Medicines', 'Activity'], Page: window.ActivityPage },
|
||||
library: { title: 'Library', subtitle: 'All known medicines', crumbs: ['Medicines', 'Library'], Page: window.LibraryPage },
|
||||
refills: { title: 'Refills', subtitle: 'Running-low alerts and shopping lists', Page: window.RefillsPage },
|
||||
purchases: { title: 'Purchases', subtitle: 'Order history and pending arrivals', Page: window.PurchasesPage },
|
||||
prices: { title: 'Prices', subtitle: 'Track & compare across stores', Page: window.PricesPage },
|
||||
stores: { title: 'Stores', subtitle: 'Pharmacies and vendors', Page: window.StoresPage },
|
||||
settings: { title: 'Settings', subtitle: 'Household and account', Page: window.SettingsPage },
|
||||
};
|
||||
|
||||
const pg = pages[route];
|
||||
const Page = pg.Page;
|
||||
|
||||
return (
|
||||
<div className="mt-app">
|
||||
<Sidebar route={route} onNav={setRoute} />
|
||||
<div className="mt-main" data-screen-label={'MT / ' + pg.title}>
|
||||
<Topbar
|
||||
title={pg.title}
|
||||
subtitle={pg.subtitle}
|
||||
crumbs={pg.crumbs}
|
||||
theme={theme}
|
||||
onToggleTheme={() => { const t = theme === 'light' ? 'dark' : 'light'; setTheme(t); setTweak('theme', t); }}
|
||||
actions={route === 'cabinet' ? <button className="mt-btn mt-btn--primary"><span>+</span> Add to cabinet</button> : null}
|
||||
/>
|
||||
<Page onNav={setRoute} />
|
||||
</div>
|
||||
|
||||
<div className={'mt-tweaks' + (tweaksOpen ? ' is-open' : '')}>
|
||||
<div className="mt-tweaks__title">
|
||||
<span>Tweaks</span>
|
||||
<button className="mt-btn mt-btn--subtle" onClick={() => setTweaksOpen(false)} style={{padding:'2px 6px'}}>×</button>
|
||||
</div>
|
||||
<div className="mt-tweaks__row">
|
||||
<span>Theme</span>
|
||||
<div className="mt-seg">
|
||||
<button className={theme==='light'?'is-active':''} onClick={()=>{setTheme('light'); setTweak('theme','light');}}>Light</button>
|
||||
<button className={theme==='dark'?'is-active':''} onClick={()=>{setTheme('dark'); setTweak('theme','dark');}}>Dark</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-tweaks__row">
|
||||
<span>Accent</span>
|
||||
<div className="mt-seg">
|
||||
{Object.keys(ACCENTS).map(k => (
|
||||
<button key={k} className={accent===k?'is-active':''} onClick={()=>{setAccent(k); setTweak('accent',k);}} style={{textTransform:'capitalize'}}>{k}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontSize: 10, color: 'var(--ink-faint)', marginTop: 8, lineHeight: 1.5}}>
|
||||
See alt-directions canvas for 3 novel cabinet layouts.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
docs/design/screenshots/01-check-org2.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/design/screenshots/01-check-org3.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/design/screenshots/01-dashboard.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/design/screenshots/02-cabinet.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/design/screenshots/02-check-org2.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/design/screenshots/02-check-org3.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/design/screenshots/03-alt.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/design/screenshots/check-cab.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
docs/design/screenshots/check-org.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/design/screenshots/check-sched3.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
docs/design/screenshots/check-sched4.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/design/screenshots/check-schedule.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/design/screenshots/check-schedule2.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
};
|
||||
473
docs/design/styles/cabinet.css
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
/* Cabinet — hero screen */
|
||||
|
||||
.cab-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cab-summary__item {
|
||||
background: var(--bg-elev);
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.cab-summary__label {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cab-summary__value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink-strong);
|
||||
line-height: 1.15;
|
||||
}
|
||||
.cab-summary__item--danger .cab-summary__value { color: var(--danger); }
|
||||
.cab-summary__item--warn .cab-summary__value { color: var(--warn); }
|
||||
.cab-summary__item--brand .cab-summary__value { color: var(--brand); }
|
||||
.cab-summary__hint { font-size: 11px; color: var(--ink-faint); margin-top: 2px; }
|
||||
|
||||
/* Filters */
|
||||
.cab-filters {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cab-filters__tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-elev);
|
||||
padding: 4px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.cab-filters__tab {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.cab-filters__tab:hover { color: var(--ink); }
|
||||
.cab-filters__tab.is-active {
|
||||
background: var(--ink);
|
||||
color: var(--bg-elev);
|
||||
font-weight: 500;
|
||||
}
|
||||
.cab-filters__count {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.cab-filters__tab.is-active .cab-filters__count {
|
||||
background: rgba(255,255,255,0.12);
|
||||
color: var(--bg-elev);
|
||||
}
|
||||
.cab-filters__tools {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.cab-filters__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 6px 10px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.cab-filters__search input {
|
||||
border: 0; outline: 0; background: transparent; width: 120px; font-size: 12px;
|
||||
}
|
||||
.cab-filters__search input::placeholder { color: var(--ink-faint); }
|
||||
|
||||
/* Grid */
|
||||
.cab-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.cab-card {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cab-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.cab-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 3px;
|
||||
background: var(--viz-1);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.cab-card:hover::before { opacity: 0.6; }
|
||||
.cab-card--lvl-low::before, .cab-card--lvl-critical::before {
|
||||
background: var(--danger); opacity: 1;
|
||||
}
|
||||
.cab-card--lvl-expiring::before { background: var(--warn); opacity: 1; }
|
||||
|
||||
.cab-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.cab-card__swatch {
|
||||
width: 34px; height: 34px;
|
||||
border-radius: var(--r-sm);
|
||||
display: grid; place-items: center;
|
||||
background: color-mix(in oklab, var(--c) 16%, var(--bg-elev));
|
||||
color: var(--c);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cab-card__swatch--sm { width: 22px; height: 22px; border-radius: 6px; }
|
||||
.cab-card__meta { flex: 1; min-width: 0; }
|
||||
.cab-card__name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-strong);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.cab-card__strength {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.cab-card__flag { flex-shrink: 0; }
|
||||
|
||||
.cab-card__qty {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.cab-card__qtyNum {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink-strong);
|
||||
line-height: 1;
|
||||
}
|
||||
.cab-card__qtyUnit {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.cab-card__foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.cab-card__footLabel {
|
||||
font-size: 10px;
|
||||
color: var(--ink-faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cab-card__footValue {
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.cab-card__footValue.is-warn { color: var(--warn); }
|
||||
|
||||
/* Days bar */
|
||||
.daysbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.daysbar__track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--bg-inset);
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.daysbar__fill {
|
||||
height: 100%;
|
||||
background: var(--ok);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.daysbar--low .daysbar__fill { background: var(--warn); }
|
||||
.daysbar--critical .daysbar__fill { background: var(--danger); }
|
||||
.daysbar__mark {
|
||||
position: absolute;
|
||||
top: -2px; bottom: -2px;
|
||||
width: 1px;
|
||||
background: var(--ink-faint);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.daysbar__label {
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 3px;
|
||||
min-width: 50px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.daysbar__num {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
.daysbar__unit { color: var(--ink-muted); font-size: 10px; }
|
||||
.daysbar--low .daysbar__num { color: var(--warn); }
|
||||
.daysbar--critical .daysbar__num { color: var(--danger); }
|
||||
|
||||
/* Table view */
|
||||
.cab-table {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cab-table__head, .cab-table__row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 0.7fr 1.4fr 1fr 0.8fr 0.8fr;
|
||||
gap: 14px;
|
||||
padding: 10px 18px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.cab-table__head {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-faint);
|
||||
background: var(--bg-inset);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cab-table__row {
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.cab-table__row:last-child { border-bottom: 0; }
|
||||
.cab-table__row:hover { background: var(--bg-inset); }
|
||||
.cab-table__cellMed {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
|
||||
/* Shelf view */
|
||||
.cab-shelf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
padding: 20px 0 40px;
|
||||
}
|
||||
.cab-shelf__level {
|
||||
position: relative;
|
||||
}
|
||||
.cab-shelf__tag {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.cab-shelf__bottles {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-end;
|
||||
min-height: 160px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.cab-shelf__line {
|
||||
height: 3px;
|
||||
background: linear-gradient(180deg, var(--border-strong), var(--border));
|
||||
border-radius: 1px;
|
||||
margin-top: 8px;
|
||||
box-shadow: 0 1px 0 var(--bg-inset);
|
||||
}
|
||||
|
||||
.cab-bottle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: transform 0.15s;
|
||||
width: 80px;
|
||||
}
|
||||
.cab-bottle:hover { transform: translateY(-3px); }
|
||||
.cab-bottle__body {
|
||||
width: 46px;
|
||||
height: 110px;
|
||||
background: color-mix(in oklab, var(--c) 18%, var(--bg-elev));
|
||||
border: 1.5px solid color-mix(in oklab, var(--c) 50%, var(--bg-elev));
|
||||
border-radius: 8px 8px 6px 6px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cab-bottle__fill {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
background: var(--c);
|
||||
transition: height 0.4s;
|
||||
}
|
||||
.cab-bottle__cap {
|
||||
position: absolute;
|
||||
top: -6px; left: 50%; transform: translateX(-50%);
|
||||
width: 28px; height: 8px;
|
||||
background: var(--ink-muted);
|
||||
border-radius: 3px 3px 2px 2px;
|
||||
}
|
||||
.cab-bottle--lvl-low .cab-bottle__body, .cab-bottle--lvl-critical .cab-bottle__body {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.cab-bottle__label { text-align: center; }
|
||||
.cab-bottle__name {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cab-bottle__qty { font-size: 10px; color: var(--ink-muted); }
|
||||
|
||||
/* Detail drawer */
|
||||
.cab-detail {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(20, 18, 10, 0.3);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
animation: fade-in 0.2s;
|
||||
}
|
||||
@keyframes fade-in { from { opacity: 0; } }
|
||||
.cab-detail__panel {
|
||||
width: min(520px, 100%);
|
||||
background: var(--bg-elev);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow-y: auto;
|
||||
animation: slide-in 0.2s;
|
||||
padding: 24px 28px 32px;
|
||||
}
|
||||
@keyframes slide-in { from { transform: translateX(40px); opacity: 0; } }
|
||||
.cab-detail__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.cab-detail__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
.cab-detail__sub {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.cab-detail__hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-inset);
|
||||
border-radius: var(--r-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cab-detail__bigLabel {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.cab-detail__big {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink-strong);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.cab-detail__big span {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
font-weight: 400;
|
||||
font-family: var(--font-sans);
|
||||
margin-left: 2px;
|
||||
}
|
||||
.cab-detail__big.is-warn { color: var(--warn); }
|
||||
.cab-detail__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cab-detail__section { margin-top: 20px; }
|
||||
.cab-detail__sectionTitle {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-muted);
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cab-detail__prices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.cab-detail__price {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.cab-detail__price.is-best {
|
||||
border-color: var(--ok);
|
||||
background: var(--ok-soft);
|
||||
}
|
||||
.cab-detail__priceStore { flex: 1; font-size: 13px; }
|
||||
.cab-detail__priceBody { text-align: right; }
|
||||
152
docs/design/styles/dashboard.css
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/* Dashboard */
|
||||
.dash-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 4px 4px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.dash-hero__day {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.dash-hero__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink-strong);
|
||||
line-height: 1.05;
|
||||
}
|
||||
.dash-hero__sub {
|
||||
font-size: 14px;
|
||||
color: var(--ink-muted);
|
||||
margin-top: 8px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.dash-panel { overflow: hidden; }
|
||||
|
||||
/* Schedule */
|
||||
.dash-schedule { padding: 4px 18px 18px; }
|
||||
.dash-sched__slot {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
gap: 16px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.dash-sched__slot:last-child { border-bottom: 0; }
|
||||
.dash-sched__slot.is-done { opacity: 0.55; }
|
||||
.dash-sched__time { padding-top: 4px; }
|
||||
.dash-sched__items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.dash-sched__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px 6px 8px;
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.dash-sched__pill:hover { border-color: var(--border-strong); }
|
||||
.dash-sched__pill.is-taken { background: var(--ok-soft); border-color: transparent; }
|
||||
.dash-sched__pill.is-taken .dash-sched__name { text-decoration: line-through; color: var(--ink-muted); }
|
||||
.dash-sched__check {
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
display: grid; place-items: center;
|
||||
color: transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.dash-sched__check.is-taken { background: var(--ok); border-color: var(--ok); color: white; }
|
||||
.dash-sched__name { font-weight: 500; color: var(--ink-strong); }
|
||||
.dash-sched__dose { color: var(--ink-muted); font-size: 11px; }
|
||||
|
||||
/* Low */
|
||||
.dash-low { padding: 4px 18px 16px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.dash-low__item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.dash-low__item:last-child { border-bottom: 0; }
|
||||
|
||||
/* Spend */
|
||||
.dash-spend { padding: 12px 18px 18px; }
|
||||
|
||||
/* Supply */
|
||||
.dash-supply { padding: 4px 18px 16px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.dash-supply__row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr 48px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.dash-supply__name { font-weight: 500; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dash-supply__bar {
|
||||
height: 8px;
|
||||
background: var(--bg-inset);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.dash-supply__fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
|
||||
.dash-supply__fill--ok { background: var(--brand); }
|
||||
.dash-supply__fill--low { background: var(--warn); }
|
||||
.dash-supply__fill--critical { background: var(--danger); }
|
||||
.dash-supply__days { font-weight: 600; text-align: right; }
|
||||
.dash-supply__days span { color: var(--ink-faint); font-weight: 400; margin-left: 1px; font-size: 10px; }
|
||||
|
||||
/* Orders */
|
||||
.dash-orders { padding: 4px 18px 16px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.dash-order {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.dash-order:last-child { border-bottom: 0; }
|
||||
.dash-order__icon {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-soft-ink);
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.dash-order.is-past .dash-order__icon { background: var(--bg-inset); color: var(--ink-muted); }
|
||||
|
||||
/* Activity */
|
||||
.dash-activity { padding: 4px 18px 16px; }
|
||||
.dash-act {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.dash-act:last-child { border-bottom: 0; }
|
||||
199
docs/design/styles/other.css
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/* Other pages */
|
||||
.sub-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; }
|
||||
.form-label { display:block; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-muted); font-weight: 600; margin-bottom: 6px; }
|
||||
.form-select {
|
||||
padding: 8px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.lib-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.lib-row:last-child { border-bottom: 0; }
|
||||
.lib-row:hover { background: var(--bg-inset); }
|
||||
|
||||
.org-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 10px; }
|
||||
.org-day { padding: 14px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--r-md); }
|
||||
.org-day__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
|
||||
.org-day__slots { display: flex; flex-wrap: wrap; gap: 4px; min-height: 70px; }
|
||||
.org-pill {
|
||||
width: 14px; height: 20px; background: var(--c); border-radius: 7px;
|
||||
box-shadow: inset 0 -3px 0 rgba(0,0,0,0.1), inset 0 3px 0 rgba(255,255,255,0.2);
|
||||
display: inline-block;
|
||||
}
|
||||
.org-day__count { font-size: 10px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .06em; font-weight:600; margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); }
|
||||
|
||||
/* Organizer — fill + inventory impact */
|
||||
.org-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.3fr;
|
||||
gap: 0;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.org-hero__controls {
|
||||
padding: 20px 22px;
|
||||
background: var(--bg-inset);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.org-hero__divider { height: 1px; background: var(--border); }
|
||||
.org-hero__select {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-strong);
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
.org-hero__stepper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.org-hero__stepper button {
|
||||
width: 32px; height: 36px;
|
||||
border: none; background: transparent;
|
||||
font-size: 16px; color: var(--ink-strong);
|
||||
cursor: pointer;
|
||||
}
|
||||
.org-hero__stepper button:hover { background: var(--bg-inset); }
|
||||
.org-hero__stepper span {
|
||||
padding: 0 14px; font-size: 20px; font-weight: 600; min-width: 48px; text-align: center;
|
||||
color: var(--ink-strong);
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
}
|
||||
.org-hero__presets { display: flex; gap: 4px; margin-top: 8px; }
|
||||
.org-hero__preset {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.org-hero__preset.is-active { background: var(--ink); color: var(--bg-elev); border-color: var(--ink); }
|
||||
|
||||
.org-hero__summary {
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.org-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
.org-alert strong { font-weight: 600; color: var(--ink-strong); }
|
||||
.org-alert > div:first-of-type { flex: 1; }
|
||||
.org-alert--ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.org-alert--ok strong { color: var(--ok); }
|
||||
.org-alert--warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.org-alert--warn strong { color: var(--warn); }
|
||||
.org-alert--danger { background: var(--danger-soft); color: var(--danger); }
|
||||
.org-alert--danger strong { color: var(--danger); }
|
||||
|
||||
.org-impact { padding: 4px 18px 16px; }
|
||||
.org-impact__head, .org-impact__row {
|
||||
display: grid;
|
||||
grid-template-columns: 2.4fr 0.8fr 0.8fr 1.6fr 0.8fr;
|
||||
gap: 14px;
|
||||
padding: 10px 8px;
|
||||
font-size: 13px;
|
||||
align-items: center;
|
||||
}
|
||||
.org-impact__head {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: var(--ink-faint); font-weight: 600;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.org-impact__row { border-bottom: 1px dashed var(--border); }
|
||||
.org-impact__row:last-child { border-bottom: 0; }
|
||||
.org-impact__row--short { background: color-mix(in oklab, var(--danger) 5%, transparent); }
|
||||
.org-impact__bar {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.org-impact__barFill {
|
||||
position: absolute; top: 0; left: 0; bottom: 0;
|
||||
border-radius: 3px;
|
||||
transition: width .2s;
|
||||
}
|
||||
|
||||
.prices-table { padding: 0 18px 16px; }
|
||||
.prices-table__head, .prices-table__row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
gap: 14px;
|
||||
padding: 10px 8px;
|
||||
font-size: 13px;
|
||||
align-items: center;
|
||||
}
|
||||
.prices-table__head {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: var(--ink-faint); font-weight: 600;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.prices-table__row { border-bottom: 1px dashed var(--border); }
|
||||
.prices-table__row:last-child { border-bottom: 0; }
|
||||
.prices-table__row.is-best { background: var(--ok-soft); border-radius: 8px; margin: 2px 0; border-bottom: 0; }
|
||||
|
||||
.refill-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 0; border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.refill-row:last-child { border-bottom: 0; }
|
||||
|
||||
.act-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 0;
|
||||
position: relative;
|
||||
}
|
||||
.act-rail { width: 12px; position: relative; align-self: stretch; }
|
||||
.act-dot { position: absolute; top: 14px; left: 2px; width: 8px; height: 8px; border-radius: 50%; }
|
||||
.act-dot--consumed { background: var(--info); }
|
||||
.act-dot--added { background: var(--ok); }
|
||||
.act-dot--adjusted { background: var(--warn); }
|
||||
|
||||
.settings-row {
|
||||
display: grid; grid-template-columns: 120px 1fr auto;
|
||||
gap: 16px; align-items: center;
|
||||
padding: 10px 0; border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.settings-row:last-child { border-bottom: 0; }
|
||||
.settings-row > span:first-child { color: var(--ink-muted); font-size: 12px; }
|
||||
181
docs/design/styles/schedule.css
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/* Schedule page — slot-based */
|
||||
|
||||
/* Week bar: compact adherence heatmap */
|
||||
.sched-weekbar {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.sched-weekbar__days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
.sched-weekday {
|
||||
padding: 8px 6px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 2px 8px;
|
||||
align-items: center;
|
||||
transition: background .12s, border-color .12s;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sched-weekday:hover { background: var(--bg-inset); }
|
||||
.sched-weekday.is-active { background: var(--bg-inset); border-color: var(--border-strong); }
|
||||
.sched-weekday__label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 600;
|
||||
grid-row: 1;
|
||||
}
|
||||
.sched-weekday__num {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
grid-row: 2;
|
||||
}
|
||||
.sched-weekday__bar {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / 3;
|
||||
width: 5px;
|
||||
height: 28px;
|
||||
background: var(--bg-inset);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sched-weekday__fill {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-radius: 2px;
|
||||
transition: height .2s;
|
||||
}
|
||||
|
||||
/* Today: slot rows */
|
||||
.sched-slots {
|
||||
padding: 4px 18px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.sched-slot {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-elev);
|
||||
overflow: hidden;
|
||||
transition: border-color .12s;
|
||||
}
|
||||
.sched-slot:hover { border-color: var(--border-strong); }
|
||||
.sched-slot.is-open { border-color: var(--border-strong); }
|
||||
.sched-slot--done { background: color-mix(in oklab, var(--ok) 4%, var(--bg-elev)); }
|
||||
.sched-slot--overdue { border-color: color-mix(in oklab, var(--warn) 40%, var(--border)); }
|
||||
|
||||
.sched-slot__row {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sched-slot__time { display: flex; flex-direction: column; gap: 2px; }
|
||||
.sched-slot__hour {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1;
|
||||
}
|
||||
.sched-slot__phase {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sched-slot__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sched-slot__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.sched-slot__dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.sched-slot__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid;
|
||||
background: transparent;
|
||||
transition: all .15s;
|
||||
}
|
||||
.sched-slot__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.sched-slot__detail {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 10px 16px 14px 116px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.sched-slot__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
transition: all 0.12s;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sched-slot__item:hover { border-color: var(--border-strong); }
|
||||
.sched-slot__item.is-taken { opacity: 0.65; }
|
||||
|
||||
.sched-check {
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
display: grid; place-items: center;
|
||||
color: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sched-check.is-taken { background: var(--ok); border-color: var(--ok); color: white; }
|
||||
|
||||
.sched-regimen {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
370
docs/design/styles/shell.css
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
/* Shell: sidebar + topbar + layout */
|
||||
|
||||
.mt-app {
|
||||
display: grid;
|
||||
grid-template-columns: 248px 1fr;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.mt-side {
|
||||
background: var(--bg-elev);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
.mt-side__brand {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 18px 18px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-side__logo {
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 10px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
.mt-side__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
.mt-side__sub {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mt-side__nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 10px 16px;
|
||||
}
|
||||
.mt-side__group { margin-bottom: 14px; }
|
||||
.mt-side__section {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
padding: 10px 10px 6px;
|
||||
}
|
||||
.mt-side__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
text-align: left;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mt-side__item > span:not(.mt-side__badge) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mt-side__item { min-width: 0; }
|
||||
.mt-side__item:hover { background: var(--bg-inset); color: var(--ink); }
|
||||
.mt-side__item.is-active {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-soft-ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
.mt-side__item svg { opacity: 0.8; }
|
||||
.mt-side__item.is-active svg { opacity: 1; }
|
||||
.mt-side__soon {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-faint);
|
||||
background: var(--bg-inset);
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.mt-side__item.is-soon { opacity: 0.55; cursor: not-allowed; }
|
||||
.mt-side__item.is-soon:hover { background: transparent; color: var(--ink-muted); }
|
||||
.mt-side__badge {
|
||||
margin-left: auto;
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mt-side__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.mt-side__avatar {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--viz-4), var(--viz-3));
|
||||
color: white;
|
||||
display: grid; place-items: center;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mt-side__whoName { font-size: 13px; font-weight: 500; color: var(--ink-strong); }
|
||||
.mt-side__whoRole { font-size: 11px; color: var(--ink-muted); }
|
||||
|
||||
/* Topbar */
|
||||
.mt-main { display: flex; flex-direction: column; min-width: 0; }
|
||||
.mt-top {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
padding: 22px 32px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.mt-top__crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.mt-top__crumbs .is-current { color: var(--ink); }
|
||||
.mt-top__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink-strong);
|
||||
margin: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.mt-top__sub {
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.mt-top__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.mt-top__search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 6px 10px;
|
||||
min-width: 280px;
|
||||
color: var(--ink-muted);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.mt-top__search:focus-within { border-color: var(--brand); }
|
||||
.mt-top__search input {
|
||||
border: 0; outline: 0; background: transparent;
|
||||
font-size: 13px; flex: 1; color: var(--ink);
|
||||
}
|
||||
.mt-top__search input::placeholder { color: var(--ink-faint); }
|
||||
.mt-top__search kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 2px 5px;
|
||||
background: var(--bg-inset);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.mt-iconbtn {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: var(--r-sm);
|
||||
display: grid; place-items: center;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink-muted);
|
||||
position: relative;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.mt-iconbtn:hover { color: var(--ink); border-color: var(--border-strong); }
|
||||
.mt-iconbtn__dot {
|
||||
position: absolute;
|
||||
top: 6px; right: 6px;
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
border: 1.5px solid var(--bg-elev);
|
||||
}
|
||||
|
||||
/* Page container */
|
||||
.mt-page {
|
||||
padding: 28px 32px 56px;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.mt-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.12s;
|
||||
border: 1px solid transparent;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mt-btn--primary {
|
||||
background: var(--brand);
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
.mt-btn--primary:hover { background: var(--brand-deep); }
|
||||
.mt-btn--ghost {
|
||||
background: var(--bg-elev);
|
||||
color: var(--ink);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.mt-btn--ghost:hover { border-color: var(--border-strong); background: var(--bg-inset); }
|
||||
.mt-btn--subtle {
|
||||
color: var(--ink-muted);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.mt-btn--subtle:hover { color: var(--ink); background: var(--bg-inset); }
|
||||
.mt-btn--danger {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger-soft);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.mt-btn--danger:hover { background: var(--danger-soft); }
|
||||
|
||||
/* Card */
|
||||
.mt-card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.mt-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-card__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-strong);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.mt-card__sub {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mt-card__body { padding: 16px 18px; }
|
||||
|
||||
/* Pill / badge */
|
||||
.mt-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0.01em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.mt-pill--brand { background: var(--brand-soft); color: var(--brand-soft-ink); }
|
||||
.mt-pill--ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.mt-pill--warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.mt-pill--danger { background: var(--danger-soft); color: var(--danger); }
|
||||
.mt-pill--info { background: var(--info-soft); color: var(--info); }
|
||||
.mt-pill--ghost { background: var(--bg-inset); color: var(--ink-muted); }
|
||||
.mt-pill--outline { border-color: var(--border); color: var(--ink-muted); }
|
||||
|
||||
/* Utility */
|
||||
.mt-row { display: flex; gap: 12px; align-items: center; }
|
||||
.mt-col { display: flex; flex-direction: column; gap: 12px; }
|
||||
.mt-grid { display: grid; gap: 12px; }
|
||||
.mt-divider { height: 1px; background: var(--border); margin: 12px 0; }
|
||||
.mt-stack > * + * { margin-top: 12px; }
|
||||
.mt-mono { font-family: var(--font-mono); font-size: 12px; }
|
||||
|
||||
/* Tweaks panel */
|
||||
.mt-tweaks {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 100;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 14px;
|
||||
min-width: 240px;
|
||||
display: none;
|
||||
}
|
||||
.mt-tweaks.is-open { display: block; }
|
||||
.mt-tweaks__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.mt-tweaks__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
.mt-tweaks__row:first-of-type { border-top: 0; }
|
||||
.mt-seg {
|
||||
display: inline-flex;
|
||||
background: var(--bg-inset);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
gap: 0;
|
||||
}
|
||||
.mt-seg button {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.mt-seg button.is-active {
|
||||
background: var(--bg-elev);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
123
docs/design/styles/tokens.css
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/* MeshiTrack design tokens */
|
||||
|
||||
:root {
|
||||
/* Type */
|
||||
--font-sans: 'Inter Tight', -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
||||
--font-display: 'Fraunces', 'Inter Tight', serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
|
||||
|
||||
/* Neutrals (warm off-white → near-black) */
|
||||
--bg: #f6f4ef;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-inset: #efebe3;
|
||||
--border: #e4dfd4;
|
||||
--border-strong: #cfc8b8;
|
||||
--ink-faint: #a39b89;
|
||||
--ink-muted: #6e6759;
|
||||
--ink: #1d1b17;
|
||||
--ink-strong: #0b0a08;
|
||||
|
||||
/* Brand — refined sage-leaning green (was straight green) */
|
||||
--brand: #2f6b4a;
|
||||
--brand-deep: #1e4a32;
|
||||
--brand-ink: #ffffff;
|
||||
--brand-soft: #e6efe8;
|
||||
--brand-soft-ink: #1e4a32;
|
||||
|
||||
/* Status */
|
||||
--danger: #b8361a;
|
||||
--danger-soft: #fbe8e0;
|
||||
--warn: #a66a0a;
|
||||
--warn-soft: #f9ecd2;
|
||||
--ok: #3e7a4c;
|
||||
--ok-soft: #e3ede0;
|
||||
--info: #3a5a85;
|
||||
--info-soft: #e1e8f1;
|
||||
|
||||
/* Data viz (cabinet categories / charts) */
|
||||
--viz-1: #2f6b4a;
|
||||
--viz-2: #a66a0a;
|
||||
--viz-3: #3a5a85;
|
||||
--viz-4: #8a4c6e;
|
||||
--viz-5: #6b6237;
|
||||
--viz-6: #7a3a28;
|
||||
|
||||
/* Shape */
|
||||
--r-xs: 6px;
|
||||
--r-sm: 10px;
|
||||
--r-md: 14px;
|
||||
--r-lg: 20px;
|
||||
--r-xl: 28px;
|
||||
|
||||
/* Shadow */
|
||||
--shadow-sm: 0 1px 2px rgba(20, 18, 10, 0.04), 0 0 0 1px rgba(20, 18, 10, 0.04);
|
||||
--shadow-md: 0 4px 16px -6px rgba(20, 18, 10, 0.08), 0 0 0 1px rgba(20, 18, 10, 0.05);
|
||||
--shadow-lg: 0 20px 40px -20px rgba(20, 18, 10, 0.22), 0 0 0 1px rgba(20, 18, 10, 0.06);
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--bg: #141310;
|
||||
--bg-elev: #1c1b17;
|
||||
--bg-inset: #100f0c;
|
||||
--border: #2a2823;
|
||||
--border-strong: #3a372f;
|
||||
--ink-faint: #6a6559;
|
||||
--ink-muted: #9a9386;
|
||||
--ink: #ece8de;
|
||||
--ink-strong: #f8f5ec;
|
||||
|
||||
--brand: #5fa87b;
|
||||
--brand-deep: #8fc9a4;
|
||||
--brand-ink: #0b0a08;
|
||||
--brand-soft: #1e2c23;
|
||||
--brand-soft-ink: #8fc9a4;
|
||||
|
||||
--danger: #e37358;
|
||||
--danger-soft: #2c1a15;
|
||||
--warn: #d6a152;
|
||||
--warn-soft: #2c2217;
|
||||
--ok: #7aba89;
|
||||
--ok-soft: #1a2820;
|
||||
--info: #7ea3cf;
|
||||
--info-soft: #1a2028;
|
||||
|
||||
--viz-1: #5fa87b;
|
||||
--viz-2: #d6a152;
|
||||
--viz-3: #7ea3cf;
|
||||
--viz-4: #c088a5;
|
||||
--viz-5: #b8ac76;
|
||||
--viz-6: #d88a74;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
--shadow-md: 0 4px 16px -6px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
--shadow-lg: 0 20px 40px -20px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: 'ss01', 'cv11';
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
letter-spacing: -0.005em;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
button { font: inherit; color: inherit; cursor: pointer; border: 0; background: none; padding: 0; }
|
||||
input, select, textarea { font: inherit; color: inherit; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
::selection { background: var(--brand); color: var(--brand-ink); }
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 10px; border: 2px solid var(--bg); }
|
||||
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.serif { font-family: var(--font-display); }
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
|
|
@ -472,7 +472,7 @@ All packages enforce coverage thresholds via `vitest.config.ts`. CI will fail if
|
|||
| ----------------- | ----- | --------- | -------- | ---------- |
|
||||
| `packages/api` | 100% | 100% | 90% | 100% |
|
||||
| `packages/shared` | 100% | 100% | 90% | 100% |
|
||||
| `packages/web` | TBD | TBD | TBD | TBD |
|
||||
| `packages/web` | 15% | 14% | 13% | 15% |
|
||||
|
||||
### Coverage Provider
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,30 @@
|
|||
# Phase 4 — Pharmacies, Prices & Refills
|
||||
# Phase 4 — Pharmacies, Prices, Purchases & Refills
|
||||
|
||||
**Goal**: Track where you buy medicines, compare prices across pharmacies, and get automatic refill alerts when cabinet stock is running low. The Store and PriceRecord infrastructure built here is shared with food tracking (Phase 9).
|
||||
**Goal**: Track store catalog prices for medicines, record purchases (which may mix medicines and food), and get automatic refill alerts when cabinet stock is running low. The Store and PriceRecord infrastructure built here is shared with food tracking (Phase 9).
|
||||
|
||||
**Depends on**: Phase 0, Phase 1 (medicines), Phase 2 (cabinet), Phase 3 (regimens — for burn rate)
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
## Domain Model
|
||||
|
||||
1. `Store` MongoDB schema and CRUD API (shared infrastructure)
|
||||
2. `PriceRecord` schema for medicine price tracking
|
||||
3. Price history and store comparison
|
||||
4. Refill alerts based on burn rate
|
||||
5. Refill list generation
|
||||
6. Web UI: stores, price history, refill management
|
||||
### Price Records vs Purchases
|
||||
|
||||
These are two distinct concepts:
|
||||
|
||||
- **Price Record** — a catalog observation: "Product X at Store Y is listed at $Z for a pack of N units." Nothing moves. Multiple records can exist for the same product+store (different pack sizes, bulk deals, insurance tier, etc.). This is price surveillance.
|
||||
- **Purchase** — recording that you actually acquired something. Can contain multiple line items mixing medicines and (Phase 9) food products from the same store.
|
||||
|
||||
### Purchase Status
|
||||
|
||||
Most purchases happen at a physical store and items are immediately in hand. Online purchases may take time to arrive. The status reflects this:
|
||||
|
||||
- **`in_cabinet`** — the default for physical store purchases. Items are available immediately and are added to the cabinet right away.
|
||||
- **`ordered`** — used only for online purchases where the items have not yet arrived. Items in this state are counted toward pending stock in refill alerts (so the user is not repeatedly alerted to reorder something already purchased and on its way). Once items arrive, the purchase is moved to `in_cabinet` and items are added to the cabinet.
|
||||
|
||||
For food (Phase 9), purchases have no intermediate state — food items always go directly to pantry/fridge upon recording.
|
||||
|
||||
Refill alerts must account for both cabinet stock and purchases with status `ordered` so the alert reflects actual available stock.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -28,13 +39,10 @@ export interface Store {
|
|||
householdId: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
location?: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
location?: { lat: number; lng: number };
|
||||
url?: string;
|
||||
notes?: string;
|
||||
tags: string[]; // e.g., 'pharmacy', 'grocery', 'online', 'bulk', 'discount'
|
||||
tags: string[]; // e.g. 'pharmacy', 'grocery', 'online', 'bulk', 'discount'
|
||||
isActive: boolean;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
|
|
@ -49,25 +57,62 @@ export interface Store {
|
|||
export interface MedicinePriceRecord {
|
||||
id: string;
|
||||
householdId: string;
|
||||
medicineProductId: string; // Reference to MedicineProduct (purchasable level)
|
||||
medicineProductBrand: string; // Denormalized
|
||||
medicineId: string; // Reference to Medicine (generic level, for cross-brand comparison)
|
||||
medicineName: string; // Denormalized
|
||||
medicineProductId: string; // specific brand/package
|
||||
medicineProductBrand: string; // denormalized
|
||||
medicineId: string; // generic medicine, for cross-brand comparison
|
||||
medicineName: string; // denormalized
|
||||
storeId: string;
|
||||
storeName: string; // Denormalized
|
||||
storeName: string; // denormalized
|
||||
price: number;
|
||||
currency: string; // Default from household settings
|
||||
quantity: number; // How many pills/units for this price (package size)
|
||||
currency: string;
|
||||
quantity: number; // units in this price point (package size)
|
||||
unit: DosageUnit;
|
||||
pricePerUnit: number; // Computed: price / quantity
|
||||
date: Date;
|
||||
isInsurancePrice: boolean; // With insurance vs retail
|
||||
notes?: string;
|
||||
pricePerUnit: number; // computed: price / quantity
|
||||
isInsurancePrice: boolean;
|
||||
notes?: string; // e.g. "bulk deal", "fast shipping tier"
|
||||
date: Date; // when price was observed
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### Purchase Schema
|
||||
|
||||
```typescript
|
||||
// packages/shared/src/types/purchase.ts
|
||||
export type PurchaseStatus = 'ordered' | 'in_cabinet';
|
||||
|
||||
export interface Purchase {
|
||||
id: string;
|
||||
householdId: string;
|
||||
storeId: string;
|
||||
storeName: string; // denormalized
|
||||
status: PurchaseStatus; // 'in_cabinet' for physical; 'ordered' for online until received
|
||||
items: PurchaseItem[];
|
||||
notes?: string;
|
||||
purchasedAt: Date; // when the purchase was made (or order placed)
|
||||
receivedAt?: Date; // set when status moves to in_cabinet for online orders
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface PurchaseItem {
|
||||
id: string; // stable item id within the purchase
|
||||
// exactly one of these is set:
|
||||
medicineProductId?: string;
|
||||
foodProductId?: string; // reserved for Phase 9
|
||||
// denormalized display info:
|
||||
name: string; // brand name / food product name
|
||||
quantity: number; // units in package
|
||||
unit: string;
|
||||
actualPrice?: number; // what was actually paid (optional)
|
||||
currency?: string;
|
||||
priceRecordId?: string; // optionally links to a PriceRecord used as reference
|
||||
addedToCabinet: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### RefillAlert (Computed, not stored)
|
||||
|
||||
```typescript
|
||||
|
|
@ -77,18 +122,20 @@ export interface RefillAlert {
|
|||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
medicineStrengthUnit: StrengthUnit;
|
||||
daysUntilEmpty: number;
|
||||
daysUntilEmpty: number; // based on cabinet stock only
|
||||
daysUntilEmptyWithOrders: number; // cabinet + pending ordered items
|
||||
dailyConsumption: number;
|
||||
currentStock: number;
|
||||
suggestedQuantity: number; // Enough for N days (configurable, default 30)
|
||||
lastKnownPrice?: {
|
||||
pendingOrderStock: number; // units in orders with status 'ordered'
|
||||
suggestedQuantity: number; // enough for N days (configurable, default 30)
|
||||
cheapestOption?: {
|
||||
price: number;
|
||||
pricePerUnit: number;
|
||||
storeName: string;
|
||||
storeId: string;
|
||||
date: Date;
|
||||
};
|
||||
cheapestOption?: {
|
||||
lastKnownPrice?: {
|
||||
price: number;
|
||||
pricePerUnit: number;
|
||||
storeName: string;
|
||||
|
|
@ -142,9 +189,14 @@ export enum RefillListStatus {
|
|||
|
||||
// MedicinePriceRecord
|
||||
{ householdId: 1, medicineProductId: 1, storeId: 1, date: -1 }
|
||||
{ householdId: 1, medicineId: 1, date: -1 } // Cross-brand comparison
|
||||
{ householdId: 1, medicineId: 1, date: -1 }
|
||||
{ householdId: 1, storeId: 1, date: -1 }
|
||||
|
||||
// Purchase
|
||||
{ householdId: 1, status: 1, purchasedAt: -1 }
|
||||
{ householdId: 1, storeId: 1 }
|
||||
{ householdId: 1, 'items.medicineProductId': 1 }
|
||||
|
||||
// RefillList
|
||||
{ householdId: 1, status: 1 }
|
||||
{ householdId: 1, createdAt: -1 }
|
||||
|
|
@ -166,42 +218,55 @@ export enum RefillListStatus {
|
|||
|
||||
### MedicinePricesModule
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
| ------ | -------------------------------------- | --------------------------------- | ------ |
|
||||
| POST | `/medicine-prices` | Record a price | member |
|
||||
| GET | `/medicine-prices/history/:medicineId` | Price history for a medicine | member |
|
||||
| GET | `/medicine-prices/compare/:medicineId` | Compare stores for a medicine | member |
|
||||
| GET | `/medicine-prices/analytics` | Spending analytics | member |
|
||||
| Method | Path | Description | Auth |
|
||||
| ------ | -------------------------------------- | --------------------------------------- | ------ |
|
||||
| POST | `/medicine-prices` | Record a catalog price observation | member |
|
||||
| GET | `/medicine-prices/history/:medicineId` | Price history for a medicine | member |
|
||||
| GET | `/medicine-prices/compare/:medicineId` | Compare stores for a medicine | member |
|
||||
| GET | `/medicine-prices/analytics` | Price trend analytics | member |
|
||||
|
||||
### PurchasesModule
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
| ------ | ------------------------------- | --------------------------------------------------------- | ------ |
|
||||
| POST | `/purchases` | Record a purchase (physical → immediately in_cabinet; online → ordered) | member |
|
||||
| GET | `/purchases` | List purchases (filterable by status) | member |
|
||||
| GET | `/purchases/:id` | Get purchase detail | member |
|
||||
| PATCH | `/purchases/:id` | Update purchase (notes, items) | member |
|
||||
| POST | `/purchases/:id/receive` | Mark online purchase received — moves items to cabinet | member |
|
||||
| DELETE | `/purchases/:id` | Delete purchase (only if status is ordered) | member |
|
||||
|
||||
### RefillsModule
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
| ------ | ----------------------------------- | ---------------------------------------- | ------ |
|
||||
| GET | `/refills/alerts` | Get refill alerts (medicines running low)| member |
|
||||
| POST | `/refills/lists` | Create refill list (manual or from alerts)| member |
|
||||
| GET | `/refills/lists` | List refill lists | member |
|
||||
| GET | `/refills/lists/:id` | Get refill list | member |
|
||||
| PATCH | `/refills/lists/:id` | Update refill list | member |
|
||||
| PATCH | `/refills/lists/:id/items/:itemId` | Check off / update item | member |
|
||||
| POST | `/refills/lists/:id/add-to-cabinet` | Move checked items to cabinet | member |
|
||||
| GET | `/refills/lists/:id/store-comparison`| Best store for this list | member |
|
||||
| Method | Path | Description | Auth |
|
||||
| ------ | ---------------------------------- | ----------------------------------------- | ------ |
|
||||
| GET | `/refills/alerts` | Get refill alerts (accounts for pending orders) | member |
|
||||
| POST | `/refills/lists` | Create refill list (manual or from alerts)| member |
|
||||
| GET | `/refills/lists` | List refill lists | member |
|
||||
| GET | `/refills/lists/:id` | Get refill list | member |
|
||||
| PATCH | `/refills/lists/:id` | Update refill list | member |
|
||||
| PATCH | `/refills/lists/:id/items/:itemId` | Check off / update item | member |
|
||||
|
||||
### Query Parameters
|
||||
|
||||
```
|
||||
# GET /stores
|
||||
?tags=pharmacy # Filter by tags
|
||||
&search=walgreens # Name search
|
||||
?tags=pharmacy
|
||||
&search=walgreens
|
||||
|
||||
# GET /medicine-prices/history/:medicineId
|
||||
?storeId=abc123 # Filter by store
|
||||
&startDate=2026-01-01 # Date range
|
||||
?storeId=abc123
|
||||
&startDate=2026-01-01
|
||||
&endDate=2026-03-27
|
||||
&limit=50
|
||||
|
||||
# GET /purchases
|
||||
?status=ordered
|
||||
&storeId=abc123
|
||||
|
||||
# GET /refills/alerts
|
||||
?thresholdDays=7 # Alert when <= N days of stock remain (default: 7)
|
||||
&userId=abc123 # Filter by user's regimens
|
||||
?thresholdDays=7
|
||||
&userId=abc123
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -210,9 +275,10 @@ export enum RefillListStatus {
|
|||
|
||||
### 4.1 — Shared Types & Validation
|
||||
|
||||
- Add store types to `packages/shared/src/types/store.ts`
|
||||
- Add medicine price types to `packages/shared/src/types/medicine-price.ts`
|
||||
- Add refill types to `packages/shared/src/types/refill.ts`
|
||||
- Store types in `packages/shared/src/types/store.ts`
|
||||
- Medicine price types in `packages/shared/src/types/medicine-price.ts`
|
||||
- Order types in `packages/shared/src/types/order.ts`
|
||||
- Refill types in `packages/shared/src/types/refill.ts`
|
||||
- Zod schemas for all create/update operations
|
||||
|
||||
### 4.2 — Stores CRUD (Shared Infrastructure)
|
||||
|
|
@ -220,89 +286,85 @@ export enum RefillListStatus {
|
|||
- `packages/api/src/modules/stores/`
|
||||
- Standard CRUD, scoped to `householdId`
|
||||
- Tag-based filtering (pharmacy, grocery, online, etc.)
|
||||
- This module is used by both medicine and food domains
|
||||
- Reused by both medicine and food domains
|
||||
|
||||
### 4.3 — Medicine Price Service
|
||||
|
||||
Records catalog price observations. Multiple records per product+store are allowed (different pack sizes, bulk tiers, insurance pricing).
|
||||
|
||||
```typescript
|
||||
class MedicinePriceService {
|
||||
/** Record a price, computing pricePerUnit */
|
||||
recordPrice(data: CreateMedicinePriceRecord): Promise<MedicinePriceRecord>;
|
||||
|
||||
/** Get price history for a medicine, optionally filtered by store */
|
||||
getPriceHistory(medicineId: string, householdId: string, options?: {
|
||||
storeId?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
limit?: number;
|
||||
}): Promise<MedicinePriceRecord[]>;
|
||||
|
||||
/** Compare current prices across stores for a medicine */
|
||||
recordPrice(data: CreateMedicinePriceRecord, householdId: string): Promise<MedicinePriceRecord>;
|
||||
getPriceHistory(medicineId: string, householdId: string, options?: { storeId?: string; startDate?: Date; endDate?: Date; limit?: number }): Promise<MedicinePriceRecord[]>;
|
||||
compareStores(medicineId: string, householdId: string): Promise<StoreComparison[]>;
|
||||
|
||||
/** Estimate price based on most recent record */
|
||||
estimatePrice(medicineId: string, householdId: string, storeId?: string): Promise<number | null>;
|
||||
|
||||
/** Spending analytics over time */
|
||||
getAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise<MedicineSpendingAnalytics>;
|
||||
getPriceAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise<PriceAnalytics>;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 — Refill Alert Service
|
||||
Analytics on this module are **price trends**, not spending — they show how prices change over time, not purchases made.
|
||||
|
||||
### 4.4 — Purchases Module
|
||||
|
||||
A purchase records that items were actually acquired. A single purchase can contain any mix of medicine products and (Phase 9) food products from the same store.
|
||||
|
||||
**Record physical purchase** (default flow — store visit):
|
||||
- Set `status: 'in_cabinet'` at creation time
|
||||
- Items are added to cabinet immediately as part of the create call
|
||||
- No separate receive step needed
|
||||
|
||||
**Record online purchase**:
|
||||
- Set `status: 'ordered'` at creation time
|
||||
- Items are NOT added to cabinet yet
|
||||
- Items in `ordered` purchases are counted as pending stock in refill alerts
|
||||
- When items arrive, call `POST /purchases/:id/receive` to add to cabinet
|
||||
|
||||
**Receive flow** (`POST /purchases/:id/receive`):
|
||||
1. For each item with `medicineProductId` and `addedToCabinet: false`:
|
||||
- Create a `CabinetItem` (status: active, purchaseDate: today)
|
||||
- If `actualPrice` was recorded on the item, create a `MedicinePriceRecord`
|
||||
- Mark `addedToCabinet: true`
|
||||
2. Set purchase status to `in_cabinet`
|
||||
3. Return summary: `{ addedCount, priceRecordsCreated }`
|
||||
|
||||
### 4.5 — Refill Alert Service
|
||||
|
||||
Refill alerts account for both current cabinet stock and pending orders (status `ordered`) so users are not prompted to reorder medicine already on its way.
|
||||
|
||||
```typescript
|
||||
class RefillAlertService {
|
||||
/**
|
||||
* For each medicine in the user's active regimens:
|
||||
* 1. Get burn rate from BurnRateService (Phase 3)
|
||||
* 2. If daysUntilEmpty <= thresholdDays, create alert
|
||||
* 3. Attach last known price + cheapest store option
|
||||
* 4. Calculate suggested quantity (enough for configurable days, default 30)
|
||||
* 2. Sum cabinet stock + units in purchases with status 'ordered'
|
||||
* 3. If daysUntilEmpty (cabinet only) <= thresholdDays, create alert
|
||||
* 4. Attach pending purchase stock, cheapest price option, last known price
|
||||
* 5. Calculate suggested quantity (enough for configurable days, default 30)
|
||||
*/
|
||||
getAlerts(householdId: string, userId: string, thresholdDays?: number): Promise<RefillAlert[]>;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 — Refill Lists
|
||||
### 4.6 — Refill Lists
|
||||
|
||||
- CRUD for refill lists
|
||||
- `POST /refills/lists` with optional `fromAlerts: true` to auto-populate from current alerts
|
||||
- Each item can have an estimated price (from price history) and an actual price (entered when purchased)
|
||||
- Store comparison: for each item, find cheapest store based on recent price records
|
||||
|
||||
### 4.6 — Refill to Cabinet Flow
|
||||
|
||||
- `POST /refills/lists/:id/add-to-cabinet`:
|
||||
- For each checked item with `addedToCabinet: false`:
|
||||
- Create a `CabinetItem` (status: active, purchaseDate: today)
|
||||
- If `actualPrice` was entered, create a `MedicinePriceRecord`
|
||||
- Mark `addedToCabinet: true`
|
||||
- Return summary: `{ addedCount, priceRecordsCreated }`
|
||||
- Each item can carry an estimated price (from price history) and an actual price (entered when purchased)
|
||||
- Refill lists are planning aids — actual cabinet addition goes through Purchases
|
||||
|
||||
### 4.7 — Price Analytics
|
||||
|
||||
```typescript
|
||||
interface MedicineSpendingAnalytics {
|
||||
/** Total spending per period */
|
||||
spendingOverTime: { period: string; total: number }[];
|
||||
Analytics on the medicine-prices page show **price trends across stores over time**, not spending:
|
||||
|
||||
/** Most expensive medicines */
|
||||
topBySpending: {
|
||||
```typescript
|
||||
interface PriceAnalytics {
|
||||
priceOverTime: { period: string; avgPricePerUnit: number; storeName: string }[];
|
||||
cheapestByMedicine: {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
totalSpent: number;
|
||||
avgPricePerUnit: number;
|
||||
}[];
|
||||
|
||||
/** Per-store spending */
|
||||
spendingByStore: {
|
||||
storeId: string;
|
||||
cheapestPricePerUnit: number;
|
||||
storeName: string;
|
||||
totalSpent: number;
|
||||
purchaseCount: number;
|
||||
}[];
|
||||
|
||||
/** Price trend alerts (significant increases) */
|
||||
priceAlerts: {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
|
|
@ -314,49 +376,44 @@ interface MedicineSpendingAnalytics {
|
|||
}
|
||||
```
|
||||
|
||||
### 4.8 — Web UI: Pharmacies & Prices
|
||||
Spending analytics (total money spent) live on the Orders page, computed from order items with `actualPrice`.
|
||||
|
||||
- `/stores` page:
|
||||
- Store list with CRUD
|
||||
- Filter by tags (pharmacy, grocery, etc.)
|
||||
- Per-store summary: total spent, last visit, item count
|
||||
### 4.8 — Web UI: Stores & Prices
|
||||
|
||||
- `/stores` page: store list with CRUD, filter by tags
|
||||
- `/medicine-prices` page:
|
||||
- Medicine search -> price history line chart (per store, color-coded)
|
||||
- Record a price: store → medicine → product → price + quantity + currency + insurance flag + notes
|
||||
- Price history per medicine: filterable by store, shows price-per-unit over time
|
||||
- Store comparison table for selected medicine
|
||||
- Spending over time bar chart
|
||||
- Price alert panel
|
||||
- Price trend analytics (price changes over time, not spending)
|
||||
|
||||
### 4.9 — Web UI: Refill Management
|
||||
### 4.9 — Web UI: Purchases
|
||||
|
||||
- `/purchases` page:
|
||||
- List of purchases grouped by status (ordered / in cabinet)
|
||||
- Record purchase: pick store, choose physical or online, add line items (medicine products)
|
||||
- Physical purchases: items go to cabinet immediately on save
|
||||
- Online purchases: sit in `ordered` state until received; "Mark as received" bulk-adds to cabinet
|
||||
- Shows actual price paid per item (used to auto-create price records on receive)
|
||||
|
||||
### 4.10 — Web UI: Refill Management
|
||||
|
||||
- `/refills` page:
|
||||
- **Alerts section**: medicines running low
|
||||
- Card per medicine: name, days remaining, suggested quantity, cheapest store
|
||||
- "Generate Refill List" button -> creates list from all alerts
|
||||
- **Refill lists section**:
|
||||
- Active lists at top, completed/archived below
|
||||
- List detail view:
|
||||
- Items with checkbox, medicine name, quantity, estimated price
|
||||
- Check off: optionally enter actual price
|
||||
- Store comparison panel
|
||||
- "Done Shopping" -> prompts "Add items to cabinet?"
|
||||
- **Dashboard widget**: refill alert count badge, medicines needing refill soon
|
||||
- **Alerts section**: medicines running low, with `daysUntilEmpty` and `daysUntilEmptyWithOrders` shown separately so the user can see how much the pending order helps
|
||||
- **Refill lists section**: planning lists with check-off and estimated prices
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Can create and manage stores with tags
|
||||
- [ ] Can record medicine prices and view price history
|
||||
- [ ] Store comparison shows cheapest option per medicine
|
||||
- [ ] Refill alerts correctly identify medicines running low based on burn rate
|
||||
- [ ] Refill lists can be auto-generated from alerts
|
||||
- [ ] Checked refill items can be added to cabinet in one action
|
||||
- [ ] Price analytics show spending trends
|
||||
- [ ] Can record catalog price observations; multiple price points per product+store allowed
|
||||
- [ ] Store comparison shows cheapest observed price per medicine
|
||||
- [ ] Can record a purchase (physical or online) mixing medicine and (future) food items
|
||||
- [ ] Physical purchases add medicine items to cabinet immediately
|
||||
- [ ] Online purchases sit in ordered state; receiving them adds items to cabinet and optionally records prices
|
||||
- [ ] Refill alerts correctly factor in pending purchases (status: ordered) when computing days-until-empty
|
||||
- [ ] Price analytics show price trends, not spending
|
||||
- [ ] Spending analytics live on the Orders page
|
||||
- [ ] Store infrastructure is reusable for food tracking (Phase 9)
|
||||
- [ ] All queries scoped to `householdId`
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
Medium-large. Store/price infrastructure, refill alert logic, and the refill-to-cabinet flow involve significant work. The store comparison and analytics add moderate complexity.
|
||||
|
|
|
|||