Files
helix-engage/src/components/call-desk/appointment-form.tsx
saridsa2 ca482e731e feat: Contacts page + P360 for all tabs + dynamic column toggle + slot flicker fix
Contacts page:
 - New /contacts route — shows leads with source=PHONE/WALK_IN/REFERRAL
 - Leads page now excludes those sources (campaign-sourced only)
 - Sidebar: Contacts nav item added for all roles; Leads added for cc-agent
 - Same LeadTable + pagination + CSV export pattern as All Leads

P360 context panel for all worklist tabs (#6-10):
 - WorklistPanel: onSelectLead → onSelectItem (generic WorklistSelection)
 - call-desk: handleSelectItem builds ContextPanelSubject for any row type
 - ContextPanelSubject type replaces (lead as any).patientId casts
 - Highlight tracks row.id (mc-*/fu-*/lead-*) not lead.id

Dynamic column toggle (blank-screen fix):
 - missed-calls + call-recordings refactored to React Aria dynamic
   collections API (Table.Header columns={} + Table.Row columns={})
 - Fixes "Cell count must match column count" crash on column hide
 - Row-header column metadata in columnDefs instead of hardcoded JSX

Slot flickering fix (#2):
 - Removed clinic + timeSlot from slot-fetch useEffect deps (circular
   loop: effect sets clinic → clinic in deps → re-fires)
 - Memoized timeSlotSelectItems

Other:
 - GlobalSearch hidden (stale appointment state on navigation)
 - Branch column: shows campaign name from relation, falls back to DID
 - formatSource maps PHONE → "Phone"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:55:57 +05:30

719 lines
34 KiB
TypeScript

import { useState, useEffect, useMemo } 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, today, getLocalTimeZone } 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;
// When true, the form shows the existing appointment's data in a
// disabled state — no input editing, no Save/Cancel. Only a Close
// button. Used by the reschedule-confirm flow when the agent picks
// "No, just view" on an upcoming-appointment pill.
readOnly?: boolean;
};
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,
readOnly = false,
}: AppointmentFormProps) => {
const isEditMode = !!existingAppointment;
// Doctor data from platform
const [doctors, setDoctors] = useState<DoctorRecord[]>([]);
// 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<string | null>(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<string | null>(existingAppointment?.clinicId ?? null);
const [clinicItems, setClinicItems] = useState<Array<{ id: string; label: string }>>([]);
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
const [doctor, setDoctor] = useState<string | null>(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<string | null>(() => {
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<Array<{ id: string; label: string }>>([]);
// Fetch available time slots when doctor + date change
useEffect(() => {
if (!doctor || !date) {
setTimeSlotItems([]);
return;
}
apiClient.get<Array<{ time: string; label: string; clinicId: string; clinicName: string }>>(
`/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([]));
// eslint-disable-next-line react-hooks/exhaustive-deps — clinic and timeSlot
// deliberately excluded. Including clinic causes a loop: the effect calls
// setClinic() for auto-selection → clinic changes → effect re-fires → loop.
// timeSlot is only needed for the synthetic "current" option injection which
// is a read, not a trigger. Re-fetch should only happen on doctor/date change.
}, [doctor, date]);
// Availability state
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(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<Array<{ id: string; name: string; phone: string; address: string }>>('/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<Array<{ id: string; name: string; department: string; qualifications: string }>>('/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<Array<{ id: string; label: string }>>([]);
useEffect(() => {
if (!isOpen) return;
apiClient.get<string[]>('/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;
// Always include the currently-selected doctor even if the department
// filter would exclude them. Needed for edit mode: the saved
// Appointment.department may be stored as a display string ("ENT") or
// a legacy value that doesn't match the doctor's current department
// enum — without this, the Select renders blank.
const doctorSelectItems = useMemo(() => {
const items = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
if (doctor && !items.some(i => i.id === doctor)) {
const selected = doctors.find(d => d.id === doctor);
if (selected) items.unshift({ id: selected.id, label: selected.name });
}
return items;
}, [filteredDoctors, doctors, doctor]);
const timeSlotSelectItems = useMemo(() => timeSlotItems.map(slot => ({
...slot,
isDisabled: bookedSlots.includes(slot.id),
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
})), [timeSlotItems, bookedSlots]);
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<string, any> = {
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<string, any> = {
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 (
<div className="flex flex-col flex-1 min-h-0">
{/* Form fields — scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-3">
{/* Patient Info — only for new appointments */}
{!isEditMode && (
<>
<div className="flex flex-col gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">
Patient Information
</span>
</div>
{/* 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. */}
<div className="flex items-end gap-2">
<div className="flex-1">
<Input
label="Patient Name"
placeholder="Full name"
value={patientName}
onChange={setPatientName}
isDisabled={readOnly || !isNameEditable}
/>
</div>
{!isNameEditable && initialLeadName.length > 0 && (
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faUserPen} className={className} />
)}
onClick={() => setEditConfirmOpen(true)}
>
Edit
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Phone"
placeholder="Phone number"
value={patientPhone}
onChange={setPatientPhone}
/>
<Input
label="Age"
placeholder="Age"
type="number"
value={age}
onChange={setAge}
/>
</div>
<Select
label="Gender"
placeholder="Select gender"
items={genderItems}
selectedKey={gender}
onSelectionChange={(key) => setGender(key as string)}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<div className="border-t border-secondary" />
</>
)}
{/* Appointment Details */}
<div className="flex flex-col gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">
Appointment Details
</span>
</div>
{!isEditMode && (
<Select
label="Clinic / Branch"
placeholder="Select clinic"
items={clinicItems}
selectedKey={clinic}
onSelectionChange={(key) => setClinic(key as string)}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
)}
<div className="grid grid-cols-2 gap-3">
<Select
label="Department *"
placeholder={doctors.length === 0 ? 'Loading...' : 'Select department'}
items={departmentItems}
selectedKey={department}
onSelectionChange={(key) => setDepartment(key as string)}
isDisabled={readOnly || doctors.length === 0}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<Select
label="Doctor *"
placeholder={!department ? 'Select department first' : 'Select doctor'}
items={doctorSelectItems}
selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)}
isDisabled={readOnly || !department}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
<DatePicker
value={date ? parseDate(date) : null}
onChange={(val) => setDate(val ? val.toString() : '')}
granularity="day"
isDisabled={readOnly || !doctor}
// Block past dates — appointments can't be booked or
// rescheduled into the past. React Aria's DatePicker
// honours minValue in both the calendar grid and the
// typed-input fallback.
minValue={today(getLocalTimeZone())}
/>
</div>
{/* Time slot grid */}
{doctor && date && (
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-secondary">
{loadingSlots ? 'Checking availability...' : 'Available Slots'}
</span>
<div className="grid grid-cols-4 gap-1.5">
{timeSlotSelectItems.map(slot => {
const isBooked = slot.isDisabled;
const isSelected = timeSlot === slot.id;
return (
<button
key={slot.id}
type="button"
disabled={readOnly || isBooked}
onClick={() => setTimeSlot(slot.id)}
className={cx(
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
isBooked
? 'cursor-not-allowed bg-disabled text-disabled line-through'
: isSelected
? 'bg-brand-solid text-white ring-2 ring-brand'
: 'cursor-pointer bg-secondary text-secondary hover:bg-secondary_hover hover:text-secondary_hover',
)}
>
{timeSlotItems.find(t => t.id === slot.id)?.label ?? slot.id}
</button>
);
})}
</div>
</div>
)}
{!doctor || !date ? (
<p className="text-xs text-tertiary">Select a doctor and date to see available time slots</p>
) : null}
<TextArea
label="Chief Complaint"
placeholder="Describe the reason for visit..."
value={chiefComplaint}
onChange={setChiefComplaint}
isDisabled={readOnly}
rows={2}
/>
{/* Additional Info — only for new appointments */}
{!isEditMode && (
<>
<div className="border-t border-secondary" />
<Input
label="Source / Referral"
placeholder="How did the patient reach us?"
value={source}
onChange={setSource}
/>
<TextArea
label="Agent Notes"
placeholder="Any additional notes..."
value={agentNotes}
onChange={setAgentNotes}
rows={2}
/>
</>
)}
{error && (
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">
{error}
</div>
)}
</div>
</div>
{/* Footer — pinned */}
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
<div>
{isEditMode && !readOnly && (
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
Cancel Appointment
</Button>
)}
</div>
<div className="flex items-center gap-3">
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
Close
</Button>
{!readOnly && (
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
</Button>
)}
</div>
</div>
<EditPatientConfirmModal
isOpen={editConfirmOpen}
onOpenChange={setEditConfirmOpen}
onConfirm={() => {
setIsNameEditable(true);
setEditConfirmOpen(false);
}}
description={
<>
You&apos;re about to change the name on this patient&apos;s record. This will
update their profile across Helix Engage, including past appointments,
lead history, and AI summary. Only proceed if the current name is
actually wrong for all other cases, cancel and continue with the
appointment as-is.
</>
}
/>
</div>
);
};