import { useState, useEffect } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faUserPen } from '@fortawesome/pro-duotone-svg-icons'; import { Input } from '@/components/base/input/input'; import { Select } from '@/components/base/select/select'; import { TextArea } from '@/components/base/textarea/textarea'; import { Button } from '@/components/base/buttons/button'; import { DatePicker } from '@/components/application/date-picker/date-picker'; import { parseDate } from '@internationalized/date'; import { apiClient } from '@/lib/api-client'; import { cx } from '@/utils/cx'; import { notify } from '@/lib/toast'; import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal'; type ExistingAppointment = { id: string; scheduledAt: string; doctorName: string; doctorId?: string; department: string; clinicId?: string; reasonForVisit?: string; status: string; }; type AppointmentFormProps = { isOpen: boolean; onOpenChange: (open: boolean) => void; callerNumber?: string | null; leadName?: string | null; leadId?: string | null; patientId?: string | null; // Called after a successful save. Passes back what actually happened so // the parent can pre-lock the disposition (BOOKED vs RESCHEDULED vs // CANCELLED each map to distinct disposition outcomes). onSaved?: (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => void; existingAppointment?: ExistingAppointment | null; }; type DoctorRecord = { id: string; name: string; department: string; clinic: string }; // Clinics are fetched dynamically from the platform — no hardcoded list. // If the workspace has no clinics configured, the dropdown shows empty. const genderItems = [ { id: 'male', label: 'Male' }, { id: 'female', label: 'Female' }, { id: 'other', label: 'Other' }, ]; // Time slots are fetched from /api/masterdata/slots based on // doctor + date. No hardcoded times. export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName, leadId, patientId, onSaved, existingAppointment, }: AppointmentFormProps) => { const isEditMode = !!existingAppointment; // Doctor data from platform const [doctors, setDoctors] = useState([]); // Initial name captured at form open — used to detect whether the // agent actually changed the name before we commit any destructive // updatePatient / updateLead.contactName mutations. const initialLeadName = (leadName ?? '').trim(); // Form state — initialized from existing appointment in edit mode const [patientName, setPatientName] = useState(leadName ?? ''); // The patient-name input is locked by default when there's an // existing caller name (to prevent accidental rename-on-save), and // unlocked only after the agent clicks the Edit button and confirms // in the warning modal. First-time callers with no existing name // start unlocked because there's nothing to protect. const [isNameEditable, setIsNameEditable] = useState(initialLeadName.length === 0); const [editConfirmOpen, setEditConfirmOpen] = useState(false); const [patientPhone, setPatientPhone] = useState(callerNumber ?? ''); const [age, setAge] = useState(''); const [gender, setGender] = useState(null); // Preload clinic from the existing appointment when editing — so the // select lands on the right branch instead of being empty and forcing // the agent to re-pick. Only historical rows that predate clinicId // persistence will fall through to the auto-select-from-slot logic. const [clinic, setClinic] = useState(existingAppointment?.clinicId ?? null); const [clinicItems, setClinicItems] = useState>([]); const [department, setDepartment] = useState(existingAppointment?.department ?? null); const [doctor, setDoctor] = useState(existingAppointment?.doctorId ?? null); const [date, setDate] = useState(() => { if (existingAppointment?.scheduledAt) return existingAppointment.scheduledAt.split('T')[0]; return new Date().toISOString().split('T')[0]; }); const [timeSlot, setTimeSlot] = useState(() => { if (existingAppointment?.scheduledAt) { const dt = new Date(existingAppointment.scheduledAt); return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`; } return null; }); const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? ''); const [source, setSource] = useState('Inbound Call'); const [agentNotes, setAgentNotes] = useState(''); const [timeSlotItems, setTimeSlotItems] = useState>([]); // Fetch available time slots when doctor + date change useEffect(() => { if (!doctor || !date) { setTimeSlotItems([]); return; } apiClient.get>( `/api/masterdata/slots?doctorId=${doctor}&date=${date}`, ).then(slots => { // Filter by selected clinic — doctor may visit multiple branches const filtered = clinic ? slots.filter(s => s.clinicId === clinic) : slots; let items = filtered.map(s => ({ id: s.time, label: s.label })); // In edit mode, the saved timeSlot may have been filtered out // (past-slot filter, schedule change, clinic mismatch). Inject // it as a synthetic option so the dropdown still shows the // existing value — otherwise the agent sees a cleared field // and assumes the save-time was lost. if (timeSlot && !items.some(i => i.id === timeSlot)) { items = [{ id: timeSlot, label: `${timeSlot} (current)` }, ...items]; } setTimeSlotItems(items); // Auto-select clinic from the slot's clinic only if no clinic chosen if (filtered.length === 0 && slots.length > 0 && !clinic) { setClinic(slots[0].clinicId); const autoItems = slots.filter(s => s.clinicId === slots[0].clinicId).map(s => ({ id: s.time, label: s.label })); if (timeSlot && !autoItems.some(i => i.id === timeSlot)) { autoItems.unshift({ id: timeSlot, label: `${timeSlot} (current)` }); } setTimeSlotItems(autoItems); } }).catch(() => setTimeSlotItems([])); }, [doctor, date, clinic, timeSlot]); // Availability state const [bookedSlots, setBookedSlots] = useState([]); const [loadingSlots, setLoadingSlots] = useState(false); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); // Fetch doctors on mount. Doctors are hospital-wide — no single // `clinic` field anymore. We pull the full visit-slot list via the // Fetch clinics + doctors from the master data endpoint (Redis-cached). // This is faster than direct GraphQL and returns pre-formatted data. useEffect(() => { if (!isOpen) return; apiClient.get>('/api/masterdata/clinics') .then(clinics => { setClinicItems(clinics.map(c => ({ id: c.id, label: c.name || 'Unnamed Clinic' }))); }).catch(() => {}); }, [isOpen]); useEffect(() => { if (!isOpen) return; apiClient.get>('/api/masterdata/doctors') .then(docs => { setDoctors(docs.map(d => ({ id: d.id, name: d.name, department: d.department, clinic: '', // clinic assignment via visit slots, not on doctor directly }))); }).catch(() => {}); }, [isOpen]); // Fetch booked slots when doctor + date selected useEffect(() => { if (!doctor || !date) { setBookedSlots([]); return; } setLoadingSlots(true); apiClient.graphql<{ appointments: { edges: Array<{ node: any }> } }>( `{ appointments(filter: { doctorId: { eq: "${doctor}" }, scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" } }) { edges { node { id scheduledAt durationMin status } } } }`, ).then(data => { // Filter out cancelled/completed appointments client-side const activeAppointments = data.appointments.edges.filter(e => { const status = e.node.status; return status !== 'CANCELLED' && status !== 'COMPLETED' && status !== 'NO_SHOW'; }); const slots = activeAppointments.map(e => { const dt = new Date(e.node.scheduledAt); return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`; }); // In edit mode, don't block the current appointment's slot if (isEditMode && existingAppointment) { const currentDt = new Date(existingAppointment.scheduledAt); const currentSlot = `${currentDt.getHours().toString().padStart(2, '0')}:${currentDt.getMinutes().toString().padStart(2, '0')}`; setBookedSlots(slots.filter(s => s !== currentSlot)); } else { setBookedSlots(slots); } }).catch(() => setBookedSlots([])) .finally(() => setLoadingSlots(false)); }, [doctor, date, isEditMode, existingAppointment]); // Reset doctor when department changes useEffect(() => { setDoctor(null); setTimeSlot(null); }, [department]); // Reset time slot when doctor or date changes useEffect(() => { setTimeSlot(null); }, [doctor, date]); // Departments from master data (or fallback to deriving from doctors) const [departmentItems, setDepartmentItems] = useState>([]); useEffect(() => { if (!isOpen) return; apiClient.get('/api/masterdata/departments') .then(depts => setDepartmentItems(depts.map(d => ({ id: d, label: d })))) .catch(() => { // Fallback: derive from doctor list const derived = [...new Set(doctors.map(d => d.department).filter(Boolean))]; setDepartmentItems(derived.map(d => ({ id: d, label: d }))); }); }, [isOpen, doctors]); const filteredDoctors = department ? doctors.filter(d => d.department === department) : doctors; const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name })); const timeSlotSelectItems = timeSlotItems.map(slot => ({ ...slot, isDisabled: bookedSlots.includes(slot.id), label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label, })); const handleSave = async () => { if (!date || !timeSlot || !doctor || !department) { setError('Please fill in the required fields: date, time, doctor, and department.'); return; } const today = new Date().toISOString().split('T')[0]; if (!isEditMode && date < today) { setError('Appointment date cannot be in the past.'); return; } setIsSaving(true); setError(null); try { const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString(); const selectedDoctor = doctors.find(d => d.id === doctor); if (isEditMode && existingAppointment) { // Update existing appointment. Flip status to RESCHEDULED so // the Appointments > Rescheduled tab reflects it and the // patient timeline records the reschedule event. await apiClient.graphql( `mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`, { id: existingAppointment.id, data: { scheduledAt, doctorName: selectedDoctor?.name ?? '', department: selectedDoctor?.department ?? '', doctorId: doctor, reasonForVisit: chiefComplaint || null, status: 'RESCHEDULED', }, }, ); // Propagate name change during reschedule. Same gate as the // create branch — nameChanged implies isNameEditable=true, // which means the agent went through EditPatientConfirmModal. const trimmedName = patientName.trim(); const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName; if (nameChanged) { const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' }; if (patientId) { await apiClient.graphql( `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, { id: patientId, data: { fullName: nameParts } }, ).catch((err: unknown) => console.warn('Failed to update patient name:', err)); } if (leadId) { await apiClient.graphql( `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, { id: leadId, data: { contactName: nameParts } }, ).catch((err: unknown) => console.warn('Failed to update lead name:', err)); } } notify.success('Appointment Updated'); } else { // If no patient record exists yet (new caller), create one now let resolvedPatientId = patientId; if (!resolvedPatientId && callerNumber) { const trimmedName = patientName.trim(); const nameParts = { firstName: trimmedName.split(' ')[0] || '', lastName: trimmedName.split(' ').slice(1).join(' ') || '', }; // Normalize phone to +91XXXXXXXXXX format const phoneDigits = callerNumber.replace(/\D/g, '').slice(-10); const phoneE164 = `+91${phoneDigits}`; try { const patientData: Record = { fullName: nameParts, phones: { primaryPhoneNumber: phoneE164 }, patientType: 'NEW', }; if (age) patientData.dateOfBirth = new Date(Date.now() - parseInt(age) * 365.25 * 86400000).toISOString().split('T')[0]; if (gender) patientData.gender = gender.toUpperCase(); const created = await apiClient.graphql<{ createPatient: { id: string } }>( `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, { data: patientData }, ); resolvedPatientId = created.createPatient.id; } catch (err) { console.warn('Failed to create patient:', err); } } // Create appointment const appointmentData: Record = { scheduledAt, durationMin: 30, appointmentType: 'CONSULTATION', status: 'SCHEDULED', doctorName: selectedDoctor?.name ?? '', department: selectedDoctor?.department ?? '', doctorId: doctor, reasonForVisit: chiefComplaint || null, ...(resolvedPatientId ? { patientId: resolvedPatientId } : {}), ...(clinic ? { clinicId: clinic } : {}), ...(agentNotes ? { agentNotes } : {}), ...(source ? { source } : {}), }; console.log('[APPOINTMENT] Creating appointment:', JSON.stringify(appointmentData)); await apiClient.graphql( `mutation CreateAppointment($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`, { data: appointmentData }, ); // Determine whether the agent actually renamed the patient. // Only a non-empty, changed-from-initial name counts — empty // strings or an unchanged name never trigger the rename // chain, even if the field was unlocked. const trimmedName = patientName.trim(); const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName; // Update patient name when the agent explicitly renamed. // `nameChanged` already requires isNameEditable=true (the // agent went through EditPatientConfirmModal), so the // rename intent is unambiguous. Bug #527's silent-overwrite // case can no longer happen because the confirm modal // gates the input. if (nameChanged && patientId) { const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' }; apiClient.graphql( `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, { id: patientId, data: { fullName: nameParts } }, ).catch((err: unknown) => console.warn('Failed to update patient name:', err)); } // Update lead status/lastContacted on every appointment book // (those are genuinely about this appointment), but only // touch lead.contactName if the agent explicitly renamed. // // NOTE: field name is `status`, NOT `leadStatus` — the // staging platform schema renamed this. The old name is // rejected by LeadUpdateInput. if (leadId) { await apiClient.graphql( `mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, { id: leadId, data: { status: 'APPOINTMENT_SET', lastContacted: new Date().toISOString(), ...(nameChanged ? { contactName: { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' } } : {}), }, }, ).catch((err: unknown) => console.warn('Failed to update lead:', err)); } // If the agent actually renamed the patient, kick off the // side-effect chain: regenerate the AI summary against the // corrected identity. Fire-and-forget; the save toast // fires immediately regardless. if (nameChanged && leadId) { apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {}); } } onSaved?.(isEditMode ? 'RESCHEDULED' : 'BOOKED'); } catch (err) { console.error('Failed to save appointment:', err); setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.'); } finally { setIsSaving(false); } }; const handleCancel = async () => { if (!existingAppointment) return; setIsSaving(true); try { await apiClient.graphql( `mutation CancelAppointment($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`, { id: existingAppointment.id, data: { status: 'CANCELLED' }, }, ); notify.success('Appointment Cancelled'); onSaved?.('CANCELLED'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to cancel appointment'); } finally { setIsSaving(false); } }; if (!isOpen) return null; return (
{/* Form fields — scrollable */}
{/* Patient Info — only for new appointments */} {!isEditMode && ( <>
Patient Information
{/* Patient name — locked by default for existing callers, unlocked for new callers with no prior name on record. The Edit button opens a confirm modal before unlocking; see EditPatientNameModal for the rationale. */}
{!isNameEditable && initialLeadName.length > 0 && ( )}
)} {/* Appointment Details */}
Appointment Details
{!isEditMode && ( )}
Date * setDate(val ? val.toString() : '')} granularity="day" isDisabled={!doctor} />
{/* Time slot grid */} {doctor && date && (
{loadingSlots ? 'Checking availability...' : 'Available Slots'}
{timeSlotSelectItems.map(slot => { const isBooked = slot.isDisabled; const isSelected = timeSlot === slot.id; return ( ); })}
)} {!doctor || !date ? (

Select a doctor and date to see available time slots

) : null}