fix(appointment-form): filter past pills + confirm modal + view-only mode

- leadAppointments now filters out past appointments — past dates
  can't be rescheduled, so "14 Apr · Meena Patel" shouldn't appear in
  the pill row today. Uses scheduledAt >= now.
- Click Edit pill → reschedule-confirm modal:
    "Yes, reschedule" → form opens in edit mode (prefilled + editable)
    "No, just view"    → form opens read-only (prefilled + disabled)
- Prefill was broken — AppointmentForm's useState initializers only
  run at mount, so switching pills didn't re-seed state. Added
  key={editingApptId}-{apptMode} so the form fully remounts whenever
  the selection or mode changes.
- Thread readOnly prop through every form control (patient name,
  phone, age, gender, clinic, department, doctor, date, time slots,
  chief complaint). In view mode all inputs are disabled and the
  Update Appointment + Cancel Appointment buttons hide — only Close
  remains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 13:39:54 +05:30
parent ffb8bcb6ad
commit 04f559037c
2 changed files with 93 additions and 12 deletions

View File

@@ -14,6 +14,7 @@ import { setOutboundPending } from '@/state/sip-manager';
import { useSip } from '@/providers/sip-provider';
import { DispositionModal } from './disposition-modal';
import type { CallAction } from './disposition-modal';
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
import { AppointmentForm } from './appointment-form';
import { TransferDialog } from './transfer-dialog';
import { EnquiryForm } from './enquiry-form';
@@ -74,12 +75,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const leadAppointments = useMemo(() => {
const patientId = (lead as any)?.patientId;
if (!patientId) return [];
const now = Date.now();
return appointments
.filter((a) =>
a.patientId === patientId
&& a.appointmentStatus !== 'CANCELLED'
&& a.appointmentStatus !== 'NO_SHOW'
&& a.appointmentStatus !== 'COMPLETED',
&& a.appointmentStatus !== 'COMPLETED'
// Only future appointments make sense as reschedule targets.
// Past ones can't be edited — they already happened.
&& a.scheduledAt
&& new Date(a.scheduledAt).getTime() >= now,
)
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime());
}, [appointments, lead]);
@@ -89,6 +95,12 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
[leadAppointments, editingApptId],
);
// Pending pill click awaiting the reschedule-confirm modal. When the
// agent clicks a pill, we store the appointment id here + open the modal.
// Yes → promote to editingApptId in edit mode. No → promote in view mode.
const [pendingApptId, setPendingApptId] = useState<string | null>(null);
const [apptMode, setApptMode] = useState<'edit' | 'view'>('edit');
const agentConfig = localStorage.getItem('helix_agent_config');
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
const { supervisorPresence } = useAgentState(agentIdForState);
@@ -397,7 +409,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
</div>
<button
type="button"
onClick={() => setEditingApptId(appt.id)}
onClick={() => setPendingApptId(appt.id)}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-brand-secondary hover:bg-brand-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faPenToSquare} className="size-3" />
@@ -408,16 +420,23 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
</div>
)}
{/* Key forces a full remount when switching between
pills (or between edit/view modes) so the form's
internal state re-initializes from the new
existingAppointment prop instead of staying
stuck on the first-mounted values. */}
<AppointmentForm
key={`${editingApptId ?? 'new'}-${apptMode}`}
isOpen={appointmentOpen}
onOpenChange={(open) => {
setAppointmentOpen(open);
if (!open) setEditingApptId(null);
if (!open) { setEditingApptId(null); setApptMode('edit'); }
}}
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null}
readOnly={apptMode === 'view'}
existingAppointment={editingAppt ? {
id: editingAppt.id,
scheduledAt: editingAppt.scheduledAt ?? '',
@@ -430,6 +449,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
} : null}
onSaved={(outcome) => {
setEditingApptId(null);
setApptMode('edit');
handleAppointmentSaved(outcome);
}}
/>
@@ -451,6 +471,58 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
)}
</div>
{/* Reschedule confirm modal — fires when the agent clicks Edit
on an upcoming-appointment pill. Yes → open the form in
edit mode (fields editable, Save button). No → open in
view-only mode (fields disabled, Close button). */}
<ModalOverlay
isOpen={pendingApptId !== null}
onOpenChange={(open) => { if (!open) setPendingApptId(null); }}
isDismissable
>
<Modal className="sm:max-w-md">
<Dialog>
{() => (
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
<p className="text-sm text-tertiary">
Choose "Yes, reschedule" to change the date, time, or doctor.
Choose "No, just view" to see the details without changing anything.
</p>
<div className="flex items-center gap-2 justify-end">
<Button
size="sm"
color="secondary"
onClick={() => {
if (pendingApptId) {
setEditingApptId(pendingApptId);
setApptMode('view');
setPendingApptId(null);
}
}}
>
No, just view
</Button>
<Button
size="sm"
color="primary"
onClick={() => {
if (pendingApptId) {
setEditingApptId(pendingApptId);
setApptMode('edit');
setPendingApptId(null);
}
}}
>
Yes, reschedule
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
{/* Disposition Modal — the ONLY path to end a call */}
<DispositionModal
isOpen={dispositionOpen}

View File

@@ -35,6 +35,11 @@ type AppointmentFormProps = {
// 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 };
@@ -60,6 +65,7 @@ export const AppointmentForm = ({
patientId,
onSaved,
existingAppointment,
readOnly = false,
}: AppointmentFormProps) => {
const isEditMode = !!existingAppointment;
@@ -471,7 +477,7 @@ export const AppointmentForm = ({
placeholder="Full name"
value={patientName}
onChange={setPatientName}
isDisabled={!isNameEditable}
isDisabled={readOnly || !isNameEditable}
/>
</div>
{!isNameEditable && initialLeadName.length > 0 && (
@@ -544,7 +550,7 @@ export const AppointmentForm = ({
items={departmentItems}
selectedKey={department}
onSelectionChange={(key) => setDepartment(key as string)}
isDisabled={doctors.length === 0}
isDisabled={readOnly || doctors.length === 0}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
@@ -555,7 +561,7 @@ export const AppointmentForm = ({
items={doctorSelectItems}
selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)}
isDisabled={!department}
isDisabled={readOnly || !department}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
@@ -567,7 +573,7 @@ export const AppointmentForm = ({
value={date ? parseDate(date) : null}
onChange={(val) => setDate(val ? val.toString() : '')}
granularity="day"
isDisabled={!doctor}
isDisabled={readOnly || !doctor}
/>
</div>
@@ -585,7 +591,7 @@ export const AppointmentForm = ({
<button
key={slot.id}
type="button"
disabled={isBooked}
disabled={readOnly || isBooked}
onClick={() => setTimeSlot(slot.id)}
className={cx(
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
@@ -613,6 +619,7 @@ export const AppointmentForm = ({
placeholder="Describe the reason for visit..."
value={chiefComplaint}
onChange={setChiefComplaint}
isDisabled={readOnly}
rows={2}
/>
@@ -649,7 +656,7 @@ export const AppointmentForm = ({
{/* Footer — pinned */}
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
<div>
{isEditMode && (
{isEditMode && !readOnly && (
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
Cancel Appointment
</Button>
@@ -659,9 +666,11 @@ export const AppointmentForm = ({
<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>
{!readOnly && (
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
</Button>
)}
</div>
</div>