Merge branch 'phase3/feature-uis'
Phase 3b: medication/health/interactions feature UIs on the Vite dashboard.
This commit is contained in:
commit
71add3fe92
7 changed files with 886 additions and 60 deletions
79
web/normogen-web/package-lock.json
generated
79
web/normogen-web/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^7.3.11",
|
||||||
"@mui/material": "^7.3.9",
|
"@mui/material": "^7.3.9",
|
||||||
"@mui/x-charts": "^8.27.4",
|
"@mui/x-charts": "^8.27.4",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
|
@ -1127,26 +1128,52 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/core-downloads-tracker": {
|
"node_modules/@mui/core-downloads-tracker": {
|
||||||
"version": "7.3.9",
|
"version": "7.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.11.tgz",
|
||||||
"integrity": "sha512-MOkOCTfbMJwLshlBCKJ59V2F/uaLYfmKnN76kksj6jlGUVdI25A9Hzs08m+zjBRdLv+sK7Rqdsefe8X7h/6PCw==",
|
"integrity": "sha512-a7I/b/nBTdXYz2cOSlEmkQ9WWE1x8FHpqMhFPp+Y1VPFxcOw91G5ELOHARQAGSPy5V+UCgJua6K/1x70bAtQPw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/mui-org"
|
"url": "https://opencollective.com/mui-org"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mui/icons-material": {
|
||||||
|
"version": "7.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.11.tgz",
|
||||||
|
"integrity": "sha512-+hz5ilwHZ3djd5es3sCErLioqe/NhZcYTsV/TNXZAMdJdb23F4xzJjqnnZdnurc3S1+ietcssRNqieOhPQLZ7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mui-org"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mui/material": "^7.3.11",
|
||||||
|
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mui/material": {
|
"node_modules/@mui/material": {
|
||||||
"version": "7.3.9",
|
"version": "7.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.11.tgz",
|
||||||
"integrity": "sha512-I8yO3t4T0y7bvDiR1qhIN6iBWZOTBfVOnmLlM7K6h3dx5YX2a7rnkuXzc2UkZaqhxY9NgTnEbdPlokR1RxCNRQ==",
|
"integrity": "sha512-yq8bPc3LxOwKRWpcjRgDkYFmpM6aKlARfESTmOQcvLYFeJwtHte2tw6hJDrb8sk8wcvpDprHEHVaoUU0MslIkw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.6",
|
"@babel/runtime": "^7.28.6",
|
||||||
"@mui/core-downloads-tracker": "^7.3.9",
|
"@mui/core-downloads-tracker": "^7.3.11",
|
||||||
"@mui/system": "^7.3.9",
|
"@mui/system": "^7.3.11",
|
||||||
"@mui/types": "^7.4.12",
|
"@mui/types": "^7.4.12",
|
||||||
"@mui/utils": "^7.3.9",
|
"@mui/utils": "^7.3.11",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@types/react-transition-group": "^4.4.12",
|
"@types/react-transition-group": "^4.4.12",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -1165,7 +1192,7 @@
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@emotion/react": "^11.5.0",
|
"@emotion/react": "^11.5.0",
|
||||||
"@emotion/styled": "^11.3.0",
|
"@emotion/styled": "^11.3.0",
|
||||||
"@mui/material-pigment-css": "^7.3.9",
|
"@mui/material-pigment-css": "^7.3.11",
|
||||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
|
@ -1192,13 +1219,13 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@mui/private-theming": {
|
"node_modules/@mui/private-theming": {
|
||||||
"version": "7.3.9",
|
"version": "7.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz",
|
||||||
"integrity": "sha512-ErIyRQvsiQEq7Yvcvfw9UDHngaqjMy9P3JDPnRAaKG5qhpl2C4tX/W1S4zJvpu+feihmZJStjIyvnv6KDbIrlw==",
|
"integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.6",
|
"@babel/runtime": "^7.28.6",
|
||||||
"@mui/utils": "^7.3.9",
|
"@mui/utils": "^7.3.11",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -1219,9 +1246,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/styled-engine": {
|
"node_modules/@mui/styled-engine": {
|
||||||
"version": "7.3.9",
|
"version": "7.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz",
|
||||||
"integrity": "sha512-JqujWt5bX4okjUPGpVof/7pvgClqh7HvIbsIBIOOlCh2u3wG/Bwp4+E1bc1dXSwkrkp9WUAoNdI5HEC+5HKvMw==",
|
"integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.6",
|
"@babel/runtime": "^7.28.6",
|
||||||
|
|
@ -1253,16 +1280,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/system": {
|
"node_modules/@mui/system": {
|
||||||
"version": "7.3.9",
|
"version": "7.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz",
|
||||||
"integrity": "sha512-aL1q9am8XpRrSabv9qWf5RHhJICJql34wnrc1nz0MuOglPRYF/liN+c8VqZdTvUn9qg+ZjRVbKf4sJVFfIDtmg==",
|
"integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.6",
|
"@babel/runtime": "^7.28.6",
|
||||||
"@mui/private-theming": "^7.3.9",
|
"@mui/private-theming": "^7.3.11",
|
||||||
"@mui/styled-engine": "^7.3.9",
|
"@mui/styled-engine": "^7.3.10",
|
||||||
"@mui/types": "^7.4.12",
|
"@mui/types": "^7.4.12",
|
||||||
"@mui/utils": "^7.3.9",
|
"@mui/utils": "^7.3.11",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"csstype": "^3.2.3",
|
"csstype": "^3.2.3",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
|
|
@ -1310,9 +1337,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/utils": {
|
"node_modules/@mui/utils": {
|
||||||
"version": "7.3.9",
|
"version": "7.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
|
||||||
"integrity": "sha512-U6SdZaGbfb65fqTsH3V5oJdFj9uYwyLE2WVuNvmbggTSDBb8QHrFsqY8BN3taK9t3yJ8/BPHD/kNvLNyjwM7Yw==",
|
"integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.6",
|
"@babel/runtime": "^7.28.6",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^7.3.11",
|
||||||
"@mui/material": "^7.3.9",
|
"@mui/material": "^7.3.9",
|
||||||
"@mui/x-charts": "^8.27.4",
|
"@mui/x-charts": "^8.27.4",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
|
|
||||||
17
web/normogen-web/src/components/common/SeverityChip.tsx
Normal file
17
web/normogen-web/src/components/common/SeverityChip.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Chip } from '@mui/material';
|
||||||
|
import { InteractionSeverity } from '../../types/api';
|
||||||
|
|
||||||
|
const CONFIG: Record<
|
||||||
|
InteractionSeverity,
|
||||||
|
{ color: 'error' | 'warning' | 'success' | 'default'; label: string }
|
||||||
|
> = {
|
||||||
|
[InteractionSeverity.Severe]: { color: 'error', label: 'Severe' },
|
||||||
|
[InteractionSeverity.Moderate]: { color: 'warning', label: 'Moderate' },
|
||||||
|
[InteractionSeverity.Mild]: { color: 'success', label: 'Mild' },
|
||||||
|
[InteractionSeverity.Unknown]: { color: 'default', label: 'Unknown' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SeverityChip = ({ severity }: { severity: InteractionSeverity }) => {
|
||||||
|
const cfg = CONFIG[severity] ?? CONFIG[InteractionSeverity.Unknown];
|
||||||
|
return <Chip color={cfg.color} label={cfg.label} size="small" />;
|
||||||
|
};
|
||||||
309
web/normogen-web/src/components/health/HealthStats.tsx
Normal file
309
web/normogen-web/src/components/health/HealthStats.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import { useEffect, useState, type FC } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Alert,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import TrendUpIcon from '@mui/icons-material/TrendingUp';
|
||||||
|
import TrendDownIcon from '@mui/icons-material/TrendingDown';
|
||||||
|
import TrendFlatIcon from '@mui/icons-material/TrendingFlat';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { useHealthStore } from '../../store/useStore';
|
||||||
|
import { HealthStatType, type CreateHealthStatRequest, type TrendData } from '../../types/api';
|
||||||
|
|
||||||
|
const STAT_TYPES = Object.values(HealthStatType);
|
||||||
|
|
||||||
|
const UNIT_DEFAULTS: Record<HealthStatType, string> = {
|
||||||
|
[HealthStatType.Weight]: 'kg',
|
||||||
|
[HealthStatType.Height]: 'cm',
|
||||||
|
[HealthStatType.BloodPressure]: 'mmHg',
|
||||||
|
[HealthStatType.HeartRate]: 'bpm',
|
||||||
|
[HealthStatType.BloodSugar]: 'mg/dL',
|
||||||
|
[HealthStatType.Temperature]: '°C',
|
||||||
|
[HealthStatType.OxygenSaturation]: '%',
|
||||||
|
[HealthStatType.Cholesterol]: 'mg/dL',
|
||||||
|
[HealthStatType.Other]: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const trendIcon = (trend: TrendData['trend']) => {
|
||||||
|
if (trend === 'up') return <TrendUpIcon color="error" fontSize="small" />;
|
||||||
|
if (trend === 'down') return <TrendDownIcon color="success" fontSize="small" />;
|
||||||
|
return <TrendFlatIcon color="action" fontSize="small" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toLocalInput = (d: Date) => {
|
||||||
|
// datetime-local expects YYYY-MM-DDTHH:mm in local time.
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HealthStats: FC = () => {
|
||||||
|
const { stats, trends, isLoading, error, loadStats, loadTrends, createStat, clearError } =
|
||||||
|
useHealthStore();
|
||||||
|
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [chartType, setChartType] = useState<HealthStatType>(HealthStatType.Weight);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<CreateHealthStatRequest>({
|
||||||
|
stat_type: HealthStatType.Weight,
|
||||||
|
value: 0,
|
||||||
|
unit: UNIT_DEFAULTS[HealthStatType.Weight],
|
||||||
|
measured_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStats();
|
||||||
|
loadTrends();
|
||||||
|
}, [loadStats, loadTrends]);
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
const t = HealthStatType.Weight;
|
||||||
|
setForm({
|
||||||
|
stat_type: t,
|
||||||
|
value: 0,
|
||||||
|
unit: UNIT_DEFAULTS[t],
|
||||||
|
measured_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
setAddOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAdd = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await createStat(form);
|
||||||
|
setAddOpen(false);
|
||||||
|
} catch {
|
||||||
|
/* error surfaced via store */
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const typedTrends = trends as TrendData[];
|
||||||
|
const chartData = stats
|
||||||
|
.filter((s) => s.stat_type === chartType)
|
||||||
|
.sort((a, b) => new Date(a.measured_at).getTime() - new Date(b.measured_at).getTime())
|
||||||
|
.map((s) => ({ time: format(new Date(s.measured_at), 'MMM d'), value: s.value }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h6">Health statistics</Typography>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>
|
||||||
|
Record
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={clearError}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box display="flex" justifyContent="center" py={4}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Trend summary cards */}
|
||||||
|
{typedTrends.length > 0 && (
|
||||||
|
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{typedTrends.map((t) => (
|
||||||
|
<Card key={t.stat_type} sx={{ minWidth: 160 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t.stat_type}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||||
|
<Typography variant="h6">{t.average.toFixed(1)}</Typography>
|
||||||
|
{trendIcon(t.trend)}
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t.min} – {t.max}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chart with type selector */}
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label="Chart"
|
||||||
|
value={chartType}
|
||||||
|
onChange={(e) => setChartType(e.target.value as HealthStatType)}
|
||||||
|
sx={{ minWidth: 180 }}
|
||||||
|
>
|
||||||
|
{STAT_TYPES.map((t) => (
|
||||||
|
<MenuItem key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
|
{chartData.length === 0 ? (
|
||||||
|
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
No {chartType} readings recorded yet.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="time" fontSize={12} />
|
||||||
|
<YAxis fontSize={12} />
|
||||||
|
<Tooltip />
|
||||||
|
<Line type="monotone" dataKey="value" stroke="#1976d2" strokeWidth={2} dot />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent readings table */}
|
||||||
|
{stats.length > 0 && (
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Type</TableCell>
|
||||||
|
<TableCell>Value</TableCell>
|
||||||
|
<TableCell>When</TableCell>
|
||||||
|
<TableCell>Notes</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{[...stats]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.measured_at).getTime() - new Date(a.measured_at).getTime(),
|
||||||
|
)
|
||||||
|
.slice(0, 15)
|
||||||
|
.map((s) => (
|
||||||
|
<TableRow key={s.stat_id}>
|
||||||
|
<TableCell>{s.stat_type}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{s.value} {s.unit}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{format(new Date(s.measured_at), 'MMM d, HH:mm')}</TableCell>
|
||||||
|
<TableCell>{s.notes ?? ''}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats.length === 0 && typedTrends.length === 0 && (
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
No health readings yet — record one.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add dialog */}
|
||||||
|
<Dialog open={addOpen} onClose={() => setAddOpen(false)} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>Record a measurement</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Type"
|
||||||
|
fullWidth
|
||||||
|
value={form.stat_type}
|
||||||
|
onChange={(e) => {
|
||||||
|
const t = e.target.value as HealthStatType;
|
||||||
|
setForm({ ...form, stat_type: t, unit: UNIT_DEFAULTS[t] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STAT_TYPES.map((t) => (
|
||||||
|
<MenuItem key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Value"
|
||||||
|
type="number"
|
||||||
|
fullWidth
|
||||||
|
value={form.value === 0 ? '' : form.value}
|
||||||
|
onChange={(e) => setForm({ ...form, value: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Unit"
|
||||||
|
fullWidth
|
||||||
|
value={form.unit}
|
||||||
|
onChange={(e) => setForm({ ...form, unit: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
label="Measured at"
|
||||||
|
type="datetime-local"
|
||||||
|
fullWidth
|
||||||
|
value={toLocalInput(new Date(form.measured_at))}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, measured_at: new Date(e.target.value).toISOString() })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Notes"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={form.notes ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={submitAdd}
|
||||||
|
disabled={saving || !form.value || !form.unit}
|
||||||
|
>
|
||||||
|
{saving ? <CircularProgress size={24} /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HealthStats;
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { useState, type FC } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
Chip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import { useInteractionStore, useMedicationStore } from '../../store/useStore';
|
||||||
|
import { SeverityChip } from '../common/SeverityChip';
|
||||||
|
|
||||||
|
export const InteractionsChecker: FC = () => {
|
||||||
|
const { medications } = useMedicationStore();
|
||||||
|
const { interactions, isChecking, error, checkInteractions, clearError } =
|
||||||
|
useInteractionStore();
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const toggle = (name: string) => {
|
||||||
|
setSelected((prev) =>
|
||||||
|
prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCheck = async () => {
|
||||||
|
if (selected.length < 2) return;
|
||||||
|
await checkInteractions(selected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChecked = interactions.length > 0 || !isChecking;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
|
Drug interactions
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Select two or more of your medications to check for known interactions.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={clearError}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{medications.length === 0 ? (
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
Add medications first (Medications tab), then check them for interactions.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 2 }}>
|
||||||
|
{medications.map((m) => {
|
||||||
|
const on = selected.includes(m.name);
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={m.medication_id}
|
||||||
|
label={m.name}
|
||||||
|
color={on ? 'primary' : 'default'}
|
||||||
|
variant={on ? 'filled' : 'outlined'}
|
||||||
|
onClick={() => toggle(m.name)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={runCheck}
|
||||||
|
disabled={selected.length < 2 || isChecking}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
{isChecking ? <CircularProgress size={24} /> : 'Check interactions'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{hasChecked && !isChecking && selected.length >= 2 && (
|
||||||
|
<>
|
||||||
|
{interactions.length === 0 ? (
|
||||||
|
<Alert icon={<CheckCircleIcon fontSize="inherit" />} severity="success">
|
||||||
|
No known interactions found between the selected medications.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Alert icon={<WarningAmberIcon fontSize="inherit" />} severity="warning">
|
||||||
|
{interactions.length} interaction{interactions.length > 1 ? 's' : ''} found.
|
||||||
|
</Alert>
|
||||||
|
{interactions.map((ix, i) => (
|
||||||
|
<Card key={i} variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600}>
|
||||||
|
{ix.medications.join(' + ')}
|
||||||
|
</Typography>
|
||||||
|
<SeverityChip severity={ix.severity} />
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
{ix.description}
|
||||||
|
</Typography>
|
||||||
|
{ix.disclaimer && (
|
||||||
|
<Typography variant="caption" color="text.secondary" fontStyle="italic">
|
||||||
|
{ix.disclaimer}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InteractionsChecker;
|
||||||
348
web/normogen-web/src/components/medication/MedicationManager.tsx
Normal file
348
web/normogen-web/src/components/medication/MedicationManager.tsx
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
import { useEffect, useState, type FC } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardActions,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import { useMedicationStore, useAuthStore } from '../../store/useStore';
|
||||||
|
import type {
|
||||||
|
Medication,
|
||||||
|
CreateMedicationRequest,
|
||||||
|
UpdateMedicationRequest,
|
||||||
|
} from '../../types/api';
|
||||||
|
|
||||||
|
const ROUTES = ['oral', 'topical', 'injection', 'inhalation', 'other'] as const;
|
||||||
|
|
||||||
|
const emptyCreate: CreateMedicationRequest = {
|
||||||
|
name: '',
|
||||||
|
dosage: '',
|
||||||
|
frequency: '',
|
||||||
|
route: 'oral',
|
||||||
|
profile_id: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MedicationManager: FC = () => {
|
||||||
|
const {
|
||||||
|
medications,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadMedications,
|
||||||
|
createMedication,
|
||||||
|
updateMedication,
|
||||||
|
deleteMedication,
|
||||||
|
clearError,
|
||||||
|
} = useMedicationStore();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createForm, setCreateForm] = useState<CreateMedicationRequest>(emptyCreate);
|
||||||
|
const [editTarget, setEditTarget] = useState<Medication | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<UpdateMedicationRequest>({});
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMedications();
|
||||||
|
}, [loadMedications]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
// TODO: real profile management — for now, source profile_id from the user,
|
||||||
|
// falling back to 'default' (the backend accepts any string).
|
||||||
|
setCreateForm({
|
||||||
|
...emptyCreate,
|
||||||
|
profile_id: user?.profile_id ?? 'default',
|
||||||
|
});
|
||||||
|
setCreateOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCreate = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await createMedication(createForm);
|
||||||
|
setCreateOpen(false);
|
||||||
|
} catch {
|
||||||
|
/* error surfaced via store */
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (med: Medication) => {
|
||||||
|
setEditTarget(med);
|
||||||
|
setEditForm({
|
||||||
|
name: med.name,
|
||||||
|
dosage: med.dosage,
|
||||||
|
frequency: med.frequency,
|
||||||
|
instructions: med.instructions,
|
||||||
|
active: med.active,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitEdit = async () => {
|
||||||
|
if (!editTarget?.medication_id) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateMedication(editTarget.medication_id, editForm);
|
||||||
|
setEditTarget(null);
|
||||||
|
} catch {
|
||||||
|
/* error surfaced via store */
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteTarget?.medication_id) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await deleteMedication(deleteTarget.medication_id);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} catch {
|
||||||
|
/* error surfaced via store */
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h6">Medications</Typography>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={clearError}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box display="flex" justifyContent="center" py={4}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : medications.length === 0 ? (
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
No medications yet — add one.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List disablePadding>
|
||||||
|
{medications.map((med) => (
|
||||||
|
<ListItem
|
||||||
|
key={med.medication_id}
|
||||||
|
disableGutters
|
||||||
|
sx={{ mb: 1.5 }}
|
||||||
|
>
|
||||||
|
<Card sx={{ width: '100%' }}>
|
||||||
|
<CardContent sx={{ pb: 1 }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" component="span" fontWeight={600}>
|
||||||
|
{med.name}
|
||||||
|
</Typography>{' '}
|
||||||
|
<Typography component="span" color="text.secondary">
|
||||||
|
{med.dosage} · {med.frequency}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 0.5 }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={med.active ? 'active' : 'inactive'}
|
||||||
|
color={med.active ? 'success' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{med.instructions && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
{med.instructions}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions sx={{ justifyContent: 'flex-end' }}>
|
||||||
|
<IconButton size="small" onClick={() => openEdit(med)} aria-label="edit">
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setDeleteTarget(med)}
|
||||||
|
aria-label="delete"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create dialog */}
|
||||||
|
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>Add medication</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={createForm.name}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Dosage"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={createForm.dosage}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, dosage: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Frequency"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={createForm.frequency}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, frequency: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Route"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={createForm.route}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, route: e.target.value as CreateMedicationRequest['route'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ROUTES.map((r) => (
|
||||||
|
<MenuItem key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="Instructions"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={createForm.instructions ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, instructions: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={submitCreate}
|
||||||
|
disabled={saving || !createForm.name || !createForm.dosage || !createForm.frequency}
|
||||||
|
>
|
||||||
|
{saving ? <CircularProgress size={24} /> : 'Add'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!editTarget}
|
||||||
|
onClose={() => setEditTarget(null)}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="sm"
|
||||||
|
>
|
||||||
|
<DialogTitle>Edit medication</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
fullWidth
|
||||||
|
value={editForm.name ?? ''}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Dosage"
|
||||||
|
fullWidth
|
||||||
|
value={editForm.dosage ?? ''}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, dosage: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Frequency"
|
||||||
|
fullWidth
|
||||||
|
value={editForm.frequency ?? ''}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, frequency: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
label="Instructions"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={editForm.instructions ?? ''}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, instructions: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Status"
|
||||||
|
fullWidth
|
||||||
|
value={editForm.active ? 'active' : 'inactive'}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, active: e.target.value === 'active' })}
|
||||||
|
>
|
||||||
|
<MenuItem value="active">active</MenuItem>
|
||||||
|
<MenuItem value="inactive">inactive</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setEditTarget(null)}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={submitEdit} disabled={saving}>
|
||||||
|
{saving ? <CircularProgress size={24} /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete confirm */}
|
||||||
|
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
|
||||||
|
<DialogTitle>Delete medication?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
Remove {deleteTarget?.name}? This cannot be undone.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
||||||
|
<Button variant="contained" color="error" onClick={confirmDelete} disabled={saving}>
|
||||||
|
{saving ? <CircularProgress size={24} /> : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MedicationManager;
|
||||||
|
|
@ -1,24 +1,20 @@
|
||||||
import { useEffect, type FC } from 'react';
|
import { useEffect, useState, type FC } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import { AppBar, Toolbar, Typography, Button, Container, Box, Tabs, Tab } from '@mui/material';
|
||||||
AppBar,
|
|
||||||
Toolbar,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
Container,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Box,
|
|
||||||
CircularProgress,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { useAuthStore } from '../store/useStore';
|
import { useAuthStore } from '../store/useStore';
|
||||||
|
import { MedicationManager } from '../components/medication/MedicationManager';
|
||||||
|
import { HealthStats } from '../components/health/HealthStats';
|
||||||
|
import { InteractionsChecker } from '../components/interactions/InteractionsChecker';
|
||||||
|
|
||||||
|
type TabIndex = 0 | 1 | 2;
|
||||||
|
|
||||||
export const Dashboard: FC = () => {
|
export const Dashboard: FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, isAuthenticated, isLoading, loadUser, logout } = useAuthStore();
|
const { user, isAuthenticated, loadUser, logout } = useAuthStore();
|
||||||
|
const [tab, setTab] = useState<TabIndex>(0);
|
||||||
|
|
||||||
// On mount, refresh the user record from the backend (confirms the token is
|
// On mount, refresh the user record from the backend (confirms the token is
|
||||||
// still valid and pulls the latest profile).
|
// still valid, and medication-create needs user.profile_id).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && !user) {
|
if (isAuthenticated && !user) {
|
||||||
loadUser();
|
loadUser();
|
||||||
|
|
@ -37,34 +33,32 @@ export const Dashboard: FC = () => {
|
||||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||||
Normogen
|
Normogen
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{user && (
|
||||||
|
<Typography variant="body2" sx={{ mr: 2, opacity: 0.85 }}>
|
||||||
|
{user.username}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
<Button color="inherit" onClick={handleLogout}>
|
<Button color="inherit" onClick={handleLogout}>
|
||||||
Logout
|
Logout
|
||||||
</Button>
|
</Button>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
<Container maxWidth="md" sx={{ mt: 4 }}>
|
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
||||||
{isLoading && (
|
<Tabs value={tab} onChange={(_, v) => setTab(v as TabIndex)} sx={{ mb: 3 }}>
|
||||||
<Box display="flex" justifyContent="center" mt={4}>
|
<Tab label="Medications" />
|
||||||
<CircularProgress />
|
<Tab label="Health" />
|
||||||
</Box>
|
<Tab label="Interactions" />
|
||||||
)}
|
</Tabs>
|
||||||
|
|
||||||
<Card>
|
<Box>
|
||||||
<CardContent>
|
{tab === 0 && <MedicationManager />}
|
||||||
<Typography variant="h5" gutterBottom>
|
{tab === 1 && <HealthStats />}
|
||||||
Welcome{user ? `, ${user.username}` : ''}
|
{tab === 2 && <InteractionsChecker />}
|
||||||
</Typography>
|
</Box>
|
||||||
<Typography variant="body1" color="text.secondary">
|
|
||||||
You're signed in to Normogen.
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
|
||||||
This is the dashboard shell. Medication management, health statistics,
|
|
||||||
and drug-interaction checking UIs will be built here.
|
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue