feat(web): Phase 3b — medication/health/interactions feature UIs
Build the three core feature UIs on top of the now-wired stores + corrected API client from Phase 3a, turning the runnable shell into a usable app. Dashboard (pages/Dashboard.tsx): AppBar (Normogen + username + logout) over an MUI Tabs container (Medications | Health | Interactions), widened to lg. User loads on mount (needed for profile_id in medication create). MedicationManager (components/medication/): full CRUD. * List of medication cards (name, dosage·frequency, active chip, instructions) with edit/delete actions and an empty state. * Create dialog: name/dosage/frequency/route(select)/instructions. profile_id sourced from user.profile_id with a 'default' fallback (TODO: real profiles). Payload typed to CreateMedicationRequest. * Edit dialog (UpdateMedicationRequest shape) + delete confirm. Guards the optional medication_id field. HealthStats (components/health/): trend cards + chart + add + table. * Trend summary cards from /health-stats/trends (avg/min/max + trend arrow). * recharts LineChart of a selected stat type over measured_at, with a type selector. Timestamp field is measured_at (not timestamp). * Record dialog (stat_type/value/unit/measured_at/notes) + recent-readings table (date-fns formatted). InteractionsChecker (components/interactions/): multi-select medication names (chips) -> POST /interactions/check. Results rendered as cards with a SeverityChip, description, and disclaimer. Success/empty states. Shared SeverityChip (components/common/): maps InteractionSeverity -> MUI Chip color (severe=error, moderate=warning, mild=success, unknown=default). Added @mui/icons-material@7 (pinned to match @mui/material 7; npm tried to grab v9 which needs material ^9). Verified: npm run build clean (TS5 strict), dev server serves the app + all component modules transform. Solaria round-trip confirmed every contract the UIs call: medication create/list (200), interactions warfarin+aspirin -> severe, health-stat create (201).
This commit is contained in:
parent
ef1bb9f4be
commit
1515923996
7 changed files with 886 additions and 60 deletions
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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue