diff --git a/backend/src/app.rs b/backend/src/app.rs index 7a6ca1f..de21413 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -78,12 +78,6 @@ pub fn build_app(state: AppState) -> Router { // Drug interactions (Phase 2.8) .route("/api/interactions/check", post(handlers::check_interactions)) .route("/api/interactions/check-new", post(handlers::check_new_medication)) - // Appointments - .route("/api/appointments", post(handlers::create_appointment)) - .route("/api/appointments", get(handlers::list_appointments)) - .route("/api/appointments/:id", get(handlers::get_appointment)) - .route("/api/appointments/:id", post(handlers::update_appointment)) - .route("/api/appointments/:id/delete", post(handlers::delete_appointment)) .layer(axum::middleware::from_fn_with_state( state.clone(), middleware::jwt_auth_middleware, diff --git a/backend/src/handlers/appointments.rs b/backend/src/handlers/appointments.rs deleted file mode 100644 index 9e7ab8b..0000000 --- a/backend/src/handlers/appointments.rs +++ /dev/null @@ -1,136 +0,0 @@ -use axum::{ - extract::{Extension, Json, Path, Query, State}, - http::StatusCode, -}; -use serde::Deserialize; -use std::time::SystemTime; - -use crate::{ - auth::jwt::Claims, - config::AppState, - models::appointment::{ - AppointmentRepository, AppointmentResponse, CreateAppointmentRequest, - UpdateAppointmentRequest, - }, - models::health_data::EncryptedField, -}; -use mongodb::bson::DateTime; - -#[derive(Debug, Deserialize)] -pub struct AppointmentListQuery { - /// Filter by status (upcoming/completed/cancelled). Optional. - pub status: Option, -} - -pub async fn create_appointment( - State(state): State, - Extension(claims): Extension, - Json(req): Json, -) -> Result, StatusCode> { - let database = state.db.get_database(); - let repo = AppointmentRepository::new(database.collection("appointments")); - - let now = SystemTime::now(); - let appointment_id = uuid::Uuid::new_v4().to_string(); - - // Pack the request fields into the data blob (camelCase keys). - let appointment_data = EncryptedField { - data: serde_json::json!({ - "title": req.title, - "provider": req.provider, - "appointmentType": req.appointment_type, - "dateTime": req.date_time, - "location": req.location, - "durationMinutes": req.duration_minutes, - "reason": req.reason, - "notes": req.notes, - "status": req.status.unwrap_or_else(|| "upcoming".to_string()), - }) - .to_string(), - encrypted: false, - iv: String::new(), - auth_tag: String::new(), - }; - - let appointment = crate::models::appointment::Appointment { - id: None, - appointment_id, - user_id: claims.sub, - profile_id: req.profile_id.clone(), - appointment_data, - reminders: vec![], - created_at: DateTime::from_system_time(now), - updated_at: DateTime::from_system_time(now), - }; - - match repo.create(appointment).await { - Ok(appt) => Ok(Json(appt.into())), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), - } -} - -pub async fn list_appointments( - State(state): State, - Extension(claims): Extension, - Query(query): Query, -) -> Result>, StatusCode> { - let database = state.db.get_database(); - let repo = AppointmentRepository::new(database.collection("appointments")); - - match repo - .find_by_user_filtered(&claims.sub, query.status.as_deref()) - .await - { - Ok(appointments) => { - let resp: Vec = appointments.into_iter().map(Into::into).collect(); - Ok(Json(resp)) - } - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), - } -} - -pub async fn get_appointment( - State(state): State, - Extension(_claims): Extension, - Path(id): Path, -) -> Result, StatusCode> { - let database = state.db.get_database(); - let repo = AppointmentRepository::new(database.collection("appointments")); - - match repo.find_by_appointment_id(&id).await { - Ok(Some(appt)) => Ok(Json(appt.into())), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), - } -} - -pub async fn update_appointment( - State(state): State, - Extension(_claims): Extension, - Path(id): Path, - Json(req): Json, -) -> Result, StatusCode> { - let database = state.db.get_database(); - let repo = AppointmentRepository::new(database.collection("appointments")); - - match repo.update_by_appointment_id(&id, req).await { - Ok(Some(appt)) => Ok(Json(appt.into())), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), - } -} - -pub async fn delete_appointment( - State(state): State, - Extension(_claims): Extension, - Path(id): Path, -) -> Result { - let database = state.db.get_database(); - let repo = AppointmentRepository::new(database.collection("appointments")); - - match repo.delete_by_appointment_id(&id).await { - Ok(true) => Ok(StatusCode::NO_CONTENT), - Ok(false) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), - } -} diff --git a/backend/src/handlers/medications.rs b/backend/src/handlers/medications.rs index 3ee8021..1aed58b 100644 --- a/backend/src/handlers/medications.rs +++ b/backend/src/handlers/medications.rs @@ -72,7 +72,6 @@ pub async fn create_medication( .collect(), created_at: now.into(), updated_at: now.into(), - active: req.active, pill_identification: req.pill_identification, // Phase 2.8 }; @@ -90,11 +89,9 @@ pub async fn list_medications( let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - // Honor the active filter (and optional profile_id) when provided. - match repo - .find_by_user_filtered(&claims.sub, query.active, query.profile_id.as_deref()) - .await - { + let _limit = query.limit.unwrap_or(100); + + match repo.find_by_user(&claims.sub).await { Ok(medications) => { let resp: Vec = medications.into_iter().map(Into::into).collect(); Ok(Json(resp)) diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 417a445..a6930c2 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -1,4 +1,3 @@ -pub mod appointments; pub mod auth; pub mod health; pub mod health_stats; @@ -11,9 +10,6 @@ pub mod shares; pub mod users; // Re-export commonly used handler functions -pub use appointments::{ - create_appointment, delete_appointment, get_appointment, list_appointments, update_appointment, -}; pub use auth::{login, logout, recover_password, refresh, register}; pub use health::{health_check, ready_check}; pub use health_stats::{ diff --git a/backend/src/models/appointment.rs b/backend/src/models/appointment.rs index 0f19b4f..0e0b144 100644 --- a/backend/src/models/appointment.rs +++ b/backend/src/models/appointment.rs @@ -1,93 +1,6 @@ -use super::health_data::EncryptedField; -use futures::StreamExt; -use mongodb::bson::{doc, oid::ObjectId, DateTime}; -use mongodb::Collection; use serde::{Deserialize, Serialize}; - -// ============================================================================ -// APPOINTMENT MODEL -// ============================================================================ - -/// The structured content stored (as a JSON string) inside -/// `appointment_data.data`. Deserializes from the camelCase keys the create -/// handler packs in. Mirrors the MedicationData pattern. -#[derive(Debug, Clone, Deserialize, Default)] -pub struct AppointmentData { - pub title: String, - pub provider: String, - #[serde(rename = "appointmentType")] - pub appointment_type: String, - #[serde(rename = "dateTime")] - pub date_time: String, - pub location: Option, - #[serde(rename = "durationMinutes")] - pub duration_minutes: Option, - pub reason: Option, - pub notes: Option, - #[serde(default = "default_status")] - pub status: String, -} - -fn default_status() -> String { - "upcoming".to_string() -} - -/// The flat, snake_case appointment representation returned by the API. Built -/// from a stored `Appointment` by deserializing its `appointment_data.data` blob. -#[derive(Debug, Serialize)] -pub struct AppointmentResponse { - pub id: String, - pub appointment_id: String, - pub user_id: String, - pub profile_id: String, - pub title: String, - pub provider: String, - pub appointment_type: String, - pub date_time: String, - pub location: Option, - pub duration_minutes: Option, - pub reason: Option, - pub notes: Option, - pub status: String, - pub created_at: String, - pub updated_at: String, -} - -impl std::convert::From for AppointmentResponse { - fn from(a: Appointment) -> Self { - let data: AppointmentData = - serde_json::from_str(&a.appointment_data.data).unwrap_or_default(); - - AppointmentResponse { - id: a.id.map(|o| o.to_hex()).unwrap_or_default(), - appointment_id: a.appointment_id, - user_id: a.user_id, - profile_id: a.profile_id, - title: data.title, - provider: data.provider, - appointment_type: data.appointment_type, - date_time: data.date_time, - location: data.location, - duration_minutes: data.duration_minutes, - reason: data.reason, - notes: data.notes, - status: data.status, - created_at: system_time_to_rfc3339(a.created_at.to_system_time()), - updated_at: system_time_to_rfc3339(a.updated_at.to_system_time()), - } - } -} - -/// Format a SystemTime as an ISO 8601 (RFC 3339) string. -fn system_time_to_rfc3339(t: std::time::SystemTime) -> String { - let secs = t - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - chrono::DateTime::from_timestamp(secs, 0) - .map(|dt| dt.to_rfc3339()) - .unwrap_or_default() -} +use mongodb::bson::{oid::ObjectId, DateTime}; +use super::health_data::EncryptedField; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Appointment { @@ -116,230 +29,3 @@ pub struct AppointmentReminder { #[serde(rename = "scheduledTime")] pub scheduled_time: String, } - -// ============================================================================ -// REQUEST TYPES -// ============================================================================ - -#[derive(Debug, Deserialize)] -pub struct CreateAppointmentRequest { - pub title: String, - pub provider: String, - pub appointment_type: String, - pub date_time: String, - pub profile_id: String, - pub location: Option, - pub duration_minutes: Option, - pub reason: Option, - pub notes: Option, - /// Defaults to "upcoming" when omitted. - pub status: Option, -} - -#[derive(Debug, Deserialize)] -pub struct UpdateAppointmentRequest { - pub title: Option, - pub provider: Option, - pub appointment_type: Option, - pub date_time: Option, - pub location: Option, - pub duration_minutes: Option, - pub reason: Option, - pub notes: Option, - pub status: Option, -} - -// ============================================================================ -// REPOSITORY -// ============================================================================ - -#[derive(Clone)] -pub struct AppointmentRepository { - collection: Collection, -} - -impl AppointmentRepository { - pub fn new(collection: Collection) -> Self { - Self { collection } - } - - pub async fn create( - &self, - appointment: Appointment, - ) -> Result> { - self.collection - .insert_one(appointment.clone(), None) - .await?; - Ok(appointment) - } - - /// List a user's appointments, optionally filtered by status (a field - /// inside the data blob — so filter post-query in Rust). - pub async fn find_by_user_filtered( - &self, - user_id: &str, - status: Option<&str>, - ) -> Result, Box> { - let filter = doc! { "userId": user_id }; - let mut cursor = self.collection.find(filter, None).await?; - let mut appointments = Vec::new(); - while let Some(appt) = cursor.next().await { - appointments.push(appt?); - } - // status lives inside the data blob; filter here. - if let Some(wanted) = status { - appointments.retain(|a| { - serde_json::from_str::(&a.appointment_data.data) - .map(|d| d.status == wanted) - .unwrap_or(false) - }); - } - Ok(appointments) - } - - pub async fn find_by_appointment_id( - &self, - appointment_id: &str, - ) -> Result, Box> { - let filter = doc! { "appointmentId": appointment_id }; - Ok(self.collection.find_one(filter, None).await?) - } - - pub async fn update_by_appointment_id( - &self, - appointment_id: &str, - updates: UpdateAppointmentRequest, - ) -> Result, Box> { - let filter = doc! { "appointmentId": appointment_id }; - let existing = self.collection.find_one(filter.clone(), None).await?; - let Some(existing) = existing else { - return Ok(None); - }; - - let mut data: AppointmentData = - serde_json::from_str(&existing.appointment_data.data).unwrap_or_default(); - - if let Some(v) = updates.title { - data.title = v; - } - if let Some(v) = updates.provider { - data.provider = v; - } - if let Some(v) = updates.appointment_type { - data.appointment_type = v; - } - if let Some(v) = updates.date_time { - data.date_time = v; - } - if let Some(v) = updates.location { - data.location = Some(v); - } - if let Some(v) = updates.duration_minutes { - data.duration_minutes = Some(v); - } - if let Some(v) = updates.reason { - data.reason = Some(v); - } - if let Some(v) = updates.notes { - data.notes = Some(v); - } - if let Some(v) = updates.status { - data.status = v; - } - - let data_json = serde_json::json!({ - "title": data.title, - "provider": data.provider, - "appointmentType": data.appointment_type, - "dateTime": data.date_time, - "location": data.location, - "durationMinutes": data.duration_minutes, - "reason": data.reason, - "notes": data.notes, - "status": data.status, - }) - .to_string(); - - let update_doc = doc! { - "appointmentData.data": data_json, - "updatedAt": DateTime::now(), - }; - - Ok(self - .collection - .find_one_and_update(filter, doc! { "$set": update_doc }, None) - .await?) - } - - pub async fn delete_by_appointment_id( - &self, - appointment_id: &str, - ) -> Result> { - let filter = doc! { "appointmentId": appointment_id }; - let result = self.collection.delete_one(filter, None).await?; - Ok(result.deleted_count > 0) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn appointment_data_deserializes_camel_case_blob() { - let json = r#"{ - "title": "Cardiology follow-up", - "provider": "Dr. House", - "appointmentType": "in-person", - "dateTime": "2026-07-01T10:00:00Z", - "location": "Main Clinic", - "durationMinutes": 30, - "reason": "check-up", - "notes": "bring results", - "status": "upcoming" - }"#; - let data: AppointmentData = serde_json::from_str(json).unwrap(); - assert_eq!(data.title, "Cardiology follow-up"); - assert_eq!(data.appointment_type, "in-person"); - assert_eq!(data.duration_minutes, Some(30)); - assert_eq!(data.status, "upcoming"); - } - - #[test] - fn appointment_data_defaults_status_when_missing() { - let json = r#"{"title":"X","provider":"Y","appointmentType":"lab","dateTime":"2026-07-01T00:00:00Z"}"#; - let data: AppointmentData = serde_json::from_str(json).unwrap(); - assert_eq!(data.status, "upcoming"); - } - - #[test] - fn appointment_response_flattens() { - let blob = serde_json::json!({ - "title": "Dentist", "provider": "Dr. Molar", "appointmentType": "in-person", - "dateTime": "2026-08-01T09:00:00Z", "location": null, - "durationMinutes": null, "reason": null, "notes": null, "status": "completed" - }) - .to_string(); - let appt = Appointment { - id: Some(ObjectId::new()), - appointment_id: "apt-1".to_string(), - user_id: "user-1".to_string(), - profile_id: "profile_user-1".to_string(), - appointment_data: EncryptedField { - data: blob, - encrypted: false, - iv: String::new(), - auth_tag: String::new(), - }, - reminders: vec![], - created_at: DateTime::now(), - updated_at: DateTime::now(), - }; - let resp: AppointmentResponse = appt.into(); - assert_eq!(resp.appointment_id, "apt-1"); - assert_eq!(resp.title, "Dentist"); - assert_eq!(resp.appointment_type, "in-person"); - assert_eq!(resp.status, "completed"); - assert!(!resp.id.is_empty()); - } -} diff --git a/backend/src/models/medication.rs b/backend/src/models/medication.rs index 7bbbadd..b39f642 100644 --- a/backend/src/models/medication.rs +++ b/backend/src/models/medication.rs @@ -124,7 +124,9 @@ pub struct MedicationData { /// The flat, snake_case medication representation returned by the API. Built from /// a stored `Medication` by deserializing its `medication_data.data` blob. -/// Timestamps are ISO 8601 strings; `active` reflects the persisted flag. +/// +/// NOTE: `active` is synthesized as `true` for now — the storage model does not +/// track active/inactive (TODO). Timestamps are ISO 8601 strings. #[derive(Debug, Serialize)] pub struct MedicationResponse { pub id: String, @@ -174,7 +176,7 @@ impl std::convert::From for MedicationResponse { end_date: data.end_date, notes: data.notes, tags: data.tags, - active: m.active, + active: true, // TODO: persist a real active flag. created_at: system_time_to_rfc3339(m.created_at.to_system_time()), updated_at: system_time_to_rfc3339(m.updated_at.to_system_time()), } @@ -210,22 +212,12 @@ pub struct Medication { pub created_at: DateTime, #[serde(rename = "updatedAt")] pub updated_at: DateTime, - /// Whether this medication is currently active. Defaults to true for old - /// documents that predate the field (see `default_true`). - #[serde(rename = "active", default = "default_true")] - pub active: bool, /// Physical pill identification (Phase 2.8 - optional) #[serde(skip_serializing_if = "Option::is_none")] pub pill_identification: Option, } -/// serde default helper — old medication documents predate the `active` field -/// and should deserialize as active. -fn default_true() -> bool { - true -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MedicationReminder { #[serde(rename = "reminderId")] @@ -273,9 +265,6 @@ pub struct CreateMedicationRequest { pub tags: Option>, pub reminder_times: Option>, pub profile_id: String, - /// Whether the medication is active on creation (defaults to true). - #[serde(default = "default_true")] - pub active: bool, /// Pill identification (Phase 2.8 - optional) #[serde(rename = "pill_identification")] @@ -298,8 +287,6 @@ pub struct UpdateMedicationRequest { pub notes: Option, pub tags: Option>, pub reminder_times: Option>, - /// Set the medication active/inactive. - pub active: Option, /// Pill identification (Phase 2.8 - optional) #[serde(rename = "pill_identification")] @@ -349,29 +336,6 @@ impl MedicationRepository { Ok(medications) } - /// Like find_by_user, but optionally filtered by `active` and/or `profile_id` - /// at the Mongo level (these are real top-level document fields). - pub async fn find_by_user_filtered( - &self, - user_id: &str, - active: Option, - profile_id: Option<&str>, - ) -> Result, Box> { - let mut filter = doc! { "userId": user_id }; - if let Some(active) = active { - filter.insert("active", active); - } - if let Some(pid) = profile_id { - filter.insert("profileId", pid); - } - let mut cursor = self.collection.find(filter, None).await?; - let mut medications = Vec::new(); - while let Some(medication) = cursor.next().await { - medications.push(medication?); - } - Ok(medications) - } - pub async fn find_by_user_and_profile( &self, user_id: &str, @@ -464,7 +428,7 @@ impl MedicationRepository { if let Some(v) = updates.tags { data.tags = v; } - // `active` lives on the document, not in the data blob. + // NOTE: updates.active is accepted by the API but not yet persisted. let data_json = serde_json::json!({ "name": data.name, @@ -487,9 +451,6 @@ impl MedicationRepository { "medicationData.data": data_json, "updatedAt": DateTime::now(), }; - if let Some(active) = updates.active { - update_doc.insert("active", active); - } if let Some(pill_identification) = updates.pill_identification { if let Ok(pill_doc) = mongodb::bson::to_document(&pill_identification) { update_doc.insert("pill_identification", pill_doc); @@ -571,7 +532,8 @@ impl MedicationRepository { if let Some(v) = updates.tags { data.tags = v; } - // `active` lives on the document, not in the data blob. + // NOTE: updates.active is accepted by the API but not yet persisted — + // the storage model has no active column (TODO). // Re-serialize with the SAME camelCase keys the create handler uses, so // reads stay consistent. @@ -596,9 +558,6 @@ impl MedicationRepository { "medicationData.data": data_json, "updatedAt": DateTime::now(), }; - if let Some(active) = updates.active { - update_doc.insert("active", active); - } if let Some(pill_identification) = updates.pill_identification { if let Ok(pill_doc) = mongodb::bson::to_document(&pill_identification) { update_doc.insert("pill_identification", pill_doc); @@ -687,7 +646,6 @@ mod tests { reminders: vec![], created_at: DateTime::now(), updated_at: DateTime::now(), - active: true, pill_identification: None, }; @@ -716,7 +674,6 @@ mod tests { reminders: vec![], created_at: DateTime::now(), updated_at: DateTime::now(), - active: true, pill_identification: None, }; // Should NOT panic — falls back to defaults. diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index bc326e8..c5dba69 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,4 +1,3 @@ -pub mod appointment; pub mod audit_log; pub mod family; pub mod health_data; diff --git a/web/normogen-web/src/components/appointments/AppointmentsManager.tsx b/web/normogen-web/src/components/appointments/AppointmentsManager.tsx deleted file mode 100644 index 7391943..0000000 --- a/web/normogen-web/src/components/appointments/AppointmentsManager.tsx +++ /dev/null @@ -1,395 +0,0 @@ -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(emptyCreate); - const [editTarget, setEditTarget] = useState(null); - const [editForm, setEditForm] = useState({}); - const [deleteTarget, setDeleteTarget] = useState(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 ( - - - Appointments - - - - {error && ( - - {error} - - )} - - {isLoading ? ( - - - - ) : appointments.length === 0 ? ( - - No appointments yet — add one. - - ) : ( - - {appointments.map((appt) => ( - - - - - - - {appt.title} - {' '} - - · {appt.provider} - - - - - - - {format(new Date(appt.date_time), 'MMM d, yyyy HH:mm')} - {appt.location ? ` · ${appt.location}` : ''} - - {appt.notes && ( - - {appt.notes} - - )} - - - - - openEdit(appt)} aria-label="edit"> - - - setDeleteTarget(appt)} - aria-label="delete" - color="error" - > - - - - - - ))} - - )} - - {/* Create dialog */} - setCreateOpen(false)} fullWidth maxWidth="sm"> - Add appointment - - - setCreateForm({ ...createForm, title: e.target.value })} - /> - setCreateForm({ ...createForm, provider: e.target.value })} - /> - - - setCreateForm({ ...createForm, appointment_type: e.target.value }) - } - > - {APPT_TYPES.map((t) => ( - {t} - ))} - - - setCreateForm({ - ...createForm, - date_time: new Date(e.target.value).toISOString(), - }) - } - /> - - - setCreateForm({ ...createForm, location: e.target.value || undefined }) - } - /> - - setCreateForm({ ...createForm, notes: e.target.value || undefined }) - } - /> - - - - - - - - - {/* Edit dialog */} - setEditTarget(null)} fullWidth maxWidth="sm"> - Edit appointment - - - setEditForm({ ...editForm, title: e.target.value })} - /> - setEditForm({ ...editForm, provider: e.target.value })} - /> - - - setEditForm({ ...editForm, appointment_type: e.target.value }) - } - > - {APPT_TYPES.map((t) => ( - {t} - ))} - - setEditForm({ ...editForm, status: e.target.value })} - > - {STATUSES.map((s) => ( - {s} - ))} - - - - setEditForm({ - ...editForm, - date_time: e.target.value - ? new Date(e.target.value).toISOString() - : undefined, - }) - } - /> - setEditForm({ ...editForm, notes: e.target.value })} - /> - - - - - - - - - {/* Delete confirm */} - setDeleteTarget(null)}> - Delete appointment? - - Remove {deleteTarget?.title}? This cannot be undone. - - - - - - - - ); -}; - -export default AppointmentsManager; diff --git a/web/normogen-web/src/pages/Dashboard.tsx b/web/normogen-web/src/pages/Dashboard.tsx index dfabf52..94921f3 100644 --- a/web/normogen-web/src/pages/Dashboard.tsx +++ b/web/normogen-web/src/pages/Dashboard.tsx @@ -6,9 +6,8 @@ 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 | 4; +type TabIndex = 0 | 1 | 2 | 3; export const Dashboard: FC = () => { const navigate = useNavigate(); @@ -51,7 +50,6 @@ export const Dashboard: FC = () => { - @@ -59,8 +57,7 @@ export const Dashboard: FC = () => { {tab === 0 && } {tab === 1 && } {tab === 2 && } - {tab === 3 && } - {tab === 4 && } + {tab === 3 && } diff --git a/web/normogen-web/src/services/api.ts b/web/normogen-web/src/services/api.ts index 3cf213b..79b31bd 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -18,9 +18,6 @@ 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 @@ -265,34 +262,6 @@ class ApiService { return response.data; } - // ---- Appointments ---- - - async getAppointments(status?: string): Promise { - const response = await this.client.get('/appointments', { - params: status ? { status } : undefined, - }); - return response.data; - } - - async getAppointment(id: string): Promise { - const response = await this.client.get(`/appointments/${id}`); - return response.data; - } - - async createAppointment(data: CreateAppointmentRequest): Promise { - const response = await this.client.post('/appointments', data); - return response.data; - } - - async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise { - const response = await this.client.post(`/appointments/${id}`, data); - return response.data; - } - - async deleteAppointment(id: string): Promise { - await this.client.post(`/appointments/${id}/delete`); - } - // ---- Drug Interactions (Phase 2.8) ---- async checkInteractions(medications: string[]): Promise { diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index d471874..36fcda8 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -7,9 +7,6 @@ import { DrugInteraction, AdherenceStats, Profile, - Appointment, - CreateAppointmentRequest, - UpdateAppointmentRequest, } from '../types/api'; import apiService from '../services/api'; @@ -83,17 +80,6 @@ interface ProfileState { clearError: () => void; } -interface AppointmentState { - appointments: Appointment[]; - isLoading: boolean; - error: string | null; - loadAppointments: (status?: string) => Promise; - createAppointment: (data: CreateAppointmentRequest) => Promise; - updateAppointment: (id: string, data: UpdateAppointmentRequest) => Promise; - deleteAppointment: (id: string) => Promise; - clearError: () => void; -} - // Auth Store export const useAuthStore = create()( devtools( @@ -480,70 +466,3 @@ export const useProfileStore = create()( clearError: () => set({ error: null }), })), ); - -// Appointment Store -export const useAppointmentStore = create()( - 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 }), - })), -); diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 6ba868e..1b5711d 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -244,50 +244,6 @@ 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;