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:
goose 2026-06-28 16:35:50 -03:00
parent 0921d73a6d
commit 4c4c29f72e
10 changed files with 1019 additions and 4 deletions

View file

@ -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;