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

@ -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 }),
})),
);