feat: full appointment feature (CRUD + Appointments tab)
Builds the complete appointment feature end-to-end, mirroring the medication feature structure. The model existed but was orphaned dead code (no handlers, routes, repo, or frontend) — this builds the whole stack. Backend: - models/appointment.rs: AppointmentData (deserializes the data blob, camelCase keys), AppointmentResponse (flat snake_case + From<Appointment>), Create/Update request structs, AppointmentRepository (create, find_by_user_filtered with optional status filter, find/update/delete by appointment_id). Mirrors the medication serialization pattern. Module added to models/mod.rs. - handlers/appointments.rs: full CRUD (create/list/get/update/delete) returning AppointmentResponse. Routes wired in app.rs (POST/GET /api/appointments, GET/POST /:id, POST /:id/delete). - 3 unit tests (AppointmentData deser, default status, response flatten). Frontend: - Appointment/Create/Update types; getAppointments/getAppointment/create/ update/deleteAppointment in api.ts; useAppointmentStore. - AppointmentsManager component (list with type+status chips, create/edit dialogs, delete confirm, empty state) as a 5th Dashboard tab. Verified: backend fmt/clippy 0 warnings, 26 tests pass; frontend build clean, 20 tests pass.
This commit is contained in:
parent
0921d73a6d
commit
4c4c29f72e
10 changed files with 1019 additions and 4 deletions
|
|
@ -0,0 +1,395 @@
|
|||
import { useEffect, useState, type FC } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Alert,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
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 { format } from 'date-fns';
|
||||
import { useAppointmentStore, useAuthStore } from '../../store/useStore';
|
||||
import type {
|
||||
Appointment,
|
||||
CreateAppointmentRequest,
|
||||
UpdateAppointmentRequest,
|
||||
} from '../../types/api';
|
||||
|
||||
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
|
||||
const STATUSES = ['upcoming', 'completed', 'cancelled'];
|
||||
|
||||
const statusColor = (status: string): 'success' | 'default' | 'error' => {
|
||||
if (status === 'upcoming') return 'success';
|
||||
if (status === 'cancelled') return 'error';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const emptyCreate: CreateAppointmentRequest = {
|
||||
title: '',
|
||||
provider: '',
|
||||
appointment_type: 'in-person',
|
||||
date_time: new Date().toISOString(),
|
||||
profile_id: 'default',
|
||||
};
|
||||
|
||||
const toLocalInput = (d: Date) => {
|
||||
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 AppointmentsManager: FC = () => {
|
||||
const {
|
||||
appointments,
|
||||
isLoading,
|
||||
error,
|
||||
loadAppointments,
|
||||
createAppointment,
|
||||
updateAppointment,
|
||||
deleteAppointment,
|
||||
clearError,
|
||||
} = useAppointmentStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState<CreateAppointmentRequest>(emptyCreate);
|
||||
const [editTarget, setEditTarget] = useState<Appointment | null>(null);
|
||||
const [editForm, setEditForm] = useState<UpdateAppointmentRequest>({});
|
||||
const [deleteTarget, setDeleteTarget] = useState<Appointment | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadAppointments();
|
||||
}, [loadAppointments]);
|
||||
|
||||
const openCreate = () => {
|
||||
setCreateForm({
|
||||
...emptyCreate,
|
||||
profile_id: `profile_${user?.user_id ?? 'default'}`,
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await createAppointment(createForm);
|
||||
setCreateOpen(false);
|
||||
} catch {
|
||||
/* error surfaced via store */
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (appt: Appointment) => {
|
||||
setEditTarget(appt);
|
||||
setEditForm({
|
||||
title: appt.title,
|
||||
provider: appt.provider,
|
||||
appointment_type: appt.appointment_type,
|
||||
date_time: appt.date_time,
|
||||
location: appt.location,
|
||||
duration_minutes: appt.duration_minutes,
|
||||
reason: appt.reason,
|
||||
notes: appt.notes,
|
||||
status: appt.status,
|
||||
});
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!editTarget?.appointment_id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAppointment(editTarget.appointment_id, editForm);
|
||||
setEditTarget(null);
|
||||
} catch {
|
||||
/* error surfaced via store */
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget?.appointment_id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteAppointment(deleteTarget.appointment_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">Appointments</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>
|
||||
) : appointments.length === 0 ? (
|
||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||
No appointments yet — add one.
|
||||
</Typography>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{appointments.map((appt) => (
|
||||
<ListItem key={appt.appointment_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}>
|
||||
{appt.title}
|
||||
</Typography>{' '}
|
||||
<Typography component="span" color="text.secondary">
|
||||
· {appt.provider}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={appt.appointment_type}
|
||||
variant="outlined"
|
||||
sx={{ mr: 0.5 }}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={appt.status}
|
||||
color={statusColor(appt.status)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{format(new Date(appt.date_time), 'MMM d, yyyy HH:mm')}
|
||||
{appt.location ? ` · ${appt.location}` : ''}
|
||||
</Typography>
|
||||
{appt.notes && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{appt.notes}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
<CardActions sx={{ justifyContent: 'flex-end' }}>
|
||||
<IconButton size="small" onClick={() => openEdit(appt)} aria-label="edit">
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setDeleteTarget(appt)}
|
||||
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 appointment</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Title"
|
||||
required
|
||||
fullWidth
|
||||
value={createForm.title}
|
||||
onChange={(e) => setCreateForm({ ...createForm, title: e.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="Provider"
|
||||
required
|
||||
fullWidth
|
||||
value={createForm.provider}
|
||||
onChange={(e) => setCreateForm({ ...createForm, provider: e.target.value })}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
select
|
||||
label="Type"
|
||||
required
|
||||
fullWidth
|
||||
value={createForm.appointment_type}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, appointment_type: e.target.value })
|
||||
}
|
||||
>
|
||||
{APPT_TYPES.map((t) => (
|
||||
<MenuItem key={t} value={t}>{t}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="When"
|
||||
type="datetime-local"
|
||||
required
|
||||
fullWidth
|
||||
value={toLocalInput(new Date(createForm.date_time))}
|
||||
onChange={(e) =>
|
||||
setCreateForm({
|
||||
...createForm,
|
||||
date_time: new Date(e.target.value).toISOString(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
<TextField
|
||||
label="Location"
|
||||
fullWidth
|
||||
value={createForm.location ?? ''}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, location: e.target.value || undefined })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Notes"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={createForm.notes ?? ''}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, notes: e.target.value || undefined })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={submitCreate}
|
||||
disabled={saving || !createForm.title || !createForm.provider}
|
||||
>
|
||||
{saving ? <CircularProgress size={24} /> : 'Add'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={!!editTarget} onClose={() => setEditTarget(null)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Edit appointment</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Title"
|
||||
fullWidth
|
||||
value={editForm.title ?? ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="Provider"
|
||||
fullWidth
|
||||
value={editForm.provider ?? ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, provider: e.target.value })}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
select
|
||||
label="Type"
|
||||
fullWidth
|
||||
value={editForm.appointment_type ?? 'in-person'}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, appointment_type: e.target.value })
|
||||
}
|
||||
>
|
||||
{APPT_TYPES.map((t) => (
|
||||
<MenuItem key={t} value={t}>{t}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Status"
|
||||
fullWidth
|
||||
value={editForm.status ?? 'upcoming'}
|
||||
onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<MenuItem key={s} value={s}>{s}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Stack>
|
||||
<TextField
|
||||
label="When"
|
||||
type="datetime-local"
|
||||
fullWidth
|
||||
value={editForm.date_time ? toLocalInput(new Date(editForm.date_time)) : ''}
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
date_time: e.target.value
|
||||
? new Date(e.target.value).toISOString()
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Notes"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={editForm.notes ?? ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
|
||||
/>
|
||||
</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 appointment?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Remove {deleteTarget?.title}? 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 AppointmentsManager;
|
||||
|
|
@ -6,8 +6,9 @@ import { MedicationManager } from '../components/medication/MedicationManager';
|
|||
import { HealthStats } from '../components/health/HealthStats';
|
||||
import { InteractionsChecker } from '../components/interactions/InteractionsChecker';
|
||||
import { ProfileEditor } from '../components/profile/ProfileEditor';
|
||||
import { AppointmentsManager } from '../components/appointments/AppointmentsManager';
|
||||
|
||||
type TabIndex = 0 | 1 | 2 | 3;
|
||||
type TabIndex = 0 | 1 | 2 | 3 | 4;
|
||||
|
||||
export const Dashboard: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -50,6 +51,7 @@ export const Dashboard: FC = () => {
|
|||
<Tab label="Medications" />
|
||||
<Tab label="Health" />
|
||||
<Tab label="Interactions" />
|
||||
<Tab label="Appointments" />
|
||||
<Tab label="Profile" />
|
||||
</Tabs>
|
||||
|
||||
|
|
@ -57,7 +59,8 @@ export const Dashboard: FC = () => {
|
|||
{tab === 0 && <MedicationManager />}
|
||||
{tab === 1 && <HealthStats />}
|
||||
{tab === 2 && <InteractionsChecker />}
|
||||
{tab === 3 && <ProfileEditor />}
|
||||
{tab === 3 && <AppointmentsManager />}
|
||||
{tab === 4 && <ProfileEditor />}
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ import {
|
|||
LogDoseRequest,
|
||||
AdherenceStats,
|
||||
Profile,
|
||||
Appointment,
|
||||
CreateAppointmentRequest,
|
||||
UpdateAppointmentRequest,
|
||||
} from '../types/api';
|
||||
|
||||
// API base URL. In dev this is "/api", proxied by the Vite dev server to the
|
||||
|
|
@ -262,6 +265,34 @@ class ApiService {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
// ---- Appointments ----
|
||||
|
||||
async getAppointments(status?: string): Promise<Appointment[]> {
|
||||
const response = await this.client.get<Appointment[]>('/appointments', {
|
||||
params: status ? { status } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAppointment(id: string): Promise<Appointment> {
|
||||
const response = await this.client.get<Appointment>(`/appointments/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createAppointment(data: CreateAppointmentRequest): Promise<Appointment> {
|
||||
const response = await this.client.post<Appointment>('/appointments', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise<Appointment> {
|
||||
const response = await this.client.post<Appointment>(`/appointments/${id}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteAppointment(id: string): Promise<void> {
|
||||
await this.client.post(`/appointments/${id}/delete`);
|
||||
}
|
||||
|
||||
// ---- Drug Interactions (Phase 2.8) ----
|
||||
|
||||
async checkInteractions(medications: string[]): Promise<DrugInteraction[]> {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import {
|
|||
DrugInteraction,
|
||||
AdherenceStats,
|
||||
Profile,
|
||||
Appointment,
|
||||
CreateAppointmentRequest,
|
||||
UpdateAppointmentRequest,
|
||||
} from '../types/api';
|
||||
import apiService from '../services/api';
|
||||
|
||||
|
|
@ -80,6 +83,17 @@ interface ProfileState {
|
|||
clearError: () => void;
|
||||
}
|
||||
|
||||
interface AppointmentState {
|
||||
appointments: Appointment[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
loadAppointments: (status?: string) => Promise<void>;
|
||||
createAppointment: (data: CreateAppointmentRequest) => Promise<void>;
|
||||
updateAppointment: (id: string, data: UpdateAppointmentRequest) => Promise<void>;
|
||||
deleteAppointment: (id: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
// Auth Store
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
devtools(
|
||||
|
|
@ -466,3 +480,70 @@ export const useProfileStore = create<ProfileState>()(
|
|||
clearError: () => set({ error: null }),
|
||||
})),
|
||||
);
|
||||
|
||||
// Appointment Store
|
||||
export const useAppointmentStore = create<AppointmentState>()(
|
||||
devtools((set) => ({
|
||||
appointments: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
loadAppointments: async (status) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const appointments = await apiService.getAppointments(status);
|
||||
set({ appointments, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to load appointments',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createAppointment: async (data) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const appt = await apiService.createAppointment(data);
|
||||
set((state) => ({
|
||||
appointments: [...state.appointments, appt],
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to create appointment',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateAppointment: async (id, data) => {
|
||||
try {
|
||||
const updated = await apiService.updateAppointment(id, data);
|
||||
set((state) => ({
|
||||
appointments: state.appointments.map((a) =>
|
||||
a.appointment_id === id ? updated : a,
|
||||
),
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to update appointment' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteAppointment: async (id) => {
|
||||
try {
|
||||
await apiService.deleteAppointment(id);
|
||||
set((state) => ({
|
||||
appointments: state.appointments.filter((a) => a.appointment_id !== id),
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to delete appointment' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
})),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -244,6 +244,50 @@ export interface LabResult {
|
|||
created_at?: string;
|
||||
}
|
||||
|
||||
// Appointment Types — match backend AppointmentResponse (snake_case).
|
||||
export interface Appointment {
|
||||
id?: string;
|
||||
appointment_id: string;
|
||||
user_id?: string;
|
||||
profile_id?: string;
|
||||
title: string;
|
||||
provider: string;
|
||||
appointment_type: string;
|
||||
date_time: string;
|
||||
location?: string;
|
||||
duration_minutes?: number;
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateAppointmentRequest {
|
||||
title: string;
|
||||
provider: string;
|
||||
appointment_type: string;
|
||||
date_time: string;
|
||||
profile_id: string;
|
||||
location?: string;
|
||||
duration_minutes?: number;
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAppointmentRequest {
|
||||
title?: string;
|
||||
provider?: string;
|
||||
appointment_type?: string;
|
||||
date_time?: string;
|
||||
location?: string;
|
||||
duration_minutes?: number;
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// Dose Log Types — match backend MedicationDose (camelCase serialization).
|
||||
export interface DoseLog {
|
||||
id?: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue