feat: call-desk refresh — disposition modal, active-call UI, worklist + perf updates

- Call-desk: active-call-card supervisor presence badges, incoming-call-card polish, transfer-dialog, call-log
- Disposition modal: auto-lock based on actions taken, not-interested split
- Forms: appointment-form + enquiry-form improvements (placeholder handling, phone format)
- Worklist-panel: pagination awareness, filter chips
- Pages: all-leads/patients/patient-360/missed-calls/team-performance/call-history/appointments polish
- SIP: sip-client reconnect, sip-provider + sip-manager state, agent-status-toggle spinner
- Hooks: use-agent-state supervisor SSE events, use-worklist, use-performance-alerts
- Types: entities.ts extended

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 06:49:36 +05:30
parent 642911fa6c
commit 42e23a52ec
28 changed files with 614 additions and 246 deletions

View File

@@ -29,7 +29,10 @@ type AppointmentFormProps = {
leadName?: string | null;
leadId?: string | null;
patientId?: string | null;
onSaved?: () => void;
// 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;
};
@@ -241,7 +244,9 @@ export const AppointmentForm = ({
const selectedDoctor = doctors.find(d => d.id === doctor);
if (isEditMode && existingAppointment) {
// Update existing appointment
// 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 }
@@ -254,9 +259,32 @@ export const AppointmentForm = ({
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
@@ -271,9 +299,16 @@ export const AppointmentForm = ({
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: { fullName: nameParts, phones: { primaryPhoneNumber: phoneE164 }, patientType: 'NEW' } },
{ data: patientData },
);
resolvedPatientId = created.createPatient.id;
} catch (err) {
@@ -282,24 +317,26 @@ export const AppointmentForm = ({
}
// 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: {
scheduledAt,
durationMin: 30,
appointmentType: 'CONSULTATION',
status: 'SCHEDULED',
doctorName: selectedDoctor?.name ?? '',
department: selectedDoctor?.department ?? '',
doctorId: doctor,
reasonForVisit: chiefComplaint || null,
...(resolvedPatientId ? { patientId: resolvedPatientId } : {}),
...(clinic ? { clinicId: clinic } : {}),
},
},
{ data: appointmentData },
);
// Determine whether the agent actually renamed the patient.
@@ -309,11 +346,13 @@ export const AppointmentForm = ({
const trimmedName = patientName.trim();
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
// Update patient name only when it was empty (new caller with no name).
// Don't overwrite an existing patient name — that would
// retroactively change the name on all past appointments.
// Bug #527: only set name on patients with no existing name.
if (nameChanged && patientId && initialLeadName.length === 0) {
// 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 } }`,
@@ -346,21 +385,14 @@ export const AppointmentForm = ({
// 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.
// 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(() => {});
} 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?.();
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.');
@@ -383,7 +415,7 @@ export const AppointmentForm = ({
},
);
notify.success('Appointment Cancelled');
onSaved?.();
onSaved?.('CANCELLED');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
} finally {