Files
helix-engage/src/components/call-desk/appointment-form.tsx
saridsa2 05de50f796 fix: remove hardcoded clinic list, fetch from platform dynamically
Appointment form clinic dropdown was hardcoded to 3 "Global Hospital"
branches. Replaced with a GraphQL query to { clinics } so each
workspace shows its own clinics. If no clinics are configured, the
dropdown is empty instead of showing wrong data.

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

632 lines
28 KiB
TypeScript

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;
reasonForVisit?: string;
status: string;
};
type AppointmentFormProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
callerNumber?: string | null;
leadName?: string | null;
leadId?: string | null;
patientId?: string | null;
onSaved?: () => 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' },
];
const timeSlotItems = [
{ id: '09:00', label: '9:00 AM' },
{ id: '09:30', label: '9:30 AM' },
{ id: '10:00', label: '10:00 AM' },
{ id: '10:30', label: '10:30 AM' },
{ id: '11:00', label: '11:00 AM' },
{ id: '11:30', label: '11:30 AM' },
{ id: '14:00', label: '2:00 PM' },
{ id: '14:30', label: '2:30 PM' },
{ id: '15:00', label: '3:00 PM' },
{ id: '15:30', label: '3:30 PM' },
{ id: '16:00', label: '4:00 PM' },
];
const formatDeptLabel = (dept: string) =>
dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
export const AppointmentForm = ({
isOpen,
onOpenChange,
callerNumber,
leadName,
leadId,
patientId,
onSaved,
existingAppointment,
}: 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);
const [clinic, setClinic] = useState<string | null>(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('');
// 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
// doctorVisitSlots reverse relation so the agent can see which
// clinics + days this doctor covers in the picker.
// Fetch clinics from platform
useEffect(() => {
if (!isOpen) return;
apiClient.graphql<{ clinics: { edges: Array<{ node: { id: string; clinicName: string } }> } }>(
`{ clinics(first: 50) { edges { node { id clinicName } } } }`,
).then(data => {
setClinicItems(
data.clinics.edges.map(e => ({ id: e.node.id, label: e.node.clinicName || 'Unnamed Clinic' })),
);
}).catch(() => {});
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
`{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } department
doctorVisitSlots(first: 50) {
edges { node { id clinic { id clinicName } dayOfWeek startTime endTime } }
}
} } } }`,
).then(data => {
const docs = data.doctors.edges.map(e => {
// Flatten the visit-slot list into a comma-separated
// clinic summary for display. Keep full slot data on
// the record in case future UX needs it (e.g., show
// only slots matching the selected date's weekday).
const slotEdges: Array<{ node: any }> = e.node.doctorVisitSlots?.edges ?? [];
const clinicNames = Array.from(
new Set(
slotEdges
.map((se) => se.node.clinic?.clinicName ?? se.node.clinicId)
.filter((n): n is string => !!n),
),
);
return {
id: e.node.id,
name: e.node.fullName
? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim()
: e.node.name,
department: e.node.department ?? '',
// `clinic` here is a display-only summary: "Koramangala, Whitefield"
// or empty if the doctor has no slots yet.
clinic: clinicNames.join(', '),
};
});
setDoctors(docs);
}).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]);
// Derive department and doctor lists from fetched data
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
.map(dept => ({ id: dept, label: formatDeptLabel(dept) }));
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
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,
},
},
);
notify.success('Appointment Updated');
} else {
// Create appointment
await apiClient.graphql(
`mutation CreateAppointment($data: AppointmentCreateInput!) {
createAppointment(data: $data) { id }
}`,
{
data: {
scheduledAt,
durationMin: 30,
appointmentType: 'CONSULTATION',
status: 'SCHEDULED',
doctorName: selectedDoctor?.name ?? '',
department: selectedDoctor?.department ?? '',
doctorId: doctor,
reasonForVisit: chiefComplaint || null,
...(patientId ? { patientId } : {}),
},
},
);
// 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 ONLY if the agent explicitly renamed.
// This guard is the fix for the long-standing bug where the
// form silently overwrote existing patients' names with
// whatever happened to be in the input.
if (nameChanged && patientId) {
await apiClient.graphql(
`mutation UpdatePatient($id: UUID!, $data: PatientUpdateInput!) {
updatePatient(id: $id, data: $data) { id }
}`,
{
id: patientId,
data: {
fullName: { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' },
},
},
).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 AND invalidate the Redis caller
// resolution cache so the next incoming call from this
// phone picks up fresh data. Both are fire-and-forget —
// the save toast fires immediately either way.
if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {});
} else if (callerNumber) {
// No rename but still invalidate the cache so status +
// lastContacted updates propagate cleanly to the next
// lookup.
apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {});
}
}
onSaved?.();
} 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?.();
} 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={!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={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={!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={!doctor}
/>
</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={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}
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 && (
<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>
<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>
);
};